[container]: add container export for live containers (#1630)

- When container is not running, the runtime helper
  traverses the container's root fs and writes it to the
  specified tar archive or stdout.
- When the container is running, the helper performs
  the same operation but wraps it in freeze/thaw
  to ensure data integrity for the resulting archive.
This commit is contained in:
Saehej Kang
2026-08-02 14:55:01 -07:00
committed by GitHub
parent 6e65319fe4
commit da8bec6223
9 changed files with 130 additions and 7 deletions
@@ -67,7 +67,11 @@ extension Application {
}
try fileHandle.close()
} else {
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
let outputURL = URL(fileURLWithPath: output!)
if FileManager.default.fileExists(atPath: outputURL.path(percentEncoded: false)) {
try FileManager.default.removeItem(at: outputURL)
}
try FileManager.default.moveItem(at: archive, to: outputURL)
}
}
}
@@ -106,6 +106,7 @@ extension RuntimeLinuxHelper {
RuntimeRoutes.statistics.rawValue: XPCServer.route(server.statistics),
RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn),
RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut),
RuntimeRoutes.snapshotDisk.rawValue: XPCServer.route(server.snapshotDisk),
],
log: log
)
@@ -901,14 +901,22 @@ public actor ContainersService {
self.log.debug("\(#function)")
let state = try self._getContainerState(id: id)
guard state.snapshot.status == .stopped else {
throw ContainerizationError(.invalidState, message: "container is not stopped")
}
let path = self.containerRoot.appendingPathComponent(id)
let bundle = ContainerResource.Bundle(path: path)
let rootfs = bundle.containerRootfsBlock
try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive))
switch state.snapshot.status {
case .running:
let client = try state.getClient()
let snapshot = rootfs.appendingPathExtension("snapshot")
defer { try? FileManager.default.removeItem(at: snapshot) }
try await client.snapshotDisk(imagePath: rootfs.path, destinationPath: snapshot.path)
try EXT4.EXT4Reader(blockDevice: FilePath(snapshot)).export(archive: FilePath(archive))
case .stopped:
try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive))
default:
throw ContainerizationError(.invalidState, message: "container must be running or stopped")
}
}
private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws {
@@ -319,6 +319,22 @@ extension RuntimeClient {
}
}
public func snapshotDisk(imagePath: String, destinationPath: String) async throws {
let request = XPCMessage(route: RuntimeRoutes.snapshotDisk.rawValue)
request.set(key: RuntimeKeys.imagePath.rawValue, value: imagePath)
request.set(key: RuntimeKeys.destinationPath.rawValue, value: destinationPath)
do {
try await self.client.send(request, responseTimeout: .seconds(300))
} catch {
throw ContainerizationError(
.internalError,
message: "failed to snapshot disk in container \(self.id)",
cause: error
)
}
}
public func statistics() async throws -> ContainerStats {
let request = XPCMessage(route: RuntimeRoutes.statistics.rawValue)
@@ -48,6 +48,8 @@ public enum RuntimeKeys: String {
case destinationPath
case fileMode
case createParents
/// Image path for snapshot operations
case imagePath
/// Special-case environment variables recomputed on each container start
case dynamicEnv
@@ -56,4 +56,6 @@ public enum RuntimeRoutes: String {
case copyIn = "com.apple.container.runtime/copyIn"
/// Copy a file or directory out of the container.
case copyOut = "com.apple.container.runtime/copyOut"
/// Snapshot the container's root filesystem to an image file.
case snapshotDisk = "com.apple.container.runtime/snapshotDisk"
}
@@ -782,6 +782,72 @@ public actor RuntimeService {
}
}
/// Snapshot the container's root filesystem.
///
/// When the container is running, freeze/thaw around the copy for consistency.
/// When it is not running, copy directly without freeze/thaw.
///
/// - Parameters:
/// - message: An XPC message with the following parameters:
/// - imagePath: The path to the source filesystem image.
/// - destinationPath: The path where the snapshot will be written.
///
/// - Returns: An XPC message with no parameters.
@Sendable
public func snapshotDisk(_ message: XPCMessage) async throws -> XPCMessage {
self.log.info("`snapshotDisk` xpc handler")
switch self.state {
case .running, .booted:
guard let imagePath = message.string(key: RuntimeKeys.imagePath.rawValue) else {
throw ContainerizationError(
.invalidArgument,
message: "no image path supplied for snapshotDisk"
)
}
guard let destinationPath = message.string(key: RuntimeKeys.destinationPath.rawValue) else {
throw ContainerizationError(
.invalidArgument,
message: "no destination path supplied for snapshotDisk"
)
}
let ctr = try getContainer()
let shouldFreeze = self.state == .running
if shouldFreeze {
try await ctr.container.filesystemOperation(operation: .freeze, path: "/")
}
do {
try FileManager.default.copyItem(atPath: imagePath, toPath: destinationPath)
} catch {
if shouldFreeze {
do {
try await ctr.container.filesystemOperation(operation: .thaw, path: "/")
} catch {
self.log.error(
"failed to thaw filesystem after snapshotDisk error",
metadata: [
"error": "\(error)"
])
}
}
throw error
}
if shouldFreeze {
try await ctr.container.filesystemOperation(operation: .thaw, path: "/")
}
return message.reply()
default:
throw ContainerizationError(
.invalidState,
message: "cannot snapshot disk: container is not running"
)
}
}
/// Dial a vsock port on the virtual machine.
///
/// - Parameters:
@@ -53,4 +53,28 @@ struct TestCLIExportCommand {
}
}
}
@Test func testExportCommandRunningContainerAndOverwrite() async throws {
try await ContainerFixture.with { f in
let image = WarmupImage.alpine320.rawValue
try await f.withContainer(image: image, autoRemove: false) { name in
let mustBeInImage = "must-be-in-image-live"
try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo-live"])
let exportPath = f.testDir.appending("export-live.tar")
try f.run(["export", name, "-o", exportPath.string]).check()
try f.run(["export", name, "-o", exportPath.string]).check()
let exportURL = URL(filePath: exportPath.string)
let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string)
let fileSize = attrs[.size] as! UInt64
#expect(fileSize > 0)
let reader = try ArchiveReader(file: exportURL)
let (fooLive, fooLiveData) = try reader.extractFile(path: "/foo-live")
#expect(fooLive.fileType == .regular)
#expect(String(data: fooLiveData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false)
}
}
}
}
+1 -1
View File
@@ -381,7 +381,7 @@ container exec [--detach] [--env <env> ...] [--env-file <env-file> ...] [--gid <
### `container export`
Exports a stopped container's filesystem as a tar archive. The container must be stopped before exporting. If no output file is specified, the tar stream is written to stdout.
Exports a container's filesystem as a tar archive. For running containers, export automatically takes a runtime snapshot to preserve consistency. If no output file is specified, the tar stream is written to stdout.
**Usage**