ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Docker Compose 依赖等待陷阱:depends_on 不等于服务就绪,healthcheck 才是正解

Docker Compose 依赖等待陷阱:depends_on 不等于服务就绪,healthcheck 才是正解 这一篇是容器编排实战系列的第 21 篇。先讲一个我真实处理过的场景有朋友用 docker compose 部署 Nacos 3.xcompose 文件里明明写了depends_on: - mysql结果 Nacos 容器还是反复启动失败日志里刷屏的是一句句“数据库连接失败”。他第一反应是“depends_on 是不是坏了”跟 Docker 没关系。其实不是坏了是depends_on被理解错了。90% 的新手包括当年的我都以为depends_on能做“MySQL 完全就绪之后再启动 Nacos”这种控制。但它真实做到的只是“先启动 MySQL 这个容器”然后 Nacos 容器紧接着就启动。容器起来了和容器里的服务能干活了是两码事。这篇文章我打算把depends_on的行为边界讲透再把真正能解决问题的写法一步步给你。1. 现场复现一个“看起来完全正确”的 compose 文件为什么失败1.1 典型配置文件先看一份当时朋友写的 compose 文件这也是网上最常见的模板之一services: mysql: image: mysql:8.0 container_name: nacos-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: nacos ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql nacos: image: nacos/nacos-server:v3.0.0 container_name: nacos-server depends_on: - mysql environment: MODE: standalone SPRING_DATASOURCE_PLATFORM: mysql MYSQL_SERVICE_HOST: mysql MYSQL_SERVICE_PORT: 3306 MYSQL_SERVICE_DB_NAME: nacos MYSQL_SERVICE_USER: root MYSQL_SERVICE_PASSWORD: root123 ports: - 8848:8848 volumes: mysql-data:单看配置逻辑很顺MySQL 先行Nacos 紧随其后。评论区也一定会有人说“我这么写怎么没问题”。问题恰恰就出在这个“看起来没问题”上。1.2 日志里的真相执行docker compose up -d之后我先看容器状态docker compose ps结果往往是这样NAME IMAGE STATUS PORTS nacos-mysql mysql:8.0 Up 35 seconds 0.0.0.0:3306-3306/tcp nacos-server nacos/nacos-server:v3.0.0 Restarting (1) 12 seconds ago 0.0.0.0:8848-8848/tcpNacos 容器已经进入Restarting状态。再看日志docker compose logs -f nacos你会看到类似这样的报错Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.Nacos 启动时它内部的数据库连接池会立刻去连mysql:3306但这个时刻 MySQL 容器虽然处于Up它内部的服务端可能还没准备好接受客户端连接。连接一失败Nacos 的主线程直接抛异常退出容器随之退出。你可能会说那不是有depends_on吗MySQL 先启动Nacos 再启动怎么还会连不上关键在于Compose 判断“可以启动下一个服务”的时机是上一个服务容器进入了 running 状态而不是服务进程已经完全可用。1.3 理解差异容器启动完成 ≠ 服务就绪Docker 把“容器状态”分成几个阶段created、running、restarting、exited。docker compose up启动服务时只要容器状态从created变成runningCompose 就认为这个服务“启动完成”了接着马上去启动下一个服务。但running只代表“容器主进程被拉起来了”。你在服务器上启动一个 Nginxnginx进程存在不代表它就是可用的万一它配置错误立刻退出running也会很快变成exited。容器化场景更复杂因为容器里往往只有一个主进程在跑而这个主进程启动时要做的事情可能远比预期的多。用日常的事打个比方depends_on保证的是“第一辆车先发车”它不保证“第一辆车到站之前第二辆车不可以发车”。在发车顺序这件事上它是靠谱的但你要的是“到了站再换乘”的效果它确实管不着。这个错位的认知就是 90% 新手在depends_on上踩坑的根源。下面我会把它的真实职责拆开讲清楚。2. depends_on 真正做的事启动顺序、停止顺序、重建顺序2.1 启动时谁先被创建、谁先被启动depends_on的第一个职责是解决容器的创建与启动顺序。当你执行docker compose up -dCompose 会解析服务之间的依赖关系图得到一个拓扑排序。被依赖的服务会先被创建然后先被启动。比如nacos依赖mysqlCompose 执行时就是先create mysql、start mysql再create nacos、start nacos。如果你有多个服务依赖关系是链式的比如app依赖redisredis又依赖某种基础配置服务Compose 也会按顺序处理这整条链。但这里有个细节很多人没注意如果两个服务同时被一个服务依赖Compose 会尽可能并行启动它们不是严格串行。例如services: web: depends_on: - mysql - redismysql和redis之间没有依赖关系Compose 会同时开始启动这两个容器等它们都进入 running 状态后再启动web。如果你以为web一定会等mysql完全就绪、redis完全就绪那又是一个错误预期。2.2 停止时逆序停机depends_on不止影响启动也影响停止行为。默认情况下docker compose down或docker compose stop会按照依赖顺序的逆向来停止容器。比如nacos依赖mysql停机时会先停nacos再停mysql。这个设计是有道理的依赖方先退出被依赖方再退出避免被依赖方停止时依赖方还在持续写数据或者报错。这个行为经常被新手忽略但实际排查问题时会遇到。我曾经见过一个场景有人手动用docker stop直接把 MySQL 停了结果 Nacos 容器开始疯狂报错、重启。这不是depends_on没生效而是因为手动操作绕过了 Compose 的停机顺序控制。2.3 它不会做的事情清单我把depends_on不会做的事明确列一下方便你对照排查不会等待服务真正可用。它只等容器状态变成 running。不会自动配置网络访问。服务之间能不能用服务名互通是networks决定的不是depends_on决定的。哪怕不写depends_on只要两个服务在同一个默认网络里同样可以用服务名访问写了depends_on也不代表网络就打通了。不会给应用注入连接参数。depends_on不会把被依赖服务的地址、端口、密码自动填进依赖方的配置里。连接信息还是要靠环境变量、配置中心或者别的方式传进去。不会代替应用层重试逻辑。即使depends_on让依赖方晚启动应用仍然可能因为被依赖方还没就绪而连接失败这种情况需要应用自身有重连机制。之所以强调这份清单是因为很多人把depends_on当成了“全能依赖管理工具”。它只是一个声明式依赖关系的原语只能控制容器生命周期层面的顺序跟服务健康状态没有任何关系。3. 为什么“先启动”解决不了“先就绪”剖析几类常见服务的初始化过程理解了depends_on的边界后下一个问题是为什么一个服务就算容器进入了 running服务本身还是不可用这要回到各类服务的初始化流程上看。3.1 MySQL 8 的初始化流程MySQL 官方镜像的 entrypoint 脚本做的事情远比你想的复杂。第一次启动时它会经历大致这样的流程初始化数据目录生成mysql系统库。启动一个临时 mysqld 实例。创建 root 用户设置密码。执行/docker-entrypoint-initdb.d/目录下的初始化脚本创建 MYSQL_DATABASE 指定的库。关闭临时实例。以正式方式启动 mysqld开始监听 3306 端口。也就是说容器状态变为 running 的时候mysqld 可能还在做初始化或者可能还没开始监听端口。甚至某些阶段它已经在监听了但还没来得及创建nacos这个库。所以如果你用depends_on只等“容器 running”Nacos 启动后连上来碰到的情况基本就是两个要么连接被拒绝要么数据库还不存在。前者报 Communications link failure后者报Unknown database nacos。3.2 Nacos 3.x 的启动依赖Nacos 3.x 配置了外部 MySQL 作为存储时启动阶段必须要能连上数据库它要去执行建表、校验版本、初始化系统配置这些动作。一旦数据库连接失败Nacos 不会安静地在后台等着它直接抛异常退出进程。这与很多“客户端型”应用不一样。Nacos 角色更像“服务端数据消费者”它启动阶段对数据库的依赖是硬性的没有“先起来再说等会儿重试”这种兜底设计。当然你可以用它的参数去开启一些重试配置但在 compose 层面给一个错误的启动顺序就是白折腾。这就是为什么“MySQL Nacos 3.x”组合最容易踩depends_on的坑两边启动链路都不短依赖又是硬性的没有缓冲余地。3.3 Redis、Elasticsearch 等服务的“启动成功”判定Redis 的一个特点是启动非常快。它有自己的快照加载、AOF 重放流程但在常规小数据集下几秒钟内就能从容器 running 进入“可接受客户端命令”的状态。所以depends_on对付 Redis 时经常表现良好因为两个时间窗口的重叠度很高。但这不意味着depends_on就一定能用在所有红线场景里。Elasticsearch 就要慢得多。它启动时要创建线程池、加载分片、甚至做集群发现。如果配置了多节点还要等待 discovery 完成。单节点 ES 也可能需要几十秒才能真正接受请求。你在 compose 里写depends_on让 Kibana 等 ESES 容器明明 running 了Kibana 却可能连不上http://elasticsearch:9200。换句话说一个工具能不能“碰巧”等对取决于服务启动时序的差异程度。Redis 这种快服务碰巧可以MySQL、ES 这种慢服务就暴露问题。3.4 三类典型“依赖陷阱”模式根据这些经验我归纳出三类典型的依赖陷阱模式典型场景为什么 depends_on 救不了初始化型MySQL、PostgreSQL 首次建库容器 running 时数据库可能还没建完硬依赖型Nacos、配置中心连不上就退出应用启动阶段没有容错重试慢服务型Elasticsearch、集群类中间件启动流程长running 后仍长时间不可用针对这些模式下面两个解法才是真正能落地的手段。4. 解法一healthcheck condition让 Compose 等到“真正健康”4.1 healthcheck 配置项逐字拆解Compose 支持给服务配置健康检查语法如下services: mysql: image: mysql:8.0 healthcheck: test: [CMD-SHELL, mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent] interval: 5s timeout: 3s retries: 20 start_period: 30s字段含义如下test容器内执行的探测命令退出码为 0 表示健康非 0 表示不健康。interval每两次探测之间的间隔。timeout单次探测命令的超时时间。retries连续失败多少次后容器状态被标记为 unhealthy。start_period启动宽限期。这个期间内探测失败不计入 retries避免因为初始化慢而误判。我习惯把这个机制理解为“给容器装一个心跳仪”容器本身不会说话健康检查让 Docker 能持续知道“进程活着”和“服务可用”的区别。这里有一个非常容易踩的细节如果你在test里直接写$MYSQL_ROOT_PASSWORDCompose 会在宿主机层面做变量替换把$MYSQL_ROOT_PASSWORD当成宿主机环境变量来解析结果大概率是空字符串。必须写成$$MYSQL_ROOT_PASSWORD先让 Compose 把它转义成容器内的$MYSQL_ROOT_PASSWORD进到容器里再由 shell 读取。4.2 实战配置给 MySQL 加健康检查让 Nacos 等它现在把 1.1 节的配置改造成真正可用的版本services: mysql: image: mysql:8.0 container_name: nacos-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: nacos ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql healthcheck: test: [CMD-SHELL, mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent] interval: 5s timeout: 3s retries: 20 start_period: 30s nacos: image: nacos/nacos-server:v3.0.0 container_name: nacos-server depends_on: mysql: condition: service_healthy environment: MODE: standalone SPRING_DATASOURCE_PLATFORM: mysql MYSQL_SERVICE_HOST: mysql MYSQL_SERVICE_PORT: 3306 MYSQL_SERVICE_DB_NAME: nacos MYSQL_SERVICE_USER: root MYSQL_SERVICE_PASSWORD: root123 ports: - 8848:8848 volumes: mysql-data:关键变化在depends_on部分。condition: service_healthy表示只有当 MySQL 容器健康检查通过后Compose 才会启动 Nacos。启动后你可以用下面命令观察docker compose ps正常情况下你会看到 MySQL 先进入healthy状态之后 Nacos 才会开始创建和启动。这种情况下的启动顺序才是你在需求文档里真正想写的“顺序”。顺便说明白condition的三个取值condition含义等待时机service_started依赖服务容器进入 running默认行为等价于不写 conditionservice_healthy依赖服务健康检查通过必须配置 healthcheck否则永远等不到 healthyservice_completed_successfully依赖服务容器正常退出且退出码为 0用于一次性任务类容器service_completed_successfully这个用得少但很实用。比如你要先跑一个数据库迁移的 job迁移容器执行完退出再启动应用服务就可以用这个条件。4.3 start_period 与重试次数的工程取值很多人会把 healthcheck 参数照抄但实际场景不同取值差别很大。对于 MySQL 首次初始化体积稍大一点的初始化脚本可能跑 20 到 40 秒。如果interval设置成 5 秒、retries设置成 3 次可能容器还没初始化完就已经被判定为 unhealthy 了。我的一般做法是start_period给到30s或60s把首次初始化的宽限期覆盖掉。interval用5s不要用太短的 1 秒避免占用过多资源。retries给到10到30。按 interval 5 秒算20 次重试意味着健康检查机制会容忍约 100 秒的不健康窗口。举个例子如果start_period: 30s、interval: 5s、retries: 20极端情况下从容器启动到被判定 unhealthy大约要 30 秒加 100 秒也就是 130 秒。这个窗口足够覆盖绝大多数服务的初始化过程。有一个反向的坑某些服务初始化失败后容器并不会退出会一直处于 running但健康检查始终不通过于是依赖它的服务也一直不被启动。进而是整体docker compose up卡住。这种时候别慌先看docker compose ps找哪个服务一直没变成healthy再用docker compose logs去看探测失败的细节。4.4 版本兼容性docker-compose 与 docker compose 的差别如果你在旧环境里用 Python 版的docker-compose可能会遇到Unsupported config option for services.nacos: condition之类的报错或者干脆静默忽略 condition导致依赖方还是没等到健康检查通过。新版 Compose 使用 Go 实现的插件命令是docker compose中间有空格。下划线版的docker-compose已经是老一代实现虽然新版 V1 也能解析 condition但版本差异带来的坑太多了。我的建议是直接使用 Docker Desktop 或 Docker Engine 内置的docker compose插件。如果确实只能用老版本先确认版本在 1.27 以上并且测试一下 condition 是否真的生效比如故意让 MySQL 健康检查失败看 Nacos 是否还会启动。如果 Nacos 照样启动说明 condition 被忽略了。还有一个 Compose V2 的实用命令docker compose up -d --wait这个命令会让up一直等到服务满足依赖条件后才返回。如果服务配置了 healthcheck它会等 healthy如果没配置它只等容器 running。所以--wait也不是银弹它依赖你提前把 healthcheck 配好。5. 解法二不改镜像用入口脚本做“等待与重试”有时你没法给基础镜像加 healthcheck比如某些镜像没有内置可用的探测命令或者你不想改他人维护的镜像。这种情况下可以用入口脚本在容器内等待依赖可用。5.1 端口探测的基本思路最粗暴也最常用的思路是在应用启动之前先做一个 TCP 存活探测不断尝试连接目标服务的 IP 和端口直到连接成功再执行真正的启动命令。一个典型的脚本逻辑是接收目标 host 和 port。循环执行nc -z host port。连接成功后用exec启动应用命令。这里的关键是exec。用exec替换当前 shell 进程能保证应用进程成为容器的主进程信号传递给正确的进程容器退出状态也是应用自己的退出状态。如果不用exec而是直接cmd那么 shell 会多等一层容器里会多一个父进程信号处理会变得很别扭。5.2 一个 wait-for-it 脚本实战我经常在项目里用一个精简版脚本内容如下#!/bin/sh set -e HOST$1 PORT$2 shift 2 TIMEOUT30 COUNT0 while ! nc -z $HOST $PORT; do COUNT$((COUNT 1)) if [ $COUNT -ge $TIMEOUT ]; then echo timeout waiting for $HOST:$PORT 2 exit 1 fi echo waiting for $HOST:$PORT ... sleep 1 done echo $HOST:$PORT is ready exec $在 compose 里把它挂载进应用容器services: app: build: . depends_on: - mysql volumes: - ./wait-for-it.sh:/wait-for-it.sh command: [/wait-for-it.sh, mysql, 3306, --, java, -jar, app.jar]注意depends_on这里可以继续保留作用是把两个容器的创建顺序理顺真正的“等待”交给脚本去完成。两者配合既避免了依赖服务容器还没创建就尝试连接的极端情况又能把等待粒度精确到“TCP 能连通”。5.3 从“端口通了”到“业务可用”还需要再走一步nc -z host port验证的是 TCP 可以建立连接不代表业务已经就绪。MySQL 最典型端口能连不代表用户创建完成也不代表业务库已经建好。如果要精确判断得用mysqladmin ping或者执行一次真实 SQL 查询比如SELECT 1。其他中间件也一样Nacos 的 8848 端口能连不代表 Nacos 已经完成集群初始化Elasticsearch 的 9200 端口能连不代表分片已经就绪。所以如果场景允许我建议脚本里探测的不只是 TCP 端口而是真实业务接口。比如 Nacos 就提供了健康检查接口你可以改成这样while ! curl -f http://127.0.0.1:8848/nacos/v1/console/health/readiness; do sleep 1 done这种脚本比单纯等端口精确很多缺点是依赖容器内有没有curl命令。没有的话也可以用wget -qO-或者写一段 Python 探测。如果镜像里什么网络工具都没有还有一种思路应用自身具备重试能力。比如 Spring Boot 应用可以配置数据库连接池的重试参数启动阶段连不上就持续重试。这时候脚本甚至都不需要“等”的逻辑由应用自己消化。关键是你得真的去配而不是默认框架会无限重试。5.4 restart 策略止损兜底但不是银弹很多人在踩了坑之后会补一句“那我在服务上加上restart: always不就行了”。它能解决部分问题但本质是事后补救不是依赖顺序控制。restart: on-failure或restart: always做的事情是当容器内进程退出或异常退出时Docker 重新拉起这个容器。Docker 会自动带上退避延迟不会疯狂高频重启所以从外部看Nacos 虽然会反复失败但最终可能在 MySQL 就绪后被某一次重启拉起来了。问题在于重启期间服务不可用的时间不可控。如果应用启动失败不是简单连接问题而是因为脏数据、配置错误那么重启多少次都是白搭。日志会非常混乱排错时很难判断“哪次日志是最后一次成功的”。所以我的建议是restart可以加但要明白它是安全网不是主要方案。主要方案永远是 healthcheck 加 condition再加应用层重试。6. 容易被冤枉成 depends_on 的另外三个“真凶”在实际排障中我见过不少明明不是depends_on的锅却让depends_on背了锅的情况。这几个问题值得单独拎出来讲排查的时候可以先过一遍。6.1 资源限制导致启动奇慢有一次排查compose 文件里确实用了condition: service_healthy但依赖方就是迟迟不启动。最后发现是 MySQL 容器内存被限制得很低初始化阶段性能极差健康检查一直没法通过。如果你用过mem_limit或者deploy.resources.limits限制内存请先确认资源够不够。MySQL 初始化比较吃内存256MB 在某些场景下会非常吃力甚至直接被 OOM kill。排查思路很简单看目标容器状态是不是一直在restarting或被OOMKilled。用docker compose ps就能看到哪一步卡住。如果是资源问题先把限制调大再做验证。6.2 网络配置错误导致连不上另一种情况容器都 healthy 了依赖方还是连不上。这时候先别怀疑 depends_on去检查网络。常见错误是应用配置里把数据库地址写成了127.0.0.1或者localhost。在容器里这两个地址指的是容器自己不是 MySQL 容器。正确写法是使用 compose 服务名比如mysql。检查网络是否通的命令docker compose exec nacos getent hosts mysql如果有输出说明 DNS 解析正常。如果没有说明两个服务可能不在同一个自定义网络里或者服务名写错了。还有一种情况涉及外部网络。如果你用external: true把服务接到了某个已有网络那跨主机、跨 compose 项目之间的服务名解析规则会不一样depends_on只能控制本 compose 文件内的依赖关系管不了外部网络里的服务顺序。6.3 被依赖服务根本没进入“可接收连接”状态有些服务即使健康检查通过也可能在之后的几秒内退出。比如 MySQL 初始化完成后因为某个初始化脚本写坏了mysqld 直接退出了。这种场景跟depends_on毫无关系纯粹是服务本身没有稳定跑起来。排查顺序应该是步骤操作作用1docker compose ps看容器状态是否 healthy / running / restarting2docker compose logs -f 服务名看应用启动日志找连接异常或配置错误3docker compose exec 服务名 getent hosts 依赖名确认网络解析是否正常4直接进入依赖容器执行一次探测命令确认服务是真的能接受请求5看 healthcheck 的探测日志确认健康检查命令本身是否靠谱我自己的习惯是永远从ps和logs开始看不要凭感觉猜。90% 的问题最后都能在这两步里暴露出来。6.4 我的排查顺序如果你现在手头正好有“depends_on 管不住顺序”的疑惑按这个顺序排查基本够用docker compose ps看目标服务状态。docker compose logs -f 目标服务确认到底报什么错。确认依赖服务是否有 healthcheck 且状态是 healthy。确认depends_on用的是 long syntax 的condition不是简写的- 服务名。进入依赖容器手动执行一次探测命令确认服务真实可用。再加一层应用重试作为最后防线。这套顺序走下来基本能把 99% 的“顺序问题”定位到真正的根因是 Compose 没等到位是网络没配好还是服务本身起不来。最后分享一个我个人的习惯。凡是涉及数据库、注册中心这一类依赖我在 compose 里至少会做三层保障被依赖方配置 healthcheck依赖方用depends_on加condition: service_healthy应用内部的连接池或启动器配重试。三层都到位了启动顺序问题基本就不会找上你。写这篇文章的时候我又核对了一遍 Nacos 3.x 的部署配置发现它的很多启动参数和健康检查路径相较 2.x 都有调整。这里也提醒一下镜像版本务必固定到具体 tag不要用 latest并把容器日志挂出来。真出问题的时候日志可比记忆靠谱多了。
返回列表