把 docker compose up -d 直接写进发布脚本,是很多团队踩过坑的做法。问题不在命令本身,而在于 Compose 文件的解析发生在容器真正启动之前:当 .env 缺变量、YAML 缩进错位、端口写成字符串、挂载路径引用了未定义变量时,报错往往夹杂在镜像拉取和容器创建日志里,排查成本高,而且失败可能发生在替换了部分容器之后,留下半新半旧的状态。
docker compose config 是 Compose 提供的「解析并渲染」子命令:它读取 compose 文件、加载环境变量、做变量插值、校验结构,最后输出最终生效的配置。加上 --quiet 后它只做校验,不打印内容,成功时退出码为 0,失败时退出码非 0 并把错误写到 stderr。把它放在部署脚本的最前面,就等于在动任何容器之前先做一次「干跑」,把变量替换问题和格式问题挡在门外。
下面从命令行为、变量替换规则、校验能力边界、脚本封装、常见报错五个层面展开,最后给一份可直接落地的发布前检查清单。
一、命令行为与退出码:先把工具用对
先看清 config 的常用参数组合,很多人以为 --quiet 只是「少打印点」,实际上它的语义是「只校验,不输出」。
# 渲染并打印最终配置(用于人工核对变量替换结果)
docker compose config
# 只做校验:成功无输出、退出码 0;失败把原因写到 stderr、退出码非 0
docker compose config --quiet
# 校验指定文件,并显式指定 env 文件与项目名
docker compose \
-f deploy/compose.yaml \
--env-file deploy/.env.prod \
-p myapp \
config --quiet
# 校验并输出为 JSON,方便脚本里用 jq 断言关键字段
docker compose config --format json | jq -r '.services | keys[]'
# 一次性校验多个文件(多文件会合并,后者覆盖前者)
docker compose -f compose.yaml -f compose.prod.yaml config --quiet
几个必须注意的行为细节:
- 退出码语义:
0表示解析成功,非0表示失败。脚本里用set -e或显式判断$?都可以,但不要把config --quiet的输出重定向到/dev/null把 stderr 一起吞掉,否则报错信息就丢了。 - 默认读取文件:不指定
-f时依次查找compose.yaml、compose.yml、docker-compose.yaml、docker-compose.yml。如果目录里同时存在新旧两种命名,容易读到不是你想的那份,发布脚本里建议永远显式-f。 - env 文件优先级:
--env-file只影响 Compose 自身做变量插值时读取的文件,不改变容器内的环境变量。不指定时默认读当前工作目录下的.env,而不是 compose 文件所在目录的.env——这是跨目录执行脚本时最常见的「变量明明写了却没生效」。 - 项目名:不指定
-p时取目录名。CI 中工作目录往往是随机路径,导致项目名漂移,进而出现「校验通过但上线创建了新的一套容器」。用-p固定项目名。 - version 字段:Compose V2 已忽略顶层
version,保留会打印一条 obsolete 警告,不影响退出码,但会污染日志,建议删掉。
实践建议:把
docker compose -f <file> --env-file <env> -p <project> config --quiet作为唯一入口封装成函数,所有脚本都调用它,避免参数散落各处导致行为不一致。
二、变量替换:三种写法与默认值的取舍
Compose 的插值语法是 ${VAR} 系列,它发生在 YAML 解析之后、服务创建之前。config 正是把这一步提前暴露出来的工具。
services:
api:
image: registry.example.com/myapp/api:${TAG:?TAG 未设置}
environment:
DB_HOST: ${DB_HOST:-127.0.0.1}
DB_PORT: ${DB_PORT:-5432}
LOG_LEVEL: ${LOG_LEVEL-default}
ports:
- "${HOST_PORT}:8080"
volumes:
- ${DATA_DIR:-./data}:/var/lib/app
deploy:
replicas: ${REPLICAS:-2}
三种写法的区别决定了「变量缺失时是报错还是静默兜底」:
| 写法 | 变量未设置时 | 变量为空字符串时 | 适用场景 |
|---|---|---|---|
${VAR:?msg} |
直接报错并输出 msg | 同样报错 | 镜像 tag、数据库密码等必须有值的项 |
${VAR:-default} |
用 default | 用 default | 端口、副本数等可兜底的项 |
${VAR-default} |
用 default | 保留空字符串 | 明确允许为空的项 |
$$ |
转义为字面量 $ |
— | 容器内需要 $ 的脚本片段 |
最容易被忽略的是 image 上的 tag。用 ${TAG:-latest} 看似稳妥,实则危险:CI 里忘传 TAG 时校验照样通过,上线却拉了个 latest,等同于静默降级。这时应该用 ${TAG:?TAG 未设置},让 config --quiet 在部署前直接失败。
另一个高频坑是端口。Compose 要求端口映射的左右两端是「数字或数字范围」,写成 ${HOST_PORT}:8080 且 HOST_PORT 为空时,渲染结果变成 :8080,config 会报端口格式错误。更隐蔽的情况是把整个映射写成 ports: ["$PORT_MAPPING"],当环境变量里带了空格或引号,YAML 层面看起来没问题,渲染后才暴露。校验时用 config --format json 断言 .services.api.ports[0].published 是数字字符串,比肉眼看日志更可靠。
# 断言渲染后的端口与镜像 tag 符合预期,防止默认值悄悄兜底
rendered=$(docker compose -f compose.yaml --env-file .env.prod -p myapp config --format json)
echo "$rendered" | jq -e '.services.api.ports[0].published | test("^[0-9]+$")' >/dev/null
echo "$rendered" | jq -e '.services.api.image | endswith(":latest") | not' >/dev/null
还要记住 .env 与 compose 文件里 env_file: 的区别:env_file 指定的文件是给容器用的,不参与 ${} 插值。想让变量参与插值,必须让它出现在 --env-file、Shell 环境变量或 compose 文件所在目录的 .env 中。优先级为:Shell 环境变量 > --env-file 指定文件 > 默认 .env。因此 CI 里如果存在同名环境变量,会覆盖文件里的值,校验结果可能和本地不一致,发布脚本应显式 env -i 或清理相关变量,或反过来利用这一点用环境变量做一次性覆盖。
三、校验能拦住什么、拦不住什么
把 config --quiet 当万能校验会失望。它做的是「Compose 规范层面的解析与结构校验」,不是业务校验。
能够拦截
- YAML 语法错误:缩进、tab 字符、重复 key、非法锚点引用。
- 顶层与服务层字段拼写错误:如
enviroment、depends_on写成列表以外的结构。 - 类型错误:
ports写成映射、replicas写成非整数、environment写成非法结构。 - 变量缺失:被
${VAR:?msg}引用但未定义。 - 服务间引用错误:
depends_on指向不存在的服务、extends目标不存在。 - 多文件合并后的冲突:同一服务在多个文件中定义了不兼容的字段类型。
无法拦截
- 镜像是否存在、tag 是否可拉取——那是 registry 的事。
- 宿主端口是否已被占用——只有真正创建容器时才会撞。
- 挂载源路径是否存在(bind mount)——
config不触碰宿主文件系统。 - 镜像内健康检查命令是否可用、
healthcheck的语义是否正确。 - 用了
deploy.resources但目标不是 Swarm 或 Compose V2 支持范围时的静默忽略。
所以合理的分层是:config --quiet 负责「语法 + 插值 + 结构」三件事,属于秒级、零副作用的检查;端口占用、镜像可达性、路径存在性放到校验之后用独立的小检查补上。
# 补充检查:渲染后逐项验证宿主端口未被占用
docker compose -f compose.yaml --env-file .env.prod -p myapp config --format json \
| jq -r '.services[] | .ports[]? | .published' \
| sort -u \
| while read -r port; do
if ss -ltn "sport = :$port" | grep -q LISTEN; then
echo "端口 $port 已被占用" >&2
exit 1
fi
done
四、封装成发布前检查脚本
把校验、渲染存档、断言三步固化进脚本,让发布流程在任何容器被触碰之前就失败。下面的脚本可直接运行,参数化程度足够覆盖常见 CI 场景。
#!/usr/bin/env bash
# preflight.sh —— 部署前预检:变量替换 + 格式校验 + 关键字段断言
set -Eeuo pipefail
COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}"
ENV_FILE="${ENV_FILE:-.env.prod}"
PROJECT="${PROJECT:-myapp}"
ARTIFACT_DIR="${ARTIFACT_DIR:-.preflight}"
log() { printf '[preflight] %s\n' "$*" >&2; }
fail() { printf '[preflight][ERROR] %s\n' "$*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令"
docker compose version >/dev/null 2>&1 || fail "未安装 docker compose 插件"
[[ -f "$COMPOSE_FILE" ]] || fail "compose 文件不存在: $COMPOSE_FILE"
[[ -f "$ENV_FILE" ]] || fail "env 文件不存在: $ENV_FILE"
# 1. 格式与变量替换校验:失败立即退出,不触碰任何容器
log "校验 $COMPOSE_FILE(env: $ENV_FILE, project: $PROJECT)"
if ! err=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" config --quiet 2>&1); then
fail "compose 校验失败:\n$err"
fi
log "格式与变量替换校验通过"
# 2. 渲染存档,便于回滚比对与事后审计
mkdir -p "$ARTIFACT_DIR"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" \
config > "$ARTIFACT_DIR/rendered.yaml"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" \
config --format json > "$ARTIFACT_DIR/rendered.json"
log "已生成 $ARTIFACT_DIR/rendered.yaml 与 rendered.json"
# 3. 关键字段断言:防止默认值静默兜底
jq -e '.services | length > 0' "$ARTIFACT_DIR/rendered.json" >/dev/null \
|| fail "渲染结果中没有任何 service"
if jq -e '.services[] | select(.image? and (.image | endswith(":latest")))' \
"$ARTIFACT_DIR/rendered.json" >/dev/null; then
fail "存在使用 latest 标签的镜像,请在 env 中显式指定 TAG"
fi
if jq -e '.services[] | select(.volumes[]?.source? | startswith("/"))' \
"$ARTIFACT_DIR/rendered.json" >/dev/null; then
log "存在绝对路径 bind mount,请确认目标机路径已就绪"
fi
log "预检全部通过"
几个值得照着抄的工程细节:
set -Eeuo pipefail中的-E让 ERR trap 在函数与子 shell 中生效,pipefail保证| jq这类管道任一段失败都能被捕获。- 用
err=$(... 2>&1)捕获 stderr 再打印,比直接执行更容易在 CI 日志里定位到具体的文件与行号。 - 同时输出 YAML 与 JSON 两份存档:YAML 给人看、做 diff,JSON 给
jq做断言。 - 断言只覆盖「静默兜底会造成事故」的项,不要写成大而全的 schema 校验,否则脚本会变成维护负担。
提交
.preflight/rendered.yaml到发布工单或对象存储,可以做到「线上到底跑的是哪份渲染结果」可追溯。排查事故时,比对这份文件与当前仓库的 compose 文件,比回忆当时的环境变量高效得多。
五、常见报错与定位方法
以下报错均来自实际执行 docker compose config 的输出,按出现频率排序。
| 报错信息(节选) | 原因 | 处理方式 |
|---|---|---|
yaml: line 12: did not find expected key |
缩进用了 tab 或层级错位 | 改用空格;grep -Pn "\t" compose.yaml 定位 tab |
error while interpolating services.api.image: required variable TAG is missing a value: TAG 未设置 |
${TAG:?} 引用的变量未提供 |
检查 --env-file 路径与 CI 变量注入 |
services.api.environment must be a mapping |
environment 写成了列表混映射 |
统一改成 KEY: value 或 - KEY=value |
services.api.ports contains an invalid type, it should be a number or an object |
端口字符串带非法字符,如 ${HOST_PORT} 为空 |
用 ${HOST_PORT:-8080} 兜底 |
service "api" depends on undefined service "cache" |
depends_on 指向的服务名拼错或未定义 |
核对服务名,注意多文件合并时是否被覆盖 |
services.api.deploy.replicas must be a integer |
变量值带空格或引号,如 REPLICAS="2 " |
清理 env 文件中的多余空白与引号 |
version is obsolete |
顶层 version 字段 |
删除该字段,不影响退出码但污染日志 |
定位时优先用「渲染 → diff」的思路,而不是盯着报错猜:
# 对比不同 env 文件渲染出的差异,快速看出哪个变量没生效
docker compose -f compose.yaml --env-file .env.dev -p myapp config > /tmp/dev.yaml
docker compose -f compose.yaml --env-file .env.prod -p myapp config > /tmp/prod.yaml
diff -u /tmp/dev.yaml /tmp/prod.yaml
# 单独确认某个变量在 Compose 眼中是什么值
docker compose -f compose.yaml --env-file .env.prod config --format json \
| jq -r '.services.api.environment.DB_HOST'
还有一类问题不出现在报错里,而是「校验通过、上线行为不对」:--env-file 路径是相对当前工作目录的,如果你在 scripts/ 目录下执行脚本而 .env.prod 在项目根,Compose 会读到不存在的文件并静默退化为「只使用 Shell 环境变量」,于是 ${VAR:-default} 全部走了默认值。加一条 [[ -f "$ENV_FILE" ]] || fail 就能把这类事故提前挡住,这也是上面脚本里那行检查的意义。
小结
docker compose config --quiet 的价值不在于命令本身有多复杂,而在于它把「变量替换 + YAML 结构」这两类最廉价也最高频的失败,提前到了部署的第一步。配合三点实践就能稳定落地:一是所有调用统一封装、显式指定 -f、--env-file、-p;二是关键变量用 ${VAR:?} 拒绝兜底,可兜底的项用 ${VAR:-} 但要通过 --format json 做断言;三是把渲染结果存档,让「线上跑的是哪份配置」可追溯。它拦不住端口占用与镜像可达性,但那些检查成本低、用几行 ss 与 jq 补齐即可,职责分开反而更好维护。
FAQ
Q1:config --quiet 和 config 的输出差异会不会影响脚本判断?
不会影响退出码,二者在解析失败时都返回非 0。--quiet 只是抑制标准输出,只在需要断言渲染结果时才去掉它并用 --format json 配合 jq。
Q2:为什么本地校验通过,CI 上却报变量缺失?
大概率是 --env-file 用了相对路径而 CI 工作目录不同,或 CI 中已存在同名环境变量覆盖了文件值(Shell 环境变量优先级高于 --env-file)。脚本中显式校验文件存在,并打印当前生效的变量值排查。
Q3:多文件覆盖时,怎么确认合并结果符合预期?
用相同的 -f 顺序执行 config 渲染出 YAML,与单文件渲染结果 diff。config 输出的就是合并后的最终形态,比人脑推演覆盖规则可靠。
Q4:config 能校验 healthcheck 里写的命令吗?
只能校验结构(如 test 必须是列表或字符串、interval 必须是时长字符串),不会在镜像里执行命令。命令是否存在于镜像中,只能等容器启动后由健康状态体现。
Q5:Docker Compose V1(docker-compose)是否支持这些参数?
V1 的 config 支持 --quiet、--format json、-f、--env-file,但不支持 --project-directory 之外的部分新语义,且 V1 已停止维护。发布脚本里用 docker compose version 做一次版本探测,缺失插件时直接失败更省事。
Q6:校验会不会真的去拉镜像或创建网络?
不会。config 只做本地解析与渲染,不访问 Docker daemon 创建任何资源,因此可以安全地在任意机器、任意次数重复执行,也可以放进 pre-commit 钩子。