SSD Nodes Learn 🎉 VPS da $5.50/mese
Guide Matt ConnorDi Matt Connor · Aggiornato 2026-08-13

Django o Flask: quale conviene su un piccolo VPS?

Confronto reale tra Django e Flask su VPS da 1 o 2 GB: RAM residente per worker Gunicorn e numero di worker gestibili senza saturare il server.

Costo di Django e Flask su un piccolo VPS

Confrontare Django e Flask su un piccolo VPS significa prima di tutto valutare il consumo di memoria. Django carica il suo object relational mapper (ORM), il sistema di migrazione e, se lo abiliti, il sito di amministrazione in ogni processo worker avviato. Flask carica un router e un oggetto request. Su un server con 1 GB, questa differenza determina quanti worker possono essere eseguiti; il numero di worker determina quante richieste puoi gestire contemporaneamente.

Questo costo incide davvero su Django solo se non utilizzi ciò che il framework fornisce. Un’applicazione con account utente, sessioni e pannello di amministrazione è adatta a Django: la RAM per worker è il costo del codice che non devi scrivere. Un’API JSON davanti a un datastore già in esecuzione è adatta a Flask, perché non verrebbe mai caricato nessuno dei componenti inclusi. È una questione di adeguatezza al caso d’uso. Le misurazioni riportate di seguito indicano a quale dei due casi appartiene la tua applicazione.

Quanta memoria usa un worker di gunicorn?

ChartMemory per gunicorn worker, minimal app, three workers with preload on
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
  }
]

Questi sono valori pubblicati tipici per un'applicazione hello world di ciascun tipo su Ubuntu 24.04 con Python 3.12, tre worker di gunicorn e preload attivo. Considerali valori minimi, perché ai relativi import si aggiunge il codice importato dalla tua applicazione. Un worker Django con l'interfaccia di amministrazione abilitata utilizza 96 MB di memoria residente, mentre la sua quota proporzionale di memoria è pari a 58 MB. La differenza tra questi due valori è l'argomento della sezione successiva.

Esegui la stessa misurazione sul tuo server.

sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitle

Installa setproctitle. Quando è disponibile, gunicorn rinomina i propri processi in gunicorn: master [site1] e gunicorn: worker [site1]. In questo modo i comandi successivi possono individuare i worker in base al nome, senza doverli identificare con stime.

pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')

La colonna rss indica la dimensione del resident set in kilobyte: comprende ogni pagina di memoria che il processo mantiene attualmente in RAM. Sommare questo valore tra i worker produce un risultato troppo alto, perché un worker creato con fork condivide le pagine con il processo padre e con i processi fratelli. Di conseguenza, la stessa pagina viene conteggiata più volte. Chiedi invece al kernel la proportional set size (PSS), che suddivide ogni pagina condivisa tra i processi che la mappano.

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; done

Esegui il comando con l'utente proprietario dei worker oppure con sudo. Per la pianificazione della memoria devi usare la PSS, perché la PSS esegue correttamente la somma, mentre la RSS no.

Django è più pesante per il lavoro svolto da django.setup(). Importa ogni voce di INSTALLED_APPS, crea il registro delle applicazioni e istanzia ogni classe di modello, oltre a un oggetto Python per ogni campo del modello. L'aggiunta di django.contrib.admin esegue l'autodiscovery dell'interfaccia di amministrazione. Questa operazione importa il modulo admin di ogni applicazione e carica anche i livelli dei form e dei template. Un worker Flask importa Werkzeug e Jinja2 e si ferma.

Una precisazione importante: spesso il framework costituisce solo una parte ridotta del consumo. Un worker che importa un SDK cloud o una libreria per calcoli numerici può occupare più memoria di uno che importa Django. Misura la tua applicazione reale prima di concludere che il problema sia il framework.

Copy on write e perché preload modifica il numero

