DEV Community

Cover image for Stop losetup Guesswork: Practical systemd-dissect for Discoverable Disk Images on Linux
Lyra
Lyra

Posted on

Stop losetup Guesswork: Practical systemd-dissect for Discoverable Disk Images on Linux

Stop losetup Guesswork: Practical systemd-dissect for Discoverable Disk Images on Linux

You have a disk image. Maybe it came from mkosi, a cloud vendor, a portable service build, a sysext raw, or last night's A/B update pipeline. The old muscle memory is familiar:

losetup -fP ./image.raw
fdisk -l /dev/loopX
mount /dev/loopXp2 /mnt
# ... later, which partitions did I attach again?
Enter fullscreen mode Exit fullscreen mode

That works until the image has LUKS, dm-verity, a multi-partition GPT layout, or architecture-specific root types. Then the one-liner becomes a script, and the script becomes the bug.

systemd-dissect is the systemd-native tool for introspecting and operating on Discoverable Disk Images (DDIs). It understands GPT labels from the UAPI Discoverable Partitions Specification, can mount whole OS trees correctly, copy files in and out, validate image policy, and even plug into classic mount / fstab as mount.ddi.

This guide is operational. Commands and options below are taken from systemd-dissect(1), systemd.image-policy(7), and the DPS.

What a DDI is (and what dissect does)

A Discoverable Disk Image is an OS disk image systemd can reason about automatically. systemd-dissect accepts three shapes:

  1. GPT image with DPS partition types (root, usr, home, srv, esp, verity, …)
  2. Plain filesystem image (no partition table — treated as the root FS)
  3. GPT/MBR with a single partition (that partition is the root FS)

Inside those images you can have normal Linux filesystems, LUKS, and dm-verity data. The same class of images boots with systemd-nspawn --image= and can back RootImage= on services.

When you run systemd-dissect with no command switch, it does not dump every GPT entry like fdisk. It shows the partitions it understands and would operate on: unknown types are skipped, duplicates are ignored, and root/usr partitions for foreign architectures are filtered. That is intentional — dissect reports the OS view, not the full partition editor view.

Tool Job
fdisk / sfdisk Edit/list the raw partition table
losetup Attach a file as a block device
veritysetup / cryptsetup Manual integrity / encryption setup
systemd-dissect OS-aware inspect, mount, copy, validate, archive

Prerequisites

# Debian/Ubuntu
sudo apt install systemd-container

# Arch
sudo pacman -S systemd

systemd-dissect --version
# mount.ddi should resolve to the same binary (symlink helper)
command -v mount.ddi || ls -l /usr/bin/mount.ddi /sbin/mount.ddi 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

Most inspect/validate paths need read access to the image. Mount, attach, copy-in, and write paths need privileges (typically root) so loop devices and nested mounts can be set up.

Lab 0 — Build a tiny plain-FS image to practice on

You do not need a full multi-partition DDI to learn the tool. A single ext4 image is enough for mount/list/copy/with flows:

mkdir -p ~/dissect-lab && cd ~/dissect-lab

# 256 MiB sparse file + ext4 (plain FS image = valid DDI shape #2)
truncate -s 256M plain.raw
mkfs.ext4 -L labroot plain.raw

# Put something recognizable inside via a temporary loop mount
sudo mkdir -p /mnt/plain-lab
sudo mount -o loop plain.raw /mnt/plain-lab
sudo mkdir -p /mnt/plain-lab/etc
printf 'ID=lab\nVERSION_ID=1\nPRETTY_NAME="dissect-lab"\n' | sudo tee /mnt/plain-lab/etc/os-release >/dev/null
echo 'hello from plain DDI' | sudo tee /mnt/plain-lab/ROOT.txt >/dev/null
sudo umount /mnt/plain-lab
Enter fullscreen mode Exit fullscreen mode

For a real GPT + DPS image, build with mkosi or systemd-repart (covered in earlier posts). Dissect is happiest when partition type GUIDs match DPS (for example SD_GPT_ROOT_X86_64 = 4f68bce3-e8cd-4db1-96e7-fbcaf984b709 on amd64).

Lab 1 — Inspect before you mount

