Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cmd/atelet/deviceplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ import (
// 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.

// cloud-hypervisor needs /dev/kvm (VmCreate fails with EPERM without it), and
// worker pods request the matching extended resource, so they only schedule to
// nodes where the device exists. Device presence is therefore the earliest
// reliable eligibility signal — known at atelet startup, before any WorkerPool
// schedules here.
//
// TODO(https://github.com/agent-substrate/substrate/pull/1207): /dev/kvm is
// not the only micro-VM hypervisor device; once /dev/mshv support lands, an
// mshv-only node would be wrongly reported incapable here. Treat presence of
// any micro-VM hypervisor device in SandboxDevices as capability, not KVM
// alone.
func microvmNodeCapable(devRoot string) bool {
for _, d := range deviceplugin.SandboxDevices {
if d.ResourceName == deviceplugin.ResourceKVM {
return d.Present(devRoot)
}
}
return false
}

// startDevicePlugins advertises the sandbox host devices present on this node to
// kubelet as extended resources, in the background for the lifetime of ctx. This
// is what lets a worker be granted /dev/kvm without running privileged. atelet
Expand Down
18 changes: 18 additions & 0 deletions cmd/atelet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,24 @@ func main() {
csiDriverConfigLister,
clusterTrustBundleLister,
)
// 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.
//
// The informer is requested only now, after the factory's blocking
// WaitForCacheSync above, so it cannot hold up atelet startup when its
// list/watch fails (e.g. Forbidden while the ClusterRole rollout lags the
// binary): the reflector retries in the background and prewarm stays cold
// until it recovers.
sandboxConfigInformer := ateFactory.Api().V1alpha1().SandboxConfigs().Informer()
if err := startSandboxAssetPrewarm(ctx, sandboxConfigInformer, wmService, imageCache, microvmNodeCapable(hostDevRoot)); err != nil {
slog.ErrorContext(ctx, "Sandbox asset prewarm disabled", slog.Any("err", err))
}
// The factory only runs informers that exist when Start is called: the
// Start above predates the SandboxConfigs informer, so without this call
// it would never list or watch. Start is idempotent per informer — this
// launches the new one and leaves the already-running ones untouched.
ateFactory.Start(stopCh)
dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{
K8sClient: k8sClient,
CAFile: *ateapiCAFile,
Expand Down
311 changes: 311 additions & 0 deletions cmd/atelet/sandbox_prewarm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"runtime"
"sync"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"

"github.com/agent-substrate/substrate/internal/imagecache"
"github.com/agent-substrate/substrate/pkg/api/v1alpha1"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
)

// prewarmMaxJitter spreads the fleet's asset downloads after a SandboxConfig
// change. Every atelet observes a create/update within about a second, and
// without jitter they would all open the same bucket objects at once. The
// jitter is applied as the enqueue delay, so duplicate events arriving inside
// the window collapse into one queue entry instead of stacking waits in the
// worker. A var so tests can zero it.
var prewarmMaxJitter = 30 * time.Second

// prewarmTimeout bounds a single prewarm attempt. The queue is drained by one
// worker, so without a deadline a download that hangs without failing (a
// registry that accepts the connection and stalls, a wedged bucket read)
// would block every other config forever — prewarm runs on the daemon's
// never-cancelled context. Generous enough for multi-hundred-MiB micro-VM
// guest images on a busy node; a timed-out attempt requeues with backoff like
// any other failure. A var so tests can shorten it.
var prewarmTimeout = 5 * time.Minute

// prewarmMaxRetries bounds how often one config's failed prewarm is retried
// before it is dropped until the next config event (or first use, which stays
// the correctness path). The backoff below caps at 5 minutes, so this covers
// transient bucket or registry outages of several minutes without hammering a
// permanently broken config forever.
const prewarmMaxRetries = 8

// sandboxAssetFetcher is the one slice of AteomHerder the prewarmer needs.
// Prewarming through the same method as the Run/Restore path keeps the two
// fetches from ever diverging on cache layout or validation.
type sandboxAssetFetcher interface {
ensureSandboxAssets(ctx context.Context, rec *sandboxAssetsRecord) (map[string]string, error)
}

// sandboxPrewarmer downloads SandboxConfig assets into the node's
// content-addressed static-files cache before any actor asks for them, so the
// 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 {
assets sandboxAssetFetcher
images *imagecache.Store
// lister resolves a queued config name to its latest revision at
// processing time, so coalesced events never prewarm a stale spec.
lister listersv1alpha1.SandboxConfigLister
// queue decouples informer event handlers (which must not block) from the
// downloads, dedupes by config name so relists cannot stack duplicate
// work, and rate-limits retries after failures. A single worker drains
// it, which also serializes downloads so concurrent prewarms never
// compete for node bandwidth.
queue workqueue.TypedRateLimitingInterface[string]
// microvmCapable gates micro-VM configs: their guest images run to
// hundreds of MiB, and a node without /dev/kvm can never run that class
// (workers request the ate.dev/kvm extended resource, so they only
// schedule where the device exists). See microvmNodeCapable.
microvmCapable bool
}

