Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Sources/ContainerPersistence/ContainerSystemConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/ContainerResource/Container/Bundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Tests/ContainerResourceTests/ContainerConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down