1. 问题背景:为什么需要精细化编排?

去年接手一个电商后台项目,架构很标准:前端Nginx反向代理,后端Spring Boot应用,数据层PostgreSQL。开发时各服务用docker run手动启动,一到测试环境就出问题:

  • 启动竞态:Spring Boot启动时PostgreSQL还没就绪,导致连接超时失败,需要手动重启两次才能成功。
  • 网络混乱:所有容器都在默认bridge网络里,Nginx能直接连数据库,存在安全隐患。
  • 卷权限:PostgreSQL挂载宿主机目录后,启动时因权限不足写不了数据,容器反复重启。
  • 日志丢失:Spring Boot容器重启后日志随之消失,排查线上问题极其困难。

这些问题的根因是缺乏标准化的容器编排方案。Docker Compose能一次性解决,但如果你只是简单定义services,该踩的坑一个不少。下面我会展示一个生产可用版本,包含网络隔离、健康检查、启动顺序和卷挂载。

2. 环境与版本

  • Docker版本:24.0.7
  • Docker Compose版本:v2.24.1(注意:v1已弃用,请使用docker compose命令)
  • 镜像:
  • nginx:1.25.3-alpine(Alpine基础镜像,体积小,约25MB)
  • postgres:16.1-alpine(支持健康检查的官方镜像)
  • 业务镜像:registry.example.com/order-service:2.3.1(基于Eclipse Temurin 17 JRE,构建后约180MB)

宿主机操作系统:Ubuntu 22.04 LTS,内核5.15.0-91。

3. 方案设计:网络、卷与启动策略

整体架构分为三层:

  1. 外部访问层:Nginx容器暴露80端口,反向代理到后端。
  2. 业务逻辑层:Spring Boot容器,仅对Nginx暴露,不直接对外。
  3. 数据层:PostgreSQL容器,完全隔离在内部网络,不对外暴露任何端口。

网络策略:
- 创建两个自定义网络:frontend(Nginx与Spring Boot通信)和backend(Spring Boot与PostgreSQL通信)。
- Spring Boot容器同时加入两个网络,作为桥梁。Nginx只加入frontend,PostgreSQL只加入backend

卷挂载策略:
- PostgreSQL:挂载pgdata卷到/var/lib/postgresql/data,持久化数据库文件。
- Spring Boot:挂载logs卷到/app/logs,防止日志随容器销毁。
- Nginx:挂载宿主机配置文件目录,方便热更新配置。

启动顺序:
- 使用depends_on定义依赖关系,但仅靠depends_on只能保证容器启动,不能保证服务就绪。
- 关键:在depends_on基础上添加condition: service_healthy,配合healthcheck实现真正就绪检测。

4. 核心实现:可运行的docker-compose.yml

直接上配置,完整的docker-compose.yml如下:

version: '3.8'

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24
  backend:
    driver: bridge
    internal: true  # 禁止外部访问,增强安全性
    ipam:
      config:
        - subnet: 172.20.1.0/24

volumes:
  pgdata:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres  # 生产环境建议用独立磁盘分区
  logs:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/app-logs

services:
  postgres:
    image: postgres:16.1-alpine
    container_name: order-db
    networks:
      - backend
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: order_db
      POSTGRES_USER: order_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # 从.env文件读取,勿硬编码
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U order_user -d order_db"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s  # 给数据库初始化留出时间
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M

  backend:
    image: registry.example.com/order-service:2.3.1
    container_name: order-backend
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - frontend
      - backend
    volumes:
      - logs:/app/logs
      - ./config/application.yml:/app/config/application.yml:ro  # 外部配置文件
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/order_db
      SPRING_DATASOURCE_USERNAME: order_user
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SERVER_PORT: 8080
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s  # Spring Boot启动较慢,给足时间
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 1G

  nginx:
    image: nginx:1.25.3-alpine
    container_name: order-nginx
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - frontend
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 5s
      retries: 2
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 128M