# Human-readable OS / partition summary
systemd-dissect plain.raw

# Machine-readable
systemd-dissect --json=pretty plain.raw
Enter fullscreen mode Exit fullscreen mode

Expect os-release fields (when present), architecture hints, and the design designations dissect derived. On multi-partition DDIs this is where you confirm "yes, this has root + usr + verity" before you hand the file to nspawn, vmspawn, or a service RootImage=.

Useful companion:

# Full GPT including types dissect ignores — use when debugging image builds
sudo fdisk -l plain.raw
Enter fullscreen mode Exit fullscreen mode

Lab 2 — Mount and unmount the OS view

sudo mkdir -p /mnt/ddi-plain

# Create target if missing (-M == --mount --mkdir)
sudo systemd-dissect -M plain.raw /mnt/ddi-plain

# Read-only mount
sudo systemd-dissect --mount --read-only plain.raw /mnt/ddi-plain

ls /mnt/ddi-plain
cat /mnt/ddi-plain/ROOT.txt
cat /mnt/ddi-plain/etc/os-release

# Recursive unmount + remove empty dir (-U == --umount --rmdir)
sudo systemd-dissect -U /mnt/ddi-plain
Enter fullscreen mode Exit fullscreen mode

What mount does for you (from the man page):

  • Dissects the image and mounts the root (and nested DPS mounts such as /usr, /home, … when present)
  • Sets up LUKS and Verity automatically when the image carries them, and tears them down on unmount
  • Runs fsck in automatic-fix mode on writable access unless you pass --fsck=no
  • Honors GPT growfs bit 59 by growing the filesystem to the partition size unless --growfs=no

Disable grow/fsck when you want a surgical inspection:

sudo systemd-dissect --mount --fsck=no --growfs=no --read-only \
  ./image.raw /mnt/ddi
Enter fullscreen mode Exit fullscreen mode

mount.ddi and fstab

systemd-dissect can be invoked as mount.ddi, implementing mount(8)'s external helper for type ddi:

sudo mkdir -p /mnt/ddi-plain
sudo mount -t ddi plain.raw /mnt/ddi-plain

# Multi-FS DDIs need recursive unmount
sudo umount -R /mnt/ddi-plain
Enter fullscreen mode Exit fullscreen mode

Boot-time / persistent mount via fstab:

/path/to/myimage.raw  /images/myimage/  ddi  defaults  0  0
Enter fullscreen mode Exit fullscreen mode

Mapped mount options from the man page:

fstab / mount option dissect equivalent
ro --read-only
rw writable (default for --mount)
discard --discard=all
nodiscard --discard=disabled

Those options apply to how the image is attached; they are not generically passed through to every inner filesystem.

Lab 3 — Run a command inside a temporary mount (--with)

This is the cleanest one-shot pattern — mount, cwd into the tree, run a command, always unmount:

# Interactive shell in the image
sudo systemd-dissect --with --read-only plain.raw

# One command (man page tarball example)
sudo systemd-dissect --with plain.raw tar cz . > plain-from-with.tar.gz

# Environment the child sees (documented):
#   $SYSTEMD_DISSECT_ROOT   — absolute temp mount path
#   $SYSTEMD_DISSECT_DEVICE — loop device path
sudo systemd-dissect --with --read-only plain.raw \
  sh -c 'echo root=$SYSTEMD_DISSECT_ROOT; echo dev=$SYSTEMD_DISSECT_DEVICE; ls -la'
Enter fullscreen mode Exit fullscreen mode

Exit status of --with is the exit status of the child command.

Writable by default; add --read-only for inspection. Prefer --in-memory when you need a writable scratch view of a read-only golden image without dirtying it:

sudo systemd-dissect --with --in-memory plain.raw \
  sh -c 'echo scratch > ./SCRATCH.txt; cat ./SCRATCH.txt'
# original plain.raw unchanged
Enter fullscreen mode Exit fullscreen mode

Lab 4 — List files and mtree manifests

# All paths in the image
systemd-dissect --list plain.raw | head

# BSD mtree-compatible manifest with SHA256 content digests
# (timestamps/nlink/ino intentionally omitted for reproducibility)
systemd-dissect --mtree plain.raw | head

