DEMO/Infrastructure

← Back to dashboard

Deploying the job worker

Audience: operator · Profile: both · Read this when: you are running the worker as its own process or container, or more than one of them.

Part of Job Worker.

The worker is a second deployment of the same image with a different command. It shares the database, the config table and the state backend with the app, and it needs no ingress, no volume and no cloud credentials of its own beyond what the app already has.

python -m web_dashboard.jobs_worker

Docker Compose

Already wired: the worker service in docker-compose.yml (and docker-compose.hub.yml). scripts/onboard.sh brings it up with the app. Replicas default to 1 — raise the caps before raising WORKER_REPLICAS.

Container Apps

The worker is its own Container App, not a sidecar in the app's. That is deliberate: a sidecar shares the parent's replica count and scale rules, so scaling the worker would scale gunicorn and the proxy with it, and the two have nothing in common in how they should scale.

az containerapp create -n dash-worker -g RG-CWeaver --environment cae-infradash --image chrweav/infra-dashboard:latest --command python --args "-m" "web_dashboard.jobs_worker" --cpu 0.5 --memory 1Gi --min-replicas 1 --max-replicas 1 --secrets db-url="postgresql://..." jwt-key="..." --env-vars DATABASE_URL=secretref:db-url JWT_SECRET_KEY=secretref:jwt-key APP_ENV=production

To change the pool later, without touching the caps:

az containerapp update -n dash-worker -g RG-CWeaver --set-env-vars DB_POOL_SIZE=8 DB_MAX_OVERFLOW=6

Four things to get right, in order of how badly they bite:

1. Pin --min-replicas 1 --max-replicas 1. The worker has no HTTP ingress, so minReplicas: 0 is not "scale to zero" — it is off. Nothing would ever generate the traffic that scales it up, so no queued job would ever run, and the only symptom is jobs sitting pending forever with nothing logged anywhere. At the other end, each replica is a whole extra connection pool (see the budget above), so raise max-replicas only after you have the connections for it.

2. There is no CLI flag for terminationGracePeriodSeconds. It is a template property, so it needs az containerapp update --yaml (or the portal). Unset means the platform default of 30 seconds, which is why WORKER_DRAIN_TIMEOUT_S defaults to 20 — comfortably inside it. If you raise the drain, raise the grace period first, or the platform kills the process before the drain it was told to perform can finish.

3. The app and the worker are separate Container Apps, which means they can carry different values for the same setting names with no code change and no role detection. This is the cheapest concurrency you will find: moving connections from the request path to the worker costs nothing in total.

dash        DB_POOL_SIZE=3  DB_MAX_OVERFLOW=5   ->  8 x 2 gunicorn workers = 16
dash-worker DB_POOL_SIZE=8  DB_MAX_OVERFLOW=6   ->  14
                                          total = 30, unchanged
worker clamp: (8 + 6 - 4) / 2 = 5  ->  the full heavy 2 + medium 1 + light 3 mix fits

4. Size CPU and memory for the heavy tier, not the average. A measured worker process holds about 130 MB with every cloud SDK loaded — boto3, the eight azure-mgmt packages, the seven google-cloud ones, oci, pyVmomi. That is the floor; what varies is what a job spawns outside the process. A Terraform apply is the terraform binary plus one provider plugin process per provider in the module, each tens of MB, so heavy=2 is the high-water mark. On 0.5 vCPU / 1 GiB — the reference deployment — that leaves roughly 890 MB for subprocesses, which is comfortable for two applies. Raise memory before raising heavy past 2.

CPU matters less than it looks: these jobs are overwhelmingly waiting on cloud APIs, not computing. 0.5 vCPU is fine for the shipped caps.

The reference deployment

For comparison, what the live install actually runs (RG-CWeaver / eastus2):

dash dash-worker
containers gateway (Caddy) + app worker
CPU / memory 0.25 / 0.5 Gi + 1.0 / 2 Gi 0.5 / 1 Gi
replicas 1–1 1–1
ingress port 80 none
health endpoint /api/health on 8000 (via Caddy) /api/health on 8080, pod-local
command image default (gunicorn) python -m web_dashboard.jobs_worker
database pg-infradash-601087, Burstable B1ms same

Caddy is a sidecar in the app's Container App rather than its own, because that is what keeps TRUSTED_PROXY_HOSTS=127.0.0.1 true. The worker is the opposite case — nothing about it wants to share a lifecycle with the request path.

The dash half of that table — the environment, the private database, the custom domains and the gateway sidecar's own config — is covered in cloud-hosting.md, along with the Cloud Run and ECS equivalents.