Health-Check-Cache für HTTP-Backends

Was ist das, wozu brauchen wir das und was ist daran so kompliziert?

Ein Health-Check-Cache soll, wie der Name schon vermuten lässt, Health-Checks cachen. In Wirklichkeit passiert aber noch mehr, was für uns auch sehr wichtig ist.

Unsere Loadbalancer laufen mit HAProxy. Dieser macht regelmäßig Health-Checks gegen die Backends. Das ist auch gut und richtig, denn er muss ja immer wissen, welches Backend tatsächlich up oder down ist, um den Traffic für den User möglichst ohne Unterbrechung oder Fehler zu lenken. Wenn man jetzt, so wie wir, sehr viele Loadbalancer hat und diese auch noch innerhalb sehr kurzer Zeit vervielfachen kann, wird das jedoch ab einem gewissen Punkt zu einem Problem und skaliert einfach nicht mehr, was die Health-Checks angeht.

Stellen wir uns 50 Loadbalancer vor, die alle N Sekunden gegen ein und das selbe Backend prüfen. Dazu kommt der normale Traffic, der nicht aus dem Cache ausgeliefert wird, oder der gerade im Cache expired ist, plus vielleicht noch eine „nicht optimale“ Backendsoftware, die einfach nicht so viele Requests pro Sekunde schafft. Das alles in Kombination ist denkbar ungünstig. Das Ziel eines Caches ist es ja, das Backend zu entlasten und die Auslieferung bestenfalls noch zu beschleunigen. Selbiges können wir auch für Health-Checks tun. Wieso sollen alle 50 Loadbalancer einzeln ihre Health-Checks gegen die Backends fahren? Auch hier kann man das super konsolidieren, indem wir nur für die Health-Checks einen Cache betreiben und der HAProxy gegen den Cache prüft.

Das Backend bzw. die Software können wir uns leider nicht in jedem Fall aussuchen oder so beeinflussen, dass sie für uns ausreichend performt. Also brauchen wir eine Lösung, die wir einfach überall anwenden können, unabhängig vom eigentlichen Backend. Da passt ein solcher Cache sehr gut ins Konzept.

Ist es nicht böse, Health-Checks zu cachen?

Nun, man sollte sich vorab Gedanken dazu machen, wie man das ganze umsetzen möchte. Wenn die Loadbalancer z. B. alle 3s checken, können wir ja auch einfach 3s cachen. Wenn wir nun planen, dass es auch ok ist, wenn wir daraus 10s machen und vielleicht, im schlimmsten Fall, für wenige Sekunden Traffic auf ein gerade down gehendes oder kaputtes Backend geht, dann kann man argumentieren und diskutieren, ob das nicht vertretbar ist. Das Ziel sollte hier also sein, die Health-Checks weiterhin so aussagekräftig wie möglich zu halten, damit der User möglichst wenig negativ beeinflusst wird, jedoch die Requests gegen die Backends auf ein Minimum zu reduzieren, um diese nicht noch zusätzlich zu belasten.

Wieso haben wir überhaupt so viele Loadbalancer, das ist doch gar nicht nötig?

Doch, leider schon! Wir haben unser Setup so vorgesehen, dass wir innerhalb kürzester Zeit jeden Loadbalancer für jedes Backend nutzen können, ohne groß etwas deployen zu müssen, die Config neu zu erstellen oder sonstige Schritte vorzunehmen. Unser Setup ist so äußerst flexibel einsetzbar und die einzigen Kosten sind eben die Health-Checks. Außerdem brauchen wir mehr Loadbalancer, je größer der DDoS-Angriff ist, abhängig natürlich auch von der Art des Angriffs.

Ok, was ist denn nun mit unserem Health-Check-Cache?

Neben den Loadbalancern kommen 3 Health-Check-Caches hinzu. Auf diesen Maschinen betreiben wir einen nginx. Die Config ist relativ klein. nginx erlaubt es uns, einen Header auszuwerten und diesen einfach für die Backend-Zuweisung zu nutzen. Im HAProxy können wir beim Health-Check einstellen, dass er einen Header setzen soll, der für den nginx lesbar ist. In dem Header sind die benötigen Informationen enthalten: das tatsächliche Backend, d.h. Adresse und Port.

http-check send-state

Der Header sieht dann z. B. so aus:

x-haproxy-server-state: UP; address=192.168.70.74; port=80; name=somebackend/s070074; node=n095138; weight=100/200; scur=0/0; qcur=0

In der HAProxy-Konfiguration sieht es dann so aus:

http-check send-state
option httpchk
http-check send meth HEAD uri / ver HTTP/1.1 hdr Host "www.example.com" hdr Accept "*/*" hdr Accept-Encoding "none" hdr User-Agent "HAProxy"

server example 1.2.3.4:80 addr 127.0.0.1 port 62040 inter 10000 fall 2 rise 2

http-check send-state für den o.g. Header
option httpcheck damit ein HTTP Health-Check stattfindet
http-check send wo wir unseren gesamten Check definieren
Und zu guter Letzt dann noch das „addr“ und „port“ in der „server“ Zeile, damit wir dem HAProxy sagen, dass er nicht das Backend selbst sondern unseren Health-Check Cache nutzt, für den Health-Check. Dieser geht nämlich dann via 62040 auf den HAProxy und von dort dann verteilt an einen der 3 Caches.

Im nginx wird dieser dann ausgelesen und die benötigen Informationen in für nginx verwertbare Variablen gespeichert.

map $http_x_haproxy_server_state $backend_server_address {

  "~address=([^;]+)" $1;
}
map $http_x_haproxy_server_state $haproxy_backend_server {

"~name=([^;]+)" $1;
}
map $http_x_haproxy_server_state $haproxy_node {
"~node=([^;]+)" $1;
}
map $http_x_haproxy_server_state $backend_server_port {
        "~port=([^;]+)" $1;
}

Dank dieses Headers müssen wir nicht die Backends statisch in der NGINX-Konfiguration auflisten.

Beispiel anhand von HTTP:

server {
  listen *:80 default;
  server_name           cache;
  
  index  index.html index.htm index.php;
  access_log            /var/log/nginx/cache.access.log health_check_cache;
  error_log             /var/log/nginx/cache.error.log;

  location / {
    proxy_pass            $scheme://$backend_server_address:$backend_server_port;
    proxy_read_timeout    90s;
    proxy_connect_timeout 10s;
    proxy_send_timeout    90s;
    proxy_set_header      Host $http_host;
    proxy_set_header      X-Haproxy-Server-State "";
    proxy_pass_header      Date;
    proxy_pass_header      Server;
    proxy_cache           cache_zone;
    proxy_cache_valid     any 10s;
    proxy_cache_key           "$scheme://$backend_server_address:$backend_server_port $http_host $request";
    proxy_cache_lock         on;
    proxy_cache_convert_head off;
    proxy_cache_lock_timeout 61s;
    proxy_ignore_headers X-Accel-Expires Expires Cache-Control Set-Cookie Vary;
    proxy_ssl_name $ssl_server_name;
  }
}

Die Requests gegen die Backends werden dadurch von zuvor min. ~50 pro 3 Sekunden um ein vielfaches reduziert. Damit ist das Problem aber noch nicht ganz gelöst, denn wir würden eigentlich gerne auf ~3 Requests pro 10 Sekunden kommen. Auch wenn wir nun für 10 Sekunden cachen und alle LBs gegen die 3 Caches checken, kommen am Ende aber noch mehr Requests bei den Backends an. Woran liegt das?

Wenn unser Object im Cache expired ist es so, dass von da an bis zum nächsten validen Object, also der Zeitraum dazwischen, u. U. mehrere Requests direkt zum Backend durchgehen, bis einer von diesen dann den Cache wieder befüllt.

Wenn wir jetzt mal rein theoretisch bleiben und einen ganz unglücklichen Fall vor Augen halten, wo alle 50 gleichzeitig einen Request absetzen, wenn das Object gerade im Cache expired ist, könnten u. U. sogar alle 50 direkt zum Backend durchgehen. Das wollen wir verhindern. Wir können Nginx so konfigurieren, dass er bitte nur einen Request zum Backend lassen soll.

proxy_cache_lock on;

Dazu sollte man die Hinweise in der nginx Doku bzgl. Cache-Key und Lock-Timeout unbedingt beachten! Der Timeout muss natürlich zum Gesamtsetup passen.

Wenn jetzt also identische Requests zum selben Backend sollen, wo bereits einer Request läuft, werden weitere Requests pausiert, bis zu einem konfiguriertem Timeout oder, wenn der Cache vor Ablauf des Timeouts schon befüllt wurde, aus dem Cache die Response generiert. Somit haben wir die Health-Checks gegen die Backends auf max. 3 pro 10 Sekunden reduzieren können, egal wie viele oder wenige Loadbalancer wir haben.

Bei Tests ist dann aufgefallen, dass der nginx bei default HEAD Requests, welche wir in der Regel für unsere Checks verwenden, zu GET Requests umwandelt. Auch das lässt sich glücklicherweise per Option ändern.

proxy_cache_convert_head off;

Damit ist die Geschichte leider aber noch nicht zu Ende. Es gibt ein Problem mit dem nginx, welches wir scheinbar so nicht lösen können. Aus uns unerklärlichen Gründen ist es so, dass wenn das Backend so down ist, dass es z. B. Timeouts gibt, dass die Anzahl der Requests gegen die Backends wieder die 3 pro 10s deutlich übersteigen. Wenn jetzt ein Backend zum Beispiel überlastet ist und Health-Checks in ein Timeout rennen, kann es also nun passieren, dass der nginx das ganze noch schlimmer macht, weil die Requests für die Health-Checks gegen das Backend wieder deutlich vervielfacht werden. Wir haben keine Möglichkeiten gefunden, dies zu verhindern bzw. genau herauszufinden, wo oder was das Problem im nginx ist. Möglicherweise handelt es sich sogar um einen Bug.

Wie geht es weiter?

Da wir sowieso Varnish für unser HTTP-Caching verwenden, würden wir diesen auch sehr gerne an dieser Stelle mit einbeziehen. Nginx dagegen verwenden wir nur sehr wenig.

Der Standard Open-Source Varnish ist so leider nicht ausreichend, in Kombination mit der „dynamic“ VMod jedoch schon.

Für uns bietet der Varnish hier gegenüber dem nginx sehr viele Vorteile. Mit der dynamic VMod können wir das ganze ähnlich simpel wie im nginx umsetzen, haben jedoch noch deutlich mehr Flexibilität, feinere Konfigurationsmöglichkeiten und für uns auch ein einfacheres und deutlich besseres Debugging, u.A. durch varnishlog.

Unser Ziel ist es also final auf max. 3 Requests pro 10 Sekunden zu kommen, egal ob das Backend up oder down ist, egal ob mit oder ohne Timeout. In ersten Tests sah das mit Varnish+dynamic sehr gut aus. Sollte es auch hier zu Problemen kommen, die wir nicht einfach lösen können, werden wir letztlich vermutlich sogar unsere eigene Lösung für dieses Problem entwickeln, die gezielt und ausschließlich für diesen Zweck dient.

(Header-Bild von Lijovklm, unter CC BY-SA)