Skip to content

atelet: pre-download sandbox assets from SandboxConfigs - #1358

Merged
Benjamin Elder (BenTheElder) merged 10 commits into
agent-substrate:mainfrom
dberkov:atelet-sandbox-asset-prewarm
Sep 3, 2026
Merged

atelet: pre-download sandbox assets from SandboxConfigs#1358
Benjamin Elder (BenTheElder) merged 10 commits into
agent-substrate:mainfrom
dberkov:atelet-sandbox-asset-prewarm

Conversation

@dberkov

@dberkov Dmitry Berkovich (dberkov) commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

atelet now watches SandboxConfig objects and pre-downloads their sandbox assets into the node's content-addressed cache in the background, instead of only fetching them inside the first Run/Restore on the node.

Related to #811 — this does not fix it, it is one more optimization step toward it. It removes the cold-cache download+extract from the first Run/Restore in the common case (node boot, or a release bump while the node is idle), but the window between a SandboxConfig change and prewarm completion still exists, and the extraction itself remains on the critical path when a resume lands inside that window. The uncancellable/silent extraction called out in #811 is unchanged by this PR.

How it works

  • A handler on the existing shared informer factory enqueues SandboxConfig add/update events; registration happens after cache sync, so a freshly booted node replays every existing config as a synthetic Add and prewarms immediately.
  • A single background worker drains the queue, so concurrent prewarms never compete for node bandwidth. Downloads are jittered by up to 30s so a config rollout doesn't hit the bucket from every node in the fleet at once.
  • gVisor configs prewarm on every node. Micro-VM configs prewarm only where /dev/kvm exists (microvmNodeCapable, the same signal the device plugin advertises as ate.dev/kvm) — known at atelet startup, before any WorkerPool schedules to the node, and avoids pulling multi-hundred-MiB guest images onto nodes that can never run them.
  • Prewarm is purely best-effort: failures are logged and the on-demand fetch in ensureSandboxAssets remains the correctness path. Racing the two is safe because both install content-addressed files via atomic rename.
  • The atelet ClusterRole gains get/list/watch on sandboxconfigs.

Left as TODOs in the code: retry-with-backoff on prewarm failure, and GC of cached assets no longer referenced by any SandboxConfig or on-node actor record.

Pause image prewarm (cherry-picked)

This PR also carries atelet: prewarm the pause image alongside sandbox assets (cherry-picked from 2c7dd31). The prewarmer downloaded the runtime binaries but not the SandboxConfig's pause image, so every node still pulled it from the registry inside its first Run/Restore. At benchmark start that is a synchronized fleet-wide pull burst — registry.k8s.io answered a 1000-node run with 429s, failing Restores — and a failed pull writes no cache record, so each subsequent Restore re-pinged the registry and fed the rate limiter. The prewarm worker now pulls the pause image into the image cache concurrently with the asset downloads (different backends, so neither fetch waits on or fails the other), inheriting the existing per-config jitter. It remains best-effort: the pull inside prepareOCIBundles is still the correctness path.

Testing

  • Unit tests cover the arch projection (recordFromSandboxConfig), the enqueue filtering (KVM gating, unknown classes, full-queue drop), and microvmNodeCapable negative cases.
  • An end-to-end test drives a SandboxConfig from a fake clientset through the real informer into the prewarm worker and verifies the asset lands in the static-files cache.
  • The pause-image commit adds TestPrewarmPauseImage, pulling from a local test registry to verify the image lands in the image cache and that an asset-fetch failure does not stop the pull.
  • go vet and the full cmd/atelet test package pass.

🤖 Generated with Claude Code

Sandbox assets were fetched inside the first Run/Restore on a node,
putting the gVisor release download+extract on that actor's critical
path. atelet now watches SandboxConfig objects and pre-downloads their
assets for the node's architecture in the background: gvisor configs on
every node, microvm configs only where /dev/kvm exists (the same signal
the device plugin advertises, and known at startup before any WorkerPool
schedules here). Downloads are jittered by up to 30s so a config rollout
does not hit the bucket from every node at once.

Prewarming is best-effort and shares the content-addressed cache with
the on-demand fetch, which remains the correctness path; racing the two
is safe because both install files via atomic rename. Retry-with-backoff
on prewarm failure and GC of unreferenced cached assets are left as
TODOs. The atelet ClusterRole gains get/list/watch on sandboxconfigs.
@BenTheElder

Copy link
Copy Markdown
Collaborator

gVisor configs prewarm on every node. Micro-VM configs prewarm only where /dev/kvm exists (microvmNodeCapable, the same signal the device plugin advertises as ate.dev/kvm) — known at atelet startup, before any WorkerPool schedules to the node, and avoids pulling multi-hundred-MiB guest images onto nodes that can never run them.

