Équilibrage de charge NGINX simplifié avec Loadcat

Publié: 2022-03-11

Les applications Web conçues pour être évolutives horizontalement nécessitent souvent un ou plusieurs nœuds d'équilibrage de charge. Leur objectif principal est de répartir équitablement le trafic entrant sur les serveurs Web disponibles. La possibilité d'augmenter la capacité globale d'une application Web simplement en augmentant le nombre de nœuds et en adaptant les équilibreurs de charge à ce changement peut s'avérer extrêmement utile en production.

NGINX est un serveur Web qui offre des fonctionnalités d'équilibrage de charge hautes performances, parmi de nombreuses autres fonctionnalités. Certaines de ces fonctionnalités ne sont disponibles que dans le cadre de leur modèle d'abonnement, mais la version gratuite et open source est toujours très riche en fonctionnalités et est livrée avec les fonctionnalités d'équilibrage de charge les plus essentielles prêtes à l'emploi.

Équilibrage de charge NGINX simplifié avec Loadcat

Équilibrage de charge NGINX simplifié avec Loadcat
Tweeter

Dans ce didacticiel, nous allons explorer les mécanismes internes d'un outil expérimental qui vous permet de configurer votre instance NGINX à la volée pour qu'elle agisse comme un équilibreur de charge, en supprimant tous les détails les plus élémentaires des fichiers de configuration NGINX en fournissant un web- interface utilisateur basée. Le but de cet article est de montrer à quel point il est facile de commencer à construire un tel outil. Il convient de mentionner que le projet Loadcat est fortement inspiré des NodeBalancers de Linode.

NGINX, serveurs et flux en amont

L'une des utilisations les plus populaires de NGINX est le proxy inverse des demandes des clients vers les applications de serveur Web. Bien que les applications Web développées dans des langages de programmation tels que Node.js et Go puissent être des serveurs Web autonomes, le fait d'avoir un proxy inverse devant l'application serveur réelle offre de nombreux avantages. Un bloc « serveur » pour un cas d'utilisation simple comme celui-ci dans un fichier de configuration NGINX peut ressembler à ceci :

 server { listen 80; server_name example.com; location / { proxy_pass http://192.168.0.51:5000; } }

Cela obligerait NGINX à écouter sur le port 80 toutes les requêtes dirigées vers example.com et à les transmettre à une application de serveur Web exécutée à 192.168.0.51:5000. Nous pourrions également utiliser l'adresse IP de bouclage 127.0.0.1 ici si le serveur d'applications Web s'exécutait localement. Veuillez noter que l'extrait ci-dessus manque de quelques ajustements évidents qui sont souvent utilisés dans la configuration de proxy inverse, mais est conservé de cette façon par souci de brièveté.

Mais que se passerait-il si nous voulions équilibrer toutes les requêtes entrantes entre deux instances du même serveur d'applications Web ? C'est là que la directive « en amont » devient utile. Dans NGINX, avec la directive "upstream", il est possible de définir plusieurs nœuds back-end parmi lesquels NGINX équilibrera toutes les requêtes entrantes. Par exemple:

 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; } }

Remarquez comment nous avons défini un bloc "en amont", nommé "nœuds", composé de deux serveurs. Chaque serveur est identifié par une adresse IP et le numéro de port sur lequel il écoute. Avec cela, NGINX devient un équilibreur de charge dans sa forme la plus simple. Par défaut, NGINX distribuera les requêtes entrantes de manière circulaire, où la première sera transmise au premier serveur, la seconde au deuxième serveur, la troisième au premier serveur et ainsi de suite.

Cependant, NGINX a bien plus à offrir en matière d'équilibrage de charge. Il vous permet de définir des pondérations pour chaque serveur, de les marquer comme temporairement indisponibles, de choisir un algorithme d'équilibrage différent (par exemple, il y en a un qui fonctionne en fonction du hachage IP du client), etc. Ces fonctionnalités et directives de configuration sont toutes bien documentées sur nginx.org . De plus, NGINX permet de modifier et de recharger les fichiers de configuration à la volée, presque sans interruption.

La configurabilité de NGINX et les fichiers de configuration simples permettent de l'adapter très facilement à de nombreux besoins. Et une pléthore de tutoriels existent déjà sur Internet qui vous apprennent exactement comment configurer NGINX en tant qu'équilibreur de charge.

Loadcat : outil de configuration NGINX