# Faster mtree without content hashes on large images
systemd-dissect --mtree --mtree-hash=no ./big.raw | head
Enter fullscreen mode Exit fullscreen mode

--mtree is excellent for CI diffing of image builds. The man page notes current limitations: no xattrs, capabilities, MAC labels, chattr flags, or btrfs subvolume metadata in the manifest.

Lab 5 — Copy files in and out without a long-lived mount

# Host ← image
systemd-dissect --copy-from plain.raw /ROOT.txt ./ROOT-from-image.txt
# stdout:
systemd-dissect --copy-from plain.raw /etc/os-release -

# Host → image (writable; fsck runs first by default)
echo 'injected' > ./inject.txt
sudo systemd-dissect --copy-to plain.raw ./inject.txt /opt/inject.txt

# Verify
systemd-dissect --copy-from plain.raw /opt/inject.txt -
Enter fullscreen mode Exit fullscreen mode

Rules of thumb from the docs:

  • Regular files copy mode/xattrs/timestamps; ownership is not copied for single files
  • Directories copy recursively and include ownership
  • Source - reads stdin (--copy-to); destination - writes stdout (--copy-from)

Lab 6 — Attach as a loop device (when you still need block tools)

Sometimes you want cfdisk, btrfs inspect-internal, or another block-level tool. Prefer dissect's attach over bare losetup so sector size and partition nodes are correct:

# Prints the loop path, creates partition sub-nodes before returning
LOOP=$(sudo systemd-dissect --attach --loop-ref=labplain plain.raw)
echo "$LOOP"
ls -l /dev/disk/by-loop-ref/labplain

# Use the stable by-loop-ref name
sudo cfdisk /dev/disk/by-loop-ref/labplain

# Detach by loop path or by backing image path
sudo systemd-dissect --detach "$LOOP"
# or: sudo systemd-dissect --detach plain.raw
Enter fullscreen mode Exit fullscreen mode

--loop-ref= sets the kernel .lo_file_name field (up to 63 chars) used for /dev/disk/by-loop-ref/.... That is distinct from sysfs backing_file, which is mount-namespace translated.

Lab 7 — Validate image policy before you trust an image

Image policy is a colon-separated string of partition=flags rules. Flags include unprotected, verity, signed, encrypted, unused, absent, plus GPT flag requirements like read-only-on / growfs-off. Shortcuts:

Policy Meaning
* use everything recognized (common default)
- use nothing (unused+absent)
~ everything must be absent
# Syntax/meaning helper
systemd-analyze image-policy 'usr=verity+read-only-on:root=encrypted:swap=encrypted'

# Validate arrangement + policy without mounting (unprivileged if the file is readable)
systemd-dissect --validate plain.raw
systemd-dissect --validate --image-policy='root=unprotected' plain.raw

# Refuse unprotected roots in an automation gate
systemd-dissect --validate --image-policy='root=verity+signed' ./golden.raw && echo OK
Enter fullscreen mode Exit fullscreen mode

--validate parses the partition table and probes filesystems but does not mount or set up LUKS/Verity. It prints OK and exits 0 when the image matches policy.

Example policies from systemd.image-policy(7):

# Read-only Verity /usr, encrypted root+swap; ignore the rest
usr=verity+read-only-on:root=encrypted:swap=encrypted

# Encrypted writable root; /srv encrypted if present; no swap
root=encrypted+read-only-off:srv=encrypted+absent:swap=absent
Enter fullscreen mode Exit fullscreen mode

Lab 8 — Discover images on the system and archive them

# Machines, portables, sysext/confext locations systemd knows about
systemd-dissect --discover

# Archive the filesystem view (format from output suffix; libarchive-backed)
sudo systemd-dissect --make-archive plain.raw plain.tar.gz
# stdout tarball (uncompressed when path omitted):
sudo systemd-dissect --make-archive plain.raw - > plain.tar
Enter fullscreen mode Exit fullscreen mode

--discover walks the usual trees: /usr/lib/machines/, /var/lib/machines/, /usr/lib/portables/, /var/lib/portables/, /usr/lib/extensions/, /var/lib/extensions/, confext paths, and friends.

