Auto-update blog content from Obsidian: 2026-04-29 19:59:52
Some checks failed
Blog Deployment / Check-Rebuild (push) Successful in 8s
Blog Deployment / Build (push) Successful in 31s
Blog Deployment / Deploy-Staging (push) Successful in 9s
Blog Deployment / Test-Staging (push) Failing after 3s
Blog Deployment / Merge (push) Has been skipped
Blog Deployment / Deploy-Production (push) Has been skipped
Blog Deployment / Test-Production (push) Has been skipped
Blog Deployment / Clean (push) Has been skipped
Blog Deployment / Notify (push) Successful in 3s
Some checks failed
Blog Deployment / Check-Rebuild (push) Successful in 8s
Blog Deployment / Build (push) Successful in 31s
Blog Deployment / Deploy-Staging (push) Successful in 9s
Blog Deployment / Test-Staging (push) Failing after 3s
Blog Deployment / Merge (push) Has been skipped
Blog Deployment / Deploy-Production (push) Has been skipped
Blog Deployment / Test-Production (push) Has been skipped
Blog Deployment / Clean (push) Has been skipped
Blog Deployment / Notify (push) Successful in 3s
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
---
|
||||
slug: expose-kubernetes-pods-externally-ingress-tls
|
||||
title: Exposer des Pods Kubernetes en externe avec Ingress et TLS
|
||||
description: Découvrez comment exposer des pods Kubernetes en externe avec Services, Ingress et TLS grâce à BGP, NGINX et Cert-Manager dans un homelab.
|
||||
date: 2025-08-19
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- helm
|
||||
- bgp
|
||||
- opnsense
|
||||
- cilium
|
||||
- nginx-ingress-controller
|
||||
- cert-manager
|
||||
categories:
|
||||
- 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 d’exposer un pod simple à l’extérieur, accessible via une URL et sécurisé avec un certificat TLS validé par Let’s Encrypt.
|
||||
|
||||
Pour y parvenir, j’ai besoin de configurer plusieurs composants :
|
||||
- **Service** : Expose le pod à l’intérieur du cluster et fournit un point d’accès.
|
||||
- **Ingress** : Définit des règles de routage pour exposer des services HTTP(S) à l’exté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 Let’s Encrypt.
|
||||
|
||||
Cet article vous guide pas à pas pour comprendre comment fonctionne l’accès externe dans Kubernetes dans un environnement homelab.
|
||||
|
||||
C'est parti.
|
||||
|
||||
---
|
||||
## Helm
|
||||
|
||||
J’utilise **Helm**, le gestionnaire de paquets de facto pour Kubernetes, afin d’installer des composants externes comme l’Ingress 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 d’installer des applications en une seule commande, en s’appuyant sur des charts versionnés et configurables.
|
||||
|
||||
### Installer Helm
|
||||
|
||||
J’installe Helm sur mon hôte bastion LXC, qui dispose déjà d’un accès au cluster Kubernetes :
|
||||
```bash
|
||||
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 à l’extérieur, il faut d’abord le rendre accessible à l’intérieur du cluster. C’est là qu’interviennent 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 l’intérieur.
|
||||
- **NodePort** expose le Service sur un port statique de l’IP de chaque nœud, accessible depuis l’exté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, j’ai envisagé d’utiliser **MetalLB** pour exposer les adresses IP des services sur mon réseau local. C’est ce que j’utilisais 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](https://devopstales.github.io/kubernetes/cilium-opnsense-bgp/), 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.
|
||||
|
||||
### Qu’est-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 d’annoncer 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 l’allocation d’IP et les réponses ARP, tes nœuds disent directement à ton routeur : « Hé, c’est moi qui possède l’adresse 192.168.1.240 ».
|
||||
|
||||
### L’approche 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 l’utiliser si mon CNI (Cilium) le gère déjà nativement ?
|
||||
|
||||
### BGP avec Cilium
|
||||
|
||||
Avec Cilium + BGP, tu obtiens :
|
||||
- L’agent 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
|
||||
|
||||
D’après la [documentation officielle OPNsense](https://docs.opnsense.org/manual/dynamic_routing.html#bgp-section), l’activation de BGP nécessite d’installer 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 l’onglet **General** :
|
||||
- Coche la case pour activer BGP.
|
||||
- Défini ton **ASN BGP**. J’ai choisi `64512`, le premier ASN privé de la plage réservée (voir [ASN table](https://en.wikipedia.org/wiki/Autonomous_system_\(Internet\)#ASN_Table)) :
|
||||

|
||||
|
||||
Ajoute ensuite tes voisins BGP. Je ne fais le peering qu’avec mes **nœuds workers** (puisque seuls eux hébergent des workloads). Pour chaque voisin :
|
||||
- Mets l’IP 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
|
||||
|
||||
N’oublie 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
|
||||
|
||||
J’ai déjà Cilium installé et je n’ai pas trouvé comment activer BGP avec la CLI, donc je l’ai simplement réinstallé avec l’option BGP :
|
||||
|
||||
```bash
|
||||
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` :
|
||||
```bash
|
||||
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=""
|
||||
```
|
||||
```plaintext
|
||||
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, j’ai 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 d’IPs attribuées aux services `LoadBalancer`.
|
||||
|
||||
```yaml
|
||||
---
|
||||
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 :
|
||||
```bash
|
||||
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 :
|
||||
```bash
|
||||
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` :
|
||||
```yaml
|
||||
---
|
||||
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 :
|
||||
```bash
|
||||
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 n’importe quel appareil du LAN, on peut tester l’accès sur le port 80 :
|
||||

