Jeder Entwickler, der mal beispielsweise mit Googles APIs gearbeitet hat, kennt das Problem - bei zu vielen Anfragen pro Minute werden diese nicht mehr verarbeitet und man bekommt einen 403 Fehler mit Statuscode OVER_QUERY_LIMIT. Damit möchte man beispielsweise folgendes erreichen:
Welche Möglichkeiten gibt es derartige Limitierung umzusetzen? Die erste und sehr naive Methode wäre zum Beispiel ein Anfrage-Zähler in der Applikation selbst. Man implementiere einen ServletFilter in dem die IP-Adresse sowie die letzten Anfragen (Requests) inkl. Zeitstempel des Clients gespeichert und ausgewertet werden. Diese Methode ist zwar “cool” und es macht sicher Spaß so etwas selbst zu programmieren, hat aber einige schwerwiegende Nachteile:
Vor allem der letzte Punkt ist sehr kritisch. Hat man mehrere Knoten hinter einem Lastverteiler (Loadbalancer), muß die Anfragehistorie an einer zentralen Stelle gespeichert werden. Der Alptraum beginnt. Die Filter-Lösung hat also einen großen Lerneffekt ist aber nicht praktikabel. Stattdessen soll die Limitierung an einer anderen Stelle stattfinden. Das Stichwort “Loadbalancer” ist schon gefallen. nginx ist ein sehr verbreiteter Webserver bzw. Proxyserver mit dem solche Anfragelimitierungen sehr einfach umsetzen lassen. Wir verzichten hier auf eine ausführliche nginx-Einführung und gehen direkt zu Konfiguration über.
Die Konfiguration einer solchen Limitierung erfolgt in der Regel in zwei Schritten:
Eine Zone legt man in der Konfigurationsdatei /etc/nginx/nginx.conf. Im Block http muss dabei folgende zwei Regeln definiert werden:
http {
limit_req_zone $binary_remote_addr zone=search:10m rate=1r/s;
limit_req_status 429;
[...]
}
Hiermit wird eine 10 MB Zone mit dem Namen “search” angelegt, die IP-Adresse im binären Format merkt. Die maximale Anfrage-Rate beträgt 1 Request pro Sekunde (1r/s). Da nginx bei Ablehnung standardmäßig einen 503 Fehlercode sendet, setzen wir den Statuscode explizit auf 429 - so wie es eigentlich im RFC-6585 definiert ist.
Im binär-Format benötigen die IP-Adressen 4 Bytes für IPv4 oder 16 Bytes für IPv6-Adressen. Laut Dokumentation, wird pro IP-Adresse immer ein s.g. State in der Zone angelegt (IP-Adresse, Zähler, Zeitstempel usw.), das 64 Bytes auf einer 32-bit- bzw. 128 Bytes auf einer 64-bit-Maschine benötigt. Das bedeutet also, daß unsere 10 MB Zone maximal 160 000 IPv4 oder 80 000 IPv6-States merken kann. Falls also dieser Speicherraum komplett belegt ist, können keine weiteren Anfragen mehr verarbeitet (gezählt) werden und sie werden mit einem, in unserem Fall, 429 Fehler abgelehnt.
Im zweiten Schritt wird die Zone “search” für einen oder mehrer Pfade mittels limit_req aktiviert werden. Dazu muss lediglich folgende Regel im Knoten location definiert werden:
server {
[...]
location /search/ {
limit_req zone=search;
[...]
}
}
Hier sollte man aufpassen, falls man die Limitierung für eine Webseite setzt. Da der Browser beim Öffnen einer Seite mehrere Anfragen an den Server schickt (css, js, Bilder), werden viele davon scheitern und die Seite sieht dann entsprechend aus...
Nun sollte man die Konfiguration testen. Es gibt viele Möglichkeiten die gesetzte Anfragelimitierung zu testen. Scripting, Jmeter oder einfach F5 im Browser. Eine noch bessere Methode ist das Stresstest-Tool namens ab aus dem Paket apache2-utils in dem auch das sehr bekannte Tool htpasswd liegt.
Das Apache benchmarking tool oder eben kurz ab ist einfach zu bedienen. In unserem Fall müssen nur mehrere Anfragen möglichst schnell versendet werden. Dazu werden 5 Anfragen (-n5) mit fünf Threads (-c5) wie folgt abgefeuert:
root@host:~# sudo apt-get install apache2-utils
[...]
root@host:~# ab -c5 -n5 http://*******/search/
[...]
Concurrency Level: 5
Time taken for tests: 0.035 seconds
Complete requests: 5
Failed requests: 4
Non-2xx responses: 4
[...]
Die wichtigste Information steckt in diesen Zeilen:
Complete requests: 5
Failed requests: 4
Von 5 Anfragen wurde tatsächlich 4 abgelehnt.
Statt die Anfragen sofort abzulehnen, gibt es die Möglichkeit diese verzögert abzuarbeiten. Dazu muss die Anweisung wie folgt geändert werden:
server {
[...]
location /search/ {
limit_req zone=search burst=5;
[...]
}
}
Es kam also zusätzlich eine neue Anweisungen burst hinzu. Hätte man diese Anweisung queue benannt, wäre wohl sofort klar was sie tut. Falls also die Anfragen von einer IP-Adresse zu oft ankommen, also mehr als eine pro Sekunde, dann werden diese nicht sofort abgelehnt, sondern kommen erstmal in eine Warteschlange, die in unserem Fall maximal 5 Anfragen aufnehmen kann. Diese Anfragen werden nun so in der Verarbeitung verzögert, daß die definierte Rate von 1r/s erfüllt ist. Solange die Warteschlange voll ist, werden alle weiteren Anfragen abgelehnt. Das bedeutet also, daß man eine Art Bonus von 6 Anfragen hat (die erste Anfrage + 5 die in die Warteschlange kommen). Auch hier kann man Funktionalität dieser Regel sehr leicht überprüfen:
root@host:~# ab -c6 -n6 http://*******/search/
[...]
Concurrency Level: 6
Time taken for tests: 5.030 seconds
Complete requests: 6
Failed requests: 0
[...]
Alle Anfragen werden angenommen. Der Client hat also 6 Anfragen innerhalb einer Sekunde abgeschickt, bekam aber Antworten für 5 davon mit einer Verzögerung von jeweils 1 Sekunde. Der Server hat somit selbst dafür gesorgt, daß die geforderte maximale Anfrage-Rate eingehalten wird. Und das passiert, wenn man 7 Anfragen absendet:
root@host:~# ab -c6 -n6 http://*******/search/
[...]
Concurrency Level: 7
Time taken for tests: 5.031 seconds
Complete requests: 7
Failed requests: 1
[...]
Wie erwartet, hat sich die Gesamtdauer nicht verändert, da die letzte siebte Anfrage promt abgelehnt wurde.
Nun betrachten wir zum Schluß eine andere interessante Zusatzregel. Mit nodelay ist es möglich die eben angesprochene Verzögerung abzuschalten.
server {
[...]
location /search/ {
limit_req zone=search burst=5 nodelay;
[...]
}
}
Das bedeutet, daß die "burst-Anfragen" sofort verarbeitet werden. Man erlaubt somit also in Wirklichkeit 6 Anfragen pro Sekunde. Kommen innerhalb einer Sekunde 7 Anfragen von einer IP-Adresse, werden 6 davon sofort verarbeitet und die 7-te abgelehnt:
root@host:~# ab -c6 -n6 http://*******/search/
[...]
Concurrency Level: 7
Time taken for tests: 0.034 seconds
Complete requests: 7
Failed requests: 1
Non-2xx responses: 1
[...]
Ok, das war jetzt wirklich viel Text für so eine auf den ersten Blick kleine Funktionalität. Die Anfrage-Limitierung ist aber eine ziemlich wichtige und notwendige Funktion eines Proxy-Servers. Falls etwas unklar ist, lohnt es sich einen Blick in die Dokumentation zu werfen: