Docker Compose 部署多服务时,依赖启动顺序乱了怎么控制

Docker Compose 跑多服务时,经常会遇到一个现象:明明在 docker-compose.yml 里写了 depends_on,结果应用容器还是报 MySQL connection refused、Redis timeout、Kafka broker not available。看日志会发现,数据库容器确实先启动了,但业务服务启动得更快,连上去的时候数据库还没 ready。

这个问题本质上不是 Docker Compose “没按顺序启动”,而是很多人把“容器启动”和“服务可用”混在一起了。容器进程起来,不代表 MySQL 已经完成初始化,不代表 PostgreSQL 已经能接连接,也不代表 Elasticsearch 集群状态已经可用。

depends_on 只能解决一部分问题

在 Docker Compose 里,depends_on 最常见的写法是这样:

services:
  app:
    image: my-app:latest
    depends_on:
      - mysql
  mysql:
    image: mysql:8.0

这个配置的效果是:Compose 会先启动 mysql 容器,再启动 app 容器。注意,只是“先启动容器”,不是“等 MySQL 能正常处理 SQL 之后再启动 app”。

实际使用中发现,MySQL、PostgreSQL、Redis 这种服务,在本地开发环境可能问题不明显,因为机器快、数据少。但到了云服务器上,尤其是第一次初始化数据目录、挂载远程盘、加载大量数据时,服务 ready 时间可能会从几秒变成几十秒。业务容器一上来就连数据库,失败是很正常的。

Compose 版本差异也要注意

老一些的 Compose 文件里经常看到这种写法:

depends_on:
  mysql:
    condition: service_healthy

这个写法在 Docker Compose V2 里是可用的,但很多旧文档、旧项目、不同 compose file version 的行为容易让人混淆。现在更建议直接使用当前 Docker Compose 插件,也就是 docker compose,不要再混用老的 docker-compose 二进制版本。

可以用这个命令看版本:

docker compose version

如果线上机器是多年没维护的环境,建议先确认 Compose 版本。很多启动顺序问题,排查半天,最后发现是部署机上的 Compose 行为和本地不一致。

用 healthcheck 判断服务是否真的 ready

控制启动顺序时,比较稳的做法是给被依赖服务加 healthcheck,然后让业务服务依赖它的 healthy 状态。

以 MySQL 为例:

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: appdb
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 30s

  app:
    image: my-app:latest
    depends_on:
      mysql:
        condition: service_healthy

这里重点看 healthcheck。mysqladmin ping 成功后,mysql 容器才会被标记为 healthy。app 配了 condition: service_healthy,Compose 会等 mysql healthy 后再启动 app。

interval 是检查间隔,timeout 是单次检查超时时间,retries 是失败重试次数,start_period 是容器刚启动时的宽限时间。MySQL 第一次初始化可能比较慢,start_period 不要给太短,不然健康检查还没等服务起来就开始判失败。

Redis 的 healthcheck 写法

Redis 更简单,可以用 redis-cli ping

services:
  redis:
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 2s
      retries: 10

如果 Redis 开了密码,就不能直接 ping,需要加 auth:

test: ["CMD", "redis-cli", "-a", "your_password", "ping"]

多说一句,healthcheck 里不要写太重的检查逻辑。比如每次都跑复杂 SQL、查一堆业务表,这种会把健康检查变成负担。健康检查只需要判断服务进程、端口、基础认证、最小可用能力。

depends_on 不是服务异常后的自动编排工具

这里补充一点,很多人以为 app 依赖 mysql healthy 后,后面 mysql 如果挂了,Compose 会自动把 app 停掉或按顺序重启。这个理解不对。

depends_on 主要影响创建和启动阶段。服务运行过程中,某个依赖挂了,Compose 不会像 Kubernetes 那样做复杂的控制器编排。比如 MySQL 运行 10 分钟后崩了,app 容器通常还是继续跑,只是应用日志里会开始报数据库连接错误。

所以线上服务不能只靠 Compose 的启动顺序,业务应用自身也要有重连机制。数据库连接池、Redis client、MQ client 都应该能处理短暂断连。容器只是部署形态,应用的容错能力不能省。

业务容器里加等待脚本,适合兼容老项目

有些项目环境比较旧,Compose 版本不方便升级,或者依赖的服务没有合适的 healthcheck 命令。这种情况下,可以在业务容器启动前加 wait 脚本。

