Login Logout

Howto HAProxy

HAProxy est un puissant load balancer pour les protocoles TCP/HTTP/HTTPS. Il gère la répartition de charge et la tolérance de panne. Son principal auteur est Willy Tarreau, un développeur actif du noyau Linux. HAProxy est écrit en langage C, il est optimisé pour Linux, mais tourne également sous BSD. Des sites web importants l’utilisent comme Twitter, Github, Reddit, Airbnb, etc.

Installation

# apt install haproxy

Configuration

La configuration se passe dans le fichier /etc/haproxy/haproxy.cfg :

global
      log 127.0.0.1 local5 debug
defaults
      mode     http
listen www
      bind *:80
      balance roundrobin
      option httpchk OPTIONS * HTTP/1.1\r\nHost:\ www.example.com
      stats uri /haproxy-stats
      stats auth foo:bar
      server www00 192.0.2.1:80 maxconn 50 check inter 10s
      server www01 192.0.2.2:80 maxconn 50 check inter 10s

On note l’activation des logs en debug ce qui permet de voir toutes les requêtes. Attention, il faut donc que le démon syslog ait un paramétrage sur la facilité local5 (ou autre selon votre configuration). Pour rsyslog cela se fait ainsi dans rsyslog.conf :

local5.*  -/var/log/haproxy.log

On vérifie qu’il n’y a pas d’erreur de syntaxes :

haproxy -c -V -f /etc/haproxy/haproxy.cfg

Configuration avancée

Exemple d’une configuration avec frontend/backend HTTP

global
    log /dev/log    local5
    log /dev/log    local5 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5000
    timeout client  50000
    timeout server  50000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http
    default-server port 80 maxconn 250 on-error fail-check slowstart 60s inter 1m fastinter 5s downinter 10s weight 100


listen stats
    bind *:8080
    stats enable
    stats uri /haproxy
    stats show-legends
    stats show-node
    stats realm Auth\ required
    stats auth foo:bar
    stats admin if TRUE

frontend myfront
    option  forwardfor
    maxconn 800
    bind 0.0.0.0:80
    default_backend myback

backend myback
    balance roundrobin
    server web01 192.0.2.1:80 check observe layer4 weight 100
    server web02 192.0.2.2:80 check observe layer4 weight 100
    server web03 192.0.2.3:80 check observe layer4 weight 100

La visualisation des statistiques peut aussi se faire via la console : ~ hatop -s /var/run/haproxy/admin.sock ~

SSL

global
    # Default SSL material locations
    ca-base /etc/ssl/certs
    crt-base /etc/ssl/private

    # Default ciphers to use on SSL-enabled listening sockets.
    # For more information, see ciphers(1SSL). This list is from:
    #  https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
    ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS
    ssl-default-bind-options no-sslv3 no-tls-tickets
    ssl-default-server-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS
    ssl-default-server-options no-sslv3 no-tls-tickets

Dans un frontend il faut ensuite faire un “binding” avec des arguments pour le SSL :

frontend fe_https
    bind 0.0.0.0:443 ssl crt /etc/ssl/haproxy/example_com.pem

    http-request set-header X-Forwarded-Proto: https

    default_backend myback

Le fichier example_com.pem doit contenir le certificat ainsi que la clé privée et éventuellement des paramètres Diffie-Hellman (tout au format PEM).

Il est possible d’indiquer plusieurs fois crt /chemin/vers/fichier.pem pour avoir plusieurs certificats possibles. HAProxy utilisera alors le mécanisme de SNI. Si on indique plutôt un dossier (par exemple /etc/ssl/haproxy/) tous les fichiers trouvés seront chargé par ordre alphabétique.

Pour chaque fichier PEM trouvé, HAProxy cherchera un fichier .ocsp du même nom. Il peut être vide ou contenir une réponse OCSP valide (au format DER). Cela active le mécanisme de « OCSP stapling »

