angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Un conmutador de base de datos con HAProxy y el HAProxy Runtime API

Utilizando el HAProxy Runtime API, podemos añadir y eliminar servidores de bases de datos backend manualmente y con un script o programa.

13 agosto 2024
post main image
https://unsplash.com/@georgiadelotz

Uno de mis proyectos necesitaba una base de datos de alta disponibilidad (no todos queremos esto ...). Esto significa (asíncrono) de replicación, y una configuración multi-nodo. Existen varias soluciones, escribiré sobre esto en otro post. En estos escenarios tenemos múltiples réplicas de la base de datos principal, y cuando ocurre un problema, cambiamos de una base de datos a otra.

Para hacer este cambio de bases de datos sin tocar el cliente y las bases de datos, el cliente debe acceder a la base de datos no directamente, sino a través de un proxy. Y esto es exactamente de lo que trata este post. Implementamos un "interruptor manual" entre las bases de datos. Esto es sólo una demostración de cómo esto puede funcionar.

Como siempre hago esto en Ubuntu 22.04.

Base de datos proxy: HAProxy

Hay muchas opciones para una base de datos proxy. Aquí utilizamos HAProxy. mEste software se utiliza a menudo para distdistribuir cargas entre aplicaciones web, actuando como equilibrador de carga, pero tiene muchas más opciones.

HAProxy comprobaciones de salud no son utilizables para nuestro propósito

HAProxy puede comprobar la salud de los servidores backend y utilizar reglas para seleccionar el mejor servidor backend para una petición. Eso está muy bien, pero tenemos varias bases de datos y sólo la que especificamos debe ser accedida por el cliente.

HAProxy también soporta un agente externo para comprobaciones de salud. Podemos crear este agente nosotros mismos. En escenarios avanzados el agente puede reunir mucha más información sobre el sistema total, y tomar mejores decisiones que HAProxy.

Parece que utilizar un agente externo HAProxy es problemático para nuestro propósito, una de las razones es que el estado inicial no es 'mantenimiento' o 'caído' y no hay forma de cambiarlo. El agente externo HAProxy se utiliza mejor con balanceo de carga, no para conmutación on/off.

Tiempo de ejecución de HAProxy API

HAProxy también tiene un API, el HAProxy Runtime API. Con este API conseguimos el control que queremos. Podemos añadir nuevos servidores backend, cambiar el estado de los servidores y eliminar servidores backend. Eso es exactamente lo que buscamos.

Resumen del proyecto

Tenemos dos bases de datos, sólo una puede ser accedida por el cliente. Hacemos esto poniendo un servicio de base de datos proxy en medio, y por supuesto necesitamos algún tipo de gestor para controlar la base de datos proxy. A continuación se muestra un diagrama de lo que queremos lograr.

  +--------+      +----------+                    +-------------+
  | client |      | db-proxy |                    | backend-db1 |
  |        |      |          |                    |             |
  |        |------|8320      |------+-------------|8310         |
  |        |      |          |      |             |             |
  +--------+      |          |      |             +-------------+
                  |          |      |
                  |          |      |             +-------------+
   statistics ----|8360      |      |             | backend-db2 |
                  |          |      |             |             |
                  |  api     |-------------+------|8311         |
                  |  9999    |      |      |      |             |
                  +----------+      |      |      +-------------+
                       ^            |      |   
                       |            |      |    
                       |      +------------------+
                       |      |   db-proxy-man   |
                       +----- |                  |
                              |                  |
                              +------------------+
                                       ^
                                       |
                                select backend      

Breve descripción de los componentes en esta demostración:

  • cliente
    Programa simple que envía una petición a db-proxy:8620 e imprime la respuesta. El programa no es más que un Shell script one-liner, enviando una petición cada segundo.
  • db-proxy
    Este es el HAProxy. Habilitamos el HAProxy Runtime API en el puerto 9999.
  • db-proxy-man
    Desde aquí enviamos comandos al API, en db-proxy:9999. Podemos hacer esto manualmente o crear un script Shell o programa Python para añadir nuestro servidor de base de datos seleccionado a la lista (vacía) de servidores backend.
  • backend-db1, backend-db2
    Nuestros servidores de bases de datos. No utilizamos servidores de bases de datos reales sino servidores eco. Ellos hacen eco de lo que se recibe con un prefix.

Los archivos en este proyecto son:

.
├── docker-compose.yml
├── Dockerfile
└── haproxy.cfg

Es posible que desee añadir un archivo .env y establecer DOCKER_COMPOSE_PROJECT al nombre de su proyecto, por ejemplo, db-proxy-demo.

