10 000 req/s

Django + SQLite

sur une VM à 20€

Apéro Web Nancy 🍺

Loïc — CTO & Co-fondateur @ Kalvad

Qui suis-je ?

  • CTO & Co-fondateur — Kalvad DMCC (Dubai 🇦🇪)
  • Ancien d'EPITECH (promo 2014) — originaire de Nancy
  • 15+ ans dans la tech — DevOps, infra & automation
  • Fan de Python, FreeBSD, Alpine Linux, PostgreSQL
  • Contributeur PyInfra (#6) — speaker FOSDEM 2026
  • Mantra : Release faster and automate more.

🌐 kalvad.com · 🐙 github.com/wowi42 · 🐦 @wowi42 · 💼 linkedin.com/in/ltosser

Le pitch

On va prendre Django, SQLite et une VM à 20€/mois.

On va construire un site e-commerce.

Et on va le pousser à 10 000 requêtes par seconde.

7 étapes. 1 geste à chaque fois. Rien de magique.

Pourquoi ces choix volontairement "naïfs" ?

Django

Le framework que tout le monde connaît.
Celui que les gens "sérieux" disent de remplacer par Go ou Rust.

Instagram tourne sur Django.
228 millions d'utilisateurs actifs par jour.

SQLite

La base de données "jouet" qu'il ne faut "surtout pas mettre en prod".

SQLite gère :

  • 👉 Tous les smartphones du monde (Android + iOS)
  • 👉 Tous les navigateurs (Firefox, Chrome, Safari)
  • 👉 Airbus, Bloomberg, Bosch...

1 fichier. 0 serveur. 0 configuration. 0 problème.

La VM

Scaleway DEV1-M ou OVH B2-7 — environ 20€/mois

  • 3 vCPU
  • 4 Go RAM
  • SSD NVMe

C'est ce que vous payez pour un café par semaine.

Le projet : une boutique e-commerce

# models.py
class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)

