一、问题背景:手动部署的痛

上个月接手一个项目,技术栈是Nginx + Node.js API + Redis + PostgreSQL + 一个定时任务worker。之前部署靠的是运维写的一堆shell脚本,每次上线要手动执行:

docker network create app-net
docker run -d --name postgres --network app-net -v /data/pg:/var/lib/postgresql/data -e POSTGRES_PASSWORD=xxx postgres:16.2
sleep 10
docker run -d --name redis --network app-net redis:7.2-alpine
docker run -d --name api --network app-net -e DB_HOST=postgres ...
# 还要等API起来才能起Nginx

问题很明显:启动顺序靠sleep硬等,数据库没就绪API就崩;容器挂了不会自动重启;换台机器要重新敲一遍;卷路径写死在脚本里。最离谱的一次是数据库卷挂载路径写错,容器重启后数据全没了。

后来统一用Docker Compose重写,才算把这些坑填上。下面把配置和踩坑过程完整记录一下。

二、环境与版本

  • 宿主机:Ubuntu 22.04 LTS,4C8G
  • Docker Engine:25.0.3
  • Docker Compose:v2.24.5(注意是compose plugin,命令是docker compose不是docker-compose)
  • 镜像版本:postgres:16.2-alpine、redis:7.2.4-alpine、nginx:1.25.4-alpine、node:20.11.1-alpine

选alpine版本是因为镜像小,postgres从16.2的420MB降到16.2-alpine的240MB左右,拉取快很多。但alpine的musl libc在某些npm原生模块上有坑,后面会说。

三、方案设计

整体思路:

  1. 网络:自定义bridge网络app-network,不用默认的。默认网络里所有容器都能互相访问,自定义网络可以通过服务名做DNS解析,还能隔离。
  2. 卷:PostgreSQL数据用命名卷pg-data,Redis用redis-data开启AOF持久化。代码目录用bind mount方便开发,生产环境直接打进镜像。
  3. 健康检查:给postgres、redis、api都配healthcheck,这是控制启动顺序的关键。
  4. 启动顺序:depends_on配合condition: service_healthy,等依赖服务真正健康了再启动。
  5. 重启策略:统一restart: unless-stopped。

服务依赖关系:

nginx → api → postgres
            → redis
worker → postgres
       → redis

四、核心实现

目录结构:

project/
├── docker-compose.yml
├── .env
├── api/
│   └── Dockerfile
├── nginx/
│   └── nginx.conf
└── initdb/
    └── 01-init.sql

.env文件(不提交到git):

POSTGRES_USER=appuser
POSTGRES_PASSWORD=changeme_strong_pwd
POSTGRES_DB=appdb
REDIS_PASSWORD=redis_pwd_2024

主配置文件docker-compose.yml:

services:
  postgres:
    image: postgres:16.2-alpine
    container_name: app-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s

  redis:
    image: redis:7.2.4-alpine
    container_name: app-redis
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --appendonly yes
      --appendfsync everysec
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    container_name: app-api
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: ${POSTGRES_DB}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 40s
    deploy:
      resources:
        limits:
          cpus: '1.5'
          memory: 1G

  worker:
    build:
      context: ./api
      dockerfile: Dockerfile
    container_name: app-worker
    restart: unless-stopped
    command: ["node", "worker.js"]
    environment:
      DB_HOST: postgres
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    depends_on:
      api:
        condition: service_healthy
    networks:
      - app-network

  nginx:
    image: nginx:1.25.4-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - app-network

networks:
  app-network:
    driver: bridge
    name: app-network

volumes:
  pg-data:
    name: app-pg-data
  redis-data:
    name: app-redis-data

API的Dockerfile:

FROM node:20.11.1-alpine

WORKDIR /app

# 先复制依赖文件,利用层缓存
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY . .

# alpine默认没有wget? 有的,busybox自带
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 && \
    chown -R nodejs:nodejs /app
USER nodejs

EXPOSE 3000
CMD ["node", "server.js"]

nginx.conf关键片段:

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

server {
    listen 80;
    server_name _;

    location /health {
        access_log off;
        return 200 "ok\n";
    }

    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 60s;
    }
}

启动命令:

docker compose up -d
docker compose ps
docker compose logs -f api

五、踩坑与优化

坑1:depends_on不等待健康检查。 一开始只写了depends_on: [postgres],结果API启动时postgres还在初始化,直接报连接拒绝。后来改成condition: service_healthy才解决。注意这个语法在Compose v2里是原生支持的,但v1的docker-compose需要2.1以上版本的compose file format。

坑2:postgres的start_period太短。 默认healthcheck在容器启动后立刻开始算,postgres初始化数据目录可能要20-30秒,期间pg_isready会返回失败,retries用完了容器就被标记为unhealthy。加了start_period: 30s后,这30秒内的失败不计入retries。

坑3:PGDATA路径问题。 直接用默认的/var/lib/postgresql/data,如果这个目录是挂载卷的根,postgres会因为目录非空(有lost+found)而拒绝初始化。官方推荐把PGDATA设成子目录/var/lib/postgresql/data/pgdata,或者挂载到具体子路径。

坑4:redis healthcheck把密码明文写进命令。 redis-cli -a会警告密码不安全,而且docker inspect能看到。可以改用环境变量REDISCLI_AUTH,或者干脆用redis-cli ping配合配置文件的requirepass。

坑5:alpine镜像的时区。 默认是UTC,日志时间对不上。在Dockerfile里加RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime,或者在compose里挂载/etc/localtime:/etc/localtime:ro。

优化1:资源限制。 一开始没设limit,某次worker内存泄漏把宿主机8G吃满,OOM Killer把postgres杀了。加了deploy.resources.limits后,容器内部OOM不会影响其他服务。注意在非swarm模式下,deploy字段需要Compose v2才生效。

优化2:日志滚动。 默认json-file驱动不限制大小,跑一周日志能到几个G。给每个服务加:

logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

优化3:构建缓存。 npm ci这层单独抽出来,只要package.json不变就不会重新装依赖。实测改业务代码后的重建时间从90秒降到12秒。

六、效果数据

对比手动部署:

指标 手动脚本 Docker Compose
冷启动时间 ~8分钟 45秒
服务重启数据丢失 发生过1次 0次
换机器部署耗时 30分钟+ 3分钟(拉镜像)
日志排查 逐个docker logs docker compose logs -f
配置变更 改脚本重跑 改yml up -d

资源占用(空闲状态,docker stats):

  • postgres:约85MB
  • redis:约12MB
  • api:约75MB
  • worker:约60MB
  • nginx:约8MB
  • 合计约240MB,比之前跑在虚拟机里省了70%内存

启动顺序实测日志(docker compose up):

postgres  | database system is ready to accept connections  (t+28s)
redis     | Ready to accept connections                      (t+3s)
api       | Server listening on 3000                         (t+35s)
nginx     | start worker processes                          (t+36s)

API在postgres healthy后2秒启动,nginx等API healthy后才起,整个链路45秒内完成。

七、总结

Docker Compose编排的核心不是把docker run翻译成yml,而是用声明式的方式表达服务之间的依赖和约束。几个关键点:

  1. healthcheck + depends_on.condition是控制启动顺序的正确姿势,别用sleep。
  2. start_period对慢启动服务(数据库)必须配,否则会被误判。
  3. 命名卷比bind mount更适合持久化数据,跨平台路径问题少。
  4. 资源限制和日志滚动是生产环境必备,出事的时候能救命。

这套配置跑了两个月,没再出现数据丢失或启动顺序导致的服务崩溃。下一步打算把敏感配置挪到Docker secrets,再把镜像推到私有registry做版本管理。