Wednesday, November 4, 2026 Edition No. 228
Search
Review · Infrastructure · 25 min read

OpenShell on Amazon EKS: Stress Testing

By Fabio Douek

Jump to section
Explain (TLDR) like I am...
What is this?

I tried to squeeze as many robot rooms as possible into two big houses. The houses had plenty of space and power left over, but they ran out of door numbers and parking spots first, so 46 rooms fit one way and 61 another. Next time, I'd count the door numbers first.

The rooms worked fine even when I built 40 at once. But when one robot banged on the shared floor, everyone next door slowed down, and every time I changed the house rules, all the robots on the phone got hung up on.

This post records load tests of OpenShell 0.1.6 on two Amazon EKS nodes. Capacity was bounded by Kubernetes limits on Pods and attached volumes per node, not by compute; no sandbox creation failed or hung under concurrency up to 40, once the shared supervisor pool was accepting sandboxes.

Operational risks to document: every policy change interrupts all open connections in affected sandboxes; sandboxes have no resource limits by default, so one tenant can degrade another; and a trial run observed a deletion that left billable storage behind.

I loaded two nodes until OpenShell refused new sandboxes: 46 with the default design, 61 with the shared supervisor. Vital signs stayed calm throughout, with memory and CPU mostly idle; the limit was the number of Pods and volume attachments each node allows.

Side effects under stress: policy changes sever every open connection, disk-heavy neighbours slow shared disk writes about thirty-sixfold, and CPU limits help only with CPU. Prescribe limits per sandbox and batch policy changes.

Load tests are where a system shows its temperament. This one stayed composed: creating forty sandboxes at once slowed things down but broke nothing, and the control plane answered about two thousand requests a second without a single error.

The tension shows up between neighbours. With no limits, one busy sandbox takes from everyone nearby, and a single policy edit interrupts every conversation in progress in that sandbox. Agreeing on limits and change windows keeps a shared cluster a calm place.

Think of two stages that could host plenty of players, until the venue runs out of dressing rooms and parking spaces. That's the real ceiling here: 46 acts the standard way, 61 with a shared crew.

The band held tempo even when forty acts arrived at once. The trouble is acoustic: one loud drummer bleeds into every neighbouring mic, and each time the setlist changes, every song in progress stops mid-bar. The fix is old stagecraft: give each drummer a booth, and change the setlist between songs.

The capacity story is concrete: two modest nodes hosted 46 isolated agent sandboxes as shipped and 61 with the shared supervisor, with most of their CPU and memory still free. Once set up correctly, creation never failed under load, and the control plane handled about 2,000 requests a second.

The positioning for buyers: plan capacity in Pods and volumes, not cores; set limits per sandbox; and treat policy changes as events that reset connections. With that, the platform scales predictably.

GitHub See the source code here: openshell-stress

Overview

OpenShell, a CNCF Sandbox project since 2026-09-26, has been the subject of five posts in this series: the architecture, an install on Amazon EKS, a sandbox’s lifecycle, policy and egress and AWS credentials. This last part, 6 of 6, asks how much the same two-node cluster can take.

I found no independent OpenShell numbers for ordinary Kubernetes nodes to check against. The figures NVIDIA cites are hardware claims for its own platforms, which this series doesn’t test. So this post measures rather than checks: six tests on two m7i.2xlarge nodes, with nothing added to the cluster except Container Insights, each test starting from zero sandboxes and deleting what it made.

The short version: on these nodes, OpenShell runs out of Kubernetes before it runs out of machine. 46 sandboxes with a supervisor Pod each, 61 with the shared supervisor pool, and both stopped by per-node limits (Pods, then EBS volume attachments) while CPU and memory sat mostly idle. Creates slow down with concurrency but don’t fail once the setup is right, the gateway handles about 2,000 API calls a second, the policy layers add a couple of milliseconds per new connection, and the things that hurt are a policy edit (it cuts every open connection) and a neighbour hammering the node’s disk.

Architecture