|
||||
|
||||
✅ 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.
|
||||
|
||||
C’est là qu’intervient **Ingress**.
|
||||
|
||||
### Qu’est-ce qu’un Kubernetes Ingress ?
|
||||
|
||||
Un Kubernetes **Ingress** est un objet API qui gère **l’accès externe aux services** d’un cluster, généralement en HTTP et HTTPS, le tout via un point d’entrée unique.
|
||||
|
||||
Au lieu d’attribuer une IP par service, on définit des règles de routage basées sur :
|
||||
- **Des noms d’hô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 d’`Ingress`, qui route le trafic de `test.vezpi.me` vers le service `test-lb` sur le port 80 :
|
||||
```yaml
|
||||
---
|
||||
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, n’est qu’un 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 l’API 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 l’Ingress.
|
||||
|
||||
Parmi les contrôleurs populaires, on retrouve NGINX, Traefik, HAProxy, et d’autres encore. Comme je cherchais quelque chose de simple, stable et largement adopté, j’ai choisi le **NGINX Ingress Controller**.
|
||||
|
||||
### Installer NGINX Ingress Controller
|
||||
|
||||
J’utilise Helm pour installer le contrôleur, et je définis `controller.ingressClassResource.default=true` pour que tous mes futurs ingress l’utilisent par défaut :
|
||||
```bash
|
||||
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 :
|
||||
```bash
|
||||
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 m’assurer que l’Ingress Controller reçoive toujours la même adresse IP. Pour cela, j’ai créé deux pools d’IP Cilium distincts :
|
||||
- Un réservé pour l’Ingress Controller avec une seule IP.
|
||||
- Un pour tout le reste.
|
||||
```yaml
|
||||
---
|
||||
# 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, l’Ingress Controller reçoit bien l’IP dédiée `192.168.55.55`, et le service `test-lb` obtient `192.168.55.100` comme prévu :
|
||||
```bash
|
||||
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` d’origine pour le convertir en `ClusterIP` (puisque c’est désormais l’Ingress Controller qui l’exposera en externe) :
|
||||
```yaml
|
||||
---
|
||||
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, j’applique le manifeste `Ingress` pour exposer le service en HTTP.
|
||||
|
||||
Comme j’utilise le plugin **Caddy** dans OPNsense, j’ai encore besoin d’un routage local de type Layer 4 pour rediriger le trafic de `test.vezpi.me` vers l’adresse IP de l’Ingress Controller (`192.168.55.55`). Je crée donc une nouvelle règle dans le plugin Caddy.
|
||||
|
||||

|
||||
|
||||
Puis je teste l’accès dans le navigateur :
|
||||

|
||||
Test d’un 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 l’authenticité 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 :
|
||||
```bash
|
||||
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 Let’s Encrypt. Cette ressource indique à Cert-Manager comment demander des certificats :
|
||||
```yaml
|
||||
---
|
||||
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 Let’s Encrypt ACME pour les tests. Les certificats de staging ne sont pas reconnus par les navigateurs, mais ils évitent d’atteindre les limites strictes de Let’s Encrypt lors du développement.
|
||||
|
||||
Appliquez-le :
|
||||
```bash
|
||||
kubectl apply -f clusterissuer.yaml
|
||||
```
|
||||
|
||||
Vérifiez si votre `ClusterIssuer` est `Ready` :
|
||||
```bash
|
||||
kubectl get clusterissuers.cert-manager.io
|
||||
NAME READY AGE
|
||||
letsencrypt-staging True 14m
|
||||
```
|
||||
|
||||
S’il 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` :
|
||||
```yaml
|
||||
---
|
||||
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 l’`Ingress` avec `tls` et le `ClusterIssuer`.
|
||||
- Crée un CRD **Certificate** décrivant le certificat souhaité + l’emplacement du Secret.
|
||||
- Crée un CRD **Order** pour représenter une tentative d’émission avec Let’s 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 à Let’s Encrypt.
|
||||
- Reçoit le certificat signé et le stocke dans un Secret Kubernetes.
|
||||
- L’Ingress utilise automatiquement ce Secret pour servir en HTTPS.
|
||||
|
||||
✅ Une fois ce processus terminé, votre Ingress est sécurisé avec un certificat TLS.
|
||||

|
||||
|
||||
### Passer aux certificats de production
|
||||
|
||||
Une fois que le staging fonctionne, nous pouvons passer au serveur **production** ACME pour obtenir un certificat Let’s Encrypt reconnu :
|
||||
```yaml
|
||||
---
|
||||
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 l’`Ingress` pour pointer vers le nouveau `ClusterIssuer` :
|
||||
```yaml
|
||||
---
|
||||
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 :
|
||||
```bash
|
||||
kubectl delete secret test-vezpi-me-tls
|
||||
```
|
||||
|
||||
🎉 Mon `Ingress` est désormais sécurisé avec un certificat TLS valide délivré par Let’s 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` :
|
||||

