Le contexte
Mon homelab tourne sur un seul serveur physique : un vieux i7 avec 32 Go de RAM.
Dessus, j’ai monté un cluster Kubernetes avec microk8s. Pourquoi ce choix ? Parce qu’il passe facilement à l’échelle, que la communauté est énorme et qu’il existe un outil pour tout : logs, sécurité, sauvegardes. Il peut aussi fonctionner en mode hybride, avec mon nœud local d’un côté et quelques nœuds dans le cloud de l’autre, si un jour j’en ai besoin.
Je n’ai pas commencé avec trois conteneurs Docker pour migrer plus tard. Je suis passé directement à Kubernetes.
Aujourd’hui, ça représente 28 services répartis dans 16 namespaces. Sur un seul nœud.
⚠️ Un avertissement avant d’aller plus loin ⚠️ : ce setup est une preuve de concept. C’est un labo que j’ai construit pour apprendre la gestion réseau et la détection de menaces sur Kubernetes. Ne le déployez pas tel quel en production ! Si vous avez des services publics et des services privés, mettez-les dans des clusters séparés.
Ansible
La création des VM reste manuelle. Tout le reste passe par Ansible, ce qui me permet de reconstruire le cluster sur n’importe quel serveur avec exactement la même configuration.
J’ai écrit deux playbooks :
- le premier provisionne et sécurise une ou plusieurs VM microk8s ;
- le second synchronise les ressources Kubernetes avec le cluster. Mon dépôt Git devient ainsi la seule source de vérité sur ce qui est installé.
Au total : 384 tâches et 88 secrets, appliqués de la même façon à chaque fois. Pas d’étape d’installation manuelle, donc pas d’erreur d’installation manuelle.
C’est aussi ce qui m’a permis de reconstruire le cluster une bonne centaine de fois pour tester chaque règle d’accès et de sécurité. Le nœud unique, c’est là où j’en suis aujourd’hui, pas une limite de l’architecture. Passer en multi-nœud, changer de serveur, migrer, se relever d’un incident ou basculer une partie vers le cloud : tout commence par la même commande.
Deux IP, deux Traefik
La plupart de mes services sont privés, quelques-uns sont publics. Je ne les ai pas séparés par cluster, mais par adresse IP (pour l’instant).
MetalLB dispose de deux pools d’une seule adresse chacun : .210 pour le public, .220 pour le privé. Les deux sont en autoAssign: false.
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: public-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.210/32
autoAssign: false # personne ne prend cette IP sans la demander explicitement
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: private-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.220/32
autoAssign: false
En général, on voit autoAssign: false comme un moyen d’éviter d’exposer quelque chose par accident. Pour moi, c’est surtout une protection contre les collisions. Avec une seule adresse par pool, c’est premier arrivé, premier servi. Je l’ai appris à mes dépens : un service lancé sans trop réfléchir a récupéré l’IP, Traefik l’a perdue, et tout ce qui se trouvait derrière Traefik est tombé. Désormais, un Service n’obtient une adresse que s’il la demande.
Le routeur ne connaît que l’adresse publique. La redirection de ports pointe vers .210, et rien n’est jamais redirigé vers .220.
Traefik expose 6 entrypoints, dont la paire web-public (8080) / web-private (8081). Chaque IP a son propre Service Traefik, qui n’écoute que sur ses propres entrypoints.
Au final, j’ai deux tiers de routes privées pour un tiers de routes publiques. L’essentiel de mon cluster n’est jamais censé voir Internet.
externalTrafficPolicy: Local
Par défaut, kube-proxy fait du SNAT sur l’IP source quand il transfère un paquet. Résultat : Traefik verrait l’IP du nœud au lieu de celle du edge Cloudflare. Et CrowdSec, qui bannit par IP, finirait par bannir… mon propre nœud.
Avec externalTrafficPolicy: Local sur le Service public, l’IP source d’origine est conservée. Traefik fait ensuite confiance aux plages d’IP de Cloudflare via forwardedHeaders.trustedIPs, et lit la vraie IP du visiteur dans l’en-tête X-Forwarded-For. Ces plages sont rafraîchies à chaque déploiement depuis cloudflare.com/ips-v4 et /ips-v6.
Voici le comportement attendu :
| Depuis | Vers | Résultat attendu |
|---|---|---|
| Internet (via le proxy Cloudflare) | public (.210) | vraie IP du visiteur lue dans X-Forwarded-For, la requête passe |
| LAN | public (.210) | rejetée par cloudflare-ips |
| Tailscale | privé (.220) | passe, comme depuis le LAN |
| — | — | décision de ban déclenchée volontairement, puis vérifiée |
Si cloudflare-ips rejette une requête venant du LAN, c’est la preuve que la vraie IP source arrive bien jusqu’à Traefik.
Le revers de Local : les paquets qui arrivent sur un nœud sans pod Traefik sont abandonnés. Sauf que je n’ai qu’un nœud. Pour une fois, ma contrainte matérielle me simplifie la vie.
Privé par choix, et moins bien loti
Les routes privées n’ont ni certificat ni redirection HTTPS. Côté public, en revanche, tout passe par des certificats Let’s Encrypt.
Pour le DNS, CoreDNS porte un enregistrement wildcard (* IN A) qui pointe vers l’adresse privée. Ajouter un service privé, c’est donc une seule IngressRoute, et c’est tout. CoreDNS partage l’IP .220 avec le Traefik privé grâce à allow-shared-ip, puis Traefik route vers le bon service selon le nom de domaine utilisé (reverse proxy classique).
Vient ensuite le compromis Tailscale. Je veux pouvoir passer par mon tailnet. Mais quand je suis chez moi, je veux aussi accéder à mes services privés sans rien lancer. Le middleware whitelist-local est donc large : il accepte toute la plage du réseau local.
Soyons honnêtes, ce n’est pas une protection solide. Un objet connecté ou un invité sur le même Wi-Fi passe sans problème. Le vrai verrou côté privé, ce n’est pas le middleware, c’est la question : « est-ce que tu peux seulement router jusqu’à cette IP ? ». Depuis Internet, la réponse est non. Le middleware n’est que la deuxième barrière, pas la première.
Des helpers pour les NetworkPolicies
130 appels, 10 helpers, 28 services.
Sans eux, il faudrait environ 80 lignes de YAML identiques par service. Soit à peu près 1 700 lignes à modifier en parallèle à chaque changement de règle.
Mon préféré, c’est block-local-access. Il laisse le trafic sortant ouvert vers 0.0.0.0/0, sauf vers la plage du LAN. Un pod public compromis peut donc toujours joindre Internet (il en a de toute façon besoin pour fonctionner normalement), mais il ne peut pas scanner mon réseau local.
# ce que block-local-access génère dans chaque namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-local-access
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 192.168.1.0/24
Deux bouncers
Certains services ne parlent pas HTTP, et c’est là que le setup montre ses limites.
Ils passent par une IngressRouteTCP avec HostSNI(*), sur un entrypoint dédié avec un port spécifique. Pas de chaîne public-security ici : une ipAllowList ou un bouncer HTTP n’a aucune prise sur un protocole sans SNI. Ces services ne sont pas non plus dans le périmètre de mon DDNS, puisque cloudflare.records ne couvre que les services HTTP. De toute façon, Cloudflare ne proxifie pas le TCP arbitraire, sauf avec son offre payante Spectrum.
Le port est donc exposé directement. La seule protection qui reste, c’est le bouncer pare-feu.
C’est pour ça que je fais tourner deux bouncers, chacun avec sa propre clé d’API :
- L7 : le plugin Traefik, pour le trafic HTTP ;
- L3/L4 : un DaemonSet en
hostNetwork: true, avec les capabilitiesNET_ADMIN/NET_RAW, qui écrit des règles nftables avecdeny_action: DROPsur les hooks input et forward.
Les deux ne consomment qu’un seul type de décision : ban.
CrowdSec
Le plugin Traefik tourne en crowdsecMode: stream et récupère les décisions toutes les 300 secondes. J’ai monté httpTimeoutSeconds à 30, car le pod LAPI met parfois du temps à répondre.
Dans la chaîne public-security, cloudflare-ips passe avant crowdsec-bouncer. Le bouncer L7 a ainsi moins de travail : il ne voit que le trafic qui a déjà franchi l’allowlist.
Les logs
Les seuls logs qui pèsent dans une décision, ce sont les access logs de Traefik. Ils sont activés avec tous leurs champs, parce que c’est ce dont CrowdSec a besoin pour décider qui bannir.
Le reste relève du monitoring classique : des ServiceMonitors, des PrometheusRules pour les alertes, et des sondes Uptime Kuma enregistrées automatiquement via une CRD. Quand j’ajoute un service, sa sonde arrive avec lui.
Les limites que j’assume
Je ne parle pas de dette technique, puisque ce sont des choix. Le CPU est surprovisionné à 5,6×, la RAM à 2,1×, sur un seul nœud. C’est un pari : celui que les pics de charge n’arrivent pas tous en même temps. Jusqu’ici, ça tient.
La création des VM reste manuelle. Je l’accepte parce que ce nœud unique n’est qu’un point de départ, et la centaine de reconstructions prouve qu’il peut bouger le jour où il le faudra.
Et la suite ?
Ce setup fonctionne. Mais je sais que ce n’est pas la meilleure réponse au problème de sécurité. Je l’ai construit ainsi pour apprendre le réseau, les logs et les outils de sécurité sur Kubernetes, et pour ça, il a parfaitement rempli son rôle.
La prochaine étape : abandonner le cluster unique au profit de deux clusters séparés, un public et un privé. Une bonne partie de la complexité décrite ici vient du fait de faire cohabiter les deux mondes sur le même nœud :
- les deux pools MetalLB, et le risque de collision d’IP qui va avec ;
whitelist-localetcloudflare-ipsqui cohabitent dans le même Traefik ;- les 18 namespaces que je maintiens à la main dans une liste
traefik_backend_namespaces; - une bonne partie des 130 appels de NetworkPolicy.
Avec deux clusters, un service public compromis ne peut même pas voir le cluster privé. La frontière n’est plus une règle, c’est l’infrastructure elle-même. Il ne reste que quelques règles de pare-feu. C’est BEAUCOUP moins de choses à maintenir, et beaucoup moins d’occasions de se tromper dans une règle.