GKE capacity for 60 sessions¶
This page is part of Team scale.
Node type: why AMD¶
GKE Sandbox (gVisor) uses Linux core scheduling against side channels, and on Intel machine types it turns
Hyper-Threading off by default ([GKE Sandbox][sandbox]); with SMT off a VM shows half its vCPUs
([Configure SMT][smt]), and it is still billed for all of them. AMD machine types keep SMT on. So the previous default,
an n2-standard-8, gives gVisor pods 4 usable vCPUs, not 8: with 2-vCPU sandboxes reserving their full limit,
that is one sandbox per node, and sandbox_max_nodes = 4 meant about 4 sandboxes in all. (This follows from the
docs; nobody has measured it on this cluster. kubectl get nodes -o jsonpath='{..allocatable.cpu}' on a sandbox
node shows it.)
The new default is n2d-standard-16 (AMD EPYC, 16 vCPU, 64 GiB, Spot). The Kata pool needs a series with
nested virtualization, so kata_machine_type is unchanged. (Keeping Intel and turning SMT back on with
--threads-per-core=2 is also possible, but Google warns it exposes MDS-vulnerable CPUs to side channels.)
Packing¶
Allocatable per node, from GKE's reservation formulas ([plan node sizes][nodes]; node pools created on GKE 1.36 or earlier, the conservative case) less an estimate of ~0.5 vCPU and ~1 GiB for the node's DaemonSets (Dataplane V2, metadata server, logging and metrics agents, CSI):
| n2-standard-8 (Intel, previous) | n2d-standard-16 (AMD, new) | |
|---|---|---|
| vCPUs gVisor pods see | 4 (SMT off) | 16 |
| CPU reserved by GKE | 80 m | 110 m |
| Memory reserved by GKE (+100 MiB eviction) | 3.66 GiB | 5.58 GiB |
| Left for sandboxes (estimate) | ~3.4 vCPU, ~27 GiB | ~15.4 vCPU, ~57.4 GiB |
| Sandboxes of 2 vCPU / 4 GiB, CPU requested in full (before) | 1 (CPU-bound) | 7 (CPU-bound) |
Same, CPU request 1 vCPU (HANGAR_K8S_CPU_REQUEST_FRACTION=0.5) |
3 | 14 (memory-bound) |
Why request half the CPU: a coding agent spends most of a turn waiting on the model, then bursts (tests, builds). Each sandbox keeps its 2-vCPU limit and its full 4 GiB; only the CPU it is guaranteed under contention halves. Memory is never overcommitted, so nothing is OOM-killed for its neighbours. gVisor enforces both limits only when all containers set them (they do) ([GKE Sandbox][sandbox]).
Services (environments.md) are sidecars in the sandbox's pod, so their requests add to
it, up to HANGAR_ENV_SERVICES_MAX_CPUS and HANGAR_ENV_SERVICES_MAX_MEMORY_GB (2 and 2 GiB by default; the repo
writes each service's size, the budget caps the sum). A sandbox that uses the whole budget requests 2 vCPU and 6 GiB:
7 a node (CPU-bound) instead of 14. Size the node count for the share of sessions whose repos declare services.
gVisor overhead. Google gives no number for gVisor ("dynamic overhead with low base memory for the userspace
kernel") and declares no pod overhead for it ([GKE Sandbox][sandbox]). Google doesn't say where the sentry's memory is
charged; gVisor runs it in the pod's cgroup, so expect it to come out of the sandbox's own 4 GiB rather than the
node's (estimate: tens of MB per sandbox). Agents that need the whole 4 GiB for themselves should get a larger
resources.memory_gb.
Disk is the next limit. gVisor writes a sandbox's root filesystem (the workspace lives there) to a file in the
container's upper layer on the node's boot disk ([rootfs overlay][overlay]), so it counts as ephemeral storage. GKE
keeps 10% of the boot disk for eviction plus min(50%, 6 GiB + 35%, 100 GiB) for the system ([plan node
sizes][nodes]): a 200 GB disk leaves ~104 GiB for environment images and 14 workspaces, a 100 GB one ~49 GiB.
Sandboxes set no ephemeral-storage request unless their environment sets resources.disk_gb, so a full disk shows as
DiskPressure evictions, not as a scheduling wait. Keep sandbox_disk_gb = 200 at 14 a node, and check
kubectl describe node on a busy node (not measured).
How many nodes¶
| Load | Sandboxes | Nodes at 14 a node |
|---|---|---|
| Typical day: ~60 live sessions (idle ones suspend after 30 min), ~12 jobs, warm pools | ~75 | 6 |
Peak: HANGAR_MAX_SANDBOXES (sessions, job slots raised to 24, best-of-N attempts) |
120 | 9 |
sandbox_max_nodes default (peak, plus a preempted Spot node's worth) |
~140 | 10 (was 4) |
Quotas to request before applying (check IAM & Admin → Quotas for your region): N2D_CPUS ≥ 160 for the
sandbox pool at its maximum (Spot VMs use preemptible CPU quota where the project has it), CPUS_ALL_REGIONS to
match, and SSD_TOTAL_GB ≥ 2,250: 10 sandbox nodes with 200 GB pd-balanced boot disks, the system pool at its
2 nodes of 100 GB, and the 20 GiB hangar-data volume (the default quota is 500 GB; pd-balanced counts against it,
[quotas][quotas]). Don't halve it with sandbox_disk_gb = 100 at 14 sandboxes a node (see Disk above). Spot VMs
take standard N2D_CPUS quota unless the region has been granted preemptible quota. hangar doctor and the create
error name the quota when a scale-up is refused for one.
The control plane¶
Measured on the fake provider at 60 sessions and 12–24 jobs: ~0.13–0.2 of a core on average (the event loop is one
thread), and 260–280 MB RSS (220 MB with six months of history: finished jobs and sessions stay in the store). Forks and snapshots add up to HANGAR_WORKSPACE_TRANSFER_MB
(1 GiB) of archives in flight, or one oversized archive at 2–3× its size. deploy/k8s/deployment.yaml now requests
500m / 1Gi with a 3Gi memory limit (was 250m / 512Mi / 2Gi), which fits the e2-standard-2 system node
(~6.1 GiB allocatable by the same formulas). Still one replica: state is one SQLite file.
Applying it¶
- Request the quotas above first; a refused scale-up shows as sandboxes stuck "waiting for a node".
- Apply while the sandbox pools are powered down (01:00–09:50 ET, after
power.sh downhas finished; with the power schedule the apply also brings back the gVisor pool's warm floor node, so runpower.sh downagain after), or suspend live sessions first (hangar session suspend <name>). The pool itself is not replaced: with the google provider ≥ 5.x (versions.tfasks for ≥ 6.0)machine_typeis an in-place node pool update, andterraform planshows~ update in-placeongoogle_container_node_pool.sandboxes. GKE carries it out by replacing every node, so every sandbox on the pool is killed. Powered down, the pool has no nodes and the update is quick. With nodes up, GKE surges a node at a time, which needs a node's worth of spare quota. If the plan shows the pool being destroyed and created (-/+), stop: something else in it changed (sandbox_spot, for one, forces a new pool). terraform applyindeploy/terraform/gke(or setsandbox_machine_type/sandbox_max_nodesin your tfvars to keep the old pool). The apply turns the pool's autoscaling back on at 0–10; the nextpower.sh upkeeps that range rather than putting back the 0–4 it saved atdown.- Deploy the control plane (
deploy/gke/up.sh): the new manifest's requests andHANGAR_MAX_SANDBOXES=120. The first create after the deploy rewrites each environment's warm-pool template once (the CPU request changed), so those creates are cold. - Add each engineer as a person (
hangar person add <name>), so the caps are per person. The whole procedure for this release, people included, is in releases/team-release.md.
On the first start, retention moves every settled record older than 7 days into the archive, 100 rows at a time in the background (20,000 records took 201 batches, ~1.3 s of lock time in all, at most 0.11 s at once, during the soak run below). Records a snapshot or a credential lease points at stay put and are never deleted: a fork of a snapshot inherits the snapshot's sandbox's credentials, harness, labels and git mode.
Upgrading and rolling back¶
The upgrade adds indexes and a sandboxes_archive table to the SQLite store when the new version first opens it
(no existing row is rewritten; 20,000 records index in well under a second). The previous version runs on the
upgraded file unchanged: it ignores the archive table and keeps the indexes up to date as it writes. What it can't
see is the archive. After a rollback, sandboxes stopped more than 7 days ago are gone from list_sandboxes
(include_stopped) and from lookups by id, which only matters for looking them up by hand. To give them back to the
old version, before rolling back:
kubectl -n hangar exec deploy/hangar -- python3 -c "import sqlite3; db = sqlite3.connect('/data/hangar.db'); \
db.execute('INSERT OR IGNORE INTO sandboxes(key, data) SELECT key, data FROM sandboxes_archive'); db.commit()"
The next upgrade archives them again. HANGAR_SANDBOX_RETENTION_DAYS=0 turns archiving off.
Jobs and sessions get the same treatment, and more indexes (person, owner, ticket, idempotency key, PR number,
branch, head commit, landing state, name, job, parent, conversation; and per person by date for listings), in the main
and the archive tables. The first start indexes the existing rows: ~2 s for 21,000 jobs and sessions on the 2-vCPU
VM, before hangar answers. Finished jobs untouched for HANGAR_JOB_RETENTION_DAYS (30) and settled sessions for
HANGAR_SESSION_RETENTION_DAYS (30) move to jobs_archive and sessions_archive, in batches of 100, and are never
deleted. Not archived, whatever their age: a job whose PR is open or approved, or merged or closed with its session
not yet ended, one with auto-fix triggers waiting, a schedule's last job (its next run updates that PR), a session
with checkpoints (it can always be resumed), and a session a job in the main table points at (the job goes first).
A record changed again (a follow-up on an archived job) moves back to the main table. Before rolling back to a version
without the archive, hand them back as above:
kubectl -n hangar exec deploy/hangar -- python3 -c "import sqlite3; db = sqlite3.connect('/data/hangar.db'); \
[db.execute(f'INSERT OR IGNORE INTO {t}(key, data) SELECT key, data FROM {t}_archive') for t in ('jobs', 'sessions')]; \
db.commit()"