一、问题背景
上个季度我把公司一个内部管理系统从"三台虚拟机手动部署"迁移到了容器化方案。这套系统包含四个组件: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编排看着简单,但网络、卷、健康检查、启动顺序这四块设计不好,后面全是坑。核心经验就几条:
- 永远用自定义网络,服务名互访,别碰IP和localhost
- 有状态服务必须挂命名卷,
down不丢数据 - 健康检查配
start_period,给慢启动服务留缓冲 - 用
depends_on的condition: service_healthy控制顺序,而不是简单写个depends_on: [postgres] - 密码走
.env,别硬编码进YAML
这套配置现在跑在测试和预发环境,稳定运行三个月没出过编排层面的问题。下一步打算把backend和nginx拆到独立的compose文件用-f组合,方便按环境覆盖配置。配置本身可以直接复制去改,注意按自己服务的健康端点和启动时间调整start_period和interval。