一、问题背景

团队项目早期采用手动方式部署:先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版本省了一半多。

三、方案设计

整体设计思路:

  1. 网络:创建自定义bridge网络app-net,三个服务都接入。自定义网络的好处是Docker内置DNS,服务之间直接用服务名互相访问(比如Web应用连db:5432),不用管IP。同时自定义网络和默认bridge隔离,避免和其他项目的容器串在一起。

  2. 卷挂载:

  3. PostgreSQL数据目录/var/lib/postgresql/data挂载到命名卷pgdata
  4. Redis开启AOF持久化,/data挂载到命名卷redisdata
  5. Web应用代码在开发环境用bind mount挂载进去,方便热更新;生产环境直接打进镜像

  6. 健康检查:

  7. PostgreSQL用pg_isready检测
  8. Redis用redis-cli ping
  9. Web应用用一个HTTP健康检查端点/healthz

  10. 启动顺序:用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能看到健康状态,这两个命令在调试时很好用。