11 — Services et réseau Kubernetes#

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Circle
import numpy as np
import pandas as pd
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted")

Le problème des IPs éphémères#

Imaginons un Pod comme un locataire d’appartement. À chaque fois qu’il déménage (redémarrage, mise à jour, crash), il reçoit une nouvelle adresse IP. Si d’autres Pods communiquaient directement avec cette IP, ils perdraient le contact à chaque redémarrage.

C’est exactement le problème que résout le Service Kubernetes.

Analogie : la boîte postale

Un Service, c’est comme une boîte postale à adresse fixe. Peu importe combien de fois le destinataire déménage (les Pods changent d’IP), le courrier arrive toujours à la bonne boîte. C’est le Service qui fait le tri et achemine vers les Pods actifs.

Le Service Kubernetes#

Un Service est une abstraction stable qui définit :

  • Un sélecteur de labels : quels Pods sont ciblés

  • Une ClusterIP virtuelle : adresse stable, inchangée tant que le Service existe

  • Des ports : mapping port du Service → port du Pod

apiVersion: v1
kind: Service
metadata:
  name: mon-app
  namespace: production
spec:
  selector:
    app: mon-app          # Sélectionne les Pods avec ce label
    version: stable
  ports:
    - name: http
      port: 80            # Port du Service (stable)
      targetPort: 8080    # Port du Pod (conteneur)
      protocol: TCP
  type: ClusterIP

kube-proxy : le routeur de Kubernetes#

Sur chaque nœud tourne un composant essentiel : kube-proxy. Son rôle est de programmer les règles réseau pour que le trafic vers une ClusterIP soit redirigé vers l’un des Pods réels.

Comment fonctionne kube-proxy ?

kube-proxy surveille l’API Server. Dès qu’un Service est créé ou modifié, il met à jour les règles du noyau Linux (iptables ou IPVS) sur le nœud. Le trafic ne passe jamais « par » kube-proxy en production — ce composant ne fait que configurer les règles, le noyau fait le routage directement.

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# --- Schéma gauche : flux requête ClusterIP ---
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Flux d'une requête via ClusterIP", fontsize=13, fontweight='bold', pad=15)

def boite(ax, x, y, w, h, label, sublabel="", color="#4A90D9", textcolor="white", fontsize=10):
    box = FancyBboxPatch((x - w/2, y - h/2), w, h,
                          boxstyle="round,pad=0.1", linewidth=1.5,
                          edgecolor="white", facecolor=color, alpha=0.9)
    ax.add_patch(box)
    ax.text(x, y + (0.15 if sublabel else 0), label, ha='center', va='center',
            color=textcolor, fontsize=fontsize, fontweight='bold')
    if sublabel:
        ax.text(x, y - 0.35, sublabel, ha='center', va='center',
                color=textcolor, fontsize=8, alpha=0.85)

def fleche(ax, x1, y1, x2, y2, label="", color="#333"):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color=color, lw=2))
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        ax.text(mx + 0.15, my, label, fontsize=8, color=color, ha='left')

boite(ax, 2, 8.5, 3.2, 0.9, "Client Pod", "10.244.1.5", "#6C63FF")
fleche(ax, 2, 8.05, 2, 7.15, "dst: 10.96.0.1:80")
boite(ax, 2, 6.7, 3.2, 0.9, "iptables / IPVS", "(noyau Linux)", "#E67E22")
ax.text(5.5, 6.7, "ClusterIP\n10.96.0.1:80", ha='center', va='center',
        fontsize=9, color="#E67E22",
        bbox=dict(boxstyle="round,pad=0.3", facecolor="#FEF9E7", edgecolor="#E67E22", lw=1.5))
ax.annotate("", xy=(4.0, 6.7), xytext=(3.4, 6.7),
            arrowprops=dict(arrowstyle="-|>", color="#E67E22", lw=1.5, linestyle='dashed'))

fleche(ax, 2, 6.25, 2, 5.35, "DNAT →")
boite(ax, 2, 4.9, 3.2, 0.9, "Pod A", "10.244.2.3:8080", "#27AE60")
boite(ax, 5, 4.9, 3.2, 0.9, "Pod B", "10.244.3.7:8080", "#27AE60")
boite(ax, 3.5, 3.3, 3.2, 0.9, "Pod C", "10.244.1.9:8080", "#27AE60")

fleche(ax, 2, 6.25, 5, 5.35, "ou")
fleche(ax, 2, 6.25, 3.5, 3.75, "ou")

ax.text(2, 2.5, "Load balancing aléatoire\n(ou IPVS : round-robin, least-conn...)",
        ha='center', va='center', fontsize=8.5, color="#555",
        bbox=dict(boxstyle="round,pad=0.3", facecolor="#ECF0F1", edgecolor="#BDC3C7"))

# --- Schéma droit : Endpoints ---
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 10)
ax2.axis('off')
ax2.set_title("Service, Endpoints et Pods", fontsize=13, fontweight='bold', pad=15)

boite(ax2, 5, 9, 5, 0.9, "Service: mon-app", "ClusterIP: 10.96.0.1:80", "#4A90D9")
boite(ax2, 5, 7.2, 5, 0.9, "Endpoints", "10.244.2.3:8080, 10.244.3.7:8080, 10.244.1.9:8080", "#8E44AD", fontsize=8)
fleche(ax2, 5, 8.55, 5, 7.65, "surveille les Pods via sélecteur")

