尧图精选

Docker Compose 多服务依赖测试实战:从 depends_on 到健康检查与故障演练

🕒 发布时间:2026/9/7 14:45:58 📁 来源:尧图网络
先从一个我踩过不少回的场景说起你用docker compose up -d把一整套多服务应用拉起来数据库、缓存、消息队列、业务后端依次创建容器。结果后端日志刷出来一堆Connection refused然后进程直接退出Compose 只能重启反复几次之后干脆进入Restarting状态。很多人第一反应是“服务启动太慢了”于是给depends_on加个condition再写个healthcheck好像问题就解决了。但如果你真正去拆解依赖、测试依赖、验证依赖会发现这件事远没有想象中那么简单。这篇文章我想从 Docker Compose 多服务依赖测试的角度把我实际项目里用到的思路、踩过的坑、写过的脚本和验证方法整理出来。标题里说的“构建可靠容器化应用”本质上就是让你的服务编排不再是靠运气启动而是靠机制保证谁先就绪、谁等待谁、等待多久、等不到怎么办。无论你是刚接触 Compose 的新手还是已经在生产环境维护多套 Compose 栈的开发者这些实践应该都能直接落进你的日常工作中。1. depends_on 的真相它保证的是顺序不是可用性1.1 一个复现率极高的失败现场先看我用过的一个“有代表性”的docker-compose.yml版本不算新但很常见services: db: image: postgres:16-alpine environment: POSTGRES_DB: demo POSTGRES_USER: demo POSTGRES_PASSWORD: demo ports: - 5432:5432 api: build: . depends_on: - db ports: - 8080:8080在 Compose 的世界里这个配置看起来没什么问题api依赖db先启动数据库再启动后端。可实际情况是db容器的进程一启动Compose 就会认为“这个依赖已经满足了”然后立刻去启动api。PostgreSQL 的容器进程起来之后还要经历初始化数据目录、创建用户、监听端口这一系列流程整个过程可能持续几秒甚至十几秒。后端代码在这个窗口期尝试连接数据库自然只有Connection refused。如果后端没有重试机制直接抛异常退出api就会进入无限重启循环你甚至来不及登录进容器里去排查问题。这个现象的核心在于depends_on控制的只是容器启动的先后顺序不是服务真正可用的时间点。它可以保证数据库容器在 API 容器之前创建、之前启动但保证不了 API 启动时 PostgreSQL 已经能接受连接。很多项目把这层依赖关系想得太简单才导致各种启动时序问题。1.2 depends_on 三种形态的演进短语法、长语法与 conditionDocker Compose 的depends_on有两种写法。一种是短语法就是我们上面看到的那种只列服务名。短语法在 Compose V1 时代还附带一个隐藏效果会先等待容器启动但不会做任何健康判断。Compose V2 之后短语法的语义更纯粹就是“先启动再启动依赖者”。长语法则多了一些可控选项services: api: build: . depends_on: db: condition: service_startedservice_started表示等待db容器启动完成这跟短语法差不多。更有价值的是service_healthy它要求依赖的服务先通过健康检查才会继续启动当前服务。也就是说Compose 会持续观察db的健康状态直到变成healthy再去启动api。这才是从“顺序保证”走向“可用性保证”的关键一步。services: api: build: . depends_on: db: condition: service_healthy注意condition这个配置需要依赖服务本身定义了healthcheck。如果db服务没有健康检查Compose 就永远等不到healthy状态api会一直卡在启动阶段。这是初用长语法时特别容易踩的坑condition写了但被依赖的服务没有healthcheck一启动就卡住日志也没输出表现像是“部署超时”。1.3 为什么只靠 depends_on 不靠谱就算你用了condition: service_healthy也仍然有一个隐含假设健康检查通过的那一刻服务就能服务于真实请求。实际上健康检查的命令本身是你自己定义的。如果你把健康检查写成pg_isready -U demo它只能说明 PostgreSQL 接受了本地连接请求不代表业务库表已经完成迁移更不代表新增的初始化脚本已经执行完。更现实的情况是一个服务依赖的不是“数据库进程活着”而是“某个表存在”“某个缓存键可以写入”“某个下游接口返回 200”。用一个生活化的类比depends_on相当于你叫了外卖系统提示“骑手已取餐”但这和“餐已经送到你手上”是两码事。骑手可能还在路上堵着可能送错楼可能找不到你。容器编排里的依赖关系也一样仅仅“容器启动了”和“外部调用方真正可以开始干活”之间隔着一大段状态同步的时间差和逻辑鸿沟。所以真正可靠的多服务依赖测试需要把以下三个层面一起打通容器启动顺序、健康检查状态、业务就绪探测。我在后面的章节里会逐一展开。2. healthcheck把“容器起来了”变成“服务能用了”2.1 healthcheck 的配置最容易被忽略的两个参数如果只想做最小改造让 Compose 能感知“服务是否可用”你就应该从healthcheck写起。下面是一个我常用的 PostgreSQL 健康检查配置services: db: image: postgres:16-alpine environment: POSTGRES_DB: demo POSTGRES_USER: demo POSTGRES_PASSWORD: demo healthcheck: test: [CMD-SHELL, pg_isready -U demo -d demo] interval: 2s timeout: 3s retries: 5 start_period: 10s这里最容易被人忽略的参数是start_period。它表示在容器启动后的一段时间内健康检查如果失败不会被计入重试次数。很多刚接触 healthcheck 的人不知道这个参数结果数据库要初始化 15 秒健康检查每 2 秒跑一次第 3 次失败后容器就被标记为unhealthy即使后面数据库真正起来了状态也可能不会自动恢复或者恢复得很慢。合理设置start_period相当于告诉 Compose“给它一点热身时间热身期的失败别急着算账。”另一个容易被忽略的是interval和retries的组合。如果检测间隔太短健康检查命令本身占用资源不说还可能在你的应用还没准备好时频繁报错刷日志如果间隔太长依赖方排队等待的时间也会变长。一般来说我会把interval设置在 5~10 秒之间retries设置在 3~5 次之间。既要快速感知就绪也要容忍短暂抖动。2.2 自定义命令的坑nc、wget、curl 可能不存在给容器写健康检查命令最常见的问题就是“镜像里没有我想要的工具”。很多 Alpine 镜像为了控制体积默认不带bash、curl、wget甚至连nc都没有。你如果用curl -f http://localhost:8080/healthz做健康检查启动的时候会直接报curl: not found容器一直被判定为 healthcheck 失败。针对不同服务我一般优先使用官方提供的健康探测命令PostgreSQL使用pg_isready镜像自带不用额外装东西。Redis使用redis-cli ping镜像自带返回PONG。MySQL使用mysqladmin ping -h 127.0.0.1 -u root -pxxx或者mysql -e SELECT 1。Nginx/Web 服务如果镜像里有curl就用curl -f http://127.0.0.1/healthz否则可以检测进程是否存活但检测进程存活的意义有限最好还是能做 HTTP 探测。如果是自定义 Java 或 Node.js 应用比较推荐在镜像内直接调用运行时自带的探活方式。比如 Node.js 可以写一个node -e脚本来发 HTTP 请求或者用wget如果镜像装了就很好。实在没有这些工具还有一个“硬核”技巧使用 Bash 的/dev/tcp。healthcheck: test: [CMD-SHELL, exec 3/dev/tcp/127.0.0.1/8080 echo open 3 exec 3- || exit 1]但要注意/dev/tcp是 Bash 的语法Alpine 镜像默认的 shell 是ash不一定支持。如果镜像没有bash这个命令会失败。所以在写健康检查脚本之前先确认docker compose exec 服务名 sh -c command -v bash; command -v nc; command -v curl再做决定。2.3 健康状态如何被 Compose 感知一旦服务定义了healthcheckdocker compose ps里就会多出一列STATUS显示容器当前的健康状态。初始是starting通过检查后变为healthy超过重试次数变为unhealthy。这个状态不仅人可读Compose 编排时也会读取。当你在depends_on里写了condition: service_healthyCompose 会持续观察被依赖服务的健康状态直到出现healthy才会启动依赖服务。这一点我可以给你看一个实际例子services: db: image: postgres:16-alpine healthcheck: test: [CMD-SHELL, pg_isready -U demo -d demo] interval: 2s timeout: 3s retries: 5 start_period: 10s api: build: . depends_on: db: condition: service_healthy command: [npm, start]这样配置之后执行docker compose up你会看到 Compose 先启动db然后db的状态从starting到healthy接着 Compose 才启动api。日志里的时间戳会非常清楚地展示这条链路。这是多服务依赖测试中“第一关”。不过我还是想提醒一句健康检查的状态是编排器的判断不一定等于业务可用。所以下一章我再聊聊在业务容器内部如何做更贴近请求路径的等待逻辑。3. 依赖就绪的等待策略脚本、工具与 Compose 内置命令的组合3.1 最朴素的方案在入口命令里串一个等待脚本很多项目不能保证所有依赖服务都写了healthcheck或者业务方不想改动上游服务的 compose 定义。这种情况下你需要在当前服务的启动命令里加上“等待依赖就绪”的逻辑。最常见的做法是写一个wait-for-it.sh或者wait-for.sh然后在command里用sh -c串起来。示例services: api: build: . depends_on: - db - redis command: sh -c ./wait-for-it.sh db:5432 -- ./wait-for-it.sh redis:6379 -- npm start这里的思路很简单在真正启动业务进程之前先让脚本去探测指定主机和端口通不通。通了才继续执行后面的命令。不通脚本内部循环重试直到超时或成功。好处是逻辑显式你一眼就能看出这个服务依赖哪几个地址。但这里有一个容易被忽略的细节depends_on列表中虽然列出了db和redis但如果没有condition它并不能保证数据库和缓存已经就绪。等待脚本承担的才是“探测就绪”的职责。所以这是一个双保险结构Compose 保证容器顺序脚本保证连接可用。3.2 如何自己写一个干净的 wait-for 脚本网上有很多现成的wait-for-it.sh但如果你不想引入额外的文件也可以自己写一个精简版本。我常用的这类脚本基于nc命令核心逻辑如下#!/bin/sh set -e HOST$1 PORT$2 shift 2 TIMEOUT${TIMEOUT:-30} START_TS$(date %s) until nc -z $HOST $PORT; do if [ $(( $(date %s) - START_TS )) -gt $TIMEOUT ]; then echo Timeout waiting for $HOST:$PORT exit 1 fi echo Waiting for $HOST:$PORT... sleep 2 done exec $使用方式./wait-for.sh db 5432 -- npm start这个脚本有几点我特别想强调一是exec $一定要用exec这样脚本进程会被实际的业务进程替换掉保证 PID 1 是业务进程信号能正确传递而不是脚本作为父进程产生孤儿进程。二是超时机制必须有。如果不设置超时某个依赖服务永远起不来容器就会一直卡在等待中从外面看毫无反应很难排查。如果镜像里没有nc也可以换一种纯 shell 的实现利用 Bash 的/dev/tcp#!/bin/bash host$1 port$2 shift 2 while ! (echo /dev/tcp/$host/$port) 2/dev/null; do echo Waiting for $host:$port... sleep 2 done exec $但就像前面说的这个方案强依赖 Bash。如果你的镜像只有ash或sh它就不好使。所以最稳妥的方法是在 Dockerfile 里安装netcat-openbsd或者postgresql-client这类工具用官方网络工具去探测。3.3 重试 vs 重试健康检查选择哪种策略等待端口只是最低限度的探测。端口通不代表服务真正可用。举个例子一个 Java 应用可能已经绑定了 8080 端口但还在 Spring 容器初始化阶段另一个微服务调用它时HTTP 请求会得到 503 而不是 200。如果业务代码只做“连接是否成功”的判断就会误判为依赖已就绪。应对这种问题我的建议是分层设计对于基础设施类数据库、Redis、消息队列等待端口足够因为它们监听端口后基本就能对外提供服务。对于应用类服务自己的 API、第三方 HTTP 服务最好等待一个“就绪端点”比如/healthz或/actuator/health。你可以用curl --fail --silent去探测直到返回 200 再继续。如果服务之间是通过 SDK 调用比如 gRPC那么端口探测配合应用自身的重试机制会更好因为 SDK 连接池的建立有时比 TCP 握手复杂得多。我常用的一种组合是容器内先跑一个端口探测脚本端口通了之后再调用业务初始化脚本最后启动主进程。同时应用代码里保留一定的连接重试逻辑作为最后一道防线。这样即使编排层判断有误应用也能自己恢复。3.4 为什么不要把无限重试写死在应用里有的团队喜欢在应用启动时对依赖服务做无限重试比如“连不上数据库就每 5 秒重试一次永不退出”。这样做在本地开发时确实省心但在生产环境会带来排查困难。一旦数据库故障应用进程不会失败退出也不会触发 Kubernetes 或 Compose 的restart策略日志只会反复打印连接错误而编排系统认为容器还活着就不会做出任何自愈动作。最终结果就是你接到一堆业务超时告警登录服务器去看发现应用容器一直在做无意义的空转。所以我的经验是**应用本身要设置重试次数上限和失败退出机制编排层负责等待和重启两者分工明确。**无限重试不是一个好策略它会掩盖系统真正的故障状态。依赖测试里你应该验证的是“依赖不可用时应用能快速失败”而不是“应用无限重试到天荒地老”。这一点等会儿讲故障演练时还会提到。4. 多服务依赖测试把“运气”变成“确定性”4.1 用 docker compose config 静态校验编排结构依赖测试的第一步不是把服务跑起来而是静态检查编排文件本身有没有问题。docker compose config是一个非常实用的指令它会把我们的 YAML 文件解析成一份展开后的完整配置并输出到标准输出。这样一眼就能看出 Compose 实际解析了哪些服务、哪些依赖、哪些环境变量。docker compose config如果只关心配置是否有语法错误可以使用docker compose config -q没有输出就表示配置解析通过。想查看服务列表以及它们的依赖关系可以试试docker compose config --services docker compose config --volumes docker compose config --profiles在 CI 环境或 pre-commit 钩子里我会加一步docker compose config -q防止有人提交了depends_on指向不存在的服务名。这种事看着低级但多人协作时真的会发生。比如有人删除了cache服务但api的depends_on还留着cacheCompose 只有在启动时才会报错。加上静态校验之后错误可以被提前拦截。另外一个可以观察的命令是docker compose config --images它会列出所有服务的镜像名。结合docker compose pull使用可以提前检查网络问题和镜像是否存在。4.2 实战演练一人为制造依赖故障依赖测试不只是写一个能用的 Compose 文件更要在异常场景下验证行为。我第二次强烈建议你在本地做一次“故意搞坏”的实验否则你根本不知道系统在多服务故障时是什么表现。实验目标验证当数据库服务不可用时api服务会不会因为依赖注入而卡死或者是否会快速失败重启。操作步骤先正常启动整套服务docker compose up -d docker compose ps确认所有服务状态正常后把数据库停掉docker compose stop db观察api容器的表现docker compose logs -f api docker compose ps如果应用本身配置为连接失败后退出你会看到api容器状态变成exited而且短时间内会反复重启取决于restart策略。如果应用内部有连接重试你可能看到Restarting或者一直处于running但日志刷错误。这一步的意义在于你可以根据观察结果决定要不要调整depends_on和healthcheck。如果api在数据库停止后仍然继续运行但无法工作说明编排层面没有感知到下游故障如果api退出后 Compose 反复重启但数据库始终没恢复你就会看到重启风暴。这些都是线上出问题时最怕看到的场景。4.3 实战演练二延迟启动依赖观察等待效果依赖测试还要验证“慢启动”场景。有些容器镜像首次初始化时需要下载资源、做数据迁移启动时间可能长达一分钟。这时候你需要确认api会等这么久而不是等个几秒就超时退出。你可以在db服务上临时加一个延迟命令来模拟services: db: image: postgres:16-alpine command: sh -c sleep 20 docker-entrypoint.sh postgres healthcheck: test: [CMD-SHELL, pg_isready -U demo -d demo] interval: 2s timeout: 3s retries: 10 start_period: 30s注意sleep 20只模拟容器启动前的睡眠真正的 PostgreSQL 初始化仍然在docker-entrypoint.sh里。接着启动整套服务docker compose up -d docker compose ps你会看到db状态先是starting20 秒后才真正初始化并进入healthy。如果api配置了condition: service_healthy它会一直等到db变healthy后才启动。这就是我们想要的效果。如果没有等待api就会在数据库还没起来的时候尝试连接导致启动失败。在实际项目中我不会真的在生产镜像里加sleep只是在验证依赖顺序时用来模拟“坏情况”。你也可以直接使用docker compose up -d --wait这种带等待机制的命令来做日常冒烟测试后面会专门讲到。4.4 检查时序日志用时间戳还原启动过程依赖测试一个容易被忽视的收尾动作是检查日志里面的时间戳顺序。很多人只看容器是否最终启动成功却忽略了“谁先谁后”。docker compose logs默认显示相对时间你最好加上-t参数docker compose logs -t -f api db redis输出里每一行都会带有 RFC3339 格式的时间戳。我们可以据此还原启动过程db容器被创建并开始初始化db的 healthcheck 从starting变成healthyapi容器启动api内部执行等待脚本探测到db:5432可连接api主进程启动。如果看到api的时间戳早于db的 healthy 时间戳说明等待逻辑没有生效。如果api启动后立刻报连接错误说明依赖探测失败或超时设置太短。时序日志是做这种判断最直观的依据。另外如果 Compose 服务内部还有多个进程可以在自己的应用日志里也加上启动阶段标记。例如echo [$(date -Iseconds)] waiting for db... ./wait-for.sh db 5432 echo [$(date -Iseconds)] db is ready, starting app... exec npm start这样日志自带关键节点一眼就能看出卡在哪一段。4.5 自动化冒烟测试的整合在本地手动跑依赖测试是一回事在 CI 里自动跑又是另一回事。Docker Compose V2 提供了一个很关键的特性--wait。使用它可以让docker compose up在服务启动后等待所有服务进入健康状态才返回特别适合在 CI 脚本里做“环境就绪检查”。docker compose up -d --wait这条命令会等待所有定义了healthcheck的服务变为healthy。如果某个服务在超时时间内没有变成 healthy命令会返回非零退出码。配合--wait-timeout可以设置超时时间docker compose up -d --wait --wait-timeout 120在 GitHub Actions 或 GitLab CI 里我惯用的流程是docker compose up -d --wait sleep 5 docker compose exec api npm run test:integration docker compose down -vdocker compose down -v里的-v会删除服务挂载的匿名卷确保下一次测试环境是全新的。如果不清卷数据库里的脏数据可能导致测试结果不稳定到时候你很难判定是依赖问题还是数据问题。对于没有定义healthcheck的服务--wait会退而求其次等待容器启动完成。所以为了让 CI 里的--wait真正有意义我强烈建议所有核心服务都定义健康检查。5. 把这些实践固化到项目里的三个文件5.1 Dockerfile 里的健康检查声明健康检查不一定要写在 compose 文件里也可以写进 Dockerfile。用HEALTHCHECK指令是一个很不错的做法因为只要镜像构建出来运行这个镜像的任何编排器Compose、Kubernetes、Swarm、Nomad都能感知到健康状态不需要每家编排系统都单独配置。示例FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY . . HEALTHCHECK --interval5s --timeout3s --start-period15s --retries3 \ CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 CMD [node, server.js]注意这里我假设镜像里装了wget。如果连wget都没有就用 Node.js 本身发起自请求或者装一个curl。写进 Dockerfile 的好处是即使有人忘了在 compose 里写healthcheck容器启动后也会有一个默认健康状态配合depends_on: condition: service_healthy时不容易踩空。5.2 docker-compose.yml 里的依赖关系模板一个成熟项目的 compose 文件应该长成下面这样而不是一眼望过去全是depends_on短语法。services: db: image: postgres:16-alpine environment: POSTGRES_DB: demo POSTGRES_USER: demo POSTGRES_PASSWORD: demo healthcheck: test: [CMD-SHELL, pg_isready -U demo -d demo] interval: 5s timeout: 3s retries: 5 start_period: 10s networks: - backend redis: image: redis:7-alpine healthcheck: test: [CMD, redis-cli, ping] interval: 5s timeout: 3s retries: 5 start_period: 5s networks: - backend api: build: . environment: DATABASE_URL: postgres://demo:demodb:5432/demo REDIS_URL: redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_healthy healthcheck: test: [CMD-SHELL, wget -qO- http://127.0.0.1:8080/healthz || exit 1] interval: 5s timeout: 3s retries: 5 start_period: 15s networks: - backend - frontend networks: backend: frontend:这里的关键点是depends_on和healthcheck是配套的。depends_on里使用service_healthy被依赖服务就必须有健康检查。此外我还会把服务放进不同的自定义网络里避免所有服务都在同一个扁平网络里互相乱连至少可以防止前端容器直接访问数据库容器这种不必要的暴露。5.3 Makefile 里的 up、test、down 指令把常用命令封装到Makefile里是我觉得提升团队协作效率最立竿见影的做法。开发者和 CI 只需要运行make up、make test、make down不需要每个人都背一长串 docker compose 命令。makefile .PHONY: up down test logs up: docker compose up -d --wait down: docker compose down -v test: docker compose up -d --wait sleep 5 docker compose exec api npm run test:integration docker compose down -v logs: docker compose logs -t -f写进 Makefile 之后make test这个目标成为“多服务依赖测试”的入口它会启动所有服务并等待健康检查通过等待几秒让应用完全初始化然后运行集成测试最后拆除环境。这样不管是新同事入职还是 CI 跑流水线都能用同一套命令验证配置。我还会在 Makefile 里加一个make validate目标用来做静态检查和故障演练的快速入口避免每次手动敲一堆命令。整个依赖测试的过程就变得更像工程流程而不是凭感觉。最后再分享一个小细节如果你正在做一个会被长期维护的 Compose 项目我建议你把“依赖测试”写进你的文档或 README至少说明两点每个服务依赖哪些其他服务如果被依赖服务启动失败当前服务会有什么表现。我在实际项目中见过太多次这样的情况人走了服务编排文件在但没人知道为什么api一定要等redis健康之后才能起来于是新来的人为了“加快启动速度”把等待逻辑删了结果线上随机出现缓存连接异常。我自己做了多年容器化应用之后最大的体会是多服务依赖测试不是为了“测”而测而是为了把一团模糊的启动时序变得可视、可验证、可预期。你把depends_on、healthcheck、等待脚本、故障演练这四个环节串起来你手上的 Compose 应用才算真正“可靠”了。希望这篇分享能帮你少踩几个坑。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →