Lesson 17 ended with a list of everything the compose file in this track is not good enough for. This is that list, worked through — which of it Docker can fix, and where you have genuinely run out of Docker.
Restart policies
restart: unless-stoppedFour values:
no the default. It stays dead.
on-failure[:5] restart on a non-zero exit, optionally capped
always restart whatever happens, including after a daemon restart
unless-stopped like always, except a container YOU stopped stays stoppedunless-stopped is the usual right answer. The difference from always
only shows up on reboot: with always, a container you deliberately stopped last week
comes back.
⚠️ A restart policy is not a health policy. It reacts to the process exiting. An
application that is deadlocked, or returning 500 to everything, is still running — Docker sees a
healthy process and does nothing. A healthcheck marks it unhealthy and still does not
restart it; plain Docker has no mechanism for that. Swarm and Kubernetes do.
Resource limits, and the JVM
Without limits, one container can consume the machine and take everything else with it.
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 512mMemory is a hard ceiling: exceed it and the kernel OOM-kills the process. Exit code 137, lesson 13.
CPU is a throttle rather than a wall — cpus: "1.5" means one and a half cores'
worth of time. The container is slowed, not killed, which makes it much harder to notice.
⚠️ Set the memory limit before you tune the JVM, not after. A JVM with no
-Xmx sizes its heap from what it believes the machine has. Modern JVMs are
container-aware and read the cgroup limit — but only if there is one. With no limit set, the JVM in
a 1 GB container sees the host's 32 GB, sizes a heap accordingly, and is OOM-killed long
before it ever runs a garbage collection. The container looks like it crashed at random.
-XX:MaxRAMPercentage=75.0Better than a fixed -Xmx, because it tracks the limit. Leave the remaining 25% for
metaspace, thread stacks and native allocations, which are not in the heap and are the thing that
usually pushes a "correctly sized" JVM over the edge.
Logs
Log to stdout. Not to a file — a file inside a container is on the writable layer, which dies with it, and is invisible to every collection tool.
Then decide what happens to stdout, because the default is a problem:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"The default json-file driver has no rotation at all. A chatty
service on a long-running host fills the disk, and the failure this produces is spectacular and
confusing — everything on the machine starts failing at once, and nothing points at logs. Those four
lines cap it at 30 MB.
Beyond one host you want the logs off the machine entirely — awslogs, gelf,
fluentd, or a sidecar collector.
Shutting down without dropping requests
The sequence: Docker sends SIGTERM, waits (10 seconds by default), then
SIGKILL.
Getting a clean shutdown needs three things, and all three have to be right.
The signal must reach your process. Exec-form ENTRYPOINT, so your
process is PID 1 — lesson 4. Shell form means /bin/sh gets the signal and does not
pass it on.
Your application must handle it. Stop accepting new connections, finish in-flight requests, close the pool. Spring Boot:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20sThe grace period must be long enough.
stop_grace_period: 30s⚠️ These two numbers must agree. A 20-second application shutdown behind a 10-second Docker default is SIGKILL every time — and it looks exactly like a graceful shutdown that was configured and did nothing.
⚠️ And a second trap: PID 1 does not get default signal handlers. A process that ignores
SIGTERM as PID 1 would be fine anywhere else. If your entrypoint is a shell script
spawning children, you also inherit zombie reaping — docker run --init or
init: true inserts a minimal init process to deal with both.
Hardening
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:truePlus USER from lesson 9, which matters more than all of these.
read_only is the one with real teeth and the one most likely to break something —
most applications write somewhere, so expect to add tmpfs mounts until it
starts. Worth the hour.
no-new-privileges stops a setuid binary inside the container from escalating.
Cheap, and there is rarely a reason not to.
Where Docker stops
Be honest about this, because it is the most useful thing in the lesson.
Docker runs containers on one machine. Which means:
That machine is a single point of failure. It dies, everything on it is down. Nothing moves the work elsewhere.
There is no zero-downtime deploy. docker compose up -d stops the old
container and starts the new one. In between, requests fail. Rolling updates and health-gated
rollouts are an orchestrator feature.
There is no scaling. --scale starts more containers on the same
machine, which does nothing for a machine that is already saturated.
Unhealthy is not restarted. As above.
Secrets are environment variables unless you build something better.
None of that means you need Kubernetes. A single well-configured Docker host is a perfectly good answer for an internal tool, a side project, a staging environment, or anything whose users tolerate a few seconds of downtime during a deploy — and it is far simpler to operate than a cluster. Reach for more when you actually have the requirement, not in advance:
one host + compose simple, cheap, has downtime during deploys
Swarm multi-host, rolling updates, tiny learning curve, fading
ECS / Cloud Run managed. No control plane to operate. Cloud-specific.
Kubernetes everything, at the cost of being a full-time jobThe order of things to fix, if you are running containers on one host today: a memory limit and a
matching MaxRAMPercentage, log rotation, restart: unless-stopped,
USER, and a graceful shutdown whose two timeouts agree. That is most of the value, and
none of it requires an orchestrator.