Il processo master di Gunicorn esegue il fork dei worker. Subito dopo fork(), il processo figlio condivide ogni pagina di memoria con il processo padre e il kernel copia una pagina solo quando uno dei due processi vi scrive. Di conseguenza, il fatto che il registro dei modelli Django esista una sola volta o quattro volte sul server dipende da quale processo lo ha creato prima del fork.

Con preload_app disattivato, ogni worker importa l'applicazione dopo il fork, quindi ciascuno crea una propria copia privata. Quando è attivato, il processo master importa l'applicazione una volta sola e i worker ereditano quelle pagine.

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 è in contrasto con il meccanismo copy on write. L'header di ogni oggetto contiene un contatore dei riferimenti e l'accesso a un oggetto modifica quell'header. Di conseguenza, le pagine condivise vengono copiate una alla volta mentre il garbage collector attraversa l'heap. gc.freeze() sposta tutto ciò che è stato allocato fino a quel momento in una generazione permanente che il garbage collector non visita più, mantenendo condivise più pagine. when_ready è l'hook corretto perché viene eseguito dopo il preload e prima del fork del primo worker. Misurate il PSS prima e dopo averlo aggiunto, perché il risparmio dipende dalla quantità di stato dell'applicazione creato durante l'importazione.

Il preload ha un costo che sorprende durante il deploy. systemctl reload invia HUP e, secondo il comportamento documentato di gunicorn, in caso di HUP ricarica la configurazione e avvia nuovi worker. Quando l'applicazione è stata precaricata, gunicorn non importa nuovamente il codice. Di conseguenza, la nuova release non è in esecuzione anche se i processi worker sono nuovi. Usate systemctl restart dopo una modifica al codice oppure la sequenza USR2 quindi WINCH se dovete prima consentire ai worker precedenti di esaurire le richieste in corso.

Quanti worker può eseguire realisticamente un VPS da 1 GB?

ChartWhere a 1 GB VPS goes, typical idle figures before any traffic
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
  }
]

Questi sono valori a riposo su un server che non gestisce alcuna richiesta. Restano circa 550 MB per i worker applicativi, prima ancora che arrivi la prima richiesta.

Ora dividi, applicando un margine prudenziale. Una richiesta consuma memoria mentre viene elaborata: ad esempio, un queryset che carica alcune migliaia di righe seguito dal rendering di un template. Il picco per worker è spesso quasi il doppio del valore a riposo, quindi considera il doppio nel dimensionamento. Django con l'admin a 58 MB a riposo consente di eseguire quattro worker su questo server. Flask con SQLAlchemy a 38 MB consente di eseguirne sette.

Il suggerimento di Gunicorn, (2 x cores) + 1, presuppone che la risorsa limitante sia la CPU e che la RAM non lo sia. Su un VPS di piccole dimensioni è il contrario. Un singolo vCPU condiviso fornisce inoltre meno della capacità di elaborazione di un core quando l'host è sotto carico. È un aspetto da comprendere prima di attribuire il problema al codice: il CPU steal time causato da un vicino rumoroso appare in top come valore st.

Se le viste attendono soprattutto un database o un'API upstream, in questo caso i thread sono preferibili ai processi. --worker-class gthread --workers 2 --threads 4 consente otto richieste simultanee con il consumo di memoria di due worker, perché i thread condividono un'unica copia caricata dell'interprete e del framework. Il global interpreter lock impedisce ai thread di migliorare le prestazioni di una vista che utilizza intensivamente la CPU.

Configura la swap sul server. Un VPS da 1 GB senza swap trasforma un picco di memoria in un processo terminato, mentre uno swapfile trasforma lo stesso picco in una richiesta lenta.

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 --system

Limita quindi anche l'applicazione. MemoryMax=600M nell'unità gunicorn fa sì che il kernel recuperi la memoria dal cgroup dell'applicazione invece di scegliere un processo da terminare in tutto il server. In questo modo una richiesta fuori controllo non ti fa perdere la sessione SSH.

Comportamento all'avvio e dopo il riavvio

ChartImport to ready, minimal app, one shared vCPU
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
  }
]

