Deploy resources — cpu, memory, pids

A node running several spaces shares one kernel and one pool of RAM. Without a cap, the service that leaks decides how much everyone else gets. `resources:` puts a ceiling on each container.

For AI coding agents (Claude Code, Codex, Cursor)

This page is the source of truth for the resources: block in a deploy.yaml. It maps to ResourcesSpec in spacemanager/resources.go, which parses into ServiceResources and reaches the container as cgroups v2 limits via space/cgroups.go.

Hard rules: the four keys are cpu, memory, pids, require — nothing else parses. Every field is optional. A service that names no pids cap still gets the default fork-bomb backstop. Applying caps rootless requires cgroups v2 delegation; without it a cap is silently dropped unless the service sets require: true, which turns the same situation into a launch failure.

Why the block exists

A space is an isolation boundary for networking and storage, not for compute. Every container on a node draws from the same CPU and the same RAM. A service that leaks memory is therefore not just its own problem: the kernel OOM killer picks a victim by score, and the victim is frequently not the service that caused the pressure. One leaking container can take down unrelated spaces on the same node.

resources: closes that gap by giving each container its own cgroups v2 limits, so the blast radius of a runaway service is the service itself.

Shape

services:
  api:
    image: rg.fr-par.scw.cloud/skalpai/api:20260824-1200-a1b2c3d
    resources:
      cpu: 1.5
      memory: 512Mi
      pids: 2048
      require: true

The same block is accepted on a jobs: entry.

KeyTypeMeaning
cpustringCore count. 1.5 and 1500m are the same cap. Maps to cpu.max (quota over a fixed 100ms period).
memorystringHard ceiling. Maps to memory.max. Exceeding it gets the container OOM-killed — the container, not the node.
pidsintMax processes/threads. Maps to pids.max. Defaults to a backstop when unset.
requireboolFail the launch when the caps cannot be enforced, instead of running uncapped.

Quantities

cpu accepts a plain core count (2, 0.5, 1.5) or the milli-core form (2000m, 500m, 1500m). Both spell the same limit.

memory accepts a raw byte count (536870912) or a suffixed quantity. Binary suffixes are powers of 1024 (Ki, Mi, Gi, Ti); decimal ones are powers of 1000 (KB/K, MB/M, GB/G, TB/T). So 512Mi is 536,870,912 bytes and 512MB is 512,000,000 — prefer the binary form for RAM.

An unparsable quantity fails at parse time, naming the service, rather than at launch.

The pids default

Every deployed service gets pids.max = 4096 when its config names no pids cap — including a service with no resources: block at all. It is a fork-bomb backstop: a runaway fork() loop exhausts the kernel's process table and makes a node unusable for every workload on it, not just the guilty one.

This one applies more widely than the others. The pids controller is normally delegated even on hosts where cpu and memory are not, so the backstop holds where a memory cap could not.

Rootless delegation, and what require is for

Applying cgroup limits without root needs cgroups v2 with a delegated subtree — in practice Delegate=cpu memory pids on the operator's user@.service. When that is missing, sklp cannot enforce what the YAML asked for. There are only two honest options, and the config picks one:

On a production node, prefer require: true. A cap you think you have is worse than no cap: it is the one you will not think to check when the node falls over.

To enable delegation:

# /etc/systemd/system/user@.service.d/delegate.conf
[Service]
Delegate=cpu cpuset io memory pids

Then systemctl daemon-reload and re-login so the user slice picks it up. sudo sklet host setup writes this drop-in for you and reports which controllers the slice still lacks.

Root without systemd (a VM guest, a CI container) needs no delegation: there is nothing to delegate from. sklp enables the controllers it needs under /sys/fs/cgroup/sklp and runc writes the limits there directly (the cgroupfs driver). The require semantics are unchanged.

CI runs

CI spaces do not read this block: their runner is capped by the spawner's CPU budget (SKLP_CI_CPUS, default max(2, NumCPU/4)), applied as cpu.max plus a pids backstop, and in CI with require semantics. See CI — CPU budget.

What this is not

There is no requests:, and no scheduler. sklet applies the deploy contract it is handed; it does not sum the declared caps against the node's capacity and refuse to place a service that would oversubscribe it. Not oversubscribing the node is the operator's call, expressed in the YAML.

That is deliberate: admission control only starts paying for itself once there are several nodes to choose between. With one VM, a limit is the useful half — it bounds the damage — and a request would only be bookkeeping.