6.5.9 Reusing CI Runners Safely
Reusing a CI runner is not one optimization. It is a decision to retain several different kinds of state—source checkout, Bazel server and output base, explicit local caches, and credentials—each with its own compatibility and isolation rules. A runner is safe to reuse only when the next job can prove which state it inherited, why that state is compatible, and how contamination would be detected and recovered.
Inventory State Before Choosing a Lifecycle
A remote action cache does not make the CI runner stateless. Loading, repository evaluation, and analysis still happen in the Bazel server that coordinates the build. The server retains the in-memory graph, while the output base contains outputs and Bazel's internal working state. The repository cache and disk cache are separate stores, and the checkout has state that Bazel does not manage at all.1
Start with an explicit retained-state inventory:
| State | Why retain it | Isolation question |
|---|---|---|
| checkout and workspace files | avoid a full clone and reuse filesystem metadata | can one job observe files, generated data, or untracked changes from another? |
| Bazel server and output base | preserve the in-memory graph and incremental output state | can incompatible or concurrent invocations reach the same server and mutable tree? |
| repository and repository-contents caches | avoid downloading or reconstructing external repositories | which dependency, repository-rule, and producer inputs make an entry reusable? |
| local disk cache | reuse action results without a remote round trip | are writers trusted, entries namespaced, and corruption recoverable? |
| credentials and runner services | authenticate source, cache, BES, registry, and release access | can a later or less-privileged job read or use an earlier job's identity? |
These rows must not collapse into one warm=true flag. For example, an
ephemeral VM can attach a persistent cache volume, so its process memory is new
while its cache is old. A persistent host can start every job with a fresh
output base. A snapshot can clone memory and disk together. “Stateful” and
“ephemeral” describe lifecycle choices, not reliable security or freshness
properties.
The CI cache baseline demonstrates only one deliberately narrow layer of this inventory: fresh, separate output bases can share a remote cache, and a job shuts Bazel down before saving explicit caches. It does not qualify reuse of a complete runner allocation, tenant separation, or checkout cleanup; those missing boundaries are the point of the rest of this article.
3.6.2 Bazel Server Lifecycle in CI introduces why a cold server repeats loading and analysis. Here the operational question is whether the saved time is worth the larger compatibility and recovery contract. A case study from Uber illustrates the distinction: its jobs matched reusable containers using dimensions such as job name, Bazel version, image hash, agent identity, configuration files, and environment, while using read-only dependency caches and a remote action cache as separate layers.2
Give Every Concurrent Job an Exclusive Mutable Domain
Bazel serializes invocations that use the same output base. The output base also contains the lock that prevents multiple Bazel processes from mutating it concurrently. If two CI jobs need to run at the same time, assign each an exclusive output base—or deliberately queue them behind the same server. A different checkout path alone is not proof of isolation, because startup options can direct both jobs to the same output base.3
One safe assignment model is:
runner allocation
checkout: /work/jobs/<allocation-id>/repo
output base: /work/jobs/<allocation-id>/output-base
credentials: short-lived identity for <allocation-id>
shared read-only state: versioned dependency seed
shared writable state: only stores whose concurrency and trust contract allows it
The runner-reuse qualification fixture makes the allocation boundary concrete. Its qualification script builds two clean allocations with distinct output bases, admits a matching reuse, then quarantines an identity mismatch and an untracked checkout file before proving a clean recovery build. It models runner-owned lifecycle decisions around a real Bazel action; credential scoping and cross-tenant authorization remain deployment tests for the CI provider and secret system.
The allocation identity should be unguessable to other tenants and should own all mutable directories and local processes for the job. Do not reuse a Bazel server simultaneously between users merely because Bazel has a lock: waiting for a lock prevents concurrent output-base mutation, but it does not establish tenant authorization, credential separation, or workspace cleanup.
After the job, reconcile results before promoting any warm state. A successful process alone is not enough if the intended CI work or its artifacts are incomplete; use the complete-result boundary from 6.5.8 Complete CI Results with BEP/BES. Promotion should also reject a runner with leaked processes, mounted filesystems, unexpected writable files, exhausted disk or file descriptors, or a failed cleanup check. Persistent pools otherwise invite resource leaks that accumulate across jobs.4
Credentials deserve the strictest rule: inject the minimum identity for the current job, keep it outside reusable workspace and cache content, and revoke or remove it before returning the runner to the pool. A snapshot that contains a credential is a credential copy, even if the snapshot was created only to save an analysis cache. Release credentials should not enter an ordinary test-runner cohort at all.5
Reuse by Compatibility, Not by Git SHA Alone
An immutable source revision is necessary evidence, but it does not identify a complete Bazel invocation. Reuse can also depend on the Bazel binary and startup options, checked-in and injected rc files, rules and module state, repository inputs, supported platform and toolchain, runner image, and job identity. Define a compatibility record for each retained layer rather than one global cache key:
{
"checkout": {"repository": "<repo-id>", "revision": "<commit>"},
"bazel_state": {
"bazel_version": "<version>",
"startup_profile": "<startup-options-id>",
"build_configuration": "<configuration-id>",
"runner_image": "<image-digest>"
},
"dependency_seed": {"schema": 3, "producer": "<image-build-id>"},
"security_domain": "presubmit-untrusted"
}
This is a design sketch, not a Bazel file format. Its purpose is to make the runner's claims reviewable. When one dimension cannot be proven compatible, discard or bypass only the affected layer if isolation permits it. A changed checkout may still use a trustworthy repository cache; a changed Bazel startup profile may require another output base; a changed security domain requires a new allocation regardless of cache warmth.
Bazel does invalidate retained analysis state for changes it knows affect the configuration. That protects Bazel's internal incremental model, but it cannot validate every surrounding CI promise. It cannot prove that a checkout cleanup removed an untracked secret, that an image was patched under the same tag, that a credential is still scoped correctly, or that an external cache volume came from the expected producer. The runner owns those lifecycle facts. The deeper Skyframe mechanics are covered by 5.9.4 Incrementality Mechanism.
Avoid routine bazel clean as a substitute for this model. Cleaning discards
useful evidence and Bazel-managed incremental state while leaving unrelated
runner state—credentials, untracked workspace files, leaked processes, mounted
volumes, and external caches—untouched. Quarantine the allocation, capture its
identity and diagnostics, then rebuild or discard the specific unsafe layers.
Decide: Two presubmit jobs use different checkout directories on one host,
but both invoke Bazel with the same --output_base. Their source revisions and
credentials differ. Is the setup safe because Bazel serializes access with a
lock?
Reveal
No. The lock prevents concurrent mutation but makes both jobs clients of the same mutable Bazel domain. It neither proves that the retained server state is compatible nor isolates identities. Give the jobs exclusive output bases and credential domains, or deliberately serialize a trusted, compatibility-matched cohort with a documented cleanup contract.
Choose a Lifecycle from Measured Trade-offs
There are four common lifecycle shapes:
- Fresh runner, restored explicit caches. Startup and analysis are cold, but the mutable job boundary is simple. Cache archives or volumes still need provenance, namespaces, concurrency semantics, and credential-free contents.
- Persistent runner or hot pool. Checkout, server, and disks can stay warm. The service must schedule compatible work, isolate concurrent allocations, health-check hosts, and recycle them before accumulated state becomes unsafe.
- Prebuilt disk image or seed. Jobs start fresh from a versioned read-only dependency and tool seed. This reduces setup without treating the result of an arbitrary prior job as trusted; rebuilding and rotating the seed is part of the design.6
- Cloned VM or remotely hosted Bazel server. A snapshot can preserve both memory and disk, including a warm server. This is powerful but makes snapshot keying, copy-on-write isolation, promotion, credential scrubbing, and corrupt snapshot recovery part of the service contract.7
These are variants of the same state problem, not unrelated tricks. Compare them on reproducible cohorts from 6.1.2 Measuring Builds Fairly. Measure cold startup, steady-state latency, tail latency after incompatible work, queueing, storage and transfer cost, and recovery time. Gate every result on correctness and isolation. “Persistent is faster” is not a conclusion if warm jobs queue for scarce compatible runners; “ephemeral is clean” is not a conclusion if every job restores the same contaminated archive.
Do not generalize vendor performance figures or “drop-in” claims into Bazel semantics. Firecracker snapshot cloning and hosted remote Bazel are named implementations with reported case-study results, not portable guarantees.8 Likewise, preemptible instances and disk cloning can lower a particular fleet's startup cost, but they do not remove the need to qualify the image, disk, and identity used by each job.9
Prove Reuse with Mutation and Recovery Tests
Qualification needs negative-path tests, not only a warm-build benchmark. Create a matrix that mutates one compatibility dimension at a time:
- change the Git revision and leave an untracked file in the old checkout;
- change Bazel version, startup options, build configuration, platform, toolchain, rules, or runner image;
- run two allocations concurrently with distinct users and credentials;
- corrupt or replace one explicit cache layer;
- kill the Bazel server or host during a job;
- exhaust disk space or leak a process, port, mount, or file descriptor;
- expire credentials while retained state remains warm.
For every mutation, assert which layers are reused, which are invalidated, and which cause the allocation to be quarantined. Then assert recovery: the next job must obtain a clean compatible allocation, complete its intended work, and publish evidence tied to its own revision and identity. Also retain a cold control cohort; without it, a pool that silently stopped reusing state could pass every isolation test while delivering none of the intended benefit.
Record the inventory and compatibility decision with each invocation. When a failure appears only on reused runners, operators can then compare checkout, output-base, server, cache, image, and credential provenance rather than wiping everything and hoping the symptom disappears. Repository checkout acceleration at larger scale continues in H.3.1 Git Workspace; the safety boundary here is that a faster snapshot or mirror must still resolve to the intended immutable source state.
Safe runner reuse begins by separating checkout, Bazel server/output base, explicit caches, and credentials. Give concurrent jobs exclusive mutable and identity domains; reuse each layer only under a versioned compatibility rule, and never treat a workspace path, Git SHA, live process, ephemeral host, or remote cache as proof that the rest of the runner is compatible.
Choose fresh runners, hot pools, seeded images, or snapshots from measured startup, warmth, isolation, cost, and recovery trade-offs. Prove the choice with cross-user concurrency tests, one-dimension-at-a-time invalidation mutations, contamination injection, and recovery controls whose complete results remain bound to the current job's revision and identity.
Check your understanding · 3 questions
1.A CI job receives a new checkout directory, but its startup configuration points Bazel at an output base used by another allocation. What is the safe conclusion?
Select one answer
2.Which checks belong in a decision to reuse a retained runner allocation?
Select all that apply
3.Classify each retained-state claim:
Choose True or False for each sentence
Footnotes
-
Why is my Bazel build so slow? — separation of in-memory analysis state, repository cache, and remote action reuse ↩
-
How Uber halved monorepo build times with Buildkite — compatibility-labelled persistent containers and separate local and remote cache layers ↩
-
Calling Bazel from scripts — output-base locking, concurrent invocations, and multiple-server choices ↩
-
Estimating the work for Bazel CI/CD — persistent-runner resource leaks, health checks, and recycling ↩
-
Estimating the work for Bazel CI/CD — separation of test and delivery credentials and pipelines ↩
-
Self-Driving Scaling — versioned disk images preloaded with external dependencies, operating-system state, certificates, and configuration ↩
-
Reusing bazel's analysis cache by cloning micro-VMs — memory-and-disk snapshots, copy-on-write clones, configuration keying, and snapshot-discard recovery ↩
-
Introducing Remote Bazel — named hosted-server and Firecracker workspace-reuse implementation with scoped performance results ↩
-
CI on Spot: Bazel at 30% the Cost With Zero Downtime - Rahul Roy, Glean — case-specific disk cloning, preemptible fleet operation, and cold-start measurements ↩