Quando abbiamo cercato un proxy inverso che fungesse da punto di ingresso per la nostra infrastruttura del server, abbiamo scoperto l’eccellente Traefik. Naturalmente, volevamo il bilanciamento del carico e, ovviamente, volevamo assegnare pesi; quindi, abbiamo sfogliato la documentazione e rapidamente trovato ciò che stavamo cercando. Traefik supporta il bilanciamento del carico nella forma di Round Robin Ponderato (WRR), che dirige il traffico web in cicli attraverso i servizi disponibili. Ciò sembrava proprio ciò che volevamo. Tuttavia, come abbiamo scoperto, non era del tutto così. Il nostro viaggio inizia qui.
Aggiornamento: Un giorno dopo aver proposto la nostra soluzione alternativa nel repository GitHub di Traefik, il team di Traefik ha iniziato a lavorare sulla funzionalità. Sarà finalmente disponibile in Traefik v3.0.
TL;DR, potresti voler saltare direttamente alla soluzione.
Servizi contro Server
C’era qualcosa che avevamo trascurato: un piccolo accenno nella documentazione che il bilanciamento del carico pesato è disponibile solo per i servizi, non per i server.
Questa strategia è disponibile solo per bilanciare il carico tra servizi e non tra server.
Inizialmente, dovevamo imparare a configurare Traefik correttamente e non avevamo idea di cosa significassero servizi e server nel contesto di Traefik. Adesso che lo sappiamo, volevamo fornire un singolo servizio su più server come segue:
services:
my_service:
loadBalancer:
servers:
- url: "https://server1:1234/"
- url: "https://server2:1234/"
- url: ...
Poiché ogni server ha un diverso livello di potenza, volevamo anche pesare ogni server. Tuttavia, non è possibile assegnare pesi ai server in questo setup, e c’è anche un problema aperto su GitHub in corso dal 2019.
Solo Traefik v1 aveva una proprietà di peso, ma non volevamo affidarci a software legacy.
Idee
Abbiamo avuto diverse idee su come superare questa funzionalità mancante.
Idea 1: Abusare dei controlli di salute per simulare server inefficienti
L’idea era quella di mimare un server inefficiente quando raggiungeva la sua capacità massima. In questo modo, Traefik avrebbe smesso di dirigere il traffico verso il server una volta che non aveva più slot liberi.
# 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>
Ogni volta che la coda dei lavori superava la capacità di calcolo del server, sarebbe stato segnalato come inefficiente a Traefik nel giro di al massimo otto secondi. Traefik avrebbe quindi tolto il server dalla rotazione e lasciato servire le richieste ad altri server. Abbiamo testato questa soluzione e, come è emerso, ha causato problemi.
In primo luogo, la disconnessione del server è lenta. Anche quando sovraccaricato, i server ricevono ancora le richieste fino a quando il controllo di salute non segnala una situazione critica. Otto secondi possono essere un tempo molto lungo e ridurre l’intervallo non era un’opzione per mantenere la carico del server gestibile.
In secondo luogo, e più sorprendentemente, abbiamo incontrato errori NS_ERROR_NET_PARTIAL_TRANSFER per alcune richieste. Non abbiamo indagato ulteriormente, ma crediamo che ciò sia accaduto a causa di server bloccati tra richieste in esecuzione prolungata e segnalati come non disponibili, causando la rottura della connessione client.
In terzo luogo, il nostro servizio è statoful. Ciò significa che utilizziamo sessioni sticky, garantendo che ogni cliente comunichi solo con un server dedicato assegnato all’inizio della sessione. Se uno di questi server diventa non disponibile durante una sessione, pone un problema che pensavamo di poter risolvere. Non abbiamo potuto farlo. Abbiamo abbandonato l’Idea 1.
Idea 2: Manipolazione della Sessione
Quando stabiliamo una sessione, utilizziamo JavaScript fetch() con credenziali incluse:
await fetch('https://edge_server/', {credentials: 'include'})
In questo modo, Traefik assegna un nuovo cookie di sessione al cliente con l’intestazione Set-Cookie sulla richiesta iniziale fetch.
# Example Traefik configuration with sticky sessions enabled
services:
my_service:
loadBalancer:
sticky:
cookie:
name: session_name
httpOnly: true
Se omettiamo le credenziali nella richiesta, Traefik assegnerà una nuova sessione ad ogni nuova richiesta utilizzando la procedura Round Robin. L’idea 2 era quella di permettere al client di raccogliere nuove sessioni fino a quando non trova un server con slot liberi.
Abbiamo dovuto solo definire un nuovo endpoint di dispatch FastAPI che informa il client se il server dietro la sessione corrente ha slot liberi, oltre a un metodo di retry fetch in JavaScript per trovare il server giusto. Per l’endpoint, potevamo riutilizzare il metodo health() sopra menzionato. Per il metodo di retry fetch, ovviamente volevamo anche gestire il caso in cui tutti i server fossero occupati. Abbiamo escogitato questo:
<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>
Ora, prima di ogni altra chiamata a fetch(), abbiamo chiamato il nostro endpoint di dispatch senza credenziali:
await fetch_retry('https://edge_server/dispatch');
E infatti, abbiamo ricevuto un nuovo header Set-Cookie per ogni iterazione del ciclo.
Purtroppo, il cookie di sessione esistente non viene aggiornato con la chiamata fetch_retry(), poiché ciò accade solo quando vengono incluse le credenziali, almeno con Firefox. Abbiamo quindi provato a includerle e cancellare la storage di sessione quando abbiamo una sessione per un server “cattivo”. Sfortunatamente, sessionStorage.clear() non cancella il cookie delle credenziali, anche se è anch’esso un cookie di sessione. Ciò è vero anche quando si imposta httpOnly su false (anche se ciò non è raccomandato per motivi di sicurezza).
Così, abbiamo dovuto dormire un’altra notte prima di venire a capo di un’altra idea.
L’Idea Finale: Fingere I Server
L’idea finale si basava sulla domanda: Come gestisce Traefik gli URL dei server?
Se potessimo puntare più URL che appaiono diversi in Traefik verso lo stesso server, potremmo emulare il peso del server fingendo multiple URL di server che tutti puntano allo stesso server. E infatti, questo ha funzionato come ci aspettavamo. Abbiamo solo dovuto aggiungere un percorso non esistente, come ad esempio “/1/” o “/2/”, agli URL, e Traefik li avrebbe gestiti separatamente ma instradati senza errori e senza allegare il percorso al server giusto.
services:
my_service:
loadBalancer:
servers:
- url: "https://server_A:1234/1/"
- url: "https://server_A:1234/2/"
- url: "https://server_B:1234/"
Abbiamo solo dovuto assicurare di creare il numero corretto di URL di server fittizi ridondanti corrispondenti alle rispettive capacità del server. Quindi, ad esempio, se avevamo un server A che era due volte più capace del server B, allora avremmo dovuto aggiungere due URL per il server A. In questo modo, durante la selezione circolare, il server A avrebbe ricevuto due volte il numero di richieste rispetto al server B.