Ingress-Nginx dans Kubernetes : Concepts et Pratiques

Ingress-Nginx dans Kubernetes

Principes de l'Ingress-Nginx

Équilibrage de charge : couche 4 vs couche 7

Équilibrage de couche 4 (ex : LVS) : Le répartiteur agit uniquement comme un aiguilleur de trafic. Le client établit une connexion TCP directe avec le serveur backend via le répartiteur. Celui-ci ne fait que transmettre les paquets sans inspecter le contenu des requêtes.

Équilibrage de couche 7 (ex : Nginx) : Le répartiteur joue le rôle de proxy inverse. Il termine la connexion TCP du client, analyse la requête HTTP, puis établit une nouvelle connexion TCP vers le serveur backend. Cette approche permet une inspection approfondie du trafic et un routage basé sur les en-têtes, les chemins d'URL ou les noms d'hôte.

Avantages de la double connexion TCP

Avec un répartiteur de couche 4 comme LVS, si un client se connecte en HTTPS, le répartiteur transmet simplement le flux chiffré tel quel. Avec Nginx (couche 7), le répartiteur peut décider du protocole entre lui et le backend : HTTP ou HTTPS. Dans un environnement réseau interne sécurisé au sein d'un cluster, l'utilisation de HTTP entre Nginx et les backends évite la surcharge liée aux handshakes TLS multiples.

Routage par chemin d'URL

upstream groupe_admin {
    server 10.0.1.11;
    server 10.0.1.12;
}
upstream groupe_client {
    server 10.0.1.14;
    server 10.0.1.15;
}
server {
    location /admin {
        proxy_pass http://groupe_admin:80;
    }
    location /client {
        proxy_pass http://groupe_client:80;
    }
}

Le répartiteur de couche 4 ne peut router que par IP + port. Le répartiteur de couche 7 peut inspecter le chemin de l'URL et diriger le trafic vers différents pools de backends selon les règles location.

Simulation d'Ingress dans un cluster Kubernetes

Dans un cluster Kubernetes, on pourrait manuellement déployer un pod Nginx, l'exposer via un Service de type NodePort, puis configurer le nginx.conf pour pointer vers les Services backend. Cependant, cette approche pose un problème : à chaque modification des Pods backend, il faut mettre à jour manuellement la configuration Nginx et recharger.

L'Ingress-Nginx résout ce problème en surveillant automatiquement les objets Ingress dans l'API Kubernetes. Lorsqu'une règle Ingress est créée ou modifiée, le contrôleur Nginx met à jour dynamiquement nginx.conf et applique les changements.

Architecture interne du contrôleur Ingress-Nginx

L'API Kubernetes fait tourner une goroutine qui surveille les changements d'objets Ingress. Les événements sont classés en deux canaux : les informations non critiques passent par un tampon intermédiaire puis sont récupérées périodiquement, tandis que les informations critiques sont envoyées directement à la file de synchronisation. Le contrôleur décide ensuite s'il faut recharger Nginx ou appliquer une mise à jour asynchrone via Lua. Les rechargements sont minimisés car chaque reload interrompt les connexions actives.

Composants alternatifs

  • Istio Ingress Gateway : orienté microservices, offre une excellente observabilité du trafic avec visualisation graphique.
  • Traefik : proxy inverse écrit en Go, nativement conçu pour le cloud, détecte automatiquement les changements de configuration.
  • APISIX Ingress Controller : populaire dans l'écosystème microservices Java.

Déploiement et configuration

Installation via Helm

Préparation des images sur tous les nœuds :

# Sur le nœud master
docker load -i ingress-nginx-controller-v1.9.4.tar
docker load -i ingress-nginx-kube-webhook-certgen-v20231011-8b53cabe0.tar

# Distribution vers les workers
scp *.tar worker1:/root/
scp *.tar worker2:/root/

# Sur chaque worker
for img in *ingress*.tar; do docker load -i "$img"; done

Configuration du chart Helm (values.yaml) :

# Réseau hôte pour éviter le NodePort
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet

# DaemonSet pour une haute disponibilité
kind: DaemonSet

