KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · Compose:把"一组容器"描述成一份可解析的声明 — keel 龙骨

这一章回答:compose.yml 里的声明最终变成了什么?depends_on 写了为什么还连不上?变量从哪儿来、谁优先?

这一章回答:compose.yml 里的声明最终变成了什么?depends_on 写了为什么还连不上?变量从哪儿来、谁优先?

前两章造的是一个镜像。真实系统里不会只有一个容器:应用、worker、数据库、缓存各有各的容器,它们之间还有启动顺序和依赖关系。Compose 的作用就是把这一组容器的关系写成一份声明。

这一章有一个贯穿的方法:不靠读文件猜,用解析工具把它展开。

现场

一个多服务项目,api 与 db 都在 compose.yml 里,api 写了依赖:

  api:
    depends_on:
      - db

docker compose up 之后,api 的日志里是:

Error: connect ECONNREFUSED 172.20.0.3:5432

db 的日志显示它确实启动了,只是在几秒后才输出 database system is ready to accept connections。同事的修法是加一个 sleep 10——它能用,但下次换台慢机器又会挂。

真正的问题在于 depends_on 的默认语义:它等的是"容器被启动了",不是"里面的服务可以接受连接了"。这两件事之间有一段真实存在的、长度不确定的窗口。

一、Compose 声明了哪几类东西

compose.yml 的顶层结构只有四个概念:

概念 描述什么 谁创建
services 一组要跑的容器,每个用 image 或 build 定义 Compose
networks 服务之间能不能互相看见 Compose(不写也会自动创建一个默认的)
volumes 需要跨容器重建存活的数据 Compose
变量插值 声明里那些 ${...} 的值从哪来 由 CLI 在解析期替换,与容器无关

最后一行是本章的关键:${...} 不是在容器里被替换的,而是在你的机器上、解析文件的那一刻就被替换掉了。 所以它属于"解析期"的问题——你可以在不启动任何容器的情况下验证它。

二、docker compose config:把声明展开

这是本节课里最有用的一条命令,因为它是纯客户端解析:不需要 daemon 运行,因此可以放进 CI 当作一份"配置语法与变量完整性"的检查。

把 compose.yml 展开(本机实测,Docker CLI 29.2.1):

$ docker compose -f compose.yml config
name: dockerlab
services:
  api:
    build:
      context: C:\...\dockerlab\app
      dockerfile: Dockerfile.multi
    depends_on:
      db:
        condition: service_healthy
        required: true
      migrate:
        condition: service_completed_successfully
        required: true
    deploy:
      resources:
        limits:
          cpus: 1.5
          memory: "536870912"
    environment:
      DATABASE_URL: postgresql://app:localdev@db:5432/orders
    ...
    restart: unless-stopped
    stop_grace_period: 10m0s

输出的每一处都是能直接用的信息:

输出的变化 说明什么
context 变成绝对路径 相对路径是相对文件所在目录解析的,不是相对你敲命令的目录
depends_on 下多了 required: true 依赖被展开成结构化对象,且条件是显式的
memory: "536870912" 512M 被归一化成字节(单位的真实含义被暴露出来)
stop_grace_period: 10m0s 10m 被规范化成 10m0s(第 06 章用得上)
name: dockerlab 项目名:没写 name 时取自目录名,它决定了网络与卷的前缀

所以"我写的配置到底生效成什么"这个问题,有一把确定的尺子,不需要靠猜。

三、变量从哪来:一张表加一条报错

变量清单

$ docker compose -f compose.yml config --variables
NAME                REQUIRED            DEFAULT VALUE       ALTERNATE VALUE
API_PORT            false               8080
APP_TAG             false               dev
DATABASE_URL        true
DB_PASSWORD         true
LOG_LEVEL           false               info

这张表是本节信息密度最高的输出。它把声明里的三类变量分开了:

声明写法 语义 表里的表现
${LOG_LEVEL:-info} 没给就用默认值 REQUIRED=false,DEFAULT VALUE=info
${DATABASE_URL:?DATABASE_URL is required} 没给或为空 → 报错终止 REQUIRED=true
${APP_TAG} 没给就是空 REQUIRED=false,无默认值

REQUIRED=true 的两项,正是"配错了就起不来"的两项——数据库连接串和数据库口令。把必填项标成必填是最便宜的防呆:它让"忘配环境变量"从"启动后连不上数据库"的运行时故障,提前成"解析期直接报错"。

报错长什么样

同一个文件,换一个空的 env 文件(本机实测):

