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
| 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
|
function main(r) { r.return(200, `Hello ${r.args.name}!`) }
export default { main }
|
hello.js
中定义并导出了 main
函数,接收的参数 r
是 HTTP 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) 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,内容有效期为 60s,evict
表示当内存区域用满时驱逐旧数据。
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; js_periodic lb.check interval=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 => { 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) { ngx.shared.servers.replace(server, state) console.error(`server ${server} now is ${state}`) } }) })) }
function getServer(r) { let availableServers = ngx.shared.servers.items(). filter(srv => srv[1] === ServerUP). map(srv => srv[0])
switch (availableServers.length) { case 0: return "" case 1: return availableServers[0] default: let index = Math.floor(Math.random() * availableServers.length) return availableServers[index] } }
function setServer(r) { if (r.method === 'POST') { ngx.shared.servers.add(r.variables.server, ServerDown) } else if (r.method === 'DELETE') { 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" "-" ...
|