一、问题背景

去年接手一个内部管理系统,技术栈是 Vue3 + Node.js(Express) + PostgreSQL + Redis。开发阶段大家都是本地起服务,数据库用 Docker 跑一个容器,勉强能对付。但到了测试环境部署,问题就来了:

  1. API 容器启动比数据库快,连不上 PG 直接崩溃退出,得手动 restart。
  2. Redis 没设密码,测试环境被扫到,虽然没造成损失但被安全同事点名。
  3. 数据库数据放在容器内,一次 docker compose down 把测试数据全清了,重新造数据花了大半天。
  4. 四个容器各自 docker run,端口、网络、环境变量散落在部署文档里,换台机器就得重新对一遍。

这些问题的本质是:容器之间没有编排,启动顺序、网络、持久化全靠人肉维护。后来用 Docker Compose 重构了一遍,把这些问题一次性解决。下面把这套配置完整拆开讲。

二、环境与版本

先明确版本,Compose 文件格式在不同版本间差异不小,尤其是 depends_on 的 condition 支持情况。

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是 v2,命令是 docker compose 而不是 docker-compose
  • Compose 文件格式:version 字段在 v2 中已废弃,不再写,直接用顶层 services
  • 镜像版本:
  • nginx:1.25.3-alpine
  • node:20.10.0-alpine3.18
  • postgres:16.1-alpine
  • redis:7.2.3-alpine

选 alpine 版本主要是镜像小,nginx 官方 alpine 镜像约 40MB,postgres alpine 约 240MB,比 Debian 版小一半以上。

三、方案设计

整体结构是:Nginx 作为唯一对外入口,监听 80/443,反向代理到 API;API 通过内部网络访问 PG 和 Redis;PG 和 Redis 不暴露宿主机端口,只在内网可达。

网络设计上,我用了两个自定义 bridge 网络:

  • frontend-net:Nginx 和 API 加入,负责外部请求转发。
  • backend-net:API、PG、Redis 加入,数据层流量隔离在这张网里。

这样即使 Nginx 被攻破,它也访问不到数据库,只能到 API。API 同时挂两张网,作为唯一能跨网通信的服务。

卷设计上,三个命名卷:

  • pg_data:PostgreSQL 数据目录 /var/lib/postgresql/data
  • redis_data:Redis AOF 持久化目录 /data
  • nginx_logs:Nginx 日志,方便宿主机直接看

启动顺序用 depends_on + condition: service_healthy 控制。这里有个关键点:depends_on 默认只等容器启动,不等服务就绪,所以必须配合 healthcheckservice_healthy 条件。

四、核心实现

先看完整的 docker-compose.yml:

name: internal-admin

services:
  postgres:
    image: postgres:16.1-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD is required}
      POSTGRES_DB: admin_db
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pg_data:/var/lib/postgresql/data
    networks:
      - backend-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin -d admin_db"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 1G
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  redis:
    image: redis:7.2.3-alpine
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
      --appendonly yes
      --appendfsync everysec
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: internal-admin/api:1.4.2
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: 3000
      DATABASE_URL: postgres://admin:${PG_PASSWORD}@postgres:5432/admin_db
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend-net
      - backend-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  nginx:
    image: nginx:1.25.3-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - nginx_logs:/var/log/nginx
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend-net
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

networks:
  frontend-net:
    driver: bridge
  backend-net:
    driver: bridge
    internal: false

volumes:
  pg_data:
  redis_data:
  nginx_logs:

几个关键点解释一下。

1. 环境变量必填校验

${PG_PASSWORD:?PG_PASSWORD is required} 这个语法会在变量未设置时直接报错退出,而不是用空字符串继续跑。这个在 .env 文件忘记配的时候能救命,比跑起来后数据库连接失败再排查快得多。

2. PGDATA 子目录

PostgreSQL 官方镜像有个坑:如果数据目录挂载的是非空目录,初始化会失败。把 PGDATA 指到 /var/lib/postgresql/data/pgdata 子目录,即使挂载点里有 lost+found 之类的文件也不影响初始化。

3. 健康检查的 start_period

start_period 是给容器启动预留的宽限期,这段时间内健康检查失败不计入 retries。PG 首次初始化要跑 initdb,冷启动大概 15-20 秒,所以给 30 秒。API 是 Node 应用,冷启动加载依赖约 8 秒,给 20 秒。

4. Redis 密码通过命令行传

Redis 的健康检查用了 redis-cli -a,密码会出现在容器进程列表里。如果对安全要求更高,可以改用 REDISCLI_AUTH 环境变量,避免密码出现在命令行。

再看 API 的 Dockerfile,多阶段构建:

FROM node:20.10.0-alpine3.18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20.10.0-alpine3.18
WORKDIR /app
RUN apk add --no-cache wget
COPY --from=builder /app/node_modules ./node_modules
COPY . .
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "src/server.js"]

