---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# CDN, load balancers et proxies

Lorsqu'une application doit servir des milliers, des millions ou des milliards d'utilisateurs, une seule machine ne suffit plus. Les **load balancers** répartissent le trafic entre plusieurs serveurs ; les **CDN** rapprochent le contenu des utilisateurs ; les **proxies inverses** centralisent le TLS, le cache et l'authentification. Ce chapitre explore ces composants fondamentaux de l'infrastructure web moderne.

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
import seaborn as sns
import random
import time
from collections import OrderedDict

sns.set_theme(style="whitegrid", palette="muted")
```

## Reverse proxy

Un **proxy inverse** (*reverse proxy*) se place devant les serveurs d'application et intercepte toutes les requêtes entrantes. Il est transparent pour le client : celui-ci croit communiquer directement avec le serveur final.

### Différence forward proxy / reverse proxy

```{code-cell} python
fig, axes = plt.subplots(1, 2, figsize=(13, 4))

for ax, (titre, gauche, milieu, droite, desc) in zip(axes, [
    ("Forward proxy",
     "Client\n(interne)", "Proxy\nforward", "Serveurs\n(Internet)",
     "Le client configure explicitement le proxy.\nUsage : contournement de filtrages,\nCache entreprise, anonymisation."),
    ("Reverse proxy",
     "Clients\n(Internet)", "Proxy\ninverse", "Serveurs\n(backend)",
     "Le client ne sait pas qu'un proxy existe.\nUsage : TLS termination, load balancing,\ncache, WAF, authentification."),
]):
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 5)
    ax.axis('off')
    ax.set_title(titre, fontsize=12, fontweight='bold')

    for x, label, col in [(1.5, gauche, '#4575b4'), (5, milieu, '#d73027'), (8.5, droite, '#1a9850')]:
        ax.add_patch(mpatches.FancyBboxPatch((x-1, 1.8), 2, 1.2,
                     boxstyle="round,pad=0.1", facecolor=col, alpha=0.2,
                     edgecolor=col, linewidth=2))
        ax.text(x, 2.4, label, ha='center', va='center', fontsize=9, color=col, fontweight='bold')

    for x1, x2 in [(2.5, 4.0), (6.0, 7.5)]:
        ax.annotate("", xy=(x2, 2.4), xytext=(x1, 2.4),
                    arrowprops=dict(arrowstyle='<->', color='#555555', lw=2))

    ax.text(5, 0.9, desc, ha='center', va='center', fontsize=8.5, color='#333333', style='italic')

plt.tight_layout()
plt.savefig('_static/proxy_types.png', dpi=100, bbox_inches='tight')
plt.show()
```

### Nginx comme reverse proxy

```nginx
# /etc/nginx/sites-available/app.conf

upstream backend_app {
    server 10.0.1.10:8000 weight=3;
    server 10.0.1.11:8000 weight=3;
    server 10.0.1.12:8000 weight=1;  # serveur moins puissant
    keepalive 32;  # connexions persistantes vers les backends
}

server {
    listen 443 ssl http2;
    server_name app.exemple.fr;

    ssl_certificate     /etc/ssl/certs/app.pem;
    ssl_certificate_key /etc/ssl/private/app-key.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;

    # TLS termination : le backend reçoit du HTTP en clair
    location / {
        proxy_pass         http://backend_app;
        proxy_http_version 1.1;
        proxy_set_header   Connection      "";
        proxy_set_header   Host            $host;
        proxy_set_header   X-Real-IP       $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;

        proxy_connect_timeout  5s;
        proxy_send_timeout     60s;
        proxy_read_timeout     60s;

        # Cache de réponses statiques
        proxy_cache            STATIC;
        proxy_cache_valid      200 10m;
        proxy_cache_use_stale  error timeout updating;
        add_header             X-Cache-Status $upstream_cache_status;
    }
}
```

---

## Algorithmes de load balancing

### Panorama des algorithmes

```{code-cell} python
fig, ax = plt.subplots(figsize=(11, 5))
ax.axis('off')

données = [
    ['Round-Robin', 'Distribution cyclique', 'Simple, équitable', 'Sessions sans état'],
    ['Weighted R-R', 'Cyclique avec poids', 'Respecte la capacité', 'Capacités hétérogènes'],
    ['Least Connections', 'Vers le moins chargé', 'Optimal pour requêtes longues', 'Connexions persistantes'],
    ['Least Time', 'Moins de conx + RTT min', 'Optimal globalement', 'Nginx Plus, HAProxy EE'],
    ['IP Hash', 'Hash de l\'IP src', 'Persistance session', 'Sessions avec état (sans sticky cookie)'],
    ['Random', 'Aléatoire uniforme', 'Simple', 'Tests de charge'],
    ['Resource-Based', 'Selon CPU/RAM backend', 'Adaptatif', 'Avec health-check actif'],
]
cols = ['Algorithme', 'Principe', 'Avantage', 'Cas d\'usage']

