Docker Compose:把本地依赖变成可验证的项目合同
环境明明启动了,为什么应用仍然连不上数据库
新成员执行 docker compose up -d,终端显示三个容器都已启动,应用却在初始化阶段报 connection refused。他重启了一次,错误消失;第二天换了目录名,数据库里的测试数据又像“丢了”。更麻烦的是,提交中的 compose.override.yaml 把端口改成了另一个值,CI 使用基础文件,本机使用合并文件,双方看到的根本不是同一个运行模型。
这不是几条启动命令能解决的问题。Compose CLI 读取一个或多个 YAML 文件,完成变量插值与模型合并,再以 project 为边界创建容器、网络、volume、config 和 secret。任何一层没有被固定,都可能让“同一份配置”产生不同结果:
架构师真正需要维护的不是 YAML,而是一份项目合同:谁启动、何时算就绪、谁能互通、哪些状态要持久化、哪些值可以提交、故障后怎样证明环境回到了可信状态。
先确认 CLI、Daemon 与 Context 指向同一处
Docker Desktop 已包含 Compose 插件;Linux 上使用 Docker 官方软件源安装 Engine 时,可安装 docker-compose-plugin。团队应使用带空格的 docker compose 子命令,而不是把旧的独立 docker-compose 二进制当作默认入口。实际安装命令会随发行版变化,执行前应在 Docker 的 Engine 与 Compose 安装页选择对应系统。
安装完成后,不要只看 Compose 版本:
docker version
docker compose version
docker context ls
docker context show
docker info --format '{{json .ServerVersion}}'docker version 同时显示客户端与服务端,只有 Client 没有 Server 通常意味着 daemon 未启动、socket 权限不足或 context 指向不可达端点。docker context show 必须与预期运行时一致;Docker Desktop、Rancher Desktop、Podman 兼容 socket 和远程 Engine 可能同时存在。此时“看不到旧容器”首先应检查 context,不能先删 volume。
Linux 上若普通用户通过 /var/run/docker.sock 操作 daemon,要知道 docker 用户组通常具有接近 root 的主机控制能力。共享开发机更适合使用独立虚拟机、rootless 模式或受控远程构建服务,不应把 socket 暴露给任意项目容器。
用一个自包含项目建立第一条证据链
建立临时目录 compose-contract-lab,其中包含以下文件:
compose-contract-lab/
├─ compose.yaml
├─ compose.debug.yaml
├─ .env.example
├─ config/
│ └─ app.conf
└─ secrets/
└─ local_token.txt.env.example 只保存无敏感默认值:
COMPOSE_PROJECT_NAME=compose-contract-lab
APP_PORT=18085
APP_MEMORY=128m复制为本机 .env 后,将 .env 与 secrets/ 加入 .gitignore。这里的 .env 负责 Compose 插值,不会自动把所有变量放进容器。
config/app.conf:
mode=local
message=compose-readysecrets/local_token.txt 使用无生产意义的实验值:
local-lab-valuecompose.yaml 把健康状态、网络和持久化都写进模型:
name: ${COMPOSE_PROJECT_NAME:?set COMPOSE_PROJECT_NAME}
services:
state:
image: nginx:1.27-alpine
command:
- sh
- -ec
- |
test -s /run/secrets/local_token
test -s /etc/contract/app.conf
count=0
test -f /data/count && count="$$(cat /data/count)"
count=$$((count + 1))
printf '%s\n' "$$count" > /data/count
cp /etc/contract/app.conf /usr/share/nginx/html/app.conf
printf '%s\n' "$$count" > /usr/share/nginx/html/count
exec nginx -g 'daemon off;'
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:80/app.conf | grep -q 'compose-ready'"]
interval: 2s
timeout: 1s
retries: 15
start_period: 2s
networks: [backend]
volumes:
- state-data:/data
configs:
- source: app-config
target: /etc/contract/app.conf
secrets:
- source: local-token
target: local_token
mem_limit: ${APP_MEMORY:-128m}
cpus: "0.50"
labels:
io.example.lifecycle: disposable
io.example.owner: platform-dev
probe:
image: alpine:3.20
command:
- sh
- -ec
- |
body="$$(wget -q -O - http://state:80/app.conf)"
printf '%s\n' "$$body"
test "$$body" = "mode=local
message=compose-ready"
depends_on:
state:
condition: service_healthy
profiles: [verify]
networks: [backend]
debug:
image: alpine:3.20
command: ["sh", "-c", "sleep infinity"]
profiles: [debug]
networks: [backend]
read_only: true
networks:
backend:
internal: true
labels:
io.example.lifecycle: disposable
volumes:
state-data:
labels:
io.example.lifecycle: persistent-local
configs:
app-config:
file: ./config/app.conf
secrets:
local-token:
file: ./secrets/local_token.txt这里刻意没有发布宿主机端口。probe 与 state 通过 Compose 网络中的服务名通信,减少端口冲突和意外暴露。backend.internal: true 使该网络不提供默认外部连通能力,适合不需要访问互联网的依赖;若服务确实要拉取外部数据,应显式增加另一条出口网络,而不是取消所有隔离。
先复制 .env.example 为 .env,再解析模型并启动:
docker compose config --quiet
docker compose config --services
docker compose config --profiles
docker compose up -d --wait --wait-timeout 45 state
docker compose --profile verify run --rm probe
docker compose ps --all
docker compose exec state cat /data/count预期 config --quiet 退出码为 0;服务列表用于审查声明模型,Profile 列表包含 verify 与 debug。up --wait 返回 0 时,state 应为 healthy;一次性 probe 输出配置文件内容并以 0 退出,计数为 1。如果镜像拉取、secret 文件、健康检查或网络解析失败,命令应非零退出,不能用“容器创建过”冒充成功。
接着验证持久化边界:
docker compose down
docker compose up -d --wait --wait-timeout 45 state
docker compose exec state cat /data/count第二次计数应为 2,因为普通 down 删除服务容器和项目默认资源,但保留声明的 named volume。这个实验同时解释了常见的“初始化脚本为什么没有重新执行”:容器重建不等于数据重置。
实验结束后先保留 volume,便于继续做反例:
docker compose downname 决定对象归属,不只是显示名称
Project name 会成为容器、默认网络与非外部 volume 的归属标识。当前 Compose CLI 的优先级从高到低是:命令行 -p、COMPOSE_PROJECT_NAME、Compose 顶层 name、第一份 Compose 文件所在项目目录的 basename,最后才是当前目录 basename;多份 Compose 文件都声明 name 时,以最后一份为准。团队不要同时依赖多套来源;上例让 .env 中的 COMPOSE_PROJECT_NAME 成为可审查输入,并用 ${...:?...} 拒绝空值。
每次自动化操作前都可先解析名称:
project_name="$(docker compose config --format json | jq -r '.name')"
printf 'project=%s\n' "$project_name"
test "$project_name" = "compose-contract-lab"实际项目更适合用已有 JSON 工具解析,不要在关键清理脚本里依赖脆弱的文本截取。无论使用何种实现,删除前必须断言 project name 是允许值,并再次检查对象上的 com.docker.compose.project 标签。
多文件合并要审查最终模型
开发环境经常用基础文件加覆盖文件。创建 compose.debug.yaml:
services:
state:
environment:
LOG_LEVEL: debug
labels:
io.example.variant: debug
debug:
profiles: []解析并观察:
docker compose -f compose.yaml -f compose.debug.yaml config > merged.yaml
docker compose -f compose.yaml -f compose.debug.yaml config --services后面的文件不会对所有字段做同一种操作。映射通常按键合并,command、entrypoint 与 healthcheck.test 等字段以后者为准;普通序列会追加,ports、volumes、secrets 与 configs 则按各自唯一键合并,其中后三者的唯一键是容器内 target。要删除前一份文件留下的值,使用当前规范的 !reset;需要完全替换而不是继续合并时使用 !override,并先确认团队 Compose 版本支持。所有相对路径仍以第一份 -f 文件为基准;若模块必须保持自己的相对路径边界,应改用 include。真正的审核对象应是 config 产生的最终模型,而不是只看覆盖文件的几行差异。
但 docker compose config 也不是天然可公开的诊断信息。Compose 文件可以读取 env_file、config、secret、include 和 extends 指向的主机文件;解析结果可能展开环境值或路径。把输出上传工单前应脱敏,运行陌生仓库前还要先检查 bind mount、privileged、host namespace、设备和 Docker socket。
插值发生在合并前,Shell 值可能悄悄改写环境
Compose 支持 ${VAR}、${VAR:-default}、${VAR:?error} 等插值形式。插值来源的优先级从高到低是 Shell、--env-file 指定的文件、未指定该参数时项目目录中的 .env;多个 --env-file 按命令行顺序读取,后面的同名值覆盖前面的值。插值与容器进程环境又是两套优先级:docker compose run -e 最高,其后是由 Shell 或环境文件插值产生的 environment/env_file 值、Compose 文件里的 environment、env_file,最后才是镜像 ENV。排障时不能拿其中一套解释另一套。
使用以下命令查看 Compose 实际采用了哪些插值变量:
docker compose config --environment
APP_MEMORY=96m docker compose config | grep -A 4 'mem_limit'在 POSIX Shell 中,第二条命令会让 Shell 值覆盖 .env 的同名值。若团队成员的全局环境里残留 COMPOSE_FILE、COMPOSE_PROJECT_NAME 或业务变量,本地模型就可能漂移。关键变量应使用必填表达式,并在启动脚本中构造允许列表,不要把整个登录会话环境透传给容器。
反向实验可以证明必填变量确实阻断启动:
mv .env .env.saved
set +e
docker compose config --quiet
rc=$?
set -e
mv .env.saved .env
test "$rc" -ne 0
printf 'missing-env exit=%s\n' "$rc"预期 docker compose config 非零退出,并指出 COMPOSE_PROJECT_NAME 未设置。若脚本继续执行 up,说明错误被 || true、管道退出码或 Shell 配置吞掉,门禁本身不可信。
Profile 应隔离可选工具,不能隐藏核心依赖
profiles 适合调试器、数据库控制台、流量观测等可选服务。未声明 profile 的核心服务默认启用;声明了 profile 的服务只有在 profile 被启用或被显式指定时才启动。显式指定某个服务只会自动启用该服务及其 depends_on 依赖,不会顺带启动同一 profile 的所有服务;若依赖也受另一个 profile 限制且两者没有共同激活条件,Compose 会把模型判为无效。
docker compose config --profiles
docker compose --profile debug up -d debug
docker compose ps
docker compose down不要给数据库、迁移任务或应用主服务随意加 profile,否则一条遗漏 --profile 的命令就会得到不完整环境。Profile 也不是权限边界:启用它的人仍能运行其中的高权限容器,安全审查不能因为服务“默认不启动”而跳过。
depends_on 只表达依赖,healthcheck 才提供就绪证据
容器进入 running 只表示主进程已启动,不表示数据库已接受连接、迁移已完成或 HTTP 端点可用。短语法 depends_on: [state] 只保证依赖先启动,不等待它变为 healthy;需要就绪等待时,必须使用长语法与 condition: service_healthy。一次性迁移任务则应使用 service_completed_successfully,避免把“进程启动过”误当成迁移成功。
制造一个稳定失败的健康检查:
docker compose up -d state
docker compose exec state sh -c 'rm -f /usr/share/nginx/html/app.conf'
sleep 5
docker compose ps
docker inspect "$(docker compose ps -q state)" --format '{{json .State.Health}}'预期 state 转为 unhealthy,docker inspect 中的 Log 保存每次探测的退出码和输出。修复不是盲目增加 retries,而是确认探测是否覆盖真实依赖、超时是否小于业务容忍时间、命令是否存在于镜像。健康检查太弱会制造假绿,太重则会持续消耗 CPU、连接和日志容量。
清理这个失败现场并重建:
docker compose down
docker compose up -d --wait --wait-timeout 45 stateNetwork、Volume、Config 与 Secret 承担不同职责
Compose 默认网络为同一 project 内的服务提供 DNS,服务重建后 IP 可能变化,调用方应使用服务名而不是写死 IP。只有确需被宿主机或外部系统访问时才配置 ports;expose 只是描述容器端口,不等同于发布宿主机端口。
Named volume 的生命周期独立于容器,适合数据库与依赖缓存;bind mount 把主机路径直接暴露给容器,适合源码热更新,但权限、大小写、换行符和 Windows/WSL 文件系统性能会进入运行模型。执行陌生 Compose 前,应逐项检查挂载源是否越过项目目录,尤其禁止把主目录、SSH 目录、云凭据目录或 daemon socket交给不可信容器。
Config 与 secret 都以文件形式交给服务,但本地 Compose 的 file: secret 仍来源于主机普通文件,不等于外部密钥管理系统。它能避免把值硬编码在 YAML 和环境变量里,却不能自动提供加密、轮换、撤销和审计。团队仍要保护源文件、限制读取者,并在共享环境接入专用密钥系统。
资源限制是故障隔离输入,不是容量结论
mem_limit 与 cpus 可防止单个本地依赖拖垮开发机,也能帮助复现资源不足。它们受到 daemon、宿主内核和 Docker Desktop 虚拟机资源上限影响,不能直接复制成生产规格。若同时声明 deploy.resources.limits.memory/cpus,数值必须与服务级字段一致;reservations 表达调度或软保留意图,不等于本地 Engine 已为进程预留了独占资源。
docker compose up -d state
docker stats --no-stream "$(docker compose ps -q state)"
docker inspect "$(docker compose ps -q state)" --format 'memory={{.HostConfig.Memory}} nano_cpus={{.HostConfig.NanoCpus}}'验证应观察实际 inspect 值,而不是看到 YAML 字段就算生效。反向实验可以把 APP_MEMORY 暂时降到明显不足的值,断言服务退出或健康检查失败,再读取 .State.OOMKilled、退出码和 daemon 日志;实验结束必须恢复 .env 并重建服务。生产容量要基于业务基线、峰值、并发、SLO 和故障冗余测量;本地限制只负责让资源假设显性化。
结束实验时只回收这个 Project
前面的持久化实验刻意保留了 named volume。离开实验前先展示候选对象,再用完整 project name 二次确认:
project_name="$(docker compose config --format json | jq -r '.name')"
test "$project_name" = 'compose-contract-lab'
docker compose ps --all
docker volume ls --filter "label=com.docker.compose.project=$project_name"
printf 'type CLEAN-%s to remove this project and its volume: ' "$project_name"
read -r confirmation
test "$confirmation" = "CLEAN-$project_name"
docker compose down --volumes --remove-orphans
test -z "$(docker ps -aq --filter "label=com.docker.compose.project=$project_name")"
test -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$project_name")"down --volumes 会删除这份 Compose 模型声明的 named volume 和附着在容器上的匿名 volume,因此只能在候选清单、project 断言和人工确认全部通过后执行。业务数据需要先走备份与恢复演练,不能照搬实验清理命令。
把 Compose 接入项目而不是个人命令历史
仓库应保留一个权威入口,例如 scripts/dev-up:
#!/usr/bin/env sh
set -eu
expected='compose-contract-lab'
actual="$(docker compose config --format json | jq -r '.name')"
test "$actual" = "$expected"
docker compose config --quiet
docker compose pull --quiet
docker compose up -d --wait --wait-timeout 45 state
docker compose --profile verify run --rm probe
docker compose ps --allCI 与本地可以复用 config --quiet 和短生命周期验证,但共享同一个 daemon 时要给每个任务设置唯一 project name,避免容器、网络和 volume 冲突。需要保留失败现场时,CI 应上传经过脱敏的 compose ps、服务日志、健康状态和磁盘摘要,再按 project 标签清理。
镜像必须有团队基线。开发阶段至少固定不可变 tag;需要供应链可追溯时进一步固定 digest,并建立定期升级而不是永不升级。企业代理和私有 CA 要分别验证 daemon 拉镜像、构建阶段下载依赖和容器运行时访问网络三条链,系统浏览器能访问仓库不代表 daemon 已信任同一证书。
按第一证据定位常见失败
看到 running,应用仍然拒绝连接
先看 docker compose ps 与 docker inspect ...State.Health。没有 healthcheck 时补真实就绪探测;已有探测时读取最近一次退出码和输出。端口已监听不一定代表迁移完成,探测应接近调用方真正依赖的能力。
换目录后数据像是消失了
先执行 docker context show,再用 docker volume ls --filter label=com.docker.compose.project 查看 project 标签。目录名或 -p 改变可能创建了新 project,旧 volume 通常仍在原 context 中。确认引用关系前不要执行 prune。
本机变量正确,容器里却没有
docker compose config --environment 查看插值来源,docker compose config 查看模型,docker compose exec <service> env 查看进程环境。三层证据能区分“没有插值”“模型未传入”和“程序未读取”。
服务名无法解析
执行 docker inspect 查看两个容器的 Networks,确认它们至少共享一条网络。Compose 不会因为 depends_on 自动修复显式网络隔离;固定容器 IP 只会把生命周期问题藏起来。
拉取镜像报 TLS 或超时
先区分 daemon pull、Dockerfile build 与运行中容器访问。分别检查 Docker Desktop/daemon 代理、BuildKit 配置、容器环境变量和企业 CA 信任。不要通过关闭 TLS 校验或永久启用 insecure registry 绕过诊断。
Compose 文件本身是代码执行入口
镜像、build、bind mount、privileged、host namespace、设备、secret 文件和远程 include 都能跨越主机边界。陌生 PR 中的 Compose 变更应与脚本一样评审;低信任代码不应接触生产凭证、个人主目录和 Docker socket。
就绪不等于持续可用
service_healthy 解决启动时序,却不替代应用重试、熔断和运行中故障处理。依赖启动后再次失效,调用方仍要能退避重连;健康检查的连续失败也要进入本地诊断和共享环境监控。
持久化提高效率,也会污染验证
Named volume 让依赖无需每次初始化,但旧 schema、旧账号、旧测试数据会让新代码在错误状态上“通过”。项目应同时提供保留数据的 down 与显式重置流程,重置前展示 volume、标签和备份动作,重置后重新跑初始化与最小业务验证。
本地便利不能变成共享环境架构
单机 Compose 没有自动获得跨主机调度、故障转移、备份、滚动升级和租户隔离。需要多团队共享或持续在线时,应根据状态性、可用性、审计和成本选择托管服务、Kubernetes 或专用基础设施,而不是把本地 YAML 原样搬到服务器。
版本升级要验证模型与数据两条线
升级 Compose CLI 可能改变校验和特性支持,升级镜像可能改变配置、健康检查和数据格式。团队应保存 docker compose version、解析后的模型摘要、镜像 digest 与迁移记录;回滚前确认数据格式是否仍兼容旧镜像,不能只把 tag 改回去。
Client、Server、Compose 与 context 都能输出并指向预期 daemon。Project name 有唯一权威来源,自动化删除前会校验名称与标签。多文件配置以解析后的最终模型接受评审,诊断输出会先脱敏。
核心依赖有真实 healthcheck,调用方等待就绪且自身具备重试。Profile 只承载可选工具,没有隐藏核心依赖或安全审查。网络按通信需求划分,宿主机端口只在确需外部访问时发布。
Named volume、bind mount、config 与 secret 的生命周期和所有者明确。普通停止、数据重置与全局清理是三条不同流程。镜像来源、tag/digest、代理、证书和升级节奏有 owner。
CI 使用唯一 project,失败证据脱敏后留存,清理按 project 标签执行。