Taking a note ... this is yet another place that hardcodes knowledge of the two classes :(

I think this factoring is going to prove badly later.

Also ... right now we run atelet on all nodes. So even nodes that never schedule an actor will start downloading these ..?

@dberkov

Copy link
Copy Markdown
Collaborator Author

Taking a note ... this is yet another place that hardcodes knowledge of the two classes :(
I think this factoring is going to prove badly later.
Also ... right now we run atelet on all nodes. So even nodes that never schedule an actor will start downloading these ..?

We need run atelet only on the nodes where we are planning to run workloads. I did not want to add dependency on the worker, since I wanted to download images ASAP.

google/gvisor#14528 been merged today, so tomorrow we will update sandboxConfig parameters to use zstd images, so the first run will go down to 2sec from 23 sec. In addition to it, based on #811 (comment), the size of gVisor image will be shrink even more, so might be all the pre-warming for gVisor will not be required. On the other hand, pre-warming non gVisor images might be still usefull, since they are large.

@BenTheElder

Copy link
Copy Markdown
Collaborator

On the other hand, pre-warming non gVisor images might be still usefull, since they are large.

Yeah, I'm just a little worried about space usage.

I have some ideas about how we can get that down, but we will need somewhere to host those scripts and pre-built artifacts.

The sandbox asset prewarmer downloaded the runtime binaries but not the
SandboxConfig's pause image, so every node pulled it from the registry
inside its first Run/Restore. At benchmark start that is a synchronized
fleet-wide pull burst — registry.k8s.io answered a 1000-node run with
429s, failing Restores — and a failed pull writes no cache record, so
each subsequent Restore on the node re-pinged the registry and fed the
rate limiter.

The prewarm worker now pulls the pause image into the image cache
concurrently with the asset downloads (they live in different backends,
so neither fetch waits on or fails the other), inheriting the existing
per-config jitter that spreads the fleet's pulls after a config rollout.
Prewarming remains best-effort: the pull inside prepareOCIBundles is
still the correctness path.
// exist; workers are handed the real host paths, which kubelet resolves.
const hostDevRoot = "/host/dev"

// microvmNodeCapable reports whether this node can host micro-VM workers:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: #1207

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a TODO on microvmNodeCapable in b4d312b: once /dev/mshv lands, capability should mean presence of any micro-VM hypervisor device in SandboxDevices, not KVM alone.

Comment thread cmd/atelet/main.go Outdated
// Pre-download sandbox assets as SandboxConfigs appear/change so the first
// Run/Restore on this node hits the cache. Best-effort: on failure the
// on-demand fetch in ensureSandboxAssets still covers correctness.
if err := startSandboxAssetPrewarm(ctx, sandboxConfigInformer, wmService, microvmNodeCapable(hostDevRoot)); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting the SandboxConfigs informer on ateFactory before Start makes WaitForCacheSync block forever if list/watch is Forbidden (RBAC skew during rollout).

This goes from a best effort optimization to hanging the node ...?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d7654c: the SandboxConfigs informer is now requested only after the factory's blocking WaitForCacheSync, and started with a second factory Start call (which launches only informers added since the first). A Forbidden list/watch during RBAC skew now just leaves prewarm cold while the reflector retries in the background — atelet startup no longer waits on it. A watch error handler logs the degradation explicitly.

Comment thread cmd/atelet/sandbox_prewarm.go Outdated
if rec.PauseImage == "" {
return
}
if _, err := p.herder.imageCache.EnsureImage(ctx, rec.PauseImage); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is running on main's ctxt.Background() which is never cancelled that seems bad? we should probably put some kind of timeout on this

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ec2d95: prewarm now derives a 5-minute timeout context per attempt, so a download that stalls without failing can't wedge the worker. A timed-out attempt goes through the same requeue-with-backoff path as any failure. Added a test with a hanging bucket read verifying the attempt returns.

Comment thread cmd/atelet/sandbox_prewarm.go Outdated
// content-addressed files via atomic rename, and the image cache collapses
// concurrent pulls of one digest.
func (p *sandboxPrewarmer) prewarm(ctx context.Context, cfg *v1alpha1.SandboxConfig) error {
rec, err := recordFromSandboxConfig(cfg)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this fails, we still fail to warm pause, which doesn't need the arch => config lookup

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ec2d95: the pause image pull no longer depends on recordFromSandboxConfig — it's scheduled first and runs even when the config has no assets for the node's architecture. The projection error is still returned so the failure stays visible. Test added for the no-assets-for-arch case.

}
}

func (p *sandboxPrewarmer) run(ctx context.Context) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we wind up doing this in a single worker with no concurrency or timeouts, so one failed config will hang the rest

imagine one config has a bad pause image reference, we'll never be able to pull it. and it will hang forever

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ec2d95 (plus the workqueue in 224f35a): each attempt now runs under a 5-minute timeout, so a config with an unpullable pause image costs the worker at most 5 minutes before it requeues with backoff and moves on. Other configs process between its retries, and after 8 failed attempts it's dropped until the next config event. Kept the single worker deliberately so prewarms don't compete for node bandwidth — with the timeout it's no longer a liveness risk.

Comment thread cmd/atelet/sandbox_prewarm.go Outdated
select {
case <-ctx.Done():
return
case <-time.After(rand.N(prewarmMaxJitter)):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't dedupe configs, so a relist can cause this to stack additively?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 224f35a: the buffered channel is replaced with a rate-limited workqueue keyed by config name, so relists and event bursts coalesce into one entry. The worker resolves the name through the informer's lister at processing time, so it always prewarms the latest revision. This also brought retry-with-backoff (1s–5min, 8 attempts) for free, closing the retry TODO.

Comment thread cmd/atelet/sandbox_prewarm.go Outdated
// fetch inside the first Run/Restore on the node is a cache hit instead of a
// download+extract on the critical path.
type sandboxPrewarmer struct {
herder *AteomHerder

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like kind of a weird touchpoint. we don't need most of it, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f7e3392: the prewarmer now takes a one-method sandboxAssetFetcher interface (still implemented by AteomHerder, so prewarm and the on-demand fetch can't diverge on cache layout) plus the image cache store directly, instead of the whole herder.

A relist or event burst used to stack duplicate SandboxConfigs in the
buffered channel, each copy costing its own jitter wait and fetch pass.
Queue config names in a rate-limited workqueue instead: duplicate events
coalesce into one entry, the worker resolves the name to the config's
latest revision through the informer's lister at processing time, and a
failed prewarm retries with download-scale backoff (1s to 5min, 8
attempts) instead of staying cold until the next config event.

The per-config jitter moves from a sleep inside the worker to the
enqueue delay, so one config's jitter window no longer serializes
unrelated configs, and duplicates arriving inside the window collapse
too. The queue-full drop path is gone: the workqueue is unbounded but
deduped, so a handful of cluster-scoped configs can never grow it.
A prewarm attempt ran on the daemon's never-cancelled context, so a
download that hung without failing (a registry that accepts the
connection and stalls, a wedged bucket read) would block the single
prewarm worker — and with it every other queued config — forever. Each
attempt now runs under a 5-minute timeout; a timed-out attempt requeues
with backoff like any other failure, and other configs process between
its retries.

The pause image pull no longer depends on the per-architecture asset
projection: it needs no arch lookup, so it is scheduled first and runs
even when the config has no assets for the node's architecture. The
projection error is still returned so the failure stays visible.
The prewarm informer was requested on the shared factory before its
blocking WaitForCacheSync, so a failing list/watch — most plausibly
Forbidden while the ClusterRole rollout lags the binary — would hang
atelet startup forever, turning a best-effort optimization into a node
outage.

Request the informer only after WaitForCacheSync returns and start it
with a second factory Start call, which launches only informers added
since the first. A failing list/watch now degrades prewarm instead of
blocking the node: the reflector retries in the background and prewarm
recovers with it. A watch error handler names the degradation in the
log instead of leaving only generic reflector noise.

The prewarm handler is now registered before its informer starts; the
initial List still replays every existing SandboxConfig into it as an
Add, so a freshly booted node prewarms current configs as before.
The prewarmer took the whole AteomHerder while calling exactly two
things on it: the asset fetch and the pause image pull. It now takes a
one-method sandboxAssetFetcher interface — still implemented by
AteomHerder, so prewarm and the on-demand Run/Restore fetch cannot
diverge on cache layout — and the image cache store directly, which
main already owns. The prewarmer's real dependencies are visible at
the call site instead of implied by a service-wide handle.
microvmNodeCapable equates micro-VM capability with /dev/kvm presence.
With /dev/mshv support proposed for AKS nodes, an mshv-only node would
be wrongly reported incapable and skip prewarming micro-VM assets.
Leave a TODO to treat presence of any micro-VM hypervisor device in
SandboxDevices as capability once a second device exists.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

three claude findings, given the time constraints, I think these are valid

// schedule prewarm pause image if provided
if cfg.Spec.PauseImage != "" {
wg.Go(func() {
if _, err := p.images.EnsureImage(ctx, cfg.Spec.PauseImage); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 blocking 🔴 – Prewarm can fail a serving Restore.

EnsureImage collapses concurrent pulls of one digest, and the winning call's context governs the pull for every waiter (internal/imagecache/imagecache.go:368). Until now every caller was an RPC bounded by its own client. A prewarm that wins the slot imposes prewarmTimeout on a Restore waiting on the same digest, and its context is the daemon context, which cancels at SIGTERM while the gRPC server still drains for 5 minutes.

Fix in imagecache: run the singleflight body on a context detached from whichever caller won, bounded by its own pull timeout, so waiters only ever see real pull failures. The comment at imagecache.go:368 states the old assumption and needs updating either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 288edfb: the singleflight body now runs on a context detached from whichever caller starts the flight (context.WithoutCancel, so trace/log values survive), bounded by a store-level pull timeout (default 10m, WithPullTimeout to override). A waiter only ever sees a real pull failure — never another caller's cancellation, deadline, or the daemon context ending at SIGTERM. EnsureImage switched to DoChan, so each caller also stops waiting when its own context ends while the pull runs on to warm the cache for the next attempt (the prewarm worker therefore stays bounded by prewarmTimeout even when the underlying pull runs longer). The stale comment is rewritten to state the new contract, and tests cover both halves: a cancelled caller doesn't kill the pull for a later joiner, and a wedged detached pull is reclaimed by the pull timeout.

return
}
if err == nil {
err = p.prewarm(ctx, cfg)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – The class gate is enforced against a stale object.

enqueue filters on SandboxClass but queues a name. process re-resolves the current revision and prewarms whatever class it holds by then. sandboxClass is mutable, so a gvisor→microvm edit inside the jitter or backoff window makes a node without /dev/kvm download the micro-VM guest assets the gate exists to avoid.

Factor the switch in enqueue into a skipConfig(ctx, cfg) bool and call it here too, after the lister Get.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d785c6b: the switch is factored into skipConfig, applied both in enqueue and in process after the lister Get, so the gate holds for the revision actually prewarmed. A test covers the gvisor→microvm edit landing between enqueue and processing on a non-KVM node (skipped and forgotten, not fetched).

Comment thread cmd/atelet/sandbox_prewarm.go Outdated
arch := runtime.GOARCH
files := cfg.Spec.Assets[arch]
if len(files) == 0 {
return nil, fmt.Errorf("sandbox config %q has no assets for architecture %q", cfg.Name, arch)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit 🟢 – A permanent error travelling the transient-retry path.

A config with no assets for this node's architecture will never grow them without a config update, and an update re-enqueues anyway. process cannot tell this apart from a failed download, so every node of a non-listed architecture burns prewarmMaxRetries rate-limited attempts with a warning each. Every attempt also redoes the pause-image pull that already succeeded, because prewarm joins both errors into one retry decision.

Return a sentinel error here so process can log at debug and Forget the config immediately.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 02616d5: recordFromSandboxConfig now wraps a sentinel errNoAssetsForArch, and prewarm treats it as nothing-to-do — logged at debug, pause image still pulled, config Forgetten immediately. One deviation from the suggestion: the sentinel is handled inside prewarm before the error join rather than in process, because errors.Is on the joined error would also match when only the pause pull failed transiently, silently dropping its retry.

… caller

EnsureImage collapses concurrent pulls of one digest, and the winning
call's context used to govern the pull for every waiter. Callers with
very different lifetimes share these flights -- a serving Restore, a
best-effort prewarm bounded by its own deadline, a caller whose daemon
is draining after SIGTERM -- so a prewarm that won the slot could fail
a Restore with its cancellation or timeout.

The singleflight body now runs on a context that keeps the initiating
caller's values but none of its cancellation, bounded by a store-level
pull timeout (default 10m, WithPullTimeout to override). A waiter only
ever sees a real pull failure; each caller stops waiting when its own
context ends, while the pull runs on to warm the cache for the next
attempt.
enqueue filters on SandboxClass but queues only the config name, and
process re-resolves the current revision at processing time.
sandboxClass is mutable, so a gvisor-to-microvm edit inside the jitter
or backoff window made a node without /dev/kvm download the micro-VM
guest assets the gate exists to avoid. The gate now lives in
skipConfig, applied both at enqueue and after the lister Get in
process.
…e arch

A config with no assets for this node's architecture will not grow
them without a config update, and an update re-enqueues anyway.
process could not tell this apart from a failed download, so every
node of a non-listed architecture burned all eight rate-limited
attempts with a warning each -- and every attempt redid the pause-image
pull that had already succeeded, because prewarm joins both errors
into one retry decision.

recordFromSandboxConfig now wraps a sentinel error, and prewarm treats
it as nothing-to-do: logged at debug, pause image still pulled, and
the worker Forgets the config immediately. The sentinel is handled
before the error join rather than in process, because errors.Is on the
joined error would also match when only the pause pull failed
transiently, silently dropping its retry.
@BenTheElder
Benjamin Elder (BenTheElder) merged commit a9c1bd3 into agent-substrate:main Sep 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants