一、问题背景
去年接手一个内部管理系统,技术栈是 Vue3 + Node.js(Express) + PostgreSQL + Redis。开发阶段大家都是本地起服务,数据库用 Docker 跑一个容器,勉强能对付。但到了测试环境部署,问题就来了:
- API 容器启动比数据库快,连不上 PG 直接崩溃退出,得手动 restart。
- Redis 没设密码,测试环境被扫到,虽然没造成损失但被安全同事点名。
- 数据库数据放在容器内,一次
docker compose down把测试数据全清了,重新造数据花了大半天。 - 四个容器各自
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/dataredis_data:Redis AOF 持久化目录/datanginx_logs:Nginx 日志,方便宿主机直接看
启动顺序用 depends_on + condition: service_healthy 控制。这里有个关键点:depends_on 默认只等容器启动,不等服务就绪,所以必须配合 healthcheck 用 service_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.1 和 Connection "" 开启长连接,压测时 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: 10s、retries: 5、start_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 down再up,数据完整保留,不再需要重新造数据。 - 网络隔离后,安全扫描不再报告数据库端口暴露问题。
- 日志轮转配置后,连续运行 30 天磁盘占用稳定在 200MB 以内。
API 的 P95 响应时间在加入 Nginx keepalive 后从 45ms 降到 38ms,提升约 15%。这个数字不大,但属于顺手优化。
七、总结
这套 Compose 配置的核心就三件事:网络隔离、卷持久化、健康检查驱动的启动顺序。看起来简单,但每个点都有细节:
- 网络用两张 bridge,数据层不对外暴露。
- 卷用命名卷而非 bind mount,跨机器迁移更方便。
- 健康检查的
start_period和retries要按服务实际启动时间调,不能照抄。 depends_on的condition在 Compose v2 里才完整支持,别用 v3 格式。
这套配置目前跑在三个环境(测试、预发、生产),除了资源限制参数按环境微调,其他完全一致。如果项目再复杂一点,比如要加消息队列或者多个 API 实例,可以考虑上 Kubernetes,但就目前这个规模,Compose 足够且运维成本最低。
最后提醒一句:.env 文件千万别提交到 Git,密码用 ${VAR:?} 语法强制校验,能避免很多低级事故。