for i, (px, py, name, ip) in enumerate([
    (2, 5.2, "Pod A", "10.244.2.3"), (5, 5.2, "Pod B", "10.244.3.7"), (8, 5.2, "Pod C", "10.244.1.9")
]):
    boite(ax2, px, py, 2.5, 1.1, name, ip, "#27AE60")
    fleche(ax2, 5, 6.75, px, 5.75)

ax2.text(5, 3.5,
    "kube-proxy met à jour les règles\nà chaque changement de Pod\n(crash, scale, rolling update)",
    ha='center', va='center', fontsize=9, color="#555",
    bbox=dict(boxstyle="round,pad=0.4", facecolor="#EBF5FB", edgecolor="#4A90D9"))

plt.tight_layout()
plt.savefig("11_flux_clusterip.png", dpi=120, bbox_inches='tight')
plt.show()
_images/a5a198c62a475cb262501e894be586a2341f14084da18d2230efe014ee932a6c.png

Les quatre types de Services#

Kubernetes propose quatre saveurs de Service, adaptées à des cas d’usage différents.

Hide code cell source

fig, axes = plt.subplots(2, 2, figsize=(15, 11))
fig.suptitle("Les quatre types de Services Kubernetes", fontsize=15, fontweight='bold', y=1.01)

couleurs = {
    "ClusterIP": "#4A90D9",
    "NodePort": "#E67E22",
    "LoadBalancer": "#27AE60",
    "ExternalName": "#8E44AD",
}

def draw_cluster(ax, title, color, description, extra_fn=None):
    ax.set_xlim(0, 12)
    ax.set_ylim(0, 9)
    ax.axis('off')
    ax.set_title(title, fontsize=12, fontweight='bold', color=color, pad=10)
    # Fond cluster
    cluster_bg = FancyBboxPatch((1, 1), 10, 7, boxstyle="round,pad=0.2",
                                 linewidth=2, edgecolor=color, facecolor=color, alpha=0.07)
    ax.add_patch(cluster_bg)
    ax.text(6, 7.7, "Cluster Kubernetes", ha='center', fontsize=9, color=color, style='italic')
    ax.text(6, 0.4, description, ha='center', fontsize=8.5, color="#444",
            bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor=color, alpha=0.8))
    if extra_fn:
        extra_fn(ax, color)

def clusterip_fn(ax, color):
    boite(ax, 6, 5.5, 3, 0.9, "Service ClusterIP", "10.96.42.1:80", color)
    boite(ax, 4, 3.2, 2.2, 0.85, "Pod A", "10.244.1.2", "#27AE60")
    boite(ax, 8, 3.2, 2.2, 0.85, "Pod B", "10.244.1.3", "#27AE60")
    fleche(ax, 6, 5.05, 4, 3.63)
    fleche(ax, 6, 5.05, 8, 3.63)
    boite(ax, 6, 7.0, 2.5, 0.75, "Client interne", "", "#6C63FF", fontsize=9)
    fleche(ax, 6, 6.63, 6, 5.95)
    ax.text(2.5, 6.5, "Inaccessible\ndepuis l'extérieur", ha='center', fontsize=8,
            color="#E74C3C", bbox=dict(boxstyle="round,pad=0.2", facecolor="#FDEDEC", edgecolor="#E74C3C"))

def nodeport_fn(ax, color):
    # Extérieur
    ax.add_patch(FancyBboxPatch((0.1, 6.8), 2, 1.3, boxstyle="round,pad=0.1",
                                 facecolor="#F8F9FA", edgecolor="#95A5A6", lw=1.5))
    ax.text(1.1, 7.45, "Internet /\nClient ext.", ha='center', fontsize=8, color="#555")
    fleche(ax, 2.1, 7.45, 3.5, 7.45, f":30080")
    # Node
    ax.add_patch(FancyBboxPatch((3.5, 5.8), 7, 2.5, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.1, edgecolor=color, lw=1.5))
    ax.text(7, 8.1, "Node (IP: 192.168.1.10)", ha='center', fontsize=9, color=color)
    boite(ax, 7, 7.05, 3, 0.85, "NodePort :30080", "→ Service :80", color)
    boite(ax, 5, 3.5, 2.2, 0.85, "Pod A", "", "#27AE60")
    boite(ax, 9, 3.5, 2.2, 0.85, "Pod B", "", "#27AE60")
    fleche(ax, 7, 6.63, 5, 3.93)
    fleche(ax, 7, 6.63, 9, 3.93)

def lb_fn(ax, color):
    ax.add_patch(FancyBboxPatch((0.1, 7.0), 2.2, 1.2, boxstyle="round,pad=0.1",
                                 facecolor="#F8F9FA", edgecolor="#95A5A6", lw=1.5))
    ax.text(1.2, 7.6, "Internet", ha='center', fontsize=9, color="#555")
    fleche(ax, 2.3, 7.6, 3.5, 7.6)
    boite(ax, 5.5, 7.6, 3.2, 0.85, "Cloud LB", "34.105.12.77", "#E74C3C")
    fleche(ax, 7.1, 7.6, 8.5, 7.6)
    ax.text(9.8, 7.6, "Cloud\nprovider", ha='center', fontsize=8, color="#E74C3C")
    boite(ax, 5.5, 5.8, 3, 0.85, "Service LB", "10.96.5.1:80", color)
    fleche(ax, 5.5, 7.18, 5.5, 6.23)
    boite(ax, 3.5, 3.5, 2, 0.8, "Pod A", "", "#27AE60")
    boite(ax, 7.5, 3.5, 2, 0.8, "Pod B", "", "#27AE60")
    fleche(ax, 5.5, 5.38, 3.5, 3.9)
    fleche(ax, 5.5, 5.38, 7.5, 3.9)

