Skip to content

Commit 2a89c8c

Browse files
committed
feat(mount): support --mount type=image
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. Read-write and subpath are not yet supported. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
1 parent dbb53e9 commit 2a89c8c

9 files changed

Lines changed: 415 additions & 18 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package container
18+
19+
import (
20+
"fmt"
21+
"testing"
22+
23+
"github.com/containerd/nerdctl/mod/tigron/expect"
24+
"github.com/containerd/nerdctl/mod/tigron/require"
25+
"github.com/containerd/nerdctl/mod/tigron/test"
26+
27+
"github.com/containerd/nerdctl/v2/pkg/testutil"
28+
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
29+
)
30+
31+
// TestRunMountTypeImage verifies that `--mount type=image` mounts the source
32+
// image's filesystem into the container so its files are readable at the target.
33+
func TestRunMountTypeImage(t *testing.T) {
34+
testCase := nerdtest.Setup()
35+
36+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
37+
return helpers.Command("run", "--rm",
38+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img", testutil.CommonImage),
39+
testutil.CommonImage, "cat", "/mnt/img/etc/os-release")
40+
}
41+
42+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
43+
return &test.Expected{
44+
ExitCode: expect.ExitCodeSuccess,
45+
Output: expect.Contains("Alpine"),
46+
}
47+
}
48+
49+
testCase.Run(t)
50+
}
51+
52+
// TestRunMountTypeImageMultipleDestinations verifies the same image can be
53+
// mounted at two destinations in one container.
54+
func TestRunMountTypeImageMultipleDestinations(t *testing.T) {
55+
testCase := nerdtest.Setup()
56+
// nerdctl-only: Docker keys an image mount by its source image and rejects
57+
// mounting the same image twice ("mount already exists with name").
58+
testCase.Require = require.Not(nerdtest.Docker)
59+
60+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
61+
return helpers.Command("run", "--rm",
62+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/a", testutil.CommonImage),
63+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/b", testutil.CommonImage),
64+
testutil.CommonImage, "cat", "/mnt/a/etc/os-release", "/mnt/b/etc/os-release")
65+
}
66+
67+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
68+
return &test.Expected{
69+
ExitCode: expect.ExitCodeSuccess,
70+
Output: expect.Contains("Alpine"),
71+
}
72+
}
73+
74+
testCase.Run(t)
75+
}
76+
77+
// TestRunMountTypeImageReadOnly verifies an image mount is read-only (writing
78+
// fails). nerdctl-only: Docker mounts images read-write by default.
79+
func TestRunMountTypeImageReadOnly(t *testing.T) {
80+
testCase := nerdtest.Setup()
81+
testCase.Require = require.Not(nerdtest.Docker)
82+
83+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
84+
return helpers.Command("run", "--rm",
85+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img", testutil.CommonImage),
86+
testutil.CommonImage, "touch", "/mnt/img/should-fail")
87+
}
88+
89+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
90+
return &test.Expected{
91+
ExitCode: expect.ExitCodeGenericFail,
92+
Errors: []error{fmt.Errorf("Read-only file system")},
93+
}
94+
}
95+
96+
testCase.Run(t)
97+
}
98+
99+
// TestRunMountTypeImageErrors verifies that an image mount missing its source,
100+
// or using the not-yet-supported subpath option, is rejected. subpath is
101+
// nerdctl-specific behaviour here, so the test is not run against Docker.
102+
func TestRunMountTypeImageErrors(t *testing.T) {
103+
testCase := nerdtest.Setup()
104+
testCase.Require = require.Not(nerdtest.Docker)
105+
106+
testCase.SubTests = []*test.Case{
107+
{
108+
Description: "missing source",
109+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
110+
return helpers.Command("run", "--rm", "--mount", "type=image,destination=/mnt/img",
111+
testutil.CommonImage, "true")
112+
},
113+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
114+
return &test.Expected{
115+
ExitCode: expect.ExitCodeGenericFail,
116+
Errors: []error{fmt.Errorf("source")},
117+
}
118+
},
119+
},
120+
{
121+
Description: "subpath not supported",
122+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
123+
return helpers.Command("run", "--rm",
124+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,subpath=etc", testutil.CommonImage),
125+
testutil.CommonImage, "true")
126+
},
127+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
128+
return &test.Expected{
129+
ExitCode: expect.ExitCodeGenericFail,
130+
Errors: []error{fmt.Errorf("subpath")},
131+
}
132+
},
133+
},
134+
}
135+
136+
testCase.Run(t)
137+
}

docs/command-reference.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,9 @@ Volume flags:
295295
Consists of multiple key-value pairs, separated by commas and each
296296
consisting of a `<key>=<value>` tuple.
297297
e.g., `-- mount type=bind,source=/src,target=/app,bind-propagation=shared`.
298-
- :whale: `type`: Current supported mount types are `bind`, `volume`, `tmpfs`.
298+
- :whale: `type`: Current supported mount types are `bind`, `volume`, `tmpfs`, `image`.
299299
The default type will be set to `volume` if not specified.
300300
i.e., `--mount src=vol-1,dst=/app,readonly` equals `--mount type=volume,src=vol-1,dst=/app,readonly`
301-
- unimplemented type: `image`
302301
- Common Options:
303302
- :whale: `src`, `source`: Mount source spec for bind and volume. Mandatory for bind.
304303
- :whale: `dst`, `destination`, `target`: Mount destination spec.
@@ -313,6 +312,9 @@ Volume flags:
313312
Defaults to `1777` or world-writable.
314313
- Options specific to `volume`:
315314
- unimplemented options: `volume-nocopy`, `volume-label`, `volume-driver`, `volume-opt`
315+
- Options specific to `image`:
316+
- :whale: `src`, `source`: image reference (mandatory). The image filesystem is mounted read-only.
317+
- unimplemented options: `subpath`
316318
- :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container".
317319

318320
Rootfs flags:

pkg/cmd/container/create.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import (
7070
)
7171

7272
// Create will create a container.
73-
func Create(ctx context.Context, client *containerd.Client, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error) {
73+
func Create(ctx context.Context, client *containerd.Client, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (_ containerd.Container, _ func(), retErr error) {
7474
// Acquire an exclusive lock on the volume store until we are done to avoid being raced by any other
7575
// volume operations (or any other operation involving volume manipulation)
7676
volStore, err := volume.Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address)
@@ -94,6 +94,23 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa
9494
internalLabels.platform = options.Platform
9595
internalLabels.namespace = options.GOptions.Namespace
9696

97+
// If creation fails after image-mount views are created, remove them so the
98+
// snapshots do not leak (the cleanup label is only persisted on success).
99+
defer func() {
100+
if retErr == nil {
101+
return
102+
}
103+
var keys []string
104+
for _, mp := range internalLabels.mountPoints {
105+
if mp.ImageMountSnapshot != "" {
106+
keys = append(keys, mp.ImageMountSnapshot)
107+
}
108+
}
109+
if len(keys) > 0 {
110+
removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), keys)
111+
}
112+
}()
113+
97114
var (
98115
id = idgen.GenerateID()
99116
opts []oci.SpecOpts
@@ -806,6 +823,22 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
806823
m[labels.AnonymousVolumes] = string(anonVolumeJSON)
807824
}
808825

826+
// Record the snapshot keys of any type=image mount views so they can be
827+
// removed when the container is deleted.
828+
var imageMountSnapshots []string
829+
for _, mp := range internalLabels.mountPoints {
830+
if mp.ImageMountSnapshot != "" {
831+
imageMountSnapshots = append(imageMountSnapshots, mp.ImageMountSnapshot)
832+
}
833+
}
834+
if len(imageMountSnapshots) > 0 {
835+
b, err := json.Marshal(imageMountSnapshots)
836+
if err != nil {
837+
return nil, err
838+
}
839+
m[labels.ImageMountSnapshots] = string(b)
840+
}
841+
809842
if internalLabels.pidFile != "" {
810843
m[labels.PIDFile] = internalLabels.pidFile
811844
}

pkg/cmd/container/remove.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
109109
return err
110110
}
111111