Il costo dell'avvio viene sostenuto due volte: a ogni deploy e a ogni riavvio automatico dopo un crash. Un'app Flask minimale è pronta in circa 90 ms, mentre Django con l'area di amministrazione abilitata richiede circa 720 ms sulla stessa vCPU condivisa. Sono valori pubblicati tipici. Misurate il vostro ambiente, perché sono soprattutto le dipendenze a determinare il risultato.

cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20

Le ultime righe elencano le importazioni più lente con i microsecondi cumulativi. Per Flask, eseguite lo stesso flag sul vostro modulo: python -X importtime -c "import app".

Con il preload attivo, il processo master sostiene questo costo una sola volta e ogni worker creato con fork si avvia immediatamente. Con il preload disattivato, ogni worker sostiene il costo e il timeout di gunicorn copre sia l'avvio sia la gestione delle richieste. Un worker che non ha completato il check-in entro timeout secondi viene terminato e sostituito. Di conseguenza, un'applicazione pesante su una vCPU condivisa lenta può entrare in un ciclo di riavvii senza mai gestire una richiesta. Il log mostra:

[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)

Le migrazioni devono appartenere all'unità, non al codice di avvio dell'applicazione. ExecStartPre viene eseguito una volta prima della creazione dei worker. Inserire migrate nell'applicazione fa sì che tre worker competano sullo stesso lock dello schema.

La struttura della distribuzione è quasi la stessa

Gestore dei processi

Entrambi i framework vengono eseguiti con gunicorn, che a sua volta viene eseguito tramite 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.target

RuntimeDirectory=site1 crea /run/site1 all'avvio e lo rimuove all'arresto, quindi il percorso del socket esiste sempre con il proprietario corretto. La riga umask = 0o007 nella configurazione di gunicorn rende scrivibile il socket dal gruppo www-data, consentendo a nginx di raggiungerlo.

L'unità Flask è lo stesso file con una riga modificata: ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app, senza ExecStartPre. L'argomento app:app usa prima il modulo e poi il callable, quindi l'errore Failed to find attribute 'app' in 'app'. indica che il modulo non definisce una variabile con quel nome. Anche le attività pianificate seguono lo stesso schema e un timer systemd sostituisce cron per un comando di gestione Django senza aggiungere una coda di attività a un server di queste dimensioni.

File statici

Django con DEBUG = False non distribuisce alcun file statico. Imposta STATIC_ROOT, esegui python manage.py collectstatic e configura il server web affinché punti alla directory di output. Se salti questo passaggio, l'interfaccia di amministrazione viene caricata senza stile e il log si riempie di Not Found: /static/admin/css/base.css.

Esistono due modi ragionevoli per distribuirli. Un blocco nginx alias non consuma risorse dell'applicazione. WhiteNoise, aggiunto come middleware, distribuisce i file dal worker ed evita il blocco nginx, consumando però una piccola quantità di tempo del worker per ogni file. Flask distribuisce la propria cartella static/ in sviluppo; in produzione, il proxy deve puntare alla cartella per lo stesso motivo.

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;
    }
}

Dietro qualsiasi proxy, Django deve sapere che la richiesta originale usava HTTPS. In caso contrario, i controlli contro la falsificazione delle richieste tra siti (CSRF) rifiutano i moduli dell'applicazione.

ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Se il server esegue già dei container, Traefik davanti a più applicazioni Docker Compose svolge lo stesso compito usando le label del container invece di un file per ogni sito.

Quale database

SQLite è adatto per un singolo server applicativo con una frequenza di scrittura di pochi eventi al secondo e rimuove un demone dal budget di memoria. Abilita il logging write-ahead (WAL) e imposta un timeout di attesa per il driver.

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
        "OPTIONS": {
            "timeout": 20,
            "init_command": "PRAGMA journal_mode=WAL;",
        },
    }
}

Senza queste due opzioni, incontrerai django.db.utils.OperationalError: database is locked appena due worker tenteranno di scrivere contemporaneamente. La modalità journal predefinita blocca i lettori durante una scrittura e il timeout predefinito termina quasi immediatamente. L'analisi completa, incluso il punto in cui SQLite non è più la scelta corretta, è disponibile in eseguire SQLite in produzione su un VPS.

