Nginx 作为一款高性能的反向代理服务器和负载均衡器,广泛应用于处理大量并发连接和静态内容的服务。其高效的事件驱动架构使得 Nginx 能够在资源有限的情况下,提供卓越的性能和稳定性。然而,Nginx 的配置文件 nginx.conf 虽然灵活且功能强大,却还是存在一些局限性。首先 nginx.conf 是静态配置,意味着任何配置的更改都需要重新加载服务 (reload),这在快速变化的环境中可能导致服务中断或延迟;其次如果需要在请求处理过程中进行一些复杂的逻辑处理,nginx.conf 无法满足需求。

Njs 是 Nginx 官方提供的一个扩展模块,可以简单的理解为 Njs = Nginx + JavaScript,在保证高性能的前提下,我们可以通过 JavaScript 脚本在 Nginx 处理请求的各个阶段插入自定义逻辑,极大地提升了 Nginx 的灵活性。njs 包含用于处理 HTTP 请求的 ngx_http_js_module 模块以及处理 TCP 和 UDP 流量的 ngx_stream_js_module 模块,本文主要介绍比较常用的 ngx_http_js_module 模块。

安装 ngx_http_js_module 模块

Njs 以动态模块形式提供因此需要单独安装或编译,Nginx 的 官方包仓库 中已经包含了 ngx_http_js_module 模块,多数情况下只需要根据系统发行版安装即可。在 Ubuntu 系统中可以通过以下命令安装:

1
2
3
4
5
6
7
8
9
$ apt update

$ apt install libnginx-mod-http-js

$ ls -la /usr/share/nginx/modules
total 1196
drwxr-xr-x 2 root root 4096 9月 27 12:11 .
drwxr-xr-x 3 root root 4096 9月 27 12:06 ..
-rw-r--r-- 1 root root 1027112 4月 11 07:29 ngx_http_js_module.so

如果使用容器化方式部署 官方镜像 内已经包含了 ngx_http_js_module 模块无需另外安装,这也是本文所采取的部署方式。

Hello World

下面我们通过一个简单的 Hello World 例子来演示如何使用 njs,先来看在 nginx.conf 中如何加载 njs 模块以及引用 njs 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# /etc/nginx/nginx.conf
load_module modules/ngx_http_js_module.so;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

server {
listen 3000;

js_import hello.js;

location / {
js_content hello.main;
}
}
}

在配置的第一行我们通过 load_module 指令加载了 ngx_http_js_module.so 模块,官方容器镜像中该模块位于配置目录下 modules/ 目录中,使用包管理器安装则一般位于 /usr/lib/nginx/modules 目录下。

server 块中通过 js_import 指令引入了 hello.js 脚本,在 location 块中通过 js_content 指令调用了 hello.main 函数,js_content 指令的作用是调用 JavaScript 函数生成响应,接下来我们来看 hello.js 脚本的内容:

1
2
3
4
5
6
7
// /etc/nginx/hello.js

function main(r) {
r.return(200, `Hello ${r.args.name}!`)
}

export default { main }

hello.js 中定义并导出了 main 函数,接收的参数 rHTTP Request 对象,main 函数调用了 return 方法返回 HTTP 响应,响应状态码为 200,响应内容为 Hello ${r.args.name}!,其中 r.args.name 是 URL 查询参数中的 name 参数,等同于 nginx.conf 中的内置变量$arg_name

下面我们启动 Nginx 服务并访问:

1
2
$ curl http://localhost:3000/?name=njs
Hello njs!

通过这个例子我们大致了解了 njs 的使用方法,简单总结一下:

  • load_module modules/ngx_http_js_module.so; 指令加载 njs 模块,必须要在 nginx.conf 的顶层配置块中加载
  • js_import 指令引入 njs 脚本,文件名会被用作 module 名称
  • js_content 指令调用 njs 脚本中的函数生成响应,函数名规则为 module.function
  • njs 脚本中被 nginx.conf 相关指令调用的函数必须导出

使用 ngx_http_js_module

ngx_http_js_module 模块提供了一系列指令用于在请求不同阶段执行 njs 脚本,下面我们来看一些常用的指令:

js_body_filter / js_header_filter

这两个指令用于修改响应内容和响应头,下面来看一个清除响应内容中敏感信息的例子:

1
2
3
4
5
6
7
8
location / {
js_import filter.js;

js_body_filter filter.hidePassword;
js_header_filter filter.cleanContentLength;

proxy_pass http://my-app;
}

filter.js 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
function hidePassword(r, chunk, flags) {
r.sendBuffer(
chunk.replace(/"password":\s*"[^"]+"/g, '"password":"***"'),
flags
)
}

function cleanContentLength(r) {
r.headersOut['Content-Length'] = ''
}

export default { hidePassword, cleanContentLength }

