Docker Compose 把服务声明转化成以项目管理的容器、网络和存储连接。文件描述有用关系:服务运行哪个镜像,配置如何传入,以及使用哪些存储或网络。复现整套环境仍依赖镜像可用性、变量值、宿主能力和既有数据等外部输入。

坏掉的样子

调用 shell 提供不同变量时,相同 Compose 文本可能解析成不同结果。环境文件参与插值,但不等于其中所有值都会自动注入每个容器。服务级 environment 声明和 env_file 设置有各自作用。应同时检查渲染配置和实际运行服务,不能因为某个文件里写了一个值,就认定它必然是最终生效值。

网络地址也是常见陷阱。Compose 网络中的服务可以通过服务名解析彼此,并使用容器端口;发布到宿主的端口属于另一条访问路径。如果客户端容器请求 localhost,通常请求会回到客户端自身,而不是兄弟 API。硬编码容器 IP 也会让替换更脆弱,稳定服务名更适合表达本来就属于服务关系的目标。

项目生命周期不意味着所有资源具有相同寿命。普通 down 删除项目容器和网络,命名卷则通常保留,除非明确要求删除卷。重建网络会改变其身份,也可能改变分配地址。数据仍在,不代表旧网络仍在;新容器已经创建,也不代表连接的数据卷为空。排查重建行为时,需要分别观察这些资源。

客户端到 API 的箭头位于同一项目网络内。另两行分别比较普通 down/up 后变化的网络身份与保留的卷内标记;文字区分服务名通信、宿主回环发布配置和变量插值。 静态说明原文;制作本图未执行文章实验。
Compose 服务网络、宿主端口发布、变量插值与命名卷生命周期是不同关系。

快速验证

使用启用断言的宿主 Python 3.9 或更新版本、Docker Compose,以及兼容平台上的固定镜像。Docker 端点发布的回环端口必须能由这个宿主进程直接访问,且不受代理干扰。早期记录报告 Compose 5.1.1、Docker 29.3.1、Linux arm64;这里没有复现该次运行。唯一命名的项目使用 YAML 接受的 JSON 语法,请求仅绑定宿主回环地址,并由引擎自动分配可用端口:

from pathlib import Path
from tempfile import mkdtemp
from urllib.request import urlopen
import json,os,subprocess,sys,time,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True,env=None,timeout=120):
    result=subprocess.run(args,text=True,capture_output=True,timeout=timeout,env=env)
    if check:assert result.returncode==0,result.stderr
    return result
def docker(*args,check=True,timeout=120):return run(['docker',*args],check,timeout=timeout)

def cleanup_steps(steps, primary):
    failures=[]
    for label, action in steps:
        try:action()
        except BaseException as error:
            failures.append(label+': '+type(error).__name__+': '+str(error))
    if failures:
        message='cleanup incomplete or uncertain: '+'; '.join(failures)
        try:print(message,file=sys.stderr)
        except (OSError,ValueError):pass
        if primary is None:raise RuntimeError(message)
    return failures

def remove_file(path):
    try:path.unlink()
    except FileNotFoundError:pass

def require_empty(value):
    if value:raise RuntimeError('residual resources: '+str(value))

name='xbcatm-project-'+uuid.uuid4().hex;environment=os.environ.copy();environment.pop('TASK_COMPOSE_GREETING',None)
root=None;project_started=False
label='com.docker.compose.project='+name
kinds=[('container',['ps','-aq'],2),('network',['network','ls','-q'],1),('volume',['volume','ls','-q'],1)]
def listed(command):return docker(*command,'--filter','label='+label,timeout=10).stdout.split()
def clear_kind(kind,command,limit):
    identities=listed(command)
    if len(identities)>limit:raise RuntimeError('unexpected project resource count; inspect '+name)
    cleanup_steps([(kind+' '+identity,lambda identity=identity:docker(kind,'rm',*(['-f'] if kind=='container' else []),identity,timeout=10)) for identity in identities],None)