112+
// Capture the container's snapshotter before deletion: image-mount views were
113+
// created against it, which may differ from the current --snapshotter flag.
114+
imageMountSnapshotter := globalOptions.Snapshotter
115+
if info, err := c.Info(ctx); err == nil && info.Snapshotter != "" {
116+
imageMountSnapshotter = info.Snapshotter
117+
}
118+
112119
// Get datastore
113120
dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address)
114121
if err != nil {
@@ -275,6 +282,16 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
275282
}
276283
}
277284
}
285+
286+
// Remove the read-only views backing type=image mounts - soft failure.
287+
if snapshotsJSON, ok := containerLabels[labels.ImageMountSnapshots]; ok {
288+
var keys []string
289+
if err = json.Unmarshal([]byte(snapshotsJSON), &keys); err != nil {
290+
log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount snapshots for container %q", id)
291+
} else {
292+
removeImageMountViews(ctx, client.SnapshotService(imageMountSnapshotter), keys)
293+
}
294+
}
278295
}()
279296

280297
// Get the task.

pkg/cmd/container/run_mount.go

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/containerd/containerd/v2/core/containers"
3838
"github.com/containerd/containerd/v2/core/leases"
3939
"github.com/containerd/containerd/v2/core/mount"
40+
"github.com/containerd/containerd/v2/core/snapshots"
4041
"github.com/containerd/containerd/v2/pkg/oci"
4142
"github.com/containerd/continuity/fs"
4243
"github.com/containerd/errdefs"
@@ -122,17 +123,83 @@ func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCr
122123
return parsed, nil
123124
}
124125

