Docker 基础体系 · 第 64/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker Engine 升级:兼容、存储驱动、API、灰度、回滚和验证

Docker Engine 升级不是简单地替换一个二进制文件。一个正在运行的 Linux Docker 主机至少包含以下状态:

docker CLI / Compose / Buildx
          │
          │ Unix socket 或 TCP/TLS
          ▼
Docker daemon
          │
          ├── containerd / runc
          ├── 镜像与容器元数据
          ├── 存储驱动与 data-root
          ├── 网络、卷、插件
          └── 日志、事件、运行中容器

因此升级同时影响四类兼容性:

  1. 客户端与 Daemon 的 API 兼容性
  2. Daemon 与 containerd、runc、内核及文件系统的兼容性
  3. 镜像、容器、卷和网络等持久化状态的兼容性
  4. 业务负载与升级期间故障、重启、回滚之间的兼容性

本文只讨论 Linux 容器边界。Windows 容器、Docker Desktop 内部虚拟机、第三方发行版打包方式可能具有不同的升级和存储约束。


一、先区分升级对象:CLI、Daemon、运行时和业务镜像

Docker 常被当作一个软件包理解,但实际至少有四个独立的版本边界:

组件 作用 升级影响
Docker CLI 解析 docker 命令并调用 API 影响命令语义与 API 请求
Docker Engine / dockerd 管理容器、镜像、网络、卷和 API 影响主机上的容器生命周期
containerd、runc 管理容器运行时和进程 影响容器创建、启动、停止
镜像构建与编排工具 BuildKit、Buildx、Compose 影响构建结果、API 调用和部署模型

安装包名称也不一定相同。官方 Docker 软件源通常包含 docker-cedocker-ce-clicontainerd.io 等包,但不同 Linux 发行版可能使用发行版自己的 docker.io、独立的 containerd 包或不同的 systemd 单元。升级前必须先确认实际安装来源,而不是只根据命令名判断。

docker version
docker info
command -v dockerd
systemctl cat docker
dpkg -l | grep -E 'docker|containerd|runc'   # Debian/Ubuntu
rpm -qa | grep -E 'docker|containerd|runc'   # RPM 系发行版

典型的 docker version 输出包含:

Client:
 Version:           ...
 API version:       ...
 Buildx version:    ...

Server:
 Engine:
  Version:          ...
  API version:      ...
  Minimum API version: ...
 containerd version: ...
 runc version:      ...

这里的 Client 和 Server 版本可以不同。升级 Docker CLI 并不等于升级 Daemon,升级 Daemon 也不一定自动升级 Compose 或 Buildx。

业务镜像则是另一条版本线。升级 Engine 通常不应被理解为“重新构建所有镜像”。镜像由内容寻址的层组成,容器由镜像层、可写层、配置和运行时状态共同组成。只要存储格式和运行时接口仍然兼容,已有镜像通常可以继续使用;但这不是对所有未来版本和所有插件的绝对保证。


二、Docker API 兼容:版本协商决定请求能否成立

2.1 API 版本的三个值

Daemon 的 /version 接口通常会返回:

  • ApiVersion:Daemon 支持的最高 API 版本;
  • MinAPIVersion:Daemon 支持的最低 API 版本;
  • Version:Engine 版本。

可以直接通过 Unix socket 查询:

sudo curl --unix-socket /var/run/docker.sock \
  http://localhost/version | jq

输出结构类似:

{
  "Version": "27.x",
  "ApiVersion": "1.xx",
  "MinAPIVersion": "1.yy",
  "Os": "linux",
  "Arch": "amd64"
}

Docker 客户端发起请求时,不是任意版本都能使用。设:

  • C C :客户端希望使用的 API 版本;
  • Dmax D_{\max} :Daemon 支持的最高 API 版本;
  • Dmin D_{\min} :Daemon 支持的最低 API 版本。

请求至少需要满足:

DminCDmaxD_{\min} \leq C \leq D_{\max}