js_body_filter 指令调用 filter.hidePassword 函数将响应内容中的 password 字段值替换为 ***,需要注意的是当上游服务以 chunked 编码方式传输时 filter.hidePassword 函数会被多次调用,每次调用传入的 chunk 是响应内容的一部分。如果需要取得完整的响应内容再进行处理,可以使用一个全局变量将内容缓存起来并在上游返回最后一个数据块时处理:

1
2
3
4
5
6
7
8
9
let data = '' // 每个请求都有独立的上下文,全局变量不会被其他请求共享

function hidePassword(r, chunk, flags) {
if (flags.last) { // 最后一个数据块
r.sendBuffer(data.replace('10.42.1.56', '***'), flags)
} else {
data += chunk
}
}

如果 js_body_filter 指令修改了响应内容的长度,还需要使用 js_header_filter 指令清除响应头中的 Content-Length 字段,否则可能会导致客户端无法正常处理响应。虽然 js_body_filter 同样接收了 HTTP Request 参数,但在这个阶段响应头已经发送给了客户端,因此无法修改响应头。

js_set

js_set 指令用于设置 nginx 变量,例如根据请求体设置不同的后端服务:

1
2
3
4
5
6
location / {
js_import proxy.js;
js_set $backend proxy.getBackend;

proxy_pass $backend;
}

proxy.js 内容如下:

1
2
3
4
5
6
function getBackend(r) {
let data = JSON.parse(r.requestText) // 解析 JSON 请求内容
return `http://${data.region}.internal`
}

export default { getBackend }

相比起 map 指令 js_set 指令更加灵活。

js_shared_dict_zone

前文提到请求之间的上下文是隔离的,如果需要在请求之间共享数据可以使用 js_shared_dict_zone 指令,下面实现一个简单的代理缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
http {
...
js_shared_dict_zone zone=cache:10m timeout=60s evict;

server {
listen 3000;

js_import cache.js;

location / {
js_set $hasCache cache.hasCache;

if ( "$hasCache" ) {
rewrite . /getcache;
}

js_body_filter cache.setCache;
proxy_pass http://my-app;
}

location =/getcache {
internal;
js_content cache.sendCache;
}
}
}

cache.js 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function hasCache(r) {
return ngx.shared.cache.has('data') ? "1" : "0"
}

let data = ''

function setCache(r, chunk, flags) {
if (flags.last) {
ngx.shared.cache.add('data', data + chunk)
} else {
data += chunk
}

r.sendBuffer(chunk, flags)
ngx.log(ngx.ERR, 'set cache')
}

function sendCache(r) {
r.return(200, ngx.shared.cache.get('data'))
}

export default { hasCache, setCache, sendCache }

js_shared_dict_zone 定义了一个名为 cache 的共享内存区域,大小为 10MB,内容有效期为 60sevict 表示当内存区域用满时驱逐旧数据。

location / {} 中使用 js_cache 指令设置了一个 $hasCache 变量判断是否有缓存,如果有则重定向到 location /getcache {} 路由,调用 cache.sendCache 方法从缓存中获取数据返回;如果没有缓存则进入反向代理步骤,上游服务响应后调用 cache.setCache 方法将内容缓存到共享内存中。

js_preload_object

js_preload_object 用于预加载 JavaScript 对象,可以在 Nginx 启动时从 JSON 文件中加载配置或者初始化一些数据:

1
2
3
4
5
6
7
8
js_preload_object config.json;

location / {
js_set $basicAuth proxy.getBasicAuth;

proxy_set_header Authorization "Basic $basicAuth";
proxy_pass http://my-app;
}

config.json 内容如下:

1
2
3
4
5
6
{
"authorization":{
"username": "admin",
"password": "123456"
}
}

proxy.js 内容如下:

1
2
3
4
5
function getBasicAuth(r) {
return `${btoa(config.authorization.username + ':' + config.authorization.password)}`
}

export default { getBasicAuth }

默认情况下 js_preload_object 会使用文件名作为全局变量名,在 njs 脚本中可以直接使用 config 访问。

js_periodic

js_periodic 指令用于周期性执行 JavaScript 函数,例如定时刷新缓存:

1
2
3
4
5
6
7
8
9
js_import cache.js;

location @periodics {
js_periodic cache.refresh interval=60s;
}

location / {
js_content cache.getCache;
}

内部路由 location @periodics {}js_periodic cache.refresh interval=60s; 指令定义了一个周期性任务,每隔 60s 执行一次 cache.refresh 函数,路由名不强制要求为 @periodics,Nginx 启动后会自动执行周期性任务。

以上就是 ngx_http_js_module 模块提供的一些常用指令,更多指令的使用方法可以参考 官方文档

JavaScript API

