用 docker compose config –quiet 做部署前的变量替换与格式预检

把 docker compose up -d 直接写进发布脚本,是很多团队踩过坑的做法。问题不在命令本身,而在于 Compose 文件的解析发生在容器真正启动之前:当 .env 缺变量、YAML 缩进错位、端口写成字符串、挂载路径引用了未定义变量时,报错往往夹杂在镜像拉取和容器创建日志里,排查成本高,而且失败可能发生在替换了部分容器之后,留下半新半旧的状态。

把 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 钩子。