一、问题背景:手动部署的痛
上个月接手一个项目,技术栈是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原生模块上有坑,后面会说。
三、方案设计
整体思路:
- 网络:自定义bridge网络
app-network,不用默认的。默认网络里所有容器都能互相访问,自定义网络可以通过服务名做DNS解析,还能隔离。 - 卷:PostgreSQL数据用命名卷
pg-data,Redis用redis-data开启AOF持久化。代码目录用bind mount方便开发,生产环境直接打进镜像。 - 健康检查:给postgres、redis、api都配healthcheck,这是控制启动顺序的关键。
- 启动顺序:
depends_on配合condition: service_healthy,等依赖服务真正健康了再启动。 - 重启策略:统一
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,而是用声明式的方式表达服务之间的依赖和约束。几个关键点:
healthcheck+depends_on.condition是控制启动顺序的正确姿势,别用sleep。start_period对慢启动服务(数据库)必须配,否则会被误判。- 命名卷比bind mount更适合持久化数据,跨平台路径问题少。
- 资源限制和日志滚动是生产环境必备,出事的时候能救命。
这套配置跑了两个月,没再出现数据丢失或启动顺序导致的服务崩溃。下一步打算把敏感配置挪到Docker secrets,再把镜像推到私有registry做版本管理。