Als wir nach einem Reverse-Proxy suchten, der als Einstiegspunkt für unsere Server-Infrastruktur dient, stießen wir auf das hervorragende Traefik. Natürlich wollten wir Lastverteilung und natürlich wollten wir Gewichte zuweisen; daher überflogen wir die Dokumentation und fanden schnell, wonach wir suchten. Traefik unterstützt Lastverteilung in Form des gewichteten Round-Robin-Verfahren (WRR), das den Web-Verkehr in Zyklen durch die verfügbaren Dienste leitet. Dies klang genau nach dem, was wir suchten. Doch wie wir herausfanden, war es nicht ganz so wie gedacht. Unser Weg beginnt hier.
Aktualisierung: Einen Tag nachdem wir unseren Workaround in ihrem GitHub-Repository vorgeschlagen hatten, begann das Traefik-Team mit der Arbeit an diesem Feature. Es wird schließlich in Traefik v3.0 landen.
Kurzfassung: Wenn Sie direkt zur Lösung springen möchten, klicken Sie bitte hier.
Dienste versus Server
Wir haben etwas übersehen: einen kleinen Hinweis in der Dokumentation, dass das gewichtete Rund-Robin-Verfahren (WRR) nur für Dienste und nicht für Server verfügbar ist.
Diese Strategie ist nur zur Lastverteilung zwischen Diensten und nicht zwischen Servern verfügbar.
Zunächst mussten wir lernen, wie man Traefik korrekt konfiguriert, und hatten keine Ahnung, was Dienste und Server im Kontext von Traefik bedeuteten. Jetzt, da wir es wissen, wollten wir einen einzelnen Dienst auf mehreren Servern wie folgt bereitstellen:
services:
my_service:
loadBalancer:
servers:
- url: "https://server1:1234/"
- url: "https://server2:1234/"
- url: ...
Da jeder Server eine unterschiedliche Leistung hat, wollten wir auch jedem Server ein Gewicht zuweisen. Doch ist es in diesem Setup nicht möglich, den Servern Gewichte zuzuweisen, und es gibt sogar ein offenes GitHub-Issue dazu seit 2019.
Nur Traefik v1 hatte eine Gewichtseigenschaft, aber wir wollten nicht auf veraltete Software setzen.
Ideen
Wir hatten unterschiedliche Ideen, wie wir dieses fehlende Feature überwinden konnten.
Idee 1: Nutzen von Gesundheitschecks zum Simulieren schlechter Server
Die Idee bestand darin, einen Server als gesundheitlich beeinträchtigt zu melden, wenn er seine maximale Auslastung erreicht hatte. Auf diese Weise würde Traefik den Server aus dem Round-Robin-Verfahren entfernen, sobald keine freien Slots mehr verfügbar waren.
# Example health check configuration
http:
services:
my-service:
loadBalancer:
healthCheck:
path: /health
interval: "5s"
timeout: "3s"
<pre class="wp-block-syntaxhighlighter-code"># Example FastAPI endpoint
@app.get('/health')
def health():
if COMPUTE_CAPABILITY / job_q.qsize() < 1:
return Response('Too Many Requests', status_code=429)
return Response('OK', status_code=200)</pre>
Wenn die Job-Warteschlange die Rechenleistung des Servers übertraf, würde sie innerhalb von höchstens acht Sekunden als gesundheitlich beeinträchtigt gemeldet werden. Traefik würde den Server dann aus dem Round-Robin-Verfahren entfernen und andere Server ermöglichen, die Anfrage zu bearbeiten. Wir haben diese Lösung getestet und festgestellt, dass sie Probleme verursachte.
Erstens dauerte das Ausscheiden des Servers zu lange. Selbst wenn ein Server überlastet war, erhielt er noch Anforderungen, bis der Gesundheitscheck berichtete, dass er nicht mehr verfügbar war. Acht Sekunden konnten sehr lang sein, und eine Verringerung des Intervalls war keine Option, um die Serverauslastung zu reduzieren.
Zweitens und überraschenderweise traten NS_ERROR_NET_PARTIAL_TRANSFER-Fehler bei einigen Anforderungen auf. Wir haben dies nicht weiter untersucht, aber wir glauben, dass dies dadurch verursacht wurde, dass Server zwischen der Bearbeitung langlaufender Anforderungen und der Meldung als ungesund gefangen waren, was den Client-Verbindungsaufbau unterbrach. Ein solches Infrastrukturkonzept ist unerwünscht.
Drittens ist unser Dienst Zustands-behaftet. Dies bedeutet, dass wir sogenannte Sticky-Sitzungen verwenden, um sicherzustellen, dass jeder Client nur mit einem bestimmten Server kommuniziert, der am Anfang der Sitzung zugewiesen wird. Wenn einer dieser Server während einer Sitzung nicht mehr verfügbar ist, stellt dies ein Problem dar, das wir lösen wollten. Wir konnten es jedoch nicht lösen. Deshalb haben wir Idee 1 fallen gelassen.
Idee 2: Manipulation der Sitzung
Beim Einrichten einer Sitzung verwenden wir JavaScript-Fetch mit included Anmeldedaten:
await fetch('https://edge_server/', {credentials: 'include'})
Auf diese Weise weist Traefik dem Client ein neues Sitzungs-Cookie zu, indem es den Set-Cookie-Header in der initialen Fetch-Anforderung sendet.
# Example Traefik configuration with sticky sessions enabled
services:
my_service:
loadBalancer:
sticky:
cookie:
name: session_name
httpOnly: true
Wenn wir die Anmeldedaten in der Anfrage weglassen, würde Traefik bei jeder neuen Anfrage eine neue Sitzung mithilfe des Rundrufverfahrens zuweisen. Idee 2 bestand darin, dem Client zu ermöglichen, neue Sitzungen zu sammeln, bis er einen Server gefunden hatte, der noch freie Plätze hatte.
Wir mussten lediglich einen neuen FastAPI-Dispatch-Endpunkt definieren, der dem Client mitteilt, ob der Server hinter der aktuellen Sitzung noch freie Plätze hat. Außerdem mussten wir eine Fetch-Wiederholmethode in JavaScript erstellen, um den richtigen Server zu finden. Für den Endpunkt konnten wir die health()-Methode von oben wiederverwenden. Für die Wiederholmethode wollten wir selbstverständlich auch den Fall behandeln, wenn alle Server ausgelastet waren. Wir haben folgendes entwickelt:
<pre class="wp-block-syntaxhighlighter-code">async function fetch_retry(...args) {
for (let i = 0; i < 300; i++) {
for (let s = 0; s < N_SERVERS; s++) {
const response = await fetch(...args);
if (response.status === 503) {
// Waiting for a free slot...
if (s === (N_SERVERS - 1)) {
await new Promise(r => setTimeout(r, 20000));
}
continue;
}
return response;
}
}
return response;
}
</pre>
Bevor wir irgendwelche anderen Aufrufe an fetch() durchführten, rufen wir unseren Dispatch-Endpunkt ohne Anmeldedaten auf:
await fetch_retry('https://edge_server/dispatch');
Und tatsächlich erhielten wir für jede Schleifeniteration einen neuen Set-Cookie-Header.
Leider wird das vorhandene Sitzungs-Cookie nicht aktualisiert, wenn fetch_retry() aufgerufen wird, da dies nur passiert, wenn Anmeldedaten mitgesendet werden, zumindest bei Firefox. Wir haben dann versucht, diese einzuschließen und den Session-Speicher zu leeren, wenn wir eine Sitzung für einen „schlechten“ Server haben. Leider löscht sessionStorage.clear() nicht das Anmeldungs-Cookie, obwohl es auch ein Sitzungs-Cookie ist. Dies gilt sogar, wenn httpOnly auf false gesetzt wird (obwohl dies aus Sicherheitsgründen ohnehin nicht empfohlen wird).
Daher mussten wir eine weitere Nacht schlafen, bevor wir auf eine andere Idee kamen.
Die Endgültige Idee: Server-Imitation
Die endgültige Idee basierte auf der Frage: Wie behandelt Traefik Server-URLs?
Wenn wir mehrere URLs anzeigen könnten, die von Traefik als unterschiedlich erkannt werden, auf denselben Server zu zeigen, könnten wir die Server-Gewichtung nachahmen, indem wir mehrere Server-URLs vortäuschen, die alle auf denselben Server verweisen. Und tatsächlich funktionierte dies wie erwartet. Wir mussten nur einen nicht existierenden Pfad, wie zum Beispiel „/1/“ oder „/2/“, zu den URLs hinzufügen, und Traefik würde sie separat behandeln, aber ohne Fehler weiterleiten und ohne den Pfad an den richtigen Server anzuhängen.
services:
my_service:
loadBalancer:
servers:
- url: "https://server_A:1234/1/"
- url: "https://server_A:1234/2/"
- url: "https://server_B:1234/"
Wir mussten nur sicherstellen, dass wir die korrekte Anzahl redundanter falscher Server-URLs entsprechend der jeweiligen Server-Fähigkeit erstellten. Wenn wir also einen Server A hatten, zum Beispiel, der doppelt so leistungsfähig war wie Server B, dann mussten wir zwei URLs für Server A hinzufügen. Auf diese Weise würde während des Round-Robin-Lastausgleichs Server A doppelt so viele Anforderungen erhalten wie Server B.