上周六晚上十一点,我把手头一个客户的后台系统从单机 Docker 挪到了 Compose 编排,一共五个服务:Nginx 反代、后端 API、PostgreSQL、Redis、还有一个异步任务 worker。配置写好,docker compose up -d 一敲,我以为这事十分钟就结束了,结果应用容器起来四十秒就退出了,日志里一行 ECONNREFUSED 刷了十几遍。查了一圈发现,PostgreSQL 的官方镜像首次启动要初始化数据目录、建系统用户、跑 initdb,这个过程在我这台机器上花了将近一分钟;而我的 API 容器八秒就已经跑完启动脚本开始连库了。两边撞车,应用直接被重启策略拉起来又崩,循环了大概二十次才慢慢缓过来。
如果你手上也是一台机器跑好几个服务、正准备从 docker run 迁到 Compose,可以先看看这两家的机器,配置和磁盘 IO 稳一点,排查问题的时间能省一半:西西云服务器(弹性配置,按需扩缩)、狗云DOGYUN(高性价比云服务器,多机房可选)。Compose 这类编排真正吃资源的地方不在容器本身,而在镜像拉取和数据库初始化那几秒的 IO 等待,机器的磁盘和带宽稳一点,启动顺序问题才暴露得出来,不然你分不清是配置错了还是机器慢。

一、depends_on 有三种写法,语义完全不是一回事
很多人对 depends_on 的印象停留在「写了就会按顺序来」,但 Compose 里这个字段有三个层级,行为差别很大。
第一种是列表形式,也就是最常见的写法:
depends_on:
- db
- redis
这种写法只保证一件事:Compose 会先创建 db 和 redis 这两个容器,再创建你的应用容器。注意是「创建」,不是「启动」,更不是「能连上」。而且这里的顺序指的是容器被创建出来的顺序,容器进程实际什么时候开始跑、跑起来之后要多久才真的能接受连接,Compose 一概不管。
第二种是长语法加上 condition: service_started:
depends_on:
db:
condition: service_started
这一种比列表形式强一点点,它会等依赖容器的进程真的启动起来才算完。但注意,容器进程启动起来的那一刻,数据库通常还停在「正在初始化」状态。PostgreSQL 官方镜像的入口脚本第一步是 mkdir 数据目录,第二步是判断是否首次初始化,第三个才是 exec postgres 真正拉起进程。所以 service_started 判定完成的时刻,postmaster 进程可能刚 fork 起来,端口刚 listen,第一次 TCP 连接会成功,但立刻被服务端拒绝因为 initdb 还没做完,系统用户和共享内存区都还没准备好。这个窗口在快机器上三五秒,慢一点能到一分钟。
第三种是 condition: service_healthy,这个才是我们真正需要的:
depends_on:
db:
condition: service_healthy
restart: true
redis:
condition: service_healthy
restart: true
但 service_healthy 有个前提:依赖的那个服务必须自己定义 healthcheck。没定义 healthcheck 的时候,Compose 会直接报错说这个依赖条件无法满足,而不是退回去按 service_started 处理。这个设计我觉得挺合理,宁可报错也别给你一个假的安全感。

二、healthcheck 到底该怎么写
healthcheck 的核心是 command 这个字段里那条命令,必须在容器内部能执行,而且返回码 0 表示健康、非 0 表示不健康。写 healthcheck 有几个容易踩的点。
第一个坑是用 curl -f http://localhost:8080/health 这种应用自身的接口。对我的 API 服务来说这个做法是对的,因为它顺带验证了应用真的能处理请求。但如果你给数据库写 healthcheck 时去 curl 一个不存在的 HTTP 端口,那永远不会健康,Compose 会一直等下去,你会看到日志里一直刷「waiting for dependency to start」,容器却始终不创建,现象比刚才的连不上还难查。
第二个坑是命令路径不对。Alpine 系的镜像默认没有 curl,也没有 wget(BusyBox 的 wget 倒是有的),很多人在 Debian 基础镜像上照抄了 curl 命令,结果报 command not found。解决办法是用镜像自带的东西:PostgreSQL 镜像自带 pg_isready,这个是最省事的选择:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 3s
retries: 12
start_period: 30s
这里我想重点说说 start_period 这个参数,它是我这次被坑了两周的核心。start_period 的语义是:在 start_period 这段时间内,健康检查的失败结果不计入 unhealthy 状态的判定,也就是给容器一个「启动宽限期」。数据库首次初始化要一分多钟,而 pg_isready 在这期间是一直失败的。如果不设 start_period,按照 retries: 12 × interval: 5s 的节奏,60 秒后容器就会被标记成 unhealthy,依赖它的应用容器永远等不到健康状态,Compose 直接放弃启动那一串服务。设了 start_period: 30s 之后,前 30 秒的失败被忽略,后面再看真实状态。如果你的数据库数据目录特别大、首次初始化要更久,把 start_period 往上调到 60s、90s 都合理。
第三个坑是 timeout 比 interval 小的情况。默认 timeout 是 30s、interval 是 30s,也就是上一轮检查还没结束下一轮就开始了,容器在高负载下会出现检查堆积,health 状态一直卡在 starting。把 timeout 压到 3s、5s 比 interval 短,语义上更干净。
三、healthcheck 也不是银弹,我最后加了一层启动脚本兜底
即便把上面这些都配对了,还是有 healthcheck 覆盖不到的场景。
我遇到的实际问题是:我的应用启动脚本里连了数据库,脚本本身跑完就退出,容器靠 restart: on-failure 兜底重启。这时候即便数据库健康了,应用在「等数据库」的那几秒里已经崩过一次,虽然最终会恢复,但日志里留下一堆吓人的 stack trace,而且健康检查的应用接口在启动期间一直不通过,整个服务的启动时间被拉长。
我最终的解法是给应用加一个 wait-for-it 风格的等待循环,放在 entrypoint 里,数据库真正可连之后才执行真正的启动命令:
#!/bin/sh
set -e
until nc -z db 5432 2>/dev/null; do
echo "waiting for postgres..."
sleep 2
done
# 多等几秒,让 pg_hba 和共享内存准备就绪
sleep 3
exec ./start-api.sh
这里的 nc -z 只是测试 TCP 端口通不通,不做真正的认证,所以第一次通的时候数据库可能还在做最后的初始化。我额外加了 3 秒固定等待,这个数字是拍出来的经验值,不优雅但有效。如果你想要更严谨的判断,可以直接用 pg_isready:
until PGPASSWORD=$PGPASSWORD pg_isready -h db -U appuser -d appdb; do
sleep 2
done
不过 pg_isready 这个工具在应用镜像里通常没有,得额外装 postgresql-client,会让镜像变大十几兆。我权衡之后选择了 nc 加固定延迟。另一个选择是用 Compose 新版的 service_completed_successfully 条件,让一个专门的 init 容器跑完迁移脚本之后,应用容器才启动。这个方案更干净,缺点是迁移脚本失败会卡死整条链,得配合超时和告警。

