Django ou Flask sur un petit VPS : quel choix ?
Comparez Django et Flask sur un VPS de 1 à 2 Go : mémoire résidente par worker Gunicorn et nombre de workers réellement supportés par une petite machine.
Ce que coûtent Django et Flask sur un petit VPS
Sur un petit VPS, le choix entre Django et Flask est d’abord une question de mémoire. Django charge son object relational mapper (ORM), son système de migrations et, si vous l’activez, son site d’administration dans chaque processus worker que vous démarrez. Flask charge un routeur et un objet de requête. Sur une machine avec 1 GB de RAM, cette différence détermine le nombre de workers que vous pouvez exécuter, et ce nombre détermine le nombre de requêtes que vous pouvez traiter simultanément.
Ce coût ne pénalise Django que si vous devez réimplémenter tout ce qu’il fournit. Une application avec des comptes utilisateur, des sessions et un panneau d’administration a intérêt à utiliser Django : la RAM consommée par chaque worker est le prix du code que vous n’avez pas à écrire. Une API JSON devant un datastore que vous exécutez déjà a intérêt à utiliser Flask, car aucun de ces composants intégrés ne serait chargé. Il s’agit d’une question d’adéquation. Les mesures ci-dessous vous indiquent de quel côté se situe votre application.
Quelle quantité de mémoire utilise un worker gunicorn ?
The data behind this chart
[
{
"label": "Bare Python 3.12 process",
"rss_mb": 14,
"pss_mb": 9
},
{
"label": "Flask, one route",
"rss_mb": 42,
"pss_mb": 26
},
{
"label": "Flask + SQLAlchemy",
"rss_mb": 58,
"pss_mb": 38
},
{
"label": "Django, admin disabled",
"rss_mb": 78,
"pss_mb": 47
},
{
"label": "Django, admin enabled",
"rss_mb": 96,
"pss_mb": 58
}
]Voici des valeurs typiques publiées pour une application hello world de chaque type, sur Ubuntu 24.04 avec Python 3.12, trois workers gunicorn et preload activé. Considérez-les comme un minimum, car vos propres imports s’y ajoutent. Un worker Django avec l’interface d’administration activée utilise 96 Mo de mémoire résidente, tandis que sa part proportionnelle de la mémoire est de 58 Mo. L’écart entre ces deux valeurs constitue le sujet de la section suivante.
Effectuez la même mesure sur votre propre serveur.
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitleInstallez setproctitle. Lorsqu’il est présent, gunicorn renomme ses processus en gunicorn: master [site1] et gunicorn: worker [site1]. Les commandes suivantes peuvent ainsi trouver les workers par leur nom au lieu de devoir le deviner.
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')La colonne rss correspond à la taille de l’ensemble résident, en kilo-octets : elle inclut chaque page mémoire que le processus conserve actuellement en RAM. Additionner cette valeur pour tous les workers donne un résultat trop élevé, car un worker forké partage des pages avec son parent et ses processus frères. La même page est donc comptée plusieurs fois. Demandez plutôt au kernel la taille proportionnelle de l’ensemble (PSS). Elle répartue chaque page partagée entre les processus qui la mappent.
for pid in $(pgrep -f 'gunicorn: worker'); do awk -v p="$pid" '/^Pss:/ {printf "%s %d MB\n", p, $2/1024}' /proc/$pid/smaps_rollup; doneExécutez cette commande avec l’utilisateur qui possède les workers, ou avec sudo. La PSS est la valeur à utiliser pour établir le budget mémoire, car elle s’additionne correctement, contrairement à la RSS.
Django est plus volumineux à cause de ce que fait django.setup(). Il importe chaque élément de INSTALLED_APPS, construit le registre des applications et instancie chaque classe de modèle, ainsi qu’un objet Python pour chaque champ. L’ajout de django.contrib.admin active l’autodiscovery de l’administration. Celle-ci importe le module admin de chaque application et charge également les couches des formulaires et des templates. Un worker Flask importe Werkzeug et Jinja2, puis s’arrête là.
Une réserve importante : le framework est souvent la plus petite partie. Un worker qui importe un SDK cloud ou une bibliothèque de calcul numérique en charge souvent davantage qu’un worker Django. Mesurez votre application réelle avant de conclure que le framework est le problème.
Copie à l’écriture : pourquoi preload modifie le nombre
Le processus maître de Gunicorn crée les workers avec fork. Juste après fork(), le processus fils partage chaque page mémoire avec le processus parent. Le kernel ne copie une page que lorsqu’un des deux processus y écrit. Le registre des modèles Django n’existe donc qu’une fois ou quatre fois sur le serveur selon le processus qui l’a créé avant le fork.
Avec preload_app désactivé, chaque worker importe votre application après avoir été créé par fork. Chacun construit donc sa propre copie privée. Lorsqu’il est activé, le processus maître importe l’application une seule fois et les workers héritent de ces pages.
import gc
bind = "unix:/run/site1/gunicorn.sock"
umask = 0o007
workers = 3
timeout = 30
preload_app = True
max_requests = 500
max_requests_jitter = 50
def when_ready(server):
gc.freeze()CPython fonctionne à l’encontre de la copie à l’écriture. L’en-tête de chaque objet contient un compteur de références. Toute consultation d’un objet écrit donc dans cet en-tête. Les pages partagées sont ainsi copiées progressivement, au fur et à mesure que le garbage collector parcourt le tas. gc.freeze() déplace toutes les données déjà allouées dans une génération permanente que le garbage collector ne parcourt plus. Cela permet de conserver davantage de pages partagées. when_ready est le hook approprié, car il s’exécute après le preload et avant la création du premier worker par fork. Mesurez le PSS avant et après son ajout, car le gain dépend de la quantité d’état de votre application créée lors de l’import.
Le preload entraîne un coût qui surprend souvent lors d’un déploiement. systemctl reload envoie HUP, et le comportement documenté de gunicorn avec HUP consiste à recharger sa configuration et à démarrer de nouveaux workers. Lorsque l’application est preloadée, le code n’est pas réimporté. Votre nouvelle release ne s’exécute donc pas, même si les processus worker sont nouveaux. Utilisez systemctl restart après une modification du code, ou la séquence USR2 puis WINCH si vous devez d’abord laisser les anciens workers terminer le traitement de leurs requêtes.
Combien de workers un VPS de 1 GB peut-il réellement exécuter ?
The data behind this chart
[
{
"label": "Ubuntu 24.04 base",
"ram_mb": 190
},
{
"label": "nginx",
"ram_mb": 12
},
{
"label": "PostgreSQL, default config",
"ram_mb": 120
},
{
"label": "Headroom you must leave",
"ram_mb": 150
},
{
"label": "Left for gunicorn workers",
"ram_mb": 550
}
]Ces chiffres correspondent à un serveur inactif qui ne sert rien. Il reste environ 550 Mo pour les workers de l’application, avant même l’arrivée de la première requête.
Faites ensuite les divisions, avec une estimation pessimiste. Une requête consomme de la mémoire pendant son exécution : un queryset qui charge quelques milliers de lignes, puis le rendu d’un template. Le pic de mémoire par worker est souvent proche du double de la valeur au repos. Prévoyez donc le double. Avec Django et l’interface d’administration, 58 Mo au repos permettent d’exécuter quatre workers sur ce serveur. Avec Flask et SQLAlchemy, 38 Mo permettent d’en exécuter sept.
La suggestion de Gunicorn, (2 x cores) + 1, suppose que le CPU est la ressource limitée et que la RAM ne l’est pas. Sur un petit VPS, c’est l’inverse. Un vCPU partagé fournit également moins de puissance qu’un cœur complet lorsque l’hôte est chargé. Il faut le comprendre avant d’accuser votre code : le temps de vol CPU causé par un voisin bruyant apparaît dans top sous la forme de la valeur st.
Si vos vues attendent surtout une base de données ou une API upstream, les threads sont plus efficaces que les processus dans ce cas. --worker-class gthread --workers 2 --threads 4 permet de traiter huit requêtes simultanées avec la mémoire de deux workers, car les threads partagent une seule copie chargée de l’interpréteur et du framework. Le verrouillage global de l’interpréteur signifie que les threads n’aident pas une vue qui consomme du CPU.
Ajoutez du swap au serveur. Un VPS de 1 GB sans swap transforme un pic de mémoire en processus tué. Un swapfile transforme le même pic en requête lente.
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness = 10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --systemLimitez ensuite l’application elle-même. MemoryMax=600M dans l’unité gunicorn indique au kernel de récupérer la mémoire auprès du cgroup de votre application, au lieu de choisir une victime sur l’ensemble du serveur. Ainsi, une requête qui s’emballe ne vous fait pas perdre votre session SSH.
Comportement au démarrage à froid et après un redémarrage
The data behind this chart
[
{
"label": "Flask, one route",
"cold_start_ms": 90
},
{
"label": "Flask + SQLAlchemy",
"cold_start_ms": 260
},
{
"label": "Django, admin disabled",
"cold_start_ms": 480
},
{
"label": "Django, admin enabled",
"cold_start_ms": 720
}
]Le coût du démarrage est payé deux fois : à chaque déploiement et après chaque redémarrage automatique consécutif à un crash. Une application Flask minimale est prête en environ 90 ms, tandis que Django avec l’interface d’administration activée prend environ 720 ms sur le même vCPU partagé. Ces deux valeurs correspondent à des chiffres généralement publiés. Mesurez votre propre application, car ses dépendances ont le plus d’impact.
cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20Les dernières lignes répertorient les imports les plus lents avec leur durée cumulée en microsecondes. Pour Flask, utilisez le même flag avec votre module : python -X importtime -c "import app".
Avec le preload activé, le master paie ce coût une seule fois et chaque worker forké démarre immédiatement. Avec le preload désactivé, chaque worker paie ce coût, et timeout de gunicorn couvre aussi bien le démarrage que le traitement de la requête. Un worker qui ne s’est pas signalé dans les timeout secondes est tué puis remplacé. Une application lourde sur un vCPU partagé lent peut donc rester bloquée dans une boucle de redémarrages sans jamais répondre à une requête. Le journal indique :
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)Les migrations doivent être définies dans l’unité, et non dans le code de démarrage de l’application. ExecStartPre s’exécute une fois avant la création de tout worker. Placer migrate dans votre application signifie que trois workers se disputent simultanément le même verrou de schéma.
La structure du déploiement est presque identique
Gestionnaire de processus
Les deux frameworks s’exécutent avec gunicorn, lui-même exécuté par systemd.
[Unit]
Description=gunicorn for site1
After=network.target
[Service]
User=site1
Group=www-data
WorkingDirectory=/srv/site1
RuntimeDirectory=site1
Environment="PATH=/srv/site1/.venv/bin"
ExecStartPre=/srv/site1/.venv/bin/python manage.py migrate --noinput
ExecStart=/srv/site1/.venv/bin/gunicorn -c /srv/site1/gunicorn.conf.py site1.wsgi:application
Restart=on-failure
RestartSec=3
MemoryMax=600M
[Install]
WantedBy=multi-user.targetRuntimeDirectory=site1 crée /run/site1 au démarrage et le supprime à l’arrêt. Le chemin de socket existe donc toujours avec le propriétaire approprié. La directive umask = 0o007 dans la configuration de gunicorn rend cette socket accessible en écriture au groupe www-data. C’est ainsi que nginx y accède.
L’unité Flask est identique, à l’exception d’une ligne : ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app, sans ExecStartPre. L’argument app:app indique d’abord le module, puis le callable. L’erreur Failed to find attribute 'app' in 'app'. signifie donc que votre module ne définit pas de variable portant ce nom. Les tâches planifiées suivent le même modèle. Un timer systemd remplace cron pour une commande de gestion Django sans ajouter de task queue sur un serveur de cette taille.
Fichiers statiques
Avec Django et DEBUG = False, aucun fichier statique n’est servi. Définissez STATIC_ROOT, exécutez python manage.py collectstatic, puis configurez le serveur web pour utiliser le répertoire de sortie. Si vous ignorez cette étape, l’interface d’administration se charge sans mise en forme et le journal se remplit de Not Found: /static/admin/css/base.css.
Deux méthodes sont raisonnables pour les servir. Un bloc nginx alias ne consomme aucune ressource de votre application. WhiteNoise, ajouté comme middleware, sert les fichiers depuis le worker et évite le bloc nginx, au prix d’un peu de temps CPU du worker pour chaque fichier. Flask sert son propre répertoire static/ en développement. En production, configurez le proxy pour utiliser ce répertoire, pour la même raison.
Reverse proxy
server {
listen 80;
server_name example.com;
location /static/ {
alias /srv/site1/static/;
expires 30d;
}
location / {
proxy_pass http://unix:/run/site1/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Derrière un proxy, Django doit savoir que la requête d’origine était en HTTPS. Sinon, ses contrôles de cross-site request forgery (CSRF) rejettent vos propres formulaires.
ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")Si le serveur exécute déjà des conteneurs, Traefik devant plusieurs applications Docker Compose remplit le même rôle avec des labels sur les conteneurs, au lieu d’un fichier par site.
Quelle base de données
SQLite convient réellement à un serveur hébergeant une seule application, avec un débit d’écriture de quelques opérations par seconde. Il supprime aussi un daemon du budget mémoire. Activez la journalisation write-ahead (WAL) et définissez un busy timeout pour le driver.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL;",
},
}
}Sans ces deux options, vous rencontrez django.db.utils.OperationalError: database is locked dès que deux workers écrivent simultanément. Le mode de journalisation par défaut bloque les lecteurs pendant une écriture, et le timeout par défaut abandonne presque immédiatement. L’explication complète, notamment le point à partir duquel SQLite n’est plus adapté, figure dans exécuter SQLite en production sur un VPS.
PostgreSQL sur le même serveur de 1 GB consomme les 120 MB prévus dans le budget ci-dessus, auxquels s’ajoute un processus backend pour chaque connexion persistante. Le CONN_MAX_AGE de Django maintient une connexion ouverte par worker. Quatre workers impliquent donc quatre backends. C’est généralement un bon compromis. Comptez-les simplement avant de définir le nombre de workers. Si vous préférez conserver la base de données dans un conteneur à côté de l’application, exécuter Docker sur un VPS revient au même compromis, avec un seuil plus élevé : le daemon et chaque conteneur ajoutent une surcharge qui compte sur une machine de cette taille.
Ce que les batteries fournissent, et ce qu’elles coûtent
Les mégaoctets supplémentaires de Django correspondent à des composants qui existent déjà et fonctionnent déjà ensemble : l’ORM avec les migrations, le système de session et d’authentification, le modèle de permissions, la couche de formulaires avec la protection CSRF, le moteur de templates, les commandes de gestion et l’interface d’administration. L’interface d’administration est souvent sous-estimée. C’est un éditeur fonctionnel de la base de données pour vos modèles, avec recherche et filtres, pour une ligne dans INSTALLED_APPS.
Flask est l’exact opposé. Vous disposez du routage, d’un objet de requête, de templates Jinja2 et d’un objet de configuration. Tout le reste est un choix que vous faites. C’est un avantage réel lorsque l’application est petite, car aucun ORM n’est chargé si vous n’en importez jamais.
Le piège se situe entre les deux. Ajoutez SQLAlchemy pour les modèles, Alembic pour les migrations, Flask-Login pour les sessions, Flask-WTF pour les formulaires et la protection CSRF, ainsi qu’une extension d’administration pour le back office, et vous obtenez un assemblage qui présente l’empreinte mémoire de Django sans sa cohérence. Chaque composant suit son propre cycle de publication et impose sa propre conception de l’intégration de l’application. C’est à ce stade que Django devient le choix le plus économique, en RAM comme en temps consacré aux mises à niveau.
Django ou Flask : la règle de décision
Utilisez Django lorsque l’application gère des comptes, du contenu modifiable, un schéma qui continuera d’évoluer et un back office que quelqu’un ouvrira réellement. Utilisez Flask lorsque l’application est une interface JSON au-dessus d’un datastore existant ou un récepteur de webhook sans HTML.
Le critère décisif est une liste écrite. Notez chaque package que vous installeriez avec Flask pour obtenir les fonctionnalités nécessaires. Si cette liste contient un ORM et un outil de migration, vous avez déjà choisi Django et vous payez simplement un coût supplémentaire pour y arriver plus lentement.
Un cas favorise réellement Flask sur du matériel limité : plusieurs petits services sur une même machine. Chaque service Flask est un processus peu coûteux, géré par sa propre unité. Trois sites Django sur un VPS de 1 GB signifient que trois copies du framework résident simultanément en mémoire, et le calcul précédent ne s’applique plus. Si le résultat reste insuffisant, choisir une offre plus grande est souvent la solution honnête, et ce qu’un VPS coûte réellement par mois est une conversation plus courte que réécrire une application fonctionnelle.
Modes d’échec et messages affichés
Les workers disparaissent puis réapparaissent. Gunicorn affiche [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Ce message apparaît lorsqu’il arrête un worker qui n’a pas envoyé son heartbeat dans le délai imparti, mais aussi lorsque le kernel arrête le processus. Utilisez dmesg -T | grep -i "killed process" pour distinguer les deux cas. Une ligne correspondante indique un problème de mémoire : réduisez le nombre de workers ou ajoutez du swap.
Toutes les pages renvoient 400 et le journal indique Invalid HTTP_HOST header. Le message complet est Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.. Django rejette la requête avant qu’elle n’atteigne votre code, car ALLOWED_HOSTS est vide ou ne contient pas le nom transmis par le proxy dans Host.
Les formulaires échouent avec Origin checking failed. La page indique que la vérification CSRF a échoué. Ce problème survient derrière un proxy qui termine TLS : l’application voit du HTTP simple, construit une origine http://, puis la compare à une requête reçue via https://. Définissez SECURE_PROXY_SSL_HEADER et CSRF_TRUSTED_ORIGINS, puis vérifiez que le proxy transmet bien X-Forwarded-Proto.
nginx renvoie immédiatement 502. Le journal d’erreurs indique la cause : connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) signifie que l’unité n’est pas en cours d’exécution, tandis que (13: Permission denied) signifie que le socket existe, mais que nginx ne peut pas l’ouvrir. Vérifiez le paramétrage de umask et du groupe.
L’interface d’administration n’a pas de style. collectstatic n’a pas été exécuté, ou le chemin alias ne correspond pas à STATIC_ROOT. Le journal d’accès affiche des erreurs 404 sous /static/admin/.
Les écritures échouent avec une faible charge. Le message database is locked provenant de SQLite indique que WAL est désactivé ou que le délai d’attente busy est trop court lorsque deux workers écrivent au même moment.
FAQ
Django est-il trop lourd pour un VPS de 1 Go ?
Non. Django avec un petit nombre de workers, nginx en frontal et SQLite en backend fonctionne correctement avec 1 Go. La mémoire devient juste lorsque vous ajoutez PostgreSQL avec ses paramètres par défaut, un cache, un worker d’arrière-plan et Docker sur le même serveur. Mesurez le proportional set size d’un worker, doublez cette valeur pour tenir compte des pics de requêtes, puis comparez le total à la mémoire restante après le système d’exploitation et la base de données.
Combien de workers gunicorn dois-je exécuter avec 1 vCPU ?
Commencez par trois et mesurez. Sur un petit VPS, la mémoire est généralement la contrainte principale. Divisez la RAM restante après le système d’exploitation et la base de données par deux fois le proportional set size d’un worker. Si vos vues attendent principalement une base de données ou une API upstream, utilisez la classe de workers gthread avec un petit nombre de workers et plusieurs threads par worker. Les threads partagent une seule copie chargée du framework et consomment beaucoup moins de mémoire que des processus supplémentaires.
Ai-je besoin de PostgreSQL ou SQLite suffit-il ?
SQLite convient à un serveur d’application unique avec un volume d’écritures modéré. Il retire aussi un daemon entier du budget mémoire. Activez la journalisation write ahead et définissez un délai d’attente busy timeout. Sinon, les écritures concurrentes échouent avec database is locked. Passez à PostgreSQL lorsque plusieurs machines doivent écrire, ou lorsque vous avez besoin d’une fonctionnalité absente de SQLite, comme la gestion d’écritures concurrentes intensives ou le contrôle d’accès par rôle.
Dois-je utiliser uvicorn à la place de gunicorn ?
Uniquement si vous avez des vues async et une opération réelle sur laquelle attendre. Flask est une application WSGI. Une vue async s’exécute donc dans une nouvelle boucle d’événements, au sein du thread du worker, et se termine avant le début de la requête suivante. Cela n’apporte aucune concurrence supplémentaire. Les vues async de Django ont besoin d’un serveur ASGI pour en tirer un bénéfice. Les versions récentes de uvicorn ont déplacé leur classe de workers gunicorn dans un package séparé. Consultez donc la documentation actuelle de uvicorn au lieu de recopier un ancien flag de classe de workers depuis un ancien tutoriel.
Pourquoi mon worker a-t-il disparu sans traceback ?
Un processus tué par l’out of memory killer du noyau reçoit SIGKILL et ne peut rien journaliser avant sa terminaison. Le journal de votre application s’arrête donc simplement. Gunicorn détecte l’interruption et affiche Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Confirmez-le avec dmesg -T | grep -i "killed process". La solution consiste à réduire le nombre de workers ou à créer un swapfile, afin qu’un pic de mémoire provoque une requête lente plutôt qu’un processus tué.