table = ax.table(cellText=données, colLabels=cols,
                 cellLoc='center', loc='center',
                 colWidths=[0.18, 0.25, 0.27, 0.28])
table.auto_set_font_size(False)
table.set_fontsize(9)
table.scale(1, 2.0)

for (row, col), cell in table.get_celld().items():
    if row == 0:
        cell.set_facecolor('#2c7bb6')
        cell.set_text_props(color='white', fontweight='bold')
    elif row % 2 == 0:
        cell.set_facecolor('#f5f8fc')
    cell.set_edgecolor('#dddddd')

ax.set_title("Algorithmes de load balancing", fontsize=13, fontweight='bold', pad=20)
plt.tight_layout()
plt.savefig('_static/lb_algorithms.png', dpi=100, bbox_inches='tight')
plt.show()
```

### Simulation Python — Round-Robin avec weights

```{code-cell} python
import itertools
from dataclasses import dataclass, field

@dataclass
class Serveur:
    adresse: str
    poids: int = 1
    connexions_actives: int = 0
    requêtes_totales: int = 0
    disponible: bool = True

    def traiter(self):
        self.connexions_actives += 1
        self.requêtes_totales  += 1

    def terminer(self):
        self.connexions_actives = max(0, self.connexions_actives - 1)


class LoadBalancer:
    def __init__(self, algorithme: str = 'round_robin'):
        self.serveurs: list[Serveur] = []
        self.algorithme = algorithme
        self._index = 0

    def ajouter(self, serveur: Serveur):
        self.serveurs.append(serveur)

    def _serveurs_disponibles(self) -> list[Serveur]:
        return [s for s in self.serveurs if s.disponible]

    def choisir(self) -> Serveur | None:
        dispos = self._serveurs_disponibles()
        if not dispos:
            return None

        if self.algorithme == 'round_robin':
            s = dispos[self._index % len(dispos)]
            self._index += 1
            return s

        elif self.algorithme == 'weighted_rr':
            # Développe la liste selon les poids
            pool = [s for s in dispos for _ in range(s.poids)]
            s = pool[self._index % len(pool)]
            self._index += 1
            return s

        elif self.algorithme == 'least_connections':
            return min(dispos, key=lambda s: s.connexions_actives)

        elif self.algorithme == 'ip_hash':
            raise NotImplementedError("ip_hash nécessite l'IP source")

        return dispos[0]

    def statistiques(self) -> pd.DataFrame:
        return pd.DataFrame([{
            'adresse':     s.adresse,
            'poids':       s.poids,
            'requêtes':    s.requêtes_totales,
            'conx_active': s.connexions_actives,
            'disponible':  s.disponible,
        } for s in self.serveurs])


# Comparaison round-robin vs weighted vs least-connections
serveurs_config = [
    ('10.0.1.10', 3),  # serveur puissant
    ('10.0.1.11', 3),  # serveur puissant
    ('10.0.1.12', 1),  # serveur moins puissant
]

fig, axes = plt.subplots(1, 3, figsize=(13, 5))
algos = ['round_robin', 'weighted_rr', 'least_connections']
titres = ['Round-Robin\n(sans pondération)', 'Weighted\nRound-Robin', 'Least\nConnections']

for ax, algo, titre in zip(axes, algos, titres):
    lb = LoadBalancer(algorithme=algo)
    for addr, poids in serveurs_config:
        lb.ajouter(Serveur(adresse=addr, poids=poids))

    # Simulation de 300 requêtes ; least_connections : durées variables
    durées = np.random.exponential(1.0, 300) if algo == 'least_connections' else [0]*300

    for dur in durées:
        s = lb.choisir()
        if s:
            s.traiter()
            if algo == 'least_connections':
                # Simuler des fins de requêtes aléatoires
                for srv in lb.serveurs:
                    if srv.connexions_actives > 0 and random.random() < 0.3:
                        srv.terminer()

    # Pour least_conn — on ne compte que les totaux
    df = lb.statistiques()
    cols_bar = ['#4575b4', '#1a9850', '#d73027']
    bars = ax.bar(df['adresse'].str.split('.').str[-1].apply(lambda x: f"Srv {x}"),
                  df['requêtes'], color=cols_bar, edgecolor='white')
    ax.set_title(titre, fontsize=11, fontweight='bold')
    ax.set_ylabel("Requêtes reçues")
    for bar, val in zip(bars, df['requêtes']):
        ax.text(bar.get_x() + bar.get_width()/2, bar.get_height()+2,
                str(val), ha='center', fontsize=10, fontweight='bold')

    # Afficher les poids
    for i, (bar, poids) in enumerate(zip(bars, df['poids'])):
        ax.text(bar.get_x() + bar.get_width()/2, 5,
                f"w={poids}", ha='center', fontsize=8.5, color='white', fontweight='bold')