PostgreSQL sullo stesso server da 1 GB utilizza i 120 MB indicati nel budget precedente, oltre a un processo backend per ogni connessione persistente. CONN_MAX_AGE di Django mantiene aperta una connessione per ogni worker, quindi quattro worker corrispondono a quattro backend. Di solito è un compromesso valido. Conta comunque le connessioni prima di impostare il numero di worker. Se preferisci mantenere il database in un container accanto all'applicazione, eseguire Docker su un VPS comporta lo stesso compromesso con un limite minimo più elevato, perché il demone e ogni container aggiungono un overhead rilevante su un server di queste dimensioni.

Cosa offrono le batterie e quanto costano

I megabyte aggiuntivi di Django corrispondono a componenti che esistono già e funzionano già insieme: l'ORM con le migrazioni, il sistema di sessioni e autenticazione, il modello delle autorizzazioni, il livello dei form con la protezione CSRF, il motore dei template, i comandi di gestione e l'admin. L'admin è il componente che viene spesso sottovalutato. È un editor funzionante del database per i tuoi modelli, con ricerca e filtri, per una riga in INSTALLED_APPS.

Flask è l'esatto opposto. Offre il routing, un oggetto request, i template Jinja2 e un oggetto di configurazione. Tutto il resto è una scelta che devi fare tu. Per un'applicazione piccola questo è un vantaggio concreto, perché non viene caricato alcun ORM se non ne importi uno.

Il problema nasce nella zona intermedia. Se aggiungi SQLAlchemy per i modelli, Alembic per le migrazioni, Flask-Login per le sessioni, Flask-WTF per i form e la protezione CSRF e un'estensione admin per il back office, ottieni un insieme con il profilo di memoria di Django e senza la sua coerenza. Ogni componente ha il proprio ciclo di rilascio e il proprio modo di organizzare l'applicazione. A questo punto Django è la scelta più economica, sia in termini di RAM sia per le ore necessarie agli aggiornamenti.

Django e Flask: la regola decisionale

Usa Django quando l’applicazione gestisce account, contenuti modificabili, uno schema destinato a cambiare e un back office che verrà effettivamente utilizzato. Usa Flask quando l’applicazione espone un’interfaccia JSON sopra un datastore già esistente oppure riceve webhook e non contiene HTML.

Il criterio decisivo è una lista scritta. Elenca ogni pacchetto che installeresti in Flask per ottenere le funzionalità necessarie. Se la lista contiene un ORM e uno strumento per le migrazioni, hai già scelto Django e stai pagando un costo aggiuntivo per arrivare lentamente allo stesso risultato.

Un caso favorisce realmente Flask su hardware limitato: diversi servizi piccoli sullo stesso host. Ogni servizio Flask è un processo indipendente e leggero, gestito dalla propria unit. Tre siti Django su un VPS da 1 GB mantengono in memoria tre copie del framework contemporaneamente, quindi il calcolo precedente non è più valido. Se il risultato continua a essere insufficiente, spesso la soluzione corretta è scegliere un piano più grande; quanto costa davvero un VPS al mese è una conversazione più breve che riscrivere un’applicazione funzionante.

Cause di errore e stringhe visualizzate

