Equilibrio de carga NGINX simplificado con Loadcat
Publicado: 2022-03-11Las aplicaciones web que están diseñadas para ser escalables horizontalmente a menudo requieren uno o más nodos de equilibrio de carga. Su objetivo principal es distribuir el tráfico entrante entre los servidores web disponibles de manera justa. La capacidad de aumentar la capacidad general de una aplicación web simplemente aumentando la cantidad de nodos y haciendo que los balanceadores de carga se adapten a este cambio puede resultar tremendamente útil en la producción.
NGINX es un servidor web que ofrece funciones de equilibrio de carga de alto rendimiento, entre muchas de sus otras capacidades. Algunas de esas funciones solo están disponibles como parte de su modelo de suscripción, pero la versión gratuita y de código abierto sigue siendo muy rica en funciones y viene con las funciones de equilibrio de carga más esenciales listas para usar.
En este tutorial, exploraremos la mecánica interna de una herramienta experimental que le permite configurar su instancia de NGINX sobre la marcha para que actúe como un balanceador de carga, abstrayendo todos los detalles esenciales de los archivos de configuración de NGINX al proporcionar una web ordenada. interfaz de usuario basada El propósito de este artículo es mostrar lo fácil que es comenzar a construir una herramienta de este tipo. Vale la pena mencionar que el proyecto Loadcat está fuertemente inspirado en los NodeBalancers de Linode.
NGINX, Servidores y Upstreams
Uno de los usos más populares de NGINX es el proxy inverso de las solicitudes de los clientes a las aplicaciones del servidor web. Aunque las aplicaciones web desarrolladas en lenguajes de programación como Node.js y Go pueden ser servidores web autosuficientes, tener un proxy inverso frente a la aplicación del servidor real brinda numerosos beneficios. Un bloque de "servidor" para un caso de uso simple como este en un archivo de configuración de NGINX puede verse así:
server { listen 80; server_name example.com; location / { proxy_pass http://192.168.0.51:5000; } }
Esto haría que NGINX escuche en el puerto 80 todas las solicitudes dirigidas a ejemplo.com y pase cada una de ellas a alguna aplicación de servidor web que se ejecute en 192.168.0.51:5000. También podríamos usar la dirección IP de bucle invertido 127.0.0.1 aquí si el servidor de aplicaciones web se estuviera ejecutando localmente. Tenga en cuenta que el fragmento anterior carece de algunos ajustes obvios que se usan a menudo en la configuración de proxy inverso, pero se mantiene así por brevedad.
Pero, ¿qué pasaría si quisiéramos equilibrar todas las solicitudes entrantes entre dos instancias del mismo servidor de aplicaciones web? Aquí es donde la directiva "aguas arriba" se vuelve útil. En NGINX, con la directiva "upstream", es posible definir múltiples nodos de back-end entre los cuales NGINX equilibrará todas las solicitudes entrantes. Por ejemplo:
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; } }
Observe cómo definimos un bloque "ascendente", llamado "nodos", que consta de dos servidores. Cada servidor está identificado por una dirección IP y el número de puerto en el que están escuchando. Con esto, NGINX se convierte en un balanceador de carga en su forma más simple. De forma predeterminada, NGINX distribuirá las solicitudes entrantes de forma rotativa, donde la primera se enviará al primer servidor, la segunda al segundo servidor, la tercera al primer servidor y así sucesivamente.
Sin embargo, NGINX tiene mucho más que ofrecer cuando se trata de equilibrio de carga. Le permite definir pesos para cada servidor, marcarlos como no disponibles temporalmente, elegir un algoritmo de equilibrio diferente (por ejemplo, hay uno que funciona según el hash de IP del cliente), etc. Estas características y directivas de configuración están muy bien documentadas en nginx.org . Además, NGINX permite cambiar y recargar los archivos de configuración sobre la marcha casi sin interrupciones.
La capacidad de configuración de NGINX y los archivos de configuración simples hacen que sea muy fácil adaptarlo a muchas necesidades. Y ya existe una gran cantidad de tutoriales en Internet que le enseñan exactamente cómo configurar NGINX como balanceador de carga.
Loadcat: herramienta de configuración de NGINX
Hay algo fascinante en los programas que, en lugar de hacer algo por sí mismos, configuran otras herramientas para que lo hagan por ellos. Realmente no hacen mucho más que tal vez tomar entradas de los usuarios y generar algunos archivos. La mayoría de los beneficios que obtiene de esas herramientas son, de hecho, características de otras herramientas. Pero, ciertamente hacen la vida más fácil. Mientras intentaba configurar un balanceador de carga para uno de mis propios proyectos, me pregunté: ¿por qué no hacer algo similar para NGINX y sus capacidades de balanceo de carga?
¡Había nacido Loadcat!
Loadcat, construido con Go, todavía está en pañales. En este momento, la herramienta le permite configurar NGINX solo para balanceo de carga y terminación SSL. Proporciona una GUI simple basada en web para el usuario. En lugar de recorrer las características individuales de la herramienta, echemos un vistazo a lo que hay debajo. Sin embargo, tenga en cuenta que si a alguien le gusta trabajar con los archivos de configuración de NGINX a mano, es posible que encuentre poco valor en dicha herramienta.
Hay algunas razones para elegir Go como lenguaje de programación para esto. Uno de ellos es que Go produce binarios compilados. Esto nos permite construir y distribuir o implementar Loadcat como un binario compilado en servidores remotos sin preocuparnos por resolver las dependencias. Algo que simplifica mucho el proceso de configuración. Por supuesto, el binario asume que NGINX ya está instalado y existe un archivo de unidad systemd para él.
En caso de que no seas ingeniero de Go, no te preocupes en absoluto. Go es bastante fácil y divertido para empezar. Además, la implementación en sí es muy sencilla y debería poder seguirla fácilmente.
Estructura
Las herramientas de compilación Go imponen algunas restricciones sobre cómo puede estructurar su aplicación y dejar el resto al desarrollador. En nuestro caso, hemos dividido las cosas en algunos paquetes de Go según sus propósitos:
- cfg: carga, analiza y proporciona valores de configuración
- cmd/loadcat: paquete principal, contiene el punto de entrada, compila en binario
- datos: contiene "modelos", utiliza un almacén de clave/valor incrustado para la persistencia
- feline: contiene funcionalidad central, por ejemplo, generación de archivos de configuración, mecanismo de recarga, etc.
- ui: contiene plantillas, controladores de URL, etc.
Si observamos más de cerca la estructura del paquete, especialmente dentro del paquete feline, notaremos que todo el código específico de NGINX se ha mantenido dentro de un subpaquete feline/nginx. Esto se hace para que podamos mantener el resto de la lógica de la aplicación genérica y extender el soporte para otros balanceadores de carga (por ejemplo, HAProxy) en el futuro.
Punto de entrada
Empecemos por el paquete principal de Loadcat, que se encuentra dentro de "cmd/loadcatd". La función principal, punto de entrada de la aplicación, hace tres cosas.
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) }
Para simplificar las cosas y hacer que el código sea más fácil de leer, se eliminó todo el código de manejo de errores del fragmento anterior (y también de los fragmentos más adelante en este artículo).
Como puede ver en el código, estamos cargando el archivo de configuración según el indicador de línea de comando "-config" (que por defecto es "loadcat.conf" en el directorio actual). A continuación, estamos inicializando un par de componentes, a saber, el paquete felino central y la base de datos. Finalmente, estamos iniciando un servidor web para la GUI basada en web.
Configuración
Cargar y analizar el archivo de configuración es probablemente la parte más fácil aquí. Estamos usando TOML para codificar la información de configuración. Hay un buen paquete de análisis TOML disponible para Go. Necesitamos muy poca información de configuración del usuario y, en la mayoría de los casos, podemos determinar valores predeterminados sensatos para estos valores. La siguiente estructura representa la estructura del archivo de configuración:
struct { Core struct { Address string Dir string Driver string } Nginx struct { Mode string Systemd struct { Service string } } }
Y así es como se vería un archivo típico "loadcat.conf":
[core] address=":26590" dir="/var/lib/loadcat" driver="nginx" [nginx] mode="systemd" [nginx.systemd] service="nginx.service"
Como podemos ver, existe una similitud entre la estructura del archivo de configuración codificado en TOML y la estructura que se muestra arriba. El paquete de configuración comienza estableciendo algunos valores predeterminados sensatos para ciertos campos de la estructura y luego analiza el archivo de configuración sobre él. En caso de que no pueda encontrar un archivo de configuración en la ruta especificada, crea uno y primero descarga los valores predeterminados en él.
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 }
Datos y Persistencia
Conoce a Bolt. Un almacén de clave/valor incrustado escrito en Go puro. Viene como un paquete con una API muy simple, admite transacciones listas para usar y es inquietantemente rápido.
Dentro de los datos del paquete, tenemos estructuras que representan cada tipo de entidad. Por ejemplo, tenemos:
type Balancer struct { Id bson.ObjectId Label string Settings BalancerSettings } type Server struct { Id bson.ObjectId BalancerId bson.ObjectId Label string Settings ServerSettings }
… donde una instancia de Balancer representa un solo balanceador de carga. Loadcat le permite equilibrar de manera efectiva las solicitudes de múltiples aplicaciones web a través de una sola instancia de NGINX. Cada balanceador puede tener uno o más servidores detrás de él, donde cada servidor puede ser un nodo de back-end separado.
Dado que Bolt es un almacén de clave-valor y no admite consultas de base de datos avanzadas, tenemos una lógica del lado de la aplicación que hace esto por nosotros. Loadcat no está diseñado para configurar miles de equilibradores con miles de servidores en cada uno de ellos, por lo que, naturalmente, este enfoque ingenuo funciona bien. Además, Bolt funciona con claves y valores que son segmentos de bytes, y es por eso que codificamos con BSON las estructuras antes de almacenarlas en Bolt. La implementación de una función que recupera una lista de estructuras de Balancer de la base de datos se muestra a continuación:
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 función ListBalancers inicia una transacción de solo lectura, itera sobre todas las claves y valores dentro del depósito de "balanceadores", decodifica cada valor en una instancia de la estructura Balancer y los devuelve en una matriz.

