故障档案

一个默认配置,决定了我能有多少用户

一行 apt 改动引出的 16 倍网络扩容:docker daemon.json 的配置陷阱如何偷偷限制了业务增长。

我以为这是十分钟的活:一行 apt 改动,给 runtime 镜像加 ffmpeg,打包、灰度、完。结果花了三小时多。不是因为代码复杂,而是在灰度的那一刻,整个基础设施的隐性容量上限暴露了——Docker Swarm 的 gwbridge 只有 253 个 IP,现在已经用满了。

现象

早上 7:30,用户报 AI 容器里没有 ffmpeg,无法剪视频。表面上这是一行 Dockerfile 改动——apt 列表里漏了 ffmpeg。

一小时内排查完毕:容器确实没装这个工具。改 Dockerfile、打包新镜像(1.0.18),准备灰度。

然后,在给一个用户灰度升级时,事情出了岔子。

Swarm 调度了一个新的 task 去 pull 新镜像,但 gwbridge(Docker overlay 网络中负责容器出站的虚拟网桥)拿不到 IP。Task 失败了。灰度用户被卡在 0/1 状态。自动回滚也失败了。

同时看 prod 集群的 service 状态,有 10 个长期处于 cycling 启动失败、重试、又失败的状态——这些现象其实已经存在很久,但因为看起来像是"偶发起不来",一直被当作瞬时故障忽视了。现在它们集中暴露了。

很快发现真问题:生产环境的 gwbridge 是 /24 网段,253 个可用 IP 已经占满。当前有 251 个用户容器 + ingress network 的 1 个 endpoint + 其他系统组件 = 已用 253/253。任何新容器启动时需要分配 endpoint,但池子空了——没有可用 IP,task 调度失败,开始 cycling。

那 10-13 个看起来"偶发失败"的 service?它们就是一直在试图找到那不存在的第 254 个 IP。

排查过程

8:10 — 首先检查 service 状态

连接到生产服务器,运行 docker service ls,发现当前 service 的复制状态:

ID          NAME              MODE        REPLICAS    IMAGE
...
251 个 RUNNING 状态的 service,每个 replicas=1/1
10 个 RUNNING 状态但 replicas=0/1,反复 cycling

所有用户的容器中有 251 个正常运行,另外 10 个卡在了反复重试的状态。再深入看 gwbridge 的配置:

$ docker network inspect gwbridge
[{
  "Name": "docker_gwbridge",
  "Subnet": "172.30.1.0/24",
  "Gateway": "172.30.1.1"
}]

/24 网段分配给了 gwbridge。接下来检查当前分配的 IP 究竟用到了哪里:

$ docker network inspect gwbridge --verbose

一遍遍历下来,251 个用户容器占了 endpoint,加上 ingress 及其他系统组件的占位,gwbridge 内 253 个可用 IP 已经无余量。/24 网段理论上是 254 个 IP(.0 到 .255),但除去网络地址 .0 和广播地址 .255,可用的就是 253 个(.1 网关也在其中)。

已用 253/253。没有余量。这就是为什么新容器无法获取 IP,灰度失败了。

9:00 — 假设 1:改 daemon.json 配置

想过扩大 gwbridge 的网段。改 /etc/docker/daemon.json

{
  "default-address-pools": [{
    "base": "172.31.0.0/16",
    "size": 22
  }]
}

这样,下次创建网络时,Swarm 应该用 /22(1022 个 IP)而不是 /24(253 个)。

重启 docker daemon,让配置生效。等待 60 秒,让集群自愈。

再看 gwbridge:

$ docker info | grep "Default Address Pools:" -A 1

没有输出。空。配置没读进去。

9:15 — 尝试各种做法

想快速解决,试过几个办法:

  1. 删除 /var/lib/docker/network/files/local-kv.db(Docker 网络配置持久化库),强制重建。——不行。
  2. 确认 daemon.json 语法无误,重新加载 daemon 配置。——不行。
  3. 查阅 Docker 文档。发现一句关键的话:default-address-pools 只在 swarm init 时消费一次。

意思是,这个配置项的生效时机是 Docker Swarm 初始化那一刻——在那时,Docker 会根据这个池子创建初始网络。但对于已经存在的网络,改这个配置不会有任何效果。gwbridge 是 5 天前 swarm init 时创建的。现在改 daemon.json 已经太晚。配置修改完全无法影响到已有网络的设置。

9:30 — 改回配置,准备另一条路

把 daemon.json 恢复到原样,重启 docker。集群自愈回到"252 healthy + 10 cycling"状态。

9:45 — Staging 验证

有个 staging 环境(同样的 Docker Swarm 架构,但用户数少得多)。用它做 dry-run。

方案:手动删除 gwbridge,再用新 subnet 重建。

docker network rm docker_gwbridge
docker network create \
  --driver bridge \
  --subnet 172.31.0.0/20 \
  --gateway 172.31.0.1 \
  docker_gwbridge

Staging 上,105 秒内完成整个过程,所有 service 自动重新调度,容器全部拿回 IP,状态恢复。

确认这个方案可行。

9:50 — Canary 试错