for kind,command,limit in kinds:require_empty(listed(command))
print('fixture project:',name,flush=True)
try:
    root=Path(mkdtemp(prefix=name+'-'));file=root/'compose.yaml'
    def compose(*args,check=True,env=None,timeout=120):return run(['docker','compose','-p',name,'--env-file',str(root/'.env'),'-f',str(file),*args],check,environment if env is None else env,timeout)
    def container(service):return compose('ps','-q',service).stdout.strip()
    def inspect(identity):return json.loads(docker('inspect',identity).stdout)[0]
    def request():
        port=inspect(container('api'))['NetworkSettings']['Ports']['8080/tcp'][0]
        assert port['HostIp']=='127.0.0.1'
        deadline=time.monotonic()+10
        while time.monotonic()<deadline:
            try:
                with urlopen('http://127.0.0.1:'+port['HostPort'],timeout=1) as response:return response.read().decode()
            except OSError:time.sleep(.1)
        raise AssertionError('published endpoint unavailable')
    server='from http.server import BaseHTTPRequestHandler,HTTPServer\nimport os\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):self.send_response(200);self.end_headers();self.wfile.write(os.environ["GREETING"].encode())\n def log_message(self,*args):pass\nHTTPServer(("0.0.0.0",8080),H).serve_forever()\n'
    spec={'services':{'api':{'image':BASE,'command':['python','-c',server],'environment':{'GREETING':'${TASK_COMPOSE_GREETING:-default}'},'ports':[{'target':8080,'published':'0','host_ip':'127.0.0.1'}],'volumes':['state:/state']},'client':{'image':BASE,'command':['python','-c','import time;time.sleep(180)']}},'volumes':{'state':{}}}
    file.write_text(json.dumps(spec,indent=2));(root/'.env').write_text('TASK_COMPOSE_GREETING=from-file\n')
    rendered=json.loads(compose('config','--format','json').stdout);assert rendered['services']['api']['environment']['GREETING']=='from-file'
    override=environment|{'TASK_COMPOSE_GREETING':'from-shell'}
    rendered=json.loads(compose('config','--format','json',env=override).stdout);assert rendered['services']['api']['environment']['GREETING']=='from-shell'
    project_started=True
    compose('up','-d',env=override);assert request()=='from-shell'
    api=container('api');client=container('client');first_network=next(iter(inspect(api)['NetworkSettings']['Networks'].values()))['NetworkID']
    result=docker('exec',client,'python','-c','import urllib.request;print(urllib.request.urlopen("http://api:8080",timeout=2).read().decode())')
    assert result.stdout.strip()=='from-shell'
    docker('exec',client,'python','-c','import socket;s=socket.socket();assert s.connect_ex(("127.0.0.1",8080))!=0;s.close()')
    docker('exec',api,'python','-c','from pathlib import Path;Path("/state/record").write_text("preserved")')
    volume=next(m['Name'] for m in inspect(api)['Mounts'] if m['Destination']=='/state')
    assert volume==name+'_state'
    compose('down');assert docker('network','inspect',first_network,check=False).returncode!=0
    assert docker('volume','inspect',volume,check=False).returncode==0
    compose('up','-d',env=override);assert request()=='from-shell'
    api=container('api');assert next(iter(inspect(api)['NetworkSettings']['Networks'].values()))['NetworkID']!=first_network
    docker('exec',api,'python','-c','from pathlib import Path;assert Path("/state/record").read_text()=="preserved"')
    assert docker('exec',container('client'),'python','-c','import urllib.request;print(urllib.request.urlopen("http://api:8080",timeout=2).read().decode())').stdout.strip()=='from-shell'
    print('shell interpolation overrides .env; internal clients resolve api:8080 while localhost remains their own container')
    print('the host endpoint is published only on loopback; down/up recreates the project network and retains the named data volume')
finally:
    primary=sys.exc_info()[1];steps=[]
    if project_started:
        steps.append(('compose down',lambda:compose('down','--volumes','--remove-orphans','--timeout','5',timeout=10)))
        steps.extend((kind+' removal',lambda kind=kind,command=command,limit=limit:clear_kind(kind,command,limit)) for kind,command,limit in kinds)
        steps.extend((kind+' absence',lambda command=command:require_empty(listed(command))) for kind,command,limit in kinds)
    if root is not None:
        steps.extend([('env file',lambda:remove_file(root/'.env')),('compose file',lambda:remove_file(root/'compose.yaml')),('temporary directory',root.rmdir)])
    cleanup_steps(steps,primary)

私有环境文件提供 TASK_COMPOSE_GREETING=from-file。脚本显式选择这个文件,并先从调用环境中移除同名变量。compose config --format json 把服务的 GREETING 解析成 from-file;随后在调用环境提供 from-shell,渲染值变为后者。这直接验证本次使用的插值优先级,也避免工作目录中其他环境文件干扰实验。

采用覆盖值启动项目后,API 返回 from-shell。宿主请求使用容器检查发现的实际发布端口,并断言绑定地址为 127.0.0.1。这检查有效配置和请求的绑定地址,并未验证每一条远程访问路径。仅仅成功渲染文档,不能证明容器真的以该值运行,也不能证明端口按预期绑定,因此实验还检查了运行结果。

客户端服务通过 Compose 网络请求 http://api:8080,成功得到相同响应;请求自己回环地址的 8080 端口则失败。这两项观察区分了服务名发现、宿主端口发布和容器本地回环。应用请求没有嵌入固定容器 IP,因此后续网络重建时仍能表达同一逻辑目标,而不依赖旧地址碰巧被复用。

API 向命名状态卷写入标记。普通 compose down 之后,检查确认旧网络已不存在,命名卷却仍存在。再次启动项目,网络身份不同,卷内标记仍可读取,客户端也仍通过相同服务名访问 API。这里分别检查网络和数据,避免只看服务响应成功,就忽略底层资源到底保留了什么。

修订后的夹具尚未运行,只能用于独占控制的一次性端点。保护先于准备,预检拒绝既有项目资源。清理分别尝试 down、按项目标签限量删除、残留检查和本地文件删除;十秒 CLI 超时及资源数量限制约束各次尝试。清理错误不替换最初异常,只有清理失败时脚本也会失败。镜像保留。超时的守护进程操作可能稍后完成,须在端点稳定后按打印出的项目名检查。进程创建、停滞的文件系统调用和突然终止仍不受这些保证约束。普通 down 保留卷,最后的 --volumes 有意销毁一次性数据,不适合随手用于生产排错。

记录真正影响项目的输入

明确必需变量名、镜像摘要、存储所有权和暴露端口。在目标执行环境中先渲染配置,但不要公开含有凭据的渲染输出。固定镜像可以减少一类漂移,却不能冻结宿主架构、挂载文件、运行配置或持久数据库内容。记录输入的目的,是知道哪些部分已经固定,哪些仍需要独立管理。

需要资源隔离的本地或 CI 实验,可以使用不同项目名。显式命名的外部网络和卷能够有意跨越项目边界,因此应审阅这些引用,不能认定项目名不同就隔离了全部连接。端口绑定也可能在其他方面独立的项目之间冲突,需要把宿主级资源纳入配置检查。

Compose 除了本地测试,也可以支持受到管理的单机部署。是否适合取决于运维要求,不应一概声称不能用于生产。宿主冗余、更新协调、备份、就绪和监控,仍需要明确方案。声明的价值在于提供清楚、可测试、边界已知的资源模型,而不是仅凭一份文件就消除所有运行差异。

来源