这里 apk add wget 是必须的,因为 alpine 镜像默认没有 wget,健康检查会失败。踩过这个坑,一开始用 curl 也不行,得装。用非 root 用户运行是基本要求,adduser -S 创建系统用户。

API 的 /health 端点实现很简单,但要确认它检查了下游依赖:

app.get('/health', async (req, res) => {
  try {
    await db.query('SELECT 1');
    await redis.ping();
    res.status(200).json({ status: 'ok' });
  } catch (err) {
    res.status(503).json({ status: 'unhealthy', error: err.message });
  }
});

注意这里返回 503 而不是 500,健康检查端点用 503 更语义化。但有个细节:如果 PG 短暂抖动,API 健康检查失败,Nginx 的 depends_on 不会因此重启 Nginx,这个只影响启动阶段。运行时的故障恢复靠 restart: unless-stopped 和 API 自己的重连逻辑。

Nginx 配置片段:

upstream api_backend {
    server api:3000;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    location /api/ {
        proxy_pass http://api_backend;
        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 30s;
    }

    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }
}

keepalive 32 配合 proxy_http_version 1.1Connection "" 开启长连接,压测时 QPS 能提升约 15%。

五、踩坑与优化

坑 1:depends_on 的 condition 在 v2 里语法变了

网上很多老文章写的是:

depends_on:
  postgres:
    condition: service_healthy

这个在 Compose v2 里是支持的,但如果你用的是 v3 格式(version: "3.8"),condition 会被忽略。我一开始写的是 v3.8,结果发现启动顺序根本没生效,排查了半天。v2 直接不写 version 字段,用顶层 services 即可

坑 2:健康检查间隔太短导致误判

最初 PG 的 interval 设的是 5s,retries 是 3,结果 PG 初始化时因为 IO 抖动,健康检查连续失败被标记为 unhealthy,API 一直等不到就绪。后来改成 interval: 10sretries: 5start_period: 30s 才稳定。健康检查不是越频繁越好,给服务留出启动余量。

坑 3:Redis 的 AOF 和 maxmemory 需要一起配

只开 AOF 不设 maxmemory,Redis 会一直吃内存直到 OOM。加上 --maxmemory 256mb --maxmemory-policy allkeys-lru 后,内存稳定在 200MB 左右。但注意 LRU 会淘汰 key,如果业务里有不能丢的缓存,得用 volatile-lru 只淘汰带过期时间的 key。

坑 4:日志不轮转,磁盘被写满

默认 json-file 日志驱动不限制大小,API 跑了三周把宿主机 40G 磁盘写满了。加上 max-size: "10m"max-file: "3" 后,单服务日志最多占 30MB。

优化:资源限制用 deploy.resources

docker compose 在非 swarm 模式下也支持 deploy.resources.limits。实测限制后,API 内存稳定在 180MB 左右,PG 在 400MB 左右,Redis 在 200MB 左右,四个服务加起来不到 1G,一台 2C4G 的机器跑得绰绰有余。

六、效果数据

重构后在测试环境实测:

  • 完整启动(从 docker compose up -d 到所有服务 healthy):约 38 秒。其中 PG 初始化 22 秒,API 启动 8 秒,Nginx 启动 2 秒,健康检查轮询间隔贡献约 6 秒。
  • 相比之前手动 docker run 加手动 restart 的方式,部署时间从平均 15 分钟降到 1 分钟以内。
  • 数据持久化后,docker compose downup,数据完整保留,不再需要重新造数据。
  • 网络隔离后,安全扫描不再报告数据库端口暴露问题。
  • 日志轮转配置后,连续运行 30 天磁盘占用稳定在 200MB 以内。

API 的 P95 响应时间在加入 Nginx keepalive 后从 45ms 降到 38ms,提升约 15%。这个数字不大,但属于顺手优化。

七、总结

这套 Compose 配置的核心就三件事:网络隔离、卷持久化、健康检查驱动的启动顺序。看起来简单,但每个点都有细节:

  • 网络用两张 bridge,数据层不对外暴露。
  • 卷用命名卷而非 bind mount,跨机器迁移更方便。
  • 健康检查的 start_periodretries 要按服务实际启动时间调,不能照抄。
  • depends_oncondition 在 Compose v2 里才完整支持,别用 v3 格式。

这套配置目前跑在三个环境(测试、预发、生产),除了资源限制参数按环境微调,其他完全一致。如果项目再复杂一点,比如要加消息队列或者多个 API 实例,可以考虑上 Kubernetes,但就目前这个规模,Compose 足够且运维成本最低。

最后提醒一句:.env 文件千万别提交到 Git,密码用 ${VAR:?} 语法强制校验,能避免很多低级事故。