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>
12 KiB
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 |
| DGX Spark, Docker Compose, with root | the lecturer's Spark | deploy/Dockerfile, deploy/docker-compose.*.yml, deploy/deploy_to_spark.sh |
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 inHOME. - No
HEALTHCHECK: Kubernetes ignores it.
1. Build the image
From the repository root, for the platform of the cluster's nodes:
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:
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:
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:
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:
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:
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 |
quantcore-api --url http://<node>:30501 selftest # every endpoint, pass/fail table
5. Operate
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/basedeployed, 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
restrictedPod 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.