冷蟊初退 孤灯野澜 志起鸡鸣 墓不悲秋 技术交流 软件开发 商业合作 加Q:411239339

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

浏览:234次阅读
没有评论

共计 3782 个字符,预计需要花费 10 分钟才能阅读完成。

文章目录
  • Docker 全栈应用
  • 第三步:配置 Nginx 反向代理
  • 第四步:环境变量管理
  • 第五步:编排大合唱 - docker-compose.yml
  • 启动和验证
  • 环境变量实战技巧
  • 常见问题 Q&A
  • 小结
  • 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:

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

    1. 只暴露 80 端口 :MySQL 和 Redis 不对外暴露,只在内部网络通信,更安全
    2. 数据卷持久化 mysql_dataredis_data 保证容器重启不丢数据
    3. depends_on:声明启动顺序依赖,但注意它只保证启动顺序,不保证服务就绪。所以后端代码里我们有重试机制
    4. 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 自己就能搞定这件事,而且搞得更优雅。

    正文完
    创作不易,扫码加点动力
    post-qrcode
     0
    果较瘦
    版权声明:本站原创文章,由 果较瘦 于2026-03-29发表,共计3782字。
    转载说明:除特殊说明外本站文章皆由果较瘦原创发布,转载请注明出处。