daemon/c8d: Implement save and load

This makes the `docker save` and `docker load` work with the containerd
image store. The archive is both OCI and Docker compatible.

Saved archive will only contain content which is available locally.  In
case the saved image is a multi-platform manifest list, the behavior
depends on the local availability of the content. This is to be
reconsidered when we have the `--platform` option in the CLI.

- If all manifests and their contents, referenced by the manifest list
  are present, then the manifest-list is exported directly and the ID
will be the same.
- If only one platform manifest is present, only that manifest is
  exported (the image id will change and will be the id of
platform-specific manifest, instead of the full manifest list).
- If multiple, but not all, platform manifests are available, a new
  manifest list will be created which will be a subset of the original
manifest list.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski 2023-01-13 10:49:25 +01:00
parent 9052e33a10
commit af32603ae3
No known key found for this signature in database
GPG key ID: B85EFCFE26DEF92A

View file

@ -2,13 +2,20 @@ package containerd
import (
"context"
"fmt"
"io"
"github.com/containerd/containerd"
cerrdefs "github.com/containerd/containerd/errdefs"
containerdimages "github.com/containerd/containerd/images"
"github.com/containerd/containerd/images/archive"
"github.com/containerd/containerd/platforms"
cplatforms "github.com/containerd/containerd/platforms"
"github.com/docker/distribution/reference"
"github.com/pkg/errors"
"github.com/docker/docker/errdefs"
"github.com/docker/docker/pkg/platforms"
"github.com/docker/docker/pkg/streamformatter"
"github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
)
@ -20,18 +27,64 @@ import (
//
// TODO(thaJeztah): produce JSON stream progress response and image events; see https://github.com/moby/moby/issues/43910
func (i *ImageService) ExportImage(ctx context.Context, names []string, outStream io.Writer) error {
platform := platforms.AllPlatformsWithPreference(cplatforms.Default())
opts := []archive.ExportOpt{
archive.WithPlatform(platforms.Ordered(platforms.DefaultSpec())),
archive.WithSkipNonDistributableBlobs(),
// This makes the exported archive also include `manifest.json`
// when the image is a manifest list. It is needed for backwards
// compatibility with Docker image format.
// The containerd will choose only one manifest for the `manifest.json`.
// Our preference is to have it point to the default platform.
// Example:
// Daemon is running on linux/arm64
// When we export linux/amd64 and linux/arm64, manifest.json will point to linux/arm64.
// When we export linux/amd64 only, manifest.json will point to linux/amd64.
// Note: This is only applicable if importing this archive into non-containerd Docker.
// Importing the same archive into containerd, will not restrict the platforms.
archive.WithPlatform(platform),
}
is := i.client.ImageService()
for _, imageRef := range names {
named, err := reference.ParseDockerRef(imageRef)
ctx, release, err := i.client.WithLease(ctx)
if err != nil {
return errdefs.System(err)
}
defer release(ctx)
for _, name := range names {
target, err := i.resolveDescriptor(ctx, name)
if err != nil {
return err
}
opts = append(opts, archive.WithImage(is, named.String()))
// We may not have locally all the platforms that are specified in the index.
// Export only those manifests that we have.
// TODO(vvoland): Reconsider this when `--platform` is added.
if containerdimages.IsIndexType(target.MediaType) {
desc, err := i.getBestDescriptorForExport(ctx, target)
if err != nil {
return err
}
target = desc
}
if ref, err := reference.ParseNormalizedNamed(name); err == nil {
ref = reference.TagNameOnly(ref)
opts = append(opts, archive.WithManifest(target, ref.String()))
logrus.WithFields(logrus.Fields{
"target": target,
"name": ref.String(),
}).Debug("export image")
} else {
opts = append(opts, archive.WithManifest(target))
logrus.WithFields(logrus.Fields{
"target": target,
}).Debug("export image without name")
}
}
return i.client.Export(ctx, outStream, opts...)
}
@ -41,33 +94,132 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, outStrea
//
// TODO(thaJeztah): produce JSON stream progress response and image events; see https://github.com/moby/moby/issues/43910
func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, outStream io.Writer, quiet bool) error {
platform := platforms.All
// TODO(vvoland): Allow user to pass platform
platform := cplatforms.All
imgs, err := i.client.Import(ctx, inTar, containerd.WithImportPlatform(platform))
if err != nil {
// TODO(thaJeztah): remove this log or change to debug once we can; see https://github.com/moby/moby/pull/43822#discussion_r937502405
logrus.WithError(err).Warn("failed to import image to containerd")
return errors.Wrap(err, "failed to import image")
logrus.WithError(err).Debug("failed to import image to containerd")
return errdefs.System(err)
}
store := i.client.ContentStore()
progress := streamformatter.NewStdoutWriter(outStream)
for _, img := range imgs {
platformImg := containerd.NewImageWithPlatform(i.client, img, platform)
unpacked, err := platformImg.IsUnpacked(ctx, i.snapshotter)
allPlatforms, err := containerdimages.Platforms(ctx, store, img.Target)
if err != nil {
// TODO(thaJeztah): remove this log or change to debug once we can; see https://github.com/moby/moby/pull/43822#discussion_r937502405
logrus.WithError(err).WithField("image", img.Name).Debug("failed to check if image is unpacked")
continue
logrus.WithError(err).WithField("image", img.Name).Debug("failed to get image platforms")
return errdefs.Unknown(err)
}
if !unpacked {
err := platformImg.Unpack(ctx, i.snapshotter)
if err != nil {
// TODO(thaJeztah): remove this log or change to debug once we can; see https://github.com/moby/moby/pull/43822#discussion_r937502405
logrus.WithError(err).WithField("image", img.Name).Warn("failed to unpack image")
return errors.Wrap(err, "failed to unpack image")
}
name := img.Name
if named, err := reference.ParseNormalizedNamed(img.Name); err == nil {
name = reference.FamiliarName(named)
}
for _, platform := range allPlatforms {
logger := logrus.WithFields(logrus.Fields{
"platform": platform,
"image": name,
})
platformImg := containerd.NewImageWithPlatform(i.client, img, cplatforms.OnlyStrict(platform))
unpacked, err := platformImg.IsUnpacked(ctx, i.snapshotter)
if err != nil {
logger.WithError(err).Debug("failed to check if image is unpacked")
continue
}
if !unpacked {
err = platformImg.Unpack(ctx, i.snapshotter)
if err != nil {
return errdefs.System(err)
}
}
logger.WithField("alreadyUnpacked", unpacked).WithError(err).Debug("unpack")
}
fmt.Fprintf(progress, "Loaded image: %s\n", name)
}
return nil
}
// getBestDescriptorForExport returns a descriptor which only references content available locally.
// The returned descriptor can be:
// - The same index descriptor - if all content is available
// - Platform specific manifest - if only one manifest from the whole index is available
// - Reduced index descriptor - if not all, but more than one manifest is available
//
// The reduced index descriptor is stored in the content store and may be garbage collected.
// It's advised to pass a context with a lease that's long enough to cover usage of the blob.
func (i *ImageService) getBestDescriptorForExport(ctx context.Context, indexDesc ocispec.Descriptor) (ocispec.Descriptor, error) {
none := ocispec.Descriptor{}
if !containerdimages.IsIndexType(indexDesc.MediaType) {
err := fmt.Errorf("index/manifest-list descriptor expected, got: %s", indexDesc.MediaType)
return none, errdefs.InvalidParameter(err)
}
store := i.client.ContentStore()
children, err := containerdimages.Children(ctx, store, indexDesc)
if err != nil {
if cerrdefs.IsNotFound(err) {
return none, errdefs.NotFound(err)
}
return none, errdefs.System(err)
}
// Check which platform manifests have all their blobs available.
hasMissingManifests := false
var presentManifests []ocispec.Descriptor
for _, mfst := range children {
if containerdimages.IsManifestType(mfst.MediaType) {
available, _, _, missing, err := containerdimages.Check(ctx, store, mfst, nil)
if err != nil {
hasMissingManifests = true
logrus.WithField("manifest", mfst.Digest).Warn("failed to check manifest's blob availability, won't export")
continue
}
if available && len(missing) == 0 {
presentManifests = append(presentManifests, mfst)
logrus.WithField("manifest", mfst.Digest).Debug("manifest content present, will export")
} else {
hasMissingManifests = true
logrus.WithFields(logrus.Fields{
"manifest": mfst.Digest,
"missing": missing,
}).Debug("manifest is missing, won't export")
}
}
}
if !hasMissingManifests || len(children) == 0 {
// If we have the full image, or it has no manifests, just export the original index.
return indexDesc, nil
} else if len(presentManifests) == 1 {
// If only one platform is present, export that one manifest.
return presentManifests[0], nil
} else if len(presentManifests) == 0 {
// Return error when none of the image's manifest is present.
return none, errdefs.NotFound(fmt.Errorf("none of the manifests is fully present in the content store"))
}
// Create a new index which contains only the manifests we have in store.
index := ocispec.Index{
Versioned: specs.Versioned{
SchemaVersion: 2,
},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: presentManifests,
Annotations: indexDesc.Annotations,
}
reducedIndexDesc, err := storeJson(ctx, store, index.MediaType, index, nil)
if err != nil {
return none, err
}
return reducedIndexDesc, nil
}