Docker Compose微服务编排:从踩坑到压测实战
发布日期: 2026/08/03 阅读总量: 0

先看一个我踩过的坑

周三下午,我把一个 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 按以下顺序合并,越靠后优先级越高:

  1. docker-compose.yml 里的默认值
  2. docker-compose.override.yml(本地开发覆盖)
  3. 环境变量
  4. 命令行 -f 参数指定的文件

这意味着你把敏感配置写进 .env 并在 compose 文件里用插值引用,override 文件里可以安全地只改用途相关配置,比如端口映射和挂载目录。

效果数据:Compose 方案的实际收益

配置优化完不能靠感觉说话。我把原来那套 bash 脚本方案和 Compose 方案做了对比测试,场景是同一台 4C8G 服务器冷启动全部服务。

启动耗时对比

服务数量bash 脚本 + sleepDocker Compose提升
4(nginx+api+mysql+redis)2m 12s31s4.3x
8(上表完整的 6 服务)4m 25s42s6.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,84828.17ms62.48ms
静态图片18,6424.12ms9.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 是开发和测试阶段最平衡的选择。它虽然不是生产级调度器,但是能把复杂的容器生命周期管理压缩成一条命令。