# Classe Ingress par défaut
ingressClassResource:
  name: nginx
  enabled: true
  default: true

# Désactiver les vérifications de digest
# digest: sha256:...
# digestChroot: sha256:...

La stratégie DNS ClusterFirstWithHostNet est nécessaire quand hostNetwork: true. Le Pod partage la pile réseau de l'hôte et résout d'abord via le DNS de l'hôte, puis via kube-dns.

kubectl create namespace ingress-nginx
helm install ingress-nginx -n ingress-nginx . -f values.yaml

kubectl get pod -o wide -n ingress-nginx
NAME                             READY   STATUS    NODE
ingress-nginx-controller-abc12   1/1     Running   worker1
ingress-nginx-controller-xyz34   1/1     Running   worker2

Proxy HTTP

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app-v1
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web-v1
  template:
    metadata:
      labels:
        app: web-v1
    spec:
      containers:
      - name: app
        image: myapp:v1.0
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web-app-v1-svc
spec:
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
  selector:
    app: web-v1
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-app-v1-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: app1.exemple.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-app-v1-svc
                port:
                  number: 80

L'accès direct à l'IP sans en-tête Host renvoie 404, car aucun serveur par défaut n'est configuré. Après ajout d'une entrée DNS locale dans /etc/hosts ou C:\Windows\System32\drivers\etc\hosts, le domaine app1.exemple.com répond correctement.

Proxy HTTPS avec terminaison SSL

# Génération du certificat
openssl req -x509 -sha256 -nodes -days 365 \
  -newkey rsa:2048 \
  -keyout tls.key -out tls.crt \
  -subj "/CN=nginxsvc/O=nginxsvc"

# Création du Secret Kubernetes
kubectl create secret tls tls-secret --key tls.key --cert tls.crt

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: secure-app-ingress
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  rules:
  - host: secure.exemple.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: secure-app-svc
            port:
              number: 80
  tls:
  - hosts:
    - secure.exemple.com
    secretName: tls-secret

La terminaison SSL se fait au niveau d'Ingress-Nginx : le client se connecte en HTTPS, mais le trafic vers le Service backend utilise HTTP en clair. C'est ce qu'on appelle la « déchargement SSL » (SSL offloading).

Authentification BasicAuth

# Création du fichier d'authentification
htpasswd -c authfile adminuser
# Saisir le mot de passe deux fois

# Encapsulation dans un Secret
kubectl create secret generic basic-auth-secret --from-file=authfile

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: protected-app-ingress
  annotations:
    nginx.ingress.kubernetes.io/auth-type: basic
    nginx.ingress.kubernetes.io/auth-secret: basic-auth-secret
    nginx.ingress.kubernetes.io/auth-realm: 'Accès restreint'
spec:
  ingressClassName: nginx
  rules:
  - host: protect.exemple.com
    http:
      paths:
      - path: /
        pathType: ImplementationSpecific
        backend:
          service:
            name: protected-app-svc
            port:
              number: 80

Redirection d'URL

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: redirect-ingress
  annotations:
    nginx.ingress.kubernetes.io/permanent-redirect: https://www.exemple.org
    nginx.ingress.kubernetes.io/permanent-redirect-code: '301'
spec:
  ingressClassName: nginx
  rules:
  - host: old.exemple.com
    http:
      paths:
      - pathType: Prefix
        path: "/"
        backend:
          service:
            name: dummy-svc
            port:
              number: 80

# Vérification
curl old.exemple.com -I
HTTP/1.1 301 Moved Permanently
Location: https://www.exemple.org

Réécriture d'URL (Rewrite)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rewrite-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  rules:
  - host: api.exemple.com
    http:
      paths:
      - path: /svc(/|$)(.*)
        pathType: ImplementationSpecific
        backend:
          service:
            name: rewrite-backend-svc
            port:
              number: 80

Différence entre Rewrite et Redirect :

  • Rewrite : modifie le chemin de la requête côté serveur, de manière transparente pour le client. Aucun code HTTP de redirection n'est renvoyé.
  • Redirect : le serveur informe le client qu'il doit se rendre à une nouvelle URL (codes 301, 302, etc.). Le client effectue une nouvelle requête vers la nouvelle adresse.

