Skip to content

fix: what a bare-metal comparison turned up (gateway waits, Firecracker cold start, port overlap, guest hostname) - #380

Merged
WaylandYang merged 10 commits into
devfrom
fix/gateway-boot-wait
Oct 30, 2026
Merged

WaylandYang merged 10 commits into
devfrom
fix/gateway-boot-wait

Conversation

@WaylandYang

@WaylandYang WaylandYang commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

These came out of comparing Sandstorm with other sandboxes on a bare-metal machine with KVM (2× Xeon E5-2637 v2, 16 threads, 31 GB). Each one was found by tracing a number that did not add up.

  • Gateway waits for a sandbox that is still starting (gateway.rs). A request for a sandbox whose program was not listening yet was held while the gateway retried, backing off from 50 ms up to 500 ms. That held the first answer of an app64 300–370 ms after the program was already listening; on the same machine, Docker with the same runsc answered 6 ms after listen. Retries now start at 5 ms and stop doubling at 50 ms. New test: a program that starts listening at 400 ms must be answered within 600 ms (it fails with the old backoff). Sandstorm on gVisor, cold app64: 941 → 616 ms, now ahead of Docker + gVisor (660 ms).
  • Firecracker cold start, 1.95 s → 0.64 s (web) and 2.1 s → 0.96 s (app64). Fixing each cause exposed the next:
    • The guest kernel probed for an i8042 controller that Firecracker does not emulate. Firecracker's recommended i8042 boot options are now set, and asserted in boot_args' test.
    • The agent imported numpy in PID 1 before starting the template's program. It now imports numpy on first eval, and reads the version from package metadata.
    • The gateway's connect waited a full second for the kernel to resend a dropped SYN. It now sends a fresh SYN every 50 ms (connect_upstream).
    • The main one: the netns's first packet to a guest that was still booting triggered an ARP request that got no answer, and the kernel asked again only a second later. tcpdump showed every SYN leaving together at +1,101 ms. The guest's MAC is fixed, so its neighbour entry is now permanent too (ip neigh … nud permanent in ensure_tap, which pooled namespaces also get).
    • The tap now has a fixed MAC as well. A restored guest keeps the seed's tap in its ARP cache, and while the netns ARPed for the guest, the guest refreshed that entry from the request. With the guest's entry pinned, nothing asks any more, so warm starts took 43 s until the stale entry expired. With a fixed tap MAC, warm starts are back to 133 / 183 ms.
    • quiet on the kernel command line. Every byte printed to ttyS0 costs a VM exit through the emulated serial port, and a boot printed 273 lines. Web 861 → 652 ms, app64 1,167 → 1,066 ms.
    • The agent now starts the template's program first, using posix_spawn before importing anything else, so the program no longer waits behind the agent's own startup on the single vCPU. Web 652 → 636 ms, app64 1,066 → 964 ms.
    • Tried and dropped: two vCPUs per guest. Web went 636 → 597 ms and app64 was unchanged, at the cost of a second vCPU thread in every VM.
  • Guest hostname (sandstorm-init.sh). The kernel names a guest after its IP, and Python's HTTPServer resolves the hostname before it listens. With no resolver reachable, that waited out a timeout: 20 s per Firecracker sandbox on bm. The init now names the guest sandbox and writes that name to /etc/hosts.
  • Overlapping gateway port ranges (main.rs). Two master-and-node processes on one machine both allocated gateway ports from 20000, so the second sandbox on a taken port never became reachable. A master-and-node now records its range under /run/sandstorm/gateway-ports and refuses to start if it overlaps a running process's range, naming that process and the --port-min and --port-max flags to fix it.

Tests

  • Linux (the bare-metal machine): cargo test 208 passed, clippy clean, and agent unittest 32 passed.
  • macOS: the same, except the three detach_tests that also fail on unmodified dev there.

Each change was also checked on the machine itself: the port refusal message, tcpdump before and after the neighbour entry, and start times before and after.

🤖 Generated with Claude Code

WaylandYang and others added 6 commits October 30, 2026 02:45
A request for a sandbox that is up but not yet listening is held while the
gateway retries the connect, until BOOT_WAIT. The retries backed off from
50 ms to 500 ms, so the first answer came up to 500 ms after the program
was listening, and the caller's own request was the one held.

Measured on bare metal (2x Xeon E5-2637 v2, runsc), an app64 timestamping
its own startup: listening at about 540 ms, answered at about 890 -- 300 to
370 ms later. Docker with the same runsc answered 6 ms after listening. A
refused connect to the sandbox's address costs microseconds, so the pauses
now start at 5 ms and stop doubling at 50.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s from

