|
|
你有没有经历过这样的场景:前端项目跑在 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
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>全栈 Docker 应用</title>
- </head>
- <body>
- <div id="root"></div>
- </body>
- </html>
复制代码
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 <p>加载中...</p>;
- return (
- <div style={{ textAlign: 'center', padding: '50px', fontFamily: 'sans-serif' }}>
- <h1>Docker 全栈应用</h1>
- <p>当前访问量:<strong>{stats?.count || 0}</strong></p>
- <p>数据来源:{stats?.source === 'cache' ? 'Redis 缓存' : 'MySQL 数据库'}</p>
- <button onClick={recordVisit} style={{ padding: '10px 20px', fontSize: '16px' }}>
- 再次访问
- </button>
- </div>
- );
- }
- 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 需要把所有路径都指向,否则会报 404。
━━━━━━━━━━━━━━━━━━━━
第四步:环境变量管理
环境变量是容器化应用的"配置中心"。我们用文件集中管理:
.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=超强密码_!@#$ |
- +----------------------------------+
复制代码
注意:文件里有密码,记得加到里。生产环境的密码应该通过 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 不对外暴露,只在内部网络通信,更安全
数据卷持久化:和保证容器重启不丢数据
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
复制代码
检查服务状态
预期输出:
- 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 状态是,这是正常的 —— 它的工作就是构建静态文件然后退出,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"}
复制代码
打开浏览器访问,你应该能看到一个显示访问计数的页面。
查看日志
- # 查看所有服务日志
- docker compose logs
- # 只看后端日志
- docker compose logs backend
- # 实时跟踪日志
- docker compose logs -f backend
复制代码
━━━━━━━━━━━━━━━━━━━━
环境变量实战技巧
多环境配置
实际项目中,你可能需要区分开发、测试和生产环境:
- # 开发环境(默认)
- docker compose up -d
- # 生产环境(使用生产配置覆盖)
- docker compose --env-file .env.production up -d
复制代码 里的值会覆盖的默认值,不需要改一行代码。
敏感信息处理
对于密码这类敏感信息,更推荐使用 Docker Secrets:
- services:
- backend:
- secrets:
- - db_password
- secrets:
- db_password:
- file: ./secrets/db_password.txt
复制代码
不过 Secrets 在 Compose 里的支持有限,更多用于 Docker Swarm 或 Kubernetes。日常开发用足够了,生产环境建议走 CI/CD 变量注入。
━━━━━━━━━━━━━━━━━━━━
常见问题 Q&A
Q1: 后端一直报 "等待 MySQL 就绪",最后连接失败怎么办?
这通常是因为 MySQL 初始化比较慢,特别是第一次启动要创建数据库和用户。两个解决办法:一是把后端的重试次数和间隔调大,二是使用 Docker Compose 的配合(我们 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
复制代码
因为我们用了命名卷,即使容器删除重建,数据也不会丢。但定期备份依然是好习惯。
━━━━━━━━━━━━━━━━━━━━
小结
今天我们完成了一个麻雀虽小五脏俱全的全栈应用容器化。回顾一下核心要点:
架构设计先行:五个容器各司其职,通过内部网络通信
Nginx 做统一入口:只暴露一个端口,安全又简洁
环境变量集中管理:用文件 +指令,一处修改处处生效
数据卷持久化:数据库和缓存的数据不跟着容器生死
多阶段构建:前端镜像从几百 MB 瘦身到几十 MB
这种"一条命令启动整个技术栈"的体验,一旦用过就回不去了。新同事入职?。换台电脑开发?。部署到测试环境?还是。
明天 Day 19,我们将学习容器健康检查与自动重启。今天我们手动处理了"等 MySQL 就绪"的问题,明天你会发现 Docker 自己就能搞定这件事,而且搞得更优雅。 |
|