Almacenar un balanceador en el balde es casi igualmente 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 función Put asigna algunos valores predeterminados a ciertos campos, analiza el certificado SSL adjunto en la configuración de HTTPS, comienza una transacción, codifica la instancia de estructura y la almacena en el depósito contra la ID del balanceador.
Mientras se analiza el certificado SSL, se extraen dos piezas de información utilizando la codificación/pem del paquete estándar y se almacenan en SSLOptions en el campo Configuración : los nombres DNS y la huella digital.
También tenemos una función que busca servidores por balanceador:
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 }
Esta función muestra cuán ingenuo es realmente nuestro enfoque. Aquí, estamos leyendo efectivamente todo el depósito de "servidores" y filtrando las entidades irrelevantes antes de devolver la matriz. Pero, de nuevo, esto funciona bien y no hay ninguna razón real para cambiarlo.
La función Put para servidores es mucho más simple que la de Balancer struct , ya que no requiere tantas líneas de configuración de código predeterminadas y campos calculados.
Controlando NGINX
Antes de usar Loadcat, debemos configurar NGINX para cargar los archivos de configuración generados. Loadcat genera un archivo "nginx.conf" para cada equilibrador en un directorio por ID del equilibrador (una cadena hexadecimal corta). Estos directorios se crean en un directorio de "salida" en cwd
. Por lo tanto, es importante que configure NGINX para cargar estos archivos de configuración generados. Esto se puede hacer usando una directiva "incluir" dentro del bloque "http":
Edite /etc/nginx/nginx.conf y agregue la siguiente línea al final del bloque "http":
http { include /path/to/out/*/nginx.conf; }
Esto hará que NGINX analice todos los directorios que se encuentran en "/ruta/hacia/salida/", busque archivos llamados "nginx.conf" dentro de cada directorio y cargue cada uno que encuentre.
En nuestro paquete principal, feline, definimos un controlador de interfaz. Cualquier estructura que proporcione dos funciones, Generar y Recargar , con la firma correcta califica como controlador.
type Driver interface { Generate(string, *data.Balancer) error Reload() error }
Por ejemplo, la estructura Nginx en los paquetes 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 se puede invocar con una cadena que contiene la ruta al directorio de salida y un puntero a una instancia de estructura de Balancer . Go proporciona un paquete estándar para plantillas de texto, que el controlador NGINX usa para generar el archivo de configuración final de NGINX. La plantilla consta de un bloque "ascendente" seguido de un bloque "servidor", generado en función de cómo está configurado el equilibrador:
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'; } } `))
Recargar es la otra función en la estructura de Nginx que hace que NGINX vuelva a cargar los archivos de configuración. El mecanismo utilizado se basa en cómo está configurado Loadcat. De forma predeterminada, asume que NGINX es un servicio systemd que se ejecuta como nginx.service, de modo que [sudo] systemd reload nginx.service
funcionaría. Sin embargo, en lugar de ejecutar un comando de shell, establece una conexión con systemd a través de D-Bus utilizando el paquete github.com/coreos/go-systemd/dbus.
GUI basada en web
Con todos estos componentes en su lugar, lo envolveremos todo con una sencilla interfaz de usuario de Bootstrap.
Para estas funcionalidades básicas, unos pocos controladores de ruta GET y POST son suficientes:
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
Repasar cada ruta individual puede no ser lo más interesante que hacer aquí, ya que estas son prácticamente las páginas de CRUD. No dude en echar un vistazo al código de interfaz de usuario del paquete para ver cómo se han implementado los controladores para cada una de estas rutas.
Cada función de controlador es una rutina que:
- Obtiene datos del almacén de datos y responde con plantillas renderizadas (usando los datos obtenidos)
- Analiza los datos del formulario entrante, realiza los cambios necesarios en el almacén de datos y utiliza el paquete feline para regenerar los archivos de configuración de NGINX.
Por ejemplo:
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) }
Todo lo que hace la función ServeServerNewForm es obtener un equilibrador del almacén de datos y generar una plantilla, TplServerList en este caso, que recupera la lista de servidores relevantes utilizando la función Servidores en el equilibrador.
La función HandleServerCreate , por otro lado, analiza la carga útil POST entrante del cuerpo en una estructura y usa esos datos para instanciar y conservar una nueva estructura de servidor en el almacén de datos antes de usar el paquete feline para regenerar el archivo de configuración NGINX para el balanceador.
Todas las plantillas de página se almacenan en el archivo "ui/templates.go" y los archivos HTML de plantillas correspondientes se pueden encontrar en el directorio "ui/templates".
probandolo
Implementar Loadcat en un servidor remoto o incluso en su entorno local es muy fácil. Si está ejecutando Linux (64 bits), puede obtener un archivo con un binario de Loadcat prediseñado de la sección Versiones del repositorio. Si se siente un poco aventurero, puede clonar el repositorio y compilar el código usted mismo. Aunque, en ese caso, la experiencia puede ser un poco decepcionante ya que compilar programas Go no es realmente un desafío. Y en caso de que esté ejecutando Arch Linux, ¡está de suerte! Se ha construido un paquete para la distribución por conveniencia. Simplemente descárguelo e instálelo usando su administrador de paquetes. Los pasos involucrados se describen con más detalles en el archivo README.md del proyecto.
Una vez que haya configurado y ejecutado Loadcat, apunte su navegador web a "http://localhost:26590" (asumiendo que se está ejecutando localmente y escuchando en el puerto 26590). A continuación, cree un equilibrador, cree un par de servidores, asegúrese de que algo esté escuchando en esos puertos definidos y listo, debería tener NGINX equilibrando la carga de las solicitudes entrantes entre esos servidores en ejecución.
¿Que sigue?
Esta herramienta está lejos de ser perfecta y, de hecho, es un proyecto bastante experimental. La herramienta ni siquiera cubre todas las funcionalidades básicas de NGINX. Por ejemplo, si desea almacenar en caché los activos atendidos por los nodos de back-end en la capa NGINX, aún tendrá que modificar los archivos de configuración de NGINX a mano. Y eso es lo que hace que las cosas sean emocionantes. Hay mucho que se puede hacer aquí y eso es exactamente lo que sigue: cubrir aún más funciones de balanceo de carga de NGINX: las básicas y probablemente incluso las que NGINX Plus tiene para ofrecer.
Prueba Loadcat. Echa un vistazo al código, bifurcalo, cámbialo, juega con él. Además, háganos saber si ha creado una herramienta que configura otro software o si ha usado uno que realmente le gusta en la sección de comentarios a continuación.