class Product(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

50 catégories, 10 000 produits en base. Classique.

La vue qu'on va torturer

# views.py
def product_list(request):
    category = request.GET.get('category')
    products = Product.objects.select_related('category')

    if category:
        products = products.filter(category__slug=category)

    products = products.order_by('-created_at')[:100]

    return render(request, 'shop/product_list.html', {
        'products': products,
        'categories': Category.objects.all(),
    })

Listing produit + filtre catégorie.
La page la plus visitée de tout e-commerce.

L'outil : Locust 🦗

# locustfile.py
from locust import HttpUser, task, between

class ShopUser(HttpUser):
    @task(3)
    def browse_products(self):
        self.client.get("/products/")

    @task(2)
    def browse_category(self):
        self.client.get("/products/?category=electronics")

On simule des vrais utilisateurs qui naviguent.

Mesurer la perf : oubliez la moyenne

La moyenne ment. Elle masque les pics de latence.

Si 99 requêtes prennent 1ms et 1 requête prend 10s :
Moyenne = 101ms. "Ça va." Sauf pour l'utilisateur qui attend 10s.

On mesure avec les percentiles :

  • P95 : 95% des requêtes sont en dessous de cette valeur
  • P99 : 99% des requêtes sont en dessous

Le P95/P99 montre ce que vivent vos pires utilisateurs.
C'est eux qui partent. C'est eux qui comptent.

🏁 C'est parti.

7 étapes.

1 geste par étape.

1 seule VM.

Étape 1

python manage.py runserver

Étape 1 — runserver

Le serveur de dev. Mono-thread. Mono-process.

DO NOT USE THIS SERVER IN A PRODUCTION SETTING.
— Django docs, en majuscules, en gras, en rouge

Le geste : aucun. C'est le point de départ.

📊 runserver

25 utilisateurs en //

Métrique Valeur
Requêtes/sec ~85
Latence médiane ~150ms
Latence P95 ~630ms

Le plancher. On ne peut que monter.

Étape 2

Changer la commande de lancement

Étape 2 — Gunicorn

# Avant
python manage.py runserver 0.0.0.0:8000

# Après
pip install gunicorn
gunicorn ecommerce.wsgi:application \
    --workers 3 \
    --bind 0.0.0.0:8000

Le geste : changer la commande de lancement.

  • 3 workers (1 par vCPU)
  • Pre-fork : chaque requête a son process
  • 0 ligne de code applicatif modifiée

📊 Gunicorn

Tujours avec 25 users en //

Métrique runserver Gunicorn
Req/s 85 ~180
Latence méd. 180ms ~160ms
Latence P95 800ms ~180ms
100 users 💀 ✅

x2. En changeant une commande.

FYI -> 40 Mbps

Étape 3

Changer le serveur WSGI

Étape 3 — Granian 🦀 (et ASGI)

# Avant (WSGI)
gunicorn ecommerce.wsgi:application --workers 3

# Après (ASGI)
pip install granian
granian --interface asginl ecommerce.asgi:application  --host 0.0.0.0 --port 8080 --workers 3

Le geste : remplacer gunicorn par granian + passer en ASGI.

  • Écrit en Rust — multi-thread dans chaque worker
  • Toujours 0 ligne de code applicatif modifiée

WSGI vs ASGI — c'est quoi la diff ?

WSGI (Web Server Gateway Interface) — le standard historique :

  • Synchrone : 1 requête = 1 thread bloqué jusqu'à la réponse
  • Simple, éprouvé, tout Django est construit dessus

ASGI (Asynchronous Server Gateway Interface) — le successeur :

  • Asynchrone : 1 thread peut gérer plusieurs requêtes en parallèle
  • Supporte WebSockets, HTTP/2, Server-Sent Events
  • Permet async def view(request) dans Django

On passe en ASGI ici pour profiter du multi-thread natif de Granian.
Django fonctionne en ASGI sans aucune modification (compatibilité automatique).

📊 Granian

Métrique runserver Gunicorn Granian
Req/s 85 180 ~450
Latence méd. 180ms 160ms 32ms
Latence P95 800ms 180ms 140ms

x2.5 vs Gunicorn. x5 vs runserver.

Toujours zéro modification de code.

⏸ Pause

450 req/s. C'est ~39M requêtes/jour.

Un e-commerce à 1M€/an fait ~500 visites/jour.

On couvre déjà 99% des besoins.

Mais on continue.

Étape 4

Ajouter les index manquants

Étape 4 — Les index 🔍

On regarde les requêtes Django avec django-debug-toolbar :

-- La requête de listing par catégorie
SELECT p.*, c.name FROM product p
JOIN category c ON p.category_id = c.id
WHERE c.slug = 'electronics'
ORDER BY p.created_at DESC
LIMIT 20;

SQLite fait un full table scan sur 10 000 lignes.
À chaque requête.

Le geste : une migration

# 0002_add_indexes.py
class Migration(migrations.Migration):
    dependencies = [('shop', '0001_initial')]

    operations = [
        migrations.AddIndex(
            model_name='product',
            index=models.Index(
                fields=['category', '-created_at'],
                name='idx_product_cat_created'
            ),
        ),
        migrations.AddIndex(
            model_name='category',
            index=models.Index(
                fields=['slug'],
                name='idx_category_slug'
            ),
        ),
    ]
python manage.py migrate   # 0.3 secondes

📊 Avec index

Métrique runserver Gunicorn Granian + Index
Req/s 85 180 450 ~800
Latence méd. 180ms 160ms 32ms ~10ms
Latence P95 800ms 180ms 140ms ~28ms

x1.8 vs Granian seul. x9 vs runserver.

Le geste : 1 fichier de migration. 10 lignes.
La requête SQL passe de 12ms → 0.3ms.

Étape 5

Ajouter un décorateur

Étape 5 — Cache de vue

Le constat : on exécute les mêmes requêtes pour chaque visiteur.
Le catalogue change 2 fois par jour. On le requête 800 fois par seconde.

# dans settings.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    }
}

# views.py — AVANT
def product_list(request):
    ...

# views.py — APRÈS (1 ligne ajoutée)
from django.views.decorators.cache import cache_page

@cache_page(60 * 5)  # 5 minutes
def product_list(request):
    ...   # rien d'autre ne change

Le geste : ajouter @cache_page. C'est tout.
Django utilise LocMemCache par défaut (mémoire du process).

📊 Cache de vue (LocMemCache)

