Nginx rate limiting - Unlimited edition

Updated at by

Introduction from the very basic per server or per client rate limiting to almost perverse parameter capture from location! See the docs and usage from NGINX Rate Limiting.

Per Server

Most basic method of limiting requests is to limit requests per server block.

http {
limit_req_zone $server_name zone=muh_server:10m rate=1r/s;
  server {
    server_name [a-z].local naah.local hurdur.local;
    limit_req zone=muh_server;
  }
  server {
    server_name derp.local;
    limit_req zone=muh_server;
  }
}

All servers respond with an average of 1 request per second and first server_name in the matching block is used as key regardless of which server_name matched. To clarify, request to "naah.local" is will use "[a-z].local" as key.

Two options to fiddle with limit_req are burst and nodelay. For example burst=5 for would allow 5 requests to arrive at any rate and they will be processed at 1r/s. This is what is described as "leaky bucket". The bucket holds 5 requests and it leaks 1 request / second to be processed.

Nginx delaying requests can be observed with siege. If concurrency is same or lower than configured burst response time will reach burst divided by rate limit. Eg. siege -c5 on a zone with 1r/s and burst=5. Once a siege thread completes one request the next request is immediately placed into the queue which is drained at rate of 1r/s.

The server is now under siege...
HTTP/1.1 200     0.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     1.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     2.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     3.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     4.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     5.00 secs:       4 bytes ==> GET  /a.txt

nodelay disables the pacing done by Nginx and requests are processed as fast as possible. Once burst queue is full and rate limiter determines the subsequent request arrived too early, server responds to clients immediately with status code specified by limit_req_status (default 503).

The server is now under siege...
HTTP/1.1 200     0.00 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     0.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     0.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 200     0.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 503     0.01 secs:     197 bytes ==> GET  /a.txt
HTTP/1.1 200     0.01 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 503     0.01 secs:     197 bytes ==> GET  /a.txt
HTTP/1.1 503     0.01 secs:     197 bytes ==> GET  /a.txt
HTTP/1.1 200     0.02 secs:       4 bytes ==> GET  /a.txt
HTTP/1.1 503     0.00 secs:     197 bytes ==> GET  /a.txt

Per client

Rate limiting by client IP address.

http {
limit_req_zone $binary_remote_addr zone=muh_server:10m rate=1r/s;
  server {
    limit_req zone=muh_server;
  }
}

This means each IPv4/6 address can make 1 request / second.

Different rates for different locations

http {
limit_req_zone $binary_remote_addr zone=zone_slow:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=zone_speedy:10m rate=100r/s;
  server {
    limit_req zone=zone_speedy;
    location = /vote {
        limit_req zone=zone_slow;
    }
  }
}

Rate limiting by client IP and location /vote has a much slower rate limit compared to rest of the site.

Server and client limits combined

If you define more than one limit_req per block the most restrictive will prevail. Eg. Server has enough capacity to handle 100r/s but we allocate each client with 5r/s.

http {
limit_req_zone $server_name zone=server_zone:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=client_zone:10m rate=5r/s;
  server {
    limit_req zone=server_zone;
    limit_req zone=client_zone;
    }
  }
}

Note! Defining a limit_req in eg. location block overrides both server block limiters, so you have to drag it along if you're defining more granular client rates.

Unlimited requests for certain client IPs

http {
  geo $zone_key {
    10.1.1.1/24 "";
    default  $binary_remote_addr;
  }
  limit_req_zone $zone_key zone=muh_server:10m rate=1r/s;
  server {
    limit_req zone=muh_server;
  }
}

This requires geoip module (nginx-mod-http-geoip on CentOS) which installed by default on most or at least available in most distributions. When key for the limit_req_zone is empty it is effectively disabled thus clients from the 10.1.1.1/24 have a free reign.

Different rates for client IP ranges.

http {
  geo $ip_speed {
    10.1.1.1/24    "slow";
    192.168.1.1/24 "fast";
    default        "normal";
  }
  map $ip_speed $slow_zone_key {
    "slow"  $binary_remote_addr;
    default "";
  }
  map $ip_speed $normal_zone_key {
    "normal" $binary_remote_addr;
    default  "";
  }
  map $ip_speed $fast_zone_key {
    "fast"  $binary_remote_addr;
    default "";
  }

  limit_req_zone $slow_zone_key zone=slow_zone:10m rate=1r/s;
  limit_req_zone $normal_zone_key zone=normal_zone:10m rate=5r/s;
  limit_req_zone $fast_zone_key zone=fast_zone:10m rate=100r/s;

  server {
    limit_req zone=slow_zone;
    limit_req zone=normal_zone;
    limit_req zone=fast_zone;
  }
}

Isn't that a beauty? By defining three rate limits and three client ip blocks the magic of empty limit_req_zone key disables the two other limiters when one IP block matches.

Unlimited requests for clients who know a secret or are from certain IP block.

I'll just lay out the geo and map parts.

geo $unlimited {
  10.1.1.1/24 "1";
  default  "0";
}
map "$unlimited:$http_super_secret_header" $limit_req_key {
  "1:"                         "";
  "1:seeeecret_header_content" "";
  "0:seeeecret_header_content" "";
  default                      $binary_remote_addr;
}
limit_req_zone $limit_req_key zone=one_per_sec_zone:10m rate=1r/s;

Conditions for unlimited requests : Client from 10.1.1.1/24 block OR client knows the super secret header and it's content and as an added bonus, setting wrong value for the secret header as client from 10.1.1.1/24 block you can feel the limits. This could be useful eg. parts of your app are residing in cloud and need unlimited access to APIs or external load testing. $cookie_ could be used here as well.

Or more simply, just knowing the header+content goes unlimited:

map "$http_super_secret_header" $limit_req_key {
  "seeeecret_header_content" "";
  default                    $binary_remote_addr;
}
limit_req_zone $limit_req_key zone=one_per_sec_zone:10m rate=1r/s;

Note! By default Nginx transforms dashes in header fields to underscores to match variable names and ditches headers which have underscores in field names unless you have configured underscores_in_headers to do otherwise. In this example siege parameter would be --header="super-secret-header: seeeecret_header_content"

Using a captured variable from location regex as key

Imagine you're providing calculation heavy API in which the identifier is available in URL eg.

  • Predict odds for tabletop game /api/predict/<hash for table>
  • Fetching a playlist is fast (GET) /api/playlist/<hash for playlist>
  • Modifying playlist is painful (PATCH/PUT) /api/playlist/<hash for playlist>
  • Get candidates for job opening /api/job/<opening id>/candidates

Using the playlist example, first we'll capture the playlist ID and use it as limit_req_zone key.

http {

  limit_req_zone $playlistId zone=playlist_zone:10m rate=1r/s;

  server {
    location ~ "^/api/playlist/(?<playlistId>[a-z0-9]{8})$" {
      limit_req zone=playlist_zone;
    }
  }
}

Now we have a simple limiter for the playlist URLs. It's a bit counter-intuitive that keying limit_req_zone is done after location matching vOv but it's very useful :). Now let's beef it up with different limiters for GET and PUT/PATCH methods.

http {
  map $request_method $playlist_get_key {
    "GET"   $playlistId;
    default "";
  }
  map $request_method $playlist_modify_key {
    "PATCH" $playlistId;
    "PUT"   $playlistId;
    default "";
  }

  limit_req_zone $playlist_get_key zone=playlist_get_zone:10m rate=10r/s;
  limit_req_zone $playlist_modify_key zone=playlist_modify_zone:10m rate=1r/s;

  server {
    location ~ "^/api/playlist/(?<playlistId>[a-z0-9]{8})$" {
      limit_req zone=playlist_get_zone;
      limit_req zone=playlist_modify_zone;
    }
  }
}

As in the previous examples, an empty limit_req_zone key disables the zone and by inspecting the $request_method nginx decides which zone is enabled. Modifying the playlist is limited to 1 request per second and GETting limited to 10 requests / second.

Final thoughts

Nginx provides nice features for rate limiting even thou some of the ways it is utilized are bit quirky. Allmost all Nginx request time variables can be utilized in creation of rate limits and combined with metre long mappings to suit your needs. These are just a few examples which I've used myself or helped work on. Thanks to Mirko aka. Hermo for the parameter capture thingie. If you have some practical ideas or troubles with rate limiting with Nginx, leave a comment!

Happy fun rate limiting! :)


Leave a comment