mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
[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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user