四、怎么确认这套配置真的生效了
光看容器跑起来了不算数。我用三个办法验证。
第一个是看依赖状态。docker compose ps 会多出一列 STATUS,healthy 的容器显示 (healthy),等待中的显示 (health: starting)。在依赖条件满足之前,应用那一行会显示 (service db is starting) 之类的状态,一目了然。
第二个是看健康检查的耗时统计。docker inspect --format='{{json .State.Health}}' 容器名 能看到 Log 数组,里面每条记录都有 Start、End、ExitCode、Output。前几次失败的 Output 写着 pg_isready 输出「server is starting up」,等到某一次 ExitCode 变成 0,healthy 就成立了。我第一次配好 healthcheck 的时候,就是靠这段日志确认等待时间刚好卡在数据库初始化完成的边界上。
第三个是重启整台机器做冷启动演练。docker compose up 成功不代表重启后还能起来,因为容器之间有依赖残留的时候,冷启动顺序才是真实场景。我把机器重启了三次,每次都记录从 docker compose up 到 API 接口可用花了多久:第一次 96 秒,第二次 51 秒,第三次 48 秒。波动主要来自镜像层是否命中本地缓存。有一段时间我误以为是配置有问题,后来发现是其中一台机器的 overlay2 缓存被清了。
五、我沉淀下来的最终配置模板
综合上面这些,我现在的模板长这样,五个服务里有三个依赖数据库,Redis 也是被依赖方:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 20
start_period: 60s
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
build: ./api
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
entrypoint: ["/entrypoint.sh"]
几个关键取舍说明一下。Postgres 的 start_period 给到 60 秒,是因为我这个数据目录首次初始化接近 40 秒,留了余量。Redis 用 CMD 数组形式而不是 CMD-SHELL,redis-cli ping 在 Alpine 镜像里天然存在,不需要装额外东西,而且数组形式不经过 shell,行为更可预测。API 的 healthcheck 用 curl,前提是镜像里装了 curl;如果基于 Alpine 的 slim 镜像,得先 apk add --no-cache curl,这一条我一开始忘了加,结果 health 一直是 unhealthy,排查花了不少时间。
另外提醒一句,restart: unless-stopped 和 depends_on 的 condition: service_healthy 组合起来有个细节:如果应用因为别的原因反复崩溃重启,Compose 不会重新评估依赖的健康状态,它只管启动顺序。所以别指望这个组合能自动修复「数据库后来挂了」的情况,那种场景还是要靠应用自身的重连逻辑。

六、写在最后
这次前后折腾两周,主要时间不是花在写配置上,而是花在「等」上等数据库初始化、等健康检查通过、等日志刷够行数去判断。这里面有相当一部分是我一开始配置写错造成的:没设 start_period 硬等了一分钟、curl 在 slim 镜像里不存在、忘了把 depends_on 升级成 service_healthy。每一个坑单看文档都不难,写代码时却很容易滑过去,因为它们的表现都是「服务起不来」,但根因完全不一样。
如果只让我留一条经验:写完 Compose 之后,一定做一次真正的冷启动演练,把机器重启一遍看能不能自己起来。docker compose up 成功只是证明配置在当前状态下没问题,不能证明它在半夜没人管的时候也能起来。而在自动化部署的环境里,冷启动才是常态。
另外把依赖关系画出来贴墙上这件事,我低估了它的作用。我那五个服务的依赖图是手画的,贴出来以后一眼就能看出 worker 其实不需要等 API 启动原来我给它加了个没必要的 depends_on,白白多了 20 秒启动时间。这种依赖关系的冗余,靠读配置是读不出来的,得看图。


喜欢
顶
难过
囧
围观
无聊



