Docker Compose 描述服务依赖图,并可以按声明关系启动服务。进程启动只是图中的一种条件:数据库或 API 可能已经运行,却还不能接受其他服务需要的操作。显式 service_healthy 条件让 Compose 在启动依赖方之前,等待被依赖服务的健康检查通过;前提是这个检查确实表达有意义的就绪状态,而不是随意返回成功。
坏掉的样子
简短 depends_on 形式建立启动顺序,却不建立应用就绪保证。用固定 sleep 替代也很脆弱,因为初始化时间会随存储、缓存、迁移和机器负载变化。同一段等待在某次运行中过长,在另一次又不足。条件应来自可以观察的依赖行为,而不是估计已经过去多久;否则构建机变快或变慢,都可能暴露新的偶发故障。
基于健康状态的依赖条件,也不是持续可用性保证。依赖方启动之后,被依赖服务仍可能失败或重启。Compose 不会仅因依赖后来变得不健康,就自动停止或重启已经运行的依赖方。应用仍需要处理连接中断、有界重试,以及操作可能部分完成的情况,不能把启动编排当成完整运行期容错。
嵌套在依赖项里的 restart: true,与服务自身重启策略含义不同。它针对显式 Compose 操作更新或重启依赖后,对依赖方进行相应重启,不是对每一次健康转换或引擎级崩溃的通用订阅。相似字段名容易造成误解,应按照配置层级和文档契约区分,而不是看到 restart 就认为所有失败都会触发。
快速验证
使用宿主 Python、Docker Compose 和固定 Python 镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。脚本用 YAML 接受的 JSON 语法写 Compose 文档,创建唯一项目,所有 HTTP 访问都留在项目网络中。清理只移除该项目,不影响其他服务:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True):
result=subprocess.run(args,text=True,capture_output=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def docker(*args,check=True):return run(['docker',*args],check)
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]
name='xbcatm-depends-'+uuid.uuid4().hex[:10]
with TemporaryDirectory(prefix=name+'-') as directory:
file=Path(directory)/'compose.yaml';base=['docker','compose','-p',name,'-f',str(file)]
def compose(*args,check=True):return run(base+list(args),check)
def service_id(service):
return docker('ps','-aq','--filter','label=com.docker.compose.project='+name,'--filter','label=com.docker.compose.service='+service).stdout.strip()
def wait(predicate):
deadline=time.monotonic()+20
while time.monotonic()<deadline:
result=predicate()
if result:return result
time.sleep(.1)
raise AssertionError('condition did not become true')
server='from http.server import BaseHTTPRequestHandler,HTTPServer\nfrom pathlib import Path\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):self.send_response(200 if Path("/ready").exists() else 503);self.end_headers()\n def log_message(self,*args):pass\nHTTPServer(("0.0.0.0",8080),H).serve_forever()\n'
health=['CMD','python','-c','import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080",timeout=.5).read()']
application='import urllib.request,time;urllib.request.urlopen("http://dependency:8080",timeout=2).read();print("started-after-ready",flush=True);time.sleep(180)'
file.write_text(json.dumps({'services':{'dependency':{'image':BASE,'command':['python','-c',server],'healthcheck':{'test':health,'interval':'1s','timeout':'1s','retries':2,'start_period':'30s'}},'application':{'image':BASE,'command':['python','-c',application],'depends_on':{'dependency':{'condition':'service_healthy'}}}}},indent=2))
process=None
try:
process=subprocess.Popen(base+['up','-d'],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
dependency=wait(lambda:service_id('dependency'))
wait(lambda:inspect(dependency)['State']['Running'])
assert inspect(dependency)['State']['Health']['Status']=='starting'
application_id=service_id('application')
assert not application_id or not inspect(application_id)['State']['Running']
assert process.poll() is None
docker('exec',dependency,'python','-c','from pathlib import Path;Path("/ready").touch()')
stdout,stderr=process.communicate(timeout=30);assert process.returncode==0,stderr
application_id=service_id('application');wait(lambda:'started-after-ready' in docker('logs',application_id).stdout)
original=inspect(application_id);assert original['State']['Running']
assert inspect(dependency)['State']['Health']['Status']=='healthy'
docker('exec',dependency,'python','-c','from pathlib import Path;Path("/ready").unlink()')
wait(lambda:inspect(dependency)['State']['Health']['Status']=='unhealthy')
after=inspect(application_id);assert after['State']['Running'] and after['State']['StartedAt']==original['State']['StartedAt'] and after['RestartCount']==0
print('service_healthy keeps the dependent application stopped until the dependency probe succeeds')
print('later dependency health failure does not stop or restart the already-running dependent application')
finally:
if process is not None and process.poll() is None:process.terminate();process.communicate(timeout=10)
compose('down','--volumes','--remove-orphans',check=False)
依赖服务运行 HTTP 服务器,在 /ready 出现前一直返回 503,健康检查请求真实端点。三十秒启动宽限让控制器有时间读取初始状态,避免早期失败马上耗尽两次失败阈值。应用声明 condition: service_healthy,并在真正启动后再自行发起 HTTP 请求,使声明条件与实际连接行为可以对照。
控制器异步启动 compose up -d,观察依赖进程已运行,但健康状态仍是 starting。此时应用要么尚未创建,要么还未运行,Compose 命令也仍在等待。实验接受这两种创建细节,因为需要验证的是依赖方启动受到条件限制,而不是绑定某一种中间容器分配顺序。这样断言对应稳定契约,而不是偶然实现顺序。
创建 /ready 后,依赖端点返回 200。Compose 成功完成启动,应用只有在自身请求成功后才打印 started-after-ready。这把声明条件连接到真正可达的行为。如果依赖方无法通过实际网络访问目标,仅仅看到健康标签变绿,证据就不够完整;实验因此同时检查了依赖服务内部探测和依赖方请求。
随后删除 /ready,经过足够失败探测,依赖变为 unhealthy。应用仍然运行,启动时间相同,重启次数为零。这是关键反例:曾经满足启动条件,不意味着 Compose 会持续让依赖方生命周期与后续健康状态同步。服务已经启动与服务能够继续完成业务,是需要分别观测的事实。
应用在首次请求后特意进入睡眠,以便独立观察进程寿命。它不是生产客户端实现,实验也没有声称睡眠状态证明依赖故障期间业务操作仍能成功。实际结论仅是 Compose 没有主动处理这个已存在的依赖方进程。限制结论范围,能避免把测试用于它并未覆盖的业务恢复能力。
一起设计启动条件和运行期处理
真正只要求进程启动时使用 service_started;具有有效健康检查的就绪要求使用 service_healthy;适当的一次性前置任务则使用成功完成条件。迁移任务成功退出,与长时间运行数据库就绪,回答的是不同问题。选择匹配前置要求的条件,并保留不满足时的有用错误输出,不能靠无限等待隐藏失败。
就绪检查应便宜且具体。TCP 监听可能已经存在,认证、表结构初始化或必需数据却还未完成。反过来,把所有非必需下游都加入检查,也可能阻止本来能提供有用功能的服务启动。检查需要表达被约束服务真正需要的最小依赖契约,而不是越多越保险的任意检查集合。
运行后,在应用和运维控制中处理依赖变化:限制重试,避免重复非幂等操作,并测试目标替换后的重新连接。Compose 能协调可重复的启动顺序,但持续正确性仍取决于服务真实通信和失败语义。把这些职责分别实现并验证,才能避免一次启动成功掩盖长期运行问题。
来源
- Control startup order in Compose,已启动、健康和成功完成等依赖条件,已核验 2026-09-12。
- Compose services reference,depends_on 条件与显式 Compose 重启行为,已核验 2026-09-12。
- Dockerfile HEALTHCHECK,探测状态、启动宽限与连续失败计数,已核验 2026-09-12。