func newSandboxPrewarmer(assets sandboxAssetFetcher, images *imagecache.Store, lister listersv1alpha1.SandboxConfigLister, microvmCapable bool) *sandboxPrewarmer {
return &sandboxPrewarmer{
assets: assets,
images: images,
lister: lister,
// Downloads fail on the scale of network timeouts, not API conflicts,
// so back off in seconds and cap in minutes rather than the
// millisecond-based controller default.
queue: workqueue.NewTypedRateLimitingQueue(
workqueue.NewTypedItemExponentialFailureRateLimiter[string](time.Second, 5*time.Minute)),
microvmCapable: microvmCapable,
}
}

// startSandboxAssetPrewarm registers an event handler on the SandboxConfig
// informer and starts a background worker that pre-downloads each config's
// sandbox assets for this node's architecture, and pulls its pause image into
// the image cache. Prewarming is purely a latency optimization: every failure
// is logged and left to the on-demand fetch in ensureSandboxAssets and the
// pull inside prepareOCIBundles, which remain the correctness path.
//
// TODO: the static-files cache is never pruned, and prewarming every config
// revision makes stale releases accumulate faster. Add a GC that removes
// assets referenced by no current SandboxConfig and no on-node actor record.
func startSandboxAssetPrewarm(ctx context.Context, informer cache.SharedIndexInformer, assets sandboxAssetFetcher, images *imagecache.Store, microvmCapable bool) error {
p := newSandboxPrewarmer(assets, images, listersv1alpha1.NewSandboxConfigLister(informer.GetIndexer()), microvmCapable)
// Atelet startup never waits for this informer to sync: prewarm is
// best-effort, so a failing list/watch (e.g. Forbidden while an RBAC
// rollout lags the binary) must degrade prewarm, not hang the node. The
// handler makes that degradation visible in the log; the reflector keeps
// retrying and prewarm recovers with it. Setting it fails only on an
// already-started informer, where the default reflector logging applies.
if err := informer.SetWatchErrorHandler(func(_ *cache.Reflector, err error) {
slog.WarnContext(ctx, "SandboxConfig list/watch failed; sandbox asset prewarm degraded until it recovers", slog.Any("err", err))
}); err != nil {
slog.InfoContext(ctx, "Could not set sandbox config watch error handler", slog.Any("err", err))
}
// The initial List replays every existing SandboxConfig into the handler
// as an Add: a freshly booted node prewarms the current configs, not only
// future changes.
if _, err := informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj any) { p.enqueue(ctx, obj) },
UpdateFunc: func(_, obj any) { p.enqueue(ctx, obj) },
}); err != nil {
return fmt.Errorf("while registering sandbox config prewarm handler: %w", err)
}
go p.run(ctx)
slog.InfoContext(ctx, "Sandbox asset prewarm started", slog.Bool("microvmCapable", microvmCapable))
return nil
}

// skipConfig reports whether this node has nothing to prewarm for cfg,
// logging why. Both enqueue and process apply it: sandboxClass is mutable,
// so the class observed at enqueue time can be stale by the time the worker
// resolves the name after jitter or backoff, and the gate must hold for the
// revision actually prewarmed.
func (p *sandboxPrewarmer) skipConfig(ctx context.Context, cfg *v1alpha1.SandboxConfig) bool {
switch cfg.Spec.SandboxClass {
case v1alpha1.SandboxClassGvisor:
// Every node runs gVisor workers; always prewarm.
return false
case v1alpha1.SandboxClassMicroVM:
if !p.microvmCapable {
slog.DebugContext(ctx, "Skipping sandbox asset prewarm: node has no /dev/kvm, cannot run micro-VM workers",
slog.String("config", cfg.Name))
return true
}
return false
default:
// An unknown class has no backend in this atelet (likely version skew
// with a newer control plane); nothing to prewarm.
slog.InfoContext(ctx, "Skipping sandbox asset prewarm: unknown sandbox class",
slog.String("config", cfg.Name),
slog.String("sandboxClass", string(cfg.Spec.SandboxClass)))
return true
}
}

