Random and fixed routes with Apache APISIX

Random and fixed routes with Apache APISIX

My ideas for blog posts inevitably start to dry up after over two years at Apache APISIX. Hence, I did some triage on the APISIX repo. I stumbled upon this one question:

We have a requirement to use a plugin, where we need to route the traffic on percentage basis. I'll give an example for better understanding.

We have an URL https://xyz.com/ca/fr/index.html where ca is country (canada) and fr is french language. Now the traffic needs to routed 10% to https://xyz.com/ca/en/index.html and the remaining 90% to https://xyz.com/ca/fr/index.html. And whenever we're routing the traffic to https://xyz.com/ca/en/index.html we need to set a cookie. So for next call, if the cookie is there, it should directly go to https://xyz.com/ca/en/index.html else it should go via a 10:90 traffic split. What is the best possible way to achieve this ??

-- help request: Setting cookie based on a condition

The use case is interesting, and I decided to tackle it.

I'll rephrase the requirements first:

  • If no cookie is set, randomly forward the request to one of the upstreams

  • If a cookie has been set, forward the request to the correct upstream.

For easier testing:

  • I change the odds from 10:90 to 50:50

  • I use the root instead of a host plus a path

Finally, I assume that the upstream sets the cookie.

Newcomers to Apache APISIX understand the matching algorithm very quickly: if a request matches a route's host, method, and path, forward it to the upstream set.

routes:
  - id: 1
    uri: /hello
    host: foo.com
    methods:
      - GET
      - PUT
      - POST
    upstream_id: 1
curl --resolve foo.com:127.0.0.1 http://foo.com/hello            #1
curl -X POST --resolve foo.com:127.0.0.1 http://foo.com/hello    #2
curl -X PUT --resolve foo.com:127.0.0.1 http://foo.com/hello     #2
curl --resolve bar.com:127.0.0.1 http://bar.com/hello            #3
curl --resolve foo.com:127.0.0.1 http://foo.com/hello/john       #4
  1. Matches host, method as curl defaults to GET, and path

  2. Matches host, method, and path

  3. Doesn't match host

  4. Doesn't match path as the configured path doesn't hold a * character

path is the only required parameter; neither host nor methods are. host defaults to any host and methods to any method.

Beyond these three main widespread matching parameters, others are available, e.g., remote_addrs or vars. Let's focus on the latter. The documentation on the Route API is pretty concise:

Matches based on the specified variables consistent with variables in Nginx. Takes the form [[var, operator, val], [var, operator, val], ...]]. Note that this is case sensitive when matching a cookie name. See lua-resty-expr for more details.

-- Route API

One can only understand vars in the Router Radix Tree documentation. The Router Radix Tree powers the Apache APISIX's matching engine.

Nginx provides a variety of built-in variables that can be used to filter routes based on certain criteria. Here is an example of how to filter routes by Nginx built-in variables:

-- How to filter route by Nginx built-in variable?

$ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
{
   "uri": "/index.html",
   "vars": [
       ["http_host", "==", "iresty.com"],
       ["cookie_device_id", "==", "a66f0cdc4ba2df8c096f74c9110163a9"],
       ["arg_name", "==", "json"],
       ["arg_age", ">", "18"],
       ["arg_address", "~~", "China.*"]
   ],
   "upstream": {
       "type": "roundrobin",
       "nodes": {
           "127.0.0.1:1980": 1
       }
   }
}'

This route will require the request header host equal iresty.com, request cookie key _device_id equal a66f0cdc4ba2df8c096f74c9110163a9, etc. You can learn more at radixtree-new.

Among all Nginx variables, we can find $cookie_xxx. Hence, we can come up with the following configuration:

routes:
  - name: Check for French cookie
    uri: /
    vars: [[ "cookie_site", "==", "fr" ]]             #1
    upstream_id: 1
  - name: Check for English cookie
    uri: /
    vars: [[ "cookie_site", "==", "en" ]]             #2
    upstream_id: 2
  1. Match if a cookie named site has value fr

  2. Match if a cookie named site has value en

We need to configure the final route, the one used when no cookie is set. We use the traffic-split plugin to assign a route randomly.

The traffic-split Plugin can be used to dynamically direct portions of traffic to various Upstream services.

This is done by configuring match, which are custom rules for splitting traffic, and weighted_upstreams which is a set of Upstreams to direct traffic to.

When a request is matched based on the match attribute configuration, it will be directed to the Upstreams based on their configured weights. You can also omit using the match attribute and direct all traffic based on weighted_upstreams.

-- traffic-split

The third route is the following:

  - name: Let the fate decide
    uri: /
    upstream_id: 1                                    #1
    plugins:
      traffic-split:
        rules:
          - weighted_upstreams:
              - weight: 50                            #1
              - upstream_id: 2                        #2
                weight: 50                            #2
  1. The weight of the upstream 1 is 50

  2. The upstream 2 weight is also 50 out of the total weight sum. It's a half-half chance of APISIX forwarding it to either upstream

At this point, we need to solve one remaining issue: the order in which APISIX will evaluate the routes. When routes' paths are disjoint, the order plays no role; when they are overlapping, it does.

For example, if APISIX evaluates the last route first, it will forward the request to a random upstream, even though a cookie might have been set. We need to force the evaluation of the first two routes first. For that, APISIX offers the priority parameter; its value is 0 by default. It evaluates routes matching by order of decreasing priority. We need to override it to evaluate the random route last.

  - name: Let the fate decide
    uri: /
    upstream_id: 1
    priority: -1
#...

You can try the setup in a browser or with curl. With curl, we can set the "first" request like this:

curl -v localhost:9080

If the upstream sets the cookie correctly, you should see the following line among the different response headers:

Set-Cookie: site=fr

Since curl doesn't store cookies by default, the value should change across several calls. If we set the cookie, the value stays constant:

curl -v --cookie 'site=en' localhost:9080                  #1
  1. The cookie name is case-sensitive; beware

The browser keeps the cookie, so it's even simpler. Just go to http:localhost:9080 and refresh several times: the content is the same as well. The content will change if you change the cookie to another possible value and request again.

The complete source code for this post can be found on GitHub:

To go further:


Originally published at A Java Geek on June 9th, 2024