Tutorial: a GPU cluster end to end
A step-by-step walkthrough with the real CLI commands - create a site and a pool, mint a join token, join a GPU server with bcd, watch it get adopted, reserve GPUs, use the CDI device names on the node, release them and follow the events.
This tutorial builds a small GPU cluster with the commands that exist today: a site and a pool, a join token, a GPU
server joined with bcd, its adoption, a reservation of four GPUs, the device names on
the node, the release, and the events each step publishes. Every command below is in the better-compute help
manifest or in bcd's own flags. The pages linked from each step explain the parts in depth; this page puts them in
order.
The quickstart does the second half (reserve, bind, release) with simulated machines on a laptop. This tutorial adds the first half, the part a real server goes through.
What you need
| Piece | Why | See |
|---|---|---|
| A control plane with a gateway | bcd only dials out, to a gateway over mTLS on port 443 (8443 in the examples) | deployment, examples/postgres |
| The machine PKI | JoinService's keys and the gateway's serving CA, whose pins the node carries | PKI |
| Better IAM wired in, and a root administrator's token | Every command below is decided by IAM as the token | Better IAM |
A Linux server with NVIDIA GPUs, the driver and nvidia-smi | bcd reads GPUs through nvidia-smi | host configuration |
| Go 1.26 or later | To build bcd | installing bcd |
examples/postgres/README.md is the reference for the control plane: pki init, pki issue-gateway, migrate,
and serve with the api, worker and gateway roles from one configuration, which also mounts GET /watch for
step 9. Its IAM section shows the bridge to wire; until it is wired no credential authenticates.
Act as the platform operator
Point the CLI at the API and give it the token of a root administrator. With BETTER_COMPUTE_URL set, every command
is an HTTP call to that server (plain http is allowed only for loopback addresses); BETTER_COMPUTE_TENANT is the
default --tenant of every token command.
export BETTER_COMPUTE_URL=https://compute.example.com
export BETTER_COMPUTE_TOKEN='biam_…' # a root administrator's token
export BETTER_COMPUTE_TENANT=root # the root tenant's id
better-compute api system.whoamibetter-compute help lists every command, and better-compute help <command> shows one; see
the CLI for credentials, output formats and --query.
Create a site and a pool
A is where machines live; a is a set of machines of one GPU
model with one policy. A platform site is shared (offered to tenants) or dedicated, and only the root tenant
creates those (sites).
export SITE=$(better-compute sites create --kind shared --region eu-1 --key site-eu-1 --query id)The pool's policy decides what a joining machine must pass before it takes work. Write it to a file:
{
"acceptance": [{ "kind": "diag.nvml" }],
"expect": { "gpuCount": 8 }
}export POOL=$(better-compute pools create --site $SITE --class metal-host --gpu-model H100 \
--policy-file pool-policy.json --key pool-eu-1-h100 --query id)
better-compute pools get $POOL--class metal-hostmakes joining machineshostnodes, whose GPUs are reserved as holds (classes). The agent defaults tobcd, which join tokens require.--gpu-modelis the GPU family inventory derives from the card's name, such asH100forH100-SXM5-80GB. A pool holds one model (one GPU model).diag.nvmlwithexpect.gpuCountadopts a machine only when it shows 8 healthy GPUs (see acceptance checks). Set the count to what your server has.--keymakes the call retry-safe: the same key returns the same site or pool.
The pool's owner, here the root tenant, gets an effectively unlimited gpu quota on it. To let a customer tenant
reserve from it, offer it with a quota. The resource is gpu (singular): a quota row for any other name grants
nothing.
better-compute pools offer $POOL --to acme --quota gpu=16See offerings.
Mint a join token
A lets a limited number of machines join one pool for a limited time. Bind it to the machine you mean when you know one of its identifiers, and write it to a file instead of the terminal:
better-compute join-tokens create --pool $POOL --ttl 2h --max-uses 1 \
--expect-serial SN-7701 --key join-sn-7701 --out ./join-token--out writes a new file with mode 0600 and never overwrites one. The token is shown once; the control plane keeps
only a hash of its secret. --ttl takes 1m to 24h. A machine whose firmware reports no product UUID or serial (WSL
2 and many VMs) has no trusted hardware key, and a token without an expect binding is refused for it; bind such a
token with --expect-mac instead, as tests/e2e/bcd-host-real.test.ts does. See
binding a token to one machine.
Join the server with bcd
Build bcd for the server. The default build reads GPUs through nvidia-smi and needs no cgo:
cd daemon
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/bcd ./cmd/bcdThere are two ways to install it.
With the install script. Serve the binary from the API (downloads, which the examples set from
BETTER_COMPUTE_DOWNLOADS_DIR; see serving the binaries) under
goreleaser's file name bcd_<version>_linux_amd64, then generate a script that pins its digest and the PKI's keys:
better-compute daemon install-script --pool $POOL --site $SITE \
--gateway-url https://gw.eu-1.example.com:8443 --base-url https://compute.example.com \
--bcd-version 0.1.0 --sha256 amd64=$(sha256sum bin/bcd | cut -d' ' -f1) --out install-bcd.shCopy install-bcd.sh and join-token to the server and run the script as root, with the token on standard input:
sudo sh install-bcd.sh < join-tokenIt checks the host, downloads and verifies bcd, writes /etc/bcd/config.toml with the pins, runs bcd doctor, pipes
the token into bcd join and enables bcd.service. See what the script does.
By hand, from the well-known document. When the control plane publishes its gateway
(BETTER_COMPUTE_GATEWAY_URL in the examples), a machine with no config.toml can join with the control plane's URL
and three pins. Print the pins on the control plane, from the sealed bundle:
better-compute pki show --query servingCaSpkiPin
better-compute pki show --query joinSigningKeyPin
better-compute pki show --query joinEncryptionKeyPinThen, on the server, as root, with bcd copied to /usr/local/bin/bcd:
bcd join --server https://compute.example.com/api/compute \
--serving-ca-pin 'sha256/…' --join-signing-pin 'sha256/…' --join-encryption-pin 'sha256/…' \
--token-file ./join-token
bcd node --config /etc/bcd/config.tomlbcd join reads <server>/.well-known/better-compute, refuses it when a published pin differs from yours, joins,
and only then writes /etc/bcd/config.toml and join-encryption.jwk. --token-file takes only a regular file its
owner alone can read (0600 or 0400), and deletes it after the join. The token is never an argument: bcd reads it from
BCD_JOIN_TOKEN, standard input or --token-file, and refuses positional arguments. On success it prints
joined as node_… (site site_…, role node); certificate valid until …, and a second run prints
already joined as node_… without spending a token. Run bcd node under systemd in production; the install script's
unit is in the systemd unit. See
joining from the well-known document.
What happens inside the join (the sealed token, the signed JoinResponse, the node certificate) is in
the join flow.
Watch the node get adopted
The node is created in adopting. Once its session is up, the NodeLifecycle controller sends the pool's acceptance
checks; when diag.nvml passes, the node becomes adopted, gets desired-state generation 1, and the placer starts
to consider it.
better-compute nodes list --pool $POOL
export NODE=node_… # the id nodes list printed (or bcd join)
better-compute nodes get $NODE --query provisionState
better-compute nodes get $NODE --query conditions
better-compute nodes events $NODEnodes events shows node.joined, node.connected, node.acceptance.started and node.adopted in that order. On
the server, bcd status reads the running agent over its unix socket (/run/bcd/bcd.sock by default):
sudo bcd status # session, spec generations, sections, GPUs
sudo bcd status --jsonThe text form shows the node, its session (connected (epoch …)), the applied and observed spec generations, one
line per section, and one line per GPU with its utilization, temperature, power and memory, and held by <allocation> once a hold has it. See bcd status and
telemetry and status.
When the node stays adopting:
| What you see | Cause | Fix |
|---|---|---|
AcceptanceFailed true, reason diag.nvml:<check> | A GPU failed a check (nvml.init, ecc.uncorrectable, row-remap.pending, pcie.link, xid.recent, …) or the count is not expect.gpuCount | Fix the machine, then re-run acceptance (below) |
AcceptanceFailed true, reason Expired | The check did not finish within its 10 minutes | Check the session (bcd status), then re-run |
GpuModelUnknown true | The card's name is not in inventory's GPU model table; the node is NoSchedule | See GPU naming |
| No node at all | The join was refused: the agent only ever sees Join refused | Read the pool owner's security.join.refused events (refusals) |
A failed acceptance is sticky. Fix the cause and start a new attempt with nodes.adopt, which has no command of its
own; call it through api:
better-compute api nodes.adopt tenantId=$BETTER_COMPUTE_TENANT nodeId=$NODE reason='GPU 3 reseated'Check capacity
better-compute capacity --pool $POOL
better-compute capacity --preview --pool $POOL --gpus 4 --for 2hThe rollup reports, per resource, total, allocatable, allocated, reserved and available, plus the GPUs that
are provisioning, unhealthy, in maintenance or unreachable. One adopted 8-GPU server gives 8 allocatable GPUs.
A preview books nothing; it answers whether the request would fit now, with the host CPUs and memory it would book.
Reserve GPUs
better-compute reserve --pool $POOL --gpus 4 --for 2hreserve calls reservations.create with a new idempotency key, which it
prints on standard error; run it again with --key <that key> to retry without booking twice. It waits up to
--wait seconds (default 20) for the placement. With the default placer the answer is already placed: the placer
picked the node, locked its GPUs and booked four of them, together with the pool's per-GPU share of the host's CPUs
and memory (see host resources). Other flags narrow the choice:
--model, --product, --selector "vramGiB >= 80", --nvlink for one NVLink domain, --start +2h for a later
start. See reservations.
The reservation then binds on its own:
At the start, the node's desired state gains a device assignment for the allocation. bcd writes the hold's CDI spec
and env file, then journals an AllocationBound signed with its node key; once the control plane verified it, the
reservation is active.
export RSV=rsv_… # the id reserve printed
better-compute reservations get $RSV --query state
better-compute reservations get $RSV --query binding{
"nodeId": "node_…",
"cdiDevices": ["bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…"],
"envFile": "/run/bcd/allocations/alloc_….env",
"cgroupParent": "bcd-alloc-alloc_….slice"
}tests/e2e/spine.test.ts checks this end to end (3c3a53f): the CDI device names reservations.get returns are
exactly the GPU UUIDs the database booked, and the node's CDI spec lists exactly those GPUs. A hold that fails on the
node (overlap, device-missing, resources-exhausted) is placed again, at most twice; no report within 5 minutes
counts as a failure (bindings).
Use the GPUs on the node
The binding is a hold: the GPUs are set aside for the reservation, and a workload manager on the node uses them by their CDI names. On the server:
ls /etc/cdi/ # bcd-alloc_….json beside nvidia.json
cat /etc/cdi/bcd-alloc_….json
cat /run/bcd/allocations/alloc_….env| Where | What it holds |
|---|---|
/etc/cdi/bcd-<allocation>.json | A CDI spec of kind bcd.dev/gpu with one device per held GPU, named by its UUID, and the annotations bcd.dev/device-id and bcd.dev/reservation-id |
/run/bcd/allocations/<allocation>.env | NVIDIA_VISIBLE_DEVICES (the held GPUs' UUIDs), BCD_ALLOCATION_ID and, with host limits, BCD_CGROUP_PARENT |
bcd.slice/bcd-alloc.slice/bcd-alloc-<allocation>.slice | The cgroup slice with the booked CPUs, memory and huge pages, when the hold has limits |
Since 6ecb860 a hold's spec also carries the device nodes, mounts and hooks of exactly the held GPUs, copied from
NVIDIA's own CDI spec, /etc/cdi/nvidia.json. So the name bcd.dev/gpu=<uuid> alone gives a container its GPUs and
nothing else. bcd writes that vendor spec with the container-toolkit unit
when a profile names it; on a server you adopted, install the NVIDIA container toolkit and generate the same spec once,
as the unit does:
nvidia-ctk cdi generate --format=json --device-name-strategy=uuid > /etc/cdi/nvidia.jsonWithout a vendor spec a hold carries only NVIDIA_VISIBLE_DEVICES. Any CDI-aware runtime then takes the names as
devices. For example, with Podman, placing the container in the hold's slice:
set -a; . /run/bcd/allocations/alloc_….env; set +a
podman run --rm --device bcd.dev/gpu=GPU-… --device bcd.dev/gpu=GPU-… \
--cgroup-parent "$BCD_CGROUP_PARENT" nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi -Lbcd does not move processes into the slice and never drops a hold on its own, not even while disconnected; the
workload manager (containerd, Slurm, a wrapper) places the workload. For Kubernetes, reserve with --kubernetes on a
pool with policy.kubernetes, and bcd's device plugin hands the held GPUs to the kubelet as bcd.dev/gpu (see
Kubernetes workers). The node side is described in
device assignments.
Follow the events
Every step above published events through the transactional outbox. With a host that mounts the events module's
GET /watch (the examples do), follow them live, one JSON line each:
better-compute events watch --types 'node.*,reservation.*'Run it in a second terminal before step 7 to see the reservation move: reservation.placed, reservation.binding,
reservation.active, and after step 10 reservation.released. Each line carries a sequence; --since <sequence>
resumes after it. Without a /watch route, page through the same events with better-compute events list or
better-compute nodes events $NODE. See watch and the
event reference.
Release
better-compute release $RSV
better-compute capacity --pool $POOLrelease calls reservations.release: the bookings end at the current
database time, the quota is refunded, and the device assignment leaves the node's desired state. bcd removes the CDI
spec and the env file, reports AllocationReleased signed with its node key, and stops the hold's slice once it is
empty, so a release never kills a running workload: stop it first. available goes back up. A reservation that has
not started is withdrawn with release --cancel; one that should run longer is extended with
better-compute extend $RSV --by 2h (start, lease, extend and release).
Commands used
| Step | Command | API method |
|---|---|---|
| 1 | api system.whoami | system.whoami |
| 2 | sites create, pools create, pools offer | sites.create, pools.create, pools.offer |
| 3 | join-tokens create | joinTokens.create |
| 4 | daemon install-script, pki show; bcd join, bcd node | none (local), then JoinService's Join RPC |
| 5 | nodes get, nodes list, nodes events, api nodes.adopt; bcd status | nodes.get, nodes.list, nodes.events, nodes.adopt |
| 6 | capacity | capacity.* |
| 7 | reserve, reservations get | reservations.create, reservations.get |
| 9 | events watch, events list | GET /watch, events.list |
| 10 | release, extend | reservations.release, reservations.extend |
Cleaning up
To take the server out again, cordon it so nothing new places on it, let its reservations end, then delete the node, which revokes its certificate and frees its hardware key for a later join:
better-compute nodes cordon $NODE --reason 'tutorial done'
better-compute nodes delete $NODE --yesThen stop and disable bcd.service on the server and remove /etc/bcd and /var/lib/bcd; bcd has no uninstall step
(removing bcd). better-compute pools archive $POOL retires the pool. See
removing an adopted host.
Next steps
Enroll servers by their BMCs and let metal wipe and image them instead of adopting them by hand.
Fractional GPUsMIG instances and time-sliced or MPS slots in place of whole GPUs.
Host configurationHave bcd install the driver, the container toolkit and the GPU settings for every machine of the pool.
Quotas and usageCap what each tenant may book and bill what it used.
Better Compute is created by Sean Filimon
Last updated
Installation
Requirements, installing better-compute and Better IAM from npm, the umbrella package's subpaths, packages outside it, optional peers, and building from a clone.
Concepts
The ideas Better Compute is built on, from one Postgres truth and desired state to one IAM decision per mutation, and its fifteen design principles.