plt.suptitle("Distribution des requêtes selon l'algorithme (300 requêtes)", fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig('_static/lb_comparison.png', dpi=100, bbox_inches='tight')
plt.show()
```

---

## Health checks et circuit breaker

### Health checks actifs et passifs

```{code-cell} python
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 5)
ax1.axis('off')
ax1.set_title("Health check actif", fontsize=11, fontweight='bold')

for x, label, col in [(1.5, "Load\nBalancer", '#4575b4'), (5, "Serveur\nbackend", '#1a9850')]:
    ax1.add_patch(mpatches.FancyBboxPatch((x-0.9, 1.8), 1.8, 1.0,
                 boxstyle="round,pad=0.1", facecolor=col, alpha=0.2,
                 edgecolor=col, linewidth=2))
    ax1.text(x, 2.3, label, ha='center', va='center', fontsize=9, color=col, fontweight='bold')

for y, texte, col, dir_ in [
    (3.5, "GET /healthz → 200 OK", '#4575b4', (1.5, 5)),
    (2.9, "Réponse < 200ms", '#1a9850', (5, 1.5)),
]:
    ax1.annotate("", xy=(dir_[1]-0.9, y), xytext=(dir_[0]+0.9, y),
                arrowprops=dict(arrowstyle='->', color=col, lw=1.8))
    ax1.text(3.5, y+0.15, texte, ha='center', fontsize=8.5, color=col)

ax1.text(5, 0.8, "Toutes les 5s — si 3 échecs → serveur retiré\nSi 2 succès → serveur réintégré",
         ha='center', fontsize=8.5, color='#555555', style='italic')

ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 5)
ax2.axis('off')
ax2.set_title("Circuit Breaker — états", fontsize=11, fontweight='bold')

états_cb = [
    (2,   3.5, "CLOSED\n(normal)", '#1a9850'),
    (5.5, 3.5, "OPEN\n(erreurs)", '#d62728'),
    (8,   1.5, "HALF-OPEN\n(test)", '#f46d43'),
]
for x, y, label, col in états_cb:
    ax2.add_patch(plt.Circle((x, y), 0.7, facecolor=col, alpha=0.25, edgecolor=col, linewidth=2))
    ax2.text(x, y, label, ha='center', va='center', fontsize=8, color=col, fontweight='bold')

transitions = [
    ((2.7, 3.5), (4.8, 3.5), "Taux d'erreurs > seuil"),
    ((8, 2.2),   (5.5, 2.8), "Sonde OK → réintégration"),
    ((5.5, 2.8), (8, 2.2),   "Sonde KO → reste OPEN"),
]
for (x1,y1),(x2,y2),label in transitions:
    ax2.annotate("", xy=(x2,y2), xytext=(x1,y1),
                arrowprops=dict(arrowstyle='->', color='#555555', lw=1.5))
    ax2.text((x1+x2)/2+0.1, (y1+y2)/2+0.1, label, ha='center', fontsize=7.5, color='#444444')

plt.tight_layout()
plt.savefig('_static/healthcheck_cb.png', dpi=100, bbox_inches='tight')
plt.show()
```

### Configuration HAProxy

```haproxy
# /etc/haproxy/haproxy.cfg

global
    maxconn     50000
    log         /dev/log local0
    user        haproxy
    group       haproxy

defaults
    mode        http
    timeout     connect 5s
    timeout     client  30s
    timeout     server  30s
    option      httplog
    option      dontlognull
    option      forwardfor
    option      http-server-close

frontend web_in
    bind *:80
    bind *:443 ssl crt /etc/ssl/haproxy.pem
    http-request redirect scheme https unless { ssl_fc }
    default_backend app_servers

    # ACL — routing applicatif
    acl is_api  path_beg /api/
    acl is_ws   hdr(Upgrade) -i WebSocket
    use_backend api_servers if is_api
    use_backend ws_servers  if is_ws