Page d'erreur personnalisée - Backend par défaut

Activation du backend par défaut dans values.yaml :

defaultBackend:
  enabled: true
  name: defaultbackend
  image:
    registry: docker.io
    image: custom-error-pages
    tag: "errweb1.0"
    pullPolicy: IfNotPresent
  port: 80

# Mise à jour du déploiement
helm upgrade -f values.yaml ingress-nginx . -n ingress-nginx

Désormais, toute requête ne correspondant à aucune règle Ingress renvoie la page d'erreur personnalisée au lieu d'un 404 standard.

Page d'erreur personnalisée - Backend spécifique

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: custom-error-ingress
  annotations:
    nginx.ingress.kubernetes.io/default-backend: 'error-page-svc'
    nginx.ingress.kubernetes.io/custom-http-errors: "404,415"
spec:
  rules:
  - host: err.exemple.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: main-app-svc
            port:
              number: 80

Le Service error-page-svc reçoit les requêtes uniquement lorsque le backend principal renvoie les codes 404 ou 415.

Routage basé sur les en-têtes (User-Agent)

Activation des annotations snippet dans le ConfigMap :

kubectl edit configmap ingress-nginx-controller -n ingress-nginx
# Ajouter :
data:
  allow-snippet-annotations: "true"

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ua-routing-ingress
  annotations:
    nginx.ingress.kubernetes.io/server-snippet: |
      set $mobile_flag 0;
      if ($http_user_agent ~* "(Android|IPhone)") {
        set $mobile_flag 1;
      }
      if ($mobile_flag = 1) {
        return 302 http://m.exemple.com;
      }
spec:
  rules:
  - host: www.exemple.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: desktop-svc
            port:
              number: 80

# Test
curl www.exemple.com -H 'User-Agent: Android' -I
HTTP/1.1 302 Moved Temporarily
Location: http://m.exemple.com

Listes de contrôle d'accès (IP)

ConfigMap (portée globale) vs Annotations (portée par Ingress). Les annotations ont une priorité supérieure au ConfigMap.

Liste noire via ConfigMap :

kubectl edit configmap ingress-nginx-controller -n ingress-nginx
data:
  block-cidrs: 192.168.1.50

Liste noire via Annotations :

metadata:
  annotations:
    nginx.ingress.kubernetes.io/server-snippet: |-
      deny 192.168.1.50;
      allow all;

Liste blanche via ConfigMap :

data:
  whitelist-source-range: 192.168.1.60

Liste blanche via Annotations :

metadata:
  annotations:
    nginx.ingress.kubernetes.io/whitelist-source-range: 192.168.1.60

Limitation de débit (Rate Limiting)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rate-limited-ingress
  annotations:
    nginx.ingress.kubernetes.io/limit-connections: "1"
    # Autres options disponibles :
    # nginx.ingress.kubernetes.io/limit-rps: "5"
    # nginx.ingress.kubernetes.io/limit-rpm: "100"
    # nginx.ingress.kubernetes.io/limit-rate: "100"  # en Ko/s
    # nginx.ingress.kubernetes.io/limit-whitelist: "192.168.1.0/24"
spec:
  rules:
  - host: limited.exemple.com
    http:
      paths:
      - pathType: Prefix
        path: "/"
        backend:
          service:
            name: limited-svc
            port:
              number: 80

# Test de charge sans limitation
ab -c 10 -n 100 http://limited.exemple.com/ | grep requests
Complete requests:      100
Failed requests:        0

# Avec limit-connections: "1"
ab -c 10 -n 100 http://limited.exemple.com/ | grep requests
Complete requests:      100
Failed requests:        88

Déploiement Canary (Grayscale)

Version stable (v1) :

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: stable-ingress
spec:
  rules:
  - host: canary.exemple.com
    http:
      paths:
      - pathType: Prefix
        path: "/"
        backend:
          service:
            name: stable-svc
            port:
              number: 80

