找回密码
 立即注册

QQ登录

只需一步,快速开始

peUfSYR.png
查看: 46|回复: 0

Day 18 Docker Compose多容器全栈应用实战

[复制链接]

876

主题

13

回帖

2808

积分

管理员

积分
2808
发表于 2026-3-29 16:17:13 | 显示全部楼层 |阅读模式
Docker 30天实战系列 - 第18天

你有没有经历过这样的场景:前端项目跑在 3000 端口,后端 API 跑在 8080 端口,MySQL 要单独装一遍,Redis 又要配一遍,Nginx 还得改配置文件。好不容易在自己电脑上跑通了,换台机器又从头来一遍。每次部署就像在玩拼图,少一块都拼不上。

今天我们要彻底终结这种痛苦。用 Docker Compose 把 React 前端、Express 后端、MySQL 数据库、Redis 缓存和 Nginx 反向代理,一把梭全部编排起来。一条命令启动,一条命令停止,换谁来都一样。

本文你将学到

  • 如何设计一个多容器全栈应用的架构
  • React + Express + MySQL + Redis 的容器化方案
  • Nginx 反向代理在容器环境下的配置技巧
  • 环境变量的分层管理策略
  • 容器间的网络通信和依赖管理

    | 阅读时间 | 实操时间 | 难度等级 |
    |---------|---------|---------|
    | 15 分钟 | 45 分钟 | 中级 |

    ━━━━━━━━━━━━━━━━━━━━

    架构全景:五个容器的交响乐

    在动手之前,我们先看看整体架构长什么样。这就像开一家餐厅,每个角色各司其职:
    1.                     用户浏览器
    2.                        |
    3.                        v
    4.               +----------------+
    5.               |    Nginx       |  <-- 门迎(反向代理,端口 80)
    6.               |   (端口 80)    |
    7.               +-------+--------+
    8.                       |
    9.            +----------+----------+
    10.            |                     |
    11.            v                     v
    12.   +----------------+   +------------------+
    13.   |  React 前端    |   |  Express 后端    |
    14.   | (静态文件)     |   |  (端口 3001)     |
    15.   +----------------+   +--------+---------+
    16.                                 |
    17.                        +--------+--------+
    18.                        |                 |
    19.                        v                 v
    20.               +----------------+  +----------------+
    21.               |    MySQL       |  |    Redis        |
    22.               |  (端口 3306)   |  |  (端口 6379)    |
    23.               +----------------+  +----------------+
    复制代码

  • Nginx 是门迎,所有请求从它这里进来,静态文件直接返回,API 请求转给后端
  • Express 是大厨,负责处理业务逻辑
  • MySQL 是仓库,存放持久化数据
  • Redis 是备忘条,缓存热点数据加速响应
  • React 打包后的静态文件由 Nginx 直接伺服

    这五个"员工"各干各的,通过 Docker 的内部网络互相沟通,对外只暴露 Nginx 的 80 端口。

    ━━━━━━━━━━━━━━━━━━━━

    项目结构

    先把项目骨架搭好:
    1. mkdir -p fullstack-app/{frontend,backend,nginx}
    2. cd fullstack-app
    复制代码

    最终的目录结构如下:
    1. fullstack-app/
    2. ├── docker-compose.yml
    3. ├── .env
    4. ├── frontend/
    5. │   ├── Dockerfile
    6. │   ├── package.json
    7. │   ├── src/
    8. │   │   └── App.jsx
    9. │   └── public/
    10. │       └── index.html
    11. ├── backend/
    12. │   ├── Dockerfile
    13. │   ├── package.json
    14. │   └── src/
    15. │       └── index.js
    16. └── nginx/
    17.     └── default.conf
    复制代码

    ━━━━━━━━━━━━━━━━━━━━

    第一步:搭建后端服务

    后端是整个应用的心脏,我们先把它搞定。

    backend/package.json
    1. {
    2.   "name": "fullstack-backend",
    3.   "version": "1.0.0",
    4.   "scripts": {
    5.     "start": "node src/index.js",
    6.     "dev": "node --watch src/index.js"
    7.   },
    8.   "dependencies": {
    9.     "express": "^4.18.2",
    10.     "mysql2": "^3.6.0",
    11.     "redis": "^4.6.7",
    12.     "cors": "^2.8.5"
    13.   }
    14. }
    复制代码

    backend/src/index.js
    1. const express = require('express');
    2. const mysql = require('mysql2/promise');
    3. const { createClient } = require('redis');
    4. const app = express();
    5. app.use(express.json());
    6. // 数据库连接配置 —— 全部从环境变量读取
    7. const dbConfig = {
    8.   host: process.env.DB_HOST || 'mysql',
    9.   port: parseInt(process.env.DB_PORT || '3306'),
    10.   user: process.env.DB_USER || 'app',
    11.   password: process.env.DB_PASSWORD || 'app_secret',
    12.   database: process.env.DB_NAME || 'fullstack_db',
    13. };
    14. // Redis 连接
    15. const redisClient = createClient({
    16.   url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
    17. });
    18. let db;
    19. async function initDB() {
    20.   // 等待 MySQL 就绪,最多重试 10 次
    21.   for (let i = 0; i < 10; i++) {
    22.     try {
    23.       db = await mysql.createConnection(dbConfig);
    24.       console.log('MySQL 连接成功');
    25.       // 初始化表
    26.       await db.execute(`
    27.         CREATE TABLE IF NOT EXISTS visitors (
    28.           id INT AUTO_INCREMENT PRIMARY KEY,
    29.           ip VARCHAR(45),
    30.           visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    31.         )
    32.       `);
    33.       return;
    34.     } catch (err) {
    35.       console.log(`等待 MySQL 就绪... (${i + 1}/10)`);
    36.       await new Promise(r => setTimeout(r, 3000));
    37.     }
    38.   }
    39.   throw new Error('MySQL 连接失败');
    40. }
    41. async function initRedis() {
    42.   await redisClient.connect();
    43.   console.log('Redis 连接成功');
    44. }
    45. // 健康检查接口
    46. app.get('/api/health', (req, res) => {
    47.   res.json({ status: 'ok', timestamp: new Date().toISOString() });
    48. });
    49. // 访问统计接口
    50. app.get('/api/stats', async (req, res) => {
    51.   try {
    52.     // 先查 Redis 缓存
    53.     const cached = await redisClient.get('visitor_count');
    54.     if (cached) {
    55.       return res.json({ count: parseInt(cached), source: 'cache' });
    56.     }
    57.     // 缓存未命中,查数据库
    58.     const [rows] = await db.execute('SELECT COUNT(*) as count FROM visitors');
    59.     const count = rows[0].count;
    60.     // 写入缓存,30 秒过期
    61.     await redisClient.setEx('visitor_count', 30, count.toString());
    62.     res.json({ count, source: 'database' });
    63.   } catch (err) {
    64.     res.status(500).json({ error: err.message });
    65.   }
    66. });
    67. // 记录访问
    68. app.post('/api/visit', async (req, res) => {
    69.   try {
    70.     const ip = req.ip || 'unknown';
    71.     await db.execute('INSERT INTO visitors (ip) VALUES (?)', [ip]);
    72.     // 清除缓存,下次查询会从数据库拿最新数据
    73.     await redisClient.del('visitor_count');
    74.     res.json({ message: '访问已记录' });
    75.   } catch (err) {
    76.     res.status(500).json({ error: err.message });
    77.   }
    78. });
    79. const PORT = process.env.PORT || 3001;
    80. Promise.all([initDB(), initRedis()]).then(() => {
    81.   app.listen(PORT, '0.0.0.0', () => {
    82.     console.log(`后端服务运行在端口 ${PORT}`);
    83.   });
    84. });
    复制代码

    backend/Dockerfile
    1. FROM node:20-alpine
    2. WORKDIR /app
    3. COPY package.json ./
    4. RUN npm install --production
    5. COPY src ./src
    6. EXPOSE 3001
    7. CMD ["npm", "start"]
    复制代码

    这个 Dockerfile 很朴素:基于 Alpine 镜像保持体积小,先装依赖再复制代码利用构建缓存。

    ━━━━━━━━━━━━━━━━━━━━

    第二步:搭建前端应用

    前端我们用一个轻量的 React 应用来演示。

    frontend/public/index.html
    1. <!DOCTYPE html>
    2. <html lang="zh-CN">
    3. <head>
    4.   <meta charset="UTF-8" />
    5.   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    6.   <title>全栈 Docker 应用</title>
    7. </head>
    8. <body>
    9.   <div id="root"></div>
    10. </body>
    11. </html>
    复制代码

    frontend/src/App.jsx
    1. import React, { useState, useEffect } from 'react';
    2. function App() {
    3.   const [stats, setStats] = useState(null);
    4.   const [loading, setLoading] = useState(true);
    5.   const fetchStats = async () => {
    6.     try {
    7.       const res = await fetch('/api/stats');
    8.       const data = await res.json();
    9.       setStats(data);
    10.     } catch (err) {
    11.       console.error('获取统计失败:', err);
    12.     } finally {
    13.       setLoading(false);
    14.     }
    15.   };
    16.   const recordVisit = async () => {
    17.     await fetch('/api/visit', { method: 'POST' });
    18.     fetchStats();
    19.   };
    20.   useEffect(() => {
    21.     recordVisit();
    22.   }, []);
    23.   if (loading) return <p>加载中...</p>;
    24.   return (
    25.     <div style={{ textAlign: 'center', padding: '50px', fontFamily: 'sans-serif' }}>
    26.       <h1>Docker 全栈应用</h1>
    27.       <p>当前访问量:<strong>{stats?.count || 0}</strong></p>
    28.       <p>数据来源:{stats?.source === 'cache' ? 'Redis 缓存' : 'MySQL 数据库'}</p>
    29.       <button onClick={recordVisit} style={{ padding: '10px 20px', fontSize: '16px' }}>
    30.         再次访问
    31.       </button>
    32.     </div>
    33.   );
    34. }
    35. export default App;
    复制代码

    frontend/package.json
    1. {
    2.   "name": "fullstack-frontend",
    3.   "version": "1.0.0",
    4.   "private": true,
    5.   "scripts": {
    6.     "start": "react-scripts start",
    7.     "build": "react-scripts build"
    8.   },
    9.   "dependencies": {
    10.     "react": "^18.2.0",
    11.     "react-dom": "^18.2.0",
    12.     "react-scripts": "5.0.1"
    13.   }
    14. }
    复制代码

    frontend/Dockerfile

    前端采用多阶段构建,这是一个很经典的模式 —— 第一阶段编译,第二阶段只保留产物:
    1. # 构建阶段
    2. FROM node:20-alpine AS builder
    3. WORKDIR /app
    4. COPY package.json ./
    5. RUN npm install
    6. COPY public ./public
    7. COPY src ./src
    8. RUN npm run build
    9. # 生产阶段 —— 只保留静态文件
    10. FROM nginx:alpine
    11. COPY --from=builder /app/build /usr/share/nginx/html
    复制代码

    这样最终镜像只有 Nginx + 静态文件,体积从几百 MB 缩小到几十 MB。就像搬家时只搬家具,不搬装修工具。

    ━━━━━━━━━━━━━━━━━━━━

    第三步:配置 Nginx 反向代理

    Nginx 是整个应用的入口,它要做两件事:伺服前端静态文件,把 API 请求转发给后端。

    nginx/default.conf
    1. upstream backend {
    2.     server backend:3001;
    3. }
    4. server {
    5.     listen 80;
    6.     server_name localhost;
    7.     # 前端静态文件
    8.     location / {
    9.         root /usr/share/nginx/html;
    10.         index index.html;
    11.         try_files $uri $uri/ /index.html;
    12.     }
    13.     # API 反向代理
    14.     location /api/ {
    15.         proxy_pass http://backend;
    16.         proxy_set_header Host $host;
    17.         proxy_set_header X-Real-IP $remote_addr;
    18.         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    19.         proxy_set_header X-Forwarded-Proto $scheme;
    20.     }
    21. }
    复制代码

    这里有个细节值得注意:
    1. try_files $uri $uri/ /index.html
    复制代码
    是为了支持前端路由。React 是单页应用,刷新页面时 Nginx 需要把所有路径都指向
    1. index.html
    复制代码
    ,否则会报 404。

    ━━━━━━━━━━━━━━━━━━━━

    第四步:环境变量管理

    环境变量是容器化应用的"配置中心"。我们用
    1. .env
    复制代码
    文件集中管理:

    .env
    1. # 数据库配置
    2. DB_HOST=mysql
    3. DB_PORT=3306
    4. DB_USER=app
    5. DB_PASSWORD=app_secret_2024
    6. DB_NAME=fullstack_db
    7. MYSQL_ROOT_PASSWORD=root_secret_2024
    8. # Redis 配置
    9. REDIS_HOST=redis
    10. REDIS_PORT=6379
    11. # 应用配置
    12. NODE_ENV=production
    13. PORT=3001
    复制代码

    分层管理的思路是这样的:
    1. +----------------------------------+
    2. |  docker-compose.yml              |  <-- 定义哪些变量需要传递
    3. |  environment / env_file          |
    4. +-----------+----------------------+
    5.             |
    6.             v
    7. +----------------------------------+
    8. |  .env 文件                       |  <-- 存放默认值(开发环境)
    9. |  DB_HOST=mysql                   |
    10. |  DB_PASSWORD=app_secret          |
    11. +----------------------------------+
    12.             |
    13.             v
    14. +----------------------------------+
    15. |  部署时覆盖                      |  <-- 生产环境用真实密码
    16. |  DB_PASSWORD=超强密码_!@#$       |
    17. +----------------------------------+
    复制代码

    注意:
    1. .env
    复制代码
    文件里有密码,记得加到
    1. .gitignore
    复制代码
    里。生产环境的密码应该通过 CI/CD 管道注入,永远不要提交到代码仓库。

    ━━━━━━━━━━━━━━━━━━━━

    第五步:编排大合唱 - docker-compose.yml

    万事俱备,现在把所有服务编排到一起:

    docker-compose.yml
    1. version: "3.8"
    2. services:
    3.   # Nginx 反向代理
    4.   nginx:
    5.     image: nginx:alpine
    6.     ports:
    7.       - "80:80"
    8.     volumes:
    9.       - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    10.       - frontend_build:/usr/share/nginx/html
    11.     depends_on:
    12.       - frontend
    13.       - backend
    14.     networks:
    15.       - app-network
    16.     restart: unless-stopped
    17.   # React 前端(构建后将产物复制到共享卷)
    18.   frontend:
    19.     build: ./frontend
    20.     volumes:
    21.       - frontend_build:/usr/share/nginx/html
    22.     networks:
    23.       - app-network
    24.   # Express 后端
    25.   backend:
    26.     build: ./backend
    27.     env_file:
    28.       - .env
    29.     depends_on:
    30.       - mysql
    31.       - redis
    32.     networks:
    33.       - app-network
    34.     restart: unless-stopped
    35.   # MySQL 数据库
    36.   mysql:
    37.     image: mysql:8.0
    38.     environment:
    39.       MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    40.       MYSQL_DATABASE: ${DB_NAME}
    41.       MYSQL_USER: ${DB_USER}
    42.       MYSQL_PASSWORD: ${DB_PASSWORD}
    43.     volumes:
    44.       - mysql_data:/var/lib/mysql
    45.     networks:
    46.       - app-network
    47.     restart: unless-stopped
    48.   # Redis 缓存
    49.   redis:
    50.     image: redis:7-alpine
    51.     volumes:
    52.       - redis_data:/data
    53.     networks:
    54.       - app-network
    55.     restart: unless-stopped
    56. networks:
    57.   app-network:
    58.     driver: bridge
    59. volumes:
    60.   mysql_data:
    61.   redis_data:
    62.   frontend_build:
    复制代码

    几个关键设计决策说明一下:

  • 只暴露 80 端口:MySQL 和 Redis 不对外暴露,只在内部网络通信,更安全
  • 数据卷持久化
    1. mysql_data
    复制代码
    1. redis_data
    复制代码
    保证容器重启不丢数据
  • depends_on:声明启动顺序依赖,但注意它只保证启动顺序,不保证服务就绪。所以后端代码里我们有重试机制
  • restart: unless-stopped:容器异常退出会自动重启,但手动停止后不会重启

    ━━━━━━━━━━━━━━━━━━━━

    启动和验证

    一键启动
    1. docker compose up -d --build
    复制代码

    预期输出:
    1. [+] Building 45.2s (23/23) FINISHED
    2. [+] Running 5/5
    3. ✔ Container fullstack-app-mysql-1     Started
    4. ✔ Container fullstack-app-redis-1     Started
    5. ✔ Container fullstack-app-backend-1   Started
    6. ✔ Container fullstack-app-frontend-1  Started
    7. ✔ Container fullstack-app-nginx-1     Started
    复制代码

    检查服务状态
    1. docker compose ps
    复制代码

    预期输出:
    1. NAME                           STATUS              PORTS
    2. fullstack-app-backend-1        Up 30 seconds       3001/tcp
    3. fullstack-app-frontend-1       Exited (0)
    4. fullstack-app-mysql-1          Up 32 seconds       3306/tcp
    5. fullstack-app-nginx-1          Up 28 seconds       0.0.0.0:80->80/tcp
    6. fullstack-app-redis-1          Up 32 seconds       6379/tcp
    复制代码

    注意 frontend 状态是
    1. Exited (0)
    复制代码
    ,这是正常的 —— 它的工作就是构建静态文件然后退出,Nginx 会接管静态文件服务。

    测试 API
    1. # 健康检查
    2. curl http://localhost/api/health
    3. # 输出: {"status":"ok","timestamp":"2024-01-18T10:30:00.000Z"}
    4. # 记录一次访问
    5. curl -X POST http://localhost/api/visit
    6. # 输出: {"message":"访问已记录"}
    7. # 查看统计
    8. curl http://localhost/api/stats
    9. # 输出: {"count":1,"source":"database"}
    10. # 再查一次(30秒内会命中缓存)
    11. curl http://localhost/api/stats
    12. # 输出: {"count":1,"source":"cache"}
    复制代码

    打开浏览器访问
    1. http://localhost
    复制代码
    ,你应该能看到一个显示访问计数的页面。

    查看日志
    1. # 查看所有服务日志
    2. docker compose logs
    3. # 只看后端日志
    4. docker compose logs backend
    5. # 实时跟踪日志
    6. docker compose logs -f backend
    复制代码

    ━━━━━━━━━━━━━━━━━━━━

    环境变量实战技巧

    多环境配置

    实际项目中,你可能需要区分开发、测试和生产环境:
    1. # 开发环境(默认)
    2. docker compose up -d
    3. # 生产环境(使用生产配置覆盖)
    4. docker compose --env-file .env.production up -d
    复制代码
    1. .env.production
    复制代码
    里的值会覆盖
    1. .env
    复制代码
    的默认值,不需要改一行代码。

    敏感信息处理

    对于密码这类敏感信息,更推荐使用 Docker Secrets:
    1. services:
    2.   backend:
    3.     secrets:
    4.       - db_password
    5. secrets:
    6.   db_password:
    7.     file: ./secrets/db_password.txt
    复制代码

    不过 Secrets 在 Compose 里的支持有限,更多用于 Docker Swarm 或 Kubernetes。日常开发用
    1. .env
    复制代码
    足够了,生产环境建议走 CI/CD 变量注入。

    ━━━━━━━━━━━━━━━━━━━━

    常见问题 Q&A

    Q1: 后端一直报 "等待 MySQL 就绪",最后连接失败怎么办?

    这通常是因为 MySQL 初始化比较慢,特别是第一次启动要创建数据库和用户。两个解决办法:一是把后端的重试次数和间隔调大,二是使用 Docker Compose 的
    1. healthcheck
    复制代码
    配合
    1. depends_on.condition
    复制代码
    (我们 Day 19 会详细讲健康检查)。临时方案是等 MySQL 启动完成后手动重启后端:
    1. docker compose restart backend
    复制代码


    Q2: 修改了前端代码,怎么更新?
    1. docker compose up -d --build frontend nginx
    复制代码

    只重新构建前端并重启 Nginx,其他服务不受影响。这比全部重建快得多。

    Q3: 数据库数据怎么备份?
    1. docker compose exec mysql mysqldump -u root -p fullstack_db > backup.sql
    复制代码

    因为我们用了命名卷
    1. mysql_data
    复制代码
    ,即使容器删除重建,数据也不会丢。但定期备份依然是好习惯。

    ━━━━━━━━━━━━━━━━━━━━

    小结

    今天我们完成了一个麻雀虽小五脏俱全的全栈应用容器化。回顾一下核心要点:

  • 架构设计先行:五个容器各司其职,通过内部网络通信
  • Nginx 做统一入口:只暴露一个端口,安全又简洁
  • 环境变量集中管理:用
    1. .env
    复制代码
    文件 +
    1. env_file
    复制代码
    指令,一处修改处处生效
  • 数据卷持久化:数据库和缓存的数据不跟着容器生死
  • 多阶段构建:前端镜像从几百 MB 瘦身到几十 MB

    这种"一条命令启动整个技术栈"的体验,一旦用过就回不去了。新同事入职?
    1. docker compose up -d
    复制代码
    。换台电脑开发?
    1. docker compose up -d
    复制代码
    。部署到测试环境?还是
    1. docker compose up -d
    复制代码


    明天 Day 19,我们将学习容器健康检查与自动重启。今天我们手动处理了"等 MySQL 就绪"的问题,明天你会发现 Docker 自己就能搞定这件事,而且搞得更优雅。
  • 您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    扫码关注微信公众号

    Archiver|手机版|小黑屋|风叶林

    GMT+8, 2026-5-8 21:43 , Processed in 0.151179 second(s), 20 queries .

    Powered by 风叶林

    © 2001-2026 Discuz! Team.

    快速回复 返回顶部 返回列表