backend app_servers
    balance     roundrobin
    option      httpchk GET /healthz HTTP/1.1\r\nHost:\ localhost
    http-check  expect status 200
    server      app1 10.0.1.10:8000 check inter 5s rise 2 fall 3
    server      app2 10.0.1.11:8000 check inter 5s rise 2 fall 3
    server      app3 10.0.1.12:8000 check inter 5s rise 2 fall 3 weight 50

backend api_servers
    balance     leastconn
    option      httpchk GET /api/health
    server      api1 10.0.2.10:9000 check
    server      api2 10.0.2.11:9000 check

listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 10s
    stats auth admin:password
```

---

## CDN — Content Delivery Networks

Un **CDN** (*Content Delivery Network*) est un réseau de serveurs distribués géographiquement (**Points of Presence — PoP**) qui mettent en cache le contenu au plus près des utilisateurs.

### Anycast

```{code-cell} python
fig, ax = plt.subplots(figsize=(12, 6))
ax.set_xlim(-180, 180)
ax.set_ylim(-70, 85)
ax.axis('off')
ax.set_facecolor('#f0f8ff')
ax.set_title("Réseau CDN mondial — PoP et routage Anycast", fontsize=13, fontweight='bold', pad=12)

# Continents schématiques (rectangles simplifiés)
continents = [
    (-130, 25, 60, 40, "Amérique du N.", '#e8e8e8'),
    (-82, -55, 50, 40, "Amérique du S.", '#e8e8e8'),
    (-10, 35, 40, 35, "Europe", '#e8e8e8'),
    (10, -35, 50, 50, "Afrique", '#e8e8e8'),
    (60, 10, 80, 50, "Asie", '#e8e8e8'),
    (110, -45, 40, 35, "Océanie", '#e8e8e8'),
]
for x, y, w, h, label, col in continents:
    ax.add_patch(mpatches.FancyBboxPatch((x, y), w, h,
                 boxstyle="round,pad=0.5", facecolor=col, edgecolor='#cccccc', linewidth=1))
    ax.text(x+w/2, y+h/2, label, ha='center', va='center', fontsize=8, color='#666666')

# PoP CDN
pops = [
    (-95, 40,   "NYC"),
    (-115, 35,  "LAX"),
    (-60, -20,  "SAO"),
    (0,   48,   "PAR"),
    (10,  52,   "FRA"),
    (28,  55,   "AMS"),
    (55,  24,   "DXB"),
    (72,  19,   "BOM"),
    (103, 1,    "SIN"),
    (116, 39,   "PEK"),
    (139, 35,   "TYO"),
    (151, -33,  "SYD"),
    (37,  55,   "MOW"),
    (-80, 43,   "TOR"),
]
for lon, lat, code in pops:
    ax.plot(lon, lat, 'o', markersize=9, color='#d73027', zorder=5,
            markeredgecolor='white', markeredgewidth=1.5)
    ax.text(lon+2, lat+2, code, fontsize=7.5, color='#333333', fontweight='bold')

# Utilisateur en Europe → PoP PAR (le plus proche)
user_lon, user_lat = 2, 46  # France
ax.plot(user_lon, user_lat, 's', markersize=12, color='#4575b4', zorder=6,
        markeredgecolor='white', markeredgewidth=2)
ax.text(user_lon+2, user_lat-4, "Utilisateur\n(Paris)", fontsize=8.5, color='#4575b4', fontweight='bold')

# Flèche vers le PoP le plus proche
for (lon, lat, code), (col, label) in zip(
    [(0, 48, "PAR")],
    [('#d73027', '← PoP le plus proche (12 ms)')]):
    ax.annotate("", xy=(lon, lat-1), xytext=(user_lon, user_lat+0.5),
                arrowprops=dict(arrowstyle='->', color=col, lw=2.5))
    ax.text(1, 47.5, label, fontsize=8.5, color=col, style='italic')

# Légende
ax.plot([], [], 'o', color='#d73027', markersize=9, markeredgecolor='white',
        markeredgewidth=1.5, label='PoP CDN')
ax.plot([], [], 's', color='#4575b4', markersize=10, markeredgecolor='white',
        markeredgewidth=2, label='Utilisateur')
ax.legend(loc='lower left', fontsize=9)

plt.tight_layout()
plt.savefig('_static/cdn_map.png', dpi=100, bbox_inches='tight')
plt.show()
```

### Cache hit / miss et invalidation

```{code-cell} python
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# --- Flux cache hit vs miss ---
ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 8)
ax1.axis('off')
ax1.set_title("Cache hit vs Cache miss", fontsize=11, fontweight='bold')

