Passer au contenu principal

Kubernetes Management

Ingress : Protocole ACME derrière un Nginx

J'ai un problème récurrent pour exposer mes applications hébergées sur Kubernetes.
Prenons par exemple Rancher avec une architecture simple en HA :

  • L'application est hébergée sur un cluster RKE2 composé de 3 noeuds, avec tous les rôles (le problème ne changerait pas s'ils étaient séparés)
  • Le contrôleur Ingress expose les ports 80/443 sur tous les noeuds.
L'architecture est simple, 3 noeuds, une application, 1 service, 1 Ingress

Pour garantir que le service reste fonctionnel même en cas de perte d'un noeud, le contrôleur Ingress est doit être exposé par un Load Balancer (et idéalement, le Load Balancer lui-même doit être en HA).

Il existe plusieurs configurations possible selon l'environnement, toutes plus ou moins coûteuse et plus ou moins flexibles.

On parlera ici d'environnement OnPremise avec un load balancer Nginx, donc :

SSL Termination

Par défaut, Nginx se charge de la terminaison SSL/TLS.

Cela signifie qu'il se charge du chiffrement entre le client et l'application.
Bien que possible et toujours recommandé, le chiffrement n'est alors pas obligatoire entre le load balancer et la destination.
En revanche, le load balancer ne vérifie l'authenticité du certificat de l'application qui est très souvent auto-signé, puisque jamais exposé aux clients.

Cette méthode fonctionne dans 90% des cas, mais demande parfois à ce que l'application s'adapte quand le protocole n'est pas du simple HTTPS.

Exemple

Prenons l'exemple de notre Rancher, SUSE décrit la configuration recommandée ici .

We recommend configuring your load balancer as a Layer 4 balancer, forwarding plain 80/tcp and 443/tcp to the Rancher Management cluster nodes. The Ingress Controller on the cluster will redirect http traffic on port 80 to https on port 443.
You may terminate the SSL/TLS on a L7 load balancer external to the Rancher cluster (ingress). Use the --set tls=external option and point your load balancer at port http 80 on all of the Rancher cluster nodes. This will expose the Rancher interface on http port 80. Be aware that clients that are allowed to connect directly to the Rancher cluster will not be encrypted. If you choose to do this we recommend that you restrict direct access at the network level to just your load balancer.

Un détail avec Rancher, c'est qu'il ne sert aussi de endpoint à kubectl, et kubectl embarque le certificat du CA dans sa configuration. Dans le cas de Let's Encrypt, cela signifie qu'il faut donner le certificat du CA ACME dans un secret, un peu comme pour un certificat commercial payant traditionnel.

