From 62788b8a5339d1a58b903a0bcdb1cf2c7b8abda1 Mon Sep 17 00:00:00 2001 From: abdullahtas0 Date: Sat, 17 Oct 2026 21:56:23 +0300 Subject: [PATCH 1/2] Exclude recreatable container data from Time Machine. Sparse image snapshots and container root filesystems make backups stall and can trigger thinning. Volume data stays included. --- Sources/ContainerPersistence/PathUtils.swift | 14 +++++++++++ .../Server/Containers/ContainersService.swift | 1 + .../Server/SnapshotStore.swift | 2 ++ .../PathUtilsTests.swift | 25 +++++++++++++++++++ docs/resource-usage.md | 4 +++ 5 files changed, 46 insertions(+) diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift index d91bb3e4e..3d8194b4c 100644 --- a/Sources/ContainerPersistence/PathUtils.swift +++ b/Sources/ContainerPersistence/PathUtils.swift @@ -58,4 +58,18 @@ public enum PathUtils { } } } + + /// Marks an existing directory so Time Machine skips it. + /// + /// The exclusion is the sticky item attribute, not a path-list entry, and + /// applying it again is safe. Call it whenever the directory is ensured so an + /// install that already has the directory is excluded on the next start. Use it + /// for data that can be recreated, such as image snapshots and container root + /// filesystems, not for volume data. + public static func excludeFromBackup(_ url: URL) throws { + var values = URLResourceValues() + values.isExcludedFromBackup = true + var mutableURL = url + try mutableURL.setResourceValues(values) + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b947212f5..0cbcf9e3e 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -74,6 +74,7 @@ public actor ContainersService { ) throws { let containerRoot = appRoot.appendingPathComponent("containers") try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) + try PathUtils.excludeFromBackup(containerRoot) self.exitMonitor = ExitMonitor(log: log) self.lock = AsyncLock(log: log) self.containerRoot = containerRoot diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index 92198dc44..be232cddf 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerPersistence import ContainerResource import Containerization import ContainerizationEXT4 @@ -63,6 +64,7 @@ public actor SnapshotStore { self.unpackStrategy = unpackStrategy self.log = log try self.fm.createDirectory(at: root, withIntermediateDirectories: true) + try PathUtils.excludeFromBackup(root) try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true) } diff --git a/Tests/ContainerPersistenceTests/PathUtilsTests.swift b/Tests/ContainerPersistenceTests/PathUtilsTests.swift index c38f58a4e..35c55d8d6 100644 --- a/Tests/ContainerPersistenceTests/PathUtilsTests.swift +++ b/Tests/ContainerPersistenceTests/PathUtilsTests.swift @@ -74,4 +74,29 @@ struct PathUtilsTests { let path = PathUtils.BaseConfigPath.installRoot.basePath(env: ["CONTAINER_INSTALL_ROOT": "/usr/local"]) #expect(path == FilePath("/usr/local")) } + + @Test func testExcludeFromBackupMarksDirectoryAndLeavesSiblingAlone() throws { + let parent = FileManager.default.temporaryDirectory + .appendingPathComponent("container-backup-exclusion-\(UUID().uuidString)", isDirectory: true) + let snapshots = parent.appendingPathComponent("snapshots", isDirectory: true) + let volumes = parent.appendingPathComponent("volumes", isDirectory: true) + try FileManager.default.createDirectory(at: snapshots, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: volumes, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parent) } + + try PathUtils.excludeFromBackup(snapshots) + try PathUtils.excludeFromBackup(snapshots) + FileManager.default.createFile( + atPath: snapshots.appendingPathComponent("snapshot").path, + contents: Data("sparse".utf8) + ) + + #expect(try Self.isExcludedFromBackup(snapshots)) + #expect(try !Self.isExcludedFromBackup(volumes)) + } + + private static func isExcludedFromBackup(_ url: URL) throws -> Bool { + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + return values.isExcludedFromBackup == true + } } diff --git a/docs/resource-usage.md b/docs/resource-usage.md index cb88c7e5b..3244f0fa9 100644 --- a/docs/resource-usage.md +++ b/docs/resource-usage.md @@ -114,6 +114,10 @@ data, these images grow; when a container process deletes a file, the freed bloc aren't automatically returned to the host filesystem, so image size doesn't shrink on its own. +Image snapshots and container root filesystems are excluded from Time Machine. +Both are large sparse files that can be recreated by pulling the image or creating +the container again. Named volume data under `volumes/` stays in backups. + Check overall usage with: ```bash From 55d71dad7b2e51c465086cdf3a91750f04f5f954 Mon Sep 17 00:00:00 2001 From: abdullahtas0 <29359748+abdullahtas0@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:20:35 +0300 Subject: [PATCH 2/2] Keep an existing backup exclusion choice across restarts. The exclusion is applied only when snapshots or containers is created. Setting it again on every start replaced a directory a person had included in backups. The docs now describe that exclusion for backup software in general, not only Time Machine. --- Sources/ContainerPersistence/PathUtils.swift | 19 ++++++++++++------- .../Server/Containers/ContainersService.swift | 3 +-- .../Server/SnapshotStore.swift | 3 +-- .../PathUtilsTests.swift | 16 ++++++++++------ docs/resource-usage.md | 9 ++++++--- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift index 3d8194b4c..ca638e17e 100644 --- a/Sources/ContainerPersistence/PathUtils.swift +++ b/Sources/ContainerPersistence/PathUtils.swift @@ -59,14 +59,19 @@ public enum PathUtils { } } - /// Marks an existing directory so Time Machine skips it. + /// Creates `url` and excludes that new directory from backups. /// - /// The exclusion is the sticky item attribute, not a path-list entry, and - /// applying it again is safe. Call it whenever the directory is ensured so an - /// install that already has the directory is excluded on the next start. Use it - /// for data that can be recreated, such as image snapshots and container root - /// filesystems, not for volume data. - public static func excludeFromBackup(_ url: URL) throws { + /// Backup software that reads the item's exclusion attribute, including Time Machine, + /// skips the directory. The attribute is set only when this call creates `url`. + /// An existing directory is left unchanged, so a person who includes it in backups + /// again keeps that choice across later starts. Use this for data that can be + /// recreated, such as image snapshots and container root filesystems, not for volume data. + public static func createDirectoryExcludingFromBackup(_ url: URL, fileManager: FileManager = .default) throws { + let alreadyExists = fileManager.fileExists(atPath: url.path) + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + guard !alreadyExists else { + return + } var values = URLResourceValues() values.isExcludedFromBackup = true var mutableURL = url diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 0cbcf9e3e..d92033796 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -73,8 +73,7 @@ public actor ContainersService { debugHelpers: Bool = false ) throws { let containerRoot = appRoot.appendingPathComponent("containers") - try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) - try PathUtils.excludeFromBackup(containerRoot) + try PathUtils.createDirectoryExcludingFromBackup(containerRoot) self.exitMonitor = ExitMonitor(log: log) self.lock = AsyncLock(log: log) self.containerRoot = containerRoot diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index be232cddf..e600d2377 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -63,8 +63,7 @@ public actor SnapshotStore { self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName) self.unpackStrategy = unpackStrategy self.log = log - try self.fm.createDirectory(at: root, withIntermediateDirectories: true) - try PathUtils.excludeFromBackup(root) + try PathUtils.createDirectoryExcludingFromBackup(root, fileManager: self.fm) try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true) } diff --git a/Tests/ContainerPersistenceTests/PathUtilsTests.swift b/Tests/ContainerPersistenceTests/PathUtilsTests.swift index 35c55d8d6..56df35ce5 100644 --- a/Tests/ContainerPersistenceTests/PathUtilsTests.swift +++ b/Tests/ContainerPersistenceTests/PathUtilsTests.swift @@ -75,24 +75,28 @@ struct PathUtilsTests { #expect(path == FilePath("/usr/local")) } - @Test func testExcludeFromBackupMarksDirectoryAndLeavesSiblingAlone() throws { + @Test func testNewDirectoryIsExcludedAndExistingChoiceIsKept() throws { let parent = FileManager.default.temporaryDirectory .appendingPathComponent("container-backup-exclusion-\(UUID().uuidString)", isDirectory: true) let snapshots = parent.appendingPathComponent("snapshots", isDirectory: true) let volumes = parent.appendingPathComponent("volumes", isDirectory: true) - try FileManager.default.createDirectory(at: snapshots, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: volumes, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: parent) } - try PathUtils.excludeFromBackup(snapshots) - try PathUtils.excludeFromBackup(snapshots) + try PathUtils.createDirectoryExcludingFromBackup(snapshots) + try FileManager.default.createDirectory(at: volumes, withIntermediateDirectories: true) FileManager.default.createFile( atPath: snapshots.appendingPathComponent("snapshot").path, contents: Data("sparse".utf8) ) - #expect(try Self.isExcludedFromBackup(snapshots)) #expect(try !Self.isExcludedFromBackup(volumes)) + + var included = URLResourceValues() + included.isExcludedFromBackup = false + var mutableSnapshots = snapshots + try mutableSnapshots.setResourceValues(included) + try PathUtils.createDirectoryExcludingFromBackup(snapshots) + #expect(try !Self.isExcludedFromBackup(snapshots)) } private static func isExcludedFromBackup(_ url: URL) throws -> Bool { diff --git a/docs/resource-usage.md b/docs/resource-usage.md index 3244f0fa9..8c3a6f5a8 100644 --- a/docs/resource-usage.md +++ b/docs/resource-usage.md @@ -114,9 +114,12 @@ data, these images grow; when a container process deletes a file, the freed bloc aren't automatically returned to the host filesystem, so image size doesn't shrink on its own. -Image snapshots and container root filesystems are excluded from Time Machine. -Both are large sparse files that can be recreated by pulling the image or creating -the container again. Named volume data under `volumes/` stays in backups. +Image snapshots and container root filesystems are excluded from backups when +those directories are first created. Backup software that honors this exclusion, +including Time Machine, skips them. Both are large sparse files that can be +recreated by pulling the image or creating the container again. Named volume data +under `volumes/` stays included. An exclusion removed from an existing directory +is left as it is. Check overall usage with: