From a03029bdfb1b193377df7b75b84ab92ff782f8a4 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 4 Aug 2022 10:37:04 +0200 Subject: [PATCH] Introduce support for docker commit Signed-off-by: Nicolas De Loof --- builder/builder.go | 2 +- builder/dockerfile/dispatchers.go | 4 +- builder/dockerfile/internals.go | 6 +- builder/dockerfile/mockbackend_test.go | 2 +- daemon/commit.go | 2 +- daemon/containerd/image.go | 4 +- daemon/containerd/image_commit.go | 301 ++++++++++++++++++++++++- daemon/containerd/service.go | 10 +- daemon/daemon.go | 2 +- daemon/image_service.go | 4 +- daemon/images/image_commit.go | 7 +- 11 files changed, 320 insertions(+), 24 deletions(-) diff --git a/builder/builder.go b/builder/builder.go index 2fc371da90246..f23de283e5273 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -42,7 +42,7 @@ type Backend interface { // CommitBuildStep creates a new Docker image from the config generated by // a build step. - CommitBuildStep(backend.CommitConfig) (image.ID, error) + CommitBuildStep(context.Context, backend.CommitConfig) (image.ID, error) // ContainerCreateWorkdir creates the workdir ContainerCreateWorkdir(containerID string) error diff --git a/builder/dockerfile/dispatchers.go b/builder/dockerfile/dispatchers.go index 78ae91683ffba..ac27a18105395 100644 --- a/builder/dockerfile/dispatchers.go +++ b/builder/dockerfile/dispatchers.go @@ -316,7 +316,7 @@ func dispatchWorkdir(ctx context.Context, d dispatchRequest, c *instructions.Wor return err } - return d.builder.commitContainer(d.state, containerID, runConfigWithCommentCmd) + return d.builder.commitContainer(ctx, d.state, containerID, runConfigWithCommentCmd) } // RUN some command yo @@ -390,7 +390,7 @@ func dispatchRun(ctx context.Context, d dispatchRequest, c *instructions.RunComm runConfigForCacheProbe.ArgsEscaped = stateRunConfig.ArgsEscaped } - return d.builder.commitContainer(d.state, cID, runConfigForCacheProbe) + return d.builder.commitContainer(ctx, d.state, cID, runConfigForCacheProbe) } // Derive the command to use for probeCache() and to commit in this container. diff --git a/builder/dockerfile/internals.go b/builder/dockerfile/internals.go index 544b977f240f2..1baf63366dbc4 100644 --- a/builder/dockerfile/internals.go +++ b/builder/dockerfile/internals.go @@ -87,10 +87,10 @@ func (b *Builder) commit(ctx context.Context, dispatchState *dispatchState, comm return err } - return b.commitContainer(dispatchState, id, runConfigWithCommentCmd) + return b.commitContainer(ctx, dispatchState, id, runConfigWithCommentCmd) } -func (b *Builder) commitContainer(dispatchState *dispatchState, id string, containerConfig *container.Config) error { +func (b *Builder) commitContainer(ctx context.Context, dispatchState *dispatchState, id string, containerConfig *container.Config) error { if b.disableCommit { return nil } @@ -103,7 +103,7 @@ func (b *Builder) commitContainer(dispatchState *dispatchState, id string, conta ContainerID: id, } - imageID, err := b.docker.CommitBuildStep(commitCfg) + imageID, err := b.docker.CommitBuildStep(ctx, commitCfg) dispatchState.imageID = string(imageID) return err } diff --git a/builder/dockerfile/mockbackend_test.go b/builder/dockerfile/mockbackend_test.go index da77054521c9e..9f3ae759e2453 100644 --- a/builder/dockerfile/mockbackend_test.go +++ b/builder/dockerfile/mockbackend_test.go @@ -39,7 +39,7 @@ func (m *MockBackend) ContainerRm(name string, config *types.ContainerRmConfig) return nil } -func (m *MockBackend) CommitBuildStep(c backend.CommitConfig) (image.ID, error) { +func (m *MockBackend) CommitBuildStep(ctx context.Context, c backend.CommitConfig) (image.ID, error) { if m.commitFunc != nil { return m.commitFunc(c) } diff --git a/daemon/commit.go b/daemon/commit.go index fbb02469a33e4..45af6ac8d9797 100644 --- a/daemon/commit.go +++ b/daemon/commit.go @@ -155,7 +155,7 @@ func (daemon *Daemon) CreateImageFromContainer(ctx context.Context, name string, return "", err } - id, err := daemon.imageService.CommitImage(backend.CommitConfig{ + id, err := daemon.imageService.CommitImage(ctx, backend.CommitConfig{ Author: c.Author, Comment: c.Comment, Config: newConfig, diff --git a/daemon/containerd/image.go b/daemon/containerd/image.go index ee19399379391..bcb4fd1564de1 100644 --- a/daemon/containerd/image.go +++ b/daemon/containerd/image.go @@ -29,7 +29,7 @@ func (i *ImageService) GetContainerdImage(ctx context.Context, refOrID string, p // GetImage returns an image corresponding to the image referred to by refOrID. func (i *ImageService) GetImage(ctx context.Context, refOrID string, options imagetype.GetImageOpts) (*image.Image, error) { - ii, img, err := i.getImage(ctx, refOrID, options.Platform) + ii, img, err := i.getImage(ctx, refOrID) if err != nil { return nil, err } @@ -49,7 +49,7 @@ func (i *ImageService) GetImage(ctx context.Context, refOrID string, options ima return img, err } -func (i *ImageService) getImage(ctx context.Context, refOrID string, platform *ocispec.Platform) (containerd.Image, *image.Image, error) { +func (i *ImageService) getImage(ctx context.Context, refOrID string) (containerd.Image, *image.Image, error) { desc, err := i.ResolveImage(ctx, refOrID) if err != nil { return nil, nil, err diff --git a/daemon/containerd/image_commit.go b/daemon/containerd/image_commit.go index bb5a6cc348f6c..113e39f49646c 100644 --- a/daemon/containerd/image_commit.go +++ b/daemon/containerd/image_commit.go @@ -1,13 +1,306 @@ package containerd import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "runtime" + "time" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/diff" + "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/leases" + "github.com/containerd/containerd/platforms" + "github.com/containerd/containerd/rootfs" + "github.com/containerd/containerd/snapshots" "github.com/docker/docker/api/types/backend" + containerapi "github.com/docker/docker/api/types/container" "github.com/docker/docker/image" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/identity" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" ) -// CommitImage creates a new image from a commit config. -func (i *ImageService) CommitImage(c backend.CommitConfig) (image.ID, error) { - panic("not implemented") +/* +This code is based on `commit` support in nerdctl, under Apache License +https://github.com/containerd/nerdctl/blob/master/pkg/imgutil/commit/commit.go +with adaptations to match the Moby data model and services. +*/ + +func (i *ImageService) CommitImage(ctx context.Context, cc backend.CommitConfig) (image.ID, error) { + container := i.containers.Get(cc.ContainerID) + + cimg, _, err := i.getImage(ctx, container.Config.Image) + if err != nil { + return "", err + } + + baseImgWithoutPlatform, err := i.client.ImageService().Get(ctx, cimg.Name()) + if err != nil { + return "", err + } + + baseImg := containerd.NewImageWithPlatform(i.client, baseImgWithoutPlatform, platforms.DefaultStrict()) + + contentStore := baseImg.ContentStore() + conf, err := baseImg.Config(ctx) + if err != nil { + return "", err + } + imageConfigBytes, err := content.ReadBlob(ctx, baseImg.ContentStore(), conf) + if err != nil { + return "", err + } + var ociimage ocispec.Image + if err := json.Unmarshal(imageConfigBytes, &ociimage); err != nil { + return "", err + } + + target := baseImg.Target() + b, err := content.ReadBlob(ctx, contentStore, target) + var ocimanifest ocispec.Manifest + if err := json.Unmarshal(b, &ocimanifest); err != nil { + return "", err + } + + var ( + differ = i.client.DiffService() + sn = i.client.SnapshotService(containerd.DefaultSnapshotter) + ) + + // Don't gc me and clean the dirty data after 1 hour! + ctx, done, err := i.client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour)) + if err != nil { + return "", fmt.Errorf("failed to create lease for commit: %w", err) + } + defer done(ctx) + + diffLayerDesc, diffID, err := createDiff(ctx, cc.ContainerID, sn, contentStore, differ) + if err != nil { + return "", fmt.Errorf("failed to export layer: %w", err) + } + + imageConfig, err := generateCommitImageConfig(ctx, container.Config, ociimage, diffID, cc) + if err != nil { + return "", fmt.Errorf("failed to generate commit image config: %w", err) + } + + rootfsID := identity.ChainID(imageConfig.RootFS.DiffIDs).String() + if err := applyDiffLayer(ctx, rootfsID, ociimage, sn, differ, diffLayerDesc); err != nil { + return "", fmt.Errorf("failed to apply diff: %w", err) + } + + layers := append(ocimanifest.Layers, diffLayerDesc) + commitManifestDesc, configDigest, err := writeContentsForImage(ctx, containerd.DefaultSnapshotter, baseImg, imageConfig, layers) + if err != nil { + return "", err + } + + // image create + img := images.Image{ + Name: configDigest.String(), + Target: commitManifestDesc, + CreatedAt: time.Now(), + } + + if _, err := i.client.ImageService().Update(ctx, img); err != nil { + if !errdefs.IsNotFound(err) { + return "", err + } + + if _, err := i.client.ImageService().Create(ctx, img); err != nil { + return "", fmt.Errorf("failed to create new image: %w", err) + } + } + return image.ID(img.Target.Digest), nil +} + +// generateCommitImageConfig returns commit oci image config based on the container's image. +func generateCommitImageConfig(ctx context.Context, container *containerapi.Config, baseConfig ocispec.Image, diffID digest.Digest, opts backend.CommitConfig) (ocispec.Image, error) { + if opts.Config.Cmd != nil { + baseConfig.Config.Cmd = opts.Config.Cmd + } + if opts.Config.Entrypoint != nil { + baseConfig.Config.Entrypoint = opts.Config.Entrypoint + } + if opts.Author == "" { + opts.Author = baseConfig.Author + } + + createdTime := time.Now() + arch := baseConfig.Architecture + if arch == "" { + arch = runtime.GOARCH + logrus.Warnf("assuming arch=%q", arch) + } + os := baseConfig.OS + if os == "" { + os = runtime.GOOS + logrus.Warnf("assuming os=%q", os) + } + logrus.Debugf("generateCommitImageConfig(): arch=%q, os=%q", arch, os) + return ocispec.Image{ + Architecture: arch, + OS: os, + Created: &createdTime, + Author: opts.Author, + Config: baseConfig.Config, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: append(baseConfig.RootFS.DiffIDs, diffID), + }, + History: append(baseConfig.History, ocispec.History{ + Created: &createdTime, + CreatedBy: "", // FIXME(ndeloof) ? + Author: opts.Author, + Comment: opts.Comment, + EmptyLayer: diffID == "", + }), + }, nil +} + +// writeContentsForImage will commit oci image config and manifest into containerd's content store. +func writeContentsForImage(ctx context.Context, snName string, baseImg containerd.Image, newConfig ocispec.Image, layers []ocispec.Descriptor) (ocispec.Descriptor, image.ID, error) { + newConfigJSON, err := json.Marshal(newConfig) + if err != nil { + return ocispec.Descriptor{}, "", err + } + + configDesc := ocispec.Descriptor{ + MediaType: images.MediaTypeDockerSchema2Config, + Digest: digest.FromBytes(newConfigJSON), + Size: int64(len(newConfigJSON)), + } + + newMfst := struct { + MediaType string `json:"mediaType,omitempty"` + ocispec.Manifest + }{ + MediaType: images.MediaTypeDockerSchema2Manifest, + Manifest: ocispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + Config: configDesc, + Layers: layers, + }, + } + + newMfstJSON, err := json.MarshalIndent(newMfst, "", " ") + if err != nil { + return ocispec.Descriptor{}, "", err + } + + newMfstDesc := ocispec.Descriptor{ + MediaType: images.MediaTypeDockerSchema2Manifest, + Digest: digest.FromBytes(newMfstJSON), + Size: int64(len(newMfstJSON)), + } + + // new manifest should reference the layers and config content + labels := map[string]string{ + "containerd.io/gc.ref.content.0": configDesc.Digest.String(), + } + for i, l := range layers { + labels[fmt.Sprintf("containerd.io/gc.ref.content.%d", i+1)] = l.Digest.String() + } + + err = content.WriteBlob(ctx, baseImg.ContentStore(), newMfstDesc.Digest.String(), bytes.NewReader(newMfstJSON), newMfstDesc, content.WithLabels(labels)) + if err != nil { + return ocispec.Descriptor{}, "", err + } + + // config should reference to snapshotter + labelOpt := content.WithLabels(map[string]string{ + fmt.Sprintf("containerd.io/gc.ref.snapshot.%s", snName): identity.ChainID(newConfig.RootFS.DiffIDs).String(), + }) + err = content.WriteBlob(ctx, baseImg.ContentStore(), configDesc.Digest.String(), bytes.NewReader(newConfigJSON), configDesc, labelOpt) + if err != nil { + return ocispec.Descriptor{}, "", err + } + + return newMfstDesc, image.ID(configDesc.Digest), nil +} + +// createDiff creates a layer diff into containerd's content store. +func createDiff(ctx context.Context, name string, sn snapshots.Snapshotter, cs content.Store, comparer diff.Comparer) (ocispec.Descriptor, digest.Digest, error) { + newDesc, err := rootfs.CreateDiff(ctx, name, sn, comparer) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + + info, err := cs.Info(ctx, newDesc.Digest) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + + diffIDStr, ok := info.Labels["containerd.io/uncompressed"] + if !ok { + return ocispec.Descriptor{}, digest.Digest(""), fmt.Errorf("invalid differ response with no diffID") + } + + diffID, err := digest.Parse(diffIDStr) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + + return ocispec.Descriptor{ + MediaType: images.MediaTypeDockerSchema2LayerGzip, + Digest: newDesc.Digest, + Size: info.Size, + }, diffID, nil +} + +// applyDiffLayer will apply diff layer content created by createDiff into the snapshotter. +func applyDiffLayer(ctx context.Context, name string, baseImg ocispec.Image, sn snapshots.Snapshotter, differ diff.Applier, diffDesc ocispec.Descriptor) (retErr error) { + var ( + key = uniquePart() + "-" + name + parent = identity.ChainID(baseImg.RootFS.DiffIDs).String() + ) + + mount, err := sn.Prepare(ctx, key, parent) + if err != nil { + return err + } + + defer func() { + if retErr != nil { + // NOTE: the snapshotter should be hold by lease. Even + // if the cleanup fails, the containerd gc can delete it. + if err := sn.Remove(ctx, key); err != nil { + logrus.Warnf("failed to cleanup aborted apply %s: %s", key, err) + } + } + }() + + if _, err = differ.Apply(ctx, diffDesc, mount); err != nil { + return err + } + + if err = sn.Commit(ctx, name, key); err != nil { + if errdefs.IsAlreadyExists(err) { + return nil + } + return err + } + return nil +} + +// copied from github.com/containerd/containerd/rootfs/apply.go +func uniquePart() string { + t := time.Now() + var b [3]byte + // Ignore read failures, just decreases uniqueness + rand.Read(b[:]) + return fmt.Sprintf("%d-%s", t.Nanosecond(), base64.URLEncoding.EncodeToString(b[:])) } // CommitBuildStep is used by the builder to create an image for each step in @@ -19,6 +312,6 @@ func (i *ImageService) CommitImage(c backend.CommitConfig) (image.ID, error) { // - it doesn't log a container commit event // // This is a temporary shim. Should be removed when builder stops using commit. -func (i *ImageService) CommitBuildStep(c backend.CommitConfig) (image.ID, error) { +func (i *ImageService) CommitBuildStep(ctx context.Context, c backend.CommitConfig) (image.ID, error) { panic("not implemented") } diff --git a/daemon/containerd/service.go b/daemon/containerd/service.go index 1c0686e74423a..bc9ff60a68648 100644 --- a/daemon/containerd/service.go +++ b/daemon/containerd/service.go @@ -19,14 +19,16 @@ import ( // ImageService implements daemon.ImageService type ImageService struct { - client *containerd.Client - usage singleflight.Group + client *containerd.Client + usage singleflight.Group + containers container.Store } // NewService creates a new ImageService. -func NewService(c *containerd.Client) *ImageService { +func NewService(c *containerd.Client, containers container.Store) *ImageService { return &ImageService{ - client: c, + client: c, + containers: containers, } } diff --git a/daemon/daemon.go b/daemon/daemon.go index 6d104821042fe..45e2f0f675d38 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -1027,7 +1027,7 @@ func NewDaemon(ctx context.Context, config *config.Config, pluginStore *plugin.S d.linkIndex = newLinkIndex() if d.UsesSnapshotter() { - d.imageService = ctrd.NewService(d.containerdCli) + d.imageService = ctrd.NewService(d.containerdCli, d.containers) } else { ifs, err := image.NewFSStoreBackend(filepath.Join(imageRoot, "imagedb")) if err != nil { diff --git a/daemon/image_service.go b/daemon/image_service.go index 9eae28ef8fe8a..83f382af3f139 100644 --- a/daemon/image_service.go +++ b/daemon/image_service.go @@ -40,7 +40,7 @@ type ImageService interface { TagImageWithReference(ctx context.Context, imageID image.ID, newTag reference.Named) error GetImage(ctx context.Context, refOrID string, options imagetype.GetImageOpts) (*image.Image, error) ImageHistory(ctx context.Context, name string) ([]*imagetype.HistoryResponseItem, error) - CommitImage(c backend.CommitConfig) (image.ID, error) + CommitImage(ctx context.Context, c backend.CommitConfig) (image.ID, error) SquashImage(id, parent string) (string, error) // Layers @@ -61,7 +61,7 @@ type ImageService interface { // Build MakeImageCache(ctx context.Context, cacheFrom []string) builder.ImageCache - CommitBuildStep(c backend.CommitConfig) (image.ID, error) + CommitBuildStep(ctx context.Context, c backend.CommitConfig) (image.ID, error) // Other diff --git a/daemon/images/image_commit.go b/daemon/images/image_commit.go index dcde88adc4690..c6924d9f62124 100644 --- a/daemon/images/image_commit.go +++ b/daemon/images/image_commit.go @@ -1,6 +1,7 @@ package images // import "github.com/docker/docker/daemon/images" import ( + "context" "encoding/json" "io" @@ -12,7 +13,7 @@ import ( ) // CommitImage creates a new image from a commit config -func (i *ImageService) CommitImage(c backend.CommitConfig) (image.ID, error) { +func (i *ImageService) CommitImage(ctx context.Context, c backend.CommitConfig) (image.ID, error) { rwTar, err := exportContainerRw(i.layerStore, c.ContainerID, c.ContainerMountLabel) if err != nil { return "", err @@ -109,7 +110,7 @@ func exportContainerRw(layerStore layer.Store, id, mountLabel string) (arch io.R // - it doesn't log a container commit event // // This is a temporary shim. Should be removed when builder stops using commit. -func (i *ImageService) CommitBuildStep(c backend.CommitConfig) (image.ID, error) { +func (i *ImageService) CommitBuildStep(ctx context.Context, c backend.CommitConfig) (image.ID, error) { container := i.containers.Get(c.ContainerID) if container == nil { // TODO: use typed error @@ -118,5 +119,5 @@ func (i *ImageService) CommitBuildStep(c backend.CommitConfig) (image.ID, error) c.ContainerMountLabel = container.MountLabel c.ContainerOS = container.OS c.ParentImageID = string(container.ImageID) - return i.CommitImage(c) + return i.CommitImage(ctx, c) }