Il y a quelque chose de fascinant dans les programmes qui, au lieu de faire quelque chose par eux-mêmes, configurent d'autres outils pour le faire à leur place. Ils ne font pas grand-chose d'autre que peut-être prendre les entrées des utilisateurs et générer quelques fichiers. La plupart des avantages que vous tirez de ces outils sont en fait des fonctionnalités d'autres outils. Mais, ils rendent certainement la vie facile. En essayant de configurer un équilibreur de charge pour l'un de mes propres projets, je me suis demandé : pourquoi ne pas faire quelque chose de similaire pour NGINX et ses capacités d'équilibrage de charge ?

Loadcat est né !

Loadcat, construit avec Go, en est encore à ses balbutiements. Pour le moment, l'outil vous permet de configurer NGINX pour l'équilibrage de charge et la terminaison SSL uniquement. Il fournit une interface graphique simple basée sur le Web pour l'utilisateur. Au lieu de parcourir les fonctionnalités individuelles de l'outil, jetons un coup d'œil à ce qu'il y a en dessous. Sachez cependant que si quelqu'un aime travailler avec les fichiers de configuration NGINX à la main, il peut trouver peu de valeur dans un tel outil.

Il y a plusieurs raisons derrière le choix de Go comme langage de programmation pour cela. L'un d'eux est que Go produit des binaires compilés. Cela nous permet de construire et de distribuer ou de déployer Loadcat en tant que binaire compilé sur des serveurs distants sans nous soucier de la résolution des dépendances. Quelque chose qui simplifie grandement le processus d'installation. Bien sûr, le binaire suppose que NGINX est déjà installé et qu'un fichier d'unité systemd existe pour celui-ci.

Si vous n'êtes pas un ingénieur Go, ne vous inquiétez pas du tout. Go est assez facile et amusant pour commencer. De plus, la mise en œuvre elle-même est très simple et vous devriez pouvoir suivre facilement.

Structure

Les outils de construction Go imposent quelques restrictions sur la façon dont vous pouvez structurer votre application et laissez le reste au développeur. Dans notre cas, nous avons divisé les éléments en quelques packages Go en fonction de leurs objectifs :

  • cfg : charge, analyse et fournit des valeurs de configuration
  • cmd/loadcat : package principal, contient le point d'entrée, se compile en binaire
  • data : contient des "modèles", utilise un magasin clé/valeur intégré pour la persistance
  • feline : contient les fonctionnalités de base, par exemple la génération de fichiers de configuration, le mécanisme de rechargement, etc.
  • ui : contient des modèles, des gestionnaires d'URL, etc.

Si nous examinons de plus près la structure du package, en particulier au sein du package feline, nous remarquerons que tout le code spécifique à NGINX a été conservé dans un sous-package feline/nginx. Ceci est fait afin que nous puissions garder le reste de la logique d'application générique et étendre la prise en charge d'autres équilibreurs de charge (par exemple HAProxy) à l'avenir.

Point d'accès

Commençons par le package principal pour Loadcat, trouvé dans "cmd/loadcatd". La fonction principale, point d'entrée de l'application, fait trois choses.

 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) }

Pour garder les choses simples et rendre le code plus facile à lire, tout le code de gestion des erreurs a été supprimé de l'extrait ci-dessus (ainsi que des extraits plus loin dans cet article).

Comme vous pouvez le voir dans le code, nous chargeons le fichier de configuration en fonction de l'indicateur de ligne de commande "-config" (qui est par défaut "loadcat.conf" dans le répertoire courant). Ensuite, nous initialisons quelques composants, à savoir le package feline principal et la base de données. Enfin, nous démarrons un serveur Web pour l'interface graphique Web.

Configuration

Charger et analyser le fichier de configuration est probablement la partie la plus simple ici. Nous utilisons TOML pour coder les informations de configuration. Il existe un package d'analyse TOML soigné disponible pour Go. Nous avons besoin de très peu d'informations de configuration de la part de l'utilisateur et, dans la plupart des cas, nous pouvons déterminer des valeurs par défaut saines pour ces valeurs. La structure suivante représente la structure du fichier de configuration :

 struct { Core struct { Address string Dir string Driver string } Nginx struct { Mode string Systemd struct { Service string } } }

Et voici à quoi peut ressembler un fichier "loadcat.conf" typique :

 [core] address=":26590" dir="/var/lib/loadcat" driver="nginx" [nginx] mode="systemd" [nginx.systemd] service="nginx.service"

Comme nous pouvons le voir, il existe une similitude entre la structure du fichier de configuration encodé en TOML et la structure affichée au-dessus. Le package de configuration commence par définir des valeurs par défaut saines pour certains champs de la structure , puis analyse le fichier de configuration dessus. S'il ne parvient pas à trouver un fichier de configuration dans le chemin spécifié, il en crée un et y dépose d'abord les valeurs par défaut.

 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 }

