67 docs indexed
Run obleth-managed models on a Slurm HPC cluster: configure the optional obleth-provisioner plugin, define per-model Slurm specs, and build Apptainer images for your inference servers.
The Slurm provisioner is an optional plugin service — obleth-provisioner — that keeps managed models alive on a preemptible Slurm cluster. It submits batch jobs via slurmrestd, probes replicas for health, and promotes each healthy replica into obleth's model endpoint pool so the gateway routes traffic to it automatically.
The provisioner is not part of the obleth core stack. You opt in via config — add slurm to COMPOSE_PROFILES in your .env — after which it builds and starts with the same single command as the rest of the stack (or run the binary yourself).
Every reconcile tick (OBLETH_PROVISIONER_INTERVAL_SECS, default 15s):
slurmrestd, identified by a job-name prefix.If either the Management API or slurmrestd is unreachable the tick bails out without taking any destructive action; it retries on the next tick.
Each tick's outcome — ok, idle (with the reason), or error (with the failure
text) — is reported back to the gateway on the next poll, so the dashboard can
tell a live reconcile loop from a frozen one. See
Step 2.
Before enabling the provisioner, verify that your cluster has:
slurmrestd running and reachable from the provisioner. The provisioner authenticates with X-SLURM-USER-NAME + X-SLURM-USER-TOKEN headers; no username/password login is used.apptainer (or singularity) installed on all compute nodes. Jobs run as apptainer exec --nv <image> <launch_command>. The --nv flag passes NVIDIA GPU devices through; Apptainer must be able to find it on the job's PATH (/opt/apptainer/bin and /opt/singularity/bin are included in the job's default PATH automatically).scontrol token, or via your cluster's token service. Paste the raw JWT into the dashboard — it is encrypted at rest with the gateway's OBLETH_ENCRYPTION_KEY..sif image file must be reachable by all nodes that could run the job (NFS, Lustre, GPFS, etc.). The path you give in the image field is used as-is on every compute node.The provisioner is gated behind the slurm profile. Opt in via config by adding slurm to COMPOSE_PROFILES in deploy/docker/.env (copy it from .env.example if you haven't):
# deploy/docker/.env
COMPOSE_PROFILES=benchmark,edge,observability,slurm
Then one command — run from deploy/docker/, with no -f flags — builds and starts everything, the provisioner included:
cd deploy/docker
docker compose up -d --build # build + start the whole stack (provisioner too)
docker compose down # tear it all down
Running from deploy/docker/ lets Compose auto-load .env (so the profile activates) and auto-merge a local docker-compose.override.yml if you use one (see Troubleshooting). No --profile flag and no extra -f arguments are needed. The container only needs OBLETH_ADMIN_TOKEN (inherited from the core stack's .env) and OBLETH_ADMIN_BASE_URL; Slurm connection details are configured in the dashboard, not in .env.
Note:
If you pass explicit -f paths (e.g. running docker compose -f deploy/docker/docker-compose.yml … from the repo root), Compose stops auto-loading .env profiles and stops auto-merging docker-compose.override.yml. Prefer running from deploy/docker/ so the single command Just Works.
Set provisioner.enabled: true in your values. provisioner.intervalSecs,
provisioner.restartAfterFailures, provisioner.logLevel, and
provisioner.resources map to the environment variables below. slurmrestd
connection details and the master enable switch are configured at runtime in the
dashboard, not in values.
Note:
The provisioner is a reconciler and runs as a singleton: the chart pins
replicas: 1 with the Recreate rollout strategy so two of them can never submit
jobs for the same model at once. Do not scale it.
A unit file ships at deploy/systemd/obleth-provisioner.service. Put env vars in /etc/obleth/provisioner.env, then:
sudo cp deploy/systemd/obleth-provisioner.service /etc/systemd/system/
sudo cp obleth/target/release/obleth-provisioner /usr/local/bin/
sudo systemctl daemon-reload
sudo systemctl enable --now obleth-provisioner
OBLETH_ADMIN_TOKEN=dev-admin-token \
OBLETH_ADMIN_BASE_URL=http://localhost:9180 \
cargo run -p obleth-provisioner
Open the dashboard → Settings → Slurm.
| Field | Description |
|---|---|
| Enable Slurm | Master switch. The provisioner idles until this is on. |
| slurmrestd URL | Base URL of your slurmrestd, e.g. http://slurm-head.cluster.local:6820. Required when enabled. |
| API version | slurmrestd API version segment, e.g. v0.0.40. Match this to the version your cluster exposes at /openapi/v3. Default v0.0.40. |
| Slurm user | The Slurm username whose JWT you are using. Required when enabled. |
| JWT | The raw Slurm JWT. Write-only: leave blank when saving to keep the stored value. Encrypted at rest with OBLETH_ENCRYPTION_KEY. |
| Node address overrides | Optional list of compute-node hostname → IP entries. Empty (the default) resolves node names through DNS. See below. |
After saving, click Test connection to verify:
GET /slurm/{version}/ping endpoint responds with 2xx and how fast.The provisioner reads these settings from the Management API on every tick, so changes take effect within one interval without a restart.
The Settings → Slurm tab also shows whether the provisioner process is currently running and, when the provisioner reports it, its version (and short commit). Because the provisioner ships as its own image and is deployed separately from the gateway, this makes a stale provisioner deployment obvious at a glance.
It also shows the outcome of the provisioner's last reconcile tick — ok,
idle, or error with the failure text — and how long a non-ok streak has
lasted. A heartbeat only proves the process is alive: a provisioner can poll
green for days while every tick fails against slurmrestd, holding all replica
state frozen. The tab therefore distinguishes "running" from "running but failing
since X", and when reconciliation has been held for 10 minutes while Slurm
provisioning is enabled, a deduplicated alert fires (with a recovery notice when
it clears).
Slurm allocations name compute nodes by short hostname (scgh001). Where the
pods running obleth resolve those names unreliably — a corporate resolver behind
a long DNS search list, for instance — that flakiness lands on every health
probe and on every proxied request, because the data plane resolves the
endpoint host per call. One missed lookup then surfaces as an instant
502 upstream request failed.
The Node address overrides list takes DNS off that path. Each entry is a hostname and an IP literal; the address must parse as an IP, because a hostname there would just push the flaky lookup back onto the proxy. The provisioner registers each replica's endpoint by IP and probes by IP, so neither the health check nor proxied traffic depends on per-request DNS. Edits take effect on the next provisioner tick without a restart.
Two behaviours follow from this, and neither needs any configuration:
On Kubernetes the chart offers a lower-level alternative for the same problem:
nodeResolution.hostAliases (static /etc/hosts entries), nodeResolution.dnsConfig
(extra nameservers and search domains), and nodeResolution.dnsPolicy, applied to
the obleth and provisioner pods only. Use those when you want the whole pod to
resolve cluster names; use the overrides above when you only want obleth's Slurm
endpoints pinned.
Note:
slurmrestd version: if job submissions fail with schema/validation errors, compare the payload in obleth/crates/obleth-provisioner/src/slurm.rs against your cluster's /openapi/v3 schema and update the API version field to match.
When creating a model in the dashboard → Models → New model, choose Slurm provisioned as the hosting mode. This choice is made at creation time — you cannot change it after creation.
Slurm-provisioned models have no static upstream endpoint. The provisioner registers replicas dynamically as they become healthy, so you do not provide api_base or api_key during creation.
Picking that hosting option lists the available deployment recipes — an sbatch
script an admin has already tested, with a small YAML header carrying the routing
metadata (engine, API model name, port, health path, replica targets) and the
default placement. Deploying one creates the model route and its managed spec in a
single step. Recipes are read from OBLETH_RECIPES_DIR (default: recipes/ beside
the dashboard) and from templates authored in the dashboard; the deploy dialog
pre-fills Deploy overrides (partition, QoS, replicas, time limit, plus any
variables the recipe declares) and shows the launch script in an editable box.
Overrides and script edits apply to that deployment only — the recipe file is
unchanged. #SBATCH directives in the body are lifted into the submit fields,
because slurmrestd ignores them.
Everything below describes what the provisioner does with the resulting spec, and applies equally to a spec you fill in by hand on the model's Provisioning tab.
When the spec has no script_body (the hand-filled path), the provisioner
assembles a Slurm batch script that runs:
#!/bin/bash
set -euo pipefail
<preamble>
apptainer exec --nv <image> <launch_command>
With image blank, launch_command runs directly on the node instead. A
non-empty script_body — what a recipe produces — is submitted verbatim and
replaces this assembly entirely.
<image> — the path to an Apptainer .sif image on shared cluster storage. This path is used verbatim on each compute node, so all nodes must be able to read it.<preamble> — the spec's preamble lines, verbatim, before the exec line (module loads, PATH changes).<launch_command> — the inference server startup command to run inside the container, e.g. python -m vllm.entrypoints.openai.api_server --model /data/models/llama3 --port 8080.--nv — passes NVIDIA GPU devices through to the container. Remove this flag from launch_command if you are not using GPUs.Either way, the provisioner prepends a short bash block that binds the first free
port in this replica's port window and exports it as OBLETH_SERVING_PORT. The
window is serving_port + i × OBLETH_PORT_SPAN (span defaults to 8) for the
smallest i no live replica already holds, so two replicas can share a node
without colliding. Have your launch command listen on $OBLETH_SERVING_PORT
rather than a hardcoded port — the provisioner probes the whole window to find
where the server actually came up.
Building an Apptainer image for vLLM:
# Pull the vLLM Docker image and convert it to a .sif
apptainer pull vllm.sif docker://vllm/vllm-openai:latest
# Or build from a definition file for a custom environment
apptainer build vllm-custom.sif vllm.def
Place the .sif on your cluster's shared filesystem (NFS, Lustre, GPFS) and use that path as the image field in the model spec.
Image checklist:
serving_port.GET <health_path> with a 2xx when healthy (used by the provisioner's health probe).--nv; you do not need to bundle CUDA in the image (though bundling it is fine too).module load apptainer), make sure apptainer is on the default PATH for batch jobs, or prepend the load command to launch_command.The spec is set via the Provisioning tab on the model detail panel (or directly via the Management API PUT /api/v1/models/{id}/managed).
| Field | Required | Default | Description |
|---|---|---|---|
partition | yes | — | Slurm partition to submit to |
gres | no | "" | Generic resource spec, e.g. gpu:a100:1 |
nodes | no | 1 | Node count per replica |
image | no | "" | Path to the Apptainer .sif on shared storage. Leave blank to run launch_command directly on the node — for sites serving a native, module-loaded binary rather than a container |
preamble | no | "" | Shell lines injected before apptainer exec in the batch script. Use to load modules or extend PATH, e.g. module load apptainer/1.3.4. Supports multiple lines. |
launch_command | yes | — | Command run inside the container, e.g. python -m vllm.entrypoints.openai.api_server --model /data/models/llama3 --port 8080 |
serving_port | yes | — | Port the inference server listens on inside the job |
health_path | no | /health | HTTP path used to probe replica health |
target_replicas | no | 2 | Number of replicas to keep alive |
account | no | — | Slurm account for billing |
qos | no | — | Quality-of-service class |
time_limit | no | — | Job walltime in any format Slurm's --time accepts (minutes, MM:SS, HH:MM:SS, D-HH, D-HH:MM:SS). The provisioner converts it to the whole minutes slurmrestd requires, rounding partial minutes up; an unparseable value is omitted so the partition default applies |
constraints | no | — | --constraint filter, e.g. a100 |
exclude | no | — | Comma-separated list of nodes to exclude |
cpus_per_task | no | cluster default | Slurm --cpus-per-task |
mem | no | cluster default | Slurm --mem, e.g. 560G; converted to megabytes for slurmrestd |
log_output_dir | no | Slurm default | Directory for job stdout/stderr; jobs write {dir}/{job_name}-%j.out and .err |
script_body | no | "" | A fully-rendered job script. When non-empty it is submitted verbatim and takes precedence over the image / preamble / launch_command assembly — this is what a deployment recipe produces |
min_replicas | no | 1 | Health floor for the endpoint pool: the model reports healthy while at least this many endpoints are serving, degraded while some but fewer are |
max_job_failures | no | 0 | Stop resubmitting once this many lost replicas are visible; 0 = no limit |
enabled | no | true | Toggle without deleting the spec. Disabling drains replicas to zero. |
On the model detail panel, the Provisioning tab appears only for Slurm-provisioned models. It shows:
pending, starting, healthy, draining, lost), node, and Slurm job ID, plus a warning banner when those states can no longer be trusted (see below).
Replicas transition through states automatically:
pending — job submitted, waiting on the Slurm queue for an allocationstarting — job submitted, not yet health-probedhealthy — health probe passed; the replica is in the endpoint poollost — job preempted or gone; the endpoint is detached and a new job will be submitted on the next tickdraining — the replica is being removed on purpose (scale-down, disable, cancel); the row says whyA replica passes its health check as soon as the inference server answers
/health, but its first request can still be slow — the model has to do its
first forward pass (graph capture, cache warmup), which on a cold box can take
long enough to surface to a user as a 502/504. To avoid that, the provisioner
fires one throwaway request at each replica right after it is promoted, so the
cold first-token cost is paid by the gateway instead of by the first real user.
Warmup is on by default. Tune or disable it with
OBLETH_PROVISIONER_WARMUP_TIMEOUT_SECS (default 600s; 0 disables) — see
Environment Variables.
Each Slurm-backed endpoint has a Restart action on the model's Reliability tab: it cancels that replica's Slurm job, and the provisioner launches a fresh one to hold the target count. Static (non-managed) endpoints are unaffected. Cancelling a replica deregisters its endpoint before the job is killed, so it leaves the routing pool immediately rather than serving a few more requests into a dying process.
A Slurm job can report RUNNING while the inference server inside it is dead or
wedged. The provisioner restarts such a replica on either of two independent
signals:
healthy replica whose job reports RUNNING. A miss
increments a per-replica counter; a pass decays it by one rather than
resetting it. Once the counter reaches
OBLETH_PROVISIONER_RESTART_AFTER_FAILURES (default 20, 0 disables), the
replica is restarted. The decay is what keeps a flapping replica alive: a
busy single-threaded server such as llama.cpp misses the occasional probe and
passes the next, so its counter hovers near zero and never trips, while a
genuinely dead server climbs steadily. At the default 15 s tick, 20 ticks is
roughly five minutes of sustained failure.unhealthy verdicts on a recent check are enough —
this catches the zombie the provisioner's own GET probe cannot see, a server
that answers metadata instantly but hangs forever on actual inference.Restarts are capped at one per model per tick, so a probe-side network problem rolls replicas gradually instead of mass-cancelling a fleet.
A related repair needs no signal at all: a replica marked healthy whose endpoint
was removed out of band — a manual delete in the Reliability tab, or a
cancellation that only half-landed — used to sit healthy forever while the model
ran one endpoint short. The provisioner detects the dangling reference and
registers a fresh endpoint on the next tick.
Note:
If you set OBLETH_PROVISIONER_RESTART_AFTER_FAILURES=3 yourself — the value the
Compose fallback, Helm chart default, and .env.example all shipped before v0.9.4
— remove or raise it. At three ticks a healthy-but-busy replica is cancelled after
about 45 seconds of probe flaps and resubmitted in a loop.
When slurmrestd rejects a submit — a bad account, partition, or QoS — the reason
is stored on the managed spec (last_provision_error) and shown as a banner on the
model. Fix the cause and dismiss the banner; it returns on its own if the next
launch also fails. Cancel failures are logged with the response body from
slurmrestd (for example Access/permission denied from slurm_kill_job2), not
just the HTTP status, so causes like a JWT user / job owner mismatch are visible.
The Placement and Service fields on the Provisioning tab are validated in the
browser before the save — time-limit format, port range, replica counts, node and
CPU numbers — so a malformed value is highlighted inline instead of surfacing as
an opaque slurmrestd 500.
/v1 rootWhen a replica passes its health probe, the provisioner registers it as a model endpoint with api_base = http://<resolved-address>:<discovered-port>/v1 — the node's override or resolved IP, and the port in its window that answered. The /v1 root matches the convention used by every statically-registered model and the model health check, which appends /models to a model's api_base expecting …/v1/models. OpenAI-compatible inference servers (vLLM, SGLang, LiteLLM, Ollama) all serve their OpenAI surface under /v1.
This is distinct from the provisioner's own liveness probe, which uses the spec's health_path (e.g. Ollama's native /api/tags, or vLLM's /health) against the bare node — it does not assume /v1.
A Slurm-provisioned model has no static api_base — its live URL lives in the endpoint pool, which changes as replicas come and go. So the model-level health badge is derived from the endpoint pool, not from a single static base:
min_replicas live endpoints are servingmin_replicas"Serving" counts an endpoint that answered, whether or not the upstream catalog
advertised the model id — the provisioner already confirmed the model was up
before promoting the replica, so reachability is the right signal here. This is
why a Slurm model whose framework renames the id (qwen2.5-0.5b vs
qwen2.5:0.5b) is not marked down for it.
A replica can be promoted to healthy (the provisioner reached it) while the model badge still shows unhealthy if the gateway cannot reach the endpoint — the two checks run from different places. See Troubleshooting.
RUNNING but the replica never becomes healthyThere are two independent health checks, and a replica is only promoted when the provisioner's probe passes:
Provisioner probe (promotes the replica). Each tick the provisioner does GET http://<address>:<port><health_path> from wherever the provisioner runs (its container or host — not from the compute node), for every port in the replica's window, concurrently. <address> is the node's resolved address — the override if one is set, otherwise the cached or freshly resolved IP. The replica is promoted on the lowest port that answers 2xx; the outcome is logged either way:
health probe: healthy replica_id=... job_id=19359 port=8000
health probe: not yet healthy replica_id=... job_id=19359 port_base=8000 port_span=8
not yet healthy almost always means the provisioner cannot reach the node, not that the model is down. A curl that works on the node itself proves nothing — the probe originates from the provisioner. Verify reachability from inside the provisioner container:
getent hosts gpu7 # name resolves?
curl -s -o /dev/null -w '%{http_code}\n' http://gpu7:8000/api/tags # port reachable?
If the log instead says job is RUNNING but slurmrestd returned no nodes, that is a different problem — slurmrestd is not returning the allocated nodelist. Check that your API version matches /openapi/v3.
Model health check (the red/green badge). This runs in the gateway (obleth) container against the registered endpoints, so the gateway also needs to reach <node>:<serving_port>. This is why a replica can be healthy while the model badge is still unhealthy.
The model detail Health tab shows the probe history — handy for spotting a model that has just started recovering. In the run below the endpoint was registered before the /v1 fix, so the gateway probes returned unhealthy (HTTP —, 0 ms) until a fresh promotion moved it to degraded:
Before reaching for container plumbing, try the node address overrides in Settings → Slurm: they pin each node to an IP inside obleth itself, so both the probe and proxied traffic stop depending on name resolution, and they need no restart.
When the container cannot route to the node at all, fix it at the container
level. Both the provisioner and the gateway need to resolve and route to
your cluster node hostnames. Add a gitignored
deploy/docker/docker-compose.override.yml so no node IPs land in git, and put
extra_hosts on both services:
services:
obleth-provisioner:
extra_hosts:
- "gpu7:10.0.0.24" # real routable IP of the node
obleth: # the model health check + data plane need it too
extra_hosts:
- "gpu7:10.0.0.24"
Run the single command from deploy/docker/ (docker compose up -d --build) and the override is auto-merged. extra_hosts is applied at container-create time, so recreate the containers to pick it up (--force-recreate if Compose thinks they are current).
Note:
extra_hosts only fixes name→IP resolution; the container must still be able to route to that IP (same LAN/VPN). On Docker Desktop, arbitrary LAN cluster hosts are often unreachable — in that case run the provisioner on a login/head node via systemd instead.
/v1 fixPromotion only creates an endpoint; it never rewrites an existing one. A replica promoted by an older provisioner keeps its pre-/v1 api_base until a fresh promotion. To refresh it, force a new replica — toggle the model's provisioning off and back on, or set target_replicas to 0 then back — so the next promotion registers at the /v1 root.
| Scope | Effect |
|---|---|
| Dashboard Settings → Slurm enabled = off | Provisioner idles; does not drain existing replicas. v1 limitation — drain-on-global-disable is planned. |
Per-model enabled = false | Replicas drained to zero; jobs cancelled; endpoints detached. The spec is kept and the model can be re-enabled later. |
Delete managed spec (DELETE /api/v1/models/{id}/managed) | Same drain behavior as disabling, but the spec is removed. |
serving_port is the base of that window, not a fixed port.gpu[01-04]) are not expanded.target_replicas is a fixed count. The provisioner only maintains it and replaces preempted jobs — it does not scale based on load.| Method | Path | Description |
|---|---|---|
GET | /api/v1/managed | List all managed model specs |
GET | /api/v1/models/{id}/managed | Get the managed spec for one model |
PUT | /api/v1/models/{id}/managed | Create or update a managed spec |
DELETE | /api/v1/models/{id}/managed | Remove the spec (triggers drain) |
GET | /api/v1/replicas | List all replicas |
GET | /api/v1/models/{id}/replicas | List replicas for one model |
PATCH | /api/v1/models/{id}/managed/provision-error | Dismiss the model's last provisioning error (it returns if the next launch also fails) |
POST | /api/v1/models/{id}/replicas | Register a replica row |
POST | /api/v1/models/{id}/replicas/clear-lost | Drop the model's lost replica rows so the provisioner retries from a clean slate |
PATCH | /api/v1/replicas/{id} | Update a replica row (state, node, endpoint, cancel request) |
POST | /api/v1/replicas/{id}/restart | Cancel that replica's Slurm job; the provisioner launches a fresh one |
DELETE | /api/v1/replicas/{id} | Remove a replica row |
Slurm system settings (see also Management API reference):
| Method | Path | Description |
|---|---|---|
GET | /api/v1/settings/slurm | Current settings (JWT masked) |
PUT | /api/v1/settings/slurm | Update settings |
POST | /api/v1/settings/slurm/test | Test connection: JWT expiry + slurmrestd ping |
GET | /api/v1/slurm/resources | Best-effort cluster discovery (partitions, nodes, the Slurm user's accounts and QoS) used to populate the dashboard's suggestions |
GET | /api/v1/settings/slurm/resolved | Full settings with decrypted JWT (provisioner-internal) |