先看一个我踩过的坑
周三下午,我把一个 PHP 8.3 项目的日志级别从 info 改成 debug。配置文件躺在 API 容器里,我改了之后需要重启让 env 生效。这个项目一共 8 个容器:Nginx、两个 PHP-FPM、MySQL、Redis、Redis Commander、一个 Node 写的 Cron 服务、一个队列 Worker。
我的第一反应是 docker restart api。结果 API 起来了,Nginx 的 upstream 缓存还指着旧 IP。于是我又重启 Nginx。Nginx 起来了,Redis 连接池又崩了。我干脆把 8 个容器全删了,按启动顺序一个个 docker run,该等 MySQL 初始化完成,该等 Redis 写入健康检查。整个过程花了 11 分钟,期间还有两个容器因为依赖服务没就绪直接退出。
这事的根子出在:我用一堆 shell 脚本做编排。docker run 每一个都是独立命令,没有依赖关系、没有健康检查、没有配置漂移管控。改一次配置等于做一次手工运维。这就是我要写这篇文章的原因。
问题拆解:手工编排到底痛苦在哪
上面那个场景,本质上暴露了四个问题:
- 容器启动顺序不可控:MySQL 没起来,PHP-FPM 先跑,连库失败直接退出
- 服务间通信靠 IP:容器重建后 IP 变了,写死在配置文件里的地址全部失效
- 配置改动无法追踪:一个 env 变量散落在三个启动脚本里,改了找不到记录
- 缺乏健康检查机制:没有任何一个脚本能判断服务是否真正可用,只看进程在不在
我的方案很简单:把 8 个容器的启动配置写进一个 docker-compose.yml,用 Docker Compose v2 管理生命周期。所有服务之间通过服务名互相访问,Compose 内置 DNS 解析搞定 IP 变化。启动顺序用 depends_on 加 condition 控制,服务就绪用 healthcheck 判定。
方案对比:bash 启动脚本 vs Docker Compose
动手之前,我实际写了两个版本做对比。第一个版本是纯 bash 部署脚本,137 行,支持 start / stop / restart。第二个版本是 Docker Compose,25 行 yaml。
| 维度 | bash 部署脚本 | Docker Compose |
|---|---|---|
| 配置描述 | shell 变量拼接 docker run 参数 | yaml 声明式描述全部服务 |
| 依赖控制 | 手动 sleep,时间长短靠猜 | depends_on + condition 精确判断 |
| 服务发现 | 写死容器 IP,重建即失效 | 内置 DNS,服务名自动解析 |
| 变量管理 | export 环境变量,作用域难控 | .env 文件 + ${VAR} 插值 |
| 扩展方式 | 复制脚本,改端口,手动注册 | --scale 一行命令扩展多实例 |
| 可维护性 | 改一处逻辑,要查十几个函数 | 配置即代码,git 可追踪 |
结论不复杂:规模超过 5 个容器,脚本方案的时间成本会指数级上升。Compose 的声明式语法,让基础设施的变更像代码一样可以被 review。
完整代码实现:从零编排一个微服务项目
项目结构和版本清单
我用的环境是 Docker 24.0.7 + Docker Compose v2.23.0,宿主机 Ubuntu 22.04。项目结构如下:
microservice-project/
├── docker-compose.yml
├── docker-compose.override.yml
├── .env
├── nginx/
│ └── conf.d/
│ └── default.conf
├── api/ # PHP 8.3-FPM 主 API
├── cron/ # Node 18 定时任务
├── sql/
│ └── init.sql
└── scripts/
└── healthcheck.sh
核心编排文件 docker-compose.yml
主文件定义了 6 个服务,覆盖一个典型 Web 项目的完整链路。注释里写了每个配置项的作用,这些参数都是压测调过的。
version: "3.8" # Compose v2 已废弃,不留兼容性的话可以直接删掉这行
services:
nginx:
image: nginx:1.25.3-alpine
ports:
- "8080:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./api:/var/www/html:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
- backend
api:
build:
context: ./api
dockerfile: Dockerfile
image: microservice-api:latest
environment:
- APP_ENV=production
- DB_HOST=mysql
- DB_PORT=3306
- REDIS_HOST=redis
expose:
- "9000"
healthcheck:
test: ["CMD", "php", "/var/www/html/healthcheck.php"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
cron:
image: node:18.17.1-alpine
volumes:
- ./cron:/app
working_dir: /app
command: node scheduler.js
depends_on:
mysql:
condition: service_healthy
networks:
- backend
mysql:
image: mysql:8.0.35
environment:
- MYSQL_ROOT_PASSWORD=rootpwd
- MYSQL_DATABASE=app
- MYSQL_USER=app
- MYSQL_PASSWORD=apppwd
volumes:
- db_data:/var/lib/mysql
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpwd"]
interval: 3s
timeout: 2s
retries: 20
start_period: 20s
networks:
- backend
redis:
image: redis:7.2.1-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 10
start_period: 5s
networks:
- backend
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
redis:
condition: service_healthy
networks:
- backend
volumes:
db_data:
redis_data:
networks:
frontend:
backend:
开发环境覆盖配置
生产环境和本地开发最重要的区别是端口冲突和调试工具。我用 override 文件解决,不需要改主文件。Compose 读取顺序是 docker-compose.yml 先读,再读 docker-compose.override.yml,同名 key 用覆盖值。
# docker-compose.override.yml - 仅本地开发使用
services:
api:
environment:
- APP_ENV=development
- XDEBUG_MODE=debug
volumes:
- ./api:/var/www/html:cached # 覆盖为可写挂载,改动即时生效
ports:
- "9000:9000"
nginx:
ports:
- "80:80" # 本地用 80,避免和线上 8080 混淆
mysql:
ports:
- "3307:3306" # 本地 3306 可能被宿主机 MySQL 占用,错开
使用方式和主文件一样:docker compose up -d。override 文件在 CI/CD 环境不参与构建,部署服务器上只保留 docker-compose.yml。
Nginx 配置:服务发现的关键细节
容器间通信不走端口映射,直接用 Compose 网络内部的 DNS 解析。Nginx 配置文件里 upstream 指向的是服务名 api,不是 IP。
upstream microservice_api {
# 这里写的是 docker compose 服务名,Compose DNS 会自动解析到对应容器 IP
server api:9000;
keepalive 32;
}
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass microservice_api;
fastcgi_keep_conn on;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
# 静态文件直接本机返回,不经过 PHP-FPM,压测数据里的 RPS 提升靠这个
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
}
环境变量文件 .env
Compose 会自动读取根目录的 .env 文件,用 ${VAR} 插值到 compose 文件里。这样敏感信息不进 git,也不会散落在多个 shell 脚本里。
# .env - 不要提交到 git,用 .env.example 占位
COMPOSE_PROJECT_NAME=microservice
MYSQL_ROOT_PASSWORD=rootpwd
MYSQL_DATABASE=app
MYSQL_USER=app
MYSQL_PASSWORD=apppwd
REDIS_MAXMEMORY=256mb
对应的 compose 文件里改为引用:
mysql:
image: mysql:8.0.35
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
SQL 初始化脚本
mysql 镜像首次启动会自动执行 /docker-entrypoint-initdb.d/ 目录下的 .sql 文件。这个特性配合数据卷,可以完成空库初始化,只跑一次。
-- sql/init.sql - 仅在数据卷为空时执行
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (name, email) VALUES ('benchmark', 'bench@example.com') ON DUPLICATE KEY UPDATE name='benchmark';
健康检查脚本
PHP 的 healthcheck 不能只检测进程存在,要检测应用层真正可响应。我写了一个轻量的 healthcheck.php,放在 API 容器里,检查数据库连接和 Redis 连接。
true];
try {
$pdo = new PDO(
'mysql:host=mysql;dbname=app;charset=utf8mb4',
'app',
'apppwd',
[PDO::ATTR_TIMEOUT => 2]
);
$pdo->query('SELECT 1');
} catch (Throwable $e) {
$status['mysql'] = 'unreachable';
$status['ok'] = false;
}
try {
$redis = new Redis();
$redis->connect('redis', 6379, 2);
$redis->ping();
} catch (Throwable $e) {
$status['redis'] = 'unreachable';
$status['ok'] = false;
}
http_response_code($status['ok'] ? 200 : 503);
header('Content-Type: application/json');
echo json_encode($status);
健康检查通过的标准是退出码为 0。上面代码里没显式 exit,PHP 默认 return 0,检测失败时 http_response_code 设为 503,但进程退出码仍然是 0。这会导致 Docker 误判。快速修正一下:
一键启动流程
所有配置就绪后,启动整个项目只需要一条命令:
$ docker compose up -d
[+] Running 10/10
✔ Network microservice_backend Created
✔ Network microservice_frontend Created
✔ Volume "microservice_db_data" Created
✔ Container microservice-redis-1 Healthy
✔ Container microservice-mysql-1 Healthy
✔ Container microservice-api-1 Started
✔ Container microservice-nginx-1 Started
✔ Container microservice-cron-1 Started
✔ Container microservice-redis-commander-1 Started
这里能看到 Compose 真正的价值:Redis 和 MySQL 先启动并跑到 Healthy,API 才启动,Nginx 最后跟着 API 走。全程没有 sleep,没有人工干预。
原理:Compose 是怎么做到这点的
命令简单,但背后有三件事值得说清楚。
自定义网络与内置 DNS
凡是在同一个 compose 文件里定义的服务,自动加入同一个 bridge 网络。Compose 在这张网上跑了一个内置 DNS 服务(Docker 引擎从 1.10 版本引入,监听 127.0.0.11:53)。所有容器使用该 DNS 做名称解析,服务名就是域名,解析结果指向对应服务的所有容器 IP。
这就是为什么 Nginx 里可以写 server api:9000。容器重建了,IP 变了,DNS 记录跟着变,Nginx 不需要重启。
depends_on 的两种模式
Compose 的 depends_on 有两种判断标准。默认模式只检查容器是否处于 running 状态,不检查内部服务是否就绪。上面我用的是 condition 模式,配合 healthcheck 做真正的可用性判断:
depends_on:
mysql:
condition: service_healthy
service_healthy 必须是服务通过健康检查之后才算数。service_started 只看容器起来。如果你用后者,MySQL 启动到接受连接要 30 秒,API 提前 28 秒起来,连接失败退出。这是最常见的 Compose 入门坑。
配置优先级
当有多个配置文件时,Compose 按以下顺序合并,越靠后优先级越高:
- docker-compose.yml 里的默认值
- docker-compose.override.yml(本地开发覆盖)
- 环境变量
- 命令行
-f参数指定的文件
这意味着你把敏感配置写进 .env 并在 compose 文件里用插值引用,override 文件里可以安全地只改用途相关配置,比如端口映射和挂载目录。
效果数据:Compose 方案的实际收益
配置优化完不能靠感觉说话。我把原来那套 bash 脚本方案和 Compose 方案做了对比测试,场景是同一台 4C8G 服务器冷启动全部服务。
启动耗时对比
| 服务数量 | bash 脚本 + sleep | Docker Compose | 提升 |
|---|---|---|---|
| 4(nginx+api+mysql+redis) | 2m 12s | 31s | 4.3x |
| 8(上表完整的 6 服务) | 4m 25s | 42s | 6.3x |
脚本方案大部分时间浪费在 sleep 上。2 个服务依赖链条,靠 sleep 15 秒去赌 MySQL 就绪,赌错了整个链路重启。Compose 的 healthcheck 用 1 秒级探测代替了人为猜测。
扩展效率:一行命令横向扩展
脚本方案把一个 API 容器扩展成 3 个,需要复制整个 docker run 命令,手动改端口、改容器名、改 Nginx upstream。Compose 的做法:
$ docker compose up -d --scale api=3
[+] Running 4/4
✔ Container microservice-api-1 Already exists
✔ Container microservice-api-2 Started
✔ Container microservice-api-3 Started
Nginx 里的 upstream 会自动发现新的容器 IP。我压测时从 1 个 API 扩到 3 个,花了 6 秒。
压测数据:Compose 网络性能无损耗
用 wrk 对 Nginx 跑了 2 轮压测,每轮 20 秒、8 线程、800 并发。第一轮请求的是 PHP 动态接口,第二轮请求静态图片。每个场景跑 3 次取中位数。wrk 命令:
wrk -t8 -c800 -d20s --latency http://localhost:8080/index.php
wrk -t8 -c800 -d20s --latency http://localhost:8080/static/img/logo.png
| 场景 | QPS | 平均延迟 | P99 |
|---|---|---|---|
| PHP 动态请求 | 2,848 | 28.17ms | 62.48ms |
| 静态图片 | 18,642 | 4.12ms | 9.81ms |
Compose 自定义 bridge 网络相比宿主机端口转发,在内网通信路径上少了 iptables 的 DNAT 环节。动态请求和静态请求的 QPS 与裸 docker run 无差异,网络不是瓶颈。
调试效率:一条命令进入服务网络
排查问题的时候,可以用 compose 的临时服务进入网络并附加工具包,不影响现有容器:
docker compose run --rm --no-deps --entrypoint "" nginx sh -c "apt update && apt install -y curl && curl http://api:9000/healthcheck.php"
这条命令创建的临时容器共享 compose 网络,能直接用服务名访问其他容器,退出即删除。脚本方案里,这种调试能力完全不存在。
避坑指南:这群容器差点把项目搞崩
Compose 用熟了也会踩坑。以下 5 个是我实际遇到过、并且对线上环境造成过影响的场景。
坑 1:version 字段导致版本混乱
网上教程一大堆在 compose 文件开头写 version: "3.8"。Docker Compose v2.20 之后的版本把这个字段废弃了。写了会有一个 deprecation 警告,但更麻烦的是当你用 docker compose(v2)和 docker-compose(v1)混着执行时,v1 对这个字段的解析行为不一致,可能忽略部分 depends_on 条件。
我的建议:所有新项目直接删掉 version 字段。老项目迁移时先全局搜索删掉,验证一遍服务能正常启动再上线。
坑 2:healthcheck 参数配错导致假死
我给 MySQL 配的 healthcheck 最初是这样的:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 5s
retries: 3
看起来合理。但 MySQL 容器刚起步时 root 账户还没初始化好,mysqladmin ping 会在 socket 权限上直接报错。配合 retries: 3,20 秒内判不健康,Compose 不认为 MySQL 就绪。API 等服务全部卡在等待状态。
正确的做法是在健康检查里带上用户名密码,并给足 start_period:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpwd"]
interval: 5s
timeout: 3s
retries: 20
start_period: 30s
start_period 内失败的探测不算失败次数,避免容器初始化慢导致误判。interval 缩短到 5 秒,判定速度更快。
坑 3:depends_on 不配 condition 等于没有
compose 文件里写了 depends_on 但没写 condition,Compose 只保证启动顺序,不保证可用性。我以前线上有过一次:API 容器先启动,MySQL 刚开始初始化,API 连不上库,进程退出。Compose 不会帮你重启 API。
所有外部依赖都要写 condition: service_healthy。依赖的服务必须先通过健康检查,这是 Compose 编排可靠性的底线。
坑 4:php healthcheck 退出码错误导致误判为健康
之前 PHP 健康检查脚本里我用了 exit(0) 做无脑兜底,写的是:
http_response_code(503);
exit(0); // 这是错的
Docker 只看退出码,不看 HTTP 状态码。退出码 0 恒等于健康。于是 MySQL 挂了 5 分钟,nginx 里的服务仍然显示 healthy,Nginx 不会从 upstream 中摘除这个节点,流量全打到 503。
修正:只有所有依赖检查通过才 exit 0,MySQL 或 Redis 任何一个挂了就 exit 1。教训是不要写无脑的 exit(0),健康检查脚本必须反映真实的业务可用性。
坑 5:docker compose down -v 会删数据卷
这条看着人畜无害,但 CI/CD 环境里我们天天跑 docker compose down -v。结果某次生产发布后数据卷全被清了,MySQL 数据从零开始重新初始化。停机和数据丢失双故障。
-v 会删除所有 anonymous volumes。如果你把数据库数据放在 named volume 里,-v 删的是 volume 里的全部内容,不只是容器层。生产环境的清理应该用:
docker compose down # 不带 -v,保留数据卷
要彻底清理数据卷,手动指定:
docker compose down --volumes # 或者干脆 docker volume rm 指定名字
最后:什么情况别用 Compose
不能只讲 Compose 的好处。单机编排,Compose 是够用的。但有几个场景它不适合:
- 需要跨宿主机编排。Compose 不支持集群调度,服务分布在多台机器上时,需要上 Swarm 或 Kubernetes。
- 需要弹性伸缩和自愈。Compose 不会因为容器挂了自动补充新实例,这是 K8s 的活。
- 服务间需要复杂的有向无环图依赖。Compose 的 depends_on 只支持层级,不支持并行依赖判断。这时候可以用 Makefile 或上 K8s。
如果你的服务数量少于 5 个,且不做跨机部署,一台机器上跑 Compose 是开发和测试阶段最平衡的选择。它虽然不是生产级调度器,但是能把复杂的容器生命周期管理压缩成一条命令。