# upgrade-docker-ce — migrate a host from Debian's docker.io (20.10.x, # bookworm-packaged) to Docker's official docker-ce repo (28+). # # Bookworm's docker.io stays pinned at 20.10.24, which: # * uses an old client/daemon API (1.41) that newer compose clients # (1.52+) refuse to talk to → "client version 1.52 is too new" # during builds # * is EOL upstream — docker.io upstream doesn't ship to it anymore # * is missing modern buildx driver versions our newer client expects # # This playbook handles the migration on one host at a time. Stops # every running stack via `docker compose stop`, removes the old # packages (preserving /var/lib/docker/), adds Docker's signed APT # repo, installs docker-ce + docker-compose-plugin + containerd.io, # starts the new daemon, and brings each stack back up via # `docker compose up -d` (compose's restart=unless-stopped also # auto-restarts containers when the daemon comes back, but doing # them explicitly per-stack lets us see failures cleanly). # # Usage: # scripts/elway --playbook playbooks/upgrade-docker-ce.yaml # # Recommended host order (least → most blast radius): # 1. nh3-docker # 2. esh-docker-vm # 3. ana-docker # # Verify after each before moving to the next: # ssh 'docker version --format "{{.Server.Version}}"' # ssh 'docker ps --format "{{.Names}}\t{{.Status}}" | head' # # Rollback (if a daemon won't start, or a container errors out): # ssh 'sudo apt install --allow-downgrades docker.io' # then re-add the docker.io packages from /tmp/docker-pre-upgrade.txt # # /var/lib/docker/ is preserved throughout (apt remove, not purge), # so volumes / images / containers survive the package swap. The # overlay2 storage driver is the default on both packages, so no # data migration needed. vars: pkgs_to_remove: docker.io docker-compose docker-compose-plugin docker-buildx-plugin docker-doc docker pkgs_to_install: docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin steps: # ── snapshot + stop ───────────────────────────────────────────────── - name: Snapshot current docker package versions (rollback reference) shell: | dpkg -l | awk '/^ii\s+(docker|containerd)/ {print $2 "=" $3}' \ | sudo tee /tmp/docker-pre-upgrade.txt >/dev/null cat /tmp/docker-pre-upgrade.txt sudo: true changed_when: "false" - name: List currently-running compose stacks (snapshot for restart) shell: | docker ps --format '{{.Label "com.docker.compose.project"}}' \ | sort -u | grep -v '^$' \ | tee /tmp/docker-pre-upgrade-stacks.txt changed_when: "false" - name: Stop every running stack via docker compose # Walk each /opt/docker/compose// dir that has a running # container and `compose stop` it. Skips dirs without a running # stack so reruns don't error. shell: | set +e for dir in /opt/docker/compose/*/; do stack=$(basename "$dir") if docker ps --format '{{.Label "com.docker.compose.project"}}' | grep -q "^${stack}$"; then echo " stopping $stack" (cd "$dir" && docker compose stop) || echo " (no-op or failed)" fi done true # ── add Docker's official APT repo ────────────────────────────────── - name: Install prereqs for the new APT repo shell: apt-get install -y ca-certificates curl gnupg sudo: true - name: Ensure /etc/apt/keyrings exists (mode 0755) shell: install -d -m 0755 /etc/apt/keyrings sudo: true creates: /etc/apt/keyrings - name: Fetch + install Docker's signing key (only if absent) shell: | curl -fsSL https://download.docker.com/linux/debian/gpg \ | sudo gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg sudo: true when: '[ ! -f /etc/apt/keyrings/docker.gpg ]' - name: Add Docker's APT source # codename is whatever lsb_release says — bookworm on this fleet. shell: | codename=$(lsb_release -cs) arch=$(dpkg --print-architecture) url="https://download.docker.com/linux/debian" line="deb [arch=${arch} signed-by=/etc/apt/keyrings/docker.gpg] ${url} ${codename} stable" echo "${line}" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null sudo: true when: '[ ! -f /etc/apt/sources.list.d/docker.list ]' - name: apt-get update (now sees Docker's repo) shell: apt-get update -qq sudo: true - name: Show candidate version of docker-ce shell: apt-cache policy docker-ce | head -5 sudo: true changed_when: "false" # ── swap packages ─────────────────────────────────────────────────── - name: Remove Debian's docker.io packages (PRESERVE /var/lib/docker) # apt remove (not purge) keeps /var/lib/docker/* in place — images, # volumes, container metadata all survive the swap. shell: DEBIAN_FRONTEND=noninteractive apt-get remove -y {{ pkgs_to_remove }} || true sudo: true - name: Install docker-ce + plugins from Docker's repo shell: DEBIAN_FRONTEND=noninteractive apt-get install -y {{ pkgs_to_install }} sudo: true - name: Rewrite docker.service drop-in to match the new package layout # Two related fixes for the systemd unit, learned the hard way on # nh3-docker. The docker.io era left an # /etc/systemd/system/docker.service.d/override.conf hardcoding # ExecStart=/usr/sbin/dockerd (Debian package path) and usually # adding `-H tcp://...` for remote discovery. # # After the swap to docker-ce two things are wrong: # 1. /usr/sbin/dockerd doesn't exist — docker-ce installs at # /usr/bin/dockerd. Daemon fails status=203/EXEC. # 2. If daemon.json defines `hosts:` (typical here for the # 0.0.0.0:2375 homepage-discovery binding), then ANY `-H` # flag in ExecStart conflicts: dockerd refuses to start with # "conflicting host options". # # The shipped docker-ce unit's ExecStart is `dockerd -H fd:// # --containerd=/run/containerd/containerd.sock` — that `-H fd://` # ALSO conflicts with daemon.json hosts:. So we always need an # override that strips `-H` entirely when daemon.json defines # hosts. # # Procedure: # * Back up any existing override (preserves it as .pre-upgrade) # * Probe /etc/docker/daemon.json for a `hosts:` setting # * If hosts: is defined → install an override that clears the # base unit's ExecStart and replaces it WITHOUT -H # * Otherwise no override is needed (base unit's `-H fd://` is fine) shell: | d=/etc/systemd/system/docker.service.d f="$d/override.conf" sudo install -d -m 0755 "$d" if [ -f "$f" ]; then echo " backing up existing $f → ${f}.pre-upgrade" sudo mv "$f" "${f}.pre-upgrade" fi if [ -f /etc/docker/daemon.json ] && grep -q '"hosts"' /etc/docker/daemon.json; then echo " daemon.json has hosts: — installing override that strips -H from ExecStart" sudo tee "$f" >/dev/null <<'OVR' [Service] ExecStart= ExecStart=/usr/bin/dockerd --containerd=/run/containerd/containerd.sock OVR else echo " daemon.json has no hosts: — base unit's ExecStart is fine, no override needed" fi sudo: true - name: systemctl daemon-reload (so the new unit + override are loaded) shell: systemctl daemon-reload sudo: true - name: Reset the docker.service failed state (3 failed starts during the install often leaves it in start-rate-limited state — reset so the next start isn't immediately denied) shell: systemctl reset-failed docker.service sudo: true - name: Enable + start the new daemon (idempotent) shell: systemctl enable --now docker sudo: true - name: Confirm new daemon is responsive + report its version shell: docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Server.APIVersion}}' changed_when: "false" # ── bring stacks back up ──────────────────────────────────────────── - name: Start every previously-running compose stack # Use the snapshot we took before the swap. Stacks whose containers # have restart=unless-stopped will already be coming back via the # daemon — `compose up -d` is idempotent and surfaces failures we # might otherwise miss. shell: | set +e while read stack; do dir="/opt/docker/compose/$stack" if [ -d "$dir" ]; then echo " starting $stack" (cd "$dir" && docker compose up -d) || echo " FAILED — investigate $stack" fi done < /tmp/docker-pre-upgrade-stacks.txt true - name: Brief settle pause then list container states shell: sleep 8 && docker ps --format 'table {{.Names}}\t{{.Status}}' changed_when: "false" verify: - name: docker daemon responding shell: docker info >/dev/null changed_when: "false" - name: docker server version is 24+ (was 20.10) shell: | v=$(docker version --format '{{.Server.Version}}' | cut -d. -f1) [ "$v" -ge 24 ] changed_when: "false" - name: docker compose plugin v2+ installed # docker-ce 29 ships docker-compose-plugin renumbered to v5.x (was # v2.x with docker-ce 26-28). Both are the same Compose v2 codebase # under the hood — Docker just realigned the plugin's major number. # Accept anything ≥ 2 so this verify keeps working across future # plugin renumbers. shell: | major=$(docker compose version --short | sed 's/^v//' | cut -d. -f1) [ "$major" -ge 2 ] changed_when: "false" - name: at least one previously-running stack is back up # If the host had no running stacks pre-upgrade (e.g. nh3-docker may # be empty), this verify passes trivially. shell: | [ ! -s /tmp/docker-pre-upgrade-stacks.txt ] && exit 0 head -1 /tmp/docker-pre-upgrade-stacks.txt | xargs -I {} docker ps \ --filter 'label=com.docker.compose.project={}' --format '{{.Names}}' \ | grep -q . changed_when: "false"