🚢 Deployment¶
How to run LiSa-LLM for real: in containers, behind TLS, watched by your monitoring, and stopped without dropping anyone's request.
Docker¶
KEY="$(openssl rand -hex 24)"; echo "Your API key: $KEY"
docker run -d --name lisa -p 8080:8080 \
-v ./models:/models:ro \
-e LISA_API_KEYS="$KEY" -e LISA_API_KEY= \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
ghcr.io/yog-sotho/lisa-llm:1.0.1
- The image expects the model at
/models/model.ggufand reads/etc/lisa/config.yaml; mount your own config there, or useLISA_*environment variables. - It runs as UID 10001 and needs nothing writable but
/tmp, so--read-onlyand--cap-drop ALLcost nothing. docker psshowshealthyonce the model is loaded: the image'sHEALTHCHECKrunslisa --health-check, which asks/readyz. Big models take a while; the check allows two minutes before it counts failures.
Sandbox in a container¶
The sandbox creates user namespaces, which Docker's default seccomp and AppArmor profiles forbid. Relax only Docker's own profiles; the worker then installs a far stricter seccomp filter and Landlock rules of its own:
docker run -d --name lisa -p 8080:8080 -v ./models:/models:ro \
-e LISA_API_KEYS="$KEY" -e LISA_API_KEY= -e LISA_SANDBOX_ENABLED=true \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
--security-opt seccomp=unconfined --security-opt apparmor=unconfined \
ghcr.io/yog-sotho/lisa-llm:1.0.1
docker logs lisa | grep "Sandboxed worker ready"
On Ubuntu 23.10+ hosts, also allow unprivileged user namespaces on the host:
sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0. CI runs exactly this setup on every
change.
GPU¶
docker run -d --gpus all -p 8080:8080 -v ./models:/models:ro \
-e LISA_API_KEYS="$KEY" -e LISA_API_KEY= \
ghcr.io/yog-sotho/lisa-llm:1.0.1-cuda
Needs an NVIDIA driver and the NVIDIA Container Toolkit.
GPU drivers need device files and ioctl calls that the sandbox can't allow, so GPU mode runs
with the sandbox off: only serve models you trust there, and pin them with model_sha256.
Docker Compose¶
docker-compose.yml has three ready-made services, all with a read-only
root, no capabilities and no-new-privileges:
echo "LISA_API_KEYS=$(openssl rand -hex 24)" > .env # or a sha256: entry (lisa --hash-key)
cp /path/to/your-model.gguf models/model.gguf
docker compose up -d lisa # CPU
docker compose up -d lisa-sandboxed # CPU, model parsed in the sandboxed worker
docker compose --profile gpu up -d lisa-cuda # NVIDIA GPU
TLS and mutual TLS¶
enable_tls: true
cert_file: "/etc/lisa/tls/server.crt" # PEM, full chain
key_file: "/etc/lisa/tls/server.key"
tls_min_version: "1.3" # or "1.2" (forward-secret AEAD suites only)
client_ca_file: "/etc/lisa/tls/clients-ca.crt" # optional: require client certificates
With TLS on, responses carry Strict-Transport-Security. With client_ca_file, only clients with
a certificate signed by that CA can even complete the handshake; API keys are still checked on
top.
A quick self-signed setup for testing:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost" \
-keyout server.key -out server.crt
curl --cacert server.crt https://localhost:8080/readyz
Behind a reverse proxy¶
Terminating TLS at nginx, Caddy or a load balancer works too. Two things matter: turn off response buffering so streamed tokens arrive as they are generated, and allow long responses.
location /v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off; # stream tokens as they come
proxy_read_timeout 300s; # long generations
proxy_set_header X-Request-Id $request_id;
}
Keep LiSa itself on 127.0.0.1 then. Behind a proxy every request comes from the proxy's address,
so rate limits are per key: give each client its own key.
Health checks¶
| Check | Use it for | Answers |
|---|---|---|
GET /healthz |
Liveness: restart if this fails | 200 while the process runs |
GET /readyz |
Readiness: send traffic only when this passes | 200 when ready; 503 while draining or when the worker keeps crashing |
lisa --health-check |
Containers without curl (the image's HEALTHCHECK) |
Exit 0 when /readyz says ready, 1 otherwise |
--health-check reads the same configuration as the server (address, port, TLS), so pass it the
same --config and environment.
Kubernetes probes (the container port is 8080):
livenessProbe:
httpGet: {path: /healthz, port: 8080}
periodSeconds: 10
readinessProbe:
httpGet: {path: /readyz, port: 8080}
periodSeconds: 5
startupProbe: # loading a big model takes a while
httpGet: {path: /readyz, port: 8080}
periodSeconds: 5
failureThreshold: 60
Use scheme: HTTPS in the probes when enable_tls is on.
Monitoring¶
GET /metrics serves Prometheus metrics (it needs an API key when keys are configured):
scrape_configs:
- job_name: lisa
authorization: {credentials_file: /etc/prometheus/lisa-key}
static_configs: [{targets: ["lisa:8080"]}]
| Metric | Type | What it tells you |
|---|---|---|
lisa_ready |
gauge | 1 when ready, 0 while draining or when the worker is down |
lisa_requests_running, lisa_requests_queued |
gauge | Slots in use, requests waiting |
lisa_http_requests_total{route,code} |
counter | Requests by route and status |
lisa_http_request_duration_seconds |
histogram | Request latency |
lisa_queue_wait_seconds |
histogram | Time spent waiting for a slot |
lisa_time_to_first_token_seconds |
histogram | How long until the first token |
lisa_generation_tokens_per_second |
histogram | Generation speed per request |
lisa_prompt_tokens_total, lisa_completion_tokens_total |
counter | Tokens in and out |
lisa_prompt_cached_tokens_total |
counter | Prompt tokens served from the prompt cache |
lisa_generations_total |
counter | Completed generations |
lisa_requests_rejected_total{reason} |
counter | 429s and other refusals, by reason |
lisa_worker_deaths_total, lisa_worker_restarts_total |
counter | Crashed or killed inference workers |
lisa_build_info{version} |
gauge | The running version |
Good alerts to start with: lisa_ready == 0 for more than a minute, any increase of
lisa_worker_deaths_total (a crash or a blocked system call: investigate the model), and a
growing lisa_requests_rejected_total (time for more parallel_slots or another instance).
Stopping and upgrading¶
SIGTERM or SIGINT (Ctrl-C) starts a graceful drain:
/readyzturns503, so load balancers stop sending traffic.- New requests get
503withRetry-After. - Running and queued requests finish, for up to
shutdown_timeout_seconds(30 s); whatever is still running then is cancelled.
A second signal stops at once. For rolling upgrades, give the orchestrator a stop timeout longer
than shutdown_timeout_seconds (Kubernetes: terminationGracePeriodSeconds: 45).
systemd¶
# /etc/systemd/system/lisa.service
[Unit]
Description=LiSa-LLM inference server
After=network-online.target
Wants=network-online.target
[Service]
User=lisa
Group=lisa
ExecStart=/usr/local/bin/lisa --config /etc/lisa/config.yaml
Restart=on-failure
TimeoutStopSec=45
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target
sudo useradd --system --no-create-home lisa
sudo systemctl daemon-reload && sudo systemctl enable --now lisa
journalctl -u lisa -f -o cat
Keep the configuration and key files readable only by lisa (chmod 600; LiSa warns about a
plaintext key file that group or others can read). If you add more
systemd sandboxing, leave user namespaces allowed (no RestrictNamespaces= or PrivateUsers=):
LiSa's own sandbox needs them. For hard memory limits, add Delegate=yes and point
sandbox_cgroup at the delegated cgroup folder.