From ca97b07f7fdbd4a86ead251b2aabd3ef788b0016 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Thu, 13 Aug 2026 20:11:47 +0000 Subject: [PATCH 1/2] Unpack the host's platform unless the flags name another A pull, load, or build with no platform unpacked a snapshot for every platform in the image's index. Only the host's platform is ever mounted from this store, so on an arm64 machine each multi-arch reference paid for amd64, s390x, riscv64, and the rest as full ext4 snapshots nothing could use; five small images measured 20G of snapshots this way. Keep every pulled platform in the content store and unpack the host's platform when no flag names one. An image that does not carry the host platform stays packed, the way a foreign-only image arrives from a load or a cross-platform build; any platform unpacks on demand when something asks to use it, which is also how a Rosetta run of an amd64 image acquires its snapshot. An explicit --platform still unpacks exactly what it names. The engine default a reference would give is one platform per pull, the host's, which scopes what is fetched as well as what is unpacked: https://docs.docker.com/reference/cli/docker/image/pull/ Fetching every platform and narrowing only the unpacking is this store's own choice, and it keeps a foreign platform available to run under Rosetta or to be pushed on without a second fetch. What that costs is compressed blobs, against the ext4 snapshot per platform that unpacking them all cost. --- Sources/ContainerCommands/BuildCommand.swift | 2 +- .../ContainerCommands/Image/ImageLoad.swift | 2 +- .../ContainerCommands/Image/ImagePull.swift | 9 ++++++- .../Client/ClientImage.swift | 24 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index c216bc26b..fbd0cfba2 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -430,7 +430,7 @@ extension Application { } for image in result.images { try Task.checkCancellation() - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) // Tag the unpacked image with all requested tags for tagName in imageNames { diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift index a70d127e4..ebc4a8931 100644 --- a/Sources/ContainerCommands/Image/ImageLoad.swift +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -101,7 +101,7 @@ extension Application { progress.set(description: "Unpacking image") progress.set(itemsName: "entries") for image in result.images { - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) } await taskManager.finish() progress.finish() diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index 7506dc315..5dd0a8008 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -102,7 +102,14 @@ extension Application { progress.set(description: "Unpacking image") progress.set(itemsName: "entries") let unpackTask = await taskManager.startTask() - try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + // A pull with no platform keeps every platform in the content + // store; the snapshot unpacks for the platform the host runs, + // and any other platform unpacks on demand when it is used. + if let p { + try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } else { + try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } await taskManager.finish() progress.finish() } diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift index 8b3a4d966..4236d8f5a 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -416,6 +416,30 @@ extension ClientImage { // MARK: Snapshot Methods + /// Unpack the platform this host runs, when the image carries it. + /// + /// The content store can hold every platform of a reference while only + /// the host's platform is ever mounted here; the others unpack on + /// demand when something asks to use them. An image that does not + /// provide the host platform stays packed, the way a foreign-only + /// image arrives from a load or a cross-platform build. + /// + /// The engine default a reference would give is one platform per pull, + /// the host's, which scopes what is fetched as well as what is unpacked: + /// https://docs.docker.com/reference/cli/docker/image/pull/ + /// A pull here fetches every platform the index carries and narrows only + /// the unpacking, so a foreign platform is there to run under Rosetta or + /// to be pushed on without being fetched again. What that costs is + /// compressed blobs, against the ext4 snapshot per platform that + /// unpacking them all cost. + public func unpackPreferringHost(progressUpdate: ProgressUpdateHandler? = nil) async throws { + do { + try await self.unpack(platform: .current, progressUpdate: progressUpdate) + } catch let error as ContainerizationError where error.code == .notFound { + // The reference has no host-platform variant; leave it packed. + } + } + public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imageUnpack) From 3274825a597ac4306a22f71a547f089f65ed79d3 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Thu, 13 Aug 2026 22:30:47 +0000 Subject: [PATCH 2/2] Test that a pull unpacks the host's platform alone The store's snapshot directories carry the truth: after a plain pull of a multi-arch reference the host platform's variant is unpacked and the foreign ones stay packed, and an explicit --platform pull unpacks exactly what it names. The inspect fixture type gains the variant digest the assertions resolve. --- .../ContainerFixture+ImageHelpers.swift | 1 + .../Images/TestCLIImageUnpackSerial.swift | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 Tests/IntegrationTests/Images/TestCLIImageUnpackSerial.swift diff --git a/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift index ffdc48db7..500d5506c 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift @@ -30,6 +30,7 @@ extension ContainerFixture { public let architecture: String } public let platform: Platform + public let digest: String } public let configuration: Configuration public let variants: [Variant] diff --git a/Tests/IntegrationTests/Images/TestCLIImageUnpackSerial.swift b/Tests/IntegrationTests/Images/TestCLIImageUnpackSerial.swift new file mode 100644 index 000000000..122871726 --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIImageUnpackSerial.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerPersistence +import ContainerTestSupport +import Foundation +import Testing + +/// Serialized: asserts on the shared store's snapshot directories. +@Suite(.serialized) +struct TestCLIImageUnpackSerial { + private let alpine = WarmupImage.alpine320.rawValue + + private func snapshotExists(digest: String) -> Bool { + let hex = digest.split(separator: ":").last.map(String.init) ?? digest + let dir = + PathUtils.BaseConfigPath.appRoot.basePath() + .appending("snapshots") + .appending(hex) + return FileManager.default.fileExists(atPath: dir.string) + } + + @Test func testPullUnpacksTheHostPlatformAlone() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let variants = try f.doInspectImages(alpine).flatMap { $0.variants } + #expect(variants.count > 1, "the assertion needs a multi-arch reference") + + for variant in variants where variant.platform.os == "linux" { + let isHost = variant.platform.architecture == "arm64" + if isHost { + #expect(snapshotExists(digest: variant.digest), "the host platform unpacks on pull") + } + } + let foreign = variants.filter { $0.platform.os == "linux" && $0.platform.architecture != "arm64" } + let foreignUnpacked = foreign.filter { snapshotExists(digest: $0.digest) } + #expect( + foreignUnpacked.count < foreign.count || foreign.isEmpty, + "platforms the host cannot mount stay packed until something asks for them") + } + } + + @Test func testExplicitPlatformUnpacksWhatItNames() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine, args: ["--platform", "linux/amd64"]) + let variants = try f.doInspectImages(alpine).flatMap { $0.variants } + guard let amd64 = variants.first(where: { $0.platform.architecture == "amd64" && $0.platform.os == "linux" }) else { + throw CommandError.executionFailed("no amd64 variant in \(alpine)") + } + #expect(snapshotExists(digest: amd64.digest), "an explicit --platform unpacks exactly what it names") + } + } +}