Bilanciamento del carico NGINX semplificato con Loadcat
Pubblicato: 2022-03-11Le applicazioni Web progettate per essere scalabili orizzontalmente spesso richiedono uno o più nodi di bilanciamento del carico. Il loro scopo principale è distribuire il traffico in entrata tra i server Web disponibili in modo equo. La capacità di aumentare la capacità complessiva di un'applicazione Web semplicemente aumentando il numero di nodi e facendo in modo che i bilanciatori di carico si adattino a questo cambiamento può rivelarsi estremamente utile in produzione.
NGINX è un server Web che offre funzionalità di bilanciamento del carico ad alte prestazioni, tra molte delle sue altre capacità. Alcune di queste funzionalità sono disponibili solo come parte del loro modello di abbonamento, ma la versione gratuita e open source è ancora molto ricca di funzionalità e include le funzionalità di bilanciamento del carico più essenziali pronte all'uso.
In questo tutorial, esploreremo i meccanismi interni di uno strumento sperimentale che ti consente di configurare al volo la tua istanza NGINX per fungere da bilanciamento del carico, astraendo tutti i dettagli essenziali dei file di configurazione NGINX fornendo un web-ordinato interfaccia utente basata. Lo scopo di questo articolo è mostrare quanto sia facile iniziare a creare uno strumento del genere. Vale la pena ricordare che il progetto Loadcat è fortemente ispirato dai NodeBalancers di Linode.
NGINX, Server e Upstream
Uno degli usi più popolari di NGINX è il proxy inverso delle richieste dai client alle applicazioni del server web. Sebbene le applicazioni Web sviluppate in linguaggi di programmazione come Node.js e Go possano essere server Web autosufficienti, disporre di un proxy inverso davanti all'applicazione server effettiva offre numerosi vantaggi. Un blocco "server" per un caso d'uso semplice come questo in un file di configurazione NGINX può assomigliare a questo:
server { listen 80; server_name example.com; location / { proxy_pass http://192.168.0.51:5000; } }
Ciò farebbe in modo che NGINX ascolti sulla porta 80 tutte le richieste che puntano a example.com e le passerebbe ciascuna a un'applicazione del server Web in esecuzione a 192.168.0.51:5000. Potremmo anche utilizzare l'indirizzo IP di loopback 127.0.0.1 qui se il server delle applicazioni Web fosse in esecuzione localmente. Si noti che lo snippet sopra non presenta alcune ovvie modifiche che vengono spesso utilizzate nella configurazione del proxy inverso, ma viene mantenuto in questo modo per brevità.
Ma cosa accadrebbe se volessimo bilanciare tutte le richieste in arrivo tra due istanze dello stesso server di applicazioni Web? È qui che la direttiva “a monte” diventa utile. In NGINX, con la direttiva “upstream”, è possibile definire più nodi di back-end tra i quali NGINX bilancerà tutte le richieste in entrata. Per esempio:
upstream nodes { server 192.168.0.51:5000; server 192.168.0.52:5000; } server { listen 80; server_name example.com; location / { proxy_pass http://nodes; } }
Nota come abbiamo definito un blocco "upstream", chiamato "nodes", composto da due server. Ciascun server è identificato da un indirizzo IP e dal numero di porta su cui è in ascolto. Con questo, NGINX diventa un sistema di bilanciamento del carico nella sua forma più semplice. Per impostazione predefinita, NGINX distribuirà le richieste in arrivo in modo round-robin, dove la prima verrà inviata tramite proxy al primo server, la seconda al secondo server, la terza al primo server e così via.
Tuttavia, NGINX ha molto di più da offrire quando si tratta di bilanciamento del carico. Ti permette di definire i pesi per ogni server, contrassegnarli come temporaneamente non disponibili, scegliere un algoritmo di bilanciamento diverso (es. ce n'è uno che funziona in base all'hash IP del client), ecc. Queste caratteristiche e direttive di configurazione sono tutte ben documentate su nginx.org . Inoltre, NGINX consente di modificare e ricaricare i file di configurazione al volo quasi senza interruzioni.
La configurabilità di NGINX e i semplici file di configurazione rendono davvero facile adattarlo a molte esigenze. E su Internet esiste già una pletora di tutorial che ti insegnano esattamente come configurare NGINX come sistema di bilanciamento del carico.
Loadcat: strumento di configurazione NGINX
C'è qualcosa di affascinante nei programmi che invece di fare qualcosa da soli, configurano altri strumenti per farlo per loro. In realtà non fanno molto altro che forse prendere gli input degli utenti e generare alcuni file. La maggior parte dei vantaggi che ottieni da questi strumenti sono in realtà funzionalità di altri strumenti. Ma sicuramente rendono la vita facile. Durante il tentativo di configurare un bilanciamento del carico per uno dei miei progetti, mi sono chiesto: perché non fare qualcosa di simile per NGINX e le sue capacità di bilanciamento del carico?
È nato Loadcat!
Loadcat, creato con Go, è ancora agli inizi. In questo momento, lo strumento consente di configurare NGINX solo per il bilanciamento del carico e la terminazione SSL. Fornisce una semplice GUI basata sul Web per l'utente. Invece di esaminare le singole caratteristiche dello strumento, diamo un'occhiata a ciò che c'è sotto. Tieni presente, tuttavia, che se a qualcuno piace lavorare con i file di configurazione NGINX a mano, potrebbe trovare poco valore in uno strumento del genere.
Ci sono alcuni motivi alla base della scelta di Go come linguaggio di programmazione per questo. Uno di questi è che Go produce binari compilati. Questo ci consente di creare e distribuire o distribuire Loadcat come binario compilato su server remoti senza preoccuparci di risolvere le dipendenze. Qualcosa che semplifica notevolmente il processo di installazione. Naturalmente, il binario presuppone che NGINX sia già installato e che esista un file di unità systemd per esso.
Nel caso in cui tu non sia un ingegnere Go, non preoccuparti affatto. Go è abbastanza facile e divertente per iniziare. Inoltre, l'implementazione stessa è molto semplice e dovresti essere in grado di seguirla facilmente.
Struttura
Gli strumenti di go build impongono alcune restrizioni su come strutturare la tua applicazione e lascia il resto allo sviluppatore. Nel nostro caso, abbiamo suddiviso le cose in alcuni pacchetti Go in base ai loro scopi:
- cfg: carica, analizza e fornisce valori di configurazione
- cmd/loadcat: pacchetto principale, contiene il punto di ingresso, compila in binario
- dati: contiene "modelli", utilizza un archivio chiave/valore incorporato per la persistenza
- feline: contiene funzionalità di base, ad esempio generazione di file di configurazione, meccanismo di ricarica, ecc.
- ui: contiene modelli, gestori di URL, ecc.
Se osserviamo più da vicino la struttura del pacchetto, specialmente all'interno del pacchetto feline, noteremo che tutto il codice specifico di NGINX è stato mantenuto all'interno di un sottopacchetto feline/nginx. Questo viene fatto in modo da poter mantenere il resto della logica dell'applicazione generica ed estendere il supporto per altri sistemi di bilanciamento del carico (ad es. HAProxy) in futuro.
Punto d'entrata
Partiamo dal pacchetto principale per Loadcat, che si trova all'interno di “cmd/loadcatd”. La funzione principale, punto di ingresso dell'applicazione, fa tre cose.
func main() { fconfig := flag.String("config", "loadcat.conf", "") flag.Parse() cfg.LoadFile(*fconfig) feline.SetBase(filepath.Join(cfg.Current.Core.Dir, "out")) data.OpenDB(filepath.Join(cfg.Current.Core.Dir, "loadcat.db")) defer data.DB.Close() data.InitDB() http.Handle("/api", api.Router) http.Handle("/", ui.Router) go http.ListenAndServe(cfg.Current.Core.Address, nil) // Wait for an “interrupt“ signal (Ctrl+C in most terminals) }
Per semplificare le cose e rendere il codice più facile da leggere, tutto il codice di gestione degli errori è stato rimosso dallo snippet sopra (e anche dagli snippet più avanti in questo articolo).
Come puoi vedere dal codice, stiamo caricando il file di configurazione in base al flag della riga di comando "-config" (che per impostazione predefinita è "loadcat.conf" nella directory corrente). Successivamente, stiamo inizializzando un paio di componenti, vale a dire il pacchetto feline principale e il database. Infine, stiamo avviando un server Web per la GUI basata sul Web.
Configurazione
Il caricamento e l'analisi del file di configurazione è probabilmente la parte più semplice qui. Stiamo usando TOML per codificare le informazioni di configurazione. È disponibile un accurato pacchetto di analisi TOML per Go. Abbiamo bisogno di pochissime informazioni di configurazione dall'utente e nella maggior parte dei casi possiamo determinare valori predefiniti sani per questi valori. La struttura seguente rappresenta la struttura del file di configurazione:
struct { Core struct { Address string Dir string Driver string } Nginx struct { Mode string Systemd struct { Service string } } }
Ed ecco come potrebbe apparire un tipico file "loadcat.conf":
[core] address=":26590" dir="/var/lib/loadcat" driver="nginx" [nginx] mode="systemd" [nginx.systemd] service="nginx.service"
Come possiamo vedere, c'è una somiglianza tra la struttura del file di configurazione con codifica TOML e la struttura mostrata sopra di esso. Il pacchetto di configurazione inizia impostando alcuni sani valori predefiniti per determinati campi dello struct e quindi analizza il file di configurazione su di esso. Nel caso in cui non riesca a trovare un file di configurazione nel percorso specificato, ne crea uno e prima esegue il dump dei valori predefiniti.
func LoadFile(name string) error { f, _ := os.Open(name) if os.IsNotExist(err) { f, _ = os.Create(name) toml.NewEncoder(f).Encode(Current) f.Close() return nil } toml.NewDecoder(f).Decode(&Current) return nil }
Dati e persistenza
Incontra Bolt. Un archivio chiave/valore incorporato scritto in puro Go. Viene fornito come un pacchetto con un'API molto semplice, supporta le transazioni immediatamente ed è incredibilmente veloce.
All'interno dei dati del pacchetto, abbiamo strutture che rappresentano ogni tipo di entità. Ad esempio abbiamo:
type Balancer struct { Id bson.ObjectId Label string Settings BalancerSettings } type Server struct { Id bson.ObjectId BalancerId bson.ObjectId Label string Settings ServerSettings }
... dove un'istanza di Balancer rappresenta un singolo sistema di bilanciamento del carico. Loadcat ti consente di bilanciare efficacemente le richieste per più applicazioni web attraverso una singola istanza di NGINX. Ogni bilanciatore può quindi avere uno o più server dietro, in cui ogni server può essere un nodo back-end separato.
Poiché Bolt è un archivio di valori-chiave e non supporta le query di database avanzate, abbiamo una logica lato applicazione che lo fa per noi. Loadcat non è pensato per configurare migliaia di bilanciatori con migliaia di server in ciascuno di essi, quindi naturalmente questo approccio ingenuo funziona perfettamente. Inoltre, Bolt funziona con chiavi e valori che sono fette di byte, ed è per questo che codifichiamo BSON le strutture prima di memorizzarle in Bolt. L'implementazione di una funzione che recupera un elenco di strutture Balancer dal database è mostrata di seguito:
func ListBalancers() ([]Balancer, error) { bals := []Balancer{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { bal := Balancer{} bson.Unmarshal(v, &bal) bals = append(bals, bal) } return nil }) return bals, nil }
La funzione ListBalancers avvia una transazione di sola lettura, esegue l'iterazione su tutte le chiavi e i valori all'interno del bucket "balancers", decodifica ogni valore in un'istanza di Balancer struct e li restituisce in un array.

Conservare un bilanciatore nel secchio è quasi altrettanto semplice:
func (l *Balancer) Put() error { if !l.Id.Valid() { l.Id = bson.NewObjectId() } if l.Label == "" { l.Label = "Unlabelled" } if l.Settings.Protocol == "https" { // Parse certificate details } else { // Clear fields relevant to HTTPS only, such as SSL options and certificate details } return DB.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) p, err := bson.Marshal(l) if err != nil { return err } return b.Put([]byte(l.Id.Hex()), p) }) }
La funzione Put assegna alcuni valori predefiniti a determinati campi, analizza il certificato SSL allegato nella configurazione HTTPS, avvia una transazione, codifica l'istanza struct e la archivia nel bucket rispetto all'ID del bilanciatore.
Durante l'analisi del certificato SSL, vengono estratte due informazioni utilizzando la codifica/pem del pacchetto standard e archiviate in SSLOptions nel campo Impostazioni : i nomi DNS e l'impronta digitale.
Abbiamo anche una funzione che cerca i server in base al bilanciatore:
func ListServersByBalancer(bal *Balancer) ([]Server, error) { srvs := []Server{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("servers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { srv := Server{} bson.Unmarshal(v, &srv) if srv.BalancerId.Hex() != bal.Id.Hex() { continue } srvs = append(srvs, srv) } return nil }) return srvs, nil }
Questa funzione mostra quanto sia davvero ingenuo il nostro approccio. Qui, stiamo effettivamente leggendo l'intero bucket "server" e filtrando le entità irrilevanti prima di restituire l'array. Ma poi di nuovo, funziona bene e non c'è un vero motivo per cambiarlo.
La funzione Put per i server è molto più semplice di quella di Balancer struct in quanto non richiede tante righe di codice che impostano valori predefiniti e campi calcolati.
Controllo di NGINX
Prima di utilizzare Loadcat, dobbiamo configurare NGINX per caricare i file di configurazione generati. Loadcat genera il file "nginx.conf" per ogni bilanciatore in una directory in base all'ID del bilanciatore (una breve stringa esadecimale). Queste directory vengono create in una directory "out" in cwd
. Pertanto, è importante configurare NGINX per caricare questi file di configurazione generati. Questo può essere fatto usando una direttiva "include" all'interno del blocco "http":
Modifica /etc/nginx/nginx.conf e aggiungi la seguente riga alla fine del blocco "http":
http { include /path/to/out/*/nginx.conf; }
Ciò farà sì che NGINX esamini tutte le directory trovate in "/path/to/out/", cerchi i file denominati "nginx.conf" all'interno di ciascuna directory e carichi ognuno di quelli che trova.
Nel nostro pacchetto principale, feline, definiamo un'interfaccia Driver . Qualsiasi struct che fornisce due funzioni, Genera e Reload , con la firma corretta si qualifica come driver.
type Driver interface { Generate(string, *data.Balancer) error Reload() error }
Ad esempio, la struct Nginx sotto i pacchetti feline/nginx:
type Nginx struct { sync.Mutex Systemd *dbus.Conn } func (n Nginx) Generate(dir string, bal *data.Balancer) error { // Acquire a lock on n.Mutex, and release before return f, _ := os.Create(filepath.Join(dir, "nginx.conf")) TplNginxConf.Execute(f, /* template parameters */) f.Close() if bal.Settings.Protocol == "https" { // Dump private key and certificate to the output directory (so that Nginx can find them) } return nil } func (n Nginx) Reload() error { // Acquire a lock on n.Mutex, and release before return switch cfg.Current.Nginx.Mode { case "systemd": if n.Systemd == nil { c, err := dbus.NewSystemdConnection() n.Systemd = c } ch := make(chan string) n.Systemd.ReloadUnit(cfg.Current.Nginx.Systemd.Service, "replace", ch) <-ch return nil default: return errors.New("unknown Nginx mode") } }
Generate può essere richiamato con una stringa contenente il percorso della directory di output e un puntatore a un'istanza della struttura Balancer . Go fornisce un pacchetto standard per la creazione di modelli di testo, che il driver NGINX utilizza per generare il file di configurazione NGINX finale. Il template è costituito da un blocco “upstream” seguito da un blocco “server”, generato in base a come è configurato il bilanciatore:
var TplNginxConf = template.Must(template.New("").Parse(` upstream {{.Balancer.Id.Hex}} { {{if eq .Balancer.Settings.Algorithm "least-connections"}} least_conn; {{else if eq .Balancer.Settings.Algorithm "source-ip"}} ip_hash; {{end}} {{range $srv := .Balancer.Servers}} server {{$srv.Settings.Address}} weight={{$srv.Settings.Weight}} {{if eq $srv.Settings.Availability "available"}}{{else if eq $srv.Settings.Availability "backup"}}backup{{else if eq $srv.Settings.Availability "unavailable"}}down{{end}}; {{end}} } server { {{if eq .Balancer.Settings.Protocol "http"}} listen {{.Balancer.Settings.Port}}; {{else if eq .Balancer.Settings.Protocol "https"}} listen {{.Balancer.Settings.Port}} ssl; {{end}} server_name {{.Balancer.Settings.Hostname}}; {{if eq .Balancer.Settings.Protocol "https"}} ssl on; ssl_certificate {{.Dir}}/server.crt; ssl_certificate_key {{.Dir}}/server.key; {{end}} location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://{{.Balancer.Id.Hex}}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; } } `))
Reload è l'altra funzione su Nginx struct che fa in modo che NGINX ricarichi i file di configurazione. Il meccanismo utilizzato si basa sulla configurazione di Loadcat. Per impostazione predefinita, presuppone che NGINX sia un servizio systemd in esecuzione come nginx.service, in modo tale che [sudo] systemd reload nginx.service
funzioni. Tuttavia, invece di eseguire un comando di shell, stabilisce una connessione a systemd tramite D-Bus utilizzando il pacchetto github.com/coreos/go-systemd/dbus.
GUI basata sul Web
Con tutti questi componenti in atto, concluderemo il tutto con una semplice interfaccia utente Bootstrap.
Per queste funzionalità di base sono sufficienti alcuni semplici gestori di route GET e POST:
GET /balancers GET /balancers/new POST /balancers/new GET /balancers/{id} GET /balancers/{id}/edit POST /balancers/{id}/edit GET /balancers/{id}/servers/new POST /balancers/{id}/servers/new GET /servers/{id} GET /servers/{id}/edit POST /servers/{id}/edit
Andare su ogni singolo percorso potrebbe non essere la cosa più interessante da fare qui, dal momento che queste sono più o meno le pagine CRUD. Sentiti assolutamente libero di dare un'occhiata al codice dell'interfaccia utente del pacchetto per vedere come sono stati implementati i gestori per ciascuna di queste rotte.
Ogni funzione del gestore è una routine che:
- Recupera i dati dal datastore e risponde con modelli renderizzati (usando i dati recuperati)
- Analizza i dati del modulo in arrivo, apporta le modifiche necessarie nel datastore e utilizza il pacchetto feline per rigenerare i file di configurazione NGINX
Per esempio:
func ServeServerNewForm(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) TplServerNewForm.Execute(w, struct { Balancer *data.Balancer }{ Balancer: bal, }) } func HandleServerCreate(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) r.ParseForm() body := struct { Label string `schema:"label"` Settings struct { Address string `schema:"address"` } `schema:"settings"` }{} schema.NewDecoder().Decode(&body, r.PostForm) srv := data.Server{} srv.BalancerId = bal.Id srv.Label = body.Label srv.Settings.Address = body.Settings.Address srv.Put() feline.Commit(bal) http.Redirect(w, r, "/servers/"+srv.Id.Hex()+"/edit", http.StatusSeeOther) }
Tutto ciò che fa la funzione ServeServerNewForm è recuperare un bilanciatore dal datastore ed eseguire il rendering di un modello, in questo caso TplServerList , che recupera l'elenco dei server rilevanti utilizzando la funzione Server sul bilanciatore.
La funzione HandleServerCreate , d'altra parte, analizza il payload POST in entrata dal corpo in uno struct e utilizza quei dati per creare un'istanza e rendere persistente una nuova struttura del server nel datastore prima di utilizzare il pacchetto feline per rigenerare il file di configurazione NGINX per il sistema di bilanciamento.
Tutti i modelli di pagina sono archiviati nel file "ui/templates.go" e i file HTML del modello corrispondenti si trovano nella directory "ui/templates".
Provarlo
Distribuire Loadcat su un server remoto o anche nel tuo ambiente locale è semplicissimo. Se stai utilizzando Linux (64 bit), puoi prendere un archivio con un binario Loadcat precompilato dalla sezione Rilasci del repository. Se ti senti un po' avventuroso, puoi clonare il repository e compilare tu stesso il codice. Anche se, l'esperienza in questo caso potrebbe essere un po' deludente poiché la compilazione di programmi Go non è davvero una sfida. E nel caso tu stia utilizzando Arch Linux, allora sei fortunato! Per comodità è stato creato un pacchetto per la distribuzione. Basta scaricarlo e installarlo utilizzando il tuo gestore di pacchetti. I passaggi coinvolti sono descritti in maggior dettaglio nel file README.md del progetto.
Dopo aver configurato ed eseguito Loadcat, puntare il browser Web su "http://localhost:26590" (supponendo che sia in esecuzione localmente e in ascolto sulla porta 26590). Quindi, crea un bilanciatore, crea un paio di server, assicurati che qualcosa sia in ascolto su quelle porte definite e voilà dovresti avere il bilanciamento del carico NGINX delle richieste in arrivo tra quei server in esecuzione.
Qual è il prossimo?
Questo strumento è tutt'altro che perfetto, e in effetti è un progetto piuttosto sperimentale. Lo strumento non copre nemmeno tutte le funzionalità di base di NGINX. Ad esempio, se desideri memorizzare nella cache le risorse servite dai nodi back-end al livello NGINX, dovrai comunque modificare manualmente i file di configurazione di NGINX. Ed è questo che rende le cose eccitanti. C'è molto che si può fare qui ed è esattamente quello che succede dopo: coprire ancora di più le funzionalità di bilanciamento del carico di NGINX: quelle di base e probabilmente anche quelle che NGINX Plus ha da offrire.
Prova Loadcat. Controlla il codice, esegui il fork, cambialo, giocaci. Inoltre, facci sapere se hai creato uno strumento che configura altri software o ne hai utilizzato uno che ti piace molto nella sezione commenti qui sotto.