Verity and encrypted images

When the DDI embeds Verity per DPS, mount/attach paths set it up for you. For detached metadata:

sudo systemd-dissect --mount \
  --root-hash=HEX_ROOT_HASH \
  --root-hash-sig=./roothash.p7s \
  --verity-data=./image.verity \
  ./image.raw /mnt/ddi
Enter fullscreen mode Exit fullscreen mode

Prefer embedding Verity in the GPT image (root + root-verity + optional signature partitions) so tools do not need extra flags. That is the same model used by systemd-repart image builds and image-based update flows.

Discard policy for thin/encrypted backends:

# disabled | loop | all | crypto
sudo systemd-dissect --mount --discard=loop ./image.raw /mnt/ddi
Enter fullscreen mode Exit fullscreen mode

Wire-up: nspawn, services, and friends

Once dissect is happy with an image, the rest of the systemd image ecosystem lights up:

# Namespace OS container from the same DDI
sudo systemd-nspawn --image=./image.raw

# Service with image root (unit fragment)
# RootImage=/var/lib/machines/app.raw
# RootImagePolicy=root=verity+signed+encrypted+unprotected+absent:...
Enter fullscreen mode Exit fullscreen mode

RootImage=, MountImage=, and ExtensionImage= all take related image-policy settings (RootImagePolicy= and friends in systemd.exec(5)). Dissect is how you debug those images before a unit fails at boot.

A practical CI-shaped workflow

#!/usr/bin/env bash
set -euo pipefail
IMG=${1:?image path}

# 1. Policy gate (no mount, minimal privilege)
systemd-dissect --validate --image-policy='root=unprotected+verity+signed+encrypted' "$IMG"

# 2. Show what systemd will actually use
systemd-dissect --json=short "$IMG" >/tmp/ddi-meta.json

# 3. Content fingerprint for regression
systemd-dissect --mtree --mtree-hash=no "$IMG" | sha256sum

# 4. Smoke: read os-release without leaving mounts behind
sudo systemd-dissect --with --read-only "$IMG" cat etc/os-release
Enter fullscreen mode Exit fullscreen mode

Boundaries — pick the right tool

Need Prefer
Inspect / mount / copy / validate a DDI systemd-dissect
Boot the image as a container systemd-nspawn --image=
Boot the image as a full VM systemd-vmspawn --image=
Attach a portable service image portablectl
Merge /usr add-ons at runtime systemd-sysext / confext
Build/grow GPT layouts systemd-repart
Low-level Verity format/open veritysetup
Raw partition editing sfdisk / sgdisk

Dissect does not replace your image builder. It replaces the fragile glue between "image file on disk" and "filesystem tree I can trust and use."

Troubleshooting checklist

  • Empty or sparse inspect output — foreign-arch root type, unknown GPT types, or not a filesystem systemd can probe. Confirm with fdisk -l and rebuild with DPS types.
  • Permission denied on mount/attach — need root (or equivalent) for loop + mount namespace operations.
  • fsck delays on every write open — expected; use --fsck=no for throwaway labs.
  • Image grew unexpectedly — GPT growfs flag (bit 59); disable with --growfs=no.
  • leftover loop devices — always --umount / --detach; for mount -t ddi, use umount -R.
  • Policy validate fails on a "fine" image — your policy may require verity/signed while the lab image is unprotected. Start from * and tighten.
  • --with left nothing mounted but command failed — check the child exit status; dissect propagates it.

Wrap-up

If you already live in the systemd image world — nspawn, vmspawn, portable services, sysext, repart, sysupdate — systemd-dissect is the missing inspection plane.

Day-to-day defaults:

# What is this file?
systemd-dissect ./image.raw

# Safe look around
sudo systemd-dissect --with --read-only ./image.raw

# Policy gate
systemd-dissect --validate --image-policy='root=unprotected+verity+signed' ./image.raw

# Persistent host mount
# /var/lib/images/app.raw  /srv/app-image  ddi  ro  0  0
Enter fullscreen mode Exit fullscreen mode

Stop writing losetup scaffolding for every OS image. Let dissect own the loop, the nested mounts, and the teardown.

References

Top comments (0)