LAPTOP 01–06 test scripts Python SDK + CLI, 0.1.6 sampler.py kubelet summary, every 2 s results/*.csv → charts.py → this post EKS 1.37 · 2 × m7i.2xlarge · 2 AZs · + Container Insights gateway (1 replica) SQLite, mTLS Kubernetes API nodes/proxy/stats/summary sandboxes p6-* workload Pod + supervisor Pod + 2 GiB EBS volume each (or one Pod each, with the shared supervisor pool) probes inside: bench, flip, noisy_probe echo server from part 4 p6-load Pod API load, test 5 cloudwatch- agent CloudWatch Container Insights port-forward
The test harness. Everything runs on the cluster from part 2; the laptop drives the tests and samples the nodes.

Two ways of watching, on purpose. sampler.py reads each node’s kubelet summary API every 2 seconds and writes CPU, memory, Pod counts, attached EBS volumes and the gateway’s own usage to CSV, next to the tests’ own results. Container Insights (metrics only) runs alongside, to see how the same tests look from CloudWatch, which is where most teams would watch them.

Setup

The cluster is part 2’s, unchanged: two m7i.2xlarge nodes (8 vCPUs, 32 GiB) in two availability zones, EKS 1.37, the VPC CNI with network policy enforcement, OpenShell 0.1.6 with one gateway replica on SQLite. Sandboxes run on runc. Part 4’s echo server is the in-cluster destination for the network tests.

Step 1: Container Insights, metrics only

00_container_insights.sh on flips a variable in part 2’s Terraform that adds the Amazon CloudWatch Observability add-on with its agent’s IAM role. The plan added three resources and changed nothing else; the add-on runs one cloudwatch-agent Pod per node, which counts against the Pod limit that turns out to matter in Test 1. It collects metrics only, with no container log shipping (Container Insights stores the metrics themselves as performance log events in CloudWatch Logs).

Step 2: A sampler

sampler.py reads each node’s kubelet summary API (/api/v1/nodes/<node>/proxy/stats/summary) every 2 seconds and writes one CSV row per node: CPU, memory, Pod counts, unscheduled Pods, attached EBS volumes, NetworkPolicies, and the gateway Pod’s own CPU, memory and SQLite volume. Most tests start it in the background; charts.py draws this post’s charts from the tests’ result files.

Step 3: Every test from zero

Each test checks that no other sandboxes exist, creates only sandboxes named p6-*, and deletes everything it made, including anything the gateway forgets (more on that below). Tests that change the gateway’s configuration (the shared supervisor pool, push delivery) apply a Helm overlay and roll the release back to the exact previous revision at the end, also on Ctrl-C, and check that the rendered gateway.toml is what it was.

uv sync
./00_container_insights.sh on
uv run python 01_density.py --variant dedicated
uv run python 02_create_latency.py --topology dedicated
# ... see the README for the full run order

Testing

Test 1: Density until something refuses

01_density.py creates sandboxes in batches of 10, waits for each to be Ready, and stops at the first sandbox that isn’t Ready within 240 seconds. Then it reads the stuck sandboxes’ Pods, volumes and events to name the cause. Three variants: the default (a supervisor Pod per sandbox), the experimental shared supervisor pool (supervisor_topology = "gateway": one supervisor Deployment, one Pod per member, serving every sandbox), and the pool with 1 GiB workspace volumes instead of 2 GiB.

Bar chart: 46 Ready sandboxes with dedicated supervisors, stopped by Pods per node; 61 with the shared supervisor pool and 61 with the pool and 1 GiB workspaces, both stopped by EBS attachments per node Bar chart: 46 Ready sandboxes with dedicated supervisors, stopped by Pods per node; 61 with the shared supervisor pool and 61 with the pool and 1 GiB workspaces, both stopped by EBS attachments per node
VariantReadyWhat stopped itcreate→Ready per batch (p50)
Dedicated supervisors46Pods per node: 58 on each node, “Too many pods”14.0 to 17.7 s
Shared supervisor pool61EBS attachments: 31 per node (CSINode)9.8 to 15.0 s
Shared pool, 1 GiB workspaces61EBS attachments8.7 to 16.3 s

With a supervisor Pod per sandbox, each sandbox costs two Pods, and an m7i.2xlarge on the VPC CNI allows 58 Pods. The nodes already ran 22 between them (system Pods, the gateway, the CloudWatch agent), so once both nodes held 58 Pods, four sandboxes of the fifth batch couldn’t get all their Pods scheduled (“0/2 nodes are available: 2 Too many pods”); two of their supervisor Pods took the last free slots, which is why the count is 46 and not 47. The shared pool halves the Pods, and the next limit is the EBS volume each sandbox’s workspace needs: 31 attachments per node (the instance’s EBS attachment limit after the root volume, as the node’s CSINode object reports), one of them already used by the gateway’s own volume. Smaller volumes don’t help, since the limit counts volumes, not gigabytes.

CPU and memory weren’t close to a limit. At 46 sandboxes, each node used under 2.8 GiB of its 32 GiB and under 0.9 of its 8 cores; the sandbox Pods together took about 780 MiB per node, roughly 33 MiB per idle sandbox and supervisor pair. Two m7i.2xlarge nodes are sized for far more than 46 idle sandboxes; what they run out of is Pod slots and volume attachments. VPC CNI prefix delegation raises the Pod limit; the volume limit stays as long as each sandbox gets its own workspace volume, which is the default (driver config can mount a different volume at /sandbox, which I didn’t test).

The shared pool needed care. The first runs failed every create with “the shared supervisor has no capacity for another sandbox instance”, because the test started as soon as the pool’s Deployment was rolled out. The gateway only places sandboxes once the pool members have connected. The harness now creates a canary sandbox every 10 seconds until one is accepted; including the canary’s own create and delete, that step took 49 to 110 seconds.

Test 2: Create latency under concurrency

02_create_latency.py sends 1, 5, 10, 20 and 40 CreateSandbox calls at once and times each sandbox from the call to Ready.

Line chart: create to Ready p50 rises from 9.2 s with 1 create to 32.4 s with 40 for dedicated supervisors, and stays between 8.7 s and 17.5 s for the shared pool; p95 at 40 creates is 42.4 s dedicated and 30.3 s shared Line chart: create to Ready p50 rises from 9.2 s with 1 create to 32.4 s with 40 for dedicated supervisors, and stays between 8.7 s and 17.5 s for the shared pool; p95 at 40 creates is 42.4 s dedicated and 30.3 s shared
Creates at onceDedicated p50 / p95Shared pool p50 / p95CreateSandbox call p50, dedicated / shared
19.2 / 9.2 s12.3 / 12.3 s0.47 / 0.30 s
514.0 / 18.2 s8.7 / 9.8 s1.91 / 0.43 s
1017.2 / 17.7 s15.1 / 15.9 s3.52 / 0.49 s
2021.4 / 25.7 s16.2 / 17.7 s4.31 / 0.70 s
4032.4 / 42.4 s17.5 / 30.3 s7.27 / 0.96 s

Every sandbox reached Ready, none got stuck, and none of the creates failed (the shared-pool numbers are from runs after the pool accepted sandboxes). The gateway’s mutation lock wasn’t the bottleneck (mean wait under 2 ms at every level). What grows is the CreateSandbox call itself with dedicated supervisors, from half a second to over 7 seconds with 40 in flight, probably because each create also has to set up a supervisor Pod with its Secrets and Services. With the shared pool there’s no supervisor Pod to create, and the median for 40 at once (17.5 s) was close to the median for 10 (15.1 s), though the p95 doubled.

Deleting is the slow direction in both: about 31 seconds per sandbox, as in part 2, and 98 seconds (dedicated) or 94 seconds (shared) for 40 at a time, 16 in parallel. In one of the harness’s smoke runs, a delete sent about a second after a sandbox became Ready returned in 26 ms and the gateway forgot the sandbox, while its Kubernetes objects and its EBS volume stayed behind; the scripts now check for that and clean up through Kubernetes. I didn’t see it again in the recorded runs.

Test 3: Policy propagation

03_policy_propagation.py starts 20 sandboxes, each with a probe that tries the echo server every 50 ms and a 90-second streaming download. One policy change allows the echo server; a second, unrelated change creates a new policy revision. Ten repeats, with the default poll delivery and with push.

Cumulative chart: with push delivery, every sandbox got through within 0.4 s of the change; with poll, most times fall between about 3 and 10 s Cumulative chart: with push delivery, every sandbox got through within 0.4 s of the change; with poll, most times fall between about 3 and 10 s
DeliveryAllowed after (p50 / p95 / max, 200 sandbox-changes)Open streams cut by the unrelated change
Poll (default)4.96 / 9.93 / 10.01 s200 of 200
Push0.07 / 0.18 / 0.39 s200 of 200

With polling, a change landed anywhere within the 10-second interval, mostly after the first 3 seconds; push was effectively immediate. Part 4 measured 7.9 to 12.5 seconds with polling and 1.3 seconds with push, but it timed from the CLI call through repeated sandbox exec round trips; the probes here run inside the sandbox every 50 ms, so they time the delivery alone. What doesn’t change is the cost: every new policy revision cut every open stream in the sandbox it reached, including a change that had nothing to do with it. Part 4 showed this for one sandbox. Here each of the 20 per-sandbox edits cut its sandbox’s download; a gateway-wide policy change, which I didn’t run, would reach every sandbox at once.

One thing went wrong in the poll run’s cleanup: a delete got “storage is (re)initializing” (HTTP 429) from the Kubernetes API, and that sandbox took a later cleanup pass to go. CloudWatch shows 8 API server 5xx responses during the same test.

Test 4: What each policy layer costs

04_egress_ladder.py runs the same benchmark from one sandbox under rising policy work: L4 with tls: skip (the supervisor relays bytes without looking at them), L4 (no request rules, but the supervisor checks each HTTP request’s host and path), a REST rule in audit mode, a REST rule enforced, and a substituted credential. The baseline is a plain Pod on the same node. The echo server, the workload and its supervisor were all on one node; the variant with the supervisor on the other node never ran, because the scheduler put the supervisor next to the workload in 10 out of 10 tries.

Bar chart: one GET on a new connection to the in-cluster echo server, p50: direct 1.1 ms, tls-skip 2.7 ms, l4 3.4 ms, rest-audit 2.9 ms, rest-enforce 3.4 ms, credential 2.5 ms Bar chart: one GET on a new connection to the in-cluster echo server, p50: direct 1.1 ms, tls-skip 2.7 ms, l4 3.4 ms, rest-audit 2.9 ms, rest-enforce 3.4 ms, credential 2.5 ms
RungNew connection p50 / p95Upload
Direct (a plain Pod)1.11 / 1.50 msn/a
L4, tls: skip2.66 / 3.02 ms172 MiB/s
L43.40 / 3.87 ms178 MiB/s
REST, audit2.87 / 3.35 ms164 MiB/s
REST, enforce3.35 / 4.17 ms169 MiB/s
Credential substituted2.52 / 3.45 ms101 MiB/s

Going through OpenShell adds about 1.4 to 2.3 ms to a new connection to an in-cluster service: the runtime traps the connect, the Sandbox Protocol carries it to the supervisor, and the supervisor dials. Each rung ran once, and the differences between them are small enough that I wouldn’t read an order into them. Uploads went through at 160 to 180 MiB/s, except with a credential substituted, at 101 MiB/s. I don’t have throughput numbers for the direct baseline: all its uploads and about half of its requests-per-second run failed with an OSError in my harness, so those figures are left out, and the L4 and REST-enforce throughput runs each had 8 timeouts, so I’d not compare requests per second across rungs either.

Against an AWS endpoint over HTTPS, with keep-alive, the p50 went from 3.06 ms direct to 3.27 ms with tls: skip, 3.35 ms with TLS terminated and 3.57 ms with a REST rule enforced: TLS termination and inspection cost a fraction of a millisecond per request once the connection is open. New HTTPS connections cost more, 7.2 ms direct against 9.6 to 10.2 ms through OpenShell.

I left out a rung with SigV4 signing, so this ladder doesn’t measure what signing costs; part 5 covers what it does.

Test 5: The gateway under API load

05_control_plane.py runs 1 to 64 parallel API clients, each cycling list, get and exec against five sandboxes, from a Pod inside the cluster: in a smoke run, every call from the laptop paid about 90 ms for the port-forward. The gateway is one replica with no resource limits.

Line chart: gateway API calls per second rise from 189 with 1 client to about 1,965 with 16 clients and level off at about 2,040 with 32 and 64 clients Line chart: gateway API calls per second rise from 189 with 1 client to about 1,965 with 16 clients and level off at about 2,040 with 32 and 64 clients
ClientsCalls/slist p50exec p50 / p95Gateway CPUGateway memory
11891.9 ms13.1 / 17.7 ms0.23 cores83 MiB
81,3092.0 ms14.6 / 22.9 ms1.80 cores102 MiB
161,9652.2 ms17.9 / 42.5 ms2.65 cores107 MiB
642,0442.3 ms20.1 / 384.9 ms2.73 cores126 MiB

No errors at any level. Throughput levels off at about 2,040 calls a second with the gateway at about 2.7 cores. I can’t say that’s the gateway’s limit: its node was then at about 6 of its 8 cores, and my load client is a single Python process. list and get stay at about 2 ms; exec, which opens a session through the sandbox’s supervisor, goes from 13 to 20 ms at the median and to 385 ms at the p95 with 64 clients. A Health call, the transport floor, took 0.89 ms.

Test 6: Noisy neighbours

06_noisy_neighbors.py puts a probe sandbox on one node next to three hog sandboxes: first CPU hogs (8 threads each), then disk hogs writing to the node’s root volume and their own workspaces. The probe measures a fixed piece of CPU work, an fsync on its own EBS workspace and on the node’s root volume, and a request to the echo server, once a second. The probe’s node isn’t the echo server’s, so its echo request crosses nodes, unlike Test 4’s, and starts from a higher baseline. The second run limits each hog to 1 CPU.

Line chart: the probe's fsync on its own EBS workspace stays near 3 ms apart from spikes up to about 24 ms during the CPU hogs, while fsync on the node's root volume jumps from about 1.4 ms to around 40 to 80 ms, median 51 ms, while the disk hogs run Line chart: the probe's fsync on its own EBS workspace stays near 3 ms apart from spikes up to about 24 ms during the CPU hogs, while fsync on the node's root volume jumps from about 1.4 ms to around 40 to 80 ms, median 51 ms, while the disk hogs run
Probe, p50BaselineCPU hogsCPU hogs, 1 CPU eachDisk hogsDisk hogs, 1 CPU each
CPU work10.6 ms20.6 ms15.0 ms10.7 ms10.7 ms
Request to the echo server6.1 ms15.5 ms7.4 ms6.2 ms7.2 ms
fsync, own EBS workspace2.5 ms2.9 ms2.9 ms2.8 ms2.8 ms
fsync, node root volume (/tmp)1.4 ms1.5 ms1.5 ms51.5 ms53.6 ms

Sandboxes have no resource limits unless you set them (--cpu, --memory), and OpenShell’s isolation layers don’t add any. Three unlimited CPU hogs doubled the probe’s CPU time and more than doubled its request latency; a 1-CPU limit per hog cut most of that. The disk hogs didn’t touch the probe’s own EBS volume, but they made fsync on the node’s root volume about 36 times slower, and CPU limits did nothing for it. Everything a sandbox writes outside /sandbox, its /tmp included, shares the node’s root disk with every other sandbox on that node.

What CloudWatch Saw

Container Insights saw the same tests, one data point a minute. node_number_of_running_pods peaked at 58 on a node during the dedicated density run, the Pod limit Test 1 hit, and cluster_number_of_running_pods at 116. node_cpu_utilization stayed under 10% for the density, create and propagation tests, reached about 42% during the egress ladder and 72% under API load, and 100% on the hogs’ node with the noisy neighbours; node_memory_utilization never went above 9%. The API server reported 8 responses with a 5xx status during the policy propagation test.

Two things it doesn’t show. Nothing in Container Insights knows about sandboxes, so a sandbox stuck in Provisioning is just a Pending Pod, and the density ceiling looks like an ordinary scheduling failure. And at one data point a minute, a create storm that takes 40 seconds is a single point. For OpenShell itself, the gateway exposes Prometheus metrics on port 9090 (supervisor sessions, config delivery, gRPC calls), which a Prometheus scraper or the CloudWatch agent’s Prometheus support could collect; I read them directly in Test 5.

Results Summary

Result
Density, a supervisor Pod per sandbox46 sandboxes; stopped by Pods per node (58)
Density, shared supervisor pool61; stopped by EBS attachments per node (31)
Density, shared pool with 1 GiB workspaces61; same limit
Node use at 46 sandboxesunder 2.8 GiB of 32 GiB memory and 0.9 of 8 cores per node
Shared pool after a switchcreates refused until the pool members connected (the canary step took 49 to 110 s)
Create→Ready, 1 / 40 at once, dedicated9.2 s / 32.4 s p50 (42.4 s p95)
Create→Ready, 1 / 40 at once, shared pool12.3 s / 17.5 s p50 (30.3 s p95)
Stuck or failed creates0 (shared pool: once it accepted sandboxes)
Deleteabout 31 s per sandbox
Policy propagation, poll / pushp50 4.96 s, max 10.0 s / p50 0.07 s, max 0.39 s
Open streams cut by an unrelated policy change200 of 200
New connection to an in-cluster service1.1 ms direct, 2.5 to 3.4 ms through OpenShell
HTTPS keep-alive request, direct vs REST enforced3.06 ms vs 3.57 ms
Gateway API throughputabout 2,040 calls/s at about 2.7 cores, no errors
Noisy CPU neighboursprobe CPU work 10.6 → 20.6 ms; 15.0 ms with 1-CPU limits
Noisy disk neighboursnode root volume fsync 1.4 → 51.5 ms; own EBS workspace unaffected

Pricing

The tests ran on part 2’s cluster with nothing added except Container Insights, from about 06:10 to 08:10, including the re-runs of the shared-pool tests. At part 2’s idle rate of about $0.98 an hour (two m7i.2xlarge nodes, the EKS control plane, the NAT gateway and the root volumes), that’s roughly $2 of cluster time.

The rest is small:

ItemPriceFor these tests
Workspace volumes, up to 61 × 2 GiB gp3$0.08/GB-monthunder $0.02 an hour at the peak
Container Insightsbilled per metric observation (CloudWatch pricing)switch it off afterwards

Sandboxes on these nodes are cheap: 46 idle ones used a few percent of two nodes that cost $0.81 an hour together. The cost of density here is the cost of Pod slots and volume attachments, so check those limits for an instance type before its vCPUs and memory.

Cleanup

./99_cleanup.sh               # anything a killed script left: p6 sandboxes, Pods, Helm overlays
./00_container_insights.sh off
cd ../openshell-eks
./99_teardown.sh              # the whole cluster, at the end of the series

Each test deletes what it created and puts back any gateway change; 99_cleanup.sh is for when a script was killed hard. It also removes Sandbox resources the gateway no longer lists, the orphans described in Test 2.

Verdict

Two m7i.2xlarge nodes ran 46 OpenShell sandboxes as shipped and 61 with the shared supervisor pool, and in both cases the cluster ran out of per-node Pod slots and EBS attachments (instance limits that Kubernetes enforces) long before it ran out of CPU or memory. That’s the practical lesson for sizing: count Pods (two per sandbox by default) and EBS attachments (one per sandbox), not gigabytes and cores. Prefix delegation in the VPC CNI lifts the first, and the shared pool halves it; the second stays as long as every sandbox gets its own volume, which is the default.

Under load, OpenShell behaved well. Creates slowed with concurrency but never failed or got stuck once the setup was right, the gateway served about 2,000 API calls a second from one replica with no errors, and the policy layers cost a couple of milliseconds per new connection. The shared pool is the faster way to create many sandboxes, but it’s experimental, and after each switch to it the gateway refused sandboxes until the pool members had connected.

The behaviours to design around:

  • Policy changes are disruptive at scale. Every revision cuts every open connection in every sandbox it reaches, and with polling it takes up to 10 seconds (about 5 at the median). Batch policy edits, and use push delivery if agents wait on them.
  • There are no resource limits by default. A CPU-heavy sandbox slows its neighbours, and a disk-heavy one slows everything that writes to the node’s root volume, which CPU limits don’t fix. Set --cpu and --memory, keep scratch data in /sandbox, and consider separate node pools for heavy agents.
  • Deletes are slow, and one can orphan. About 31 seconds each, and in one smoke run a delete right after Ready left a sandbox’s objects and its volume behind. Check for leftover Sandbox resources and volumes after bulk deletes.
  • Watch it with OpenShell’s own metrics. Container Insights sees Pods and nodes, not sandboxes.

That’s the end of the series. Over six parts, OpenShell 0.1.6 on EKS turned out to be a careful design with sharp edges: the agent doesn’t hold its credentials (with one known exception in 0.1.6, covered in part 5), the isolation fails closed, and the egress model is precise, while the defaults (audit mode, telemetry on, no limits, no VM isolation on these nodes) and a handful of bugs need attention before anything important runs in it. Part 1 is where to start if you’re deciding.

Comments