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

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

浏览:10次阅读
没有评论

共计 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:

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

  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字。
转载说明:除特殊说明外本站文章皆由果较瘦原创发布,转载请注明出处。