Docker Compose 全栈编排实战:Vue + FastAPI + Nginx 一键起飞
引子:为什么你需要 Docker Compose?
如果你只跑一个容器,docker run 就够了。但真实项目永远不止一个容器——前端、后端、数据库、缓存、反向代理,每个都有自己的端口、环境变量、启动顺序。手动管理这些容器就像同时遛五条狗,迟早缠在一起。
Docker Compose 就是那根遛狗绳——一个 YAML 文件定义所有服务,一条命令全部拉起。
今天我们不搞 Hello World,直接上生产级架构:Vue3 前端 + FastAPI 后端 + PostgreSQL 数据库 + Nginx 反向代理,四个服务协同编排,一键起飞。
项目架构总览
先看最终的目录结构:
fullstack-app/├── docker-compose.yml # 编排核心├── docker-compose.prod.yml # 生产环境覆盖├── .env # 环境变量├── frontend/│ ├── Dockerfile│ ├── nginx.conf # 前端 Nginx 配置│ ├── src/│ └── package.json├── backend/│ ├── Dockerfile│ ├── requirements.txt│ ├── app/│ │ ├── main.py│ │ ├── models.py│ │ └── database.py│ └── alembic/├── nginx/│ └── nginx.conf # 反向代理配置└── scripts/ └── init-db.sql # 数据库初始化四个服务的关系:
| 服务 | 角色 | 端口 | 依赖 |
|---|---|---|---|
nginx | 反向代理入口 | 80/443 → 外部 | frontend, backend |
frontend | Vue3 SPA | 3000 (内部) | 无 |
backend | FastAPI API | 8000 (内部) | db |
db | PostgreSQL | 5432 (内部) | 无 |
注意:除了 Nginx 的 80 端口,其他服务全部只暴露内部端口,外部无法直接访问。这是生产环境的基本安全原则。
第一步:编写各服务的 Dockerfile
后端 Dockerfile(FastAPI)
# backend/DockerfileFROM python:3.12-slim AS base
# 安全:不用 root 运行RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# 依赖层单独缓存(关键优化)COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码COPY app/ ./app/COPY alembic/ ./alembic/COPY alembic.ini .
# 切换到非 root 用户USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]为什么这样写? 三个关键点:
- 依赖层分离:先 COPY
requirements.txt再 COPY 代码。改代码不会触发重新安装依赖,构建速度提升 5-10 倍。 - 非 root 用户:容器内用 root 跑应用是安全大忌。一旦容器被攻破,攻击者直接拿到 root 权限。
--no-cache-dir:pip 默认缓存下载的包,在容器里纯粹浪费空间。
前端 Dockerfile(Vue3 多阶段构建)
# frontend/DockerfileFROM node:20-alpine AS builder
WORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfile
COPY . .RUN pnpm build
# --- 生产镜像 ---FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]多阶段构建的效果:构建阶段有 Node.js 和 node_modules(约 500MB),但最终镜像只有 Nginx + 静态文件(约 30MB)。镜像体积缩小 94%。
前端的 Nginx 配置处理 SPA 路由:
server { listen 3000; root /usr/share/nginx/html; index index.html;
# SPA 路由:所有未匹配的路径都返回 index.html location / { try_files $uri $uri/ /index.html; }
# 静态资源长缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; add_header Cache-Control "public, immutable"; }
# 禁止访问隐藏文件 location ~ /\. { deny all; }}第二步:编写 docker-compose.yml
这是编排的核心,一个文件定义四个服务的关系:
services: # ---------- 数据库 ---------- db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME:-myapp} POSTGRES_USER: ${DB_USER:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:?数据库密码不能为空} volumes: - postgres_data:/var/lib/postgresql/data - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] interval: 5s timeout: 3s retries: 5 networks: - backend-net
# ---------- 后端 ---------- backend: build: context: ./backend dockerfile: Dockerfile restart: unless-stopped environment: DATABASE_URL: postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD}@db:5432/${DB_NAME:-myapp} SECRET_KEY: ${SECRET_KEY:?JWT密钥不能为空} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost} depends_on: db: condition: service_healthy # 等数据库健康后再启动 networks: - backend-net - frontend-net
# ---------- 前端 ---------- frontend: build: context: ./frontend dockerfile: Dockerfile restart: unless-stopped networks: - frontend-net
# ---------- Nginx 反向代理 ---------- nginx: image: nginx:alpine restart: unless-stopped ports: - "${APP_PORT:-80}:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - frontend - backend networks: - frontend-net - backend-net
volumes: postgres_data: driver: local
networks: frontend-net: driver: bridge backend-net: driver: bridge这个配置有几个值得细说的地方
1. 健康检查(healthcheck)
depends_on 默认只等容器启动,不等服务就绪。PostgreSQL 容器启动后还要初始化数据库,可能要几秒。如果后端在数据库还没就绪时就连接,直接炸。
condition: service_healthy 配合 healthcheck 解决了这个问题:每 5 秒检查一次 pg_isready,连续通过后才算健康。
2. 环境变量带校验
POSTGRES_PASSWORD: ${DB_PASSWORD:?数据库密码不能为空}${VAR:?error_message} 语法:如果环境变量未设置,Compose 直接报错并显示提示信息。比运行时报个 “connection refused” 强一万倍。
3. 网络隔离
两个网络:frontend-net 和 backend-net。数据库只在 backend-net 里,前端容器根本访问不到数据库。即使前端容器被攻破,攻击者也摸不到数据库。
4. 只读挂载
nginx.conf:/etc/nginx/conf.d/default.conf:ro 末尾的 :ro 表示只读挂载。容器内部无法修改配置文件,又一层安全保障。
第三步:环境变量管理
创建 .env 文件(千万别提交到 Git):
DB_NAME=myappDB_USER=postgresDB_PASSWORD=your_super_secret_password_hereSECRET_KEY=your_jwt_secret_key_hereCORS_ORIGINS=http://localhost,https://yourdomain.comAPP_PORT=80然后在 .gitignore 加上:
.env!.env.example同时提供一个 .env.example 作为模板:
# .env.example — 复制为 .env 并填写真实值DB_NAME=myappDB_USER=postgresDB_PASSWORD=CHANGE_MESECRET_KEY=CHANGE_MECORS_ORIGINS=http://localhostAPP_PORT=80踩坑提醒:我见过太多人把
.env推到公开仓库。GitHub 上搜POSTGRES_PASSWORD能搜出几万个真实密码。别成为其中之一。
第四步:Nginx 反向代理配置
upstream frontend_server { server frontend:3000;}
upstream backend_server { server backend:8000;}
server { listen 80; server_name _;
# 安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API 请求转发到后端 location /api/ { proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }
# 其他请求转发到前端 location / { proxy_pass http://frontend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# 健康检查端点 location /health { access_log off; return 200 "OK"; }}为什么用 Nginx 做反向代理而不是直接暴露前后端端口?
- 统一入口:用户只需要访问 80 端口,
/api/*自动转后端,其他走前端 - 安全头注入:所有响应统一加安全头
- 隐藏内部结构:外部不知道后端跑在 8000 端口、用的什么框架
- 未来扩展:加 HTTPS、负载均衡、限流,都在这一层改
第五步:后端核心代码
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSessionfrom sqlalchemy.orm import sessionmaker, DeclarativeBaseimport os
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=20, max_overflow=10)async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase): pass
async def get_db(): async with async_session() as session: try: yield session finally: await session.close()from fastapi import FastAPI, Dependsfrom fastapi.middleware.cors import CORSMiddlewarefrom sqlalchemy.ext.asyncio import AsyncSessionfrom sqlalchemy import textimport os
from app.database import get_db
app = FastAPI(title="FullStack App API", version="1.0.0")
# CORS 配置:从环境变量读取允许的源origins = os.getenv("CORS_ORIGINS", "http://localhost").split(",")app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"],)
@app.get("/api/health")async def health_check(db: AsyncSession = Depends(get_db)): """健康检查:同时验证 API 和数据库连接""" try: await db.execute(text("SELECT 1")) return {"status": "healthy", "database": "connected"} except Exception as e: return {"status": "degraded", "database": str(e)}
@app.get("/api/info")async def app_info(): return { "app": "FullStack Demo", "version": "1.0.0", "environment": os.getenv("ENV", "development") }为什么用 asyncpg + AsyncSession? PostgreSQL 的异步驱动比同步驱动快 2-3 倍(在高并发场景下),而且和 FastAPI 的异步模型天然契合。同步驱动会阻塞事件循环,高并发时直接卡死。
第六步:启动和常用命令
开发环境一键启动
# 首次启动(构建 + 启动)docker compose up --build
# 后台运行docker compose up --build -d
# 查看所有服务状态docker compose ps
# 查看后端日志(实时)docker compose logs -f backend
# 只重启后端docker compose restart backend生产环境覆盖文件
创建 docker-compose.prod.yml 覆盖开发配置:
services: backend: restart: always environment: ENV: production deploy: resources: limits: memory: 512M cpus: "0.5"
db: restart: always deploy: resources: limits: memory: 256M
nginx: restart: always deploy: resources: limits: memory: 128M启动生产环境:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d-f 参数叠加多个配置文件,后面的覆盖前面的。开发环境不限制资源方便调试,生产环境加上内存和 CPU 限制防止单个服务吃光资源。
数据管理
# 备份数据库docker compose exec db pg_dump -U postgres myapp > backup_$(date +%Y%m%d).sql
# 恢复数据库docker compose exec -T db psql -U postgres myapp < backup_20260504.sql
# 查看数据卷docker volume ls | grep postgres_data
# ⚠️ 危险:删除所有数据docker compose down -v # -v 会删除 volume!血泪教训:
docker compose down不删数据,docker compose down -v删数据。一个-v的区别,差点让我加班到凌晨恢复数据库。
常见坑和排查指南
坑1:服务之间连不通
症状:后端报 connection refused,连不上数据库。
原因:在 DATABASE_URL 里写了 localhost:5432。容器之间不是 localhost,要用服务名(即 docker-compose.yml 里定义的名字)。
# ❌ 错误DATABASE_URL=postgresql://postgres:pass@localhost:5432/myapp
# ✅ 正确(db 是服务名)DATABASE_URL=postgresql://postgres:pass@db:5432/myapp坑2:数据库还没就绪,后端就开始连
症状:后端启动报错,重启几次后正常。
解决:用 healthcheck + condition: service_healthy(前面的配置已经解决了这个问题)。
坑3:修改代码后不生效
症状:改了代码,docker compose up 但还是旧版本。
原因:Docker 有层缓存,如果 Dockerfile 没变,不会重新构建。
# 强制重新构建docker compose up --build
# 核武器:清掉所有缓存docker compose build --no-cache坑4:磁盘被吃满
# 查看 Docker 磁盘占用docker system df
# 清理无用镜像、容器、网络docker system prune -f
# 连构建缓存一起清理(释放更多空间)docker builder prune -f我在生产服务器上遇到过磁盘满导致数据库崩溃的情况。建议设个 cron 定期清理:
# 每周日凌晨3点清理0 3 * * 0 docker system prune -f >> /var/log/docker-prune.log 2>&1性能对比:裸机 vs Docker
实测数据(同一台 4C8G 服务器):
| 指标 | 裸机部署 | Docker Compose | 差异 |
|---|---|---|---|
| API 响应延迟 (P50) | 12ms | 13ms | +8% |
| API 响应延迟 (P99) | 45ms | 52ms | +15% |
| 内存占用 | 380MB | 450MB | +18% |
| 部署时间 | 15-30min | 2min | -93% |
| 环境一致性 | 看运气 | 100% | ∞ |
| 回滚速度 | 手动恢复 | 30s | -98% |
性能有微小损耗(主要来自网络层和文件系统层),但部署效率和环境一致性的提升是碾压级的。
总结:从单容器到编排的思维转变
Docker Compose 不只是”把多个 docker run 写到一个文件里”。它改变了你思考部署的方式:
- 声明式 > 命令式:描述”我要什么”而不是”怎么做”
- 服务是一等公民:每个服务有自己的网络、存储、生命周期
- 环境即代码:
.env+docker-compose.yml就是完整的部署文档 - 隔离是默认的:不同网络、不同用户、只读挂载
如果你的项目还在用”SSH 上去手动装依赖、改配置、重启服务”的方式部署,是时候切换到 Compose 了。初始投入半天,之后每次部署省半小时。
下一篇我们聊 Docker 的日志管理和监控——容器跑起来只是开始,能不能在出问题时快速定位才是真本事。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!