tva
← 洞察

运行中的生产服务器:Docker Engine 升级

在正在运行一百多个容器的服务器上升级 Docker Engine,不能临场发挥。我们需要将 Hetzner 生产服务器上的 Docker Engine 从 28.1 升级到 28.2,并将 Docker Compose 从 2.35 升级到 2.36 时,采用了与数据库迁移相同的严谨流程:书面的准备清单、经过测试的回滚路径、精确的执行顺序,以及宣布维护窗口结束前的升级后验证。

以下完整记录了我们的做法:每一步的理由、实际运行的命令,以及容器数量足够多时需要留意的问题。在这种规模下,错误的重启策略可能引发连锁故障,使恢复变得困难。

为何这次升级需要正式运维手册

Docker Engine 28.2 新增了 bridge 网络选项和用于直接路由到容器的守护进程选项,并移除了对拉取旧版 schema 1 镜像的支持。在同一维护窗口内,Compose 插件从 2.35.1 升级到 2.36.0。这是一次次要版本更新,但服务栈启动方式的任何变化都值得测试,尤其是使用 condition: service_healthy 的 depends_on 块。

一台服务器上的单个 docker-compose.yml 可能管理十二个相互依赖的服务,包括数据库、反向代理、应用容器和后台工作进程。依赖解析中未明确提示的行为变化,恰恰不会立即暴露。它可能到 03:00 才出现:健康检查始终不触发,依赖它的容器陷入无休止的循环。

实际应对方式是做好准备,而不是为谨慎而谨慎。

准备清单

修改任何软件包之前,我们逐项完成了以下清单。每项都有其理由。

1. 盘点所有运行中的容器及其重启策略。

docker ps --format "table {{.Names}}	{{.Status}}	{{.Image}}" | sort
docker inspect $(docker ps -q) --format '{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}' | sort

这提供了一个基线。配置了 restart: always 的容器,会在 Docker 守护进程重启后尝试自动恢复运行。这正是我们需要的行为,但前提是底层 Compose 文件和镜像处于已知的正常状态。升级时已经陷入崩溃循环的容器,升级后仍会如此;最好现在就明确这一点。

2. 升级前验证所有 Compose 文件。

docker compose config --quiet 2>&1 | grep -i warning

在每个包含 docker-compose.yml 的目录中运行此命令。它使用当前安装的 Compose 版本,因此只能发现已有的警告,不能证明与目标版本兼容;要验证兼容性,请在预发布环境中测试目标版本。当前 Compose 版本会对文件顶部已过时的 version: 键发出警告(该键会被忽略)。旧的 links: 指令已无必要,因为同一网络上的服务可以通过名称互相访问。警告可能在后续版本中变为错误,值得现在就处理。

3. 确认可用磁盘空间。

df -h /var/lib/docker
docker system df

升级会拉取新的 containerd shim,并替换 Docker Engine 二进制文件。旧二进制文件及其依赖并不总会自动清理。在已运行一年的服务器上,docker system df 往往会显示几个 GB 的可回收镜像层。应在升级前清理,而不是升级过程中。

docker system prune -f --volumes

只有确定所有匿名卷都不含需要保留的数据时,才使用 --volumes。我们的服务器上,所有持久化数据都挂载自明确声明的命名卷,因此这样做是安全的。

4. 将当前 Docker Engine 和 Compose 版本导出到参考文件。

docker version > /root/docker-pre-upgrade.txt
docker compose version >> /root/docker-pre-upgrade.txt
docker ps -a >> /root/docker-pre-upgrade.txt
dpkg-query -W docker-ce docker-ce-cli containerd.io docker-compose-plugin >> /root/docker-pre-upgrade.txt

这只需十秒钟。如果出了问题,需要确认升级时哪些容器正在运行,该文件就能提供明确的回滚参考。

5. 通知下游系统。

我们的服务器运行着自托管 Supabase 服务栈和多个 n8n 工作流自动化实例。容器重启期间触发的任何 webhook,都会失败;如果发送方不重试,事件可能在无人察觉的情况下丢失。我们在可用性监控工具中设置了维护窗口,并在此期间禁用了 n8n 的入站 webhook。

回滚计划

只有提前决定什么条件会触发回滚,回滚计划才有用。我们的条件很简单:升级完成十分钟后,如果任何升级前运行的容器仍未运行,且无法通过 docker compose up -d 恢复,就回滚 Docker Engine 二进制文件。

在基于 Debian 的系统上回滚 Docker Engine,需要指定之前的软件包版本。开始前,我们记录了准确的版本字符串:

apt-cache policy docker-ce docker-ce-cli containerd.io docker-compose-plugin

升级前,我们服务器上的输出如下:

docker-ce:
  Installed: 5:28.1.1-1~debian.12~bookworm
  Candidate: 5:28.2.2-1~debian.12~bookworm

docker-compose-plugin:
  Installed: 2.35.1-1~debian.12~bookworm
  Candidate: 2.36.0-1~debian.12~bookworm

如果需要回滚,使用以下命令:

