herzogflorian c057f8efb7 Kubernetes deployment: shared data directory, write lock, probes
A third deployment next to the two on the DGX Spark, which stay as they
are: deploy/k8s/ has its own image and Kustomize manifests. The API is a
Deployment with probes, the data a PersistentVolumeClaim, the nightly
update a CronJob; overlays/gpu adds cuOpt on a GPU of the cluster.

Five assumptions of the single-container setup did not hold for pods, so
the service changed (on the Spark nothing changes that one would notice):

- probes: GET /health/live (async, no I/O) and GET /health/ready (data
  directory readable); neither needs cuOpt or market data, so a cuOpt
  outage never gets the API restarted. /health is unchanged
- DataStore replaces every file atomically (temporary file renamed into
  place) and saves the manifest last; the API reloads its market data
  when the manifest changes, so it serves what a CronJob wrote
- one writer at a time across processes and pods: an flock on
  /data/.write.lock, taken by API jobs and the CLI's data commands
  (setup, reselect, update, clean, snapshot); a second writer gets 409 or
  exit status 3, with the holder's name. `clean` now updates the manifest
- job records live in /data/jobs/ and are written at every status
  change: every replica answers GET /jobs/{id}, and a job whose process
  has gone is reported failed ("interrupted")
- the image runs as uid 10001 under a read-only root file system, with
  the library versions pinned to deploy/apptainer/constraints.txt (SciPy
  1.18 releases the GIL while HiGHS solves, so the liveness probe is
  answered during an optimisation)

docs/KUBERNETES.md is the guide (build, deploy, load the data, operate,
troubleshooting); README, USAGE and ARCHITECTURE point to it.

