Toolchains and hermeticity
Your action ran on a worker and failed on a missing compiler. Here is why that happens, and how to pin the toolchain so local and remote builds hash identically.
Who this is for: anyone whose actions execute remotely but fail on the worker for reasons that never happen locally. What you'll have at the end: a Nix-pinned toolchain that produces the same action digests on your machine and on a worker. Time: thirty minutes for the walkthrough.
Before you start
A cache serving hits to your build. If you don't have one yet, start with Getting started.
The problem
An action is a closed description of some work: input digests, a command line, an environment, and platform properties. If the compiler your command invokes isn't in that description, the action is a lie: it works on your machine because your machine happens to have the compiler, and it fails on a worker that doesn't.
This is not a NativeLink behaviour. It is what remote execution is: the protocol will faithfully reproduce whatever you described, and no more. Caching lets you get away with an under-described action because the only machine that ever ran it was yours. Execution doesn't.
Two ways out exist.
- Make the environment part of the image. Bake the toolchain into the
worker's container, and route actions to it with the
container-imageproperty. Minimal, and it's what most fleets do first. Containers and images covers this path. - Make the toolchain part of the action. Pin every tool at a content-addressed path so the toolchain itself is an input the CAS knows about. This is Local Remote Execution, and it's the rest of this page.
The second is strictly stronger: it gets you identical action digests locally and remotely, which means a local build and a remote build share cache entries instead of each maintaining their own. What LRE is and why is the conceptual write-up; this page is the mechanism.
You don't need LRE to use remote execution
A worker image with your toolchain in it is a legitimate answer, and it's the right one if you don't already use Nix. Read the problem statement above, then decide which of the two paths you're on.
What ships in @local-remote-execution
Three toolchain families, and they do not behave the same way once you leave
x86_64-linux.
| Family | Bazel target prefix | Platforms with a native config |
|---|---|---|
| Rust | @local-remote-execution//rust/... | aarch64-darwin, aarch64-linux, x86_64-darwin, x86_64-linux |
| C++ | @local-remote-execution//generated-cc/... | x86_64-linux only (container nativelink-worker-lre-cc) |
| Java | @local-remote-execution//generated-java/... | x86_64-linux only (container nativelink-worker-lre-java) |
The flake module always emits the Rust platform and toolchain flags, and adds
the C++ ones only on Linux, where the generated config is x86_64 and
carries a container-image property. On an x86_64-linux host that means
C++ actions can run locally out of the same /nix/store paths the worker
image has, or remotely on that image, with the same digest either way. On
macOS there is no local C++ or Java toolchain at all, so those actions can
only execute on a remote worker running the matching image, and even a
Nix-pinned toolchain costs a network round-trip: LRE gives you
hermeticity, not offline-ness.
This is the single most common surprise
Reading "local remote execution" as "everything runs locally" leads to a confusing first hour on macOS. What is local is the toolchain definition. Whether the action executes locally depends on which family it uses.
Set it up
Install Nix with flakes enabled
The next-gen installer is the shortest path. If you already have Nix, make sure
experimental-features = nix-command flakesis in yournix.conf.The pinning is the guarantee here: every tool lives at a content-addressed
/nix/storepath, and that path is what makes action hashes match across machines. No non-Nix version of this mechanism exists.Pull the template
mkdir my-lre-test && cd my-lre-test nix flake init -t github:TraceMachina/nativelink#bazelThis writes a
flake.niximportingnativelink.flakeModules.lreand pinninglre = { inherit (pkgs.lre.lre-cc.meta) Env; }, plus ahello-world.cppexample, itsBUILD.bazel, andplatforms/BUILD.bazel.bazelis the only template the flake ships; Examples and templates has the full list and what to do if you want a different starting point.Initialise git before you enter the shell
git init && git add -AThis step is not optional and it fails quietly.
Skip this and lre.bazelrc is silently never written
The flake module's install script checks for a
.gitdirectory before generating anything. In a plain non-git folder,nix developprints this and carries on without writinglre.bazelrc(no error, no toolchain):WARNING: lre: .git not found; skipping installation.Enter the dev shell
nix developThis fetches the Nix-pinned toolchain and writes
lre.bazelrc(a symlink into the Nix store). Onaarch64-darwinthe generated file looks like this, abbreviated; your store paths will differ:# These flags are dynamically generated by the lre flake module. # # PATH=/nix/store/...-binutils-wrapper-.../bin:/nix/store/...-customClang/bin:... # CC=/nix/store/...-customClang/bin/customClang # Bazel-side configuration for LRE. build --define=EXECUTOR=remote build --extra_execution_platforms=@local-remote-execution//rust/platforms:aarch64-apple-darwin,@local-remote-execution//rust/platforms:aarch64-apple-darwin build --extra_toolchains=@local-remote-execution//rust:rust-aarch64-darwin build --extra_toolchains=@local-remote-execution//rust:rustfmt-aarch64-darwin build --platforms=@local-remote-execution//rust/platforms:aarch64-apple-darwinNote that it registered only the Rust platform and toolchains even though the flake asked for the
lre-ccEnv: the module emits the Rust lines on every system and adds thegenerated-ccplatform andcc-toolchainlines only on Linux.Envonly decides which store paths are listed (and therefore fetched) in the commented header. The template's own.bazelrcseparately adds--extra_execution_platforms=@//platforms:lre-ccfor the C++ example, which is the container-based platform from the table above.Point
user.bazelrcat your clusternix flake initwrites auser.bazelrcwith the literal stringTODOas every value. That is what lands on disk, not a placeholder left in this page:build --remote_cache=grpcs://TODO build --bes_backend=grpcs://TODO build --remote_timeout=600 build --remote_executor=grpcs://TODOFilled in against a plain local cluster: no TLS, so
grpc://, and no BES endpoint to point at:build --remote_cache=grpc://127.0.0.1:50051 build --remote_timeout=600 build --remote_executor=grpc://127.0.0.1:50051A hosted or self-managed cluster is the same shape with
grpcs://and real hostnames, plus abes_backendline if you want build results streamed.If you build before editing this file, Bazel fails on the literal hostname
TODO:[8 / 10] Compiling src/hello-world.cpp; 0s remote, remote-cache ERROR: BUILD.bazel:3:10: Compiling src/hello-world.cpp failed: Failed to query remote execution capabilities: UNAVAILABLE: Unable to resolve host TODOEverything before that DNS failure worked: the Nix toolchain, the platform wiring, and the
lre-ccexecution platform selection.0s remote, remote-cachemeans Bazel had already committed to executing remotely.Build
bazel build hello-worldWith a real endpoint and a worker running the
nativelink-worker-lre-ccimage, this compilessrc/hello-world.cppon that worker and returns the binary through the same CAS your local Bazel reads from. Run it again and it's a cache hit.
You did it right if
lre.bazelrcexists in the workspace root and contains--extra_toolchainslines.bazel buildreports the action asremote(forlre-cc), or, run without--remote_executor, builds locally with the pinned toolchain (Rust on any of the four systems, C++ onx86_64-linux).- The same target built on two different machines produces a cache hit on the second. This is the actual test of hermeticity; same-machine re-runs prove nothing about it.
No container-capable worker handy?
You can still validate the cache half.
Local testing starts a NativeLink
instance on localhost, which is enough to exercise --remote_cache
against real credentials before you also need a worker matching
lre-cc's platform requirements.
Adding LRE to a project you already have
The template bootstraps a new workspace. For an existing one you need the
flake-parts module wiring, the bazel_dep override, and a try-import of
the generated lre.bazelrc.
has that path, and also covers verifying the setup against a Kubernetes cluster.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
WARNING: lre: .git not found; skipping installation. | nix develop ran outside a git repo | git init && git add -A, then re-enter the shell |
Unable to resolve host TODO | user.bazelrc still has the generated placeholders | Replace all three TODOs |
| Local and remote stop sharing cache hits | lre.bazelrc has stale /nix/store paths | Compare against the @local-remote-execution module at the commit pinned in MODULE.bazel / flake.nix; version drift is the usual cause |
| C++ actions queue forever | No worker advertises the lre-cc container image | See Containers and images and Platform properties |
| Action runs remotely but can't find a header | The toolchain is pinned, the inputs aren't | Check that the header is a declared Bazel dependency, not something on the include path by accident |
FAQ
The other path to a reproducible worker environment, and what the
container-image property actually does.
The conceptual write-up behind the mechanism on this page.
Platform properties
How the scheduler decides which worker gets an action, why a mismatch queues forever instead of failing, and how to read the matching diagnostics it already emits.
Containers and images
What the container-image property actually does (and doesn't), plus how to build worker images and keep what your build requests in sync with what your fleet advertises.