De ingress-nginx à Envoy Gateway
Il y a trois ans, je vous expliquais comment protéger les services derrière vos ingress avec OAuth2 Proxy.
Trois annotations sur un ingress nginx, un proxy installé une fois dans le cluster, et le tour était joué.
Aujourd'hui, nous avons retiré ingress-nginx de tous nos clusters. Et oauth2-proxy avec.
Sur le papier, une migration d'ingress c'est « juste » réécrire des manifests. En pratique, c'est surtout découvrir tout ce que nginx faisait pour nous sans qu'on le lui demande.
Cet article raconte cette migration, et surtout ce qui a cassé.
Pourquoi migrer ?
La raison la plus évidente : ingress-nginx n'est plus maintenu. Le projet est passé en maintenance minimale puis s'est arrêté en mars 2026. Garder un composant exposé sur Internet sans correctifs de sécurité n'est pas une option.
Mais c'est aussi l'occasion de régler un problème plus ancien.
L'objet Ingress de Kubernetes est très pauvre. Il sait router un host et un chemin vers un service, et c'est à peu près tout. Tout le reste (taille des requêtes, timeouts, authentification, websockets, sessions persistantes…) passe par des annotations propres à chaque contrôleur :
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/affinity: cookie
nginx.ingress.kubernetes.io/auth-url: https://url-du-proxy/oauth2/auth
Ces annotations sont devenues une API de fait : des chaînes de caractères, non typées, non validées, et qui ne veulent rien dire pour un autre contrôleur.
La Gateway API corrige ça avec un modèle en plusieurs objets, chacun porté par un rôle différent :
GatewayClass: quelle implémentation (ici Envoy Gateway), gérée par l'équipe plateformeGateway: un point d'entrée, ses listeners et ses portsHTTPRoute: le routage d'une application, géré par l'équipe qui la déploie
Et pour tout ce que le standard ne couvre pas encore, Envoy Gateway propose des policies typées qui s'attachent à une Gateway ou à une HTTPRoute. Nous allons en croiser plusieurs.
Pourquoi Envoy Gateway et pas Cilium ?
Nos clusters utilisent déjà Cilium comme CNI, et Cilium implémente la Gateway API. Le choix semblait évident.
Il ne l'a pas été, pour une raison très concrète : les sessions persistantes.
Une de nos applications est stateful côté serveur. Un utilisateur doit toujours retomber sur le même pod, sinon il perd sa session. Avec nginx, c'était l'annotation affinity: cookie.
Dans la Gateway API, cette fonctionnalité existe (sessionPersistence, GEP-1619), mais seulement dans le canal experimental des CRDs. Au moment où nous avons fait le choix, Cilium ne l'implémentait pas contrairement à Envoy Gateway.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mon-app
spec:
parentRefs:
- name: envoy-gateway
namespace: edge
hostnames:
- mon-app.exemple.fr
rules:
- backendRefs:
- name: mon-app
port: 8080
sessionPersistence:
sessionName: mon_app_sticky
type: Cookie
absoluteTimeout: 48h
cookieConfig:
lifetimeType: Permanent
Il faut donc installer les CRDs du canal experimental, et accepter que ces champs puissent encore évoluer. C'est un compromis que nous avons fait en connaissance de cause.
Installer sans se tirer une balle dans le pied
Tout est déployé en GitOps avec Argo CD. Nous avons séparé l'installation en trois applications distinctes :
- les CRDs (Gateway API et Envoy Gateway)
- le contrôleur Envoy Gateway
- les objets propres au cluster :
GatewayClass,Gateway,EnvoyProxy
La raison est simple : si le contrôleur et les objets qui dépendent des CRDs arrivent dans la même synchronisation, Argo CD peut tenter de créer une Gateway avant que son type existe. Séparer les applications (et ajouter des sync waves) garantit l'ordre.
Ce découpage a un autre intérêt, plus important à mes yeux : il suit le modèle de rôles de Gateway API. Là où une ressource Ingress mélangeait tout (le choix du contrôleur, le TLS, le routage et des annotations propres à nginx), Gateway API sépare les responsabilités :
- la
GatewayClasset l'EnvoyProxy: comment le trafic entre dans le cluster, c'est l'affaire de la plateforme - la
Gateway: les points d'entrée, les listeners, le TLS, toujours côté plateforme - les
HTTPRouteet les policies attachées à une route : le routage d'une application, qui appartient à l'équipe qui la déploie
Dans une logique plateforme, c'est exactement ce que nous cherchions. L'équipe infra possède les trois applications Argo CD ci-dessus. Les équipes produit gardent la main sur les couches hautes : leurs HTTPRoute, leurs timeouts, leur authentification, versionnés avec leur application. Elles n'ont pas besoin de toucher à la Gateway pour publier un nouveau chemin, et une erreur dans une route n'impacte pas les autres.
La frontière se règle sur la Gateway elle-même, avec allowedRoutes :
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
from: All laisse n'importe quel namespace s'attacher. Pour être plus strict, from: Selector limite l'attachement aux namespaces portant un label choisi par la plateforme.
Exposer Envoy : un NodePort derrière Traefik
Nos clusters avaient déjà un point d'entrée : un load balancer, puis des Traefik sur des VM, hors des clusters, qui terminent le TLS et répartissent le trafic vers les nœuds.
Nous l'avons gardé tel quel. Envoy est simplement exposé en NodePort : le même port est ouvert sur chaque nœud, et Traefik a tous les nœuds comme backends.
C'est le modèle standard pour Kubernetes hors cloud. C'est d'ailleurs ce que font les load balancers des clouds : un NLB AWS en mode instance ne fait rien d'autre que viser un NodePort sur chaque nœud.
Avec Envoy Gateway, le service est piloté par un objet EnvoyProxy, référencé par la GatewayClass :
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: envoy-gateway-nodeport
namespace: edge
spec:
provider:
type: Kubernetes
kubernetes:
envoyService:
patch:
type: StrategicMerge
value:
spec:
type: NodePort
externalTrafficPolicy: Cluster
ports:
- name: http
port: 80
targetPort: 10080
nodePort: 30080
externalTrafficPolicy: Cluster est un choix délibéré. Avec cette valeur, n'importe quel nœud accepte la connexion et la renvoie vers un pod Envoy, éventuellement sur un autre nœud. On paie un saut réseau de plus, mais chaque nœud est un backend valide pour Traefik, même s'il n'héberge aucun Envoy. Un health check simple suffit.
Avec Local, on économiserait ce saut, mais un nœud sans pod Envoy refuserait le trafic. Traefik devrait alors savoir, à tout moment, quels nœuds hébergent un Envoy.
Retrouver l'IP du client
Deux proxys en cascade entrainent une conséquence directe : Envoy ne voit plus l'IP du client, mais celle de Traefik. Pour retrouver la vraie IP, il faut lui dire de faire confiance à un saut dans X-Forwarded-For :
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
name: envoy-gateway-trusted-proxy-headers
namespace: edge
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: envoy-gateway
clientIPDetection:
xForwardedFor:
numTrustedHops: 1
Sans ça, tous vos logs d'accès, vos rate limits et vos listes d'IP autorisées voient la même adresse. Et avec une valeur trop grande, n'importe quel client peut forger son IP en ajoutant un en-tête X-Forwarded-For.
Mais ce réglage repose sur une hypothèse forte : seul Traefik parle à Envoy. Si un client peut joindre Envoy directement, c'est lui qui écrit le dernier X-Forwarded-For, et Envoy le croira sur parole.
Ne laisser entrer que Traefik
Or un NodePort écoute sur toutes les interfaces de tous les nœuds. Sans filtrage, n'importe quelle machine du réseau peut joindre Envoy sur le port 30080, en contournant tout ce que fait Traefik (TLS, rate limiting, listes d'IP), et en choisissant l'IP qu'elle veut faire croire.
Le premier réflexe serait une NetworkPolicy sur les pods Envoy, qui n'accepte que les IP des Traefik. Avec externalTrafficPolicy: Cluster, ça ne fonctionne pas : quand un nœud renvoie la connexion vers un Envoy situé sur un autre nœud, il fait du SNAT. Le pod voit arriver l'IP du nœud, plus celle de Traefik.
Il faut donc filtrer avant la répartition, sur l'hôte lui-même. Cilium sait le faire avec son firewall hôte (hostFirewall.enabled=true) et une policy qui sélectionne des nœuds plutôt que des pods :
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: host-nodeport-envoy
spec:
nodeSelector: {}
ingress:
- fromCIDR:
- 10.0.0.15/32
- 10.0.1.15/32
toPorts:
- ports:
- port: "30080"
protocol: TCP
Attention au piège : dès qu'une policy hôte sélectionne un nœud, celui-ci passe en refus par défaut pour tout le trafic entrant. SSH, API server, kubelet, etcd, tunnel VXLAN, health checks de Cilium : tout ce qui n'est pas explicitement autorisé est coupé.
Nous avons donc écrit la policy complète (le trafic intra-cluster via fromEntities: [cluster, remote-node, health], puis les ports d'administration), et l'avons d'abord déployée avec policyAuditMode activé.
Dans ce mode, Cilium laisse tout passer mais journalise ce qu'il aurait bloqué. Quelques jours de Hubble plus tard, plus rien d'inattendu n'apparaissait, et nous avons activé l'application réelle.
Au passage, on corrige aussi la faiblesse du X-Forwarded-For vue plus haut : seuls les Traefik peuvent désormais joindre Envoy, donc le dernier saut est toujours un proxy de confiance.
Basculer sans big bang
Pour chaque cluster, nous avons fait tourner nginx et Envoy en parallèle. Les applications étaient d'abord exposées via Envoy sur une URL différente, testées, puis nous avons basculé le vrai nom et retiré l'ingress.
C'est plus long, mais c'est ce qui nous a permis de découvrir les problèmes qui suivent pendant notre QA, avant qu'ils n'atteignent les utilisateurs.
Les uploads
C'est le premier point sur lequel nous sommes tombés pendant la QA : sur l'URL de test servie par Envoy, l'envoi de gros fichiers échouait, alors qu'il passait sans problème par nginx.
Côté nginx, nous avions proxy-body-size: 0, c'est-à-dire pas de limite. Et nginx a une particularité : quand le corps d'une requête dépasse son buffer mémoire, il l'écrit dans un fichier temporaire sur disque. Il peut donc absorber un fichier de plusieurs gigaoctets sans exploser en mémoire.
Envoy a une philosophie différente. Il est pensé pour streamer : il fait passer les octets au fil de l'eau et applique une limite de buffer par connexion (32 Ko par défaut) pour gérer le contrôle de flux. Tant que rien n'a besoin du corps complet, ça marche très bien.
Concrètement, quand le backend lit moins vite que le client n'envoie, Envoy accumule jusqu'à cette limite, puis arrête de lire la socket du client le temps que ça se vide. Le client ralentit, la mémoire reste stable. Un fichier de 5 Go traverse Envoy sans jamais occuper plus de quelques dizaines de kilo-octets.
Le problème apparaît dès qu'un élément de la chaîne a besoin du corps complet avant de le laisser passer. Là, la limite n'est plus un seuil de contrôle de flux, c'est un plafond. Au-delà, la requête est rejetée avec un 413 Payload Too Large.
Restait à trouver qui, dans notre chaîne, réclamait le corps complet.
Le coupable : notre WAF
Nous avions profité de la migration pour ajouter un WAF, Coraza, chargé dans Envoy sous forme de module Wasm, avec les règles OWASP CRS. Il était en mode DetectionOnly : il journalise ce qu'il aurait bloqué, mais ne bloque rien. Du moins, c'est ce que nous pensions.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyExtensionPolicy
metadata:
name: coraza-waf
namespace: edge
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: envoy-gateway
wasm:
- name: coraza-waf
rootID: waf
failOpen: true
code:
type: Image
image:
url: ghcr.io/corazawaf/coraza-proxy-wasm:0.6.0
config:
default_directives: default
directives_map:
default:
- Include @recommended-conf
- Include @crs-setup-conf
- Include @owasp_crs/*.conf
- SecRuleEngine DetectionOnly
- SecRequestBodyAccess On
Pour inspecter le corps d'une requête (chercher une injection SQL dans un formulaire, par exemple), un WAF doit l'avoir en entier. C'est ce que fait SecRequestBodyAccess On : Coraza demande à Envoy de bufferiser le corps avant de le transmettre au backend. C'est exactement le cas qui transforme la limite de buffer en plafond.
Et il y a un deuxième étage. La configuration recommandée de Coraza fixe SecRequestBodyInMemoryLimit à 128 Kio, avec SecRequestBodyLimitAction Reject. Au-delà de 128 Kio, la requête est rejetée.
Le détail qui tue : ce rejet ne passe pas par le moteur de règles. Il s'applique donc même en DetectionOnly. Notre WAF « en observation » bloquait tous les uploads de plus de 128 Kio.
La mauvaise réponse
Le réflexion instinctif serait de monter la limite de buffer d'Envoy, dans la ClientTrafficPolicy :
spec:
connection:
bufferLimit: 1Gi
Ça fonctionne, en partie : Envoy accepte de garder de gros corps en mémoire. Mais c'est une très mauvaise idée.
Cette limite s'applique par connexion, à toutes les routes du cluster. Quelques uploads simultanés de gros fichiers, ou un client rapide face à un backend lent, et un pod Envoy peut consommer plusieurs gigaoctets avant d'être tué pour dépassement de mémoire. Or tout le trafic du cluster passe par ces pods. Pour comparaison, le guide d'Envoy pour un proxy exposé en bordure de réseau recommande de rester à 32 Ko.
Et pourquoi ne pas faire comme nginx, en écrivant sur disque ? Envoy a bien un filtre pour ça (file_system_buffer), mais sa documentation le dit explicitement : travail en cours, pas destiné à la production. Envoy Gateway ne l'expose d'ailleurs pas.
La bonne réponse
Le problème n'était pas la taille du buffer, mais le fait que quelqu'un réclame le corps complet. Il suffit de dire à Coraza d'inspecter le début du corps puis de laisser passer le reste :
- SecRequestBodyAccess On
- SecRequestBodyLimitAction ProcessPartial
Avec ProcessPartial, Coraza analyse les 128 premiers Kio et laisse filer la suite sans la retenir. Les requêtes classiques (formulaires, JSON d'API) restent inspectées en entier, et les uploads repassent en streaming.
Ce compromis a un prix, et il faut le dire : un attaquant peut remplir les 128 premiers Kio avec du contenu anodin, puis placer sa charge après. Coraza inspecte le début, ne voit rien, et laisse passer le reste sans l'avoir lu. C'est un contournement de WAF bien connu.
En DetectionOnly, c'est acceptable : le WAF ne bloque rien de toute façon, il nous sert à observer. Mais ce n'est pas la configuration à garder au moment de passer en blocage.
La cible est de ne pas traiter toutes les routes de la même façon. Les injections SQL, XSS ou de commandes passent par des champs de formulaire ou du JSON, qui font quelques kilo-octets. Un corps de plusieurs centaines de kilo-octets sur une route qui n'attend pas de fichier est déjà suspect en soi. D'où :
- une inspection stricte par défaut (
Reject), avec uneEnvoyExtensionPolicyattachée à laGateway - une
EnvoyExtensionPolicydédiée, attachée aux seulesHTTPRouted'upload, enProcessPartial - sur ces routes d'upload, une protection portée par l'application : type de fichier, taille maximale, analyse antivirus
La limite de buffer d'Envoy est redescendue à une valeur raisonnable, et la mémoire des pods Envoy ne dépend plus de la taille des fichiers envoyés.
La leçon dépasse le WAF. Avec Envoy, quand un gros corps de requête pose problème, la bonne question n'est pas « quelle limite monter ? », mais « quel filtre a besoin du corps complet, et en a-t-il vraiment besoin ? ».
Un ext_authz qui transmet le corps au service d'autorisation, un filtre Lua ou un autre module Wasm peuvent produire exactement le même effet.
Les timeouts qui ne veulent pas dire la même chose
Deuxième surprise, sur des téléchargements longs et des traitements côté serveur qui prennent plusieurs minutes.
Chez nginx, proxy_read_timeout est un délai entre deux lectures : tant que le backend envoie des octets régulièrement, la connexion vit.
Chez Envoy, le timeout de route est une durée totale : le temps laissé au backend pour envoyer la réponse complète, à partir du moment où la requête a été entièrement reçue. Et sa valeur par défaut est de 15 secondes.
Un export qui met deux minutes à se générer ? Coupé à 15 secondes, avec un 504 et un upstream request timeout.
La Gateway API permet de le régler par règle de route :
rules:
- backendRefs:
- name: mon-app
port: 8080
timeouts:
request: 5m
backendRequest: 5m
Plutôt que de monter la valeur globalement, nous l'avons fait route par route, là où le besoin était réel. Un timeout long partout, c'est aussi des connexions qui s'accumulent quand un backend est lent.
Websockets et protocoles maison
Avec nginx, les websockets fonctionnaient avec deux annotations de timeout. Avec Envoy, un upgrade de connexion n'est accepté que s'il est explicitement autorisé.
Pour une application de bureau à distance en HTML5, qui garde une connexion websocket ouverte pendant toute la session :
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: remote-desktop-session
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: remote-desktop
httpUpgrade:
- type: websocket
timeout:
http:
requestTimeout: 0s
connectionIdleTimeout: 3600s
requestTimeout: 0s désactive le timeout total (une session peut durer des heures), et connectionIdleTimeout coupe les sessions réellement inactives.
Le cas le plus intéressant a été Headscale, l'implémentation open source du serveur de contrôle Tailscale. Le client Tailscale ne fait pas un upgrade websocket : il demande un upgrade vers son propre protocole, tailscale-control-protocol, pour établir un canal chiffré avec le protocole Noise.
Nginx laissait passer n'importe quel en-tête Upgrade. Envoy le refuse tant qu'on ne l'a pas déclaré :
spec:
httpUpgrade:
- type: websocket
- type: tailscale-control-protocol
Le symptôme, lui, n'avait rien d'évident : des clients incapables de s'enregistrer, et rien de parlant côté application. C'est typiquement le genre de comportement implicite qu'on ne soupçonne pas avant de changer de proxy.
Adieu oauth2-proxy
C'est la partie qui m'a fait le plus plaisir.
Envoy sait faire de l'OIDC nativement. L'authentification se déclare dans une SecurityPolicy attachée à une route :
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: mon-app-oidc
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: mon-app
oidc:
provider:
issuer: "https://login.microsoftonline.com/${tenant_id}/v2.0"
clientID: "${client_id}"
clientSecret:
name: mon-app-oidc
scopes:
- openid
- email
- profile
cookieConfig:
sameSite: Lax
Par rapport à l'approche de mon article de 2023, plusieurs choses changent :
- plus de service tiers à déployer, superviser et mettre à jour
- l'authentification est déclarée par route, et non par une chaîne d'annotations qui pointe vers un autre service
- chaque application a son propre client OIDC, donc ses propres droits. Avec oauth2-proxy, la portée du cookie (
*.recette.bar.frdans l'exemple de l'époque) donnait accès à tous les services du domaine d'un coup
Un piège, quand même, et pas des moindres.
Par défaut, Envoy chiffre les jetons qu'il stocke dans les cookies. Or un navigateur limite un cookie à environ 4096 octets (nom et valeur compris).
Chez certains utilisateurs, dont le jeton d'accès contient beaucoup de claims (des groupes, par exemple), le cookie chiffré dépasse cette taille. Le navigateur l'ignore silencieusement, et l'utilisateur tourne en boucle sur la mire de login.
Seuls certains comptes étaient touchés, ce qui rend le diagnostic assez pénible.
C'est une limite structurelle de l'implémentation OIDC d'Envoy Gateway : elle ne connaît que le cookie comme mécanisme de session. oauth2-proxy, lui, propose plusieurs backends de stockage, dont Redis : le cookie ne contient plus alors qu'un identifiant de session, et le jeton complet reste côté serveur. La taille du jeton devient indolore, au prix d'un service avec état à opérer, celui-là même que nous cherchions à retirer.
Pour des services internes, nous avons désactivé le chiffrement des jetons (les cookies restent HttpOnly) :
oidc:
refreshToken: false
disableTokenEncryption: true
C'est un compromis. Sur un service exposé publiquement, je préférerais réduire la taille du jeton côté fournisseur d'identité (moins de claims) plutôt que de désactiver le chiffrement.
EnvoyPatchPolicy : la porte de sortie à manier avec précaution
Quand une policy ne couvre pas un besoin, Envoy Gateway propose une échappatoire : EnvoyPatchPolicy. Elle permet d'appliquer un JSON Patch directement sur la configuration xDS générée pour Envoy.
C'est puissant, et c'est exactement le problème.
Les noms des ressources xDS sont générés par le contrôleur, et peuvent changer d'une version à l'autre. Un patch écrit à la main s'appuie sur une structure que personne ne garantit. Et surtout, il s'applique après toutes les autres policies.
Nous en avons fait l'expérience : un patch de type replace sur les routes d'un listener a écrasé la configuration générée par une SecurityPolicy OIDC. Rien dans les objets Kubernetes ne le laissait voir : la SecurityPolicy était acceptée, son statut était correct, mais la configuration réellement envoyée à Envoy ne la contenait plus.
Ma règle aujourd'hui :
EnvoyPatchPolicyseulement en dernier recours, avec un commentaire qui explique pourquoi aucune policy typée ne convient- le moins de
replacepossible, desaddciblés - vérifier la configuration réelle avec
egctl config envoy-proxyaprès chaque montée de version d'Envoy Gateway
Conclusion
Techniquement, remplacer ingress-nginx par Envoy Gateway n'a rien de compliqué. Les manifests sont plus verbeux, mais plus lisibles et validés par le schéma.
Le vrai travail, c'est de faire l'inventaire de tout ce que nginx faisait implicitement : bufferiser sur disque, laisser passer n'importe quel upgrade, compter les timeouts entre deux lectures plutôt que sur la durée totale. Aucun de ces comportements n'apparaissait dans nos annotations, et pourtant nos applications en dépendaient.
Si vous préparez la même migration, mon conseil : faites tourner les deux en parallèle, et testez en priorité les gros uploads, les téléchargements longs, les websockets et l'authentification avec vos comptes aux jetons les plus lourds.