Skip to content

Add support for multiple platforms in image export and load#50166

Merged
thaJeztah merged 2 commits intomoby:masterfrom
ctalledo:fix-for-48759
Jul 23, 2025
Merged

Add support for multiple platforms in image export and load#50166
thaJeztah merged 2 commits intomoby:masterfrom
ctalledo:fix-for-48759

Conversation

@ctalledo
Copy link
Collaborator

@ctalledo ctalledo commented Jun 10, 2025

- What I did

Add support for multiple platforms when loading or exporting images from/to tar archives.

This way clients (e.g., docker cli) can do things like:

docker image save --platform linux/amd64,linux/arm64/v8,riscv64 -o alpine.tar alpine:latest

Or

docker image load --platform linux/arm64/v8,riscv64 -i alpine.tar

Fixes #48759 .

NOTE: See the corresponding PR in the Docker CLI so that docker image load and docker image save accept multiple platforms via the --platform option (as opposed to a single platform as they currently do).

- How I did it

Changed the image export and load APIs so that they accept an array of platforms, as opposed to a single platform. Also modified the code that handles those APIs accordingly.

- How to verify it

Updated unit and integration tests accordingly.

- Human readable description for the release notes

`GET /images/{name}/get` and `POST /images/load` now accept multiple `platform` query parameters, allowing export and load of images for multiple platforms.

- A picture of a cute animal (not mandatory but encouraged)

@ctalledo ctalledo marked this pull request as draft June 10, 2025 16:28
@ctalledo ctalledo changed the title Add support for multiple platforms when loading or exporting images from/to tar archives [DRAFT] Add support for multiple platforms when loading or exporting images from/to tar archives Jun 10, 2025
@ctalledo ctalledo self-assigned this Jun 10, 2025
@ctalledo ctalledo added area/api API area/daemon Core Engine labels Jun 10, 2025
@ctalledo ctalledo force-pushed the fix-for-48759 branch 2 times, most recently from 6fb6efe to 377f4d7 Compare June 10, 2025 21:46
@ctalledo ctalledo changed the title [DRAFT] Add support for multiple platforms when loading or exporting images from/to tar archives Add support for multiple platforms in image export and load. Jun 10, 2025
@ctalledo ctalledo changed the title Add support for multiple platforms in image export and load. Add support for multiple platforms in image export and load Jun 10, 2025
@ctalledo ctalledo marked this pull request as ready for review June 10, 2025 23:07
@ctalledo ctalledo requested review from thaJeztah and vvoland June 10, 2025 23:07
@thaJeztah
Copy link
Member

thaJeztah commented Jun 11, 2025

Looks like the description and title may not match; it's probably either export and import or save and load 😅 ?

For a quick TL;DR (as they are confusing, and have confused people many times);

  • docker export / docker container export - exports a rootfs; it's not an image, only the (container's) filesystem
  • docker import / docker image import - imports a rootfs, and creates an image from it; by default, it adds default "config" metadata (default command (/bin/sh on Linux), platform), but allows additional options to be set through --changes (which is a bit of a hack to use Dockerfile commands as an indirect).
  • docker save / docker image save - saves docker / OCI image(s) to a tar; these are images, and includes all metadata (Config, such as command, platform, labels)
  • docker load / docker image load - loads docker / OCI image(s) from a tar; these are images, and includes all metadata (Config, such as command, platform, labels)

@ctalledo
Copy link
Collaborator Author

ctalledo commented Jun 11, 2025

Hi @thaJeztah,

Looks like the description and title may not match; it's probably either export and import or save and load 😅 ?

From a Docker CLI perspective, I mean docker save and docker load.

But the naming I used in the title reflects the naming in the Moby code itself (exportImage for docker save and loadImage for docker load.

And to make the naming even more confusing, exportImage corresponds to the /images/get API :)

So some renaming would be good here for consistency (can do that in a follow-up commit).

