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_oncondition属性)
  • 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_oncondition: 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 健康检查设计

每个服务定义独立的健康检查探针:
- PostgreSQLpg_isready命令,间隔10s,超时5s,重试3次
- Redisredis-cli ping命令,间隔5s
- Go API:HTTP GET /health端点,状态码200视为正常
- Nginx:监听nginx.pid文件存在性(简单版)或HTTP检查(复杂版)

3.4 启动顺序控制

使用depends_oncondition: 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_netbackend_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条铁律

  1. 网络隔离是安全的基础:至少分内外两个网络,数据库永远不暴露到外部
  2. 健康检查必须精确:不要只用CMD-SHELL pg_isready,要指定用户和数据库名
  3. 资源限制一定要加:没有限制的容器会互相争抢,导致OOM或CPU飙升
  4. 启动顺序靠conditiondepends_on只保证容器启动顺序,不保证服务可用,必须配合service_healthy
  5. 日志管理不能忘:设置max-sizemax-file,否则日志文件会撑爆磁盘

最后提醒一下:如果你的Docker版本低于20.10,升级是值得的——depends_oncondition属性和healthcheckstart_period都是关键功能,老版本不支持。

如果你在生产环境中遇到类似问题,或者有更好的配置技巧,欢迎在评论区交流。下期预告:《如何用Docker Compose实现零停机更新:Rolling Update与Blud-Green部署》。