|
||||
|
||||
|
||||
---
|
||||
## Conclusion
|
||||
|
||||
Dans ce parcours, je suis parti des bases, en exposant un simple pod avec un service `LoadBalancer`, puis j’ai 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 d’entré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 Let’s Encrypt.
|
||||
|
||||
🚀 Résultat : mon pod est maintenant accessible via une véritable URL, sécurisé en HTTPS, comme n’importe quelle application web moderne.
|
||||
|
||||
C’est 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 !
|
||||
@@ -0,0 +1,630 @@
|
||||
---
|
||||
slug: expose-kubernetes-pods-externally-ingress-tls
|
||||
title: Exposing Kubernetes Pods externally with Ingress and TLS
|
||||
description: Learn how to expose Kubernetes pods externally with Services, Ingress, and TLS using BGP, NGINX, and Cert-Manager in a homelab setup.
|
||||
date: 2025-08-19
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- helm
|
||||
- bgp
|
||||
- opnsense
|
||||
- cilium
|
||||
- nginx-ingress-controller
|
||||
- cert-manager
|
||||
categories:
|
||||
- homelab
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
After building my own Kubernetes cluster in my homelab using `kubeadm` in [that post]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}), my next challenge is to expose a simple pod externally, reachable with an URL and secured with a TLS certificate verified by Let's Encrypt.
|
||||
|
||||
To achieve this, I needed to configure several components:
|
||||
- **Service**: Expose the pod inside the cluster and provide an access point.
|
||||
- **Ingress**: Define routing rules to expose HTTP(S) services externally.
|
||||
- **Ingress Controller**: Listen to Ingress resources and handles actual traffic routing.
|
||||
- **TLS Certificates**: Secure traffic with HTTPS using certificates from Let’s Encrypt.
|
||||
|
||||
This post guides you through each step to understand how external access works in Kubernetes in a homelab environment.
|
||||
|
||||
Let’s dive in.
|
||||
|
||||
---
|
||||
## Helm
|
||||
|
||||
I use **Helm**, the de facto package manager for Kubernetes, to install external components like the Ingress controller or cert-manager.
|
||||
|
||||
### Why Helm
|
||||
|
||||
Helm simplifies the deployment and management of Kubernetes applications. Instead of writing and maintaining large YAML manifests, Helm lets you install applications with a single command, using versioned and configurable charts.
|
||||
|
||||
### Install Helm
|
||||
|
||||
I install Helm on my LXC bastion host, which already has access to the Kubernetes cluster:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
## Kubernetes Services
|
||||
|
||||
Before we can expose a pod externally, we need a way to make it reachable inside the cluster. That’s where Kubernetes Services come in.
|
||||
|
||||
Services act as the bridge between pods and the network, making sure applications remain reachable even as pods are rescheduled.
|
||||
|
||||
There are several types of Kubernetes Services, each serving a different purpose:
|
||||
- **ClusterIP** exposes the Service on a cluster-internal IP, only accessible inside the cluster.
|
||||
- **NodePort** exposes the Service on a static port on each node’s IP, accessible from outside the cluster.
|
||||
- **LoadBalancer** exposes the Service on an external IP, typically using cloud integrations (or BGP in a homelab).
|
||||
|
||||
---
|
||||
## Expose a `LoadBalancer` Service with BGP
|
||||
|
||||
Initially, I considered using **MetalLB** to expose service IPs to my home network. That’s what I used in the past when relying on my ISP box as the main router. But after reading this post, [Use Cilium BGP integration with OPNsense](https://devopstales.github.io/kubernetes/cilium-opnsense-bgp/), I realize I can achieve the same (or even better) using BGP with my **OPNsense** router and **Cilium**, my CNI.
|
||||
### What Is BGP?
|
||||
|
||||
BGP (Border Gateway Protocol) is a routing protocol used to exchange network routes between systems. In the Kubernetes homelab context, BGP allows your Kubernetes nodes to advertise IPs directly to your network router or firewall. Your router then knows how to reach the IPs managed by your cluster.
|
||||
|
||||
So instead of MetalLB managing IP allocation and ARP replies, your nodes directly tell your router: « Hey, I own 192.168.1.240 ».
|
||||
### Legacy MetalLB Approach
|
||||
|
||||
Without BGP, MetalLB in Layer 2 mode works like this:
|
||||
- Assigns a `LoadBalancer` IP (e.g., `192.168.1.240`) from a pool.
|
||||
- One node responds to ARP for that IP on your LAN.
|
||||
|
||||
Yes, MetalLB can also work with BGP, but what if my CNI (Cilium) can handle it out of the box?
|
||||
### BGP with Cilium
|
||||
|
||||
With Cilium + BGP, you get:
|
||||
- Cilium’s agent on the node advertises LoadBalancer IPs over BGP.
|
||||
- Your router learns that IP and routes to the correct node.
|
||||
- No need for MetalLB.
|
||||
|
||||
### BGP Setup
|
||||
|
||||
BGP is disabled by default on both OPNsense and Cilium. Let’s enable it on both ends.
|
||||
|
||||
#### On OPNsense
|
||||
|
||||
According to the [official OPNsense documentation](https://docs.opnsense.org/manual/dynamic_routing.html#bgp-section), enabling BGP requires installing a plugin.
|
||||
|
||||
Head to `System` > `Firmware` > `Plugins` and install the `os-frr` plugin:
|
||||

|
||||
Install `os-frr` plugin in OPNsense
|
||||
|
||||
Once installed, enable the plugin under `Routing` > `General`:
|
||||

|
||||
Enable routing in OPNsense
|
||||
|
||||
Then navigate to the `BGP` section. In the **General** tab:
|
||||
- Tick the box to enable BGP.
|
||||
- Set your **BGP ASN**. I used `64512`, the first private ASN from the reserved range (see [ASN table](https://en.wikipedia.org/wiki/Autonomous_system_\(Internet\)#ASN_Table)):
|
||||

|
||||
General BGP configuration in OPNsense
|
||||
|
||||
Now create your BGP neighbors. I’m only peering with my **worker nodes** (since only they run workloads). For each neighbor:
|
||||
- Set the node’s IP in `Peer-IP`
|
||||
- Use `64513` as the **Remote AS** (Cilium’s ASN)
|
||||
- Set `Update-Source Interface` to `Lab`
|
||||
- Tick `Next-Hop-Self`:
|
||||

|
||||
BGP neighbor configuration in OPNsense
|
||||
|
||||
Here’s how my neighbors list looks once complete:
|
||||

|
||||
BGP neighbor list
|
||||
|
||||
Don’t forget to create a firewall rule allowing BGP (port `179/TCP`) from the **Lab** VLAN to the firewall:
|
||||

|
||||
Allow TCP/179 from Lab to OPNsense
|
||||
|
||||
#### In Cilium
|
||||
|
||||
I already have Cilium installed and couldn’t find a way to enable BGP with the CLI, so I simply reinstall it with the BGP option:
|
||||
|
||||
```bash
|
||||
cilium uninstall
|
||||
cilium install --set bgpControlPlane.enabled=true
|
||||
```
|
||||
|
||||
I configure only worker nodes to establish BGP peering by labeling them for the `nodeSelector`:
|
||||
```bash
|
||||
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=""
|
||||
```
|
||||
```plaintext
|
||||
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
|
||||
```
|
||||
|
||||
For the entire BGP configuration, I need:
|
||||
- **CiliumBGPClusterConfig**: BGP settings for the Cilium cluster, including its local ASN and its peer
|
||||
- **CiliumBGPPeerConfig**: Sets BGP timers, graceful restart, and route advertisement settings.
|
||||
- **CiliumBGPAdvertisement**: Defines which Kubernetes services should be advertised via BGP.
|
||||
- **CiliumLoadBalancerIPPool**: Configures the range of IPs assigned to Kubernetes LoadBalancer services.
|
||||
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Apply it:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
If everything works, you should see the BGP sessions **established** with your workers:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Deploying a `LoadBalancer` Service with BGP
|
||||
|
||||
Let’s quickly validate that the setup works by deploying a test `Deployment` and `LoadBalancer` `Service`:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Check if it gets an external IP:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
The service got the first IP from our defined pool: `192.168.55.20`.
|
||||
|
||||
Now from any device on the LAN, try to reach that IP on port 80:
|
||||

|
||||
|
||||
✅ Our pod is reachable through BGP-routed `LoadBalancer` IP, first step successful!
|
||||
|
||||
---
|
||||
## Kubernetes Ingress
|
||||
|
||||
We managed to expose a pod externally using a `LoadBalancer` service and a BGP-assigned IP address. This approach works great for testing, but it doesn't scale well.
|
||||
|
||||
Imagine having 10, 20, or 50 different services, would I really want to allocate 50 IP addresses, and clutter my firewall and routing tables with 50 BGP entries? Definitely not.
|
||||
|
||||
That’s where **Ingress** kicks in.
|
||||
|
||||
### What Is a Kubernetes Ingress?
|
||||
|
||||
A Kubernetes **Ingress** is an API object that manages **external access to services** in a cluster, typically HTTP and HTTPS, all through a single entry point.
|
||||
|
||||
Instead of assigning one IP per service, you define routing rules based on:
|
||||
- **Hostnames** (`app1.vezpi.me`, `blog.vezpi.me`, etc.)
|
||||
- **Paths** (`/grafana`, `/metrics`, etc.)
|
||||
|
||||
With Ingress, I can expose multiple services over the same IP and port (usually 443 for HTTPS), and Kubernetes will know how to route the request to the right backend service.
|
||||
|
||||
Here is an example of a simple `Ingress`, routing traffic of `test.vezpi.me` to the `test-lb` service on port 80:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
|
||||
On its own, an Ingress is just a set of routing rules. It doesn’t actually handle traffic. To bring it to life, I need an **Ingress Controller**, which will:
|
||||
- Watches the Kubernetes API for `Ingress` resources.
|
||||
- Opens HTTP(S) ports on a `LoadBalancer` or `NodePort` service.
|
||||
- Routes traffic to the correct `Service` based on the `Ingress` rules.
|
||||
|
||||
Popular controllers include NGINX, Traefik, HAProxy, and more. Since I was looking for something simple, stable, and widely adopted, I picked the **NGINX Ingress Controller**.
|
||||
|
||||
### Install NGINX Ingress Controller
|
||||
|
||||
I use Helm to install the controller, and I set `controller.ingressClassResource.default=true` so that all my future ingresses use it by default:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
The controller is deployed and exposes a `LoadBalancer` service. In my setup, it picks the second available IP in the BGP range:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Reserving a Static IP for the Controller
|
||||
|
||||
I want to make sure the Ingress Controller always receives the same IP address. To do this, I created two separate Cilium IP pools:
|
||||
- One dedicated for the Ingress Controller with a single IP.
|
||||
- One for everything else.
|
||||
```yaml
|
||||
---
|
||||
# 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
|
||||
```
|
||||
|
||||
After replacing the previous shared pool with these two, the Ingress Controller gets the desired IP `192.168.55.55`, and the `test-lb` service picks `192.168.55.100` as expected:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Associate a Service to an Ingress
|
||||
|
||||
Now let’s wire up a service to this controller.
|
||||
|
||||
First, I update the original `LoadBalancer` service and convert it into a `ClusterIP` (since the Ingress Controller will now expose it externally):
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Then I apply the `Ingress` manifest as shown earlier to expose the service over HTTP.
|
||||
|
||||
Since I'm using the Caddy plugin on OPNsense, I still need a local Layer 4 route to forward traffic for `test.vezpi.me` to the NGINX Ingress Controller IP (`192.168.55.55`). I simply create a new rule in the Caddy plugin.
|
||||
|
||||

|
||||
|
||||
Now let’s test it in the browser:
|
||||

|
||||
Test Ingress on HTTP
|
||||
|
||||
✅ Our pod is now reachable on its HTTP URL using an Ingress. Second step complete!
|
||||
|
||||
---
|
||||
## Secure Connection with TLS
|
||||
|
||||
Exposing services over plain HTTP is fine for testing, but in practice we almost always want **HTTPS**. TLS certificates encrypt traffic and provide authenticity and trust to users.
|
||||
|
||||
### Cert-Manager
|
||||
|
||||
To automate certificate management in Kubernetes, we use **Cert-Manager**. It can request, renew, and manage TLS certificates without manual intervention.
|
||||
|
||||
#### Install Cert-Manager
|
||||
|
||||
We deploy it with Helm on the cluster:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
#### Setup Cert-Manager
|
||||
|
||||
Next, we configure a **ClusterIssuer** for Let’s Encrypt. This resource tells Cert-Manager how to request certificates:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
ℹ️ Here I define the **staging** Let’s Encrypt ACME server for testing purposes. Staging certificates are not trusted by browsers, but they prevent hitting Let’s Encrypt’s strict rate limits during development.
|
||||
|
||||
Apply it:
|
||||
```bash
|
||||
kubectl apply -f clusterissuer.yaml
|
||||
```
|
||||
|
||||
Verify if your `ClusterIssuer` is `Ready`:
|
||||
```bash
|
||||
kubectl get clusterissuers.cert-manager.io
|
||||
NAME READY AGE
|
||||
letsencrypt-staging True 14m
|
||||
```
|
||||
|
||||
If it doesn’t become `Ready`, use `kubectl describe` on the resource to troubleshoot.
|
||||
|
||||
### Add TLS in an Ingress
|
||||
|
||||
Now we can secure our service with TLS by adding a `tls` section in the `Ingress` spec and referencing the `ClusterIssuer`:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Behind the scenes, Cert-Manager goes through this workflow to issue the certificate:
|
||||
- Detects the `Ingress` with `tls` and the `ClusterIssuer`.
|
||||
- Creates a Certificate CRD that describes the desired cert + Secret storage.
|
||||
- Creates an Order CRD to represent one issuance attempt with Let’s Encrypt.
|
||||
- Creates a Challenge CRD (e.g., HTTP-01 validation).
|
||||
- Provisions a temporary solver Ingress/Pod to solve the challenge.
|
||||
- Creates a CertificateRequest CRD and sends the CSR to Let’s Encrypt.
|
||||
- Receives the signed certificate and stores it in a Kubernetes Secret.
|
||||
- The Ingress automatically uses the Secret to serve HTTPS.
|
||||
|
||||
✅ Once this process completes, your Ingress is secured with a TLS certificate.
|
||||

|
||||
|
||||
### Switch to Production Certificates
|
||||
|
||||
Once staging works, we can safely switch to the **production** ACME server to get a trusted certificate from Let’s Encrypt:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Update the `Ingress` to reference the new `ClusterIssuer`:
|
||||
```yaml
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
Since the staging certificate is still stored in the Secret, I delete it to trigger a fresh request against production:
|
||||
```bash
|
||||
kubectl delete secret test-vezpi-me-tls
|
||||
```
|
||||
|
||||
🎉 My `Ingress` is now secured with a valid TLS certificate from Let’s Encrypt. Requests to `https://test.vezpi.me` are encrypted end-to-end and routed by the NGINX Ingress Controller to my `nginx` pod:
|
||||

|
||||
|
||||
|
||||
---
|
||||
## Conclusion
|
||||
|
||||
In this journey, I started from the basics, exposing a single pod with a `LoadBalancer` service, and step by step built a production-ready setup:
|
||||
- Learned about **Kubernetes Services** and their different types.
|
||||
- Used **BGP with Cilium** and OPNsense to assign external IPs directly from my network.
|
||||
- Introduced **Ingress** to scale better, exposing multiple services through a single entry point.
|
||||
- Installed the **NGINX Ingress Controller** to handle routing.
|
||||
- Automated certificate management with **Cert-Manager**, securing my services with Let’s Encrypt TLS certificates.
|
||||
|
||||
🚀 The result: my pod is now reachable at a real URL, secured with HTTPS, just like any modern web application.
|
||||
|
||||
This is a huge milestone in my homelab Kubernetes journey. In the next article, I want to explore persistent storage and connect my Kubernetes cluster to my **Ceph** setup on **Proxmox**.
|
||||
Reference in New Issue
Block a user