
很多 Docker Compose 项目都有一段相似的生命周期:
第一天,只有一个应用和一个数据库,docker compose up -d 之后一切正常;一个月后,Redis、对象存储、反向代理、消息队列、监控面板陆续加入;半年后,compose.yaml 已经超过数百行,变量散落在多个 .env 文件中,谁也不敢轻易升级镜像,更没人能准确说明删掉某个卷会发生什么。
问题往往不在 Docker Compose 本身,而在于我们把它当成了“容器启动命令的存档”,而不是一份应用运行模型。
Docker 官方对 Compose 的定义很直接:用一个 Compose 文件描述构成应用的服务、网络、卷、配置和密钥,再由 Compose 创建并管理这些资源。换句话说,Compose 文件不仅要让项目启动,还应当回答下面这些问题:
- 系统由哪些组件组成?
- 每个组件依赖谁?
- 哪些接口允许对外暴露?
- 哪些数据需要持久化?
- 服务如何判断自己已经可以工作?
- 密钥存放在哪里?
- 失败后如何恢复?
如果这些问题没有答案,即使项目现在能跑,也只是把复杂性暂时藏了起来。
本文默认使用现代的
docker compose命令,而不是已经进入历史阶段的docker-compose独立命令。刚开始配置 Windows、WSL2 与 Docker Desktop,可以先阅读站内的《Windows 11 + WSL2 开发环境搭建全教程》。
一、先理解:Compose 管理的不是容器,而是应用