Métrique Granian + Index + Cache vue
Req/s 450 800 ~3 200
Latence méd. 32ms 10ms ~2.5ms
Latence P95 140ms 28ms ~7ms

x4 vs index. x38 vs runserver.

SQLite ne travaille quasiment plus.
Le bottleneck est maintenant le rendu de template pour les cache miss.

(on est à 450MBps...)

La taille de la page compte

À 10 000 req/s avec une page de 140 kB :

10 000 × 140 kB = 1,4 GB/s = 11,2 Gbps

La carte réseau d'une VM à 20€ plafonne à 1-5 Gbps.

Le bottleneck n'est plus Django, ni SQLite, ni le cache.
C'est le tuyau.

Optimiser la taille des réponses (gzip, minification, pagination) devient aussi important que le code.

Étape 6

Revenir en WSGI

Étape 6 — Retour au WSGI

Le constat : nos vues Django sont 100% synchrones.
L'event loop ASGI ne fait que wrapper du code synchrone → overhead inutile.

# Avant (ASGI)
granian --interface asginl ecommerce.asgi:application --workers 3

# Après (WSGI)
granian --interface wsgi ecommerce.wsgi:application --workers 3

Le geste : changer asginl → wsgi dans la commande.

Pourquoi WSGI gagne sous pression ?

ASGI avec du code synchrone :

  • Django wrappe chaque vue sync dans sync_to_async
  • Chaque requête = context switch vers un thread pool
  • L'event loop coordonne mais n'accélère rien
  • Overhead mesurable : ~10-15% de CPU en plus

WSGI pur :

  • Appel direct, zéro indirection
  • Pas de thread pool, pas de context switch
  • Chaque worker fait une chose à la fois, vite

ASGI brille avec des I/O async (WebSockets, appels API externes).
Pour du Django classique sous charge, WSGI est plus rapide.

📊 Retour WSGI

Métrique + Index + Cache vue + WSGI
Req/s 800 3 200 ~4 500
Latence méd. 10ms 2.5ms ~1.8ms
Latence P95 28ms 7ms ~4ms

x1.4 juste en enlevant l'overhead async.

Moins de couches = moins de latence.

Étape 7

Tuner le noyau Linux

Étape 7 — Tuner l'OS

Pendant le benchmark, on atteint les limites du noyau avant celles de Django.
On passe sur Alpine Linux et on tune tout.

7a — sysctl : débloquer le réseau

# /etc/sysctl.d/99-bench.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.core.netdev_max_backlog = 4096
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
  • somaxconn : file d'attente des connexions (défaut : 128 → 4096)
  • tcp_tw_reuse : réutilise les sockets TIME_WAIT
  • Sans ça, le bench sature les sockets avant Django

7b — Alpine Linux + musl

Pourquoi Alpine au lieu d'Ubuntu/Debian ?

  • musl libc au lieu de glibc :
    • Allocateur mémoire plus simple et plus prédictible
    • Moins de overhead sur malloc/free en haute concurrence
    • Empreinte mémoire ~50% plus petite par process
  • Kernel virt patchset minimal — moins de modules, moins de bruit
  • Image de base ~5 Mo vs ~80 Mo pour Debian

Moins de couches entre Python et le hardware.

7c — Scheduler CPU custom

Le scheduler Linux par défaut (EEVDF) est conçu pour le desktop :
il répartit le temps CPU équitablement entre tous les processus.

Pour un serveur web, on veut l'inverse : donner toute la priorité aux workers.

# Pinning des workers Granian sur les CPU dédiés
taskset -c 0-2 granian --interface wsgi ecommerce.wsgi:application --workers 3

# Priorité temps réel pour les workers
chrt -f 50 granian ...

# Isoler les CPUs du scheduler général
# /etc/default/grub → isolcpus=0-2 nohz_full=0-2

Le geste : dédier les CPUs aux workers, éliminer les interruptions.

📊 Avec tuning OS

Étape Req/s Latence méd. vs départ
① runserver 85 180ms --
② Gunicorn 180 160ms x2
③ Granian (ASGI) 450 32ms x5
④ Index DB 800 10ms x9
⑤ Cache vue 3 200 2.5ms x38
⑥ Retour WSGI 4 500 1.8ms x53
⑦ OS tuning ~10 500 < 1ms x124

🎯 10 000+ req/s

Django. SQLite. 1 VM. 20€/mois.

x124 en 7 gestes.

Récap : chaque geste

# Geste Modif
① runserver → rien baseline
② gunicorn remplace runserver 1 commande
③ granian + ASGI 1 commande
④ Ajouter des index DB 1 migration (~10 lignes)
⑤ @cache_page sur la vue 1 décorateur
⑥ Retour WSGI 1 flag dans la commande
⑦ Alpine + sysctl + scheduler config OS

Total : ~20 lignes de code + config. 0€ de surcoût.

La courbe

  Req/s
  10K+ ┤                                          ╭── ⑦ OS tuning
       │                                     ╭────╯
  4.5K ┤                                ╭────╯ ⑥ retour WSGI
   3K  ┤                           ╭────╯ ⑤ cache_page
       │                      ╭────╯
   800 ┤                 ╭────╯ ④ index
   450 ┤            ╭────╯ ③ granian (ASGI)
   180 ┤       ╭────╯ ② gunicorn
    85 ┤───────╯ ① runserver
       └───────────────────────────────────────────
         Serveur        DB       Cache    OS
         ──────→    ──────→    ──────→  ──────→

4 phases : serveur → base de données → cache → OS.

"Oui mais SQLite c'est pas pour la prod"

Faux.

  • Lectures : ~50 000 req/s sur NVMe
  • Mode WAL = lectures concurrentes sans lock
  • Un e-commerce c'est 95% de lectures
  • Backup = cp database.db database.db.bak
  • Réplication temps réel = Litestream
# settings.py
'OPTIONS': {
    'init_command': (
        'PRAGMA journal_mode=WAL;'
        'PRAGMA synchronous=NORMAL;'
        'PRAGMA cache_size=-64000;'
    ),
}

La limite de SQLite c'est l'écriture concurrente. Pas la lecture.

"Oui mais si je dois scaler ?"

Vertical (d'abord) :

Scaleway PRO2-XXS — ~40€/mois — 4 vCPU, 16 Go RAM
→ Doubler les perfs sans rien toucher.

Horizontal (si vraiment) :

                   ┌── VM 1 : Granian + cache ──┐
Client → Traefik → ├── VM 2 : Granian + cache ──┤→ Redis
                   └── VM 3 : Granian + cache ──┘

SQLite → PostgreSQL.

Combien ça coûte chez les autres ?

Stack Perf Coût/mois
Django + SQLite + OVH 10K req/s ~20€
ECS Fargate + RDS + ALB ~10K req/s ~650-900€
K8s + microservices ~10K req/s ~1800-4000€
Lambda + DynamoDB ~10K req/s ~6000-10000€

Même perf. 10x à 100x moins cher.

Sans compter le DevOps à 600€/jour pour maintenir le K8s.

Le vrai ennemi

Ce n'est pas la performance.
Ce n'est pas la technologie.

C'est la complexité inutile.

Chaque service ajouté = un point de défaillance.
Chaque couche d'abstraction = de la latence.
Chaque outil "cloud-native" = du vendor lock-in.

Les règles

  1. Mesure avant d'optimiser — Locust, pas ton intuition
  2. 1 geste à la fois — mesure l'impact avant le geste suivant
  3. Serveur → DB → Cache — dans cet ordre, toujours
  4. La complexité a un coût — et il est toujours sous-estimé

Retour d'expérience — Kalvad

En production, systèmes critiques :

  • 🏛️ Gouvernement UAE — haute dispo, données sensibles
  • 💳 Fintech PCI-DSS — transactions financières
  • 🏗️ Bare metal Alpine Linux — pas de cloud provider

Pas de K8s. Pas de microservices.
Des VM, du Python, du bon sens, et du sommeil la nuit.

La meilleure architecture,

c'est celle que le stagiaire

peut comprendre un lundi matin.

Questions ? 🍺

Loïc — CTO @ Kalvad
kalvad.com

Infrastructure as Code, not as YAML

Ressources

  • Locust — locust.io
  • Granian — github.com/emmett-framework/granian
  • Django — djangoproject.com
  • SQLite — sqlite.org/whentouse.html
  • Litestream — litestream.io — réplication SQLite
  • Mon blog — "DevOps in 2025: The Pauperization Machine"