CONTAINERD_PREV=$(awk '$1 == "containerd.io" {print $2}' /root/docker-pre-upgrade.txt)
apt-get install -y --allow-downgrades   docker-ce=5:28.1.1-1~debian.12~bookworm   docker-ce-cli=5:28.1.1-1~debian.12~bookworm   containerd.io="${CONTAINERD_PREV}"   docker-compose-plugin=2.35.1-1~debian.12~bookworm

该命令还将 containerd.io 固定为第 4 步保存在参考文件中的版本,并传入 --allow-downgrades,因为 apt-get -y 会中止操作,而不会以非交互方式降级软件包。

我们将这个命令保存在服务器上的文本文件 /root/docker-rollback.sh 中,并赋予执行权限,以便需要时直接运行,无须在压力下手动输入。

升级流程

准备完成后,实际升级并不复杂。顺序很重要:先更新软件包索引,核实将安装的版本,再执行安装。

步骤 1:仅更新 Docker 软件源的 apt 索引。

apt-get update -o Dir::Etc::sourcelist="sources.list.d/docker.list"   -o Dir::Etc::sourceparts="-"   -o APT::Get::List-Cleanup="0"

这将更新范围限制在 Docker 软件源,而非刷新所有软件源。在生产服务器的 Docker 维护窗口内,不相关软件源带来的意外软件包更新,是一个没有必要引入的变量。

步骤 2:确认候选版本。

apt-cache policy docker-ce docker-ce-cli containerd.io docker-compose-plugin

继续操作前,确认候选版本符合预期。如果候选版本比预发布环境中测试过的版本更新,应暂停并评估。

步骤 3:安装。

apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

这会停止 Docker 守护进程、替换二进制文件,并重启守护进程。配置了 restart: always 的容器,会由守护进程在重启后重新启动。对于配置适中的服务器,容器停止运行的窗口通常不到三十秒,但会随 containerd 的初始化时间而变化。

在第二个终端中观察重启情况:

watch -n 2 'docker ps --format "table {{.Names}}	{{.Status}}" | sort'

步骤 4:验证已安装的版本。

docker version
docker compose version

我们的服务器输出确认如下:

Docker Engine: 28.2.2
Docker Compose: v2.36.0

Docker Compose 的版本独立于 Docker Engine:docker-compose-plugin 软件包的版本(此处为 2.36.0)就是 Compose 的版本,也是 apt 索引追踪的版本。

升级后验证

升级十分钟后,我们进行了系统性的验证。这一步不能省略:守护进程重启会启动容器,但并不能保证依赖健康检查的容器确实已通过检查。

检查所有容器是否处于预期状态。

docker ps -a --format "table {{.Names}}	{{.Status}}	{{.RunningFor}}" | sort

任何显示 Restarting 或 Exited 的容器,都需要立即调查。我们的服务器上,有一个 Supabase 分析服务容器显示 Restarting (1)。它在升级前就已存在低频崩溃循环;升级并非原因,只是升级后扫描让问题更明显。这正是升级前盘点要发现的既有问题。

确认健康检查已通过。

docker inspect $(docker ps -q)   --format '{{.Name}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'   | grep -v "health=none" | sort

没有显式健康检查的容器没有健康状态;根据 Docker 版本的不同,未加判断的 {{.State.Health.Status}} 可能显示 <no value>,也可能因模板错误使整条命令中止。因此模板会先检查 .State.Health,再由 grep 过滤掉没有健康检查的容器。真正需要关注的是其他容器通过 condition: service_healthy 依赖的容器。

对所有服务栈进行 Compose 配置验证。

for dir in /opt/*/; do
  if [ -f "${dir}docker-compose.yml" ]; then
    echo "--- $dir ---"
    docker compose -f "${dir}docker-compose.yml" config --quiet 2>&1
  fi
done

这可以发现 新版 Compose会视为警告或错误的文件指令。我们的服务器上,三个服务栈产生了 version 键的弃用警告。这些警告不影响运行,但值得在下一个维护周期处理。

检查 Docker 守护进程日志中的错误。

journalctl -u docker --since "1 hour ago" | grep -i -E "error|warn|fatal"

正常完成的升级不会在守护进程日志中产生错误。关于弃用配置的警告值得记录,但无须立即处理。

确认容器之间的网络连通性。

docker network ls
docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{end}}'

第二条命令只显示默认的 bridge 网络。请对 docker network ls 列出的每个自定义网络重复执行该命令,确认守护进程重启后,自定义桥接网络中的容器成员仍符合预期,并至少测试一条依赖服务之间的实际连接。

实际观察结果

升级在两分钟内完成,所有升级前运行的容器在升级后都恢复了运行。我们最先注意到的 Compose 变化,是更清晰的 --dry-run 输出格式:新版本提供结构化的彩色输出,使我们更容易在执行前检查 docker compose up 实际会做什么。

在我们的服务栈中,depends_on 的健康检查顺序与 Compose 2.35 完全相同。这符合预期,因为我们提前验证过 Compose 文件,但实际确认仍让人放心。

一个实质性发现是:Compose 不允许将 container_name 字段与 scale 结合使用,因为容器名称必须唯一。我们有一个服务栈同时使用了两者,这是转向正确配置副本之前留下的旧配置。对于这个服务栈,Compose 在运行 docker compose up 时明确报错。修复只需改动一行,删除多余的 container_name 字段。

相关洞察

延伸阅读