Tous les détails de configuration pour l’attribut crt sont consultables sur http://cbonte.github.io/haproxy-dconv/1.8/configuration.html#5.1-crt

Dans le cas où HAProxy gère plusieurs domaines dont certains seulement ont un certificat SSL, HAProxy enverra par défaut le certificat défini par la directive crt. Si il pointe sur un répertoire, un des certificats du répertoire (ça ne semble pas être le premier/dernier par ordre alphabétique) sera envoyé. Pour éviter ce comportement, on peut rajouter la directive strict-sni. Dans ce cas, si HAProxy ne trouve pas le certificat qui correspond au domaine demandé par le client, il retournera l’erreur SSL SSL_ERROR_BAD_CERT_DOMAIN.

Il est aussi possible de désigner un fichier contenant la liste de tous les PEM :

frontend fe_https
    bind 0.0.0.0:443 ssl crt-list /etc/ssl/crt-list

Le fichier crt-list contient le chemin des fichiers PEM, ligne par ligne, comme ceci :

/chemin/vers/fichier1.pem
/chemin/vers/fichier2.pem
/chemin/vers/fichier3.pem

Terminaison SSL

Si HAProxy doit faire la terminaison SSL et dialoguer en clair avec le backend, on se contente de transmettre la requête.

backend myback
    balance roundrobin
    server web01 192.0.2.1:80 check observe layer4 weight 100
    server web02 192.0.2.2:80 check observe layer4 weight 100

Si HAProxy doit faire la terminaison SSL et maintenir une communication chiffrée avec le backend, on doit le spécifier dans le backend (avec l’argument ssl verify [none|optional|required], car le port 443 ne suffit pas à forcer le ssl).

backend myback
    balance roundrobin
    server web01 192.0.2.1:443 ssl verify none check observe layer4 weight 100
    server web02 192.0.2.2:443 ssl verify none check observe layer4 weight 100

La vérification du SSL sur le backend est à voir en fonction des besoins de sécurité.

Attention, HAProxy ne supporte pas le SNI pour la communication avec le backend, du moins en version 1.5. L’erreur n’est pas explicite, il se contente d’indiquer une erreur de négociation SSL, cela peut se voir en détail avec wireshark par exemple. Il faut donc s’assurer que le backend délivre le bon certificat par défaut.

Exemple avec plusieurs backends et du « sticky session »

global
   [...]

defaults
   [...]

frontend http-in
   bind *:8080

   # On définit des ACL qui associe un Host: HTTP à un backend
   acl is_domain1 hdr_end(host) -i domain1.example.com
   acl is_domain2 hdr_end(host) -i domain2.example.com
   use_backend domain1 if is_domain1
   use_backend domain2 if is_domain2
   default_backend domain1

backend domain1
   # Avec cette directive, HAProxy ajoute automatiquement un cookie SERVERID aux réponses HTTP,
   # et l'utilise pour sélectionner le bon serveur lors de la prochaine requête
   cookie SERVERID insert indirect
   balance roundrobin

   # Pour ce serveur, la valeur du cookie SERVERID sera "web01" (directive "cookie")
   server web01 192.0.2.1:80 cookie web01 check
   # Pour ce serveur, la valeur du cookie SERVERID sera "web02"
   server web02 192.0.2.2:80 cookie web02 check

backend domain2
   cookie SERVERID insert indirect
   balance roundrobin

   server web01 192.0.2.1:80 cookie web01 check
   server web02 192.0.2.2:80 cookie web02 check

Reproduire un ProxyPass Apache

Avec Apache il est courant de faire un proxy qui modifie le chemin (path) :

ProxyPass / http://localhost:9999/path/to/app
ProxyPassReverse / http://localhost:9999/path/to/app

Ainsi, une requete à /foo/bar sera transise au final à /path/to/app/foo/bar.

Il est possible de reproduire le même comportement directement dans HAProxy :

