一、问题背景:手工docker run已经失控了

上个月接手一个老项目,架构不复杂:Nginx做反向代理,一个Spring Boot应用提供REST API,后端挂PostgreSQL和Redis。但部署方式极其原始——四个docker run命令,每个容器单独启动,靠--link参数互相连接。

我接手后的第一个需求是加一个定时任务模块,需要再起一个Spring Boot实例。这意味着我要手动管理5个容器的启动顺序、网络别名、数据卷路径。更头疼的是,每次宿主机重启后,PostgreSQL容器的IP会变,Spring Boot配置文件里的spring.datasource.url就得改。

这种部署方式的致命缺陷在某个周五下午彻底爆发:我重启了Redis容器,结果Spring Boot连接池里的旧连接没失效,整个服务挂了40分钟。老板站在我身后看我用docker inspect查IP,那感觉别提了。

这次事故之后我决定彻底重构部署方案,核心目标有三个:
1. 一条命令启动全部服务,且能保证PostgreSQL和Redis先于Spring Boot就绪
2. 容器间通过服务名互相访问,彻底抛弃IP地址
3. 数据持久化到宿主机,容器随便删,数据不能丢

二、环境与版本:别用太老的版本

先说下我这边的运行环境,版本差异会导致配置语法不同,这是很多教程没讲清楚的:

宿主机:Ubuntu 22.04.3 LTS
Docker Engine:26.1.3
Docker Compose:v2.24.2(重要!旧版v1不支持depends_on的condition语法)

服务镜像版本:

nginx:1.25.3-alpine
postgres:16.1-alpine
redis:7.2.3-alpine
spring-boot镜像:基于eclipse-temurin:17-jdk构建,tag为1.0.0

如果你还在用docker-compose v1(Python写的那个),请立刻升级。v2是Go写的,性能和语法支持都更好。检查版本用docker compose version,注意中间有空格,不是docker-compose

三、方案设计:网络、卷、健康检查三位一体

我的设计思路分三层:

第一层:网络隔离。创建一个自定义bridge网络app-network,所有服务都挂在这个网络上。好处是:服务名即DNS,postgres这个容器名在整个网络内可以直接当hostname用;而且这个网络与宿主机隔离,外部只能通过Nginx的80端口访问。

第二层:数据持久化。PostgreSQL和Redis的数据必须落在宿主机上。我用了Bind Mount方式,直接映射到宿主机特定目录。为什么不建议用named volume?因为named volume在docker compose down -v时会被删除,而Bind Mount只要不手动删目录,数据永远在。

第三层:启动顺序控制。这才是最关键的。Spring Boot连接PostgreSQL和Redis,如果数据库没起来就启动应用,连接池初始化会直接失败(除非你配置了retry)。我用了完整的depends_on条件控制:

depends_on:
  postgres:
    condition: service_healthy
  redis:
    condition: service_healthy

这个配置的意思是:只有当postgres和redis的healthcheck返回healthy状态后,才会启动springboot容器。这就彻底解决了启动顺序问题。

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

下面是我项目里实际使用的配置,为了脱敏改了一些密码和IP,但结构完全一致:

version: "3.8"

networks:
  app-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local

services:
  # ---------- Nginx 反向代理 ----------
  nginx:
    image: nginx:1.25.3-alpine
    container_name: gateway-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./logs/nginx:/var/log/nginx
    networks:
      - app-network
    depends_on:
      springboot:
        condition: service_started
    restart: unless-stopped

  # ---------- Spring Boot 应用 ----------
  springboot:
    image: myapp/server:1.0.0
    container_name: app-server
    expose:
      - "8080"
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
      SPRING_DATASOURCE_USERNAME: app_user
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
      TZ: Asia/Shanghai
    volumes:
      - ./logs/app:/logs
      - /etc/localtime:/etc/localtime:ro
    networks:
      - app-network
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

  # ---------- PostgreSQL 数据库 ----------
  postgres:
    image: postgres:16.1-alpine
    container_name: db-postgres
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-sql:/docker-entrypoint-initdb.d:ro
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d mydb"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  # ---------- Redis 缓存 ----------
  redis:
    image: redis:7.2.3-alpine
    container_name: cache-redis
    command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
    volumes:
      - redis_data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

注意几个细节:
- springboot服务我用的是expose而不是ports,因为不需要直接暴露给宿主机,只有Nginx需要访问它
- Nginx的depends_on只用了service_started,因为Nginx启动后如果后端没起来,它只会报502但不会退出,所以不需要等healthy
- 密码用了环境变量${DB_PASSWORD},通过.env文件注入,避免明文写在yml里
- PostgreSQL的pg_isready命令必须指定用户名和数据库名,否则即使服务起来了但库还没创建好,也会误报healthy