单独看一个容器时,我们关心镜像、端口和启动命令;看一个真实应用时,还必须关心服务关系、网络边界、数据生命周期和故障传播。
例如一个常见 Web 系统可能包含:
用户
│
▼
反向代理 ──► Web/API ──► PostgreSQL
│
├────► Redis
└────► 对象存储
这张图真正重要的地方,不是“有五个容器”,而是其中隐含的约束:
- 只有反向代理应直接对外开放端口;
- API 可以访问数据库,但用户不能直接访问数据库;
- Redis 丢失缓存可以重建,数据库丢失数据则不可接受;
- API 必须等待数据库真正可用,而不只是等待数据库容器进程启动;
- 对象存储、数据库和应用日志需要不同的备份策略。
当 Compose 文件能够准确表达这些关系时,它才开始具备工程价值。
二、不要把所有内容塞进一个文件
小项目只有一个 compose.yaml 没有问题,但当开发、测试、生产的差异越来越多时,继续把条件判断和临时配置堆进同一个文件,维护成本会快速上升。
一种比较清晰的结构是:
project/
├─ compose.yaml
├─ compose.dev.yaml
├─ compose.prod.yaml
├─ .env.example
├─ env/
│ ├─ dev.env
│ └─ prod.env.example
├─ secrets/
│ └─ README.md
├─ deploy/
│ ├─ nginx/
│ └─ scripts/
└─ data/
└─ .gitkeep
其中:
compose.yaml保存所有环境共有的基础结构;compose.dev.yaml添加源码挂载、调试端口和开发工具;compose.prod.yaml添加资源限制、只读文件系统、日志轮转和生产镜像;.env.example只保存变量名称与安全的示例值;- 真正的密钥不进入 Git。
启动时明确指定组合:
docker compose -f compose.yaml -f compose.dev.yaml up -d
现代 Compose 也支持 include,适合把相对独立的子系统拆分为各自维护的 Compose 文件。但不要为了“看起来高级”而过度拆分。判断标准只有一个:拆分之后,边界是否更清楚,修改是否更安全。
三、尽量不要写 container_name
很多人习惯给每个服务写固定容器名:
services:
api:
container_name: my-api
这看起来更直观,但会带来三个问题:
- 同一台机器上难以并行启动多个相同项目;
- 固定名称容易与其他环境冲突;
- 指定
container_name后,该服务无法正常扩展为多个容器实例。
Compose 已经会根据项目名、服务名和序号生成容器名称。服务之间通信时,直接使用服务名即可:
postgres://db:5432/app
redis://redis:6379
真正需要稳定的是服务发现名称,而不是宿主机上看起来整齐的容器名。
项目名可以通过目录名、顶层 name、-p 参数或 COMPOSE_PROJECT_NAME 控制,比硬编码每一个容器名更合理。
四、不要把 latest 当成版本策略
下面这种配置短期很方便:
services:
db:
image: postgres:latest
但它无法回答一个关键问题:下次部署时,拉到的还是不是同一个镜像?
更合理的做法至少固定到明确的大版本或小版本:
services:
db:
image: postgres:18.1-alpine
对稳定性要求更高时,可以进一步固定镜像摘要:
image: postgres:18.1-alpine@sha256:...
版本固定并不意味着永远不升级,而是把升级从“无意中发生”变成“经过审查后主动发生”。一个成熟的升级流程通常包括:
查看更新说明 → 备份数据 → 测试环境升级 → 执行迁移 → 验证回滚 → 生产升级
真正危险的不是旧版本,而是你不知道版本何时发生了变化。
五、区分三种完全不同的“变量”
Compose 项目中最容易混淆的是环境变量。至少要区分三类:
1. Compose 插值变量
用于生成 Compose 配置:
image: myapp:${APP_VERSION}
ports:
- "${APP_PORT:-8080}:8080"
这些值通常来自项目目录下的 .env 或命令行指定的 --env-file。
2. 容器运行时环境变量
真正传给容器内进程:
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
3. 应用密钥
例如数据库密码、JWT 私钥、云服务密钥。这些内容不应因为“环境变量方便”就全部塞进 .env。
建议为每个变量写清楚:
| 变量 | 用途 | 是否敏感 | 默认值 | 必填 |
|---|---|---|---|---|
APP_VERSION |
选择应用镜像版本 | 否 | 无 | 是 |
LOG_LEVEL |
日志级别 | 否 | info |
否 |
DB_PASSWORD |
数据库密码 | 是 | 无 | 是 |
同时让必填变量在缺失时立即失败:
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
与其让应用启动五分钟后才报一个难以理解的连接错误,不如在生成配置时就明确拒绝启动。
六、密钥不要和普通配置混在一起
Docker 官方明确建议,对密码、证书和 API Key 等敏感信息使用 Secrets,而不是简单地作为普通环境变量注入。Compose Secret 会按服务授权,并以文件形式挂载到容器内的 /run/secrets/。
services:
db:
image: postgres:18.1-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
这样做并不会自动解决所有安全问题:宿主机上的 Secret 文件仍然需要正确设置权限,备份中也不能随意包含密钥。但它至少建立了一个重要边界:
普通配置可以进入版本库,敏感信息必须通过独立通道注入。
生产环境还可以进一步接入 OpenBao、Vault、云厂商密钥管理服务或部署平台自身的 Secret 系统。
七、depends_on 不等于“依赖已经可用”
很多 Compose 文件都写过:
services:
api:
depends_on:
- db
这种短写法只保证数据库服务先被启动,不保证数据库已经完成初始化并能接受连接。数据库进程可能还在恢复日志、执行迁移或创建用户,此时 API 依然可能启动失败。
更可靠的方式是为依赖服务定义健康检查:
services:
db:
image: postgres:18.1-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
api:
image: example/api:1.4.2
depends_on:
db:
condition: service_healthy
还要注意:健康检查不是“进程还活着”检查,而应尽量验证服务是否真的具备工作能力。Web 服务可以检查 /health/ready,数据库可以执行原生命令,消息队列可以验证管理接口或协议握手。
不过健康检查也不能替代应用自身的重试机制。真实系统中,依赖可能在运行期间短暂失效,因此应用仍应具备超时、退避重试和熔断能力。
八、只暴露真正需要暴露的端口
下面的写法会把数据库端口映射到宿主机:
ports:
- "5432:5432"
开发环境为了本地调试可以这样做,但生产环境通常没有必要。Compose 网络内的 API 可以直接通过 db:5432 访问数据库,不需要把数据库端口开放给宿主机,更不需要开放给公网。
一个更清晰的网络结构是:
services:
proxy:
image: nginx:1.29-alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend
api:
image: example/api:1.4.2
networks:
- frontend
- backend
db:
image: postgres:18.1-alpine
networks:
- backend
networks:
frontend:
backend:
internal: true
此时:
- 反向代理可以接收外部请求;
- API 同时连接前端网络与后端网络;
- 数据库只存在于内部网络;
- 数据库不会因为一个随手写下的端口映射而暴露出去。
安全并不总是来自复杂的防火墙规则,很多时候只是来自“根本不开放”。
九、不要只考虑持久化,还要考虑恢复
给数据库挂一个卷,只能说明“容器删除后数据还在”,不能说明数据是安全的。
volumes:
db_data:
services:
db:
volumes:
- db_data:/var/lib/postgresql/data
真正完整的数据策略需要回答:
- 卷位于哪里?
- 哪些数据必须备份?
- 多久备份一次?
- 备份保留多久?
- 备份是否加密?
- 能否在另一台机器恢复?
- 最近一次实际恢复测试是什么时候?
可以把数据分成三类:
| 数据类型 | 示例 | 丢失后处理 |
|---|---|---|
| 权威数据 | 数据库、用户上传文件 | 必须备份并验证恢复 |
| 可重建数据 | 缓存、搜索索引 | 保留重建流程即可 |
| 临时数据 | 构建缓存、临时文件 | 通常不需要备份 |
最容易制造虚假安全感的一句话是:“我们每天都在备份。”
真正重要的问题应该是:“我们最近一次成功恢复是什么时候?”
十、为资源、日志和停止过程设定边界
没有资源边界的容器,在异常时可能拖垮整台服务器;没有日志轮转的容器,可能在几周后写满磁盘;没有优雅停止时间的服务,可能在更新时中断正在执行的任务。
services:
api:
image: example/api:1.4.2
cpus: 1.5
mem_limit: 1g
pids_limit: 256
init: true
stop_grace_period: 30s
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
这些配置的意义分别是:
cpus、mem_limit:防止单个服务无限占用资源;pids_limit:限制异常进程扩散;init: true:帮助正确转发信号并回收僵尸进程;stop_grace_period:给应用处理未完成请求和关闭连接的时间;- 日志轮转:避免默认 JSON 日志无限增长;
restart:处理进程异常退出,但不能掩盖持续崩溃。
restart: always 不是可靠性的代名词。一个配置错误的服务每十秒重启一次,只是在持续制造日志和压力。
十一、用 Profiles 管理“偶尔才需要”的服务
数据库管理界面、调试代理、性能分析器、邮件测试服务,通常只在特定场景使用。不要让它们默认随着核心服务一起启动。
services:
api:
image: example/api:1.4.2
adminer:
image: adminer:5
profiles: ["debug"]
ports:
- "127.0.0.1:8081:8080"
默认启动核心系统:
docker compose up -d
需要调试工具时:
docker compose --profile debug up -d
Profiles 的价值不是少启动一个容器,而是让 Compose 文件显式表达:哪些是系统核心组成,哪些只是特定场景下的辅助能力。
十二、把“能启动”升级为“可验证”
提交 Compose 文件之前,至少运行:
docker compose config --quiet
docker compose config
第一条检查配置是否合法,第二条展示变量插值、文件合并后的最终结果。它经常能提前发现:
- 变量没有设置;
- 缩进或字段拼写错误;
- 多个 Compose 文件合并结果与预期不同;
- 镜像标签或端口被意外覆盖;
- Secret、网络或卷引用不存在。
运行后还可以使用:
docker compose ps
docker compose logs --tail=200
docker compose events
docker compose top
进一步检查服务状态、日志、生命周期事件与进程。
更成熟的项目可以在 CI 中执行:
语法检查
↓
生成最终配置
↓
拉取或构建镜像
↓
启动测试环境
↓
等待健康检查
↓
执行接口与迁移测试
↓
销毁环境并检查残留
这也是 AI 编程时代尤其值得坚持的一点:AI 可以迅速生成一份结构漂亮的 Compose 文件,但它不能仅凭文本保证镜像存在、健康检查正确、前后端已经连通,更不能保证数据真的可以恢复。
使用 Codex 等工具进行工程开发时,可以参考站内的《OpenAI Codex 安装与使用全指南》,但无论代码由谁生成,最终都应该由可重复的验证流程给出结论。
一个相对完整的基础示例
下面这个示例没有试图覆盖所有场景,但已经表达了镜像版本、健康检查、密钥、网络隔离、日志与资源边界:
name: example-app
services:
proxy:
image: nginx:1.29-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
restart: unless-stopped
logging: &default_logging
driver: json-file
options:
max-size: "20m"
max-file: "5"
api:
image: example/api:${APP_VERSION:?APP_VERSION must be set}
environment:
APP_ENV: ${APP_ENV:-production}
DATABASE_HOST: db
DATABASE_PORT: "5432"
DATABASE_NAME: app
DATABASE_USER: app
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- frontend
- backend
cpus: 1.5
mem_limit: 1g
pids_limit: 256
init: true
stop_grace_period: 30s
restart: unless-stopped
logging: *default_logging
db:
image: postgres:18.1-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- backend
restart: unless-stopped
logging: *default_logging
adminer:
image: adminer:5
profiles: ["debug"]
ports:
- "127.0.0.1:8081:8080"
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt
使用前仍要根据镜像实际包含的工具调整健康检查。例如有些精简镜像没有 wget 或 curl,这不是 Compose 能替你猜测的事情。
结语:可维护性来自明确,而不是来自 YAML 技巧
Docker Compose 项目失控,通常不是因为文件太长,而是因为其中存在太多没有被说清楚的事情:
- 谁依赖谁没有说清楚;
- 什么能对外访问没有说清楚;
- 哪些变量是密钥没有说清楚;
- 数据如何恢复没有说清楚;
- 服务何时算健康没有说清楚;
- 哪些配置只用于开发没有说清楚;
- 修改之后如何验证没有说清楚。
所谓工程化,本质上就是不断消除这些模糊地带。
一份优秀的 Compose 文件不一定短,也不一定使用了所有新特性。它只需要让下一位维护者在打开文件后,能够准确理解系统如何启动、如何连接、如何失败,以及失败之后如何恢复。
当一个项目不仅“今天能跑”,而且“半年后仍然有人敢改”,它才真正越过了演示代码与工程系统之间的边界。
延伸阅读
- Docker Compose 官方文档
- Compose services 配置参考
- Docker Compose Secrets
- Docker Compose Profiles
- Windows 11 + WSL2 开发环境搭建全教程
- OpenAI Codex 安装与使用全指南
配图来源:Docker 官方网站、Docker 官方文档与 Docker Compose 开源仓库。为兼顾中国大陆访问,Compose 标志使用 jsDelivr 对开源仓库资源进行分发。
为什么你的 Docker Compose 项目越写越乱?从“能跑”到“可维护”的 12 条工程实践
https://wangling.hauchet.cn/archives/docker-compose-maintainable-engineering-practices
评论