Données et persistance

Rencontrez Bolt. Un magasin clé/valeur intégré écrit en Go pur. Il se présente sous la forme d'un package avec une API très simple, prend en charge les transactions prêtes à l'emploi et est d'une rapidité inquiétante .

Dans les données de package, nous avons des structures représentant chaque type d'entité. Par exemple, nous avons :

 type Balancer struct { Id bson.ObjectId Label string Settings BalancerSettings } type Server struct { Id bson.ObjectId BalancerId bson.ObjectId Label string Settings ServerSettings }

… où une instance de Balancer représente un seul équilibreur de charge. Loadcat vous permet d'équilibrer efficacement les demandes de plusieurs applications Web via une seule instance de NGINX. Chaque équilibreur peut alors avoir un ou plusieurs serveurs derrière lui, où chaque serveur peut être un nœud principal distinct.

Étant donné que Bolt est un magasin clé-valeur et ne prend pas en charge les requêtes de base de données avancées, nous avons une logique côté application qui le fait pour nous. Loadcat n'est pas destiné à configurer des milliers d'équilibreurs avec des milliers de serveurs dans chacun d'eux, donc naturellement cette approche naïve fonctionne très bien. De plus, Bolt fonctionne avec des clés et des valeurs qui sont des tranches d'octets, et c'est pourquoi nous encodons les structures en BSON avant de les stocker dans Bolt. L'implémentation d'une fonction qui récupère une liste de structures Balancer à partir de la base de données est illustrée ci-dessous :

 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 fonction ListBalancers démarre une transaction en lecture seule, itère sur toutes les clés et valeurs du compartiment "balancers", décode chaque valeur en une instance de la structure Balancer et les renvoie dans un tableau.

Le stockage d'un équilibreur dans le godet est presque aussi simple :

 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 fonction Put attribue des valeurs par défaut à certains champs, analyse le certificat SSL joint dans la configuration HTTPS, démarre une transaction, encode l'instance de structure et la stocke dans le compartiment par rapport à l'ID de l'équilibreur.

Lors de l'analyse du certificat SSL, deux informations sont extraites à l'aide de l'encodage/pem du package standard et stockées dans SSLOptions sous le champ Paramètres : les noms DNS et l'empreinte digitale.

Nous avons également une fonction qui recherche les serveurs par équilibreur :

 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 }

Cette fonction montre à quel point notre approche est vraiment naïve. Ici, nous lisons effectivement l'ensemble du compartiment "serveurs" et filtrons les entités non pertinentes avant de renvoyer le tableau. Mais encore une fois, cela fonctionne très bien, et il n'y a aucune raison réelle de le changer.

La fonction Put pour les serveurs est beaucoup plus simple que celle de la structure Balancer car elle ne nécessite pas autant de lignes de code définissant les valeurs par défaut et les champs calculés.

Contrôler NGINX

Avant d'utiliser Loadcat, nous devons configurer NGINX pour charger les fichiers de configuration générés. Loadcat génère le fichier "nginx.conf" pour chaque équilibreur sous un répertoire par l'ID de l'équilibreur (une courte chaîne hexadécimale). Ces répertoires sont créés sous un répertoire "out" à cwd . Par conséquent, il est important que vous configuriez NGINX pour charger ces fichiers de configuration générés. Cela peut être fait en utilisant une directive "include" à l'intérieur du bloc "http":

