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/* 走默认 / 代理。
部署流程
- DNS 把
mill-api.varka.cn的 A 记录指向 VPS 公网 IP - 拷贝配置文件到
/etc/nginx/conf.d/ sudo certbot --nginx -d mill-api.varka.cn,Certbot 会自动改写 listen 和 ssl 行(首次 ACME challenge 走 80 端口 HTTP 验证,签下来后改写成 443 + 80 跳转两段)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 头转发、缓存策略分层。