< ahmetkaygisiz />

Nginx Rate Limiting

Selam selam, Yılda bir yerine iki blog postu paylaşmak için oturdum klavyenin başına. İşimi gören ve ihtiyaç olabilecek bir uygulama yöntemini paylaşmak istedim. Nginx ile path bazlı rate limiting.

Gereksinimler

NGINX versiyonu

  • Minimum: NGINX 1.7.6+
  • Önerilen: güncel stable sürüm ( 1.18+ veya daha yenisi). Sürümü kontrol etmek için aşağıdaki komutları kullanıyoruz.
nginx -v # nginx sürümü
nginx -V 2>&1 # sürüm + derleme parametreleri + modul listesi

Gerekli moduller

Bu konfigürasyonda temelde üç modülü kullanıyoruz. Üçü de resmi NGINX dağıtımında varsayılan olarak gelir, ancak real_ip modülü kendi derlediğin (custom build) NGINX’te açıkça etkinleştirilmiş olmalı, bu yüzden bunları kontrol etmemiz gerekiyor.

ModülAmacıDerleme Seçeneği
ngx_http_map_modulemap direktifi (URI -> bucket -> key eşleştirmesi) 2Varsayılan derli
ngx_http_limit_req_modulelimit_req_zone / limit_req (asıl rate limiting)Varsayılan derli
ngx_http_realip_moduleset_real_ip_from / real_ip_header (gerçek client IP)–with-http_realip_module

Modüllerin derlenip derlenmediğini doğrulama:

nginx -V 2>&1 | tr ' ' '\n' | grep -E 'realip|limit_req|http_map'
# realip icin ozellikle:
nginx -V 2>&1 | grep -o with-http_realip_module

Diğer

Eğer uygulama öncesinde Reverse proxy / LB katmanının bulunuyorsa X-Forwarded-For header’ının olduğundan/eklendiğinden emin olmamız gerekiyor. (NGINX’te proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for)

map yapısı nedir?

map, NGINX’te http bloğu içinde tanımlanan bir direktiftir (ngx_http_map_module). Bir kaynak değişkenin değerine bakıp, ona karşılık gelen yeni bir değişken üretir. Kısacası bir lookup table / switch-case gibi çalışır.

Örnek yapısı:

map $kaynak_degisken $sonuc_degisken {
      default "varsayilan_deger"; # hicbir eslesme yoksa
      "tam_eslesme" "deger1"; # exact string
      ~^/api/ "deger2"; # regex (~ = case-sensitive,
      ~* = case-insensitive)
      *.example.com "deger3"; # hostname wildcard
}

Önemli özellikleri:

  • default : Hiçbir satır eşleşmezse bu uygulanır. Yazılmazsa boş string ( "" ) olur.
  • Lazy evaluation: $sonuc_degisken sadece bir yerde kullanıldığında hesaplanır, o yüzden ucuzdur. Yani match olursa hesaplama olur.
  • Eşleşme sırası: önce tam (exact) string’ler, sonra regex’ler; ilk eşleşen kazanır.
  • Değer olarak başka bir değişken de atanabilir (aşağıdaki $binary_remote_addr gibi).

Burada neden iki map neden kullandık. Çünkü: rate limit’i sadece hedef endpoint’e uygulamak, diğer tüm trafiği limitsiz bırakacağız. Birinde endpoint tutulacak diğerinde ise ile ve limitin tutulduğu bucket.


# 1. Adim: gelen URI hangi "bucket"a dusuyor?
map $uri $rlmt_bucket {
    default ""; # eslesme yok -> bucket yok -> limit yok
    /api/v1/submit "limit30perminute"; # bu endpoint bu bucket'a dussun
}

# 2. Adim: bucket'a dusen istekler icin limit anahtari (key) ne olsun
map $rlmt_bucket $rlmt_key_limit30perminute {
    default ""; # bucket bos -> key bos -> limit UYGULANMAZ
    limit30perminute $binary_remote_addr; # bucket doluysa -> key = client IP -> IP basina limit
}

Zincirin mantığı:

  1. $uri limitlenecek endpoint’e eşleşirse $rlmt_bucket = “limit30perminute” olur, eşleşmezse "" kalır.
  2. İkinci map bu bucket değerini bir limit anahtarına çevirir. Bucket boşsa anahtar da boş olur.
  3. limit_req_zone anahtarı boş ( "" ) olan istekleri saymaz, yani onlara rate limiting uygulanmaz. Böylece sadece hedef endpoint IP başına limitlenmiş olur.

Neden tek map yerine iki map?

İlk map “hangi endpoint limitli?” sorusunu (URI eşleştirme), ikinci map “limit anahtarı ne?” sorusunu (IP mı, boş mu) ayırır. Yeni bir limitli endpoint eklemek istediğinde çoğunlukla sadece ilk map’e bir satır eklemen yeterli olur.

$binary_remote_addr neden $remote_addr yerine?

İkisi de client IP’sini verir; $binary_remote_addr binary (IPv4 için 4 byte) formda tutar. Rate limit zone’unda çok sayıda IP saklanacağı için binary form daha az bellek kullanır. 10m ’lik bir zone kabaca ~160.000 IPv4 durumu tutar.

Yeni bir limitli endpoint eklemek

Sadece ilk map’e satır ekle (aynı bucket’ı paylaşabilir ya da yeni bucket açabilirsin):

