From 979794f71dd6c0b75649b2bb7f10ee0916386b1a Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 18:47:54 +0000 Subject: [PATCH 1/3] Give a container the swap it asks for A container whose workload exceeds its memory limit meets the out of memory killer, with nothing between the two. The runtime it sits on can hand the guest a swap area to reclaim to instead, but nothing here asks for one. Take a size on the command line, keep it on the container's resources beside the memory limit it plays against, and make the area from it at bootstrap: a raw block file in the container's own bundle, alongside the root filesystem, which the guest agent writes a swap header to and enables. The size counts swap alone rather than the memory and swap total the runtime spec carries, so it reads against `--memory` rather than including it. A container that asks for none gets none, which is what one expected to stay within its memory wants. The area holds nothing that outlives the container, so it is made afresh at each bootstrap and the host is told not to synchronize it. --- .../ContainerResource/Container/Bundle.swift | 6 ++++ .../Container/ContainerConfiguration.swift | 6 ++++ .../ContainerAPIService/Client/Flags.swift | 13 +++++++- .../ContainerAPIService/Client/Parser.swift | 7 +++++ .../ContainerAPIService/Client/Utility.swift | 1 + .../RuntimeLinux/Server/RuntimeService.swift | 30 +++++++++++++++++++ .../ContainerConfigurationTests.swift | 23 ++++++++++++++ 7 files changed, 85 insertions(+), 1 deletion(-) 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..d0c900f9c 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -105,6 +105,7 @@ public struct Parser { public static func resources( cpus: Int64?, memory: String?, + swap: String? = nil, defaultCPUs: Int, defaultMemory: MemorySize, ) throws -> ContainerConfiguration.Resources { @@ -120,6 +121,12 @@ public struct Parser { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() } + // Left unset the container gets no swap area at all, which is what a + // container that is expected to stay within its memory wants. + 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..ee7d0ff83 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -156,6 +156,7 @@ 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 ) diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a65603..c1d284941 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,31 @@ 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. Fully allocated rather than sparse, because the + /// kernel maps a swap area's blocks directly and refuses one with holes. + /// 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 { From bab4965db6a7b713a677665a86bed6669fdb7014 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Thu, 6 Aug 2026 18:45:04 +0000 Subject: [PATCH 2/3] Back the swap device with a sparse file The guest reaches the swap area as a block device, which the kernel takes as a single extent without consulting the host's layout, so the hole-free requirement that binds a swap file inside the guest does not bind the host file backing the device. A sparse backing costs the host only the pages the guest has actually swapped out and gives them back on discard. https://github.com/torvalds/linux/blob/master/mm/swapfile.c --- Sources/Services/RuntimeLinux/Server/RuntimeService.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index c1d284941..57f756b89 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -1422,8 +1422,12 @@ 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. Fully allocated rather than sparse, because the - /// kernel maps a swap area's blocks directly and refuses one with holes. + /// 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 { From e8112a55cd0e47468ad425cd22e0ca41c9ff6a13 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Fri, 7 Aug 2026 14:35:08 +0000 Subject: [PATCH 3/3] config: let a swap default sit beside the memory default Swap was the only container resource a user could ask for but not configure a default for: --swap existed with nothing behind it, while --cpus and --memory both fall back to their [container] keys. Add [container] swap, defaulting to zero so a container still gets no area unless one is asked for, and resolve it the way the other two resolve. --- .../ContainerPersistence/ContainerSystemConfig.swift | 8 +++++++- .../Services/ContainerAPIService/Client/Parser.swift | 10 ++++++++-- .../Services/ContainerAPIService/Client/Utility.swift | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) 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/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index d0c900f9c..57b9ce960 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -108,10 +108,15 @@ public struct Parser { 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) @@ -121,8 +126,9 @@ public struct Parser { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() } - // Left unset the container gets no swap area at all, which is what a - // container that is expected to stay within its memory wants. + // 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() } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index ee7d0ff83..0bef87be9 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -158,7 +158,8 @@ public struct Utility { 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)