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.
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
Leer más
Database Docker Docker-compose
Recientes
- Uso de Ingress para acceder a RabbitMQ en un clúster Microk8s
- Galería de vídeo simple con Flask, Jinja, Bootstrap y JQuery
- Programación básica de trabajos con APScheduler
- Un conmutador de base de datos con HAProxy y el HAProxy Runtime API
- Docker Swarm rolling updates
- Cómo ocultar las claves primarias de la base de datos UUID de su aplicación web
Más vistos
- Usando PyInstaller y Cython para crear un ejecutable de Python
- Reducir los tiempos de respuesta de las páginas de un sitio Flask SQLAlchemy web
- Usando Python's pyOpenSSL para verificar los certificados SSL descargados de un host
- Conectarse a un servicio en un host Docker desde un contenedor Docker
- Usando UUIDs en lugar de Integer Autoincrement Primary Keys con SQLAlchemy y MariaDb
- SQLAlchemy: Uso de Cascade Deletes para eliminar objetos relacionados