Nginx 反向代理:API + SCADA + WebSocket + 登录限速

Mill 系统的 API 网关 mill-api.varka.cn 跑在一台 VPS 上,前面挂 Nginx。这台 Nginx 要同时干几件事:把后端 API 反代到 127.0.0.1:3001、托管 SCADA 前端 SPA、代理 WebSocket 长连接、给登录接口限速、管 SSL 证书。

完整配置 94 行,下面逐段拆开讲。

整体结构

limit_req_zone ...                  # 限速预声明(http 块)
server {                            # 443 主块
    server_name / root
    /scada 相关 4 个 location       # 静态服务 + auth_basic
    /api/auth/login                 # 限速代理
    /                               # API 默认代理
    /ws/live                        # WebSocket 代理
    ssl_*                           # Certbot 注入
}
server {                            # 80 块,301 跳到 443 }

两个 server 块,主块 443,副块 80 做跳转,这是 Certbot 的标准改法。

SCADA 静态服务:auth_basic + SPA 回退

SCADA 前端打包后扔在 /var/www/mill-api.varka.cn/scada/ 下,不能直接裸奔让人访问,加一层 auth_basic 密码保护。

location = /scada {
    return 301 /scada/;
}

location = /scada/index.html {
    auth_basic "STM32_Mill SCADA";
    auth_basic_user_file /etc/nginx/auth/mill-scada.htpasswd;
    add_header Cache-Control "no-store, no-cache, must-revalidate" always;
    try_files $uri =404;
}

location /scada/ {
    auth_basic "STM32_Mill SCADA";
    auth_basic_user_file /etc/nginx/auth/mill-scada.htpasswd;
    add_header Cache-Control "no-store, no-cache, must-revalidate" always;
    try_files $uri $uri/ /scada/index.html;
}

第一个 location 把 /scada 补上斜杠重定向到 /scada/,不然用户访问不带斜杠的地址会 404。

/scada/index.html 单独列一个 location,是为了明确入口 HTML 的缓存策略。SPA 入口不能强缓存,否则用户拿到旧 index.html,里面引用的 chunk 文件名已经变了,会白屏。所以用 no-store

/scada/ 这个 location 用 try_files $uri $uri/ /scada/index.html 做 SPA 回退。前端路由 /scada/dashboard 这种路径在服务器上不存在文件,最后回退到 index.html,交给前端路由处理。

htpasswd 文件用 htpasswd 命令生成:

sudo htpasswd -c /etc/nginx/auth/mill-scada.htpasswd username

-c 是新建文件,加用户用 htpasswd /path username 不带 -c

静态资源缓存:1周 immutable

SCADA 里的 js、css、图片走单独的正则 location,缓存策略和入口 HTML 不一样:

location ~* ^/scada/.+\.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?|ttf|otf)$ {
    auth_basic "STM32_Mill SCADA";
    auth_basic_user_file /etc/nginx/auth/mill-scada.htpasswd;
    add_header Cache-Control "public, max-age=604800, immutable" always;
    try_files $uri =404;
}

max-age=604800 是 7 天。immutable 告诉浏览器这个资源永远不会变,连 304 验证都省了。

为什么敢用 immutable?因为前端打包工具(Vite、webpack)会给文件名加 hash,内容变了 hash 就变,URL 就变了。同一个 URL 的文件内容保证不变,immutable 是安全的。

注意这个 location 也有 auth_basic,因为 SCADA 整个目录都要密码保护,静态资源也不例外。

location 匹配优先级:精确 = > 正则 ~* > 前缀 /scada/。所以 index.html 走精确匹配,js/css 走正则,其他路径走 /scada/ 的 SPA 回退。

API 反向代理:/ → 127.0.0.1:3001

后端服务跑在 3001 端口,Nginx 把根路径代理过去:

location / {
    proxy_pass http://127.0.0.1:3001;
    proxy_http_version 1.1;
    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;
}

三个转发头要带齐:

  • X-Real-IP 是客户端真实 IP,后端日志和风控要用
  • X-Forwarded-For 是标准代理链,$proxy_add_x_forwarded_for 会自动追加
  • X-Forwarded-Proto 告诉后端原始协议是 http 还是 https

proxy_http_version 1.1 默认是 1.0,改成 1.1 支持 keepalive,减少连接开销。

/scada//ws/live 有自己的 location,优先匹配,不会走到这个 /。其他请求(比如 /api/data)都走这里代理到后端。

登录限速:5r/m + burst=5 nodelay

登录接口 /api/auth/login 单独拎出来限速:

limit_req_zone $binary_remote_addr zone=stm32_mill_login:10m rate=5r/m;

location = /api/auth/login {
    limit_req zone=stm32_mill_login burst=5 nodelay;
    proxy_pass http://127.0.0.1:3001;
    ...
}

limit_req_zone 声明在 http 块,必须在 server 块外面。$binary_remote_addr 按客户端 IP 限速,10m 是状态存储大小,rate=5r/m 是每分钟 5 个请求。

burst=5 允许瞬时 5 个请求排队。nodelay 让排队的不延迟立即转发。

组合起来:平均每分钟 5 次,允许瞬时 5 次突发。超过的请求直接 503。

为什么放 Nginx 层?后端 JWT 鉴权管的是”你是谁”,管不了”你试了多少次”。暴力破解在请求打到后端业务逻辑之前就该挡掉,省 CPU。

5r/m 意味着一个 IP 每天最多 7200 次尝试,对正常用户完全够用,对脚本攻击限制明显。真要更严可以调到 5r/m 以下,或者加 fail2ban 联动。

WebSocket 代理:/ws/live

WebSocket 是 HTTP 升级协议,代理时要带上 Upgrade 头:

location /ws/live {
    proxy_pass http://127.0.0.1:3001;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

关键的两个头:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

$http_upgrade 是 Nginx 变量,取客户端发来的 Upgrade 头的值。Connection "upgrade" 是写死的,告诉后端这次要升级协议。

proxy_read_timeout 3600s 很重要。默认 60 秒,WebSocket 连接空闲 60 秒就会被 Nginx 断开。SCADA 实时数据可能几分钟才推一次,改成 1 小时保险。

/ws/live 这个路径要和后端路由对上,后端收到 /ws/live 的 Upgrade 请求时建立 WebSocket。

SSL 配置:Certbot 接管

证书用 Let’s Encrypt,Certbot 自动改写配置:

listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/mill-api.varka.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mill-api.varka.cn/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

# managed by Certbot 注释的行都是 Certbot 自己加的,手动别改。再跑 certbot renew 时 Certbot 会识别这些标记。

80 端口单独一个 server 块做跳转:

server {
    if ($host = mill-api.varka.cn) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    server_name mill-api.varka.cn;
    return 404;
}

所有 HTTP 请求 301 到 HTTPS。这是 Certbot 加的,不是手写的。

首次申请证书前,配置文件里只有 listen 80;,让 ACME challenge 的 HTTP 验证能通过。证书签下来后 Certbot 自动改写成 443 + 80 跳转两段。

完整配置骨架

把上面各段拼起来就是完整配置,骨架如下(省略重复的 auth_basic 和转发头):

limit_req_zone $binary_remote_addr zone=stm32_mill_login:10m rate=5r/m;  # http 块

server {
    server_name mill-api.varka.cn;
    root /var/www/mill-api.varka.cn;

    location = /scada          { return 301 /scada/; }                    # 补斜杠
    location = /scada/index.html { ... no-store ... }                    # 入口 no-store
    location ~* ^/scada/.+\.(js|css|png|...)$ { ... immutable 1周 ... }   # 静态资源
    location /scada/           { ... try_files $uri $uri/ /scada/index.html; }  # SPA 回退
    location = /api/auth/login { limit_req zone=stm32_mill_login burst=5 nodelay; proxy_pass ...; }
    location /                 { proxy_pass http://127.0.0.1:3001; ... }  # API 默认代理
    location /ws/live          { proxy_set_header Upgrade ...; proxy_read_timeout 3600s; }

    listen 443 ssl http2;                  # Certbot 注入
    ssl_certificate ...;
}

server {                                   # 80 → 443 跳转
    if ($host = mill-api.varka.cn) { return 301 https://$host$request_uri; }
    listen 80;
    return 404;
}

location 匹配顺序按从上到下:精确 = 优先,正则 ~* 次之,前缀 /scada// 兜底。/api/auth/login 精确匹配走限速分支,其他 /api/* 走默认 / 代理。

部署流程

  1. DNS 把 mill-api.varka.cn 的 A 记录指向 VPS 公网 IP
  2. 拷贝配置文件到 /etc/nginx/conf.d/
  3. sudo certbot --nginx -d mill-api.varka.cn,Certbot 会自动改写 listen 和 ssl 行(首次 ACME challenge 走 80 端口 HTTP 验证,签下来后改写成 443 + 80 跳转两段)
  4. sudo systemctl reload nginx

踩过的坑

limit_req_zone 必须在 http 块,不能写在 server 里,写错位置 Nginx 直接报错启动不了。auth_basic 的 htpasswd 文件权限要设 644,设成 600 Nginx worker 读不到会 500。

WebSocket 的 proxy_read_timeout 默认 60 秒,SCADA 推送间隔大于 60 秒时连接会被掐,前端一直重连,看起来像数据断流,改到 3600 秒后稳定。SPA 入口 index.html 也用过 immutable,结果出过事:发布新版本后旧 index.html 还在浏览器缓存里,引用的 chunk 文件名变了,加载失败白屏,改成 no-store 后没再出过。

94 行配置解决 API 代理、SCADA 托管、WebSocket 长连接、登录限速、SSL 证书五件事。套路就那么几个:location 匹配优先级、proxy_set_header 头转发、缓存策略分层。