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 能挡住大部分问题,但别把它当成唯一保障。原因有三个:

  1. 数据库可能因为主从切换、重启、磁盘满在运行中变成不可用;
  2. 别的编排方式(裸 docker run、K8s、systemd)里根本没有 condition 这种语义;
  3. 健康检查有间隔,永远存在「刚变不健康但还没被检测到」的窗口。

所以引导脚本自己带重试,是最稳的一层:

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;
  • 种子导入必须走事务,否则「部分成功」会被幂等判断永久跳过。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。