我把 compose 的 override 写错,站点停了二十分钟

线上事故记录

先说背景:这台机器上宿主机的 nginx 占着 80 和 443,Docker 里的 gateway 必须让开,绑到 127.0.0.1:8081。仓库里的 docker-compose.yml 写的是 "80:80",所以每次重新上传代码,都要跑一条 sed 把端口改回来。

我想免掉这条重复的 sed,于是建了 docker-compose.override.yml:

services:
  gateway:
    ports:
      - "127.0.0.1:8081:80"

然后 docker compose up -d gateway。

站点直接 502。

原因:ports 是列表,compose 合并 override 时对列表是追加,不是替换。合并结果是两条都在:

ports:
  - mode: ingress
    target: 80
    published: "80"
    protocol: tcp
  - mode: ingress
    host_ip: 127.0.0.1
    target: 80
    published: "8081"
    protocol: tcp

80 已经被宿主机 nginx 占着,绑定失败,容器起不来。而 compose 的执行顺序是"先创建替换后的容器"——旧的那个能服务的容器已经被换掉了。也就是说,失败发生在我已经失去服务之后。这一点是我最没预料到的:我以为 up 失败会保持现状。

更糟的是我原本设了一个检查:

docker compose config | grep '80:80'

没输出,我就判断"合并成功、80 已经不在了"。这个检查从头到尾没有效力——config 的输出是结构化字段(published / target / host_ip),根本不是 "80:80" 这个字符串形式。我 grep 的是一个不存在于输出格式里的写法。

恢复过程里我又犯了第二个错。我把两条命令用 && 串起来:

grep -q '80:80' docker-compose.yml && docker compose up -d gateway

前一条失败(正常,因为文件里是 127.0.0.1:8081),后一条根本没执行,而我盯着屏幕等了两分钟才反应过来。站点多停了一段。

最后救回来的是最朴素的三步,一条一条执行:

rm docker-compose.override.yml
sed -i 's|"80:80"|"127.0.0.1:8081:80"|' docker-compose.yml
docker compose up -d gateway
docker compose ps

之后我试过 ports: !override 这个标签,想让 override 文件替换而不是追加。在那台 compose 2.40.3 上,docker compose config 用这个标签之后一行输出都没有(报错走 stderr,被我管道吞了)。没验证成功的东西我不再往生产上带。

最终方案是把端口变成一个显式变量,不依赖任何合并行为:

ports:
  - "${GATEWAY_PUBLISH:-80:80}"

值写在 .env 里,重新上传代码不会覆盖它。默认值 80:80 保留,独立部署的人不受影响。

这次之后我改了三条习惯:

一、动线上容器的命令,一条一条跑,不用 && 串。串起来的代价是失败会静默跳过后面那步,而你以为它在跑。
二、验证要看结构化输出,不要 grep 一个你想象中的字符串。现在我会明确看 published 和 host_ip 两个字段的值。
三、任何"让 compose 换掉正在跑的容器"的操作,都要先假设它可能起不来。所以要么在低峰做,要么先确认回滚路径(旧镜像还在不在、能不能一条命令换回去)。

相关阅读