Every master defaults to 20000-32000 and allocates without knowing of any
other, while the gateways bind on the node's machine. On bare metal a gVisor
and a Firecracker master-and-node both handed out 20000, and the second
sandbox's gateway logged `Address already in use` every 25 s while its
caller waited. A master-and-node now records its range under
/run/sandstorm/gateway-ports and refuses to start on one that overlaps a
live process's, naming it and the flags to fix it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kernel names a guest after its IP when ip= gives none, and resolving
that goes to a resolver a sandbox usually cannot reach. Python's HTTPServer
resolves the hostname before it listens, so every Firecracker sandbox's
first answer waited out the timeout: 20 s on bm. The init now names it
`sandbox` and puts that in /etc/hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Traced on bare metal (2x Xeon E5-2637 v2), a cold Firecracker web sandbox
took 1.95 s to its first answer. Four things, each found once the one before
was out of the way:

- The guest kernel waited for a PS/2 controller Firecracker does not
  emulate. Firecracker's own i8042 options are now in the boot args: boot to
  init 0.77 -> 0.29 s on bm, 0.32 -> 0.22 s here.
- The agent imported numpy in PID 1, as a demo of forking from warmed
  state, before starting the template's program. It is imported on first
  `eval` now, and its version read from the package metadata.
- The gateway's connect to the sandbox waited for the kernel to resend a
  dropped SYN, a second later. It sends a fresh one every 50 ms.
- Above all, the netns's first packet to a guest still booting asked ARP
  for it, got no answer, and asked again only a second later; every SYN
  queued behind it. tcpdump showed them all leave at +1,101 ms. The guest's
  MAC is fixed, so its neighbour entry now is too (`nud permanent`).

Cold web, create to first answer: 1,953 -> about 700-850 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restored guest has the tap it was snapshotted beside in its ARP cache,
and every sandbox has its own tap with a random MAC. The guest kept
sending to the seed's tap until its entry went stale. That was masked
while the netns asked ARP for the guest, because the guest updates its
cache from the request; once the previous commit pinned the guest's
entry and stopped asking, every warm start on bare metal took 43 s and
the clock sync timed out.

The tap now has a fixed MAC, like the guest. Warm starts are back to
133 ms (web) and 183 ms (app64), and no clock-sync warnings appear since
the restart. Cold starts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e agent

Two more steps off a cold start, each measured on its own on bare metal
(2x Xeon E5-2637 v2, 1 vCPU guests, 10 starts each, p50):

- `quiet` on the kernel command line. Every byte the kernel prints to
  ttyS0 is a VM exit through Firecracker's emulated serial port, and a
  boot printed 273 lines. Web 861 -> 652 ms, app64 1,167 -> 1,066 ms.
- The agent starts the template's program before it imports anything it
  does not need for that, with posix_spawn instead of subprocess. Its own
  start (pty, socket, threading and the rest) used to come first, on the
  same single vCPU. Web 652 -> 636 ms, app64 1,066 -> 964 ms.

Tried and not kept: two vCPUs per guest. Web 636 -> 597 ms, app64
unchanged and its p90 worse, for a second vCPU thread in every VM;
`--fc-vcpus` is there for whoever wants it.

Warm starts are unchanged (143 / 182 ms), with no warnings on the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang and others added 3 commits October 30, 2026 06:01
…oy before it

Traced on bare metal (2x Xeon E5-2637 v2), a warm Firecracker web start
one after another took 104-126 ms against CubeSandbox's 72. In order:

- Renaming an interface took 6-11 ms holding the RTNL lock, and a claim
  renamed two. A claimed namespace now keeps its pooled veth's names,
  recorded under /run/sandstorm/veth for everything that reaches them by
  name (setup on an existing namespace, teardown); sandboxes from before
  have no record and keep their derived names.
- Naming and addressing the veth runs beside the restore for Firecracker,
  whose guest is on the tap (`net::Pending`, `Backend::veth_needed_to_start`;
  runsc takes the veth as it finds it at boot, so it still waits).
- Deleting a namespace holds the kernel's networking locks for a long
  while. A destroyed sandbox's host end is brought down, freeing its
  address at once, and its namespace renamed out of the way and handed to
  the pool, which deletes it when nothing is being created
  (`Pool::retire`).
- The node ran Destroy on its command loop, so a create right behind it
  waited out the whole destroy: 25-35 ms. Destroy now runs on its own
  thread holding the sandbox's lock, a repeat while it runs is a no-op, and
  its address is freed (`net::quiesce`) before the loop moves on.
- The master hands a deleted sandbox's gateway port straight on, while its
  listener could still be up: one create in five could not bind and
  waited a second for the next report. A new sandbox now takes the port
  over, and the old one, still reported, is skipped.
- Back-to-back creates never leave the pool the quiet spell it waits for,
  and a pool of 32 ran dry: below a quarter it now refills regardless.
- A pooled host end's name now counts when a stale veth holds a slot.

Warm start p50, 20 one after another: web 129 -> 57 ms, app64 199 -> 98 ms
(CubeSandbox 72 and 78). Fork from a tag until all answer: 10 at once
831 -> 487 ms, 20 at once 1,510 -> 924 ms.

Tried and not kept: loading without dirty-page tracking. The first request
to app64 faulted in its heap 3x faster, but back to back app64 did not
start faster, and every branch became a Full snapshot: 52 ms -> 0.5-2.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not KVM

