Nginx 配置从 210 行到 62 行:一个正则的重构之路

一、问题:配置膨胀

最初的 Nginx 配置是我自己一次次加路由时累积出来的。每次新增一个 Flask 端点,就复制粘贴一个 location 块:

location ^~ /fundtracker/ {
    proxy_pass http://127.0.0.1:5000;
    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;
}

location ^~ /cron/ {
    proxy_pass http://127.0.0.1:5000;
    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;
}

# ... 重复 24 次

等到我数了一下,26 个独立的 location ^~ /xxx/,每个 6-8 行,总共占了 180 多行。而且有两个真实的问题:

  1. 有些块漏了 X-Forwarded-For,导致 Flask 拿不到真实 IP
  2. 新增路由忘了加 Nginx 配置,前端能点进去但返回 404

二、方案:正则 location 合并

Nginx location 支持 ~ 正则匹配。将所有只做 proxy_pass http://127.0.0.1:5000 的路由合并为一个:

location ~ ^/(api|fundanalysis|archive[1-5]|demo(?:[1-8])?|
              chinastock|funddata|fundreturns|fundtracker|
              cryptonews|cron|skills|ainews|wallstreet|
              token|system|dcacalc)/ {
    proxy_pass http://127.0.0.1:5000;
    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;
}

210 行 → 62 行(含安全头和缓存配置),整整减少了 70%。


三、正则详解

^/(api|fundanalysis|archive[1-5]|demo(?:[1-8])?|...|dcacalc)/
片段 含义
^/ 匹配路径开头
(api\|fundanalysis\|...) 捕获组:任一路由名
archive[1-5] 匹配 archive1 ~ archive5
demo(?:[1-8])? 非捕获组 + 可选:匹配 /demo//demo1/ ~ /demo8/
/ 末尾斜杠,防止 /demoxyz 被匹配

注意:末尾的 / 很重要。不加的话 /demotest 也会命中。


四、第一个坑:demodemo1-8 的冲突

/demo//demo1//demo2/……/demo8/ 都是合法路由。如果写成两个独立正则块,/demo/ 会先匹配 /demo1/

解决方法:用 (?:[1-8])?demo 后面可选跟 1-8 的数字,不跟数字就是 /demo/ 本身。

验证:

curl -sI https://venxine.vip/demo/
# HTTP/1.1 302 → ✅ 匹配 demo

curl -sI https://venxine.vip/demo8/
# HTTP/1.1 200 → ✅ 匹配 demo8

curl -sI https://venxine.vip/demo9/
# HTTP/1.1 404 → ✅ 不匹配(demo9 不存在)

五、第二个坑:add_header 覆盖机制(Nginx 设计如此,不是 bug)

这是我踩得最深的一个坑。

原来的配置只在 server 块写了一次安全头:

server {
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    # ...
}

但当我在 location 块内添加了 add_header Cache-Control ... 后,那个 location 匹配到的所有响应,server 级的安全头全部消失了。

因为 Nginx 的逻辑是:如果某个 location 里用了 add_header,它只会输出这个 location 自己声明的头,不会继承 server 级的头。(这是文档里写明的行为,但很少有人注意到。)

解决方案:在每个 location 块里显式重复四个安全头:

location ~ ^/(api|...)/ {
    # 代理
    proxy_pass http://127.0.0.1:5000;
    # 动态内容不缓存
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    # 必须在这里重复安全头!
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

六、第三个坑:sites-enabled 中的备份文件

为了安全,我在改配置前备份了旧文件:

sudo cp venxine.vip venxine.vip.bak.$(date +%Y%m%d_%H%M%S)

结果 nginx -t 报了 warning:

conflicting server name "venxine.vip" on 0.0.0.0:80, ignored
conflicting server name "venxine.vip" on 0.0.0.0:443, ignored

因为 sites-enabledinclude 通配的目录,.bak 文件也被 include 了。两个文件都定义了同一个 server_name,Nginx 选了第一个(新的),第二个被忽略。

教训:备份文件不要放在 sites-enabled/,应移到别处:

sudo mv venxine.vip /etc/nginx/conf-backups/venxine.vip.bak

七、完整的 Gzip 配置

很多人只加了一行 gzip on; 就以为搞定了。实际上完整配置长这样:

gzip on;
gzip_vary on;            # ← 必须:告诉 CDN 有压缩版
gzip_proxied any;         # ← 必须:反向代理后的响应也压缩
gzip_comp_level 6;        # 5-6 是最佳平衡(再高 CPU 成本 > 压缩收益)
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json
           application/javascript text/xml
           application/xml application/xml+rss
           text/javascript image/svg+xml font/woff2;

验证:

# 未压缩
curl -s https://venxine.vip/ | wc -c
# 28625

# gzip
curl -sH "Accept-Encoding: gzip" https://venxine.vip/ | wc -c
# 5491  ← 80.8% 压缩率

八、静态资源缓存策略

三个 location 块对应三种缓存策略:

# 1. 静态资源:30 天不可变缓存
location ~* \.(ico|png|jpg|jpeg|svg|css|js|woff2?|ttf|eot)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
}

# 2. 首页:1 小时可刷新缓存
location = /index.html {
    expires 1h;
    add_header Cache-Control "public";
}

# 3. 动态内容:禁止缓存
location ~ ^/(api|fundanalysis|...)/ {
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    expires -1;
}

immutable 的含义:告诉浏览器"这个文件永远不会变",不会发 If-Modified-Since 验证请求。适用于 hash 化的文件名。


九、TLS 协议升级的验证

# TLSv1.1 → 应拒绝
curl -sI --tls-max 1.1 https://venxine.vip/
# curl: (35) error:... ← ✅ 连接被拒绝

# TLSv1.2 → 应正常
curl -sI --tls-max 1.2 https://venxine.vip/
# HTTP/1.1 200 OK ← ✅

十、最终配置全貌

server {
    listen 80;
    server_name venxine.vip;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name venxine.vip;
    ssl_certificate     /etc/letsencrypt/live/venxine.vip/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/venxine.vip/privkey.pem;

    # 安全头(server 级默认值)
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # 静态资源 30 天缓存
    location ~* \.(ico|png|jpg|svg|css|js|woff2?)$ {
        root /var/www/venxine.vip;
        expires 30d;
        add_header Cache-Control "public, immutable";
        # 重复安全头
        add_header Strict-Transport-Security "max-age=63072000" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    }

    # 首页 1 小时缓存
    location = /index.html {
        root /var/www/venxine.vip;
        expires 1h;
        add_header Cache-Control "public";
        # 重复安全头...
    }

    # Flask 后端:25 条路由 → 1 个正则
    location ~ ^/(api|fundanalysis|archive[1-5]|demo(?:[1-8])?|
                  chinastock|funddata|fundreturns|fundtracker|
                  cryptonews|cron|skills|ainews|wallstreet|
                  token|system|dcacalc)/ {
        proxy_pass http://127.0.0.1:5000;
        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;
        add_header Cache-Control "no-store";
        # 重复安全头...
    }

    location / {
        root /var/www/venxine.vip;
        index index.html;
        try_files $uri $uri/ =404;
    }
}

62 行,覆盖了原来 210 行能做到和做不到的全部事情。


这套配置最省心的地方不是行数少,是新增路由再也不用碰 Nginx:改完 Flask 代码重启就行。正则合并这一次之后,后面几个月我没再动过它。