一、问题背景:从裸机部署到容器化,我们踩了哪些坑
上个月我们团队接手了一个老项目,6个服务模块散落在三台物理机上,部署靠shell脚本,依赖靠人肉记忆。每次发版都要经历:手动启动数据库 → 等30秒 → 启动后端 → 再等20秒 → 启动前端网关……整个流程耗时5分钟以上,且经常因为忘记启动依赖服务导致联调环境崩溃。
更头疼的是,不同服务的配置文件散落各处,端口冲突时有发生——有一次Redis和某个监控服务同时抢6379端口,排查了两个小时。我们决定引入Docker Compose统一编排,目标很明确:
- 一条命令拉起全部服务,且保证依赖顺序正确
- 数据必须持久化,容器重启不丢数据
- 服务间通信走内网,不暴露多余端口
- 能自动检测服务健康状态,失败自动重启
二、环境与版本
本次实践基于以下环境,请确保版本不低于这些,否则某些语法不支持:
- Docker Engine: 24.0.7(含buildx插件)
- Docker Compose: v2.24.2(注意,不再支持
version:字段,直接写服务定义即可) - 操作系统: Ubuntu 22.04 LTS(内核5.15+)
- 目标镜像:OpenJDK 17、PostgreSQL 16.2-alpine、Redis 7.2.4-alpine、RabbitMQ 3.13-management
三、方案设计:网络、卷、健康检查的协同
3.1 网络拓扑设计
使用自定义bridge网络 app-network,所有服务加入该网络。这里没有用默认网络,是因为自定义网络提供内置DNS解析,服务名就是主机名,且可以设置网络别名(alias),便于未来服务迁移。
app-network (bridge, subnet: 172.28.0.0/16)
├── nginx-gateway (映射80端口)
├── backend-service (仅内网)
├── postgres-db (仅内网)
├── redis-cache (仅内网)
├── rabbit-mq (仅内网)
└── scheduler-job (仅内网)
只有nginx暴露80端口到宿主机,其余服务全部内网互联。这样端口冲突问题直接从根上消失。
3.2 卷挂载策略
| 服务 | 卷挂载 | 用途 |
|---|---|---|
| postgres-db | pg_data:/var/lib/postgresql/data |
数据库文件持久化 |
| rabbit-mq | rabbitmq_data:/var/lib/rabbitmq |
消息队列持久化 |
| backend-service | ./app/logs:/app/logs |
日志输出到宿主机,便于ELK采集 |
| nginx-gateway | ./nginx/conf.d:/etc/nginx/conf.d:ro |
配置文件只读挂载 |
命名卷由Compose管理,存放于/var/lib/docker/volumes/,适合数据库这种需要备份恢复的场景。绑定挂载适合配置文件,因为我们要经常改nginx路由规则。
3.3 健康检查与启动顺序
这是本次方案的核心。Docker Compose的depends_on有几种形式:
- 简单形式:仅控制启动顺序,不关心服务是否就绪
- 条件形式:
condition: service_healthy,要求依赖服务健康后才启动
我们采用条件形式,配合各服务的healthcheck命令。例如PostgreSQL的检查命令:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
start_period很关键,它给了服务首次启动的宽限期,期间失败的检查不计入重试次数。比如PostgreSQL首次启动可能需要8秒初始化数据目录,如果立即开始检查,可能在retries: 5内还没就绪就误判为unhealthy。
四、核心实现:docker-compose.yml完整配置
下面这个文件就是我们线上在用的配置,敏感信息用环境变量替换了:
name: multi-service-app
x-common-env: &common-env
TZ: Asia/Shanghai
JAVA_OPTS: "-Xms512m -Xmx1g -XX:+UseG1GC"
networks:
app-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
pg_data:
driver: local
rabbitmq_data:
driver: local
services:
postgres-db:
image: postgres:16.2-alpine
container_name: postgres-db
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app_main
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
networks:
app-network:
aliases:
- db.host
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d app_main"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
redis-cache:
image: redis:7.2.4-alpine
container_name: redis-cache
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb"]
volumes:
- redis_data:/data
networks:
app-network:
aliases:
- cache.host
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
rabbit-mq:
image: rabbitmq:3.13-management
container_name: rabbit-mq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
app-network:
aliases:
- mq.host
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
restart: unless-stopped
backend-service:
image: registry.example.com/backend:1.4.2
container_name: backend-service
depends_on:
postgres-db:
condition: service_healthy
redis-cache:
condition: service_healthy
rabbit-mq:
condition: service_healthy
environment:
<<: *common-env
SPRING_DATASOURCE_URL: jdbc:postgresql://db.host:5432/app_main
SPRING_DATA_REDIS_HOST: cache.host
SPRING_RABBITMQ_HOST: mq.host
volumes:
- ./app/logs:/app/logs
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
restart: unless-stopped
scheduler-job:
image: registry.example.com/scheduler:2.0.1
container_name: scheduler-job
depends_on:
backend-service:
condition: service_healthy
environment:
<<: *common-env
BACKEND_URL: http://backend-service:8080
networks:
- app-network
restart: unless-stopped
nginx-gateway:
image: nginx:1.27-alpine
container_name: nginx-gateway
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./nginx/logs:/var/log/nginx
depends_on:
backend-service:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
注意几个细节:
x-common-env是YAML锚点,避免重复写JVM参数network.aliases让服务拥有多个DNS名——比如db.host比postgres-db更语义化,后续换数据库中间件时不用改代码depends_on全部使用service_healthy条件,形成链式依赖:postgres → backend → scheduler/nginx- 数据库初始化脚本通过
init-scripts目录挂载,首次启动自动执行建表SQL
五、踩坑与优化:三个真实问题及解决
5.1 坑一:healthcheck的start_period设置不当
最初PostgreSQL的healthcheck没有设置start_period,导致首次启动时data目录初始化耗时6秒,而检查间隔5秒、重试3次,刚好在第3次检查时服务还没就绪,被标记为unhealthy。虽然restart: unless-stopped会触发重启,但重启又需要重新初始化,形成死循环。
解决:设置start_period: 10s,并增加retries到10。实际含义是:首次启动后10秒内不计数失败,之后每5秒检查一次,最多重试10次。
5.2 坑二:RabbitMQ健康检查命令选错
最开始用rabbitmqctl status做检查,但这个命令需要Erlang VM完全启动,且会输出大量日志,把Compose的日志都刷屏了。后来换成rabbitmq-diagnostics -q ping,这个命令只返回PING_OK,安静且快速。
5.3 坑三:卷权限问题导致PostgreSQL启动失败
在postgres:16.2-alpine镜像中,数据目录属主是postgres用户(UID 999)。我们最初用./data/pg:/var/lib/postgresql/data绑定挂载宿主机目录,但宿主机目录属主是root,导致PostgreSQL无法写入。
解决:改用命名卷pg_data,由Docker自动管理属主。如果必须用绑定挂载,需要先执行chown -R 999:999 ./data/pg。
六、效果数据:上线后的真实对比
| 指标 | 部署前(裸机) | 部署后(Compose) |
|---|---|---|
| 全量启动耗时 | 3分12秒 | 58秒 |
| 环境搭建耗时 | 45分钟(手动装依赖) | 5分钟(pull镜像+启动) |
| 端口冲突事件 | 月均2-3次 | 0次 |
| 服务不可用恢复 | 人工介入,平均15分钟 | 自动重启,平均40秒 |
| 日志采集 | 手动ssh查看 | 挂载卷直接对接ELK |
另外一个意外收获:因为使用了自定义网络别名,我们后来把Redis升级到7.2时,代码里连接地址从redis.host改成cache.host,只需要改一行配置,其他服务无感知。
七、总结与建议
Docker Compose做多服务编排,核心就是把"启动顺序"和"资源隔离"这两件事用声明式配置固化下来。healthcheck + depends_on: service_healthy是解决顺序问题的金钥匙,custom network是解决网络隔离和端口冲突的银弹。
给正在迁移的团队三个建议:
- 不要一上来就上K8s,如果服务数在10个以内,Compose完全够用,运维成本低一个量级
- healthcheck一定要写,而且要针对服务特性定制命令,别用默认的
wget localhost糊弄 - 环境变量全部走
.env文件,不要把密码写在compose文件里,我们是通过${DB_PASSWORD}引用,.env文件加入.gitignore
最后,我们的完整配置已经放在GitLab内部仓库,如果你们有类似场景,可以评论区留言交流。下一步我们计划把docker-compose.yml升级到docker compose插件版,并接入GitLab CI实现自动构建和滚动更新。