常见做法是用 wait-for-it.shdockerizenc 这类工具等待端口可连接。

例如 app 启动前等 MySQL 3306 端口:

services:
  app:
    image: my-app:latest
    command: ["./wait-for-it.sh", "mysql:3306", "--", "java", "-jar", "app.jar"]
    depends_on:
      - mysql

这种方式的好处是简单,哪怕 Compose 不支持 service_healthy 也能用。缺点也明显:等端口通,不等于服务完全可用。MySQL 端口起来了,但权限、初始化 SQL、schema migration 还没跑完,业务仍然可能失败。

如果只是开发环境,这种方式通常够用。生产环境建议还是 healthcheck + 应用重试一起上。

不要把数据库迁移和应用启动绑得太死

多服务部署里还有一个常见坑:app 启动时自动执行数据库迁移,比如 Flyway、Liquibase、Prisma migrate、Django migration。单实例时还好,多个 app 副本同时启动时,很容易出现迁移锁冲突,甚至重复执行。

在 Compose 场景里,可以把 migration 拆成单独服务:

services:
  migrate:
    image: my-app:latest
    command: ["java", "-jar", "app.jar", "migrate"]
    depends_on:
      mysql:
        condition: service_healthy

  app:
    image: my-app:latest
    command: ["java", "-jar", "app.jar"]
    depends_on:
      migrate:
        condition: service_completed_successfully

service_completed_successfully 适合一次性任务,比如数据库迁移、初始化索引、生成配置文件。migrate 服务成功退出后,再启动 app。

这里要注意,迁移失败时不要让 app 硬启动。否则应用跑起来后缺表、缺字段,错误更隐蔽。

restart 策略要配,但别指望它解决依赖关系

Compose 里常见 restart 策略有:

restart: "no":不自动重启,默认行为。

restart: always:容器退出就重启,Docker daemon 重启后也会拉起来。

restart: unless-stopped:手动停止后不再自动启动,比较适合长期运行服务。

restart: on-failure:非 0 退出码时重启,适合任务型进程。

实际线上用得比较多的是:

restart: unless-stopped

但 restart 只是让容器失败后再起来,它不理解“我要等 MySQL ready 后再重启 app”。如果 app 因为数据库没连上直接退出,restart 会让它不断重启,直到数据库可用。这个行为有时能救场,但日志会刷得很厉害,而且启动时序不可控。

更稳的做法是:依赖服务用 healthcheck,业务服务内部做重试,容器层用 restart 兜底。

实际排查时看这几个状态就够用了

看容器启动情况:

docker compose ps

如果配置了 healthcheck,会看到类似 healthystartingunhealthy 的状态。

看某个服务日志:

docker compose logs -f mysql

看业务服务是否在数据库 ready 前就启动:

docker compose logs -f app

看容器健康检查细节:

docker inspect 容器名

里面的 State.Health 会记录最近几次健康检查的输出。这个信息很有用,尤其是 healthcheck 命令写错、镜像里没有对应 client 工具、密码变量没传进去时,单看 Compose 日志不一定明显。

多服务放到云服务器上,还要考虑机器性能和网络

本地 Compose 一切正常,上云后启动顺序问题变多,除了配置原因,也可能是资源原因。CPU 被打满、磁盘 IO 慢、内存不足触发 OOM,都会导致数据库 ready 时间变长。MySQL 初始化 5 秒和 60 秒,对业务容器来说是完全不同的启动环境。

如果是游戏服、企业业务后台、海外访问服务这类场景,部署 Compose 时别只看容器配置,也要看宿主机线路、带宽、防护和磁盘性能。如果你也在找这种云服务器或高防服务器,可以看看129云,他们有高性能云服务器、G口大带宽服务器、高防服务器租用和海外云计算方案,客服热线 400-9177118。

比如面向东南亚用户的小型业务,可以考虑马来西亚-E型,16C CPU、16G DDR4 ECC、120G SSD、70Mbps 峰值带宽,三网优化,适合跑 Web、API、Redis、MySQL 这类中小规模 Compose 栈。

如果是欧洲方向轻量业务,德国双ISP-B型有 1Gbps 带宽、GTT直连、双ISP,适合对海外访问质量比较敏感的站点。