A restored VM ran with KVM dirty logging so its next checkpoint could be a
diff, and every page it touched first faulted in on its own: a warm app64,
which reads its 64 MiB heap on the first request, answered in 103 ms on bm
against 65 ms without it.

Restores now load with enable_diff_snapshots off. The pages a diff needs
are tracked by the kernel instead: clear_refs before the guest resumes,
and at the checkpoint the soft-dirty pages of the mapped memory file,
found by device and inode in /proc/<pid>/maps, are copied from
/proc/<pid>/mem into the sparse mem.N. Firecracker writes only the
vmstate, to a snapshot whose memory file is /dev/null: it writes the
vmstate first and stops at sizing the memory file. CubeSandbox snapshots
the same way.

A cold-booted VM keeps KVM tracking. On bm: warm app64 103 -> 67 ms back
to back, branch 52 -> 25 ms; ten chained branches of an app64 writing
8 MiB each, across a Full rollover, all forked back identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he pool runs dry

Two faults under back-to-back starts on bm. The refill's wait for quiet
was extended by every take and never looked at how low the pool was, so
it ran on until the pool was empty: the 33rd start took 185-236 ms, not
70. Waking it in time then built namespaces under load, which put about
10 ms on every start.

The wait now asks on every pass whether the pool is low, behind on
deletes, or has a namespace to reuse. A retired namespace is made ready
again instead of being deleted and a new one built: its end brought down,
which takes its routes, its conntrack table flushed so no late answer to
an old connection reaches the next guest, and renamed into the pool. The
next claim gives it its address and route as before.

On bm, app64 warm through the comparison harness: 124-136 ms p50 (the
cliff) -> 70-73 ms; web 57-65 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang added a commit that referenced this pull request Oct 30, 2026
Cold 308/624 -> 234/545 ms, warm 168/186 -> 107/123 ms (web/app64);
clone 20 ~2,760 -> ~2,280 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@WaylandYang
WaylandYang merged commit a43b883 into dev Oct 30, 2026
10 checks passed
@WaylandYang
WaylandYang deleted the fix/gateway-boot-wait branch October 30, 2026 05:34
WaylandYang added a commit that referenced this pull request Oct 30, 2026
…gVisor and containers on one KVM machine (#384)

* bench(baremetal): Sandstorm against other sandboxes on one KVM machine

compare.py measures start to first answer and memory per instance kept
running, for the same two programs, on: a plain process, bubblewrap,
Docker (runc), Podman, Docker + gVisor, Kata 4.2, microsandbox 0.7.8,
CubeSandbox 0.7.3, and Sandstorm on gVisor and on Firecracker, cold and
from a warm template. The runtimes from bench/fleet/baselines.py are
reused as they are. fork.py, fork_msb.py and fork_cube.py measure
snapshotting a running sandbox and cloning it, each the way its own API
does it. setup.sh brings up the two Sandstorm nodes, with their own port
ranges, and microsandbox's images.

Results on 2x Xeon E5-2637 v2 in results/2026-10-30, with the raw rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench(baremetal): Sandstorm Firecracker after soft-dirty snapshots and the pool fix

Warm start 129/199 -> 62/74 ms (web/app64), ahead of CubeSandbox's 72/78;
snapshot 52 -> 27 ms; clone 20 ~1,562 -> ~980 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench(baremetal): Sandstorm gVisor re-measured with #380

Cold 308/624 -> 234/545 ms, warm 168/186 -> 107/123 ms (web/app64);
clone 20 ~2,760 -> ~2,280 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): bare-metal comparison against CubeSandbox, microsandbox, Kata and Docker, with two charts

bench/baremetal/charts.py draws start-and-memory and clone charts from
the results directory with the fleet charts' palette and primitives.
CubeSandbox's clone row is added to fork.jsonl as fork_cube.py printed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): Firecracker badge and backend in the intro; gVisor-only setup and fleet chart for the 2c2g comparison

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): gVisor and Firecracker logos on their badges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): gVisor and Firecracker badges without the ISOLATION label

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang added a commit that referenced this pull request Oct 30, 2026
The figures and tables measured before #376, #380 and #385 go, the capacity
headline with them until the fills are rerun. bench/fleet/results/2026-10-30
holds the traffic, wake, herd and wall runs on the six nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang added a commit that referenced this pull request Oct 30, 2026
…-30 results (#388)

* bench: start times on the six 2-core, 2 GB nodes with the new build, replacing the one-VM table

Every runtime started one at a time on each of six cloud nodes, the median
of their p50s, drawn by bench/baremetal/charts.py. charts.py for the fleet
now reads a round's layout from its builds.json, so a new round can be
drawn without renaming its directories; 2026-10-28 draws unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): badges as files in the repo, and the backend described as each node's own

GitHub's image proxy would not show the shields.io badges with an embedded
logo; the same SVGs are now in docs/assets/badges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): shorter, one chart, and only numbers from this round

The figures and tables measured before #376, #380 and #385 go, the capacity
headline with them until the fills are rerun. bench/fleet/results/2026-10-30
holds the traffic, wake, herd and wall runs on the six nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): console screenshots of the current console

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant