Nginx 反向代理

0 阅读9分钟

Nginx 反向代理

在实际的项目部署过程中,我们前端经常会使用到 nginx 这台服务器进行部署,后端可能会有多台服务器,那 nginx 服务器会请求多台服务器,请求后端的服务器就是一个反向代理的过程,这么说可能比较抽象,我们结合正向代理解释下。


正向代理

概念

正向代理:是一个位于客户端和目标服务器之间的服务器(代理服务器),为了从目标服务器取得内容,客户端向代理服务器发送一个请求并指定目标,然后代理服务器向目标服务器转交请求并将获得的内容返回给客户端。

案例

比如你想访问 Google,但直接连不上。你连到一个国外的代理服务器,让它帮你去访问 Google,然后把结果传给你,也就是常说的 VPN。

作用

  • 突破访问限制(翻墙)
  • 通过缓存加速访问资源(代理服务器如果已经访问过该资源,可以直接给你,不用再去源站拿)
  • 隐藏客户端真实 IP(目标网站看到的 IP 是代理服务器的,不是你的)

反向代理

概念

反向代理:与正向代理正好相反,反向代理中的代理服务器,代理的是服务器那端。代理服务器接收客户端请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外表现为一个反向代理服务器的角色。

反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源。同时,用户不需要知道目标服务器的地址,也无须在用户端作任何设定。

image.png

案例

比如每天几亿人访问淘宝,不可能只有一台服务器,你访问 www.taobao.com 时,请求其实是先到了一个巨大的反向代理服务器,这个代理看到你访问的图片,然后把请求转到负责图片的服务器,看到你访问的是下单页面,然后把请求转发到负责下单的服务器。

作用

  • 负载均衡:把流量分摊给后面 n 台服务器,不让某一台累死。
  • 统一入口:无论后面业务怎么变(服务器加减、换 IP),对用户来说,永远只需要访问这一个网址。

实践

本次实践使用 Docker Compose 搭建一个包含 Nginx 反向代理多个 Node.js 后端服务 的服务架构环境。

.
├── docker-compose.yml    # 容器编排配置:定义了 Nginx 和 3 个 Node 服务
├── nginx.conf            # Nginx 核心配置:定义了反向代理规则和 Upstream
├── index.html            # (可选) 根目录静态页面
└── server/               # Node.js 服务代码目录
    ├── server.js         # 简单的 HTTP 服务器,支持 --port 参数
    ├── index.html        # 服务默认返回的页面
    └── package.json      # 依赖配置

Node 服务器

