Handing Off to Docker and Kubernetes
The container is where this book stops and two others begin, and the line is precise. The service provides an image that starts with one command, reads its configuration from the environment, takes its secrets from a variable or a mounted file, serves its two probes, handles SIGTERM, logs to stdout and declares what memory and CPU it needs. Everything about building that image well, storing it in a registry, wrapping it in a pod, rolling it out and scheduling it onto a node belongs to Docker Deep Dive and Kubernetes Deep Dive. Stagedoor's first image met one of the seven items, the command, and the other six were discovered on the platform's side, one outage at a time, which is the order this chapter exists to reverse.
This topic is the checklist from the service's side, the twelve-line image Stagedoor ships, the one step that runs outside the rollout, and what the rollout and the autoscaler look like from inside a process that does not know they are happening. The last section is the list of things that are not the service's problem, which is longer than the list of things that are.
The Contract the Container Expects
Seven items, and each one is a topic already written. One process per container: the api and the worker are two containers from one image with two commands, and under an orchestrator each api container runs one event loop, so that the four processes of api-01 become four replicas and every limit describes one loop. Configuration by environment, from Topic 23. Secrets by variable or mounted file, from Topic 59. /healthz and /readyz, from Topic 60. SIGTERM handled with a drain inside 30 seconds, from Topic 60. One JSON line per event on stdout, from Topic 58 and Chapter 13. Limits declared from measurements, from Topic 61. The table is the checklist with a tick per process, and it is the document Marek hands to whoever runs the platform.
| The container expects | api | worker | migrator | Where it was built |
|---|---|---|---|---|
| One process, one command | stagedoor api | stagedoor worker | stagedoor migrate | this topic |
| Config from the environment | 10 variables | 10 variables | 1 variable | Topic 23 |
| Secrets by file or variable | 7 files | 3 files | 1 file | Topic 59 |
| Liveness and readiness | both | liveness only | none: it exits | Topic 60 |
SIGTERM drained under 30 s | requests | the current job | the current statement | Topic 60 |
| Logs to stdout | JSON lines | JSON lines | JSON lines | Topic 58 |
| Limits declared | 1.25 GB, 4 cores | 2.5 GB, 8 cores | 256 MB, 1 core | Topic 61 |
Three columns because there are three commands, and the differences between them are the interesting cells. The worker has no readiness because nothing routes traffic to it; its liveness is a file it touches every loop iteration, which the probe checks the age of, because a worker with no port needs a probe that is not HTTP. The migrator has neither, because it runs to completion and its exit status is its health. The api limit is Topic 61's 1.25 GB and four cores for the four loops on api-01; under an orchestrator that runs one loop per replica it becomes 312 MB and one core each, four times, and the worker keeps its 2.5 GB and 8 cores because it is one process with 8 render threads whichever platform runs it.
The Image, Minimally
A base image with Python 3.14, the locked dependencies installed, the code copied in, a user that is not root, and one command. Twelve lines, and every one of them is there for a reason the prose can name; what is not in it, the multi-stage build that leaves the build tools behind, the layer cache that makes a rebuild take 4 seconds instead of 90, the size of the result, is Docker Deep Dive's first three chapters.
FROM python:3.14-slim ENV PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH" # stdout unbuffered: the collector sees each line as it is written WORKDIR /app COPY pyproject.toml uv.lock ./ RUN pip install --no-cache-dir uv && uv sync --locked --no-dev --no-install-project COPY src/ ./src/ RUN uv sync --locked --no-dev # installs the project itself: the stagedoor command RUN useradd --uid 10001 --no-create-home stagedoor USER stagedoor EXPOSE 8000 CMD ["stagedoor", "api"] # exec form: PID 1 is the process, and SIGTERM reaches it
The base image is the interpreter and nothing else. The two environment lines make stdout unbuffered, so a log line reaches the collector when it is written rather than when a 4 KB buffer fills, and put the virtual environment's commands on the path. The lockfile is copied before the code and the dependencies installed from it with --locked, which refuses to build if the lockfile and the project disagree, so the image built on Tuesday installs what Monday's did. The code is copied after, and the project installed, which is what makes stagedoor a command. A user with a fixed id is created and switched to, so the process runs without root from the first line of its life. The port is declared. And the command is written in the form that runs the process directly as the container's first process, rather than through a shell, because a shell as PID 1 receives the SIGTERM and does not forward it, and the drain of Topic 60 never runs.
What is missing is deliberate. No .env, no secret, no environment name: the image runs unchanged in staging and production and could be published without leaking anything. No web server in front of uvicorn: the process serves HTTP itself and the platform's balancer terminates TLS. No migration in the command, for the next section's reason. And no second process: a worker image is this file with the last line's second word changed, or more simply this image started with a different command, which is what Stagedoor does.
Migrations Are a Separate Step
stagedoor migrate runs once per deploy, before the rollout, as a job whose exit status gates the rollout: a Kubernetes Job, a Cloud Run job, a CI step against the database, whichever the platform offers. It runs from the same image with the same commit, so the migration files it applies are exactly the ones the code it precedes was written against. It runs with the migrator's one secret and one variable. And it runs with a lock_timeout of 2 seconds, as Topic 35 set, so that a migration that cannot get its lock fails fast, the job exits non-zero, the rollout never starts, and the old code keeps serving while an engineer picks a quieter minute.
apiVersion: batch/v1 kind: Job metadata: { name: stagedoor-migrate-a4f6555 } # one job per commit; a rerun is a new job spec: backoffLimit: 0 # a failed migration is read by a human, not retried blind template: spec: restartPolicy: Never containers: - name: migrate image: registry.example/stagedoor:a4f6555 # the same image the rollout will use args: ["stagedoor", "migrate"]
The manifest says four things and the prose says why. The job is named for the commit, so the deploy pipeline can wait for exactly this one and a rerun after a fix is a distinct job with a distinct record. It does not retry on failure, because a migration that failed on a lock or on a mistake in the file must be read by a person before it runs again. It runs the same image as the code that follows, with a different command. And its success is what the rollout waits for; a pipeline that starts the rollout without waiting is the double-run of Topic 58 with extra steps. Everything that makes the migration safe to run before the new code and beside the old, the expand-migrate-contract discipline, was Topic 35's work, and this step is where it pays.
The Rollout From the Service's View
New containers start, bind their port, warm up, pass readiness and receive traffic. Old containers receive SIGTERM, fail readiness, drain and exit. The platform does this a few at a time, and for some seconds, on Stagedoor's two-replica deployment usually 10 to 20, both versions are serving. The service does not know it is being rolled out and does not need to. What it needs is that every change survives the window: every schema change is one the old code can run against, which Topic 35's expand step guarantees; every API change is additive or versioned by date, which Topic 17 of Chapter 3 guarantees; and every message on the stream is one the old worker can still parse, which is the same rule applied to Chapter 8's job payloads.
The stall is the rollout's best feature and it depends entirely on the probes. A new version whose config is missing a field fails at startup, never passes readiness, and the platform keeps the old replicas serving and reports the stall; a new version that binds and then cannot reach the database fails readiness and gets the same treatment. Stagedoor's missing PAYRAIL_KEY from Chapter 4, which took 40 minutes to find when it was read lazily, now takes zero minutes and zero buyers: the rollout stops at the first replica, and the deploy log says which field.
Scaling From the Service's View
Scaling the api is more replicas of a stateless container, and Topic 73 of Chapter 14 is what stateless costs. The autoscaler adds a replica when CPU or request rate crosses a line, the replica starts and passes readiness, and the balancer sends it a share; the service notices nothing. What it notices is Chapter 6's arithmetic: each replica brings a pool of 20, eight replicas are 160 connections, and the tenth replica is the one that makes Postgres refuse the 201st. The constraint scaling hits first is not CPU; it is max_connections, and the answer is either a smaller pool per replica or the pooler in front of the primary that Topic 31 named, and either way it is a number the service has to have made visible before the autoscaler finds it.
The worker scales on a different signal. Topic 47 of Chapter 8 put the age of the oldest unacknowledged entry on the dashboard, and that age is what the worker's autoscaler reads: 30 seconds of age against the 5 renders a second an on-sale brings means a second worker, and each worker brings its 8 render threads, its 2.5 GB and its own pool of 5. The service's job in both cases is to have exposed the numbers, request rate, pool in use, queue age, and to have made every replica interchangeable; the decision to add one, and the node it lands on, is the orchestrator's.
What Is Not the Service's Problem
The image registry and who may pull from it. The node, its kernel, its disk and its capacity. The pod's network, the cluster's DNS, the ingress that routes stagedoor.example to the api replicas, and the service mesh if there is one. The scheduler's placement, the quality-of-service classes, the eviction order under pressure. The rollout strategy's knobs, the autoscaler's thresholds, the platform's own upgrades. None of it is in the process, none of it is in this book, and a service that has met the seven items does not need to know which of them is in use.
The proof of the boundary is where Stagedoor's image has run. Production today is the two VMs, api-01 and api-02, running the image under systemd with four loops per container, which is the process model of Chapter 1 unchanged. Staging is Cloud Run, because it is cheaper for two replicas that idle at night. A Kubernetes trial ran the same image as four single-loop replicas per host with the migrate job above, and Marek's laptop runs it under Docker with a .env file, which is where every example in this book has run. Four platforms, one image, one checklist, and no branch in the code that asks which one it is on. That is the handoff: the process is a good citizen of whatever runs it, and the courses that begin here, Docker Deep Dive for the image and Kubernetes Deep Dive for everything after the push, start from a service that has already done its part.
- The
apiand the worker in one container — they share one memory limit and oneSIGTERM, a render's kill restarts theapi, and the platform cannot scale one without the other. - Migrations in
CMD— two replicas run it twice, the second queues on the first's lock, readiness never passes, and the startup hangs behind on-sale traffic holding an exclusive lock. - Running as root — the default every image scanner flags, and the starting point of every container escape; one
useraddand oneUSERline remove it. - Dependencies installed without the lockfile — the image built on Tuesday resolves a newer minor than Monday's, and the difference is found in production.
- The command in shell form —
CMD stagedoor apimakes a shell PID 1, the shell swallowsSIGTERM, and the drain never runs; every deploy becomes the 340-checkout kill. - Assuming one version runs at a time — the API change that removes a field, or the job payload the old worker cannot parse, breaks during the 20 seconds both versions serve.
- Meet the seven-item contract before the first image is built, and keep the checklist with a tick per process in the repository.
- Run one process per container, build one image per repository, and start it with a different command per process in exec form.
- Run
stagedoor migrateas a separate job from the same image, gated on its exit status, before the rollout begins. - Write every schema, API and job-payload change to survive two versions serving at once, and let the probes stall a rollout that cannot.
- Expose request rate, pool in use and queue age, and size the pool per replica against
max_connectionsbefore the autoscaler finds the limit.
Knowledge Check
Which item is on the seven-point contract the container expects from the process, and which is the platform's job instead?
- Handling SIGTERM is the process's; choosing the node is the platform's
- Running the registry is the process's; serving the probes is the platform's
- Rotating the secret store is the process's; declaring limits is the platform's
- Routing the ingress is the process's; the rollout strategy is the platform's too
Why is stagedoor migrate a job that gates the rollout rather than the first thing the api container does at startup?
- Because the api container has no database password, and only a job can be given one
- Because a migration cannot run while any version of the code is serving traffic
- Because a startup migration runs per replica, and a job runs once and can stop the rollout
- Because a job runs with more CPU than a container that must also serve requests at the same time
For 20 seconds of a rollout both versions of the api serve. What does that require of a change that removes a response field?
- A longer termination grace, so that the old version finishes before the new one is ready
- The field stays, versioned by date, until clients have moved; removal is a later deploy
- A pause with zero replicas between the versions, so that no client ever sees both shapes
- Sticky routing so each client sees one version, which the balancer arranges by cookie
The Dockerfile's last line is CMD ["stagedoor", "api"] and not CMD stagedoor api. What is the difference in production?
- The exec form installs the stagedoor command; the shell form expects it on the path
- The exec form reads environment variables; the shell form cannot expand them at start
- The exec form runs as the non-root user; the shell form runs the process as root
- The exec form makes the process PID 1 and gets SIGTERM; the shell form swallows the signal
You got correct