Nginx njs 模块实战:用 JavaScript 写网关逻辑,灰度分流、回源签名与响应改写一次讲清

为什么个人站长也该认识 njs

大多数人对 Nginx 的印象是“配置文件里写写 location、proxy_pass、rewrite 就完事了”。可一旦遇到稍微复杂的逻辑——比如根据请求头做灰度、按时间戳做签名校验、对返回的 JSON 做一次改写——配置文件那点正则能力立刻就不够用了。传统做法是上 Lua(OpenResty),但为了几行逻辑装一整套 OpenResty 镜像,对一台 1 核 2G 的小 VPS 来说太重了。

njs(Nginx JavaScript)是 Nginx 官方从 0.4.x 起就在推进的一个轻量脚本模块,它把一个小巧的 JavaScript 引擎嵌进 Nginx,用 js_content、js_set、js_header_filter 这些指令在请求处理的各个阶段插入逻辑。它不需要 LuaJIT,不需要额外的运行时,脚本在 Nginx 的 worker 进程里直接跑,内存开销只有几 MB 量级。对于个人站长这种“逻辑不复杂、但配置文件写不出来”的场景,njs 是非常划算的一块拼图。

这篇文章不谈抽象概念,直接讲三个我实际用过的 njs 场景:按请求头做小流量灰度分流、给回源请求加 HMAC 签名、以及把响应里的旧域名内链批量改写。最后会讲清楚 nginx 官方源、编译还是装包、以及几个我踩过的坑。

先把 njs 装进 Nginx:装包还是编译

先确认你现在的 Nginx 是不是带 njs。执行 nginx -V,看输出里有没有 --add-dynamic-module=.../njs。如果有,说明已经带上了;如果没有,就得自己补。

最省事的做法是用官方预编译包。Debian/Ubuntu 上先加 nginx 官方源:

curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/debian bookworm nginx" > /etc/apt/sources.list.d/nginx.list
apt-get update
apt-get install -y nginx nginx-module-njs

装完之后 njs 是以动态模块形式提供的,需要在 /etc/nginx/nginx.conf 顶部的 load_module 段把它加载进来:

load_module modules/ngx_http_js_module.so;
load_module modules/ngx_stream_js_module.so;   # 如果要用四层

如果你用的是自己编译的 Nginx,那就在 configure 时加上 --add-dynamic-module=/path/to/njs/nginx,然后 make modules 把生成的 .so 拷到 modules 目录。njs 的源码可以从官方 hg 仓库或者 GitHub 镜像拿到。个人建议:能用包就用包,除非你要的 njs 版本有特性差异。

装完验证一下:nginx -V 2>&1 | tr ' ' '\n' | grep -i js,能看到 ngx_http_js_module 就 OK。别忘了 nginx -t 检查配置语法。

场景一:用 js_set 做按请求头的小流量灰度

假设你要上一版新首页,只想让 10% 的流量看到,而且希望指定用户(比如带 X-Beta: 1 头的内部同事)强制走新版。配置文件只能按 UA、IP 段匹配,这种“固定比例随机”很难优雅实现。njs 里一行 Math.random() 就解决了。

# /etc/nginx/conf.d/gateway.conf
js_import gray from /etc/nginx/njs/gray.js;

map $http_x_beta $force_beta {
    "1"      "on";
    default  "off";
}

server {
    listen 80;
    server_name www.example.com;

    js_set $variant gray.pick;

    location / {
        # $variant 会是 "a"(旧版)或 "b"(新版)
        proxy_pass http://127.0.0.1:8080$variant_upstream;
        add_header X-Variant $variant always;
    }
}

对应的 gray.js:

function pick(r) {
    // 内部同事强制走 B
    if (r.variables.force_beta === 'on') {
        return 'b';
    }
    // 其余按 10% 比例随机
    return Math.random() < 0.1 ? 'b' : 'a';
}

export default { pick };