backend be_http
    mode http
    http-request set-path /path/to/app%[path]
    acl header_location res.hdr(Location) -m found
    http-response replace-header Location (https?://%[req.hdr(Host)](:[0-9]+)?)?(/path/to/app)(.*) \1\4 if header_location

    server localhost 127.0.0.1:9999

La partie http-request set-path permet de modifier le path au moment du traitement de la requête (équivalent à ProxyPass pour Apache).

Le serveur amont n’ayant aucune information de l’URL intiale, s’il doit envoyer un en-tête de redirection calculé de manière relative à la requête, celui-ci ne sera pas correct. Il faut le modifier à la volée avant de renvoyer la réponse.

La partie http-response replace-header va donc remplacer la valeur de l’en-tête Location. L’expression régulière ne sera satisfaite que si le domaine d’origine est utilisé (ou totalement absent), conservant ainsi la possibilité d’avoir des redirection intactes vers d’autres domaines. Détail des captures :

  1. http ou https, suivi du host et éventuellement un port (facultatif)
  2. port (factultatif)
  3. partie du path à supprimer
  4. reste du path à garder

L’utilisation d’un ACL (très rapide) permet de ne faire l’opération (plus lente) que si l’entête est présent.

Exemple en mode TCP

frontend fe_memcached
    bind 127.0.0.1:11211
    mode tcp
    default_backend be_memcached

backend be_memcached
    mode tcp
    option tcp-check
    server nosql00 192.0.2.3:11211 check
    server nosql01 192.0.2.4:11211 check backup

Exemple pour MySQL

Il existe 2 modes principaux pour un proxy MySQL :

  • le mode simple, qui effectue un test de connexion au serveur MySQL ;
  • le mode avancé, qui exécute des tests poussés (et personnalisés) pour valider le bon fonctionnement du serveur.

Mode simple

HAProxy fourni une option “mysql-check”. Il va alors faire une connexion identifiée au serveur MySQL, puis la fermer et vérifier dans les infos renvoyées que tout semble correct.

Ce mode ne nécessite pas d’outillage supplémentaire et nous le recommandons lorsqu’HAProxy agit seulement comme un proxy et pas comme un load-balancer ou pour de la tolérance de panne.

frontend fe_mysql 
    bind 127.0.0.1:3306
    mode tcp
    default_backend be_mysql

backend be_mysql
    mode tcp
    option mysql-check user haproxy_check post-41
    server sql00 192.0.2.1:3306 check

Il faut penser à créer l’utilisateur “haproxy_check” (sans mot de passe mais sans droits et restreint à une IP source) sur les serveurs ciblés

CREATE USER haproxy_check@IP_OF_HAPROXY;

Mode avancé

Protection d’attaques

HAProxy peut prendre des mesures face à des attaques de type HTTP flood. Pour cela, il utilise un mécanisme qui permet de suivre l’activité des visiteurs en stockant en mémoire le nombres de requêtes par client. Cela est possible grâce à l’utilisation des « stick tables ». Les « stick tables » fournissent un stockage clé-valeur pouvant être utilisé pour suivre divers compteurs associés à chaque client. Ces compteurs peuvent se baser sur tout ce qui se trouve dans la requête (adresse IP, UserAgent, URL, token…). Les valeurs comptabilisées sont le nombre de requêtes ainsi que le taux sur une période donnée.

La première chose à faire est de définir une directive stick-table dans un backend ou un frontend. Il faut avoir à l’esprit que chaque backend/frontend ne peut contenir qu’une directive stick-table. On peut voir cela comme une vraie limitation, si l’on veut par exemple pouvoir suivre le nombre de requêtes par IP ainsi que que le nombre de requêtes par IP pour chaque URL. On peut vouloir également utiliser ces compteurs dans plusieurs backend/frontend simultanément. La bonne nouvelle est qu’il est possible de définir des frontend/backend dont la seule utilité est de contenir une directive stick-table. Il est alors possible ensuite d’utiliser ce compteur ailleurs via une directive http-request y faisant référence.

Dans l’exemple suivant, on définit deux backends. Le premier sert à enregistrer le nombre de requête par IP « per_ip_rates ». Le second sert à suivre le nombre requête par IP pour chaque URL « per_ip_and_url_rates » :

backend per_ip_rates
    stick-table type ip size 1m expire 10m store http_req_rate(10s)
backend per_ip_and_url_rates
    stick-table type binary len 8 size 1m expire 1h store http_req_rate(10s)

Pour les détails, dans le backend « per_ip_rates » on définit une directive stick-table de type IP qui peut contenir jusqu’à 1 million d’entrées, qui expire au bout de 10 minutes et qui comptabilise le nombre de requêtes sur les 10 dernières secondes.

Dans le second backend « per_ip_and_url_rates », on définit une directive stick-table de type binary (longueur de 8 bits), qui peut contenir jusqu’à 1 million d’entrées, qui expire au bout d’une heure et qui comptabilise le nombre de requêtes IP/URL sur les 10 dernières secondes.

On peut ensuite utiliser ces deux compteurs dans les frontends/backend de notre choix en y faisant référence dans une directive http-request via la paramètre table.

Dans l’exemple suivant, on crée deux blocs de 2 directives http-request.

frontend default
    […]
    http-request track-sc0 src table per_ip_rates unless { path_end .css .js .png .jpeg .gif }
    http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
    
    http-request track-sc1 url32+src table per_ip_and_url_rates unless { path_end .css .js .png .jpeg .gif }
    http-request deny deny_status 429 if { sc_http_req_rate(1) gt 10 }
    […]

Le premier bloc contient deux lignes dont la première définit une règle « track-sc0 » qui se basant sur la table du backend « per_ip_rates » en omettant toutes les requêtes vers certains type de fichiers (CSS, JS…). La seconde ligne indique ensuite de renvoyer un status 429 si le nombre des requêtes HTTP/sec pour une même IP selon les données provenant de « track-sc0 » est supérieur à 100 (donc 100 requêtes depuis une même IP sur les 10 dernières seconndes).

Le second bloc contient quant à lui deux lignes dont la première définit une règle « track-sc1 » qui se base sur la table du backend « per_ip_and_url_rates » en omettant toute les requêtes vers certains types de fichiers (CSS, JS…). La seconde ligne indique ensuite de renvoyer un status 429 si le nombre des requêtes HTTP/sec vers une URL unique depuis une même IP selon les données provenant de « track-sc1 » est supérieur à 10 (donc 10 requêtes depuis une même IP et vers la même URL sur les 10 dernières secondes).

Check HTTP

Cela consiste à utiliser un check http pour déterminer l’état du serveur.

frontend fe_mysql
    bind 127.0.0.1:3306
    mode tcp
    default_backend be_mysql

backend be_mysql
    mode tcp
    option httpchk HEAD
    http-check disable-on-404
    server sql00 192.0.2.1:3306 check port 8306
    server sql01 192.0.2.2:3306 check port 8306 backup

On note l’option httpchk qui va permettre de faire un check en HTTP et vérifier des conditions avancées (réplication OK, etc.).

Un moyen simple (inspiré de ce vieux blog post) est de créer un script qui sera déclenché par xinetd.

# apt install xinetd

On ajoute un service à xinetd, dans /etc/xinetd.d/mysqlchk (droits: root:root 0644) :

service mysqlchk
{
        flags           = REUSE
        socket_type     = stream
        port            = 8306
        wait            = no
        user            = root
        server          = /root/mysqlchk
        log_on_failure  += USERID
        disable         = no
        only_from       = 192.0.2.0/27 10.0.0.1/24
        per_source      = UNLIMITED
}

Il faut penser à ajuster la liste d’adresses IP autorisées dans only_from. On peut utiliser IP, des plages… séparées par des espaces.

On ajoute la ligne suivante dans /etc/services :

mysqlchk    8306/tcp            # mysqlchk

On crée le script à exécuter dans /root/mysqlchk (droits: root:root 0750) :

#!/bin/sh

# Mysql is down, return a 503
return="503 Service Unavailable"

# Mysql is fine, return a 200
/usr/lib/nagios/plugins/check_mysql -f /etc/mysql/debian.cnf >/dev/null && return="200 OK"

# Mysql is up but replication is not ok, return a 404
# You may want to comment this line in master/master mode
# It disable server (NOLB status) when replication is down or lagging
/usr/lib/nagios/plugins/check_mysql -f /etc/mysql/debian.cnf --check-slave -c 60 >/dev/null || return="404 Not Found"

cat <<EOF
HTTP/1.0 ${return}
Content-Type: Content-Type: text/plain
Content-Length: 0

EOF

On redémarre xinetd (surveiller /var/log/syslog pour d’éventuelles erreurs) et on pense à autoriser le port 8306 au niveau firewall depuis les IP concernées.

Il est également possible d’utiliser tout programme ou script, pourvu qu’au final il puisse être accessible en HTTP.

Ajustement dynamique

Le propos est d’utiliser l’option agent-check pour pouvoir ajuster dynamiquement le poids ou les états des membres d’un backend.

On utilisera ici xinetd pour pouvoir interroger la charge de chacun des membres à intervale régulier

frontend fe_www
    bind :80
    bind :443 ssl crt /etc/ssl/haproxy/ alpn h2,http/1.1
    option  forwardfor
    default_backend be_www

backend be_www
    balance roundrobin
    option httpchk OPTIONS *
    server www01 192.0.2.1:80 check agent-check agent-inter 5s agent-addr 192.0.2.1 agent-port 9999
    server www02 192.0.2.2:80 check agent-check agent-inter 5s agent-addr 192.0.2.2 agent-port 9999
    server www03 192.0.2.3:80 check agent-check agent-inter 5s agent-addr 192.0.2.3 agent-port 9999

Un moyen simple (inspiré de ce blog post) est de créer un script qui sera déclenché par xinetd.

# apt install xinetd

On ajoute un service à xinetd, dans /etc/xinetd.d/haproxy-agent-check (droits: root:root 0644) :

service haproxy-agent-check
{
        disable         = no
        flags           = REUSE
        socket_type     = stream
        port            = 9999
        wait            = no
        user            = nobody
        server          = /usr/local/bin/haproxy-agent-check
        log_on_failure  += USERID
        only_from       = 192.0.2.0/27
        per_source      = UNLIMITED
}

Il faut penser à ajuster la liste d’adresses IP autorisées dans only_from.

On ajoute la ligne suivante dans /etc/services :

haproxy-agent-check 9999/tcp                # haproxy-agent-check

On crée le script à exécuter dans /usr/local/bin/haproxy-agent-check (droits: root:root 0755) :

#!/bin/bash
LMAX=90

load=$(uptime | grep -E -o 'load average[s:][: ].*' | sed 's/,//g' | cut -d' ' -f3-5)
cpus=$(grep processor /proc/cpuinfo | wc -l)

while read -r l1 l5 l15; do {
    l5util=$(echo "$l5/$cpus*100" | bc -l | cut -d"." -f1);
    [[ $l5util -lt $LMAX ]] && echo "up 100%" && exit 0;
    [[ $l5util -gt $LMAX ]] && [[ $l5util -lt 100 ]] && echo "up 50%" && exit 0;
    echo "drain";
}; done < <(echo $load)

exit 0

On redémarre xinetd (surveiller /var/log/syslog pour d’éventuelles erreurs) et on pense à autoriser le port 9999 au niveau firewall depuis les IP concernées.

Les valeurs renvoyées peuvent être les suivantes :

  • Un % entier (ex 75%) pour l’ajustement du poids
  • La chaîne maxconn: suivie d’un entier pour spécifier le nombre max de connexions
  • Les mot ready, drain, maint, down et up pour modifier les états

On notera que seuls les algorithmes d’équilibrage dynamiques (roundrobin et leastconn) permettront l’ajustement du poids via agent-check. Dans le cas de l’utilisation d’un algorithme statique comme source par exemple, seules des opérations telles que le passage en DRAIN, DOWN, MAINT et UP seront possibles.

HTTP basic authentication

Pour mettre en place une authentification HTTP basique au niveau d’HAProxy, définir dans la section globale une liste d’utilisateur, soit avec un mot de passe en clair soit avec un mot de passe chiffré :

userlist NomDeMaUserList
user user1 insecure-password passwordEnClair
user user2 password $6$passwordSHA512
[…]

Dans le backend concerné rajouter :

auth AuthOkayPourMonSite http_auth(NomDeMaUserList)
http-request auth realm Texte if !AuthOkayPourMonSite

Redirection d’un domaine

acl domaine hdr(host) -i domaine1.com
redirect prefix http://www.domaine2.com code 301 if domaine

Ainsi, il fait la redirection si l’acl domaine correspond au domaine spécifié.

Résolution DNS et nombre dynamique de serveurs

À partir de HAProxy 1.8

Lorsqu’on ne connait pas l’adresse IP d’un serveur on peut donner à HAProxy un nom de domaine.

Il faut alors ajouter une section resolvers pour indiquer à HAProxy comment faire la résolution. Par défaut nous conseillons de reprendre la même configuration que dans /etc/resolv.conf (par exemple 192.168.10.1 sur le port 53), mais il est possible d’indiquer d’autres resolveurs.

resolvers mydns
    nameserver self 192.168.10.1:53
    nameserver google 8.8.8.8:53
    nameserver quad9 9.9.9.9:53
    nameserver cloudflare 1.1.1.1:53

backend myback
    balance roundrobin
    server www01 www01.example.com:80 check resolvers mydns
    server www02 www02.example.com:80 check resolvers mydns

À partir de la verison 2.0 il est possible d’iniquer l’utilisation automatique du resolveur configuré dans /etc/resolv.conf :

resolvers mydns
    parse-resolv-conf

Lorsque le nom de domaine indiqué comporte plusieurs adresse IP et qu’on veut avoir autant de serveurs disponibles que d’IP disponibles, on peut utiliser server-template. Exemple avec un domaine qui aurait 4 IP résolues :

backend myback
    balance roundrobin
    server-template example 4 example.com:80 check init-addr none resolvers mydns

La section resolvers peut être configurée avec plusieurs paramètres pour indiquer les timeout, nombres de tentatives, comportement en cas d’erreur…

Optimisations TCP/kernel

Sur un serveur servant principalement de proxy/load-balancer, nous conseillons plusieurs optimisations TCP/kernel, à appliquer au niveau “sysctl” :

# Augmentation du nombre de connexions simultanées possibles
net.netfilter.nf_conntrack_max=1000000
# Réduction du délai de timeout FIN
net.ipv4.tcp_fin_timeout=20
# Elargissement de la plage de ports locaux utilisables
net.ipv4.ip_local_port_range=1025 65534
# augmentation du nombre maximum d'orphelins
net.ipv4.tcp_max_orphans=65536

Désactiver/Activer un serveur en CLI

# echo disable server <backend>/<server> | socat stdio /var/run/haproxy.sock
# echo enable server <backend>/<server> | socat stdio /var/run/haproxy.sock

Afficher le status des FRONTEND/BACKEND

# echo "show stat" | socat stdio unix-connect:/var/run/haproxy/admin.sock | cut -d ',' -f1,2,18

Debug

# echo "show info" | socat stdio /var/run/haproxy.sock
# echo "show acl" | socat stdio /var/run/haproxy.sock
# echo "show acl #<ID>" | socat stdio /var/run/haproxy.sock