如果客户端启用了 API 协商,客户端会尝试选择一个双方都支持的版本。理想情况下:

Cnegotiated=min(Cclient,Dmax)C_{\text{negotiated}} = \min(C_{\text{client}}, D_{\max})

并且还要满足:

CnegotiatedDminC_{\text{negotiated}} \geq D_{\min}

例如:

客户端支持:1.41
Daemon 支持:1.24 ~ 1.43
协商结果:1.41

如果:

客户端支持:1.20
Daemon 支持:1.24 ~ 1.43

则双方没有交集,客户端无法正常调用该 Daemon。

2.2 API 兼容不等于功能兼容

即使 API 版本落在可接受范围内,也不代表所有功能都存在。某些字段、参数和行为可能只在较新的 API 中提供。例如,CLI 能否使用某个选项,取决于:

  1. CLI 是否认识该选项;
  2. 协商后的 API 是否支持对应字段;
  3. Daemon、containerd 和运行时是否实现该操作;
  4. 当前对象状态是否允许执行该操作。

因此,下面两种错误含义不同:

client is newer than server

通常表示 API 版本或功能边界不匹配。

unknown flag: ...

可能是 CLI 本身版本过旧,也可能是命令属于 Buildx、Compose 等独立组件而不是旧版 Docker CLI。

检查实际请求版本:

docker version
docker info
env | grep '^DOCKER_'

DOCKER_HOST 会改变请求目标,例如从本地 Unix socket 切换到远端 TCP;DOCKER_API_VERSION 会强制使用指定 API 版本。强制设置后,客户端可能不再正常协商,因此它适合诊断或兼容旧环境,不适合无理由长期固定。

例如:

DOCKER_API_VERSION=1.41 docker version

如果该命令失败,而普通的 docker version 成功,说明环境变量或固定 API 版本正在限制客户端,而不是 Daemon 本身不可用。

2.3 TLS 远程 API 还增加了一层连接兼容性

远程 Daemon 的兼容性不只有 API 版本,还包括:

  • DOCKER_HOST 指向的地址;
  • TLS 是否启用;
  • CA、客户端证书和私钥是否匹配;
  • 服务端证书的主机名是否包含目标地址;
  • 防火墙和 systemd socket 是否允许连接。

例如:

export DOCKER_HOST=tcp://docker.example.internal:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH="$HOME/.docker/docker-example"

docker version

如果没有 TLS,直接将 Daemon 监听在可访问网络上的 2375 端口,会把 Docker 控制权暴露给能够连接该端口的主体。Docker API 权限基本等价于主机上的高权限控制权,因为 API 可以创建挂载宿主机路径的特权容器。升级时修改监听地址或 TLS 配置,必须作为独立的连接验证步骤处理。


三、升级前必须识别的持久化状态

docker ps 只显示容器,不等于显示主机上所有需要保护的数据。至少要盘点:

docker ps -a
docker images --digests
docker volume ls
docker network ls
docker plugin ls
docker system df -v

需要分别记录:

  • 正在运行和已停止的容器;
  • 镜像名称与 digest;
  • 容器的挂载、环境变量、重启策略和网络;
  • 命名卷及其实际使用者;
  • 自定义网络的子网和连接关系;
  • 日志驱动及日志路径;
  • registry、代理、TLS、镜像源配置;
  • Swarm、插件、GPU 运行时等额外能力;
  • Compose 文件和 .env 文件;
  • systemd drop-in 与 /etc/docker/daemon.json

容器的可写层不是可靠的业务数据层。升级前即使不迁移 data-root,也应该将数据库数据放在卷或宿主机挂载路径中,并使用数据库自身的备份机制。卷数据也不能简单等同于一致性备份:正在写入的数据库卷被直接复制,可能得到逻辑上不一致的文件集合。

一个基本的卷文件备份示例:

docker run --rm \
  -v app_data:/source:ro \
  -v "$PWD/backup":/backup \
  alpine \
  tar czf /backup/app_data.tgz -C /source .

这个命令只保证“容器内看到的文件被读取并打包”,不保证数据库事务一致性。对于 PostgreSQL、MySQL 等数据库,应该先执行数据库原生备份,或在应用停止、冻结写入后再复制卷。


四、data-root、存储驱动和文件系统不是同一个概念

4.1 data-root 保存的是 Docker 的状态

Docker 默认将大量状态放在 /var/lib/docker。可以在 daemon.json 中修改:

{
  "data-root": "/srv/docker"
}

data-root 是 Docker 状态目录的位置,不是存储驱动名称。修改它意味着 Docker 将从另一个目录读取:

  • 镜像层;
  • 容器元数据;
  • 容器可写层;
  • 网络和卷相关状态;
  • 构建缓存等部分状态。

如果只把 data-root 改到空目录,Docker 看起来会像一台新主机:镜像、容器和卷都不会自动出现。正确迁移需要先停止 Daemon,完整复制或恢复整个数据目录,并保持权限、符号链接、扩展属性和底层文件系统语义。

sudo systemctl stop docker

sudo rsync -aHAX --numeric-ids \
  /var/lib/docker/ /srv/docker/

sudo install -d -m 0755 /etc/docker
sudoedit /etc/docker/daemon.json

sudo systemctl start docker
docker info --format 'Root={{.DockerRootDir}}'

rsync-A-X 用于保留 ACL 与扩展属性,--numeric-ids 避免迁移时按名称重新映射 UID/GID。执行复制时不能让旧 Daemon 继续写入源目录,否则复制结果可能缺少刚写入的元数据,形成不一致状态。

更安全的做法是:

  1. 先做一次停止前的预复制;
  2. 停止 Docker;
  3. 再做一次最终增量同步;
  4. 修改配置;
  5. 启动并验证;
  6. 保留旧目录,直到恢复演练完成。

4.2 存储驱动决定层如何落盘

存储驱动负责把镜像只读层、容器可写层和层之间的联合视图映射到宿主机文件系统。检查当前驱动:

docker info --format \
'Driver={{.Driver}}
BackingFilesystem={{.BackingFilesystem}}
DockerRootDir={{.DockerRootDir}}'

现代 Linux 安装通常优先使用 overlay2,但“支持 overlay2”并不只取决于 Docker 版本,还取决于:

  • 内核是否支持所需 OverlayFS 能力;
  • 底层文件系统是否满足要求;
  • XFS 是否启用了正确的 ftype
  • rootful 或 rootless 模式的限制;
  • 是否存在网络文件系统等不适合作为容器层存储的后端。

XFS 的检查示例:

findmnt -no FSTYPE,OPTIONS /var/lib/docker
xfs_info "$(findmnt -no TARGET /var/lib/docker)" | grep ftype

对于 overlay2,XFS 通常需要 ftype=1,也就是目录项类型信息可用。若底层条件不满足,可能出现 Daemon 启动失败、镜像拉取失败、容器启动失败,或者更隐蔽的权限和文件行为异常。不能只看 docker info 中显示了 overlay2 就认为所有功能都已经验证。

4.3 不要在原地切换存储驱动

存储驱动不是一个可以随意修改的运行参数。假设旧目录按驱动 A 组织,启动新 Daemon 时改为驱动 B:

{
  "storage-driver": "overlay2"
}

新驱动通常无法直接解释旧驱动的层目录。即使 Docker 成功启动,也可能只看到空的镜像和容器状态,或者产生不可预期的混合数据。

正确边界是:

  • 只迁移 data-root:保持存储驱动不变,复制整个 Docker 数据目录;
  • 更换存储驱动:新建目标 Docker 状态目录,使用镜像重新拉取或通过镜像归档迁移,使用卷和数据库备份恢复业务数据;
  • 不要把旧驱动目录逐层复制到新驱动目录

镜像迁移可以使用:

docker image save myapp@sha256:... -o myapp.tar
docker image load -i myapp.tar

docker image save 只处理镜像,不处理容器的可写层、卷、网络和 Daemon 配置。它不是整机 Docker 备份。