for x, y, label, col in [
    (1.2, 5,   "Client",        '#4575b4'),
    (4.5, 5,   "PoP CDN\n(cache)", '#d73027'),
    (8.5, 5,   "Origine\n(serveur)", '#1a9850'),
]:
    ax1.add_patch(mpatches.FancyBboxPatch((x-0.9, 4.4), 1.8, 1.0,
                 boxstyle="round,pad=0.1", facecolor=col, alpha=0.2,
                 edgecolor=col, linewidth=2))
    ax1.text(x, 4.9, label, ha='center', va='center', fontsize=9, color=col, fontweight='bold')

# Cache HIT
for y, texte, dir_, col in [
    (7.0, "GET /image.jpg", (1.2, 4.5), '#4575b4'),
    (6.4, "200 OK (X-Cache: HIT) ← données du cache", (4.5, 1.2), '#d73027'),
]:
    x_src, x_dst = dir_
    ax1.annotate("", xy=(x_dst+0.9, y), xytext=(x_src+0.9, y),
                arrowprops=dict(arrowstyle='->', color=col, lw=1.8))
    ax1.text((x_src+x_dst)/2+0.9, y+0.15, texte, ha='center', fontsize=7.5, color=col)

ax1.text(5, 5.8, "Cache HIT ✓", ha='center', fontsize=10, color='#1a9850', fontweight='bold')

# Cache MISS
for y, texte, dir_, col in [
    (3.8, "GET /video.mp4", (1.2, 4.5), '#4575b4'),
    (3.2, "MISS → forward vers origine", (4.5, 8.5), '#888888'),
    (2.6, "200 OK + cache update", (8.5, 4.5), '#1a9850'),
    (2.0, "200 OK (X-Cache: MISS)", (4.5, 1.2), '#d73027'),
]:
    x_src, x_dst = dir_
    ax1.annotate("", xy=(x_dst+0.9, y), xytext=(x_src+0.9, y),
                arrowprops=dict(arrowstyle='->', color=col, lw=1.5))
    ax1.text((x_src+x_dst)/2+0.9, y+0.15, texte, ha='center', fontsize=7.5, color=col)

ax1.text(5, 1.2, "Cache MISS ✗", ha='center', fontsize=10, color='#d73027', fontweight='bold')

# --- Taux de hit simulé ---
ax2 = axes[1]
ressources = ['Images', 'CSS/JS', 'HTML\n(pages)', 'API\ndynamique', 'Vidéo\nstreaming']
taux_hit   = [0.95, 0.90, 0.60, 0.05, 0.85]
cols_hit   = ['#1a9850' if t > 0.7 else '#fdae61' if t > 0.3 else '#d73027' for t in taux_hit]

bars = ax2.bar(ressources, taux_hit, color=cols_hit, edgecolor='white')
ax2.axhline(0.7, color='#888888', linestyle='--', linewidth=1.5, label='Seuil 70%')
ax2.set_ylim(0, 1.1)
ax2.set_ylabel("Taux de cache hit", fontsize=11)
ax2.set_title("Taux de cache hit typiques\npar type de ressource", fontsize=11, fontweight='bold')
ax2.legend(fontsize=9)
for bar, val in zip(bars, taux_hit):
    ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height()+0.02,
             f"{val:.0%}", ha='center', fontsize=10, fontweight='bold')

plt.tight_layout()
plt.savefig('_static/cdn_cache.png', dpi=100, bbox_inches='tight')
plt.show()
```

---

## Cache HTTP

Le cache HTTP est contrôlé par des en-têtes spécifiques. Une bonne stratégie de cache peut réduire la charge serveur de 80 à 95 %.

### En-têtes de contrôle du cache

```{code-cell} python
en_têtes_cache = {
    'Cache-Control: no-store': "Ne jamais cacher (données sensibles, RGPD)",
    'Cache-Control: no-cache': "Cacher mais revalider systématiquement",
    'Cache-Control: private, max-age=3600': "Cacher 1h côté client seulement (HTML personnalisé)",
    'Cache-Control: public, max-age=86400': "Cacher 24h partout (images, CSS, JS statiques)",
    'Cache-Control: public, s-maxage=3600, stale-while-revalidate=60':
        "CDN : cacher 1h, puis servir stale 60s pendant la revalidation",
    'Cache-Control: immutable, max-age=31536000':
        "Assets versionnés (hashes dans le nom) — cacher 1 an, jamais revalider",
    'ETag: "abc123"': "Empreinte du contenu — revalidation conditionnelle (If-None-Match)",
    'Last-Modified: Tue, 01 Jan 2026 00:00:00 GMT':
        "Date de modification — revalidation (If-Modified-Since)",
    'Vary: Accept-Encoding, Accept-Language':
        "Cache distinct selon l'encodage et la langue",
}