njs 对 JavaScript 语言提供了 有限的 支持,在此之上 njs 加入了一些 Nginx 相关的函数和方法,下面我们来看一些常用的 API:

  • ngx.log(level, message) 打印日志,level 的值可以是 ngx.INFO ngx.WARN ngx.ERR,需要注意的是 ngx.log 会受到 error_log 指令的影响,例如 error_log /var/log/nginx/error.log warn;ngx.INFO 级别的日志将不会被记录。

  • process.env 获取环境变量,需要注意的是只有在 nginx.conf 中使用 env 指令声明的环境变量才能被 njs 访问,例如在 nginx.conf 中声明env HOSTNAME;,在 njs 中可以通过 process.env.HOSTNAME 访问。

  • ngx.fetch Fetch API 的实现,用于发起 HTTP 请求,在后面的例子中会详细介绍。

  • FileSystem API,njs 提供了一系列操作文件系统的 API,例如 fs.readFileSync fs.writeFileSync 等。

完整的 API 手册请参考 官方文档

实战: 负载均衡器

下面我们使用 njs 实现一个具有以下功能的负载均衡器:

  • 通过 API 添加、删除后端服务
  • 通过 /_/health 路由检查后端服务健康状态并剔除不健康的服务

首先来编写 nginx.conf 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 定义共享内存区域存放后端服务状态
js_shared_dict_zone zone=servers:1m timeout=60s evict;

js_import lb.js;
js_preload_object servers.json;

server {
listen 80;

# 后端服务健康监测
location @periodics {
js_fetch_timeout 3s; # 设置 ngx.fetch 请求超时时间
js_periodic lb.check interval=10s; # 每隔 10s 检查一次后端服务
}

# 管理后端服务
location ~ ^/_/server/([0-9.]+)$ {
set $server $1;
js_content lb.setServer;
}

# 列出后端服务
location = /_/servers {
js_content lb.listServers;
}

# 入口路由
location / {
js_set $server lb.getServer;

if ( "$server" = "" ) {
return 503 "Service Unavailable";
}

proxy_pass "http://$server";
}
}

lb.js 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const ServerUP = "up"
const ServerDown = "down"

async function check() {
await Promise.all(ngx.shared.servers.keys().map(server => {
// 使用 ngx.fetch 向 server 发起健康检查请求
// ngx.fetch 返回 Promise 对象,与 FetchAPI 一致
return ngx.fetch(`http://${server}/_/health`, {
headers: {
'User-Agent': 'njs-loadbalance'
}
}).then(r => {
let state = r.status === 200 ? ServerUP : ServerDown

if (ngx.shared.servers.get(server) != state) { // 更新 server 状态
ngx.shared.servers.replace(server, state)
console.error(`server ${server} now is ${state}`) // console.error 等效于 ngx.log(ngx.ERR, ...)
}
})
}))
}

function getServer(r) {
let availableServers = ngx.shared.servers.items(). // 列出所有 servers
filter(srv => srv[1] === ServerUP). // 过滤可用 servers
map(srv => srv[0]) // 获取 server 地址

switch (availableServers.length) {
case 0:
return ""
case 1:
return availableServers[0]
default: // 随机返回 server
let index = Math.floor(Math.random() * availableServers.length)
return availableServers[index]
}
}

function setServer(r) {
if (r.method === 'POST') { // 添加 server
// `r.variables.server` 获取在 nginx.conf 中使用 `set $server $1` 设置的变量
ngx.shared.servers.add(r.variables.server, ServerDown)
} else if (r.method === 'DELETE') { // 删除 server
ngx.shared.servers.delete(r.variables.server)
} else {
r.return(405)
return
}

r.return(200)
}

function listServers(r) {
let servers = ngx.shared.servers.items().map(item => ({
server: item[0],
status: item[1]
}))

r.return(200, JSON.stringify(servers))
}

export default { check, getServer, setServer, listServers }

编写好配置后启动 nginx 并启动 2 个后端服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 没有可用的 server
$ curl http://127.0.0.1/whoami
Service Unavailable

# 添加 servers
$ curl -XPOST http://127.0.0.1/_/server/192.168.215.3
$ curl -XPOST http://127.0.0.1/_/server/192.168.215.4

# 获取 servers 列表
$ curl http://127.0.0.1/_/servers
[{"server":"192.168.215.4","status":"up"},{"server":"192.168.215.3","status":"up"}]

# 访问后端服务
$ curl http://127.0.0.1/whoami
192.168.215.4

$ curl http://127.0.0.1/whoami
192.168.215.3

从后端服务的日志中可以看到 njs 定时检查后端服务的健康状态:

1
2
3
4
192.168.215.2 - - [27/Sep/2024:14:24:44 +0000] "GET /_/health HTTP/1.1" 200 0 "-" "njs-loadbalance" "-"
192.168.215.2 - - [27/Sep/2024:14:24:54 +0000] "GET /_/health HTTP/1.1" 200 0 "-" "njs-loadbalance" "-"
192.168.215.2 - - [27/Sep/2024:14:25:05 +0000] "GET /_/health HTTP/1.1" 200 0 "-" "njs-loadbalance" "-"
...