一、背景:当Shell脚本撑不住的时候

上季度我们团队接手了一个电商后台系统,包含网关、用户服务、订单服务、MySQL和Redis五个组件。最初用Shell脚本按顺序启动,每次发版都要人工盯着日志,Redis没起来MySQL就先连了,MySQL没准备好Spring Boot就启动失败——这种竞态问题在测试环境一周能碰到三次。更头疼的是,不同服务对网络配置、日志路径各有要求,五台开发机的环境差异导致“在我这能跑”成为口头禅。

后来我们决定迁移到Docker Compose。目标很明确:一套配置到处跑,启动顺序可靠,依赖关系清晰。但这套方案落地过程中,我们踩了不少坑——比如healthcheck不能乱写、depends_on的condition在旧版本Compose里不支持、named volume和bind mount的权限问题。这篇文章会把最终可用的配置拆开揉碎讲清楚。

二、环境与版本

我们最终锁定的版本组合(2024年3月验证通过):

  • Docker Engine:24.0.7(含containerd 1.7.11)
  • Docker Compose:v2.24.2(使用docker compose子命令,非docker-compose
  • 基础镜像:nginx:1.25.3-alpineeclipse-temurin:17-jdk-alpinepostgres:16.1-alpineredis:7.2-alpine
  • 宿主机:Ubuntu 22.04 LTS,内核5.15.0

注意:Compose V2的depends_on.condition字段必须配合version: "3.8"以上使用,我们用的是3.8

三、方案设计:四个服务,两张网络,三种卷

设计思路遵循“最小暴露,最佳隔离”原则:

  • 网络:自定义bridge网络backend(服务间通信)和frontend(仅Nginx对外暴露80端口)。应用服务不映射宿主机端口,只通过Nginx反向代理对外。
  • :三种类型混用:
  • named volume:PostgreSQL数据目录pgdata,Redis持久化redisdata
  • bind mount:应用日志输出到宿主机./logs/app,方便ELK采集
  • tmpfs:Nginx缓存目录,追求读写速度且不需要持久化
  • 健康检查:每个服务都配healthcheck,PostgreSQL用pg_isready,Redis用redis-cli ping,Spring Boot用curl /actuator/health,Nginx用wget -qO- http://localhost/healthz
  • 启动顺序depends_on + condition: service_healthy,确保严格按“基础设施→应用→网关”顺序启动。

四、核心实现:docker-compose.yml逐段拆解

先看完整配置(这是最终跑通的版本):

version: "3.8"

services:
  postgres:
    image: postgres:16.1-alpine
    container_name: shop-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: shop_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # 从.env文件读取
      POSTGRES_DB: shop_order
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_order"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    ports:
      - "5432:5432"  # 仅测试环境开放,生产将注释掉

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

  app:
    build: ./app
    container_name: shop-app
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/shop_order
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - ./logs/app:/app/logs
    networks:
      - backend
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 40s

  nginx:
    image: nginx:1.25.3-alpine
    container_name: shop-gateway
    restart: unless-stopped
    depends_on:
      app:
        condition: service_healthy
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - type: tmpfs
        target: /var/cache/nginx
        tmpfs-size: 100M
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true  # 关键:禁止外网访问backend网络

volumes:
  pgdata:
  redisdata:

4.1 网络配置的细节:internal网络防暴露

backend网络设置为internal: true,这是最容易被忽略的安全点。加了之后,容器无法通过backend网络访问外网,但容器之间通信不受影响。为什么需要?因为Spring Boot应用如果被攻破,攻击者无法用它做跳板访问公网。代价是应用无法直接访问外网API——如果业务里面有调用第三方接口的需求,需要把该服务放到非internal网络,或者配置代理。

4.2 健康检查的间隔设计

PostgreSQL的start_period: 30s是血的教训。Postgres 16在冷启动需要初始化数据目录,期间pg_isready会返回失败。没有start_period,Compose会在5次重试(每10秒一次)后判定容器不健康,但此时数据库其实还没初始化完。设置30秒宽限期后,Compose会忽略这段时间内健康检查的失败结果。

Redis的requirepass参数导致健康检查必须带密码。我们踩过坑:在test里直接用redis-cli ping,返回NOAUTH Authentication required,健康检查永远失败。最终改成redis-cli -a ${REDIS_PASSWORD} ping,但注意密码会出现在docker inspect的进程参数里,测试环境可以接受,生产建议用--no-auth-warning参数或Redis ACL。

4.3 depends_on的condition语义

Compose V2中,depends_on默认只控制启动顺序(不等待健康)。加了condition: service_healthy后,才会真正等待对端健康。这里有隐藏坑:如果app服务的健康检查配置有误(比如返回码不对),整个启动链会卡住直到超时。我们的做法是先单独启动基础服务,用docker compose ps观察健康状态,确认无误后再启动完整栈。

五、Nginx配置与日志卷挂载的权限坑

Nginx配置用的是bind mount,宿主机目录./nginx/conf.d需要包含default.conf

upstream shop_app {
    server app:8080;
}

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

    location /healthz {
        access_log off;
        return 200 "ok\n";
    }

    location / {
        proxy_pass http://shop_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

这段配置有个细节:/healthz路径必须在Nginx容器内可访问,且不能走代理到后端。因为nginx的健康检查是wget http://localhost/healthz,如果这个路径转发到app服务,而app还没就绪,Nginx会判定不健康——但实际上Nginx本身没问题。

日志卷挂载的坑:Spring Boot应用以非root用户运行(eclipse-temurin镜像默认用uid=1000),而宿主机./logs/app目录如果属主是root,应用会报Permission denied。解决办法:

mkdir -p logs/app
chown 1000:1000 logs/app

如果不做这步,应用启动时日志框架会直接抛异常,但应用本身不退出——排查起来非常诡异。

六、踩坑优化记录与最终效果

踩坑1:重建容器时数据卷权限错乱

某次docker compose down -v后,PostgreSQL的pgdata卷被删除重建,属主变成了root。再启动PostgreSQL时,容器内的postgres用户无法写入数据目录。解决方案:初始化时用--user参数指定UID,或者在启动命令里加chown -R postgres:postgres /var/lib/postgresql/data。我们最终选择在init-scripts目录放一个01-chown.sh脚本,结合docker-entrypoint-initdb.d机制自动修复。

踩坑2:tmpfs卷挂载后Nginx启动失败

最初把Nginx的/var/cache/nginx挂成bind mount(宿主机目录),结果启动直接报mkdir() "/var/cache/nginx/client_temp" failed。原因是宿主机目录权限是755,Nginx的worker进程无法在其中创建子目录。改用tmpfs后问题消失,而且性能更好——缓存读写走内存,响应时间平均降低12ms。

踩坑3:healthcheck的start_period不是万能的

Spring Boot应用在start_period: 40s内如果还没有监听8080端口,健康检查仍然算失败。但我们的应用启动需要60秒(数据源初始化+缓存预热)。最终优化方案:把interval从15秒调到20秒,retries从3次调到5次,同时start_period改为60秒。这样总等待时间是60+5×20=160秒,比之前的15×3=45秒宽容得多。

效果数据(对比迁移前):

指标 Shell脚本部署 Docker Compose
平均部署耗时 15分钟(含人工等待) 2分40秒
启动失败率 23%(10次中有2-3次) 3.8%(52次中有2次)
环境切换时间 30分钟(手动改配置) 2分钟(改.env文件)
新员工上手时间 2天 3小时

七、总结与建议

这套配置我们已经稳定运行三个月,线上环境(去掉调试端口)零故障。给后来者三个建议:

  1. 不要迷信depends_on:它只解决“启动顺序”,不解决“可用性”。真正的可靠性来自healthcheck,务必给每个服务配好,且start_period要大于最慢服务的启动时间。
  2. 网络隔离越早做越省心internal: true虽然会让某些服务无法访问外网,但这是安全底线。如果确实需要外网访问,用network字段单独接入frontend网络。
  3. 日志卷挂载前先想权限:官方镜像的默认UID各不相同(Postgres是70,Temurin是1000),挂载宿主机目录前先chown对应UID,否则排查权限问题会浪费大量时间。

最后,完整配置已脱敏上传至GitHub仓库(链接见评论区),需要的自取。有问题欢迎在评论区交流——特别是healthcheck的start_period参数调优,不同服务差异很大,欢迎分享你的经验值。