print("En-têtes HTTP de contrôle du cache")
print("=" * 70)
for entête, desc in en_têtes_cache.items():
    print(f"\n  {entête}")
    print(f"  → {desc}")
```

### LRU Cache HTTP en Python

```{code-cell} python
class CacheHTTPLRU:
    """
    Cache HTTP LRU (Least Recently Used) simplifié.
    Simule le comportement d'un proxy de cache.
    """

    def __init__(self, capacité: int = 5):
        self.capacité = capacité
        self._cache: OrderedDict[str, dict] = OrderedDict()
        self.hits   = 0
        self.misses = 0

    def _est_expiré(self, entrée: dict) -> bool:
        return time.time() > entrée['expires_at']

    def get(self, url: str) -> dict | None:
        if url in self._cache:
            entrée = self._cache[url]
            if not self._est_expiré(entrée):
                # Déplacer en fin (recently used)
                self._cache.move_to_end(url)
                self.hits += 1
                return entrée['data']
            else:
                # Expiré — invalider
                del self._cache[url]

        self.misses += 1
        return None

    def set(self, url: str, données: dict, max_age: int = 60):
        if url in self._cache:
            self._cache.move_to_end(url)
        elif len(self._cache) >= self.capacité:
            # Éviction LRU : retirer l'entrée la moins récemment utilisée
            victime, _ = self._cache.popitem(last=False)
            print(f"  [LRU] Éviction : {victime}")
        self._cache[url] = {
            'data':       données,
            'expires_at': time.time() + max_age,
            'max_age':    max_age,
        }

    @property
    def taux_hit(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0

    def afficher(self):
        print(f"\nCache ({len(self._cache)}/{self.capacité} entrées) :")
        for url, entrée in self._cache.items():
            ttl_restant = max(0, entrée['expires_at'] - time.time())
            print(f"  {url:<35} TTL={ttl_restant:.0f}s")
        print(f"  Hit rate : {self.taux_hit:.1%}  ({self.hits} hits / {self.misses} misses)")


cache = CacheHTTPLRU(capacité=4)

# Simulation de requêtes
requêtes = [
    '/static/logo.png',
    '/static/app.css',
    '/api/users',           # dynamique — ne sera pas caché longtemps
    '/static/logo.png',     # HIT
    '/static/bundle.js',
    '/static/app.css',      # HIT
    '/static/fonts/roboto.woff2',  # → éviction de la plus ancienne
    '/static/logo.png',     # HIT
]

print("Simulation de requêtes vers le cache LRU :")
print("-" * 50)
for url in requêtes:
    résultat = cache.get(url)
    if résultat is None:
        # Simuler la récupération depuis l'origine
        données_origine = {'status': 200, 'body': f'Contenu de {url}', 'size': len(url) * 100}
        max_age = 10 if '/api/' in url else 3600
        cache.set(url, données_origine, max_age=max_age)
        print(f"  MISS : {url}")
    else:
        print(f"  HIT  : {url}")

cache.afficher()
```

---

## Service mesh : Envoy, Istio

Un **service mesh** ajoute une couche de proxy transparent entre tous les microservices d'une architecture, gérant automatiquement :

- **mTLS** (mutual TLS) entre tous les services.
- **Load balancing** L7 avancé.
- **Observabilité** : traces distribuées, métriques, logs.
- **Politiques de trafic** : retries, timeouts, circuit breaking.

```{code-cell} python
fig, ax = plt.subplots(figsize=(11, 6))
ax.set_xlim(0, 11)
ax.set_ylim(0, 7)
ax.axis('off')
ax.set_title("Architecture Service Mesh (Istio / Envoy)", fontsize=13, fontweight='bold', pad=12)

# Plan de données
services = [
    (2, 5.5, "Service A\n(app)"),
    (5.5, 5.5, "Service B\n(app)"),
    (9, 5.5, "Service C\n(app)"),
    (2, 2.5, "Service D\n(app)"),
    (5.5, 2.5, "Service E\n(app)"),
    (9, 2.5, "Service F\n(app)"),
]
proxies = [
    (2, 4.5,   "Envoy\nsidecar"),
    (5.5, 4.5, "Envoy\nsidecar"),
    (9, 4.5,   "Envoy\nsidecar"),
    (2, 3.5,   "Envoy\nsidecar"),
    (5.5, 3.5, "Envoy\nsidecar"),
    (9, 3.5,   "Envoy\nsidecar"),
]
for (sx, sy, slabel), (px, py, plabel) in zip(services, proxies):
    ax.add_patch(mpatches.FancyBboxPatch((sx-0.7, sy-0.3), 1.4, 0.7,
                 boxstyle="round,pad=0.05", facecolor='#4575b4', alpha=0.2,
                 edgecolor='#4575b4', linewidth=2))
    ax.text(sx, sy, slabel, ha='center', va='center', fontsize=8, color='#4575b4', fontweight='bold')

    ax.add_patch(mpatches.FancyBboxPatch((px-0.5, py-0.25), 1.0, 0.6,
                 boxstyle="round,pad=0.05", facecolor='#d73027', alpha=0.2,
                 edgecolor='#d73027', linewidth=1.5))
    ax.text(px, py, plabel, ha='center', va='center', fontsize=7.5, color='#d73027', fontweight='bold')

# Connexions mTLS entre proxies
connexions = [(2, 4.5, 5.5, 4.5), (5.5, 4.5, 9, 4.5), (2, 3.5, 5.5, 3.5),
              (5.5, 3.5, 9, 3.5), (5.5, 4.5, 5.5, 3.5)]
for x1, y1, x2, y2 in connexions:
    ax.plot([x1+0.5, x2-0.5], [y1, y2], '-', color='#d62728', linewidth=1.5, alpha=0.7)
    mx, my = (x1+x2)/2, (y1+y2)/2
    ax.text(mx, my+0.12, "mTLS", ha='center', fontsize=6.5, color='#d62728', style='italic')

# Control plane Istio
ax.add_patch(mpatches.FancyBboxPatch((3.5, 0.3), 4, 1.2,
             boxstyle="round,pad=0.1", facecolor='#1a9850', alpha=0.15,
             edgecolor='#1a9850', linewidth=2))
ax.text(5.5, 0.9, "Istiod (control plane) — xDS API", ha='center', va='center',
        fontsize=10, color='#1a9850', fontweight='bold')
ax.text(5.5, 0.5, "Pilot (routage) · Citadel (certificats) · Galley (config)",
        ha='center', va='center', fontsize=8, color='#1a9850')

# Flèches control plane → sidecars
for x in [2, 5.5, 9]:
    ax.annotate("", xy=(x, 3.25), xytext=(x, 1.5),
                arrowprops=dict(arrowstyle='<->', color='#1a9850', lw=1.2, linestyle='dashed'))

plt.tight_layout()
plt.savefig('_static/service_mesh.png', dpi=100, bbox_inches='tight')
plt.show()
```

---

## Simulation complète — topologie CDN et décision de routage

```{code-cell} python
# Simulation de la décision de routage Anycast / CDN

class PointOfPresence:
    def __init__(self, code: str, ville: str, continent: str,
                 lat: float, lon: float, capacité: float = 100.0):
        self.code = code
        self.ville = ville
        self.continent = continent
        self.lat = lat
        self.lon = lon
        self.capacité = capacité
        self.charge_actuelle = 0.0

    @property
    def charge_pct(self) -> float:
        return self.charge_actuelle / self.capacité * 100

    def distance_vers(self, lat: float, lon: float) -> float:
        """Distance approchée (formule sphérique simplifiée)."""
        d_lat = abs(self.lat - lat)
        d_lon = abs(self.lon - lon)
        return (d_lat**2 + d_lon**2)**0.5

    def latence_estimée(self, lat: float, lon: float) -> float:
        """Latence estimée en ms = distance * 0.5 + charge * 0.2."""
        dist = self.distance_vers(lat, lon)
        return dist * 0.5 + self.charge_pct * 0.2


pops = [
    PointOfPresence("NYC", "New York",  "Amérique",  40.7, -74.0,  100),
    PointOfPresence("LAX", "Los Angeles","Amérique", 34.0, -118.2, 80),
    PointOfPresence("PAR", "Paris",     "Europe",    48.9,   2.3,  90),
    PointOfPresence("FRA", "Francfort", "Europe",    50.1,   8.7,  85),
    PointOfPresence("SIN", "Singapour", "Asie",       1.3, 103.8,  75),
    PointOfPresence("TYO", "Tokyo",     "Asie",      35.7, 139.7,  70),
    PointOfPresence("SYD", "Sydney",    "Océanie",  -33.9, 151.2,  50),
    PointOfPresence("DXB", "Dubaï",     "Asie",      25.2,  55.3,  60),
]

# Simuler des charges
charges = [45, 72, 38, 55, 80, 25, 15, 50]
for pop, charge in zip(pops, charges):
    pop.charge_actuelle = charge

def routage_cdn(lat_client: float, lon_client: float,
                pops: list[PointOfPresence]) -> PointOfPresence:
    """Choisit le PoP avec la latence estimée la plus basse."""
    return min(pops, key=lambda p: p.latence_estimée(lat_client, lon_client))

# Test avec plusieurs clients
clients = [
    ("Paris, France",        48.9,  2.3),
    ("New York, USA",        40.7, -74.0),
    ("Mumbai, Inde",         19.1,  72.9),
    ("Melbourne, Australie", -37.8, 144.9),
    ("Moscou, Russie",       55.8,  37.6),
    ("São Paulo, Brésil",   -23.5, -46.6),
]

print(f"{'Client':<25} {'PoP choisi':<8} {'Ville':<15} {'Latence est. (ms)':<20} {'Charge PoP'}")
print("-" * 80)
for nom, lat, lon in clients:
    pop = routage_cdn(lat, lon, pops)
    lat_est = pop.latence_estimée(lat, lon)
    print(f"{nom:<25} {pop.code:<8} {pop.ville:<15} {lat_est:<20.1f} {pop.charge_pct:.0f}%")
```

```{code-cell} python
# Visualisation de la charge des PoP et de la décision de routage

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Charge par PoP
ax1 = axes[0]
noms_pops = [p.code for p in pops]
charges_pct = [p.charge_pct for p in pops]
couleurs_charge = ['#d73027' if c > 75 else '#fdae61' if c > 50 else '#1a9850' for c in charges_pct]

bars = ax1.bar(noms_pops, charges_pct, color=couleurs_charge, edgecolor='white')
ax1.axhline(75, color='#d73027', linestyle='--', linewidth=1.5, label='Seuil alerte (75%)')
ax1.axhline(50, color='#fdae61', linestyle='--', linewidth=1.2, label='Seuil attention (50%)')
ax1.set_ylabel("Charge (%)", fontsize=11)
ax1.set_title("Charge actuelle des PoP CDN", fontsize=12, fontweight='bold')
ax1.set_ylim(0, 100)
ax1.legend(fontsize=9)
for bar, val in zip(bars, charges_pct):
    ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height()+1,
             f"{val:.0f}%", ha='center', fontsize=9)

