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.

Hide code cell source

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#

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()
_images/cacf625f0c3282f35ee842260adcf1cda8812e4903a40952730287bfe6b291ce.png

Nginx comme reverse proxy#

# /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#

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()
_images/441df954be57a9735deaca6252659a63fc72fd49edfa4be354d174d03adfeb3e.png

Simulation Python — Round-Robin avec weights#

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()
_images/9c5e63666fb8b903b0883392bbd9e5b2da1a671e999dc8f559810c6f7b948b3d.png

Health checks et circuit breaker#

Health checks actifs et passifs#

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()
_images/1c13395edaf79f486101559f0ac4825d42dfb9ac1397e54631f0661fbd06e737.png

Configuration 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#

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()
_images/d0e675e45337813e94956c89e5904d76eafec1b4f440946dbada982bde7fd1c0.png

Cache hit / miss et invalidation#

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()
_images/142406f884763be38b4c87686a6247af6c6f32dc48e389b9e775aaeead99287a.png

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#

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}")
En-têtes HTTP de contrôle du 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

LRU Cache HTTP en 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()
Simulation de requêtes vers le cache LRU :
--------------------------------------------------
  MISS : /static/logo.png
  MISS : /static/app.css
  MISS : /api/users
  HIT  : /static/logo.png
  MISS : /static/bundle.js
  HIT  : /static/app.css
  [LRU] Éviction : /api/users
  MISS : /static/fonts/roboto.woff2
  HIT  : /static/logo.png

Cache (4/4 entrées) :
  /static/bundle.js                   TTL=3600s
  /static/app.css                     TTL=3600s
  /static/fonts/roboto.woff2          TTL=3600s
  /static/logo.png                    TTL=3600s
  Hit rate : 37.5%  (3 hits / 5 misses)

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.

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()
_images/d40c4ca3b3651dd7a53af02ea791f2cb2fbb6bfa4cafe79ea49d8bcd0b059fa8.png

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

# 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}%")
Client                    PoP choisi Ville           Latence est. (ms)    Charge PoP
--------------------------------------------------------------------------------
Paris, France             PAR      Paris           8.4                  42%
New York, USA             NYC      New York        9.0                  45%
Mumbai, Inde              DXB      Dubaï           26.0                 83%
Melbourne, Australie      SYD      Sydney          9.7                  30%
Moscou, Russie            PAR      Paris           26.4                 42%
São Paulo, Brésil         NYC      New York        43.9                 45%
# 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()
_images/2b4a4efcba48c419eb542a712001cb46b3937d3632ca3159a9050eef1fa77e86.png

Résumé#

Points clés du chapitre

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