共计 3782 个字符,预计需要花费 10 分钟才能阅读完成。
文章目录 [显示]
Docker 30 天实战系列 – 第 18 天
你有没有经历过这样的场景:前端项目跑在 3000 端口,后端 API 跑在 8080 端口,MySQL 要单独装一遍,Redis 又要配一遍,Nginx 还得改配置文件。好不容易在自己电脑上跑通了,换台机器又从头来一遍。每次部署就像在玩拼图,少一块都拼不上。
今天我们要彻底终结这种痛苦。用 Docker Compose 把 React 前端、Express 后端、MySQL 数据库、Redis 缓存和 Nginx 反向代理,一把梭全部编排起来。一条命令启动,一条命令停止,换谁来都一样。
本文你将学到
- 如何设计一个多容器全栈应用的架构
- React + Express + MySQL + Redis 的容器化方案
- Nginx 反向代理在容器环境下的配置技巧
- 环境变量的分层管理策略
- 容器间的网络通信和依赖管理
| 阅读时间 | 实操时间 | 难度等级 |
|---|---|---|
| 15 分钟 | 45 分钟 | 中级 |
架构全景:五个容器的交响乐
在动手之前,我们先看看整体架构长什么样。这就像开一家餐厅,每个角色各司其职:
用户浏览器
|
v
+----------------+
| Nginx | <-- 门迎(反向代理,端口 80)| (端口 80) |
+-------+--------+
|
+----------+----------+
| |
v v
+----------------+ +------------------+
| React 前端 | | Express 后端 |
| (静态文件) | | (端口 3001) |
+----------------+ +--------+---------+
|
+--------+--------+
| |
v v
+----------------+ +----------------+
| MySQL | | Redis |
| (端口 3306) | | (端口 6379) |
+----------------+ +----------------+
- Nginx 是门迎,所有请求从它这里进来,静态文件直接返回,API 请求转给后端
- Express 是大厨,负责处理业务逻辑
- MySQL 是仓库,存放持久化数据
- Redis 是备忘条,缓存热点数据加速响应
- React 打包后的静态文件由 Nginx 直接伺服
这五个 " 员工 " 各干各的,通过 Docker 的内部网络互相沟通,对外只暴露 Nginx 的 80 端口。
项目结构
先把项目骨架搭好:
mkdir -p fullstack-app/{frontend,backend,nginx}
cd fullstack-app
最终的目录结构如下:
fullstack-app/
├── docker-compose.yml
├── .env
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── src/
│ │ └── App.jsx
│ └── public/
│ └── index.html
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ └── src/
│ └── index.js
└── nginx/
└── default.conf
第一步:搭建后端服务
后端是整个应用的心脏,我们先把它搞定。
backend/package.json
{
"name": "fullstack-backend",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.0",
"redis": "^4.6.7",
"cors": "^2.8.5"
}
}
backend/src/index.js
const express = require('express');
const mysql = require('mysql2/promise');
const {createClient} = require('redis');
const app = express();
app.use(express.json());
// 数据库连接配置 —— 全部从环境变量读取
const dbConfig = {
host: process.env.DB_HOST || 'mysql',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'app',
password: process.env.DB_PASSWORD || 'app_secret',
database: process.env.DB_NAME || 'fullstack_db',
};
// Redis 连接
const redisClient = createClient({url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
});
let db;
async function initDB() {
// 等待 MySQL 就绪,最多重试 10 次
for (let i = 0; i < 10; i++) {
try {db = await mysql.createConnection(dbConfig);
console.log('MySQL 连接成功');
// 初始化表
await db.execute(`
CREATE TABLE IF NOT EXISTS visitors (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45),
visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
return;
} catch (err) {console.log(` 等待 MySQL 就绪... (${i + 1}/10)`);
await new Promise(r => setTimeout(r, 3000));
}
}
throw new Error('MySQL 连接失败');
}
async function initRedis() {await redisClient.connect();
console.log('Redis 连接成功');
}
// 健康检查接口
app.get('/api/health', (req, res) => {res.json({ status: 'ok', timestamp: new Date().toISOString()});
});
// 访问统计接口
app.get('/api/stats', async (req, res) => {
try {
// 先查 Redis 缓存
const cached = await redisClient.get('visitor_count');
if (cached) {return res.json({ count: parseInt(cached), source: 'cache' });
}
// 缓存未命中,查数据库
const [rows] = await db.execute('SELECT COUNT(*) as count FROM visitors');
const count = rows[0].count;
// 写入缓存,30 秒过期
await redisClient.setEx('visitor_count', 30, count.toString());
res.json({count, source: 'database'});
} catch (err) {res.status(500).json({error: err.message});
}
});
// 记录访问
app.post('/api/visit', async (req, res) => {
try {
const ip = req.ip || 'unknown';
await db.execute('INSERT INTO visitors (ip) VALUES (?)', [ip]);
// 清除缓存,下次查询会从数据库拿最新数据
await redisClient.del('visitor_count');
res.json({message: '访问已记录'});
} catch (err) {res.status(500).json({error: err.message});
}
});
const PORT = process.env.PORT || 3001;
Promise.all([initDB(), initRedis()]).then(() => {app.listen(PORT, '0.0.0.0', () => {console.log(` 后端服务运行在端口 ${PORT}`);
});
});
backend/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY src ./src
EXPOSE 3001
CMD ["npm", "start"]
这个 Dockerfile 很朴素:基于 Alpine 镜像保持体积小,先装依赖再复制代码利用构建缓存。
第二步:搭建前端应用
前端我们用一个轻量的 React 应用来演示。
frontend/public/index.html
全栈 Docker 应用
frontend/src/App.jsx
import React, {useState, useEffect} from 'react';
function App() {const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const fetchStats = async () => {
try {const res = await fetch('/api/stats');
const data = await res.json();
setStats(data);
} catch (err) {console.error('获取统计失败:', err);
} finally {setLoading(false);
}
};
const recordVisit = async () => {await fetch('/api/visit', { method: 'POST'});
fetchStats();};
useEffect(() => {recordVisit();
}, []);
if (loading) return 加载中...
;
return (
Docker 全栈应用
当前访问量:{stats?.count || 0}
数据来源:{stats?.source === 'cache' ? 'Redis 缓存' : 'MySQL 数据库'}
);
}
export default App;
frontend/package.json
{
"name": "fullstack-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
}
}
frontend/Dockerfile
前端采用多阶段构建,这是一个很经典的模式 —— 第一阶段编译,第二阶段只保留产物:
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY public ./public
COPY src ./src
RUN npm run build
# 生产阶段 —— 只保留静态文件
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
这样最终镜像只有 Nginx + 静态文件,体积从几百 MB 缩小到几十 MB。就像搬家时只搬家具,不搬装修工具。
第三步:配置 Nginx 反向代理
Nginx 是整个应用的入口,它要做两件事:伺服前端静态文件,把 API 请求转发给后端。
nginx/default.conf
upstream backend {server backend:3001;}
server {
listen 80;
server_name localhost;
# 前端静态文件
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /api/ {
proxy_pass http://backend;
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;
}
}
这里有个细节值得注意:try_files $uri $uri/ /index.html 是为了支持前端路由。React 是单页应用,刷新页面时 Nginx 需要把所有路径都指向 index.html,否则会报 404。
第四步:环境变量管理
环境变量是容器化应用的 " 配置中心 "。我们用 .env 文件集中管理:
.env
# 数据库配置
DB_HOST=mysql
DB_PORT=3306
DB_USER=app
DB_PASSWORD=app_secret_2024
DB_NAME=fullstack_db
MYSQL_ROOT_PASSWORD=root_secret_2024
# Redis 配置
REDIS_HOST=redis
REDIS_PORT=6379
# 应用配置
NODE_ENV=production
PORT=3001
分层管理的思路是这样的:
+----------------------------------+
| docker-compose.yml | <-- 定义哪些变量需要传递
| environment / env_file |
+-----------+----------------------+
|
v
+----------------------------------+
| .env 文件 | <-- 存放默认值(开发环境)| DB_HOST=mysql |
| DB_PASSWORD=app_secret |
+----------------------------------+
|
v
+----------------------------------+
| 部署时覆盖 | <-- 生产环境用真实密码
| DB_PASSWORD= 超强密码_!@#$ |
+----------------------------------+
注意:.env 文件里有密码,记得加到 .gitignore 里。生产环境的密码应该通过 CI/CD 管道注入,永远不要提交到代码仓库。
第五步:编排大合唱 - docker-compose.yml
万事俱备,现在把所有服务编排到一起:
docker-compose.yml
version: "3.8"
services:
# Nginx 反向代理
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- frontend_build:/usr/share/nginx/html
depends_on:
- frontend
- backend
networks:
- app-network
restart: unless-stopped
# React 前端(构建后将产物复制到共享卷)frontend:
build: ./frontend
volumes:
- frontend_build:/usr/share/nginx/html
networks:
- app-network
# Express 后端
backend:
build: ./backend
env_file:
- .env
depends_on:
- mysql
- redis
networks:
- app-network
restart: unless-stopped
# MySQL 数据库
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-network
restart: unless-stopped
# Redis 缓存
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge
volumes:
mysql_data:
redis_data:
frontend_build:
几个关键设计决策说明一下:
- 只暴露 80 端口 :MySQL 和 Redis 不对外暴露,只在内部网络通信,更安全
- 数据卷持久化 :
mysql_data和redis_data保证容器重启不丢数据 - depends_on:声明启动顺序依赖,但注意它只保证启动顺序,不保证服务就绪。所以后端代码里我们有重试机制
- restart: unless-stopped:容器异常退出会自动重启,但手动停止后不会重启
启动和验证
一键启动
docker compose up -d --build
预期输出:
[+] Building 45.2s (23/23) FINISHED
[+] Running 5/5
✔ Container fullstack-app-mysql-1 Started
✔ Container fullstack-app-redis-1 Started
✔ Container fullstack-app-backend-1 Started
✔ Container fullstack-app-frontend-1 Started
✔ Container fullstack-app-nginx-1 Started
检查服务状态
docker compose ps
预期输出:
NAME STATUS PORTS
fullstack-app-backend-1 Up 30 seconds 3001/tcp
fullstack-app-frontend-1 Exited (0)
fullstack-app-mysql-1 Up 32 seconds 3306/tcp
fullstack-app-nginx-1 Up 28 seconds 0.0.0.0:80->80/tcp
fullstack-app-redis-1 Up 32 seconds 6379/tcp
注意 frontend 状态是 Exited (0),这是正常的 —— 它的工作就是构建静态文件然后退出,Nginx 会接管静态文件服务。
测试 API
# 健康检查
curl http://localhost/api/health
# 输出: {"status":"ok","timestamp":"2024-01-18T10:30:00.000Z"}
# 记录一次访问
curl -X POST http://localhost/api/visit
# 输出: {"message":"访问已记录"}
# 查看统计
curl http://localhost/api/stats
# 输出: {"count":1,"source":"database"}
# 再查一次(30 秒内会命中缓存)curl http://localhost/api/stats
# 输出: {"count":1,"source":"cache"}
打开浏览器访问 http://localhost,你应该能看到一个显示访问计数的页面。
查看日志
# 查看所有服务日志
docker compose logs
# 只看后端日志
docker compose logs backend
# 实时跟踪日志
docker compose logs -f backend
环境变量实战技巧
多环境配置
实际项目中,你可能需要区分开发、测试和生产环境:
# 开发环境(默认)docker compose up -d
# 生产环境(使用生产配置覆盖)docker compose --env-file .env.production up -d
.env.production 里的值会覆盖 .env 的默认值,不需要改一行代码。
敏感信息处理
对于密码这类敏感信息,更推荐使用 Docker Secrets:
services:
backend:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
不过 Secrets 在 Compose 里的支持有限,更多用于 Docker Swarm 或 Kubernetes。日常开发用 .env 足够了,生产环境建议走 CI/CD 变量注入。
常见问题 Q&A
Q1: 后端一直报 " 等待 MySQL 就绪 ",最后连接失败怎么办?
这通常是因为 MySQL 初始化比较慢,特别是第一次启动要创建数据库和用户。两个解决办法:一是把后端的重试次数和间隔调大,二是使用 Docker Compose 的 healthcheck 配合 depends_on.condition(我们 Day 19 会详细讲健康检查)。临时方案是等 MySQL 启动完成后手动重启后端:docker compose restart backend。
Q2: 修改了前端代码,怎么更新?
docker compose up -d --build frontend nginx
只重新构建前端并重启 Nginx,其他服务不受影响。这比全部重建快得多。
Q3: 数据库数据怎么备份?
docker compose exec mysql mysqldump -u root -p fullstack_db > backup.sql
因为我们用了命名卷 mysql_data,即使容器删除重建,数据也不会丢。但定期备份依然是好习惯。
小结
今天我们完成了一个麻雀虽小五脏俱全的全栈应用容器化。回顾一下核心要点:
- 架构设计先行 :五个容器各司其职,通过内部网络通信
- Nginx 做统一入口 :只暴露一个端口,安全又简洁
- 环境变量集中管理 :用
.env文件 +env_file指令,一处修改处处生效 - 数据卷持久化 :数据库和缓存的数据不跟着容器生死
- 多阶段构建 :前端镜像从几百 MB 瘦身到几十 MB
这种 " 一条命令启动整个技术栈 " 的体验,一旦用过就回不去了。新同事入职?docker compose up -d。换台电脑开发?docker compose up -d。部署到测试环境?还是 docker compose up -d。
明天 Day 19,我们将学习容器健康检查与自动重启。今天我们手动处理了 " 等 MySQL 就绪 " 的问题,明天你会发现 Docker 自己就能搞定这件事,而且搞得更优雅。