Version canary (v2) avec 10% du trafic :

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: canary-ingress
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  rules:
  - host: canary.exemple.com
    http:
      paths:
      - pathType: Prefix
        path: "/"
        backend:
          service:
            name: canary-svc
            port:
              number: 80

# Test de distribution
for i in {1..100}; do curl canary.exemple.com; done | sort | uniq -c
   91 Hello MyApp | Version: v1
    9 Hello MyApp | Version: v2

# Ajustement dynamique à 50%
kubectl edit ingress canary-ingress
# Modifier canary-weight: "50"

for i in {1..100}; do curl canary.exemple.com; done | sort | uniq -c
   49 Hello MyApp | Version: v1
   51 Hello MyApp | Version: v2

Proxy vers un backend HTTPS

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: backend-https-ingress
  annotations:
    nginx.ingress.kubernetes.io/backend-protocol: HTTPS
spec:
  rules:
  - host: backend-tls.exemple.com
    http:
      paths:
      - backend:
          service:
            name: tls-backend-svc
            port:
              number: 443
        path: /
        pathType: ImplementationSpecific

Le champ http dans les règles est utilisé quel que soit le protocole backend ; c'est l'annotation backend-protocol qui détermine le protocole de la connexion amont.

Proxy de couche 4 (TCP)

Ajout de l'argument au DaemonSet :

kubectl edit daemonset -n ingress-nginx ingress-nginx-controller
# Ajouter dans les args :
- --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services-cm

apiVersion: v1
kind: ConfigMap
metadata:
  name: tcp-services-cm
  namespace: ingress-nginx
data:
  "9000": "default/backend-svc:80"

Le port 9000 du nœud est maintenant redirigé vers le port 80 du Service spécifié.

Proxy UDP

kubectl edit daemonset -n ingress-nginx ingress-nginx-controller
# Ajouter :
- --udp-services-configmap=$(POD_NAMESPACE)/udp-services-cm

apiVersion: v1
kind: ConfigMap
metadata:
  name: udp-services-cm
  namespace: ingress-nginx
data:
  "53": "kube-system/kube-dns:53"

Tracing distribué avec Jaeger

apiVersion: apps/v1
kind: Deployment
metadata:
  name: jaeger
  labels:
    app: jaeger
spec:
  replicas: 1
  selector:
    matchLabels:
      app: jaeger
  template:
    metadata:
      labels:
        app: jaeger
    spec:
      containers:
      - name: jaeger
        image: jaegertracing/all-in-one
        env:
        - name: COLLECTOR_ZIPKIN_HTTP_PORT
          value: "9411"
        ports:
        - containerPort: 5775
          protocol: UDP
        - containerPort: 6831
          protocol: UDP
        - containerPort: 16686
          protocol: TCP
        - containerPort: 9411
          protocol: TCP
        readinessProbe:
          httpGet:
            path: "/"
            port: 14269
          initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: jaeger-query
spec:
  ports:
  - name: query-http
    port: 80
    targetPort: 16686
  selector:
    app: jaeger
  type: NodePort

Activation du tracing dans le ConfigMap :

kubectl edit configmap ingress-nginx-controller -n ingress-nginx
data:
  enable-opentracing: "true"
  jaeger-collector-host: jaeger-agent.ingress-nginx.svc.cluster.local

Redémarrage du pod pour appliquer la configuration :

kubectl delete pod -l app=jaeger -n ingress-nginx
kubectl get svc jaeger-query -n ingress-nginx
# Accès à l'interface Jaeger via le NodePort exposé

Récapitulatif : couche 4 vs couche 7

Couche 4 : une seule connexion TCP de bout en bout entre le client et le serveur. Le répartiteur ne fait que transmettre les paquets au niveau réseau.

Couche 7 : deux connexions TCP distinctes. La première entre le client et le proxy, la seconde entre le proxy et le serveur backend. Le proxy peut inspecter, modifier et router le trafic en fonction du contenu HTTP.

Étiquettes: Ingress-Nginx kubernetes nginx Helm LoadBalancing

Publié le 25 juin à 19h32