How NGINX Gateway Fabric Implements Complex Routing Rules

by

in
Woman touching oversized computer screen with man leaning on the side holding a laptop

NGINX Gateway Fabric is an implementation of the Kubernetes Gateway API specification that uses NGINX as the data plane. It handles Gateway API resources such as GatewayClass, Gateway, ReferenceGrant, and HTTPRoute to configure NGINX as an HTTP load balancer that exposes applications running in Kubernetes to outside of the cluster.

In this blog post, we explore how NGINX Gateway Fabric uses the NGINX JavaScript scripting language (njs) to simplify an implementation of HTTP request matching based on a request’s headers, query parameters, and method.

Before we dive into NGINX JavaScript, let’s go over how NGINX Gateway Fabric configures the data plane.

Configuring NGINX from Gateway API Resources Using Go Templates

To configure the NGINX data plane, we generate configuration files based on the Gateway API resources created in the Kubernetes cluster. These files are generated from Go templates. To generate the files, we process the Gateway API resources, translate them into data structures that represent NGINX configuration, and then execute the NGINX configuration templates by applying them to the NGINX data structures. The NGINX data structures contain fields that map to NGINX directives.

For the majority of cases, this works very well. Most fields in the Gateway API resources can be easily translated into NGINX directives. Take, for example, traffic splitting. In the Gateway API, traffic splitting is configured by listing multiple Services and their weights in the backendRefs field of an HTTPRouteRule.

This configuration snippet splits 50% of the traffic to service-v1 and the other 50% to service-v2:

backendRefs: 
- name: service-v1 
   port: 80 
   weight: 50 
- name: service-v2 
   port: 80 
   weight: 50 

Since traffic splitting is natively supported by the NGINX HTTP split clients module, it is straightforward to convert this to an NGINX configuration using a template.

The generated configuration would look like this:

split_clients $request_id $variant { 
    50% upstream-service-v1; 
    50% upstream-service-v2; 
}  

In cases like traffic splitting, Go templates are simple yet powerful tools that enable you to generate an NGINX configuration that reflects the traffic rules that the user configured through the Gateway API resources.

However, we found that more complex routing rules defined in the Gateway API specification could not easily be mapped to NGINX directives using Go templates, and we needed a higher-level language to evaluate these rules. That’s when we turned to NGINX JavaScript.

What Is NGINX JavaScript?

NGINX JavaScript is a general-purpose scripting framework for NGINX that’s implemented as a Stream and HTTP NGINX module. The NGINX JavaScript module allows you to extend NGINX’s configuration syntax with njs code, a subset of the JavaScript language that was designed to be a modern, fast, and robust high-level scripting tailored for the NGINX runtime. Unlike standard JavaScript, which is primarily intended for web browsers, njs is a server-side language. This approach was taken to meet the requirements of server-side code execution and to integrate with NGINX’s request-processing architecture.

There are many use cases for njs (including response filtering, diagnostic logging, and joining subrequests) but this blog specifically explores how NGINX Gateway Fabric uses njs to perform HTTP request matching.

HTTP Request Matching

Before we dive into the NGINX JavaScript solution, let’s talk about the Gateway API feature being implemented.

HTTP request matching is the process of matching requests to routing rules based on certain conditions (matches) – e.g., the headers, query parameters, and/or method of the request. The Gateway API allows you to specify a set of HTTPRouteRules that will result in client requests being sent to specific backends based on the matches defined in the rules.

For example, if you have two versions of your application running on Kubernetes and you want to route requests with the header version:v2 to version 2 of your application and all other requests version 1, you can achieve this with the following routing rules:

rules: 
  - matches: 
      - path: 
          type: PathPrefix 
          value: / 
    backendRefs: 
      - name: v1-app 
        port: 80 
  - matches: 
      - path: 
          type: PathPrefix 
          value: / 
        headers: 
          - name: version 
            value: v2 
    backendRefs: 
      - name: v2-app 
        port: 80 

Now, say you also want to send traffic with the query parameter TEST=v2 to version 2 of your application, you can add another rule that matches that query parameter:

- matches 
  - path: 
      type: PathPrefix 
      value: /coffee 
    queryParams: 
      - name: TEST 
        value: v2 

These are the three routing rules defined in the example above:

  1. Matches requests with path / and routes them to backend v1-app
  2. Matches requests with path / and the header version:v2 and routes them to the backend v2-app.
  3. Matches requests with path / and the query parameter TEST=v2 and routes them to the backend v2-app.

NGINX Gateway Fabric must process these routing rules and configure NGINX to route requests accordingly. In the next section, we will use NGINX JavaScript to handle this routing.

The NGINX JavaScript Solution

To determine where to route a request when matches are defined, we wrote a location handler function in njs – named redirect – which redirects requests to an internal location block based on the request’s headers, arguments, and method.

Let’s look at the NGINX configuration generated by NGINX Gateway Fabric for the three routing rules defined above.

Note: This config has been simplified for the purpose of this blog.