def extname_fn(ax, color):
    boite(ax, 5.5, 7.0, 4, 0.85, "Service ExternalName", "type: ExternalName", color)
    ax.text(5.5, 6.2, 'externalName:\n  "db.externe.example.com"',
            ha='center', fontsize=9, color="#555",
            bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor=color))
    boite(ax, 3, 4.5, 2.5, 0.85, "Pod client", "", "#6C63FF")
    fleche(ax, 3, 4.07, 5.5, 6.58, "DNS CNAME")
    ax.add_patch(FancyBboxPatch((7.5, 4.0), 3, 1.2, boxstyle="round,pad=0.1",
                                 facecolor="#F8F9FA", edgecolor="#E74C3C", lw=1.5))
    ax.text(9, 4.6, "Service\nextérieur", ha='center', fontsize=9, color="#E74C3C")
    fleche(ax, 7.5, 4.6, 7.5, 6.58)

draw_cluster(axes[0, 0], "ClusterIP (défaut) — accès interne uniquement",
             couleurs["ClusterIP"],
             "Usage : communication entre microservices dans le cluster",
             clusterip_fn)

draw_cluster(axes[0, 1], "NodePort — exposition sur un port du nœud",
             couleurs["NodePort"],
             "Usage : accès direct depuis l'extérieur (dev/test) — port 30000-32767",
             nodeport_fn)

draw_cluster(axes[1, 0], "LoadBalancer — via le cloud provider",
             couleurs["LoadBalancer"],
             "Usage : exposition en production sur un cloud (GKE, EKS, AKS…)",
             lb_fn)

draw_cluster(axes[1, 1], "ExternalName — alias DNS vers l'extérieur",
             couleurs["ExternalName"],
             "Usage : pointer vers une base de données ou API externe par nom DNS",
             extname_fn)

plt.tight_layout()
plt.savefig("11_types_services.png", dpi=120, bbox_inches='tight')
plt.show()
_images/b4ec08e1590b84fe99df522b58f2463df23615db55146f334c41350e00453a0c.png

Comparatif des types de Services#

Type

Accès

ClusterIP ?

Cas d’usage

ClusterIP

Interne uniquement

Oui (virtuelle)

Communication inter-services

NodePort

NodeIP:30000-32767

Oui + NodePort

Dev/test, accès direct

LoadBalancer

IP publique via cloud

Oui + NodePort + LB

Production sur cloud

ExternalName

DNS CNAME

Non

Alias vers services externes

DNS Kubernetes : CoreDNS#

Kubernetes embarque un serveur DNS interne : CoreDNS. Il permet de joindre un Service par nom plutôt que par IP.

Le format complet d’un nom DNS de Service est :

<nom-service>.<namespace>.svc.cluster.local

Par exemple, mon-app.production.svc.cluster.local résout vers la ClusterIP du Service mon-app dans le namespace production.

Raccourcis DNS

Depuis le même namespace, on peut utiliser juste mon-app (sans suffixe). Depuis un autre namespace, il faut au minimum mon-app.production. Le suffixe complet .svc.cluster.local est toujours valide quel que soit le contexte.

# Tester la résolution DNS depuis un Pod
kubectl run -it --rm dns-test --image=busybox --restart=Never -- nslookup mon-app.production

# Résultat attendu :
# Server: 10.96.0.10 (CoreDNS)
# Address: 10.96.0.10:53
# Name: mon-app.production.svc.cluster.local
# Address: 10.96.42.1

Endpoints et EndpointSlices#

Quand un Service est créé avec un sélecteur, Kubernetes crée automatiquement un objet Endpoints qui liste les IPs et ports des Pods correspondants.

# Voir les Endpoints d'un Service
kubectl get endpoints mon-app -n production

# NAME      ENDPOINTS                                      AGE
# mon-app   10.244.1.2:8080,10.244.2.5:8080,10.244.3.1:8080   5d

Pour les clusters de grande taille, les EndpointSlices (depuis K8s 1.21) découpent les Endpoints en tranches de 100 entrées maximum pour améliorer les performances.

# Exemple d'EndpointSlice (géré automatiquement par K8s)
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: mon-app-abc12
  labels:
    kubernetes.io/service-name: mon-app
addressType: IPv4
ports:
  - name: http
    protocol: TCP
    port: 8080
endpoints:
  - addresses: ["10.244.1.2"]
    conditions:
      ready: true
    nodeName: node-1
  - addresses: ["10.244.2.5"]
    conditions:
      ready: true
    nodeName: node-2

kube-proxy : trois modes de fonctionnement#

Hide code cell source

fig, axes = plt.subplots(1, 3, figsize=(15, 6))
fig.suptitle("Modes de kube-proxy", fontsize=14, fontweight='bold')

