Files
Blog/content/post/9-expose-kubernetes-pods-externally-ingress-tls.fr.md
Gitea Actions 6e6d16e474
All checks were successful
Blog Deployment / Check-Rebuild (push) Successful in 7s
Blog Deployment / Build (push) Has been skipped
Blog Deployment / Deploy-Staging (push) Successful in 12s
Blog Deployment / Test-Staging (push) Successful in 3s
Blog Deployment / Merge (push) Successful in 8s
Blog Deployment / Deploy-Production (push) Successful in 12s
Blog Deployment / Test-Production (push) Successful in 4s
Blog Deployment / Clean (push) Has been skipped
Blog Deployment / Notify (push) Successful in 4s
Auto-update blog content from Obsidian: 2025-08-19 14:01:03
2025-08-19 14:01:03 +00:00

24 KiB
Raw Blame History

slug, title, description, date, draft, tags, categories
slug title description date draft tags categories
expose-kubernetes-pods-externally-ingress-tls Exposer des Pods Kubernetes en externe avec Ingress et TLS Découvrez comment exposer des pods Kubernetes en externe avec Services, Ingress et TLS grâce à BGP, NGINX et Cert-Manager dans un homelab. 2025-08-19 false
kubernetes
helm
bgp
opnsense
cilium
nginx-ingress-controller
cert-manager
homelab

Intro

Après avoir construit mon propre cluster Kubernetes dans mon homelab avec kubeadm dans [cet article]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}), mon prochain défi est dexposer un pod simple à lextérieur, accessible via une URL et sécurisé avec un certificat TLS validé par Lets Encrypt.

Pour y parvenir, jai besoin de configurer plusieurs composants :

  • Service : Expose le pod à lintérieur du cluster et fournit un point daccès.
  • Ingress : Définit des règles de routage pour exposer des services HTTP(S) à lextérieur.
  • Ingress Controller : Surveille les ressources Ingress et gère réellement le routage du trafic.
  • Certificats TLS : Sécurisent le trafic en HTTPS grâce à des certificats délivrés par Lets Encrypt.

Cet article vous guide pas à pas pour comprendre comment fonctionne laccès externe dans Kubernetes dans un environnement homelab.

C'est parti.


Helm

Jutilise Helm, le gestionnaire de paquets de facto pour Kubernetes, afin dinstaller des composants externes comme lIngress Controller ou cert-manager.

Pourquoi Helm

Helm simplifie le déploiement et la gestion des applications Kubernetes. Au lieu décrire et de maintenir de longs manifestes YAML, Helm permet dinstaller des applications en une seule commande, en sappuyant sur des charts versionnés et configurables.

Installer Helm

Jinstalle Helm sur mon hôte bastion LXC, qui dispose déjà dun accès au cluster Kubernetes :

curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt update
sudo apt install helm

Services Kubernetes

Avant de pouvoir exposer un pod à lextérieur, il faut dabord le rendre accessible à lintérieur du cluster. Cest là quinterviennent les Services Kubernetes.

Les Services agissent comme un pont entre les pods et le réseau, garantissant que les applications restent accessibles même si les pods sont réordonnés ou redéployés.

Il existe plusieurs types de Services Kubernetes, chacun avec un objectif différent :

  • ClusterIP expose le Service sur une IP interne au cluster, uniquement accessible depuis lintérieur.
  • NodePort expose le Service sur un port statique de lIP de chaque nœud, accessible depuis lextérieur du cluster.
  • LoadBalancer expose le Service sur une IP externe, généralement via une intégration cloud (ou via BGP dans un homelab).

Exposer un Service LoadBalancer avec BGP

Au départ, jai envisagé dutiliser MetalLB pour exposer les adresses IP des services sur mon réseau local. Cest ce que jutilisais auparavant quand je dépendais de la box de mon FAI comme routeur principal. Mais après avoir lu cet article, Use Cilium BGP integration with OPNsense, je réalise que je peux obtenir le même résultat (voire mieux) en utilisant BGP avec mon routeur OPNsense et Cilium, mon CNI.

Quest-ce que BGP ?

BGP (Border Gateway Protocol) est un protocole de routage utilisé pour échanger des routes entre systèmes. Dans un homelab Kubernetes, BGP permet à tes nœuds Kubernetes dannoncer directement leurs IPs à ton routeur ou firewall. Ton routeur sait alors exactement comment atteindre les adresses IP gérées par ton cluster.

Au lieu que MetalLB gère lallocation dIP et les réponses ARP, tes nœuds disent directement à ton routeur : « Hé, cest moi qui possède ladresse 192.168.1.240 ».

Lapproche MetalLB classique

Sans BGP, MetalLB en mode Layer 2 fonctionne comme ceci :

  • Il assigne une adresse IP LoadBalancer (par exemple 192.168.1.240) depuis un pool.
  • Un nœud répond aux requêtes ARP pour cette IP sur ton LAN.

Oui, MetalLB peut aussi fonctionner avec BGP, mais pourquoi lutiliser si mon CNI (Cilium) le gère déjà nativement ?

BGP avec Cilium

Avec Cilium + BGP, tu obtiens :

  • Lagent Cilium du nœud annonce les IPs LoadBalancer via BGP.
  • Ton routeur apprend ces routes et les envoie au bon nœud.
  • Plus besoin de MetalLB.

Configuration BGP

BGP est désactivé par défaut, aussi bien sur OPNsense que sur Cilium. Activons-le des deux côtés.

Sur OPNsense

Daprès la documentation officielle OPNsense, lactivation de BGP nécessite dinstaller un plugin.

Va dans System > Firmware > Plugins et installe le plugin os-frr :
  Installer le plugin os-frr dans OPNsense

Une fois installé, active le plugin dans Routing > General :
  Activer le routage dans OPNsense

Ensuite, rends-toi dans la section BGP. Dans longlet General :

  • Coche la case pour activer BGP.
  • Défini ton ASN BGP. Jai choisi 64512, le premier ASN privé de la plage réservée (voir ASN table) :

Ajoute ensuite tes voisins BGP. Je ne fais le peering quavec mes nœuds workers (puisque seuls eux hébergent des workloads). Pour chaque voisin :

  • Mets lIP du nœud dans Peer-IP.
  • Utilise 64513 comme Remote AS (celui de Cilium).
  • Configure Update-Source Interface sur Lab.
  • Coche Next-Hop-Self.

Voici la liste de mes voisins une fois configurés :
  Liste des voisins BGP

Noublie pas la règle firewall pour autoriser BGP (port 179/TCP) depuis le VLAN Lab vers le firewall :
  Autoriser TCP/179 de Lab vers OPNsense

Dans Cilium

Jai déjà Cilium installé et je nai pas trouvé comment activer BGP avec la CLI, donc je lai simplement réinstallé avec loption BGP :

cilium uninstall
cilium install --set bgpControlPlane.enabled=true

Je configure uniquement les nœuds workers pour établir le peering BGP en les labellisant avec un nodeSelector :

kubectl label node apex-worker node-role.kubernetes.io/worker=""
kubectl label node vertex-worker node-role.kubernetes.io/worker=""
kubectl label node zenith-worker node-role.kubernetes.io/worker=""
NAME            STATUS   ROLES           AGE    VERSION
apex-master     Ready    control-plane   5d4h   v1.32.7
apex-worker     Ready    worker          5d1h   v1.32.7
vertex-master   Ready    control-plane   5d1h   v1.32.7
vertex-worker   Ready    worker          5d1h   v1.32.7
zenith-master   Ready    control-plane   5d1h   v1.32.7
zenith-worker   Ready    worker          5d1h   v1.32.7

Pour la configuration BGP complète, jai besoin de :

  • CiliumBGPClusterConfig : paramètres BGP pour le cluster Cilium, incluant son ASN local et son pair.
  • CiliumBGPPeerConfig : définit les timers, le redémarrage gracieux et les routes annoncées.
  • CiliumBGPAdvertisement : indique quels services Kubernetes annoncer via BGP.
  • CiliumLoadBalancerIPPool : définit la plage dIPs attribuées aux services LoadBalancer.
---
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPClusterConfig
metadata:
  name: bgp-cluster
spec:
  nodeSelector:
    matchLabels:
      node-role.kubernetes.io/worker: "" # Only for worker nodes
  bgpInstances:
  - name: "cilium-bgp-cluster"
    localASN: 64513 # Cilium ASN
    peers:
    - name: "pfSense-peer"
      peerASN: 64512 # OPNsense ASN
      peerAddress: 192.168.66.1  # OPNsense IP
      peerConfigRef:
        name: "bgp-peer"
---
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPPeerConfig
metadata:
  name: bgp-peer
spec:
  timers:
    holdTimeSeconds: 9
    keepAliveTimeSeconds: 3
  gracefulRestart:
    enabled: true
    restartTimeSeconds: 15
  families:
    - afi: ipv4
      safi: unicast
      advertisements:
        matchLabels:
          advertise: "bgp"
---
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisement
  labels:
    advertise: bgp
spec:
  advertisements:
    - advertisementType: "Service"
      service:
        addresses:
          - LoadBalancerIP
      selector:
        matchExpressions:
          - { key: somekey, operator: NotIn, values: [ never-used-value ] }
---
apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "dmz"
spec:
  blocks:
  - start: "192.168.55.20" # LB Range Start IP
    stop: "192.168.55.250" # LB Range End IP

Applique la configuration :

kubectl apply -f bgp.yaml 

ciliumbgpclusterconfig.cilium.io/bgp-cluster created
ciliumbgppeerconfig.cilium.io/bgp-peer created
ciliumbgpadvertisement.cilium.io/bgp-advertisement created
ciliumloadbalancerippool.cilium.io/dmz created

Si tout fonctionne, tu devrais voir les sessions BGP établies avec tes workers :

cilium bgp peers

Node            Local AS   Peer AS   Peer Address   Session State   Uptime   Family         Received   Advertised
apex-worker     64513      64512     192.168.66.1   established     6m30s    ipv4/unicast   1          2    
vertex-worker   64513      64512     192.168.66.1   established     7m9s     ipv4/unicast   1          2    
zenith-worker   64513      64512     192.168.66.1   established     6m13s    ipv4/unicast   1          2

Déployer un Service LoadBalancer avec BGP

Validons rapidement que la configuration fonctionne en déployant un Deployment de test et un Service de type LoadBalancer :

---
apiVersion: v1
kind: Service
metadata:
  name: test-lb
spec:
  type: LoadBalancer
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
    name: http
  selector:
    svc: test-lb
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      svc: test-lb
  template:
    metadata:
      labels:
        svc: test-lb
    spec:
      containers:
      - name: web
        image: nginx
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
        readinessProbe:
          httpGet:
            path: /
            port: 80

Vérifions si le service obtient une IP externe :

kubectl get services test-lb

NAME         TYPE           CLUSTER-IP       EXTERNAL-IP     PORT(S)        AGE
test-lb      LoadBalancer   10.100.167.198   192.168.55.20   80:31350/TCP   169m

Le service a récupéré la première IP du pool défini : 192.168.55.20.

Depuis nimporte quel appareil du LAN, on peut tester laccès sur le port 80 :
Test LoadBalancer service with BGP

Notre pod est joignable via une IP LoadBalancer routée en BGP. Première étape réussie !


Kubernetes Ingress

Nous avons réussi à exposer un pod en externe en utilisant un service LoadBalancer et une adresse IP attribuée via BGP. Cette approche fonctionne très bien pour les tests, mais elle ne fonctionne pas à léchelle.

Imagine avoir 10, 20 ou 50 services différents. Est-ce que je voudrais vraiment allouer 50 adresses IP et encombrer mon firewall ainsi que mes tables de routage avec 50 entrées BGP ? Certainement pas.

Cest là quintervient Ingress.

Quest-ce quun Kubernetes Ingress ?

Un Kubernetes Ingress est un objet API qui gère laccès externe aux services dun cluster, généralement en HTTP et HTTPS, le tout via un point dentrée unique.

Au lieu dattribuer une IP par service, on définit des règles de routage basées sur :

  • Des noms dhôtes (app1.vezpi.me, blog.vezpi.me, etc.)
  • Des chemins (/grafana, /metrics, etc.)

Avec Ingress, je peux exposer plusieurs services via la même IP et le même port (souvent 443 pour HTTPS), et Kubernetes saura comment router la requête vers le bon service backend.

Voici un exemple simple dIngress, qui route le trafic de test.vezpi.me vers le service test-lb sur le port 80 :

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress
spec:
  rules:
    - host: test.vezpi.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: test-lb
                port:
                  number: 80

Ingress Controller

Un Ingress, en soi, nest quun ensemble de règles de routage. Il ne traite pas réellement le trafic. Pour le rendre fonctionnel, il faut un Ingress Controller, qui va :

  • Surveiller lAPI Kubernetes pour détecter les ressources Ingress.
  • Ouvrir les ports HTTP(S) via un service LoadBalancer ou NodePort.
  • Router le trafic vers le bon Service selon les règles de lIngress.

Parmi les contrôleurs populaires, on retrouve NGINX, Traefik, HAProxy, et dautres encore. Comme je cherchais quelque chose de simple, stable et largement adopté, jai choisi le NGINX Ingress Controller.

Installer NGINX Ingress Controller

Jutilise Helm pour installer le contrôleur, et je définis controller.ingressClassResource.default=true pour que tous mes futurs ingress lutilisent par défaut :

helm install ingress-nginx \
  --repo=https://kubernetes.github.io/ingress-nginx \
  --namespace=ingress-nginx \
  --create-namespace ingress-nginx \
  --set controller.ingressClassResource.default=true \
  --set controller.config.strict-validate-path-type=false

Le contrôleur est déployé et expose un service LoadBalancer. Dans mon cas, il récupère la deuxième adresse IP disponible dans la plage BGP :

NAME                       TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                      AGE   SELECTOR
ingress-nginx-controller   LoadBalancer   10.106.236.13   192.168.55.21   80:31195/TCP,443:30974/TCP   75s   app.kubernetes.io/component=controller,app.kubernetes.io/instance=ingress-nginx,app.kubernetes.io/name=ingress-nginx

Réserver une IP statique pour le contrôleur

Je veux massurer que lIngress Controller reçoive toujours la même adresse IP. Pour cela, jai créé deux pools dIP Cilium distincts :

  • Un réservé pour lIngress Controller avec une seule IP.
  • Un pour tout le reste.
---
# Pool for Ingress Controller
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: ingress-nginx
spec:
  blocks:
    - cidr: 192.168.55.55/32
  serviceSelector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
      app.kubernetes.io/component: controller
---
# Default pool for other services
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: default
spec:
  blocks:
    - start: 192.168.55.100
      stop: 192.168.55.250
  serviceSelector:
    matchExpressions:
      - key: app.kubernetes.io/name
        operator: NotIn
        values:
          - ingress-nginx

Après avoir remplacé le pool partagé par ces deux pools, lIngress Controller reçoit bien lIP dédiée 192.168.55.55, et le service test-lb obtient 192.168.55.100 comme prévu :

NAMESPACE       NAME                                 TYPE           CLUSTER-IP       EXTERNAL-IP      PORT(S)                      AGE
default         test-lb                              LoadBalancer   10.100.167.198   192.168.55.100   80:31350/TCP                 6h34m
ingress-nginx   ingress-nginx-controller             LoadBalancer   10.106.236.13    192.168.55.55    80:31195/TCP,443:30974/TCP   24m

Associer un Service à un Ingress

Maintenant, connectons un service à ce contrôleur.

Je commence par mettre à jour le service LoadBalancer dorigine pour le convertir en ClusterIP (puisque cest désormais lIngress Controller qui lexposera en externe) :

---
apiVersion: v1
kind: Service
metadata:
  name: test-lb
spec:
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
      name: http
  selector:
    svc: test-lb
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress
spec:
  rules:
    - host: test.vezpi.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: test-lb
                port:
                  number: 80  

Ensuite, japplique le manifeste Ingress pour exposer le service en HTTP.

Comme jutilise le plugin Caddy dans OPNsense, jai encore besoin dun routage local de type Layer 4 pour rediriger le trafic de test.vezpi.me vers ladresse IP de lIngress Controller (192.168.55.55). Je crée donc une nouvelle règle dans le plugin Caddy.

Create Layer4 router in Caddy plugin for OPNsense

Puis je teste laccès dans le navigateur :
  Test dun Ingress en HTTP

Mon pod est désormais accessible via son URL HTTP en utilisant un Ingress. Deuxième étape complétée !


Connexion sécurisée avec TLS

Exposer des services en HTTP simple est suffisant pour des tests, mais en pratique nous voulons presque toujours utiliser HTTPS. Les certificats TLS chiffrent le trafic et garantissent lauthenticité ainsi que la confiance pour les utilisateurs.

Cert-Manager

Pour automatiser la gestion des certificats dans Kubernetes, nous utilisons Cert-Manager. Il peut demander, renouveler et gérer les certificats TLS sans intervention manuelle.

Installer Cert-Manager

Nous le déployons avec Helm dans le cluster :

helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

Configurer Cert-Manager

Ensuite, nous configurons un ClusterIssuer pour Lets Encrypt. Cette ressource indique à Cert-Manager comment demander des certificats :

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: <email>
    privateKeySecretRef:
      name: letsencrypt-staging-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx

Ici, je définis le serveur staging de Lets Encrypt ACME pour les tests. Les certificats de staging ne sont pas reconnus par les navigateurs, mais ils évitent datteindre les limites strictes de Lets Encrypt lors du développement.

Appliquez-le :

kubectl apply -f clusterissuer.yaml

Vérifiez si votre ClusterIssuer est Ready :

kubectl get clusterissuers.cert-manager.io                                                    
NAME                  READY   AGE
letsencrypt-staging   True    14m

Sil ne devient pas Ready, utilisez kubectl describe sur la ressource pour le diagnostiquer.

Ajouter TLS dans un Ingress

Nous pouvons maintenant sécuriser notre service avec TLS en ajoutant une section tls dans la spécification Ingress et en référençant le ClusterIssuer :

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress-https
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  tls:
    - hosts:
      - test.vezpi.me
      secretName: test-vezpi-me-tls
  rules:
    - host: test.vezpi.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: test-lb
                port:
                  number: 80

En arrière-plan, Cert-Manager suit ce flux pour émettre le certificat :

  • Détecte lIngress avec tls et le ClusterIssuer.
  • Crée un CRD Certificate décrivant le certificat souhaité + lemplacement du Secret.
  • Crée un CRD Order pour représenter une tentative démission avec Lets Encrypt.
  • Crée un CRD Challenge (par ex. validation HTTP-01).
  • Met en place un Ingress/Pod temporaire pour résoudre le challenge.
  • Crée un CRD CertificateRequest et envoie le CSR à Lets Encrypt.
  • Reçoit le certificat signé et le stocke dans un Secret Kubernetes.
  • LIngress utilise automatiquement ce Secret pour servir en HTTPS.

Une fois ce processus terminé, votre Ingress est sécurisé avec un certificat TLS.
Certificat TLS validé avec le serveur de staging de Let’s Encrypt

Passer aux certificats de production

Une fois que le staging fonctionne, nous pouvons passer au serveur production ACME pour obtenir un certificat Lets Encrypt reconnu :

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: <email>
    privateKeySecretRef:
      name: letsencrypt-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx

Mettez à jour lIngress pour pointer vers le nouveau ClusterIssuer :

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress-https
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  tls:
    - hosts:
      - test.vezpi.me
      secretName: test-vezpi-me-tls
  rules:
    - host: test.vezpi.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: test-lb
                port:
                  number: 80

Comme le certificat de staging est encore stocké dans le Secret, je le supprime pour forcer une nouvelle demande en production :

kubectl delete secret test-vezpi-me-tls

🎉 Mon Ingress est désormais sécurisé avec un certificat TLS valide délivré par Lets Encrypt. Les requêtes vers https://test.vezpi.me sont chiffrées de bout en bout et routées par le NGINX Ingress Controller jusquà mon pod nginx :
Ingress HTTPS avec certificat validé par Let’s Encrypt


Conclusion

Dans ce parcours, je suis parti des bases, en exposant un simple pod avec un service LoadBalancer, puis jai construit étape par étape une configuration prête pour la production :

  • Compréhension des Services Kubernetes et de leurs différents types.
  • Utilisation du BGP avec Cilium et OPNsense pour attribuer des IP externes directement depuis mon réseau.
  • Introduction des Ingress pour mieux passer à léchelle, en exposant plusieurs services via un point dentrée unique.
  • Installation du NGINX Ingress Controller pour gérer le routage.
  • Automatisation de la gestion des certificats avec Cert-Manager, afin de sécuriser mes services avec des certificats TLS Lets Encrypt.

🚀 Résultat : mon pod est maintenant accessible via une véritable URL, sécurisé en HTTPS, comme nimporte quelle application web moderne.

Cest une étape importante dans mon aventure Kubernetes en homelab. Dans le prochain article, je souhaite explorer le stockage persistant et connecter mon cluster Kubernetes à mon setup Ceph sous Proxmox.

A la prochaine !