Nginx Javascript Module
A few years ago, at my previous company, we were exploring a new tech stack for our website. To evaluate its effectiveness, we conducted A/B testing, directing users to either the existing infrastructure (A) or the new stack (B).
Lacking extensive Nginx experience, we introduced an intermediary service (a reverse proxy), which sat between our existing Nginx reverse proxy and the application servers. This intermediary service split traffic based on a `user_id` cookie. While we were aware of the possibility of developing a custom Nginx module in C, we opted for the .NET solution due to its ease of development. This approach, however, added an extra hop to our request pipeline.
I'm currently facing a similar migration challenge. We aim to transition our website from a client-side rendering architecture to server-side rendering. To ensure a smooth transition, we're again employing A/B testing.
This time, however, we're leveraging Nginx's Layer 7 capabilities and its JavaScript module (NJS) to manage traffic splitting directly within Nginx, eliminating the need for an intermediary service.
This approach not only streamlines our architecture but also allows us to utilize Nginx as a powerful reverse proxy and API gateway. We can now implement features like generating correlation IDs, rewriting headers, content-based routing, and response parsing to redact sensitive information, all within Nginx.
Here's an example Nginx configuration:
load_module modules/ngx_http_js_module.so;
http {
js_path "/etc/nginx/njs/";
js_import utils.js;
upstream backend_a {
server host.docker.internal:3000;
}
upstream backend_b {
server host.docker.internal:3001;
}
map $server_to_map $backend {
"variant_a" backend_a;
"variant_b" backend_b;
default backend_a;
}
map $time_local $expiry {
default "$time_local + 365d";
}
server {
listen 80;
location / {
js_set $user_id utils.userIdCookie;
set $server_to_map 'default';
if ($user_id ~ "0$") {
set $server_to_map 'variant_a';
}
if ($user_id ~ "1$") {
set $server_to_map 'variant_b';
}
if ($cookie_user_id = "") {
add_header Set-Cookie "user_id=$user_id; Expires=$expiry; Path=/; HttpOnly";
}
proxy_pass http://$backend;
proxy_pass_request_headers on;
proxy_set_header X-User-Id $user_id;
}
}
}
Let's break down the configuration step by step:
1. Loading the JavaScript Module:
This line loads the ngx_http_js_module, which allows you to execute JavaScript code within your Nginx configuration.
load_module modules/ngx_http_js_module.so;
2. HTTP Block
Recommended by LinkedIn
http {
js_path "/etc/nginx/njs/";
js_import utils.js;
# ...
}
3. Upstream Blocks
These blocks define two upstream servers: backend_a and backend_b, both running on the host machine (host.docker.internal) but on different ports (3000 and 3001). These upstreams likely represent different versions of your application or different environments (e.g., staging, production).
upstream backend_a {
server host.docker.internal:3000;
}
upstream backend_b {
server host.docker.internal:3001;
}
4. Map Blocks
map $server_to_map $backend {
"variant_a" backend_a;
"variant_b" backend_b;
default backend_a;
}
map $time_local $expiry {
default "$time_local + 365d";
}
5. Server Block
server {
listen 80;
location / {
js_set $user_id utils.userIdCookie;
set $server_to_map 'default';
if ($user_id ~ "0$") {
set $server_to_map 'variant_a';
}
if ($user_id ~ "1$") {
set $server_to_map 'variant_b';
}
if ($cookie_user_id = "") {
add_header Set-Cookie "user_id=$user_id; Expires=$expiry; Path=/; HttpOnly";
}
proxy_pass http://$backend;
proxy_pass_request_headers on;
proxy_set_header X-User-Id $user_id;
}
}
The following is utils.js which nginx loaded from above setting.
function userIdCookie(r) {
var uuid = global.uuid
var cookieStr = r.headersIn.cookie;
var userId = null;
if (cookieStr) {
var cookies = cookieStr.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
if (cookie.startsWith("user_id=")) {
userId = cookie.split("=")[1];
break;
}
}
if (userId) {
return userId;
}
}
var id = uuid.v4()
return id;
}
export default { userIdCookie };
To clarify, the global.uuid object used on line #2 is provided by the uuidv4 library. Since this post is already quite extensive, I'll dedicate a separate blog post to explaining how to incorporate external Node.js modules like uuidv4 into your Nginx JavaScript configuration.
Click this link: Nginx JavaScript (NJS): Using Node Modules to see how we do it.
In summary, this Nginx configuration sets up a reverse proxy that performs A/B testing by splitting traffic between two upstream servers based on a user ID. It uses JavaScript code to generate user IDs, manage cookies, and dynamically select the appropriate upstream server.