modes = [
    {
        "name": "iptables",
        "color": "#E67E22",
        "desc": "Mode par défaut",
        "pros": ["Stable, largement utilisé", "Intégré au noyau Linux", "Pas de dépendance externe"],
        "cons": ["Règles linéaires O(n)", "Lent avec >10k Services", "Difficile à déboguer"],
        "detail": "Chaînes iptables KUBE-SVC-*\nKUBE-SEP-* par endpoint\nDNAT vers Pod réel"
    },
    {
        "name": "IPVS",
        "color": "#4A90D9",
        "desc": "Mode haute performance",
        "pros": ["Table de hash O(1)", "Algorithmes LB riches", "Meilleures perfs >1000 svc"],
        "cons": ["Nécessite kernel modules", "ipvsadm requis", "Moins répandu"],
        "detail": "Algorithmes : rr, lc,\ndh, sh, sed, nq\nHash table noyau"
    },
    {
        "name": "nftables",
        "color": "#8E44AD",
        "desc": "Futur (K8s 1.31+)",
        "pros": ["Successeur d'iptables", "API atomique", "Meilleures perfs"],
        "cons": ["Encore expérimental", "Kernel récent requis", "Pas encore par défaut"],
        "detail": "Remplace iptables\nAPI noyau moderne\nGestion atomique des règles"
    }
]

for ax, mode in zip(axes, modes):
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 12)
    ax.axis('off')

    # Titre
    ax.add_patch(FancyBboxPatch((0.5, 10.5), 9, 1.3, boxstyle="round,pad=0.1",
                                 facecolor=mode["color"], alpha=0.9, edgecolor='none'))
    ax.text(5, 11.2, mode["name"], ha='center', va='center', fontsize=14,
            fontweight='bold', color='white')
    ax.text(5, 10.75, mode["desc"], ha='center', va='center', fontsize=9, color='white', alpha=0.9)

    # Avantages
    ax.text(5, 9.9, "Avantages", ha='center', fontsize=10, fontweight='bold',
            color="#27AE60")
    for i, pro in enumerate(mode["pros"]):
        ax.text(1, 9.3 - i*0.65, f"+ {pro}", fontsize=9, color="#27AE60")

    # Inconvénients
    ax.text(5, 7.2, "Inconvénients", ha='center', fontsize=10, fontweight='bold',
            color="#E74C3C")
    for i, con in enumerate(mode["cons"]):
        ax.text(1, 6.6 - i*0.65, f"- {con}", fontsize=9, color="#E74C3C")

    # Détail technique
    ax.add_patch(FancyBboxPatch((0.5, 1.5), 9, 3.2, boxstyle="round,pad=0.2",
                                 facecolor=mode["color"], alpha=0.1, edgecolor=mode["color"], lw=1))
    ax.text(5, 4.5, "Mécanisme interne", ha='center', fontsize=9, fontweight='bold',
            color=mode["color"])
    ax.text(5, 3.1, mode["detail"], ha='center', va='center', fontsize=9,
            color="#333", family='monospace')

plt.tight_layout()
plt.savefig("11_kube_proxy_modes.png", dpi=120, bbox_inches='tight')
plt.show()
_images/bd60f2452b703d8c119ba57ae67287170ce04375dd35b47931b894b2f9132b98.png

Simulation d’un load balancer kube-proxy (iptables)#

Pour comprendre comment kube-proxy programme les règles iptables, simulons en Python le processus de sélection d’un endpoint.

import random
import hashlib
import json
from collections import defaultdict

# Simulation des règles iptables générées par kube-proxy
# pour un Service avec 3 Pods

