Repository navigation
Closed
Labels
area/observabilitykind/featureAn enhancement / feature request or implementationAn enhancement / feature request or implementationprio/P0Highest priority / required for next milestoneHighest priority / required for next milestone
Milestone
Description
Problem
Substrate cannot answer "how much CPU and memory is this actor using?" — nor the same question rolled up per ActorTemplate.
Resource usage is observable only at worker-pod granularity, via kubectl ate top workers. actor is the unit users create, name, bill for, debug, and reason about — and it is invisible.
Why this matters
Concretely, four groups are blocked:
| Who | What they cannot do today |
|---|---|
| Agent developers | Diagnose why an actor was OOM-killed. Size an ActorTemplate's resource requests with anything but guesswork. Tell which of their actors is the expensive one. |
| Platform operators | Detect noisy neighbours. Attribute cost or chargeback to a tenant, when atespace is the tenant boundary. Set quotas informed by observed usage. |
| Substrate developers | Validate the density claim, or tune multiplexing against real numbers. #44 (concurrent actors per worker, P0) has no way to measure whether it succeeded. |
| Load testing | Explain bottlenecks or confirm fleet health under load — the original motivation for #174, and still open in #168 / #176. |
This also gates scheduling work. Demand-reactive capacity (#198) and suspend-on-inactivity (#424) both make better decisions with a real actor resource footprint than without one.
Related
- Observability At-Scale #174 Observability At-Scale — parent discussion, granularity taxonomy, cardinality decisions
- Initial set of platform metrics around lifecycle, scheduling, and fleet-saturation #433 Initial set of platform metrics — the metrics workstream this complements
- All actors are under the
pausecgroup #288 All actors are under thepausecgroup — closed, was the stated blocker - ateom gvisor: drop
privileged: true#496 ateom gvisor: dropprivileged: true— delivered the per-actor cgroup leaves - fix(atelet): set OCI CgroupsPath so each actor gets its own cgroup #161 fix(atelet): set OCI CgroupsPath — open, superseded by ateom gvisor: drop
privileged: true#496 - Feature request: Implement 'kubectl ate top workers' command for real-time worker pod resource utilization #515 / feat(kubectl-ate): implement kubectl ate top workers command (#515) #516
kubectl ate top workers— the pod-level precedent - High Concurrent Active Actors per Worker #44 High Concurrent Active Actors per Worker — will invalidate pod-as-proxy-for-actor
- feat: WorkerPool autoscaling — demand-reactive capacity for warm worker pools #198 WorkerPool autoscaling / Feature request: suspend actor on inactivity #424 suspend on inactivity — consumers of actor usage data
- OpenTelemetry Application Telemetry Data Loss and Delivery Delays Across Actor Lifecycle Events #503 OpenTelemetry data loss across actor lifecycle events — adjacent, application-side
Reacted by ixcans
Metadata
Metadata
Assignees
Labels
area/observabilitykind/featureAn enhancement / feature request or implementationAn enhancement / feature request or implementationprio/P0Highest priority / required for next milestoneHighest priority / required for next milestone
Type
Projects
- StatusShow more project fieldsDone
Activity
[-][Feature] Resource-usage telemetry at actor / ActorTemplate granularity[/-][+]Feature Request: Resource-usage telemetry at actor / ActorTemplate granularity[/+]krisztianfekete commentedon Jul 28, 2026
+1 for driving this!
Most important question to officially decide I think is to what signals to use for what purpose.
To follow #174 and OTel best practices, these shouldn't be on metrics:
actor,atespace,session, and we should add high-cardinality attrs on traces/logs/events. So individual actor usage is probably best as events thatateletcan emit as something likeate.actor.usage.ActorTemplate/pool/sandbox labels can be on metrics, as those are fairly bounded. I guess we can start with two histograms, once for CPU, and one for memory?
git286 commentedon Jul 28, 2026
Thanks and agreed on the signal split.
actor/atespace/sessionas event attributes,ActorTemplate/ pool / sandbox class as metric labels is the right division, and it's the constraint I'd want this issue to inherit from #174 rather than relitigate. Two deliverables, not one: aggregated template-keyed metrics for the TSDB, and per-actor events for drill-down.I'd put the emitter in ateom rather than atelet
Two reasons:
atelet can't see the data. It's a node-level DaemonSet in a different pod, and its only host mount is
/var/lib/ateom-gvisor. No/sys/fs/cgroup, nohostPID. Reading the leaves from atelet would mean adding a host cgroupfs mount and reconstructing host cgroup paths from container IDs, which is close to the coupling #496 just removed. The leaves are only addressable as/sys/fs/cgroup/<containerName>from inside ateom's own cgroup namespace.ateom already has the identity, and already emits actor-scoped signals.
RunWorkloadRequestcarriesatespace,actor_name,actor_uid,actor_template_namespace,actor_template_name, andinternal/actorlogalready emits lifecycle events tagged with exactly thatate.dev/actor_*label set, shared by both the gVisor and micro-VM runtimes. Anate.actor.usageevent is a natural sibling ofEmitLifecycleLog— same emitter, same attributes, same code path for both sandbox classes.atelet still has a role if we want the aggregation rollup to happen off the worker, but the read has to originate in ateom.
krisztianfekete commentedon Jul 28, 2026
Ah right, that was a typo from my side! Btw.
ateamis not wired in to OTel path yet, was planning to do that here in the split chunks (#433 (comment)). Happy to do item 5) now so you're unblocked.git286 commentedon Jul 28, 2026
Thanks and that would be appreciated!
baizhenyu commentedon Jul 28, 2026
Full design proposal below, inheriting the signal split Krisztian F (@krisztianfekete) and I agreed on above:
actor/atespace/sessionon events only, template / sandbox class as the metric dimensions.One correction to the issue body first, because it changes the implementation.
#496gave us the cgroup leaves, but they are emptyThe issue lists #288 as the stated blocker and #496 as having delivered per-actor leaves. The directories exist, but they contain no processes and will always report zero.
ensureContainerCgroupsPath(cmd/ateom-gvisor/runsc.go:39-72) hands each container an absolute/<containerName>, and runsc creates it — butsetupCgroupForSubcontainerinstalls it with empty resources ("Use empty resources, just want the directory structure created"), andcontainer.go:133-135is explicit that CompatCgroup exists only so tools that expect container cgroups find them. Every application process runs inside the sentry, and the sentry is a single process inpause/.So the readable unit is the sandbox, which today equals the actor. Per-container breakdown for gVisor needs
runsc events/ContainerUsagefrom the sentry, which I have put in a later phase. Worth editing the issue body so this is not rediscovered in review.Architecture: read in ateom, emit from atelet
The read does have to originate in ateom, but I no longer think ateom should be the emitter.
Every ateom process registers as a distinct OTLP source, since
serverboot.newResourcestamps a per-processservice.instance.id. If ateom emitted the metrics, series count would scale with worker pods rather than nodes, and every worker restart would mint a fresh instance ID and therefore a fresh timeseries. atelet is a stable DaemonSet that already emits template-labeled metrics today (atelet.snapshot.size,cmd/atelet/main.go:277-311).A useful side effect: because ateom only serves a read, this work does not block on the ateom OTel wiring. Krisztian F (@krisztianfekete), your item 5 is still worth doing — ateom currently calls
InitTracingbut worker pods have noOTEL_EXPORTER_OTLP_ENDPOINT(workerpool_apply.go:38-44sets onlyPOD_UID, andapplyWorkerPoolPodTemplatehas no env path), so those spans are going tolocalhost:4317and being dropped. That is a live bug independent of this issue.What ateom reads
gVisor: from
pause/—memory.current,memory.peak,memory.stat:inactive_file,cpu.stat:usage_usec.memory.peakneeds kernel 5.19+.Micro-VM: the host cgroup is not usable. Guest RAM is a fixed allocation (default 2048 MiB via
kata.ParseConfig), so host memory stays roughly constant regardless of actor behaviour, andShared: truebacks it with a memfd, putting those pages on the file LRU — which makes the working-set calculation subtract idle guest RAM and understate the footprint. (This is already quietly skewingkubectl ate top workersfor micro-VM workers.) ateom should query the guest agent (StatsContainer) instead, which gives genuine per-container numbers.Interface
sourceexists so consumers do not compare cgroup readings against guest readings as though they were measured the same way.ateom-gvisorneeds to retain the identity fields (it is currently stateless between RPCs);ateom-microvmalready keeps them inrunning.When atelet reads
Polling alone does not handle the actor lifecycle, so the design does not rely on it. atelet already drives Run, Restore, and Checkpoint, so it reads at those boundaries in addition to the timer:
outcomerunningrunningcheckpointedoom_killedAn actor living less than one interval still contributes one histogram sample and its full CPU total. Restore is a new epoch — the sandbox is recreated so cumulative CPU restarts at zero, and atelet treats the decrease as a reset rather than emitting a negative delta.
Metrics
ate.actor.memory.usageByate.actor.cpu.usage{cpu}ate.actor.cpu.timesate.actor.memory.peakByoutcomeate.actor.cpu.time.totalsoutcomeSeries per node is namespaces × templates × 2, independent of how many actors are running.
ate.workerpool.nameis omitted because atelet does not know it today (see open questions).Krisztian F (@krisztianfekete) — agreed on the two histograms, with one adjustment: they should record one value per actor at teardown, not one per sample. A histogram fed from 60-second samples measures a distribution over sampling ticks, which weights long-lived actors more heavily and makes the percentiles unusable. Recorded once per actor lifetime, p99 is directly the number to put in the template's resource request.
The
outcomelabel on the histograms matters for the same reason. Actors killed at the container limit have a truncated peak that would drag the percentile down and cause under-provisioning; keeping them in a separate series lets you exclude them from sizing queries and count them separately as a health signal.Two other correctness constraints:
ate.actor.cpu.timeis incremented by per-actor deltas, never set from the raw cgroup value.usage_usecis monotonic within one cgroup, but the population of cgroups behind a template label churns constantly, so summing raw values is non-monotonic and breaksrate(). Because atelet is the stable emitter, churn only changes the increment size.Events
One per actor per read: the five identity fields,
sandbox_class,source, the four measurements, andoutcome. This label set already exists ininternal/actorlog, so the usage event is a direct sibling ofEmitLifecycleLogand can ship on the existing stdout path with no new dependencies. Once a logs bridge exists inserverboot(there is noLoggerProvidertoday, onlyInitTracingandInitMetrics), the same records become native OTLP LogRecords with shared Resource attributes and automatic trace correlation. The content does not change, only the transport.The periodic stream is what makes the events useful. A single teardown event says an actor peaked at 3 GB; the stream says when, which is what you need to correlate a spike against a request or a neighbouring actor.
OOM handling, and why periodic reads are mandatory
This is the first row of the issue's table and the case most likely to be missed by a teardown-only design.
There is no per-actor memory limit in Substrate today —
ActorTemplatehas noResourcesfield,WorkloadSpec.Containercarries none, andcmd/atelet/oci.go:270-289writes noLinux.Resources. The only binding limit isWorkerPool.spec.template.resources.limits.memoryon the ateom container, and post-#496 the sentry nests inside that scope.Kubernetes defaults
SingleProcessOOMKillto false on cgroup v2 (kubelet.go:781-782), somemory.oom.group=1applies and the kernel kills the entire subtree atomically: ateom daemon, sentry, every gofer. No teardown, no checkpoint, no surviving process in the worker to report anything. atelet is the only component that still holds the numbers, which is why the terminal event is emitted there.Reporting
memory.peakrather than onlymemory.currentis what makes those last-known values accurate, since the kernel maintains it as a high-water mark across the whole interval.Adjacent finding, probably its own issue: gVisor clamps
--total-memorytos.CgroupJSON.Cgroup.MemoryLimit()(sandbox.go:1333-1338), but that reads the pause cgroup, where atelet wrote no limit. The clamp never engages, so the sentry advertises roughly host RAM to the guest and the application allocates past the pod limit with no backpressure and no guest-side OOM. SettingLinux.Resources.Memoryon the pause container's spec would make the limit bind onpause/and kill only the sandbox, leaving ateom alive to report the death — but that needs aResourcesfield onActorTemplateand a carrier inWorkloadSpec.Phases
Open questions
ate.workerpool.nameas a label. Add it toRunWorkloadRequest, or omit it in v1? Including it multiplies series count by the number of pools.actorlogstdout path now, or wait for the logs bridge?Happy to split phases 0–3 into tracking issues if that is easier to review.
krisztianfekete commentedon Jul 28, 2026
This PR fixes that as well: #562, can you please review it?
krisztianfekete commentedon Jul 28, 2026
Agree with the histogram adjustments as well 👍
As per the open questions:
In-memory is probably fine for the first milestone? I'd not add Kubernetes client dependency right now. cc. Eitan Yarmush (@EItanya), wdyt?
I'd include it, it's already a low cardinality dimension we have, e.g.
ate.workerpool.workers{pool}. I'd rather have it early then add it later.Sampling should be a collector/pipeline concern, so let's keep the components as simple as possible from this point of view. E.g.
ObservableGauges are missing when idle anyway.I think actorlog on stdout is fine for now. The content should be identical and it's easy to onboard to the bridge once it's available.
27 remaining items
atelet: poll ateom workload stats into template-level metrics
eliranw commentedon Aug 17, 2026
Working on per-container memory limits in #859 for micro-VMs.
A container that exceeds its limit is OOM-killed inside the guest, but nothing surfaces that above the guest: the actor stays STATUS_RUNNING and no signal records which container died or why.
The guest cgroup’s memory.events (oom, oom_kill) is the natural source. Flagging it here since it seems to belong with the per-container telemetry this issue proposes.
atelet: poll ateom workload stats into template-level metrics
atelet: poll ateom workload stats into template-level metrics
atelet: poll ateom workload stats into template-level metrics (#961)
Per-container CPU and memory limits for micro-VM actors (#859)
atelet: emit per-actor usage events, bracketed by lifecycle samples
atelet: validate guest-influenced stats samples by saturating aggrega…
atelet: cache pool resolutions so a list flap cannot split the CPU co…
atelet: emit per-actor usage events from the stats sweep (#1206)
atelet: cache pool resolutions so a list flap cannot split the CPU co…
atelet: cache pool resolutions so a list flap cannot split the CPU co…
atelet: cache pool resolutions so a list flap cannot split the CPU co…