运维

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% 的运维需求绰绰有余。先跑起来,再优化,比一上来就堆架构实在得多。