class KubeProxySimulator:
    """Simule les règles iptables de kube-proxy pour un Service."""

    def __init__(self, service_name, cluster_ip, port):
        self.service_name = service_name
        self.cluster_ip = cluster_ip
        self.port = port
        self.endpoints = []
        self.rules = {}  # Simule les chaînes iptables KUBE-SVC-*
        self._stats = defaultdict(int)

    def add_endpoint(self, pod_name, pod_ip, pod_port):
        """Ajoute un endpoint (Pod prêt à recevoir du trafic)."""
        self.endpoints.append({
            "pod": pod_name,
            "ip": pod_ip,
            "port": pod_port,
        })
        self._build_iptables_rules()

    def remove_endpoint(self, pod_name):
        """Retire un endpoint (Pod crashé ou en cours d'arrêt)."""
        self.endpoints = [e for e in self.endpoints if e["pod"] != pod_name]
        self._build_iptables_rules()

    def _build_iptables_rules(self):
        """
        Reconstruit les règles iptables.
        iptables utilise une probabilité 1/n pour chaque endpoint :
        - 1er endpoint : probabilité 1/3
        - 2ème endpoint : probabilité 1/2 (des 2/3 restants)
        - 3ème endpoint : probabilité 1/1 (le reste)
        """
        n = len(self.endpoints)
        if n == 0:
            self.rules = {}
            return

        rules = []
        for i, ep in enumerate(self.endpoints):
            # Règle KUBE-SEP-* (Service EndPoint)
            sep_name = f"KUBE-SEP-{hashlib.md5(ep['ip'].encode()).hexdigest()[:8].upper()}"
            remaining = n - i
            probability = round(1.0 / remaining, 4)

            rules.append({
                "chain": f"KUBE-SVC-{self.service_name[:8].upper()}",
                "sep_chain": sep_name,
                "probability": probability,
                "dnat_target": f"{ep['ip']}:{ep['port']}",
                "pod": ep["pod"],
            })

        self.rules = {
            "service_chain": f"KUBE-SVC-{self.service_name[:8].upper()}",
            "cluster_ip": f"{self.cluster_ip}:{self.port}",
            "endpoints": rules,
        }

    def route_packet(self, src_ip="10.244.0.1"):
        """Simule le routage d'un paquet via les règles iptables."""
        if not self.endpoints:
            return None, "REJECT — aucun endpoint disponible"

        # Sélection probabiliste (comme iptables statistic --mode random)
        n = len(self.endpoints)
        selected = None
        for i, ep in enumerate(self.endpoints):
            remaining = n - i
            prob = 1.0 / remaining
            if random.random() < prob:
                selected = ep
                break

        if not selected:
            selected = self.endpoints[-1]

        self._stats[selected["pod"]] += 1
        return selected, f"DNAT {self.cluster_ip}:{self.port}{selected['ip']}:{selected['port']}"

    def print_rules(self):
        """Affiche les règles iptables simulées."""
        if not self.rules:
            print("Aucune règle (pas d'endpoint)")
            return
        print(f"\n{'='*60}")
        print(f"Chaîne : {self.rules['service_chain']}")
        print(f"ClusterIP : {self.rules['cluster_ip']}")
        print(f"{'='*60}")
        for rule in self.rules["endpoints"]:
            print(f"  -A {rule['chain']} -m statistic --mode random "
                  f"--probability {rule['probability']:.4f} "
                  f"-j {rule['sep_chain']}")
            print(f"     → DNAT vers {rule['dnat_target']} ({rule['pod']})")
        print(f"{'='*60}\n")


# Création du simulateur
sim = KubeProxySimulator("mon-app", "10.96.0.1", 80)

# Ajout des Pods initiaux
sim.add_endpoint("pod-a", "10.244.1.2", 8080)
sim.add_endpoint("pod-b", "10.244.2.5", 8080)
sim.add_endpoint("pod-c", "10.244.3.1", 8080)

print("=== Règles iptables générées par kube-proxy ===")
sim.print_rules()

# Simulation de 300 requêtes
print("Simulation de 300 requêtes...")
for _ in range(300):
    sim.route_packet()

print("Distribution du trafic :")
total = sum(sim._stats.values())
for pod, count in sorted(sim._stats.items()):
    pct = count / total * 100
    bar = "█" * int(pct / 2)
    print(f"  {pod:10s}: {count:4d} ({pct:.1f}%) {bar}")

# Simulation d'un crash de pod-b
print("\n--- Pod-b crash ! Mise à jour des règles... ---")
sim.remove_endpoint("pod-b")
sim.print_rules()

print("Simulation de 100 requêtes supplémentaires (sans pod-b)...")
sim._stats.clear()
for _ in range(100):
    sim.route_packet()

print("Distribution après crash de pod-b :")
total = sum(sim._stats.values())
for pod, count in sorted(sim._stats.items()):
    pct = count / total * 100
    bar = "█" * int(pct / 2)
    print(f"  {pod:10s}: {count:4d} ({pct:.1f}%) {bar}")
=== Règles iptables générées par kube-proxy ===

============================================================
Chaîne : KUBE-SVC-MON-APP
ClusterIP : 10.96.0.1:80
============================================================
  -A KUBE-SVC-MON-APP -m statistic --mode random --probability 0.3333 -j KUBE-SEP-1BF1E500
     → DNAT vers 10.244.1.2:8080 (pod-a)
  -A KUBE-SVC-MON-APP -m statistic --mode random --probability 0.5000 -j KUBE-SEP-56E5AAE2
     → DNAT vers 10.244.2.5:8080 (pod-b)
  -A KUBE-SVC-MON-APP -m statistic --mode random --probability 1.0000 -j KUBE-SEP-9594E920
     → DNAT vers 10.244.3.1:8080 (pod-c)
============================================================

Simulation de 300 requêtes...
Distribution du trafic :
  pod-a     :  104 (34.7%) █████████████████
  pod-b     :   88 (29.3%) ██████████████
  pod-c     :  108 (36.0%) ██████████████████

--- Pod-b crash ! Mise à jour des règles... ---

============================================================
Chaîne : KUBE-SVC-MON-APP
ClusterIP : 10.96.0.1:80
============================================================
  -A KUBE-SVC-MON-APP -m statistic --mode random --probability 0.5000 -j KUBE-SEP-1BF1E500
     → DNAT vers 10.244.1.2:8080 (pod-a)
  -A KUBE-SVC-MON-APP -m statistic --mode random --probability 1.0000 -j KUBE-SEP-9594E920
     → DNAT vers 10.244.3.1:8080 (pod-c)
============================================================

Simulation de 100 requêtes supplémentaires (sans pod-b)...
Distribution après crash de pod-b :
  pod-a     :   48 (48.0%) ████████████████████████
  pod-c     :   52 (52.0%) ██████████████████████████

Calcul du hash IPVS#