这里有个关键点:njs 函数接收的 r 是当前请求对象,通过 r.variables.<name> 读取变量,通过 r.headersIn / r.args 读取请求头与查询参数。返回值在 js_set 里会被当成字符串变量值。灰度分流的逻辑就藏在几行 JS 里,比在配置文件里堆一堆 map 和 split_clients 清爽得多。

注意 split_clients 也能做比例分流,但它是按请求特征做一致性哈希的(同一客户端稳定落到同一档),而 njs 里你可以自由选择“每次随机”还是“按 cookie 固定”,灵活度不在一个层级。

场景二:给回源请求加 HMAC 签名

源站不想被绕过 CDN 直连,常见做法是要求回源请求带一个签名。以前这活要写 Lua,现在 njs 内置了 crypto 模块,HMAC-SHA256 直接可用。

// /etc/nginx/njs/sign.js
import crypto from 'crypto';

function sign(r) {
    const key = 'your-secret-key';
    const path = r.uri;
    const expires = Math.floor(Date.now() / 1000) + 60; // 60 秒有效
    const data = path + ':' + expires;
    const h = crypto.createHmac('sha256', key).update(data).digest('hex');
    return 'exp=' + expires + '&sig=' + h;
}

export default { sign };

在 Nginx 里用一个变量把它拼到 proxy_pass 上:

js_import sig from /etc/nginx/njs/sign.js;
js_set $signed_args sig.sign;

location /api/ {
    proxy_set_header X-Signature $signed_args;
    proxy_pass http://origin$request_uri;
}

源站拿到 X-Signature,取出其中的 exp 与 sig,用同一个 key 重算一遍并比对,同时校验 exp 未过期。这样即使 CDN 被绕过、源站 IP 泄漏,攻击者也无法构造出合法签名。相比在 URL 里挂静态 token,带时间戳的 HMAC 能有效防重放。

场景三:用 body_filter 批量改写旧域名内链

站点改过域名之后,正文里大量的 http://old.example.com/xxx 内链会拖累收录和权重传递,一条条改文章不现实。njs 可以挂在响应的 body filter 阶段,对 HTML 做一次流式替换。

// /etc/nginx/njs/rewrite.js
function rewrite(r, data, flags) {
    if (data.length) {
        // 只处理最后一块之前,避免截断标签;简单场景直接整块替换即可
        return data.replace(/http:\/\/old\.example\.com/g, 'https://www.example.com');
    }
}

export default { rewrite };
js_import rw from /etc/nginx/njs/rewrite.js;
js_body_filter rw.rewrite buffer_type=buffer;

注意:body filter 是分块回调的,替换字符串不一定会完整落在同一个 buffer 里,所以生产上别做跨块匹配的复杂正则,最好用 buffer_type=buffer 让 njs 先攒成完整块再处理(代价是延迟和内存略增)。另外 sub_filter 模块其实也能做这个,但它的匹配是逐字面的、不支持正则,且受 sub_filter_once 影响。njs 的优势是你能写条件判断:比如只改写 <article> 区域内的链接。

场景四:用 js_content 换掉一整个 location 块

前面三个场景都是“往配置文件里插一个变量或过滤器”。njs 还有一个更彻底的用法:用 js_content 直接把一个 location 的处理逻辑整个接管。这时候 Nginx 收到请求后不做内容定位,而是直接把请求交给 JS 函数,函数里可以读参数、读请求体、做判断,再返回一段内容。

js_import api from /etc/nginx/njs/api.js;

location /health {
    js_content api.health;
}

location /echo {
    js_content api.echo;
}
// /etc/nginx/njs/api.js
function health(r) {
    r.headersOut['Content-Type'] = 'application/json';
    r.return(200, JSON.stringify({ status: 'ok', stamp: Date.now() }));
}

function echo(r) {
    const body = r.requestText || '';
    const ua = r.headersIn['User-Agent'] || '';
    r.headersOut['X-From-Njs'] = 'yes';
    r.return(200, 'ua=' + ua + '\nbody=' + body);
}

