Docker Compose微服务编排:从裸奔到7个服务一次拉起
发布日期: 2026/08/16 阅读总量: 0

一个把线上搞挂的夜晚

凌晨1点47分,微信群里连着弹出7条告警。用户服务502,订单服务连接超时,支付回调堆积到5万条。我打开服务器,执行 docker ps,看到的场景是:8个容器,活着3个,其余全部处于 Restarting 状态。

重启单个容器?每次刚拉起一个,另一个就挂。这不是容器本身的问题——MySQL还没就绪,PHP服务就已经开始连数据库;配置中心没起来,Java服务直接拿不到配置。每个服务各自为战,启动靠运气,跑起来靠玄学。

那天晚上我手动重启了37次容器,到早上6点才勉强恢复。第二天复盘,根因就一句话:没有统一的服务编排方案

裸奔的微服务:一个脚本打天下?

当时我们的部署方式:每个服务一个 docker run 命令,写在一个 deploy.sh 里。大致长这样:

#!/bin/bash
# deploy.sh - 我们的"编排"方案
docker network create myapp_net 2>/dev/null || true

# MySQL 先启动
docker run -d --name mysql \
  --network myapp_net \
  -e MYSQL_ROOT_PASSWORD=root123 \
  -p 3306:3306 \
  mysql:8.0.35

# PHP-FPM
docker run -d --name php-fpm \
  --network myapp_net \
  -v /var/www/html:/var/www/html \
  php:8.3-fpm

# Nginx
docker run -d --name nginx \
  --network myapp_net \
  -p 80:80 \
  -v /var/www/html:/var/www/html \
  nginx:1.25.3

# 然后Java、Redis、RabbitMQ...以此类推

这份脚本的问题写在脸上:docker run 是「启动即结束」,它不关心依赖服务是否真正就绪。MySQL容器启动了,但InnoDB恢复可能要20秒,这期间PHP-FPM已经在尝试连接——结果就是报错。更蠢的是,这个脚本没有幂等性,执行两遍就出现容器名冲突。

把这张表放到一起看,你就明白为什么那晚那么惨:

问题 docker run 脚本方案 Docker Compose 方案
启动顺序 靠 sleep 硬等 depends_on + healthcheck
网络配置 手动创建/连接 network 自动创建隔离网络
配置管理 环境变量散落在脚本里 统一的 environment / env_file
滚动更新 手动 rm + run docker compose up -d
日志查看 docker logs 每个容器单独看 docker compose logs --tail=100 -f
回滚 没有 compose 文件 git 版本管理

为什么是 Docker Compose?不是 K8s?

有人会说,都微服务了,怎么不用 Kubernetes?我的观点很明确:得看你手上有什么牌。当时我们团队4个人,服务7个,日活2万。上K8s意味着要维护 etcd、kube-apiserver、kubelet、ingress-controller,加上网络插件 Calico,至少多出3个运维节点。这本身就是新的故障源。

K8s当然更强大,但它的复杂度换来的是多节点调度、自动扩缩容、自愈——这些能力我们当时根本用不上。场景匹配才是关键。Compose 适合的是:单机多容器编排、本地开发环境、CI/CD中的集成测试环境。它用一份 YAML 描述整个应用栈,docker compose up 一条命令拉起全部服务——学习成本几乎为零,排障路径也和单容器一致。

实战:7个服务的完整 Compose 编排

先交代一下我们的技术栈和版本。这套配置在以下版本验证过:

  • Docker Engine 26.0.1(docker-ce)
  • Docker Compose v2.27.0
  • MySQL 8.0.35
  • Redis 7.2.4
  • RabbitMQ 3.13.1-management
  • PHP 8.3-fpm
  • Nginx 1.25.3
  • Java 17(Eclipse Temurin JRE)

目录结构

先看总体的项目布局。这一步很多人不重视,但目录结构直接影响后面的可维护性:

microservice-app/
├── docker-compose.yml
├── .env
├── nginx/
│   ├── Dockerfile
│   └── conf.d/
│       └── default.conf
├── php/
│   ├── Dockerfile
│   └── php.ini
├── java-service/
│   ├── Dockerfile
│   └── target/
│       └── app.jar
├── mysql/
│   └── init/
│       └── 01-schema.sql
└── scripts/
    └── deploy.sh

docker-compose.yml 全量代码

这是整篇文章的核心。你直接拷贝这个文件,替换镜像名和路径就能用:

version: "3.8"

# 共享网络:服务间通过服务名互访
networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

# 共享卷:日志、MySQL数据持久化
volumes:
  mysql-data:
    driver: local
  rabbitmq-data:
    driver: local

services:
  # ---------- 基础设施层 ----------
  mysql:
    image: mysql:8.0.35
    container_name: app-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      TZ: Asia/Shanghai
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-time-zone=+8:00
    ports:
      - "3306:3306"
    networks:
      backend:
        ipv4_address: 172.28.0.10
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 1.5G
        reservations:
          memory: 512M

  redis:
    image: redis:7.2.4-alpine
    container_name: app-redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"
    networks:
      backend:
        ipv4_address: 172.28.0.11
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    deploy:
      resources:
        limits:
          memory: 512M

  rabbitmq:
    image: rabbitmq:3.13.1-management
    container_name: app-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
      TZ: Asia/Shanghai
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    ports:
      - "15672:15672"  # 管理界面
      - "5672:5672"    # AMQP协议
    networks:
      backend:
        ipv4_address: 172.28.0.12
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 10s
      retries: 6
      start_period: 40s
    deploy:
      resources:
        limits:
          memory: 1G

  # ---------- 应用服务层 ----------
  php-fpm:
    build:
      context: ./php
      dockerfile: Dockerfile
    container_name: app-php-fpm
    restart: unless-stopped
    volumes:
      - ./www:/var/www/html:rw
    environment:
      DB_HOST: mysql
      DB_PORT: 3306
      DB_NAME: ${MYSQL_DATABASE}
      DB_USER: ${MYSQL_USER}
      DB_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_USER: ${RABBITMQ_USER}
      RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
    networks:
      backend:
        ipv4_address: 172.28.0.20
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

  java-service:
    build:
      context: ./java-service
      dockerfile: Dockerfile
    container_name: app-java
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      DB_NAME: ${MYSQL_DATABASE}
      DB_USER: ${MYSQL_USER}
      DB_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_USER: ${RABBITMQ_USER}
      RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
    networks:
      backend:
        ipv4_address: 172.28.0.21
    depends_on:
      mysql:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: "1.5"

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./www:/var/www/html:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    networks:
      backend:
        ipv4_address: 172.28.0.30
    depends_on:
      - php-fpm
      - java-service
    deploy:
      resources:
        limits:
          memory: 256M

.env 文件:配置与代码分离

敏感信息和环境差异放进 .env,不进 git。Compose 会自动读取同目录下的 .env 文件:

# .env - 不要提交到git
MYSQL_ROOT_PASSWORD=MyRoot@2024
MYSQL_DATABASE=app_db
MYSQL_USER=app_user
MYSQL_PASSWORD=App@2024!
REDIS_PASSWORD=Redis@2024
RABBITMQ_USER=app_rabbit
RABBITMQ_PASSWORD=Rabbit@2024

各服务的 Dockerfile

PHP 服务,注意一定要装 pdo_mysqlmysqli 扩展,否则连不上数据库。还要安装 redis 扩展,这里用 PECL 装:

# php/Dockerfile
FROM php:8.3-fpm

# 安装系统依赖
RUN apt-get update && apt-get install -y \
    libzip-dev \
    libicu-dev \
    git \
    unzip \
    && docker-php-ext-install \
    pdo_mysql \
    mysqli \
    opcache \
    zip \
    intl

# 安装 Redis 扩展(通过PECL)
RUN pecl install redis && docker-php-ext-enable redis

# 生产环境 PHP 配置
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.memory_consumption=128" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=10000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "expose_php=Off" >> /usr/local/etc/php/conf.d/security.ini

WORKDIR /var/www/html
EXPOSE 9000
CMD ["php-fpm"]

Java 服务用多阶段构建,从 Maven 镜像编译,JRE 镜像运行。这样镜像从带 JDK 的 300MB+ 缩到 120MB 左右:

# java-service/Dockerfile
# 阶段1:编译
FROM maven:3.9.6-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# 阶段2:运行
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseG1GC", "-Xms256m", "-Xmx512m", "-jar", "app.jar"]

Nginx 镜像就基于官方 Nginx,加一层健康检查用的 curl:

# nginx/Dockerfile
FROM nginx:1.25.3-alpine

# 安装 curl 用于健康检查
RUN apk add --no-cache curl

# 删除默认配置,用我们自己的
COPY conf.d/default.conf /etc/nginx/conf.d/default.conf

EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

Nginx 配置里要反代 PHP-FPM 和 Java 服务。重点是 fastcgi_pass php-fpm:9000proxy_pass http://java-service:8080——这里的 php-fpmjava-service 是 Compose 里的服务名,Docker 内置 DNS 会自动解析到对应容器 IP:

# nginx/conf.d/default.conf
server {
    listen 80;
    server_name api.example.com;
    root /var/www/html/public;
    index index.php;

    # PHP 请求转发到 php-fpm 容器
    location ~ \.php$ {
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Java 服务反向代理
    location /api/java/ {
        proxy_pass http://java-service:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }

    # 静态文件直接返回
    location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
        expires 7d;
        access_log off;
    }

    location /health {
        access_log off;
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }
}

MySQL 初始化脚本

镜像首次启动时,/docker-entrypoint-initdb.d/ 下的 .sql 文件会自动执行。利用这一点做表结构初始化:

-- mysql/init/01-schema.sql
CREATE DATABASE IF NOT EXISTS app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE app_db;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(120) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0-pending, 1-paid, 2-failed',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    KEY idx_user_id (user_id),
    KEY idx_status (status),
    CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

一条命令拉起全部服务

部署脚本只需要几行,负责拉取最新代码、重新构建镜像、滚动更新:

#!/bin/bash
# scripts/deploy.sh
set -euo pipefail

cd "$(dirname "$0")/.."

echo "==> 拉取最新代码"
git pull origin main

echo "==> 构建镜像(不缓存)"
docker compose build --pull

echo "==> 滚动更新服务"
docker compose up -d --remove-orphans

echo "==> 等待健康检查通过"
docker compose ps

echo "==> 清理悬空镜像"
docker image prune -f

echo "==> 部署完成,当前状态:"
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"

这里 docker compose up -d 是核心。它做了三件事:

  1. 对比本地镜像与 Compose 文件定义,缺失则构建/拉取
  2. 按照 depends_on 的依赖顺序启动容器
  3. 如果存在旧容器且配置有变化,自动重建

--remove-orphans 是为了清理 Compose 文件中已删除服务的残留容器。不加也行,但留着孤儿容器容易让你忘记它还在跑——尤其在意想不到的端口上。

效果数据:73秒 vs 8分钟

这组数据是在 4C8G 的阿里云 ECS(ecs.c6.xlarge, SSD云盘)上测的。环境:Docker Engine 26.0.1,Compose v2.27.0。

先看冷启动时间对比。所谓冷启动,就是服务器刚开机,所有镜像已被本地缓存,从执行命令到所有服务健康可用的总耗时:

服务 脚本方式(docker run + sleep) Compose 方式(depends_on + healthcheck) 节省时间
MySQL (InnoDB恢复) 手动等,约30s healthcheck 30s 后判定就绪 自动等待
Redis 约2s 约2s -
RabbitMQ 约25s 约25s(start_period 40s) 自动等待
PHP-FPM 15s后启动,但连MySQL失败 MySQL健康后5s内启动 避免失败重启
Java服务 30s后启动,等Spring Boot起来再+15s MySQL+MQ健康后启动,共45s 同步完成
Nginx 5s 5s -
总耗时 约8分钟(含手动干预) 73秒(无人值守) 约84%

Compose方式总耗时73秒,其中MySQL花了32秒做InnoDB恢复,RabbitMQ花了28秒启动应用,Java服务42秒完成Spring Boot初始化。这些时间是切切实实的硬件开销,Compose没有魔法——它只是让等待自动化了,不该抢跑的绝不抢跑,该等待的精确等待。

再看资源占用对比。用 docker stats 在服务稳定运行5分钟后采集:

服务 容器内存限制 实际内存占用 CPU限制 CPU使用率(空闲)
MySQL 8.0.35 1.5G 780MB 无限制 1.2%
Redis 7.2.4 512M 34MB 无限制 0.3%
RabbitMQ 3.13.1 1G 220MB 无限制 0.8%
PHP-FPM 8.3 512M 96MB 1.0核 2.1%
Java 17 1G 412MB 1.5核 1.8%
Nginx 1.25.3 256M 18MB 无限制 0.2%

通过 deploy.resources.limits 给每个容器设置了内存上限。这很重要——如果某个服务发生内存泄漏,它会被 OOM Killer 杀掉重启,而不是拖垮整台机器。Java服务最需要这个限制,JVM默认堆最大能占到物理内存1/4,8G机器上能撑到2G,不限制的话和MySQL抢内存会让整个系统卡死。

最后是压测数据。用 wrk 对Nginx入口做压力测试,看整个链路(Nginx → PHP-FPM/Java → MySQL)的表现:

# 压测命令,100个并发连接,持续30秒
wrk -t8 -c100 -d30s --latency http://localhost/api/java/users

结果:

Running 30s test @ http://localhost/api/java/users
  8 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    48.32ms   21.07ms 398.11ms   92.17%
    Req/Sec   262.84     42.31   484.00     88.02%
  Latency Distribution
     50%   43.78ms
     75%   56.12ms
     90%   70.44ms
     99%  158.09ms
  62899 requests in 30.02s, 11.33MB read
Requests/sec:   2095.46
Transfer/sec:    386.52KB

2095 QPS(每秒请求数),P99延迟158ms。对于单机4核8G上跑6个容器来说,这个数字合格。Java服务内部调MySQL查询用户列表,涉及一次索引扫描和结果集序列化,这个性能可以接受。

避坑指南:12个让你夜不能寐的坑

以下每个坑都是我真实踩过的,按杀伤力排序。

坑1:depends_on 不等待就绪

这是最经典的一个。Compose 的 depends_on 只保证启动顺序,不保证就绪状态。MySQL容器启动了,但InnoDB还在做崩溃恢复,3306端口还没有开始监听。这时候 PHP-FPM 就开始连接,必然失败。

解决方案就是 healthcheck + 新语法。注意 condition: service_healthy 这种写法是 Compose v2 的新特性,对应 YAML 里的版本是 version: "3.8" 往上。如果你在用老版本 Compose v1,只有完整写法的兼容方案:

depends_on:
  mysql:
    condition: service_healthy

坑2:healthcheck 里的变量展开

healthcheck 命令里用 ${MYSQL_ROOT_PASSWORD} 会造成隐患。如果密码里含特殊字符比如 $!、单双引号,命令解析直接乱掉,健康检查永远失败。

我后来改成在 .env 里用不含特殊字符的强密码,或者干脆用 MySQL 的 mysqladmin --protocol=socket 来跳过密码验证:

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  # 不需要密码,socket连接

坑3:env_file 编码问题

在 Windows 上编辑 .env 文件,默认可能是带 BOM 的 UTF-8 编码。BOM 头会让 Compose 解析第一个 key 时带上 \xEF\xBB\xBF 前缀,导致配置加载失败,错误信息是 key 'MYSQL_ROOT_PASSWORD' has no value 或直接诡异报错。

排查方法:hexdump -C .env | head,看到 ef bb bf 开头就是有 BOM。用 VS Code 右下角把编码改成 UTF-8(不带BOM)。

坑4:容器名冲突

如果你之前用 docker run 创建过同名容器,再 docker compose up 会报 Conflict. The container name "/app-mysql" is already in use。Compose 不会自动接管不是你创建的容器。

处理方式:

# 找到冲突容器
docker ps -a | grep app-mysql

# 删除旧容器(注意数据卷是否要保留)
docker rm -f app-mysql

坑5:镜像构建缓存导致的旧代码

docker compose build 默认使用 Dockerfile 每一层构建缓存。如果你 COPY 了整个项目目录,但 Dockerfile 里 COPY 的那一层缓存判断只基于文件内容 hash——正常情况下没问题。但如果你把 .dockerignore 写错了,把 target/ 或者 vendor/ 目录忽略了,构建就会用到旧代码缓存。

我吃过一次亏:改了 Java 代码,重新 build,跑起来发现还是旧逻辑,排查了半小时结果是 .dockerignore 写了个 * 然后白名单没加全,target 目录被忽略了。

坑6:/dev/urandom 不够用

Java 服务启动时如果 JVM 的 java.security.egd 指向 /dev/random,在高并发下会出现启动极慢或阻塞,因为 /dev/random 是阻塞式随机数生成器。在容器里尤其明显,因为容器内的熵源更少。

必须在 ENTRYPOINT 加上参数:

ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

坑7:healthcheck 的 start_period 要留足

RabbitMQ 的启动时间不稳定,有时候 20 秒,有时候 35 秒。如果你把 start_period 设成 10 秒,重试 3 次,健康检查可能在真正的故障和慢启动之间误判。start_period 的含义是:在这段时间内健康检查失败不计入重试次数。给 RabbitMQ 设 40 秒 start_period,MySQL 设 30 秒,Java 服务设 60 秒(Spring Boot 慢)。

坑8:时区问题

容器默认 UTC 时区,MySQL 的 TIMESTAMP 类型存储和读取有时区转换。如果你的业务要求北京时间,MySQL 要加 default-time-zone=+8:00,PHP 要装 tzdata 并配置 date.timezone=Asia/Shanghai,Java 在启动参数里加 -Duser.timezone=Asia/Shanghai。漏一个,日志时间和 DB 时间就对不上,排查线上问题能让你怀疑人生。

坑9:Compose 网络模式下 localhost 不通

在 Compose 网络里,每个容器有独立的网络命名空间。localhost 指向容器自身,不是宿主机。如果你的 PHP 代码里配置了 REDIS_HOST=localhostDB_HOST=127.0.0.1,必然连不上。

正确做法:所有跨容器访问一律使用服务名。PHP 里配 DB_HOST=mysql,Java 里配 DB_HOST=mysqlREDIS_HOST=redis

坑10:MySQL 初始化脚本只执行一次

/docker-entrypoint-initdb.d/ 下的脚本只在数据目录为空时执行。如果你改了 SQL 文件,但 mysql-data 卷已经有旧数据,新的表结构不会生效。你需要手动删卷(危险!):

# 小心!这会删除所有MySQL数据
docker compose down -v
docker compose up -d

更好的方式是用 Flyway 或 Liquibase 做数据库版本管理,在应用启动时执行迁移。生产环境务必用这种方式。

坑11:docker compose up 和 build 的分离

我见过很多同事在服务器上直接 docker compose up -d --build。这个命令会重新构建所有镜像——即使代码没变。构建时间有时候长达3-5分钟(Maven 下载依赖)。

建议分步:CI/CD 里先 docker compose build(带上标签),发布时 docker compose up -d 不传 --build。这样每次部署前能确认镜像构建成功,且不会意外构建。

坑12:优雅停机

默认情况下 docker compose down 会发 SIGTERM 给容器主进程,等待 10 秒(默认 stop_grace_period)后杀 SIGKILL。Java 服务 Spring Boot 处理 SIGTERM 需要时间做优雅停机——如果 10 秒不够,正在处理的请求会被强杀。

给 Java 服务设置更长的宽限期:

services:
  java-service:
    stop_grace_period: 30s
    stop_signal: SIGTERM

附:一键排查脚本

把下面这个脚本放在服务器上,出问题时先跑一遍,80% 的问题能定位到方向:

#!/bin/bash
# scripts/health-check.sh
set -uo pipefail

echo "=== 容器状态 ==="
docker compose ps

echo ""
echo "=== 最近1小时容器重启统计 ==="
docker ps -a --format "{{.Names}}\t{{.Status}}" | grep -E "Restarting|Exited"

echo ""
echo "=== 资源占用 TOP 5 ==="
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" | sort -k3 -h -r | head -6

echo ""
echo "=== 各容器最近20条日志 ==="
for service in $(docker compose config --services); do
  echo "----- $service -----"
  docker compose logs --tail=20 "$service" 2>&1 | grep -E "ERROR|WARNING|FATAL|Exception|panic" || echo "(无错误日志)"
done

什么时候该换掉 Compose?

Compose 不是银弹。当你的服务超过 15-20 个、或者需要多台宿主机时,Compose 的单机模型会开始变得痛苦。

  • 没有调度能力——不会自动把容器分配到空闲机器
  • 没有服务发现——必须自己维护服务名到 IP 的映射
  • 没有自动扩缩容——流量翻倍时你得手动 up 更多实例(且端口会冲突)
  • 没有滚动更新策略——up 是全部重建,不是流量切分

这时候应该考虑 Docker Swarm(简单迁移路径)或 K8s(功能更全)。但至少,当你团队规模还在 10 人以内、服务数量个位数、单机部署能满足业务时,Docker Compose 是性价比最高的编排工具。它不完美,但它简单、直接、够用——而且它的 YAML 知识结构迁移到 K8s 时依然有部分复用价值。

那晚之后,我把所有服务的启动都收敛到 Compose。现在凌晨1点47分如果真的出问题,我只需要跑一下 scripts/health-check.sh,看输出定位问题,然后 docker compose up -d 重建异常服务。不再有「先启动谁」的纠结——Compose 会在依赖就绪后自动拉起后续服务。从裸奔到有序编排,差的不是 K8s,而是一个 depends_onhealthcheck