# nginx.conf 
load_module /usr/lib/nginx/modules/ngx_http_js_module.so; # load NGINX JavaScript Module 
events {}  
http {  
    js_import /usr/lib/nginx/modules/httpmatches.js; # Import the njs script 
    server {  
        listen 80; 
        location /_rule1 {  
            internal; # Internal location block that corresponds to rule 1 
            proxy_pass http://upstream-v1-app$request_uri;  
         }  
        location /_rule2{  
            internal; # Internal location block that corresponds to rule 2 
            proxy_pass http://upstream-v2-app$request_uri; 
        } 
  location /_rule3{ 
internal; # Internal location block that corresponds to rule 3 
proxy_pass http://upstream-v2-app$request_uri; 
  } 
        location / {  
            # This is the location block that handles the client requests to the path / 
           set $http_matches "[{\"redirectPath\":\"/_rule2\",\"headers\":[\"version:v2\"]},{\"redirectPath\":\"/_rule3\",\"params\":[\"TEST=v2\"]},{\"redirectPath\":\"/_rule1\",\"any\":true}]"; 
             js_content httpmatches.redirect; # Executes redirect njs function 
        } 
     }  
} 

The js_import directive is used to specify the file that contains the redirect function and the js_content directive is used to execute the redirect function.

The redirect function depends on the http_matches variable. The http_matches variable contains a JSON-encoded list of the matches defined in the routing rules. The JSON match holds the required headers, query parameters, and method, as well as the redirectPath, which is the path to redirect the request to a match. Every redirectPath must correspond to an internal location block.

Let’s take a closer look at each JSON match in the http_matches variable (shown in the same order as the routing rules above):

  1. {"redirectPath":"/_rule1","any":true} – The “any” boolean means that all requests match this rule and should be redirected to the internal location block with the path /_rule1.
  2. {"redirectPath":"/_rule2","headers"[“version:v2”]} – Requests that have the header version:v2 match this rule and should be redirected to the internal location block with the path /_rule2.
  3. {"redirectPath":"/_rule3","params"[“TEST:v2”]} – Requests that have the query parameter TEST=v2 match this rule and should be redirected to the internal location block with the path /_rule3.

One last thing to note about the http_matches variable is that the order of the matches matters. The redirect function will accept the first match that the request satisfies. NGINX Gateway Fabric will sort the matches according to the algorithm defined by the Gateway API to make sure the correct match is chosen.

Now let’s look at the JavaScript code for the redirect function (the full code can be found here):

// httpmatches.js 
function redirect(r) { 
  let matches; 

  try { 
    matches = extractMatchesFromRequest(r); 
  } catch (e) { 
    r.error(e.message); 
    r.return(HTTP_CODES.internalServerError); 
    return; 
  } 

  // Matches is a list of http matches in order of precedence. 
  // We will accept the first match that the request satisfies. 
  // If there's a match, redirect request to internal location block. 
  // If an exception occurs, return 500. 
  // If no matches are found, return 404. 
  let match; 
  try { 
    match = findWinningMatch(r, matches); 
  } catch (e) { 
    r.error(e.message); 
    r.return(HTTP_CODES.internalServerError); 
    return; 
  } 

  if (!match) { 
    r.return(HTTP_CODES.notFound); 
    return; 
  } 

  if (!match.redirectPath) { 
    r.error( 
      `cannot redirect the request; the match ${JSON.stringify( 
        match, 
      )} does not have a redirectPath set`, 
    ); 
    r.return(HTTP_CODES.internalServerError); 
    return; 
  } 

  r.internalRedirect(match.redirectPath); 
} 

The redirect function accepts the NGINX HTTP request object as an argument and extracts the http_matches variable from it. It then finds the winning match by comparing the request’s attributes (found on the request object) to the list of matches and internally redirects the request to the winning match’s redirect path.

Why Use NGINX JavaScript?

While it’s possible to implement HTTP request matching using Go templates to generate an NGINX configuration, it’s not straightforward when compared to simpler use cases like traffic splitting. Unlike the split_clients directive, there’s no native way to compare a request’s attributes to a list of matches in a low-level NGINX configuration.

We chose to use njs to HTTP request match in NGINX Gateway Fabric for these reasons:

  • Simplicity – Makes complex HTTP request matching easy to implement, enhancing code readability and development efficiency.
  • Debugging – Simplifies debugging by allowing descriptive error messages, speeding up issue resolution.
  • Unit Testing – Code can be thoroughly unit tested, ensuring robust and reliable functionality.
  • Extensibility – High-level scripting nature enables easy extension and modification, accommodating evolving project needs without complex manual configuration changes.
  • Performance – Purpose-built for NGINX and designed to be fast.

Next Steps

If you are interested in our implementation of the Gateway API using the NGINX data plane, visit our NGINX Gateway Fabric project on GitHub to get involved:

  • Join the project as a contributor
  • Try the implementation in your lab
  • Test and provide feedback

And if you are interested to chat about this project and other NGINX projects, stop by the NGINX booth at KubeCon North America 2023. NGINX is proud to be a Platinum Sponsor of KubeCon NA and we hope to see you there!

To learn more about njs, check out additional examples or read this blog.