El archivo docker-compose.yml

El archivo docker-compose.yml contiene todos los elementos que hemos mencionado anteriormente. Añadimos el 'hostname' para facilitar las referencias (cuando usamos Docker Swarm, omitimos el 'hostname').

# docker-compose.yml
version: "3.7"

x-service_defaults: &service_defaults
  restart: always
  logging:
    driver: json-file
    options:
      max-size: "10m"
      max-file: "5"

services:
  client:
    << : *service_defaults
    image: busybox:1.35.0
    hostname: client
    command:
      - /bin/sh
      - -c
      - "i=0; while true; do echo \"sending: hello $$i \"; echo \"hello $$i\" | nc db-proxy 8320; i=$$((i+1)); sleep 1; done"
    networks:
      - frontend-db-network

  db-proxy:
    << : *service_defaults
    image: haproxy:1.001-d
    hostname: db-proxy
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      # http://127.0.0.1:8360/stats
      - "127.0.0.1:8360:8360"
    networks:
      - db-proxy-network
      - frontend-db-network
      - backend-db1-network
      - backend-db2-network

  db-proxy-man:
    << : *service_defaults
    image: busybox:1.35.0
    hostname: db-proxy-man
    # keep alive
    command: tail -f /dev/null
    networks:
      - db-proxy-network
      - backend-db1-network
      - backend-db2-network

  backend-db1:
    << : *service_defaults
    image: venilnoronha/tcp-echo-server
    hostname: backend-db1
    # echo with a prefix
    entrypoint: /bin/main 8310 "backend-db1 at 8310:"
    networks:
      - backend-db1-network

  backend-db2:
    << : *service_defaults
    image: venilnoronha/tcp-echo-server
    hostname: backend-db2
    # echo with a prefix
    entrypoint: /bin/main 8311 "backend-db2 at 8311:"
    networks:
      - backend-db2-network

networks:
  frontend-db-network:
    external: true
    name: frontend-db-network
  db-proxy-network:
    external: true
    name: db-proxy-network  
  backend-db1-network:
    external: true
    name: backend-db1-network
  backend-db2-network:
    external: true
    name: backend-db2-network  

Opinión personal, nunca dejes que Docker-Compose cree redes. Hazlo manualmente:

docker network create frontend-db-network
docker network create db-proxy-network
docker network create backend-db1-network
docker network create backend-db2-network

El fichero haproxy.cfg y Dockerfile

No añadimos servidores de bases de datos backend aquí, en su lugar los añadimos utilizando el HAProxy Runtime API.

# haproxy.cfg
global
  maxconn 100
  stats socket ipv4@*:9999 level admin
  log stdout format raw local0 debug
  #log stdout format raw local0 info

defaults
  log global
  retries 2
  timeout client 30m
  timeout connect 15s
  timeout server 30m
  timeout check 15s

frontend db
  mode tcp
  option tcplog
  bind :8320
  default_backend db_servers

backend db_servers
  balance roundrobin
  mode tcp
  option tcplog
  # servers here are added using the HAProxy Runtime API

frontend stats
  bind :8360
  mode http
  stats enable
  stats uri /stats
  stats refresh 5s
  #stats auth username:password
  stats admin if LOCALHOST

Es posible que desee cambiar el nivel de registro de 'debug' a 'info'.

La línea para hacer que el HAProxy Runtime API esté disponible:

  stats socket ipv4@*:9999 level admin

El Dockerfile se utiliza para construir la imagen, no hay mucho que hacer:

# Dockerfile 
FROM haproxy:lts-alpine3.20
COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg

Traer el proyecto

Primero, construimos la imagen:

docker-compose build --no-cache

Luego la iniciamos:

docker-compose up

Verás lo siguiente en la terminal:

backend-db2_1   | listening on [::]:8311, prefix: backend-db2 at 8311:
backend-db1_1   | listening on [::]:8310, prefix: backend-db1 at 8310:
client_1        | sending: hello 0 
db-proxy_1      | [NOTICE]   (1) : haproxy version is 3.0.3-95a607c
db-proxy_1      | [WARNING]  (1) : config : parsing [/usr/local/etc/haproxy/haproxy.cfg:25] : backend 'db_servers' : 'option tcplog' directive is ignored in backends.
db-proxy_1      | [NOTICE]   (1) : New worker (8) forked
db-proxy_1      | [NOTICE]   (1) : Loading success.
db-proxy_1      | 172.17.15.2:38293 [13/Aug/2024:08:05:01.543] db db_servers/<NOSRV> -1/-1/0 0 SC 1/1/0/0/0 0/0
client_1        | sending: hello 1 
db-proxy_1      | 172.17.15.2:46745 [13/Aug/2024:08:05:02.548] db db_servers/<NOSRV> -1/-1/0 0 SC 1/1/0/0/0 0/0
client_1        | sending: hello 2 
db-proxy_1      | 172.17.15.2:37693 [13/Aug/2024:08:05:03.552] db db_servers/<NOSRV> -1/-1/0 0 SC 1/1/0/0/0 0/0
client_1        | sending: hello 3 
...

