diff --git a/Package.resolved b/Package.resolved index 6d19fb55d..ad996952e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "9205a766302a18e06a0ce43f8b7e483625e3e50f", - "version" : "0.33.1" + "revision" : "2550dd49f1890702f6fe0171212050bbce9d3825", + "version" : "0.33.2" } }, { diff --git a/Package.swift b/Package.swift index 2cc2e7092..6ce90d8ba 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.12.0" -let scVersion = "0.33.1" +let scVersion = "0.33.2" let package = Package( name: "container", @@ -341,7 +341,9 @@ let package = Package( ), .target( name: "ContainerRuntimeLinuxClient", - dependencies: [], + dependencies: [ + .product(name: "ContainerizationOCI", package: "containerization"), + ], path: "Sources/Services/RuntimeLinux/Client" ), .executableTarget( diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 92f0c02c1..3d6a0433e 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -19,6 +19,7 @@ import ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerResource +import ContainerRuntimeLinuxClient import ContainerizationError import Foundation import TerminalProgress @@ -88,7 +89,15 @@ extension Application { let options = ContainerCreateOptions(autoRemove: managementFlags.remove) let client = ContainerClient() - try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2) + let blockIO = try Parser.blockIO(specs: managementFlags.blkio) + let runtimeData: Data? = try blockIO.map { try JSONEncoder().encode(LinuxRuntimeData(blockIO: $0)) } + try await client.create( + configuration: ck.0, + options: options, + kernel: ck.1, + initImage: ck.2, + runtimeData: runtimeData + ) if !self.managementFlags.cidfile.isEmpty { let path = self.managementFlags.cidfile diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 75832bd79..3a72795ed 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -19,6 +19,7 @@ import ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerResource +import ContainerRuntimeLinuxClient import Containerization import ContainerizationError import ContainerizationExtras @@ -109,11 +110,14 @@ extension Application { progress.set(description: "Starting container") let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let blockIO = try Parser.blockIO(specs: managementFlags.blkio) + let runtimeData: Data? = try blockIO.map { try JSONEncoder().encode(LinuxRuntimeData(blockIO: $0)) } try await client.create( configuration: ck.0, options: options, kernel: ck.1, - initImage: ck.2 + initImage: ck.2, + runtimeData: runtimeData ) let detach = self.managementFlags.detach diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index ec7b83b40..b00b46d00 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -101,7 +101,10 @@ public struct Flags { public struct Resource: ParsableArguments { public init() {} - public init(cpus: Int64?, memory: String?) { + public init( + cpus: Int64?, + memory: String? + ) { self.cpus = cpus self.memory = memory } @@ -190,6 +193,7 @@ public struct Flags { runtime: String?, ssh: Bool, shmSize: String?, + blkio: [String] = [], tmpFs: [String], useInit: Bool, virtualization: Bool, @@ -219,6 +223,7 @@ public struct Flags { self.runtime = runtime self.ssh = ssh self.shmSize = shmSize + self.blkio = blkio self.tmpFs = tmpFs self.useInit = useInit self.virtualization = virtualization @@ -334,6 +339,15 @@ public struct Flags { @Option(name: .customLong("shm-size"), help: "Size of /dev/shm (e.g. 64M, 1G)") public var shmSize: String? + @Option( + name: .customLong("blkio"), + help: .init( + "Block I/O cgroup tuning options (experimental: see command reference for the supported keys)", + valueName: "option" + ) + ) + public var blkio: [String] = [] + @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") public var tmpFs: [String] = [] diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 7cbe7e0b0..2068e303d 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -21,6 +21,7 @@ import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS +import Darwin import Foundation /// A parsed volume specification from user input @@ -108,6 +109,199 @@ public struct Parser { return resource } + /// Parse `--blkio` specifications into an OCI `LinuxBlockIO`. + /// + /// Each spec is a comma-separated list of `key=value` pairs: + /// + /// --blkio weight=500 + /// --blkio device=/dev/sda,weight=700,leaf-weight=300 + /// --blkio device=/dev/sda,read-bps=1048576,write-bps=1048576 + /// --blkio device=/dev/sda,read-iops=1000,write-iops=1000 + /// + /// Specs without `device=` set cgroup-wide values. Specs with `device=` + /// produce per-device entries; the device value may be a host path + /// (resolved via `stat(2)`) or a `:` literal. + public static func blockIO(specs: [String]) throws -> ContainerizationOCI.LinuxBlockIO? { + guard !specs.isEmpty else { return nil } + + var weight: UInt16? = nil + var leafWeight: UInt16? = nil + var weightDevices: [ContainerizationOCI.LinuxWeightDevice] = [] + var readBpsDevices: [ContainerizationOCI.LinuxThrottleDevice] = [] + var writeBpsDevices: [ContainerizationOCI.LinuxThrottleDevice] = [] + var readIOPSDevices: [ContainerizationOCI.LinuxThrottleDevice] = [] + var writeIOPSDevices: [ContainerizationOCI.LinuxThrottleDevice] = [] + + for spec in specs { + let pairs = try parseBlockIOSpec(spec) + + if let devicePath = pairs["device"] { + let (major, minor) = try parseBlockIODevice(devicePath) + + var deviceWeight: UInt16? = nil + var deviceLeafWeight: UInt16? = nil + + if let raw = pairs["weight"] { + let value = try parseUInt16(raw, name: "weight") + try validateBlockIOWeight(value) + deviceWeight = value + } + if let raw = pairs["leaf-weight"] { + let value = try parseUInt16(raw, name: "leaf-weight") + try validateBlockIOWeight(value) + deviceLeafWeight = value + } + + if deviceWeight != nil || deviceLeafWeight != nil { + weightDevices.append( + ContainerizationOCI.LinuxWeightDevice( + major: major, minor: minor, + weight: deviceWeight, leafWeight: deviceLeafWeight + ) + ) + } + + if let raw = pairs["read-bps"] { + readBpsDevices.append( + ContainerizationOCI.LinuxThrottleDevice(major: major, minor: minor, rate: try parseByteRate(raw)) + ) + } + if let raw = pairs["write-bps"] { + writeBpsDevices.append( + ContainerizationOCI.LinuxThrottleDevice(major: major, minor: minor, rate: try parseByteRate(raw)) + ) + } + if let raw = pairs["read-iops"] { + readIOPSDevices.append( + ContainerizationOCI.LinuxThrottleDevice(major: major, minor: minor, rate: try parseUInt64(raw, name: "read-iops")) + ) + } + if let raw = pairs["write-iops"] { + writeIOPSDevices.append( + ContainerizationOCI.LinuxThrottleDevice(major: major, minor: minor, rate: try parseUInt64(raw, name: "write-iops")) + ) + } + + let allowedDeviceKeys: Set = ["device", "weight", "leaf-weight", "read-bps", "write-bps", "read-iops", "write-iops"] + if let unknown = pairs.keys.first(where: { !allowedDeviceKeys.contains($0) }) { + throw ContainerizationError(.invalidArgument, message: "unknown --blkio key '\(unknown)'") + } + } else { + // Cgroup-wide spec. + if let raw = pairs["weight"] { + let value = try parseUInt16(raw, name: "weight") + try validateBlockIOWeight(value) + if let existing = weight, existing != value { + throw ContainerizationError(.invalidArgument, message: "--blkio weight specified multiple times with conflicting values") + } + weight = value + } + if let raw = pairs["leaf-weight"] { + let value = try parseUInt16(raw, name: "leaf-weight") + try validateBlockIOWeight(value) + if let existing = leafWeight, existing != value { + throw ContainerizationError(.invalidArgument, message: "--blkio leaf-weight specified multiple times with conflicting values") + } + leafWeight = value + } + + let allowedGlobalKeys: Set = ["weight", "leaf-weight"] + if let unknown = pairs.keys.first(where: { !allowedGlobalKeys.contains($0) }) { + throw ContainerizationError( + .invalidArgument, + message: "--blkio key '\(unknown)' is only valid when 'device=' is also set" + ) + } + } + } + + return ContainerizationOCI.LinuxBlockIO( + weight: weight, + leafWeight: leafWeight, + weightDevice: weightDevices, + throttleReadBpsDevice: readBpsDevices, + throttleWriteBpsDevice: writeBpsDevices, + throttleReadIOPSDevice: readIOPSDevices, + throttleWriteIOPSDevice: writeIOPSDevices + ) + } + + /// Tokenise a single `--blkio` spec into `key=value` pairs. + private static func parseBlockIOSpec(_ spec: String) throws -> [String: String] { + var result: [String: String] = [:] + for token in spec.split(separator: ",", omittingEmptySubsequences: true) { + let parts = token.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw ContainerizationError( + .invalidArgument, + message: "--blkio entries must use 'key=value' (got '\(token)')" + ) + } + let key = String(parts[0]) + if result[key] != nil { + throw ContainerizationError(.invalidArgument, message: "--blkio key '\(key)' specified twice in a single spec") + } + result[key] = String(parts[1]) + } + if result.isEmpty { + throw ContainerizationError(.invalidArgument, message: "--blkio spec must not be empty") + } + return result + } + + /// Resolve a `device=` value to (major, minor). Accepts an absolute path + /// (`stat`-ed on the host) or a literal `:`. + private static func parseBlockIODevice(_ value: String) throws -> (major: Int64, minor: Int64) { + if value.hasPrefix("/") { + var info = stat() + guard stat(value, &info) == 0 else { + throw ContainerizationError(.notFound, message: "block I/O device path not found: \(value)") + } + let rawDevice = UInt32(bitPattern: info.st_rdev) + let major = Int64((rawDevice >> 24) & 0xff) + let minor = Int64(rawDevice & 0x00ff_ffff) + return (major, minor) + } + + let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, let major = Int64(parts[0]), let minor = Int64(parts[1]) else { + throw ContainerizationError( + .invalidArgument, + message: "--blkio device must be an absolute path or ':' (got '\(value)')" + ) + } + return (major, minor) + } + + private static func parseByteRate(_ value: String) throws -> UInt64 { + let measurement = try Measurement.parse(parsing: value) + let bytes = measurement.converted(to: .bytes).value + guard bytes >= 0, bytes.rounded(.down) == bytes else { + throw ContainerizationError(.invalidArgument, message: "block I/O byte rate must be a non-negative whole number of bytes") + } + return UInt64(bytes) + } + + private static func parseUInt16(_ value: String, name: String) throws -> UInt16 { + guard let parsed = UInt16(value) else { + throw ContainerizationError(.invalidArgument, message: "--blkio \(name) must be an unsigned 16-bit integer") + } + return parsed + } + + private static func parseUInt64(_ value: String, name: String) throws -> UInt64 { + guard let parsed = UInt64(value) else { + throw ContainerizationError(.invalidArgument, message: "--blkio \(name) must be an unsigned 64-bit integer") + } + return parsed + } + + private static func validateBlockIOWeight(_ value: UInt16) throws { + guard (10...1000).contains(value) else { + throw ContainerizationError(.invalidArgument, message: "block I/O weight must be between 10 and 1000") + } + } + public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { var combined: [String] = [] combined.append(contentsOf: Parser.env(envList: imageEnvs)) diff --git a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift index d30185a11..017303d48 100644 --- a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift +++ b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift @@ -14,14 +14,19 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationOCI import Foundation /// Linux-specific runtime data passed through the opaque runtimeData field /// in RuntimeConfiguration. Encoded by the CLI, decoded by the Linux runtime. public struct LinuxRuntimeData: Codable, Sendable { public let variant: String? + /// Block I/O cgroup tuning (Linux-specific, carried opaquely through + /// `RuntimeConfiguration.runtimeData`). + public let blockIO: ContainerizationOCI.LinuxBlockIO? - public init(variant: String? = nil) { + public init(variant: String? = nil, blockIO: ContainerizationOCI.LinuxBlockIO? = nil) { self.variant = variant + self.blockIO = blockIO } } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 187de7af6..3c689334d 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -19,6 +19,7 @@ import ContainerOS import ContainerPersistence import ContainerResource import ContainerRuntimeClient +import ContainerRuntimeLinuxClient import ContainerXPC import Containerization import ContainerizationError @@ -158,6 +159,9 @@ public actor RuntimeService { try bundle.createLogFile() var config = try bundle.configuration + // Pick up Linux-specific runtime data (e.g. blkio cgroup tuning) carried opaquely + // through `RuntimeConfiguration.runtimeData`. + let runtimeData: Data? = (try? RuntimeConfiguration.readRuntimeConfiguration(from: self.root))?.runtimeData var kernel = try bundle.kernel kernel.commandLine.kernelArgs.append("oops=panic") @@ -252,7 +256,7 @@ public actor RuntimeService { let id = config.id let rootfs = try bundle.containerRootfs.asMount let container = try LinuxContainer(id, rootfs: rootfs, vmm: vmm, logger: self.log) { czConfig in - try Self.configureContainer(czConfig: &czConfig, config: config, dynamicEnv: dynamicEnv, log: self.log) + try Self.configureContainer(czConfig: &czConfig, config: config, runtimeData: runtimeData, dynamicEnv: dynamicEnv, log: self.log) czConfig.interfaces = interfaces czConfig.process.stdout = stdout czConfig.process.stderr = stderr @@ -965,11 +969,16 @@ public actor RuntimeService { private static func configureContainer( czConfig: inout LinuxContainer.Configuration, config: ContainerConfiguration, + runtimeData: Data? = nil, dynamicEnv: [String: String] = [:], log: Logger? = nil, ) throws { czConfig.cpus = config.resources.cpus czConfig.memoryInBytes = config.resources.memoryInBytes + if let runtimeData { + let linuxData = try JSONDecoder().decode(LinuxRuntimeData.self, from: runtimeData) + czConfig.blockIO = linuxData.blockIO.map(Self.toContainerizationBlockIO) + } czConfig.sysctl = config.sysctls.reduce(into: [String: String]()) { $0[$1.key] = $1.value } @@ -1175,6 +1184,31 @@ public actor RuntimeService { return Containerization.LinuxCapabilities(capabilities: Array(caps)) } + /// Convert the OCI block I/O wire format (carried in `ContainerConfiguration`) + /// into the `Containerization.LinuxBlockIO` wrapper expected by + /// `LinuxContainer.Configuration`. + private static func toContainerizationBlockIO(_ oci: ContainerizationOCI.LinuxBlockIO) -> Containerization.LinuxBlockIO { + Containerization.LinuxBlockIO( + weight: oci.weight, + leafWeight: oci.leafWeight, + weightDevice: oci.weightDevice.map { + Containerization.LinuxWeightDevice(major: $0.major, minor: $0.minor, weight: $0.weight, leafWeight: $0.leafWeight) + }, + throttleReadBpsDevice: oci.throttleReadBpsDevice.map { + Containerization.LinuxThrottleDevice(major: $0.major, minor: $0.minor, rate: $0.rate) + }, + throttleWriteBpsDevice: oci.throttleWriteBpsDevice.map { + Containerization.LinuxThrottleDevice(major: $0.major, minor: $0.minor, rate: $0.rate) + }, + throttleReadIOPSDevice: oci.throttleReadIOPSDevice.map { + Containerization.LinuxThrottleDevice(major: $0.major, minor: $0.minor, rate: $0.rate) + }, + throttleWriteIOPSDevice: oci.throttleWriteIOPSDevice.map { + Containerization.LinuxThrottleDevice(major: $0.major, minor: $0.minor, rate: $0.rate) + } + ) + } + private nonisolated func closeHandle(_ handle: Int32) throws { guard close(handle) == 0 else { guard let errCode = POSIXErrorCode(rawValue: errno) else { diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 8a1deabbe..49a544770 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -1201,6 +1201,59 @@ struct ParserTest { #expect(result.memoryInBytes == 256.mib()) } + @Test func testBlockIOSpecsCombined() throws { + let parsed = try Parser.blockIO(specs: [ + "weight=500,leaf-weight=300", + "device=/dev/null,weight=700,leaf-weight=400", + "device=/dev/null,read-bps=1mb,write-bps=2mb", + "device=/dev/null,read-iops=1000,write-iops=2000", + ]) + let blockIO = try #require(parsed) + #expect(blockIO.weight == 500) + #expect(blockIO.leafWeight == 300) + #expect(blockIO.weightDevice.first?.weight == 700) + #expect(blockIO.weightDevice.first?.leafWeight == 400) + #expect(blockIO.throttleReadBpsDevice.first?.rate == 1.mib()) + #expect(blockIO.throttleWriteBpsDevice.first?.rate == 2.mib()) + #expect(blockIO.throttleReadIOPSDevice.first?.rate == 1000) + #expect(blockIO.throttleWriteIOPSDevice.first?.rate == 2000) + } + + @Test func testBlockIOAcceptsMajorMinorLiteral() throws { + let parsed = try Parser.blockIO(specs: ["device=8:0,weight=600,read-bps=512kb"]) + let blockIO = try #require(parsed) + let weightDevice = try #require(blockIO.weightDevice.first) + #expect(weightDevice.major == 8) + #expect(weightDevice.minor == 0) + #expect(weightDevice.weight == 600) + #expect(blockIO.throttleReadBpsDevice.first?.rate == 512 * 1024) + } + + @Test func testBlockIORejectsInvalidWeight() throws { + #expect { + _ = try Parser.blockIO(specs: ["weight=1"]) + } throws: { _ in + true + } + } + + @Test func testBlockIORejectsUnknownKey() throws { + #expect { + _ = try Parser.blockIO(specs: ["device=/dev/null,bogus=1"]) + } throws: { _ in + true + } + } + + @Test func testBlockIORejectsGlobalKeyOnDeviceSpec() throws { + // read-bps without device= is meaningless. + #expect { + _ = try Parser.blockIO(specs: ["read-bps=1mb"]) + } throws: { _ in + true + } + } + @Test func testResourcesBuildPropertyLookup() async throws { let content = """ [build]