但真正动手前,想在 prod 上做个"单用户 canary"——选一个 idle 用户,试试 scale 0(停容器,释放 endpoint)然后 scale 1(重启容器,重新分配 endpoint)。这样可以在生产环境验证删除重建方案是否安全。

选了用户 B。

先 scale 0:

docker service scale svc-<用户B>=0

成功。容器停了,gwbridge 上的 endpoint 释放了。一个 IP 应该回归可用。

再 scale 1:

docker service scale svc-<用户B>=1

预期:gwbridge 应该有 1 个空 IP 现在可用,container 会拿到它。

实际:task 又启动失败了。为什么?

看日志发现:刚释放的那个 IP(比如 172.30.1.100),立刻被其他 cycling 的 task 抢走了。Swarm 内部有失联的 service 在不停地尝试获取 endpoint,它们的重试机制比 scale 1 的新 task 更激进。这种负反馈导致用户 B 的容器仍然拿不到 IP。

9:55 — 关键假设验证

这个失败给了一个启发:能否先把那 13 个 cycling service 全部 scale 0,让它们停止抢占 IP?

这样就有 13 个 IP 可用,足够 gwbridge 扩容时临时周转。

给 13 个失联 service 全部执行 scale 0

docker service ls --filter "label=health=fail" --format "{{.Name}}" | \
  xargs -I {} docker service scale {}=0

都成功了。集群稳定,回到"252 healthy + 0 failing"(那 13 个现在是 0/0)。

10:10 — Staging 性能优化验证

刚意识到,扩容过程中,swarm 会试图 pull 新镜像。因为网络不好,pull 可能花 60+ 秒。能否通过修改 /etc/hosts 把 docker.io 黑洞掉,让 Swarm 直接 fallback 到本地镜像?

在 staging 上测试:在容器启动前加一行 hosts 黑洞:

echo "127.0.0.1 docker.io" >> /etc/hosts

效果:scale 0 → scale 1 的过程从 63 秒降到 1 秒。

确认这个优化可行。会用到生产环境。

10:25 — 完整备份

在真正动手删 gwbridge 前,备份所有 265 个 service 的 spec JSON(2.1MB)。以防万一 raft 数据库损坏,这是唯一的重建 service 的办法。

10:42 — 真正扩容,第一次失败

开始执行删除并重建流程。先删掉旧 gwbridge,再用新网段重建:

docker network rm docker_gwbridge
docker network create \
  --driver bridge \
  --subnet 172.31.0.0/20 \
  --gateway 172.31.0.1 \
  docker_gwbridge

第二条命令报错:"Error response from daemon: Pool overlaps with other one on this address space"

原因很讽刺:在 9:15 分的探索期间,改了 daemon.json 为 172.31.0.0/16 size 22,然后重启了 docker。启动时,docker daemon 会重建系统默认的 bridge 网络。这次它从那个 /16 池子里切了一个 /22 出来——恰好是 172.31.0.0/22。这块子网现在被 bridge 网络占用了。

我想给 gwbridge 分配的 172.31.0.0/20 直接包含了这个 /22,两个网络的地址空间产生了重叠,所以冲突。必须换一个不冲突的网段。

临时决策:换一个网段。探测几个候选 subnet 看哪个能创建:

for SUBNET in 172.31.0.0/22 10.30.0.0/20 192.168.32.0/20; do
  docker network create --subnet "$SUBNET" _test 2>&1 | head -1
  docker network rm _test 2>/dev/null
done

10.30.0.0/20 可用。改用这个。

10:45 — 恢复并重建

删 gwbridge,用 10.30.0.0/20 重建:

docker network rm docker_gwbridge
docker network create \
  --driver bridge \
  --subnet 10.30.0.0/20 \
  --gateway 10.30.0.1 \
  docker_gwbridge

成功。

Swarm 立刻开始自动重新调度那 13 个被 scale 0 的 service。在接下来的 1-2 分钟内,所有 270 个 service 都拿回了 IP。gwbridge 当前用量从 0 涨回 267/4094。

10:55 — 孤儿清理

有 1 个 service 卡住了,一直 0/1。

检查发现,旧的 docker-proxy 进程还在占着 host port 18183。container 已经不存在了,但进程没清。端口被占,新容器绑定不了。

手动 kill 这个 PID,释放端口。Swarm 重试,成功。

11:00 — 数据库对账

DB 里有 13 条 Instance 记录状态仍是 PROVISIONING,实际容器已经在 RUNNING。写个快速脚本,把它们更新到 RUNNING。

同时删了 1 条孤儿 Instance 记录(某用户容器在异常重启时丢了)。

11:05 — 第一次 ffmpeg 灰度,失败

现在网络扩容完毕,可以开始灰度 ffmpeg 了。

用 digest 形式指定镜像:cpa/openclaw-runtime:1.0.18@sha256:fcebbc...

想法是 digest 是 immutable 的,Swarm 可以直接用,不必重新 pull。

实际:Swarm 看到 @sha256:... 这种格式后,认为这是"远程仓库 immutable 引用",强行去 docker.io pull。Pull 失败(网络不稳定),没有 fallback 到本地已有的 image(本地有 1.0.18 tag,但 digest 不同)。Task fatal error。Rollback。