关键配置解释:

  • networks.backend.internal: true:这个参数很多人忽略。设为true后,该网络内的容器无法访问外部网络,只能与同网络容器通信。PostgreSQL只暴露给后端服务,即使宿主机被攻破,攻击者也无法直接连接数据库。
  • depends_on + condition: service_healthy:这是解决启动顺序的核心。例如backend会等待postgres容器通过健康检查后才启动自己的容器。注意:condition: service_healthy在Docker Compose v2中才完全支持,确保docker compose version >= 2.20。
  • start_period:健康检查的"预热期"。在此时间内,健康检查失败不计入重试次数,避免因启动慢导致误判。PostgreSQL设为30s,Spring Boot设为60s,实测足够。
  • 卷挂载使用bind mount:生产环境建议用driver_opts绑定宿主机的特定目录,而不是用匿名卷。方便运维备份和排查。

Nginx配置(./nginx/conf.d/default.conf)示例:

upstream backend {
    server backend:8080;
}

server {
    listen 80;
    server_name order.example.com;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

5. 踩坑与优化

5.1 权限踩坑:PostgreSQL启动失败

第一次启动时,PostgreSQL报错:FATAL: could not create directory "/var/lib/postgresql/data/pgdata": Permission denied。原因是我用root用户挂载了宿主机的/data/postgres目录,但PostgreSQL容器内使用uid 999(postgres用户)运行,没有写入权限。

解决方案:在宿主机上设置目录权限:

sudo mkdir -p /data/postgres
sudo chown 999:999 /data/postgres  # 999是postgres用户uid

或者在docker-compose.yml中通过user指令指定容器运行用户,但推荐前一种方式,保持镜像原始用户。

5.2 健康检查超时:Spring Boot启动慢

Spring Boot应用启动需要约45秒(加载JDBC连接池、初始化Flyway迁移),但健康检查默认interval: 30s,导致第一次检查时应用还没就绪,被标记为不健康。虽然start_period可以缓解,但注意start_period不计数重试,如果应用启动时间超过start_period,后续健康检查失败依然会触发重启。

优化:将start_period设为60s,interval设为10s,retries设为3。这样应用启动后最多30秒内就能通过健康检查。

5.3 网络性能:internal网络的好处

一开始我把所有服务放在同一个自定义网络中,发现Nginx到后端的延迟偶尔飙升到200ms。排查发现是Nginx容器和PostgreSQL容器在同一网络,虽然流量不大,但Docker DNS解析和网络包转发有干扰。拆分网络后,延迟稳定在1-2ms。

internal: true还有一个隐藏好处:PostgreSQL容器无法发起出站连接,即使被注入恶意代码也无法外传数据,符合安全合规要求。

6. 效果数据

上线这个编排方案后,对比之前的裸docker run部署:

指标 之前 之后
服务启动成功率 73% 99.2%
平均部署时间 3分20秒 1分58秒
日志找回成功率 0%(重启即丢失) 100%
数据库连接错误 每次部署必现 0次(连续30天)

其中启动成功率提升的关键是健康检查与depends_on的配合。之前哪怕手动等待数据库启动再启动后端,也经常因为网络闪断导致失败。现在condition: service_healthy确保数据库真正可查询后才启动后端,同时后端的健康检查又卡住了Nginx,形成严格的启动链。

日志持久化带来的另一个收益:配合logrotate/data/app-logs进行每日切割,现在可以回溯7天内的日志,排查问题时直接grep宿主机目录,不用再进容器。

7. 总结

Docker Compose编排不是简单地把多个docker run命令拼在一起。要想在生产环境中稳定运行,必须处理三个核心问题:

  1. 网络隔离:用多个自定义网络按流量方向划分,internal: true给数据库等敏感服务加锁。
  2. 启动顺序depends_on + condition: service_healthy是黄金组合,配合start_period避免假阳性。
  3. 数据持久化:bind mount到宿主机指定目录,并管理好容器用户权限。

最后一点建议:永远不要在docker-compose.yml里硬编码密码等敏感信息。使用环境变量文件.env,并在.gitignore中排除它。上面配置中的${DB_PASSWORD}就是从.env读取的。

希望这篇博客能帮你少踩几个坑。如果你的服务架构更复杂(比如消息队列、Redis集群),后续我会分享如何用Docker Compose编排有状态服务。评论区欢迎交流。