ALL ARCHITECTURE

Architecture

The Build Fleet

Ephemeral CI agents, where their dependencies come from, and why a fleet that worked for a year starts failing on a busy Tuesday.

scroll to render
01

What you're looking at

Pull requests queue work; an autoscaler starts one short-lived pod per job. Each pod checks out the repository into a workspace volume, resolves dependencies, builds, and dies. Because the pods are ephemeral, everything they need has to come over the network every time unless something is deliberately cached — and everything they send goes out through a shared NAT gateway.

02

Why a single egress address is a ceiling

Public package registries throttle by source address. A fleet that egresses through one NAT address looks, to an upstream registry, like one very busy client. Combined with no persistent dependency cache, every build cold-downloads the full graph from that one address. That is a fixed ceiling, and nothing about it changes until a busy day crosses it — which is why it reads as a sudden outage rather than a capacity problem.

03

The fix is a mirror, not a retry

Pointing builds at a pull-through remote repository in your own registry means the agent only ever talks to you, and your registry fetches upstream server-side and caches it. Per-address throttling becomes structurally impossible rather than less likely. Adding NAT addresses buys relief; persisting the cache makes builds fast; the mirror is what makes the failure mode go away.

04

The other failure: storage, and a poisoned cache

A workspace on an ephemeral volume counts against the container's storage limit, so a big checkout plus a cold dependency download can evict the pod mid-job. The tear-down then produces errors naming whatever process died with it, which sends you chasing the wrong component. Worse, the cache layer only writes on a miss and never overwrites a hit — so if the first build on a new cache key is killed mid-download, it saves a partial archive that every later build then hits, re-downloads around, and never refreshes. A closed loop that only breaks when one run is allowed to finish.

Inspect it yourself

  • Separate eviction from cancellation

    kubectl -n NS get events --sort-by=.lastTimestamp | grep -iE 'evict|oom'

    Lost-agent and process-spawn errors are tear-down artifacts of a pod dying mid-job, never the cause. The event says whether it was evicted or simply cancelled by a rapid re-push.

  • Check the limit, not the disk

    kubectl -n NS exec POD -- df -h /tmp/work

    Free space on the node alongside an eviction message means it was limit enforcement, not physical exhaustion. That reframes the entire investigation.

  • Count your egress addresses

    gcloud compute routers nats describe NAT --router ROUTER --region REGION --format='value(natIps)'

    One manually assigned address for a whole CI fleet is the finding. Compare it against the workload gateway, which usually has several.

  • Spot a poisoned partial cache

    grep -E 'cache hit|Downloading' build.log | head -30

    A reported cache hit followed by downloads of everything means the stored archive is a sliver. Changing one line in the requirements file changes the key, and if that first build dies, the partial is what every later build inherits.

Read the source

Components

  • PULL REQUESTSbursty
  • AUTOSCALERone pod per job
  • AGENT PODSephemeral
  • WORKSPACEcounts to limit
  • DEP CACHEsaves on miss only
  • NAT GATEWAYshared egress
  • REMOTE MIRRORpull-through
  • PUBLIC REGISTRYthrottles per IP
  • ARTIFACTSsigned images

Flows

  • prscaler
  • scaleragentsscale out
  • agentswork
  • cacheagentsrestore
  • agentsnat
  • natmirror
  • mirrorpublicserver-side
  • natpublic429 per-IP
  • agentsartifactspush