Docker Compose 把服务声明转化成以项目管理的容器、网络和存储连接。文件描述有用关系:服务运行哪个镜像,配置如何传入,以及使用哪些存储或网络。复现整套环境仍依赖镜像可用性、变量值、宿主能力和既有数据等外部输入。文件进入版本控制,并不自动固定这些输入;只有把它们识别清楚,声明才真正可审阅、可解释。
坏掉的样子
调用 shell 提供不同变量时,相同 Compose 文本可能解析成不同结果。环境文件参与插值,但不等于其中所有值都会自动注入每个容器。服务级 environment 声明和 env_file 设置有各自作用。应同时检查渲染配置和实际运行服务,不能因为某个文件里写了一个值,就认定它必然是最终生效值。
网络地址也是常见陷阱。Compose 网络中的服务可以通过服务名解析彼此,并使用容器端口;发布到宿主的端口属于另一条访问路径。如果客户端容器请求 localhost,通常请求会回到客户端自身,而不是兄弟 API。硬编码容器 IP 也会让替换更脆弱,稳定服务名更适合表达本来就属于服务关系的目标。
项目生命周期不意味着所有资源具有相同寿命。普通 down 删除项目容器和网络,命名卷则通常保留,除非明确要求删除卷。重建网络会改变其身份,也可能改变分配地址。数据仍在,不代表旧网络仍在;新容器已经创建,也不代表连接的数据卷为空。排查重建行为时,需要分别观察这些资源。
快速验证
使用宿主 Python、Docker Compose 和固定 Python 镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Linux arm64。生成的 Compose 文档使用 YAML 接受的 JSON 语法,不需要额外 Python 解析器,并采用唯一项目名。API 只发布到宿主回环地址,由引擎自动分配可用端口:
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import urlopen
import json,os,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True,env=None):
result=subprocess.run(args,text=True,capture_output=True,timeout=120,env=env)
if check:assert result.returncode==0,result.stderr
return result
def docker(*args,check=True):return run(['docker',*args],check)
name='xbcatm-project-'+uuid.uuid4().hex[:10];environment=os.environ.copy();environment.pop('TASK_COMPOSE_GREETING',None)
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);file=root/'compose.yaml'
def compose(*args,check=True,env=None):return run(['docker','compose','-p',name,'--env-file',str(root/'.env'),'-f',str(file),*args],check,env or environment)
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')
try:
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'
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:compose('down','--volumes','--remove-orphans',check=False)
私有环境文件提供 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。这里分别检查网络和数据,避免只看服务响应成功,就忽略底层资源到底保留了什么。
最终清理包含 --volumes,因为本实验数据都可以丢弃。这个选项比前面用于验证保留行为的普通 down 具有更大的删除范围。在真实环境中,只有确实希望删除这些卷时才应使用,不能随手加到每次重启或排错命令末尾。理解默认生命周期,也包括理解哪些显式选项会改变它。
记录真正影响项目的输入
明确必需变量名、镜像摘要、存储所有权和暴露端口。在目标执行环境中先渲染配置,但不要公开含有凭据的渲染输出。固定镜像可以减少一类漂移,却不能冻结宿主架构、挂载文件、运行配置或持久数据库内容。记录输入的目的,是知道哪些部分已经固定,哪些仍需要独立管理。
需要资源隔离的本地或 CI 实验,可以使用不同项目名。显式命名的外部网络和卷能够有意跨越项目边界,因此应审阅这些引用,不能认定项目名不同就隔离了全部连接。端口绑定也可能在其他方面独立的项目之间冲突,需要把宿主级资源纳入配置检查。
Compose 除了本地测试,也可以支持受到管理的单机部署。是否适合取决于运维要求,不应一概声称不能用于生产。宿主冗余、更新协调、备份、就绪和监控,仍需要明确方案。声明的价值在于提供清楚、可测试、边界已知的资源模型,而不是仅凭一份文件就消除所有运行差异。
来源
- Compose services reference,服务配置、环境、端口和卷挂载,已核验 2026-09-12。
- Networking in Compose,项目网络与服务名发现,已核验 2026-09-12。
- Compose variable interpolation,shell 与环境文件的插值优先级,已核验 2026-09-12。
- Docker compose down,网络删除与可选卷删除,已核验 2026-09-12。