haproxyconf-2022/README.fr.md

23 KiB
Raw Permalink Blame History

haproxyconf-2022

Le but de cette présentation est de montrer comment on peut utiliser HAProxy et Varnish ensemble pour accélérer et fiabiliser des sites et applications web.

Nous allons commencer par les principes généraux et plonger petit-à-petit dans les détails.

Pourquoi HAProxy et Varnish ?

Il s'agit de 2 logiciels libres, performants et matures. Ils ne sont pas les seuls dans leur catégorie, mais ils sont ceux que nous connaissons et apprécions le plus. Ils sont facilement disponibles sur de nombreuses plateformes. Ils sont très bien documentés et soutenus par des vastes communautés. Ils disposent aussi de possibilités de support professionnel pour les besoins les plus exigeants.

HAProxy

HAProxy va donc servir de 1er serveur web pour le client HTTP. Il va faire la terminaison SSL/TLS, décider si la requête doit être acceptée ou rejetée et enfin il va transmettre la requête au serveur (singulier) ou serveurs (pluriel) web finaux. Il sert donc de proxy, avec des capacités de répartiteur de charge et de tolérance de panne.

Pour rappeler très succinctement les principes de base de HAProxy, on peut mentionner les frontend, les backend et le traitement intermédiaire.

Un frontend est un point d'écoute, en TCP ou HTTP, sur un ou plusieurs couples IP:PORT, avec une configuration particulière pour les logs, les timeouts, les options, les protocoles supportés. Il peut y avoir plusieurs frontend, pour avoir des configurations et traitements différents selon les besoins ; par exemple un frontend pour un site web et un autre pour une API dédiée aux applications tierces.

Grace à une analyse et une compréhension complète des protocoles — TCP, mais surtout HTTP — HAProxy peut altérer la requête ou la réponse, décider de la transmettre à telle ou telle entité… Tout ça se fait de manière très optimisée.

Un backend est un point de sortie du trafic. Il doit y en avoir un pour chaque groupe de serveurs pouvant prendre un erequête en charge. S'il y a un seul serveur dans un backend, il traitera toutes les requêtes. S'il y en a plusieurs, ils se répartiront les requêtes, selon l'algorithme de répartition choisi. C'est là qu'on commence à parler de répartition de charge. Et lorsqu'on indique à HAProxy comment gérer la défaillance d'un serveur pour envoyer la requête ailleurs, ça devient de la tolérance de panne.

Varnish

Varnish va servir à mettre en cache le résultat de certaines requêtes pour ne pas avoir à solliciter le serveur applicatif final. Il va appliquer des règles plus ou moins complexes pour décider s'il met en cache,s'il sert depuis le cache…

Varnish propose par défaut un fonctionnement courant et relativement conventionnel qui nécessite généralement peu de configuration. Il y a des bonnes pratiques qui s'appliquent bien à la plupart des situations. Les étapes clés du traitement dune requête-réponse HTTP sont gérées par des fonctions dédiées, quil est possible de personnaliser et d'empiler/composer pour modifier le comportement.

Varnish expose toute la requête et la réponse aux fonctions afin de décider sil faut modifier le contenu (ajouter/supprimer/modifier des en-têtes), servir une réponse depuis le cache ou la transmettre au serveur, mettre en cache ou pas la réponse dun serveur…

Varnish stockant toutes les données de cache en mémoire, il est aussi important de décider selon quel algorithme décider ce qui doit être détruit lorsque la capacité maximale est atteinte…

Comment combiner HAProxy et Varnish

Nous avons donc choisir de mettre ces 2 logiciels ensemble, de manière coordonnée, pour un résultat performant et riche en fonctionnalités.

Comme dit précédemment, HAProxy utilise des frontend comme point d'entrée et des backends commepoint de sortie.

Dans notre cas, après avoir pris en charge la requête du client HTTP, HAProxy va la transmettre à Varnish, via un backend spécifique. Nous avons choisi de placer HAProxy et Varnish sur le même serveur, et de les faire communiquer par socket, mais c'est un détail et rien n'empêche de les mettre sur des serveurs différents.

Une fois la requête transmise à Varnish, il va également l'analyser pour décider s'il peut servir une réponse depuis le cache ou s'il doit la faire passer. Et s'il l'a fait passer, lors du retour il va décider s'il peut la mettre en cache.

Dans le cas le plus courant d'utilisation de Varnish, lorsqu'il doit faire passer une requête, il le fait directement au serveur d'application (lui aussi appelle ça un backend). Nous avons choisi de faire un peu différemment et de ne pas gérer dans Varnish la partie load-balancing et tolérance de panne, car elle est gérée de manière plus complète par HAProxy, et c'est ce que nous maitrisons le mieux.