Comment on lines +21 to +25
if len(platformList) > 0 {
platform = platformList[len(platformList)-1]
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering if we should make this check for the overlap between the given list of platforms and the image that's specified.

Basically thinking;

  • docker pull --platform linux/s390x alpine
  • I now have alpine on my machine, but only as linux/s390x
  • docker save --platform linux/s390x,linux/amd64 alpine (save alpine, but only the s390x and amd64 variants, skip other variants
  • ^^ we only have s390x, and "skip" amd64 (which ... isn't present, so nothing to skip)

I guess that depends a bit on how we describe / consider the purpose of --platform in this case;

  • Is it a requirement? (export if present, fail if not)
  • Is it a filter? (export the given platforms, and skip other ones)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a requirement? (export if present, fail if not)

Yes, I view it as such.

Maybe the better approach is to modify the code you mentioned to error out if more than one platform is passed when using the docker image store? E.g.,

$ docker save --platform linux/s390x,linux/amd64 alpine
<error: --platform only takes a single platform when using the Docker image store.">

Then the user does:

$ docker save --platform linux/amd64 alpine
<error indicating failure because image does not contain specified platform>
$ docker save --platform linux/s390x alpine
<success>

WDYT?

Copy link
Contributor

@vvoland vvoland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, left some comment! Also needs an entry in version-history.md


type importExportBackend interface {
LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error
LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []*ocispec.Platform, outStream io.Writer, quiet bool) error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []*ocispec.Platform, outStream io.Writer, quiet bool) error
LoadImage(ctx context.Context, inTar io.ReadCloser, platforms []ocispec.Platform, outStream io.Writer, quiet bool) error

We can now drop the pointer. Before, the pointer was necessary to describe if explicit platform is specified or not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, done.

if formPlatforms := r.Form["platform"]; len(formPlatforms) > 1 {
// TODO(thaJeztah): remove once we support multiple platforms: see https://github.com/moby/moby/issues/48759
return errdefs.InvalidParameter(errors.New("multiple platform parameters not supported"))
formPlatforms := r.Form["platform"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (and the other endpoint) could use

func DecodePlatforms(platformJSONs []string) ([]ocispec.Platform, error) {

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, done.

Comment on lines +34 to +42
// The docker image store only supports single platform images. If multiple platforms are given,
// use the last one (for compatibility with previous behavior).
if len(platformList) > 0 {
platform = platformList[len(platformList)-1]
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should error out if there are more than 1 platforms passed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done.

Comment on lines +38 to +42
if len(platformList) == 0 {
pm = matchAllWithPreference(i.hostPlatformMatcher())
} else {
pl := make([]ocispec.Platform, 0, len(platformList))
for _, p := range platformList {
pl = append(pl, *p)
}
pm = platforms.Any(pl...)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matcher is ONLY used for the backwards compatible manifest.json and should still "prefer" the host platform to be used for it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good catch; I've fixed it. Had to create a new matcher called matchAnyWithPreference which matches any of the given platforms but orders the result so that the host platform comes first (if present in the image manifests).

@vvoland vvoland added the containerd-integration Issues and PRs related to containerd integration label Jun 12, 2025
@ctalledo ctalledo requested a review from vvoland June 13, 2025 00:33
Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just some quick blurbs

Comment on lines +33 to +34
// matchAnyWithPreference will return a platform matcher that matches any of the
// given platforms but will order platforms matching the preferred matcher first.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit; trying to avoid future tense;

Suggested change
// matchAnyWithPreference will return a platform matcher that matches any of the
// given platforms but will order platforms matching the preferred matcher first.
// matchAnyWithPreference returns platform matcher that matches any of the
// given platforms but orders platforms to match the preferred matcher first.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, done.


// matchAnyWithPreference will return a platform matcher that matches any of the
// given platforms but will order platforms matching the preferred matcher first.
func matchAnyWithPreference(preferred platforms.MatchComparer, platformList []ocispec.Platform) platforms.MatchComparer {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use input on this, but changing the signature to return the concrete type may be more convenient;

func matchAnyWithPreference(preferred platforms.MatchComparer, platformList []ocispec.Platform) platformsWithPreferenceMatcher {

At least; locations where it's used should already assert it satisfies the interface, but it can make it more convenient to "jump" to the implementation (i.e., in TestPlatformsWithPreferenceMatcher, clicking matcher.Match in my IDE will directly take me to the implementation, not to the interface.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, done.

Comment on lines +42 to +29
func (c platformsWithPreferenceMatcher) Match(p ocispec.Platform) bool {
return platforms.Any(c.platformList...).Match(p)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not for this PR, but we could even consider merging with the existing matchAllWithPreference, and consider the platform-list to be an optional argument, which would make it something like;

func (c platformsWithPreferenceMatcher) Match(p ocispec.Platform) bool {
	if len(c.platformList) == 0 {
		return true
	}
	return platforms.Any(c.platformList...).Match(p)
}

Mostly looking if we can reduce the number of matcher variations we have; for example, we also have ImageService. matchRequestedOrDefault, which currently accepts a single platform as argument, but (MAYBE?) could take a variadic list or slice, so that we can use the same logic in more places;

func (i *ImageService) matchRequestedOrDefault(
fpm matchComparerProvider, // function to create a platform matcher if platform is not nil
platform *ocispec.Platform, // input platform, nil if not specified
) platformMatcherWithRequestedPlatform {
var inner platforms.MatchComparer
if platform == nil {
inner = matchAllWithPreference(i.hostPlatformMatcher())
} else {
inner = fpm(*platform)
}
return platformMatcherWithRequestedPlatform{
Requested: platform,
MatchComparer: inner,
}
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea; I added a follow-up commit to the PR, PTAL.

Thanks!

Copy link
Contributor

@vvoland vvoland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@vvoland vvoland requested a review from dmcgowan July 22, 2025 11:55
cerrdefs "github.com/containerd/errdefs"
"github.com/cpuguy83/tar2go"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a rebase, and needs to use the new import paths for the api

After rebasing and updating the import paths, it's also needed to run hack/vendor.sh because we now vendor the API module in the main module (don't ask 😆)

ctalledo added 2 commits July 23, 2025 01:31
Currently the image export and load APIs can be used to export or load all
platforms for the image, or a single specified platform.

This commit updates the API so that it accepts a list of platforms to export or
load, thereby giving clients the ability to export only selected platforms of an
image into a tar file, or load selected platforms from a tar file.

Unit and integration tests were updated accordingly.

As this requires a daemon API change, the API version was bumped.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
Merge the matchAllWithPreference matcher into the matchAnyWithPreference
matcher to reduce code duplication and simplify the code.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
@thaJeztah
Copy link
Member

Did a quick rebase and vendor

@ctalledo
Copy link
Collaborator Author

Did a quick rebase and vendor

Thanks @thaJeztah!

@ctalledo
Copy link
Collaborator Author

CI looks good, the one integration test failure is spurious and unrelated to this PR.

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

I can fix-up the API version and version-history in a follow-up.

Comment on lines +10446 to +10448
type: "array"
items:
type: "string"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll open a follow-up to update the docs/api/version-history.md - those docs probably still need to be moved to the API module as well.

for _, p := range platformList {
platformNames = append(platformNames, platforms.FormatAll(p))
}
log.G(ctx).WithFields(log.Fields{"error": err, "platform(s)": platformNames}).Debug("failed to import image to containerd")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (can be updated in a follow-up); platform(s) as name for the structured logs feels a bit awkward; probably either platforms or platform would be better.

err = verifyImagePlatforms(ctx, imgSvc, imgRef, platforms)
assert.NilError(t, err)
})
cleanup(ctx, t)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to change; I initially considered this could be a t.Cleanupin each test; like https://go.dev/play/p/fyK3a91yn5g, but not sure ift.Cleanup` is guaranteed to have finished between each test 🤔 (that way the cleanup - including any failure - was associated with each test).

package main

import "testing"

func TestMultiCleanup(t *testing.T) {
	cleanup := func(t *testing.T) func() {
		return func() {
			t.Helper()
			t.Log("cleaning up", t.Name())
		}
	}

	t.Run("test one", func(t *testing.T) {
		t.Cleanup(cleanup(t))
	})
	t.Run("test two", func(t *testing.T) {
		t.Cleanup(cleanup(t))
		t.Fatal("test two failed")
	})
	t.Run("test three", func(t *testing.T) {
		t.Cleanup(cleanup(t))
	})
}

Alternatively,, we could could setup a fake store as part of a setup() for each test, so that no state is shared between tests which would allow t.Parallel() to work as well.

@thaJeztah thaJeztah merged commit 96cbee8 into moby:master Jul 23, 2025
217 of 218 checks passed
@thaJeztah
Copy link
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api API area/daemon Core Engine containerd-integration Issues and PRs related to containerd integration impact/api impact/changelog release-blocker PRs we want to block a release on

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

c8d: update image save/load to accept multiple platforms

4 participants