4.4 overlay2 的真实边界

联合文件系统会让容器看到“多个只读层加一个可写层”的合并视图。容器首次修改只读层中的文件时,可能发生 copy-up:文件被复制到可写层后再修改。因此:

  • 大文件的首次修改可能产生较大 I/O;
  • 容器可写层不适合承载数据库主数据;
  • docker system df 显示的镜像层和容器可写层应分别观察;
  • 直接修改 Docker 数据目录可能破坏层引用和元数据。

这也是“升级前清理空间”的风险来源。docker system prune 可能删除未使用的镜像、网络、停止容器,带有 --volumes 时还可能删除未使用卷。它不是升级的必要步骤,也不能代替备份。


五、配置升级:合并而不是覆盖

Docker Daemon 常见配置来源包括:

  • /etc/docker/daemon.json
  • systemd 的 ExecStart
  • /etc/systemd/system/docker.service.d/*.conf
  • 环境文件;
  • 发行版默认 unit;
  • 远程访问和 TLS 证书配置。

查看最终的 systemd 配置:

systemctl cat docker
systemctl show docker -p ExecStart -p Environment

检查 JSON 语法:

jq empty /etc/docker/daemon.json

但 JSON 合法不代表 Docker 配置有效。启动后仍要检查:

sudo systemctl restart docker
sudo systemctl --no-pager --full status docker
sudo journalctl -u docker -b --no-pager -n 200
docker info

一个常见错误是同一配置项同时出现在 daemon.json 和 systemd 启动参数中。例如,hosts、日志驱动等配置存在重复定义时,Daemon 可能拒绝启动或以难以预期的来源为准。升级前应记录旧配置,逐项合并新配置,而不是用供应商示例覆盖整个文件。

代理、镜像源、日志驱动、TLS 和 data-root 的修改也应分开验证。把多个高风险改动和 Engine 版本升级放在同一个变更中,会使失败后的归因和回滚都变得困难。


六、BuildKit、Buildx 和 Compose 的兼容边界

现代 Docker 构建通常使用 BuildKit。检查构建工具:

docker buildx version
docker buildx ls
docker compose version

BuildKit 负责构建图的求解、缓存、并行执行和输出;Buildx 是常用的 CLI 前端。构建失败不一定是 Engine API 失败,也可能来自:

  • Dockerfile 语义变化;
  • BuildKit 前端镜像或语法版本;
  • registry 认证;
  • 代理和 DNS;
  • --platform 对应的模拟器或原生构建节点;
  • 缓存导入导出配置。

升级后至少编译一次代表性镜像:

docker buildx build \
  --progress=plain \
  --load \
  -t upgrade-smoke:test \
  .

--load 将单平台构建结果加载到本地 Docker 镜像存储,便于随后用 docker run 验证;多平台构建通常需要 --push 或其他输出方式,不能假设结果自动出现在本地镜像列表中。

Compose 现代规范中,顶层 version 字段已经不再用于选择 Compose 实现版本,某些版本会提示它已过时。验证 Compose 文件应使用:

docker compose config -q
docker compose config

config -q 主要验证解析和合并结果,不会证明:

  • 镜像一定存在;
  • 网络端口没有冲突;
  • 卷权限正确;
  • 应用能够建立数据库连接;
  • 健康检查能够通过。

因此 Compose 验证必须继续到容器启动和业务探针:

docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100

升级前应固定镜像 digest,而不是只记录可变的 latest 标签:

docker image inspect myapp:prod \
  --format '{{index .RepoDigests 0}}'

这样可以区分“Engine 升级导致的行为变化”和“实际上拉到了另一份镜像”。


七、为什么生产上应优先灰度,而不是原地升级

灰度升级是先让少量、可隔离的主机或工作负载使用新 Engine,再逐步扩大范围。它与“在一台主机上先试一次”不同:真正的灰度需要能够控制流量、保留旧版本容量,并且有明确的比较指标。

推荐的主机状态转换如下:

stateDiagram-v2
    [*] --> Inventory
    Inventory --> BackupVerified
    BackupVerified --> Drained
    Drained --> Upgraded
    Upgraded --> SmokeTested
    SmokeTested --> CanaryServing
    CanaryServing --> Expanded
    CanaryServing --> RolledBack
    Expanded --> [*]
    RolledBack --> Restored
    Restored --> [*]

关键路径是:

  1. Inventory:记录版本、配置、存储驱动、挂载、镜像 digest 和业务关系;
  2. BackupVerified:备份不仅存在,还能读取或恢复到测试环境;
  3. Drained:从负载均衡器、调度器或集群中摘除主机;
  4. Upgraded:停止服务、更新软件包或 Daemon、启动服务;
  5. SmokeTested:验证 API、镜像、网络、卷和容器生命周期;
  6. CanaryServing:只承载有限流量或少量副本;
  7. Expanded:指标正常后逐批扩大;
  8. RolledBack:触发回滚条件,恢复旧 Engine 或旧主机;
  9. Restored:验证业务和数据恢复,而不是只验证进程存在。

如果只有单台主机且没有备用节点,严格意义上无法实现无损灰度,只能做维护窗口内的原地升级。此时更重要的是缩短停机路径、先完成恢复演练,并确认业务能够从备份重建。

灰度需要保留旧版本容量。若集群总容量为 NN,正在升级的主机需要消耗 UU,业务高峰需要的安全容量为 CsafeC_{\text{safe}},则至少应满足:

NUCsafeN-U \geq C_{\text{safe}}

这不是 Docker 的规范,而是容量约束。若摘除一台主机后剩余容量不足,升级动作本身就会制造调度失败或业务过载。


八、可执行的单机升级流程

下面给出适用于 systemd 管理、官方软件源安装的 Linux 主机流程。具体包名和版本格式随发行版变化,不能直接把示例版本复制到所有环境。

8.1 记录升级前基线

mkdir -p /var/tmp/docker-upgrade-before

docker version \
  > /var/tmp/docker-upgrade-before/docker-version.txt

docker info \
  > /var/tmp/docker-upgrade-before/docker-info.txt

docker ps -a --no-trunc \
  > /var/tmp/docker-upgrade-before/containers.txt

docker images --digests \
  > /var/tmp/docker-upgrade-before/images.txt

docker volume ls \
  > /var/tmp/docker-upgrade-before/volumes.txt

docker network ls \
  > /var/tmp/docker-upgrade-before/networks.txt

sudo cp -a /etc/docker \
  /var/tmp/docker-upgrade-before/etc-docker
sudo systemctl cat docker \
  > /var/tmp/docker-upgrade-before/docker-unit.txt

再检查正在运行的容器是否有重启策略:

docker ps -q | xargs -r docker inspect \
  --format '{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}'

重启策略只能帮助容器重新启动,不能证明应用已恢复。应用仍可能因为数据库未就绪、配置错误或卷权限变化而处于不可用状态。

8.2 先验证备份和恢复路径

对于数据库执行原生备份;对于卷可以在测试主机恢复:

docker volume create restore_test

docker run --rm \
  -v restore_test:/target \
  -v "$PWD/backup":/backup \
  alpine \
  sh -c 'tar xzf /backup/app_data.tgz -C /target && find /target -maxdepth 2 -type f | head'

这一步验证的是归档可读、卷可写和基本文件可见性。它仍不替代数据库启动后的完整一致性检查。

8.3 选择目标版本并检查可用包

以 Debian/Ubuntu 为例:

apt-cache madison docker-ce
apt-cache madison docker-ce-cli
apt-cache madison containerd.io

应该选择同一软件源中相互匹配的版本组合,并阅读该版本对应的发行说明。不要只降级 docker-ce 而留下不匹配的 CLI、containerd 或运行时包。

8.4 摘流、停止和升级

若主机承载业务,先从负载均衡器或调度器摘除。然后:

sudo systemctl stop docker
sudo systemctl is-active docker || true

确认没有计划任务或自动化系统在停止期间继续执行 docker 命令。需要复制 data-root 时,在此阶段进行最终同步。

升级包的命令取决于发行版和仓库。例如使用 APT 时,实际版本号应来自 apt-cache madison

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

如果需要固定到指定版本,应使用仓库中真实存在且相互匹配的完整版本字符串,而不是手写一个看似合理的版本号。

启动并检查:

sudo systemctl daemon-reload
sudo systemctl start docker
sudo systemctl --no-pager --full status docker
sudo journalctl -u docker -b --no-pager -n 200

daemon-reload 只在 systemd unit 或 drop-in 发生变化时有意义;它不会刷新 daemon.json 的语义校验,真正的配置检查仍发生在 Daemon 启动时。


九、验证必须覆盖控制面、运行时、数据面和业务面

systemctl status docker 显示 active”只证明 Daemon 进程启动了。完整验证至少分四层。

9.1 控制面:API 和配置

docker version
docker info
docker events --since 1m --until 2m 2>/dev/null || true

核对:

  • Server 版本是否为目标版本;
  • API 版本和最小 API 版本是否符合客户端;
  • DockerRootDir 是否正确;
  • Storage Driver 是否仍为预期值;
  • 日志驱动、代理、镜像源和 TLS 是否生效;
  • containerd、runc 是否为预期版本。

9.2 运行时:容器生命周期

使用一个已存在且无破坏性的测试镜像,或准备专用 smoke-test 镜像:

docker run --rm \
  --name engine-upgrade-smoke \
  alpine:3.20 \
  sh -c 'uname -a; id; test -f /etc/os-release'

这个测试验证镜像解包、容器创建、runc 启动、进程退出和清理路径。它不能验证业务镜像的动态链接库、启动脚本和权限,因此还应启动一份代表性业务容器。

检查停止、重启和日志:

docker run -d --name restart-smoke alpine:3.20 \
  sh -c 'while true; do echo alive; sleep 5; done'

docker restart restart-smoke
docker logs --tail=20 restart-smoke
docker rm -f restart-smoke

如果生产配置使用特定日志驱动,应该检查实际日志是否仍能被采集,而不是只看 docker logs

9.3 数据面:卷、网络和镜像

卷验证:

docker volume create upgrade-smoke-volume

docker run --rm \
  -v upgrade-smoke-volume:/data \
  alpine:3.20 \
  sh -c 'echo verified > /data/check.txt'

docker run --rm \
  -v upgrade-smoke-volume:/data:ro \
  alpine:3.20 \
  cat /data/check.txt

docker volume rm upgrade-smoke-volume

网络验证:

docker network create upgrade-smoke-net

docker run -d --name smoke-server \
  --network upgrade-smoke-net \
  alpine:3.20 \
  sh -c 'while true; do printf "ok\n"; sleep 1; done'

docker inspect smoke-server \
  --format '{{json .NetworkSettings.Networks}}' | jq

docker rm -f smoke-server
docker network rm upgrade-smoke-net

更有价值的网络测试是使用与生产相同的端口、DNS、代理和自定义网络条件,验证服务之间的真实连接,而不是只验证容器能启动。

9.4 业务面:健康检查和关键事务

业务验证应至少包括:

  • HTTP 或 gRPC 健康检查;
  • 读取一条关键配置;
  • 访问数据库;
  • 写入并读取一条测试数据,或执行只读的业务探针;
  • 检查错误率、延迟、重启次数和日志采集;
  • 检查监控、告警和审计事件是否仍然到达。

健康检查通过不一定代表业务可用。例如应用进程能返回 200,但异步消费者已经停止,或数据库连接池全部失败。灰度期间应比较新旧主机的相同指标,而不是只观察新主机是否“没有明显报错”。


十、回滚:回滚软件包不等于回滚数据

10.1 回滚有三个不同层次

  1. 流量回滚:把请求切回旧主机或旧副本;
  2. 工作负载回滚:恢复旧镜像 digest、旧 Compose 配置或旧部署版本;
  3. Engine 回滚:把 Daemon 和其依赖包恢复到旧版本。

优先级通常是先做流量回滚,再判断是否需要 Engine 回滚。因为 Engine 升级失败时,旧主机仍然是最小风险的业务恢复路径。

10.2 什么时候包降级不够

如果新 Engine 修改或升级了 Docker 数据目录中的元数据格式,旧 Engine 不一定能读取新状态。此时直接执行:

sudo apt-get install docker-ce=<old-version>
sudo systemctl restart docker

可能导致启动失败、对象缺失或更严重的状态不一致。具体可回退性取决于实际版本、发行版打包、containerd、存储驱动和数据目录状态,不能假设所有版本都支持任意方向降级。

可靠的 Engine 回滚需要预先保存:

  • 旧版 Engine、CLI、containerd、runc 包;
  • /etc/docker 和 systemd drop-in;
  • 旧的 Docker 数据目录快照;
  • 卷和数据库的一致性备份;
  • 镜像 digest 与 Compose 配置。

理想回滚路径是:

停止新 Daemon
    │
    ├── 若旧数据目录仍兼容:使用旧包启动并验证
    │
    └── 若数据目录可能已变更:
          恢复升级前的 data-root 快照
          恢复配置和软件包
          启动旧 Daemon
          恢复或校验业务数据

如果只有“升级前复制的部分文件”,而没有完整的 data-root 和业务数据备份,就不能把它称为可验证的回滚方案。

10.3 数据库迁移会阻断应用回滚

Engine 回滚和数据库 schema 回滚是两个独立问题。例如新版本应用执行了不可逆的数据库迁移,即使 Engine 成功降级,旧应用也可能无法读取新 schema。因此灰度发布还必须考虑:

  • 应用是否向后兼容数据库;
  • 数据迁移是否可逆;
  • 新旧版本是否会同时访问同一卷;
  • 回滚时是否需要恢复数据库快照;
  • 是否需要采用 expand/contract 迁移策略。

Docker 只负责容器和卷的生命周期,不会自动为数据库提供事务级回滚。


十一、常见失败表现与诊断路径

Daemon 启动失败

先看 systemd 和本次启动日志:

systemctl status docker
journalctl -u docker -b --no-pager
dockerd --validate 2>&1 || true

具体验证命令是否可用取决于 Engine 版本;即使命令不存在,也不能说明配置有效。最可靠的证据仍是 Daemon 日志中的解析错误、重复配置、权限错误或存储驱动错误。

常见原因包括:

  • daemon.json JSON 语法错误;
  • 配置项在 JSON 和 systemd 参数中重复;
  • data-root 不存在或权限错误;
  • 底层文件系统不满足存储驱动条件;
  • 旧插件或运行时依赖不兼容;
  • 监听地址或 TLS 证书配置错误。

容器能创建但不能启动

查看容器状态和事件:

docker ps -a
docker inspect <container>
docker logs <container>
docker events --since 10m

如果日志为空,可能是进程在日志初始化前退出、日志驱动不可用、权限错误,或错误实际出现在 Daemon 日志中。应同时查看:

journalctl -u docker -b --no-pager

API 错误但 Daemon 正常

docker version
env | grep '^DOCKER_'
docker context ls
docker context inspect "$(docker context show)"

重点检查当前 CLI 到底连接了哪个 Daemon。很多“升级无效”的问题其实是 CLI 仍连接旧的远程 context,或者 DOCKER_HOST 覆盖了预期目标。

镜像拉取失败

升级后 registry 失败不一定是 Engine API 问题,可能是:

  • 认证配置没有迁移;
  • 代理配置只写在 shell 中,Daemon 进程没有继承;
  • CA 证书路径改变;
  • DNS 或防火墙改变;
  • 镜像源配置格式不再接受;
  • 架构或平台选择发生变化。

应分别测试 Daemon 能否访问 registry、当前用户的凭据是否存在,以及目标镜像是否包含所需平台。

卷存在但应用看不到数据

先确认容器实际挂载:

docker inspect <container> \
  --format '{{json .Mounts}}' | jq

然后检查:

  • Compose 项目是否创建了另一个同名但不同作用域的卷;
  • data-root 是否指向正确目录;
  • 容器内 UID/GID 是否能访问文件;
  • SELinux 标签或其他安全策略是否阻止访问;
  • 应用是否使用了错误的挂载目标。

docker volume ls 中能看到卷”只证明 Daemon 的元数据中存在该卷,不证明该卷已挂载到目标容器,也不证明应用用户有权限读取。


十二、live-restore 与重启策略不能代替灰度

Docker 的 live-restore 可以在 Daemon 短暂不可用时尽量保持部分运行中容器继续运行,但它不是任意 Engine 升级的无中断保证。升级可能涉及 containerd、runc、存储驱动、网络初始化或配置重载;这些路径并不都能由 live-restore 覆盖。

同样,restart: unless-stopped 或 Docker 的重启策略只能解决“容器进程退出后是否重新拉起”,不能解决:

  • 镜像拉取失败;
  • 卷挂载错误;
  • 数据库尚未就绪;
  • 应用自身健康检查失败;
  • 网络和证书配置错误;
  • 数据已经损坏或 schema 不兼容。

因此,重启策略是故障恢复机制的一部分,不是升级验证和回滚方案。


十三、一份可落地的升级验收标准

升级完成不能只写“服务已恢复”。应保存可比较的证据:

[ ] Server Engine 版本符合目标版本
[ ] Client 与 Server API 存在有效交集
[ ] DockerRootDir 未意外改变
[ ] Storage Driver 和底层文件系统符合预期
[ ] containerd、runc 版本和运行时调用正常
[ ] daemon.json、systemd、代理、TLS、镜像源配置已验证
[ ] 代表性镜像可以拉取和启动
[ ] BuildKit/Buildx 可以完成代表性构建
[ ] Compose 配置可以解析并启动
[ ] 关键卷挂载正确,数据抽样可读写
[ ] 自定义网络、DNS、端口和服务发现正常
[ ] 日志和监控链路正常
[ ] 健康检查和关键业务探针通过
[ ] 新主机已承载灰度流量并达到观察窗口
[ ] 回滚包、旧配置、data-root 快照和业务备份仍可使用

其中“观察窗口”应覆盖业务真实的周期性行为,例如定时任务、消息消费、日志轮转、备份任务和自动扩缩容。刚启动几分钟没有错误,不能证明这些异步路径正常。


结语:把 Engine 升级看成状态迁移

Docker Engine 升级的核心不是安装新版本,而是把以下状态从旧实现安全地迁移到新实现:

S=(API, 配置, 存储驱动, data-root, 运行时, 镜像, 卷, 网络, 业务)S = (\text{API},\ \text{配置},\ \text{存储驱动},\ \text{data-root},\ \text{运行时},\ \text{镜像},\ \text{卷},\ \text{网络},\ \text{业务})

升级成功需要同时满足:

新 Daemon 可启动客户端 API 可调用持久化状态可读取容器生命周期正常业务探针通过\text{新 Daemon 可启动} \land \text{客户端 API 可调用} \land \text{持久化状态可读取} \land \text{容器生命周期正常} \land \text{业务探针通过}

回滚成功则还需要:

旧软件包可安装旧 Daemon 可读取对应状态业务数据与 schema 可恢复\text{旧软件包可安装} \land \text{旧 Daemon 可读取对应状态} \land \text{业务数据与 schema 可恢复}

所以,最稳妥的升级方案通常是:先盘点兼容边界,保持存储驱动不变,验证 data-root 和备份,隔离并灰度少量主机,按控制面、运行时、数据面和业务面逐层验收,并把流量回滚、应用回滚和 Engine 回滚分别设计。这样即使新版本出现问题,也能明确知道该退回哪一层,而不是盲目地重新安装一个旧软件包。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。