Public: anyone can read this; the crew can change it.

Cells on the forge pod have no kernel memory or process cap #10584

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10518-cells-on-the-forge-pod-have-no-kernel-me → main 3 commits fork

  • Merged. mason/10518-cells-on-the-forge-pod-have-no-kernel-me was merged into main as cbf8ab1.

Review

Sign in to review this change.
6 files changed +134 −22
docs/APPS.md +16
@@ -592,6 +592,22 @@ writable cgroup mount owned by the forge's uid. Without that, cells are
592592 watched only. /system says which holds the cap, and why, and shows each
593593 cell's memory beside its cap.
594594
595 Processes are held the same way. The watch stops a cell whose tree passes
596 512 processes; where the pids controller is delegated with memory, each
597 app and check cell also gets `pids.max` 512, so a fork bomb fails inside
598 its cell before a tick of the watch, and the forge's process table is
599 never exhausted (#10518). Crew runs get no process cap, as before.
600 /system says beside the memory line who holds the process count, and why
601 when it is the watch alone.
602
603 On a Kubernetes pod the forge is usually not at a cgroup namespace root
604 (`/proc/self/cgroup` is not `0::/`) and `/sys/fs/cgroup` is read-only, so
605 cells are watched only. The operator delegates one: run the container in
606 its own cgroup namespace with a writable cgroup2 mount owned by the
607 forge's uid, or set `FORGE_CELL_CGROUP` to a delegated subtree with
608 `memory` (and `pids`) in its `cgroup.controllers`. /system then says the
609 kernel holds the cap.
610
595611 The forge's own process outranks what it starts (#3319, `apps/background.ts`).
596612 Every check step (install, test, check) and every crew run starts in the
597613 background class: `SCHED_BATCH`, nice 10 (a CFS weight of 110 against the
src/apps/cgroup.ts +28 −6
@@ -20,6 +20,7 @@
2020 * cpu.weight and io.weight 1000 against the cells' 100
2121 * R/cells/c<n>/ one per cell: memory.max, memory.swap.max = 0,
2222 * memory.oom.group = 1 (the whole cell dies together);
23 * pids.max the watch's process limit (#10518);
2324 * a step's cpu.weight and io.weight 10, below an app's
2425 * 100, and cpu.max its declared cores (`apps/background.ts`)
2526 * R/cells/c<n>/ one per crew run, too: no memory cap, weight 10 and
@@ -174,16 +175,18 @@ export function prepare(
174175 const cells = join(root, "cells");
175176 mkdirSync(cells, { recursive: true });
176177 writeFileSync(join(cells, "cgroup.subtree_control"), "+memory");
177 // The forge outweighs every cell, and a step weighs less than an app
178 // (#3319). Asked for, never required: without the controller, nice and
179 // SCHED_BATCH still hold.
180 for (const controller of ["cpu", "io"]) {
178 // A cell's process count is the kernel's too (`pids.max`, #10518), and
179 // the forge outweighs every cell, a step less than an app (#3319).
180 // Asked for, never required: without pids the watch still counts every
181 // tick; without cpu or io, nice and SCHED_BATCH still hold.
182 for (const controller of ["pids", "cpu", "io"]) {
181183 if (!words(read(join(root, "cgroup.controllers"))).includes(controller)) continue;
182184 try {
183185 for (const dir of [root, cells])
184186 if (!words(read(join(dir, "cgroup.subtree_control"))).includes(controller))
185187 writeFileSync(join(dir, "cgroup.subtree_control"), `+${controller}`);
186 writeFileSync(join(root, "forge", `${controller}.weight`), `${controller === "io" ? "default " : ""}${FORGE_WEIGHT}`);
188 if (controller !== "pids")
189 writeFileSync(join(root, "forge", `${controller}.weight`), `${controller === "io" ? "default " : ""}${FORGE_WEIGHT}`);
187190 } catch {
188191 // not delegated here, or the forge's leaf is elsewhere: the memory cap still holds
189192 }
@@ -224,16 +227,28 @@ function removeDir(dir: string, tries = 10): void {
224227 }
225228 }
226229
230 /**
231 * Whether cells get the pids controller here: `R/cells` lists it in its
232 * `cgroup.subtree_control`, as cgroupfs reads it back. Then each cell's
233 * `pids.max` is the kernel's; otherwise the watch alone counts processes.
234 */
235 export function pidsDelegated(at: CgroupHost): boolean {
236 return at.ok && words(read(join(at.root, "cells", "cgroup.subtree_control"))).includes("pids");
237 }
238
227239 /**
228240 * A cgroup of its own for one cell, capped at `memoryMiB` (`undefined`: no
229241 * cap, for a crew run); `undefined` where the host has none. `share` is its
230242 * `cpu.weight`, `io.weight` and `cpu.max` where those controllers reach it;
231 * unset leaves the defaults, 100 and no quota.
243 * unset leaves the defaults, 100 and no quota. `processes` is its
244 * `pids.max` where the pids controller reaches it (`pidsDelegated`); unset
245 * or not a positive integer, no kernel cap.
232246 */
233247 export function openCellCgroup(
234248 memoryMiB: number | undefined,
235249 at: CgroupHost = cgroupHost(),
236250 share?: CpuShare,
251 processes?: number,
237252 ): CellCgroup | undefined {
238253 if (!at.ok) return undefined;
239254 const dir = join(at.root, "cells", `c${nextCell++}-${process.pid}`);
@@ -272,6 +287,13 @@ export function openCellCgroup(
272287 }
273288 }
274289 }
290 if (processes !== undefined && Number.isInteger(processes) && processes > 0 && pidsDelegated(at)) {
291 try {
292 writeFileSync(join(dir, "pids.max"), String(processes));
293 } catch {
294 // no pids controller here: the watch still counts the cell's processes
295 }
296 }
275297 return {
276298 dir,
277299 maxBytes,
src/apps/contain.ts +29 −11
@@ -38,7 +38,7 @@ import { dirname, join } from "node:path";
3838 import { portInsteadOfSocket } from "../git/hook-address.ts";
3939 import { relay, startEgress, upstreamProxy, type Egress } from "./egress.ts";
4040 import { BACKGROUND_CPU_WEIGHT } from "./background.ts";
41 import { cgroupHost, enterArgv, openCellCgroup } from "./cgroup.ts";
41 import { cgroupHost, enterArgv, openCellCgroup, pidsDelegated, type CellCgroup, type CgroupHost } from "./cgroup.ts";
4242
4343 /** `ceiling`: a declaration was cut to the machine's maximum; `declared`: forge.json's own; `default`: neither. */
4444 export type CeilingFrom = "env" | "pod" | "default";
@@ -441,6 +441,23 @@ export function cellMounts(
441441 return { hide, blank, reveal };
442442 }
443443
444 /**
445 * A cell's own cgroup on `at`: its memory cap, its share of the cores, and
446 * `pids.max` at the watch's process limit where pids is delegated (#10518).
447 */
448 export function cellCgroup(spec: CellSpec, at: CgroupHost = cgroupHost()): CellCgroup | undefined {
449 return openCellCgroup(
450 spec.limits.memoryMiB,
451 at,
452 spec.background === true
453 ? { weight: BACKGROUND_CPU_WEIGHT, cpus: spec.limits.cpus }
454 : spec.cpuQuota === true
455 ? { weight: 100, cpus: spec.limits.cpus }
456 : undefined,
457 PROCESSES_MAX,
458 );
459 }
460
444461 /** Make a cell: its sockets, its proxy, its relays. `close()` undoes it. */
445462 export function openCell(
446463 spec: CellSpec,
@@ -486,15 +503,7 @@ export function openCell(
486503 // small and bounded, and the ones that only exist to hide are read-only.
487504 const SMALL = String(1 << 20);
488505
489 const cgroup = openCellCgroup(
490 spec.limits.memoryMiB,
491 cgroupHost(),
492 spec.background === true
493 ? { weight: BACKGROUND_CPU_WEIGHT, cpus: spec.limits.cpus }
494 : spec.cpuQuota === true
495 ? { weight: 100, cpus: spec.limits.cpus }
496 : undefined,
497 );
506 const cgroup = cellCgroup(spec);
498507 const argv = (command: string): string[] => {
499508 const inner = bwrapArgv(command);
500509 return cgroup === undefined ? inner : enterArgv(join(cgroup.dir, "cgroup.procs"), inner);
@@ -702,7 +711,8 @@ function readMountinfo(): string {
702711
703712 /** How often the process table is read, and over how long CPU is averaged. */
704713 export const WATCH = { tickMs: 1_000, cpuWindowMs: 30_000, diskEveryTicks: 15 };
705 const PROCESSES_MAX = 512;
714 /** A cell's process limit: the watch's every tick, and the kernel's `pids.max` where pids is delegated. */
715 export const PROCESSES_MAX = 512;
706716 const CLK_TCK = 100;
707717 const DISK_WALK_MAX = 200_000;
708718
@@ -737,6 +747,14 @@ export function memoryCapHolder(): { by: "kernel" } | { by: "watch"; reason: str
737747 return host.ok ? { by: "kernel" } : { by: "watch", reason: host.reason };
738748 }
739749
750 /** Whether cells' process counts are the kernel's here (`pids.max`), or why they are watched only. */
751 export function processCapHolder(host: CgroupHost = cgroupHost()): { by: "kernel" } | { by: "watch"; reason: string } {
752 if (!host.ok) return { by: "watch", reason: host.reason };
753 return pidsDelegated(host)
754 ? { by: "kernel" }
755 : { by: "watch", reason: "the pids controller is not delegated to the cells" };
756 }
757
740758 /** One labelled cell as its watch last read it: cumulative CPU and resident memory. */
741759 export interface CellCounter {
742760 /** Unique per watched tree, so a rate never spans two processes. */
src/web/pages/system.ts +7 −2
@@ -19,7 +19,7 @@
1919 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
2020 import { telemetryReader } from "../../actions/telemetry.ts";
2121 import type { Ctx } from "../../http/context.ts";
22 import { memoryCapHolder } from "../../apps/contain.ts";
22 import { memoryCapHolder, PROCESSES_MAX, processCapHolder } from "../../apps/contain.ts";
2323 import { bytes, spark, span } from "../../system/format.ts";
2424 import { eventStream } from "../../http/sse.ts";
2525 import type { SystemView } from "../../system/view.ts";
@@ -239,11 +239,15 @@ export function systemPage(ctx: Ctx): Response {
239239 const reader = timePhase(ctx.request, "reader", () => telemetryReader(ctx.app, ctx.auth.user!));
240240 if (reader?.kind !== "owner") return notFoundPage(ctx);
241241 const v = timePhase(ctx.request, "sample", () => live().read());
242 const holder = timePhase(ctx.request, "caps", () => memoryCapHolder());
242 const [holder, pids] = timePhase(ctx.request, "caps", () => [memoryCapHolder(), processCapHolder()] as const);
243243 const caps: Html =
244244 holder.by === "kernel"
245245 ? html`<p class="small" id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>`
246246 : html`<p class="small" id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`;
247 const processCaps: Html =
248 pids.by === "kernel"
249 ? html`<p class="small" id="cell-pids" data-by="kernel">Each cell's processes are capped by the kernel at ${PROCESSES_MAX} (its cgroup's <code>pids.max</code>; not a crew run's): a fork past it fails inside that cell.</p>`
250 : html`<p class="small" id="cell-pids" data-by="watch">Each cell's processes are counted once a second and the cell stopped past ${PROCESSES_MAX}; the kernel does not cap them (${pids.reason}).</p>`;
247251 const memory = memorySection(ctx);
248252 const stalls = timePhase(ctx.request, "stalls", () => stallHour(loopWatch.tally(performance.now())));
249253 const rendering = performance.now();
@@ -264,6 +268,7 @@ export function systemPage(ctx: Ctx): Response {
264268 <ul class="list" id="cores" aria-label="Per-core CPU, percent, with its last readings">${at("cores")}</ul>
265269 <ul class="list" id="mem">${at("mem")}</ul>
266270 ${caps}
271 ${processCaps}
267272 <p class="small mono" id="temps">${at("temps")}</p></section>
268273 <section class="settings-block"><h2>Disks and network</h2>
269274 <ul class="list" id="sys-disks">${at("sys-disks")}</ul></section>
test/cgroup.test.ts +52 −3
@@ -22,7 +22,7 @@ import {
2222 openCellCgroup,
2323 prepare,
2424 } from "../src/apps/cgroup.ts";
25 import { cellsAvailable, type CellSpec } from "../src/apps/contain.ts";
25 import { cellCgroup, cellsAvailable, processCapHolder, PROCESSES_MAX, type CellSpec } from "../src/apps/contain.ts";
2626 import { childEnv, runStep } from "../src/apps/process.ts";
2727 import type { Proc, Raw } from "../src/system/proc.ts";
2828 import { buildSystem } from "../src/system/view.ts";
@@ -88,8 +88,9 @@ describe("the layout, on a directory shaped like cgroupfs", () => {
8888 expect(host).toEqual({ ok: true, root: dir });
8989 // One pid a write: the last one written is what the plain file holds.
9090 expect(readFileSync(join(dir, "forge", "cgroup.procs"), "utf8")).toBe("12");
91 expect(readFileSync(join(dir, "cgroup.subtree_control"), "utf8")).toBe("+memory");
92 expect(readFileSync(join(dir, "cells", "cgroup.subtree_control"), "utf8")).toBe("+memory");
91 // The fake holds the last write; cgroupfs adds each: memory, then pids.
92 expect(readFileSync(join(dir, "cgroup.subtree_control"), "utf8")).toBe("+pids");
93 expect(readFileSync(join(dir, "cells", "cgroup.subtree_control"), "utf8")).toBe("+pids");
9394 expect(existsSync(join(dir, "cells", `c7-${gone}`))).toBe(false);
9495 expect(existsSync(join(dir, "cells", `c8-${process.pid}`))).toBe(true);
9596 // Already laid out (cgroupfs reads back `memory`): nothing moves again.
@@ -121,6 +122,54 @@ describe("the layout, on a directory shaped like cgroupfs", () => {
121122 expect(app.cpuCapped).toBe(false);
122123 });
123124
125 const spec = (dir: string): CellSpec => ({
126 dir,
127 writable: [],
128 home: join(dir, ".home"),
129 hide: [],
130 egress: [],
131 reach: [],
132 limits: { memoryMiB: 64, cpus: 2, diskMiB: 512 },
133 });
134
135 test("where pids is delegated, a cell's process count is the kernel's: pids.max at the watch's limit, and /system says so", () => {
136 const dir = fake("pids", "memory pids\n");
137 const host = prepare({ ok: true, root: dir });
138 expect(host.ok).toBe(true);
139 expect(existsSync(join(dir, "forge", "pids.weight"))).toBe(false);
140 // cgroupfs reads back every controller enabled, without the `+`.
141 writeFileSync(join(dir, "cells", "cgroup.subtree_control"), "memory pids\n");
142 expect(processCapHolder(host)).toEqual({ by: "kernel" });
143 // What openCell opens: its own spec, the watch's limit as pids.max.
144 const cell = cellCgroup(spec(join(dir, "app")), host)!;
145 expect(readFileSync(join(cell.dir, "memory.max"), "utf8")).toBe(String(64 * 1024 * 1024));
146 expect(readFileSync(join(cell.dir, "pids.max"), "utf8")).toBe(String(PROCESSES_MAX));
147 expect(PROCESSES_MAX).toBe(512);
148 // Not asked for (a crew run), or not a positive integer: no file.
149 for (const processes of [undefined, 0, -1, 1.5, Number.NaN]) {
150 const run = openCellCgroup(undefined, host, { weight: 10, cpus: 4 }, processes)!;
151 expect(existsSync(join(run.dir, "pids.max"))).toBe(false);
152 }
153 });
154
155 test("no pids controller: the cell still opens with its memory cap, no pids.max, and /system says the watch counts", () => {
156 const dir = fake("nopids", "memory\n");
157 const host = prepare({ ok: true, root: dir });
158 expect(host.ok).toBe(true);
159 // Memory is still what the cells are given.
160 expect(readFileSync(join(dir, "cgroup.subtree_control"), "utf8")).toBe("+memory");
161 expect(readFileSync(join(dir, "cells", "cgroup.subtree_control"), "utf8")).toBe("+memory");
162 const cell = cellCgroup(spec(join(dir, "app")), host)!;
163 expect(cell).toBeDefined();
164 expect(readFileSync(join(cell.dir, "memory.max"), "utf8")).toBe(String(64 * 1024 * 1024));
165 expect(existsSync(join(cell.dir, "pids.max"))).toBe(false);
166 const holder = processCapHolder(host);
167 expect(holder.by).toBe("watch");
168 if (holder.by === "watch") expect(holder.reason).toContain("pids controller");
169 // No cgroup at all: the host's own reason.
170 expect(processCapHolder({ ok: false, reason: "FORGE_CELL_CGROUP=off" })).toEqual({ by: "watch", reason: "FORGE_CELL_CGROUP=off" });
171 });
172
124173 test("a crew run's cell has no memory cap, weighs 10 and is held to its cores", () => {
125174 const dir = fake("crew", "cpu io memory\n");
126175 const host = prepare({ ok: true, root: dir });
test/system-telemetry.test.ts +2
@@ -306,6 +306,8 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el
306306 // Each check cell's memory beside its cap, and who holds the cap (#2410).
307307 expect(html).toContain('data-cap="8589934592" data-cap-by="kernel">of 8.0 GiB (kernel cap)');
308308 expect(html).toMatch(/id="cell-caps" data-by="(kernel|watch)"/);
309 // And who holds each cell's process count (#10518).
310 expect(html).toMatch(/id="cell-pids" data-by="(kernel|watch)"/);
309311 // Built from the system's own parts: pills, meters and the process table grouped by kind (#2721), no style attribute (#2691).
310312 expect(html).toMatch(/id="summary"[^>]*>\s*<span class="pill"/);
311313 expect(html).toContain('<tbody data-kind="check"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs