一个把线上搞挂的夜晚
凌晨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_mysql 和 mysqli 扩展,否则连不上数据库。还要安装 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:9000 和 proxy_pass http://java-service:8080——这里的 php-fpm 和 java-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 是核心。它做了三件事:
- 对比本地镜像与 Compose 文件定义,缺失则构建/拉取
- 按照 depends_on 的依赖顺序启动容器
- 如果存在旧容器且配置有变化,自动重建
加 --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=localhost、DB_HOST=127.0.0.1,必然连不上。
正确做法:所有跨容器访问一律使用服务名。PHP 里配 DB_HOST=mysql,Java 里配 DB_HOST=mysql、REDIS_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_on 加 healthcheck。