外观
项目 9:Docker Compose 综合项目——前后端分离应用部署
约 3961 字大约 13 分钟
DockerDocker ComposeNginxFlask
2026-06-07
项目目标
你在前八节课中积累了构建镜像(第 4 课)、配置网络(第 5 课)、管理数据卷(第 6 课)、运维排错(第 7 课)、Compose 编排(第 8 课)等技能。本课是容器技术与应用课程的综合大项目——将这些技能组合起来,完成一个真实的企业级"前后端分离应用容器化交付"。
你需要从零开始:编写后端 API(Flask)、前端页面、Nginx 反向代理配置、MySQL 数据库初始化脚本、Dockerfile 和 compose.yml,最终实现只暴露一个 Nginx 入口,内部服务通过容器名称安全通信的架构。
一、架构设计
在写第一行代码之前,先理解我们要交付的架构:
浏览器
│
│ http://127.0.0.1/
▼
┌──────────────────────────────────────────┐
│ Nginx (反向代理 + 静态文件服务) │
│ - / → 前端静态页面 │
│ - /api/* → 后端 Flask API │
│ - 端口 80 对外暴露 │
└──────┬──────────────┬────────────────────┘
│ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ Frontend │ │ Backend (Flask) │
│ (Nginx) │ │ - /api/health │
│ 静态页面 │ │ - /api/messages │
│ :80 │ │ :5000 │
└────────────┘ └────────┬─────────┘
│
▼
┌──────────────────┐
│ MySQL 8.0 │
│ - app_db 数据库 │
│ - messages 表 │
│ :3306 │
└──────────────────┘前后端分离架构数据流演示
追踪一个用户请求是如何经过 Nginx 路由,最终到达数据库并返回的
👨💻
Browser
宿主机端口: 80
Docker Internal Network
🚦
Nginx (fsp-nginx)
监听: 80
📄
Frontend (fsp-frontend)
静态文件服务
⚙️
Backend (Flask)
监听: 5000
🗄️
MySQL (fsp-db)
监听: 3306
请求链路日志
等待操作...
关键设计原则:
- Nginx 是唯一对外暴露的服务(
ports: "80:80")。前端和后端都在内部网络中 - 后端和数据库只加入内部网络,不映射端口到宿主机
- 所有容器通过服务名通信(
frontend、backend、db),不写死 IP
二、项目目录规划
compose-final-project/
├── frontend/
│ ├── index.html # 前端页面
│ └── Dockerfile # 前端镜像构建文件
├── backend/
│ ├── app.py # Flask API 应用
│ ├── requirements.txt # Python 依赖
│ ├── wait-for-it.sh # 数据库就绪检查脚本(可选)
│ └── Dockerfile # 后端镜像构建文件
├── nginx/
│ └── default.conf # Nginx 反向代理配置
├── mysql/
│ └── init.sql # 数据库初始化脚本
├── compose.yml # 多服务编排文件
├── .env.example # 环境变量模板
├── .gitignore
└── README.md # 项目部署文档三、准备后端 API(Flask)
3.1 创建目录与依赖文件
mkdir -p ~/compose-final-project/backend
cd ~/compose-final-project/backend创建 requirements.txt:
flask==3.0.0
pymysql==1.1.0
cryptography==42.0.8cryptography 用于支持 MySQL 8 默认的 caching_sha2_password 认证方式;缺少它时,PyMySQL 连接数据库会报认证相关错误。
3.2 编写 Flask 应用
创建 app.py:
import os
from flask import Flask, jsonify, request
import pymysql
import time
app = Flask(__name__)
# 数据库连接配置——全部从环境变量读取,不写死在代码里
DB_CONFIG = {
"host": os.environ.get("DB_HOST", "db"),
"port": int(os.environ.get("DB_PORT", 3306)),
"user": os.environ.get("DB_USER", "app_user"),
"password": os.environ.get("DB_PASSWORD", "app_pass"),
"database": os.environ.get("DB_NAME", "app_db"),
"charset": "utf8mb4",
}
def get_db():
"""获取数据库连接,带重试逻辑"""
retries = 10
while retries > 0:
try:
conn = pymysql.connect(**DB_CONFIG)
return conn
except pymysql.Error as e:
print(f"等待数据库就绪...(剩余重试 {retries} 次): {e}")
retries -= 1
time.sleep(3)
raise RuntimeError("无法连接到数据库")
@app.route("/api/health")
def health():
"""健康检查接口"""
return jsonify({"status": "ok", "service": "backend"})
@app.route("/api/messages")
def get_messages():
"""获取所有留言"""
try:
conn = get_db()
with conn.cursor() as cursor:
cursor.execute("SELECT id, author, content, created_at FROM messages ORDER BY id DESC")
rows = cursor.fetchall()
conn.close()
messages = [
{"id": r[0], "author": r[1], "content": r[2], "created_at": str(r[3])}
for r in rows
]
return jsonify({"messages": messages})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/messages", methods=["POST"])
def create_message():
"""新增留言"""
data = request.get_json(silent=True) or {}
author = data.get("author", "匿名").strip()
content = data.get("content", "").strip()
if not content:
return jsonify({"error": "内容不能为空"}), 400
try:
conn = get_db()
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO messages (author, content) VALUES (%s, %s)",
(author[:50], content[:500]),
)
conn.commit()
conn.close()
return jsonify({"status": "created"}), 201
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)3.3 编写后端 Dockerfile
问题:构建 Docker 镜像用的构建脚本文件全名叫什么?(全大写 + 全文无后缀,10个字母)
FROM python:3.12-alpine
WORKDIR /app
# 先复制依赖文件(利用 Docker 构建缓存)
COPY requirements.txt .
RUN apk add --no-cache curl \
&& pip install --no-cache-dir -r requirements.txt
# 再复制应用代码
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]构建缓存技巧回顾
COPY requirements.txt . 和 RUN pip install 在 COPY app.py 之前——这样当你修改 app.py 但不改依赖时,pip install 层能被缓存复用。第 4 课详细讲过这个原理。
四、准备前端页面
4.1 创建目录和页面
mkdir -p ~/compose-final-project/frontend
cd ~/compose-final-project/frontend创建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>留言板 - 容器化前后端分离应用</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; }
.container { max-width: 700px; margin: 40px auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 30px; border-radius: 12px; margin-bottom: 24px; text-align: center; }
.header h1 { font-size: 24px; margin-bottom: 8px; }
.header p { opacity: 0.85; font-size: 14px; }
.api-status { background: white; padding: 16px 20px; border-radius: 8px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.status-dot { width: 12px; height: 12px; border-radius: 50%; }
.status-dot.ok { background: #52c41a; }
.status-dot.error { background: #ff4d4f; }
.form-box { background: white; padding: 24px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.form-box input, .form-box textarea { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; margin-bottom: 12px; }
.form-box textarea { resize: vertical; min-height: 80px; }
.form-box button { background: #667eea; color: white; border: none; padding: 10px 24px; border-radius: 6px; font-size: 14px; cursor: pointer; }
.form-box button:hover { background: #5a6fd6; }
.messages { display: flex; flex-direction: column; gap: 12px; }
.message-card { background: white; padding: 16px 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.message-card .author { font-weight: 600; color: #667eea; font-size: 14px; margin-bottom: 6px; }
.message-card .content { font-size: 15px; line-height: 1.6; }
.message-card .time { font-size: 12px; color: #999; margin-top: 8px; }
.empty { text-align: center; color: #999; padding: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>留言板</h1>
<p>Docker Compose · Flask · MySQL · Nginx</p>
</div>
<div class="api-status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">检查后端连接...</span>
</div>
<div class="form-box">
<input type="text" id="author" placeholder="你的昵称(选填)" maxlength="50">
<textarea id="content" placeholder="说点什么吧..." maxlength="500"></textarea>
<button onclick="submitMessage()">发布留言</button>
</div>
<div class="messages" id="messages"></div>
</div>
<script>
const API_BASE = '/api';
async function checkHealth() {
try {
const res = await fetch(`${API_BASE}/health`);
const data = await res.json();
document.getElementById('statusDot').className = 'status-dot ok';
document.getElementById('statusText').textContent = `后端服务正常 (${data.service})`;
} catch {
document.getElementById('statusDot').className = 'status-dot error';
document.getElementById('statusText').textContent = '后端连接失败';
}
}
async function loadMessages() {
try {
const res = await fetch(`${API_BASE}/messages`);
const data = await res.json();
const container = document.getElementById('messages');
if (!data.messages || data.messages.length === 0) {
container.innerHTML = '<div class="empty">暂无留言,来做第一个留言的人吧 🎉</div>';
return;
}
container.innerHTML = data.messages.map(m => `
<div class="message-card">
<div class="author">${escapeHtml(m.author)}</div>
<div class="content">${escapeHtml(m.content)}</div>
<div class="time">${m.created_at}</div>
</div>
`).join('');
} catch {
document.getElementById('messages').innerHTML = '<div class="empty">加载留言失败</div>';
}
}
async function submitMessage() {
const author = document.getElementById('author').value.trim() || '匿名';
const content = document.getElementById('content').value.trim();
if (!content) return alert('内容不能为空');
try {
const res = await fetch(`${API_BASE}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, content }),
});
if (res.ok) {
document.getElementById('content').value = '';
loadMessages();
} else {
const data = await res.json();
alert('发布失败: ' + (data.error || '未知错误'));
}
} catch {
alert('网络错误');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
checkHealth();
loadMessages();
</script>
</body>
</html>4.2 编写前端 Dockerfile
FROM nginx:alpine
# 把页面复制到 Nginx 默认的静态文件目录
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]五、准备 Nginx 反向代理
5.1 创建 Nginx 配置
mkdir -p ~/compose-final-project/nginx创建 nginx/default.conf:
server {
listen 80;
listen [::]:80;
server_name localhost;
# 前端静态页面
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# API 转发到后端
location /api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}反向代理的核心逻辑
location /→ 请求转发给frontend容器(Nginx 托管静态页面)location /api/→ 请求转发给backend容器(Flask API)proxy_pass http://frontend:80中的frontend是 Compose 服务名——Docker DNS 自动解析
六、准备数据库初始化
6.1 创建初始化 SQL
mkdir -p ~/compose-final-project/mysql创建 mysql/init.sql:
-- 确保初始化脚本按 UTF-8 写入中文内容
SET NAMES utf8mb4;
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE app_db;
-- 创建留言表
CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
author VARCHAR(50) NOT NULL DEFAULT '匿名',
content VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入两条示例数据
INSERT INTO messages (author, content) VALUES
('系统', '欢迎使用容器化留言板!这是一条自动生成的欢迎留言。'),
('Docker', '全部服务由 Docker Compose 编排:Nginx 反向代理 + Flask 后端 + MySQL 数据库。');MySQL 官方镜像约定:/docker-entrypoint-initdb.d/ 目录下的 .sql、.sh 文件会在数据库首次初始化时自动执行。把 init.sql 挂载到这个目录即可。
七、编写 compose.yml
回到项目根目录:
cd ~/compose-final-project创建 compose.yml:
问题:docker compose 的编排文件默认名是什么?(全小写,不加 .yml 后缀,7个字母)
services:
# === 数据库 ===
db:
image: mysql:8.0
container_name: fsp-db
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root-secret}
MYSQL_DATABASE: ${MYSQL_DATABASE:-app_db}
MYSQL_USER: ${MYSQL_USER:-app_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-app_pass}
volumes:
- db-data:/var/lib/mysql
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- internal
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root-secret}"]
interval: 10s
timeout: 5s
retries: 5
# === 后端 API ===
backend:
build: ./backend
container_name: fsp-backend
restart: always
environment:
DB_HOST: db
DB_PORT: 3306
DB_USER: ${MYSQL_USER:-app_user}
DB_PASSWORD: ${MYSQL_PASSWORD:-app_pass}
DB_NAME: ${MYSQL_DATABASE:-app_db}
depends_on:
db:
condition: service_healthy # 等数据库健康检查通过后才启动
networks:
- internal
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:5000/api/health"]
interval: 15s
timeout: 5s
retries: 3
# === 前端 ===
frontend:
build: ./frontend
container_name: fsp-frontend
restart: always
networks:
- internal
# === Nginx 反向代理(唯一对外入口)===
nginx:
image: nginx:alpine
container_name: fsp-nginx
restart: always
ports:
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- frontend
- backend
networks:
- internal
volumes:
db-data:
networks:
internal:
driver: bridgeDocker Compose:Build vs Image 流程演示
在 compose.yml 中,服务是如何通过构建源码或直接拉取镜像来启动的?
使用
build: ./backend📁 backend/
📄 Dockerfile
↓ docker build
💿
fsp-backend:latest
(本地实时构建)
↓ docker run
📦
backend 容器
Created
使用
image: mysql:8.0☁️ Docker Hub
↓ docker pull
💿
mysql:8.0
(从远端拉取)
↓ docker run
📦
db 容器
Created
🌐 Docker Internal Network (等待容器接入...)
关键设计决策
depends_on 的 condition: service_healthy
这是第 8 课 depends_on 陷阱的解决方案!普通的 depends_on 只等容器启动,但容器启动 ≠ 服务可用。condition: service_healthy 会等到 db 容器的健康检查通过——也就是 MySQL 真正能接受连接时——才启动 backend。
depends_on:
db:
condition: service_healthy # 等数据库"就绪",不只是"启动"健康检查链
db (healthcheck: mysqladmin ping) → backend 等待 db healthy
backend (healthcheck: curl /api/health) → 供运维和编排工具判断
nginx → 无健康检查(它只是个代理)八、环境变量管理
创建 .env.example:
# 数据库配置
MYSQL_ROOT_PASSWORD=change-root-password
MYSQL_DATABASE=app_db
MYSQL_USER=app_user
MYSQL_PASSWORD=change-user-password
# Nginx 对外端口
NGINX_PORT=80创建 .gitignore:
.env
db-data/
__pycache__/
*.pyc九、构建与启动
cd ~/compose-final-project
# 1. 构建镜像并启动所有服务
docker compose up -d --build
# 2. 观察启动过程(数据库初始化需要 10-20 秒)
docker compose logs -f
# 3. 确认所有服务正常
docker compose ps期望输出:
NAME IMAGE STATUS
fsp-db mysql:8.0 Up (healthy)
fsp-backend compose-final... Up (healthy)
fsp-frontend compose-final... Up
fsp-nginx nginx:alpine Up十、验证流程
10.1 后端健康检查
# 通过 Nginx 访问后端健康检查接口
curl http://127.0.0.1/api/health
# 期望输出:{"service":"backend","status":"ok"}10.2 前端页面
浏览器打开 http://127.0.0.1(远程实验机使用 http://宿主机IP)。
10.3 留言功能测试
- 浏览器中看到欢迎留言(来自
init.sql的两条) - 在输入框中填写昵称和内容,点击"发布留言"
- 新留言出现在列表中
- 刷新页面后,留言仍在(证明数据已持久化到 MySQL)
10.4 数据持久化验证
# 停掉所有容器
docker compose down
# 重新启动
docker compose up -d
# 再次访问——留言数据仍在
# 因为 db-data 数据卷未被删除十一、架构验证
# 确认只有 Nginx 对外暴露了端口
docker compose ps
# fsp-nginx 的 PORTS 列应该显示 0.0.0.0:80->80/tcp
# 其他服务的 PORTS 列为空
# 确认后端可以从内部网络访问数据库
docker compose exec backend curl -s http://localhost:5000/api/health
# 应返回 {"service":"backend","status":"ok"}
# 确认数据库端口未暴露到宿主机
ss -tlnp | grep 3306
# 不应有 0.0.0.0:3306 的监听十二、提交物清单
| 文件 | 说明 |
|---|---|
| 完整项目目录(压缩包) | frontend/、backend/、nginx/、mysql/、compose.yml、.env.example、.gitignore |
compose.yml | 完整的四服务编排文件 |
backend/Dockerfile | 后端镜像构建文件 |
frontend/Dockerfile | 前端镜像构建文件 |
nginx/default.conf | Nginx 反向代理配置 |
mysql/init.sql | 数据库初始化脚本 |
README.md | 项目架构说明、部署步骤、访问地址、常见问题 |
docker compose ps 截图 | 显示四个服务均在运行 |
| 浏览器前端截图 | 显示留言板正常工作 |
curl /api/health 截图 | 后端健康检查接口响应 |
troubleshooting.md | 至少三条排查记录,按"前端→Nginx→后端→数据库"的排查顺序描述 |
当堂检测
- 为什么 Nginx 是唯一映射端口的服务?前端和后端容器如何被外部访问?
depends_on的condition: service_healthy与普通的depends_on有什么区别?你能描述它如何解决第 8 课的"数据库未就绪"问题吗?- 前端 JavaScript 中把 API 地址写成了
/api/messages而不是http://backend:5000/api/messages。这两种写法有什么区别?哪种正确,为什么? - 如果用户投诉"页面打开了但留言加载不出来",你应该按什么顺序排查?(提示:前端→Nginx→后端→数据库)
十三、常见问题排查
| 现象 | 排查步骤 |
|---|---|
| 页面 502 Bad Gateway | docker compose logs backend 检查后端是否启动;docker compose logs nginx 检查代理配置 |
| 页面加载但无留言数据 | 浏览器 F12 Network 查看 /api/messages 请求 → 是否返回错误 → docker compose logs backend |
| 发布留言失败 500 | docker compose logs backend 查看错误日志——通常是数据库表不存在(init.sql 未执行) |
| 数据库连接失败 | docker compose exec db mysqladmin ping -h localhost -uroot -p 检查数据库状态 |
| 修改代码后未生效 | docker compose up -d --build 重新构建镜像(Compose 可能使用了旧镜像缓存) |
| 端口 80 被占用 | 修改 .env 中 NGINX_PORT=8080 或直接修改 compose.yml 端口映射 |
标准的四层排查顺序
1. 前端 (F12 Network) → 请求发出去了吗?返回了什么状态码?
2. Nginx (logs) → 请求到达 Nginx 了吗?代理转发正确吗?
3. 后端 (logs) → 后端收到请求了吗?报了什么错?
4. 数据库 (exec + query) → 数据库在运行吗?表存在吗?数据对吗?十四、深度探索任务
以下任务不强制提交,是拔高性质的进阶练习。
探索任务 1:增加后端重试与数据库就绪等待
目标:解决不使用 condition: service_healthy 时的竞态条件(某些 Docker Compose 版本不支持该特性)。
在 backend/app.py 中已经内置了重试逻辑(get_db 函数中 retries = 10,每次等 3 秒)。把它改成环境变量控制:
retries = int(os.environ.get("DB_RETRIES", 10))然后在 compose.yml 中为 backend 增加环境变量:
environment:
DB_RETRIES: "20" # 最多等 20 * 3 = 60 秒去掉 depends_on 中的 condition: service_healthy,改为普通 depends_on: - db。重启后验证:即使没有健康检查条件,内置重试也能等到数据库就绪。
确认问题
应用层重试和 Compose 层的 condition: service_healthy 各有什么优缺点?哪种更"Docker 原生"?哪种更容易迁移到 Kubernetes?
探索任务 2:增加 docker compose 配置覆盖
目标:学会用多个 Compose 文件实现"基础配置 + 环境覆盖"。
创建 compose.override.yml(用于本地开发):
services:
backend:
environment:
FLASK_DEBUG: "1"
volumes:
- ./backend:/app # 挂载代码目录,实现热更新
ports:
- "5000:5000" # 开发时直接暴露后端端口便于调试
db:
ports:
- "3306:3306" # 开发时暴露数据库端口便于用 GUI 工具连接启动命令(Compose 自动合并 compose.yml + compose.override.yml):
docker compose up -d生产环境时不使用 override:
docker compose -f compose.yml up -d确认问题
为什么开发环境暴露数据库端口可以接受,但生产环境不能?compose.override.yml 在团队成员之间应该如何管理(提交到 Git 还是不提交)?
探索任务 3:增强项目工程化
目标:让项目更接近企业交付标准。
- 增加
deploy.sh部署脚本:一键执行docker compose up -d --build、健康检查等待、输出访问地址 - 增加 Compose profiles:用 profiles 实现可选服务(如开发用的 Adminer 数据库管理工具):
services:
adminer:
image: adminer:latest
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
networks:
- internal
profiles:
- debug # 默认不启动,需要时 docker compose --profile debug up -d- 完善 README.md:包含架构图(可以用 Mermaid 画)、部署步骤、API 文档、环境变量说明、故障排查指南
确认问题
为什么把数据库管理工具放到 profiles: [debug] 中?这种模式有什么工程优势?在 Kubernetes 中用什么概念实现类似效果?