I worker scompaiono e ricompaiono. Gunicorn stampa [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Lo stampa sia quando termina un worker che non ha inviato il segnale di attività entro il timeout, sia quando il kernel termina il processo. Per distinguere i due casi, usa dmesg -T | grep -i "killed process". Una riga in questo output indica pressione sulla memoria: riduci il numero di worker oppure aggiungi lo swap.

Ogni pagina restituisce 400 e il log contiene Invalid HTTP_HOST header. Il messaggio completo è Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.. Django rifiuta la richiesta prima che raggiunga il tuo codice perché ALLOWED_HOSTS è vuoto oppure non include il nome passato dal proxy in Host.

I moduli restituiscono Origin checking failed. La pagina indica che la verifica CSRF non è riuscita. Il problema si verifica dietro un proxy con terminazione TLS: l'applicazione vede HTTP semplice, costruisce un'origine http:// e la confronta con una richiesta arrivata tramite https://. Imposta SECURE_PROXY_SSL_HEADER e CSRF_TRUSTED_ORIGINS e verifica che il proxy invii effettivamente X-Forwarded-Proto.

nginx restituisce immediatamente 502. Il log degli errori indica la causa: connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) significa che l'unità non è in esecuzione, mentre (13: Permission denied) significa che il socket esiste ma nginx non può aprirlo. Il problema riguarda le impostazioni di umask e del gruppo.

L'interfaccia di amministrazione non ha gli stili. collectstatic non è stato eseguito oppure il percorso alias non corrisponde a STATIC_ROOT. Il log degli accessi mostra risposte 404 sotto /static/admin/.

Le scritture falliscono anche con un carico ridotto. Il messaggio database is locked di SQLite indica che WAL è disattivato oppure che il timeout di attesa è troppo breve quando 2 worker scrivono nello stesso momento.

FAQ

Django è troppo pesante per un VPS da 1 GB?

No. Django con pochi worker, nginx davanti e SQLite come database funziona senza problemi su 1 GB. La memoria diventa insufficiente quando aggiungi PostgreSQL con le impostazioni predefinite, una cache, un worker in background e Docker sullo stesso server. Misura il proportional set size di un worker, raddoppialo per tenere conto dei picchi di richieste e confronta il totale con la memoria disponibile dopo il sistema operativo e il database.

Quanti worker gunicorn devo eseguire su 1 vCPU?

Inizia con 3 e misura. Su un VPS di piccole dimensioni, il vincolo principale è normalmente la memoria. Dividi la RAM disponibile dopo il sistema operativo e il database per il doppio del proportional set size di un worker. Se le tue view attendono soprattutto un database o un'API upstream, passa alla classe di worker gthread con pochi worker e diversi thread per ciascuno. I thread condividono una singola copia caricata del framework e consumano molta meno memoria rispetto a processi aggiuntivi.

Ho bisogno di PostgreSQL o SQLite è sufficiente?

SQLite è sufficiente per un singolo server applicativo con un numero moderato di scritture e rimuove un demone dal budget di memoria. Abilita il write-ahead logging e imposta un timeout per le operazioni occupate; in caso contrario, le scritture concorrenti falliscono con database is locked. Passa a PostgreSQL quando più di una macchina deve eseguire scritture oppure quando ti serve una funzionalità che SQLite non offre, ad esempio il supporto a più writer concorrenti con carico elevato o il controllo degli accessi per ruolo.

Devo eseguire uvicorn invece di gunicorn?

Solo se hai view asincrone e un'attività reale su cui attendere. Flask è un'applicazione WSGI, quindi una view asincrona viene eseguita in un nuovo event loop all'interno del thread del worker e termina prima dell'avvio della richiesta successiva. Non ottieni quindi maggiore concorrenza. Le view asincrone di Django richiedono un server ASGI per ottenere un vantaggio. Le versioni recenti di uvicorn hanno spostato la classe di worker per gunicorn in un pacchetto separato. Consulta quindi la documentazione corrente di uvicorn invece di copiare da un vecchio tutorial il flag della classe di worker.

Perché il mio worker è scomparso senza un traceback?

Un processo terminato dal kernel tramite l'out-of-memory killer riceve SIGKILL e non può registrare messaggi durante la terminazione. Il log dell'applicazione quindi si interrompe semplicemente. Gunicorn rileva l'interruzione e stampa Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Conferma il problema con dmesg -T | grep -i "killed process". La soluzione consiste nel ridurre il numero di worker oppure nel creare un file di swap, in modo che un picco di memoria provochi una richiesta lenta anziché la terminazione del processo.

#django#flask#python#gunicorn#deployment#vps