1. 问题背景:为什么需要精细化的Docker Compose编排?
去年我们在线上部署一个微服务聚合平台时,遇到了一个典型问题:服务启动顺序混乱导致API网关反复报503,数据库连接池耗尽,Redis缓存频繁重建。当时我们用最原始的docker run命令手动启动,每次部署要盯着日志按顺序点鼠标,运维同事吐槽“像在玩扫雷”。
这个项目包含四个核心服务:
- Nginx(反向代理,端口80/443)
- Go API(业务逻辑,端口8080)
- PostgreSQL(主数据库,端口5432)
- Redis(缓存,端口6379)
核心痛点有三个:
1. 启动顺序:Go API必须在PostgreSQL和Redis就绪后才能启动
2. 健康检查:Nginx需要检测后端API存活,否则上游会报错
3. 数据持久化:数据库数据不能随容器重启丢失
Docker Compose天然支持多容器编排,但很多人只停留在depends_on的基础用法,忽略了健康检查和网络隔离的重要性。本文会拆解一个实际可用的生产级配置。
2. 环境与版本
- Docker Engine: 24.0.7 (支持
depends_on的condition属性) - Docker Compose: v2.24.0 (推荐使用
docker compose插件而非旧版docker-compose) - Go: 1.22.2 (用于构建API镜像)
- PostgreSQL: 16.2-alpine
- Redis: 7.2.4-alpine
- Nginx: 1.25.4-alpine
注意:如果Docker Engine版本低于20.10,depends_on的condition: service_healthy可能不支持,需要升级。
3. 方案设计:网络、卷与健康检查的三层架构
3.1 网络设计
我们不使用默认的bridge网络,而是创建两个隔离网络:
- frontend_net:Nginx和Go API通信(对外暴露)
- backend_net:Go API与PostgreSQL、Redis通信(内部隔离)
这样设计的好处是:数据库不会暴露到外部网络,Nginx只能通过API访问后端,符合安全最小权限原则。
3.2 卷挂载设计
- PostgreSQL:挂载
pgdata卷,存储数据文件 - Redis:挂载
redisdata卷,存储RDB/AOF持久化文件 - Nginx:挂载配置文件和静态资源目录
- Go API:挂载日志目录(开发环境),生产环境建议用日志驱动
3.3 健康检查设计
每个服务定义独立的健康检查探针:
- PostgreSQL:pg_isready命令,间隔10s,超时5s,重试3次
- Redis:redis-cli ping命令,间隔5s
- Go API:HTTP GET /health端点,状态码200视为正常
- Nginx:监听nginx.pid文件存在性(简单版)或HTTP检查(复杂版)
3.4 启动顺序控制
使用depends_on的condition: service_healthy,确保Go API等数据库就绪后再启动,Nginx等API就绪后再暴露端口。
4. 核心实现:完整的docker-compose.yml
下面是我经过7次迭代后的最终版本配置,包含所有关键参数:
version: '3.8'
networks:
frontend_net:
driver: bridge
ipam:
config:
- subnet: "10.0.1.0/24"
gateway: "10.0.1.1"
backend_net:
driver: bridge
ipam:
config:
- subnet: "10.0.2.0/24"
gateway: "10.0.2.1"
volumes:
pgdata:
driver: local
redisdata:
driver: local
services:
# ---------- PostgreSQL ----------
postgres:
image: postgres:16.2-alpine
container_name: myapp-postgres
networks:
- backend_net
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: myapp_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-myapp_pass}
POSTGRES_DB: myapp_db
ports:
- "5432:5432" # 开发环境暴露,生产环境去掉
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp_user -d myapp_db"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s # 给数据库初始化留缓冲
deploy:
resources:
limits:
cpus: "1.0"
memory: "512M"
reservations:
cpus: "0.5"
memory: "256M"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ---------- Redis ----------
redis:
image: redis:7.2.4-alpine
container_name: myapp-redis
networks:
- backend_net
volumes:
- redisdata:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
deploy:
resources:
limits:
cpus: "0.5"
memory: "256M"
# ---------- Go API ----------
api:
build:
context: ./api
dockerfile: Dockerfile
container_name: myapp-api
networks:
- frontend_net
- backend_net
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: myapp_user
DB_PASSWORD: ${DB_PASSWORD:-myapp_pass}
DB_NAME: myapp_db
REDIS_HOST: redis
REDIS_PORT: 6379
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
deploy:
resources:
limits:
cpus: "1.0"
memory: "512M"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ---------- Nginx ----------
nginx:
image: nginx:1.25.4-alpine
container_name: myapp-nginx
networks:
- frontend_net
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./static:/usr/share/nginx/html:ro
ports:
- "80:80"
- "443:443"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 15s
timeout: 5s
retries: 2
deploy:
resources:
limits:
cpus: "0.5"
memory: "256M"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
对应的Go API健康检查端点代码片段(api/main.go):
package main
import (
"encoding/json"
"log"
"net/http"
"os"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
// 检查数据库连接
dbHealthy := checkDB()
redisHealthy := checkRedis()
status := http.StatusOK
if !dbHealthy || !redisHealthy {
status = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]bool{
"db": dbHealthy,
"redis": redisHealthy,
"api": true,
})
}
func checkDB() bool {
// 实际项目中用database/sql ping
return true
}
func checkRedis() bool {
// 实际项目中用go-redis ping
return true
}
func main() {
http.HandleFunc("/health", healthHandler)
http.HandleFunc("/api/v1/hello", helloHandler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("API server starting on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
5. 踩坑与优化:那些文档没告诉你的细节
5.1 启动顺序的“假健康”陷阱
第一个版本我只用了depends_on的默认行为,结果Go API在PostgreSQL容器启动后就连接,但数据库还在初始化(比如创建表、运行迁移脚本)。解决方案是:
- 在PostgreSQL的healthcheck里用pg_isready,但注意要用-U指定用户
- 在Go API里增加重试逻辑:连接数据库失败时等待2秒重试,最多重试5次
5.2 网络延迟的坑
第一次测试时,Nginx健康检查总是失败,因为frontend_net和backend_net之间没有路由。Go API需要同时出现在两个网络中,才能被Nginx访问到后端数据库。配置中api服务加入两个网络是必须的。
5.3 资源限制导致健康检查超时
当我们把deploy.resources.limits.memory设得太低(比如128M),Go API在启动时加载依赖库就会触发OOM,健康检查永远失败。经验值:
- Go API:至少256M,建议512M
- PostgreSQL:至少256M,生产环境1G以上
- Redis:128M足够,但数据量大时调高
5.4 卷权限问题
PostgreSQL镜像默认用uid 999运行,如果宿主机上的数据目录权限不对,会报Permission denied。解决方法是:
# 创建卷时指定uid
docker volume create --driver local \
--opt type=none \
--opt device=/data/pgdata \
--opt o=uid=999,gid=999
或者在docker-compose.yml里不直接挂载宿主机目录,用命名卷(如pgdata)让Docker自动管理权限。
6. 效果数据:优化前后的对比
我们在同一台4核8G的Ubuntu 22.04虚拟机上测试,使用这套配置部署:
| 指标 | 优化前(无健康检查/粗暴depends_on) | 优化后(本配置) |
|---|---|---|
| 平均启动时间 | 45秒(需要人工观察日志) | 18秒(自动完成) |
| 健康检查误报率 | 12%(Nginx过早报503) | 0.3% |
| 数据库连接失败率 | 8%(API先于数据库就绪) | 0% |
| 部署脚本行数 | 80+行shell脚本 | 1行docker compose up -d |
| 资源使用率 | 未限制,峰值内存2.1G | 限制后稳定1.5G |
最明显的改进是部署流程:之前运维同事要写一个80行的shell脚本,手动检查端口、启动顺序、超时重试。现在只需要docker compose up -d,15-20秒后所有服务就绪,Nginx自动暴露端口。
7. 总结:生产级Compose编排的5条铁律
- 网络隔离是安全的基础:至少分内外两个网络,数据库永远不暴露到外部
- 健康检查必须精确:不要只用
CMD-SHELL pg_isready,要指定用户和数据库名 - 资源限制一定要加:没有限制的容器会互相争抢,导致OOM或CPU飙升
- 启动顺序靠condition:
depends_on只保证容器启动顺序,不保证服务可用,必须配合service_healthy - 日志管理不能忘:设置
max-size和max-file,否则日志文件会撑爆磁盘
最后提醒一下:如果你的Docker版本低于20.10,升级是值得的——depends_on的condition属性和healthcheck的start_period都是关键功能,老版本不支持。
如果你在生产环境中遇到类似问题,或者有更好的配置技巧,欢迎在评论区交流。下期预告:《如何用Docker Compose实现零停机更新:Rolling Update与Blud-Green部署》。