$ docker compose --env-file empty.env -f compose.yml config
error while interpolating services.api.environment.DATABASE_URL: required variable DATABASE_URL is missing a value: DATABASE_URL is required
exit=1

注意报错里的路径 services.api.environment.DATABASE_URL——它直接告诉你是哪个服务、哪个字段。第一条命中的必填项就终止,所以修完一个还会冒出下一个,这是预期行为。

优先级:shell 环境变量压过 .env

Compose 的变量来源有优先级,实测验证了其中最重要的一条:shell 里已有的环境变量优先于 .env 文件。

$ docker compose -f compose.yml config | grep orders-api
    image: orders-api:1.4.2          # .env 里 APP_TAG=1.4.2

$ APP_TAG=9.9.9 docker compose -f compose.yml config | grep orders-api
    image: orders-api:9.9.9          # shell 变量覆盖了 .env

这条性质有两个使用后果:

还有一条容易错位的规则:.env 从"项目目录"读取,不是从 build.context 读取。 一个项目里可以同时存在两个 .env——外层给 Compose 用,app/ 下的那个属于应用自己的事,Compose 不会去读它。这一点在第 05 章会用一个具体的对照验证。

四、启动顺序:depends_on 的三种语义

回到现场那个 ECONNREFUSED。depends_on 有三种写法,语义完全不同:

写法 等到什么才启动依赖方 什么时候用
depends_on: [db](列表) 容器被创建并启动(不保证进程就绪) 基本没有理由用
condition: service_healthy 依赖方的 healthcheck 判定通过 长驻服务:数据库、缓存、依赖的上游
condition: service_completed_successfully 依赖方运行结束且退出码为 0 一次性任务:数据库迁移、初始化脚本

现场那个 bug 的修法就是把第一种换成第二种——而它成立的前提是依赖方定义了 healthcheck。这就是为什么"健康检查"不是可选项:它是 depends_on 的一个条件能否被表达的前提。

一次完整运行

compose.yml 里三个服务的关系是:db(有 healthcheck)→ migrate(一次性,成功退出)→ api(依赖前两者的条件)。解析出的拓扑顺序:

$ docker compose -f compose.yml config --services
db
migrate
api

按顺序展开它们的依赖关系与失败分支:

flowchart TD
  START["docker compose up"] --> NET["① 创建项目网络<br/>创建卷"]
  NET --> S1["② 启动 db<br/>postgres:16-alpine"]

  S1 --> H{"③ db healthcheck<br/>pg_isready 通过?"}
  H -->|"未通过<br/>最多 10 次 x 5s"| WAIT["继续等待<br/>(不启动任何下游)"]
  WAIT --> H
  H -->|"重试耗尽 / 容器退出"| FAIL1["④ up 失败:<br/>migrate 与 api 都不启动"]

  H -->|"通过"| S2["⑤ 启动 migrate<br/>command: node dist/migrate.js"]
  S2 --> M{"⑥ migrate 退出码 = 0?"}
  M -->|"非 0"| FAIL2["④ api 不启动<br/>service_completed_successfully 未满足"]
  M -->|"= 0"| S3["⑦ 启动 api"]

  S3 --> HC["⑧ api 的 healthcheck<br/>按 10s 周期探测"]
  HC -.->|"连续失败 3 次"| UNHEALTHY["标记为 unhealthy<br/>(容器仍在跑)"]
  HC -->|"通过"| READY["标记为 healthy"]

图上要看清两件事:

第一,第 ⑥ 跳是"退出码"而不是"仍存活"。 migrate 是一个跑完就退出的容器——service_completed_successfully 等的是它的终态。这就是"迁移必须先成功"能被声明出来的方式。api 的启动顺序不再依赖人的记忆。

第二,第 ⑧ 跳的失败分支(虚线)不改容器状态。 healthcheck 判为 unhealthy 只是打了一个标记,Compose 默认不会因此重启容器(restart 策略管的是进程退出,不是健康标记)。这一步很容易误判成"Compose 会自动拉起来"。真正依据健康标记做流量决策的是负载均衡层(Caddy/Nginx/Traefik),到 K8s 才由探针与 Service 承担。

五、网络与卷:Compose 替你创建了什么

不写 networks 和 volumes 时,Compose 会自动创建默认对象。用一份最小文件(只有一个 image)就能把注入的默认值暴露出来(本机实测):

$ docker compose -f _defaults.yml config
name: dockerlab
services:
  tiny:
    image: busybox
    networks:
      default: null
networks:
  default:
    name: dockerlab_default

