mirror of
https://github.com/apple/container.git
synced 2026-09-25 16:40:28 -04:00
[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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ public struct Application: AsyncLoggableCommand {
|
||||
CommandGroup(
|
||||
name: "Container",
|
||||
subcommands: [
|
||||
ContainerClean.self,
|
||||
ContainerCopy.self,
|
||||
ContainerCreate.self,
|
||||
ContainerDelete.self,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ public enum XPCRoute: String {
|
||||
case containerCopyIn
|
||||
case containerCopyOut
|
||||
case containerExport
|
||||
case containerClean
|
||||
|
||||
case pluginLoad
|
||||
case pluginGet
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] <container-ids> ...
|
||||
```
|
||||
|
||||
**Arguments**
|
||||
|
||||
* `<container-ids>`: 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.
|
||||
|
||||
Reference in New Issue
Block a user