一、问题背景:当docker run命令超过十条

我们的项目是典型的“网关+应用+数据库”三层结构。最初部署时,我维护着一个deploy.sh脚本,里面有13条docker run命令,每条带3-4个参数。最痛苦的不是命令长,而是启动顺序:必须先起数据库,等它完全就绪(不是容器启动,是PostgreSQL能接受连接),再起后端,最后起Nginx。否则后端启动时连不上数据库,直接抛异常退出,整套流程就得重来。

另一个痛点是网络。之前所有容器都放在默认bridge网络里,通过IP互相访问。一旦容器重建,IP变化,就得去改配置文件。后来改用docker network connect手动关联,但脚本臃肿到我自己都不想看第二次。

还有个隐患:数据库容器每次升级,都要小心翼翼处理数据卷。生产环境的数据卷已经12GB,绝不能因为误操作——比如直接映射宿主目录导致权限错乱(PostgreSQL镜像内postgres用户UID是999,宿主目录经常不是这个UID,就会报data directory has invalid permissions)——造成数据不可读。

二、环境与版本:一套干净的Compose基线

先交代实验环境,方便读者对照复现:

  • Docker Engine:24.0.7(含Compose v2.21.0插件)
  • 宿主机:Ubuntu 22.04 LTS,内核5.15,4核8G
  • 镜像版本:nginx:1.25.3-alpine、openjdk:17-slim、postgres:14.10-alpine
  • 后端应用:Spring Boot 2.7.18,JVM参数-Xmx512m -Xms256m,健康检查依赖Actuator的/actuator/health端点
  • 网络规划:前端静态资源放Nginx容器卷挂载,后端API通过内部网络访问数据库,外部仅暴露Nginx的80端口

这里强调一点:生产环境不要用latest标签,必须锁定小版本。这次改造我们特意把postgres从latest回退到14.10-alpine,因为PostgreSQL 16的存储格式变更会影响后续pg_upgrade路径。

三、方案设计:三个关键决策

决策1:网络拓扑采用“双网络”模式

我们建了两个自定义bridge网络:
- frontend_net:连接Nginx和后端(Nginx需要转发API请求到后端)
- backend_net:连接后端和数据库(数据库端口不对外)

为什么不让三个服务都在一个网络?为了隔离。后端容器即使被攻破,也无法直接扫描数据库端口。Nginx和后端在同一网络,是因为Nginx作为反向代理需要动态解析后端服务名。

决策2:数据持久化用命名卷而非bind mount

数据库数据卷用pg_data命名卷。命名卷由Docker管理,自动处理权限,避免bind mount带来的UID不匹配问题。后端代码用bind mount挂载./backend/target目录,实现开发环境的热部署——宿主Maven编译刷新,容器内Spring Boot devtools自动重启。

决策3:用healthcheck替代depends_on的裸条件

depends_on只能控制容器创建顺序,不能控制服务就绪。比如数据库容器启动了,但PostgreSQL还在恢复过程,此时后端连接肯定失败。因此必须给数据库加healthcheck,让后端在依赖“健康”状态后再启动。

四、核心实现:完整docker-compose.yml解剖

直接上配置,这是改了三版后的最终形态(文件路径:/opt/app/docker-compose.yml):

version: "3.9"

networks:
  frontend_net:
    driver: bridge
    ipam:
      config:
        - subnet: "172.28.0.0/24"
  backend_net:
    driver: bridge
    ipam:
      config:
        - subnet: "172.29.0.0/24"

volumes:
  pg_data:
    name: prod_pg_data
  pg_backup:
    name: prod_pg_backup
  nginx_html:
    name: prod_nginx_html

services:
  nginx:
    image: nginx:1.25.3-alpine
    container_name: gateway_nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - nginx_html:/usr/share/nginx/html:ro
    networks:
      - frontend_net
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: myapp-backend:1.4.2
    container_name: app_backend
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres_db:5432/myapp
      SPRING_DATASOURCE_USERNAME: app_user
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      TZ: "Asia/Shanghai"
    volumes:
      - ./backend/target:/app/target:rw
    networks:
      - frontend_net
      - backend_net
    depends_on:
      postgres_db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 60s
    deploy:
      resources:
        limits:
          memory: 768M
        reservations:
          memory: 512M
    restart: unless-stopped

  postgres_db:
    image: postgres:14.10-alpine
    container_name: db_postgres
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pg_data:/var/lib/postgresql/data
      - pg_backup:/backup
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    networks:
      - backend_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d myapp -h 127.0.0.1 -p 5432"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
    restart: unless-stopped

关键点逐个解释:

  1. depends_on的condition语法:这里用的是Compose v2支持的service_healthy条件。注意,version: "3.9"字段在Compose v2中已被标记为弃用,但为了兼容旧项目脚本,保留无妨,它不读取version字段。真正起作用的是condition关键字——只有postgres_db的healthcheck连续成功(默认3次),才会启动backend服务。

  2. 后端健康检查的start_period设为60s:因为Spring Boot首次启动需要加载大量Bean,JVM预热加连接池初始化,实测冷启动耗时约35-45秒。如果start_period太短,healthcheck会误报失败。这里设置60s给足时间,期间不计入retries失败次数。

  3. 数据库的PGDATA子目录:为什么设置PGDATA: /var/lib/postgresql/data/pgdata?因为将数据放在挂载卷的子目录中,可以避免PostgreSQL镜像初始化时与卷根目录的权限博弈。如果不设置,PostgreSQL 14+镜像会报FATAL: data directory "/var/lib/postgresql/data" has invalid permissions——因为卷根目录的所有者是root(UID 0),而容器内postgres用户是UID 999。把数据放到子目录,Docker初始化时能自动chown该子目录。

  4. 资源限制:deploy节点在非Swarm模式下只支持resources.limits,不支持restart_policy(那是Swarm的),所以restart_policy放在服务顶层。这里限制后端内存768M,实测Spring Boot应用在512M堆内运行稳定,预留256M给JVM元空间和线程栈。

  5. 密码管理:通过环境变量${DB_PASSWORD}引用宿主环境变量。执行前必须export,或者在项目目录放.env文件——注意.env会被Compose自动读取。生产环境建议用Docker Secrets或Vault,这里不做展开。

