mirror of
https://github.com/apple/container.git
synced 2026-10-15 02:29:21 +00:00
I've added an optional progress handler for rootfs unpacking so that consumers can show progress. The Ubuntu image takes about 8 seconds to unpack on my machine, and I'm developing an app where it would be useful to show progress in the UI. Total size is determined in an optional first pass that scans archive headers. Bytes written are then reported during unpacking. The optional first pass adds 15 (Alpine) to 115 (Ubuntu) ms to unpacking duration on my machine, depending on image size.
177 lines
7.1 KiB
Swift
177 lines
7.1 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2026 Apple Inc. and the Containerization 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.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
#if os(macOS)
|
|
import ContainerizationArchive
|
|
import ContainerizationEXT4
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import SystemPackage
|
|
import Testing
|
|
|
|
@testable import Containerization
|
|
|
|
/// Measures header scan overhead vs. full unpack time using real container images
|
|
/// pulled from a registry.
|
|
///
|
|
/// Run with:
|
|
/// ENABLE_TIMING_TESTS=1 swift test --filter ImageHeaderScanTimingTest
|
|
@Suite
|
|
struct ImageHeaderScanTimingTest {
|
|
private static let isEnabled = ProcessInfo.processInfo.environment["ENABLE_TIMING_TESTS"] != nil
|
|
|
|
let store: ImageStore
|
|
let dir: URL
|
|
let contentStore: ContentStore
|
|
|
|
init() throws {
|
|
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let cs = try LocalContentStore(path: dir)
|
|
let store = try ImageStore(path: dir, contentStore: cs)
|
|
self.dir = dir
|
|
self.store = store
|
|
self.contentStore = cs
|
|
}
|
|
|
|
@Test(.enabled(if: ImageHeaderScanTimingTest.isEnabled))
|
|
func measureAlpineAndUbuntu() async throws {
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let images: [(reference: String, label: String)] = [
|
|
("ghcr.io/linuxcontainers/alpine:3.20", "Alpine 3.20"),
|
|
("docker.io/library/ubuntu:24.04", "Ubuntu 24.04"),
|
|
]
|
|
|
|
for image in images {
|
|
print("\n==============================")
|
|
print("Image: \(image.label)")
|
|
print("==============================")
|
|
|
|
let img = try await store.pull(reference: image.reference, platform: .current)
|
|
let manifest = try await img.manifest(for: .current)
|
|
|
|
for (i, layer) in manifest.layers.enumerated() {
|
|
let content = try await img.getContent(digest: layer.digest)
|
|
let compression = compressionFilter(for: layer.mediaType)
|
|
let compressedSize = try FileManager.default.attributesOfItem(atPath: content.path.path)[.size] as? Int64 ?? 0
|
|
let label = "\(image.label) layer \(i + 1)/\(manifest.layers.count) (\(layer.mediaType), \(formatBytes(compressedSize)) compressed)"
|
|
try await measureOverhead(url: content.path, compression: compression, label: label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func compressionFilter(for mediaType: String) -> ContainerizationArchive.Filter {
|
|
switch mediaType {
|
|
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
|
|
return .zstd
|
|
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
|
|
return .none
|
|
default:
|
|
return .gzip
|
|
}
|
|
}
|
|
|
|
private func measureOverhead(url: URL, compression: ContainerizationArchive.Filter, label: String) async throws {
|
|
let clock = ContinuousClock()
|
|
|
|
print("\n--- \(label) ---\n")
|
|
|
|
// For zstd, pre-decompress once (matching the production code path in EXT4Unpacker).
|
|
let scanFile: URL
|
|
let scanFilter: ContainerizationArchive.Filter
|
|
var decompressedFile: URL?
|
|
if compression == .zstd {
|
|
var decompressed: URL = url
|
|
let decompressDuration = try clock.measure {
|
|
decompressed = try ArchiveReader.decompressZstd(url)
|
|
}
|
|
scanFile = decompressed
|
|
scanFilter = .none
|
|
decompressedFile = decompressed
|
|
print(" Zstd decompress: \(decompressDuration)")
|
|
} else {
|
|
scanFile = url
|
|
scanFilter = compression
|
|
}
|
|
defer {
|
|
if let decompressedFile {
|
|
ArchiveReader.cleanUpDecompressedZstd(decompressedFile)
|
|
}
|
|
}
|
|
|
|
// 1. Header scan only
|
|
var scannedTotals: (size: Int64, items: Int) = (0, 0)
|
|
let scanDuration = try clock.measure {
|
|
scannedTotals = try EXT4.Formatter.scanArchiveHeaders(
|
|
format: .paxRestricted, filter: scanFilter, file: scanFile)
|
|
}
|
|
print(" Scanned total size: \(formatBytes(scannedTotals.size)) (\(scannedTotals.items) items)")
|
|
print(" Header scan: \(scanDuration)")
|
|
|
|
// 2. Full unpack without progress
|
|
let tempDir1 = FileManager.default.uniqueTemporaryDirectory()
|
|
let fsPath1 = FilePath(tempDir1.appendingPathComponent("no-progress.ext4.img", isDirectory: false))
|
|
defer { try? FileManager.default.removeItem(at: tempDir1) }
|
|
|
|
let unpackOnlyDuration = try await clock.measure {
|
|
let formatter = try EXT4.Formatter(fsPath1)
|
|
try await formatter.unpack(source: url, compression: compression)
|
|
try formatter.close()
|
|
}
|
|
print(" Unpack (no progress): \(unpackOnlyDuration)")
|
|
|
|
// 3. Full unpack with progress (includes header scan pass)
|
|
let tempDir2 = FileManager.default.uniqueTemporaryDirectory()
|
|
let fsPath2 = FilePath(tempDir2.appendingPathComponent("with-progress.ext4.img", isDirectory: false))
|
|
defer { try? FileManager.default.removeItem(at: tempDir2) }
|
|
|
|
let noopProgress: ProgressHandler = { _ in }
|
|
let withProgressDuration = try await clock.measure {
|
|
let formatter = try EXT4.Formatter(fsPath2)
|
|
try await formatter.unpack(source: url, compression: compression, progress: noopProgress)
|
|
try formatter.close()
|
|
}
|
|
print(" Unpack (w/ progress): \(withProgressDuration)")
|
|
|
|
// Summary
|
|
let scanMs = toMs(scanDuration)
|
|
let unpackMs = toMs(unpackOnlyDuration)
|
|
let withProgressMs = toMs(withProgressDuration)
|
|
let overheadMs = withProgressMs - unpackMs
|
|
let overheadPct = unpackMs > 0 ? (overheadMs / unpackMs) * 100 : 0
|
|
|
|
print("\n Summary:")
|
|
print(" Header scan alone: \(String(format: "%.1f", scanMs)) ms")
|
|
print(" Unpack only: \(String(format: "%.1f", unpackMs)) ms")
|
|
print(" Unpack + progress: \(String(format: "%.1f", withProgressMs)) ms")
|
|
print(" Overhead: \(String(format: "%.1f", overheadMs)) ms (\(String(format: "%.1f", overheadPct))%)")
|
|
}
|
|
|
|
private func toMs(_ d: Duration) -> Double {
|
|
let c = d.components
|
|
return Double(c.seconds) * 1000.0 + Double(c.attoseconds) / 1e15
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let mb = Double(bytes) / 1_048_576.0
|
|
return "\(String(format: "%.1f", mb)) MB"
|
|
}
|
|
}
|
|
#endif
|