From b8ffd38c734448af49cab87c537075f0a803222b Mon Sep 17 00:00:00 2001 From: Saehej Kang <20051028+saehejkang@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:32:31 -0700 Subject: [PATCH] [container]: add clean command (#1949) - Closes #1763 - `container clean` marks deallocated filesystem blocks in the container VM so that the host can reclaim the unused capacity. --- Sources/APIServer/APIServer+Start.swift | 1 + Sources/ContainerCommands/Application.swift | 1 + .../Container/ContainerClean.swift | 72 +++++++++ .../ContainerFixture+ContainerHelpers.swift | 5 + .../RuntimeLinuxHelper+Start.swift | 1 + .../Client/ContainerClient.swift | 15 ++ .../ContainerAPIService/Client/XPC+.swift | 1 + .../Server/Containers/ContainersHarness.swift | 14 ++ .../Server/Containers/ContainersService.swift | 12 ++ .../Runtime/RuntimeClient/RuntimeClient.swift | 15 ++ .../Runtime/RuntimeClient/RuntimeRoutes.swift | 2 + .../RuntimeLinux/Server/RuntimeService.swift | 42 ++++++ .../Containers/TestCLIClean.swift | 140 ++++++++++++++++++ docs/command-reference.md | 24 +++ 14 files changed, 345 insertions(+) create mode 100644 Sources/ContainerCommands/Container/ContainerClean.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIClean.swift diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 936abd91..0c1b82a9 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -307,6 +307,7 @@ extension APIServer { routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn) routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut) routes[XPCRoute.containerExport] = XPCServer.route(harness.export) + routes[XPCRoute.containerClean] = XPCServer.route(harness.clean) return service } diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 6845bb15..3d917c60 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -54,6 +54,7 @@ public struct Application: AsyncLoggableCommand { CommandGroup( name: "Container", subcommands: [ + ContainerClean.self, ContainerCopy.self, ContainerCreate.self, ContainerDelete.self, diff --git a/Sources/ContainerCommands/Container/ContainerClean.swift b/Sources/ContainerCommands/Container/ContainerClean.swift new file mode 100644 index 00000000..22200d59 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerClean.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerAPIClient +import ContainerizationError +import Foundation + +extension Application { + public struct ContainerClean: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "clean", + abstract: "Clean one or more running containers" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Container IDs") + var containerIds: [String] = [] + + public func validate() throws { + if containerIds.count == 0 { + throw ContainerizationError(.invalidArgument, message: "no containers specified") + } + } + + public mutating func run() async throws { + let client = ContainerClient() + let containers = Array(Set(containerIds)) + + var errors: [any Error] = [] + try await withThrowingTaskGroup(of: (any Error)?.self) { group in + for container in containers { + group.addTask { + do { + try await client.clean(id: container) + print(container) + return nil + } catch { + return error + } + } + } + + for try await error in group { + if let error { + errors.append(error) + } + } + } + + if !errors.isEmpty { + throw AggregateError(errors) + } + } + } +} diff --git a/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift index 6cc2ace4..73c6c45b 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift @@ -152,6 +152,11 @@ extension ContainerFixture { public func doExport(_ name: String, to path: FilePath) throws { try run(["export", name, "-o", path.string]).check() } + + /// Cleans a running container. + public func doClean(_ name: String) throws { + try run(["clean", name]).check() + } } // MARK: - Inspect helpers diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index d4c049b4..2af9573a 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -107,6 +107,7 @@ extension RuntimeLinuxHelper { RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), RuntimeRoutes.snapshotDisk.rawValue: XPCServer.route(server.snapshotDisk), + RuntimeRoutes.clean.rawValue: XPCServer.route(server.clean), ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 5a2b6d0d..b52538d9 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -390,4 +390,19 @@ public struct ContainerClient: Sendable { ) } } + + public func clean(id: String) async throws { + let request = XPCMessage(route: .containerClean) + request.set(key: .id, value: id) + + do { + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to clean container", + cause: error + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index a4d5aebd..7fdd8353 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -165,6 +165,7 @@ public enum XPCRoute: String { case containerCopyIn case containerCopyOut case containerExport + case containerClean case pluginLoad case pluginGet diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 1871cd14..72f78b33 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -404,4 +404,18 @@ public struct ContainersHarness: Sendable { try await service.exportRootfs(id: id, archive: archiveUrl) return message.reply() } + + @Sendable + public func clean(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + + try await service.clean(id: id) + return message.reply() + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 81612495..06a798cf 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -919,6 +919,18 @@ public actor ContainersService { } } + public func clean(id: String) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container is not running") + } + + let client = try state.getClient() + try await client.clean(id: id) + } + private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in try await handleContainerExit(id: id, code: code, context: context) diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 32a4db06..e05ef2ba 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -358,6 +358,21 @@ extension RuntimeClient { return try JSONDecoder().decode(ContainerStats.self, from: data) } + + public func clean(id: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.clean.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to clean container \(self.id)", + cause: error + ) + } + } } extension XPCMessage { diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift index bbe1485f..b892d050 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -58,4 +58,6 @@ public enum RuntimeRoutes: String { case copyOut = "com.apple.container.runtime/copyOut" /// Snapshot the container's root filesystem to an image file. case snapshotDisk = "com.apple.container.runtime/snapshotDisk" + /// Clean up unused space in the container filesystem. + case clean = "com.apple.container.runtime/clean" } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a6560..79857712 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -848,6 +848,48 @@ public actor RuntimeService { } } + /// Clean up unused space in the container filesystem. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: The container ID. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func clean(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`clean` xpc handler") + switch self.state { + case .running: + guard message.string(key: RuntimeKeys.id.rawValue) != nil else { + throw ContainerizationError( + .invalidArgument, + message: "no id supplied for clean" + ) + } + + let ctr = try getContainer() + + // Perform filesystem trim on the root filesystem + try await ctr.container.filesystemOperation(operation: .trim, path: "/") + + // Trim all block-backed mounts. Named volumes are expected to be + // block-backed, and may be represented as either `.volume` or + // `.block` depending on how configuration was created. + for mount in ctr.config.mounts { + if mount.isBlock { + try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination) + } + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot clean: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: diff --git a/Tests/IntegrationTests/Containers/TestCLIClean.swift b/Tests/IntegrationTests/Containers/TestCLIClean.swift new file mode 100644 index 00000000..f1ada3e6 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Darwin +import Foundation +import Testing + +@Suite(.serialized) +struct TestCLIClean { + private struct StatusJSON: Codable { + struct Paths: Codable { + let appRoot: String + } + + let paths: Paths + } + + private func appRoot(_ f: ContainerFixture) throws -> URL { + let result = try f.run(["system", "status", "--format", "json"]).check() + let status = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + return URL(fileURLWithPath: status.paths.appRoot, isDirectory: true) + } + + private func allocatedBytes(at url: URL) throws -> Int64 { + var fileStatus = stat() + guard lstat(url.path, &fileStatus) == 0 else { + throw CommandError.executionFailed("failed to read allocated size for \(url.path)") + } + return fileStatus.st_blocks * 512 + } + + private func containerRootfsBlockURL(_ f: ContainerFixture, name: String) throws -> URL { + let id = try f.getContainerId(name) + return try appRoot(f) + .appendingPathComponent("containers", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + .appendingPathComponent("rootfs.ext4", isDirectory: false) + } + + private func volumeBlockURL(_ f: ContainerFixture, name: String) throws -> URL { + try appRoot(f) + .appendingPathComponent("volumes", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("volume.img", isDirectory: false) + } + + private func assertCleanReclaimedSpace(beforeWrite: Int64, afterWrite: Int64, afterClean: Int64) { + let writeAllocated = afterWrite - beforeWrite + #expect(writeAllocated > 0) + + let reclaimed = afterWrite - afterClean + #expect(reclaimed > 0) + + let minExpectedReclaimed = Int64(Double(writeAllocated) * 0.8) + #expect(reclaimed >= minExpectedReclaimed) + } + + @Test func testCleanStoppedContainerFails() async throws { + try await ContainerFixture.with { f in + try await f.withContainer(image: WarmupImage.alpine320.rawValue, autoRemove: false) { name in + try f.doStop(name) + #expect(try f.getContainerStatus(name) == "stopped") + #expect(try f.run(["clean", name]).status != 0, "clean should fail for a stopped container") + } + } + } + + @Test func testCleanMultipleContainers() async throws { + try await ContainerFixture.with { f in + try await f.withContainer(image: WarmupImage.alpine320.rawValue, tag: "c1") { name1 in + try await f.withContainer(image: WarmupImage.alpine320.rawValue, tag: "c2") { name2 in + try f.run(["clean", name1, name2]).check() + #expect(try f.getContainerStatus(name1) == "running") + #expect(try f.getContainerStatus(name2) == "running") + } + } + } + } + + @Test func testCleanAfterFileCreation() async throws { + try await ContainerFixture.with { f in + try await f.withContainer(image: WarmupImage.alpine320.rawValue) { name in + let rootfsBlockURL = try containerRootfsBlockURL(f, name: name) + let beforeWrite = try allocatedBytes(at: rootfsBlockURL) + + try f.doExec(name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/test-file bs=1M count=10"]) + try f.doExec(name, cmd: ["sync"]) + let afterWrite = try allocatedBytes(at: rootfsBlockURL) + try f.doExec(name, cmd: ["rm", "/test-file"]) + + try f.doClean(name) + try f.doExec(name, cmd: ["sync"]) + let afterClean = try allocatedBytes(at: rootfsBlockURL) + assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) + #expect(try f.getContainerStatus(name) == "running") + } + } + } + + @Test func testCleanWithVolume() async throws { + try await ContainerFixture.with { f in + let volumeName = "\(f.testID)-vol" + try f.doVolumeCreate(volumeName) + f.addCleanup { f.doVolumeDeleteIfExists(volumeName) } + + try await f.withContainer( + image: WarmupImage.alpine320.rawValue, + runArgs: ["-v", "\(volumeName):/mnt/vol"] + ) { name in + let volumeBlockURL = try volumeBlockURL(f, name: volumeName) + let beforeWrite = try allocatedBytes(at: volumeBlockURL) + + try f.doExec(name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/mnt/vol/test bs=1M count=5"]) + try f.doExec(name, cmd: ["sync"]) + let afterWrite = try allocatedBytes(at: volumeBlockURL) + try f.doExec(name, cmd: ["rm", "/mnt/vol/test"]) + + try f.doClean(name) + try f.doExec(name, cmd: ["sync"]) + let afterClean = try allocatedBytes(at: volumeBlockURL) + assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) + #expect(try f.getContainerStatus(name) == "running") + } + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index d75ab597..a99ac852 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -413,6 +413,30 @@ container export -o mycontainer.tar mycontainer container export mycontainer > mycontainer.tar ``` +### `container clean` + +Cleans unused space on the root filesystem and each named volume mount in one or more running containers. The command only works while the container is running. + +**Usage** + +```bash +container clean [--debug] ... +``` + +**Arguments** + +* ``: Container IDs + +**Examples** + +```bash +# clean a single running container +container clean mycontainer + +# clean multiple running containers +container clean mycontainer1 mycontainer2 +``` + ### `container logs` Fetches logs from a container. You can follow the logs (`-f`/`--follow`), restrict the number of lines shown, or view boot logs.