K3s : Cluster Kubernetes single-node en homelab

par

dans

Un cluster Kubernetes de homelab n'a pas besoin de trois nœuds de contrôle et d'un etcd répliqué. K3s tient dans un binaire unique et fait tourner l'ensemble des services web du homelab sur une seule VM. Voici ce qu'il y a dedans, et les trois pièges qui coûtent une soirée chacun.

L'architecture en un coup d'œil

Le cluster tourne sur une VM KVM provisionnée par Proxmox, sur un VLAN dédié routé par le pare-feu à zones du routeur principal. Le TLS est terminé en amont par HAProxy : Traefik ne reçoit que du HTTP interne.

Il est monté par un playbook Ansible, k3s-deploy.

flowchart TB
    subgraph entree["Entrée"]
        TFK["Traefik<br/>Gateway API"]
        CS["CrowdSec<br/>middleware"]
    end
    subgraph sites["Sites web"]
        WAF["waf<br/>inspection + routage"]
        VRN["varnish<br/>cache"]
        WP["jbsky-fr-production<br/>WordPress"]
    end
    subgraph socle["Socle"]
        LH["longhorn-system<br/>stockage"]
        CM["cert-manager<br/>certificats"]
        LOG["logging<br/>journaux"]
        RUN["gitea-runner<br/>intégration"]
    end
    TFK --> CS --> WAF --> VRN --> WP
    WP --> LH

Le chemin d'une requête depuis Internet — terminaison TLS, re-chiffrement, alignement des timeouts — est traité dans le reverse proxy. Ce qui suit ne regarde que l'intérieur du cluster.

Ce qui tourne

L'isolation se fait par namespace, un par fonction :

Namespace Contenu
jbsky-fr-production WordPress : nginx, php-fpm, Redis, deux exporters — chart Helm
waf l'entrée unique des sites web : un server_name par domaine, et ModSecurity réglé domaine par domaine
varnish le cache HTTP, en StatefulSet
crowdsec l'API locale et l'agent qui lit les journaux
cert-manager les certificats, émis par DNS-01
longhorn-system le stockage
logging la collecte de journaux
gitea-runner l'exécuteur de la chaîne d'intégration

Le reste du cluster héberge quelques services annexes — un agenda CalDAV, un whoami de diagnostic — dans leurs propres namespaces.

Cette séparation n'est pas cosmétique : elle porte les quotas de ressources et les règles réseau, qui s'appliquent par namespace.

Longhorn : le volume est l'unité qui compte

Même sur un nœud unique, Longhorn apporte ce que le stockage local du nœud ne donne pas : des snapshots pris sans interrompre le service, une cible de sauvegarde externe (un partage NFS), et une restauration qui ne demande pas de reconstruire le pod.

Le choix a une conséquence directe sur la sauvegarde. Les images du parc sont FROM scratch : il n'y a ni tar, ni shell, ni rien pour lire les fichiers depuis l'intérieur du conteneur. Un kubectl exec … tar cf - n'a nulle part où s'exécuter. La sauvegarde se fait donc au niveau du volume, pas au niveau applicatif — ce qui est de toute façon le bon niveau : le volume contient l'état, l'image contient le code, et le code se reconstruit.

Un seul volume persistant porte tout wp-content : les uploads, les extensions et les thèmes. Le cœur de WordPress, lui, n'est pas sauvegardé — il est retéléchargé à chaque démarrage du pod.

L'ingress : Gateway API plutôt qu'Ingress

K3s embarque Traefik. La configuration s'appuie sur les CRDs Gateway API — un HTTPRoute par service, avec ses règles de correspondance sur l'hôte et le chemin — plutôt que sur les objets Ingress, dont les annotations propres à chaque contrôleur rendaient la configuration non portable.

Les middlewares Traefik s'insèrent avant les backends : limitation de débit, en-têtes de sécurité, et le bouncer CrowdSec qui refuse les adresses déjà signalées avant même que la requête n'atteigne le WAF.

Le cluster se construit tout seul

Rien de tout cela n'est appliqué à la main. K3s applique au démarrage ce qu'il trouve dans /var/lib/rancher/k3s/server/manifests/ : le playbook y dépose Traefik, CrowdSec et cert-manager, et le cluster converge de lui-même — pas de kubectl apply, pas d'ordre de passage à respecter, pas d'état à réconcilier après une réinstallation.

Traefik mérite une précision. Il est désactivé dans la configuration de K3s, puis redéployé par un manifeste à nous. C'est ce qui permet de choisir sa version et sa configuration au lieu de subir celles de la distribution — et d'y brancher le bouncer CrowdSec.

Trois pièges qui coûtent une soirée

Un ResourceQuota rend requests et limits obligatoires

Dès qu'un ResourceQuota est actif dans un namespace, tout pod doit déclarer ses requests et ses limits CPU et mémoire. Sans elles, l'admission controller refuse le pod avec un forbidden: failed quota.

Le piège n'est pas le refus, il est dans son moment : le quota a été posé il y a des mois, le déploiement qu'on ajoute aujourd'hui est refusé, et le message ne dit pas que la cause est un quota posé ailleurs.

Un ConfigMap ne se met à jour que dans un seul des deux modes

Un ConfigMap se monte de deux façons, et elles ne se comportent pas pareil :

Montage Effet kubelet met-il à jour ?
Le volume entier sur un répertoire remplace le contenu du répertoire oui, en une minute environ
Un fichier avec subPath dépose un seul fichier sans toucher au reste non, jamais

Les deux coexistent dans un même pod, souvent dans le même volumeMounts. Le WAF de ce cluster en est l'exemple : son répertoire conf.d est monté en entier et suit donc les modifications, tandis que son nginx.conf et ses règles sont posés en subPath et restent figés jusqu'à la recréation du pod.

Ce qui rend le piège coûteux, c'est qu'il ne produit aucune erreur :

  • kubectl describe configmap montre bien la nouvelle valeur ;
  • le fichier dans le pod contient toujours l'ancienne ;
  • le service tourne, sans rien signaler.

Et la mise à jour du fichier ne suffit pas non plus. Un serveur lit sa configuration au démarrage : nginx qui voit arriver un nouveau fichier dans conf.d continue d'appliquer les règles qu'il a en mémoire. Les deux modes finissent donc au même endroit — un kubectl rollout restart — l'un parce que le fichier n'a pas bougé, l'autre parce que personne ne l'a relu.

Un tag réutilisé ne redescend jamais

Avec imagePullPolicy: IfNotPresent, une image déjà présente dans le cache du nœud n'est pas retéléchargée — même si le registre a reçu une nouvelle version sous le même tag. Le pod redémarre, le correctif n'est pas là, et rien ne l'indique.

La parade tient en deux règles : ne jamais republier sous un tag existant, et faire porter à chaque image un tag qui ne désigne qu'un seul build. Les images du parc en publient un pour ça, suffixé d'un compteur de révision ; c'est le seul qu'un manifeste doit épingler.


Liens

Articles connexes