En mode IPVS, kube-proxy utilise des tables de hash pour une sélection O(1) des endpoints.

import hashlib
import struct

def ipvs_hash_key(src_ip: str, dst_ip: str, dst_port: int, protocol: int = 6) -> int:
    """
    Simule le calcul de clé de hash IPVS pour le load balancing.
    En mode 'sh' (Source Hash), le même client va toujours
    vers le même serveur (session persistence).
    """
    # Conversion IP → entier 32 bits
    def ip_to_int(ip):
        parts = [int(p) for p in ip.split('.')]
        return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]

    src_int = ip_to_int(src_ip)
    dst_int = ip_to_int(dst_ip)

    # Clé combinée (simplifié par rapport à l'implémentation réelle)
    key_bytes = struct.pack(">IIIH", src_int, dst_int, 0, dst_port)
    hash_val = int(hashlib.md5(key_bytes).hexdigest(), 16)
    return hash_val

# Test : 5 clients, 3 serveurs
clients = [f"10.244.{i}.{j}" for i in range(1, 3) for j in range(1, 4)]
servers = [
    {"name": "pod-a", "ip": "10.244.10.1"},
    {"name": "pod-b", "ip": "10.244.10.2"},
    {"name": "pod-c", "ip": "10.244.10.3"},
]

print("Mode IPVS 'sh' (Source Hash) — persistance de session :")
print(f"{'Client IP':<18} {'Hash (mod 3)':<15} {'Serveur sélectionné'}")
print("-" * 55)

assignments = {}
for client in clients:
    h = ipvs_hash_key(client, "10.96.0.1", 80)
    server_idx = h % len(servers)
    server = servers[server_idx]
    assignments[client] = server["name"]
    print(f"  {client:<16}  {h % 1000:>8} mod 3 = {server_idx}{server['name']} ({server['ip']})")

print("\nLe même client va TOUJOURS vers le même Pod (session persistence).")
print("Utile pour : paniers e-commerce, sessions authentifiées, WebSockets.")
Mode IPVS 'sh' (Source Hash) — persistance de session :
Client IP          Hash (mod 3)    Serveur sélectionné
-------------------------------------------------------
  10.244.1.1             807 mod 3 = 0   → pod-a (10.244.10.1)
  10.244.1.2             628 mod 3 = 1   → pod-b (10.244.10.2)
  10.244.1.3             603 mod 3 = 0   → pod-a (10.244.10.1)
  10.244.2.1             675 mod 3 = 1   → pod-b (10.244.10.2)
  10.244.2.2             294 mod 3 = 0   → pod-a (10.244.10.1)
  10.244.2.3             800 mod 3 = 0   → pod-a (10.244.10.1)

Le même client va TOUJOURS vers le même Pod (session persistence).
Utile pour : paniers e-commerce, sessions authentifiées, WebSockets.

NetworkPolicy : isolation réseau entre Pods#

Par défaut, tous les Pods dans un cluster Kubernetes peuvent communiquer entre eux. C’est pratique au démarrage, mais dangereux en production. Les NetworkPolicy permettent de définir des règles de pare-feu au niveau Pod.

NetworkPolicy nécessite un CNI compatible

Les NetworkPolicy ne fonctionnent que si le plugin CNI installé les supporte. Flannel (simple overlay) ne supporte PAS les NetworkPolicy. Calico, Cilium, Weave Net, et Antrea les supportent.

# Politique : le Pod "backend" n'accepte du trafic entrant
# que depuis les Pods "frontend" du même namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend          # Politique appliquée à ces Pods
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend  # Autorise depuis les Pods frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              name: monitoring  # Autorise vers le namespace monitoring
      ports:
        - protocol: TCP
          port: 9090
    - to: []                    # DNS interne (CoreDNS)
      ports:
        - protocol: UDP
          port: 53

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(15, 7))
fig.suptitle("NetworkPolicy : isolation réseau des Pods", fontsize=14, fontweight='bold')

# --- Sans NetworkPolicy ---
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Sans NetworkPolicy\n(par défaut : tout est autorisé)", fontsize=11,
             fontweight='bold', color="#E74C3C")

pods_left = [
    (2, 8, "frontend", "#6C63FF"),
    (5, 8, "backend", "#E67E22"),
    (8, 8, "database", "#27AE60"),
    (2, 5, "monitoring", "#4A90D9"),
    (8, 5, "attaquant ?", "#E74C3C"),
]
for (x, y, name, color) in pods_left:
    boite(ax, x, y, 2.2, 0.9, name, "", color)

# Flèches dans tous les sens
connections = [
    (2,8,5,8), (5,8,8,8), (2,8,8,5), (8,5,5,8), (8,5,8,8), (8,5,2,8)
]
for (x1,y1,x2,y2) in connections:
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="<->", color="#E74C3C", lw=1.5, alpha=0.6))

ax.text(5, 3, "Tout le monde parle\nà tout le monde !", ha='center', fontsize=11,
        color="#E74C3C", fontweight='bold',
        bbox=dict(boxstyle="round,pad=0.4", facecolor="#FDEDEC", edgecolor="#E74C3C"))

# --- Avec NetworkPolicy ---
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 10)
ax2.axis('off')
ax2.set_title("Avec NetworkPolicy\n(moindre privilège réseau)", fontsize=11,
              fontweight='bold', color="#27AE60")