Both kustomizations render (kubectl kustomize); the k3s test of the
deployment is recorded in docs/KUBERNETES.md. 149 tests (148 pass, the
cuOpt round trip skipped without a server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:27:43 +01:00

208 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Running the quant service on Kubernetes
quantcore has two kinds of deployment. They run the same code and give the same results:
| Deployment | Where | Files | Guide |
|---|---|---|---|
| **DGX Spark**, Apptainer, no root | a student's Spark | `deploy/apptainer/`, `install.sh` | [SPARK_APPTAINER.md](SPARK_APPTAINER.md) |
| **DGX Spark**, Docker Compose, with root | the lecturer's Spark | `deploy/Dockerfile`, `deploy/docker-compose.*.yml`, `deploy/deploy_to_spark.sh` | [SPARK_DEPLOYMENT.md](SPARK_DEPLOYMENT.md) |
| **Kubernetes** | any cluster: k3s on a Spark, Docker Desktop, kind, minikube, a university or cloud cluster | `deploy/k8s/` (its own image and manifests) | this page |
The Spark deployments are unchanged. The Kubernetes one has its own Dockerfile and manifests, and
nothing in `deploy/k8s/` is used by the other two.
## What runs in the cluster
```
namespace quantcore
├─ Deployment quantcore API + test bench, uvicorn, 1 replica, port 5001
├─ Service quantcore NodePort 30501 (inside the cluster: http://quantcore.quantcore:5001)
├─ PVC quantcore-data the data directory /data: universe, prices, FX, snapshots, job records
├─ ConfigMap quantcore-config generated from deploy/k8s/base/quantcore.env (QUANTCORE_* settings)
├─ CronJob quantcore-update `quantcore update`, Mon–Fri 23:15 Europe/Zurich
├─ CronJob quantcore-setup `quantcore setup`, suspended: started by hand, once
└─ Deployment + Service cuopt only with overlays/gpu: cuOpt on one NVIDIA GPU, port 5000
```
```
deploy/k8s/
Dockerfile, Dockerfile.dockerignore the image (pinned libraries, uid 10001, read-only root fs)
base/ CPU only: every optimisation runs on HiGHS in the API pod
overlays/gpu/ base + cuOpt on a GPU of the cluster
```
## What changed in the service, and why
A pod is not a container on a host you look after. Kubernetes restarts it when a probe fails,
replaces it on every rollout, may run several copies, and runs the nightly update in a pod of its own.
Five assumptions of the single-container setup did not survive that, and the code changed for them.
These changes apply to the Spark deployments as well, where they change nothing you would notice.
| Assumption | Why it breaks on Kubernetes | Change |
|---|---|---|
| `/health` is the health check | It asks cuOpt (up to 5 s). As a liveness probe, a cuOpt outage would get healthy API pods restarted | `GET /health/live` (async, no I/O) for liveness, `GET /health/ready` (data volume readable) for readiness. Neither needs cuOpt or market data. `/health` is unchanged |
| The API loads the data once and reloads after its own jobs | The CronJob writes the data from another pod, and the API would keep serving yesterday's prices | Every writer saves `manifest.json` last. The API reloads its market data when that file changes |
| Files are written in place | A reader in another pod could read half a parquet file | `DataStore` writes to a temporary file and renames it into place, an atomic replace |
| One data job at a time, enforced by a lock inside the process | The CronJob, a second replica or the CLI cannot see that lock | A file lock (`flock`) on `/data/.write.lock`, taken by API jobs, CLI data commands and therefore the CronJobs. The kernel frees it when its holder dies. A second writer gets HTTP 409 or exit status 3, with the holder's name |
| Job records live in memory | A restarted pod forgets them, and another replica never knew them | Records are written to `/data/jobs/` at every status change. `GET /jobs/{id}` works from every replica. A job whose pod died is reported `failed` (`interrupted: …`) |
The Kubernetes image (`deploy/k8s/Dockerfile`) differs from `deploy/Dockerfile` in three ways:
- **Pinned libraries:** it installs the versions in `deploy/apptainer/constraints.txt`. This matters
for the liveness probe. SciPy 1.18.1 releases the GIL while HiGHS solves: during a 15 s solve the
probe was answered within 0.3 s at worst. SciPy 1.13 blocks every other thread for the whole solve,
and the pod would be restarted in the middle of an optimisation.
- **Unprivileged user:** it runs as uid 10001 with `HOME=/tmp`, under a read-only root file system.
yfinance keeps its cookie cache in `HOME`.
- **No `HEALTHCHECK`:** Kubernetes ignores it.
## 1. Build the image
From the repository root, for the platform of the cluster's nodes:
```bash
docker build -f deploy/k8s/Dockerfile -t quantcore:0.1.0 .
```
Then make it available to the cluster:
| Cluster | Command |
|---|---|
| Docker Desktop | nothing: its Kubernetes uses Docker's images |
| kind | `kind load docker-image quantcore:0.1.0` |
| minikube | `minikube image load quantcore:0.1.0` |
| k3s (e.g. on a Spark; build on the Spark itself, it is arm64) | `docker save quantcore:0.1.0 \| sudo k3s ctr images import -` |
| a registry | `docker buildx build --platform linux/arm64,linux/amd64 -f deploy/k8s/Dockerfile -t <registry>/quantcore:0.1.0 --push .` and set `newName: <registry>/quantcore` under `images:` in `deploy/k8s/base/kustomization.yaml` |
The manifests use `imagePullPolicy: IfNotPresent`. A rebuilt image with the same tag is therefore
used only by new pods: after loading it, run `kubectl -n quantcore rollout restart deploy/quantcore`.
With a registry, give every build a new tag instead.
## 2. Deploy
**Without a GPU in the cluster:**
```bash
kubectl apply -k deploy/k8s/base
kubectl -n quantcore rollout status deploy/quantcore
```
Every optimisation then runs on HiGHS in the API pod, and the result says `solver: "highs"`.
To use a cuOpt that runs outside the cluster, set `QUANTCORE_CUOPT_URL=http://<host>:5000` in
`deploy/k8s/base/quantcore.env` before applying. For example, the cuOpt container next to the Docker
Compose setup on the lecturer's Spark.
**With cuOpt on a GPU of the cluster:**
```bash
kubectl apply -k deploy/k8s/overlays/gpu
```
The cluster needs NVIDIA's container runtime and device plugin. On k3s, the RuntimeClass `nvidia` is
created by k3s itself. Then install the device plugin and check that the node offers the GPU:
```bash
helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
helm upgrade -i nvdp nvdp/nvidia-device-plugin -n nvidia-device-plugin --create-namespace \
--set runtimeClassName=nvidia
kubectl describe node | grep nvidia.com/gpu # nvidia.com/gpu: 1
```
The cuOpt pod has a startup probe and nothing else. cuOpt works on one request at a time and may not
answer its health check during a long solve. A readiness probe would remove the Service under a solve
the API is still polling, and a liveness probe would restart the solver in the middle of it.
## 3. Load the data
The volume starts empty. `GET /health/ready` answers `"market_data": false`, and the data endpoints
answer 503 until there is data. Either copy a prepared data directory into the pod:
```bash
COPYFILE_DISABLE=1 tar --no-xattrs -C data -cf - manifest.json universe prices fx snapshots \
| kubectl -n quantcore exec -i deploy/quantcore -- tar -C /data -xf -
```
(Leave out `snapshots` if you have none. `COPYFILE_DISABLE=1 --no-xattrs` keeps macOS `tar` from
adding `._*` files; on Linux both are harmless. The command names the entries rather than `.`
because the volume's root directory is not owned by the pod's user.)
Or build the data from Yahoo Finance, about 15 minutes:
```bash
kubectl -n quantcore create job --from=cronjob/quantcore-setup quantcore-setup-1
kubectl -n quantcore logs -f job/quantcore-setup-1
```
`curl -X POST 'http://<node>:30501/jobs/setup?offline=true'` does the same through the API, with the
candidate lists shipped in the package. A job of the API dies with its pod, however, so a rollout
during those 15 minutes interrupts it. The Job above does not have that problem.
The API picks up the new data by itself. No restart is needed.
## 4. Use it
| From | URL |
|---|---|
| outside the cluster | `http://<node>:30501`; Docker Desktop: `http://localhost:30501`; `/docs`, `/ui/` |
| any machine with kubectl (also kind) | `kubectl -n quantcore port-forward svc/quantcore 5001:5001`, then `http://127.0.0.1:5001` |
| another pod in the cluster | `http://quantcore.quantcore:5001` |
```bash
quantcore-api --url http://<node>:30501 selftest # every endpoint, pass/fail table
```
## 5. Operate
```bash
kubectl -n quantcore get pods,cronjobs,jobs
kubectl -n quantcore logs -f deploy/quantcore
kubectl -n quantcore create job --from=cronjob/quantcore-update update-now # the nightly update, now
kubectl -n quantcore exec deploy/quantcore -- quantcore status
kubectl -n quantcore exec deploy/quantcore -- quantcore snapshot --name demo
```
| Task | How |
|---|---|
| New code | build and load the image (section 1); with a new tag, change `newTag` in `base/kustomization.yaml`, then `kubectl apply -k …`. A rollout starts the new pod before it stops the old one, so there is no downtime |
| Change a setting | edit `deploy/k8s/base/quantcore.env` and `kubectl apply -k …`. The ConfigMap gets a new name, which rolls the pods |
| API only, without the test bench | `QUANTCORE_ENABLE_UI=false` in `quantcore.env` |
| Another update time | `schedule` / `timeZone` in `base/cronjobs.yaml` |
| More replicas | `kubectl -n quantcore scale deploy/quantcore --replicas=3`. All replicas mount one volume, so they must share a node (one-node clusters) or the PVC must use a ReadWriteMany storage class. Each replica holds the market data in memory (~0.5–1 GB) |
| Remove it, keep the data | `kubectl -n quantcore delete deploy,svc,cronjob,job --all` |
| Remove everything | `kubectl delete -k deploy/k8s/base` (or `overlays/gpu`). **This deletes the PVC and the data with it** |
## Settings in the manifests
| Setting | Value | Why |
|---|---|---|
| API resources | requests 1 CPU / 1 GiB, limits 4 CPU / 4 GiB | measured: 0.75 GB resident after a 150-asset optimisation on the full data set. 4 CPUs match `OPENBLAS_NUM_THREADS=4` |
| `terminationGracePeriodSeconds` | 360 | uvicorn finishes the requests in flight after SIGTERM, and an optimisation can use the whole 300 s time limit |
| `preStop: sleep 5` | | the Service stops routing to the pod before uvicorn stops accepting |
| startup / liveness / readiness | `/health/live` every 2 s up to 120 s / every 20 s, 3 failures / `/health/ready` every 10 s | |
| Pod security | uid 10001, read-only root fs, no capabilities, seccomp `RuntimeDefault` | the API and job pods meet the `restricted` Pod Security Standard; the cuOpt pod runs NVIDIA's image unchanged |
| Update job | `concurrencyPolicy: Forbid`, `backoffLimit: 3`, 1 h deadline | a run that finds the store busy exits with 3 and is retried |
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `ErrImageNeverPull` / `ImagePullBackOff` | the node does not have the image: load it (section 1), or set `newName` to your registry |
| `PermissionError: '/data/…'` in a pod | the storage class ignores `fsGroup`, so the volume is not writable for uid 10001. Use a class that applies it (local-path, most CSI drivers), or prepare the directory's owner on the node |
| `Multi-Attach error` for `quantcore-data` | a ReadWriteOnce volume is wanted on two nodes (API and job pod, or two replicas). Use a ReadWriteMany class, or keep to one node |
| cuOpt pod `Pending`, `Insufficient nvidia.com/gpu` | the device plugin is missing or the GPU is taken. Without cuOpt the API keeps optimising on HiGHS |
| `409 the data store is busy: … on <pod>` | a data job is running, named with its pod. Wait for it: `kubectl -n quantcore get jobs`, `GET /jobs` |
| a job record says `interrupted` | the pod that ran it was deleted (a rollout, a node drain). Start the job again, preferably as a Kubernetes Job (section 3) |
| `/optimize` answers 504 through an Ingress | the proxy timeout is shorter than the solve. For ingress-nginx: `nginx.ingress.kubernetes.io/proxy-read-timeout: "900"` |
| `/health/ready` answers 503 | the data volume cannot be read: `kubectl -n quantcore describe pod`, and look at the volume |
## What was tested
On k3s v1.32.2 (one arm64 node in Docker):
- `deploy/k8s/base` deployed, and the data imported as in section 3.
- The update CronJob ran as a Job. While it ran, the API answered 409 and named the Job's pod; afterwards it served the new prices without a restart.
- A rollout under load: 547 requests, all 200.
- The pods meet the `restricted` Pod Security Standard.
The GPU overlay passed a server-side dry run only; that cluster has no GPU. The setup job was not run end to end.