mirror of
https://github.com/apple/container.git
synced 2026-09-09 09:15:42 +00:00
## Issue
When pulling images, the download speed appears to be slower compared to
Docker.
I found that parallel chunk generation was being performed during the
layer download process.
While individual chunks allow parallel download operations, the overall
process remains sequential between chunks.
This results less performance when chunks contain both small and large
layers mixed together.
## Changes
Discontinued chunk-based segmentation to enable more efficient parallel
downloads.
## Results(in my local env)
| image | layers | old | new |
| ---|---|---|---|
| node:latest | 12 | 1m15s | 1m10s |
| ghcr.io/norio-nomura/swift_discord_bot:main | 54 | 2m45s | 2m30s |
<details>
<summary>raw terminal log</summary>
```
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_old images pull docker.io/library/node:latest
image pulled
bin/cctl_old images pull docker.io/library/node:latest 42.37s user 6.60s system 64% cpu 1:15.94 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_new images pull docker.io/library/node:latest
image pulled
bin/cctl_new images pull docker.io/library/node:latest 42.27s user 6.72s system 69% cpu 1:10.58 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_old images pull docker.io/library/node:latest
image pulled
bin/cctl_old images pull docker.io/library/node:latest 45.65s user 7.36s system 70% cpu 1:15.64 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_new images pull docker.io/library/node:latest
image pulled
bin/cctl_new images pull docker.io/library/node:latest 39.76s user 6.32s system 65% cpu 1:10.50 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_old images pull docker.io/library/node:latest
image pulled
bin/cctl_old images pull docker.io/library/node:latest 42.47s user 6.75s system 65% cpu 1:14.72 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_new images pull docker.io/library/node:latest
image pulled
bin/cctl_new images pull docker.io/library/node:latest 42.28s user 6.65s system 69% cpu 1:09.93 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_new images pull ghcr.io/norio-nomura/swift_discord_bot:main
image pulled
bin/cctl_new images pull ghcr.io/norio-nomura/swift_discord_bot:main 103.83s user 18.71s system 81% cpu 2:30.02 total
❯ rm -rf ~/Library/Application\ Support/com.apple.containerization && time bin/cctl_old images pull ghcr.io/norio-nomura/swift_discord_bot:main
image pulled
bin/cctl_old images pull ghcr.io/norio-nomura/swift_discord_bot:main 120.79s user 20.70s system 85% cpu 2:45.26 total
```
</details>
<details>
<summary>patch for download only</summary>
```diff
diff --git a/Sources/cctl/ImageCommand.swift b/Sources/cctl/ImageCommand.swift
index 84c5218..4aa4bb8 100644
--- a/Sources/cctl/ImageCommand.swift
+++ b/Sources/cctl/ImageCommand.swift
@@ -127,6 +127,7 @@ extension Application {
}
print("image pulled")
+ return
let tempDir = FileManager.default.uniqueTemporaryDirectory(create: true)
if let platform {
```
</details>
250 lines
11 KiB
Swift
250 lines
11 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the Containerization project authors.
|
|
// All rights reserved.
|
|
//
|
|
// 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 ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
|
|
extension ImageStore {
|
|
internal struct ImportOperation {
|
|
static let decoder = JSONDecoder()
|
|
|
|
let client: ContentClient
|
|
let ingestDir: URL
|
|
let contentStore: ContentStore
|
|
let progress: ProgressHandler?
|
|
let name: String
|
|
|
|
init(name: String, contentStore: ContentStore, client: ContentClient, ingestDir: URL, progress: ProgressHandler? = nil) {
|
|
self.client = client
|
|
self.ingestDir = ingestDir
|
|
self.contentStore = contentStore
|
|
self.progress = progress
|
|
self.name = name
|
|
}
|
|
|
|
/// Pull the required image layers for the provided descriptor and platform(s) into the given directory using the provided client. Returns a descriptor to the Index manifest.
|
|
internal func `import`(root: Descriptor, matcher: (ContainerizationOCI.Platform) -> Bool) async throws -> Descriptor {
|
|
var toProcess = [root]
|
|
while !toProcess.isEmpty {
|
|
// Count the total number of blobs and their size
|
|
if let progress {
|
|
var size: Int64 = 0
|
|
for desc in toProcess {
|
|
size += desc.size
|
|
}
|
|
await progress([
|
|
ProgressEvent(event: "add-total-size", value: size),
|
|
ProgressEvent(event: "add-total-items", value: toProcess.count),
|
|
])
|
|
}
|
|
|
|
try await self.fetchAll(toProcess)
|
|
let children = try await self.walk(toProcess)
|
|
let filtered = try filterPlatforms(matcher: matcher, children)
|
|
toProcess = filtered.uniqued { $0.digest }
|
|
}
|
|
|
|
guard root.mediaType != MediaTypes.dockerManifestList && root.mediaType != MediaTypes.index else {
|
|
return root
|
|
}
|
|
|
|
// Create an index for the root descriptor and write it to the content store
|
|
let index = try await self.createIndex(for: root)
|
|
// In cases where the root descriptor pointed to `MediaTypes.imageManifest`
|
|
// Or `MediaTypes.dockerManifest`, it is required that we check the supported platform
|
|
// matches the platforms we were asked to pull. This can be done only after we created
|
|
// the Index.
|
|
let supportedPlatforms = index.manifests.compactMap { $0.platform }
|
|
guard supportedPlatforms.allSatisfy(matcher) else {
|
|
throw ContainerizationError(.unsupported, message: "Image \(root.digest) does not support required platforms")
|
|
}
|
|
let writer = try ContentWriter(for: self.ingestDir)
|
|
let result = try writer.create(from: index)
|
|
return Descriptor(
|
|
mediaType: MediaTypes.index,
|
|
digest: result.digest.digestString,
|
|
size: Int64(result.size))
|
|
}
|
|
|
|
private func getManifestContent<T: Sendable & Codable>(descriptor: Descriptor) async throws -> T {
|
|
do {
|
|
if let content = try await self.contentStore.get(digest: descriptor.digest.trimmingDigestPrefix) {
|
|
return try content.decode()
|
|
}
|
|
if let content = try? LocalContent(path: ingestDir.appending(path: descriptor.digest.trimmingDigestPrefix)) {
|
|
return try content.decode()
|
|
}
|
|
return try await self.client.fetch(name: name, descriptor: descriptor)
|
|
} catch {
|
|
throw ContainerizationError(.internalError, message: "Cannot fetch content with digest \(descriptor.digest)")
|
|
}
|
|
}
|
|
|
|
private func walk(_ descriptors: [Descriptor]) async throws -> [Descriptor] {
|
|
var out: [Descriptor] = []
|
|
for desc in descriptors {
|
|
let mediaType = desc.mediaType
|
|
switch mediaType {
|
|
case MediaTypes.index, MediaTypes.dockerManifestList:
|
|
let index: Index = try await self.getManifestContent(descriptor: desc)
|
|
out.append(contentsOf: index.manifests)
|
|
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
|
let manifest: Manifest = try await self.getManifestContent(descriptor: desc)
|
|
out.append(manifest.config)
|
|
out.append(contentsOf: manifest.layers)
|
|
default:
|
|
// TODO: Explicitly handle other content types
|
|
continue
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func fetchAll(_ descriptors: [Descriptor]) async throws {
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
var iterator = descriptors.makeIterator()
|
|
for _ in 0..<8 {
|
|
if let desc = iterator.next() {
|
|
group.addTask {
|
|
try await fetch(desc)
|
|
}
|
|
}
|
|
}
|
|
for try await _ in group {
|
|
if let desc = iterator.next() {
|
|
group.addTask {
|
|
try await fetch(desc)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fetch(_ descriptor: Descriptor) async throws {
|
|
if let found = try await self.contentStore.get(digest: descriptor.digest) {
|
|
try FileManager.default.copyItem(at: found.path, to: ingestDir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix))
|
|
await progress?([
|
|
// Count the size of the blob
|
|
ProgressEvent(event: "add-size", value: descriptor.size),
|
|
// Count the number of blobs
|
|
ProgressEvent(event: "add-items", value: 1),
|
|
])
|
|
return
|
|
}
|
|
|
|
if descriptor.size > 1.mib() {
|
|
try await self.fetchBlob(descriptor)
|
|
} else {
|
|
try await self.fetchData(descriptor)
|
|
}
|
|
// Count the number of blobs
|
|
await progress?([
|
|
ProgressEvent(event: "add-items", value: 1)
|
|
])
|
|
}
|
|
|
|
private func fetchBlob(_ descriptor: Descriptor) async throws {
|
|
let id = UUID().uuidString
|
|
let fm = FileManager.default
|
|
let tempFile = ingestDir.appendingPathComponent(id)
|
|
let (_, digest) = try await client.fetchBlob(name: name, descriptor: descriptor, into: tempFile, progress: progress)
|
|
guard digest.digestString == descriptor.digest else {
|
|
throw ContainerizationError(.internalError, message: "Digest mismatch expected \(descriptor.digest), got \(digest.digestString)")
|
|
}
|
|
do {
|
|
try fm.moveItem(at: tempFile, to: ingestDir.appendingPathComponent(digest.encoded))
|
|
} catch let err as NSError {
|
|
guard err.code == NSFileWriteFileExistsError else {
|
|
throw err
|
|
}
|
|
try fm.removeItem(at: tempFile)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func fetchData(_ descriptor: Descriptor) async throws -> Data {
|
|
let data = try await client.fetchData(name: name, descriptor: descriptor)
|
|
let writer = try ContentWriter(for: ingestDir)
|
|
let result = try writer.write(data)
|
|
if let progress {
|
|
let size = Int64(result.size)
|
|
await progress([
|
|
ProgressEvent(event: "add-size", value: size)
|
|
])
|
|
}
|
|
guard result.digest.digestString == descriptor.digest else {
|
|
throw ContainerizationError(.internalError, message: "Digest mismatch expected \(descriptor.digest), got \(result.digest.digestString)")
|
|
}
|
|
return data
|
|
}
|
|
|
|
private func createIndex(for root: Descriptor) async throws -> Index {
|
|
switch root.mediaType {
|
|
case MediaTypes.index, MediaTypes.dockerManifestList:
|
|
return try await self.getManifestContent(descriptor: root)
|
|
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
|
let supportedPlatforms = try await getSupportedPlatforms(for: root)
|
|
guard supportedPlatforms.count == 1 else {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message:
|
|
"Descriptor \(root.mediaType) with digest \(root.digest) does not list any supported platform or supports more than one platform. Supported platforms = \(supportedPlatforms)"
|
|
)
|
|
}
|
|
let platform = supportedPlatforms.first!
|
|
var root = root
|
|
root.platform = platform
|
|
let index = ContainerizationOCI.Index(schemaVersion: 2, manifests: [root])
|
|
return index
|
|
default:
|
|
throw ContainerizationError(.internalError, message: "Failed to create index for descriptor \(root.digest), media type \(root.mediaType)")
|
|
}
|
|
}
|
|
|
|
private func getSupportedPlatforms(for root: Descriptor) async throws -> [ContainerizationOCI.Platform] {
|
|
var supportedPlatforms: [ContainerizationOCI.Platform] = []
|
|
var toProcess = [root]
|
|
while !toProcess.isEmpty {
|
|
let children = try await self.walk(toProcess)
|
|
for child in children {
|
|
if let p = child.platform {
|
|
supportedPlatforms.append(p)
|
|
continue
|
|
}
|
|
switch child.mediaType {
|
|
case MediaTypes.imageConfig, MediaTypes.dockerImageConfig:
|
|
let config: ContainerizationOCI.Image = try await self.getManifestContent(descriptor: child)
|
|
let p = ContainerizationOCI.Platform(
|
|
arch: config.architecture, os: config.os, osFeatures: config.osFeatures, variant: config.variant
|
|
)
|
|
supportedPlatforms.append(p)
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
toProcess = children
|
|
}
|
|
return supportedPlatforms
|
|
}
|
|
|
|
}
|
|
}
|