Fix system df to count content blobs and deduplicate shared storage (#1555)

- Closes #1526 and #1527.

## Type of Change
- [x] Bug fix
- [ ] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
This PR fixes `system df` to report actual on-disk allocated bytes
(content blobs + snapshots) instead of summing per-image snapshot sizes.
Orphaned blobs are now included as reclaimable, and storage shared
across tags is no longer double counted. Also consolidates three
identical `calculateDirectorySize` implementations into a shared
`FileManager.allocatedSize(of:)` extension.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [ ] Added/updated docs
This commit is contained in:
Raj
2026-06-01 15:44:43 -07:00
committed by GitHub
parent da8daf31de
commit 7358102c68
14 changed files with 232 additions and 131 deletions
+2 -1
View File
@@ -211,7 +211,8 @@ INTEGRATION_TEST_SUITES ?= \
TestCLIKernelSet \
TestCLIAnonymousVolumes \
TestCLINotFound \
TestCLINoParallelCases
TestCLINoParallelCases \
TestCLISystemDF
empty :=
space := $(empty) $(empty)
+3 -3
View File
@@ -1,5 +1,5 @@
{
"originHash" : "f3e6e0a1ee627e1f396b1565e72a50b179394e7011667ec4569dc53455ee06ed",
"originHash" : "2b649fdff52a3ba78453f5b81f7224584c2fa28ec12d4da8fa0c37f9ec832aa6",
"pins" : [
{
"identity" : "async-http-client",
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/containerization.git",
"state" : {
"revision" : "2550dd49f1890702f6fe0171212050bbce9d3825",
"version" : "0.33.2"
"revision" : "a2a1add6c7e1a1665e5397edc49d925c49090b3a",
"version" : "0.33.3"
}
},
{
+1 -1
View File
@@ -23,7 +23,7 @@ import PackageDescription
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
let builderShimVersion = "0.12.0"
let scVersion = "0.33.2"
let scVersion = "0.33.3"
let package = Package(
name: "container",
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension FileManager {
/// Total bytes allocated on disk for all files in a directory (recursive).
///
/// Caveats: hidden files are skipped, symlinks to directories are not followed, but
/// symlinks-to-files and hard links each contribute their target's full allocation
/// so shared inodes are counted multiple times.
public func allocatedSize(of directory: URL) -> UInt64 {
guard
let enumerator = self.enumerator(
at: directory,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
options: [.skipsHiddenFiles]
)
else {
return 0
}
var size: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
let fileSize = resourceValues.totalFileAllocatedSize
else {
continue
}
size += UInt64(fileSize)
}
return size
}
}
@@ -98,7 +98,12 @@ extension ImagesHelper {
let imageStore = try ImageStore(path: rootURL, contentStore: contentStore)
let unpackStrategy = SnapshotStore.defaultUnpackStrategy(initImage: containerSystemConfig.vminit.image)
let snapshotStore = try SnapshotStore(path: rootURL, unpackStrategy: unpackStrategy, log: log)
let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log)
let service = try ImagesService(
contentStore: contentStore,
imageStore: imageStore,
snapshotStore: snapshotStore,
log: log
)
let harness = ImagesServiceHarness(service: service, log: log)
routes[ImagesServiceXPCRoute.imagePull.rawValue] = XPCServer.route(harness.pull)
@@ -124,6 +129,7 @@ extension ImagesHelper {
routes[ImagesServiceXPCRoute.contentClean.rawValue] = XPCServer.route(harness.clean)
routes[ImagesServiceXPCRoute.contentGet.rawValue] = XPCServer.route(harness.get)
routes[ImagesServiceXPCRoute.contentDelete.rawValue] = XPCServer.route(harness.delete)
routes[ImagesServiceXPCRoute.contentSize.rawValue] = XPCServer.route(harness.totalSize)
routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = XPCServer.route(harness.newIngestSession)
routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = XPCServer.route(harness.cancelIngestSession)
routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = XPCServer.route(harness.completeIngestSession)
@@ -216,7 +216,7 @@ public actor ContainersService {
for (id, state) in await self.containers {
let bundlePath = self.containerRoot.appendingPathComponent(id)
let containerSize = Self.calculateDirectorySize(at: bundlePath.path)
let containerSize = FileManager.default.allocatedSize(of: bundlePath)
totalSize += containerSize
if state.snapshot.status == .running {
@@ -243,39 +243,6 @@ public actor ContainersService {
}
}
/// Calculate directory size using APFS-aware resource keys
/// - Parameter path: Path to directory
/// - Returns: Total allocated size in bytes
private static nonisolated func calculateDirectorySize(at path: String) -> UInt64 {
let url = URL(fileURLWithPath: path)
let fileManager = FileManager.default
guard
let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
options: [.skipsHiddenFiles]
)
else {
return 0
}
var totalSize: UInt64 = 0
for case let fileURL as URL in enumerator {
guard
let resourceValues = try? fileURL.resourceValues(
forKeys: [.totalFileAllocatedSizeKey]
),
let fileSize = resourceValues.totalFileAllocatedSize
else {
continue
}
totalSize += UInt64(fileSize)
}
return totalSize
}
/// Create a new container from the provided id and configuration.
public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil, runtimeData: Data? = nil) async throws {
log.debug(
@@ -900,7 +867,7 @@ public actor ContainersService {
let containerPath = self.containerRoot.appendingPathComponent(id).path
return Self.calculateDirectorySize(at: containerPath)
return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath))
}
public func exportRootfs(id: String, archive: URL) async throws {
@@ -158,7 +158,7 @@ public actor VolumesService {
}
let volumePath = self.volumePath(for: name)
return self.calculateDirectorySize(at: volumePath)
return FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath))
}
/// Calculate disk usage for volumes
@@ -201,7 +201,7 @@ public actor VolumesService {
// Calculate sizes
for volume in allVolumes {
let volumePath = self.volumePath(for: volume.name)
let volumeSize = self.calculateDirectorySize(at: volumePath)
let volumeSize = FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath))
totalSize += volumeSize
if !inUseSet.contains(volume.name) {
@@ -214,33 +214,6 @@ public actor VolumesService {
}
}
private nonisolated func calculateDirectorySize(at path: String) -> UInt64 {
let url = URL(fileURLWithPath: path)
let fileManager = FileManager.default
guard
let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
options: [.skipsHiddenFiles]
)
else {
return 0
}
var totalSize: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
let fileSize = resourceValues.totalFileAllocatedSize
else {
continue
}
totalSize += UInt64(fileSize)
}
return totalSize
}
private func parseSize(_ sizeString: String) throws -> UInt64 {
let measurement = try Measurement.parse(parsing: sizeString)
let bytes = measurement.converted(to: .bytes).value
@@ -33,6 +33,7 @@ public enum ImagesServiceXPCRoute: String {
case contentGet
case contentDelete
case contentClean
case contentSize
case contentIngestStart
case contentIngestComplete
case contentIngestCancel
@@ -143,6 +143,13 @@ public struct RemoteContentStoreClient: ContentStore {
request.set(key: .ingestSessionId, value: id)
try await client.send(request)
}
public func totalAllocatedSize() async throws -> UInt64 {
let client = Self.newClient()
let request = XPCMessage(route: .contentSize)
let response = try await client.send(request)
return response.uint64(key: .imageSize)
}
}
#endif
@@ -111,4 +111,12 @@ public struct ContentServiceHarness: Sendable {
reply.set(key: .digests, value: d)
return reply
}
@Sendable
public func totalSize(_ message: XPCMessage) async throws -> XPCMessage {
let size = try await self.service.totalAllocatedSize()
let reply = message.reply()
reply.set(key: .imageSize, value: size)
return reply
}
}
@@ -156,4 +156,9 @@ public actor ContentStoreService {
return try await self.contentStore.cancelIngestSession(id)
}
/// Total bytes allocated on disk for the content store.
public func totalAllocatedSize() async throws -> UInt64 {
try await self.contentStore.totalAllocatedSize()
}
}
@@ -32,7 +32,12 @@ public actor ImagesService {
private let imageStore: ImageStore
private let snapshotStore: SnapshotStore
public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
public init(
contentStore: ContentStore,
imageStore: ImageStore,
snapshotStore: SnapshotStore,
log: Logger
) throws {
self.contentStore = contentStore
self.imageStore = imageStore
self.snapshotStore = snapshotStore
@@ -270,8 +275,7 @@ public actor ImagesService {
/// Calculate disk usage for images
/// - Parameter activeReferences: Set of image references currently in use by containers
/// - Returns: Tuple of (total count, active count, total size, reclaimable size)
public func calculateDiskUsage(activeReferences: Set<String>) async throws -> (Int, Int, UInt64, UInt64) {
public func calculateDiskUsage(activeReferences: Set<String>) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) {
self.log.debug(
"ImagesService: enter",
metadata: [
@@ -290,49 +294,41 @@ public actor ImagesService {
}
let images = try await self._list()
var totalSize: UInt64 = 0
var reclaimableSize: UInt64 = 0
var activeCount = 0
var activeContentSizes: [String: UInt64] = [:]
var activeSnapshotSizes: [String: UInt64] = [:]
var processedDigests = Set<String>()
for image in images {
// Calculate size for all platform variants
let imageSize = try await self.calculateImageSize(image)
totalSize += imageSize
guard activeReferences.contains(image.reference) else { continue }
activeCount += 1
let imageDigest = image.digest.trimmingDigestPrefix
guard processedDigests.insert(imageDigest).inserted else { continue }
// Check if image is referenced by any container
let isActive = activeReferences.contains(image.reference)
if isActive {
activeCount += 1
} else {
reclaimableSize += imageSize
for digest in try await image.referencedDigests() where activeContentSizes[digest] == nil {
guard let content: Content = try await self.contentStore.get(digest: digest) else { continue }
activeContentSizes[digest] = try self.contentDiskSize(content)
}
for (digest, size) in try await self.snapshotStore.getSnapshotSizes(for: image) {
activeSnapshotSizes[digest] = size
}
}
return (images.count, activeCount, totalSize, reclaimableSize)
let snapshotDiskSize = await self.snapshotStore.totalAllocatedSize()
let contentDiskTotal = try await self.contentStore.totalAllocatedSize()
let totalOnDisk = contentDiskTotal + snapshotDiskSize
let activeSize = activeContentSizes.values.reduce(0, +) + activeSnapshotSizes.values.reduce(0, +)
let reclaimable = totalOnDisk > activeSize ? totalOnDisk - activeSize : 0
return (images.count, activeCount, totalOnDisk, reclaimable)
}
/// Calculate total size for an image including all platform variants
private func calculateImageSize(_ image: Containerization.Image) async throws -> UInt64 {
var totalSize: UInt64 = 0
let index = try await image.index()
for descriptor in index.manifests {
// Skip attestation manifests
if let refType = descriptor.annotations?["vnd.docker.reference.type"],
refType == "attestation-manifest"
{
continue
}
guard descriptor.platform != nil else { continue }
// Get snapshot size for this platform
if let snapshotSize = try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor) {
totalSize += snapshotSize
}
private func contentDiskSize(_ content: Content) throws -> UInt64 {
let values = try? content.path.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let allocatedSize = values?.totalFileAllocatedSize {
return UInt64(allocatedSize)
}
return totalSize
return try content.size()
}
}
@@ -183,7 +183,7 @@ public actor SnapshotStore {
guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else {
continue
}
deletedBytes += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0
deletedBytes += self.fm.allocatedSize(of: unpackedPath)
try self.fm.removeItem(at: unpackedPath)
}
return deletedBytes
@@ -213,38 +213,28 @@ public actor SnapshotStore {
}
/// Get the disk size for a specific snapshot descriptor
public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 {
public func getSnapshotSize(descriptor: Descriptor) -> UInt64 {
let snapshotPath = self.snapshotDir(descriptor)
guard self.fm.fileExists(atPath: snapshotPath.path) else {
return 0
}
return try self.fm.directorySize(dir: snapshotPath)
return self.fm.allocatedSize(of: snapshotPath)
}
}
extension FileManager {
fileprivate func directorySize(dir: URL) throws -> UInt64 {
var size: UInt64 = 0
let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey]
guard
let enumerator = self.enumerator(
at: dir,
includingPropertiesForKeys: resourceKeys,
options: [.skipsHiddenFiles]
)
else {
return 0
/// Returns (trimmed digest, size) pairs for every unpackable snapshot owned by the image.
public func getSnapshotSizes(for image: Containerization.Image) async throws -> [(digest: String, size: UInt64)] {
var results: [(digest: String, size: UInt64)] = []
for descriptor in try await image.unpackableDescriptors() {
let size = self.getSnapshotSize(descriptor: descriptor)
guard size > 0 else { continue }
results.append((descriptor.digest.trimmingDigestPrefix, size))
}
return results
}
for case let fileURL as URL in enumerator {
if let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
let fileSize = resourceValues.totalFileAllocatedSize
{
size += UInt64(fileSize)
}
}
return size
/// Total allocated bytes across all snapshot storage (including orphans).
public func totalAllocatedSize() -> UInt64 {
self.fm.allocatedSize(of: self.path)
}
}
@@ -0,0 +1,100 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import Testing
@Suite(.serialSuites, .serialized)
final class TestCLISystemDF: CLITest {
private struct DiskUsageStats: Decodable {
let images: ResourceUsage
}
private struct ResourceUsage: Decodable {
let active: Int
let reclaimable: UInt64
let sizeInBytes: UInt64
let total: Int
}
// Issue #1526: reported image size must include content blobs, not just unpacked snapshots.
@Test func imageDiskUsageIsPopulatedAfterPull() throws {
try withCleanImageStore {
try doPull(imageName: alpine)
let stats = try systemDiskUsage()
#expect(stats.images.total >= 1)
#expect(stats.images.active == 0)
#expect(stats.images.sizeInBytes > 0)
#expect(stats.images.reclaimable == stats.images.sizeInBytes)
}
}
// Issue #1527: tagging the same image must not double-count its storage.
@Test func tagsDoNotDoubleCountImageStorage() throws {
try withCleanImageStore {
try doPull(imageName: alpine)
let before = try systemDiskUsage()
try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-one")
try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-two")
let after = try systemDiskUsage()
#expect(after.images.total == before.images.total + 2)
#expect(after.images.sizeInBytes == before.images.sizeInBytes)
#expect(after.images.reclaimable == before.images.reclaimable)
}
}
// Issue #1527: removing one of several tags must not free shared storage.
// Assumes no background GC runs between operations; blobs stay until all references are removed.
@Test func deletingOneOfMultipleTagsPreservesSharedStorage() throws {
try withCleanImageStore {
let baseline = try systemDiskUsage()
try doPull(imageName: alpine)
try doImageTag(image: alpine, newName: "local/system-df-alpine:delete-probe")
let beforeDelete = try systemDiskUsage()
try doRemoveImages(images: ["local/system-df-alpine:delete-probe"])
let afterAliasDelete = try systemDiskUsage()
#expect(afterAliasDelete.images.total == beforeDelete.images.total - 1)
#expect(afterAliasDelete.images.sizeInBytes == beforeDelete.images.sizeInBytes)
#expect(afterAliasDelete.images.reclaimable == beforeDelete.images.reclaimable)
_ = try? run(arguments: ["image", "rm", "--all"])
let afterFullClean = try systemDiskUsage()
#expect(afterFullClean.images.total <= baseline.images.total)
#expect(afterFullClean.images.sizeInBytes <= baseline.images.sizeInBytes)
}
}
private func withCleanImageStore(_ body: () throws -> Void) throws {
_ = try? run(arguments: ["image", "rm", "--all"])
defer {
_ = try? run(arguments: ["image", "rm", "--all"])
}
try body()
}
private func systemDiskUsage() throws -> DiskUsageStats {
let (data, _, error, status) = try run(arguments: ["system", "df", "--format", "json"])
guard status == 0 else {
throw CLIError.executionFailed("system df failed: \(error)")
}
return try JSONDecoder().decode(DiskUsageStats.self, from: data)
}
}