06-Docker Compose 编排
前五篇已经分别处理了镜像、容器、Dockerfile、网络和持久化。单独看每一项并不复杂,但当一个真实应用同时包含 Web 服务、后台任务、数据库、缓存、反向代理、多个网络和数据卷时,继续依靠手工执行 docker run 会迅速失控。

1. 从多个 docker run 走向可维护的应用
前五篇已经分别处理了镜像、容器、Dockerfile、网络和持久化。单独看每一项并不复杂,但当一个真实应用同时包含 Web 服务、后台任务、数据库、缓存、反向代理、多个网络和数据卷时,继续依靠手工执行 docker run 会迅速失控。
假设要启动一个最小业务系统,至少要回答下面这些问题:
- 每个容器使用什么镜像,是否需要本地构建?
- 容器名称、重启策略、启动命令和环境变量是什么?
- 哪些端口需要发布到宿主机,哪些服务只能在内部网络访问?
- 数据库和缓存应该连接哪个网络?
- 数据放在哪个命名卷,配置文件是否只读挂载?
- 应用什么时候算真正可用,依赖未就绪时如何处理?
- 变更镜像、配置或环境变量后,哪些容器应该重建?
- 系统发生异常时,如何查看统一状态、日志和最终生效配置?
如果把这些信息散落在 Shell 历史、Wiki 片段和个人经验里,部署无法审查,也无法可靠复现。Docker Compose 的价值,就是把一个多容器应用声明为一个项目:服务、网络、卷、配置和依赖关系集中写入 compose.yaml,再由 docker compose 命令统一完成校验、创建、启动、观察、更新和清理。
本文采用“多服务实战 walkthrough + 运维排障 runbook”的形式。目标不是罗列 Compose 字段,而是建立一条可以在开发、测试和单机生产场景复用的编排链路。读完后,你应该能够理解 Compose 项目模型,编写符合当前 Compose Specification 的配置,使用健康检查和条件依赖降低启动风险,并根据最终配置、状态、日志、网络和卷证据定位常见故障。
本文以 Docker Engine 与 Compose 插件为基线,统一使用带空格的 docker compose 命令。旧式 docker-compose 独立命令以及为了兼容旧实现而常见的顶层 version: "3" 写法,不作为当前主线。具体能力仍应以本机 docker compose version 和官方文档为准。
2. Compose 的核心心智模型
Compose 不是另一套容器运行时。真正创建网络、卷和容器的仍然是 Docker Engine;Compose 是声明式配置和项目级生命周期管理层。
这张图把编排层、应用层、数据层和运行层分开。后文所有字段最终都在描述四件事:运行哪些服务、它们如何连接、状态如何保存,以及如何判断服务可用。
2.1 Project:一组资源的管理边界
一次 Compose 部署首先是一个项目。项目名会影响容器、默认网络和命名卷等资源的名称。默认情况下,项目名通常从 Compose 文件所在目录推导,也可以通过 -p、COMPOSE_PROJECT_NAME 或配置中的顶层 name 指定。
同一份配置可以启动两个相互隔离的实验环境:
docker compose -p shop-dev up -d
docker compose -p shop-test up -d
docker compose -p shop-dev ps
docker compose -p shop-test ps
两组服务使用相同的服务名,但资源归属不同项目。生产环境应显式固定项目名,不要让目录改名意外改变资源名称,更不要在排障时把不同项目的同名容器混在一起。
2.2 Service:可复制的运行定义
services 下的每个条目是一种服务定义,例如 proxy、web、worker、db、redis。服务描述镜像、构建方式、命令、环境变量、挂载、网络、健康检查等信息。服务可以对应一个或多个容器实例。
服务名同时是同一 Compose 网络内的 DNS 名称。应用连接数据库时,主机名应该写 db,而不是容器临时 IP,也不是 127.0.0.1:
postgresql://app:password@db:5432/app
容器里的 127.0.0.1 指向容器自身。把数据库地址写成 localhost 是多容器应用最常见的错误之一。
2.3 Network:服务可见性的边界
如果不声明网络,Compose 会为项目创建一个默认网络,并把服务接入其中。简单实验可以使用默认网络;更清晰的生产设计应显式划分网络:
frontend:反向代理与 Web 应用通信。backend:Web、Worker、数据库和缓存通信。- 数据库与缓存不接入
frontend,也不发布宿主机端口。
网络分区不能替代主机防火墙和应用鉴权,但能减少不必要的服务互访,帮助读者从配置中直接看出流量边界。
2.4 Volume:数据生命周期的边界
Compose 可以声明命名卷,并把卷挂载到服务的数据目录。容器更新、重建或删除时,命名卷默认不会跟着丢失。数据库卷与容器生命周期解耦,是多容器应用可维护的基础。
需要特别区分:
docker compose down
默认删除项目容器和网络,但保留命名卷。下面的命令会额外删除声明的命名卷,可能导致数据不可恢复:
docker compose down -v
生产操作手册应把 -v 视为破坏性参数,不应出现在日常重启命令中。
2.5 配置与密钥边界
环境变量适合短小的运行参数。较长的配置文件可以使用只读 bind mount 或 Compose configs;敏感值应优先由外部密钥系统、受控文件或运行平台注入。
不要把真实密码直接提交到 compose.yaml,也不要因为 .env 不会写进镜像,就误以为它天然安全。.env 仍是本地明文文件,需要设置权限、加入 .gitignore,并纳入密钥轮换流程。
3. 实验场景与目录设计
本次实验使用一个简化的 Python Web 应用,配合 PostgreSQL、Redis、后台 Worker 和 Nginx。重点是 Compose 编排方法,不依赖特定业务代码。
| 项目 | 建议 |
|---|---|
| 操作系统 | Ubuntu 22.04/24.04 LTS,或安装 Docker Desktop 的开发机 |
| Docker | 当前受支持的 Docker Engine 或 Docker Desktop |
| Compose | Docker Compose v2 插件 |
| 文件名 | compose.yaml |
| 网络 | frontend 与 backend 两个自定义 bridge |
| 持久化 | PostgreSQL 使用命名卷 pgdata |
| 暴露面 | 只把 Nginx 的 8080 端口发布到宿主机 |
确认环境:
docker version
docker compose version
docker info
如果 docker compose version 不可用,应按 Docker 官方安装文档安装 Compose 插件,不要为了临时通过而从未知来源下载旧二进制。
创建目录:
mkdir -p ~/compose-lab/{app,nginx}
cd ~/compose-lab
目录结构:
compose-lab/
├── compose.yaml
├── .env.example
├── .gitignore
├── app/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app.py
└── nginx/
└── default.conf
.gitignore 至少包含:
.env
*.log
__pycache__/
.pytest_cache/
仓库中提交 .env.example,只保留变量名和安全示例;实际 .env 由部署流程生成或从密钥管理系统下发。
4. 准备应用镜像和反向代理
创建 app/requirements.txt:
flask==3.1.1
gunicorn==23.0.0
psycopg[binary]==3.2.9
redis==6.2.0
版本号只是实验基线,正式使用前应根据包仓库、兼容性和安全扫描结果更新。
创建 app/app.py:
import os
from flask import Flask, jsonify
import psycopg
import redis
app = Flask(__name__)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]
@app.get("/")
def index():
return jsonify(service="compose-lab", status="ok")
@app.get("/health")
def health():
checks = {}
try:
with psycopg.connect(DATABASE_URL, connect_timeout=2) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
checks["database"] = cur.fetchone()[0] == 1
except Exception:
checks["database"] = False
try:
checks["redis"] = redis.from_url(
REDIS_URL, socket_connect_timeout=2
).ping()
except Exception:
checks["redis"] = False
return jsonify(checks=checks), 200 if all(checks.values()) else 503
创建 app/Dockerfile:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
创建 nginx/default.conf:
server {
listen 80;
location / {
proxy_pass http://web:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
反向代理使用服务名 web 访问应用,不需要知道应用容器 IP。即使 web 因重建而获得新 IP,只要服务名和网络不变,Docker 内置 DNS 会继续提供名称解析。
5. 编写当前规范的 compose.yaml
name: compose-lab
services:
proxy:
image: nginx:1.28-alpine
ports:
- "127.0.0.1:${APP_PORT:-8080}:80"
volumes:
- type: bind
source: ./nginx/default.conf
target: /etc/nginx/conf.d/default.conf
read_only: true
depends_on:
web:
condition: service_healthy
networks: [frontend]
restart: unless-stopped
web:
build:
context: ./app
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
- CMD
- python
- -c
- >-
import urllib.request;
urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)
interval: 10s
timeout: 4s
retries: 6
start_period: 20s
networks: [frontend, backend]
restart: unless-stopped
worker:
build:
context: ./app
command:
- python
- -c
- >-
import time;
print('worker started', flush=True);
time.sleep(86400)
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks: [backend]
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks: [backend]
restart: unless-stopped
redis:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
networks: [backend]
restart: unless-stopped
networks:
frontend:
backend:
internal: true
volumes:
pgdata:
这份配置没有顶层 version 字段。当前 Compose Specification 由 Compose 实现解析,旧式版本号不再是表达新配置的必要入口。
5.1 只发布入口端口
只有 proxy 声明 ports。web、db 和 redis 只通过内部网络提供服务。示例绑定到 127.0.0.1,适合本机验证或由宿主机反向代理继续转发。
如需局域网访问,应按安全评审结果绑定明确的业务网卡地址,并同步检查主机防火墙、云安全组和上游 ACL。不要因为访问不通就直接开放所有地址。
5.2 depends_on 不等于业务永远可用
短语法依赖主要表达创建和启动顺序,不能自动证明数据库已经可以接受连接。使用 condition: service_healthy 时,Compose 可以等待依赖健康检查通过后再启动依赖方。
但这仍然不是业务容错的替代品。数据库可能在应用启动后重启,网络也可能瞬时中断。应用自身仍需具备连接超时、重试、退避、连接池回收和可观测性。
5.3 健康检查要接近真实可用性
只检查进程是否存在,往往会得到“容器健康但业务不可用”的假象。Web 服务的 /health 同时检查 PostgreSQL 与 Redis,因此更接近实际就绪状态。
健康检查也不能无限复杂。外部支付、短信或第三方 API 短暂异常时,如果健康检查把本服务判定为失败,可能触发不必要的重启风暴。应区分进程存活、流量就绪和外部依赖健康。
5.4 双美元符号避免主机提前插值
数据库健康检查中的 $${POSTGRES_USER} 使用双美元符号,是为了把变量表达式留给容器内 Shell,而不是让 Compose 在主机侧先替换。变量到底在哪一层展开,是 Compose 配置中非常容易出错的细节。
6. 环境变量与最终配置
创建 .env.example:
APP_PORT=8080
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=replace-me
生成实验用 .env:
cp .env.example .env
chmod 600 .env
生产环境不要沿用示例密码。应由部署流程注入随机高强度值,并记录轮换、回滚和灾难恢复方法。
Compose 的变量来源可能包括 Shell 环境、.env、--env-file、配置中的 environment 和 env_file。复杂项目最容易出现“以为用了 A,实际被 B 覆盖”的问题。因此每次部署前都应查看解析后的配置:
docker compose config
docker compose config --services
docker compose config --images
docker compose config 会展开变量和合并配置,输出中可能出现敏感值。不要把完整输出直接贴到公开工单或聊天群。
如需指定独立变量文件:
docker compose --env-file .env.test config
docker compose --env-file .env.test up -d
验证与启动必须使用同一变量文件,否则预检查和实际部署可能不是同一份配置。
7. 构建、启动与分阶段验证
先拉取基础镜像并构建本地服务:
docker compose pull
docker compose build
docker compose images
docker compose config --quiet
启动项目:
docker compose up -d
不要把命令成功返回等同于应用可用。按以下顺序验证。
7.1 项目级状态
docker compose ps
docker compose ps --all
关注服务是否全部创建,状态是 running、exited 还是持续重启,健康状态是否达到 healthy,入口端口绑定是否符合预期。
7.2 日志与进程
docker compose logs --tail=100
docker compose logs -f --tail=50 proxy web db redis
docker compose top
日志用于定位,不应成为唯一监控手段。生产环境还需要日志采集、指标、告警和资源使用监控。
7.3 网络解析
docker compose exec web python -c \
'import socket; print(socket.gethostbyname("db")); print(socket.gethostbyname("redis"))'
docker compose exec web cat /etc/resolv.conf
docker network ls
docker network inspect compose-lab_backend
如果服务名无法解析,先确认两个服务是否连接到同一网络,不要改成固定容器 IP。
7.4 应用访问
curl -fsS http://127.0.0.1:8080/
curl -fsS http://127.0.0.1:8080/health
如果入口返回 502,分别检查:
docker compose ps
docker compose logs --tail=100 proxy web
docker compose exec proxy wget -qO- http://web:8000/health
这能把问题缩小为入口端口、Nginx 配置、服务名解析、Web 监听地址或应用健康检查。
7.5 数据持久化
创建测试表:
docker compose exec db psql -U app -d app -c \
'CREATE TABLE IF NOT EXISTS compose_check(id integer primary key, note text);'
docker compose exec db psql -U app -d app -c \
"INSERT INTO compose_check VALUES (1, 'persisted') ON CONFLICT (id) DO UPDATE SET note = EXCLUDED.note;"
重建数据库容器并复验:
docker compose up -d --force-recreate db
docker compose exec db psql -U app -d app -c \
'SELECT * FROM compose_check;'
如果数据仍在,说明命名卷在容器重建后继续挂载。这个验证比只看 docker volume ls 更有意义。
8. 日常更新、扩缩与可选服务
修改配置、环境变量、镜像标签或构建上下文后:
docker compose config --quiet
docker compose up -d --build
Compose 会比较期望状态与现有资源,对需要变化的服务执行重建。数据库等未变化服务通常不需要先删除。
只重启进程:
docker compose restart web
restart 不会应用新的 Compose 配置,也不会重新构建镜像。如果修改了环境变量、端口、卷或镜像,单纯重启不能生效。
拉取新镜像并更新:
docker compose pull
docker compose up -d
docker compose ps
生产环境不要长期使用不可追踪的 latest。应使用明确版本或不可变摘要,并在测试环境完成兼容性验证。
临时扩展无状态 Worker:
docker compose up -d --scale worker=3
docker compose ps worker
扩展前要确认服务没有固定 container_name、没有冲突的宿主机端口,并且任务消费逻辑支持并发。Compose 的单机扩展适合实验和小规模任务,不等同于跨主机集群编排。
调试工具可以使用 Profile:
services:
adminer:
image: adminer:5
profiles: ["debug"]
networks: [backend]
需要时显式启用:
docker compose --profile debug up -d adminer
Profile 只是启动选择机制,不会自动提供鉴权或网络安全。
9. 故障排查:从最终配置开始
这张图把运行生命周期与五类常见故障放在同一条证据链上。排障不要从反复 down/up 开始,而应先确认最终配置,再沿状态、日志、端口、网络和卷逐层定位。
9.1 配置无法解析或变量为空
症状包括 YAML 错误、变量未设置、端口或镜像名与预期不一致,以及多文件合并后字段被意外覆盖。
docker compose config
docker compose config --quiet
docker compose config --environment
处理时先修复缩进、字段类型和变量来源,再核对工作目录、-f 参数与 --env-file。对必填变量可使用错误提示:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
9.2 服务持续重启或健康检查失败
docker compose ps --all
docker compose logs --tail=200 web
docker compose top web
docker inspect "$(docker compose ps -q web)" --format '{{json .State}}'
常见原因包括启动命令错误、变量缺失、应用只监听 127.0.0.1、依赖尚未初始化、文件权限错误,或者健康检查命令在镜像中不存在。
可以临时进入一次性容器调试:
docker compose run --rm --no-deps web sh
如果镜像没有 Shell,应使用镜像提供的诊断方式或构建专用调试镜像。
9.3 端口冲突或外部无法访问
docker compose ps
docker compose port proxy 80
sudo ss -lntp | grep ':8080'
curl -v http://127.0.0.1:8080/
如果绑定到 127.0.0.1,局域网客户端无法直接访问是预期行为。如果需要外部访问,应调整为明确的业务网卡地址,并同步检查防火墙和上游策略。
9.4 服务名无法解析或网络不通
docker compose exec web getent hosts db
docker compose exec web getent hosts redis
docker compose exec web cat /etc/resolv.conf
docker network inspect compose-lab_backend
常见原因是服务没有共同网络、应用使用 localhost、依赖固定容器 IP,或者 internal: true 与真实外联需求冲突。网络隔离必须根据流量设计,不能机械套用。
9.5 数据卷权限错误或数据看似消失
docker compose exec db id
docker compose exec db ls -ld /var/lib/postgresql/data
docker volume ls
docker volume inspect compose-lab_pgdata
docker inspect "$(docker compose ps -q db)" --format '{{json .Mounts}}'
重点判断实际挂载的是不是预期卷,项目名变化是否创建了另一个卷,容器运行用户是否有权限,bind mount 路径是否存在,以及是否执行过 down -v。
不要在不理解镜像用户模型时对数据目录递归执行 chmod 777。这会掩盖权限设计问题,并扩大安全风险。
10. 备份、恢复与清理边界
Compose 只负责声明卷,不自动提供数据库一致性备份。数据库应优先使用自身的逻辑备份、物理备份或快照集成。
PostgreSQL 逻辑备份:
mkdir -p backups
docker compose exec -T db \
pg_dump -U app -d app -Fc > backups/app-$(date +%F).dump
验证:
test -s backups/app-$(date +%F).dump
file backups/app-$(date +%F).dump
恢复应在隔离环境演练:
docker compose exec -T db createdb -U app app_restore
docker compose exec -T db \
pg_restore -U app -d app_restore < backups/app-2026-06-14.dump
清理项目容器与网络:
docker compose down
docker compose down --remove-orphans
删除命名卷前必须确认备份和数据归属:
docker compose down -v
清理镜像、构建缓存或全局未使用资源属于主机级操作,影响范围超过当前项目。不要把 docker system prune -a --volumes 当作普通项目清理命令。
11. 多环境配置与生产化边界
一份基础配置可以叠加开发或生产差异:
docker compose \
-f compose.yaml \
-f compose.dev.yaml \
config
开发覆盖可以加入源码 bind mount、调试端口和热更新命令;生产覆盖可以固定镜像摘要、日志驱动和只读文件系统。无论使用多少文件,都应以 docker compose config 的最终输出作为审查对象。
需要警惕三类复杂化:
- 覆盖文件过多,没人能判断最终值来自哪里。
- 开发环境依赖宿主机目录,生产环境却误用同一 bind mount。
- 配置文件承担了密钥管理、发布审批和跨主机调度等不属于它的职责。
Compose 适合本地开发、自动化测试依赖、单机实验室、边缘节点和有明确备份监控流程的小型单机生产服务。
当需求进入跨主机调度、节点故障迁移、滚动升级、弹性伸缩、复杂密钥管理和多租户隔离时,应评估 Kubernetes、Nomad、Docker Swarm 或云平台托管编排能力。不要因为 Compose 文件已经很长,就误认为它自然升级成了集群控制平面。
12. 团队上线清单
配置审查
- [ ] 使用
compose.yaml和当前docker compose命令。 - [ ]
docker compose config --quiet通过。 - [ ] 项目名、镜像版本和变量来源明确。
- [ ] 真实密钥没有进入 Git、镜像层和公开日志。
- [ ] 只有入口服务发布宿主机端口。
- [ ] 数据库和缓存不无必要地暴露到外部网络。
运行可靠性
- [ ] 关键服务有有效健康检查。
- [ ] 应用具备依赖连接重试与超时。
- [ ] 重启策略与故障处理目标一致。
- [ ] 无状态服务与有状态服务的更新方式分开。
- [ ] 日志可采集,磁盘增长有上限和告警。
数据与恢复
- [ ] 持久化数据使用明确命名卷或受控存储。
- [ ] 已验证容器重建后数据仍存在。
- [ ] 数据库备份不是简单复制活动数据目录。
- [ ] 恢复流程在隔离环境演练过。
- [ ] 运维手册明确标注
down -v的破坏性。
安全基线
- [ ] 应用镜像尽量使用非 root 用户。
- [ ] 配置文件采用只读挂载。
- [ ] 不需要互联网访问的网络评估
internal。 - [ ] 调试服务通过 Profile 或独立配置控制。
- [ ] Docker socket 未直接挂载给普通业务容器。
13. 小结
Docker Compose 的关键不是把多条 docker run 改写成 YAML,而是形成可审查、可复现、可验证的应用运行契约。
可靠的 Compose 项目应具备清晰的服务边界、最小化的端口暴露、明确的网络分区、独立的数据生命周期、可解释的变量来源和有效健康检查。日常操作应围绕 config、up、ps、logs 和 down 建立证据链:先确认期望状态,再观察实际状态,最后实施最小范围变更。
Compose 能显著降低单机多容器应用的部署复杂度,但不会自动解决应用容错、数据库备份、日志治理、密钥管理和跨主机高可用。把边界说清楚,才能既发挥 Compose 的效率,又避免把它用成不可维护的临时平台。
下一篇将进入容器安全,讨论镜像来源、非 root 运行、能力集、只读文件系统、密钥暴露和 Docker daemon 权限等风险。
14. 参考资料
- Docker Compose file reference
- Docker Compose CLI reference
- How Compose works
- Control startup order
- Environment variables in Compose
- Use profiles with Compose
- Compose Specification repository