Modifiez /etc/nginx/nginx.conf et ajoutez la ligne suivante à la fin du bloc « http » :

 http { include /path/to/out/*/nginx.conf; }

Cela amènera NGINX à analyser tous les répertoires trouvés sous "/path/to/out/", à rechercher les fichiers nommés "nginx.conf" dans chaque répertoire et à charger chacun d'eux qu'il trouve.

Dans notre package de base, feline, nous définissons une interface Driver . Toute structure qui fournit deux fonctions, Generate et Reload , avec la signature correcte est considérée comme un pilote.

 type Driver interface { Generate(string, *data.Balancer) error Reload() error }

Par exemple, la structure Nginx sous les packages 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 peut être appelé avec une chaîne contenant le chemin d'accès au répertoire de sortie et un pointeur vers une instance de structure Balancer . Go fournit un package standard pour les modèles de texte, que le pilote NGINX utilise pour générer le fichier de configuration NGINX final. Le modèle se compose d'un bloc « amont » suivi d'un bloc « serveur », généré en fonction de la configuration de l'équilibreur :

 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 est l'autre fonction de la structure Nginx qui permet à NGINX de recharger les fichiers de configuration. Le mécanisme utilisé est basé sur la configuration de Loadcat. Par défaut, il suppose que NGINX est un service systemd exécuté en tant que nginx.service, de sorte que [sudo] systemd reload nginx.service fonctionnerait. Cependant, au lieu d'exécuter une commande shell, il établit une connexion à systemd via D-Bus en utilisant le package github.com/coreos/go-systemd/dbus.

Interface graphique Web

Avec tous ces composants en place, nous allons tout conclure avec une interface utilisateur Bootstrap simple.

Fonctionnalités d'équilibrage de charge NGINX, intégrées dans une interface graphique simple

Fonctionnalités d'équilibrage de charge NGINX, intégrées dans une interface graphique simple
Tweeter

Pour ces fonctionnalités de base, quelques simples gestionnaires de route GET et POST suffisent :

 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

Passer en revue chaque itinéraire individuel n'est peut-être pas la chose la plus intéressante à faire ici, car ce sont à peu près les pages CRUD. N'hésitez pas à jeter un coup d'œil au code de l'interface utilisateur du package pour voir comment les gestionnaires de chacune de ces routes ont été implémentés.

Chaque fonction de gestionnaire est une routine qui :

  • Récupère les données du magasin de données et répond avec des modèles rendus (en utilisant les données récupérées)
  • Analyse les données de formulaire entrantes, apporte les modifications nécessaires dans le magasin de données et utilise le package feline pour régénérer les fichiers de configuration NGINX

Par exemple:

 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) }

La fonction ServeServerNewForm récupère un équilibreur du magasin de données et affiche un modèle, TplServerList dans ce cas, qui récupère la liste des serveurs pertinents à l'aide de la fonction Servers sur l'équilibreur.

La fonction HandleServerCreate , d'autre part, analyse la charge utile POST entrante du corps dans une structure et utilise ces données pour instancier et conserver une nouvelle structure de serveur dans le magasin de données avant d'utiliser le package feline pour régénérer le fichier de configuration NGINX pour l'équilibreur.

Tous les modèles de page sont stockés dans le fichier « ui/templates.go » et les fichiers HTML de modèle correspondants se trouvent dans le répertoire « ui/templates ».

Essayer

Déployer Loadcat sur un serveur distant ou même dans votre environnement local est très simple. Si vous utilisez Linux (64 bits), vous pouvez récupérer une archive avec un binaire Loadcat pré-construit dans la section Releases du référentiel. Si vous vous sentez un peu aventureux, vous pouvez cloner le référentiel et compiler le code vous-même. Cependant, l'expérience dans ce cas peut être un peu décevante car la compilation de programmes Go n'est pas vraiment un défi. Et si vous utilisez Arch Linux, vous avez de la chance ! Un package a été construit pour la distribution pour plus de commodité. Téléchargez-le simplement et installez-le à l'aide de votre gestionnaire de packages. Les étapes impliquées sont décrites plus en détail dans le fichier README.md du projet.

Une fois que vous avez configuré et exécuté Loadcat, pointez votre navigateur Web sur "http://localhost:26590" (en supposant qu'il s'exécute localement et écoute sur le port 26590). Ensuite, créez un équilibreur, créez quelques serveurs, assurez-vous que quelque chose écoute sur ces ports définis, et le tour est joué, vous devriez avoir NGINX équilibrer la charge des requêtes entrantes entre ces serveurs en cours d'exécution.

Et après?

Cet outil est loin d'être parfait, et en fait c'est un projet plutôt expérimental. L'outil ne couvre même pas toutes les fonctionnalités de base de NGINX. Par exemple, si vous souhaitez mettre en cache les actifs servis par les nœuds principaux au niveau de la couche NGINX, vous devrez toujours modifier manuellement les fichiers de configuration NGINX. Et c'est ce qui rend les choses passionnantes. Il y a beaucoup de choses qui peuvent être faites ici et c'est exactement ce qui suit : couvrir encore plus de fonctionnalités d'équilibrage de charge de NGINX - les fonctionnalités de base et probablement même celles que NGINX Plus a à offrir.

Essayez Loadcat. Découvrez le code, bifurquez-le, modifiez-le, jouez avec. Faites-nous également savoir si vous avez créé un outil qui configure d'autres logiciels ou si vous en avez utilisé un que vous aimez vraiment dans la section des commentaires ci-dessous.