04. Réseau
Une chaîne d'appels autorisée, tout le reste refusé — et un test qui le prouve plutôt que de l'affirmer.
NetworkPolicy
d'ingress, une par cible, qui listent leurs sources autorisées.
Pas de default-deny global : le refus vient de l'existence même d'une
politique sur la cible. Le frontend ne peut jamais joindre
PostgreSQL. Le sortant se ferme séparément — c'est l'objet des quatre
politiques d'egress du chapitre
07.
Le modèle choisi : une politique par cible#
Il y a deux façons d'écrire de la segmentation avec des
NetworkPolicy, et le choix se paie différemment.
| Default-deny global | Une politique par cible retenu | |
|---|---|---|
| Principe | Une politique vide bloque tout, on rouvre ensuite | Chaque cible déclare ses sources ; l'absence de politique = ouvert |
| Sûreté par défaut | Meilleure : un oubli ferme | Moindre : un oubli laisse ouvert |
| Risque à la mise en place | Élevé : casse les composants système (réplication CNPG, opérateurs) | Faible : on n'agit que sur ce qu'on comprend |
| Lisibilité | Il faut lire toutes les politiques pour savoir qui joint quoi | Une politique = une réponse à « qui peut me parler ? » |
Le dépôt retient le second, et le dit : « Pas de default-deny global (plus sûr avec les pods CNPG, sensibles à la réplication) ». C'est un compromis assumé, pas une ignorance — et pour un cluster de démonstration dont l'objet est d'enseigner la segmentation, la lisibilité l'emporte.
NetworkPolicy sélectionne un pod, ce pod
passe en « tout ce qui n'est pas listé est refusé » pour le type de trafic
concerné. Ici toutes les politiques déclarent
policyTypes: [Ingress] : l'entrant est donc verrouillé, et le
sortant reste libre — DNS compris, ce qui évite la panne
classique du premier default-deny mal posé.
La chaîne autorisée#
Les neuf politiques d'ingress#
| Cible | Sources autorisées | Ports |
|---|---|---|
frontend | namespace envoy-gateway-system | 8080 |
api | frontend, prometheus | 8000 |
| pooler cnpg.io/podRole | api, mail-worker, + opérateur CNPG | 5432 |
| postgres cnpg.io/podRole | pods du cluster CNPG (pooler + réplication), Job db-migrate, opérateur | 5432 |
rabbitmq | api, mail-worker (5672) · gateway (15672) · lui-même (clustering) · opérateur | 5672, 15672, epmd |
valkey | api, mail-worker, redisinsight | 6379 |
mailpit | api, mail-worker (1025) · gateway (8025) | 1025, 8025 |
prometheus | namespace envoy-gateway-system | 9090 |
redisinsight | namespace envoy-gateway-system | 5540 |
Le fichier contient treize politiques : neuf pour l’ingress, quatre pour l’egress. Les autorisations de toutes les politiques sélectionnant un pod sont réunies ; il faut donc lire leur union, pas chaque fichier isolément. Pour une connexion, l’egress de la source et l’ingress de la destination doivent tous deux l’autoriser.
Trois détails que la lecture rapide manque :
- Prometheus est autorisé à joindre l'API. Il scrape
/metricssur le port 8000. Sans cette source dansallow-api, la supervision tomberait silencieusement en même temps que la segmentation serait activée. - RabbitMQ doit pouvoir se parler à lui-même. Un cluster à 3 nœuds a besoin d'epmd et du port de distribution Erlang entre ses membres, d'où une règle dont la source est… son propre label.
- Les opérateurs ont leur porte. CNPG (namespace
cnpg-system) et l'opérateur RabbitMQ (rabbitmq-system) doivent atteindre leurs pods pour les gérer, sonder leur état et déclencher les bascules. Les oublier, c'est un cluster qui se fige dès la première panne.
Le cas qui porte la leçon : frontend → postgres#
C'est le seul refus que le dépôt met en avant, parce qu'il correspond à un vrai scénario d'attaque : un frontend compromis — exécution de code dans le conteneur nginx, image altérée — tenterait de joindre la base directement.
# k8s/network-policies.yaml
metadata: { name: allow-postgres }
spec:
podSelector: { matchLabels: { cnpg.io/podRole: instance } }
policyTypes: [Ingress]
ingress:
- from:
- podSelector: { matchLabels: { cnpg.io/cluster: task-manager-db } }
- podSelector: { matchLabels: { job-name: db-migrate } }
ports:
- { protocol: TCP, port: 5432 }frontend n'apparaît nulle part : la connexion est refusée par
l'absence, pas par une règle de blocage explicite. Et le README du dépôt précise
que ce n'est pas une supposition — « vérifié : un pod non listé sur la
base time-out ; l'api passe ». Sur un projet dont la thèse est la preuve
plutôt que l'affirmation, tester une politique réseau est la moindre des choses :
une NetworkPolicy non appliquée par le CNI ne produit
aucune erreur, juste une fausse impression de sécurité.
La Gateway : quatre objets là où l'Ingress n'en avait qu'un#
L'entrée du cluster est décrite en Gateway API, servie par
Envoy Gateway. Le découpage est le
cœur du sujet : là où un Ingress mélangeait tout dans un objet plus
des annotations propriétaires, quatre ressources portent quatre décisions, qui
n'appartiennent pas aux mêmes personnes.
| Ressource | Répond à | Qui la tient |
|---|---|---|
GatewayClass | Quelle implémentation ? | l'exploitant du cluster |
EnvoyProxy hors standard | Comment le data plane est câblé ? | l'exploitant du cluster |
Gateway | Quels ports sont ouverts, pour qui ? | l'équipe plateforme |
HTTPRoute | Qui répond quoi ? | l'équipe applicative |
# k8s/gateway.yaml — les ports ouverts, et qui peut s'y attacher
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces: { from: Same }allowedRoutes limite ici l’attachement aux routes du namespace de la Gateway. Cela ne réserve pas globalement un hostname : une autre Gateway peut encore déclarer le même nom. Le contrôle multitenant dépend aussi des droits de création des Gateways, des classes autorisées et, si nécessaire, de règles d’admission.
# k8s/httproutes.yaml — l'application, sans hostname : elle attrape tout
spec:
parentRefs:
- { name: task-manager, sectionName: http }
rules:
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- { name: frontend, port: 80 }hostnames a changé de niveau
Dans un Ingress, host était un attribut de la
règle : un seul objet portait les quatre consoles. Dans une
HTTPRoute, hostnames est un attribut de la
route. Il faut donc une route par groupe de règles ; plusieurs hostnames peuvent partager une route —
cinq objets au lieu de deux.
C'est plus verbeux, et c'est délibéré : chaque route peut vivre dans son namespace, appartenir à une équipe différente, et être supprimée sans toucher aux autres. La précédence, elle, ne change pas : une route qui déclare un hostname exact l'emporte sur la route applicative qui n'en déclare aucun.
| Console | URL | HTTPRoute | Service → port |
|---|---|---|---|
| Application | http://localhost | app (sans hostname) | frontend → 80 |
| Mailpit | http://mailpit.localhost | mailpit | mailpit → 8025 |
| RabbitMQ | http://rabbitmq.localhost | rabbitmq | rabbitmq → 15672 |
| RedisInsight | http://redisinsight.localhost | redisinsight | redisinsight → 5540 |
| Prometheus | http://prometheus.localhost | prometheus | prometheus → 9090 |
*.localhost fonctionne sans rien configurer
Avec systemd-resolved, tout nom en .localhost résout
déjà vers 127.0.0.1. Sinon, une ligne dans /etc/hosts
suffit. C'est ce qui permet d'exposer cinq interfaces sur le port 80 sans
port-forward ni ports exotiques à retenir.
Les identifiants de la console RabbitMQ ne sont écrits nulle part : l'opérateur
les génère dans le secret rabbitmq-default-user. On les lit avec
kubectl get secret rabbitmq-default-user -n task-manager -o jsonpath='{.data.username}' | base64 -d.
Comment le trafic entre, et pourquoi le label ingress-ready a disparu#
Envoy Gateway expose une Gateway par un Service LoadBalancer. Sur
un cluster local, personne ne provisionne d'IP externe : le Service resterait en
<pending> indéfiniment. On le bascule donc en
NodePort figé, et Kind mappe ce port sur l'hôte.
# k8s/gateway.yaml — l'EnvoyProxy référencé par la GatewayClass
spec:
provider:
type: Kubernetes
kubernetes:
envoyService:
type: NodePort
externalTrafficPolicy: Cluster # <- indispensable, cf. ci-dessous
patch:
type: StrategicMerge
value:
spec:
ports:
- { port: 80, nodePort: 30080 } # <- fusion sur la clé `port`
# k8s/kind-config.yaml
extraPortMappings:
- { containerPort: 30080, hostPort: 80, protocol: TCP }Le patch fusionne sur port, la clé de fusion des
ServicePort : on n'écrase que le nodePort, le nom généré
par Envoy Gateway (http-80) survit. C'est le remplaçant typé des
annotations nginx.ingress.kubernetes.io/* : un champ validé par une
CRD, pas une chaîne de caractères interprétée par un contrôleur.
externalTrafficPolicy: Local
Envoy Gateway met Local par défaut. En Local, un
NodePort ne sert que les pods présents sur le nœud contacté : les nœuds
sans pod Envoy jettent silencieusement le trafic. Or Kind ne mappe les ports de
l'hôte que sur le control-plane, où le data plane n'a aucune
raison d'être scheduled. Résultat : http://localhost qui ne répond
jamais, avec une Gateway Programmed: True et des routes
Accepted: True — tout a l'air correct.
Le symptôme qui identifie la cause : curl sur l'IP d'un nœud
portant un pod Envoy répond 200, sur les autres il expire. Et depuis
l'intérieur d'un nœud, curl 127.0.0.1:30080 répond 200 même sans pod
local — kube-proxy ne restreint que le trafic venu de l'extérieur, ce qui rend le
diagnostic trompeur.
Cluster fait masquer le trafic par kube-proxy et le route vers
n'importe quel pod Envoy. Le prix est réel : l'IP source du client est
perdue. Sans objet ici, où tout vient de l'hôte ; sur un vrai cluster,
c'est précisément l'arbitrage que Local cherche à gagner.
Une fois Cluster posé, le gain est plus grand qu'il n'y paraît. Un
NodePort écoute sur tous les nœuds : le trafic entre par le
control-plane, et kube-proxy l'achemine vers les pods Envoy où qu'ils soient. L'ancien montage — hostPort
80/443 plus un label ingress-ready=true ciblé par le
nodeSelector du contrôleur — épinglait au contraire le point
d'entrée sur le control-plane. Ce label était un artefact du provider Kind
d'ingress-nginx, pas une notion Kubernetes ; il a disparu de
kind-config.yaml, avec le nodeSelector et la toleration
qui allaient avec.
Un cluster Kind créé avant ce changement porte encore hostPort 80 →
containerPort 80 : rien ne répondra sur http://localhost, et
aucun kubectl apply n'y changera quoi que ce soit.
deploy.sh détecte le cas et s'arrête plutôt que de laisser chercher.
Après sauvegarde des données à conserver, le remède est de recréer le cluster (opération destructive) : ./k8s/clean.sh &&
./k8s/deploy.sh.
Ingress, l'API historique#
Le dépôt a remplacé Ingress NGINX par Envoy Gateway. Le projet communautaire Ingress NGINX a annoncé sa retraite pour mars 2026 : voir la communication officielle Kubernetes. Cela ne retire ni l’API Ingress de Kubernetes, ni tous les autres contrôleurs basés sur nginx.
# l'ancien k8s/ingress.yaml, pour comparaison
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: frontend, port: { number: 80 } }C'est plus court, et c'est précisément le problème : tout ce qui n'entre pas dans ce moule — réécriture de chemin, timeouts, en-têtes, répartition de charge, mTLS — passait par des annotations propres à chaque contrôleur, non validées, non portables, et non documentées par l'API Kubernetes. Gateway API remonte ces réglages dans des ressources typées.
Il faut malgré tout connaître Ingress : c'est encore ce qu'on
trouve dans l'immense majorité des clusters en service, et la migration est un
exercice courant. Les correspondances utiles :
| Ingress | Gateway API |
|---|---|
ingressClassName | Gateway.spec.gatewayClassName |
rules[].host | HTTPRoute.spec.hostnames (niveau route, pas règle) |
rules[].http.paths[] | HTTPRoute.spec.rules[].matches[] |
backend.service | rules[].backendRefs[] (plusieurs backends pondérés possibles) |
| annotations du contrôleur | champs typés, ou parametersRef vers une CRD d'implémentation |
| — | Gateway.listeners[].allowedRoutes : le contrôle multi-tenant qui manquait |
Le service du frontend : 80 dehors, 8080 dedans#
spec:
selector: { app: frontend }
ports:
- port: 80 # ce que la HTTPRoute appelle
targetPort: 8080 # ce que nginx-unprivileged écouteL’image nginx-unprivileged écoute sur 8080 et le Service expose le port 80. Un processus non-root peut parfois utiliser un port inférieur à 1024 selon les capabilities et les paramètres du noyau ; 8080 évite de dépendre de cette configuration. La NetworkPolicy cible le port de destination du pod, donc 8080 ici.
À retenir#
- Une politique par cible : plus lisible et moins risqué qu'un default-deny global, au prix d'une sûreté par défaut moindre.
- Une politique
Ingressne verrouille que l'entrant. L'egress se ferme séparément, et il l'est désormais pour les workloads applicatifs (ch. 07). - Ne pas oublier les sources non applicatives : Prometheus, le clustering RabbitMQ, et les opérateurs.
- Une
NetworkPolicynon appliquée par le CNI échoue en silence : il faut la tester. - Gateway API sépare ce que l'Ingress mélangeait :
GatewayClass(l'implémentation),Gateway(les ports ouverts),HTTPRoute(les backends) — etallowedRoutesdit enfin qui a le droit de s'attacher. hostnamesest au niveau de la route, pas de la règle : uneHTTPRoutepar hostname. C'est ce qui surprend le plus en migrant depuis unIngress.- Le point d'entrée est un NodePort figé mappé par Kind, pas un
hostPortépinglé sur le control-plane : le labelingress-readyétait un artefact d'ingress-nginx, il a disparu. externalTrafficPolicydécide qui peut servir un NodePort.Local— le défaut d'Envoy Gateway — préserve l'IP source mais n'accepte que les nœuds portant un pod ;Clustermasque et route partout. Sur Kind, seulClusterfonctionne.- Le durcissement se propage jusqu'au réseau : port 8080 non privilégié, donc
targetPort: 8080et une politique qui cible 8080.