# Latence estimée vers chaque PoP depuis Paris
ax2 = axes[1]
lat_paris, lon_paris = 48.9, 2.3
latences = [p.latence_estimée(lat_paris, lon_paris) for p in pops]
cols_latence = sns.color_palette('coolwarm_r', len(pops))
bars2 = ax2.barh(noms_pops, latences, color=cols_latence, edgecolor='white')
ax2.set_xlabel("Latence estimée depuis Paris (ms)", fontsize=11)
ax2.set_title("Classement des PoP\n(depuis Paris)", fontsize=12, fontweight='bold')

pop_choisi = routage_cdn(lat_paris, lon_paris, pops)
for bar, pop, lat in zip(bars2, pops, latences):
    suffixe = " ← CHOISI" if pop.code == pop_choisi.code else ""
    col = '#d73027' if pop.code == pop_choisi.code else '#333333'
    ax2.text(lat + 0.3, bar.get_y() + bar.get_height()/2,
             f"{lat:.1f} ms{suffixe}", va='center', fontsize=9, color=col,
             fontweight='bold' if suffixe else 'normal')

plt.tight_layout()
plt.savefig('_static/cdn_routing.png', dpi=100, bbox_inches='tight')
plt.show()
```

---

## Résumé

```{admonition} Points clés du chapitre
:class: tip
- Un **proxy inverse** centralise TLS, cache, authentification et load balancing ; le client ne connaît que l'adresse du proxy.
- **Nginx** avec `upstream` supporte nativement le round-robin pondéré, le least_connections et le keepalive HTTP vers les backends.
- **HAProxy** est spécialisé dans le load balancing haute performance, avec des ACL puissantes pour le routage applicatif L7.
- Les CDN réduisent la latence en servant le contenu depuis le **PoP** le plus proche de l'utilisateur grâce à **Anycast** : une même adresse IP est annoncée depuis des dizaines de villes, et BGP route vers la plus proche.
- Le **cache HTTP** repose sur `Cache-Control`, `ETag` et `Last-Modified` ; les assets statiques versionnés peuvent être mis en cache pour un an (`immutable, max-age=31536000`).
- Un **service mesh** (Istio/Envoy) ajoute mTLS, observabilité et politiques de trafic à tous les services sans modifier leur code, grâce aux sidecars.
- Le **circuit breaker** protège les backends surchargés en interrompant temporairement les appels vers un service défaillant.
```
