A bundle carries what a cluster would download — llmgw's release with its signed checksums, every llama.cpp build per architecture with GitHub's checksums, model files pinned and checked against Hugging Face's — into a directory or a tar whose manifest sha256 is carried apart. Import checks everything, the release against the signing key, before anything is put in the mirror. With offline.mirror set, nodes install llama.cpp from the mirror against checksums the control plane holds and sends over the agent stream; releases default to the mirror for node add, ctl upgrade and agent upgrades. server init and the setup form install from a bundle. The Helm chart runs one control plane with SQLite on a claim or an external PostgreSQL, Secrets for the agent certificate and admin token, Services, Ingress and ServiceMonitor; deploy/container/Dockerfile builds its image. GPU nodes stay outside Kubernetes, as docs/kubernetes.md explains. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
274 lines
10 KiB
Markdown
274 lines
10 KiB
Markdown
# A model mirror
|
|
|
|
One machine that holds the model files, so nodes fetch over your own network
|
|
instead of over the internet.
|
|
|
|
This is the highest-value piece of infrastructure around llmgw and the cheapest.
|
|
Eight nodes each pulling forty gigabytes from Hugging Face is a day's work, and
|
|
on plenty of corporate networks it is not possible at all. From a mirror on the
|
|
same switch it is minutes.
|
|
|
|
llmgw needs nothing special for this: a model's `source` is a URL, and the URL
|
|
can be yours.
|
|
|
|
## What it is
|
|
|
|
An HTTP server serving a directory. That is the whole design.
|
|
|
|
```
|
|
/srv/models/
|
|
qwen2.5-14b-instruct/
|
|
2026-03/
|
|
q4_k_m.gguf
|
|
bge-m3/
|
|
2026-01/
|
|
model.safetensors
|
|
```
|
|
|
|
At an offline site the mirror also holds what the nodes would otherwise fetch
|
|
from the internet — llmgw's releases and the llama.cpp builds — under
|
|
`_llmgw/releases/` and `_llmgw/engines/`, put there by `llmgw bundle import`
|
|
(docs/install.md, "Offline sites"). They are not models: `mirror list` and
|
|
`mirror unused` pass over them, and a node checks each against what the
|
|
control plane says it must be, not against anything the mirror says.
|
|
|
|
The date in the path is the point of the layout, not decoration. A model
|
|
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.
|
|
|
|
## Running it
|
|
|
|
`llmgw` is the mirror. Same binary as the control plane and the agents, one
|
|
subcommand:
|
|
|
|
```
|
|
sudo mkdir -p /srv/models && sudo chown llmgw:llmgw /srv/models
|
|
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
|
|
```
|
|
|
|
There is a unit in `deploy/systemd/llmgw-mirror.service`. Set `--base` to the
|
|
address nodes reach this machine on — not `localhost`, and not a name only this
|
|
host knows itself by. It is the string that ends up in catalog entries, and a
|
|
wrong one makes `mirror unused` report files as unreferenced when they are in
|
|
use. The next step after that list is deletion.
|
|
|
|
### Two listeners
|
|
|
|
`--listen :8081` serves the weights. Read-only, unauthenticated, Range requests
|
|
supported so an interrupted fetch on a node resumes rather than restarts. This
|
|
is the path eight nodes pull forty gigabytes from.
|
|
|
|
`--api-listen :8082` is the management API. Separate on purpose: putting a
|
|
client certificate in front of the byte path would mean re-certifying every
|
|
agent's fetcher in order to change how models are administered.
|
|
|
|
The API is unauthenticated unless you give it the fleet's CA, and it says so at
|
|
startup rather than leaving you to find out. Anyone who can reach that port can
|
|
start downloads and delete models, so either keep it on a trusted network or
|
|
hand it 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 \
|
|
--api-client control-plane
|
|
```
|
|
|
|
`--api-client` is required with `--tls-ca`: it names the one certificate the
|
|
API answers, because the CA signs every node's certificate as well.
|
|
|
|
That is the same CA the agents already use, so the certificates come from the
|
|
authority you have:
|
|
|
|
```
|
|
llmgw ctl cert issue-server mirror --host models.internal --host 10.0.0.146
|
|
llmgw ctl cert issue-client control-plane
|
|
```
|
|
|
|
`--host` has to cover every address the API is called on, because that is what
|
|
a client verifies against. Both sides are checked — the mirror will not accept a
|
|
caller without a certificate from this CA, the same rule the agent stream
|
|
follows.
|
|
|
|
### nginx instead
|
|
|
|
If you already run nginx, it serves a directory perfectly well and you do not
|
|
need `mirror serve` for the bytes:
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name models.internal;
|
|
|
|
root /srv/models;
|
|
|
|
# Read-only. This is deliberate: a mirror that can be written to over HTTP
|
|
# is a way to replace a model with another one, and the nodes would fetch
|
|
# it without complaint.
|
|
location / {
|
|
autoindex on;
|
|
# Large files, and the client is a machine with a resumable fetcher.
|
|
# Sendfile and no buffering keep memory flat regardless of size.
|
|
sendfile on;
|
|
tcp_nopush on;
|
|
}
|
|
}
|
|
```
|
|
|
|
`autoindex` is there so a person can browse what is available; llmgw does not
|
|
need it.
|
|
|
|
## Filling it
|
|
|
|
From the mirror itself:
|
|
|
|
```
|
|
llmgw mirror add \
|
|
--model qwen2.5-14b-instruct --version 2026-03 \
|
|
"https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf"
|
|
```
|
|
|
|
Use a revision rather than `main` in the source URL. `main` is what moves.
|
|
|
|
It downloads to a temporary name and renames when complete, so an interrupted
|
|
download never leaves something a catalog could be pointed at, and it prints the
|
|
sha256 when it lands. Compare that against what upstream publishes — Hugging
|
|
Face gives it as `lfs.oid` from
|
|
`https://huggingface.co/api/models/<repo>/tree/<revision>`. A mirror is the one
|
|
place in this system where a corrupt file is copied to every node instead of
|
|
being caught once.
|
|
|
|
It refuses to overwrite a version that is already there. Add a new one beside
|
|
it: that refusal is what makes the old weights still available when the new ones
|
|
turn out to be worse.
|
|
|
|
## Driving it from elsewhere
|
|
|
|
The same fetch over the API, so the control plane — or anything else — can ask
|
|
for a model without anyone logging in:
|
|
|
|
```
|
|
curl -X POST http://models.internal:8082/v1/models -d '{
|
|
"source": "https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf",
|
|
"model": "qwen2.5-14b-instruct",
|
|
"version": "2026-03"
|
|
}'
|
|
```
|
|
|
|
The mirror does the downloading. A control plane that pulled the bytes itself
|
|
and pushed them here would carry every model twice over the network, through the
|
|
one machine least able to spare the bandwidth.
|
|
|
|
It answers `202` with the URL a catalog entry should use, and the bytes are not
|
|
here yet. Poll for them:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `GET /v1/models` | everything held or arriving, and the base to build URLs from |
|
|
| `POST /v1/models` | fetch this source into model/version |
|
|
| `GET /v1/models/{model}/{version}` | `fetching` with a byte count, `present`, or `failed` with the reason |
|
|
| `DELETE /v1/models/{model}/{version}` | remove a version; refuses while it is downloading |
|
|
| `GET /v1/health` | up *and* able to read its own directory |
|
|
|
|
A failed fetch stays `failed` with what went wrong, rather than disappearing:
|
|
otherwise the thing waiting on that model waits forever with nothing to say why.
|
|
Asking twice for the same download joins the one already running instead of
|
|
starting a rival writer on the same file.
|
|
|
|
`DELETE` takes the whole version, never one file of it. A model split across
|
|
several files is useless with one missing, and half a version on disk looks
|
|
present and will not load.
|
|
|
|
## Retiring a model
|
|
|
|
```
|
|
llmgw mirror unused # what no catalog entry refers to
|
|
llmgw mirror remove --model <m> --version <v> --file <f>
|
|
```
|
|
|
|
`unused` compares against every `source` in the running catalog, so the answer
|
|
is what can go rather than what looks old. It refuses to answer when it cannot
|
|
reach the control plane: an empty catalog and an unreachable one produce the
|
|
same list — everything — and the obvious next step after that list is deletion.
|
|
|
|
Retire the catalog entry first, let the fleet settle, then remove. A node that
|
|
has to refetch a file you deleted cannot, and the failure arrives later, on the
|
|
node, looking like something else.
|
|
|
|
## From the control plane's admin page
|
|
|
|
Configure the mirror on the control plane and the same operations appear at
|
|
`/admin/mirror`, behind the admin session the other admin pages use:
|
|
|
|
```yaml
|
|
mirror:
|
|
api: https://models.internal:8082
|
|
tls:
|
|
ca: /etc/llmgw/ca.crt
|
|
cert: /etc/llmgw/control-plane.crt
|
|
key: /etc/llmgw/control-plane.key
|
|
```
|
|
|
|
Leave the block out and the page is not mounted at all, which is the right page
|
|
for a deployment with no mirror — not an empty one pointing at nothing.
|
|
|
|
The page lists what is held, what is arriving with its byte count, and what
|
|
failed with the reason; it starts a fetch, retries a failed one, and removes a
|
|
version. It reloads itself only while something is downloading, because a reload
|
|
at any other moment takes with it whatever is half-typed in the form.
|
|
|
|
Two things it deliberately will not do. It cannot point the control plane at a
|
|
different mirror — that is configuration, and changing it needs access to the
|
|
host rather than a session. And it does not write the catalog for you: it shows
|
|
the URL an entry should use, and putting it there is still a deliberate act,
|
|
because that is the moment a model starts being served.
|
|
|
|
The address is a mistake worth naming: `api` is the **management** port, not the
|
|
one nodes fetch bytes from. They are two listeners so this one can be locked
|
|
down without re-certifying every agent's fetcher.
|
|
|
|
## Pointing llmgw at it
|
|
|
|
```yaml
|
|
- name: qwen2.5-14b-instruct
|
|
source: http://models.internal/qwen2.5-14b-instruct/2026-03/q4_k_m.gguf
|
|
scaling: { min: 2, max: 2 }
|
|
profiles:
|
|
- name: gpu
|
|
engine: vllm
|
|
gpu_class: h100-80g
|
|
tp: 1
|
|
```
|
|
|
|
Each node fetches once, in the background, before its engine starts — and
|
|
`llmgw ctl replicas` shows how far along it is while it does. A killed download
|
|
resumes rather than restarting, and a partial file is never visible under the
|
|
model's name, so an interrupted fetch cannot be handed to an engine.
|
|
|
|
## What this is not
|
|
|
|
It is not a cache. Nothing populates it on its own: a model is there because
|
|
somebody asked for it, by hand or over the API, and one nobody asked for is not
|
|
there. That is on purpose. A mirror that fetched on demand would reach the
|
|
internet at the least convenient moment, and in a network where that is blocked
|
|
it would fail in a way that looks like llmgw being broken.
|
|
|
|
It is also not a replacement for a shared filesystem. If you already run one
|
|
that every node mounts, put the models on it and use a path as the `source` —
|
|
nothing is fetched at all, and llmgw notices the filesystem is shared and will
|
|
not count the same free space once per node.
|
|
|
|
## When this stops being enough
|
|
|
|
Somewhere above ten or twenty nodes, one mirror serving a forty gigabyte file
|
|
to all of them at once becomes the bottleneck instead of the fix. At that point
|
|
the options are a filer, a second mirror per rack, or distribution between the
|
|
nodes themselves.
|
|
|
|
llmgw knows which nodes already hold which weights — it is in `ctl status` as
|
|
"N models on X/Y nodes" — so distributing between them is a feature it is
|
|
positioned to add. It has not been built, because a mirror is enough for
|
|
everyone who has needed it so far.
|