Read start to finish as somebody standing up a new cluster, it sent them down a path we no longer take. The mirror was absent from all 821 lines, and the page still said that distributing weights was "a known gap, not an oversight" — so the reader would have gone away to solve, by hand, the problem the binary in front of them solves. It is step 4 now, before the nodes, because where a node fetches from is the difference between minutes and a day, and on a customer network the nodes often have no route out at all. Two of the listed gaps had been closed and were still listed: a fetch does report its progress to the control plane, and there is an HA pattern. A gap somebody believes in is worse than one they discover. Added what this week made obvious was missing. Upgrading, including the zero-byte copy that ssh -n silently produces and the size check that is the only thing between that and a fleet on a truncated binary. Backing up, in the order that matters, and the fact that API keys are hashed and do not come back. Pricing in the first configuration, because a budget without a price is displayed, counted against nothing, and never binds. And scale, bench and the source check, none of which were written down anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1082 lines
43 KiB
Markdown
1082 lines
43 KiB
Markdown
# Installing llmgw from nothing
|
|
|
|
Written for whoever is standing up the cluster: you have servers, you have root
|
|
on them, and nothing is installed yet. By the end there is a control plane, a
|
|
node in the fleet, and a model answering requests.
|
|
|
|
It assumes Debian or Ubuntu and adjusts nothing for other distributions beyond
|
|
the package names. Everything here has been done on a real cluster except where
|
|
this page says otherwise, and where it says otherwise it says so plainly.
|
|
|
|
## What llmgw does not install
|
|
|
|
Read this first, because it is the difference between an afternoon and a week.
|
|
|
|
llmgw runs engines and routes requests. It does **not** install GPU drivers,
|
|
CUDA, or the inference engines themselves. A node missing any of them still
|
|
registers, still reports `ready`, and still fails every replica placed on it —
|
|
so the useful order is to make a node able to serve *before* onboarding it, not
|
|
after.
|
|
|
|
| You provide | llmgw provides |
|
|
| --- | --- |
|
|
| NVIDIA driver and `nvidia-smi` | GPU discovery, classes, health |
|
|
| InfiniBand stack (`rdma-core`/OFED) | Fabric discovery and the invariant that a replica never spans one |
|
|
| vLLM, SGLang or llama.cpp | Launching them, the right flags, supervision |
|
|
| Model weights, from somewhere | Fetching them, placement, routing, accounting |
|
|
|
|
The reason is boring and unavoidable: drivers and engines are platform decisions
|
|
with kernel modules and version matrices attached.
|
|
|
|
Weights are the one item on that list llmgw does handle. It includes a mirror —
|
|
step 4 — which holds the files on your own network and hands them to the nodes,
|
|
so getting weights onto every machine is not a problem you solve before
|
|
starting. What llmgw will not do is choose the models for you.
|
|
|
|
## 0. Before you begin
|
|
|
|
Four machines is a reasonable first cluster: one control plane and three nodes.
|
|
One of each also works.
|
|
|
|
**The control plane** needs no GPU. It is a single process holding a SQLite
|
|
file; 2 GB of RAM and a few gigabytes of disk is generous. It must be reachable
|
|
from every node on the agent port, and from your users on the client port.
|
|
|
|
**Nodes** need whatever the model needs, plus about 8 GB for the operating
|
|
system and the agent. They dial the control plane; the control plane never dials
|
|
them, so nodes need no inbound access except your own SSH.
|
|
|
|
**Ports**
|
|
|
|
| Port | Who connects | Expose to |
|
|
| --- | --- | --- |
|
|
| 8080 | clients, the web UI | your users, usually via a reverse proxy |
|
|
| 7443 | node agents | the node network only |
|
|
| 9090 | `llmgw ctl`, the management API, pprof | loopback only — it serves pprof, which can dump the process heap, and API keys live in that heap |
|
|
| 9091 | Prometheus, if you run it | your monitoring host; metrics only, nothing else is served here |
|
|
| 8081 | node agents fetching weights | the node network only (the mirror machine) |
|
|
| 8082 | the control plane administering the mirror | the control plane only — this port can delete models and start downloads |
|
|
|
|
Decide now which address nodes will use to reach the control plane. It goes into
|
|
a certificate and changing it later means reissuing one.
|
|
|
|
## 1. The binary
|
|
|
|
One static binary, no runtime dependencies, the same on every machine.
|
|
|
|
```
|
|
VERSION=v0.8.0 # check the releases page for the current tag
|
|
curl -fsSLO https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION/llmgw-$VERSION-linux-amd64
|
|
curl -fsSL https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION/checksums.txt | sha256sum -c --ignore-missing
|
|
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw
|
|
llmgw --version
|
|
```
|
|
|
|
`arm64` and `darwin` builds are published alongside. They are built with
|
|
`CGO_ENABLED=0` deliberately, so a binary built today runs on a node whose libc
|
|
is older than the builder's.
|
|
|
|
To build instead of download, you need Go 1.26 or later and nothing else:
|
|
|
|
```
|
|
git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
|
|
CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
|
|
```
|
|
|
|
## 2. The certificate authority
|
|
|
|
Agents authenticate to the control plane with mutual TLS, and a node's identity
|
|
is taken from its certificate's common name. A public authority cannot supply
|
|
these: Let's Encrypt and its peers stopped issuing certificates carrying the
|
|
`clientAuth` usage, so nothing they hand out can authenticate an agent.
|
|
|
|
Run this wherever you intend to keep the authority. It talks to nothing and
|
|
works on files, so it does not have to be a cluster machine — and there is an
|
|
argument that it should not be.
|
|
|
|
```
|
|
sudo llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
|
```
|
|
|
|
That writes `ca.pem` and `ca-key.pem`. **Whoever holds `ca-key.pem` can mint any
|
|
node's identity**, so it stays here; `ca.pem` is copied to every machine and is
|
|
not a secret.
|
|
|
|
Now the control plane's own certificate. Every address an agent might dial must
|
|
appear in `--host`, bare IP addresses included — hostname verification reads
|
|
subject alternative names and ignores the common name entirely, so a certificate
|
|
naming the host only in its subject is rejected by every client:
|
|
|
|
```
|
|
sudo llmgw ctl cert issue-server llmgw-ctl \
|
|
--host 10.0.0.140 --host llmgw-ctl.internal \
|
|
--dir /etc/llmgw/pki --out /etc/llmgw
|
|
```
|
|
|
|
This covers the fleet-facing port only. The client-facing API is public and
|
|
belongs behind an ordinary publicly trusted certificate, usually terminated by a
|
|
reverse proxy in front of port 8080.
|
|
|
|
## 3. The control plane
|
|
|
|
### The user and directories
|
|
|
|
```
|
|
sudo useradd --system --home /var/lib/llmgw --shell /usr/sbin/nologin llmgw
|
|
sudo install -d -o llmgw -g llmgw -m 0750 /var/lib/llmgw
|
|
sudo install -d -m 0755 /etc/llmgw
|
|
sudo chown root:llmgw /etc/llmgw/ca.pem /etc/llmgw/server.pem /etc/llmgw/server-key.pem
|
|
sudo chmod 0640 /etc/llmgw/server-key.pem
|
|
```
|
|
|
|
### The configuration
|
|
|
|
`/etc/llmgw/server.yaml`. This is the smallest file that is not a toy: mutual
|
|
TLS on, authentication on, one model, and limits that apply to anyone nobody has
|
|
given limits to.
|
|
|
|
```yaml
|
|
listen: ":8080"
|
|
admin_listen: "127.0.0.1:9090"
|
|
agent_listen: ":7443"
|
|
|
|
agent_tls:
|
|
ca: /etc/llmgw/ca.pem
|
|
cert: /etc/llmgw/server.pem
|
|
key: /etc/llmgw/server-key.pem
|
|
|
|
auth:
|
|
enabled: true
|
|
store: /var/lib/llmgw/llmgw.db
|
|
signup_open: true
|
|
# Applied to any key no scope gives a limit to. Without these, a project
|
|
# created without limits is metered by nothing at all, and the first sign of
|
|
# that is the bill or the outage.
|
|
default_rpm: 60
|
|
default_tpm: 200000
|
|
# What a request costs, which is what makes a budget mean anything. Without
|
|
# a price every request costs zero, so a budget is displayed, counted against
|
|
# nothing, and never binds — and nobody finds out until they expected it to
|
|
# stop something. Self-hosted models have no per-token price, so the unit is
|
|
# yours to choose: GPU-seconds, a notional rate, or 1000 everywhere, which
|
|
# turns a budget into a plain token quota.
|
|
default_price:
|
|
prompt_per_1k: 1000
|
|
completion_per_1k: 1000
|
|
# pricing:
|
|
# qwen2.5-coder-7b-instruct: { prompt_per_1k: 500, completion_per_1k: 1500 }
|
|
|
|
landing:
|
|
contact: "platform@example.com"
|
|
|
|
# Start with none. Models are added while the cluster runs — see step 8 —
|
|
# and anything listed here is declarative: it is in the catalog on every start
|
|
# and cannot be removed without editing this file.
|
|
models: []
|
|
```
|
|
|
|
`admin_listen` is on loopback on purpose. It serves pprof, which can dump the
|
|
process heap, and API keys sit in that heap in plaintext. To see the dashboard
|
|
from elsewhere, promote an account and use `/admin` on the client port — do not
|
|
move this listener.
|
|
|
|
The store file is created on first start; there is no separate migration step.
|
|
|
|
### The service
|
|
|
|
```
|
|
sudo cp deploy/systemd/llmgw-server.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now llmgw-server
|
|
systemctl status llmgw-server
|
|
```
|
|
|
|
### Your account
|
|
|
|
Accounts are created and approved from a shell on this machine, deliberately:
|
|
having a shell here is what makes someone an administrator, and there are no
|
|
roles to get subtly wrong.
|
|
|
|
```
|
|
sudo -u llmgw llmgw users create you@example.com --password 'change me now'
|
|
# prints a user id, usr-xxxxxxxx
|
|
sudo -u llmgw llmgw users promote usr-xxxxxxxx
|
|
```
|
|
|
|
`promote` is what grants `/admin`. Sign in at `http://<control-plane>:8080/login`,
|
|
change the password, and issue yourself a key from the profile page.
|
|
|
|
### Check it
|
|
|
|
```
|
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # 200, with an empty body
|
|
sudo -u llmgw llmgw ctl status
|
|
```
|
|
|
|
`ctl status` will report no nodes and no models, which is correct — nothing has
|
|
been added yet. It is worth running now so its output is familiar before you
|
|
need it.
|
|
|
|
## 4. The mirror
|
|
|
|
Do this before the nodes, not after. Every node fetches its own copy of a
|
|
model's weights, and where they fetch from decides whether bringing up a cluster
|
|
takes minutes or a day. Eight nodes pulling forty gigabytes each from Hugging
|
|
Face is a day's work, and on a good number of corporate networks it is not
|
|
possible at all — the nodes have no route out, which is usually the point.
|
|
|
|
The mirror is one machine holding the files on your own network. It is the same
|
|
binary again, and it is the highest-value piece of infrastructure around llmgw
|
|
for the least work.
|
|
|
|
### Running it
|
|
|
|
```
|
|
sudo install -d -o llmgw -g llmgw /srv/models
|
|
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
|
|
```
|
|
|
|
In production, the unit:
|
|
|
|
```
|
|
sudo cp deploy/systemd/llmgw-mirror.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload && sudo systemctl enable --now llmgw-mirror
|
|
```
|
|
|
|
**Two listeners, on purpose.** `:8081` serves bytes and nothing else — that is
|
|
what every agent talks to, and it is plain HTTP because a weights fetch is a
|
|
public file transfer that is checksummed anyway. `:8082` is the management API:
|
|
it can start downloads and delete models, and only the control plane needs it.
|
|
The split is what lets you lock the second one down without re-certifying every
|
|
agent that fetches from the first.
|
|
|
|
Give the management port the cluster's own CA and it takes client certificates:
|
|
|
|
```
|
|
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
|
|
--tls-ca /etc/llmgw/ca.pem \
|
|
--tls-cert /etc/llmgw/mirror.pem --tls-key /etc/llmgw/mirror-key.pem
|
|
```
|
|
|
|
Without those three flags the management port is open to anyone who can reach
|
|
it, and the mirror says so in its own log at every start rather than letting you
|
|
find out later.
|
|
|
|
### The layout
|
|
|
|
```
|
|
/srv/models/
|
|
qwen2.5-14b-instruct/
|
|
2026-03/
|
|
q4_k_m.gguf
|
|
```
|
|
|
|
Model, then version, then files. The version directory is the point of the
|
|
layout rather than decoration: a repository that changes upstream changes what
|
|
you are running, and the first sign is a prompt that worked last week answering
|
|
differently this week with nobody able to say why. Pin it here and that cannot
|
|
happen.
|
|
|
|
A version holding several files is a sharded model, and is served as a set: the
|
|
agent asks the directory what it consists of and fetches all of it into one
|
|
place, marking it complete only when the last file lands. A catalogue entry for
|
|
one names the directory, trailing slash included.
|
|
|
|
### Telling the control plane about it
|
|
|
|
```yaml
|
|
# /etc/llmgw/server.yaml
|
|
mirror:
|
|
api: https://models.internal:8082
|
|
tls:
|
|
ca: /etc/llmgw/ca.pem
|
|
cert: /etc/llmgw/server.pem
|
|
key: /etc/llmgw/server-key.pem
|
|
```
|
|
|
|
With this set, `/admin/mirror` appears: what the mirror holds, what it is
|
|
downloading and how far along, a search of Hugging Face, and a form that puts a
|
|
mirrored model into service without anyone writing YAML. Without it that page
|
|
stays unmounted and the mirror is still perfectly usable — a model's `source` is
|
|
just a URL, and the URL can be yours.
|
|
|
|
If the control plane cannot reach the internet, `mirror.registry` points the
|
|
search at an internal registry mirror instead.
|
|
|
|
### Filling it
|
|
|
|
```
|
|
llmgw mirror fetch --model qwen2.5-14b-instruct --version 2026-03 \
|
|
hf:Qwen/Qwen2.5-14B-Instruct-GGUF/qwen2.5-14b-instruct-q4_k_m.gguf
|
|
```
|
|
|
|
or from the admin page, or by copying files into the directory by hand — the
|
|
mirror is a directory, and anything that puts files in it works.
|
|
|
|
## 5. Preparing a node
|
|
|
|
**Do this before onboarding.** A node missing its driver or engine registers
|
|
healthy and fails on first placement, and the reason is three logs away.
|
|
|
|
### The driver
|
|
|
|
```
|
|
sudo apt-get update
|
|
sudo apt-get install -y nvidia-driver-550 # or whatever your cards need
|
|
sudo reboot
|
|
nvidia-smi # must print your GPUs
|
|
```
|
|
|
|
`nvidia-smi` must be on `PATH` for the user the agent runs as, which is root.
|
|
Containerised driver installs often put it elsewhere; if so, note the path and
|
|
set `discovery.nvidia_smi` in the agent configuration rather than moving it.
|
|
|
|
A node with no GPU at all is fine and is not an error. It reports zero cards and
|
|
can host CPU profiles, which is a good way to try llmgw before committing
|
|
hardware to it.
|
|
|
|
### NVLink, if your GPUs have it
|
|
|
|
The published binary cannot see NVLink topology. NVML is the only source of it,
|
|
its bindings need cgo, and the portable binary is built without cgo on purpose
|
|
so it runs on a node whose libc is older than the builder's.
|
|
|
|
Without that topology, a tensor-parallel group takes the lowest free GPU
|
|
indices. On an SXM board with NVSwitch that is correct, because every card is
|
|
equidistant. On a PCIe box where NVLink bridges pair specific cards, a group
|
|
straddling two bridges falls back to PCIe and runs several times slower — and
|
|
nothing reports it, because from the outside it is simply a replica that is
|
|
slow.
|
|
|
|
For those nodes, install the `-nvml` build published beside the portable one:
|
|
|
|
```
|
|
sudo install -m0755 llmgw-$VERSION-linux-amd64-nvml /usr/local/bin/llmgw
|
|
```
|
|
|
|
It is dynamically linked, so it needs a libc no older than the builder's and the
|
|
NVIDIA driver present. Check it took effect with:
|
|
|
|
```
|
|
llmgw ctl nodes --json | grep -c nvlink_peers # 0 means the topology is unknown
|
|
```
|
|
|
|
`llmgw_gpu_nvlink_peers` in the metrics says the same thing across a fleet: a
|
|
flat zero everywhere means the topology is unknown rather than absent.
|
|
|
|
### InfiniBand, if you have it
|
|
|
|
```
|
|
sudo apt-get install -y rdma-core ibverbs-utils infiniband-diags
|
|
ibstat # must list your HCAs with State: Active
|
|
ls /sys/class/infiniband
|
|
```
|
|
|
|
The agent checks what you declare against what the node has. An adapter that is
|
|
not there, or that has no ACTIVE port — physically linked but with no subnet
|
|
manager, which survives a reboot and looks like a software problem — takes that
|
|
fabric out of service on this node, loudly, in the log. Without that check the
|
|
replica starts, passes its health checks, and hangs on its first all-reduce.
|
|
|
|
Fabric membership is configuration, not discovery, and there is no way around
|
|
that: OpenSM's default subnet prefix is the same on every fabric, so a matching
|
|
prefix proves nothing and a wrong guess produces a replica that starts, passes
|
|
health checks, and then hangs on its first all-reduce. You name the fabric when
|
|
you onboard the node, and two nodes are only in the same fabric if you said so.
|
|
|
|
### An engine
|
|
|
|
Pick what you will actually serve. For a first cluster, llama.cpp is the least
|
|
trouble and runs without a GPU.
|
|
|
|
```
|
|
# llama.cpp, from a release build
|
|
sudo apt-get install -y libgomp1 # it will not start without this
|
|
sudo mkdir -p /opt/llama.cpp
|
|
# unpack a llama.cpp release into /opt/llama.cpp
|
|
/opt/llama.cpp/llama-server --version
|
|
```
|
|
|
|
That `libgomp1` line is not padding. A `llama-server` binary missing it exists,
|
|
is executable, and cannot start — and the node reports `ready` throughout.
|
|
|
|
For vLLM, a virtualenv on the node:
|
|
|
|
```
|
|
sudo apt-get install -y python3-venv
|
|
sudo python3 -m venv /opt/venvs/vllm
|
|
sudo /opt/venvs/vllm/bin/pip install vllm
|
|
/opt/venvs/vllm/bin/vllm --version
|
|
```
|
|
|
|
Check the engine still takes the flags llmgw builds for it, which drift between
|
|
releases:
|
|
|
|
```
|
|
llmgw ctl engine-check vllm --bin /opt/venvs/vllm/bin/vllm
|
|
```
|
|
|
|
### Weights
|
|
|
|
Nothing to do here if you built the mirror in step 4 — the agent fetches what a
|
|
model's `source` names, and that source is your mirror. This section is for the
|
|
arrangements where the files are already on the machine.
|
|
|
|
Put them where the node can read them. `scp`, a shared filesystem, or the
|
|
engine's own downloader are all valid, and which you want depends on how many
|
|
nodes you have.
|
|
|
|
```
|
|
sudo install -d -o root -g root /var/lib/llmgw-models
|
|
# then place your .gguf or model directory there
|
|
```
|
|
|
|
For a first model, something small: a 3B at `q4_k_m` is about 2 GB, runs on
|
|
CPU, and answers fast enough to prove the whole path works.
|
|
|
|
### Letting llmgw fetch them
|
|
|
|
The simple arrangement, and the default: every node keeps its own copy. Point a
|
|
model's `source` at a file to download and the agent fetches it once, before the
|
|
engine is launched:
|
|
|
|
```yaml
|
|
# your mirror, which is what step 4 was for
|
|
source: http://models.internal:8081/qwen2.5-coder-3b-instruct/2026-03/q4_k_m.gguf
|
|
# a whole version, for a model of several files — the trailing slash is the signal
|
|
source: http://models.internal:8081/llama-3.1-70b-instruct/2026-03/
|
|
# or straight from Hugging Face, where the nodes can reach it
|
|
source: hf:Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/qwen2.5-coder-3b-instruct-q4_k_m.gguf
|
|
```
|
|
|
|
A fetch reports its progress to the control plane, so `llmgw ctl replicas` and
|
|
the dashboard both show how far along a node is. A replica pulling a hundred
|
|
gigabytes and one that is wedged do not look alike.
|
|
|
|
Eight shards of one replica starting together produce one download, not eight.
|
|
An interrupted fetch resumes rather than starting over, and a partial file is
|
|
never visible under the model's name — so a half-downloaded model cannot be
|
|
picked up by the next scan and handed to an engine.
|
|
|
|
Three kinds of source, and only one of them is fetched:
|
|
|
|
| `source` | What happens |
|
|
| --- | --- |
|
|
| `/var/lib/llmgw-models/x.gguf` | Checked, never copied. However it got there. |
|
|
| `hf:org/repo/file.gguf`, `https://…` | Fetched once into the cache, then reused |
|
|
| `Qwen/Qwen2.5-7B-Instruct` | Left alone — vLLM and SGLang download repositories themselves |
|
|
|
|
Which means the exotic arrangements need no configuration at all. Weights on a
|
|
Lustre mount, or an image exported over the fabric with SRP or NVMe-oF, are
|
|
simply *present*, and present means nothing is fetched.
|
|
|
|
### Telling llmgw where they are
|
|
|
|
Optional, and unset by default. Naming the directories in the agent's
|
|
`model_dirs` lets a node report which models it can see, and placement then
|
|
prefers a node that already holds one over a node that must first move tens of
|
|
gigabytes:
|
|
|
|
```yaml
|
|
model_dirs:
|
|
- /var/lib/llmgw-models
|
|
- /mnt/lustre/models
|
|
```
|
|
|
|
llmgw does not manage that storage and does not care how the files arrived. A
|
|
Lustre or NFS mount, an image exported over the fabric with SRP or NVMe-oF,
|
|
local NVMe, or an `scp` all look identical to it. A node with nothing configured
|
|
reports nothing and is treated exactly as every node was before this existed.
|
|
|
|
**It matters most when your fabrics are not connected to each other.** Weights
|
|
in `ib-a` are not weights in `ib-b`: the only path between them is the
|
|
management network, which is the slow one. `llmgw ctl status` shows what each
|
|
fabric holds, so a model seeded into one fabric does not quietly leave the rest
|
|
of the cluster unable to serve it.
|
|
|
|
How much disk a node needs follows from the models rather than a rule of thumb.
|
|
`llmgw ctl model plan <repo>` prints the sizes; as a starting point, a 70B at
|
|
bf16 is 141 GB and the same model at q4 is 43 GB.
|
|
|
|
## 6. Adding the node
|
|
|
|
Two halves, because they need different access: building the machine needs the
|
|
hypervisor, and issuing its identity needs the certificate authority. Where one
|
|
person holds both — the common case — one command does it:
|
|
|
|
```
|
|
llmgw-add-node \
|
|
--name llmgw-node01 --ip 10.0.0.141/24 \
|
|
--server https://10.0.0.140:7443 \
|
|
--cores 16 --memory 131072 --disk 200 \
|
|
--fabric ib-a:mlx5_0,mlx5_1 \
|
|
--llamacpp-bin /opt/llama.cpp/llama-server
|
|
```
|
|
|
|
**The VM name, the hostname, the node id and the certificate's common name are
|
|
all the same string.** They do not have to be, and every time they are not,
|
|
somebody spends an afternoon working out which of the four a message refers to.
|
|
|
|
For a machine that already exists — bare metal, or a VM you built yourself — skip
|
|
provisioning and issue the identity only:
|
|
|
|
```
|
|
llmgw-onboard-node \
|
|
--node llmgw-node01 --addr 10.0.0.141 \
|
|
--server https://10.0.0.140:7443 \
|
|
--fabric ib-a:mlx5_0,mlx5_1 \
|
|
--llamacpp-bin /opt/llama.cpp/llama-server
|
|
```
|
|
|
|
Onboarding needs SSH from the CA host to the node. It waits for SSH rather than
|
|
failing on it, and learns the host key of a machine it has not seen — a new
|
|
machine spends its first minute regenerating host keys and restarting sshd.
|
|
|
|
It also checks that a configured engine actually runs, and says so loudly when
|
|
it does not. That check exists because the failure is otherwise invisible.
|
|
|
|
Within a few seconds:
|
|
|
|
```
|
|
llmgw ctl nodes
|
|
```
|
|
|
|
```
|
|
NODE STATE GPUS MEM CLASSES FABRICS REPLICAS LAST SEEN
|
|
llmgw-node01 ready 8/8 free 503G h100-80g ib-a 0 2s
|
|
```
|
|
|
|
If it does not appear, the control plane's log says why. A certificate whose
|
|
common name does not match the claimed node id is refused by design — that check
|
|
is what makes a node's identity something it proves rather than something it
|
|
picks.
|
|
|
|
Repeat for each node. Nodes in the same fabric must be given the same `--fabric`
|
|
name; nodes with no InfiniBand take no `--fabric` at all and are each their own
|
|
island, which is fine for single-node models.
|
|
|
|
## 7. Setting the node's CPU model, if it is a VM
|
|
|
|
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
|
from the guest. Inference then falls back to scalar code and is not slightly
|
|
slower but unusable. Measured on one 8-core VM, same weights, same threads, the
|
|
only change being `--cpu host`:
|
|
|
|
| Qwen2.5-Coder | kvm64 | host |
|
|
| --- | ---: | ---: |
|
|
| 1.5B q4_K_M | 5.80 | **39.09** tok/s |
|
|
| 3B q4_K_M | 2.90 | **20.51** |
|
|
| 7B q4_K_M | 1.33 | **9.88** |
|
|
|
|
`llmgw-provision-node` sets `--cpu host` for you. A VM you built yourself
|
|
probably did not, and nothing about it looks broken — every number is simply
|
|
small. Check with `grep -qw avx2 /proc/cpuinfo` on the node.
|
|
|
|
## 8. Adding a model
|
|
|
|
Write the definition. This is the same shape a model takes in `server.yaml`, so
|
|
an entry moves between the two by being copied:
|
|
|
|
```yaml
|
|
# qwen-coder-3b.yaml
|
|
name: qwen2.5-coder-3b-instruct
|
|
aliases: [coder]
|
|
source: http://models.internal:8081/qwen2.5-coder-3b-instruct/2026-03/q4_k_m.gguf
|
|
routing_profile: chat
|
|
# What a client may use in one request. Worth stating: llama.cpp divides
|
|
# --ctx-size across its parallel slots, so a pool of 65536 over two slots is
|
|
# 32768 each, and nothing else will tell you that.
|
|
context_length: 32768
|
|
max_output: 4096
|
|
scaling: { min: 1, max: 1 }
|
|
profiles:
|
|
- name: cpu
|
|
engine: llamacpp
|
|
weight: 1
|
|
priority: 100
|
|
startup_timeout: 5m
|
|
min_memory_gb: 8
|
|
args: ["--ctx-size", "65536"]
|
|
```
|
|
|
|
```
|
|
llmgw ctl model add -f qwen-coder-3b.yaml
|
|
llmgw ctl models
|
|
```
|
|
|
|
Adding a model checks that the source is actually there before accepting it, so
|
|
a typo or a mirror that is down is a sentence now rather than a replica failing
|
|
on a node in five minutes with an error naming the node. `--no-source-check`
|
|
skips it, which is right when the nodes reach the mirror by an address this
|
|
machine does not.
|
|
|
|
With a mirror configured you can skip the file entirely: `/admin/mirror` lists
|
|
what the mirror holds and offers a form that writes the same definition, with
|
|
the engine, the source and the memory figure already filled in from the file
|
|
itself.
|
|
|
|
```
|
|
MODEL SERVING REPLICAS STATUS ALIASES
|
|
qwen2.5-coder-3b-instruct yes 1/1 ready coder
|
|
```
|
|
|
|
No restart, and it survives one: models added this way are stored, while
|
|
anything in `server.yaml` is declarative and returns on every start.
|
|
|
|
### Models that load when someone asks
|
|
|
|
A cluster with more models than GPUs can hold every model in its catalog and
|
|
only the wanted ones in its cards:
|
|
|
|
```yaml
|
|
scaling: { min: 0, max: 1, idle_timeout: 15m }
|
|
```
|
|
|
|
`min: 0` with a `max` above it means load on demand. The first request for the
|
|
model starts it and waits briefly; if it is not serving by then the client gets
|
|
a 503 with `Retry-After` and an honest "the model is loading", rather than being
|
|
told nothing serves it — a client told to retry succeeds where one told the
|
|
model does not exist gives up. Once nobody has asked for it in `idle_timeout`,
|
|
the replicas are stopped and the cards go back to the pool.
|
|
|
|
`min: 0, max: 0` is different and equally useful: configured, and deliberately
|
|
not running. A model with no `scaling` stanza at all gets that, because making
|
|
every unscaled model loadable on demand would let any client request start a
|
|
70B nobody intended to run.
|
|
|
|
Two things to weigh. A cold start is visible to whoever triggers it, so this
|
|
suits the long tail people are experimenting with rather than the two or three
|
|
models used daily — keep those at `min: 1`. And stopping a replica throws away
|
|
the KV cache prefix routing spent its effort filling, so an idle timeout wants
|
|
to be minutes rather than seconds.
|
|
|
|
When the cluster is full, a model somebody asks for will stop an idle one to
|
|
make room. Only idle replicas of other on-demand models: a model at `min: 1` is
|
|
a promise that it stays up, and breaking that to serve a different request would
|
|
make every guarantee in the catalog conditional on load. Only single-node
|
|
replicas, because evicting a multi-node one frees several machines and costs
|
|
tens of minutes to rebuild — a decision worth making deliberately. One at a
|
|
time. And never a replica that has just started, which is what stops two models
|
|
on a cluster with room for one from taking turns evicting each other.
|
|
|
|
For a GPU model, name the class and the width instead:
|
|
|
|
```yaml
|
|
profiles:
|
|
- name: tp2
|
|
engine: vllm
|
|
gpu_class: h100-80g # as `llmgw ctl nodes` reports it
|
|
tp: 2
|
|
priority: 100
|
|
```
|
|
|
|
If it does not place, ask:
|
|
|
|
```
|
|
llmgw ctl explain-placement qwen2.5-coder-3b-instruct
|
|
```
|
|
|
|
which names every candidate and why each was rejected, rather than leaving you
|
|
to infer it.
|
|
|
|
### How many replicas, and how much each one carries
|
|
|
|
Replica count is changed while the cluster runs, without editing the
|
|
definition:
|
|
|
|
```
|
|
llmgw ctl scale qwen2.5-coder-3b-instruct --min 3
|
|
```
|
|
|
|
Models added at runtime only. One declared in `server.yaml` is read again on
|
|
every start, so a number set here would hold until the next restart and then
|
|
revert — change it in the file instead.
|
|
|
|
How much traffic each replica is given is the profile's `weight`, and guessing
|
|
it is how a cluster ends up with one saturated replica beside two idle ones.
|
|
Measure it instead:
|
|
|
|
```
|
|
llmgw ctl bench qwen2.5-coder-3b-instruct --save
|
|
```
|
|
|
|
Concurrency climbs until the latency target stops being met, and the last level
|
|
that held it is the capacity — peak throughput arrives long after latency has
|
|
become unusable, so throughput alone would overstate what the hardware serves.
|
|
It prints the `weight` to put in the profile, and `--save` records the figure
|
|
against the model so the dashboard can show what the cluster is measured to
|
|
carry rather than what somebody typed.
|
|
|
|
This drives real traffic and competes with real requests. Run it against a
|
|
cordoned replica, or in a window where that is acceptable.
|
|
|
|
An engine that publishes no metrics endpoint is measured from the outside only.
|
|
The run says so, and queue depth and KV occupancy are then not part of the
|
|
answer.
|
|
|
|
## 9. The first request
|
|
|
|
```
|
|
curl http://<control-plane>:8080/v1/chat/completions \
|
|
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"model": "coder", "messages": [{"role":"user","content":"hello"}]}'
|
|
```
|
|
|
|
Or open `/chat` in a browser and use it without a key at all — the page carries
|
|
your session, and the request still goes through admission, rate limits, budget
|
|
and accounting exactly as an API call does.
|
|
|
|
## Watching it
|
|
|
|
The control plane exports both halves on one endpoint: what the cluster is
|
|
serving — requests, latency, time to first token, tokens, admission, per
|
|
endpoint — and what it is made of, per node and per GPU, including memory,
|
|
temperature, ECC faults and utilisation.
|
|
|
|
**The dashboard is the answer for most clusters.** `/admin` shows capacity per
|
|
GPU class, free whole nodes per fabric, which replicas are holding hardware
|
|
nothing is asking for, who is using the cluster and what they are running, and
|
|
the same node and model tables `ctl` prints — with no monitoring stack to stand
|
|
up beside it. One binary, no dependencies, which is the point.
|
|
|
|
Prometheus is worth adding when you want history, alerts, or one view across
|
|
several clusters. The dashboard shows what is true now; it keeps nothing.
|
|
|
|
Metrics then get their own listener, because the admin one also carries pprof
|
|
and pprof can dump the process heap, where API keys sit in plaintext:
|
|
|
|
```yaml
|
|
admin_listen: "127.0.0.1:9090" # dashboard, management API, pprof — keep local
|
|
metrics_listen: "0.0.0.0:9091" # metrics only, safe to reach
|
|
```
|
|
|
|
One scrape target for the whole fleet. The node and GPU figures come from the
|
|
control plane, which already holds them, so there is nothing to install on the
|
|
nodes and no Prometheus configuration per machine.
|
|
|
|
A starting configuration, with the alerts worth having and a provisioned
|
|
dashboard, is in [deploy/monitoring/](../deploy/monitoring/) — including where
|
|
to run it, which is not the control plane: monitoring that dies with the thing
|
|
it monitors tells you nothing at the moment you need it.
|
|
|
|
Two figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
|
rather than zero when the driver does not report it — a flat zero across a
|
|
fleet means the measurement is missing, not that the fleet is idle. And
|
|
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
|
|
which is what a binary built without the nvml tag reports.
|
|
|
|
## PostgreSQL, when SQLite is no longer the right shape
|
|
|
|
SQLite is the default and stays the right answer for most installations: one
|
|
file, no server, and a request path that barely touches it — usage is written
|
|
in batches, budgets are counted in memory, authentication is cached.
|
|
|
|
Two things change that. Conversations and agent notes read and write on every
|
|
turn, which is the first per-request load this store has had; and SQLite takes
|
|
one writer at a time, so those turns serialise. And a second control plane is
|
|
impossible while the store is a file on one machine's disk.
|
|
|
|
The setting is the same one, and the address decides:
|
|
|
|
```yaml
|
|
auth:
|
|
store: "@/etc/llmgw/secrets/store-dsn"
|
|
```
|
|
|
|
A path is SQLite, a `postgres://` URL is PostgreSQL, and `@` followed by a path
|
|
reads the address out of that file — which is how a DSN carrying a password
|
|
stays out of a configuration that gets copied, diffed and pasted into issues.
|
|
Keep that file at `0600` and owned by the user llmgw runs as.
|
|
|
|
Moving an existing installation is a copy, not a fresh start. Keys are stored
|
|
hashed, so starting fresh means reissuing every one of them and being unable
|
|
to say which was which:
|
|
|
|
```
|
|
sudo systemctl stop llmgw-server
|
|
llmgw keys move-store --from /var/lib/llmgw/llmgw.db --to "@/etc/llmgw/secrets/store-dsn"
|
|
# point auth.store at the new address, then
|
|
sudo systemctl start llmgw-server
|
|
```
|
|
|
|
Stop the gateway first. The copy makes no attempt to follow a store being
|
|
written to: rows landing after the table they belong to has been passed would
|
|
simply be absent, and nothing would say so. The destination must be empty, and
|
|
the source is left untouched — so the way back is to point the configuration at
|
|
the file again.
|
|
|
|
On a machine that also runs the control plane, PostgreSQL's defaults assume it
|
|
owns the box. A gateway swapped out to give the database cache is a worse
|
|
outcome than a smaller cache, so cap it: `shared_buffers = 96MB` and
|
|
`max_connections = 40` are enough for a store whose working set is measured in
|
|
megabytes.
|
|
|
|
## Conversations across models
|
|
|
|
Off everywhere until a project asks. llmgw otherwise stores no message content
|
|
at all — usage rows carry token counts, costs and latencies, never text, and
|
|
even its own chat page keeps the conversation in the browser. A gateway that
|
|
keeps nothing anyone typed is a much easier thing to have in front of a team,
|
|
so keeping it is a decision someone makes rather than a default they discover.
|
|
|
|
```
|
|
llmgw keys threads --project default --retain-hours 24
|
|
```
|
|
|
|
The retention is required. A conversation store with no expiry is a transcript
|
|
of everything a team ever typed, kept forever because nobody chose a number.
|
|
The window is measured from the last turn, so a conversation still in progress
|
|
does not expire underneath it.
|
|
|
|
A client then sends a thread id and only its new turn:
|
|
|
|
```
|
|
curl $BASE/v1/chat/completions \
|
|
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
|
-H "x-llmgw-thread: my-conversation" \
|
|
-d '{"model": "<model-name>", "messages": [{"role":"user","content":"and then?"}]}'
|
|
```
|
|
|
|
llmgw puts the stored history in front of that turn and sends it to whichever
|
|
model is serving — **including a different one from the one the conversation
|
|
started on**. That is the point: a thread begun on a large model can continue
|
|
on a small one, and a caller dropped to a cheaper model by a spent budget keeps
|
|
their conversation.
|
|
|
|
When the history does not fit the model now serving, the oldest turns are
|
|
dropped until it does, and the reply carries `x-llmgw-thread-trimmed` with how
|
|
many. System messages are never dropped, and neither is the turn the caller
|
|
just sent.
|
|
|
|
`DELETE /v1/threads/{id}` forgets one. It is a client endpoint on purpose: the
|
|
person who said the thing should be able to unsay it without asking an
|
|
operator to read it first. A thread belonging to another project answers the
|
|
same as one that never existed.
|
|
|
|
Turning it off again stops new storage, and existing conversations go at the
|
|
next hourly prune:
|
|
|
|
```
|
|
llmgw keys threads --project default --off
|
|
```
|
|
|
|
## Memory notes for agents
|
|
|
|
Also off until a project asks, for the same reason. Where conversations are what
|
|
was said, notes are what an agent chose to keep, and they persist across
|
|
conversations:
|
|
|
|
```
|
|
llmgw keys memory --project default --retain-hours 720
|
|
```
|
|
|
|
The retention is required and is measured from the last write to a note. An agent
|
|
then saves, searches and deletes notes through `/v1/memory` or, as tools, through
|
|
the MCP server at `/mcp` (`remember`, `recall`, `forget`). Both need the API key
|
|
of the project, and notes are private to the project and to the agent id sent in
|
|
`x-llmgw-agent`. A note in another project or agent answers the same as one that
|
|
does not exist. llmgw does not add these tools to requests itself.
|
|
|
|
Turning it off stops new storage, and existing notes go at the next hourly
|
|
prune:
|
|
|
|
```
|
|
llmgw keys memory --project default --off
|
|
```
|
|
|
|
Without authentication, `threads.store` and `threads.retain_hours` in
|
|
`server.yaml` keep both conversations and notes in one shared pool. That is for
|
|
one trusted machine only; see [quickstart.md](quickstart.md).
|
|
|
|
## Two control planes
|
|
|
|
llmgw handles the hard half itself: only one places work, and agents find
|
|
whichever is leading. What it does not do is move client traffic, which
|
|
happens in front of both.
|
|
|
|
[deploy/ha/](../deploy/ha/) has the pattern and a keepalived example. The one
|
|
thing to get right is the health check: point it at `/ready`, not `/health`.
|
|
`/health` is 200 on a standby, which is alive and holds no endpoints at all,
|
|
so a load balancer using it would send half the traffic to something that
|
|
refuses every request.
|
|
|
|
## Upgrading
|
|
|
|
One binary on every machine, so an upgrade is a copy and a restart. The part
|
|
worth care is the copy, not the order.
|
|
|
|
**Nothing stops serving.** Engines are started with `KillMode=process`, so they
|
|
keep running when the agent restarts, and the agent reports them back on its
|
|
next connection. The control plane holds no replica state across its own
|
|
restart either — it rebuilds the fleet from what the agents report. A rolling
|
|
upgrade of the whole cluster interrupts no request that is not in flight at the
|
|
moment of the restart.
|
|
|
|
**A mixed fleet is fine while you roll.** The agent protocol only ever gains
|
|
fields, and both sides treat an absent one as unknown rather than as a default.
|
|
Order does not matter; finish the roll the same day anyway, because
|
|
`ctl nodes` reports each agent's version and a fleet that disagrees with itself
|
|
is one more thing to hold in your head.
|
|
|
|
```
|
|
VERSION=v0.9.0
|
|
# copy to every machine first, install nowhere yet
|
|
for h in 10.0.0.140 10.0.0.141 10.0.0.142 10.0.0.146; do
|
|
ssh $h 'cat > /tmp/llmgw-new; stat -c %s /tmp/llmgw-new' < llmgw-$VERSION-linux-amd64
|
|
done
|
|
```
|
|
|
|
**Check every size before installing anything.** This is not defensive writing:
|
|
on a host whose login shell prints anything, `scp` fails outright, and the
|
|
obvious workaround — `ssh -n host 'cat > file'` — points stdin at `/dev/null`
|
|
and writes a *zero-byte file* while reporting success. Installing that on a
|
|
fleet takes the fleet down. Compare the number on each machine with the local
|
|
one, then:
|
|
|
|
```
|
|
sudo install -m0755 /tmp/llmgw-new /usr/local/bin/llmgw
|
|
sudo systemctl restart llmgw-agent # or llmgw-server, or llmgw-mirror
|
|
llmgw --version
|
|
```
|
|
|
|
Then confirm what you have, rather than assuming:
|
|
|
|
```
|
|
llmgw ctl nodes # every agent back, LAST SEEN in seconds
|
|
llmgw ctl replicas # same replicas, adopted rather than replaced
|
|
```
|
|
|
|
A replica may come back marked **`ready (stale profile)`**. That means it is
|
|
running a profile that has since been edited — the reconciler leaves a serving
|
|
replica alone on purpose, because churning one costs more than the difference.
|
|
Restart it when you want the new profile to take effect; the flag is there so
|
|
the choice is yours rather than silent.
|
|
|
|
## Backing up
|
|
|
|
Three things, in descending order of how badly you want them.
|
|
|
|
**The certificate authority.** `ca-key.pem`, wherever you put it in step 2.
|
|
Losing it does not stop the cluster, and it means every node identity has to be
|
|
reissued the next time you touch one. Nothing in llmgw can reconstruct it.
|
|
|
|
**The store.** Users, API keys, organisations, teams, projects, every limit and
|
|
budget, all usage history, models added at runtime, and measured capacity.
|
|
|
|
```
|
|
# SQLite
|
|
sqlite3 /var/lib/llmgw/llmgw.db ".backup '/backup/llmgw-$(date +%F).db'"
|
|
|
|
# PostgreSQL
|
|
pg_dump --format=custom llmgw > /backup/llmgw-$(date +%F).dump
|
|
```
|
|
|
|
`.backup` and `pg_dump` are both safe against a running gateway. Copying the
|
|
SQLite file with `cp` while it is being written is not.
|
|
|
|
**Keys are stored hashed and cannot be recovered from a backup or from
|
|
anywhere else.** Restoring an old store restores which keys exist and who owns
|
|
them, not their secrets. A key created after the backup is gone and has to be
|
|
reissued — which is why the store is worth backing up on a schedule rather than
|
|
before upgrades.
|
|
|
|
**The mirror's directory** is the one thing you can rebuild, by downloading it
|
|
all again. Worth remembering that a pinned version may no longer be upstream to
|
|
download: that is exactly what pinning it protected you from, and a backup is
|
|
what makes the protection outlive the disk.
|
|
|
|
## Reclaiming disk
|
|
|
|
`llmgw ctl nodes` has a DISK column: free space against the size it is free
|
|
out of, for the filesystems holding model weights. A dash means the node was
|
|
not asked — an agent with no `model_dirs`, or one older than this — and not
|
|
that it has no space. Shared filesystems are excluded from a node's figure,
|
|
because space on a filer belongs to everyone mounting it.
|
|
|
|
Weights llmgw downloaded for models that are no longer in the catalog can be
|
|
listed and removed:
|
|
|
|
```
|
|
llmgw ctl weights # what could be freed, per node
|
|
llmgw ctl weights --collect # delete it
|
|
```
|
|
|
|
**Only files llmgw fetched itself are ever candidates.** They are recognisable
|
|
because llmgw names them after a hash of their source, so the set still wanted
|
|
is computed from the catalog exactly rather than guessed. Anything you put on
|
|
a node — by scp, by a shared mount, by hand — is left alone however
|
|
unreferenced it looks, and no llmgw command will remove it. The nodes check
|
|
every path again before deleting it, so a control plane that is wrong cannot
|
|
talk one into losing a model that took a week to copy.
|
|
|
|
Which also means llmgw will not reclaim the storage most likely to be filling
|
|
your disk. Weights you placed yourself are yours to remove.
|
|
|
|
A profile may declare `min_disk_gb`, and a node without that much free is then
|
|
not a candidate for it — checked only on nodes that do not already hold the
|
|
weights, since those need no room for them. Without the field, disk is not
|
|
considered, which is how every profile written before it keeps behaving.
|
|
|
|
## When something does not work
|
|
|
|
**The node is `ready` but every replica fails.** Almost always a missing engine
|
|
or weights. `llmgw ctl replicas` shows the shard's error and the engine's last
|
|
output, which is the actual diagnosis.
|
|
|
|
**A model will not place.** `llmgw ctl explain-placement <model>` names each
|
|
node and the reason: wrong GPU class, not enough free cards, too little memory,
|
|
no fabric, a label it does not carry.
|
|
|
|
**Everything is slow.** On a VM, check AVX2 (step 7). On CPU, check the
|
|
quantisation — the difference between `q8_0` and `q4_k_m` is roughly a factor of
|
|
two. `llmgw ctl status` shows how full the cluster is.
|
|
|
|
**Tool calls come back as text.** The model made the call and the engine did not
|
|
parse it. `llmgw ctl tool-check --endpoint http://<node>:<port>` distinguishes
|
|
that from a model ignoring its tools, which has a different fix.
|
|
|
|
**Requests fail at exactly sixty seconds.** `timeouts.response_header` is a
|
|
liveness check for streaming and a total generation budget for non-streaming
|
|
requests. On a slow model, set `timeouts.generation` higher.
|
|
|
|
## What to read next
|
|
|
|
- `docs/choosing-models.md` — which models to run and how many, if the answer
|
|
to "we have GPUs, now what" is not already obvious
|
|
- `docs/data.md` — what is stored and what leaves the machine, for the review
|
|
that happens before this is allowed near anyone's documents
|
|
- `docs/reference.md` — every configuration key, generated from the source, so
|
|
it describes the version you are running rather than the one somebody last
|
|
wrote about
|
|
- `deploy/mirror/README.md` — the mirror in more detail, including the layout
|
|
and what belongs on which port
|
|
- `deploy/monitoring/README.md` — Prometheus, Grafana and the alerts, with what
|
|
each one assumes about this system
|
|
- `docs/node-onboarding.md` — adding nodes in more detail, and what onboarding
|
|
deliberately does not do
|
|
- `docs/onboarding.md` — giving people accounts and keys
|
|
- `llmgw-plan.md` — why the system is shaped the way it is
|
|
|
|
## Known gaps, so you find them here rather than at 2am
|
|
|
|
- **Every node fetches its own copy.** There is no seeding between nodes, so
|
|
bringing up eight nodes on a new model is eight downloads. The mirror makes
|
|
those eight local, which is most of the problem; on a fabric where the nodes
|
|
can see each other, seeding between them is the obvious next thing to build.
|
|
- **A budget without a price never binds.** Set `default_price` or `pricing`
|
|
(step 3) or every request costs zero, and a budget is something the dashboard
|
|
displays rather than something that stops anything. The quotas page says so
|
|
where it can, which is not the same as being told before you rely on it.
|
|
- **Engines are yours to install.** Onboarding verifies one runs; it installs
|
|
nothing.
|
|
- **Two control planes have not been run for long.** The leader election and
|
|
agent failover work and there is a pattern in `deploy/ha/`, but every cluster
|
|
this has been run on in anger has had one. A single control plane is the
|
|
well-trodden path.
|
|
- **Multi-node replicas have not been run on real InfiniBand.** The scheduling,
|
|
the fabric invariant and the rendezvous are exercised against simulated
|
|
hardware only. Single-node placement on real GPUs is the well-trodden path;
|
|
if you are the first to run a TP group across real cards, expect to find
|
|
something.
|