NATIVELINK
How do Caddy and the NativeLink scheduler admit a call and send an action to a worker?
Binds: architecture, code
Caddy admits a request that carries a bearer token or a worker certificate
Caddy serves nativelink.loadbudget.com over TLS. A certificate from
letsencrypt.org comes from the HTTP-01 challenge on port 80. The Cloudflare
record is DNS only, so the challenge and the gRPC stream reach the host
unchanged.
Two tokens exist. Caddy sends each token to its own listener of NativeLink. Any
other request gets status 401. Caddy checks no method. After a rotation, Caddy
also admits the previous value of each token. infra/docs/operations/SECRETS.md
states the rotation.
nativelink-tokens keeps each token in its own file under
/etc/nativelink-tokens, readable by root alone. It writes a route for each
token into /home/nl-server/caddy/tokens/routes.caddy. The Caddyfile imports
it. Without a token, the file holds no route. Every request then gets status
401. Caddy mounts the directory and not the file. A file that a rename replaces
reaches the container through a directory mount alone.
A NativeLink worker off the host shows a client certificate. kmacmini and
lb-minisforum each run such a NativeLink worker. The play
infra/ansible/nativelink-worker-certs.yml writes the trust pool on the host.
The pool holds the certificate of each one. lb secret rotate runs the play.
Caddy mounts the directory and not the file, as with the token routes. Caddy
verifies a certificate against the pool, and it sends a verified client to the
workers listener. A client with no certificate must show one of the two
tokens.
The matcher reads {http.request.tls.client.certificate_der_base64}.
Without a certificate, {tls_client_fingerprint} still holds a value: the
SHA-256 of an empty input. A matcher on the fingerprint then admits every client
to the workers listener.
With client authentication on, Caddy refuses a request whose Host differs from
the server name of the TLS handshake. It answers status 421.
Each route holds its tokens as text. Inside a header matcher, the placeholder
form {env.NAME} does not expand, and every request then gets status 401.
The scheduler sends an action to a worker by its platform
infra/ansible/roles/nativelink/files/server.json5 matches OSFamily, ISA
and cpu_arch exactly. Each worker states its values in its own configuration.
The worker on the host states OSFamily linux. So an action that names
another OSFamily reaches that worker never.
The scheduler also matches macos_build and xcode_build exactly. They
name the builds of macOS and of Xcode of the Mac worker, so that an update of
either gives each Apple action a new key. The worker on the host states neither,
and no Linux action names them.
The scheduler matches distribution exactly too. It names the Ubuntu
release of the image of the Linux worker, such as ubuntu26.04. A new release
gives each Linux action a new key. A new build of the image on the same release
keeps the key, because an update within a release of Ubuntu keeps its ABI. The
Mac worker does not state it.
The scheduler matches pool exactly too. The simulator NativeLink worker of
kmacmini states pool simulator, and no other worker states pool. So a UI
test action that names it runs on that worker alone.
infra/docs/development/MAC_WORKER.md, section “The simulator NL worker runs
the UI tests in a macOS job VM”, states why.
A property that an action does not name restricts nothing. So an action with
an empty platform matches every worker. A key that the scheduler does not know,
such as cache-silo-key, also restricts nothing, because no worker states it.
Each action reserves the CPUs that its platform names. The scheduler reads
cpu_count as a minimum. While an action runs, the scheduler subtracts the
cpu_count of the action from the count of its worker. It adds the count back
after the worker uploads the outputs. Both platforms of bazel/platforms and
the default properties of .bazelrc name cpu_count 1000. A target whose
action runs more than one process names that count in its own exec_properties.
An action that names no cpu_count reserves nothing.
cpu_count counts thousandths of a CPU. Each worker advertises its CPUs
times 1000, and each action asks for 1000 for each CPU. The scheduler learns CPU
in that unit alone. resource_sketch.rs in nativelink-scheduler/src of
NativeLink v1.8.3 stores it as cpu_time_ms * 1000 / wall_time_ms. It reserves
that number in cpu_count with no change of unit. With whole CPUs, an action
that used half a CPU asked for about 500 CPUs. No worker had that many, so the
scheduler refused the action.
The worker on the host advertises twice its CPUs. worker_cpu_overcommit in
infra/ansible/roles/nativelink-worker/defaults/main.yml sets the factor. Each
action reserves the 95th percentile of its average CPU plus 20%, so the
reservations add up to more than the actions use. With the factor at 1, the
worker left about half of its CPUs idle while actions waited. --cpus still
caps the container, so a full worker shares CPU time and kills no action. Memory
takes no factor.
The scheduler raises each reservation to what the worker measured. The
historical_resource scheduler of server.json5 learns from the workers. Each
worker samples the processes of each action every 250 ms. The scheduler keeps
the 95th percentile of each kind of action, by target, mnemonic and command, for
7 days. It reserves that value plus 20%, in cpu_count and memory_kb. It
never lowers a value that the client sent. SCHEDULER_SKETCH_STORE keeps what
it learned across a restart. The 95th percentile, and the defaults of 20% and 7
days, come from HistoricalResourceSpec in
nativelink-config/src/schedulers.rs of NativeLink v1.8.3. The 250 ms is
RESOURCE_USAGE_SAMPLE_INTERVAL in
nativelink-worker/src/running_actions_manager.rs of the same version.
An action with no history reserves no memory. The worker on the host takes
any number of actions, because max_inflight_tasks is 0. So the reservations
alone bound how many run at once. The worker kills an action that grows past its
memory reservation plus 20%. The scheduler then queues it again at the next step
of ladder_kb in server.json5. Past the top step, the action reserves the
whole NativeLink worker. When the kills pass max_steps of
MemoryEscalationSpec, the scheduler fails the action. A learned reservation
can sit far below what the action needs. Doubling from there can spend every
step and still fall short, and the first step of the ladder starts above it. An
action with no history carries no memory_kb, so the NativeLink worker does not
kill it. A burst of such actions can fill the memory of the container, and the
kernel then kills inside it. That happens on a cold cache.
A worker short of free memory takes no action that reserves more. Each
worker reports its free memory with each keepalive. With live_memory_veto, the
scheduler skips a worker that reports less free memory than the memory_kb of
the action. It catches an action that reserved too little and still runs.
An action that times out runs once. A NativeLink worker reports a timeout as
an error with DeadlineExceeded. The scheduler counts that error, and an
internal error of the action, against max_job_retries, then queues the action
again. server.json5 sets max_job_retries to 0, so the scheduler completes
the action with the first such error. The default is 3. A lost NL worker and an
unreachable store spend budgets of their own, so they still queue the action
again. nativelink-config/src/schedulers.rs and
nativelink-scheduler/src/simple_scheduler_state_manager.rs, at the NativeLink
version in infra/nativelink-worker/Containerfile, state both.
The server pushes its metrics to an Alloy beside it
NativeLink pushes its metrics, logs and traces over OTLP to the address in
NL_OTEL_ENDPOINT. It serves no /metrics, so no agent can scrape it. The
exporter speaks gRPC alone, and the Prometheus agent of lb-hetzner02 takes no
OTLP over gRPC. web/apps/docs/content/docs/operate/observability.mdx and
nativelink-util/src/telemetry.rs of NativeLink state both, at the version in
infra/nativelink-worker/Containerfile.
Alloy runs as a container of nl-server on network lb. From that network,
the server reaches no port of lb-hetzner02, host.containers.internal
included. alloy.container holds the fixed address 10.89.0.200, outside
IPRange of lb.network. The unit of the server names that address in
NL_OTEL_ENDPOINT.
infra/ansible/roles/nativelink/templates/alloy.alloy.j2 holds the pipeline:
| Stage | What it does |
|---|---|
otelcol.receiver.otlp |
Takes the metrics, and drops the logs and the traces |
otelcol.processor.attributes |
Deletes execution.action_digest |
otelcol.exporter.prometheus |
Gives each metric a name of Prometheus |
prometheus.relabel |
Adds the prefix nativelink_ to each name |
prometheus.remote_write |
Pushes with the label host to Grafana Cloud |
The scheduler counts each completed action with its digest. Each action then makes a series of its own. So Alloy deletes the digest before the push.
An endpoint that refuses stops no server. NativeLink starts, and it answers
/status, while nothing listens at the endpoint. Its metrics reach Alloy once
Alloy starts. An endpoint that is no URL with a scheme, a host and a port stops
the server at its start. So does a host name that does not resolve, so the unit
names an address.
Alloy and the server run only where the metrics token exists. The role
nativelink reads push.json and the token under
/etc/loadbudget/grafana-cloud/. Until a rotation writes both, no Alloy runs,
and the unit of the server sets no NL_OTEL_ENDPOINT. Alloy reads a copy of the
token, /home/nl-server/alloy/token, that only nl-server reads. The role
writes it at each deploy, and infra/ansible/metrics-token.yml writes it at
each rotation.