一、问题背景
团队项目早期采用手动方式部署:先docker run启动PostgreSQL,再启动Redis,最后启动Web应用。三个容器各自独立管理,每次部署新环境都要重复执行一堆命令,还经常因为启动顺序不对导致Web应用连不上数据库而崩溃重启。更麻烦的是,数据库数据卷的挂载路径每次靠记忆输入,有一次误删了宿主机上的数据目录,导致测试数据全部丢失。
后来迁移到Docker Compose统一编排,一个docker compose up -d搞定所有服务。但初期配置写得很粗糙——网络用默认的,卷用匿名卷,健康检查完全没配,depends_on只写了服务名没写条件。结果Web应用虽然"依赖"数据库,但数据库初始化要8秒左右,Web应用启动时数据库还没ready,直接抛连接异常退出。容器虽然配置了restart: always会重启,但每次都要多等一轮重启周期,日志里全是错误堆栈,体验很差。
这篇文章就把这套配置从头到尾梳理一遍,把网络、卷挂载、健康检查和启动顺序这几个关键点讲清楚。
二、环境与版本
- 宿主机:Ubuntu 22.04 LTS,4核8G
- Docker Engine:25.0.3
- Docker Compose:v2.24.5(注意是
docker compose插件形式,不是老的docker-compose) - 服务栈:
- Web应用:Node.js 20.11.1,基于
node:20.11.1-alpine3.19镜像 - 数据库:PostgreSQL 16.2,基于
postgres:16.2-alpine - 缓存:Redis 7.2.4,基于
redis:7.2.4-alpine
选alpine版本是因为镜像体积小,三个镜像加起来约380MB,比debian版本省了一半多。
三、方案设计
整体设计思路:
-
网络:创建自定义bridge网络
app-net,三个服务都接入。自定义网络的好处是Docker内置DNS,服务之间直接用服务名互相访问(比如Web应用连db:5432),不用管IP。同时自定义网络和默认bridge隔离,避免和其他项目的容器串在一起。 -
卷挂载:
- PostgreSQL数据目录
/var/lib/postgresql/data挂载到命名卷pgdata - Redis开启AOF持久化,
/data挂载到命名卷redisdata -
Web应用代码在开发环境用bind mount挂载进去,方便热更新;生产环境直接打进镜像
-
健康检查:
- PostgreSQL用
pg_isready检测 - Redis用
redis-cli ping -
Web应用用一个HTTP健康检查端点
/healthz -
启动顺序:用
depends_on的condition: service_healthy,让Web应用等数据库和Redis都healthy之后再启动。
四、核心实现
先上完整的docker-compose.yml:
version: "3.9"
services:
db:
image: postgres:16.2-alpine
container_name: app-db
restart: unless-stopped
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass123
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro
networks:
- app-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
ports:
- "127.0.0.1:5432:5432"
redis:
image: redis:7.2.4-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
volumes:
- redisdata:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
ports:
- "127.0.0.1:6379:6379"
web:
build:
context: .
dockerfile: Dockerfile
container_name: app-web
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: postgres://appuser:apppass123@db:5432/appdb
REDIS_URL: redis://redis:6379
PORT: 3000
volumes:
- ./logs:/app/logs
networks:
- app-net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
ports:
- "3000:3000"
networks:
app-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
pgdata:
driver: local
redisdata:
driver: local
对应的Dockerfile(Web应用):
FROM node:20.11.1-alpine3.19
WORKDIR /app
RUN apk add --no-cache wget
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
几个关键点解释:
健康检查参数:start_period是容器启动后的"宽限期",这段时间内健康检查失败不计入retries。PostgreSQL首次初始化要建库建表,给10秒宽限期比较稳妥;Web应用启动要给15秒,因为它要等依赖项、加载配置。interval设5秒,意味着数据库最多5秒内能被检测到ready。
depends_on的condition:注意Compose v2里depends_on支持长语法,condition: service_healthy会等健康检查通过。如果只写短语法(- db),就只保证容器"启动"而不保证"可用",这是很多人踩的坑。
端口绑定:数据库和Redis的端口映射到127.0.0.1而不是0.0.0.0,避免暴露到公网。Web应用才对外开3000。
initdb挂载:./initdb目录放初始化SQL脚本,PostgreSQL镜像首次启动会自动执行,只读挂载(:ro)防止容器内误改宿主机文件。
五、踩坑与优化
坑1:健康检查命令在alpine镜像里找不到。 最初Web应用的健康检查写的是curl -f http://localhost:3000/healthz,结果alpine镜像默认没有curl,健康检查一直失败,容器被标记unhealthy,depends_on的下游服务永远起不来。换成wget -qO-(alpine自带busybox wget)解决。或者在Dockerfile里apk add curl,但没必要为个健康检查多装个包。
坑2:命名卷的权限问题。 PostgreSQL容器内以postgres用户(uid 999)运行,如果命名卷之前被root写过,权限不对会启动失败报data directory has wrong ownership。解决办法是别手动往卷里塞文件,或者用docker compose down -v清掉卷重建。
坑3:restart: always掩盖了启动顺序问题。 早期没配健康检查,Web应用连不上数据库就退出,restart: always让它反复重启,表面看"最终能起来",但日志里一堆错误,而且重启间隔是递增的(Docker的backoff机制),实际恢复时间不可控。配了condition: service_healthy之后,Web应用根本不会在依赖未就绪时启动,日志干净很多。
优化1:健康检查间隔调优。 一开始interval: 1s,三个服务每秒都在跑健康检查命令,PostgreSQL的pg_isready虽然轻量,但频率太高也没必要。调到5秒后,CPU占用从约3%降到1%以下,启动时间几乎没变(因为start_period兜底了)。
优化2:日志驱动限制。 加了全局配置防止日志把磁盘写满:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
然后在每个service下加logging: *default-logging。跑了两个月,日志目录稳定在90MB以内。
六、效果数据
配置前后的对比(在4核8G机器上,清空所有容器和卷后冷启动):
| 指标 | 手动部署 | 初版Compose(无健康检查) | 最终配置 |
|---|---|---|---|
| 冷启动到全部可用 | 约180秒 | 约45秒(含1-2次重启) | 约25秒 |
| 启动失败率 | 高(顺序错误) | 中(依赖未就绪) | 0(20次测试) |
| 容器间通信延迟 | - | 0.6ms | 0.5ms |
| 日志错误行数(启动阶段) | - | 30+ | 0 |
容器间通信延迟用docker exec app-web ping -c 100 db测的,自定义bridge网络下平均0.5ms,和默认bridge差不多,但DNS解析更稳定。
七、总结
Docker Compose编排的核心就几件事:网络让服务能互相找到,卷让数据不丢,健康检查让"依赖"真正有意义,depends_on的condition把启动顺序落到实处。这套配置不算复杂,但每个参数背后都有具体的坑。建议大家在写compose文件时,别只写短语法的depends_on,花几分钟把healthcheck配上,能省掉后面一堆"为什么我的服务起不来"的排查时间。另外,docker compose config可以校验配置文件语法,docker compose ps能看到健康状态,这两个命令在调试时很好用。