export default { health, echo };

用 js_content 实现一个健康检查接口、一个 echo 调试接口,比专门起一个后端服务轻得多。它特别适合做“网关层的小工具”:比如返回站点维护页、校验某个一次性 token 后放行、给爬虫返回专门的精简 HTML。注意 r.return(code, body) 会立刻结束请求并发送响应,函数里之后的代码不再执行。用 r.requestText 读请求体时要注意它需要请求体已经被读入内存,大文件上传场景别这么干。

场景五:在 njs 里复用缓存与限流

njs 能配合 Nginx 原生的共享内存做一层轻量缓存。比如你要给某个外部 API 加“60 秒内只请求一次”的本地缓存,不需要装 Redis:用 js_shared_dict_zone 声明一块共享内存,njs 里用 ngx.shared 读写。

js_shared_dict_zone zone=api_cache:2m;
js_import cache from /etc/nginx/njs/cache.js;
// /etc/nginx/njs/cache.js
function get(r) {
    const dict = ngx.shared.api_cache;
    const key = 'k:' + r.args.q;
    const hit = dict.get(key);
    if (hit !== undefined) {
        return 'cached';
    }
    dict.set(key, 1, 60);   // 60 秒后过期
    return 'miss';
}
export default { get };

js_shared_dict_zone 是 Nginx 0.7.0 之后提供的特性,共享内存跨所有 worker 可见,用来做去重、计数、令牌桶都很合适。它的写入是原子性的(dict.incr / dict.add 有原子语义),因此可以用它做简单的限流计数器:dict.incr(key, 1) 后判断是否超过阈值,超过就 r.return(429)。这比在配置文件里用 limit_req 更灵活,因为限流键、阈值、时间窗都可以在 JS 里动态计算。

需要注意共享内存是一块固定大小的区域,写满之后 set 会返回失败或按 LRU 淘汰旧键(取决于版本与配置),容量别设太小。2MB 的 zone 大概能放几万个短键,对个人站足够了。

几个必须知道的坑

第一,别把 njs 当万能胶。它适合“薄薄一层”的逻辑。做复杂的业务编排、连数据库、循环调外部 API,性能会明显下滑,因为 njs 是单线程模型且没有异步 IO 的原生支持(部分版本支持 r.subrequest 发子请求)。重逻辑还是交给后端语言。

第二,变量求值时机。js_set 定义的变量是惰性求值的,只有被引用时才执行。如果你在多个 location 引用同一个 js_set 变量,它每次引用都会重新执行一遍函数,可能触发多次随机、多次签名,结果不一致。需要固定值就用 js_var 或把结果存进缓存。

第三,正则的语法差异。njs 用的正则引擎和 V8 不完全一样,一些后行断言、命名捕获组在旧版本里不支持。写完务必 nginx -t 之后再用真实请求压一下,别等到线上才发现匹配不上。

第四,调试手段。njs 里可以用 r.error("msg") 往 error_log 打日志,也可以用 r.log()。开发阶段把 error_log 调到 debug,配合 r.warn() 观察变量值。没有交互式 REPL,靠日志是主流做法。

第五,模块加载顺序。load_module 必须写在 events 块之前、user 与 worker_processes 之后的位置。写错顺序 Nginx 直接起不来。

小结

njs 的定位很清楚:在“配置文件写不出来、又不想上 OpenResty”的夹缝里,提供一个只有几 MB 开销的 JavaScript 出口。灰度分流、回源签名、正文改写这三类需求,用 njs 都是十几行脚本的事。它的代价是要接受一套和浏览器不太一样的 JS 运行时、以及调试手段相对原始。但对个人站长来说,这种“轻量、够用、不引入重依赖”的方案,往往比为了几行逻辑装一整套框架更务实。装之前先 nginx -V 看一眼模块有没有,装完记得 nginx -t 再 reload,剩下的就交给你的 JS 吧。

Last modification:October 5th, 2026 at 12:28 pm

Leave a Comment