depends_on 不等于「准备好了」:健康检查与启动顺序的四个坑
容器都 Up 了,应用还在刷连不上库。depends_on 只保证启动顺序,不保证就绪;补上 healthcheck 后又踩了 mysqladmin ping 假健康、init 脚本污染官方 entrypoint、种子导入没事务三个坑。
docker compose up -d 之后 docker compose ps 里三个容器全是 Up,应用日志却在刷「连不上 MySQL」。
新手的第一反应是加 depends_on,加完发现问题还在。因为 depends_on 只保证「按顺序启动」,不保证「前一个真的能干活」。
这篇把一套能用的启动编排写清楚,顺带记录踩到的三个坑。
一、depends_on 到底做了什么
services:
app:
depends_on:
- db
它做的事只有一件:先启动 db 容器,再启动 app 容器。启动指的是「容器被创建并进入 running」,不是「MySQL 完成了初始化、开始监听、能接受连接」。
MySQL 首次启动要做不少事:初始化数据目录、建系统表、跑 /docker-entrypoint-initdb.d/ 里的脚本、最后才监听 3306。这个窗口通常 20–60 秒,而 app 容器早就在里面跑起来了。
所以更准确的说法是:depends_on 解决的是「顺序」,不是「就绪」。
二、healthcheck 要检查「能不能干活」
Compose 支持给服务定义健康检查,配合 condition: service_healthy 才能表达「等它真的好了」。
services:
db:
image: mysql:8.4
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
app:
depends_on:
db:
condition: service_healthy
几个参数的含义经常被误解:
| 参数 | 作用 | 常见错法 |
|---|---|---|
interval |
多久检查一次 | 设成 1s,白白增加负载 |
timeout |
单次检查超时 | 太短会把「慢」误判成「死」 |
retries |
连续失败几次算不健康 | 设成 1,一次抖动就重启 |
start_period |
启动宽限期,这期间的失败不计数 | 不设,导致首次初始化被判定为不健康 |
start_period 是给首次初始化留的,MySQL 这种要建库建表的服务必须给足(40–60s)。
mysqladmin ping 有个假阳性
mysqladmin ping 只要服务端进程还能响应握手就返回成功,即使它还在初始化、还不接受业务连接,也可能是 0。更严格的写法是查一条真语句:
healthcheck:
test: ["CMD-SHELL", "mysql -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1 || exit 1"]
-e 'SELECT 1' 需要完整的认证与查询链路,能筛掉「端口通了但还不可用」的状态。
三、应用侧仍然要自己等
healthcheck 能挡住大部分问题,但别把它当成唯一保障。原因有三个:
- 数据库可能因为主从切换、重启、磁盘满在运行中变成不可用;
- 别的编排方式(裸
docker run、K8s、systemd)里根本没有condition这种语义; - 健康检查有间隔,永远存在「刚变不健康但还没被检测到」的窗口。
所以引导脚本自己带重试,是最稳的一层:
async function waitForDb(retries = 30, delayMs = 2000) {
for (let i = 1; i <= retries; i++) {
try {
const conn = await mysql.createConnection({ ...DB, connectTimeout: 3000 });
await conn.query('SELECT 1');
await conn.end();
log(`数据库就绪(第 ${i} 次尝试)`);
return;
} catch (e) {
log(`等待数据库… ${e.code || e.message}`);
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw new Error('数据库始终未就绪,放弃启动');
}
配合「所有步骤幂等」,这个脚本可以每次容器启动都跑:
等库 → 跑迁移(记录在 __migrations 表,已跑过的跳过)
→ 写入权限点与角色(不覆盖人工改过的)
→ 用户表为空时建管理员
→ 内容表为空时导入种子
四、事故一:初始化脚本把官方 entrypoint 搞挂了
MySQL 官方镜像会把 /docker-entrypoint-initdb.d/ 里的 .sh source 进当前 shell 执行,不是 bash script.sh 开子进程。
我当时的脚本长这样:
#!/bin/bash
set -euo pipefail # ← 这行是灾难
mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "CREATE DATABASE ..."
set -u 被带进了 MySQL 自己的 entrypoint,随后它引用 MYSQL_ONETIME_PASSWORD(一个默认不存在的变量):
/docker-entrypoint.sh: line 78: MYSQL_ONETIME_PASSWORD: unbound variable
容器直接退出,重启循环。现象特别迷惑:数据目录已经初始化了一半,所以下一次启动不会重新初始化,而是带着不完整的库继续跑。
改法很简单,把整段包进子 shell,变量与 set 都不外泄:
#!/bin/bash
(
set -e
mysql -uroot -p"$MYSQL_ROOT_PASSWORD" <<'SQL'
CREATE DATABASE IF NOT EXISTS umami CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SQL
echo "[init] 完成"
)
五、事故二:种子导入没事务,卡在 7 篇
应用侧导入 67 篇文章,第一版是逐条 INSERT,没有事务。某次启动时进程被 OOM kill 打断,库里停在 7 篇。
而导入逻辑的守卫是:
const [[{ n }]] = await db.query('SELECT COUNT(*) AS n FROM posts');
if (Number(n) > 0) {
log('已有内容,跳过导入');
return;
}
于是之后每次启动都「跳过导入」,剩下 60 篇永远进不来。部分成功的状态 + 一个过于宽松的幂等判断,是这类脚本最典型的坑。
两个修改:
await conn.beginTransaction();
try {
for (const p of posts) { /* 逐条插入 */ }
await conn.commit();
} catch (e) {
await conn.rollback();
throw e;
}
- 要么全进,要么全不进,不会再出现「7 篇」这种中间态;
- 顺带把 67 篇的导入时间从 10 分钟压到 20 秒(单事务比 67 次自动提交快得多)。
另外还要先清掉半成品再验证:把 posts 清空、确认没有残留的 bootstrap 进程在后台跑,再重启容器。
六、一套能用的编排
services:
db:
image: mysql:8.4
restart: always
healthcheck:
test: ["CMD-SHELL", "mysql -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
app:
build: .
restart: always
depends_on:
db:
condition: service_healthy
加上应用侧自带的「等库 + 幂等引导」,三层保护各管一段:
| 层 | 负责 |
|---|---|
depends_on |
先起 db 再起 app |
healthcheck + condition |
等 db 真能执行查询 |
| 引导脚本 | 运行期兜底:重试、幂等、事务 |
小结
depends_on管顺序,不管就绪;要「等好了」必须配healthcheck+condition: service_healthy;mysqladmin ping会假阳性,用mysql -e 'SELECT 1'才准;start_period是给首次初始化留的宽限期,别省;- 引导脚本自己带重试与幂等,是唯一在所有编排环境下都成立的一层;
/docker-entrypoint-initdb.d/*.sh会被source,务必用子 shell 隔离变量与set;- 种子导入必须走事务,否则「部分成功」会被幂等判断永久跳过。
正在加载评论…