我们让 AI 帮我生成了一个简易的 node 服务器,代码整体就是使用 node server.js --port=xxxx 可以开启一个服务器,服务器会返回当前的 HTML,HTML 会展示出来当前的端口号(供我们查看当前请求的服务器使用)。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Node.js 临时服务器</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }
    .container {
      background: white;
      padding: 60px 40px;
      border-radius: 20px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
      max-width: 600px;
      width: 100%;
      text-align: center;
    }
    h1 {
      color: #667eea;
      font-size: 48px;
      margin-bottom: 20px;
    }
    p {
      color: #666;
      font-size: 18px;
      line-height: 1.6;
      margin-bottom: 15px;
    }
    .success {
      background: #d4edda;
      border: 1px solid #c3e6cb;
      color: #155724;
      padding: 15px;
      border-radius: 10px;
      margin-top: 30px;
      font-weight: bold;
    }
    .info-box {
      background: #f8f9fa;
      padding: 20px;
      border-radius: 10px;
      margin-top: 30px;
      text-align: left;
    }
    .info-box h2 {
      color: #764ba2;
      margin-bottom: 15px;
      font-size: 20px;
    }
    .info-box ul {
      list-style: none;
      padding-left: 0;
    }
    .info-box li {
      padding: 8px 0;
      color: #555;
    }
    .info-box li::before {
      content: "✓ ";
      color: #667eea;
      font-weight: bold;
      margin-right: 8px;
    }
    .emoji {
      font-size: 64px;
      margin-bottom: 20px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="emoji">🚀</div>
    <h1>服务器运行中</h1>
    <p>恭喜!你的 Node.js 临时服务器已经成功启动。</p>
    <p style="font-size: 24px; color: #667eea; font-weight: bold; margin-top: 10px;">
      端口号: ${PORT}
    </p>
    <div class="success">
      ✅ 服务器正常运行在端口 ${PORT}
    </div>
    <div class="info-box">
      <h2>服务器特性</h2>
      <ul>
        <li>静态文件服务</li>
        <li>自动 MIME 类型识别</li>
        <li>美观的 404 错误页面</li>
        <li>请求日志记录</li>
        <li>支持多种文件类型</li>
      </ul>
    </div>
    <p style="margin-top: 30px; color: #999; font-size: 14px;">
      当前时间: <span id="time"></span>
    </p>
  </div>
  <script>
    function updateTime() {
      const now = new Date();
      document.getElementById('time').textContent = now.toLocaleString('zh-CN');
    }
    updateTime();
    setInterval(updateTime, 1000);
  </script>
</body>
</html>
const http = require('http');
const fs = require('fs');
const path = require('path');

// 从命令行参数中获取端口号
const args = process.argv.slice(2);
const portIndex = args.indexOf('--port');
const PORT = portIndex !== -1 ? parseInt(args[portIndex + 1]) : 3000;

console.log('PORT', PORT);

// MIME 类型映射
const mimeTypes = {
  '.html': 'text/html',
  '.css': 'text/css',
  '.js': 'text/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.txt': 'text/plain'
};

const server = http.createServer((req, res) => {
  console.log(`[${new Date().toISOString()}] ${PORT} ${req.method} ${req.url}`);

  // 处理根路径
  let filePath = req.url === '/' ? '/index.html' : req.url;
  filePath = path.join(__dirname, filePath);

  // 获取文件扩展名
  const extname = path.extname(filePath).toLowerCase();
  const contentType = mimeTypes[extname] || 'application/octet-stream';

  // 读取并返回文件
  fs.readFile(filePath, (err, content) => {
    if (err) {
      if (err.code === 'ENOENT') {
        res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`
          <!DOCTYPE html>
          <html>
          <head>
            <meta charset="utf-8">
            <title>404 - 页面未找到</title>
            <style>
              body {
                font-family: Arial, sans-serif;
                display: flex;
                justify-content: center;
                align-items: center;
                height: 100vh;
                margin: 0;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
              }
              .container {
                text-align: center;
                color: white;
                padding: 40px;
                background: rgba(255, 255, 255, 0.1);
                border-radius: 10px;
              }
              h1 { font-size: 72px; margin: 0; }
              p { font-size: 24px; }
            </style>
          </head>
          <body>
            <div class="container">
              <h1>404</h1>
              <p>页面未找到</p>
              <p style="font-size: 16px;">请求路径: ${req.url}</p>
            </div>
          </body>
          </html>
        `);
      } else {
        res.writeHead(500);
        res.end(`服务器错误: ${err.code}`);
      }
    } else {
      let fileContent = content;
      // 如果是 HTML 文件,替换 ${PORT} 占位符
      if (extname === '.html') {
        fileContent = content.toString('utf-8').replace(/\$\{PORT\}/g, PORT);
      }
      res.writeHead(200, { 'Content-Type': contentType });
      res.end(fileContent, 'utf-8');
    }
  });
});

server.listen(PORT, () => {
  console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
  console.log(`📁 服务目录: ${__dirname}`);
  console.log(`按 Ctrl+C 停止服务器`);
});

docker-compose 配置

version: '3'
services:
  web:
    image: nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - api-server
      - frontend-server
      - backend-server
    container_name: nginx-server

  # 模拟 API 服务 (对应 8080 需求)
  api-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8080
    expose:
      - "8080"   # 只暴露给 Docker 网络,不暴露给宿主机
    container_name: api-server

  # 模拟前端服务 (对应 8081 需求)
  frontend-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8081
    expose:
      - "8081"
    container_name: frontend-server

  # 模拟后端服务 (对应 8082 需求)
  backend-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8082
    expose:
      - "8082"
    container_name: backend-server

由于我们要同时启动多个容器,所以使用了 docker-compose 配置了多个容器启动的配置。

简单解释下:

nginx-server

当前配置会启动 1 台 nginx 服务器,nginx 服务器会对外暴露一个 80 端口供宿主机访问。由于我们不方便改 nginx 容器内部的文件,所以使用了目录卷映射了当前目录下的 nginx.conf 为容器内的 nginx.conf。当前容器的启动依赖 api-server、frontend-server 和 backend-server 三个服务,等这三个服务启动后才会启动当前服务。

api-server

api-server:                      # Docker Compose 中的服务名称
    image: node:18-alpine        # 使用的镜像。Alpine 是一个超轻量级的 Linux 发行版,适合做基础镜像

    working_dir: /app            # 设置容器内的工作目录。后续的命令都会在这个目录下执行

    volumes:
      - ./server:/app            # 挂载卷:把本地当前目录下的 server 文件夹,映射到容器里的 /app
                                 # 这样你在本地修改代码,容器里能实时看到

    command: node server.js --port 8080  # 容器启动后默认执行的命令
                                         # 这里启动了 node 服务,并指定端口参数

    expose:
      - "8080"                   # 只是告诉 Docker 网络里的其他容器(如 Nginx):"我有这个端口可用"

    container_name: api-server   # 显式指定容器的名字。如果不写,Docker 会自动生成类似 folder_api-server_1 的名字

剩余两个配置一样。

Nginx 配置

当前的这个 nginx.conf 配置就是配置 nginx 反向代理服务器的核心文件。

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    # 定义 upstream,指向 docker-compose 里的服务名
    upstream api_pool {
        server api-server:8080;
    }

    upstream frontend_pool {
        server frontend-server:8081;
    }

    upstream backend_pool {
        server backend-server:8082;
    }

    upstream proxy_pool {
        server api-server:8080;
        server frontend-server:8081;
        server backend-server:8082;
    }

    server {
        listen       80;
        server_name  localhost;

        # 1. 访问 /api/ -> 代理给 api-server (8080)
        location /api/ {
            proxy_pass http://api_pool/;
        }

        # 2. 访问 / (根路径) -> 代理给 frontend-server (8081)
        location / {
            proxy_pass http://frontend_pool/;
        }

        # 3. 访问 /backend/ -> 代理给 backend-server (8082)
        location /backend/ {
            proxy_pass http://backend_pool/;
        }

        location /proxy/ {
            proxy_pass http://proxy_pool/;
        }
    }
}

该文件定义了 4 个 upstream 和 4 个 location。

upstream
upstream api_pool {
    # server <主机名>:<端口>;
    server api-server:8080;
}
  • 作用:定义了一个叫 api_pool 的组,里面只有一台机器 api-server:8080
  • 主机名解析:这里的 api-server 对应 docker-compose.yml 里的服务名。Docker 会自动把它解析成对应容器的内网 IP。
  • 使用场景:我希望把 /api/ 的流量专门导向 api_pool 服务时使用。
upstream proxy_pool {
    server api-server:8080;
    server frontend-server:8081;
    server backend-server:8082;
}
  • 作用:定义了一个叫 proxy_pool 的组,里面有三台机器。
  • 负载均衡:当 Nginx 把请求转发给 http://proxy_pool 时,它会默认使用轮询算法
    • 第一个请求 -> 转发给 api-server
    • 第二个请求 -> 转发给 frontend-server
    • 第三个请求 -> 转发给 backend-server
    • 第四个请求 -> 回到 api-server ...
  • 使用场景:用户访问同一个 URL,看到的响应会在不同的服务之间切换,用于演示反向代理和负载均衡的场景。
location
location /api/ {
    proxy_pass http://api_pool/;
}

配置含义:当匹配到 /api/ 时,将其转发给 api_pool

location /proxy/ {
    proxy_pass http://proxy_pool/;
}

当请求 /proxy/ 时,将其转到 proxy_pool 这个组内,nginx 会自动帮我们进行负载均衡的处理。

运行验证

目标命令
一键启动docker compose up -d
查看日志docker compose logs -f
停止并清理docker compose down
进入容器docker compose exec <服务名> bash
查看状态docker compose ps

在项目根目录下运行 docker compose up -d,显示启动完成了。

由于我们在 compose 里对外暴露的端口号是 80,我们访问 localhost,显示服务器已经运行成功,当前请求的服务器端口号为 8081,符合我们的配置(根路径 / 代理到 frontend-server:8081)。

访问 /api 请求了 8080 这个服务器,这样就实现了反向代理。

我们试试负载均衡前缀 /proxy,首次进来访问的是 8080 这个服务器,刷新一下会访问 8081 服务器,再次刷新会访问 8082 服务器,这样我们就做了一个简易的负载均衡的示例,用户请求过来,服务器的压力就会小很多。