一、问题背景

上个季度我把公司一个内部管理系统从"三台虚拟机手动部署"迁移到了容器化方案。这套系统包含四个组件:Spring Boot后端、PostgreSQL 15数据库、Redis 7缓存、Nginx静态资源与反向代理。最初我图省事,写了个docker-compose.yml一把梭,结果踩了一堆坑:

  • 后端容器启动比数据库快,连接池初始化直接报Connection refused,容器反复重启
  • 数据库数据放在容器内,一次docker compose down之后数据全没了
  • 服务之间用localhost互相访问,完全不通
  • Nginx配置里写死IP,容器重建后IP变了,502错误

这些问题本质上都是编排配置没设计好。后来我重新梳理了一遍docker-compose配置,把网络、卷、健康检查、启动顺序都规范了。这篇文章就把最终版本拆开讲清楚,配置可以直接拿去改改用。

二、环境与版本

先交代下环境,版本不一致可能导致行为差异:

  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是Compose V2插件版,命令是docker compose而非docker-compose)
  • 宿主机:Ubuntu 22.04 LTS,4C8G
  • 服务镜像:
  • 后端:基于eclipse-temurin:17-jre-jammy自构建
  • PostgreSQL:postgres:15.4-alpine
  • Redis:redis:7.2-alpine
  • Nginx:nginx:1.25-alpine

选alpine版本主要是镜像小,postgres alpine版约80MB,比debian版小一半多。生产环境如果对glibc有依赖要注意,alpine用的是musl。

三、方案设计

整体设计思路围绕四个关键词:网络隔离、数据持久化、健康检查、启动顺序。

网络设计:创建一个自定义bridge网络app-net,四个服务都接入。自定义网络的好处是Docker内置DNS,服务之间直接用服务名互访,比如后端连数据库写postgres:5432就行,不用管IP。同时把Nginx和后端放在同一网络,Nginx通过backend:8080转发。

卷设计:PostgreSQL数据用命名卷pg-data挂到/var/lib/postgresql/data;Redis开启AOF持久化,用命名卷redis-data;Nginx配置文件和静态资源用bind mount挂载,方便改完reload。

健康检查:给postgres、redis、backend都配healthcheck。postgres用pg_isready,redis用redis-cli ping,backend用wget请求健康端点。关键是依赖方用depends_on的condition: service_healthy,这样能保证依赖服务真正就绪,而不是容器进程刚起来就放行。

启动顺序:postgres和redis最先(无依赖),backend依赖这两者健康,nginx依赖backend健康。形成 postgres/redis → backend → nginx 的启动链。

四、核心实现

先贴完整的docker-compose.yml,然后逐段解释。

version: "3.9"

services:
  postgres:
    image: postgres:15.4-alpine
    container_name: app-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7.2-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: app-backend:1.4.2
    container_name: app-backend
    restart: unless-stopped
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s

  nginx:
    image: nginx:1.25-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./static:/usr/share/nginx/html:ro
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

volumes:
  pg-data:
    driver: local
  redis-data:
    driver: local

配套的.env文件(不要提交到git):

DB_PASSWORD=Str0ngP@ss2024
REDIS_PASSWORD=R3disP@ss2024

Nginx的conf.d/app.conf片段,重点是用服务名而非IP:

upstream backend_upstream {
    server backend:8080;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    location /api/ {
        proxy_pass http://backend_upstream;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }

    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }
}

几个关键点说明:

1. 网络别名与DNS:自定义网络下,容器名和服务名都能被解析。我在配置里同时设了container_name,所以postgres和app-postgres都能解析到。后端环境变量里写jdbc:postgresql://postgres:5432/appdb,靠的就是这个。

2. PGDATA子目录:PostgreSQL官方镜像建议PGDATA指向挂载点下的子目录(/var/lib/postgresql/data/pgdata),避免挂载点根目录权限问题导致初始化失败。这个坑我踩过,容器启动报initdb: directory not empty。

3. healthcheck的start_period:后端Spring Boot启动加连接池初始化大概40秒,我把start_period设成60s,这期间健康检查失败不计入retries计数,避免误判。这个参数很多人忽略,结果容器刚启动就被标记unhealthy。

4. depends_on的condition:Compose V2里service_healthy是核心,nginx必须等backend healthy才启动。注意depends_on只控制启动顺序,不控制重启,backend挂了nginx不会跟着重启,这属于预期行为。

五、踩坑与优化

坑1:alpine版postgres的pg_isready路径。alpine镜像里pg_isready在PATH中,直接调没问题。但如果你的健康检查写成CMD-SHELL pg_isready -h localhost,localhost走的是TCP且可能触发认证,用默认socket方式更稳,所以我去掉了-h参数。

坑2:redis健康检查的密码泄露。redis-cli -a会在进程列表里暴露密码,虽然容器内影响有限。更稳妥的做法是用REDISCLI_AUTH环境变量,但Compose的healthcheck test不方便带env,权衡后保留-a,生产环境建议换成自定义健康检查脚本。

坑3:卷挂载的权限。bind mount的./static目录如果宿主机属主是root,Nginx容器内worker进程(默认nginx用户)可能读不了。我改成chown -R 101:101 ./static(alpine里nginx的uid是101)。命名卷由Docker管理,没这个问题。

优化:健康检查参数调优。最初interval: 30s,导致后端启动后要等最多30秒才被标记healthy,nginx跟着延迟启动,整体冷启动接近2分钟。把interval降到10-15s、timeout 3-5s后,冷启动压到约75秒。别把interval设太短,pg_isready本身开销小,但太频繁没意义,10s是个平衡点。

优化:日志驱动。默认json-file日志会无限增长,我加了限制:

logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

放在每个service下,单容器日志上限30MB,避免磁盘被撑爆。

优化:资源限制。生产环境建议给backend加deploy.resources.limits,比如memory: 1g、cpus: "1.5",防止单个服务拖垮宿主机。注意在非Swarm模式下要用deploy.resources配合docker compose,V2是支持的。

六、效果数据

迁移前后对比,同一台4C8G机器:

指标 迁移前(手动部署) 迁移后(Compose)
环境搭建耗时 约2小时 docker compose up -d 约75秒
冷启动到可访问 手动启服务,易漏步骤 75秒(含健康检查等待)
服务间连通性 写死IP,改动频繁 服务名DNS,零配置
数据持久化 靠备份脚本 命名卷自动持久化
故障恢复 手动重启 restart: unless-stopped 自动拉起

实测docker compose up -d到nginx返回200,记录几次平均在72-78秒。docker compose ps能看到三个服务healthy、nginx running。docker compose down再up,数据库数据完整保留。

镜像总体积:postgres alpine 80MB + redis alpine 40MB + nginx alpine 50MB + backend约320MB,合计约490MB,比之前debian底包方案省了约400MB。

七、总结

docker-compose编排看着简单,但网络、卷、健康检查、启动顺序这四块设计不好,后面全是坑。核心经验就几条:

  1. 永远用自定义网络,服务名互访,别碰IP和localhost
  2. 有状态服务必须挂命名卷,down不丢数据
  3. 健康检查配start_period,给慢启动服务留缓冲
  4. 用depends_on的condition: service_healthy控制顺序,而不是简单写个depends_on: [postgres]
  5. 密码走.env,别硬编码进YAML

这套配置现在跑在测试和预发环境,稳定运行三个月没出过编排层面的问题。下一步打算把backend和nginx拆到独立的compose文件用-f组合,方便按环境覆盖配置。配置本身可以直接复制去改,注意按自己服务的健康端点和启动时间调整start_period和interval。