为什么需要一层 Nginx
PipeMonitor 的服务端跑在 VPS 上,由 Docker Compose 编排三个容器:MySQL、Mosquitto、Node.js API。其中 Node.js API 容器把 3000 端口直接映射到宿主机:
pipe-monitor-api:
build:
context: ./services/pipe-monitor-api
environment:
PORT: "3000"
ports:
- "3000:3000"
让 Node.js 直接暴露公网 3000 端口能跑,但有几个问题:
- 没有 HTTPS,WebSocket 只能用
ws://,浏览器在 HTTPS 页面下会拒绝混合内容 - 没有静态资源缓存,每次刷新都重新拉一遍 Flutter Web 的几 MB JS/WASM
- 没有限流,登录接口裸奔在公网上,容易被低成本爆破
- Flutter Web 的 SPA 路由刷新会 404,需要
try_files回退
所以在前面加一层 Nginx,把 pipe-monitor.varka.cn 的 443 作为唯一公网入口,静态文件、REST API、WebSocket 都从这里进。
整体路径规划
Nginx 按路径把请求分流到三处:
/scada/ -> /var/www/pipe-monitor.varka.cn/scada/ (Flutter Web 静态文件)
/api/... -> http://127.0.0.1:3000 (Node.js REST API)
/ws/live -> http://127.0.0.1:3000 (WebSocket)
Flutter Web 用 --base-href /scada/ 构建后发布到 /var/www/pipe-monitor.varka.cn/scada/,REST 和 WS 仍走后端容器。这样网页发布和后端发布两条链路独立,互不影响。
Flutter Web 的缓存策略
Flutter Web 构建产物里,index.html 体积很小但引用的 main.dart.js、flutter_bootstrap.js、Canvaskit WASM 等资源体积很大。缓存策略要分开:
index.html不做强缓存,确保发布后刷新能拿到最新资源清单- JS/WASM/字体/图片做 7 天缓存,降低刷新流量
# Flutter 入口文件不做强缓存,确保发布后刷新能拿到最新资源清单。
location = /scada/index.html {
root /var/www/pipe-monitor.varka.cn;
auth_basic "Pipe Monitor SCADA Test";
auth_basic_user_file /etc/nginx/auth/pipe-monitor-scada.htpasswd;
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# Flutter Web 的 JS/WASM/字体/图片资源体积较大,测试期先做 7 天缓存降低刷新流量。
location ~* ^/scada/.+\.(?:js|mjs|css|wasm|json|png|jpg|jpeg|gif|ico|svg|webp|woff2?|ttf|otf)$ {
root /var/www/pipe-monitor.varka.cn;
auth_basic "Pipe Monitor SCADA Test";
auth_basic_user_file /etc/nginx/auth/pipe-monitor-scada.htpasswd;
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
try_files $uri =404;
}
几个关键点:
location = /scada/index.html用精确匹配,优先级高于下面的正则和前缀匹配no-cache不是”不缓存”,而是”每次都回源校验”,配合ETag/Last-Modified仍能命中 304- 静态资源用
expires 7d+max-age=604800,Flutter 每次发布文件名带 hash,改了 hash 就等于换了 URL,旧缓存自然失效 always保证 4xx/5xx 响应也带Cache-Control,避免错误页被中间代理缓存
中间还有一段 SPA 回退:
location /scada/ {
root /var/www/pipe-monitor.varka.cn;
auth_basic "Pipe Monitor SCADA Test";
auth_basic_user_file /etc/nginx/auth/pipe-monitor-scada.htpasswd;
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /scada/index.html;
}
刷新 /scada/history 这种深链路时,Nginx 找不到对应文件就回退到子应用自己的 index.html,让 Flutter Router 自己处理路由。回退目标是 /scada/index.html 不是根路径,否则会打到后端 API。
登录接口限流
测试期公网暴露后,/api/auth/login 是最容易被脚本爆破的入口。Node.js 内部有 JWT 鉴权和密码哈希,但每次请求都要算一次 bcrypt,成本不低。在 Nginx 层先按 IP 限流,能挡掉绝大多数低成本扫描。
# 登录接口按客户端 IP 限流,避免测试期公网暴露后被低成本爆破。
limit_req_zone $binary_remote_addr zone=pipe_monitor_login:10m rate=5r/m;
limit_req_zone 必须放在 http 块(conf.d 下的配置文件整体就在 http 上下文里)。参数含义:
$binary_remote_addr:用客户端 IP 的二进制形式作 key,比文本形式省内存zone=pipe_monitor_login:10m:共享内存区命名pipe_monitor_login,分配 10MB,大约能存 16 万个 IPrate=5r/m:平均每分钟 5 个请求
然后在登录接口的 location 里启用:
location = /api/auth/login {
limit_req zone=pipe_monitor_login burst=2 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
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;
}
burst=2:允许突发 2 个请求排队nodelay:排队的请求立即转发,不按 rate 间隔匀速放行limit_req_status 429:超限返回 429 而不是默认的 503,语义更准
实际效果:单个 IP 每分钟最多 7 次登录请求(5 + burst 2),第 8 次直接 429。正常用户手输密码绝不会触发,脚本批量试探会被拦住。
WebSocket 反向代理
/ws/live 是 PipeMonitor 的实时推送入口,Node.js 收到 MQTT 帧后通过 WebSocket 推给前端。HTTP 反代默认不支持 WebSocket 的 Upgrade 握手,需要显式处理:
location /ws/live {
proxy_pass http://127.0.0.1:3000;
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_http_version 1.1:WebSocket 必须用 HTTP/1.1Upgrade $http_upgrade+Connection "upgrade":把客户端的 Upgrade 请求透传给后端,完成协议升级proxy_read_timeout 3600s:默认 60 秒读超时会让空闲连接被 Nginx 主动断开,改成 3600 秒保证长连接不被误杀proxy_send_timeout 3600s:同理,写超时也放宽
如果不改 proxy_read_timeout,客户端会发现 WS 连接每隔一分钟就断一次重连,体验很差。前端连接时用 wss://pipe-monitor.varka.cn/ws/live,Nginx 解 TLS 后以明文 ws:// 转给后端 127.0.0.1:3000,后端不需要关心 TLS。
测试期 Basic Auth
正式上线前不想让公网随意访问 SCADA 页面,但又懒得给 Flutter Web 单独加一套登录页。最省事的做法是在 Nginx 层加 Basic Auth:
auth_basic "Pipe Monitor SCADA Test";
auth_basic_user_file /etc/nginx/auth/pipe-monitor-scada.htpasswd;
先在服务器上生成账号文件:
sudo htpasswd -c /etc/nginx/auth/pipe-monitor-scada.htpasswd <用户名>
然后把这两行加到所有 /scada/ 相关的 location 里。浏览器首次访问会弹登录框,输入账号密码后整个会话期内不再询问。
注意 Basic Auth 是明文 base64 传输密码,必须配合 HTTPS 用,否则凭据会在链路上裸奔。这也是为什么 Basic Auth 要在 Certbot 跑完、HTTPS 启用之后再开启。
Certbot 自动注入 HTTPS
整个配置文件里关于 SSL 的部分都不是手写的,是 Certbot 自动改写进去的:
listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/pipe-monitor.varka.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/pipe-monitor.varka.cn/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
部署流程:
- 先写一份只有
listen 80和server_name的配置 - DNS 把
pipe-monitor.varka.cn的 A 记录指向 VPS 公网 IP sudo certbot --nginx -d pipe-monitor.varka.cn- Certbot 自动完成 ACME challenge、签发证书、改写配置文件加 SSL 块
sudo systemctl reload nginx
# managed by Certbot 注释是 Certbot 自己写的,它会在续期时识别这些行并更新。手动改这些行会导致续期失败,所以别动。
options-ssl-nginx.conf 里是 Certbot 维护的推荐 SSL 参数(协议版本、加密套件、OCSP stapling 等),比自己手写更靠谱,且会随 Let’s Encrypt 的最佳实践更新。
HTTP 到 HTTPS 的 301 跳转
Certbot 跑完后会额外生成一个 80 端口的 server 块,把所有 HTTP 请求 301 到 HTTPS:
server {
if ($host = pipe-monitor.varka.cn) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name pipe-monitor.varka.cn;
return 404; # managed by Certbot
}
这个 if 看着冗余(server_name 已经限定了 host),但 Certbot 这么写是为了兼容一个 server 块服务多个域名的情况。return 404 是兜底,理论上走不到。
代理到 Node.js API
REST 和 WS 都反代到 127.0.0.1:3000,这里是 Node.js 容器映射出来的端口:
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
几个转发头的含义:
Host $host:把原始 Host 头传给后端,Node.js 能识别真实域名X-Real-IP $remote_addr:客户端真实 IP,后端日志和限流要用X-Forwarded-For $proxy_add_x_forwarded_for:追加链路上的 IP,多层代理时形成 IP 链X-Forwarded-Proto $scheme:告诉后端原始协议是 http 还是 https
这里没有单独定义 upstream 块,因为后端只有一个容器,直接 proxy_pass http://127.0.0.1:3000 够用。如果以后做多实例负载均衡,再抽 upstream 不迟。
安全响应头
Nginx 层加安全头能防住一些常见的 Web 攻击。对比同台 VPS 上静态博客 blog.varka.cn 的配置:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
X-Content-Type-Options: nosniff:禁止浏览器猜测 Content-Type,防 MIME 嗅探X-Frame-Options: SAMEORIGIN:只允许同源页面 iframe 嵌入,防点击劫持Referrer-Policy:控制 Referer 头泄露范围
PipeMonitor 的 API 服务本身用 helmet 中间件在 Node.js 层提供了这些头,所以 Nginx 层没重复加。如果后端没戴 helmet,Nginx 层补上更稳妥——头在离用户最近的层加最省事。
HSTS(Strict-Transport-Security)由 Certbot 的 options-ssl-nginx.conf 统一处理,不需要单独加。强行手动加且配错参数(比如 max-age 太长又没回退路径)会导致域名绑死 HTTPS,谨慎。
gzip 压缩
Flutter Web 的 main.dart.js 体积动辄几 MB,不开 gzip 流量浪费明显。配置里在 server 块全局开了 gzip:
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
application/javascript
application/json
application/manifest+json
application/wasm
image/svg+xml;
gzip_min_length 1024:小于 1KB 的响应不压缩,省下的 CPU 比省下的流量更值gzip_vary on:响应头加Vary: Accept-Encoding,避免缓存代理给不支持 gzip 的客户端返回压缩内容application/wasm:Canvaskit 的 WASM 必须显式加,默认gzip_types不含
小结
- Flutter Web 入口
index.html用no-cache,静态资源按文件后缀做 7 天缓存,靠文件名 hash 失效 - 登录接口用
limit_req_zone按 IP 限流,5r/m + burst=2 nodelay + 429,挡住低成本爆破 - WebSocket 反代要加
Upgrade/Connection头,并把读写超时放宽到 3600s,否则空闲连接会被 Nginx 断开 - 测试期用 Basic Auth 临时锁住 SCADA 页面,省得给 Flutter 单独加登录页
- HTTPS 全交给 Certbot 自动注入和续期,别手动改
# managed by Certbot标记的行 - HTTP→HTTPS 301 跳转由 Certbot 生成,80 端口只留兜底
- 安全头在 Node.js 层用 helmet 处理,Nginx 层不重复加;静态站点则在 Nginx 层加
- 代理头
X-Real-IP/X-Forwarded-For/X-Forwarded-Proto一定要透传,后端日志和鉴权才准