FastAPI Hexagonal Code source

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.

TL;DR — Neuf 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 globalUne politique par cible retenu
PrincipeUne politique vide bloque tout, on rouvre ensuiteChaque cible déclare ses sources ; l'absence de politique = ouvert
Sûreté par défautMeilleure : un oubli fermeMoindre : 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 quoiUne 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.

Le mécanisme qu'il faut avoir en tête Dès qu'au moins une 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#

gateway frontend prometheus api mail-worker db-migrate pooler rabbitmq valkey mailpit postgres namespace nginx-unprivileged ×2 ×2 → 6 ×2 Job PgBouncer ×2 ×3 cache SMTP de test CNPG ×3 8080 8000 5432 5672 6379 1025 5432 8000 migrations : PostgreSQL en direct (DDL) × frontend → postgres aucune politique ne l'autorise : refusé Une politique d'ingress par cible ; tout ce qui n'est pas listé est refusé en entrée.

Les neuf politiques d'ingress#

CibleSources autoriséesPorts
frontendnamespace envoy-gateway-system8080
apifrontend, prometheus8000
pooler cnpg.io/podRoleapi, mail-worker, + opérateur CNPG5432
postgres cnpg.io/podRolepods du cluster CNPG (pooler + réplication), Job db-migrate, opérateur5432
rabbitmqapi, mail-worker (5672) · gateway (15672) · lui-même (clustering) · opérateur5672, 15672, epmd
valkeyapi, mail-worker, redisinsight6379
mailpitapi, mail-worker (1025) · gateway (8025)1025, 8025
prometheusnamespace envoy-gateway-system9090
redisinsightnamespace envoy-gateway-system5540

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 :

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é.

Vérifier le moteur de politiques. Kind fournit un support des NetworkPolicy depuis la version 0.24, via kube-network-policies. L’attribuer au seul kindnetd est imprécis. Vérifier la version et les composants installés, puis les connexions autorisées et refusées. Les résultats historiques du cours ne remplacent pas ces tests sur son propre cluster.

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.

RessourceRépond àQui la tient
GatewayClassQuelle implémentation ?l'exploitant du cluster
EnvoyProxy hors standardComment le data plane est câblé ?l'exploitant du cluster
GatewayQuels ports sont ouverts, pour qui ?l'équipe plateforme
HTTPRouteQui 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 }
Le piège de la migration : 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.

ConsoleURLHTTPRouteService → port
Applicationhttp://localhostapp (sans hostname)frontend → 80
Mailpithttp://mailpit.localhostmailpitmailpit → 8025
RabbitMQhttp://rabbitmq.localhostrabbitmqrabbitmq → 15672
RedisInsighthttp://redisinsight.localhostredisinsightredisinsight → 5540
Prometheushttp://prometheus.localhostprometheusprometheus → 9090
Pourquoi *.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.

Le défaut qui casse tout : 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.

Le mapping est figé à la création du cluster

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 :

IngressGateway API
ingressClassNameGateway.spec.gatewayClassName
rules[].hostHTTPRoute.spec.hostnames (niveau route, pas règle)
rules[].http.paths[]HTTPRoute.spec.rules[].matches[]
backend.servicerules[].backendRefs[] (plusieurs backends pondérés possibles)
annotations du contrôleurchamps 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 écoute

L’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#