11:08 — 第二次灰度,成功

改用 tag-only 形式:cpa/openclaw-runtime:1.0.18

Swarm 走 pull 流程,失败后自动 fallback 本地同 tag 的 image。成功。

容器启动,ffmpeg -version 输出 5.1.9。实际剪了一个 1 秒黑屏的 mp4 验证。

11:18 — v4 全量发布

网络问题解决后,准备一键发布所有用户的容器升级。提交全部 270 个 service 的 update(1.0.17 → 1.0.18)。用 xargs 并发提交:

cat service-list.txt | xargs -P 10 -I {} docker service update --image cpa/openclaw-runtime:1.0.18 {}

大约 1 秒完成 270 个 update 提交。

然后脚本进入 Phase 4(收敛判断)。检查所有 service 是否都运行了新镜像:

docker service ls --format "{{.Image}}"

1 秒钟后看到全部输出都变成了 1.0.18,脚本误判完成,把之前加的 /etc/hosts 黑洞过早还原。

问题来了:Swarm rolling update 是异步的。docker service ls 看到的是 spec image(声明),不是 task 实际跑的 image(事实)。Spec 在你 update 那一秒就变了,但容器真正切换需要时间——它要先 stop 旧 task,再 pull(或 fallback)新镜像,再 start 新 task。这个过程通常需要数十秒。

结果,脚本还原了 hosts 黑洞,但大批容器正在切换过程中,需要 pull 镜像。此时网络开始响应慢。30 秒的 pull 超时开始积累,导致部分容器切换失败。

11:21 — 紧急救援

检测到 hosts 还原过早,立刻加回黑洞:

echo "127.0.0.1 docker.io" >> /etc/hosts

等真实收敛。看 docker ps --filter "name=svc-" --format "{{.Image}}"(task 层的实际镜像),不是 service ls 的 spec。

11:25 — 真实收敛

270/270 容器全部跑上 1.0.18。

11:26 — 最终验证

抽 3 个用户容器实测 ffmpeg,panel/canvas health check = 200,gwbridge 用量 270/4094 (~6.6%)。

全部完成。

根因:三层问题叠加

表面:容器没装 ffmpeg。改 Dockerfile 就行。

深层 1 — 架构容量瓶颈:系统设计是"1 用户 = 1 容器",所以用户数上限 = 可用 IP 数。gwbridge 是 5 天前 swarm init 时以 docker 默认值 /24 创建的,253 个 IP。现在 251 个用户 + ingress 等系统组件已占满。

深层 2 — 配置陷阱:某人改了 daemon.jsondefault-address-pools,本想扩容。但这个配置只在 swarm init 那一刻被读取一次。已有网络(gwbridge)不会因为修改配置而改变。这是个文档没突出强调的时机陷阱。

深层 3 — 无告警机制:基础设施容量(IP 池、CPU、内存)应该主动巡检。这里 gwbridge 沉默地跑到 100% 满,用户侧只看到"个别容器偶发起不来",很难追溯到网络容量。

修复选择与执行

为什么选"手动删除重建 gwbridge"而不是其他:

  1. 改 daemon.json 已验证无效:现有网络不会因配置变化而改变
  2. 重启 docker 风险太高:所有 270 个容器停机 10+ 分钟
  3. 逐个迁移:工作量巨大,容易出错
  4. 删除重建:只涉及网络对象层面,不影响容器生命周期。Swarm 自动重调度,风险可控

验证阶梯:

  1. Staging dry-run(105 秒完成):确认 service 自动重调度、endpoint 重新分配、容器正常启动
  2. 单用户 canary(发现"释放 IP 被抢"的问题):改成先 scale 0 那 13 个 cycling service,再做扩容
  3. 全量执行(5 分钟):删 gwbridge,用 10.30.0.0/20 重建,自动调度完成

结果:253 → 4094 IP(16 倍扩容),13 个失联用户恢复,270 个 service 全部正常。

影响与防回归

停机影响

  • gwbridge 重建期间 5-10 分钟全集群不可用
  • 灰度期间部分用户约 25 分钟间歇掉线
  • 全量发布期间大批 task 切换,约 7 分钟影响
  • 数据零丢失

防回归措施

目前无已实施方案。Action Items 中有 P1 的"监控告警:gwbridge IP 池 > 70%",尚未部署。

教训

  1. 基础设施容量要主动巡检,不要等爆表。隐性容量上限通常以"偶发故障"的形式暴露,此时已接近 100%。应定期扫描 IP 池用量,设 70% 和 90% 的告警。
  2. 配置生效时机必须验证,不要假设default-address-pools 只在 swarm init 消费一次。类似的时机陷阱在分布式系统很常见(初始化 vs 运行时 vs 重启)。每项基础设施配置要写脚本验证一次:改配置 → 观察系统反映(docker info / ps / 日志),不要假设。
  3. 验证阶梯很重要。Staging dry-run 测试可行性且暴露意外行为(这次的"释放 IP 被抢")。单用户 canary 在生产真实复现,成本低。全量发布时信心最足。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。