pods_right = [
    (2, 8, "frontend", "#6C63FF"),
    (5, 8, "backend", "#E67E22"),
    (8, 8, "database", "#27AE60"),
    (2, 5, "monitoring", "#4A90D9"),
    (8, 5, "attaquant ?", "#E74C3C"),
]
for (x, y, name, color) in pods_right:
    boite(ax2, x, y, 2.2, 0.9, name, "", color)

# Flèches autorisées uniquement
ok_connections = [(2,8,5,8,"→ :8080"), (5,8,8,8,"→ :5432"), (2,5,5,8,"→ /metrics")]
for (x1,y1,x2,y2,label) in ok_connections:
    ax2.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color="#27AE60", lw=2))
    mx, my = (x1+x2)/2, (y1+y2)/2
    ax2.text(mx, my+0.25, label, fontsize=8, color="#27AE60", ha='center')

# Flèches bloquées
blocked = [(8,5,8,8), (8,5,5,8), (8,5,2,8)]
for (x1,y1,x2,y2) in blocked:
    ax2.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color="#E74C3C", lw=1.5,
                                linestyle='dashed', alpha=0.5))
    mx, my = (x1+x2)/2, (y1+y2)/2
    ax2.text(mx+0.3, my, "✗", fontsize=14, color="#E74C3C", ha='center')

ax2.text(5, 3, "Seul le trafic explicitement\nautorisé est accepté", ha='center',
         fontsize=11, color="#27AE60", fontweight='bold',
         bbox=dict(boxstyle="round,pad=0.4", facecolor="#EAFAF1", edgecolor="#27AE60"))

plt.tight_layout()
plt.savefig("11_networkpolicy.png", dpi=120, bbox_inches='tight')
plt.show()
_images/9773100d9b83cf9730f2b0bd3e3437acb5c079d6c06e9e7576b1a5df0773012a.png

CNI Plugins : le réseau sous-jacent#

Le réseau dans Kubernetes repose sur un standard : CNI (Container Network Interface). Chaque cluster doit avoir un plugin CNI installé pour que les Pods obtiennent des adresses IP et puissent communiquer.

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 7))
ax.set_xlim(0, 14)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title("Comparaison des principaux plugins CNI", fontsize=14, fontweight='bold')

plugins = [
    {
        "name": "Flannel",
        "color": "#3498DB",
        "complexite": 1,
        "perf": 3,
        "networkpolicy": False,
        "mecanisme": "VXLAN overlay\n(UDP encapsulation)",
        "usage": "Environnements\nsimples, dev/test",
        "x": 2
    },
    {
        "name": "Calico",
        "color": "#E67E22",
        "complexite": 3,
        "perf": 4,
        "networkpolicy": True,
        "mecanisme": "BGP (routage natif)\nou VXLAN",
        "usage": "Production, besoin\nde NetworkPolicy",
        "x": 7
    },
    {
        "name": "Cilium",
        "color": "#8E44AD",
        "complexite": 4,
        "perf": 5,
        "networkpolicy": True,
        "mecanisme": "eBPF (bypass netfilter)\nLayer 7 aware",
        "usage": "Haute perf, service mesh,\nobservabilité avancée",
        "x": 12
    },
]

for plugin in plugins:
    x = plugin["x"]
    color = plugin["color"]

    # En-tête
    ax.add_patch(FancyBboxPatch((x-2.2, 6.5), 4.4, 1.2, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.9, edgecolor='none'))
    ax.text(x, 7.1, plugin["name"], ha='center', va='center', fontsize=14,
            fontweight='bold', color='white')

    # Mécanisme
    ax.add_patch(FancyBboxPatch((x-2.2, 4.9), 4.4, 1.3, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.12, edgecolor=color, lw=1))
    ax.text(x, 6.1, "Mécanisme :", ha='center', fontsize=8.5, color=color, fontweight='bold')
    ax.text(x, 5.55, plugin["mecanisme"], ha='center', fontsize=9, color="#333")

    # NetworkPolicy
    np_color = "#27AE60" if plugin["networkpolicy"] else "#E74C3C"
    np_text = "NetworkPolicy : OUI" if plugin["networkpolicy"] else "NetworkPolicy : NON"
    ax.text(x, 4.6, np_text, ha='center', fontsize=9.5, color=np_color, fontweight='bold')

    # Performance (barres)
    ax.text(x, 4.1, "Performance :", ha='center', fontsize=8.5, color="#555")
    for j in range(5):
        rect_color = color if j < plugin["perf"] else "#ECF0F1"
        ax.add_patch(FancyBboxPatch((x - 2.0 + j * 0.85, 3.4), 0.75, 0.45,
                                     boxstyle="round,pad=0.05",
                                     facecolor=rect_color, edgecolor="#BDC3C7", lw=0.5))

    # Complexité
    ax.text(x, 3.1, "Complexité :", ha='center', fontsize=8.5, color="#555")
    for j in range(5):
        rect_color = "#E74C3C" if j < plugin["complexite"] else "#ECF0F1"
        ax.add_patch(FancyBboxPatch((x - 2.0 + j * 0.85, 2.4), 0.75, 0.45,
                                     boxstyle="round,pad=0.05",
                                     facecolor=rect_color, edgecolor="#BDC3C7", lw=0.5))

    # Usage recommandé
    ax.add_patch(FancyBboxPatch((x-2.2, 0.8), 4.4, 1.3, boxstyle="round,pad=0.1",
                                 facecolor="#F8F9FA", edgecolor="#BDC3C7", lw=1))
    ax.text(x, 1.85, "Usage recommandé :", ha='center', fontsize=8, color="#555", style='italic')
    ax.text(x, 1.25, plugin["usage"], ha='center', fontsize=9, color="#333")

