Docker Compose 多容器部署:一份够用的配置模板
前后端分离的项目,部署这件事说简单也简单——一个前端、一个后端、再加个数据库和 Nginx 就齐活了。但真到上线时,各种小坑就冒出来了:容器启动顺序不对导致后端连不上数据库、容器时区是 UTC 让日志看着别扭、前端打包产物怎么进到 Nginx 里、重启策略没设导致宕机不自愈……
这篇文章整理一套我自己在好几个小项目上反复用的 Docker Compose 模板,目标只有一个:够用、能复用、不折腾。整套配置覆盖后端、前端、Nginx 三个服务,加上依赖编排、健康检查、数据持久化。
一、整体拓扑
先说清楚架构。一个典型的中小项目长这样:
┌─────────────┐
用户 ──▶ 80/443 │ Nginx │ 静态资源 + 反代
└──────┬──────┘
│ /api/ ──────────▶ backend:8000
│ / ──────────▶ frontend 静态文件
│
┌─────────────▼─────────────┐
│ backend (FastAPI 等) │
└─────────────┬─────────────┘
│ 数据卷挂载
┌─────────────▼─────────────┐
│ SQLite / Postgres 数据 │
└───────────────────────────┘
对外只暴露 Nginx 的 80/443,后端服务在内网,数据库走数据卷。这是最小够用的形态。
二、完整的 docker-compose.yml
直接上配置,下面逐段拆解。这套我目前用在 Postgres 上,如果你用 SQLite,把 db 服务去掉、把数据卷挂到 backend 即可。
# docker-compose.yml
version: "3.9"
services:
db:
image: postgres:16-alpine
container_name: app-db
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
TZ: Asia/Shanghai
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- appnet
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: app-backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/logs:/app/logs
networks:
- appnet
frontend:
image: nginx:1.27-alpine
container_name: app-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_started
ports:
- "80:80"
- "443:443"
volumes:
- ./frontend/dist:/usr/share/nginx/html:ro
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
networks:
- appnet
volumes:
pgdata:
networks:
appnet:
driver: bridge
三、depends_on + healthcheck 的依赖编排
新手最容易踩的坑:后端容器先于数据库启动,结果一连数据库就报 connection refused,然后退出。
很多人知道 depends_on,但它默认只等容器"启动",不等服务就绪。Postgres 容器启动到真正能接受连接,中间还要初始化数据目录,可能耗时 10~30 秒。
正确做法是配合 healthcheck,让 Compose 等到健康检查通过:
depends_on:
db:
condition: service_healthy # 等 healthcheck 全部通过
注意写法:condition: service_healthy需要 Compose 文件格式3.9以上,且依赖较新的 Docker / Compose v2。早期version: 3在 swarm 模式下会忽略 condition,单机用没问题。
backend 服务的 healthcheck 也可以加,让 frontend 等到后端真的能响应了再启动:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 6
start_period: 40s
这里有个细节:start_period 很关键。它表示"这段时间内的失败不计入 retries",给慢启动的应用一个缓冲窗口。FastAPI + ORM 初始化迁移通常要 5~10 秒,留 30~40 秒比较稳妥。
四、数据卷持久化
数据库的数据必须放在卷里,否则容器一删,数据全没——这是事故级的事。我习惯用命名卷而不是绑定挂载宿主目录:
volumes:
pgdata: # 命名卷,由 Docker 管理,备份方便
命名卷的好处是 Docker 全权管理权限和路径,备份只需 docker run --rm -v pgdata:/data -v $(pwd):/backup alpine tar czf /backup/pgdata.tgz /data。如果用 SQLite,挂载一个文件就行:
volumes:
- ./data:/app/data # SQLite 文件落在宿主 ./data/app.db
而日志、上传文件这类"需要人工查看或共享给其他工具"的内容,用绑定挂载(bind mount)更方便,直接 ls 就能看到:
- ./backend/logs:/app/logs # 日志落到宿主机,方便 ELK / loki 采集
- ./frontend/dist:/usr/share/nginx/html:ro # 前端产物只读挂载
末尾的 :ro 让容器内只读,避免 Nginx 误写静态资源,是个好习惯。
五、前端镜像:多阶段构建 vs CI 预构建
前端静态文件怎么进容器,有两种主流思路:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 多阶段构建 | Dockerfile 里 node 阶段 npm run build,再 COPY 到 nginx 镜像 |
一条命令产出镜像,自包含、可复现 | 每次 build 镜像都重装依赖、慢;CI 缓存难利用 |
| CI 预构建 | 在 CI(GitHub Actions 等)里 npm run build,把 dist/ 直接挂载进 nginx 容器 |
构建快、CI 缓存充分;镜像用现成 nginx | 产物和运行环境分开管理,部署脚本要协调 |
我现在的选择是CI 预构建,因为前端构建在 CI 上能充分吃缓存,整体流水线更快。本模板里直接用现成的 nginx:1.27-alpine 镜像 + 绑定挂载 ./frontend/dist,部署时只需要 scp 过去 dist/ 然后 docker compose up -d。
如果你坚持要多阶段构建,前端 Dockerfile 长这样:
# frontend/Dockerfile(多阶段)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
六、常见坑清单
1. 容器时区是 UTC
这是最高频的坑。默认容器内时区是 UTC,导致日志时间、定时任务、数据库时间戳全都差 8 小时。两个地方都要设:
environment:
- TZ=Asia/Shanghai
# 数据库同理:Postgres 镜像认 TZ,MySQL 用 --default-time-zone='+08:00'
更彻底的做法是在镜像里装 tzdata 并软链 localtime,但对大多数场景 TZ 环境变量就够了。
2. 日志收集
不要在容器里写日志文件然后就不管了,容器停了日志也跟着没。两条路:要么打到 stdout 让 docker logs 能看到,要么绑定挂载到宿主机目录。我两者都用:业务日志写文件 + 关键事件打 stdout。
3. 重启策略:用 unless-stopped
三种选项里,unless-stopped 是最稳妥的:
no(默认):挂了就挂了,最不可取。always:永远重启,连你手动docker stop后下次重启 Docker 都会拉起来,烦人。unless-stopped:除非你主动 stop,否则一直自愈,推荐。on-failure:只在异常退出时重启,但首次部署如果配置错了会无限重启循环,不好排查。
4. .env 文件别提交
模板里所有密码、密钥都用 ${DB_PASSWORD} 这种占位,靠 .env 文件注入。.env 一定要进 .gitignore,并维护一份 .env.example 给同事参考。
docker compose --env-file .env up -d 启动docker compose logs -f backend 看日志docker compose restart backend 重启单个服务docker compose down 停止并删容器(数据卷保留)
✓ 小结
这套 Compose 模板的核心思路是:健康检查 + 条件依赖解决启动顺序、命名卷保证数据安全、CI 预构建 + nginx 绑定挂载让前端部署轻量、unless-stopped 实现自愈。它不是最炫的方案(没有 K8s、没有 service mesh),但对一个中小项目,覆盖 95% 的运维需求绰绰有余。先跑起来,再优化,比一上来就堆架构实在得多。