如果业务容易被 DDoS 打,比如游戏、活动页、登录网关、接口网关,十堰高防-A型的 600Gbps 单机防御更适合放入口服务,再把内部服务通过安全组或内网隔离起来。

生产环境里更推荐的 Compose 写法

下面这个结构在中小型项目里比较常见,MySQL、Redis、app 都有明确关系,启动时不乱,失败后也有基本兜底。

services:
  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: appdb
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 30s

  redis:
    image: redis:7
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 2s
      retries: 10

  app:
    image: my-app:latest
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis

volumes:
  mysql_data:

这个配置解决的是“启动时序”和“基础可用判断”。但业务代码里仍然要处理连接失败、连接池重建、请求超时。尤其是 Java、Go、Node.js 应用,数据库连接池初始化策略不同,有的启动时必须连上数据库,有的第一次请求才建连接。部署前要知道自己应用是哪种行为。

healthcheck 经常踩的坑

镜像里没有检查命令

比如想用 curl 检查 HTTP 服务,但镜像是 alpine 精简版,里面根本没有 curl。健康检查会一直失败。

可以换成 wget,或者在镜像里安装 curl,也可以用应用自带的轻量检查命令。

localhost 和服务名不要混用

在 mysql 容器内部检查自己,用 localhost 可以。app 连接 mysql,要用 Compose service name,也就是 mysql。很多配置把 app 的 DB_HOST 写成 localhost,结果 app 容器会去连自己容器里的 3306,自然连不上。

健康检查通过,不代表业务完全正常

HTTP 服务返回 200,只能说明进程和接口还活着。如果接口内部依赖数据库、缓存、对象存储,也要看检查接口怎么写。生产环境里常见做法是提供 /healthz/readyz,一个看进程存活,一个看依赖是否 ready。

start_period 太短

数据库第一次启动、Elasticsearch 恢复索引、MinIO 扫描数据目录,都可能比较慢。start_period 给 5 秒,很多时候不够。可以根据实际日志调整到 30 秒、60 秒,甚至更长。

什么时候该考虑 Kubernetes

Compose 很适合单机、多容器、小团队部署,也适合开发测试环境。服务数量不多、依赖关系清晰、流量规模可控,用 Compose 没问题。

但如果已经出现这些情况:服务副本很多、需要滚动发布、自动扩缩容、跨主机调度、服务发现、配置中心、密钥管理、灰度发布,那 Compose 会越来越吃力。这时 Kubernetes 的 readinessProbe、livenessProbe、initContainers、Job、Deployment 会更适合。

不过别为了“看起来高级”就上 Kubernetes。很多企业内部系统、管理后台、轻量 SaaS,用 Compose 部署在稳定的云服务器上,配好 healthcheck、restart、备份、监控,维护成本反而更低。

一段可直接改的 app 等待逻辑

如果应用本身能改,建议在启动阶段加重试,而不是数据库连不上就直接退出。伪代码大概是这样:

maxRetry = 30
for i in range(maxRetry):
  try:
    connectDatabase()
    connectRedis()
    break
  except Exception as e:
    sleep(2)
else:
  exit(1)

startHttpServer()

这个思路很朴素,但线上很管用。Compose 负责让依赖服务尽量先 ready,应用自己负责面对真实世界里的短暂不可用。云服务器重启、Docker daemon 重启、数据库慢启动、网络抖动,都能靠这层重试减少故障窗口。

如果是 Spring Boot,可以关注 HikariCP 的连接池初始化参数;如果是 Node.js,可以在 ORM 初始化外面包重试;如果是 Go,通常在启动依赖检查里循环 ping DB。不要让应用在依赖服务晚几秒 ready 时直接崩掉。

部署时可以按这个顺序操作

先给数据库、缓存、MQ 这类基础服务加 healthcheck。

再把 app 的 depends_on 改成 condition: service_healthy

如果有 migration,把它拆成一次性服务,并用 service_completed_successfully 控制 app 启动。

然后给长期运行服务配置 restart: unless-stopped

最后看 docker compose psdocker compose logs -f,确认 app 日志里不再出现启动阶段的 connection refused。

Compose 文件改完后,可以用下面命令重新创建服务:

docker compose up -d --force-recreate

如果只想看配置是否解析正常:

docker compose config

这个命令会把最终生效的 Compose 配置展开,排查缩进、变量替换、depends_on 写法时很方便。