Varnish va donc renvoyer la requête à HAProxy, le même que celui qui a traité la requête entrante. Mais pour ne pas tomber dans une boucle, et pour ne pas refaire certaines analyses déjà faites au début, nous faisons rentrer la requête par un autre frontend. Celui-ci sera plus simple et aura comme principale responsabilité de choisir le backend final à utiliser pour la requête. Si vous gérez des sites ou applications qui sont réparties sur des groupes de serveurs différents, il faut avoir autant de backend que de groupes de serveurs.

Au final on passe donc la requête aux serveurs web qui vont effectivement traiter la requête.

La réponse fera ensuite le chemin inverse, en revenant à HAProxy, puis Varnish (qui décidera s'il met la réponse en cache), puis HAProxy puis le client HTTP qui a fait initialement la requête.

Chaîne :

  1. HAProxy frontend « external » (général)
  2. HAProxy backend « varnish »
  3. Varnish
  4. HAProxy frontend « internal » (général)
  5. HAProxy backend « site X » (par site)
  6. Web-server

Multi-sites

Même en faisant passer tout le trafic entrant (http/https) par un seul frontend, il est tout à fait possible d'avoir des serveurs finaux spécifiques pour chaque site, ainsi que des traitements intermédiaires spécifiques aussi.

Il n'y a pas dans HAProxy de logique ressemblant aux VirtualHost de Apache ou Nginx, mais on peut utiliser des primitives conditionnelles plus bas-niveau pour cela. On crée (au moins) une ACL pour détecter si l'hôte demandée est lié à tel ou tel site, et ensuite on utilise ces ACL pour conditionnement un comportement. Une ACL est comme une variable booléenne.

frontend external
    acl example_com_domains hdr(host) -i example.com
    acl foo_bar_domains     hdr(host) -i foo-bar.com foo-bar.org
    […]
    use_backend example_com if example_com_domains
    use_backend foo_bar     if foo_bar_domains

Transmettre la requête à Varnish

Dans la configuration de HAProxy, nous avons créé un backend « varnish », avec un seul serveur final.

backend varnish
    option httpchk HEAD /varnishcheck
    server varnish_sock /run/varnish.sock check observe layer7 maxconn 3000 inter 1s send-proxy-v2

L'option httpchk permet à HAProxy de vérifier l'état de santé de Varnish, au niveau 7.

Dans la configuration de Varnish on retrouve l'URL /varnishcheck :

sub vcl_recv {
    # HAProxy check
    if (req.url == "/varnishcheck") {
        return(synth(200, "Hi HAProxy, I'm fine!"));
    }
    […]
}

La réponse est extrêmement rapide et permet de faire une vérification chaque seconde.

Dans HAProxy, au niveau du frontend il y a une ACL qui permet de savoir si Varnish est disponible ou pas. Nous pouvons donc transmettre à Varnish s'il est dispo ou le contourner s'il est absent :

frontend external
    # Is the request routable to Varnish ?
    acl varnish_available   nbsrv(varnish) gt 0

    # Use Varnish if available
    use_backend varnish if varnish_available

    # … or use normal backend
    use_backend default_backend

backend varnish
    option httpchk HEAD /varnishcheck
    server varnish_sock /run/varnish.sock check observe layer7 maxconn 3000 inter 1s send-proxy-v2

backend default_backend
    server example-hostname 1.2.3.4:443 check observe layer4 ssl

Rien n'oblige à utiliser Varnish tout le temps et pour tous les sites.

On peut soit utiliser une ACL :

frontend external
    acl example_com_domains hdr(host) -i example.com
    […]
    use_backend varnish if example_com_domains

Soit utiliser un système plus dynamique, avec un fichier dans lequel on met les domaines pour lesquels on veut utiliser Varnish :

frontend external
    acl use_cache if hdr(host) -f /etc/haproxy/cached_domains
    […]
    use_backend varnish if use_cache

Nous pouvons économiser le passage par Varnish dans certains cas. Par exemple lorsque la requête n'est ni GET, HEAD ou PURGE :

frontend external
    acl varnish_http_verb   method GET HEAD PURGE
    […]
    use_backend varnish if varnish_http_verb

On peut donc combiner ces conditions, pour passer à Varnish ou le contourner :

  • le domaine doit-il être mis en cache ?
  • la requête est-elle éligible au cache?
  • Varnish est-il disponible ?

PROXY protocol

Pourquoi ?

L'utilisation du PROXY protocol n'est pas du tout indispensable, mais elle ajoute un confort significatif dans la gestion de toute cette chaîne.

Dans leur rôle de proxy intermédiaire, HAProxy et Varnish sont vus comme des clients au niveau TCP ; entre eux, mais surtout vis-à-vis du serveur web final. Si on ne fait rien de particulier,le serveur final verra toujours l'IP d'HAProxy comme IP du client. Ça rend impossible l'application dautorisations ou restrictions par IP. Ça rend difficile le suivi des requêtes, les statistiques…

Vous me direz qu'on a pour ça l'en-tête HTTP X-Forwarded-For, mais elle est invisible au niveau TCP, et sa prise en compte au niveau applicatif n'est pas toujours facile, voire possible.

Le PROXY protocol est une simple extension de TCP qui permet d'ajouter cette notion d'IP réelle du client. Cela impose que les 2 parties de l'échange sachent gérer cette extension, mais à partir de là il n'est plus besoin de le gérer dans la couche applicative.

Comment?

Au niveau HAProxy, nous retrouvons celà dans le backend qui transmet à Varnish, dans le frontend de retour depuis Varnish et éventuellement dans le backend qui transmet au serveur web final.

backend varnish
    server varnish_sock /run/varnish.sock check observe layer7 maxconn 3000 inter 1s send-proxy-v2

frontend internal
    bind /run/haproxy-frontend-default.sock user root mode 666 accept-proxy

backend example_com
    server example-hostname 1.2.3.4:443 check observe layer4 ssl verify none send-proxy-v2

Lorsqu'HAProxy est client on ajoute send-proxy-v2 et lorsqu'il est serveur on ajoute accept-proxy.

Pour Varnish, l'écoute se gère dans la ligne de commande de démarrage avec l'option PROXY :

/usr/sbin/varnishd […] -a /run/varnish.sock,PROXY […]

Et pour le retour vers HAProxy, c'est dans la configuration, avec l'option proxy_header = 1 :

backend default {
    .path = "/run/haproxy-frontend-default.sock";
    .proxy_header = 1;
    […]
}

Debug sans PROXY protocol

Bien que ça soit une optimisation très appréciable, elle n'est pas compatible avec de nombreux outils qui peuvent avoir besoin de se connecter à HAProxy ou Varnish de manière classique.

C'est particulièrement utile au niveau de Varnish pour faire du debug sans passer par HAProxy. On ajoute alors à la ligne de commande de démarrage un autre point d'entrée. Nous le faisons uniquement en local, sur un port inaccessible à l'extérieur :

/usr/sbin/varnishd […] -a 127.0.0.1:82 […]

Il devient alors très facile de faire une requête directe :

curl --verbose \
     --resolve www.example.com:82:127.0.0.1 \
     --header "X-Forwarded-Proto: https" \
     http://www.example.com:82/foo/bar

Alors oui, curl supporte le PROXY protocol depuis laversion 7.60.0 avec l'option --haproxy-protocol, mais ça ne supporte que la version 1, et puis je n'ai pas réussi à combiner ça avec une connexion sur socket Unix locale.

Jusqu'au serveur final

Si on contrôle les serveurs web finaux et qu'il ssont compatibles, on peut aussi faire la liaison finale avec le PROXY protocol. On sera alors aussi tenté d'ajouter au serveur web un port d'écoute sans PROXY protocol, et bien protégé.

Note: si vous utilisez Apache, je vous recommande d'étudier le module "ForensicLog" qui permet facilement un debug détaillé de tous les en-têtes échangés. C'est très pratique pour débug des chaînes de proxy et voir si tout arrive bien au serveur web final. Je ne sais pas s'il existe quelque chose de similaire pour Nginx.

HTTP tagging

Toujours dans une optique de traçabilité et de facilité de debug, nous nous servons de HAProxy et Varnish pour ajouter dans les en-têtes HTTP des informations sur leur fonctionnement.

Rappelons que la norme HTTP définit des en-têtes normalisés, dont la syntaxe et l'utilisation sont bien codifiés : Host, Set-Cookie, Cache-Control… Mais nous pouvons ajouter n'importe quel en-tête no standard avec un prefix X-. Certains sont quasi standards, d'autres sont totalement personnalisés.

X-Forwarded-*

Bien que nous utilisions le PROXY protocol en interne (et éventuellement vers certains serveurs finaux), nous conservons généralement l'en-tête HTTP X-Forwarded-For.

Nous ajoutons aussi X-Forwarded-Port pour indiquer à quel port de HAProxy le client s'est adressé. Et enfin nous ajoutons X-Forwarded-Proto pour indiquer si la connexion initiale était chiffrée ou pas.

frontend external
    bind 0.0.0.0:80,:::80
    bind 0.0.0.0:443,:::443 ssl […]

    option forwardfor

    http-request set-header X-Forwarded-Port  %[dst_port]

    http-request set-header X-Forwarded-Proto http  if !{ ssl_fc }
    http-request set-header X-Forwarded-Proto https if  { ssl_fc }

Ce dernier en-tête est souvent imortant lorsque la requête finale est utilisée par des framework qui impose un accès en https te ne peuvent pas s'appuyer sur le fait que la requête leur arrive au final en https.

X-Unique-ID

Il est très utile de marquer une requête entrante d'un identifiant unique qui pourra être transmis d'étape en étape jusqu'au serveur final (et pourquoi pas au retour aussi).

frontend external
    […]
    http-request set-header X-Unique-ID %[uuid()] unless { hdr(X-Unique-ID) -m found }

Il n'y a pas de réel consensus sur le nommage de l'en-tête. On trouve souvent X-Unique-ID ou X-Request-ID.

X-Boost-*

Boost est le nom que nous avons donné à notre système basé sur HAProxy et Varnish. Nous ajoutons donc plusieurs en-têtes X-Boost-* pour ajouter des informations utiles.

Step 1

Sur le frontend « external » nous marquons la requête comme étant passée en étape 1 par « haproxy-external ». Les autres étapes de la requête sauront alors que la requête est entrée par là.

Lorsque réponse resortira de ce backend pour aller au client, on la marque aussi pour indiquer que l'étape 1 était « haproxy-external » en précisant si la connexion était en http ou https. On indique aussi le nom du serveur Boost qui a traité la requête.

frontend external
    […]
    http-request add-header X-Boost-Step1 haproxy-external

    http-response add-header X-Boost-Step1 "haproxy-external; client-https" if  { ssl_fc }
    http-response add-header X-Boost-Step1 "haproxy-external; client-http"  if !{ ssl_fc }
    http-response set-header X-Boost-Server my-hostname

Step 2

Au niveau de Varnish, nous marquons la requête transmise pour indiquer qu'elle est passée par Varnish :

sub vcl_recv {
    […]
    set req.http.X-Boost-Layer = "varnish";
}

Lorsque la réponse est renvoyée, elle est marquée sur l'étape 2 de détails à propos des modalités de cache

sub vcl_deliver {
    […]
    if (resp.http.Set-Cookie && resp.http.Cache-Control) {
      set resp.http.X-Boost-Step2 = "varnish; set-cookie; cache-control";
    } elseif (resp.http.Set-Cookie) {
      set resp.http.X-Boost-Step2 = "varnish; set-cookie; no-cache-control";
    } elseif (resp.http.Cache-Control) {
      set resp.http.X-Boost-Step2 = "varnish; no-set-cookie; cache-control";
    } else {
      set resp.http.X-Boost-Step2 = "varnish; no-set-cookie; no-cache-control";
    }

Par ailleurs, Varnish ajoute par défaut de nombreux en-têtes sur l'utilisation ou non du cache, l'âge de la ressource…

Step 3

Au niveau du frontend « internal » on applique le même principe, mais en indiquant que c'est pour l'étape 3.

frontend internal
    […]
    http-request add-header X-Boost-Step3 haproxy-internal

    http-response add-header X-Boost-Step3 "haproxy-internal; SSL to backend" if  { ssl_bc }
    http-response add-header X-Boost-Step3 "haproxy-internal; no SSL to backend" if !{ ssl_bc }

Log HAProxy complet

Dans des situations avancées de debug, nous pouvons aussi activer l'ajout dans un en-tête HTTP de la totalité de la ligne de log :

frontend external
    http-response add-header X-Haproxy-Log-External "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r"

frontend internal
    http-response add-header X-Haproxy-Log-Internal "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r"

⚠️ Il vaut mieux ne pas activer cela en production, mais ça peut être très utile pour permettre à un client en mode test/préprod de vérifier comment se comporte le proxy.

Haute disponibilité

Côté applicatif

Lorsque le site ou l'application web finale dispose de plusieurs serveurs pour gérer les requêtes, on peut prendre en charge directement dans HAProxy la répartition de charge. Lorsque c'est possible, on fait de préférence un round-robin sur les serveurs web. Lorsque l'application gère mal les sessions ou des aspects bloquants, on met un serveur en actif et les autres en backup, pour basculer sur un secours si le primaire n'est plus disponible.

Côté HAProxy

Pour éviter que HAProxy + Varnish ne soient un « Single Point Of Failure », nous avons mis en place 2 serveurs différents dans 2 réseaux différents et nous faisons un round-robin DNS en amont. Cette approche n'est pas la plus avancée, car en cas de panne d'un serveur il faut intervenir au niveau DNS. Cependant elle a le mérite de la simplicité et les pannes complète de machines virtuelles tournant sur de la virtualisation redondante sont heureusement très rares. En revanche, ce système nous permet pas mal de souplesse.

Il serait aussi possible de faire du « actif-passif » en mettant une IP flottante (keepalived/vrrp) en amont de deux serveurs, pour avoir une bascule automatique sur le secondaire en cas de panne du primaire. On aller encor eplus loin et faire du « actif-actif » avec 2 HAProxy en « layer 4 » qui renvoient ensuite sur 2 HAproxy en « layer 7 » Ces approches permettent une reprise d'activité quasi immédiate en cas de panne, mais elles impliquent plus de ressources et une topologie réseau particulière.

Fonctionnalités complémentaires

Filtrage au niveau HAProxy

Dans le frontend « external » qui gère le trafic entrant, nous pouvons vérifier si le client doit être immédiatement rejeté, par exemple selon son adresse IP :

frontend external
    […]
    # Reject the request at the TCP level if source is in the denylist
    tcp-request connection reject if { src -f /etc/haproxy/deny_ips }

Cela ne remplace pas un vrai firewall, mais ça permet de facilement exclure des client au niveau TCP (couche 4).

En analysant la requête au niveau HTTP (couche 7), on peut filtrer de manière beaucoup plus fine.

On peut exiger une authentification basée sur une liste d'utilisateur ou de groupes, ou encore des redirectionsvers https ou d'autres domaines.

userlist vip_users
    user johndoe password $6$k6y3o.eP$JlKBx9za9667qe4(…)xHSwRv6J.C0/D7cV91

frontend external
   […]
   redirect scheme https code 301 if !{ ssl_fc }
   redirect prefix https://example-to.org code 301 if { hdr(host) -i example-from.org }
   http-request auth realm "VIP Section" if !{ http_auth(vip_users) }

Mode maintenance

Le mode maintenance est une sorte de coupe-circuit qui permet de basculer toute l'installation, ou juste un site web en mode maintenance.

Pour la coupure globale, on utilise un backend spécial qui ne définit pas de serveur web final et provoquera toujours une erreur 503. On peut aussi définir une page d'erreur particulière pour cette situation. Nous avons fait le choix de ne pas logguer les requêtes lorsque ce mode et activé.

frontend external
    […]
    # List of IP that will not go the maintenance backend
    acl maintenance_ips src -f /etc/haproxy/maintenance_ips
    # Go to maintenance backend, unless your IP is whitelisted
    use_backend maintenance if !maintenance_ips

backend maintenance
    http-request set-log-level silent
    # Custom 503 error page
    errorfile 503 /etc/haproxy/errors/maintenance.http
    # With no server defined, a 503 is returned for every request

Pour une coupure par site, il faut définir un backend spécial pour ce site et activer l'utilisation de ce backend pour les requêtes. On retrouve notre ACL par domaine.

frontend external
    […]
    acl example_com_domains hdr(host) -i example.com

    acl maintenance_ips src -f /etc/haproxy/maintenance_ips
    acl example_com_maintenance_ips src -f /etc/haproxy/example_com/maintenance_ips

    use_backend example_com_maintenance if example_com_domains !example_com_maintenance_ips !maintenance_ips

Services locaux

Pour les outils locaux de monitoring (exemple: Munin) ou pour les challenges ACME, il peut être utile de renvoyer sur un serveur web local (exemple: Apache ou Nginx), au lieu de renvoyer sur Varnish ou les serveurs web appliatifs.

frontend external
    […]
    # Is the request coming for the server itself (stats…)
    acl server_hostname hdr(host) -i my-hostname
    acl munin           hdr(host) -i munin

    # Detect Let's Encrypt challenge requests
    acl letsencrypt path_dir -i /.well-known/acme-challenge

    use_backend local       if server_hostname
    use_backend local       if munin

    use_backend letsencrypt if letsencrypt

backend letsencrypt
    # Use this if the challenge is managed locally
    server localhost 127.0.0.1:81 send-proxy-v2 maxconn 10
    # Use this if the challenge is managed remotely
    ### server my-certbot-challenge-manager 192.168.2.1:80 maxconn 10

backend local
    option httpchk HEAD /haproxy-check
    server localhost 127.0.0.1:81 send-proxy-v2 maxconn 10

Personnalisation par site

On peut définir des ACL pour chaque site et ainsi provoquer des comportements lorsqu'une requête est destinée à ce site.

Par exemple, les redirections http vers https, ou pour des sous-domaines, les en-têtes HSTS