Ce scénario de configuration est donc prévu et il implique (et c'est normal) que tous les services doivent contacter Rancher par le biais d'un Load Balancer externe. Dans certaines configurations et pour certaines applications, cela peut poser problème et on est alors contraint d'intégrer le certificat au service Ingress. C'est viable pour un certificat à l'année, mais pas forcément avec un certificat Let's Encrypt de 3 mois.

💡
Bien évidemment, il existe multitude de solution pour ce type de scénario, je parle ici d'une solution dans un contexte bien précis : Nginx en frontal + Let's Encrypt + Kubernetes

Autre détail, la majorité des contrôleurs Ingress sont en mesure de demander et renouveler leurs certificats automatiquement avec cert-manager, à partir de la configuration de la ressource Ingress, là où avec un load balancer externe il est nécessaire de déployer une configuration secondaire (manuelle ou automatique, selon les outils en vigueur). On perd donc un peu l'avantages de cette solution.

D'où le sujet de ce post, que se passe-t-il si l'on veut laisser le cluster Kubernetes géré le renouvellement du certificat Let's Encrypt ?

Let's Encrypt

Si Nginx termine les connexions SSL, alors c'est au serveur hébergeant Nginx de requêter et renouveler le certificat, par exemple avec certbot.

Le protocole de certification de Let's Encrypt est très décrit sur leur site.

How It Works
The objective of Let’s Encrypt and the ACME protocol is to make it possible to set up an HTTPS server and have it automatically obtain a browser-trusted certificate, without any human intervention. This is accomplished by running a certificate management agent on the web server. To understand how the technology works, let’s walk through the process of setting up https://example.com/ with a certificate management agent that supports Let’s Encrypt. There are two steps to this process.

En bref, il s'agit d'un protocol permettant de vérifier que le nom de domaine appartient bel et bien au serveur qui requête le certificat.

Version simple, dans les grandes lignes :

  • LE = Let's Encrypt
  • Le serveur fait une requête pour un certificat comme rancher.example.com
  • LE et le serveur Web se mettent d'accord sur une valeur précise à exposer sur le port HTTP du serveur Web.
  • Le serveur Web monte alors un service en HTTP et expose la valeur indiquée.
  • LE requête alors rancher.example.com et vérifie que tout est conforme.
  • Si oui, il signe et délivre le certificat.

Dans le cas d'un load balancer externe à K8S, le serveur Web est Nginx et c'est lui qui hébergera le certificat. Le contrôleur Ingress et son gestionnaire de certificat, Traefik + Cert Manager par exemple, ne sera pas en mesure de faire une demande de certificat.

Passthru

L'alternative peut être d'utiliser un load balancer L4 type HAProxy, mais on perd la capacité de faire du vhost (peut-être qu'il y a une alternative, mais ce n'est pas le sujet ici).

Dans notre scénario, on doit donc terminer SSL sur le contrôleur Ingress et donc faire du passthrough. Le traffic n'est donc pas déchiffré par Nginx et retransmis directement à la destination.

Il y a un module précis pour ce mode opératoire :

Module ngx_stream_ssl_preread_module

L'exemple ci-dessous permet de mapper un fqdn à un upstream.

Ici on map backend.example.com aux deux serveurs définis dans l'upstream backend.

map $ssl_preread_server_name $name {
    backend.example.com      backend;
    default                  backend2;
}

upstream backend {
    server 192.168.0.1:12345;
    server 192.168.0.2:12345;
}

upstream backend2 {
    server 192.168.0.3:12345;
    server 192.168.0.4:12345;
}

server {
    listen      12346;
    proxy_pass  $name;
    ssl_preread on;
}

Cette configuration est séparée de celle des vhost et doit se trouver dans une section stream directement dans nginx.conf.

L'exemple ci-dessous est un fichier nginx.conf standard avec la section stream en question.

user www-data;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

worker_processes 4;
worker_rlimit_nofile 40000;
events {
	worker_connections 8192;
}

http {
	sendfile on;
	tcp_nopush on;
	types_hash_max_size 2048;
	include /etc/nginx/mime.types;
	default_type application/octet-stream;
	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;
	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;
	gzip on;
	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;
}


stream {
    # Votre configuration ici
}

Configuration

Adaptons la configuration à notre besoin, en tenant compte du fait que notre Nginx a peut-être déjà une configuration standard avec des vhosts.

Dans ce cas, il est nécessaire de séparer le service, par exemple sur une autre IP public (et même privée, selon le besoin).

Server

Les blocs server indique à Nginx d'écouter sur les ports 80 et 443.
Dans mon cas, je passe tout le traffic HTTP/HTTPS à un map,on note la présence de la directive ssl_preread permet d'extraire les informations du client TLS source sans terminer la connexion SSL/TLS.

server {
		listen votre.ip.public:80;
		listen votre.ip.interne:80;
		proxy_pass $name;
}

server {
		listen votre.ip.public:443;
		listen votre.ip.interne:443;
		proxy_pass $name;
		ssl_preread on;
}

Upstream

Les blocs upstream permettent de définir un ensemble de route possible pour un service défini.
On définit ici deux blocs, un par protocole, pointant vers les serveurs RKE2 hébergeant Rancher.

upstream rancher_servers_http {
		server 192.168.0.1:80 max_fails=3 fail_timeout=5s;
		server 192.168.0.2:80 max_fails=3 fail_timeout=5s;
		server 192.168.0.3:80 max_fails=3 fail_timeout=5s;
}	

upstream rancher_servers_https {
		least_conn;
		server 192.168.0.1:443 max_fails=3 fail_timeout=5s;
		server 192.168.0.2:443 max_fails=3 fail_timeout=5s;
		server 192.168.0.3:443 max_fails=3 fail_timeout=5s;
}

Map

La partie vhost est elle gérée au travers des variables recueillie par nginx.

$ssl_preread_server_name contient le fqdn de la requête, extraite par le module ssl_preread.

$hostname contient la même chose, mais pour HTTP.

map $hostname $name {
        rancher.prod.zenops.fr rancher_servers_http;
}
    
map $ssl_preread_server_name $name {
    rancher.example.com rancher_servers_https;
}

La connexion est alors chiffrée de bout en bout, et Nginx redirige le traffic vers les serveurs upstream définit dans son bloc map.

Exemple avec un map supplémentaire vers argocd

On peut donc ainsi ajouter des map et upstreams supplémentaire pour rediriger le traffic à destination d'une application vers un autre cluster K8S.

map $hostname $name {
        rancher.prod.zenops.fr rancher_servers_http;
        argocd.prod.zenops.fr	zenops_hetzner_apps_http;
}

map $ssl_preread_server_name $name {
        rancher.prod.zenops.fr rancher_servers_https;
        argocd.prod.zenops.fr	zenops_hetzner_apps_https;
}


...

upstream zenops_hetzner_apps_http {
        least_conn;
        server ip.controller.ingress:80 max_fails=3 fail_timeout=5s;
        
}		
upstream zenops_hetzner_apps_https {
        least_conn;
        server ip.controller.ingress:443 max_fails=3 fail_timeout=5s;
}	

...

Donc mon cas, j'utilise MetalLB pour distribuer une IP à Traefik, la configuration est donc assez simple et on peut même router directement le traffic interne vers Traefik. La configuration du Nginx n'est alors là que pour Let's Encrypt.

Pour en apprendre davantage sur l'intégration de Traefik et Let's Encrypt :

https://tech.zenops.fr/guides/traefik-lets-encrypt-integration/