为什么需要一层 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.jsflutter_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 万个 IP
  • rate=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.1
  • Upgrade $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

部署流程:

  1. 先写一份只有 listen 80server_name 的配置
  2. DNS 把 pipe-monitor.varka.cn 的 A 记录指向 VPS 公网 IP
  3. sudo certbot --nginx -d pipe-monitor.varka.cn
  4. Certbot 自动完成 ACME challenge、签发证书、改写配置文件加 SSL 块
  5. 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.htmlno-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 一定要透传,后端日志和鉴权才准

后续阅读