func (p *sandboxPrewarmer) enqueue(ctx context.Context, obj any) {
cfg, ok := obj.(*v1alpha1.SandboxConfig)
if !ok {
return
}
if p.skipConfig(ctx, cfg) {
return
}
if prewarmMaxJitter > 0 {
p.queue.AddAfter(cfg.Name, rand.N(prewarmMaxJitter))
return
}
p.queue.Add(cfg.Name)
}

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.

go func() {
<-ctx.Done()
p.queue.ShutDown()
}()
for {
name, shutdown := p.queue.Get()
if shutdown {
return
}
p.process(ctx, name)
}
}

// process prewarms the named config's current revision, requeueing with
// backoff on failure. The queue holds names, not objects, so an event that
// arrives while its config is being processed is simply requeued by the
// workqueue and prewarms the newer revision afterwards.
func (p *sandboxPrewarmer) process(ctx context.Context, name string) {
defer p.queue.Done(name)
cfg, err := p.lister.Get(name)
if apierrors.IsNotFound(err) {
// Deleted since it was enqueued; nothing to prewarm anymore.
p.queue.Forget(name)
return
}
if err == nil && p.skipConfig(ctx, cfg) {
// The class gate re-applies to the revision resolved now, which may
// differ from the one that passed enqueue's filter.
p.queue.Forget(name)
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).

}
if err == nil {
p.queue.Forget(name)
return
}
if ctx.Err() != nil {
// Shutting down, not a prewarm failure; drop without retry noise.
return
}
if retries := p.queue.NumRequeues(name); retries < prewarmMaxRetries {
slog.WarnContext(ctx, "Sandbox asset prewarm failed; will retry",
slog.String("config", name), slog.Int("retries", retries), slog.Any("err", err))
p.queue.AddRateLimited(name)
return
}
// Best-effort: give up until the next config event or first use.
slog.WarnContext(ctx, "Sandbox asset prewarm failed; giving up",
slog.String("config", name), slog.Int("retries", prewarmMaxRetries), slog.Any("err", err))
p.queue.Forget(name)
}

// prewarm fetches every asset of one SandboxConfig into the static-files
// cache and its pause image into the image cache. Racing an on-demand
// ensureSandboxAssets for the same assets is safe: both paths install
// 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 {
ctx, cancel := context.WithTimeout(ctx, prewarmTimeout)
defer cancel()
t := time.Now()

var imageErr error
var wg sync.WaitGroup
// 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.

imageErr = fmt.Errorf("while prewarming pause image %q: %w", cfg.Spec.PauseImage, err)
}
})
}
// schedule prewarm sandbox assets if provided
rec, assetErr := recordFromSandboxConfig(cfg)
switch {
case errors.Is(assetErr, errNoAssetsForArch):
// Permanent until the config changes, and a change re-enqueues:
// retrying would burn every attempt on a non-failure and redo the
// pause-image pull with it. Nothing to do for this node.
slog.DebugContext(ctx, "No sandbox assets to prewarm for this node's architecture",
slog.String("config", cfg.Name), slog.String("arch", runtime.GOARCH))
assetErr = nil
case assetErr == nil:
wg.Go(func() {
_, assetErr = p.assets.ensureSandboxAssets(ctx, rec)
})
}
wg.Wait()
if err := errors.Join(assetErr, imageErr); err != nil {
return err
}
assets := 0
if rec != nil {
assets = len(rec.Assets)
}
slog.InfoContext(ctx, "Sandbox assets prewarmed",
slog.String("config", cfg.Name),
slog.Int("assets", assets),
slog.String("pauseImage", cfg.Spec.PauseImage),
slog.Duration("duration", time.Since(t)))
return nil
}

// errNoAssetsForArch reports that a SandboxConfig lists no assets for this
// node's architecture. Unlike a failed download it is permanent until the
// config changes, so prewarm treats it as nothing-to-do rather than retrying.
var errNoAssetsForArch = errors.New("no sandbox assets for this architecture")

// recordFromSandboxConfig projects a SandboxConfig's per-architecture assets
// onto the local node's architecture, mirroring recordFromRequest.
func recordFromSandboxConfig(cfg *v1alpha1.SandboxConfig) (*sandboxAssetsRecord, error) {
arch := runtime.GOARCH
files := cfg.Spec.Assets[arch]
if len(files) == 0 {
return nil, fmt.Errorf("sandbox config %q, architecture %q: %w", cfg.Name, arch, errNoAssetsForArch)
}
rec := &sandboxAssetsRecord{
SandboxClass: string(cfg.Spec.SandboxClass),
PauseImage: cfg.Spec.PauseImage,
Assets: make(map[string]assetEntry, len(files)),
}
for name, f := range files {
rec.Assets[name] = assetEntry{URL: f.URL, SHA256: f.SHA256}
}
return rec, nil
}
Loading
Loading