Zum Inhalt springen

HAProxy als Load-Balancer vor zwei Webservern

Veröffentlicht am

Ein einzelner Webserver ist einfach, bis er ein Update braucht oder ausfällt. Mit zwei Webservern und einem Load-Balancer davor verteilt sich die Last, und fällt einer aus, übernimmt der andere. Ich kann einen aber auch absichtlich herausnehmen, zum Beispiel um das nächste große Update einzuspielen und selbst zu prüfen, bevor wieder Besucher darauf landen. Die merken davon nichts, solange trägt der andere Server alles.

Hier steht, wie ich das mit HAProxy aufgebaut habe: wer TLS macht, wie die Besucher verteilt und an einen Server gebunden werden. Als Probe aufs Exempel spiele ich danach ein Update durch. Das Beispiel sind zwei Webserver web1 und web2, auf web1 soll ein Update. Alles bezieht sich auf HAProxy 3.0.

Der Aufbau

Das hier ist eine Testumgebung, kein Vorschlag für den produktiven Betrieb. HAProxy und die beiden Webserver laufen als drei Container auf einer Maschine, damit sich alles in einer Minute starten und wieder wegwerfen lässt:

YAML
services:
    haproxy:
        image: haproxy:3.0-alpine
        ports:
            - '80:80'
            - '443:443'
        volumes:
            - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
            - ./ssl:/etc/haproxy/ssl:ro

    web1:
        image: nginx:alpine

    web2:
        image: nginx:alpine

HAProxy nimmt HTTPS auf Port 443 an und reicht einfaches HTTP auf Port 80 an web1 und web2 weiter; das Cookie SRV hält jeden Besucher bei einem der beiden

Nur HAProxy hat Ports nach außen. Die beiden Webserver sind nur im Netz von Compose erreichbar, und dort unter ihrem Dienstnamen: web1 und web2. In ./ssl liegt das Zertifikat samt Schlüssel in einer Datei, für den Test reicht ein selbst ausgestelltes.

Ausfallsicher ist das nicht. Fällt die Maschine aus, sind beide Webserver weg, und der HAProxy gleich mit. Im Betrieb stehen die Webserver auf getrennten Maschinen mit festen Adressen. Am Ablauf weiter unten ändert das nichts, die Befehle sind dieselben.

Dazu die haproxy.cfg:

global
    # Schnittstelle für Befehle im Betrieb. Hört nur im Container selbst,
    # von außen kommt niemand dran.
    stats socket [email protected]:9999 level admin

defaults
    mode http
    # Ist der Server aus dem Cookie nicht erreichbar, geht die Anfrage an einen anderen.
    option redispatch
    retries 2
    timeout connect 3s
    timeout client 1m
    timeout server 1m
    # Gilt für jede server-Zeile weiter unten:
    #   init-addr last,libc,none  HAProxy startet auch, wenn ein Webserver noch nicht läuft
    #                             und sein Name sich deshalb nicht auflösen lässt
    #   resolvers docker          Namen später über den DNS von Docker nachschlagen
    #   check                     regelmäßig prüfen, ob der Server antwortet
    default-server init-addr last,libc,none resolvers docker check

# Der DNS, den Docker in jedem Container bereitstellt. Er kennt die Dienste aus der
# docker-compose.yml unter ihrem Namen. Startet ein Container neu und bekommt eine
# andere Adresse, merkt HAProxy das nach spätestens 5 Sekunden.
resolvers docker
    nameserver dns 127.0.0.11:53
    hold valid 5s

# Port 80 nimmt nur an, um auf https umzuleiten.
frontend http
    bind :80
    http-request redirect scheme https code 301

# Hier endet TLS. Das Zertifikat liegt nur am HAProxy.
frontend https
    bind :443 ssl crt /etc/haproxy/ssl/example.pem alpn h2,http/1.1
    # Adresse des Besuchers als X-Forwarded-For an den Webserver weitergeben
    option forwardfor
    # dem Webserver sagen, dass der Besucher über https kam
    http-request set-header X-Forwarded-Proto https
    default_backend webservers

backend webservers
    # Neue Besucher gehen an den Server mit den wenigsten offenen Verbindungen.
    balance leastconn
    # Cookie SRV klebt den Besucher an seinen Server. Es verfällt nach 30 Minuten
    # ohne Aufruf, spätestens nach 12 Stunden.
    cookie SRV insert indirect nocache maxidle 30m maxlife 12h
    # Hintertür zum Testen: Cookie SRVTEST=<Servername> führt auf genau diesen Server,
    # auch wenn er gesperrt ist.
    force-persist if { req.cook(SRVTEST) -m found }
    use-server %[req.cook(SRVTEST)] if { req.cook(SRVTEST) -m found }
    # server <Name> <Dienst aus docker-compose.yml>:<Port> cookie <Wert im Cookie SRV>
    # Die Webserver hören nur auf Port 80, ohne TLS.
    server web1 web1:80 cookie web1
    server web2 web2:80 cookie web2

Drei Stellen davon tragen den Update-Ablauf:

  • stats socket öffnet die Schnittstelle, über die man HAProxy im Betrieb Befehle gibt.
  • cookie SRV insert klebt jeden Besucher an seinen Server. Beim ersten Aufruf setzt HAProxy ein Cookie SRV=web1 oder SRV=web2, danach landet der Besucher immer wieder dort. Nötig ist das nicht, wenn die Sitzungen an einer gemeinsamen Stelle liegen (siehe unten). Ich mache es, damit alle Anfragen eines Besuchers im Log desselben Servers stehen.
  • force-persist und use-server sind die Hintertür für mich selbst. Dazu weiter unten.

Die Zeilen default-server ... init-addr ... resolvers docker und der Block resolvers docker sind Zugeständnisse an die Testumgebung. Container bekommen ihre Adresse erst beim Start, und nach einem Neustart kann es eine andere sein. Ohne init-addr ... none bricht HAProxy beim Start ab, sobald einer der Webserver gerade nicht läuft. Ohne resolvers merkt er sich die Adresse vom Start und schickt nach einem Neustart des Webserver-Containers ins Leere. Mit beidem startet HAProxy auch ohne web1 und findet ihn von allein wieder, wenn er zurückkommt.

Stehen die Webserver auf eigenen Maschinen mit fester Adresse, fällt das alles weg. Dann steht dort schlicht server web1 10.0.0.11:80 check cookie web1.

Eine Voraussetzung liegt außerhalb von HAProxy: Die Sitzungen der Besucher müssen an einer gemeinsamen Stelle liegen, zum Beispiel in Redis oder in der Datenbank. Liegen sie auf der Platte des einzelnen Webservers, ist jeder ausgeloggt, den man auf den anderen Server schiebt.

TLS endet am HAProxy

Im Aufbau oben spricht nur HAProxy HTTPS. Das Zertifikat liegt an einer Stelle, in der bind-Zeile des Frontends. Die Webserver dahinter hören auf Port 80 und wissen von TLS nichts:

server web1 web1:80 cookie web1

Das spart Arbeit bei jedem Update. Ein Webserver ohne Zertifikat, ohne SSL-Modul und ohne 443-VirtualHost hat weniger, was nach einem Update anders sein kann. Läuft das Zertifikat ab, tausche ich eine Datei am HAProxy und nicht eine je Server.

Der Haken: Die Anwendung sieht nur noch HTTP. Fragt sie den Webserver, ob die Anfrage verschlüsselt war, lautet die Antwort immer nein. Deshalb setzt der HAProxy http-request set-header X-Forwarded-Proto https. Diesen Header muss die Anwendung an zwei Stellen auswerten:

  • Umleitung auf HTTPS. Leitet der Webserver selbst alles von HTTP auf HTTPS um, entsteht eine Schleife, denn bei ihm kommt jede Anfrage als HTTP an. Die Umleitung macht hier das Frontend http im HAProxy. Bleibt sie zusätzlich im Webserver, darf sie nur greifen, wenn X-Forwarded-Proto: https fehlt.
  • Cookies und Links. Ob das Sitzungs-Cookie das Merkmal secure bekommt und ob erzeugte Links mit https:// beginnen, hängt am selben Header.

Der Verkehr zwischen HAProxy und Webserver ist damit unverschlüsselt. In einem eigenen, abgeschlossenen Netz ist das üblich. Führt der Weg über fremde Leitungen, gehört auch dort TLS hin (server web1 web1:443 ssl verify required ca-file ...).

Die IP-Adresse des Besuchers weitergeben

Mit einem Proxy davor kommt beim Webserver jede Anfrage vom HAProxy. Logs, Sperren und Limits nach IP sehen dann nur noch dessen Adresse. option forwardfor im Frontend schreibt die Adresse des Besuchers deshalb in den Header X-Forwarded-For.

Die Anwendung muss den Header nicht selbst lesen. Beim Apache erledigt das mod_remoteip:

Apache
RemoteIPHeader X-Forwarded-For
# Nur Anfragen aus diesem Netz dürfen die Adresse per Header vorgeben.
# Hier steht das Netz, aus dem der HAProxy kommt.
RemoteIPInternalProxy 172.16.0.0/12

Danach steht in $_SERVER['REMOTE_ADDR'] wieder die Adresse des Besuchers, und der Code dahinter bleibt, wie er war. Im Access-Log muss dafür %a statt %h im LogFormat stehen, sonst loggt Apache weiter den HAProxy. Bei nginx heißt das Gegenstück set_real_ip_from und real_ip_header.

Dem Header darf der Webserver nur trauen, wenn die Anfrage vom HAProxy kommt. Sind die Webserver auch direkt erreichbar, kann sich sonst jeder eine fremde Adresse in den Header schreiben. Am einfachsten nehmen sie auf Port 80 nur Verbindungen vom HAProxy an.

Befehle an HAProxy schicken

Ein eigenes Programm dafür gibt es nicht. Das „CLI“ ist der Socket aus der global-Sektion, und man schickt Text hinein. Weil er nur im Container hört, gehe ich zuerst dort hinein. nc ist im Image haproxy:3.0-alpine schon dabei:

Bash
docker compose exec haproxy sh
Bash
# scur = Verbindungen, die gerade offen sind, status = Zustand des Servers
echo "show stat" | nc 127.0.0.1 9999 | cut -d, -f1,2,5,18,19
# Listet alle Befehle:
echo "help" | nc 127.0.0.1 9999
# pxname,svname,scur,status,weight
https,FRONTEND,1,OPEN,
webservers,web1,0,UP,1
webservers,web2,0,UP,1
webservers,BACKEND,0,UP,2

Alle weiteren Befehle in diesem Beitrag laufen in dieser Shell.

Der Socket hört hier nur auf 127.0.0.1, und das sollte so bleiben. Wer ihn erreicht, kann Server abschalten. Üblich ist auch ein Unix-Socket (stats socket /run/haproxy/admin.sock), den man mit socat anspricht. Den habe ich in diesem Aufbau nicht benutzt.

Ein Update ohne Ausfall

Der Aufbau steht. Jetzt soll web1 ein Update bekommen, während web2 alle Besucher trägt.

Schritt 1: Den Server leer machen

Es gibt zwei Zustände dafür, und der Unterschied ist wichtig.

Bash
# drain: keine neuen Besucher mehr. Wer schon das Cookie SRV=web1 hat, bleibt auf web1.
echo "set server webservers/web1 state drain" | nc 127.0.0.1 9999

Genau darüber bin ich gestolpert. Ich hatte den Server auf drain gestellt, die Seite neu geladen und war immer noch auf web1. Kein Fehler, mein Browser hatte das Cookie.

Bash
# maint: niemand mehr, auch nicht mit Cookie. Wegen option redispatch landen diese
# Besucher auf web2 und bekommen dort ein neues Cookie.
echo "set server webservers/web1 state maint" | nc 127.0.0.1 9999
Zustand Neue Besucher Besucher mit Cookie für diesen Server
ready ja ja
drain nein ja
maint nein nein, sie wandern auf den anderen

Für ein Update nehme ich direkt maint. drain lohnt sich, wenn die Sitzungen am einzelnen Server hängen und man die Leute dort in Ruhe auslaufen lassen will. Mit gemeinsamen Sitzungen gibt es dafür keinen Grund.

Anfragen, die gerade laufen, bricht maint nicht ab. Sie laufen zu Ende, nur neue kommen nicht mehr an. Vor dem Update warte ich deshalb, bis scur bei web1 auf 0 steht:

webservers,web1,0,MAINT,1
webservers,web2,3,UP,1

Bei beiden Befehlen antwortet HAProxy nur mit einer Leerzeile. Keine Ausgabe heißt, es hat geklappt.

Schritt 2: Das Update

Jetzt gehört web1 mir. Paket einspielen, Dienst neu starten, was auch immer ansteht. Von außen kommt nichts mehr an.

Schritt 3: Selbst testen, bevor alle wieder draufkommen

Der Server ist aktualisiert, aber ob er läuft, weiß ich noch nicht. Stelle ich ihn jetzt auf ready, finden es die Besucher für mich heraus. Ich will vorher selbst drauf, als Einziger.

Dafür sind die beiden Zeilen im Backend da:

# Gilt auch, wenn der Server gerade auf maint steht.
force-persist if { req.cook(SRVTEST) -m found }
# Schickt die Anfrage auf den Server, dessen Name im Cookie SRVTEST steht.
use-server %[req.cook(SRVTEST)] if { req.cook(SRVTEST) -m found }

Zusammen ergibt das ein Cookie, mit dem ich mir den Server aussuche, egal in welchem Zustand er ist.

In der Konsole des Browsers:

JavaScript
document.cookie = 'SRVTEST=web1; path=/; secure';
location.reload();

So verhält es sich, solange web1 auf maint steht:

Cookie Landet auf
keins web2
SRV=web1 web2
SRVTEST=web1 web1
SRV=web2 und SRVTEST=web1 web1
SRVTEST=gibtsnicht web2

Der Wert des Cookies ist der Servername aus der Konfiguration. Deshalb braucht ein dritter Server keine eigene Regel, SRVTEST=web3 geht dann von allein. Ein Name, den es nicht gibt, wird ignoriert, und die Anfrage wird normal verteilt.

Wenn ich fertig bin, lösche ich das Cookie wieder:

JavaScript
document.cookie = 'SRVTEST=; path=/; max-age=0';
location.reload();

Auf einem Server, der im Internet hängt, ist ein Cookie namens SRVTEST zu leicht zu erraten. Wer es setzt, kommt auf einen Server mitten im Update. Entweder bekommt das Cookie einen Namen, den niemand rät, oder die Regel gilt zusätzlich nur für interne Adressen, etwa mit { src 10.0.0.0/8 } als zweiter Bedingung.

Schritt 4: Wieder freischalten

Bash
echo "set server webservers/web1 state ready" | nc 127.0.0.1 9999

Danach passiert weniger, als man denkt. Alle Besucher haben inzwischen ein Cookie für web2 und bleiben dort. Auf web1 landen nur neue Besucher und solche, deren Cookie abgelaufen ist. Der Server füllt sich also von allein nach und nach.

Und wenn das Update doch kaputt ist:

Bash
echo "set server webservers/web1 state maint" | nc 127.0.0.1 9999

Damit sind sofort alle wieder auf web2. Das ist derselbe Befehl wie in Schritt 1, und genau das macht den Ablauf so angenehm: Es gibt nur einen Schalter, und er geht in beide Richtungen.

Warum leastconn?

balance legt fest, wie HAProxy einen Besucher ohne Cookie verteilt. Hier steht leastconn: Die Anfrage geht an den Server, der gerade die wenigsten offenen Verbindungen hat.

Der Grund ist der Moment, in dem ein Server zurückkommt. Er hat null Verbindungen, der andere trägt alles. Mit leastconn bekommt der leere Server die neuen Verbindungen, bis beide wieder ungefähr gleich viel zu tun haben. roundrobin würde stur abwechseln. Der Server, der die ganze Zeit allein war, bliebe voll, und der neue bekäme nur die Hälfte von dem, was neu dazukommt.

Zwei Einschränkungen dazu:

  • leastconn zählt Verbindungen, nicht Besucher. Wer ein Cookie hat, bleibt, wo er ist. Das Auffüllen betrifft nur die, die neu verteilt werden.
  • Ein Server, der gerade erst gestartet ist, bekommt den Schwung auf einmal. Dagegen gibt es slowstart 30s an der server-Zeile, das die Last über die angegebene Zeit hochfährt.

Der Preis: Mit leastconn kann ich nicht verlässlich sagen „dieser Server bekommt 10 %“. Gewichte lassen sich zwar setzen (set server webservers/web1 weight 10), sie wirken aber nur, wenn viele Verbindungen gleichzeitig offen sind. Ohne Last hat leastconn bei mir trotz unterschiedlicher Gewichte einfach abwechselnd verteilt. Wer ein Update erst an einem kleinen Teil der Besucher ausprobieren will, braucht roundrobin, das hält die Gewichte genau ein.

Mir fehlt das nicht. Vorab testen kann ich mit dem Cookie, und nach dem Freischalten kommen die Besucher ohnehin nur nach und nach.

Was es sonst noch gibt

Kurz, was an der Stelle von leastconn stehen kann:

Einstellung Wie verteilt wird Wofür
roundrobin Reihum, nach Gewicht. Gewichte lassen sich im Betrieb ändern Kurze, gleichartige Anfragen, Verteilung in Prozent
static-rr Wie roundrobin, aber Gewichte gelten erst nach einem Neustart Selten nötig, etwas weniger Rechenaufwand
leastconn An den Server mit den wenigsten offenen Verbindungen Lange oder ungleich lange Anfragen
first Immer der erste Server, bis sein maxconn voll ist, dann der nächste Server bei wenig Last abschalten können
source Nach der IP des Besuchers, dieselbe IP landet auf demselben Server Bindung an einen Server ohne Cookie
uri Nach der aufgerufenen Adresse, dieselbe Adresse immer auf denselben Server Caches hinter dem Load-Balancer
hdr(name) Nach dem Wert eines Headers Verteilung nach Kunde oder Mandant
random Zufällig, nach Gewicht Viele Server, die oft dazukommen und wegfallen

Solange ein Cookie die Besucher festhält, entscheidet die Einstellung ohnehin nur beim ersten Aufruf.

Worauf du achten musst

Ein Neustart vergisst alles. Was über den Socket gesetzt wurde, steht nicht in der Konfiguration. Nach einem Neustart von HAProxy ist ein Server auf maint wieder ready. Mir ist das passiert, als ich während des Tests eine Zeile in der Konfiguration geändert habe. Wer das mitten im Update tut, schaltet den halbfertigen Server für alle frei. HAProxy kann die Zustände in eine Datei sichern und beim Start wieder laden (server-state-file).

drain ist nicht leer. Wer nur drain setzt und dann das Update einspielt, trifft alle, die noch ein Cookie für diesen Server haben.

Das eigene Cookie führt in die Irre. Solange SRVTEST gesetzt ist, sehe ich immer den einen Server, egal was ich am Socket umstelle. Nach dem Test also löschen, sonst prüft man stundenlang etwas anderes als die Besucher sehen.

Der Servername ist nicht der Hostname. In den Befehlen steht webservers/web1, also Backend und der Name aus der server-Zeile. Nicht die Adresse und nicht der Name der Maschine.

Was mit zwei Servern sonst noch kommt

Der Load-Balancer ist der kleinere Teil. Sobald dieselbe Anwendung zweimal läuft, tauchen ein paar Fragen auf, die HAProxy nicht beantwortet:

  • Cronjobs. Läuft derselbe Job auf beiden Servern, läuft er doppelt. Läuft er nur auf einem, steht er still, solange dieser Server im Update ist.
  • Datenbank-Migrationen. Während des Updates arbeiten alter und neuer Code gegen dieselbe Datenbank. Eine Schemaänderung muss mit beiden Versionen funktionieren.
  • Dateien. Ein Upload landet auf einem Server, die nächste Anfrage auf dem anderen. Hochgeladenes gehört auf gemeinsamen Speicher, genauso wie die Sitzungen.
  • Reserve. Während des Updates trägt ein Server alles. Reicht er nur für die Hälfte, erzeugt das Update selbst den Ausfall.
  • Der HAProxy selbst. Er ist jetzt die eine Stelle, an der alles hängt. Üblich sind zwei davon mit einer gemeinsamen Adresse, die bei einem Ausfall wandert.

Der Ablauf in vier Zeilen

  1. set server webservers/web1 state maint, warten bis scur auf 0 steht.
  2. Update auf web1 einspielen.
  3. Mit SRVTEST=web1 selbst prüfen, Cookie wieder löschen.
  4. set server webservers/web1 state ready. Geht etwas schief, zurück zu Schritt 1.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.