Observa que el 'cliente' está enviando peticiones a nuestro db-proxy. Estas peticiones no son respondidas.

Para ver la página de estado, dirija su navegador a:

http://127.0.0.1:8360/stats

Antes de reconstruir, elimine los contenedores:

docker-compose down

Envío de comandos al HAProxy Runtime API

Según la documentación:

  • Un servidor añadido mediante el API se denomina servidor dinámico.
  • El backend debe estar configurado para utilizar un algoritmo de equilibrio de carga dinámico.
  • Un servidor dinámico no se restaura tras una operación de recarga del equilibrador de carga.
  • "Actualmente, un servidor dinámico se inicializa estáticamente con el método init-addr "none". Esto significa que no se llevará a cabo ninguna resolución si se especifica un FQDN como dirección, aunque se valide la creación del servidor."

Para enviar comandos al API utilizamos el comando 'nc' (netcat) presente en la imagen busybox :

echo "<your-command>" | nc db-proxy 9999

Sólo utilizamos unos pocos comandos para añadir y eliminar servidores, tenga en cuenta que hemos nombrado a nuestro backend en haproxy.cfg: 'db_servers'.

Para mostrar el estado de los servidores:

show servers state

Para añadir un servidor:

add server <backend-name>/<server-name> <addr>:<port>
set server <backend-name>/<server-name> state ready

El primer comando añade un servidor pero estará en modo mantenimiento. El segundo comando hace que el servidor esté listo.

De nuevo, véase también más arriba: ¡El HAProxy Runtime API no resuelve nombres de host de servidores dinámicos para nosotros! Esto significa que debemos utilizar un IP address aquí, ¡no el nombre de host!

Para eliminar un servidor:

set server <backend-name>/<server-name> state maint
del server <backend-name>/<server-name>

Ahora en serio: Añadir y eliminar servidores

Para enviar comandos al API entramos en el contenedor db-proxy-man busybox :

docker exec -it $(docker container ls | grep db-proxy-man | awk '{print $1; exit}') sh

Durante los comandos podemos ver la página de estado en nuestro navegador.

Asignamos los siguientes nombres a nuestros servidores:

backend_db1_8310
backend_db2_8311

Como ya se ha mencionado anteriormente, el API sí resuelve el IP address de nombres de host de servidores dinámicos. Utilizamos el comando 'nslookup' para obtener el IP address:

nslookup backend-db1

Resultado:

Server:		127.0.0.11
Address:	127.0.0.11:53

Non-authoritative answer:
Name:	backend-db1
Address: 172.17.13.2

Comprobemos el estado de los servidores:

echo "show servers state" | nc db-proxy 9999

Resultado:

1
# be_id be_name srv_id srv_name srv_addr srv_op_state srv_admin_state srv_uweight srv_iweight srv_time_since_last_change srv_check_status srv_check_result srv_check_health srv_check_state srv_agent_state bk_f_forced_id srv_f_forced_id srv_fqdn srv_port srvrecord srv_use_ssl srv_check_port srv_check_addr srv_agent_addr srv_agent_port

Sólo se muestra la línea de cabecera. No hay servidores presentes.

Ahora añada un servidor:

echo "add server db_servers/backend_db1_8310 172.17.13.2:8310" | nc db-proxy 9999

Resultado:

New server registered.

En el navegador verás que este servidor fue añadido con el estado 'MAINT'.

A continuación, habilitamos el servidor cambiando el estado a 'ready':

echo "set server db_servers/backend_db1_8310 state ready" | nc db-proxy 9999

También puede ver estos cambios en el registro Docker-Compose:

