ateom-gvisor: measure the sandbox cgroup for GetWorkloadStats - #739
Closed
Tim Bai (baizhenyu) wants to merge 2 commits into
Closed
ateom-gvisor: measure the sandbox cgroup for GetWorkloadStats#739Tim Bai (baizhenyu) wants to merge 2 commits into
Tim Bai (baizhenyu) wants to merge 2 commits into
Conversation
Adds ateom.Ateom/GetWorkloadStats, the RPC atelet will poll for per-actor
resource usage, plus the attribution retention both ateom runtimes need to
label a sample. The measurement half of each runtime lands in the two
follow-ups; GetWorkloadStats returns Unimplemented until then, so this change
puts nothing half-populated on the wire.
GetWorkloadStats is a pure read: unlike Run/Checkpoint/Restore it does not
move the ateom between "available" and "executing", so it is safe to call on
a timer for a workload's whole lifetime. The request carries the actor UID
the caller believes is executing here, so a recycled worker is rejected with
FAILED_PRECONDITION rather than reporting a different actor's numbers under
the requested actor's name.
sandbox_class and source are enums (SandboxClass, StatsSource) rather than
strings: both are closed sets the ateom binary picks from, and a typo in a
free-form string would silently split a metric in two downstream.
Neither runtime kept the actor's identifying fields past the call that
started the workload -- they arrive on Run/Restore and nothing downstream
needed them. A usage sample is only useful once attributed, so both now hold
them for as long as they are executing:
* ateom-gvisor gains AteomService.activeActor, set by RunWorkload and
RestoreWorkload and cleared by CheckpointWorkload and by both boot-failure
paths, tracking exactly the available/executing state machine.
* ateom-microvm gains runningActor.activeActor, populated from the existing
actorBootParams on both the cold-boot and restore paths; the existing
delete from s.running in teardownActor clears it.
The extraction from the request is shared in internal/ateomstats since both
binaries need it. The type is ActorAttribution, not ActorIdentity: "actor
identity" already means a credential in this repo (ateapi's ActorIdentity
service, substratex509, ateompath.ActorIdentityDirPath), and nothing here is
a secret or is presented as proof of anything.
Part of agent-substrate#594
Fills in the measurement half of GetWorkloadStats for the gVisor runtime, so it returns real numbers instead of Unimplemented. The micro-VM runtime keeps its stub until the guest-agent read lands. The sample comes from the sandbox's cgroup v2 leaf at /sys/fs/cgroup/pause. gVisor runs every container of a sandbox inside one host process, the sentry, and runsc places that process in the cgroup of the container that created the sandbox -- "pause", the first container RunWorkload and RestoreWorkload create. The actor's own containers get leaves too, but their memory and CPU are the sentry's and are charged there instead, which is why a sample is attributed to the actor rather than to a container. The path follows the "/" + containerName convention runsc.ensureContainerCgroupsPath writes into the OCI spec, resolved against the pod's own cgroup scope that setupCgroupDelegation prepares. The read lives in a new cmd/ateom-gvisor/internal/cgroupstats. It takes the cgroup directory as an argument rather than reaching for an absolute path, so the parsing is testable from a fixture tree without root or a live sandbox, and it carries no build tag so those tests run everywhere. It fails only when memory.current is missing or unparseable -- wrapping fs.ErrNotExist in the first case, so the handler can tell "the sandbox is gone" from "the format is not what we parse". Every other file degrades to zero on its own field, because each has a legitimate reason to be absent on a healthy node: memory.peak predates kernel 5.19, and setupCgroupDelegation enables controllers best-effort, so a cgroup with memory but no cpu is reachable. Reporting no memory numbers because the node could not report CPU would be the wrong trade. Working set is memory.current less memory.stat's inactive_file, saturating at zero rather than wrapping: the two files are read a moment apart and are not a consistent snapshot, so inactive_file can legitimately exceed the memory.current read just before it. AteomService.activeActor becomes an atomic.Pointer. The three lifecycle RPCs still hold AteomService.lock for their whole bodies and keep doing so; the point is the reader. GetWorkloadStats is polled on a timer for a workload's whole lifetime while lock is held across entire boots and checkpoints, so a lock-guarded read would park each poll behind a multi-second runsc call and let pollers pile up -- and holding the lock across the cgroup read would put a CheckpointWorkload behind telemetry, which is the worse direction. The field is only ever assigned or cleared as a whole pointer, never mutated in place, which is what atomic.Pointer is for. The handler reloads it after the read and compares pointer identity, so a checkpoint plus a fresh run completing underneath the read is reported as a failed precondition rather than misattributed. Both ateoms also gain a panic-recovery interceptor, chained ahead of InternalServerUnaryInterceptor. grpc-go serves each RPC on its own goroutine and does not recover handler panics, and the Go runtime kills the process on an unrecovered panic in any goroutine, so today a nil dereference in any handler ends every other RPC the ateom is serving -- including an in-flight checkpoint. That gap predates this change, but adding a caller that polls on a timer for the life of every workload is what makes it reachable, and the stats paths are exactly the shape that hits it: the "no workload here" state is a nil pointer, and cgroup files are parsed line by line. Not covered here: that the sentry lands in /sys/fs/cgroup/pause on a live node is a runsc placement behavior, derived from the spec convention above but not verified from a unit test. Part of agent-substrate#594
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of the three PRs for Phase 0 of #550, stacked on #667 and to be merged after it.
Review the second commit only. The base branch lives on my fork, so GitHub cannot use it as a base here and this PR is opened against
main; it therefore shows #667's commit as well. Once #667 merges I will rebase and this collapses to a single commit. Direct link to the commit under review:36879514.Fills in the measurement half of
GetWorkloadStatsfor the gVisor runtime, so it returns real numbers instead ofUnimplemented. The micro-VM runtime keeps its stub until the guest-agent read lands in PR 3, which closes #594.Where the numbers come from
/sys/fs/cgroup/pause, the sandbox's cgroup v2 leaf.gVisor runs every container of a sandbox inside one host process — the sentry — and runsc places that process in the cgroup of the container that created the sandbox. That is
pause, the first containerRunWorkloadandRestoreWorkloadcreate. The actor's own containers get leaves too, but their memory and CPU are the sentry's and are charged there instead. This is why a sample is attributed to the actor and not to a container, as the proto already says.The path follows the
"/" + containerNameconventionrunsc.ensureContainerCgroupsPathwrites into the OCI spec, resolved against the pod's own cgroup scope thatsetupCgroupDelegationprepares (the worker runs in a private cgroup namespace, so/sys/fs/cgroupis the pod's cgroup, not the host root).The one thing I could not verify locally: that the sentry really lands in that leaf on a live node is a runsc placement behavior. The convention is derived from our own spec-writing code, but if someone can confirm against a real worker before PR 3 builds on it, that would be worth doing.
The read
New
cmd/ateom-gvisor/internal/cgroupstats. It takes the cgroup directory as an argument rather than reaching for an absolute path, so the parsing is testable from a fixture tree without root or a live sandbox — and it carries no build tag, so unlike the rest ofcmd/ateom-gvisorthose tests run on every platform.It fails only when
memory.currentis missing or unparseable, wrappingfs.ErrNotExistin the first case so the handler can tell "the sandbox is gone" (FailedPrecondition) from "the format is not what we parse" (Internal).Every other file degrades to zero on its own field, because each has a legitimate reason to be absent on a healthy node:
memory.peakpredates kernel 5.19, andsetupCgroupDelegationenables controllers best-effort, so a cgroup withmemorybut nocpuis reachable. Reporting no memory numbers because the node could not report CPU seemed like the wrong trade — pushback welcome if you'd rather it be all-or-nothing.Working set is
memory.current − memory.stat:inactive_file, saturating at zero rather than wrapping. The two files are read a moment apart and are not a consistent snapshot, soinactive_filecan legitimately exceed thememory.currentread just before it; onuint64the naive subtraction gives ~1.8e19 instead of ~0.Locking
AteomService.activeActorbecomes anatomic.Pointer. The three lifecycle RPCs still holdAteomService.lockfor their whole bodies and keep doing so — the change is for the reader.GetWorkloadStatsis polled on a timer for a workload's whole lifetime, whilelockis held across entire boots and checkpoints (including the readiness wait). A lock-guarded read would park each poll behind a multi-second runsc call and let pollers pile up. Holding the lock across the cgroup read would be worse in the other direction: aCheckpointWorkloadqueued behind telemetry. The field is only ever assigned or cleared as a whole pointer, never mutated in place, which is whatatomic.Pointeris for.The handler reloads the pointer after the read and compares identity, so a checkpoint plus a fresh run completing underneath the read is reported as a failed precondition rather than misattributed to the wrong actor.
TestGetWorkloadStatsDoesNotTakeLockpins this: it holdss.lockacross the call, so a handler that ever reaches for the lock deadlocks.Panic recovery
Both ateoms gain
ateinterceptors.RecoveryUnaryInterceptor, chained ahead ofInternalServerUnaryInterceptor.grpc-go serves each RPC on its own goroutine and does not recover handler panics, and the Go runtime kills the process on an unrecovered panic in any goroutine.
grep -rn "recover()"finds nothing in our non-test code, so today a nil dereference in any handler ends every other RPC the ateom is serving — including an in-flight checkpoint, and the worker's socket with it.That gap predates this change. I've included it here because adding a caller that polls on a timer for the life of every workload is what makes it reachable, and the stats paths are exactly the shape that hits it: the "no workload here" state is a nil pointer, and cgroup files are parsed line by line. Happy to split it out if you'd rather review it separately. Scoped to the two ateoms; the other five gRPC servers in the repo have the same gap and are worth a follow-up.
Follow-up worth filing separately
The readiness wait is held under
s.lockin all four boot paths (main.go:364,:639, and the micro-VM equivalents). It is not a runsc subcommand, so it sits outside what the lock's own comment claims to protect, and it is the longest hold in the file. Shrinking it changes lifecycle behavior, so it does not belong in a telemetry PR — but it is the highest-leverage locking fix here.Testing
cgroupstats: nine-case table over fixture trees — all files present, missingmemory.peak, missingcpu.stat, missingmemory.stat,memory.statwithoutinactive_file,inactive_fileabovememory.current, all-zero, malformed/blank/over-long lines, unparseable optional files — plus missing-cgroup and malformed-memory.currentcases that pin which one matchesfs.ErrNotExist.GetWorkloadStats: happy path against a fixture cgroup root, a five-case error table (emptyactor_uid→InvalidArgument; available, UID mismatch, and vanished cgroup →FailedPrecondition; malformed cgroup →Internal), and the no-lock regression test.RecoveryUnaryInterceptor: recovers a nil-deref panic intoInternal, keeps the panic text out of the response and in the log with a stack, and passes success / status-error / plain-error through untouched.shellcheck.shneeds docker, which is not available here; no shell scripts changed.The
cmd/ateom-gvisorhandler tests are//go:build linux, so locally they are only compile-checked (GOOS=linux go test -c); CI'srun-testsexecutes them.