map $uri $rlmt_bucket {
    default "";
    /api/v1/submit "limit30perminute";
    ~^/api/limit-general "limit30perminute"; # prefix icin regex
}

Gerçek client IP ( real_ip modülü) güvenlik notu

set_real_ip_from 10.150.0.0/24; # yalnizca bu CIDR'den gelen XFF'e guvenilir
real_ip_header X-Forwarded-For;
real_ip_recursive on;
  • set_real_ip_from kritik: sadece güvenilen reverse proxy/LB ağından gelen XForwarded-For dikkate alınır.
  • Bu tanım olmadan client’lar sahte X-Forwarded-For göndererek limiti atlatabilir.
  • real_ip_recursive on : XFF zincirindeki güvenilmeyen son IP’yi gerçek client olarak seçer.

Uygulama Adımı

1 - /etc/nginx/nginx.conf

http {
 ...
    # rate limiting rule 
    # bucket ve uri eşleştirmelerinin yapıldığı map
    map $uri $rlmt_bucket {
        default ""; # no limit. defaultta herhangi bir rate limiting yapılmıyor.
        /api/v1/submit "limit30perminute"; # limitlenecek endpoint ismi ve bucket zone değeri veriliyor. 
        ~^/api/limit-general "limit30perminute"; # regex ile prefix içeren tanımlar da eklemek mümkün.
    }
    
    # İlgili bucket'ı ip adresiyle ilişkilendiriyoruz.
    map $rlmt_bucket $rlmt_key_limit30perminute {
        default "";
        limit30perminute $binary_remote_addr; #
    }

    #ilgili request zone’u için rate limit değerini belirliyoruz.
    # zone=rlmt_30perminute:10m : bu zone için memory’de 10mb’lık bir alan kullanılıyor.
    # rate=30r/m : dakikada 30 isteğe izin veriliyor. Her instance'ın tanımı kendisi için geçerli yani 2 Node varsa dakikada 60 isteğe izin verilecek.
    limit_req_zone $rlmt_key_limit30perminute zone=rlmt_30perminute:10m rate=30r/m;
    #  Rate limit aşıldığında dönülecek response code değeri
    limit_req_status 429;
    # Rate limit log level değeri burada belirtilebiliyor.
    limit_req_log_level warn;

    server {
        ...
        # get client ip address
        #  Kullanıcının gerçek ip değerini alabilmek adına iç networke ilk giriş  ip bloğu burada
        set_real_ip_from 10.150.0.0/24;
        real_ip_header X-Forwarded-For;
        real_ip_recursive on; # xforwardedip ile gönderilen ipleri engellemek için eklenmelidir.

        location = /rate_limited.json {
            internal;
            default_type application/json;
            return 429 '{"error":"rate_limit_exceed"}';
        }
    ....
    }
}

2 - Hangi location bloğunda yönlendirme yapıyorsanız o localtion içerisinde limit tanımını vermemiz gerekiyor. Örneğin custom app-related configuration exp custom-nginx.conf için aşağıdaki gibi uygulayabiliriz.

server {
 listen 8080;
 server_name localhost;
 ...

 location /api/ {
    # rate limit zone’u tanımı burada belirtiliyor. burst ilk 1+9 isteği herhangi bir kısıtlama olmadan yanıtlıyor. Ardıdan rate limit tanımı geçerli oluyor.
       limit_req zone=rlmt_30perminute burst=9 nodelay;

       proxy_pass https://localhost:3000/;
    }
}

3 - Farklı farklı durumlarda limitlerin geçerli olup olmadığının test edilmesi de gerekiyor tabi.

api-path

paths=(
  "/api/v1/submit"
  "/api/v1//submit"
  "/api/v1/submit/"
  "/api/v1/submit/../v1/submit"
  "/api/v1/submit;"
  "/api/v1/submit"
  "/api/v1/submit?arg=1"
  "/api/v1/submit?arg=2"
)

for i in $(seq 1 60); do
 for p in "${paths[@]}"; do
   echo -n "Testing $p: "
   curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8080$p" \
     -H "Content-Type: application/json" \
     -d '{"testdata":1234}'
 done
done

xff

#!/bin/bash
for i in $(seq 1 60); do
  code=$(curl -g -s -o /dev/null -H "X-Forwarded-For: 1.2.3.4" -w "%{http_code}\n" -X POST "http://localhost:8080/api/v1/submit" \
-H "Content-Type: application/json" \
-d '{"testdata":1234} \
 ')
  echo "İstek $i -> HTTP $code"
done

xf-ip

#!/bin/bash

URL="http://localhost:8080/api/v1/submit"
START_IP="1.1.1.1"
COUNT=100

ip_to_int() {
  local IFS=.; read -r a b c d <<< "$1"
  echo $(( (a << 24) + (b << 16) + (c << 8) + d ))
}

int_to_ip() {
  local ip=$1
  echo "$(( (ip>>24)&255 )).$(( (ip>>16)&255 )).$(( (ip>>8)&255 )).$(( ip&255 ))"
}

ip_int=$(ip_to_int "$START_IP")

for ((i=0; i<COUNT; i++)); do
  ip=$(int_to_ip "$ip_int")
  code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$URL" \
    -H "Content-Type: application/json" \
    -H "X-Forwarded-IP: $ip" \
    -d '{"testdata":1234}')
  echo "$ip -> $code"
  ((ip_int++))
done

Sample xforwardedip test

Mutlu pazarlar, huzurlu günler.