This document covers the husk-pods execution model, the load-bearing memory-sharing claim the model rests on, the cgroup v2 memory charging behavior that makes that claim hold, the measured CI proof, the honest first-faulter accounting nuance, and what this work proves today versus what the full epic still needs.
Status, stated up front: pod-native is now the DEFAULT. The controller runs
--enable-husk-podsby default: eachSandboxPoolbuilds its template snapshot on the KVM nodes AND maintains a warm pool of pre-scheduled husk pods pinned to the snapshot-holding nodes, and aSandboxwithspec.source.poolRefactivates a dormant husk pod in place. Sandboxes ARE pods by default. The build-vs-run split is the key idea: forkd is the snapshot BUILDER (it needs/dev/kvmand the jailer to build a template snapshot; non-privileged since #352, an explicit builder capability set, still uid 0 + CAP_SYS_ADMIN, ADR 0008), while the husk pod is the UNPRIVILEGED RUNNER (it gets/dev/kvmfrom the device plugin, noprivileged: true, and activates the pre-built snapshot read-only). raw-forkd (fork per claim, no husk pods) is the documented fallback behind--enable-raw-forkd;--mockimplies it (the dev/no-KVM path cannot run a husk pod's dormant VMM). Section 6c covers the default flip and what the kind-e2e proves; the earlier sections trace how the model was built up slice by slice (the CoW precondition, the prepare/activate split, the device plugin, the warm-pool lifecycle, claim activation) and use the pre-flip wording of their slice; section 6c and the closing "Proven vs remaining" reflect the landed default.
Today a sandbox VM is a Firecracker process forkd launches and tracks directly.
The husk-pods model moves every sandbox VM inside a Kubernetes pod so the VM
inherits real pod semantics: the scheduler sees it, ResourceQuota/LimitRange
bound it, NetworkPolicy (Cilium) governs its netns, and the husk pod's
securityContext satisfies every PSA restricted control, with exactly two
documented exceptions that keep it out of a restricted namespace: the read-only
snapshot hostPath and runAsNonRoot: false (the /dev/kvm device). The exact PSA
finding, empirically verified, is in the "Kubernetes conformance" section.
The shape:
- Pre-scheduled husk pods. A pool pre-schedules minimal pods, each running a dormant VMM stub on a vsock or unix control channel. The stub holds no VM yet; it has been scheduled, admitted, and placed, so the expensive Kubernetes work (scheduling, admission, netns, cgroup creation) is already paid for.
- Claim = activate. Claiming a sandbox activates a husk: the stub mmaps the template snapshot and performs a KVM restore INSIDE the pod's own cgroup and netns. The VM's memory is therefore charged to the pod's memcg and its traffic rides the pod's network namespace.
- /dev/kvm via a device plugin, not
privileged: true. KVM access is granted through a Kubernetes device plugin that exposes/dev/kvmto the pod, so the pod is NOT privileged. This is the one documented PSA-restricted exception (an ADR underdocs/adr/), not a blanket privilege grant. - Overflow beyond the warm pool. A claim that exceeds the warm husk pool is
served by scheduling a fresh husk pod: seconds rather than the warm-pool
target, a degraded but correct mode.
pendingClaimsis the autoscaling signal that drives the pool to grow.
The density argument for this model is that many husk pods on one node share the SAME template snapshot's clean page set, so the marginal memory of an additional activated husk is its private-dirty divergence, not a whole VM. That argument is only valid if page sharing survives the per-pod memcg boundary. That is the load-bearing claim.
Every fork of one template restores the SAME snapshot memory file with
MAP_PRIVATE (Firecracker mmaps the snapshot mem file). MAP_PRIVATE clean
pages are backed by the page cache for that file and are physically shared by
every process that maps them, until a process writes one. A write triggers
copy-on-write: the kernel allocates a fresh anonymous page for the writer and
leaves every other mapper on the original shared page. So at any instant a
fork's resident set splits into:
- a clean, shared portion: snapshot pages no fork has written, one physical copy backing all forks; and
- a private-dirty portion: pages this fork wrote after restore, an anonymous page unique to this fork.
cgroup v2 charges memory to a memcg on the page-fault that brings a page resident, per these rules that matter here:
- First-faulter charging. When a file-backed (page-cache) page is first faulted resident, it is charged to the memcg of the faulting task. A different memcg whose task later reads the SAME already-resident page is NOT re-charged: the page is already accounted, and a shared read does not duplicate the charge.
- MAP_PRIVATE clean pages stay shared. A clean MAP_PRIVATE page is the shared page-cache page; it is charged once (to whoever faulted it in) and read by all other mappers without additional charge.
- CoW writes allocate an anon page charged to the writer. When a fork writes a clean shared page, the kernel allocates a new anonymous page and charges it to the WRITER's memcg. The original shared page stays put, still charged to its first-faulter, still shared by the non-writers.
Putting each fork's Firecracker process in its own memcg changes WHO is charged for a page; it does not change WHETHER the page is physically shared. The memcg is an accounting boundary, not a copy boundary. The clean snapshot pages remain one physical copy in the page cache shared across all forks regardless of which memcgs their mappers belong to. Only a CoW write creates a new (anonymous) page, and that page is genuinely private to the writer and correctly charged to the writer's memcg. So:
- the shared clean snapshot set exists physically ONCE no matter how many per-pod memcgs map it; and
- each fork's private-dirty divergence is a distinct set of pages charged to that fork's own memcg.
This is exactly the property the husk-pods density argument needs: per-pod memcgs do not multiply the shared snapshot footprint.
The claim is verified, not asserted. The KVM integration workflow
(.github/workflows/kvm-test.yaml) runs a husk-probe phase that:
- builds one template snapshot (reusing the bench template),
- forks 4 real sandboxes from it via the KVM-backed engine,
- places each fork's Firecracker PID in its OWN cgroup v2 memory controller
under
/sys/fs/cgroup/husk-probe/vm-<i>(enabling+memoryin the root and the probe subtree so the leaf memcgs account memory), and - samples each fork's
/proc/<pid>/smaps_rollupand each memcg'smemory.current/memory.stat, then runsinternal/huskprobe.Analyze.
Analyze produces a CoW-aware Report:
NaiveSumis every fork's full RSS summed, the non-CoW-aware charge.SharedResidentis the snapshot clean resident set, counted ONCE (the max over forks ofRss - PrivateDirty, the conservative representative).TotalPrivateDirtyis the sum of every fork's own private-dirty pages.AggregatePhysical = SharedResident + TotalPrivateDirtyis the honest physical footprint.CoWSavings = NaiveSum - AggregatePhysical.CoWSurvivesis true when sharing materially lowered the footprint (the honest footprint is at least one wholeSharedResidentbelow the naive sum), which is only possible if the shared snapshot set was counted once across the separate memcgs.DirtyPerVMis true when every fork has its own non-zero private dirty.
The CI phase gates on CoWSurvives == true, AggregatePhysical < NaiveSum by
at least one SharedResident (a material margin), and DirtyPerVM == true with
every fork's PrivateDirty > 0. The exact NaiveSum, AggregatePhysical,
SharedResident, TotalPrivateDirty, CoWSavings, and per-fork private dirty are
published to that run's $GITHUB_STEP_SUMMARY. They are SHARED-CI-CLASS numbers:
ubuntu-latest is a noisy, oversubscribed, often nested-virt runner, so the
absolute values are reproducible per run but are NOT bare-metal figures. The
verdict reported there, and the property this PR claims, is CoWSurvives: the
shared snapshot pages are counted once across the four cgroup v2 memcgs, not four
times, while each fork's private dirty is charged to its own memcg. The
conclusion: the load-bearing precondition of husk pods holds; the design stands
on this point.
If a future run ever reports CoWSurvives = false, the phase fails loudly as a
HUSK-DESIGN-FAILED result (distinct from a HUSK-SETUP-LIMITATION, which is a
runner-class cgroup restriction that could not measure). CoWSurvives = false
would mean CoW does NOT survive the per-pod memcg boundary and the husk-pods
density argument would need rethinking; this document would be updated to report
that.
The husk-pods model splits a sandbox's bring-up into two phases with very
different costs. internal/husk (driven by cmd/husk-stub) is the stub that
implements that split today, ahead of the controller migration.
- Prepare (pre-claim, off the hot path). The stub brings up a DORMANT
Firecracker VMM: the
firecrackerprocess and its API socket are up, but no snapshot is loaded and no guest is running (internal/huskStateDormant). In production this happens when a husk pod is pre-scheduled into the warm pool, so the expensive work, scheduling, admission, netns and cgroup creation, and spawning the VMM process itself, is already paid for before any claim arrives. - Activate (claim time, the only cost paid on the hot path). A claim sends
one
ActivateRequest{SnapshotDir, NetworkOverrides}over the stub's control socket (a line-delimited JSON protocol). The stub doesLoadSnapshotWithOverridesagainst the already-running VMM, remapping the baked NIC to this husk's tap, resumes the VM in place, and waits for the guest agent to answer over vsock before replyingActivateResult{OK, VsockPath, LatencyMs}. Because the VMM process was pre-started, the claim-time cost is the in-place snapshot load + resume + guest-ready handshake, NOT a VMM spawn.
The stub FAILS CLOSED: a snapshot-load or guest-readiness failure returns
OK=false with actionable error text and leaves the husk NOT active. It never
reports a usable VM it could not verify over vsock. One stub owns exactly one
VM, so a successful activate is terminal for that stub.
A template snapshot is stored by templateID (the pool name), so rebuilding a pool
under the same name overwrites its mem in place and produces a new content-
addressed digest. A warm husk pod records the digest it verifies against
(annotation mitos.run/template-digest, stamped whenever a digest is known at
creation, on every creation path since #679) and, when pinned to exactly one
snapshot node, that node (mitos.run/snapshot-node; otherwise the reap uses the
node the scheduler placed the pod on). On reconcile the controller reaps any
DORMANT pod whose stamped digest no longer matches its node's current recorded
digest, and the warm-pool deficit logic refills the slot against the fresh
snapshot. Claimed (activating or active) pods are never reaped.
Pods created when NO digest was known anywhere (for example a pool running with
the unverified-snapshots escape hatch) are covered by the build generation
instead (#679): the pool status field templateBuildGeneration counts in-place
rebuilds (a content-change rebuild, a forced rebuild, an automatic
restore-failure rebuild; deficit copies do not count), every husk pod is stamped
with the generation it was created under
(mitos.run/template-build-generation), and a dormant pod stamped with an older
generation, or not stamped at all, is reaped after the next rebuild. Together
the two mechanisms ensure that once a rebuild has happened, warm husks converge
instead of CrashLoopBackOff on a stale digest, and that a claim never activates
a new mem snapshot against a rootfs CoW clone taken from the old artifacts.
The controller derives a build identity from everything that determines what the
snapshot contains: the template image, the resolved init/buildSteps, the serving
workload (command, env, ready probe), the baked volumes, the build VM resources,
and the at-rest encryption flag. It records that identity in the pool status field
templateBuildHash. On reconcile, when the template's current build identity no
longer matches the recorded one, the snapshot is rebuilt in place on every holder
node (a fresh content-addressed digest), and the #461 stale-digest reap above then
converges the warm pool onto it. So editing spec.template.workload.command (or
env, or the ready probe) on a pool re-runs the build the same way an image change
does, instead of being silently ignored until the pool is deleted and recreated.
Fields that do not change snapshot content (placement, network posture, warm-pool
sizing, budgets) are excluded from the identity, so they never force a rebuild.
A template snapshot can be structurally present, a build "succeeded" and a digest was recorded, yet fail to RESTORE: a corrupt memory file, or a kernel mismatch introduced by a bad init step. Every dormant husk pod bound to that snapshot then CrashLoopBackOffs forever, because from the controller's point of view the build already succeeded and the holder count is satisfied; no other reconcile path rebuilds it. Three seams close this gap.
Forkd-side reuse-or-rebuild gate. Engine.CreateTemplate no longer
unconditionally rebuilds an on-disk template on every call: an existing
template is reused only when its digest re-verifies AND its snapshot
artifacts pass an ownership invariant check. A template that fails either
check is deleted and rebuilt in the same call. A ForceRebuild flag on the
request lets the controller skip reuse unconditionally, even over a template
that would otherwise pass both checks; see docs/threat-model.md for the
full mitigation writeup.
Controller-side detection and backoff-bounded rebuild. Each husk-path
reconcile counts DISTINCT dormant husk pods bound to the pool's current
template digest that are in CrashLoopBackOff with at least 3 restarts. Two or
more such pods (one flaky pod is not evidence of a broken template; two
independent pods hitting the same crashloop on the same digest is) presumes
the template is restore-broken, and the pool reports it on the
TemplateHealthy condition:
True/Healthy: the warm pool is ready and no crashloopers on the current digest.False/RestoreFailing: two or more crashloopers on the current digest were observed; a rebuild has not (yet) been triggered this reconcile, because it is still inside the backoff window.False/Rebuilding: a forced rebuild was just triggered.
A rebuild forces the snapshot to rebuild in place on every current holder
(ForceRebuild, so the node's reuse-or-rebuild gate cannot silently skip a
snapshot the controller already knows is broken), then deletes the
crashlooping pods bound to the bad digest so they recreate against the fresh
one. Rebuild attempts back off exponentially: 1m << (attempts-1), capped at
30m, recorded on Status.RebuildAttempts and Status.LastRebuildTime. The
attempt counter resets to zero once the pool's husks are healthy again.
Operator-side force-rebuild annotation. An operator can force an immediate rebuild, bypassing the backoff entirely, by annotating the pool:
kubectl -n mitos annotate sandboxpool <pool> mitos.run/force-rebuild="$(date +%s)" --overwrite
Any new annotation value (a Unix timestamp is the documented convention, but
any string that differs from the last-handled value works) triggers exactly
one rebuild; the pool records the value it acted on in
Status.ForceRebuildHandled, so a repeat reconcile with the same value is a
no-op and a later --overwrite with a new value triggers exactly one more.
A forced rebuild does not increment Status.RebuildAttempts or stamp
Status.LastRebuildTime: it is an explicit, human-triggered override, not
evidence toward the automatic backoff exponent. This annotation is now the
documented recovery path for a restore-broken template; it replaces the old
hand recovery of deleting on-disk template artifacts and restarting forkd.
Build result visibility. Independent of restore-time health, every
reconcile also reports whether ensureTemplateBuilt itself succeeded via the
TemplateBuilt condition: True/Built on success, False/BuildFailed with
the build error (truncated to 512 characters) on failure. This covers both
the husk-pod and the raw-forkd (--enable-raw-forkd) reconcile paths.
The KVM integration workflow runs a husk-stub phase that proves the split and
measures the activation cost. It reuses the bench template snapshot, then for
each iteration: starts a fresh dormant stub (prepare), runs the
husk-stub --activate control client to activate that snapshot in place,
asserts the ActivateResult is OK, and on the first iteration execs a real
command through the guest agent over the returned VsockPath. The gate is
activate OK AND exec works through the activated VM, not merely a control
reply. The phase publishes nearest-rank P50/P99 of the stub-measured
LatencyMs (load-start to guest-ready) to that run's $GITHUB_STEP_SUMMARY.
These are SHARED-CI-CLASS numbers: ubuntu-latest is a noisy, oversubscribed,
often nested-virt runner, so the absolute values are reproducible per run but
are NOT bare-metal figures. The <= 10ms warm activation figure is the
bare-metal reference-node TARGET, not a shared-CI claim; the CI
phase does not assert it and it must not be quoted as achieved. The honest claim
this phase supports is narrow: the prepare/activate split works, an in-place
snapshot load activates a usable VM, and the claim-time cost is the activation
alone (the measured latency, with its shared-CI caveat), not a VMM spawn.
A correct fork delivers fresh per-activation entropy (RNG reseed), resyncs the
guest wall clock, and delivers per-claim secrets. That is the engine's
NotifyForked handshake (see docs/fork-correctness.md);
the fork/daemon path drives it, and now the husk stub's Activate runs the SAME
handshake. After the snapshot loads, resumes, and the guest answers, Activate:
- generates fresh
crypto/randentropy and an incrementing generation, then sendsNotifyForkedWithConfigso the guest reseeds its CRNG, steps its wall clock forward off the frozen snapshot time, and re-addresses its NIC; - delivers the claim-time env and secrets via
Configure, in the same order the daemon'sdeliverConfiguses (notify first, then env/secrets); - FAILS CLOSED: a connect/handshake error, or a guest that does not report
ReseededRNG, leaves the VM NOT active and unserved. A VM that did not reseed is never reported as usable. Entropy and secret VALUES are never logged.
This is CI-proven on the KVM runner: the husk activate-correctness phase activates two VMs from ONE bench template snapshot via two fresh dormant stubs and asserts, mirroring the fork-correctness suite, distinct RNG streams across the two activations (equal urandom is a real correctness failure, not a flake), each guest wall clock within 2s of the runner (the clock stepped), and an env var plus a secret delivered at activate readable in each guest while the secret value is absent from every host-side stub/client log. See docs/fork-correctness.md.
This PR proves the stub CAN apply claim-time config and reseed per activation.
The remaining work is the controller migration, which includes sourcing the
claim-time secrets and env from the controller (today the stub's --activate
client carries them over the local control socket); see "Remaining" below.
CoW sharing surviving the memcg boundary is what the density argument needs, but
it does NOT by itself give fair per-tenant memory accounting. Raw cgroup v2
charges each shared snapshot page to the FIRST pod that faulted it resident, in
full, and never re-charges the other pods that share it. So if pod A activates
first, A's memory.current carries the entire shared snapshot set and pods B, C,
D that share those exact pages appear to carry almost none of it. The shared set
is charged once (good for the node's total), but it is charged to ONE tenant, not
split across the sharers (unfair if memory.current is read as a per-tenant
bill). This is visible in the probe: the per-memcg memory.current values are
lopsided even though the physical sharing is real, which is precisely why the
smaps-derived split, not raw memory.current, is the source of truth for the
report.
Fair per-tenant memory accounting therefore does NOT use raw memory.current.
It uses the CoW-aware metering (the shared-once model): the shared
restored page set is counted once and attributed as shared, and each tenant is
billed its own unique (private-dirty) set plus a share of the common set, rather
than whichever tenant happened to fault the page first. See
docs/metering.md for the CoW-aware accounting model, its exact
versus approximate boundaries, and the cmd/bench --mode metering CI proof that
the shared template set is counted exactly once across forks. Husk pods inherit
that metering: the per-pod memcg is the right enforcement boundary
(memory.max per pod) and a useful signal, but the per-tenant BILL comes from
the CoW-aware metering, not the first-faulter memory.current.
A husk pod needs /dev/kvm to restore and run its VM, but mounting the device as
a hostPath to /dev/kvm (the way forkd did before #352) plus a permissive
capability set is incompatible with the PSA restricted profile husk pods
target. The Kubernetes device-plugin mechanism is the restricted-profile path:
the pod requests mitos.run/kvm like any extended resource and the kubelet,
not the pod spec, injects the device. forkd now uses this same device plugin too
(#352, ADR 0008), so the privileged /dev/kvm hostPath is gone fleet-wide.
cmd/kvm-device-plugin (implemented in internal/deviceplugin) is that plugin.
It runs as a DaemonSet on every node and implements the v1beta1 DevicePlugin
gRPC service:
- Scheduler truth.
ListAndWatchadvertises--device-counthealthy slots (synthetic idskvm-0..kvm-{N-1}) ONLY where the host/dev/kvmexists, and ZERO where it does not. A node without/dev/kvmadvertises nothing, so the scheduler never places a pod that requests the resource there./dev/kvmis shareable, so the slot count is a soft per-node concurrency cap on husk pods, not a count of physical devices. The DaemonSet runs everywhere (nonodeSelector) on purpose: a node that gains/dev/kvmis covered automatically on the plugin's next registration, with no relabel. - Device injection.
Allocatereturns aDeviceSpecper configured device path (/dev/kvmand/dev/net/tun), each mapped host-path to the same container-path withrwpermissions, so the kubelet bind-mounts the device nodes into the admitted container. The pod never setsprivileged: trueand carries no/dev/kvmhostPathof its own. - Registration. The Registrar serves the plugin on a unix socket under the kubelet device-plugins dir and registers with the kubelet (Version, Endpoint, ResourceName), re-registering on failure so a kubelet restart is recovered.
The DaemonSet (deploy/device-plugin/daemonset.yaml) needs only minimal
privileges, even narrower than forkd's builder capability set: it runs as root
because the kubelet device-plugins dir is root-owned (it must write its socket
there), but it is unprivileged with all capabilities dropped,
allowPrivilegeEscalation: false and a read-only root filesystem. Its only host
access is the kubelet device-plugins dir (to serve and register) and a read-only
/dev mount (to stat /dev/kvm); it creates no device nodes and starts no VMs.
So a husk pod requesting mitos.run/kvm is PSA-restricted minus exactly the
documented device-plugin exception, not a privileged escape.
This is the PSA-restricted device-access path. forkd ALSO uses it since #352
(ADR 0008): the forkd DaemonSet requests mitos.run/kvm instead of mounting a
privileged /dev/kvm hostPath, so the privileged hostPath is removed fleet-wide,
not only on the husk path.
Proven:
- The gRPC service and the kubelet registration are unit-tested
(
internal/deviceplugin/plugin_test.go):ListAndWatchadvertisesdeviceCounthealthy devices when/dev/kvmis present and zero when it is absent (driven over an in-memory plugin server);Allocatereturns the/dev/kvmand/dev/net/tunDeviceSpecs with matching host/container paths andrwpermissions; and the Registrar registers against a FAKE kubelet Registration server over a unix socket with the right Version, Endpoint, and ResourceName. No real kubelet is involved. - The kind e2e job deploys the DaemonSet and gates on it becoming Ready and not
crashlooping. The kind-e2e GitHub Actions runner has
/dev/kvm(the same runner class also runs the Firecracker KVM suite), so the plugin advertises a non-zero count. The e2e assertion is adaptive: it reads the node allocatable formitos.run/kvmand branches on whether KVM is advertised. - Full advertise->schedule->inject path proven on the kind-e2e runner (the
stronger result): the probe pod requests
mitos.run/kvm: 1, the scheduler places it (Running, not Pending), andkubectl execinto the running pod confirms/dev/kvmis present inside the container. The probe pod is explicitly non-privileged (privileged: false,allowPrivilegeEscalation: false, all capabilities dropped, read-only root filesystem):/dev/kvmaccess comes entirely from the device plugin'sAllocateresponse, not from any host-path mount or privilege escalation. This proves the full PSA-restricted device-access path end to end on the CI runner. The e2e assertion also handles the no-KVM case honestly: on a runner without/dev/kvmthe plugin advertises zero and the probe pod stays Pending (also asserted in the adaptive branch).
Open:
- Running the husk stub INSIDE a pod that requests this resource (the pod spec
wiring) is a follow-up (see section 6). Migrating the forkd DaemonSet off its
privileged
/dev/kvmhostPath onto this device plugin is DONE (#352, ADR 0008): forkd now requestsmitos.run/kvmand runs non-privileged.
This is the first controller-migration slice. It is gated behind the
--enable-husk-pods flag on the controller and is OFF by default; with the flag
off the controller's behavior is unchanged (raw-forkd: the pool builds
node-local snapshots). Nothing here makes sandboxes pods on its own; it manages
the warm pool of husk pod OBJECTS so the later slices (activation, default flip)
have pre-scheduled pods to activate.
When --enable-husk-pods is set, a SandboxPool no longer drives the
snapshot-on-nodes deficit. Instead it maintains a warm pool of husk pods sized
to spec.replicas:
buildHuskPod(internal/controller/huskpod.go) emits aGenerateName <pool>-husk-Pod in the pool's namespace, owner-referenced to the pool, with the labelsmitos.run/pool=<pool>andmitos.run/husk=true. The single containerhusk-stubruns the image from--husk-stub-imagewith args to Prepare a dormant Firecracker VMM (the--firecracker/--kernelpaths and a--control-socketto listen on). The activation transport over that socket is slice 2; slice 1 only stands the dormant stub up.- The container requests one
mitos.run/kvmslot (request AND limit, the device-plugin contract) so the pod is scheduled only onto a/dev/kvmnode, withoutprivileged: true. It also carries cpu/memory REQUESTS sized from the template'sspec.resources(or a documented default of 1 cpu / 512Mi when the template carries no sizing). Those requests are the scheduler-truth-partial result: the sandbox now shows up to the scheduler as ordinary pod requests (it counts againstkubectl describe node, ResourceQuota, and LimitRange as a normal workload). The FULL scheduler-truth conformance (a quota actually bounding the pool, a LimitRange defaulting it, eviction/preemption) is the conformance slice; this slice proves the object exists with the requests set. - The container
securityContextis the new-execution-surface lockdown, documented per field inhuskpod.go:privileged: false,allowPrivilegeEscalation: false, all capabilities dropped (drop: [ALL], none added back; networking capabilities arrive with the networking slice),seccompProfile: RuntimeDefault.runAsNonRoot: falseis the single documented exception: Firecracker opens the device-plugin-injected/dev/kvm, and the dormant-VMM bring-up is simplest as uid 0 WITHOUTprivileged; moving to a non-root uid in the kvm group is a follow-up once the device permissions are pinned. This is NOT privileged and escalation is denied. reconcileHuskPodslists the pool's husk pods by label, keeps the non-terminating ones it owns, creates the deficit towardreplicas, and deletes the surplus deterministically (by name) on scale-down. Deleting the pool garbage-collects its husk pods via the owner reference.
In PRODUCTION a husk slot is "ready" only when its pod is Running AND Ready (the
dormant VMM is up and serving the control socket); the warm-pool size would gate
on that. envtest has no kubelet, so pods never run, never go Ready, and have no
phase. To keep the reconcile convergent in BOTH, reconcileHuskPods counts by
object EXISTENCE of non-terminating owned husk pods: it creates up to replicas
and deletes the extras. The production Running+Ready gate is layered on in the
activation slice; object existence is the correct convergence target for this
object-lifecycle slice.
PROVEN (envtest, internal/controller/huskpod_test.go):
- the husk pod spec: the
mitos.run/kvmrequest and limit of 1, the non-privileged securityContext (privileged false, escalation false, drop ALL, seccomp RuntimeDefault), the owner-ref to the pool, the two labels, the cpu/memory requests (template-sized and the default fallback), and the stub image; - the warm-pool object lifecycle: a pool with
--enable-husk-podsandreplicas: 3creates 3 husk pod objects owned by the pool; a second reconcile is idempotent; scalingreplicasto 1 deletes 2; with the flag off, no husk pods are created (the raw-forkd path runs unchanged).
OPEN (later slices):
- the husk pod actually RUNNING and the dormant VMM ACTIVATING end to end (the control-socket activation transport, slice 2; kind-e2e);
- the securityContext being genuinely PSA-
restrictedenforced (the conformance slice; the device-plugin exception is the one documented carve-out); - flipping pod-native to the default with raw-forkd behind the flag (slice 3).
The default is still raw-forkd (flag off). The pod-native default is slice 3.
Slice 1 (section 6) builds the warm pool of pre-scheduled DORMANT husk pod
objects. Slice 2 is the claim side: when a Sandbox with spec.source.poolRef binds to a pool with
--enable-husk-pods, the controller picks a dormant warm husk pod and ACTIVATES
it in place over the husk pod's mTLS control channel, instead of asking forkd to
fork a node-local VM. The activated VM runs inside the Kubernetes pod; the
claim's Status.Endpoint is set to the in-pod sandbox (the pod IP and the
in-pod sandbox port).
The activation path:
- the claim reconciler selects a
Running+Ready, unclaimed husk pod for the pool (the pod the slice-1 lifecycle pre-scheduled), and dials its control server atpodIP:HuskControlPort; - it CLAIMS that pod BEFORE activating it, stamping the
mitos.run/claimlabel under an OPTIMISTIC LOCK (client.MergeFromWithOptimisticLock): the patch carries the pod'sresourceVersion, so two concurrent claims that both select the same dormant pod both attempt the patch but exactly ONE wins; the loser gets a409 Conflictand does NOT activate the pod, it requeues and picks a different dormant slot. Winning the label patch is the gate to Activate, so a dormant pod is claimed and activated by EXACTLY one claim: there is no double-assignment (two tenants on one VM); - it activates over the NETWORK mTLS control channel (
internal/huskServeTLS), authorized to the controller identity: the husk server requires and verifies a client certificate andAuthorizeControllerIdentityaccepts only a verified peer presenting thepki.ControllerNameSAN. An UNAUTHENTICATED or wrong-CA activate is rejected by the handshake before any request is read, so a non-controller peer can never drive the secret-bearing activate path; - it delivers the claim-time env and secrets through the SAME fork-correctness
handshake the engine fork path uses (
NotifyForkedreseed + clock-step, thenConfigurefor env/secrets), FAIL-CLOSED: a VM that did not reseed or whose secret delivery failed is reported as an error and never served. The controller refuses to send activation secrets over a nil (unauthenticated) TLS config (ActivateHuskPod), so secrets are never put on an unauthenticated wire; - it delivers the per-sandbox bearer token the controller mints for the claim in
the same
ActivateRequest(a SECRET, riding the mTLS control channel, never logged). After a successful activate the husk stub SERVES THE IN-POD SANDBOX HTTP API (exec/files) on the sandbox port, reusing the sameinternal/daemonSandboxAPIforkd serves: it registers the activated VM (by its host vsock path) and the delivered token, then serves the bearer-gated exec/files Handler on--sandbox-listen(default:9091). Every request is gated on the per-sandbox token (constant-time compare; a request with no token or the wrong token is401), exactly as forkd does. This makes the endpoint the claim advertises (Status.Endpoint = podIP:sandboxPort) actually reachable and token-gated: the exec/files path isSDK -> podIP:sandboxPort -> vsock -> guest agent.
The snapshot the pod activates is mounted READ-ONLY from the node (the node-local template the pool built). This is a PLACEMENT requirement for now: a husk pod can only activate a template present on its node. Fully pod-native snapshot delivery (the pod pulling the template into itself over the CAS wire rather than relying on a node read-only mount) is a refinement, not done here.
PROVEN:
- the mTLS network control TRANSPORT and AUTH:
internal/huskServeTLSrequires and verifies the controller client cert and authorizes thepki.ControllerNameidentity;ActivateHuskPoddials it with the controller client config and refuses a nil config (unit-tested ininternal/huskandinternal/controller); - the claim-activation WIRING: the claim reconciler selects a dormant Running +
Ready husk pod and activates it over the control channel, sets the endpoint to
the in-pod sandbox, pends when no dormant pod is available, and stays not-Ready
when the activate fails (envtest,
internal/controller/husk_activation_test.go:TestHuskClaimActivatesDormantPod,TestHuskClaimNoDormantPodPends,TestHuskClaimActivateFailureNotReady); - the dormant-pod NO-DOUBLE-ASSIGN guarantee: the reconciler claims the pod under
an optimistic lock BEFORE activating it, so two claims racing for one dormant
pod resolve to exactly one activation; the loser gets a
409 Conflictand requeues (envtest,TestHuskClaimSingleDormantPodNoDoubleAssign: one dormant pod + two claims yields exactly one Ready claim, the pod'smitos.run/claimlabel names only that winner, and the activator was called exactly once); - the in-pod SANDBOX API is served and token-gated: after a successful activate
the husk stub registers the activated VM and the delivered per-sandbox bearer
token with
internal/daemonSandboxAPIand serves the exec/files Handler on the sandbox port; a tokened HTTP exec reaches the guest over vsock and an untokened / wrong-token request is rejected (internal/huskTestActivateServesTokenGatedSandboxAPI), and the token value is never logged; - a REAL network activate end to end on KVM: the KVM CI husk network-activation
phase issues
internal/pkicerts (server leafpki.ServerName, controller leafpki.ControllerName), starts a dormant husk pod serving mTLS network control, activates it viaActivateHuskPodover the wire with a claim-time env var + secret + the per-sandbox bearer token, execs through the activated guest over vsock, asserts the secret is readable in the guest and absent from host-side logs, asserts a WRONG-CA controller cert is REJECTED by the mTLS gate, and ALSO execs over the in-pod SANDBOX HTTP API on the sandbox port (theStatus.Endpointwire) using the bearer token, asserting the tokened exec reaches the guest and an untokened / wrong-token request is rejected (401/403) with the token value absent from host-side logs. This proves the network transport + auth + the real activate + vsock exec + secret delivery + auth rejection + the advertised endpoint reachable and token-gated.
OPEN (later slices):
- raw-forkd is still the DEFAULT; the husk-pod claim path runs only under
--enable-husk-pods. The full kind claim -> pod -> exec as the DEFAULT (flipping pod-native on, raw-forkd behind the flag) is slice 3; - fully pod-native snapshot delivery (CAS pull into the pod) rather than the node read-only mount.
Sandboxes are pods on the husk path. Slice 2 made this opt-in
(--enable-husk-pods); slice 3 (section 6c) made it the DEFAULT.
Slice 3 flips pod-native ON by default and proves the full cluster path. The
controller's --enable-husk-pods now defaults to TRUE; --enable-raw-forkd
selects the fork-per-claim fallback; --mock forces raw-forkd (the dev/no-KVM
overlay has no /dev/kvm, so a husk pod's dormant VMM cannot run there).
Exactly one run path is active: huskPods == !rawForkd
(cmd/controller/main.go resolveRunMode).
The two roles are deliberately separated by privilege:
- forkd is the BUILDER. Building a template snapshot means
booting a VM from the template image, running its init command in the VM, and
taking a Firecracker snapshot. That needs
/dev/kvm, the jailer (per-VM uid/chroot/cgroup), and write access to the node data dir. forkd is a NON-privileged DaemonSet since #352 (an explicit builder capability set with the jailer enabled,/dev/kvmfrom the device plugin, still uid 0 + CAP_SYS_ADMIN; ADR 0008) on the KVM nodes and does this build (and remains the--enable-raw-forkdfork-per-claim engine). - the husk pod is the unprivileged RUNNER. Running a sandbox means loading a
PRE-BUILT snapshot read-only and resuming it. The husk pod gets
/dev/kvmfrom the device plugin (notprivileged: true), mounts the node's template snapshot read-only, and activates it in place. It drops ALL capabilities, runsseccompProfile: RuntimeDefault, and is not privileged (the one documented exception isrunAsNonRoot: false, section 6).
So a snapshot is BUILT once per node by the forkd builder (non-privileged since #352) and RUN many times by unprivileged husk pods. The husk pod never builds; it only activates.
- A
SandboxPoolin husk mode builds the template snapshot on the KVM nodes (the same build path raw-forkd uses) AND maintains a warm pool of husk pods pinned, via akubernetes.io/hostnamenodeAffinity, to exactly the snapshot-holding nodes, so each husk pod's read-only snapshot hostPath resolves (internal/controller/huskpod.go). - A
Sandboxwithspec.source.poolRefselects a dormant Running+Ready husk pod, claims it under an optimistic lock (no double-assign), and activates it over the mTLS control channel (slice 2, section 6b), delivering the claim-time env/secrets and the per-sandbox bearer token;Status.EndpointbecomespodIP:sandboxPort. - Exec/files go
SDK -> podIP:sandboxPort -> vsock -> guest agent, gated by the per-sandbox bearer token the husk stub serves on the in-pod sandbox API.
PROVEN:
- the run-path resolution:
resolveRunModemakes husk pods the default,--enable-raw-forkdand--mockforce raw-forkd, exactly one path active (unit-tested incmd/controller). - the deploy stack: the production base (
deploy/) runs the controller in husk mode (--enable-husk-pods,--husk-stub-image,--husk-data-dir), the forkd DaemonSet (the builder), and the KVM device plugin, with PKI bootstrap ON so the husk control channel and forkd mTLS work. kubeconform-validated. - the full CLUSTER object-lifecycle path on the KVM-capable kind runner
(
.github/workflows/ci.yaml, thekind-e2e-huskjob): the husk-default stack rolls out, EnsurePKI mints the CA + forkd + controller TLS Secrets, the device plugin advertisesmitos.run/kvm, the pool reconcile CREATES husk pods that the scheduler BINDS to the KVM node (device-plugin resource + nodeSelector- snapshot-node affinity, scheduler truth), and the claim reconcile is driven to the husk-pod activation path. When a husk pod's nested dormant VMM comes up inside the kind pod, the job tightens to the full claim -> Ready -> exec gate (exec over the in-pod sandbox API with the claim's bearer token).
- the IN-VM tail (dormant VMM Prepare, in-place mTLS activation, exec through the
guest, fork-correctness, secret delivery, wrong-CA rejection) is proven end to
end on the KVM runner in
.github/workflows/kvm-test.yaml, where Firecracker runs directly on the runner host (sections 3, 6b).
OPEN:
- the nested dormant Firecracker VMM coming up INSIDE a kind pod (Firecracker
nested in a kind-node container) is not guaranteed on a shared CI runner, so
the husk-pod-Ready -> claim-Ready -> in-pod-exec tail is best-effort in
kind-e2e-huskand GATED inkvm-test.yaml(FC on the host). The documented kind boundary is reported asHUSK-KIND-VMM; - the conformance suite is now PROVEN object-level on kind (scheduler truth,
ResourceQuota/LimitRange, NetworkPolicy attach, the exact PSA level minus the
documented exceptions,
kubectl get pods+ logs; see section 6e). What remains open is the IN-VM enforcement of a NetworkPolicy over the VM tap (bare-metal kubelet) and eviction/preemption/PDB/drain (slice 4b); - the bare-metal P99 claim-to-first-exec <= 10ms warm-pool benchmark;
- fully pod-native snapshot delivery (CAS pull into the pod) rather than the node read-only mount; removing forkd entirely (it stays the builder).
The threat model for the unprivileged-stub escape surface is RE-DERIVED in
docs/threat-model.md section 0 ("Unprivileged-stub escape
surface"): the surface is re-derived boundary by
boundary with a per-axis tally vs the old privileged forkd, each claim backed by
a CI-proven mechanism or named as a residual. The
honest verdict is that the per-sandbox EXECUTION surface is strictly improved
while the inherent /dev/kvm-and-kernel host-escape axis is EQUAL, not better,
and forkd-the-builder stays a smaller control-plane surface (non-privileged since
#352, ADR 0008; a hardened minimal builder, not zero-privilege).
The husk default changes WHICH layer enforces a sandbox's egress, and the two run modes use DIFFERENT mechanisms. They do not both govern a given sandbox; exactly one applies, decided by the run mode. (Full detail, including the CI proof, is in docs/networking.md.)
- Husk mode (the default): a Kubernetes
NetworkPolicygoverns. The VM's tap lives inside the HUSK POD's network namespace, so the sandbox's traffic IS the pod's traffic. ANetworkPolicy(or Cilium) selecting the husk pod (podSelectoronmitos.run/husk=true) is the GOVERNING egress layer, enforced by the CNI on the pod netns exactly as for any pod. This is honest pod networking with zero bespoke code: the cluster's existing pod-network policy machinery applies. The bespoke host-nftables engine is REDUNDANT here and is NOT installed for husk pods. - Raw-forkd mode (
--enable-raw-forkd,--mock): the bespoke host-nftables engine governs. There is no pod; the VM's tap lives on the HOST (forkd's netns), where aNetworkPolicycannot see it. The bespoke default-deny per-tap nftables allowlist (internal/network+internal/netconf) plus the controlled DNS proxy (internal/dnsproxy) ARE the enforcement mechanism.
The bespoke nftables engine is RETAINED (raw-forkd still depends on it); it is not deleted. It is redundant ONLY for the husk pod-netns path. Neither mode runs both layers over the same sandbox.
Honest scope: in husk mode the NetworkPolicy is the policy boundary proven at
the OBJECT level on kind (it exists and selects the husk pod; the CNI is
responsible for enforcement). The actual IN-VM enforcement of the VM tap by the
pod netns needs a KVM-capable kubelet running the husk pod's VMM (a bare-metal
reference node) and is the documented open item, not gated on the shared kind
runner where the nested VMM does not reliably come up.
Now that sandboxes are pods by default, the Kubernetes conformance criteria are
proven at the OBJECT level on the KVM-capable kind runner (the kind-e2e-husk
job in .github/workflows/ci.yaml). These assertions act on the husk pod
OBJECTS, which exist on kind regardless of whether the nested VMM boots; the
in-VM tail (a real activated VM, exec through the guest) is gated separately in
kvm-test.yaml on real KVM. Each criterion gates the job and distinguishes a
SETUP issue from a real conformance failure.
| # | Criterion | How it is proven object-level on kind |
|---|---|---|
| 1 | Scheduler truth | The husk pod carries cpu + memory REQUESTS and the scheduler BOUND it (Status.NodeName set), so the sandbox is an ordinary scheduled workload. A probe pod requesting more than node allocatable stays Pending: the native scheduler does not double-book. |
| 2 | ResourceQuota + LimitRange | A ResourceQuota (count/pods: 1 + cpu/memory caps) and a LimitRange in a test namespace bound husk-shaped pods: the first is admitted, the second is REJECTED by the quota admission with a Kubernetes exceeded quota / forbidden error. ZERO custom code: the rejection is from Kubernetes. |
| 3 | NetworkPolicy attach | A NetworkPolicy with podSelector matchLabels mitos.run/husk=true exists and SELECTS the husk pod. In husk mode this is the governing egress layer (the VM tap is in the pod netns); see section 6d. |
| 4 | PSA level | Empirically verified (see below): the husk pod is rejected by a restricted namespace on EXACTLY the documented exceptions, the same securityContext minus those exceptions IS admitted into restricted, and a privileged pod IS rejected (PSA is enforcing). |
| 5 | kubectl get pods + logs | kubectl get pods -l mitos.run/husk=true lists the sandboxes and kubectl logs returns the husk stub console (husk-stub: preparing dormant VMM / dormant / serving ... control). A sandbox is a pod an operator can list and read logs from. |
Verified against the v1.31 PodSecurity admission plugin on kind (and asserted in
the conformance job): the husk pod's securityContext satisfies EVERY restricted
control: privileged: false, allowPrivilegeEscalation: false, all capabilities
dropped (drop: [ALL], none added), and seccompProfile: RuntimeDefault at both
the pod and the container level. But the husk pod is NOT admitted into a baseline
or restricted namespace, for exactly two DOCUMENTED EXCEPTIONS, both intrinsic to
the husk model:
- the read-only snapshot hostPath.
hostPathis forbidden under BOTH baseline and restricted (the volume-types control); the husk pod mounts the node's read-only template snapshot so the dormant VMM can load it. runAsNonRoot: false. restricted requiresrunAsNonRoot: true; the husk pod runs uid 0 so Firecracker can open the device-plugin-injected/dev/kvmWITHOUTprivileged(the/dev/kvmdevice exception).
So the honest claim is precise: the husk pod is NOT fully restricted and NOT
baseline, because of the read-only snapshot hostPath; its securityContext is
restricted-clean (the SAME securityContext minus the hostPath and with
runAsNonRoot: true IS admitted into a restricted namespace, verified), and the
only PSA violations are the documented read-only-snapshot-hostPath +
runAsNonRoot-false (/dev/kvm device) exceptions, plus the mitos.run/kvm
device-plugin resource that replaces privileged: true. The conformance job also
rejects a genuinely privileged pod in the same restricted namespace, proving PSA
is actually enforcing (so the husk-pod admission result is meaningful).
PROVEN (object-level on kind, kind-e2e-husk): scheduler truth (requests +
bound + no double-booking); ResourceQuota + LimitRange bounding husk-shaped pods
with zero custom code; a NetworkPolicy selecting the husk pod (the husk-mode
governing egress layer); the exact PSA level (rejected by restricted on EXACTLY
the documented hostPath + runAsNonRoot exceptions, the restricted-clean
securityContext admitted, a privileged pod rejected); kubectl get pods +
kubectl logs showing the sandboxes.
OPEN: the IN-VM enforcement of the VM tap by the pod netns (a NetworkPolicy actually dropping the VM's egress) needs a KVM-capable kubelet running the husk pod's VMM, a bare-metal reference node, not the shared kind runner where the nested VMM does not reliably come up; the live-VM checkpoint on a Checkpoint drain actually surviving end to end (slice 4b is PROVEN object-level, see section 6f below; the live snapshot itself is bare metal); the bare-metal P99 claim-to-first-exec benchmark (slice 5).
A husk pod is an ordinary Kubernetes pod, so it is subject to the ordinary
disruption surface: a node drain (kubectl drain, a cluster-autoscaler
scale-down, a spot reclaim), an eviction, a preemption, or an operator deleting
the pod. This slice makes that surface BOUNDED and SELF-HEALING, and lets a pool
choose what happens to an active sandbox when its backing pod is lost.
The husk-mode pool reconcile creates a policy/v1 PodDisruptionBudget named
<pool>-husk, owner-referenced to the pool, selecting the pool's husk pods
(mitos.run/pool=<name>,mitos.run/husk=true). Its minAvailable is
max(1, Replicas-1).
BUDGET CHOICE (documented): minAvailable = max(1, Replicas-1) means a VOLUNTARY
drain disrupts AT MOST ONE husk pod at a time for a pool of two or more, so the
warm pool stays usable while a node drains one slot at a time; for a
single-replica pool the floor of 1 means the lone warm slot is not voluntarily
evicted (the operator must --force or wait, an explicit choice). This trades
drain speed for warm-pool availability, the right default for a latency-sensitive
warm pool: a claim should almost always find a dormant slot even mid-drain. The
bound is VOLUNTARY-disruption only: a node hard-crash still takes its husk pods,
and the self-heal below recreates them.
Each husk pod is owner-referenced to its pool, and the pool reconciler watches
its pods (Owns(&corev1.Pod{})). A husk pod delete (drain, eviction, crash,
kubectl delete) therefore enqueues the owning pool, and reconcileHuskPods
recreates the replacement so the warm pool count returns to Replicas without
waiting for the periodic requeue. The warm pool self-heals a lost dormant
slot with no operator action.
The warm pool is maintained INDEPENDENT of the snapshot build. The husk-mode
reconcile runs reconcileHuskPods FIRST and unconditionally, then attempts the
template snapshot build best-effort; a build that does not produce a ready
snapshot (or errors) is logged and reported in status but NEVER short-circuits
the warm-pool maintenance. The husk pods schedule DORMANT and cannot ACTIVATE
until the snapshot is present on their node, but the pool of pod objects exists
and self-heals regardless of build state. This decoupling is what lets the
warm pool come up and self-heal on kind, where forkd cannot boot a VM to build
the snapshot (the nested-VMM boundary) so a ready snapshot never appears. The
reconcile also requeues on a bounded interval (a shorter cadence while the
snapshot is not yet built, the steady 30s once ready), so the warm pool
re-converges to Replicas even if a pod delete event is missed, and a later
reconcile tightens each husk pod's nodeAffinity onto the snapshot-holding node
once a holder appears.
When the husk pod backing an ACTIVE claim is lost, the claim must not keep advertising a dead endpoint. Two triggers drive the re-pend, both implemented:
- Every claim reconcile of a Ready (active) claim first runs
checkHuskPodLost: if the claim's backing husk pod (Status.SandboxID= the pod name, labeledmitos.run/claim=<claim>) is missing or terminating, the claim re-pends. - The husk claim reconciler watches pods (
Watches(&corev1.Pod{})) and maps a pod event to the claim named in the pod'smitos.run/claimlabel, so a pod delete promptly reconciles the active claim instead of waiting for its own requeue.
Re-pend sets Phase Pending, clears Status.Endpoint/Node/SandboxID,
records the claim-pending metric, and requeues. The next reconcile re-activates
the claim on a replacement dormant slot (the self-heal above produced one), so a
lost sandbox transparently re-activates from the template snapshot.
SandboxPoolSpec.drainPolicy governs the active sandbox on a lost pod:
Kill(the default): re-pend as above. The lost pod's in-VM state is gone; the agent reconnects to a fresh fork from the template snapshot. Boring and always available.Checkpoint: before re-pending, the controller attempts a live-VM snapshot through the checkpointer seam (forkdForkRunning/CreateSnapshot), so the agent can resume from captured state. The live snapshot ONLY runs where the VMM still runs, a graceful drain on a KVM-capable kubelet. On an already-deleted pod there is nothing left to checkpoint, so Checkpoint degrades to the same re-pend as Kill, with a logged note. The controller plumbs the decision and calls the seam where the pod is still reachable; the live snapshot SURVIVING a drain end to end is bare-metal work (it needs the VMM running in the husk pod).
A husk pod carries real cpu/memory requests (scheduler truth, section 6e). A
pending husk pod whose requests exceed node allocatable stays Pending with a
PodScheduled condition of reason Unschedulable. This is the NATIVE
cluster-autoscaler scale-up signal: a cluster-autoscaler (or Karpenter) watches
for exactly this Pending+Unschedulable pod and provisions a node group, after
which the husk pod schedules. No custom controller code produces the signal; it
is the stock scheduler.
PROVEN (object-level on kind, kind-e2e-husk): the pool's
PodDisruptionBudget exists with the right selector and minAvailable; the warm
pool self-heals a deleted husk pod back to Replicas with a new pool-owned pod;
a claim re-pends (Phase Pending, endpoint cleared) when its husk pod is deleted
and the pool recreates a replacement; an over-allocatable husk-shaped pod stays
Pending+Unschedulable (the autoscaler signal). The drainPolicy default (Kill) and
the Checkpoint seam routing are proven in the controller envtest suite.
OPEN (bare metal / follow-up): the live-VM Checkpoint snapshot actually
surviving a drain end to end (needs the VMM running in the husk pod on a
KVM-capable kubelet); the FULL re-activate onto a Ready dormant pod after a
re-pend (the nested-VMM boundary on kind, gated in kvm-test.yaml where
Firecracker runs on the host); preemption priority-class tuning and
Karpenter-specific provisioner hints; the re-derived threat model for the
drain/checkpoint surface; a drain/re-pend latency benchmark.
A SandboxPool's warm buffer is demand-driven when spec.autoscale is set. The
controller scales the count of DORMANT husk pods from claim demand:
minWarm: dormant floor when idle (default 0). Set it equal toreplicasfor the legacy fixed pool.maxWarm: dormant ceiling, required when autoscaling is on, so a burst cannot create unbounded pods.targetSpare: spare dormant pods kept on top of the in-use count (default 2), the burst-absorption headroom.scaleDownCooldownSeconds: idle window before scaling down (default 300), the anti-thrash hysteresis. Scale-up is never delayed.
Formula, computed each pool reconcile:
desired = clamp(inUse + targetSpare, min(minWarm, maxWarm), maxWarm)
where inUse is the count of husk pods carrying mitos.run/claim. Scale-up
applies immediately; scale-down applies only after scaleDownCooldownSeconds
have elapsed since the last claim arrival, otherwise the current dormant level is
held. A scale-down only ever deletes surplus DORMANT pods (sorted by name, from
the tail); a claimed/in-use pod is never a scale-down victim. When spec.autoscale
is nil the warm pool keeps the legacy behavior: desired equals spec.replicas.
This is distinct from the unschedulable-husk cluster-autoscaler signal in section 6f: that grows the NODE pool from Pending+Unschedulable pods; this grows the WARM (dormant) pod buffer within the existing nodes from claim pressure.
Status surfaces status.desiredWarm and status.lastScaleDownTime. Metrics:
mitos_pool_warm_dormant, mitos_pool_warm_in_use, mitos_pool_desired_warm
(gauges), mitos_pool_warm_scale_up_total, mitos_pool_warm_scale_down_total
(counters), mitos_pool_refill_latency_seconds,
mitos_claim_wait_for_warm_seconds (histograms).
Refill cost: each new dormant pod re-verifies the ~680 MiB snapshot during its dormant Prepare (off the claim hot path, per pod). A per-node verify cache that lets the second-and-later dormant pod on a node skip the re-hash is tracked as a separate follow-up because it crosses the husk-pod / forkd trust boundary; see the autoscaler plan Appendix A.
- CoW page sharing survives cgroup v2 memory-controller boundaries: forks of one
snapshot in separate per-pod memcgs share the clean snapshot set physically
(counted once, not once per memcg) while each fork's private dirty is charged
to its own memcg. This is the precondition that had to be verified
FIRST, measured by a real KVM probe in CI and gated on
CoWSurvives. - The prepare/activate split: the dormant-VMM stub (
internal/husk,cmd/husk-stub) and its line-delimited JSON control protocol pre-start a Firecracker VMM and activate it in place via snapshot-load + resume + guest-ready on a control message. The KVM CI husk-stub phase measures the activation latency (load-start to first exec, shared-CI-class) and gates on activate OK plus a working exec through the activated VM. Fail-closed on a failed load. - Fork-correctness on activate: the stub's
Activateruns the sameNotifyForkedreseed + clock-step handshake as the engine fork path plus env/secret delivery viaConfigure, fail-closed (a VM that did not reportReseededRNGis not served). The KVM husk activate-correctness phase activates two VMs from one bench snapshot and gates on distinct RNG streams, each guest wall clock within 2s of the runner, and a delivered env var plus secret readable in each guest with the secret value absent from the host-side logs. - The
/dev/kvmdevice plugin:cmd/kvm-device-plugin(ininternal/deviceplugin) advertisesmitos.run/kvmonly where/dev/kvmexists and injects/dev/kvmand/dev/net/tunonAllocate, so a husk pod gets KVM as a scheduled resource instead ofprivileged: true(section 5). The DevicePlugin and Registration gRPC are unit-tested against a fake kubelet. The kind e2e gates on the DaemonSet becoming Ready, then proves the full advertise->schedule->inject path: a non-privileged probe pod requestingmitos.run/kvm: 1is scheduled to Running on the KVM-capable kind-e2e runner, andkubectl execinto the running pod confirms/dev/kvmis present inside the container (injected byAllocate, not by any privilege or hostPath). This is the PSA-restricted device-access path proven end to end. The assertion is adaptive: on a no-KVM runner it asserts Pending instead. The forkd-DaemonSet migration off its privileged hostPath onto this device plugin is DONE (#352, ADR 0008): forkd now requestsmitos.run/kvmand runs non-privileged. - Claim activation over the mTLS network control channel (slice 2, section 6b):
with
--enable-husk-podsthe claim picks a dormant warm husk pod, CLAIMS it under an optimistic lock (exactly one claim per pod, no double-assign), and activates it in place over the mTLS control channel (internal/huskServeTLS,internal/controllerActivateHuskPod) authorized to the controller identity, delivering claim-time secrets AND the per-sandbox bearer token through the fork-correctness handshake (fail-closed) and settingStatus.Endpointto the in-pod sandbox. An unauthenticated/wrong-CA activate is rejected by the mTLS gate. After activation the husk stub SERVES the in-pod sandbox HTTP API (exec/files) on the sandbox port, reusinginternal/daemonSandboxAPI, gated by the per-sandbox bearer token, soStatus.Endpointis reachable and token-gated. The claim-activation WIRING, the no-double-assign guarantee, and the token-gated sandbox API are proven in envtest +internal/husk(husk_activation_test.go,TestActivateServesTokenGatedSandboxAPI); a REAL network activate + vsock exec- secret delivery + auth rejection + token-gated HTTP exec over the sandbox
endpoint is proven on KVM (the husk network-activation CI phase, certs via
internal/pkiso the SANs match). The snapshot is mounted read-only from the node (a placement requirement). The DEFAULT is still raw-forkd; the default flip is slice 3.
- secret delivery + auth rejection + token-gated HTTP exec over the sandbox
endpoint is proven on KVM (the husk network-activation CI phase, certs via
Pod-native is now the DEFAULT (slice 3, section 6c): sandboxes are pods by
default, forkd stays the snapshot builder (non-privileged since #352, ADR 0008),
and raw-forkd is the fallback behind --enable-raw-forkd. The full husk-pods epic
still needs:
- the nested dormant Firecracker VMM coming up reliably INSIDE a kind pod, so the
full claim -> pod -> exec tail GATES on kind too. Today that tail is best-effort
in the
kind-e2e-huskjob (the cluster object lifecycle GATES there) and is GATED inkvm-test.yaml, where Firecracker runs on the runner host; - the conformance suite is PROVEN object-level on kind (section 6e): scheduler
truth, ResourceQuota/LimitRange bounding husk-shaped pods, a NetworkPolicy
selecting the husk pod, the exact PSA level (rejected by restricted on EXACTLY
the documented hostPath + runAsNonRoot exceptions, the securityContext otherwise
restricted-clean, a privileged pod rejected), and
kubectl get pods+ logs. What remains OPEN is the IN-VM enforcement of a NetworkPolicy over the VM tap (needs a KVM-capable kubelet, a bare-metal reference node) and eviction/preemption/PDB/drain behavior (slice 4b); - the bare-metal P99 claim-to-first-exec <= 10ms warm-pool benchmark (before/after); the shared-CI activation latency is not this number;
- fully pod-native snapshot delivery (CAS pull into the pod) rather than the node read-only mount, and removing forkd entirely (it stays the builder).
The threat model for the unprivileged-stub escape surface is RE-DERIVED in docs/threat-model.md section 0 (boundary by boundary, with a per-axis tally vs the old privileged forkd and named residuals); it is no longer an open item.
The default flip moves the epic forward by proving the full cluster object
lifecycle (build the snapshot, place husk pods, activate one on a claim) on the
KVM-capable kind runner, with the in-VM activation + exec path already proven on
real KVM in kvm-test.yaml.
The warm-pool design keeps pod scheduling and the Firecracker process spawn off the claim hot path. A claim that hits a warm pool pays only the activation cost: in-place snapshot load + resume + guest-ready, plus the mTLS control round-trip from the controller to the husk pod. The VMM process spawn (and pod scheduling/admission) is paid at warm-pool-fill time, before any claim arrives.
This inverts the naive concern that pod-native is slower than raw-forkd: because the
Firecracker spawn is pre-paid, the pod-native claim hot path is competitive with, or
faster than, raw-forkd's full fork_to_first_exec (which includes the FC spawn).
The one added cost vs raw-forkd is the mTLS control round-trip (a real network call
that raw-forkd's local gRPC path does not pay), but this is smaller than the FC
spawn cost eliminated. See BENCHMARKS.md for the full
comparison, the two shared-CI datapoints (raw-forkd fork_to_first_exec from the
bench phase, pod-native husk activation from the husk-stub phase), and the honest
accounting of the warm-pool-fill cost.
The bare-metal target is <= 10ms warm-pool claim-to-first-exec on the reference node. This is NOT a shared-CI claim and has not been measured; it is the directive target for when the pinned reference node exists.