diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index cd3a156f9..d18f26c5b 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -115,19 +115,25 @@ final public class BuildConfig: Codable, Sendable { final public class ContainerConfig: Codable, Sendable { public static let defaultCPUs = 4 public static let defaultMemory = try! MemorySize("1g") + /// No swap area, so a container stays within the memory it is given + /// unless the operator asks for headroom beyond it. + public static let defaultSwap = try! MemorySize("0") public let cpus: Int public let memory: MemorySize + public let swap: MemorySize - public init(cpus: Int = defaultCPUs, memory: MemorySize = defaultMemory) { + public init(cpus: Int = defaultCPUs, memory: MemorySize = defaultMemory, swap: MemorySize = defaultSwap) { self.cpus = cpus self.memory = memory + self.swap = swap } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory + self.swap = try container.decodeIfPresent(MemorySize.self, forKey: .swap) ?? Self.defaultSwap } } diff --git a/Sources/ContainerResource/Container/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift index 217531b8c..2fd005ca0 100644 --- a/Sources/ContainerResource/Container/Bundle.swift +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -23,6 +23,7 @@ public struct Bundle: Sendable { private static let kernelFilename = "kernel.json" private static let kernelBinaryFilename = "kernel.bin" private static let containerRootFsBlockFilename = "rootfs.ext4" + private static let containerSwapBlockFilename = "swap.raw" private static let containerRootFsFilename = "rootfs.json" static let containerConfigFilename = "config.json" @@ -42,6 +43,11 @@ public struct Bundle: Sendable { self.path.appendingPathComponent(Self.containerRootFsBlockFilename) } + /// The raw block file backing the container's swap area, when it has one. + public var containerSwapBlock: URL { + self.path.appendingPathComponent(Self.containerSwapBlockFilename) + } + private var containerRootfsConfig: URL { self.path.appendingPathComponent(Self.containerRootFsFilename) } diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index 87e0f9049..b8a868b17 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -166,6 +166,11 @@ public struct ContainerConfiguration: Sendable, Codable { public var cpus: Int = 4 /// Memory in bytes allocated. public var memoryInBytes: UInt64 = 1024.mib() + /// Swap in bytes allocated. When set, a raw block device of this size + /// backs the container's swap area, which the guest enables so that a + /// workload exceeding `memoryInBytes` reclaims to it. Counts swap + /// alone, not the memory and swap total the runtime spec carries. + public var swapInBytes: UInt64? /// Storage quota/size in bytes. public var storage: UInt64? /// Additional CPU cores allocated for VM overhead (guest agent, etc). @@ -177,6 +182,7 @@ public struct ContainerConfiguration: Sendable, Codable { let c = try decoder.container(keyedBy: CodingKeys.self) self.cpus = try c.decodeIfPresent(Int.self, forKey: .cpus) ?? 4 self.memoryInBytes = try c.decodeIfPresent(UInt64.self, forKey: .memoryInBytes) ?? 1024.mib() + self.swapInBytes = try c.decodeIfPresent(UInt64.self, forKey: .swapInBytes) self.storage = try c.decodeIfPresent(UInt64.self, forKey: .storage) self.cpuOverhead = try c.decodeIfPresent(Int.self, forKey: .cpuOverhead) ?? 1 } diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 39962d436..19947f555 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -101,9 +101,10 @@ public struct Flags { public struct Resource: ParsableArguments { public init() {} - public init(cpus: Int64?, memory: String?) { + public init(cpus: Int64?, memory: String?, swap: String? = nil) { self.cpus = cpus self.memory = memory + self.swap = swap } @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the container") @@ -114,6 +115,16 @@ public struct Flags { help: "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) public var memory: String? + + @Option( + name: .customLong("swap"), + help: """ + Amount of swap to give the container (1MiByte granularity), with optional K, M, G, \ + T, or P suffix. A workload whose memory exceeds its limit reclaims to it rather \ + than meeting the out of memory killer. Counts swap alone, not memory plus swap. + """ + ) + public var swap: String? } public struct DNS: ParsableArguments { diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index a52c1499e..57b9ce960 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -105,12 +105,18 @@ public struct Parser { public static func resources( cpus: Int64?, memory: String?, + swap: String? = nil, defaultCPUs: Int, defaultMemory: MemorySize, + defaultSwap: MemorySize = ContainerConfig.defaultSwap, ) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() resource.cpus = defaultCPUs resource.memoryInBytes = Int64(defaultMemory.measurement.converted(to: .mebibytes).value).mib() + let defaultSwapInBytes = Int64(defaultSwap.measurement.converted(to: .mebibytes).value).mib() + if defaultSwapInBytes > 0 { + resource.swapInBytes = defaultSwapInBytes + } if let cpus { resource.cpus = Int(cpus) @@ -120,6 +126,13 @@ public struct Parser { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() } + // Left unset everywhere the container gets no swap area at all, which + // is what a container expected to stay within its memory wants; the + // flag overrides the configured default the way memory does. + if let swap { + resource.swapInBytes = try Parser.memoryStringAsMiB(swap).mib() + } + return resource } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index f6329c35a..0bef87be9 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -156,8 +156,10 @@ public struct Utility { config.resources = try Parser.resources( cpus: resource.cpus, memory: resource.memory, + swap: resource.swap, defaultCPUs: containerSystemConfig.container.cpus, - defaultMemory: containerSystemConfig.container.memory + defaultMemory: containerSystemConfig.container.memory, + defaultSwap: containerSystemConfig.container.swap ) let tmpfs = try Parser.tmpfsMounts(management.tmpFs) diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a65603..57f756b89 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -263,8 +263,13 @@ public actor RuntimeService { let id = config.id let rootfs = try bundle.containerRootfs.asMount + // A container is only given a swap area when it asked for one. + let swapLayer = try config.resources.swapInBytes.map { + try bundle.createSwapDevice(size: $0).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) + czConfig.swapLayer = swapLayer czConfig.interfaces = interfaces czConfig.process.stdout = stdout czConfig.process.stderr = stderr @@ -1414,6 +1419,35 @@ extension XPCMessage { } extension ContainerResource.Bundle { + /// Create the raw block file backing the container's swap area. + /// + /// It carries no filesystem: the guest agent writes the swap header to the + /// device and enables it. The file is sparse, so it costs the host only the + /// pages the guest has actually swapped out, and gives them back on + /// discard. A swap area held in a file has to be free of holes, since the + /// kernel walks its extents; the guest reaches this one as a block device, + /// which the kernel takes as a single extent without consulting the host's + /// layout. https://github.com/torvalds/linux/blob/master/mm/swapfile.c + /// The area holds nothing that outlives the container, so it is made afresh + /// with every bootstrap and the host is told not to synchronize it. + func createSwapDevice(size: UInt64) throws -> Filesystem { + let path = self.containerSwapBlock + guard FileManager.default.createFile(atPath: path.path, contents: nil) else { + throw ContainerizationError( + .internalError, message: "failed to create swap device at \(path.path)") + } + let handle = try FileHandle(forWritingTo: path) + defer { try? handle.close() } + try handle.truncate(atOffset: size) + return .block( + format: Swap.mountType, + source: path.path, + destination: "", + options: [], + sync: .nosync + ) + } + func createLogFile() throws { // Create the log file we'll write stdio to. // O_TRUNC resolves a log delay issue on restarted containers by force-updating internal state diff --git a/Tests/ContainerResourceTests/ContainerConfigurationTests.swift b/Tests/ContainerResourceTests/ContainerConfigurationTests.swift index b1aafa0b0..eb28564f9 100644 --- a/Tests/ContainerResourceTests/ContainerConfigurationTests.swift +++ b/Tests/ContainerResourceTests/ContainerConfigurationTests.swift @@ -71,6 +71,29 @@ struct ContainerConfigurationResourcesTests { let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: stripped) #expect(decoded.resources.cpuOverhead == 1) } + + /// The size asked for has to reach the runtime, which reads the container's + /// configuration back rather than being handed the flags. + @Test func roundTripsSwap() throws { + var config = makeTestConfiguration() + config.resources.swapInBytes = 512.mib() + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: data) + #expect(decoded.resources.swapInBytes == 512.mib()) + } + + /// A container that asked for no swap gets none, rather than a default size. + @Test func decodesMissingSwapAsNone() throws { + let config = makeTestConfiguration() + let data = try JSONEncoder().encode(config) + var obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + var resources = try #require(obj["resources"] as? [String: Any]) + resources.removeValue(forKey: "swapInBytes") + obj["resources"] = resources + let stripped = try JSONSerialization.data(withJSONObject: obj) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: stripped) + #expect(decoded.resources.swapInBytes == nil) + } } struct ContainerConfigurationCreationDateTests {