一、问题背景:单体脚本启动的痛
上个月接手一个内部业务系统,原来部署方式是五个shell脚本依次启动,每个脚本里sleep 5到10秒。结果就是:每次发布要手动盯日志,PostgreSQL还没就绪Golang就开始连库,报一堆connection refused;Redis偶尔启动慢半拍,Nginx反代到不存在的API容器,502刷屏。最崩溃的是有一次服务器重启,我花了半小时一个个容器拉起来,还要检查它们之间的网络通了没。
这事让我下定决心,必须把服务编排统一到Docker Compose上。目标很明确:一条docker compose up -d命令搞定全部服务,容器间网络隔离干净,数据卷持久化不丢,启动顺序有保障。
二、环境与版本
先说下我用的环境,踩坑都是基于这个版本组合:
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.2
- Docker Compose:v2.20.0(注意是插件版,不是老旧的docker-compose v1)
- 项目技术栈:Golang 1.21(API服务)、PostgreSQL 15.3、Redis 7.0.12、RabbitMQ 3.12-management、Nginx 1.25
整个项目结构是标准的微服务风格:Nginx做入口网关,Golang API处理业务逻辑,PostgreSQL存业务数据,Redis做缓存,RabbitMQ处理异步任务队列。
三、方案设计:五个服务怎么编排
设计核心就三点:
网络隔离:我建了两个自定义网络。frontend网络让Nginx和Golang API互通,backend网络让Golang API连接数据库、缓存和消息队列。API容器同时挂两个网络,相当于唯一的桥梁。这样即使用户误暴露了数据库端口,外部网络也访问不到,因为PostgreSQL只在backend网络里。
卷持久化:PostgreSQL和RabbitMQ的数据绝对不能放容器可写层,容器一删全没了。我用命名卷把PG的数据目录和RabbitMQ的mnesia目录挂出来。Redis纯缓存可以不挂,但为了重启后快速热身,也给它挂了个卷存RDB快照。
健康检查与启动顺序:这是最关键的。以前用sleep硬等,现在用healthcheck让每个服务报告自己的健康状态,再用depends_on的condition: service_healthy来控制谁先启动。Golang API依赖PG、Redis、RabbitMQ全部健康后才启动,Nginx依赖API健康后才启动。
四、核心实现:docker-compose.yml全解析
直接上配置文件,每个细节我都会拆开讲:
version: "3.8"
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: "172.20.0.0/24"
backend:
driver: bridge
ipam:
config:
- subnet: "172.21.0.0/24"
volumes:
pg_data:
driver: local
rabbitmq_data:
driver: local
redis_data:
driver: local
services:
postgres:
image: postgres:15.3-alpine
container_name: app-postgres
restart: always
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${PG_PASSWORD:-apppass123}
POSTGRES_DB: appdb
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-sql:/docker-entrypoint-initdb.d:ro
networks:
- backend
ports:
- "127.0.0.1:5432:5432" # 仅本机可访问,外部无法直接连
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
redis:
image: redis:7.0.12-alpine
container_name: app-redis
restart: always
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redispass456}
volumes:
- redis_data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "-a", "redispass456", "ping"]
interval: 5s
timeout: 3s
retries: 10
rabbitmq:
image: rabbitmq:3.12-management-alpine
container_name: app-rabbitmq
restart: always
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-rabbitpass789}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
- backend
ports:
- "15672:15672" # 管理界面,按需开放
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s # RabbitMQ启动慢,给足热身时间
api:
build:
context: ./api
dockerfile: Dockerfile
container_name: app-api
restart: always
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: appuser
DB_PASSWORD: ${PG_PASSWORD:-apppass123}
DB_NAME: appdb
REDIS_ADDR: redis:6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-redispass456}
RABBITMQ_URL: amqp://admin:${RABBITMQ_PASS:-rabbitpass789}@rabbitmq:5672/
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
networks:
- frontend
- backend
expose:
- "8080"
nginx:
image: nginx:1.25-alpine
container_name: app-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./web-static:/usr/share/nginx/html:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 10s
timeout: 3s
retries: 5
4.1 网络配置细节
两个bridge网络手动指定了子网段,好处是容器IP固定,防火墙规则好写。注意我没在容器上设置固定IP,而是让Docker自动分配,但子网是可控的。Golang API容器同时加入两个网络,它内部访问PostgreSQL直接就用主机名postgres,Compose自带的DNS解析会处理,不需要关心IP。
4.2 健康检查命令的讲究
每个健康检查的命令都是有针对性的:
- PostgreSQL用pg_isready,这是官方推荐的,不要用psql -c "SELECT 1",太重了。
- Redis用redis-cli ping,注意我加了-a参数传密码,否则ping会报NOAUTH。
- RabbitMQ用rabbitmq-diagnostics -q ping,这是RabbitMQ 3.8.7+推荐的健康检查方式。
- Nginx健康检查我写的是wget请求/healthz端点,这个端点是Nginx配置里反向代理到API的,如果API挂了Nginx返回502,wget会失败,说明整个链路断了。
4.3 启动顺序控制
depends_on长语法是Compose v2.20的标配:
depends_on:
postgres:
condition: service_healthy
这意味着API容器会在PostgreSQL健康检查通过后才开始创建。比老版本的短语法(只等容器启动不等就绪)可靠得多。RabbitMQ的start_period设了20秒,因为它的健康检查命令需要等Erlang虚拟机完全起来才能响应,这段时间内即使检查失败也不会计入重试次数。
五、踩坑与优化:三个大坑
坑一:healthcheck命令里的密码硬编码
一开始我把Redis的密码直接写死在healthcheck里:redis-cli -a redispass456 ping。这样虽然能用,但密码一换就得改compose文件。后来我改用环境变量替换:
healthcheck:
test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping | grep -q PONG"]
注意要用$$转义,因为Compose会先做一次变量插值,$$会变成$传给容器内shell执行。这个坑花了我一下午才搞清楚。
坑二:RabbitMQ健康检查一直失败
RabbitMQ容器起来了,但healthcheck显示unhealthy。排查发现rabbitmq-diagnostics ping返回非零退出码。原因是容器里rabbitmq用户没有执行权限,需要指定用户:
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
user: "rabbitmq"
坑三:Golang API启动时连不上数据库
就算depends_on设了service_healthy,Golang API启动时还是会偶发连不上PG。因为健康检查通过只代表PostgreSQL进程接受了连接,不代表数据库已经完成初始化脚本执行。我的解决方式是在Golang代码里加重试逻辑:
func waitForDB(db *sql.DB) error {
for i := 0; i < 10; i++ {
err := db.Ping()
if err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("database not ready after 20s")
}
双保险:Compose层面等健康,应用层面再重试。
六、效果数据
改造成Compose编排后,部署流程变化明显:
- 原来五个shell脚本+手动sleep,完整启动耗时约45秒(有运气成分,sleep时间保守了)。
- 现在docker compose up -d,从执行命令到Nginx返回200,平均28秒。RabbitMQ的20秒热身占了大头,但这是必要的。
- 服务器重启后恢复:原来30分钟人工干预,现在一条docker compose start,所有容器按依赖顺序自动拉起,10秒内服务恢复可用。
- 网络隔离效果:外网扫描不到PostgreSQL和RabbitMQ的端口,只有Nginx的80/443暴露在外。
七、总结
Docker Compose编排的关键就三件事:网络规划要清晰(业务流量和数据流量分开)、数据持久化要挂卷(别信容器可写层)、服务依赖要显式声明(别用sleep碰运气)。这套配置我已经跑了两个月,期间经历三次发布、两次服务器重启,没有一次因为服务启动顺序出问题。如果你也在用多容器部署,建议直接抄作业改项目名就能用。
最后提醒一句:健康检查的命令一定要用服务自带的诊断工具,别用nc -z去探测端口,那只能说明端口开了,不代表服务就绪了。