126+
// gcRootLabel marks a snapshot as a GC root so containerd does not reclaim it.
127+
const gcRootLabel = "containerd.io/gc.root"
128+
129+
// setupImageMount ensures and unpacks ref, then creates a read-only GC-rooted
130+
// snapshot view of its rootfs. It returns the OCI mount for destination and the
131+
// view's snapshot key.
132+
func setupImageMount(ctx context.Context, client *containerd.Client, options types.ContainerCreateOptions, ref, destination string) (specs.Mount, string, error) {
133+
ensured, err := imgutil.EnsureImage(ctx, client, ref, options.ImagePullOpt)
134+
if err != nil {
135+
return specs.Mount{}, "", fmt.Errorf("failed to ensure image %q for image mount: %w", ref, err)
136+
}
137+
if err := ensured.Image.Unpack(ctx, options.GOptions.Snapshotter); err != nil {
138+
return specs.Mount{}, "", fmt.Errorf("failed to unpack image %q for image mount: %w", ref, err)
139+
}
140+
diffIDs, err := ensured.Image.RootFS(ctx)
141+
if err != nil {
142+
return specs.Mount{}, "", fmt.Errorf("failed to get rootfs of image %q for image mount: %w", ref, err)
143+
}
144+
chainID := identity.ChainID(diffIDs).String()
145+
146+
snapshotKey := idgen.GenerateID() + "-image-mount"
147+
s := client.SnapshotService(options.GOptions.Snapshotter)
148+
mounts, err := s.View(ctx, snapshotKey, chainID, snapshots.WithLabels(map[string]string{
149+
gcRootLabel: time.Now().UTC().Format(time.RFC3339),
150+
}))
151+
if err != nil {
152+
return specs.Mount{}, "", fmt.Errorf("failed to create read-only view of image %q: %w", ref, err)
153+
}
154+
// overlayfs and native snapshotters each yield a single mount for a view.
155+
if len(mounts) != 1 {
156+
if rmErr := s.Remove(ctx, snapshotKey); rmErr != nil && !errdefs.IsNotFound(rmErr) {
157+
log.G(ctx).WithError(rmErr).Warnf("failed to remove image-mount snapshot %q", snapshotKey)
158+
}
159+
return specs.Mount{}, "", fmt.Errorf("image mount expects exactly one mount from the snapshotter, got %d", len(mounts))
160+
}
161+
162+
m := mounts[0]
163+
opts := m.Options
164+
// A view without an upper dir is already read-only; make it explicit for
165+
// bind-backed snapshotters.
166+
if !strutil.InStringSlice(opts, "ro") {
167+
opts = append(opts, "ro")
168+
}
169+
return specs.Mount{
170+
Type: m.Type,
171+
Source: m.Source,
172+
Destination: destination,
173+
Options: opts,
174+
}, snapshotKey, nil
175+
}
176+
177+
// removeImageMountViews removes the snapshotter views created for type=image
178+
// mounts. NotFound is ignored; other failures are logged but not fatal.
179+
func removeImageMountViews(ctx context.Context, s snapshots.Snapshotter, keys []string) {
180+
for _, k := range keys {
181+
if err := s.Remove(ctx, k); err != nil && !errdefs.IsNotFound(err) {
182+
log.G(ctx).WithError(err).Warnf("failed to remove image-mount snapshot %q", k)
183+
}
184+
}
185+
}
186+
125187
// generateMountOpts generates volume-related mount opts.
126188
// Other mounts such as procfs mount are not handled here.
127189
func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredImage *imgutil.EnsuredImage,
128-
volStore volumestore.VolumeStore, options types.ContainerCreateOptions) ([]oci.SpecOpts, []string, []*mountutil.Processed, error) {
190+
volStore volumestore.VolumeStore, options types.ContainerCreateOptions) (opts []oci.SpecOpts, anonVolumes []string, mountPoints []*mountutil.Processed, retErr error) {
129191
//nolint:prealloc
130192
var (
131-
opts []oci.SpecOpts
132-
anonVolumes []string
133-
userMounts []specs.Mount
134-
mountPoints []*mountutil.Processed
193+
userMounts []specs.Mount
194+
imageMountViews []string
135195
)
196+
// Remove any image-mount views created here if this function fails, so a
197+
// partial setup does not leak snapshots.
198+
defer func() {
199+
if retErr != nil && len(imageMountViews) > 0 {
200+
removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), imageMountViews)
201+
}
202+
}()
136203
mounted := make(map[string]struct{})
137204
var imageVolumes map[string]struct{}
138205
var tempDir string
@@ -229,6 +296,20 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm
229296
} else if len(parsed) > 0 {
230297
ociMounts := make([]specs.Mount, len(parsed))
231298
for i, x := range parsed {
299+
// type=image: build the read-only view now and record its snapshot
300+
// key for cleanup on container removal.
301+
if x.Type == mountutil.Image {
302+
m, snapshotKey, err := setupImageMount(ctx, client, options, x.Mount.Source, x.Mount.Destination)
303+
if err != nil {
304+
return nil, nil, nil, err
305+
}
306+
imageMountViews = append(imageMountViews, snapshotKey)
307+
ociMounts[i] = m
308+
x.ImageMountSnapshot = snapshotKey
309+
mounted[filepath.Clean(x.Mount.Destination)] = struct{}{}
310+
continue
311+
}
312+
232313
ociMounts[i] = x.Mount
233314
mounted[filepath.Clean(x.Mount.Destination)] = struct{}{}
234315

pkg/labels/labels.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const (
8080
// AnonymousVolumes is a JSON-marshalled string of []string
8181
AnonymousVolumes = Prefix + "anonymous-volumes"
8282

83+
// ImageMountSnapshots is a JSON-marshalled []string of snapshotter keys for
84+
// the read-only views backing `--mount type=image`, removed on container deletion.
85+
ImageMountSnapshots = Prefix + "image-mount-snapshots"
86+
8387
// Platform is the normalized platform string like "linux/ppc64le".
8488
Platform = Prefix + "platform"
8589

pkg/mountutil/mountutil.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
Bind = "bind"
4040
Volume = "volume"
4141
Tmpfs = "tmpfs"
42+
Image = "image"
4243
Npipe = "npipe"
4344
pathSeparator = string(os.PathSeparator)
4445
)
@@ -50,6 +51,9 @@ type Processed struct {
5051
AnonymousVolume string // anonymous volume name
5152
Mode string
5253
Opts []oci.SpecOpts
54+
// ImageMountSnapshot is the snapshotter key of the read-only view for a
55+
// type=image mount; empty for other mount types.
56+
ImageMountSnapshot string
5357
}
5458

5559
type volumeSpec struct {

0 commit comments

Comments
 (0)