Mender Update Module for systemd-sysext: Zero-Reboot System Extensions

This update module enables zero-reboot application updates on immutable systems by leveraging systemd’s system extension (systemd-sysext) mechanism. It allows you to deliver SquashFS extensions containing updated binaries, libraries, or configurations to a read-only /usr without requiring a full system restart or A/B rootfs flip.

Prepare the development environment on your workstation

This section describes how to set up your development environment on your workstation to package system extension artifacts.

All commands outlined in this section should be run in the development environment.

Prerequisites

  • mender-artifact version 3.0.0 or later (to build .mender artifacts).

  • mksquashfs (squashfs-tools) to create .raw SquashFS extension images.

On Debian/Ubuntu hosts, install squashfs-tools:

sudo apt update && sudo apt install -y squashfs-tools

Note: You can complete the host preparation and artifact generation steps on macOS as well. To install mksquashfs on macOS via Homebrew, run brew install squashfs instead of apt install squashfs-tools.

Configure the Target Device

Before deploying artifacts, prepare the target device by ensuring systemd-sysext is active, setting up persistent storage, installing the update module, and configuring /usr immutability.

Note: The target device must be running the Mender Client (v4.0.0 or later), but it does not require an A/B dual-partition layout as this update module runs entirely in Standalone or Connected mode on single-partition or immutable rootfs setups.

Step 1: Enable systemd-sysext

Ensure systemd-sysext is installed and enabled to automatically merge extensions on system boot:

# Verify systemd-sysext is present (included with systemd)

systemd-sysext --version

# Enable the service so extensions persist and refresh across reboots

sudo systemctl enable systemd-sysext.service

Step 2: Configure Persistent Extension Storage

Set up persistent storage on /var for extension images so they persist across updates without requiring write access to /usr:

# 1. Create a persistent directory on writable /var and add a placeholder file

# (The placeholder prevents systemd-sysext.service ConditionDirectoryNotEmpty from failing)

sudo mkdir -p /var/local/extensions

sudo touch /var/local/extensions/.keep



# 2. Symlink /var/lib/extensions to persistent storage

sudo mkdir -p /var/lib

sudo rm -rf /var/lib/extensions

sudo ln -s /var/local/extensions /var/lib/extensions

Step 3: Install the Mender Update Module

Install the sysext update module to /usr/share/mender/modules/v3/sysext.

# 1. Ensure the module directory exists
sudo mkdir -p /usr/share/mender/modules/v3

# 2. Write the dynamic update module script
sudo tee /usr/share/mender/modules/v3/sysext > /dev/null << 'EOF'
#!/bin/sh
#
# Mender Update Module: sysext
#
# Installs a systemd-sysext image (.raw) and merges it onto /usr live,
# without a reboot. The previously active image is kept as a backup until
# the update is committed, so a rollback restores the previous version
# instead of falling back to the bare base OS.
#
# Install to: /usr/share/mender/modules/v3/sysext   (chmod 755)
#
# Directories:
#   EXT_DIR  - active extension images (/var/lib/extensions -> /var/local/extensions)
#   BAK_DIR  - previous images, kept until ArtifactCommit.
#              Deliberately OUTSIDE EXT_DIR: systemd-sysext treats every
#              *.raw file and sub-directory there as an extension.
#
# Optional health check:
#   If HEALTHCHECK exists and is executable, it runs after the merge.
#   A non-zero exit fails ArtifactInstall and Mender rolls back.
#
# Limitation: manages all *.raw images in EXT_DIR as one unit
# (one extension per device, like the original module).

set -eu

STATE="${1:-}"
FILES="${2:-.}"

EXT_DIR="${SYSEXT_EXT_DIR:-/var/local/extensions}"
BAK_DIR="${SYSEXT_BAK_DIR:-/var/local/extensions-backup}"
HEALTHCHECK="${SYSEXT_HEALTHCHECK:-/etc/mender/sysext-healthcheck}"
RELEASE_DIR="${SYSEXT_RELEASE_DIR:-/usr/lib/extension-release.d}"