启动命令就一行:

# 在项目根目录,确保.env文件存在
docker compose up -d --build

查看启动状态:

docker compose ps

输出类似这样:

NAME           IMAGE                    STATUS                          
gateway-nginx  nginx:1.25.3-alpine      Up 2 minutes (healthy)          
app-server     myapp/server:1.0.0       Up 2 minutes (healthy)          
db-postgres    postgres:16.1-alpine     Up 2 minutes (healthy)          
cache-redis    redis:7.2.3-alpine       Up 2 minutes (healthy)          

五、踩坑与优化:这些坑你们别再踩了

坑1:healthcheck的start_period必须设置。Spring Boot应用启动需要大约20秒(JVM加载+Spring上下文初始化),如果start_period不设置,healthcheck会从容器启动那一刻就开始检测。前20秒内检测失败5次,容器直接标记为unhealthy,然后depends_on的Spring Boot服务判断postgres不健康,一直等待。最后整个compose卡死。

坑2:pg_isready的陷阱。PostgreSQL的官方healthcheck一般推荐:

test: ["CMD-SHELL", "pg_isready -U postgres"]

但我的场景里用户是app_user,数据库是mydb。如果执行pg_isready -U app_user,返回的是accepting connections,但实际上mydb这个库可能还没创建完成。解决方式是显式指定数据库名。

坑3:Redis的healthcheck带密码会泄露redis-cli -a ${REDIS_PASSWORD} ping这个命令会在docker inspect里看到明文密码。我临时用着,生产环境建议用--no-auth-warning参数抑制警告,或者更优雅的方式是挂载redis.conf文件来配置密码,不通过命令行传参。

坑4:Nginx首次启动后连不上Spring Boot。这是因为Nginx配置里我写了proxy_pass http://springboot:8080,但Nginx的worker进程启动时解析了DNS,如果当时Spring Boot还没起来,解析失败后就缓存了失败结果。解决方案是在nginx.conf里加resolver 127.0.0.11 valid=10s,让Nginx使用Docker内置DNS服务器,并每10秒重新解析。

优化1:日志轮转。Spring Boot的日志我挂载到./logs/app,如果不处理会无限增长。我在logback.xml里配置了SizeAndTimeBasedRollingPolicy,每天一个文件,单个超过100MB自动切割。

优化2:.env文件管理环境差异。根目录的.env文件内容示例:

DB_PASSWORD=YourStrongPass123
REDIS_PASSWORD=RedisPass456
COMPOSE_PROJECT_NAME=myapp

不同环境(开发、测试、生产)维护不同的.env文件,启动时通过--env-file指定:

docker compose --env-file .env.prod up -d

六、效果数据:重构后的真实提升

这次重构的效果是立竿见影的。我做了个简单的时间对比:

操作 重构前(docker run) 重构后(docker compose)
首次全量部署 12分钟(手动敲4条命令+改配置) 3分40秒(含构建镜像)
日常重启 5分钟(手动启停+检查IP) 35秒(docker compose restart)
添加新节点 需要手动配置网络和link 修改yml加一个service即可
数据迁移 需要手动备份和恢复 docker compose down不删卷,数据保留

还有一个我没预料到的收益:因为healthcheck的存在,我现在能通过docker compose ps一眼看到所有服务的健康状态。之前Spring Boot假死(进程在但请求超时)的情况,现在healthcheck能检测到并自动重启容器。

对了,我还在CI/CD流水线里加了docker compose config --quiet这一行来校验yml语法,避免语法错误导致发布失败。

七、总结

Docker Compose解决的不只是“少打几条命令”的问题,它把服务编排变成了代码,可以版本化、可审查、可回滚。这次重构个人觉得最值的三个决策是:

  1. 自定义网络替代--link。服务名即主机名,彻底摆脱IP依赖
  2. healthcheck + depends_on condition。用声明式的方式解决了服务依赖顺序
  3. Bind Mount + named volume分离。配置文件用Bind Mount(因为要改),数据用volume(因为不能丢)

最后送大家一句话:如果你还在为一个项目手动管理3个以上容器,别犹豫了,今天就把docker-compose.yml写了吧。你不用把所有功能都学完,只需要把网络、卷、健康检查这三个点用熟,就能解决80%的实际问题。


有什么问题欢迎评论区交流,我知道的都会回复。