14. Orchestration and Production
Up to this point, most examples ran containers on one host, started by a person or by Compose. Production is different: applications run on many machines, must survive crashes and node failures, are updated many times a day without downtime, and are configured differently in each environment. This chapter covers orchestrators, which turn a cluster of machines into one place to run containers (Docker Swarm and Kubernetes), and the production practices that make a containerized application behave well under any orchestrator: clean shutdowns, a correct PID 1, meaningful health checks, externalized configuration, and deployment to managed cloud services.
<a id="141-docker-swarm"></a>
14.1 Docker Swarm
Docker Swarm is the orchestrator built into Docker Engine: with one command, a group of Docker hosts becomes a cluster that runs services declaratively, heals itself, and updates without downtime. It is simpler than Kubernetes and reuses the Compose file format, which makes it a good way to learn orchestration concepts and a practical choice for small and medium deployments. It builds on the Overlay Network section, which explains how Swarm connects containers across hosts.
Swarm Mode Architecture
Why it matters. With plain docker run, you are the orchestrator: you decide which host runs each container, you notice when one crashes, and you restart it by hand. With three hosts and ten services, that becomes a full-time job, and with a node failure at 3 a.m. it becomes an outage. An orchestrator takes over that job. You describe the desired state ("run 3 replicas of api:1.4, published on port 8080") and the orchestrator continuously works to make the actual state match it.
Analogy. A thermostat does not run the heater for a fixed time; you set a target temperature and it keeps measuring and correcting. Swarm is a thermostat for containers: you set the target (services and replica counts), and it keeps comparing and correcting.
Swarm mode. Every Docker Engine since 1.12 includes Swarm mode, based on the SwarmKit project. It is disabled by default and is activated with docker swarm init. Nothing else needs to be installed: no separate database, no separate control plane binaries.
docker CLI (docker service / stack / node ...)
|
v Docker API (TCP 2376 or local socket)
+-------------------------------- control plane ---------------------------------+
| manager 1 (leader) manager 2 (follower) manager 3 (follower) |
| +--------------------+ +--------------------+ +--------------------+ |
| | API | | same components; | | same components; | |
| | orchestrator | | forwards writes | | can be elected | |
| | allocator | | to the leader | | leader if the | |
| | scheduler | | | | current one fails | |
| | dispatcher | | | | | |
| | Raft store <------+-----+--> Raft store <----+-----+--> Raft store | |
| +--------------------+ +--------------------+ +--------------------+ |
+-------------------------------------|------------------------------------------+
| TCP 2377 (mutual TLS): task assignments, status
+--------------------------+---------------------------+
v v v
+---------------+ +---------------+ +---------------+
| worker 1 | | worker 2 | | worker 3 |
| agent+executor| | agent+executor| | agent+executor|
| [api.1][web.1]| | [api.2][db.1] | | [api.3][web.2]|
+---------------+ +---------------+ +---------------+
\____________ overlay networks (VXLAN, UDP 4789) ____________/
Components of the control plane. When you run docker service create, the request flows through a pipeline on the leader manager:
| Component | Responsibility |
|---|---|
| API | Accepts commands, validates them, and stores the service definition |
| Orchestrator | Compares desired and actual state; creates or removes tasks to close the gap |
| Allocator | Assigns IP addresses on overlay networks and virtual IPs to services |
| Scheduler | Picks a node for each new task based on resources, constraints, and spreading |
| Dispatcher | Sends tasks to node agents and receives their heartbeats and status reports |
| Raft store | Replicated, encrypted log of the whole cluster state (services, nodes, secrets, networks) |
Built-in security. Swarm creates its own certificate authority at init. Every node gets a TLS certificate identifying its role, all control traffic uses mutual TLS, and certificates rotate automatically (every 90 days by default). Joining requires a join token that encodes the CA fingerprint and a secret, so a node cannot join a fake manager and a stranger cannot join your cluster.
Creating a cluster
# On the first machine: create the swarm; it becomes the first manager (and leader)
# --advertise-addr is the IP other nodes use to reach this one
docker swarm init --advertise-addr 192.168.56.11
# Swarm initialized: current node (k3x...) is now a manager.
# To add a worker to this swarm, run the following command:
# docker swarm join --token SWMTKN-1-49nj...-8vxv 192.168.56.11:2377
# On every other machine: join as a worker using the printed command
docker swarm join --token SWMTKN-1-49nj...-8vxv 192.168.56.11:2377
# Back on a manager: list the cluster's nodes
docker node ls
# ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
# k3x... * node1 Ready Active Leader 29.0.1
# p8q... node2 Ready Active 29.0.1
# z1m... node3 Ready Active 29.0.1
# Inspect swarm-level information of this engine
docker info --format '{{json .Swarm}}'
# Networks created automatically by swarm init
docker network ls --filter driver=overlay
# NETWORK ID NAME DRIVER SCOPE
# 8r1... ingress overlay swarm <- used by the routing mesh
A single-node swarm (just docker swarm init on your laptop) is perfectly valid and is the easiest way to experiment with services, stacks, secrets, and rolling updates.
Ports between nodes
| Port | Protocol | Used for |
|---|---|---|
| 2377 | TCP | Cluster management traffic to managers |
| 7946 | TCP and UDP | Node discovery and network state gossip |
| 4789 | UDP | Overlay (VXLAN) data traffic |
Swarm compared with Kubernetes
| Aspect | Docker Swarm | Kubernetes |
|---|---|---|
| Installation | Built into Docker Engine | Separate distribution (kubeadm, managed service, k3s, ...) |
| Configuration format | Compose files (deploy: section) | Kubernetes manifests (YAML for many resource types) |
| Learning curve | Small: services, stacks, nodes | Large: pods, deployments, services, ingress, RBAC, operators, ... |
| Ecosystem | Small, mostly stable | Very large (Helm, operators, service meshes, autoscalers) |
| Managed cloud offerings | Practically none | Every major cloud (EKS, GKE, AKS, ...) |
| Typical use | Small to medium clusters, edge, teams already using Compose | Most new large-scale platforms |
Key takeaways
- An orchestrator keeps the actual state of a cluster equal to a declared desired state; this reconciliation is the core idea of both Swarm and Kubernetes.
- Swarm mode is built into Docker Engine and is enabled with
docker swarm init; nodes join with a role-specific token. - Managers run the API, orchestrator, allocator, scheduler, and dispatcher, and replicate cluster state with Raft.
- All node communication is protected by mutual TLS with automatic certificate rotation.
- Ports 2377/tcp, 7946/tcp+udp, and 4789/udp must be open between nodes.
🧪 Practice
- (Beginner) Run
docker swarm initon your machine, then inspect the result withdocker node ls,docker network ls, anddocker info. Leave the swarm withdocker swarm leave --forceand observe which networks disappear.- (Intermediate) Create a three-node swarm using three virtual machines (or
docker:dindcontainers on a user-defined network). Verify that each worker appears indocker node lsand explain which ports had to be reachable between them.- Interview question: "What does 'declarative' mean for an orchestrator, and why is it better than a script that starts containers on specific hosts?" Hint: think about what happens after the script finishes, when a node fails or a container crashes.
Managers and Workers
Why it matters. A cluster has two jobs: deciding what should run where, and running it. Swarm splits them into two node roles. Getting the number of managers right is what decides whether your cluster survives a node failure or freezes when one machine reboots.
The two roles
| Role | What it does | Runs workloads? |
|---|---|---|
| Manager | Stores cluster state (Raft), schedules tasks, serves the API | Yes by default (can be drained) |
| Worker | Receives tasks from managers and runs them | Yes |
Managers are also workers unless you tell them otherwise. In production, managers are often drained so that a busy application cannot starve the control plane of CPU or memory.
Raft and quorum. Managers keep identical copies of the cluster state using the Raft consensus algorithm. One manager is the leader; the others are followers. A change (new service, scaled replicas, new secret) is only committed when a majority (a quorum) of managers has stored it. This guarantees that a network split can never produce two clusters that disagree.
Analogy. A board of directors can only pass a decision when more than half of the members are present. With 3 members, any 2 can decide; if 2 are absent, the board cannot decide anything, even though the company's employees keep doing their current jobs.
| Managers | Quorum (majority) | Failures tolerated |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 (worse than 1: twice the chance of losing quorum) |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
The formula is: a cluster of N managers tolerates (N - 1) / 2 failures (rounded down). Use an odd number: 3 for most production clusters, 5 for high availability across zones; more than 7 slows down every write without much benefit.
What happens when quorum is lost. Running containers keep running: workers do not need managers to keep existing tasks alive. But nothing can change: no deployments, no scaling, no rescheduling of failed tasks. The recovery for a permanently lost majority is to rebuild the cluster from one surviving manager with docker swarm init --force-new-cluster, which keeps the state and makes that node a single-manager cluster; then add managers again.
Managing nodes
# Print the join command for each role (run on a manager)
docker swarm join-token worker
docker swarm join-token manager
# Rotate a token if it leaked; existing nodes are not affected
docker swarm join-token --rotate worker
# Change the role of an existing node
docker node promote node2 # worker -> manager
docker node demote node2 # manager -> worker
# Availability controls whether the scheduler places tasks on a node
# active : normal
# pause : keep existing tasks, accept no new ones
# drain : move all tasks elsewhere (maintenance, or keep managers dedicated)
docker node update --availability drain node1
docker node update --availability active node1
# Labels describe nodes so services can target them (see placement constraints)
docker node update --label-add zone=eu-west-1a --label-add disk=ssd node3
docker node inspect node3 --pretty
Placing tasks on the right nodes. The scheduler spreads tasks across eligible nodes by default. You narrow down the eligible nodes with constraints and influence the spreading with preferences:
# Only on nodes labeled disk=ssd, never on managers
docker service create --name db \
--constraint 'node.labels.disk==ssd' \
--constraint 'node.role==worker' \
postgres:17
# Spread replicas evenly across zones first, then across nodes inside each zone
docker service create --name api --replicas 6 \
--placement-pref 'spread=node.labels.zone' \
ghcr.io/acme/api:1.4
| Constraint attribute | Example |
|---|---|
node.role | node.role==manager |
node.hostname | node.hostname!=node1 |
node.platform.os / node.platform.arch | node.platform.arch==aarch64 |
node.labels.<key> | node.labels.zone==eu-west-1a (set by operators with docker node update) |
engine.labels.<key> | Labels from the engine's daemon.json |
Maintenance workflow. To patch a node safely: drain it (tasks are rescheduled on other nodes), update and reboot it, then set it back to active. Note that Swarm does not automatically move tasks back to a node that becomes available again; existing tasks stay where they are until the next update or a forced rebalance (docker service update --force <service>).
Protecting the Raft data at rest. Raft logs contain secrets (encrypted) and the keys that decrypt them. With autolock, the key that encrypts these keys is not stored on disk; a restarted manager must be unlocked by an operator:
docker swarm update --autolock=true # prints an unlock key: store it in a password manager
# after a manager restarts:
docker swarm unlock # prompts for the unlock key
Key takeaways
- Managers make decisions and store the cluster state; workers execute tasks. Managers also run tasks unless drained.
- Raft requires a majority of managers; N managers tolerate
(N - 1) / 2failures, so use 3 or 5 (odd numbers). - Without quorum, running containers continue, but the cluster cannot change;
--force-new-clusterrecovers from a lost majority. - Use node availability (
active,pause,drain) for maintenance, and labels plus constraints to control placement. - Enable autolock when manager disks may be accessed by people who should not read secrets.
🧪 Practice
- (Beginner) In a multi-node swarm, drain one node while a service with several replicas is running. Use
docker service ps <service>to watch the tasks move, then set the node back toactiveand check whether tasks return.- (Intermediate) Label two nodes
zone=aand one nodezone=b, deploy a 4-replica service with--placement-pref spread=node.labels.zone, and predict the distribution before checking it. Then add a constraint that makes the service unschedulable and read the reason indocker service ps --no-trunc.- Interview question: "Your swarm has 2 managers. Why might this be less reliable than having 1, and what would you change?" Hint: compute the quorum for 2 managers and count how many failures it tolerates.
Services and Tasks
Why it matters. In Swarm you no longer run containers directly. You create services, which describe what should run, and Swarm creates tasks, which are the individual units it schedules. Understanding the difference explains every line of docker service ps and most troubleshooting in Swarm.
The hierarchy
service "api" (desired state: image api:1.4, 3 replicas, port 8080, network backend)
|
+-- task api.1 -> scheduled on node2 -> container api.1.k3j2... (Running)
+-- task api.2 -> scheduled on node3 -> container api.2.p9x1... (Running)
+-- task api.3 -> scheduled on node1 -> container api.3.m2c7... (Running)
- A service is the desired state: image, replica count, ports, networks, resources, update policy.
- A task is one slot of that service, assigned to exactly one node. It contains exactly one container.
- A task is immutable: Swarm never restarts a failed task. It marks it as failed and creates a new task for the same slot (the same
api.2name, a new task ID). That is whydocker service psshows a history of tasks.
Analogy. A service is a job posting for "3 cashiers". Tasks are the employment contracts. If a cashier leaves, the contract is closed and a new one is signed with a new person; the position (slot) stays the same.
Service modes
| Mode | Behavior | Typical use |
|---|---|---|
replicated (default) | Exactly N tasks, spread across nodes | Web servers, APIs, workers |
global | One task on every eligible node, including nodes added later | Log shippers, monitoring agents, node-level proxies |
replicated-job | Runs N tasks to completion, then stops | Batch jobs, migrations |
global-job | Runs one task to completion on every node | Node maintenance scripts |
Working with services
# Create a replicated service with 3 replicas, published on port 8080 of every node
docker service create --name web --replicas 3 --publish 8080:80 nginx:1.27
# List services and their actual/desired replica counts
docker service ls
# ID NAME MODE REPLICAS IMAGE PORTS
# x8f... web replicated 3/3 nginx:1.27 *:8080->80/tcp
# List the service's tasks, where they run, and their states
docker service ps web
# ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
# a1b... web.1 nginx:1.27 node2 Running Running 2 minutes ago
# c3d... web.2 nginx:1.27 node3 Running Running 2 minutes ago
# e5f... web.3 nginx:1.27 node1 Running Running 2 minutes ago
# Scale up or down: only the replica count of the desired state changes
docker service scale web=5
# Logs from all tasks of the service, prefixed with the task name
docker service logs -f web
# Human-readable view of the full service definition
docker service inspect --pretty web
# A global agent: one task per node
docker service create --name node-exporter --mode global \
--mount type=bind,source=/,target=/host,readonly \
prom/node-exporter:v1.8.2 --path.rootfs=/host
# A one-off job: 10 tasks in total, at most 2 at the same time
docker service create --name reindex --mode replicated-job \
--replicas 10 --max-concurrent 2 ghcr.io/acme/reindex:2.0
# Remove the service and all its tasks
docker service rm web
Task lifecycle. Each task moves through a fixed sequence of states. Knowing them tells you where a stuck task is stuck:
NEW -> PENDING -> ASSIGNED -> ACCEPTED -> PREPARING -> STARTING -> RUNNING -> COMPLETE
| | | |
| no node satisfies | image pull | container +--> FAILED (exited non-zero)
| constraints/resources | in progress | starting +--> SHUTDOWN (stopped by Swarm,
v v v e.g. during an update)
stays PENDING REJECTED (e.g. image pull failed)
Symptom in docker service ps | Likely cause |
|---|---|
Task stuck in Pending | No node satisfies constraints, or not enough reserved CPU/memory |
Rejected with "No such image" | Image not in a registry the nodes can reach, or missing --with-registry-auth |
Tasks cycling Running -> Failed | The application crashes; check docker service logs |
Shutdown tasks in history | Normal: old tasks replaced by an update or scale down |
# Show full error messages instead of truncated ones
docker service ps --no-trunc --format '{{.Name}} {{.CurrentState}} {{.Error}}' web
# Only tasks that should be running right now (hides the history)
docker service ps --filter desired-state=running web
Resources and restart behavior. Reservations tell the scheduler how much capacity a task needs (a task is not placed on a node without that much free reservable capacity); limits cap the container at runtime, like --memory and --cpus in Memory Limits and CPU Limits and Shares.
docker service create --name api --replicas 3 \
--reserve-cpu 0.25 --reserve-memory 256M \
--limit-cpu 1 --limit-memory 512M \
--restart-condition on-failure --restart-max-attempts 5 --restart-delay 5s \
ghcr.io/acme/api:1.4
Images and registries. Every node pulls the image itself, so images must be in a registry all nodes can reach; locally built images exist only on the machine that built them. When the service is created, the manager resolves the tag to a digest and stores the digest in the service, so all replicas run exactly the same image even if the tag is moved later. For private registries, pass --with-registry-auth so the manager forwards your credentials to the nodes.
Key takeaways
- A service is the desired state; tasks are scheduled slots, each with exactly one container on one node.
- Failed tasks are never restarted in place; Swarm creates a replacement task, which is why task history exists.
- Use
replicatedfor most workloads,globalfor per-node agents, and job modes for run-to-completion work. docker service ps --no-truncshows why tasks are pending, rejected, or failing.- Images must come from a registry reachable by all nodes; Swarm pins the tag to a digest at deployment.
🧪 Practice
- (Beginner) Create a 3-replica
nginxservice, then kill one of its containers withdocker killon the node that runs it. Usedocker service psto observe the failed task and its replacement.- (Intermediate) Create a service with
--reserve-memoryhigher than the free memory of any node. Find the task state and the error message that explains the problem. Then fix it withdocker service update --reserve-memory.- Interview question: "What is the difference between a Swarm service, a task, and a container? Why does Swarm create a new task instead of restarting the old one?" Hint: think about immutability, rescheduling onto a different node, and keeping an audit trail of failures.
Stacks
Why it matters. Real applications consist of several services, networks, secrets, and volumes that belong together. Creating each one with docker service create is long, error-prone, and impossible to review in a pull request. A stack deploys an entire application from a Compose file, so the same format you use for development (see compose.yaml Structure) also describes the production deployment.
Analogy. docker service create is ordering dishes one by one; docker stack deploy is handing over the whole menu for the evening. Changing the menu and handing it over again updates only the dishes that changed.
The deploy section. Compose files have a deploy key that plain docker compose up mostly ignores (it honors only a few of its fields, such as replicas and resource limits), but Swarm uses fully:
# stack.yaml
services:
api:
image: ghcr.io/acme/api:1.4 # must be in a registry; stacks never build
environment:
DB_HOST: db # service names resolve via Swarm DNS
networks: [backend, frontend]
secrets: [db_password]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 10s
retries: 3
deploy:
mode: replicated
replicas: 3
placement:
constraints: ["node.role == worker"]
preferences:
- spread: node.labels.zone
max_replicas_per_node: 2
resources:
reservations: { cpus: "0.25", memory: 256M }
limits: { cpus: "1.0", memory: 512M }
restart_policy:
condition: on-failure # none | on-failure | any
delay: 5s
max_attempts: 5
update_config: # see Rolling Updates and Rollbacks
parallelism: 1
order: start-first
failure_action: rollback
proxy:
image: traefik:v3.1
ports:
- "80:80" # published through the routing mesh
networks: [frontend]
deploy:
mode: global # one proxy per node
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes: [db-data:/var/lib/postgresql/data]
networks: [backend]
deploy:
placement:
constraints: ["node.labels.db == true"] # pin stateful service to one node
networks:
frontend: { driver: overlay }
backend: { driver: overlay, internal: true } # no outbound internet access
volumes:
db-data: {} # local volume on whichever node runs db
secrets:
db_password:
external: true # created beforehand with docker secret create
Deploying and managing a stack
# Create the external secret once
printf 's3cr3t' | docker secret create db_password -
# Deploy (or update) the stack named "shop"
# --with-registry-auth : forward registry credentials to the nodes
# --prune : remove services that were deleted from the file
docker stack deploy -c stack.yaml --with-registry-auth --prune shop
# Objects get the stack name as prefix: shop_api, shop_db, shop_backend, ...
docker stack ls
docker stack services shop
docker stack ps shop --filter desired-state=running
# Remove everything that belongs to the stack (external secrets and named volumes stay)
docker stack rm shop
Running docker stack deploy again with a changed file is how you update: Swarm compares each service with its new definition and updates only the services that changed, using their update_config.
Compose features that behave differently in a stack
| Compose key | In docker stack deploy |
|---|---|
build | Ignored; images must already be built and pushed |
depends_on | Ignored; services must tolerate dependencies starting later (retry connections) |
container_name | Not supported; Swarm names containers itself |
restart | Ignored; use deploy.restart_policy |
network_mode, links, privileged | Not supported |
.env file | Not read automatically; export variables in the shell, or render the file first with docker compose config |
volumes (named) | Created on the node where each task runs; they are not shared between nodes |
The last row is the most common surprise. A named volume in a stack is a local volume on whichever node runs the task. If the database task moves to another node, it starts with an empty volume. Stateful services therefore need a placement constraint that pins them to one node, a volume plugin with shared storage, or (often the best choice) a managed database outside the cluster.
One file for development and production. A common pattern keeps compose.yaml for local development and adds a production override that only contains deploy settings and production images, as described in Multiple Compose Files and Overrides. docker stack deploy accepts several -c flags and merges them in order:
docker stack deploy -c compose.yaml -c compose.prod.yaml shop
Key takeaways
- A stack is a group of services, networks, secrets, and configs deployed together from a Compose file.
- The
deploysection (replicas, placement, resources, restart and update policies) is where Swarm-specific settings live. - Stacks never build images and ignore
depends_on; build and push in CI, and make services retry their dependencies. - Named volumes are local to a node; pin stateful services or use shared storage.
- Re-running
docker stack deployis the update mechanism;--pruneremoves services deleted from the file.
🧪 Practice
- (Beginner) Convert an existing two-service Compose project into a stack: push the image to a registry, add a
deploysection with 2 replicas, and deploy it on a single-node swarm. Find the names Swarm gives to the services and networks.- (Intermediate) Deploy a stack with a PostgreSQL service and a named volume on a multi-node swarm. Drain the node that runs the database and observe what happens to the data. Then fix the design with a node label and a placement constraint.
- Interview question: "Your Compose file works with
docker compose up, but afterdocker stack deploythe API crashes on startup because the database is not ready. Why, and how do you fix it properly?" Hint: check which Compose keys stacks ignore, and think about how an application should handle a dependency that is temporarily unavailable.
Rolling Updates and Rollbacks
Why it matters. Stopping all replicas, pulling the new image, and starting them again causes downtime on every deployment. A rolling update replaces replicas a few at a time, waits to see that the new ones are healthy, and continues only if they are. If the new version is broken, a rollback returns the service to its previous definition, ideally automatically.
Analogy. Changing the tires of a bus one wheel at a time while it is parked at a stop, and checking each new tire before removing the next old one. If a new tire turns out to be faulty, you put the old ones back before the bus leaves.
How a rolling update proceeds
replicas: 4, parallelism: 2, delay: 10s, order: stop-first
t0 [v1] [v1] [v1] [v1]
t1 [--] [--] [v1] [v1] stop 2 old tasks
t2 [v2] [v2] [v1] [v1] start 2 new tasks, watch them for "monitor" seconds
t3 ...wait "delay"...
t4 [v2] [v2] [--] [--] next batch
t5 [v2] [v2] [v2] [v2] done
order: start-first starts the new task before stopping the old one:
[v1] [v1] [v1] [v1] [v2] ... capacity never drops, but needs spare resources
Update settings
| Setting (CLI flag / Compose key) | Meaning | Default |
|---|---|---|
--update-parallelism / parallelism | Tasks updated at the same time (0 = all) | 1 |
--update-delay / delay | Pause between batches | 0s |
--update-order / order | stop-first or start-first | stop-first |
--update-monitor / monitor | How long a new task is watched for failure after it starts | 5s |
--update-failure-action / failure_action | pause, continue, or rollback | pause |
--update-max-failure-ratio / max_failure_ratio | Fraction of failed tasks tolerated during the update | 0 |
The same settings exist with a rollback prefix (--rollback-parallelism, rollback_config) and control how a rollback proceeds.
Health checks are the safety net. Swarm can only tell a "good" new task from a "bad" one if the container reports its health. With a HEALTHCHECK (see HEALTHCHECK), a task only counts as running once it is healthy; a task that becomes unhealthy or exits during the monitor window counts as a failure. Without a health check, a process that starts and then serves only errors looks perfectly fine to Swarm.
A safe update policy
services:
api:
image: ghcr.io/acme/api:1.5
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 5s
timeout: 2s
retries: 3
start_period: 20s
stop_grace_period: 30s # time for graceful shutdown of old tasks
deploy:
replicas: 4
update_config:
parallelism: 1 # one replica at a time
delay: 10s # observe before continuing
order: start-first # keep full capacity during the update
monitor: 30s # a task failing within 30 s counts as a failure
failure_action: rollback # do not stop at a half-updated service
max_failure_ratio: 0
rollback_config:
parallelism: 2 # roll back faster than we rolled forward
order: start-first
Performing updates and rollbacks by hand
# Change the image; Swarm performs a rolling update according to update_config
docker service update --image ghcr.io/acme/api:1.5 shop_api
# Watch progress (the command also shows a progress bar when run in a terminal)
docker service ps shop_api
docker service inspect -f '{{json .UpdateStatus}}' shop_api
# {"State":"updating","StartedAt":"...","Message":"update in progress"}
# An update paused after a failure? Inspect, then roll back manually
docker service rollback shop_api
# Change update settings without changing the image
docker service update --update-parallelism 2 --update-delay 30s shop_api
# Recreate all tasks with the same definition (for example to rebalance after maintenance)
docker service update --force shop_api
What a rollback restores. Swarm keeps exactly one previous specification (PreviousSpec) per service. A rollback swaps the current and previous specifications, so running rollback twice brings back the version you rolled away from. For older versions, deploy the older image explicitly. This is one more reason to deploy immutable tags or digests (see Image Tagging Strategies): "go back to 1.4" is only meaningful if 1.4 still means the same image.
Zero-downtime requirements. A rolling update avoids downtime only when the application cooperates:
| Requirement | Why | Covered in |
|---|---|---|
| A meaningful health check | Swarm must detect bad versions | Health Checks and Self-Healing |
Graceful shutdown on SIGTERM | Old tasks must finish in-flight requests | Graceful Shutdown and Signal Handling |
| Backward-compatible changes | Old and new versions run side by side during the update | Database migrations in expand/contract steps |
| More than one replica | With one replica and stop-first, there is always a gap | Use start-first or at least 2 replicas |
Key takeaways
- Rolling updates replace tasks in batches (
parallelism), with pauses (delay) and observation (monitor) between them. start-firstkeeps capacity during the update at the cost of extra resources;stop-firstis the default.- Health checks let Swarm detect a broken version; combine them with
failure_action: rollbackfor automatic recovery. - Swarm stores one previous spec;
docker service rollbackswaps current and previous. - Zero downtime also requires graceful shutdown, multiple replicas, and changes that are compatible across versions.
🧪 Practice
- (Beginner) Deploy a 4-replica
nginx:1.26service and update it tonginx:1.27with--update-parallelism 1 --update-delay 10s. Watchdocker service psduring the update and describe what you see.- (Intermediate) Build two versions of a small web application, where version 2 fails its health check. Configure
failure_action: rollbackandmonitor: 30s, deploy version 2, and confirm that Swarm rolls back on its own. Then rundocker service rollbacktwice and explain the result.- Interview question: "During a rolling update, users report occasional errors for about a minute, even though the new version is fine. What could cause this?" Hint: consider how old tasks are stopped, how long they need to finish requests, and when new tasks start receiving traffic.
Swarm Secrets and Configs
Why it matters. Containers need passwords, API keys, and certificates, as well as non-secret files such as nginx.conf. Baking them into images leaks them to everyone who can pull the image (see Avoiding Secrets in Images), and environment variables leak into docker inspect, logs, and crash reports. Swarm provides two first-class objects for this: secrets for sensitive data and configs for non-sensitive configuration files. Both are stored in the cluster and delivered only to the services that need them.
Analogy. A secret is a key kept in the building's safe: the facility manager hands it to the specific worker who needs it, for the duration of the job, and it never lies on a desk. A config is the posted procedure manual: not confidential, but still versioned and handed out centrally.
How secrets are delivered
docker secret create db_password - (via TLS to a manager)
|
v
Raft log on managers: encrypted at rest
|
| only to nodes running a task of a service granted this secret (mutual TLS)
v
worker node: kept in memory, mounted into the container as a file on tmpfs
|
v
container: /run/secrets/db_password (never written to the node's disk, removed when the task stops)
Secrets vs configs
| Aspect | Secret | Config |
|---|---|---|
| Purpose | Passwords, keys, certificates, tokens | Configuration files (nginx.conf, app.yaml) |
| Encrypted at rest in Raft | Yes | No |
| Default location in container | /run/secrets/<name> (tmpfs, in memory) | /<name> (path configurable) |
Visible with inspect | Metadata only, never the value | The content (base64-encoded) |
| Maximum size | 500 KB | 500 KB |
| Can be modified | No (immutable) | No (immutable) |
Using secrets and configs with the CLI
# Create a secret from stdin (avoids leaving it in shell history or files)
printf 'S3cure-Pa55' | docker secret create db_password_v1 -
# Create a config from a file
docker config create nginx_conf_v1 ./nginx.conf
docker secret ls
docker secret inspect db_password_v1 # shows metadata, never the value
# Grant both to a service; "target" chooses the file name or path inside the container
docker service create --name web \
--secret source=db_password_v1,target=db_password,mode=0400 \
--config source=nginx_conf_v1,target=/etc/nginx/nginx.conf \
nginx:1.27
# The application reads the file, not an environment variable
docker exec $(docker ps -q -f name=web) cat /run/secrets/db_password
Applications read files. Many official images support the _FILE convention: POSTGRES_PASSWORD_FILE=/run/secrets/db_password makes the image read the password from the file. In your own code, read the file at startup:
from pathlib import Path
import os
def read_secret(name: str) -> str:
"""Prefer a mounted secret file; fall back to an env var for local development."""
path = Path("/run/secrets") / name
if path.exists():
return path.read_text().strip()
return os.environ[name.upper()] # e.g. DB_PASSWORD when run without Swarm
db_password = read_secret("db_password")
In a stack file
services:
api:
image: ghcr.io/acme/api:1.5
secrets:
- source: db_password # the stack-level name below
target: db_password # file name under /run/secrets/
uid: "10001" # owner of the file in the container
mode: 0400
configs:
- source: api_config
target: /etc/api/config.yaml
secrets:
db_password:
external: true # created out of band (recommended for real secrets)
name: db_password_v2 # actual object name in the cluster
tls_key:
file: ./certs/tls.key # created by docker stack deploy from a local file
configs:
api_config:
file: ./config/api.prod.yaml
name: api_config_${CONFIG_VERSION} # versioned name, so changes create a new object
Rotation: immutability in practice. A secret or config cannot be edited, and one that is in use cannot be removed. That is intentional: running tasks keep a consistent view. To rotate, create a new object and switch the service to it; Swarm performs a rolling update:
printf 'N3w-Pa55' | docker secret create db_password_v2 -
# Remove the old grant and add the new one under the same target file name
docker service update \
--secret-rm db_password_v1 \
--secret-add source=db_password_v2,target=db_password \
web
docker secret rm db_password_v1 # possible once no service uses it
Because the application sees the same path (/run/secrets/db_password), its code does not change. For passwords shared with another system (a database), rotate in two steps: make the new password valid on the database, roll the service to the new secret, then revoke the old password.
Key takeaways
- Secrets are encrypted in the Raft log, sent only to nodes that need them, and mounted on tmpfs at
/run/secrets/<name>. - Configs deliver non-sensitive files the same way, without encryption; both are limited to 500 KB.
- Both are immutable: rotate by creating a versioned object and updating the service with
--secret-rm/--secret-add. - Applications should read secrets from files (often via
_FILEvariables), not from environment variables. - Prefer
external: truefor real secrets so that they never live in the repository.
🧪 Practice
- (Beginner) Create a secret and a service that prints it (
cat /run/secrets/<name>). Confirm thatdocker secret inspectanddocker service inspectnever show the value, and thatmountinside the container shows a tmpfs.- (Intermediate) Deploy an nginx stack whose
nginx.confcomes from a versioned config. Change the configuration, create version 2, and roll it out without downtime. Remove version 1 afterwards.- Interview question: "Why are Swarm secrets immutable, and how would you rotate a database password used by a running service without downtime?" Hint: think about consistency for running tasks, rolling updates, and the order in which the database and the application learn about the new password.
Routing Mesh
Why it matters. In a cluster, a service's replicas run on some nodes but not others, and they move during updates and failures. Clients and load balancers cannot follow these moves. Swarm's routing mesh solves this: a port published by a service is open on every node in the swarm, and any node forwards the connection to a healthy replica, wherever it runs.
Analogy. A company with one central phone number. You can call any branch's switchboard; if the person you need works elsewhere, the operator transfers the call. You never need to know which office they sit in today.
How it works. Publishing a port in the default ingress mode connects the service to the special ingress overlay network. On every node, the kernel's IPVS load balancer listens on the published port and forwards connections over the overlay to one of the service's tasks:
external load balancer / DNS (all nodes in the pool)
| | |
v v v
node1:8080 node2:8080 node3:8080 <- port open on every node
(no replica) [web.1] [web.2]
\ | /
\_______ ingress overlay network (IPVS) ______/
connection goes to web.1 or web.2,
even when it arrived at node1
# Publish port 8080 through the routing mesh (ingress mode is the default)
docker service create --name web --replicas 2 --publish published=8080,target=80 nginx:1.27
# Any node answers, including one without a replica
curl http://node1:8080
curl http://node3:8080
Two layers of load balancing. Swarm balances traffic in two different places, and they are easy to confuse:
| Traffic | Mechanism | Example |
|---|---|---|
| From outside the cluster (north-south) | Routing mesh on published ports | Browser -> node1:8080 -> any web task |
| Between services (east-west) | Service virtual IP (VIP) on shared overlay networks | api calls http://db:5432 -> one db task |
Inside an overlay network, the service name resolves to a single VIP that load-balances across tasks (see Embedded DNS and Service Discovery). The special name tasks.<service> returns the IPs of the individual tasks, which is useful for clustered software that must discover its peers.
# From a container attached to the same overlay network
nslookup web # one virtual IP
nslookup tasks.web # one IP per running task
Publishing modes and endpoint modes
| Option | Behavior | When to use |
|---|---|---|
mode=ingress (default) | Port open on all nodes; routing mesh forwards | Most stateless services |
mode=host | Port open only on nodes running a task; no mesh, no extra hop | Client IP must be preserved, highest performance, one task per node (often with --mode global) |
--endpoint-mode vip (default) | Service name resolves to one virtual IP | Most services |
--endpoint-mode dnsrr | Service name resolves to all task IPs (DNS round robin) | Your own load balancer or client-side balancing; cannot be combined with ingress-mode published ports |
# A reverse proxy on every node, bound directly to each node's port 80
docker service create --name edge --mode global \
--publish published=80,target=80,mode=host \
traefik:v3.1
Limitations to know
- Client IP is lost in ingress mode: the connection is source-NATed when forwarded, so the application sees an internal address. Use
mode=hostfor the edge proxy, or send the client IP in a header (X-Forwarded-For, PROXY protocol) from an external load balancer. - The mesh balances connections at layer 4, not HTTP requests. For path-based routing, TLS termination, and HTTP-aware balancing, run a reverse proxy (Traefik, nginx, HAProxy) as a service behind or instead of the mesh.
- An external load balancer is still useful in front of the nodes, so clients do not depend on any single node's address. It can point at all nodes, or only at a few dedicated edge nodes.
- Ports 7946 and 4789 must be open between nodes; if VXLAN traffic is blocked (common in cloud security groups), the mesh times out on nodes without a local replica.
Key takeaways
- The routing mesh opens a published port on every node and forwards connections to a healthy task over the
ingressnetwork. - Service-to-service traffic uses a virtual IP per service on shared overlay networks;
tasks.<name>lists task IPs. mode=hostbypasses the mesh: it preserves client IPs and avoids an extra hop, but only nodes running a task answer.- The mesh is a layer 4 balancer; add a reverse proxy for HTTP routing and TLS.
🧪 Practice
- (Beginner) On a multi-node swarm, publish a 1-replica service on port 8080 and send requests to every node's IP. Explain why nodes without a replica still answer.
- (Intermediate) Deploy
traefik/whoamiwith 3 replicas, call it repeatedly through the mesh, and look at the hostname and remote address in the response. Then redeploy it withmode=hostand--mode global, and compare the remote address.- Interview question: "Your application logs show the same internal IP address for every client request in Swarm. Why, and what are your options?" Hint: think about what the routing mesh does to forwarded connections and which publishing mode avoids it.
<a id="142-docker-and-kubernetes"></a>
14.2 Docker and Kubernetes
Kubernetes is the dominant container orchestrator, and almost every Kubernetes workload starts its life as a Docker image built from a Dockerfile. This section explains how Docker concepts map onto Kubernetes, how Kubernetes talks to container runtimes, what the removal of dockershim really changed, how to run your images on a cluster correctly, and how to get a local cluster with Docker Desktop. It relies on Container Runtimes Overview and Docker Alternatives (Podman, containerd, CRI-O).
From Compose to Kubernetes
Why it matters. Teams usually describe their application in a compose.yaml long before they need Kubernetes. When the move happens, the hardest part is not YAML syntax but the change of model: Compose has one big object (a service) that does everything, while Kubernetes splits the same responsibilities across several smaller resources. Knowing the mapping makes a migration systematic instead of guesswork.
Analogy. Compose is a studio apartment: one room is kitchen, bedroom, and office. Kubernetes is a house: the same functions exist, but each has its own room (a Deployment for running, a Service for addressing, a ConfigMap for settings), connected by hallways (labels and selectors).
The core Kubernetes objects in Docker terms
| Kubernetes object | What it is | Closest Docker concept |
|---|---|---|
| Pod | One or more containers sharing network and (optionally) volumes; the smallest scheduled unit | A container (plus sidecars sharing --network container:) |
| Deployment | Keeps N identical pods running and performs rolling updates | A Swarm replicated service |
| Service | Stable virtual IP and DNS name in front of a set of pods | Service name / VIP in Swarm or Compose DNS |
| Ingress / Gateway | HTTP routing from outside the cluster to Services | A reverse proxy service plus published ports |
| ConfigMap | Non-secret configuration as key-value pairs or files | Swarm configs, env_file |
| Secret | Sensitive values, mounted as files or env vars | Swarm secrets |
| PersistentVolumeClaim | A request for storage that outlives pods | A named volume |
| StatefulSet | Pods with stable names and their own storage each | No direct equivalent |
| DaemonSet | One pod per node | Swarm global mode |
| Job / CronJob | Run-to-completion work, optionally scheduled | replicated-job, or a cron container |
| Namespace | Grouping and isolation of resources | A Compose project or Swarm stack (roughly) |
Mapping Compose keys
| Compose key | Kubernetes equivalent |
|---|---|
image | spec.containers[].image |
entrypoint | command (replaces the image's ENTRYPOINT) |
command | args (replaces the image's CMD) |
ports | A Service (ClusterIP, NodePort, LoadBalancer) or an Ingress |
environment, env_file | env, envFrom with a ConfigMap or Secret |
volumes (named) | PersistentVolumeClaim + volumeMounts |
healthcheck | livenessProbe, readinessProbe, startupProbe |
depends_on | No equivalent; use readiness probes, retries, or init containers |
deploy.replicas | spec.replicas of the Deployment |
deploy.resources | resources.requests and resources.limits |
restart | Pod restartPolicy (Deployments always use Always) |
build | No equivalent; build and push the image in CI |
The entrypoint/command row trips up many people: Kubernetes command overrides the Dockerfile's ENTRYPOINT, and args overrides CMD. Setting command when you meant to pass arguments silently skips the image's entrypoint script.
One service, side by side
# compose.yaml
services:
api:
image: ghcr.io/acme/api:1.5
ports: ["8080:8080"]
environment:
LOG_LEVEL: info
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 10s
deploy:
replicas: 3
resources:
limits: { cpus: "1", memory: 512M }
# k8s/api.yaml : the same service as a Deployment plus a Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels: { app: api } # which pods this Deployment manages
template: # the pod template
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: ghcr.io/acme/api:1.5
ports:
- containerPort: 8080 # documentation only; does not publish anything
env:
- name: LOG_LEVEL
value: info
readinessProbe: # gate traffic (replaces healthcheck for routing)
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
resources:
requests: { cpu: 250m, memory: 256Mi } # used for scheduling
limits: { cpu: "1", memory: 512Mi } # enforced at runtime
---
apiVersion: v1
kind: Service
metadata:
name: api # DNS name "api" inside the namespace
spec:
selector: { app: api } # sends traffic to pods with this label
ports:
- port: 8080 # Service port
targetPort: 8080 # container port
type: ClusterIP # internal only; use an Ingress or LoadBalancer for outside
kubectl apply -f k8s/api.yaml
kubectl get deployments,pods,services -l app=api
kubectl port-forward service/api 8080:8080 # local access for testing
Labels and selectors are the glue: the Deployment creates pods with app: api, and the Service finds them by the same label. A typo in a label is a classic reason for "the Service has no endpoints".
Automatic conversion with Kompose. Kompose converts a Compose file into Kubernetes manifests. It is a good starting point, not a finished result: review probes, resources, storage classes, and secrets before using the output.
kompose convert -f compose.yaml -o k8s/
# INFO Kubernetes file "k8s/api-service.yaml" created
# INFO Kubernetes file "k8s/api-deployment.yaml" created
What does not translate
- Build steps: Kubernetes only runs images; building belongs to CI (see 13.2 Continuous Integration and Delivery).
- Startup order: there is no
depends_on; applications must retry, and readiness probes keep traffic away until they are ready. - Bind mounts of source code: development conveniences stay in Compose; clusters run immutable images.
- Host-specific paths and
network_mode: host: possible (hostPath,hostNetwork), but usually a sign that the design should change.
Key takeaways
- Kubernetes splits a Compose service into a Deployment (running), a Service (addressing), and ConfigMaps, Secrets, and PVCs (configuration and storage).
- Labels and selectors connect these objects; mismatched labels break routing.
commandreplacesENTRYPOINTandargsreplacesCMD.depends_onandbuildhave no equivalent; use retries, probes, and CI pipelines instead.- Kompose gives a first draft; production manifests need review of probes, resources, storage, and secrets.
🧪 Practice
- (Beginner) Take a single-service Compose file and write the equivalent Deployment and Service by hand. Apply them to a local cluster and reach the application with
kubectl port-forward.- (Intermediate) Convert a multi-service Compose project (app, database, cache) with Kompose. List every change you would make to the generated manifests before using them in production, and explain why.
- Interview question: "A container works with
docker run myimage --port 9000, but in Kubernetes it fails after you setcommand: ["--port", "9000"]. What went wrong?" Hint: compare how Kubernetescommandandargsrelate toENTRYPOINTandCMD.
Kubernetes Container Runtime Interface
Why it matters. Kubernetes does not start containers itself. On each node, the kubelet asks a container runtime to pull images and run containers. Early Kubernetes had Docker support hard-coded into the kubelet, which made adding other runtimes difficult. The Container Runtime Interface (CRI) replaced that with a standard API, so any runtime that implements it can be plugged in. Understanding the CRI explains what actually runs on a Kubernetes node and which tools you use to debug it.
Analogy. A power outlet standard. Appliances (runtimes) do not need to know how the power plant (kubelet) works; anything with the right plug fits into any outlet.
The layers on a node
kubectl apply ...
|
v
kube-apiserver ---- watches ----> kubelet (on each node)
|
| CRI: gRPC over a Unix socket
| RuntimeService: RunPodSandbox, CreateContainer, StartContainer, ...
| ImageService: PullImage, ListImages, RemoveImage, ...
v
high-level runtime: containerd (CRI plugin) or CRI-O
|
| OCI runtime spec (config.json + rootfs)
v
low-level runtime: runc (or crun, gVisor runsc, Kata ...)
|
v
Linux kernel: namespaces, cgroups, seccomp, overlayfs
Pod sandboxes. The CRI works in two steps. First the kubelet asks for a pod sandbox: an environment holding the pod's network namespace (and IP address, configured through a CNI plugin). With containerd and CRI-O this is a tiny "pause" container that does nothing but keep the namespaces alive. Then each application container of the pod is created inside that sandbox, joining its network namespace. That is why all containers in a pod share localhost and one IP address.
CRI runtimes in practice
| Runtime | Description | CRI socket (typical) |
|---|---|---|
| containerd | Graduated CNCF project, also used by Docker Engine; CRI support is a built-in plugin | unix:///run/containerd/containerd.sock |
| CRI-O | Runtime built only for Kubernetes (used by OpenShift) | unix:///var/run/crio/crio.sock |
| cri-dockerd | Adapter that lets the kubelet use Docker Engine (see the next topic) | unix:///run/cri-dockerd.sock |
All of them use an OCI runtime such as runc underneath, so the containers they produce are equivalent from the kernel's point of view.
Debugging a node with crictl. On a Kubernetes node without Docker, docker ps does not exist or shows nothing relevant. The CRI command-line client crictl talks to whatever runtime the kubelet uses:
# Tell crictl which runtime socket to use (usually set in /etc/crictl.yaml)
export CONTAINER_RUNTIME_ENDPOINT=unix:///run/containerd/containerd.sock
crictl pods # pod sandboxes on this node
crictl ps # running containers (add -a to include exited ones)
crictl images # images known to the runtime
crictl logs <container-id> # container logs
crictl inspect <container-id> # low-level details (PID, mounts, cgroups)
crictl exec -it <container-id> sh # like docker exec
# containerd's own client sees Kubernetes images in the "k8s.io" namespace
ctr -n k8s.io images ls
| Docker command | crictl equivalent |
|---|---|
docker ps | crictl ps |
docker images | crictl images |
docker logs | crictl logs |
docker exec | crictl exec |
docker inspect | crictl inspect / crictl inspectp (pods) |
docker pull | crictl pull |
docker build | None: nodes do not build images |
Choosing a runtime per workload: RuntimeClass. A cluster can offer several OCI runtimes behind the same CRI implementation, for example runc for normal workloads and gVisor for untrusted code. A RuntimeClass names a runtime handler, and pods select it:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc # must be configured in containerd on the nodes
---
apiVersion: v1
kind: Pod
metadata:
name: untrusted-job
spec:
runtimeClassName: gvisor # this pod runs in the gVisor sandbox
containers:
- name: job
image: ghcr.io/acme/untrusted-job:1.0
Key takeaways
- The kubelet talks to the container runtime through the CRI, a gRPC API with runtime and image services.
- containerd and CRI-O are the common CRI runtimes; both delegate to an OCI runtime such as
runc. - A pod is a sandbox (network namespace, held by a pause container) in which the pod's containers run.
- Use
crictlto inspect containers on nodes; Docker commands do not see Kubernetes workloads on containerd or CRI-O nodes. RuntimeClasslets pods choose different OCI runtimes, such as gVisor or Kata Containers.
🧪 Practice
- (Beginner) On a local cluster (kind or minikube), open a shell on a node (
docker exec -it <kind-node> bashfor kind) and list the running pods and containers withcrictl. Identify the pause containers.- (Intermediate) Find the CRI socket used by your node and the OCI runtime configured in containerd's configuration file (
/etc/containerd/config.toml). Explain the path fromkubectl applyto aruncprocess.- Interview question: "Why does Kubernetes need a pause container in each pod, and what would break without it?" Hint: think about which namespace must exist before the first container starts and survive container restarts.
dockershim Deprecation
Why it matters. In 2020, the Kubernetes project announced that "Docker support" would be removed, and many people concluded that Docker images would stop working on Kubernetes. That was not the case. What was removed was dockershim, a piece of compatibility code inside the kubelet. Understanding exactly what changed tells you what you can ignore (your images, your Dockerfiles) and what you may need to fix (anything that depends on the Docker daemon on nodes).
Background. Docker Engine predates the CRI and never implemented it. To keep Docker working as a runtime, the kubelet contained an adapter, dockershim, that translated CRI calls into Docker API calls. Docker Engine then passed them to containerd, which already supported the CRI on its own:
before (dockershim, until Kubernetes 1.23):
kubelet -> dockershim -> Docker Engine (dockerd) -> containerd -> runc
(in kubelet)
after (Kubernetes 1.24 and later):
kubelet -> containerd (CRI plugin) -> runc fewer layers, same images
kubelet -> CRI-O -> runc
kubelet -> cri-dockerd -> Docker Engine -> containerd -> runc (optional, external adapter)
Maintaining dockershim inside Kubernetes cost effort and added an extra hop, while containerd, which Docker itself uses, could be called directly.
Timeline
| Kubernetes version | Date | Change |
|---|---|---|
| 1.20 | December 2020 | dockershim deprecated; kubelet logs a warning |
| 1.24 | May 2022 | dockershim removed from the kubelet |
What did not change
- Images built with Docker run unchanged.
docker buildproduces OCI-compatible images (see Open Container Initiative (OCI)), and containerd and CRI-O run them exactly the same way. - Dockerfiles, registries, and
docker pushwork as before. - Developers' machines keep using Docker; only the runtime on cluster nodes is affected.
What did change, and how to fix it
| Dependency on Docker in the cluster | Symptom after migration | Replacement |
|---|---|---|
Pods mounting /var/run/docker.sock (CI runners building images, some agents) | Socket does not exist; builds fail | Build with BuildKit (rootless), Kaniko, or Buildah inside pods; or remote builders |
| Monitoring or logging agents reading Docker's data or API | Missing metrics or logs | Use versions that support CRI runtimes, or the kubelet's metrics and log files under /var/log/pods |
Operators running docker ps / docker logs on nodes | Commands show nothing | crictl (see the previous topic) |
Node configuration in daemon.json (registry mirrors, log options) | Settings ignored | Configure containerd (/etc/containerd/config.toml) or CRI-O instead |
| Images only present in the node's Docker image store | ErrImagePull | Push images to a registry, or import them into containerd |
Checking a cluster's runtime
# The CONTAINER-RUNTIME column shows the runtime used by each node's kubelet
kubectl get nodes -o wide
# NAME STATUS ROLES VERSION ... CONTAINER-RUNTIME
# node-a Ready control-plane v1.33.2 ... containerd://2.0.5
# node-b Ready <none> v1.33.2 ... containerd://2.0.5
# Find workloads that mount the Docker socket, which will break without Docker on nodes
kubectl get pods -A -o json \
| jq -r '.items[] | select(.spec.volumes[]?.hostPath.path? | tostring | test("docker.sock"))
| "\(.metadata.namespace)/\(.metadata.name)"'
When Docker Engine is still wanted on nodes. cri-dockerd, maintained by Mirantis and Docker outside the Kubernetes project, is a standalone CRI adapter for Docker Engine. It exists mainly for clusters that rely on Docker-specific behavior during a transition. For new clusters, containerd or CRI-O is the standard choice.
Key takeaways
- Kubernetes removed dockershim (the kubelet's built-in Docker adapter) in 1.24, not support for Docker images.
- Images built with Docker are OCI images and run unchanged on containerd and CRI-O.
- Anything that used the Docker daemon on nodes (socket mounts,
dockerCLI,daemon.json, Docker-specific agents) must be replaced. kubectl get nodes -o wideshows each node's runtime;cri-dockerdkeeps Docker Engine usable when truly needed.
🧪 Practice
- (Beginner) Check the container runtime of every node in a cluster you have access to (a local kind or Docker Desktop cluster is enough). Explain the chain of components between the kubelet and
runcfor that runtime.- (Intermediate) A CI system runs image builds in Kubernetes pods by mounting
/var/run/docker.sock. Design a replacement that works on containerd nodes, and list the security benefits of the change.- Interview question: "A manager asks whether the team must rewrite its Dockerfiles because 'Kubernetes dropped Docker'. What do you answer?" Hint: separate the image format and build tooling from the runtime used on nodes.
Running Docker Images on Kubernetes
Why it matters. An image that runs fine with docker run can still fail on Kubernetes: it cannot be pulled, it runs as root and is rejected by the cluster's security policy, it ignores signals, or it uses a tag that points to different images on different nodes. Kubernetes is stricter and more automated than a single Docker host, so images need a few properties to behave well there.
From build to running pod
developer / CI registry cluster
docker build --push ---> ghcr.io/acme/api@sha256:9f2... <--- kubelet on each node pulls
(needs network + credentials)
Nodes cannot see images on your laptop; every image must be in a registry the nodes can reach (local clusters are an exception, covered in the next topic).
Pulling from private registries. Create a registry credential as a Secret of type kubernetes.io/dockerconfigjson and reference it from the pod (or attach it to the namespace's service account so every pod uses it):
kubectl create secret docker-registry ghcr-creds \
--docker-server=ghcr.io \
--docker-username=acme-bot \
--docker-password="$GHCR_TOKEN"
# Make it the default for all pods using the "default" service account in this namespace
kubectl patch serviceaccount default \
-p '{"imagePullSecrets": [{"name": "ghcr-creds"}]}'
On managed clusters, prefer the cloud's identity integration (for example, nodes allowed to pull from the cloud's own registry) over long-lived passwords.
Tags, digests, and pull policy
imagePullPolicy | Behavior | Default when |
|---|---|---|
IfNotPresent | Pull only if the node does not have the image | The tag is not latest |
Always | Check the registry on every container start (layers already present are reused) | The tag is latest or omitted |
Never | Use only an image already on the node | Never a default (useful for local clusters) |
With a mutable tag and IfNotPresent, different nodes may run different images under the same tag, depending on when each one pulled it. Deploy immutable tags (a version or Git SHA) or digests, as described in Image Tagging Strategies:
image: ghcr.io/acme/api:1.5.2@sha256:9f2c... # tag for humans, digest for the runtime
Image properties that Kubernetes rewards
| Property | Why it matters on Kubernetes | How in the Dockerfile |
|---|---|---|
| Non-root, numeric user | runAsNonRoot: true can only be verified if the user is a number; names cause "non-numeric user" errors | USER 10001 |
| Works with a read-only root filesystem | readOnlyRootFilesystem: true is a common policy requirement | Write only to declared paths (mounted emptyDir volumes for /tmp, caches) |
Exec-form entrypoint, handles SIGTERM | Pods are stopped with SIGTERM and a grace period on every rollout and node drain | ENTRYPOINT ["/app/server"]; see Graceful Shutdown and Signal Handling |
| Logs to stdout/stderr | The kubelet collects container output; files inside the container are lost | No log files; see Structured Logging Practices |
| Configuration from env vars and files | ConfigMaps and Secrets are injected as env vars or mounted files | No environment-specific values in the image |
| Multi-architecture | Clusters increasingly mix amd64 and arm64 nodes | docker buildx build --platform linux/amd64,linux/arm64 |
A production-ready pod spec for a Docker image
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels: { app: api }
strategy:
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } # like Swarm start-first, one at a time
template:
metadata:
labels: { app: api }
spec:
imagePullSecrets: [{ name: ghcr-creds }]
securityContext:
runAsNonRoot: true # refuse to start if the image would run as root
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
image: ghcr.io/acme/api:1.5.2@sha256:9f2c...
args: ["--port", "8080"] # overrides CMD, keeps ENTRYPOINT
ports: [{ containerPort: 8080 }]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: tmp, mountPath: /tmp } # writable scratch space
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 512Mi }
volumes:
- name: tmp
emptyDir: {}
Deploying, updating, and rolling back
kubectl apply -f k8s/api.yaml
kubectl rollout status deployment/api # waits until the rollout finishes
# Update the image (in practice, CI changes the manifest in Git and applies it)
kubectl set image deployment/api api=ghcr.io/acme/api:1.5.3
kubectl rollout history deployment/api
kubectl rollout undo deployment/api # back to the previous ReplicaSet
# Quick debugging
kubectl describe pod -l app=api # events: pull errors, probe failures, OOMKilled
kubectl logs -l app=api --tail=50
kubectl debug -it pod/<pod-name> --image=busybox:1.36 --target=api # ephemeral debug container
Common failures
| Status | Typical cause |
|---|---|
ErrImagePull / ImagePullBackOff | Wrong name or tag, missing pull secret, registry unreachable, image not built for the node's architecture |
CrashLoopBackOff | The process exits repeatedly; check kubectl logs --previous |
CreateContainerConfigError | Missing ConfigMap or Secret, or a non-numeric USER with runAsNonRoot |
OOMKilled (in describe) | Memory limit too low or a memory leak |
Pod Running but no traffic | Readiness probe failing, or Service selector does not match pod labels |
Key takeaways
- Images must be in a registry reachable by nodes; use
imagePullSecretsor cloud identity for private registries. - Deploy immutable tags or digests; mutable tags with
IfNotPresentcan run different images on different nodes. - Build images for Kubernetes: numeric non-root user, read-only root filesystem, signal handling, stdout logs, multi-arch.
- Use
kubectl rollout status,history, andundoto manage deployments, anddescribeandlogsto debug them.
🧪 Practice
- (Beginner) Push one of your images to a private registry, create an image pull secret, and deploy it. Then delete the secret, restart the pod, and read the resulting event with
kubectl describe.- (Intermediate) Harden a Deployment with
runAsNonRoot,readOnlyRootFilesystem, andcapabilities.drop: ["ALL"]. Change the Dockerfile until the pod starts, and document each change you needed.- Interview question: "Two pods of the same Deployment behave differently even though both use
myapp:prod. How is this possible, and how do you prevent it?" Hint: think about mutable tags, pull policies, and when each node pulled the image.
Local Kubernetes with Docker Desktop
Why it matters. Testing manifests, Helm charts, and probes against a real cluster before deploying catches mistakes that YAML linting cannot. A local cluster gives that feedback in seconds and for free. Docker Desktop includes a Kubernetes cluster that can be enabled with one checkbox, and alternatives such as kind and minikube run Kubernetes nodes as Docker containers.
Enabling the cluster
- Open Docker Desktop Settings -> Kubernetes.
- Select Enable Kubernetes, choose the provisioning method if offered, and apply.
- Docker Desktop downloads the control-plane images, starts the cluster, and installs
kubectlwith a context nameddocker-desktop.
Recent Docker Desktop versions offer two provisioning methods:
| Method | Nodes | Notes |
|---|---|---|
| kubeadm | Single node | The long-standing option; Kubernetes version tied to the Docker Desktop release |
| kind | One or more nodes | Nodes run as containers; multi-node clusters and a choice of Kubernetes versions |
# Switch kubectl to the Docker Desktop cluster
kubectl config get-contexts
kubectl config use-context docker-desktop
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# docker-desktop Ready control-plane 2m v1.32.2
The inner loop: build locally, run on the cluster. With the kubeadm-based cluster, Kubernetes uses Docker Desktop's own engine, so an image you just built is already available to pods, without a registry. Use a unique tag and imagePullPolicy: IfNotPresent or Never so that Kubernetes does not try to pull it:
docker build -t api:dev-1 .
kubectl create deployment api --image=api:dev-1
kubectl patch deployment api -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"api","imagePullPolicy":"Never"}]}}}}'
# LoadBalancer Services are exposed on localhost by Docker Desktop
kubectl expose deployment api --type=LoadBalancer --port=8080
curl http://localhost:8080/healthz
If a pod reports ErrImageNeverPull or ErrImagePull for a local tag (which can happen with other provisioning methods or tools), the cluster's runtime does not share your image store: load the image into the cluster or push it to a registry.
Alternatives that also use Docker
| Tool | How it runs Kubernetes | Loading a local image | Good for |
|---|---|---|---|
| Docker Desktop | Built into Docker Desktop | Usually not needed (kubeadm mode) | Simplest start on macOS and Windows |
| kind | Each node is a Docker container | kind load docker-image api:dev-1 | CI pipelines, multi-node tests, specific Kubernetes versions |
| minikube | A VM or a Docker container | minikube image load api:dev-1 | Many add-ons (ingress, metrics, dashboard) |
| k3d | k3s (lightweight Kubernetes) in Docker containers | k3d image import api:dev-1 | Fast, small clusters |
# kind: a three-node cluster from a config file
cat > kind.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
EOF
kind create cluster --name dev --config kind.yaml
docker ps --format '{{.Names}}' # the nodes are just containers
# dev-control-plane
# dev-worker
# dev-worker2
kind load docker-image api:dev-1 --name dev
kind delete cluster --name dev
Tips
- Give Docker Desktop enough memory (Settings -> Resources) before enabling Kubernetes; the control plane alone uses several hundred megabytes.
- Use Reset Kubernetes Cluster in the settings to return to a clean state instead of deleting resources by hand.
- Disable the cluster when you do not need it; it uses CPU and memory even when idle.
- Keep local manifests close to production ones (same probes, resources, security context), so the local test is meaningful. Tools such as Skaffold or Tilt automate the build-load-deploy loop.
Key takeaways
- Docker Desktop includes a local Kubernetes cluster (
docker-desktopcontext), enabled in its settings. - With the kubeadm-based cluster, locally built images can be used directly; use unique tags and a non-pulling policy.
- Docker Desktop exposes
LoadBalancerServices onlocalhost. - kind, minikube, and k3d also run Kubernetes on Docker and need an explicit image load step.
🧪 Practice
- (Beginner) Enable Kubernetes in Docker Desktop, deploy
nginxwithkubectl create deployment, expose it as aLoadBalancer, and open it onlocalhost.- (Intermediate) Build an image locally, deploy it to a kind cluster with
kind load docker-image, change the code, and redeploy. Measure how long the loop takes and identify which step dominates.- Interview question: "A developer's pod shows
ErrImagePullfor an image thatdocker imageslists on their laptop. What are the possible explanations?" Hint: consider which image store the cluster's runtime uses, the tag and pull policy, and whether the cluster runs in a separate VM or container.
<a id="143-production-best-practices"></a>
14.3 Production Best Practices
Orchestrators can only restart, move, scale, and update containers safely if the applications inside them follow a few rules. This section collects those rules: the twelve-factor principles, correct shutdown behavior, a proper PID 1, health checks that support self-healing instead of undermining it, externalized configuration, and how all of this carries over to managed cloud container services.
Twelve-Factor Apps and Containers
Why it matters. The Twelve-Factor App methodology was written in 2011 by engineers at Heroku, a platform that ran thousands of applications on machines that could disappear at any time. It describes how to build applications that can be deployed, scaled, and replaced automatically. Containers and orchestrators are built on exactly the same assumptions, so the twelve factors read today like a checklist for containerized applications.
Analogy. Shipping containers work because every container follows the same standard dimensions and corner fittings, so any crane and any ship can handle any container. The twelve factors are the "standard fittings" of an application: an orchestrator can handle any application that follows them, without special treatment.
The factors, in container terms
| # | Factor | Principle | In practice with Docker |
|---|---|---|---|
| I | Codebase | One codebase in version control, many deploys | One repository produces one image; the same image goes to every environment |
| II | Dependencies | Declare and isolate dependencies explicitly | Everything the app needs is in the image; lock files; pinned base images |
| III | Config | Store config in the environment | Env vars, mounted config files, and secrets; nothing environment-specific in the image |
| IV | Backing services | Treat databases, queues, caches as attached resources | Reached through URLs from config; swapping a local Postgres for a managed one changes only config |
| V | Build, release, run | Strictly separate the stages | docker build (build) -> image + config (release, a tag or digest) -> container (run) |
| VI | Processes | Run as stateless processes | No local state between requests; sessions and files in backing services |
| VII | Port binding | Export services by binding to a port | The app runs its own HTTP server on a configurable port (EXPOSE, $PORT) |
| VIII | Concurrency | Scale out through the process model | More replicas, not bigger containers; separate process types (web, worker) as separate services |
| IX | Disposability | Fast startup and graceful shutdown | Start in seconds; handle SIGTERM; safe to kill at any time |
| X | Dev/prod parity | Keep development, staging, and production similar | Same image and same backing service types everywhere (Compose locally) |
| XI | Logs | Treat logs as event streams | Write to stdout/stderr; the platform collects and routes them |
| XII | Admin processes | Run one-off tasks as one-off processes | Migrations and scripts run as a container from the same image (docker run, a Job) |
Build once, deploy many. Factors I, III, and V combine into the most important rule for containers: build the image once, and promote that same artifact through every environment, changing only the configuration.
build (CI, once) release = image + config run
git SHA a1b2c3 --> ghcr.io/acme/api@sha256:9f2... --+--> + staging config --> staging containers
+--> + production config --> production containers
(never rebuilt per environment)
Rebuilding per environment ("the production image is built with --build-arg ENV=prod") means that the artifact you tested is not the artifact you run.
A twelve-factor-friendly service
# app.py : configuration, port, logging and shutdown follow the factors
import logging, os, signal, sys
from wsgiref.simple_server import make_server
# III. Config: everything that differs between environments comes from the environment
PORT = int(os.environ.get("PORT", "8080")) # VII. Port binding
DATABASE_URL = os.environ["DATABASE_URL"] # IV. Backing service as a URL; fail fast if missing
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
# XI. Logs: one event per line on stdout, no log files
logging.basicConfig(stream=sys.stdout, level=LOG_LEVEL,
format='{"level":"%(levelname)s","msg":"%(message)s"}')
def app(environ, start_response):
# VI. Processes: no state kept between requests
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"ok\n"]
server = make_server("0.0.0.0", PORT, app)
# IX. Disposability: exit cleanly when the platform asks
def stop(signum, frame):
logging.info("SIGTERM received, shutting down")
sys.exit(0) # raises SystemExit in the main thread, ending serve_forever
signal.signal(signal.SIGTERM, stop)
logging.info(f"listening on {PORT}")
server.serve_forever()
# XII. Admin processes: same image, different command
docker run --rm --env-file prod.env ghcr.io/acme/api:1.5.2 python manage.py migrate
Where containers go beyond the original text. Some factors need a modern reading. Factor III ("store config in the environment") is often implemented today with mounted files and secret managers rather than only environment variables, because environment variables are easy to leak (see Configuration Management). Factor II is reinforced by images: the operating system packages are now part of the declared dependencies too.
Key takeaways
- The twelve factors describe applications that can be deployed and replaced automatically, which is exactly what orchestrators do.
- Build one image per commit and promote it through environments; only configuration changes.
- Keep processes stateless, bind to a port from config, log to stdout, and start and stop quickly.
- Scale out with more replicas; run admin tasks as one-off containers from the same image.
🧪 Practice
- (Beginner) Review one of your containerized applications against the table above and list every factor it violates (for example, log files inside the container or configuration baked into the image).
- (Intermediate) Refactor that application so the same image runs in two environments with different configuration only. Prove it by running two containers from one image digest with two
--env-filefiles.- Interview question: "Why is it a problem to build a separate image for each environment, even from the same commit?" Hint: think about what was tested, reproducibility of builds, and what changes between two builds of the same source.
Graceful Shutdown and Signal Handling
Why it matters. In production, containers are stopped all the time: on every deployment, every scale-down, every node drain, and every autoscaler decision. If a container is killed while it is handling requests, users get errors, jobs are lost halfway, and database transactions are cut. A graceful shutdown turns "stop" into a controlled sequence: stop accepting new work, finish the current work, release resources, exit. The mechanics of docker stop were introduced in Stopping and Killing Containers and STOPSIGNAL; this topic covers how to build applications that shut down correctly under any orchestrator.
Analogy. A restaurant at closing time. A good one locks the front door (no new guests), lets seated guests finish their meal, cleans the kitchen, and then turns off the lights. A bad one turns off the lights while people are still eating.
The stop protocol is the same everywhere
orchestrator decides to stop the container
|
v
SIGTERM (or the image's STOPSIGNAL) sent to PID 1
|
| grace period: the application must finish within it
| docker stop -t / --stop-timeout default 10 s
| Compose / Swarm stop_grace_period default 10 s
| Kubernetes terminationGracePeriodSeconds default 30 s
v
still running? --yes--> SIGKILL (cannot be caught) -> exit code 137
|
no -> exited within the grace period -> exit code 0 (or 143 if SIGTERM's default action ended it)
What the application should do on SIGTERM
- Fail readiness: report "not ready" so load balancers stop sending new requests.
- Stop accepting new connections or new jobs (close the listener, stop consuming from the queue).
- Finish in-flight work: complete open requests, or return unfinished jobs to the queue.
- Release resources: flush buffers and logs, commit or roll back transactions, close database connections.
- Exit with code 0, before the grace period ends.
- Keep an internal deadline shorter than the grace period, so the app exits on its own terms instead of being killed.
Go: the standard pattern
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
// ctx is cancelled when SIGTERM or SIGINT arrives
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // block until a stop signal
log.Println("shutting down")
// Deadline below the platform's grace period (e.g. 25 s for a 30 s grace period)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
// Shutdown closes listeners, then waits for active requests to finish
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("forced shutdown: %v", err)
}
// close database pools, flush telemetry, ... then return (exit code 0)
}
Node.js
const http = require("node:http");
let shuttingDown = false;
const server = http.createServer((req, res) => {
if (req.url === "/readyz") {
res.statusCode = shuttingDown ? 503 : 200; // readiness fails during shutdown
return res.end();
}
res.end("ok\n");
});
server.listen(8080);
process.on("SIGTERM", () => {
shuttingDown = true;
server.close(async () => { // stops accepting; callback runs when all connections end
await db.end(); // release backing-service connections (db: your pool)
process.exit(0);
});
server.closeIdleConnections(); // keep-alive connections would otherwise delay close()
setTimeout(() => process.exit(1), 25_000).unref(); // hard deadline before SIGKILL
});
A queue worker (Python)
import signal
stopping = False
def request_stop(signum, frame):
global stopping
stopping = True # finish the current job, take no new ones
signal.signal(signal.SIGTERM, request_stop)
while not stopping:
job = queue.fetch(timeout=1) # short timeout so the flag is checked often
if job:
process(job)
job.ack() # acknowledge only after completion
# unacknowledged jobs are redelivered to another worker by the broker
The Kubernetes timing problem. When a pod is deleted, Kubernetes sends SIGTERM and removes the pod from Service endpoints at the same time. Load balancers and kube-proxy on other nodes learn about the removal a moment later, so a pod that closes its listener immediately can still receive a few new connections, which then fail. The common fix is a short delay before the shutdown starts:
spec:
terminationGracePeriodSeconds: 45 # must cover preStop + application shutdown
containers:
- name: api
image: ghcr.io/acme/api:1.5.2
lifecycle:
preStop:
sleep:
seconds: 10 # built-in sleep action (recent Kubernetes); older clusters:
# exec: { command: ["sleep", "10"] } (needs a sleep binary)
The preStop hook runs before SIGTERM is sent, and its duration counts against the grace period.
Common mistakes
| Mistake | Effect | Fix |
|---|---|---|
Shell-form CMD or a wrapper script without exec | The shell is PID 1 and does not forward SIGTERM; the app is killed after the grace period | Exec form, exec "$@" in scripts; see PID 1 and Init Processes |
CMD ["npm", "start"] | The package manager sits between the platform and the app and may not forward signals | Run node server.js directly |
| Shutdown work longer than the grace period | Killed halfway with SIGKILL | Raise the grace period, or shorten the work (smaller batches) |
| Ignoring the signal in the app | Every stop takes the full grace period and ends in exit code 137 | Install a SIGTERM handler |
| The server uses a different graceful signal | SIGTERM causes a fast, non-graceful stop | Set STOPSIGNAL (for example SIGQUIT for nginx) |
Checking it
docker run -d --name api ghcr.io/acme/api:1.5.2
time docker stop api # should take well under the grace period
docker inspect -f '{{.State.ExitCode}}' api # 0 or 143 is fine; 137 means it was killed
Key takeaways
- Containers are stopped constantly in production; graceful shutdown is required for zero-downtime operations.
- The protocol is always the same: stop signal, grace period, then
SIGKILL(exit code 137). - On
SIGTERM: fail readiness, stop accepting work, finish in-flight work, release resources, exit before the deadline. - In Kubernetes, add a short
preStopdelay so endpoint removal propagates before the app stops listening. - Make sure the signal actually reaches the application process (exec form, no shell or package manager in between).
🧪 Practice
- (Beginner) Run a container whose application does not handle
SIGTERM, measuredocker stopwithtime, and check the exit code. Add a handler and repeat.- (Intermediate) Write a small HTTP server with an endpoint that takes 5 seconds to respond. Start a request, stop the container during the request, and make the server finish the request before exiting. Repeat with a 3-second grace period (
docker stop -t 3) and explain the result.- Interview question: "During every Kubernetes rollout, clients see a few connection errors, even though the application implements graceful shutdown. Why, and how do you fix it?" Hint: think about the order and timing of
SIGTERMand endpoint removal across the cluster.
PID 1 and Init Processes
Why it matters. Inside a container, your application is usually PID 1, the process the Linux kernel treats as the system's init. On a normal Linux system, PID 1 is systemd or another init, which was written for that role. Most applications were not. Two responsibilities of PID 1 then go unhandled: signals behave differently, and orphaned processes are not reaped. The result is containers that ignore docker stop or slowly fill up with zombie processes.
Analogy. PID 1 is the head of a household who must also look after any children whose parents have left. An application suddenly put in that role often does not know about the extra duty, and the children are never taken care of.
Responsibility 1: signals. The kernel does not apply the default action of a signal to PID 1 of a PID namespace for signals that PID 1 has not installed a handler for. For a normal process, SIGTERM without a handler terminates it; for PID 1, the signal is simply ignored (except SIGKILL and SIGSTOP from the parent namespace). An application that never registered a SIGTERM handler therefore keeps running until SIGKILL arrives.
Responsibility 2: reaping zombies. When a process exits, it stays in the process table as a zombie until its parent collects its exit status with wait(). If the parent has already exited, the orphan is re-parented to PID 1, which must then reap it. An application that spawns subprocesses (shell commands, headless browsers, plugin processes) but does not reap orphans accumulates zombies. Each zombie holds a PID, and when the PID limit is reached (see PID Limits), the container can no longer start processes.
Seeing both problems
# A Python PID 1 that starts shell commands; the shells leave orphaned "sleep" processes
docker run -d --name no-init python:3.13-alpine python -c "
import os, time
for _ in range(3):
os.system('sleep 1 &') # sh exits at once; its background sleep is re-parented to PID 1
time.sleep(1000)"
sleep 3
docker exec no-init ps -o pid,ppid,stat,comm
# PID PPID STAT COMMAND
# 1 0 S python
# 8 1 Z sleep <- zombies: their parent (PID 1) never calls wait()
# 10 1 Z sleep
# 12 1 Z sleep
docker stop no-init; docker inspect -f '{{.State.ExitCode}}' no-init
# 137 <- SIGTERM ignored by PID 1, killed after the grace period
# Same command with an init process in front of it
docker run -d --init --name with-init python:3.13-alpine python -c "
import os, time
for _ in range(3):
os.system('sleep 1 &')
time.sleep(1000)"
sleep 3
docker exec with-init ps -o pid,ppid,stat,comm
# PID PPID STAT COMMAND
# 1 0 S docker-init <- tini, injected by --init
# 8 1 S python <- no zombies: the init reaped the orphans
docker stop with-init; docker inspect -f '{{.State.ExitCode}}' with-init
# 143 <- tini forwarded SIGTERM; python died of it immediately
docker rm no-init with-init
The options
| Option | How | Pros | Cons |
|---|---|---|---|
| App handles both duties | Install signal handlers; reap children (or spawn none) | No extra process | Every app must get it right; reaping orphans is easy to forget |
docker run --init / Compose init: true | Docker injects tini as PID 1 (shown as docker-init) | No image change | Depends on the run configuration; not available in Kubernetes |
tini or dumb-init in the image | ENTRYPOINT ["tini", "--", "/app/server"] | Works under every orchestrator | One more package in the image |
Kubernetes shareProcessNamespace: true | The pod's pause container becomes PID 1 and reaps orphans | No image change | All containers in the pod see each other's processes |
| Full init/supervisor (systemd, supervisord, s6) | Runs several services in one container | Fits legacy multi-process apps | Heavier; hides crashes from the orchestrator; usually a design smell |
Baking tini into the image (portable choice)
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/server /app/server
USER 10001
# tini is PID 1: it forwards signals to the child and reaps orphans
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]
On Alpine, apk add --no-cache tini installs it at /sbin/tini. tini -g forwards signals to the child's whole process group, which helps when the application starts helpers that should also stop.
Entrypoint scripts must exec. A shell script that prepares the environment and then starts the app must replace itself with the app, or the shell stays PID 1 and swallows signals:
#!/bin/sh
# docker-entrypoint.sh
set -e
# ... setup: render config templates, wait for a dependency, run migrations ...
exec "$@" # replace the shell with CMD (for example /app/server); the app becomes PID 1
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
CMD ["/app/server"]
Do you need an init? A statically compiled Go server that handles SIGTERM and never starts subprocesses works fine as PID 1. An init is strongly recommended when the application starts subprocesses, when it is a shell script or interpreter that you do not control fully, or when you cannot verify its signal handling. The cost of adding tini is tiny (well under 1 MB), so many teams add it by default.
Key takeaways
- PID 1 ignores signals it has no handler for, and must reap orphaned processes; most applications do neither.
- Symptoms:
docker stopalways takes the full grace period with exit code 137, and zombie (Z) processes accumulate. - Use
--initorinit: truewith Docker, and baketiniordumb-initinto the image for portability (Kubernetes has no--init). - Entrypoint scripts must end with
exec "$@"; avoid shell-formCMDand package managers as PID 1. - Run one main process per container; full supervisors hide failures from the orchestrator.
🧪 Practice
- (Beginner) Reproduce the zombie demonstration above, then fix it with
--init. Compare thepsoutput and the exit codes ofdocker stop.- (Intermediate) Write an entrypoint script that renders a config file and starts the application, first without
execand then with it. Usedocker exec <container> psanddocker stopto show the difference, then add tini to the image.- Interview question: "Why can a process that handles
SIGTERMcorrectly on a normal Linux host ignore it inside a container?" Hint: look at how the kernel treats PID 1 of a namespace and which process is actually PID 1 in that container.
Health Checks and Self-Healing
Why it matters. A container whose process is running is not necessarily working: it can be deadlocked, stuck in an infinite loop, out of database connections, or still warming up its cache. Orchestrators can repair many of these situations automatically (restart the container, stop sending it traffic, roll back an update), but only if the application tells them how it is doing. Badly designed health checks are worse than none: they can restart healthy containers in a loop or take a whole service offline when one dependency is slow. The HEALTHCHECK instruction and its Compose form were introduced in HEALTHCHECK and Healthchecks in Compose.
Analogy. A hospital distinguishes "is the patient alive?" (pulse) from "can the patient return to work?" (fit for duty) and "is the patient still recovering from surgery?" (do not test yet). Mixing them up leads to the wrong treatment, such as resuscitating a patient who is merely asleep.
Three different questions
| Probe type | Question | Action on failure | Kubernetes | Docker / Swarm |
|---|---|---|---|---|
| Liveness | "Is the process broken beyond self-recovery?" | Restart the container | livenessProbe | Swarm replaces unhealthy tasks; plain Docker only marks them |
| Readiness | "Can it handle traffic right now?" | Remove from load balancing, keep running | readinessProbe | No separate concept (health status gates updates and routing in Swarm) |
| Startup | "Has it finished starting?" | Delay the other probes; restart only if startup takes too long | startupProbe | start_period of HEALTHCHECK |
Who acts on what
| Platform | Uses Docker HEALTHCHECK? | What happens to an unhealthy container |
|---|---|---|
docker run | Yes, for status only | Marked unhealthy; not restarted (restart policies react only to exits) |
| Docker Compose | Yes | Marked unhealthy; used by depends_on: condition: service_healthy |
| Docker Swarm | Yes | Task is replaced; unhealthy new tasks fail rolling updates |
| Kubernetes | No, it ignores HEALTHCHECK | Uses its own probes defined in the pod spec |
| Cloud services (ECS, Cloud Run, ...) | Varies; often their own health check settings | Restart or replace, depending on the service |
Designing health endpoints. Use two endpoints with different semantics:
GET /livez (liveness) "my process works"
- checks only internal state: event loop responds, no deadlock detected
- does NOT check the database or other services
- must be cheap and fast
GET /readyz (readiness) "send me traffic"
- checks what is needed to serve requests: config loaded, warm-up done,
connection pool to critical dependencies available
- returns 503 during startup and during graceful shutdown
The most important rule: liveness must not depend on other services. If the database becomes slow and every replica's liveness check queries the database, every replica fails its liveness check at the same time, the orchestrator restarts all of them, and a database slowdown turns into a complete outage. Restarting the application cannot fix the database.
# Flask example: separate liveness and readiness
from flask import Flask
app = Flask(__name__)
state = {"ready": False, "stopping": False}
@app.get("/livez")
def livez():
return "ok", 200 # the process can answer: it is alive
@app.get("/readyz")
def readyz():
if state["stopping"] or not state["ready"]:
return "not ready", 503
if not db_pool.has_available_connection(): # dependency needed to serve requests
return "db unavailable", 503 # traffic goes elsewhere; no restart
return "ready", 200
Kubernetes probes
containers:
- name: api
image: ghcr.io/acme/api:1.5.2
startupProbe: # up to 30 x 2 s = 60 s to start before liveness applies
httpGet: { path: /livez, port: 8080 }
periodSeconds: 2
failureThreshold: 30
livenessProbe: # restart after 3 consecutive failures (about 30 s)
httpGet: { path: /livez, port: 8080 }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe: # stop routing quickly, resume when it recovers
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
failureThreshold: 2
Docker and Swarm
# One check: Docker has a single health status, so use the liveness-style endpoint
HEALTHCHECK --interval=10s --timeout=2s --start-period=30s --retries=3 \
CMD ["/app/healthcheck"] # a tiny binary or script; avoids needing curl in a minimal image
Self-healing layers. Health checks are one part of a chain of automatic recovery:
| Failure | Detection | Automatic response |
|---|---|---|
| Process crashes | Exit code | Restart policy (Docker), new task (Swarm), container restart (Kubernetes) |
| Process hangs | Liveness check | Container restart or task replacement |
| Temporarily overloaded or dependency down | Readiness check | Traffic removed until recovery |
| Out of memory | OOM kill (exit 137) | Restart; alerting should flag repeated OOMs |
| Node fails | Missing node heartbeat | Tasks or pods rescheduled on other nodes |
| Bad new version | Health checks during rollout | Swarm failure_action: rollback, Kubernetes rollout stalls (rollback via kubectl rollout undo or a deployment tool) |
Crash loops. Automatic restarts use back-off delays so that a container that crashes immediately does not consume the node: Docker increases the restart delay exponentially, and Kubernetes shows CrashLoopBackOff with delays up to five minutes. A crash loop is a signal to investigate, not something the platform will fix.
Key takeaways
- Distinguish liveness (restart), readiness (stop traffic), and startup (wait) checks; they answer different questions.
- Liveness checks must not depend on external services, or a dependency problem restarts every replica at once.
- Plain
docker runonly marks containers unhealthy; Swarm replaces them; Kubernetes ignoresHEALTHCHECKand uses probes. - Readiness should fail during startup and during graceful shutdown.
- Self-healing combines restart policies, health checks, rescheduling, and automated rollbacks, but it cannot fix a crash loop.
🧪 Practice
- (Beginner) Add a
HEALTHCHECKto an image, run it withdocker run, then make the check fail (for example, by deleting a file the check looks for). Observe the status indocker psand confirm that the container is not restarted.- (Intermediate) Implement
/livezand/readyzin a small service that uses a database, deploy it to a local Kubernetes cluster with the three probe types, then stop the database. Explain which probes fail and what Kubernetes does in each case.- Interview question: "After a short database outage, all pods of a service restarted at once and the service took 10 minutes to recover. What was probably wrong with the health checks?" Hint: consider what the liveness probe checked and what a simultaneous restart of every replica does to warm-up and to the database.
Configuration Management
Why it matters. The same image must run in development, staging, and production, with different database addresses, feature flags, log levels, and credentials. Where that configuration lives, how it gets into the container, and how it changes over time decides whether a deployment is reproducible and auditable, or whether "someone changed a variable on the server" causes an outage nobody can explain. The basics of injecting values were covered in Environment Variables, Environment and env_file, and Runtime Secrets.
Analogy. An image is a printed form; configuration is what you write in its fields. You do not print a new form for each person; you fill in the same form differently. And you keep a copy of each filled-in form, so you can see later what was submitted.
Layers of configuration
lowest priority highest priority
+--------------------+ +--------------------+ +--------------------+ +--------------------+
| defaults in the | | mounted config | | environment vars | | secrets (files) |
| code or the image |-->| file (ConfigMap, |-->| set per |-->| from a secret |
| (safe, non-secret) | | Swarm config) | | environment | | store, at runtime |
+--------------------+ +--------------------+ +--------------------+ +--------------------+
What goes where
| Kind of value | Where | Why |
|---|---|---|
| Safe defaults (port, timeouts) | Code or image ENV | The image runs without extra setup |
| Structured configuration (routing tables, feature lists) | Mounted file from a ConfigMap or Swarm config | Too large or nested for env vars; can be versioned |
| Environment-specific values (URLs, log level, replicas) | Env vars set by the deployment | Simple, visible, twelve-factor style |
| Credentials, keys, certificates | Secrets mounted as files, or fetched from a secret manager | Not visible in inspect, process listings, or crash dumps |
| Nothing environment-specific | Never in the image | The image must be promotable between environments |
Config in version control. Treat deployment configuration as code: keep Compose overrides, Kubernetes manifests, or Helm values per environment in Git (secrets excluded, or encrypted with tools such as SOPS or Sealed Secrets). Every change is then reviewed, attributable, and reversible. GitOps tools (Argo CD, Flux) take this further by applying whatever is in Git to the cluster automatically.
deploy/
base/ # shared definition
deployment.yaml
service.yaml
overlays/
staging/
kustomization.yaml # image tag, replicas, LOG_LEVEL=debug
production/
kustomization.yaml # image digest, replicas, LOG_LEVEL=info
Validate at startup, fail fast. A missing or malformed setting should stop the container immediately with a clear message, not cause an error at 3 a.m. when the code path is first used. Orchestrators make a fast failure visible (crash loop, failed rollout, automatic rollback):
# settings.py : parse and validate all configuration once, at startup
from pydantic import AnyUrl, Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: AnyUrl # required: startup fails if missing
log_level: str = Field("INFO", pattern="^(DEBUG|INFO|WARNING|ERROR)$")
port: int = Field(8080, ge=1, le=65535)
request_timeout_s: float = 5.0
model_config = {"secrets_dir": "/run/secrets"} # also reads secret files by name
settings = Settings() # raises a validation error listing every problem
Changing configuration: restart or reload. Containers usually read their configuration at startup, so a change means new containers. That is a feature: the change rolls out gradually, health checks guard it, and it can be rolled back like an image change. For that to happen automatically, the configuration change must change the deployment's definition:
# Kustomize generates the ConfigMap with a content hash in its name (api-config-7f9k2m8c4d).
# A config change => a new name => the Deployment's pod template changes => rolling update.
configMapGenerator:
- name: api-config
files:
- config.yaml
The Swarm equivalent is the versioned config name (api_config_v2) shown in Swarm Secrets and Configs. Without such a mechanism, a mounted ConfigMap is updated in place on the node after a delay (not for subPath mounts or env vars), and different replicas may read different versions at different times. Applications that reload configuration at runtime (on SIGHUP or by watching the file) must validate the new version and keep the old one if validation fails.
Common anti-patterns
| Anti-pattern | Problem |
|---|---|
One image per environment (api:prod, api:staging built with different args) | The tested artifact is not the deployed artifact |
| Secrets in env vars in committed Compose files | Leaked through Git history, docker inspect, and logs |
| Configuration edited by hand on servers or in the cluster | Not reproducible; lost on the next deploy; no audit trail |
| Hundreds of environment variables | Hard to review; use a structured, validated config file |
| Config read lazily at first use | Errors surface hours after deployment instead of failing the rollout |
Key takeaways
- Keep one image for all environments; inject configuration at runtime in layers (defaults, files, env vars, secrets).
- Store deployment configuration in Git per environment; keep secrets out of it or encrypt them.
- Validate all configuration at startup and fail fast, so bad config stops a rollout instead of breaking production later.
- Make configuration changes roll out like code changes: versioned or hashed config objects trigger rolling updates.
- Use files and secret stores for credentials, not environment variables in committed files.
🧪 Practice
- (Beginner) Move all environment-specific values out of one of your images and run it in two configurations using
--env-fileand a mounted config file. Verify that the image digest is identical in both cases.- (Intermediate) Use a Kustomize
configMapGenerator(or a versioned Swarm config) and change one value. Show that the change triggers a rolling update, and that rolling back the deployment also restores the previous configuration.- Interview question: "A team stores production configuration as environment variables edited in the cloud console. What risks do you see, and what would you propose instead?" Hint: think about reviews, history, reproducibility, secrets, and how a configuration change is tested and rolled back.
Deploying to Cloud Container Services
Why it matters. Running your own cluster means patching nodes, upgrading orchestrators, and keeping control planes highly available. Cloud providers offer services that take over part or all of that work: you hand them an image and some configuration, and they run it. The images you build with Docker run on all of them, but the services differ widely in how much they manage, what they cost, and what they expect from your container.
Analogy. Managing your own cluster is owning a car: full control, full maintenance. Managed Kubernetes is leasing: the hard parts are maintained for you, but you still drive. Serverless containers are a taxi: you only say where to go, and pay per ride.
The spectrum of control
more control, more operations less control, less operations
<------------------------------------------------------------------------------------------->
VMs + Docker / managed Kubernetes managed container serverless containers
self-run Swarm (EKS, GKE, AKS) orchestrators (Cloud Run, Azure Container
(ECS on EC2, ECS Fargate) Apps, ECS Fargate)
you run nodes, provider runs the control provider's own API, no nodes, scale to zero
orchestrator, ... plane; you run workloads no Kubernetes knowledge (on some), pay per use
and (often) the nodes needed
| Service | Provider | Model | Scale to zero | Notes |
|---|---|---|---|---|
| Amazon ECS (with Fargate or EC2) | AWS | Task definitions and services, AWS-specific API | No (Fargate bills per running task) | Deep AWS integration (IAM roles per task, ALB, CloudWatch) |
| Amazon EKS | AWS | Managed Kubernetes | With autoscaling add-ons | Standard Kubernetes manifests and tools |
| Google Cloud Run | Google Cloud | Serverless containers driven by requests or jobs | Yes | Container must listen on $PORT; instances may be throttled between requests depending on billing mode |
| Google Kubernetes Engine (GKE) | Google Cloud | Managed Kubernetes (Standard or Autopilot) | With autoscaling | Autopilot also manages the nodes |
| Azure Container Apps | Azure | Serverless containers, built on Kubernetes components | Yes | Revisions, traffic splitting, event-driven scaling |
| Azure Kubernetes Service (AKS) | Azure | Managed Kubernetes | With autoscaling | Standard Kubernetes |
| Azure Container Instances | Azure | Single containers or groups, no orchestration | No | Simple batch jobs and short-lived tasks |
What every service expects from your image. The production practices of this section are exactly what makes an image portable across these services:
| Requirement | Reason |
|---|---|
Correct CPU architecture (linux/amd64, linux/arm64) | Images built on an Apple silicon laptop are arm64 by default and fail on amd64 hosts ("exec format error") |
Listen on the configured port, on 0.0.0.0 | Services route to a specific port (Cloud Run sets $PORT); 127.0.0.1 is unreachable from outside the container |
| Stateless, with a fast startup | Instances are added, removed, and moved freely; slow starts hurt autoscaling and cold starts |
Graceful shutdown on SIGTERM | All of them stop instances with SIGTERM and a grace period (often 10 to 30 seconds) |
| Logs to stdout/stderr | Collected automatically into the provider's logging service |
| Health check endpoint | Used by load balancers and deployment health gates |
| Secrets from the provider's secret manager | Injected at runtime, with access controlled by the service's identity |
From image to running service: a typical flow
# 1. Build for the target architecture and push to the provider's registry
docker buildx build --platform linux/amd64 \
-t europe-docker.pkg.dev/acme-prod/apps/api:1.5.2 --push .
# 2a. Google Cloud Run: one command creates or updates the service
gcloud run deploy api \
--image europe-docker.pkg.dev/acme-prod/apps/api:1.5.2 \
--region europe-west1 \
--port 8080 \
--min-instances 1 --max-instances 20 \
--set-env-vars LOG_LEVEL=info \
--set-secrets DATABASE_URL=api-database-url:latest # from Secret Manager
# 2b. Azure Container Apps
az containerapp create --name api --resource-group acme-prod \
--environment acme-env \
--image acmeprod.azurecr.io/api:1.5.2 \
--target-port 8080 --ingress external \
--min-replicas 1 --max-replicas 20
Amazon ECS task definition (excerpt). ECS describes containers in a JSON task definition whose fields map closely to docker run options:
{
"family": "api",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "512",
"memory": "1024",
"runtimePlatform": { "cpuArchitecture": "ARM64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/api-task-role",
"containerDefinitions": [
{
"name": "api",
"image": "123456789012.dkr.ecr.eu-west-1.amazonaws.com/api:1.5.2",
"portMappings": [{ "containerPort": 8080 }],
"environment": [{ "name": "LOG_LEVEL", "value": "info" }],
"secrets": [
{ "name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:api/db-url" }
],
"healthCheck": {
"command": ["CMD", "/app/healthcheck"],
"interval": 10, "timeout": 2, "retries": 3, "startPeriod": 30
},
"stopTimeout": 30,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/api",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "api"
}
}
}
]
}
| Task definition field | Docker equivalent |
|---|---|
image | Image reference |
cpu, memory | --cpus, --memory |
portMappings | -p |
environment, secrets | -e, secrets as env vars or files |
healthCheck | HEALTHCHECK / --health-cmd |
stopTimeout | --stop-timeout |
logConfiguration | --log-driver, --log-opt |
taskRoleArn | No equivalent: cloud identity for the application, instead of keys in env vars |
Choosing a service
| If you need | Consider |
|---|---|
| HTTP services or jobs with spiky traffic, minimal operations | Serverless containers (Cloud Run, Azure Container Apps) |
| Long-running services deeply integrated with one cloud, no Kubernetes skills | Amazon ECS on Fargate |
| Portability across clouds, a large ecosystem, complex workloads | Managed Kubernetes (EKS, GKE, AKS) |
| A simple, cheap setup for a few services | A VM with Docker Compose, or a small Swarm |
Operational advice that applies everywhere
- Deploy immutable image references (version tags or digests), and keep the deployment definition (task definitions, service YAML, Terraform) in version control.
- Use the platform's identity (task roles, workload identity, managed identities) to access cloud resources instead of long-lived keys in environment variables.
- Push to a registry in the same region as the service to reduce pull time and transfer costs, and enable the registry's vulnerability scanning.
- Set resource sizes from measurements, not guesses: start from observed memory and CPU (see Memory Limits), then let autoscaling handle peaks.
- Automate deployments from CI (see 13.2 Continuous Integration and Delivery) with the same health-gated, rollback-capable process for every environment.
Key takeaways
- Cloud container services range from serverless (Cloud Run, Azure Container Apps) to managed orchestrators (ECS) to managed Kubernetes (EKS, GKE, AKS).
- The same Docker image runs on all of them if it follows the production practices: correct architecture, configurable port, statelessness, graceful shutdown, stdout logs, health checks.
- Build for the target architecture with
docker buildx --platformand push to the provider's registry. - Use the platform's identity and secret services instead of credentials in images or environment variables.
- Choose the service by how much operational work you want to own and how portable the deployment must be.
🧪 Practice
- (Beginner) Build an image on your machine with
docker buildx build --platform linux/amd64,linux/arm64and push it to a registry. Usedocker buildx imagetools inspectto verify both architectures are present.- (Intermediate) Deploy the same image to one serverless service (for example Cloud Run or Azure Container Apps) and describe what you had to change in the image or its configuration (port, shutdown, health checks, secrets). Then write the equivalent ECS task definition or Kubernetes Deployment and compare the two definitions.
- Interview question: "A startup with three services and a small team asks whether they should run Kubernetes. What questions do you ask and what might you recommend?" Hint: weigh operational effort, team skills, traffic patterns, portability needs, and cost against simpler alternatives such as serverless containers or Compose on a VM.
<a id="15-docker-internals-and-extensibility"></a>