feat(mount): support --mount type=image with image-subpath - #4993
feat(mount): support --mount type=image with image-subpath#4993mayur-tolexo wants to merge 1 commit into
Conversation
|
Did another round of manual testing against Docker 29.4.0 (nerdctl built from this branch) to check parity. Sharing the results. Whole-rootfs and subpath read the same as Docker$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img alpine cat /mnt/img/etc/alpine-release
3.24.1
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img alpine cat /mnt/img/etc/alpine-release
WARNING: Image mount is an experimental feature
3.24.1
$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=etc alpine cat /mnt/img/alpine-release
3.24.1
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=etc alpine cat /mnt/img/alpine-release
WARNING: Image mount is an experimental feature
3.24.1Writes are rejected on both (read-only): $ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img alpine touch /mnt/img/x
touch: /mnt/img/x: Read-only file system
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img alpine touch /mnt/img/x
WARNING: Image mount is an experimental feature
touch: /mnt/img/x: Read-only file systemBad mount specs are rejected, same as Dockernerdctl: $ nerdctl run --rm --mount type=image,destination=/mnt/img alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="type=image requires a source (the image reference)"
$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,subpath=etc alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="unexpected key 'subpath' in 'subpath=etc'"
$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=../etc alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="image-subpath \"../etc\" escapes the image rootfs"
$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=/etc alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="image-subpath must be relative to the image rootfs, got \"/etc\""
$ nerdctl run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=nope alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="image-subpath \"nope\" does not exist in image \"alpine:latest\""
$ nerdctl run --rm --mount type=bind,source=/tmp,destination=/mnt,image-subpath=etc alpine true
time="2026-07-21T08:16:29Z" level=fatal msg="image-subpath is only supported for type=image"Docker on the same specs: $ docker run --rm --mount type=image,destination=/mnt/img alpine true
docker: Error response from daemon: invalid mount config for type "image": field Source must not be empty
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,subpath=etc alpine true
invalid argument "type=image,source=alpine:latest,destination=/mnt/img,subpath=etc" for "--mount" flag: unknown option 'subpath' in 'subpath=etc'
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=../etc alpine true
docker: Error response from daemon: invalid mount config for type "image": subpath must be a relative path within the volume
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=/etc alpine true
docker: Error response from daemon: invalid mount config for type "image": subpath must be a relative path within the volume
$ docker run --rm --mount type=image,source=alpine:latest,destination=/mnt/img,image-subpath=nope alpine true
docker: Error response from daemon: cannot access path /var/lib/docker/rootfs/overlayfs/b59a45207c4d.../nope: lstat ...: no such file or directory
$ docker run --rm --mount type=bind,source=/tmp,destination=/mnt,image-subpath=etc alpine true
invalid argument "..." for "--mount" flag: cannot mix 'image-*' options with mount type 'bind'How the subpath mount is wirednerdctl is daemonless, so for a subpath it materializes the image rootfs on the host (read-only) and bind-mounts the subdir into the container. For a container started with $ grep image-mounts /proc/mounts
/dev/vda1 /var/lib/nerdctl/image-mounts/8b71320c4cd1215df46c5a7da54744badbe10192ad2801d95d7a11494d9bb918-image-mount ext4 ro,relatime,discard 0 0The OCI mount injected for {
"destination": "/mnt/img",
"type": "bind",
"source": "/var/lib/nerdctl/image-mounts/8b71320c4cd1215df46c5a7da54744badbe10192ad2801d95d7a11494d9bb918-image-mount/etc",
"options": ["rbind", "ro"]
}And inside the container: $ grep ' /mnt/img ' /proc/<pid>/mountinfo
1969 1959 254:1 /docker/volumes/nerdctl-cd-data/_data/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs/etc /mnt/img ro,relatime master:1 - ext4 /dev/vda1 rw,discardThe snapshot view and host path are recorded on the container so they can be cleaned up on removal: $ nerdctl inspect imgtest --format '{{json .Config.Labels}}' | tr ',' '\n' | grep image-mount
"nerdctl/image-mount-hostpaths":"[\"/var/lib/nerdctl/image-mounts/8b71320c4cd1215df46c5a7da54744badbe10192ad2801d95d7a11494d9bb918-image-mount\"]"
"nerdctl/image-mount-snapshots":"[\"8b71320c4cd1215df46c5a7da54744badbe10192ad2801d95d7a11494d9bb918-image-mount\"]"On $ grep -c image-mounts /proc/mounts # while running
2
$ grep -c image-mounts /proc/mounts # after stop + rm
0One difference from Docker
$ nerdctl rmi busybox:latest # while a mount-only container runs
Untagged: docker.io/library/busybox:latest@sha256:fd8d9aa63ba2...
Deleted: sha256:66cb17eae60e...
$ nerdctl exec mntonly ls /mnt/bb/bin/busybox
/mnt/bb/bin/busybox
$ docker rmi busybox:latest # while a mount-only container runs
Error response from daemon: conflict: unable to delete busybox:latest (must be forced) - container bf6231dbb625 is using its referenced image fd8d9aa63ba2 |
18a55fe to
870f56c
Compare
I only see a single commit |
|
If this PR is expected to be merged after #4990, please click |
b86abb8 to
7ee3b50
Compare
|
Is this still a draft? |
Yeah its ready for review. |
|
I think images used by existing containers as image mounts should not be removed by rmi |
| // "." means the whole rootfs (e.g. from "a/.."); that is the no-subpath case, | ||
| // not a subdirectory selection, so reject it as a misuse of image-subpath. | ||
| if clean == "." { | ||
| return "", fmt.Errorf("image-subpath %q must select a subdirectory, not the image rootfs", p) | ||
| } |
There was a problem hiding this comment.
nit, but docker accepts this.
There was a problem hiding this comment.
Good catch, fixed. A subpath that resolves to the rootfs (., or anything normalizing to it like a/..) now returns empty and mounts the whole image view like Docker, instead of erroring. Updated the two tests to match.
7ee3b50 to
35e7a07
Compare
|
Agreed it shouldn't be silently removable. The view is already pinned with a |
There was a problem hiding this comment.
Pull request overview
Adds image-subpath support for read-only image mounts, including secure resolution, persistent host materialization, cleanup, and tests.
Changes:
- Parses and validates image subpaths.
- Materializes subpath mounts and records cleanup metadata.
- Adds documentation and unit/integration coverage.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/mountutil/mountutil.go |
Adds image-subpath metadata fields. |
pkg/mountutil/mountutil_linux.go |
Parses and validates image-subpath. |
pkg/mountutil/mountutil_linux_test.go |
Tests parser behavior. |
pkg/labels/labels.go |
Defines the host-path cleanup label. |
pkg/cmd/container/run_mount.go |
Creates and cleans subpath mounts. |
pkg/cmd/container/remove.go |
Cleans image mount state on removal. |
pkg/cmd/container/create.go |
Persists and cleans mount metadata. |
docs/command-reference.md |
Documents image-subpath. |
cmd/nerdctl/container/container_run_mount_image_linux_test.go |
Adds integration coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if len(imageMountHostpaths) > 0 { | ||
| b, err := json.Marshal(imageMountHostpaths) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| m[labels.ImageMountHostpaths] = string(b) | ||
| } |
| // "." is the whole rootfs (e.g. from "a/.."); treat it as no subpath so the | ||
| // caller mounts the full image view, matching Docker. | ||
| if clean == "." { | ||
| return "", nil |
| if imageSubpath != "" && mountType != Image { | ||
| return nil, fmt.Errorf("image-subpath is only supported for type=image") |
| // itself (empty, or one normalizing to ".", e.g. "a/..") returns empty, the | ||
| // no-subpath case Docker also accepts. Image paths are always forward-slash, so | ||
| // it uses path, not filepath. | ||
| func validateImageSubpath(p string) (string, error) { |
There was a problem hiding this comment.
we can use os.OpenInRoot to make sure the subpath isn't escaped from the rootfs host path.
ref: image volume support in containerd cri
There was a problem hiding this comment.
Done — resolveImageSubpath now re-checks with os.OpenInRoot after securejoin, so the scoped kernel lookup confirms existence and rejects absolute symlinks/escapes. Verified an absolute in-image symlink (bin/sh -> /bin/busybox) is rejected while a safe relative one (var/run -> ../run) resolves within the image; both match Docker. Thanks for the pointer.
Mount an image's filesystem into a container read-only, matching Docker: --mount type=image,source=<image>,destination=<path>. The source image is ensured and unpacked, a read-only snapshot view of its rootfs is created and mounted at the destination, and the view is removed when the container is deleted. The image-subpath option exposes a single directory of the image rootfs at the destination instead of the whole rootfs. An OCI overlay mount cannot select a subdirectory, so a subpath mount materializes the read-only view on a host directory under the data root, resolves the subpath, and bind-mounts the resolved directory read-only into the container. The host materialization path is recorded on a container label and unmounted and removed on container deletion, alongside the snapshot view. The subpath is normalized and bounded to the rootfs at parse time (rejecting absolute paths and traversal that escapes), then resolved against the materialized rootfs with securejoin so the bind source has its symlinks already resolved: mount(2) resolves the source against the host root, so an unresolved path would let a symlink in the image bind the host's copy of the target. os.OpenInRoot then confirms through the kernel that the subpath exists and rejects absolute symlinks and escapes. Docker rejects those same subpaths. The whole-rootfs path hands the snapshotter mount straight to the runtime, which owns its lifecycle. Labels in the reserved nerdctl/ namespace are stripped from an image's config labels. --label already rejects that prefix, but image config labels bypassed it, which would let an image forge internal container state - including the image-mount host paths that nerdctl rm unmounts and deletes. Mounting the same image at multiple destinations is supported; the corresponding tests are skipped on Docker, which rejects mounting the same image more than once. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
35e7a07 to
f0a1e41
Compare
What
Implements
--mount type=imageend-to-end, matching Docker's--mount type=image(moby/moby#48798), including theimage-subpathoption.Part of #3867. Builds on #4990 (read-only whole-rootfs image mount); this squashes that MVP and the
image-subpathwork into a single commit.--mount type=image,source=<image>,destination=<path>: mounts the image rootfs read-only at the destination.--mount type=image,source=<image>,destination=<path>,image-subpath=<rel/path>: mounts only a subdirectory of the image rootfs.Image mounts are read-only, matching Docker (verified against Docker 29.6.2: writes return
Read-only file system).How
pkg/mountutil): acceptsimage-subpathand normalizes it. Internal..is collapsed (a/../b→b); an absolute path or a..that escapes the rootfs is rejected; a value that normalizes to the rootfs itself (., ora/..) mounts the whole rootfs, which is what Docker does. Presence is tracked separately from the value, so an explicit emptyimage-subpath=is rejected rather than silently read as "unset", andimage-subpathis rejected on any type other thanimage.securejoin(so the bind source has its symlinks resolved, becausemount(2)resolves the source against the host root) and then re-checked withos.OpenInRoot, the kernel's scoped lookup, which confirms the target exists and rejects absolute symlinks and escapes. The host materialization path is recorded on a container label and unmounted/removed onnerdctl rm, alongside the snapshot view.nerdctl/namespace are now stripped from an image's config labels.--labelalready rejects that prefix, but image config labels bypassed it, which would let an image forge internal container state — including the image-mount host paths thatnerdctl rmunmounts and deletes.Not included (parity notes vs moby#48798)
gc.rootlabel. A subpath mount's host materialization persists across container restart but not across a host reboot.Test
pkg/mountutilparser cases (subpath normalization, escaping-traversal / absolute / empty / non-image rejection, root-normalizing → whole rootfs).cmd/nerdctl/container/container_run_mount_image_linux_test.go(subpath exposure, multiple subpaths, read-only, absolute-symlink rejection, safe relative-symlink resolution, error cases).