ax.text(7, 0.2, "eBPF = Berkeley Packet Filter étendu : programmes sécurisés exécutés dans le noyau, sans patch",
        ha='center', fontsize=8, color="#888", style='italic')

plt.tight_layout()
plt.savefig("11_cni_plugins.png", dpi=120, bbox_inches='tight')
plt.show()
_images/875467d2b45159396fac61daeb8c49d50f73b686b4865d62c83558c2ada7c8ad.png

Service Mesh : quand les Services ne suffisent plus#

Un service mesh est une couche d’infrastructure qui gère la communication entre microservices de façon transparente, sans modifier le code applicatif.

Analogie : le service mesh comme un réseau téléphonique d’entreprise

Sans service mesh : chaque développeur doit coder lui-même la gestion des timeouts, les retries, le chiffrement TLS, les métriques… C’est comme si chaque employé devait construire son propre téléphone.

Avec un service mesh (Istio, Linkerd) : un proxy sidecar (Envoy) est injecté dans chaque Pod. Il intercepte tout le trafic et gère automatiquement le mTLS, le circuit breaking, le tracing distribué — comme un standard téléphonique d’entreprise.

Les fonctionnalités apportées par un service mesh :

Fonctionnalité

Sans service mesh

Avec service mesh

Chiffrement TLS

Codé dans l’app

mTLS automatique

Retries / timeouts

Codé dans l’app

Politique déclarative

Circuit breaker

Bibliothèque (Hystrix…)

Proxy transparent

Tracing distribué

Instrumentation manuelle

Automatique (Zipkin, Jaeger)

Canary deployment

Logique complexe

Règle de routage simple

# Installation d'Istio (exemple)
istioctl install --set profile=demo

# Activation de l'injection automatique du sidecar sur un namespace
kubectl label namespace production istio-injection=enabled

# Après cette étape, chaque Pod créé dans 'production'
# aura automatiquement un conteneur 'istio-proxy' (Envoy)
kubectl get pods -n production
# NAME                    READY   STATUS    RESTARTS
# mon-app-5d4f7b-x9j2k   2/2     Running   0
#                          ^-- 2 conteneurs : l'app + le proxy Envoy

Récapitulatif#

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 7))
ax.set_xlim(0, 13)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title("Vue d'ensemble : réseau Kubernetes", fontsize=14, fontweight='bold')

layers = [
    {"y": 7.0, "label": "Couche applicative", "items": ["Service mesh (Istio/Linkerd)", "mTLS, tracing, circuit breaker"], "color": "#8E44AD"},
    {"y": 5.8, "label": "Services K8s", "items": ["ClusterIP · NodePort · LoadBalancer · ExternalName", "DNS CoreDNS : <svc>.<ns>.svc.cluster.local"], "color": "#4A90D9"},
    {"y": 4.6, "label": "Routage (kube-proxy)", "items": ["iptables / IPVS / nftables", "Endpoints & EndpointSlices"], "color": "#E67E22"},
    {"y": 3.4, "label": "Politique réseau", "items": ["NetworkPolicy (ingress/egress)", "Sélecteurs de Pods et Namespaces"], "color": "#E74C3C"},
    {"y": 2.2, "label": "Réseau Pod-à-Pod (CNI)", "items": ["Flannel (simple) · Calico (BGP+NP) · Cilium (eBPF)", "Chaque Pod a une IP routable dans le cluster"], "color": "#27AE60"},
    {"y": 1.0, "label": "Réseau physique / overlay", "items": ["VXLAN · BGP · eBPF · Wireguard", "Infrastructure cloud ou bare-metal"], "color": "#7F8C8D"},
]

for layer in layers:
    ax.add_patch(FancyBboxPatch((0.3, layer["y"] - 0.45), 12.4, 0.9,
                                 boxstyle="round,pad=0.1",
                                 facecolor=layer["color"], alpha=0.15,
                                 edgecolor=layer["color"], lw=1.5))
    ax.text(1.0, layer["y"] + 0.1, layer["label"], fontsize=10,
            fontweight='bold', color=layer["color"], va='center')
    ax.text(1.0, layer["y"] - 0.2, " · ".join(layer["items"]), fontsize=8.5,
            color="#333", va='center')

plt.tight_layout()
plt.savefig("11_recapitulatif_reseau.png", dpi=120, bbox_inches='tight')
plt.show()
_images/65fbba3617c1a75c526396005ce92de4bf6a9cfdcee3808d4f4a689726e39cec.png

Dans ce chapitre, nous avons vu comment Kubernetes résout le problème des IPs éphémères avec l’abstraction Service, comment kube-proxy programme les règles réseau sur chaque nœud, et comment les NetworkPolicy permettent d’isoler les workloads. Le chapitre suivant aborde la gestion de la configuration et des secrets.