第二段代码:初始化脚本示例

./init-scripts/01-init.sql是数据库首次启动时自动执行的(仅当数据卷为空时):

-- 创建应用专用账号(非超级用户),遵循最小权限原则
CREATE ROLE app_readwrite WITH LOGIN PASSWORD 'change_me';
GRANT CONNECT ON DATABASE myapp TO app_readwrite;
GRANT USAGE ON SCHEMA public TO app_readwrite;

-- 业务表授权
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_readwrite;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_readwrite;

-- 序列授权(如果使用PostgreSQL序列)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_readwrite;

五、踩坑与优化:三个典型问题及解决

坑1:healthcheck的test命令超时

最初我给PostgreSQL的healthcheck写的是pg_isready -h localhost,结果容器一直报unhealthy。排查发现,localhost在容器内解析为IPv6的::1,而PostgreSQL默认只监听IPv4的0.0.0.0。改成127.0.0.1后立刻通过。这个坑很隐蔽,尤其当你的镜像里没有iproute2包时,用curl都没法调试。

坑2:Nginx解析不到后端服务名

Compose网络内置DNS服务,服务名(如backend)在容器内可直接解析。但Nginx配置中如果用proxy_pass http://backend:8080;,要注意Nginx启动时就会解析域名并缓存IP。如果backend容器重启导致IP变化,Nginx会报502。解决方案有二:
- Nginx使用变量:set $backend_upstream backend; proxy_pass http://$backend_upstream:8080; 这样每次请求时动态解析。
- 给backend容器设置固定IP(在networks节点下指定ipv4_address),但会牺牲灵活性。

我选择了变量方案,实测请求延迟增加约0.5ms,可忽略。

坑3:bind mount后端target目录导致启动变慢

开发环境挂载./backend/target后,容器内文件系统事件监听会扫描整个target目录(包含大量.class文件和静态资源),导致启动时间从35秒增加到55秒。优化方案:在docker-compose.yml同目录创建.dockerignore文件,排除target目录下的临时文件,同时用volume子命令挂载单个jar包目录:

volumes:
  - ./backend/target/classes:/app/classes:rw
  - ./backend/target/dependency:/app/dependency:rw

但最终我妥协了——直接挂载整个target,因为Spring Boot devtools的自动重启功能太重要,多出的20秒换取开发效率的提升是值得的。生产环境则不用这个挂载,直接打进镜像。

六、效果数据:改造前后的量化对比

改造前(手动脚本):
- 部署耗时:约15分钟,包含人工等待和检查日志
- 故障率:每月约2-3次因启动顺序导致的服务不可用
- 配置同步:需要手动维护5个不同的脚本文件

改造后(Compose编排):
- 部署耗时:docker compose up -d 一条命令,实测完整启动到健康通过时间为90秒(数据库初始化10秒+后端启动50秒+健康检查等待30秒)
- 故障率:连续运行3个月零启动顺序故障
- 回滚速度:docker compose down && git checkout && docker compose up -d 总时长不超过2分钟
- 资源占用:通过限制内存,后端容器峰值内存从1.2GB降到850MB,整体宿主机内存占用降低约30%

几个值得备注的细节:

  1. 健康检查日志要开启:docker compose logs --tail=50 能看到healthcheck的退出码,排查问题很有用。
  2. 卷备份用docker run --rm -v prod_pg_data:/data -v $(pwd):/backup alpine tar czf /backup/pg_$(date +%Y%m%d).tar.gz -C /data .,这条命令比在宿主机直接拷贝卷目录安全得多。
  3. 升级数据库版本时,先docker compose stop postgres_db,再docker compose run --rm postgres_db pg_upgrade,但注意pg_upgrade需要新旧版本二进制共存,建议用专门的升级容器。

七、总结与建议

Compose不是万能的,但它解决了我们90%的编排痛点。对于单机多容器部署,Compose的价值在于:把基础设施即代码(IaC)的理念落实到部署层——你的启动逻辑、网络拓扑、卷映射、健康检查都在一个YML文件里,版本可控、可评审、可回滚。

如果你的项目是Kubernetes集群,那Compose的deploy资源字段会失效,需要转换思路——但学习Compose的依赖控制和网络设计模式,对理解K8s的Pod、Service、Deployment概念依然有很好的铺垫作用。

最后,我的建议是:如果你的服务数量少于10个,且没有跨节点的调度需求,Docker Compose是性价比最高的选择。不要为了“先进”而强行引入K8s,运维复杂度会让你怀疑人生。用Compose把基础打牢,等规模真的上去了,再迁移到编排平台也不迟——而届时,你的docker-compose.yml里的每个配置项,都会成为你理解K8s的注脚。