Modal’s “1M concurrent sandboxes”
post argues Kubernetes cannot reach that scale: scheduling is O(n×p) and
serialized, every Pod causes multiple etcd writes, etcd is not shardable within a
keyspace, and kubelet heartbeats impose an O(nodes) write floor. Their answer is
to leave Kubernetes entirely — a fleet of stateless schedulers over in-memory
worker state published to a Redis stream, direct scheduler→worker RPC, and no
datastore on the sandbox-creation critical path.
Their diagnosis is correct. Their conclusion is not the only option. What Modal actually removed is a centralized transaction path, not API semantics — and Kubernetes already separates those two things in its own design: kubelet static Pods (the node acts first, the apiserver records after), the coordination.k8s.io Lease (a dedicated tiny object for heartbeats instead of full Node writes), and the metrics.k8s.io aggregation layer (a virtual resource served by scatter-gathering live node state, with zero etcd storage). Our thesis:
Keep Kubernetes as the record-of-intent and policy plane; push the transaction plane down to the node — behind CRDs, RBAC, and watch, so
kubectl get sandboxesnever stops working.
We stage this as four layers. L0 and L1 are shipped. L2 has a concrete,
benchmarked gateway and orphan reconciler, but still needs deployment hardening
before it is a supported mode. L3 is implemented by the aggregated-apiserver
binary, manifests, scatter-gather store, and NodeInventory publisher. Its
remaining routing follow-up is called out below.
flowchart LR
subgraph L0["L0 — API hygiene (shipped)"]
L0a["cache-fed reads<br/>diff-before-write<br/>LIST off etcd"]
end
subgraph L1["L1 — ownership transfer (this repo)"]
L1a["claim = single PATCH<br/>O(nodes) pool status<br/>per-pool sharded operator"]
end
subgraph L2["L2 — node-local claim gateway core"]
L2a["gateway → sandboxd<br/>sub-ms delivery<br/>async Bound record"]
end
subgraph L3["L3 — aggregated apiserver (implemented)"]
L3a["scatter-gather node inventory<br/>etcd stores intent only<br/>O(sandboxes)→O(pools+nodes)"]
end
L0 --> L1 --> L2 --> L3
The prerequisite, delivered in the vk-cocoon provider (2026-07-17): every
periodic read is served from a node-scoped informer cache, every write is
diffed first, and no control-loop LIST hits etcd (list at ResourceVersion=0,
or a field-selected node-local lister). This is the qualifier — without it any
scale test wedges the apiserver first. Measured on an idle virtual-kubelet node
afterward: 0.2 req/s, zero LIST (lease renew + node-status patch only). The
root cause it fixed: Kubernetes APF prices LIST seats by the total object
count of the resource, so at 2500 pods even a tiny per-node list goes
max-width and saturates a dedicated priority level — client QPS caps cannot fix
a seat-seconds problem, only removing the lists can.
A warm claim is not a create. The Pod is already scheduled, bound, image-pulled,
and booted; a SandboxClaim only needs to transfer ownership of one
pre-warmed Sandbox — the exact semantics Kubernetes already ships for
PersistentVolumeClaim → PersistentVolume binding (Phase: Bound). Nothing on
the claim path needs the scheduler, kubelet bind, or image pull.
Mechanisms
getCandidate pops one
warm ∧ unclaimed Sandbox from the in-memory queue (node-spread pick); the
claim records the adoption with an Update on the SandboxClaim and binds
the Sandbox with a merge Patch. A loser that raced the same Sandbox moves
to the next candidate rather than requeueing. The CRD path is two apiserver
writes per claim; the sub-millisecond figures below come from the node-local
gateway (L2), not from this path.readyReplicas is
recomputed each reconcile from the pool’s Sandboxes read through the indexed
cache without deep copies — an in-memory scan, never a LIST against the
apiserver.coordination.k8s.io leader lease. Pools are independent workqueue
keys, not shards spread across replicas; per-pool sharding is not
implemented.Kubernetes-semantics mapping
| Modal mechanism | L1 in pure Kubernetes |
|---|---|
| stateless scheduler fleet | per-pool sharded operator + Lease |
| worker accepts/rejects placement | optimistic PATCH with resourceVersion precondition |
| no datastore on create path | claim = ownership PATCH of a pre-warmed object (like PVC→PV Bound) |
| async result write | Sandbox status/conditions written after the fast-path returns |
Failure modes
| Scenario | Behavior | Breaks k8s semantics? |
|---|---|---|
| Two claims race one warm Sandbox | resourceVersion PATCH conflict; loser adopts the next candidate |
No — standard optimistic concurrency |
| Warm pool exhausted | Claim stays Pending until replenish (unchanged) |
No |
| Operator shard dies mid-claim | Lease expiry → another replica resumes; claim is idempotent | No |
| Stale informer picks an already-claimed Sandbox | PATCH precondition fails → next candidate | No |
Acceptance: claim p50 stays near-constant from a 100-pool to a 2000+-pool (measured 0.644 ms → 0.646 ms); the pod-exclusivity invariant (one Sandbox, at most one claim) holds under concurrent claims.
L1 still round-trips the apiserver. L2 takes the claim off the central path
entirely for the runtimes that have a node-local warm pool (sandboxd), while
keeping the SandboxClaim object as the durable record.
Mechanism. The implemented ClaimGateway is intended to run on each
virtual-kubelet node in front of sandboxd. A claim request reaches the node
gateway directly; sandboxd hands over an already-running microVM in
0.2–0.7 ms and returns connection info immediately. The SandboxClaim is
marked Bound asynchronously — the record follows the action, exactly as
kubelet static Pods record to the apiserver after the container is already
running. The repository contains the concrete gateway and orphan reconciler;
packaging it as a supported DaemonSet remains roadmap work.
Authorization stays central. The gateway runs a SubjectAccessReview
before delivery; only ownership transfer moves to the node.
// ClaimGateway is the node-local fast path for warm-pool claims.
// A claim is served by the node that already holds a warm microVM; the
// SandboxClaim object is reconciled to Bound asynchronously afterward.
type ClaimGateway interface {
// Claim transfers ownership of a node-local warm sandbox to the caller,
// returning connection info. It performs SubjectAccessReview inline;
// it does NOT block on writing the SandboxClaim.
Claim(ctx context.Context, req ClaimRequest) (Assignment, error)
// Release returns a sandbox to the node-local pool (or tears it down).
Release(ctx context.Context, assignment Assignment) error
}
Failure modes
| Scenario | Behavior | Breaks k8s semantics? |
|---|---|---|
Gateway crashes after delivery, before recording Bound |
Orphan binding → audit-only orphan GC + adopt reconciles the record (the VM is never destroyed on pod-level state — see the delete-authorization contract) | No — eventual consistency |
| Node has no warm VM | Returns the explicit fallback signal; a deployment must route that request through the L1 Kubernetes path | No |
Acceptance: claim p50 sub-millisecond on the sandboxd tier; orphan-binding
rate converges to 0 via GC; kubectl get sandboxclaims still shows every claim.
A million Sandbox objects in etcd is a dead end (churn alone blows the ~8 GB
keyspace). The Kubernetes-native fix is the aggregation layer: serve
sandboxes.agents.x-k8s.io from an aggregated apiserver (an APIService)
that scatter-gathers live node inventory on read. etcd stores only intent —
one SandboxWarmPool spec expressing a million desired replicas, plus one
inventory object per node (O(nodes)). Object count drops from
O(sandboxes) to O(pools + nodes).
Each virtual-kubelet node already knows its own VMs (L0 made that cache the
source of truth), so the aggregated server assembles a SandboxList by fanning
out to node inventories — the exact pattern metrics.k8s.io uses to serve
PodMetrics with zero etcd storage. kubectl get sandboxes, RBAC, field
selectors, and watch (currently implemented by polling and diffing the
cache-fed NodeInventory view) all keep working; users never see that storage
decentralized.
// SandboxStore backs the aggregated apiserver for sandboxes.agents.x-k8s.io.
// It holds NO per-sandbox etcd objects: List/Get/Watch use the cache-fed node
// inventories, and Create/Delete claim or release through a node-local RPC.
type SandboxStore interface {
List(ctx context.Context, opts ListOptions) (*SandboxList, error) // fan-out to node inventories
Get(ctx context.Context, ns, name string) (*Sandbox, error) // fan-out; materialize only the match
Watch(ctx context.Context, opts ListOptions) (watch.Interface, error) // poll and diff inventory
}
// NodeInventory is the one O(nodes) etcd object per node: the durable
// summary of that node's live sandboxes, server-side-applied on a slow
// cadence. The per-sandbox truth lives in the node, not etcd.
type NodeInventory struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Node string `json:"node"`
Entries []InventoryEntry `json:"entries"` // {name, id, phase, template, claimRef, addr, deadline}
}
Failure modes
| Scenario | Behavior | Breaks k8s semantics? |
|---|---|---|
| Node partitioned from aggregated server | Its sandboxes briefly absent from List (eventual consistency, same as an informer lag) |
No |
A node inventory object lost |
Rebuilt from the node’s own live state on next publish | No |
| Aggregated server restart | Stateless; rebuilds from node fan-out | No |
| Client reads before the owning node republishes inventory | The sandbox is briefly absent; retry until the next publish. Direct authoritative routing is the remaining follow-up below. | No — the read surface is explicitly eventually consistent |
Acceptance: 1M sandbox intent costs O(nodes) etcd objects; kubectl get
sandboxes returns the fanned-out list; per-sandbox Get only materializes the
matching entry. Strong read-after-write and O(1) lookup remain follow-up work.
The operator schedules off NodeInventory, which each node republishes on a
5–30 s cadence. Picking the node with the highest advertised warm count would
therefore send every claim in a refresh window to whichever node looked best
in that one snapshot — draining it while its peers stay idle. The repo’s own
100-burst run showed the signature: 98/100 warm, and both misses were
cold-provision on a single over-scheduled node.
Node choice is instead power-of-two-choices: sample two candidates that advertise warm capacity for the requested pool and take the warmer one. That keeps the bias toward warm capacity while spreading a burst across the fleet, and a stale pick still costs at most one gossip redirect inside sandboxd, so correctness is unchanged.
The watch path makes the opposite trade. Re-deriving the fleet view is
O(nodes × sandboxes) — measured at 124 ms for the list and 173 ms for a full
poll tick at 200 nodes and 50k sandboxes — yet the poll cadence is fixed on
purpose: a widened interval would let a sandbox created and deleted inside the
gap produce neither an Added nor a Deleted event. One watcher per fleet is the
supported shape.
Single-sandbox lookup no longer builds the cluster-wide SandboxList.
SandboxStore.Get and the e2b claim-id resolver fan out across the cache-fed
per-node inventories, cancel on the first match, and materialize only that
entry. This removes the previous O(total sandboxes) Sandbox allocation, but
two limitations remain: the lookup cannot see a claim until the owning node
publishes it, and a miss still scans up to every inventory entry.
It is stale for one publish interval. A sandbox is live on its node the
moment Claim returns, but it does not appear in the read view until that node
republishes its NodeInventory (default 30 s). Measured against a 20-node
fleet: p50 29.0 s on the e2b surface, 28.6 s through the aggregated API. A
lifecycle verb issued inside that window answers 404. Callers work around it
by polling until visible — which is what examples/lifecycle does — so
“create, then immediately pause” costs half a minute of polling.
A first lookup is still O(total inventory entries) CPU. An owning-node
index now answers a repeat lookup from one node’s inventory: measured at 200
nodes × 2000 sandboxes, Get fell from 2.49 ms / 36 MB to 39 µs / 217 KB per
call. A first lookup, or one whose index entry was evicted, still compares
entries across the fleet.
Both fall out of the same omission: Claim already returns the node and the
claim id — the e2b create response even hands the node back to the client as
clientID — and a lifecycle verb needs nothing else. The plan keeps that
routing information instead of re-deriving it:
Neither touches etcd. Publishing inventory on change was considered and
rejected: NodeInventory carries one 105 B entry per live sandbox
(measured), so a node holding 2500 of them is a 263 KB object. Re-applying that
on a 2 s debounce costs 52.6 MB/s of large-object server-side-apply traffic
across 400 nodes at 1 M sandboxes, against 3.5 MB/s for the current 30 s
cadence — and it would still leave the O(total inventory entries) lookup in
place.
Acceptance: a lifecycle verb succeeds on a sandbox claimed milliseconds ago;
resolving one sandbox allocates O(1), not O(total sandboxes); index memory
is capped independently of fleet size.
Modal buys throughput by leaving Kubernetes: a proprietary SDK and a proprietary
control plane. Every layer here keeps kubectl / CRDs / RBAC / the ecosystem
intact. The one-line framing:
Modal proved 1M needs a decentralized transaction plane. We show the decentralized transaction plane can hide behind Kubernetes semantics.
| Modal | sandbox-operator | |
|---|---|---|
| Scheduling | stateless fleet, in-memory worker state | per-pool sharded operator + Lease (L1) |
| Create critical path | direct scheduler→worker RPC, no datastore | ownership PATCH (L1) → node-local gateway (L2) |
| State of record | Redis stream (async) | Kubernetes objects; node inventory in etcd is O(nodes) (L3) |
| Sandbox storage | proprietary | aggregated apiserver, etcd stores intent only (L3) |
| Client interface | proprietary SDK | any Kubernetes client — unchanged |
| Scale ceiling | no practical limit | decoupled from etcd object count at L3 |
Every acceptance claim above is backed by a reproducible benchmark committed under
test/ — the evidence is regenerated by the harness, never hand-written. Numbers
are labelled by substrate: algorithmic complexity is proven on a fake apiserver
(so it isolates the scaling term, not machine speed), while absolute latency on
real microVMs is measured on a single vk-cocoon node (384 vCPU / 1.5 TB bare metal).
| Layer | Acceptance claim | Measured | Substrate / harness |
|---|---|---|---|
| L1 | claim p50 stays near-constant as the pool grows | fast-path p50 0.644 ms → 0.646 ms from N=100 to N=2000 (1.003×); a full-LIST selection over the same fixtures degrades 15.7× (1.3 → 20 ms) |
fake apiserver + real reconciler — test/scalebench |
| L1 | warm claim on real microVMs | claim→Bound p50 129 ms, p95 926 ms, p99 935 ms; 100/100 warm hits, 0 failures. Pool fills 100 microVMs in 62 s (boot p50 47 s) | the microVM node, 100 concurrent claims — test/poolbench |
| L2 | sub-millisecond node-local claim | gateway overhead p50 0.039 ms, p95 0.053 ms (sandboxd delivery itself is 0.2–0.7 ms by contract); 200/200 orphan bindings reconciled, 0 VM destroys | httptest sandboxd + fake recorder — test/l2bench |
| L3 | etcd stores intent only, kubectl unchanged |
3000 sandboxes served through client-go List/Get/Watch from 8 etcd objects (3 nodes + 5 pools) — 0 per-sandbox objects, 3 server-side-apply writes | in-process aggregated apiserver — test/l3bench |
| e2e | admission→claim→release→cleanup, zero leak | 100 real microVMs: four-way cross-check 100/100/100/100, 100/100 claims bound, 0 leaked, production workloads on the same node unaffected | the microVM node, full stack — test/e2ebench |
| sandboxd tier (deployed) | hot-pool warm claim via k8s, apiserver flat under load | 100 Sandbox (runtime: sandboxd) create→Ready p50 < 1 s (warm), 98/100, submitted in 2.9 s; 100 % routed to the sandboxd plane; apiserver LIST 37 ms/7 ms, 0 APF rejections, in-queue 0; cocoon microVMs untouched |
26-node fleet, vk-sandbox + sandboxd — the scale benches under test/ |
Two honest caveats. The sub-millisecond L1/L2 figures measure algorithmic cost and gateway overhead on fake substrates; real end-to-end latency additionally pays the apiserver round-trip, sandboxd delivery (0.2–0.7 ms), and informer convergence. And the real-microVM claim p95 (926 ms, ~7× the p50) is single-node optimistic-concurrency contention under 100 simultaneous claims — exactly the tail L1’s per-pool operator sharding is designed to spread across shards and nodes.