docker-compose 编排实战
docker-compose 编排实战
写在前面
单容器跑跑 nginx 不难,生产环境真正难的是多容器编排:Web + DB + Cache + Queue + Worker,这些服务之间有依赖、有顺序、有共享网络和卷。docker run 一个一个起,既容易出错又难以复用。
docker-compose 解决的就是这个问题——用一份 YAML 描述整套应用,然后一条命令启动/停止/扩缩容。本篇会从核心语法入手,然后拿公司一站式测试平台的 compose 文件做完整拆解。
一、为什么需要 compose
1.1 单 docker run 的痛苦
假设要起一个 WordPress:
docker run -d --name db \
-e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=wordpress \
-v mysql-data:/var/lib/mysql \
--restart unless-stopped \
mysql:8.0
docker run -d --name wp \
-p 8080:80 \
-e WORDPRESS_DB_HOST=db \
-e WORDPRESS_DB_PASSWORD=secret \
--link db:mysql \
--restart unless-stopped \
wordpress:latest
问题:
- 命令长,容易敲错
- 启动顺序——必须先起 db 再起 wp
- 网络、卷、环境变量分散,新人看不懂
- 没法版本化(只能写在 Confluence 或者 README)
1.2 compose 的解法
docker-compose.yml:
version: '3.8'
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: wordpress
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
wp:
image: wordpress:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_PASSWORD: secret
volumes:
mysql-data:
启动:
docker compose up -d
启动顺序:自动等待 db 健康后再启动 wp。
二、核心语法
2.1 文件版本选择
version 字段在 v2 格式里已经被废弃,但很多老教程还在写。Docker Compose v2(V2 工具,即 docker compose 命令)已经不需要这个字段了。
如果你的 compose 文件兼容 v1(docker-compose 命令带横杠的旧版本),可以保留 version: '3.8'。新项目建议不写。
2.2 services:服务定义
每个 service 对应一个容器。
image / build
services:
# 直接用现成镜像
nginx:
image: nginx:1.25-alpine
# 从 Dockerfile 构建
api:
build:
context: ./api # 构建上下文路径
dockerfile: Dockerfile # Dockerfile 路径(相对于 context)
args: # 构建参数
- NODE_ENV=production
target: production # 多阶段构建的 target 阶段
cache_from: # 缓存来源
- myregistry/api:cache
ports / expose
services:
web:
ports:
- "8080:80" # 宿主:容器
- "127.0.0.1:8443:443" # 绑定特定 IP
- "3000-3005:3000-3005" # 端口范围
internal:
expose:
- "3000" # 只暴露给同网络的其他容器,不映射到宿主机
ports 真的把端口暴露到宿主机;expose 只是声明"我用到这个端口",不实际映射。
environment / env_file
services:
api:
environment:
NODE_ENV: production
DB_HOST: db
DEBUG: "false" # 字符串值要加引号,避免被解析成布尔
env_file:
- .env.production
- .env.secrets
优先用 env_file,把所有环境变量集中到一个文件,既方便管理又方便不同环境切换。
volumes
services:
db:
volumes:
# 命名卷 - 由 docker 管理
- mysql-data:/var/lib/mysql
# 绑定挂载 - 直接挂载宿主机目录
- ./init-scripts:/docker-entrypoint-initdb.d:ro
# 匿名卷 - 不推荐,难以管理
- /var/lib/mysql
# tmpfs - 内存盘
- type: tmpfs
target: /tmp
tmpfs:
size: 100m
volumes:
mysql-data:
driver: local
driver_opts:
type: none
o: bind
device: /data/mysql # 实际存储路径
networks
services:
api:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # 不暴露到外网
internal: true 的网络没有网关,容器只能内部通信,不能访问外网——非常适合 DB、Cache 这种只给内网用的服务。
depends_on
services:
api:
depends_on:
db:
condition: service_healthy # 等 db 健康再启动
redis:
condition: service_started # 等 redis 启动就启动(不等健康)
⚠️ depends_on 只控制启动顺序,不能等应用就绪。这就是为什么一定要配合 healthcheck——只有 healthcheck 通过后,后续服务才会启动。
healthcheck
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s # 启动后 60s 内失败不算
start_period 很关键——容器刚启动时应用还没准备好,这个时间内 healthcheck 失败会被忽略。
restart
services:
api:
restart: unless-stopped # 推荐:除非手动 stop,否则总是重启
# 其他选项:
# no - 不自动重启
# always - 总是重启(连手动 stop 也会重启)
# on-failure - 异常退出时重启
2.3 volumes 顶级定义
volumes:
# 命名卷
mysql-data:
driver: local
# 带配置的命名卷
redis-data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ":/path/to/dir"
2.4 networks 顶级定义
networks:
default:
driver: bridge
name: my-project-net # 自定义网络名,默认是 项目名_default
2.5 configs 和 secrets(Swarm 模式)
services:
api:
configs:
- source: app-config
target: /etc/app/config.yml
secrets:
- source: db-password
target: /run/secrets/db_password
configs:
app-config:
file: ./config.yml
secrets:
db-password:
file: ./secrets/db_password.txt
secrets 会以 tmpfs 形式挂载到 /run/secrets/,不会留在镜像 layer 里,比 environment 更安全。
三、实战:公司测试平台 compose
公司内部一个测试平台,技术栈:
- Web: Vue 3 + Vite
- API: Node.js 20 + NestJS
- Worker: Python 3.11(跑定时任务、数据导入)
- MySQL 8: 主库
- Redis 7: 缓存 + 队列
- MinIO: 对象存储(替代 S3)
- Nginx: 反向代理
3.1 完整 compose 文件
# compose.yaml
services:
# ====== 前端 ======
web:
build:
context: ./web
target: runner
restart: unless-stopped
networks:
- frontend
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
# ====== API 服务 ======
api:
build:
context: ./api
target: runner
restart: unless-stopped
environment:
NODE_ENV: production
DB_HOST: mysql
DB_PORT: "3306"
DB_USER: api_user
DB_PASSWORD_FILE: /run/secrets/mysql_password
REDIS_HOST: redis
MINIO_ENDPOINT: minio
MINIO_PORT: "9000"
secrets:
- mysql_password
- minio_password
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_started
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ====== Worker 节点 ======
worker:
build:
context: ./worker
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
DB_HOST: mysql
REDIS_HOST: redis
secrets:
- mysql_password
depends_on:
api:
condition: service_healthy
networks:
- backend
deploy:
replicas: 2 # 起两个 worker 实例
healthcheck:
test: ["CMD", "python", "-c", "import redis; redis.Redis(host='redis').ping()"]
interval: 30s
timeout: 5s
retries: 3
# ====== MySQL ======
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
MYSQL_DATABASE: testplatform
MYSQL_USER: api_user
MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
secrets:
- mysql_root_password
- mysql_password
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d:ro
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 10
start_period: 60s
deploy:
resources:
limits:
memory: 2G
# ====== Redis ======
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ====== MinIO ======
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minio_admin
MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_password
secrets:
- minio_password
volumes:
- minio-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 3
# ====== Nginx 反代 ======
nginx:
image: nginx:1.25-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
web:
condition: service_healthy
api:
condition: service_healthy
networks:
- frontend
volumes:
mysql-data:
redis-data:
minio-data:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
secrets:
mysql_password:
file: ./secrets/mysql_password.txt
mysql_root_password:
file: ./secrets/mysql_root_password.txt
minio_password:
file: ./secrets/minio_password.txt
3.2 关键设计点解释
网络隔离
frontend 网络 (暴露 80/443)
├── nginx
├── web
└── api
↓
backend 网络 (internal,不连外网)
├── api
├── mysql
├── redis
├── minio
└── worker
api 同时加入两个网络,作为唯一桥梁。mysql/redis/minio/worker 都在 internal: true 的 backend 网络,完全不能访问外网——就算被入侵也无法下载恶意代码。
密钥管理
所有密码通过 secrets 挂载,不进环境变量、不进镜像历史:
# secrets 目录
secrets/
├── mysql_password.txt # api 用的密码
├── mysql_root_password.txt # root 密码
└── minio_password.txt
⚠️ 生产环境应该用 Docker Swarm 或外部密钥管理(Vault/AWS Secrets Manager),而不是 file: 方式。file: 适合本地开发。
资源限制
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
limits:容器最多能用的资源(超了就 OOM 杀进程或被限流)reservations:启动时预留的资源(避免调度到资源不够的机器)
⚠️ 只有 docker swarm 模式才会生效。docker compose 单机模式下不生效,要用 --compatibility 或者改用 mem_limit 等旧字段。我会在生产环境用 Swarm 或者 K8s。
日志管理
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
不限制的话容器日志会一直增长,最终把磁盘占满。json-file 驱动 + size/file 限制是最常见的配置。
健康检查串联启动
启动顺序:
mysql/redis/minio 健康 → api 健康 → web 健康 → nginx 启动
如果某个服务健康检查失败,docker compose up 会卡住,而不是把不健康的容器暴露给上游。这避免了"服务起是起来了,但数据库连不上"这种隐蔽问题。
3.3 日常运维命令
# 启动所有服务
docker compose up -d
# 查看状态
docker compose ps
# 查看日志(-f 实时跟踪, --tail 显示最近 N 行)
docker compose logs -f --tail=100 api
# 只看某个服务的日志
docker compose logs -f worker
# 进入容器
docker compose exec api sh
# 滚动重启某个服务(不中断)
docker compose up -d --no-deps api
# 扩缩容(仅 Swarm 模式)
docker compose up -d --scale worker=4
# 停止并删除容器
docker compose down
# 停止并删除容器、网络,保留卷
docker compose down --volumes
# 验证 compose 文件
docker compose config
3.4 .env 文件与多环境
.env.production:
COMPOSE_PROJECT_NAME=testplatform
TAG=v1.2.3
NGINX_HOST=testplatform.company.com
.env.staging:
COMPOSE_PROJECT_NAME=testplatform-staging
TAG=develop
NGINX_HOST=staging.testplatform.company.com
compose 文件里可以用 ${TAG},.env 里的值会自动注入:
services:
api:
image: myregistry/api:${TAG}
启动时指定环境:
docker compose --env-file .env.production up -d
四、compose 的局限
compose 不是万能的,有这些场景它搞不定:
| 场景 | 推荐方案 |
|---|---|
| 多机集群 | Docker Swarm / Kubernetes |
| 自动扩缩容 | Kubernetes HPA |
| 滚动更新零中断 | Kubernetes Deployment |
| 服务发现 + 负载均衡 | Kubernetes Service + Ingress |
| 配置中心 | Consul / etcd |
一句话:compose 是单机多容器编排;生产多机用 K8s。本系列最后一篇会讲从 compose 到 K8s 的迁移。
五、本篇小结
| 关键点 | 说明 |
|---|---|
| services 定义 | 每个服务是一个容器 |
| depends_on + healthcheck | 控制启动顺序,等健康再启 |
| networks 分层 | frontend / backend 隔离 |
| secrets 管密码 | 不进环境变量、不进镜像 |
| volumes 持久化 | 命名卷优于匿名卷 |
| deploy 资源限制 | limits/reservations(Swarm 模式) |
下一篇讲 Docker 网络模式详解,实战一个排查案例:容器连不上宿主 Redis 的来龙去脉。