log() { echo "sysext: $*" >&2; }

# Move every *.raw from $1 to $2 (no-op if there are none).
move_images() {
    for img in "$1"/*.raw; do
        [ -e "$img" ] || continue
        mv -f "$img" "$2/"
    done
}

# Delete every *.raw in $1 (no-op if there are none).
remove_images() {
    for img in "$1"/*.raw; do
        [ -e "$img" ] || continue
        rm -f "$img"
    done
}

case "$STATE" in
    NeedsArtifactReboot)
        echo "No"
        ;;

    SupportsRollback)
        echo "Yes"
        ;;

    ArtifactInstall)
        # 1. Locate the incoming .raw payload Mender unpacked for us
        PAYLOAD=$(find "$FILES" -type f -name '*.raw' | head -n 1)
        if [ -z "$PAYLOAD" ]; then
            log "no .raw payload found in $FILES"
            exit 1
        fi
        FILENAME=$(basename "$PAYLOAD")
        NAME="${FILENAME%.raw}"
        log "installing $FILENAME"

        mkdir -p "$EXT_DIR" "$BAK_DIR"

        # 2. Detach the current overlay before touching the images
        systemd-sysext unmerge || true

        # 3. Back up the running image(s); drop any stale backup first
        remove_images "$BAK_DIR"
        move_images "$EXT_DIR" "$BAK_DIR"

        # 4. Copy the new image in under a temporary name, then rename,
        #    so a power cut never leaves a half-written .raw in EXT_DIR
        cp "$PAYLOAD" "$EXT_DIR/$FILENAME.tmp"
        sync
        mv -f "$EXT_DIR/$FILENAME.tmp" "$EXT_DIR/$FILENAME"

        # 5. Merge onto /usr live
        systemd-sysext refresh

        # 6. Verify the merge actually happened. systemd-sysext silently
        #    skips images whose extension-release does not match the host.
        if [ ! -e "$RELEASE_DIR/extension-release.$NAME" ]; then
            log "$NAME is not merged (extension-release mismatch or name mismatch)"
            exit 1
        fi

        # 7. Optional application health check
        if [ -x "$HEALTHCHECK" ]; then
            log "running health check $HEALTHCHECK"
            if ! "$HEALTHCHECK"; then
                log "health check failed"
                exit 1
            fi
        fi

        log "$NAME merged"
        ;;

    ArtifactCommit)
        # New image is good: the backup is no longer needed
        remove_images "$BAK_DIR"
        log "committed, backup removed"
        ;;

    ArtifactRollback)
        mkdir -p "$EXT_DIR" "$BAK_DIR"
        systemd-sysext unmerge || true

        # Remove the new image and restore the previous one (if any).
        # With no backup (first ever install) this returns to the base OS.
        remove_images "$EXT_DIR"
        for img in "$EXT_DIR"/*.raw.tmp; do
            [ -e "$img" ] || continue
            rm -f "$img"
        done
        move_images "$BAK_DIR" "$EXT_DIR"

        systemd-sysext refresh
        log "rolled back"
        ;;

    *)
        # Download, ArtifactVerifyReboot, ArtifactFailure, Cleanup, ...:
        # nothing to do. Mender cleans up its own working directory.
        ;;
esac

exit 0
EOF

# 3. Make the update module executable
sudo chmod +x /usr/share/mender/modules/v3/sysext

Step 4: Configure Read-Only /usr and Lock System Down

Now that the system services and update module are configured, enforce system immutability by mounting /usr read-only via a systemd mount unit.

# 1. Create systemd mount unit to bind mount /usr as read-only
sudo tee /etc/systemd/system/usr.mount > /dev/null << 'EOF'
[Unit]
Description=Mount /usr Read-Only
DefaultDependencies=no
Conflicts=umount.target
Before=local-fs.target umount.target
After=systemd-remount-fs.service

[Mount]
What=/usr
Where=/usr
Type=none
Options=bind,ro

[Install]
WantedBy=local-fs.target
EOF

# 2. Reload systemd daemon and enable unit
sudo systemctl daemon-reload
sudo systemctl enable usr.mount

# 3. Activate the read-only mount immediately
sudo systemctl start usr.mount

# 4. Verify /usr is write-protected
sudo touch /usr/testfile

Expected output:
touch: cannot touch ‘/usr/testfile’: Read-only file system

Create Mender Artifacts

Follow these steps to structure a system extension payload, create the SquashFS image, and package it into a .mender artifact targeting the sysext update module.

Step 1: Create the Payload Workspace

Create the directory structure for your extension. Notice that the release metadata file inside /usr/lib/extension-release.d/ uses the prefix my-extension — which matches the .raw image name (my-extension.raw) built in the next step.

# 1. Create directory structure for application files and metadata

mkdir -p ./my-extension/usr/bin

mkdir -p ./my-extension/usr/lib/extension-release.d



# 2. Add your binary or application payload

cat << 'EOF' > ./my-extension/usr/bin/my-app

#!/bin/sh

echo "Hello from systemd-sysext via Mender!"

EOF



chmod +x ./my-extension/usr/bin/my-app



# 3. Add required extension-release metadata matching the extension name

cat << 'EOF' > ./my-extension/usr/lib/extension-release.d/extension-release.my-extension

ID=ubuntu

VERSION_ID=24.04

EOF

Note: The extension-release.my-extension file inside /usr/lib/extension-release.d/ must match the target system’s /etc/os-release fields (ID and VERSION_ID), and the filename suffix (my-extension) must match the name of the SquashFS .raw file (my-extension.raw).

Step 2: Build the SquashFS Image

Compress the payload folder into a raw SquashFS image file named my-extension.raw:

mksquashfs ./my-extension ./my-extension.raw -noappend

Step 3: Package the Mender Artifact

Use mender-artifact to generate the final update package targeting the sysext module type and your target device_type:

mender-artifact write module-image \\

    -t qemu-ubuntu-x86_64 \\

    -n my-extension-v1.0 \\

    -T sysext \\

    -f ./my-extension.raw \\

    -o ./my-extension-v1.0.mender

You can deploy this Artifact using the Mender Server (upload under Releases in the UI) or locally on standalone clients:

sudo mender-update install ./my-extension-v1.0.mender

sudo mender-update commit

Artifact technical details

The artifact wraps a single SquashFS (.raw) payload that is placed into the persistent extension storage on the device (/var/lib/extensions/).

Key Behaviors

  • Zero Reboot: Reports NeedsArtifactReboot = No to Mender, ensuring immediate execution without restarting the target.

  • Atomic Overlay: Uses systemd-sysext refresh to dynamically overlay the SquashFS image onto /usr in user-space via OverlayFS.

  • Immutability Friendly: Target rootfs /usr remains strictly read-only; extensions reside safely in writable persistent storage (/var/local/extensions symlinked to /var/lib/extensions).

Verify the Deployment

Once the artifact installation completes and you run sudo mender-update commit, verify that the system extension was successfully merged into /usr:

Step 1: Check System Extension Status

Run systemd-sysext status to confirm that systemd-sysext recognized and mounted the .raw image:

systemd-sysext status

Expected output:

HIERARCHY EXTENSION VERSION TYPE

/usr my-extension - raw

Step 2: Execute the Installed Application

Verify that the binary inside the SquashFS payload is now visible in /usr/bin over the read-only filesystem and can be executed directly:

# 1. Verify path resolution

which my-app


# 2. Run the application

my-app

Expected output:

/usr/bin/my-app

Hello from systemd-sysext via Mender!