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

Een databaseswitch met HAProxy en de HAProxy Runtime API

Met de HAProxy Runtime API kunnen we backend databaseservers handmatig en met een script of programma toevoegen en verwijderen.

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

Een van mijn projecten had een database met hoge beschikbaarheid nodig (willen we dat niet allemaal...). Dit betekent (asynchrone) replicatie en een opstelling met meerdere knooppunten. Er bestaan verschillende oplossingen, waarover ik in een andere post zal schrijven. In deze scenario's hebben we meerdere replica's van de hoofddatabase, en als er een probleem optreedt, schakelen we van de ene database naar de andere.

Om dit wisselen van databases te doen zonder de client en databases aan te raken, moet de client de database niet direct benaderen, maar via een proxy. En dat is precies waar deze post over gaat. We implementeren een 'handmatige switch' tussen de databases. Dit is slechts een demonstratie van hoe dit kan werken.

Zoals altijd doe ik dit op Ubuntu 22.04.

Database proxy: HAProxy

Er zijn veel keuzes voor een database proxy. Hier gebruiken we HAProxy. mDeze software wordt vaak gebruikt om dist belastingen te verdelen tussen webapplicaties, waarbij het fungeert als een load balancer, maar het heeft veel meer opties.

HAProxy gezondheidscontroles zijn niet bruikbaar voor ons doel

HAProxy kan de gezondheid van backendservers controleren en regels gebruiken om de beste backendserver voor een verzoek te selecteren. Dat is geweldig, maar we hebben een aantal databases en alleen degene die we hebben gespecificeerd, moet worden benaderd door de client.

HAProxy ondersteunt ook een externe agent voor gezondheidscontroles. We kunnen deze agent zelf maken. In geavanceerde scenario's kan de agent veel meer informatie verzamelen over het totale systeem en betere beslissingen nemen dan HAProxy.

Het blijkt dat het gebruik van een HAProxy externe agent problematisch is voor ons doel, onder andere omdat de initiële status niet 'maintenance' of 'down' is en er geen manier is om dit te veranderen. De HAProxy externe agent kan het beste worden gebruikt voor load balancing, niet voor aan/uit-schakelen.

HAProxy runtime API

HAProxy heeft ook een API, de HAProxy Runtime API. Met deze API krijgen we de controle die we willen. We kunnen nieuwe backendservers toevoegen, de status van de servers wijzigen en backendservers verwijderen. Dat is precies wat we zoeken!

Projectoverzicht

We hebben twee databases, waarvan er maar één toegankelijk is voor de klant. We doen dit door er een database proxy service tussen te zetten, en natuurlijk hebben we een soort manager nodig om de database proxy te beheren. Hieronder staat een schema van wat we willen bereiken.

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

Korte beschrijving van de componenten in deze demonstratie:

  • client
    Eenvoudig programma dat een verzoek stuurt naar db-proxy:8620 en het antwoord afdrukt. Het programma is niets meer dan een Shell-script one-liner, die elke seconde een verzoek verstuurt.
  • db-proxy
    Dit is de HAProxy. We schakelen de HAProxy Runtime API in op poort 9999.
  • db-proxy-man
    Vanaf hier sturen we commando's naar de API, op db-proxy:9999. We kunnen dit handmatig doen of een Shell-script of Python programma maken om onze geselecteerde databaseserver toe te voegen aan de (lege) lijst met backendservers.
  • backend-db1, backend-db2
    Onze databaseservers. We gebruiken geen echte databaseservers maar echoservers. Ze echoën wat wordt ontvangen met een prefix.

De bestanden in dit project zijn:

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

Je kunt eventueel een .env bestand toevoegen en DOCKER_COMPOSE_PROJECT instellen op de naam van je project, bijvoorbeeld db-proxy-demo.

Het bestand docker-compose.yml

Het bestand docker-compose.yml bevat alle elementen die we hierboven hebben genoemd. We voegen de 'hostname' toe voor gemakkelijke verwijzing (wanneer we Docker Swarm gebruiken, laten we de 'hostname' weg).

# 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  

Persoonlijke mening: laat Docker-Compose nooit netwerken aanmaken. Doe dit handmatig:

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

Het haproxy.cfg bestand en Dockerfile

We voegen hier geen backend databaseservers toe, in plaats daarvan doen we dat met de 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

Mogelijk wilt u het logniveau wijzigen van 'debug' in 'info'.

De regel om de HAProxy Runtime API beschikbaar te maken:

  stats socket ipv4@*:9999 level admin

De Dockerfile wordt gebruikt om de image te bouwen, er is niet veel aan:

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

Het project ophalen

Eerst bouwen we de image:

docker-compose build --no-cache

Dan starten we het op:

docker-compose up

In de terminal zie je het volgende:

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 
...

Merk op dat de 'client' verzoeken stuurt naar onze db-proxy. Deze verzoeken worden niet beantwoord.

Om de statuspagina te zien, wijst u uw browser naar:

http://127.0.0.1:8360/stats

Verwijder de containers voordat u opnieuw gaat bouwen:

docker-compose down

Opdrachten sturen naar de HAProxy Runtime API

Volgens de documentatie:

  • Een server die wordt toegevoegd met behulp van de API wordt een dynamische server genoemd.
  • De backend moet worden geconfigureerd om een dynamisch load balancing-algoritme te gebruiken.
  • Een dynamische server wordt niet hersteld na een herlaadoperatie van de loadbalancer.
  • "Momenteel wordt een dynamische server statisch geïnitialiseerd met de methode "none" init-addr. Dit betekent dat er geen resolutie wordt uitgevoerd als er een FQDN als adres wordt opgegeven, zelfs als het aanmaken van de server wordt gevalideerd."

Om commando's naar de API te sturen, gebruiken we het 'nc' (netcat) commando dat aanwezig is in de busybox image:

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

We gebruiken slechts een paar commando's om servers toe te voegen en te verwijderen, merk op dat we onze backend in haproxy.cfg de naam 'db_servers' hebben gegeven.

De status van de servers weergeven:

show servers state

Om een server toe te voegen:

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

Het eerste commando voegt een server toe, maar deze zal in onderhoudsmodus staan. Het tweede commando maakt de server gereed.

Zie ook hierboven: De HAProxy Runtime API lost hostnamen van dynamische servers niet voor ons op! Dit betekent dat we hier een IP address moeten gebruiken, niet de hostnaam!

Een server verwijderen:

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

Nu in het echt: Servers toevoegen en verwijderen

Om commando's naar de API te sturen, gaan we de container db-proxy-man busybox binnen:

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

Tijdens de commando's kun je de statuspagina in je browser bekijken.

We kennen de volgende namen toe aan onze servers:

backend_db1_8310
backend_db2_8311

Zoals hierboven al vermeld, lost de API wel de IP address hostnamen van dynamische servers op. We gebruiken de opdracht 'nslookup' om de IP address te krijgen:

nslookup backend-db1

Resultaat:

Server:		127.0.0.11
Address:	127.0.0.11:53

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

Laten we de status van de servers controleren:

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

Resultaat:

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

We zien dat er geen servers aanwezig zijn.

Voeg nu een server toe:

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

Resultaat:

New server registered.

In de browser zie je dat deze server is toegevoegd met de status 'MAINT'.

Vervolgens schakelen we de server in door de status te veranderen in 'ready':

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

Je kunt deze wijzigingen ook zien in het logboek 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 
...

Na het veranderen van de status van de server naar 'ready', zien we dat backend-db1 nu reageert op 'client' verzoeken,
en de 'client' ontvangt de gegevens van backend-db1. Geweldig!

Controleer nogmaals de status van de server:

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

Resultaat, waaruit blijkt dat onze server is toegevoegd:

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

Laten we nu deze server verwijderen, door de server eerst in onderhoudstoestand te zetten:

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

En vervolgens de server verwijderen:

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

Je kunt de veranderingen zien in de logs en de browser.

Nu we weten hoe de commando's werken, kunnen we een Shell-script, Bash-script of een Python programma schrijven om commando's naar de API te sturen.

Python gebruiken om toegang te krijgen tot de HAProxy Runtime API

Dit is een blog over Python maar ik ga niet in op details van een Python programma om het bovenstaande te doen.
Ik laat alleen de functie zien die je kunt gebruiken om toegang te krijgen tot de 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
    
    ...    

En het programma zou zoiets moeten doen:

    # 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
            ...

Als de IP address van de backend servers kan veranderen, dan controleren we hier ook op veranderingen.

Samenvatting

We hebben HAProxy gebruikt om een switch te maken voor onze databases. HAProxy is een erg complex stuk software en hoewel de HAProxy documentatie uitgebreid is, is het soms ook onduidelijk. Ik denk dat dit te maken heeft met de voortdurende ontwikkeling van HAProxy. Het gebruik van de HAProxy Runtime API is eenvoudig, maar de informatie die kan worden opgevraagd over de backend servers is beperkt.

Maar uiteindelijk werkt HAProxy gewoon en zou het deel moeten uitmaken van de gereedschapskist van elke systeemontwikkelaar.

Links / credits

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

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.