这一小段输出了三条可用的知识:

  1. 默认网络确实会被创建,名字是 <项目名>_default(这里项目名取自目录 dockerlab)。两个 Compose 项目的服务不会互相看见,就是因为网络名带了项目前缀。
  2. 服务名就是 DNS 名。 api 连数据库时写的主机名是 db,靠的就是同一网络内按服务名解析。这也解释了一个常见困惑:"我把数据库换到另一个 compose 项目里,为什么主机名 db 就解析不到了"——它们不在同一个网络里。
  3. 卷名同样带项目前缀。volumes: pgdata 展开成 dockerlab_pgdata。这意味着 docker compose down -v 只删本项目前缀的卷,而两个不同目录跑起来的同名项目会各自建卷、互不干扰——这既是保护,也是"数据怎么没了/怎么又多了一份"的来源。

六、资源限制:deploy 在非 Swarm 下会被归一化

compose.yml 里资源限制写在 deploy.resources.limits 下:

    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M

解析结果里,除了保留 deploy 结构,还多出一个归一化后的字段:

api      mem_limit = 536870912
api      stop_grace_period = 10m0s

512M → 536870912 字节。这一处值得注意,因为 deploy 是 Swarm 的语法,Compose(非 Swarm 模式)只支持它的一个子集:limits 这类会被翻译成容器运行时的等价参数(mem_limit / cpus),而 replicas、placement 这类调度语义在非 Swarm 模式下不生效。

所以有两个判断:

(资源限制生效之后会怎么表现——OOMKill、exit 137——是第 07 章。)

生产边界

教学替身 真实替换点 要注意什么
单机 docker compose up 生产用编排系统(Swarm / K8s) deploy 的子集差异(上一节);depends_on 的条件语义在 K8s 里由探针 + initContainer 表达,不是同一套写法
用 config 手工检查 CI 里把 docker compose config -q 作为一次静态检查 能拦住语法错与变量缺失,拦不住"变量有值但值是错的"
默认网络 <project>_default 显式声明 networks 并区分内外网 显式声明后才好在网络层做隔离(只有前端网络暴露)
pg_isready 作为健康判据 按服务真实的可服务判据设计 健康检查的粒度要匹配"能服务"的定义:TCP 通 ≠ 能查表(第 07 章展开)

动手

  1. 用 docker compose -f <file> config 展开你手上的 compose.yml,逐项核对:变量都被替换成了什么、有没有字段被归一化、项目名是什么;
  2. 跑 config --variables,把 REQUIRED=true 的项列出来,逐个确认它们在目标环境里确实会被提供;
  3. 跑 config --services,与你的 depends_on 对照,画出实际启动顺序;
  4. 判断标准:能回答"如果 migrate 失败,api 会不会启动",并且用 condition 的写法指出来;
  5. 完成标志:故意清空一个必填变量,拿到那句 required variable ... is missing a value 报错,并说出它指出了哪个服务、哪个字段。

故障注入

注入方式 观察什么 说明的现象
把 condition: service_healthy 改回裸 depends_on: [db] api 是否报连接失败 裸写法只等"容器启动",不等"服务就绪"——现场那类 ECONNREFUSED 的来源
让 migrate 以非 0 退出 api 是否启动 service_completed_successfully 检查的是终态
清空 DATABASE_URL 解析是否终止、退出码 必填变量让故障提前到解析期(实测退出码 1)
在 shell 里 export 一个与 .env 同名的变量 config 输出里的实际值 shell 优先于 .env,.env 会静默失效
docker compose down -v 卷是否被删除 卷名带项目前缀,删除范围由前缀决定(-v 会删数据,别在真环境随手用)
把 memory: 512M 改成 memory: 512 config 展开出的字节数 不带单位按字节解释,限制值差 6 个数量级

自测

  1. ${VAR:-default}、${VAR:?msg}、${VAR} 三者在"变量没设置"时的行为分别是什么?哪一种会把故障提前到解析期?
  2. 为什么"用 sleep 等数据库启动"是错的修法?正确的表达需要依赖方具备什么?
  3. service_healthy 与 service_completed_successfully 分别检查什么?各举一个适用的服务类型。
  4. 一个容器被 healthcheck 标记为 unhealthy 之后,Compose 会自动重启它吗?那这个标记是给谁用的?
  5. 两个不同的 Compose 项目里都有一个服务叫 db。它们能互相连通吗?请从网络命名和 DNS 解析两个角度说明。
  6. docker compose config 能帮你发现哪些问题、不能发现哪些问题?

现在能解释什么

下一章处理"东西怎么进去":那些变量和密钥在三道边界上分别是什么状态,以及哪一种做法会把它们留在镜像里。

进入 keel 阅读