Docker & ContainersXXIX Β· Docker UpgradesExecution
Running the maintenance window
What you'll learn
- Define go/no-go criteria before the window opens
- Execute an upgrade as an ordered runbook with a verification after each step
- Write abort criteria that a tired operator can apply without judgement
- Close a window with evidence rather than with an assumption
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The five previous lessons produced the pieces. This one is the order they go in, and the two decisions that bracket them: whether to start, and when to stop.
The window is short β a patch upgrade on a host with live restore takes about four minutes of actual work. Most of what follows is the structure that makes those four minutes safe to do at 03:00 on a host you do not know well.
Go / no-go
Every item is a command that returns an answer. None of them is a judgement call, which is the point: the gate must be applicable by whoever is holding the pager, not only by the person who planned the change.
#!/usr/bin/env bash
set -uo pipefail
TARGET='5:28.3.2-1~ubuntu.24.04~noble'
CAPTURE='/var/backups/docker-preupgrade/latest'
fail=0
chk() { if [ "$1" -eq 0 ]; then echo "PASS $2"; else echo "FAIL $2"; fail=1; fi; }
# 1. The target version exists for this distribution
apt-cache madison docker-ce | grep -qF "$TARGET"
chk $? "target version $TARGET available"
# 2. The previous version is still available for rollback
apt-cache madison docker-ce | grep -qF "$(dpkg-query -W -f='${Version}' docker-ce)"
chk $? "current version still published (rollback path exists)"
# 3. The capture exists and is recent
test -s "$CAPTURE/containers.json"
chk $? "pre-upgrade capture present"
# 4. Live restore is on, or an outage is explicitly accepted
test "$(docker info --format '{{.LiveRestoreEnabled}}')" = 'true'
chk $? "live-restore enabled"
# 5. The daemon config parses
sudo dockerd --validate --config-file /etc/docker/daemon.json > /dev/null 2>&1
chk $? "daemon.json validates"
# 6. Nothing is already unhealthy
test -z "$(docker ps --filter health=unhealthy -q)"
chk $? "no container currently unhealthy"
# 7. Enough free disk for the new packages and a layer or two
test "$(df --output=avail -BG /var/lib/docker | tail -1 | tr -dc '0-9')" -ge 5
chk $? "at least 5G free on /var/lib/docker"
exit "$fail"The runbook
-
Announce. Post the start in whatever channel your team watches, with the host, the version change, the expected impact and the expected duration. One message, before anything happens.
-
Silence monitoring for this host. A maintenance window in the monitoring system, scoped to the host and time-boxed to the planned duration plus a margin. Time-boxed, so it expires on its own.
-
Take the capture. The script from the capture lesson. Copy the output directory off the host.
-
Record the baseline.
docker psand theStartedAtof every running container, to a file. This is what you will diff against. -
Refresh package metadata.
sudo apt-get update. Nothing is installed by this step. -
Install the pinned versions. The daemon restarts inside this step. This is the only irreversible action in the runbook.
-
Verify the daemon.
systemctl is-active dockeranddocker version. If the daemon is not active, go to the abort criteria β do not proceed. -
Verify the containers. Diff
StartedAtagainst the baseline. Identical means nothing restarted. -
Verify the application. An actual request through the actual path users take. Not
docker ps. -
Un-silence monitoring and confirm green. Watch for one full check interval before you believe it.
-
Close. Post the completion with the evidence: versions before and after, container verification result, application check result.
Steps 7, 8 and 9 are three different questions and people routinely stop after the first. The daemon being up says nothing about the containers; the containers being up says nothing about whether the application works.
# 7. Daemon
systemctl is-active docker
docker version --format 'client={{.Client.Version}} server={{.Server.Version}}'
# 8. Containers - identical StartedAt means nothing was restarted
docker inspect --format '{{.Name}} {{.State.StartedAt}}' $(docker ps -q) > /tmp/started.after
diff /tmp/started.before /tmp/started.after && echo 'PASS: no restarts'
# 8b. And nothing is unhealthy
docker ps --filter health=unhealthy --format '{{.Names}}'
# 9. Application - through the real path
curl -fsS -o /dev/null -w '%{http_code} %{time_total}s\n' https://app.example.com/healthzAbort criteria
Write these before the window, in the ticket, as conditions rather than feelings. The operator at 03:00 should be able to read them and act without deciding anything.
| Condition | Action |
|---|---|
| Daemon not active 60 seconds after install | Check journalctl -u docker; if it names a config key, fix it. Otherwise abort. |
| Daemon restarting in a loop | Abort. Restart=always masks the failure as flapping. |
Any container failed to restore and is not in docker ps | Abort, then check for orphaned shims before restarting anything. |
| Application check fails and containers are healthy | Not an abort. Investigate β this is usually unrelated. |
| Any step takes longer than twice its estimate | Stop and reassess. Do not continue on momentum. |
| You are past the end of the announced window | Stop. Roll back or extend the window explicitly, with a message. |
Fleet order
For more than one host, the order is not βall of themβ.
- A staging host that matches production. Full runbook, including the application check. Wait long enough to see delayed effects β at least one business day for anything with a daily batch job.
- One production host, the least critical. Full runbook. Wait again.
- The rest, in batches, with the batch size chosen so that a failure at any point leaves enough capacity serving traffic.
Closing evidence
The completion message is short and it is not optional. Three lines:
DONE app-01: docker-ce 28.1.3 -> 28.3.2, containerd.io unchanged at 1.7.27
CONTAINERS: 9/9 StartedAt unchanged, 0 unhealthy, no restarts
APP: https://app.example.com/healthz 200 in 0.081s at 03:22Z
WINDOW: 03:04Z - 03:24Z (planned 30m). Hold NOT applied; normal patching resumes.
Anyone reading that in a week knows exactly what changed and what was proven. Compare it with the usual βupgraded docker on app-01, all goodβ, which proves nothing and is indistinguishable from an upgrade that quietly restarted every container.
Knowledge check
Knowledge check Β· 4 questions
Q1. Which is the first irreversible step in the upgrade runbook?
Q2. The application health check fails after the upgrade, but the daemon is active and every container shows unchanged StartedAt and no unhealthy status. What is the correct first move?
Q3. Which conditions are written abort criteria rather than things to work through? Select all that apply.
Q4. Once the announced window has ended, continuing to troubleshoot on the host is preferable to rolling back, because a rollback wastes the work already done.
Passing score: 75%. Answers are checked in this browser.