...
client_1        | sending: hello 61 
db-proxy_1      | 172.17.15.2:38285 [13/Aug/2024:10:05:54.779] db db_servers/<NOSRV> -1/-1/0 0 SC 2/1/0/0/0 0/0
db-proxy_1      | Connect from 172.17.13.1:54778 to 172.17.13.4:8360 (stats/HTTP)
db-proxy_1      | Server db_servers/backend_db1_8310 is UP/READY (leaving forced maintenance).
db-proxy_1      | [WARNING]  (8) : Server db_servers/backend_db1_8310 is UP/READY (leaving forced maintenance).
client_1        | sending: hello 62 
backend-db1_1   | request: hello 62
backend-db1_1   | response: backend-db1 at 8310: hello 62
db-proxy_1      | 172.17.15.2:34601 [13/Aug/2024:10:05:55.781] db db_servers/backend_db1_8310 1/0/0 30 -- 2/1/0/0/0 0/0
client_1        | backend-db1 at 8310: hello 62
client_1        | sending: hello 63 
backend-db1_1   | request: hello 63
backend-db1_1   | response: backend-db1 at 8310: hello 63
client_1        | backend-db1 at 8310: hello 63
db-proxy_1      | 172.17.15.2:42305 [13/Aug/2024:10:05:56.784] db db_servers/backend_db1_8310 1/0/0 30 -- 2/1/0/0/0 0/0
client_1        | sending: hello 64 
...

Después de cambiar el estado del servidor a "listo", vemos que backend-db1 ahora responde a las peticiones del "cliente", y el "cliente" recibe los datos de backend-db1. Estupendo.

Comprueba de nuevo el estado del servidor:

echo "show servers state" | nc db-proxy 9999

Resultado, mostrando que nuestro servidor fue añadido:

1
# be_id be_name srv_id srv_name srv_addr srv_op_state srv_admin_state srv_uweight srv_iweight srv_time_since_last_change srv_check_status srv_check_result srv_check_health srv_check_state srv_agent_state bk_f_forced_id srv_f_forced_id srv_fqdn srv_port srvrecord srv_use_ssl srv_check_port srv_check_addr srv_agent_addr srv_agent_port
3 db_servers 1 backend_db1_8310 172.17.13.2 2 0 1 1 10 1 0 0 0 0 0 0 - 8310 - 0 0 - - 0

Ahora vamos a eliminar este servidor, poniendo primero el servidor en estado de mantenimiento:

echo "set server db_servers/backend_db1_8310 state maint" | nc db-proxy 9999

Y a continuación, eliminar el servidor:

echo "del server db_servers/backend_db1_8310" | nc db-proxy 9999

Puedes ver los cambios en los logs y en el navegador.

Ahora que sabemos cómo funcionan los comandos, podemos escribir un script Shell, un script Bash, o un programa Python para enviar comandos al API.

Uso de Python para acceder al HAProxy Runtime API

Este es un blog sobre Python pero no voy a entrar en details de un programa Python para hacer lo anterior.
Sólo muestro la función que puede utilizar para acceder a la API:

import socket
import typing as _t

BUFFER_SIZE = 4096

class HAProxyRuntimeAPIUtils:
    def __init__(
        self,
        host: _t.Optional[str] = 'db-proxy',
        port: _t.Optional[int] = 9999,
    ):
        self.host = host
        self.port = port

    def send(self, send_data: str) -> _t.Optional[str]:
        send_bytes = bytes(send_data + '\n', 'utf-8')
        client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        client_socket.connect((self.host, self.port))
        client_socket.send(send_bytes)
        recv_data = client_socket.recv(BUFFER_SIZE).decode('utf-8').strip()
        client_socket.close()
        return recv_data
    
    ...    

Y el programa debe hacer algo como esto

    # set the selected_server

    # keep repeating as db-proxy may restart
    while True:

        try:
            # get all servers for the backend 
            ...

            # delete all servers that are not the selected_server
            ...

            # if not present, add the selected_server, with the proper addr, and set to ready
            ...

Si el IP address de los servidores backend puede cambiar, entonces también comprobamos los cambios aquí.

Resumen

Hemos utilizado HAProxy para crear un conmutador para nuestras bases de datos. HAProxy es una pieza de software muy compleja y, aunque la documentación de HAProxy es extensa, también es poco clara a veces. Creo que esto tiene que ver con el continuo desarrollo de HAProxy. Utilizar el HAProxy Runtime API es fácil, pero la información que se puede recuperar sobre los servidores backend es limitada.

Pero al final, HAProxy simplemente funciona y debería formar parte de la caja de herramientas de todo desarrollador de sistemas.

Enlaces / créditos

Dynamic DNS Resolution with HAProxy and Docker
https://stackoverflow.com/questions/41152408/dynamic-dns-resolution-with-haproxy-and-docker

HAProxy
https://en.wikipedia.org/wiki/HAProxy

HAProxy Runtime API
https://www.haproxy.com/documentation/haproxy-runtime-api/

venilnoronha/tcp-echo-server
https://hub.docker.com/r/venilnoronha/tcp-echo-server

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.