Django vs Flask en un VPS pequeño: memoria y workers
Compara el consumo real de Django y Flask en un VPS de 1 a 2 GB: memoria residente por worker de gunicorn y cuántos workers caben sin saturarlo.
Qué coste tienen Django y Flask en un VPS pequeño
Django frente a Flask en un VPS pequeño es, en primer lugar, una cuestión de memoria. Django carga su mapeador objeto-relacional (ORM), su sistema de migraciones y, si lo habilita, el sitio de administración en cada proceso de worker que inicia. Flask carga un router y un objeto de petición. En un equipo con 1 GB, esa diferencia determina cuántos workers caben, y el número de workers determina cuántas peticiones puede atender al mismo tiempo.
Ese coste sólo se atribuye a Django si nunca vuelve a implementar lo que proporciona. Una aplicación con cuentas de usuario, sesiones y un panel de administración necesita Django: la RAM de cada worker es el precio del código que no tiene que escribir. Una API JSON delante de un almacén de datos que ya ejecuta necesita Flask, porque ninguno de esos componentes se cargaría. Es una cuestión de adecuación. Las mediciones siguientes indican en qué caso encaja su aplicación.
¿Cuánta memoria usa un worker de 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
}
]Estas son cifras publicadas habituales para una aplicación hello world de cada tipo en Ubuntu 24.04 con Python 3.12, tres workers de gunicorn y preload activado. Considérelas un valor mínimo, porque sus propias importaciones se suman a ellas. Un worker de Django con el administrador habilitado muestra 96 MB residentes, mientras que su proporción de memoria es de 58 MB. La diferencia entre ambas cifras es el tema de la sección siguiente.
Repita la misma medición en su propio equipo.
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitleInstale setproctitle. Cuando está presente, gunicorn cambia los nombres de sus procesos a gunicorn: master [site1] y gunicorn: worker [site1]. Esto permite que los comandos siguientes encuentren los workers por nombre en lugar de tener que adivinarlos.
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')La columna rss es el tamaño del conjunto residente en kilobytes: cada página de memoria que el proceso mantiene actualmente en la RAM. Sumarlas entre los workers produce una cifra demasiado alta, porque un worker creado mediante fork comparte páginas con su proceso padre y con sus procesos hermanos. Por tanto, la misma página se cuenta varias veces. En su lugar, consulte al kernel el tamaño proporcional del conjunto (PSS), que divide cada página compartida entre los procesos que la asignan.
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; doneEjecútelo como el usuario propietario de los workers o con sudo. PSS es la columna que debe usar para calcular la memoria, porque PSS suma correctamente y RSS no.
Django ocupa más memoria por lo que hace django.setup(). Importa todas las entradas de INSTALLED_APPS, crea el registro de aplicaciones e instancia cada clase de modelo junto con un objeto de Python para cada campo. Añadir django.contrib.admin ejecuta el descubrimiento automático del administrador, que importa el módulo admin de cada aplicación y arrastra consigo las capas de formularios y plantillas. Un worker de Flask importa Werkzeug y Jinja2, y se detiene ahí.
Una salvedad importante: el framework suele ser una parte pequeña. Un worker que importa un SDK de cloud o cualquier biblioteca numérica puede cargar más memoria por eso que por Django. Mida su aplicación real antes de decidir que el problema es el framework.
Copy-on-write y por qué preload cambia la cantidad
El proceso maestro de Gunicorn crea los workers mediante fork. Justo después de fork(), el proceso hijo comparte todas las páginas de memoria con el padre, y el kernel copia una página sólo cuando uno de los dos procesos escribe en ella. Por tanto, que el registro de modelos de Django exista una vez o cuatro veces en el servidor depende de en qué lado del fork se haya creado.
Con preload_app desactivado, cada worker importa la aplicación después de que se haya creado mediante fork, por lo que cada uno crea su propia copia privada. Con esta opción activada, el proceso maestro importa la aplicación una vez y los workers heredan esas páginas.
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 no favorece el copy-on-write. La cabecera de cada objeto contiene un contador de referencias, y acceder a un objeto escribe en esa cabecera. Por eso, las páginas compartidas se copian de nuevo una a una mientras el recolector de basura recorre el heap. gc.freeze() mueve todo lo asignado hasta ese momento a una generación permanente que el recolector ya no visita, lo que mantiene compartidas más páginas. when_ready es el hook adecuado porque se ejecuta después de preload y antes de crear mediante fork el primer worker. Mida el PSS antes y después de añadirlo, porque el ahorro depende de cuánto estado de la aplicación se cree durante la importación.
Preload tiene un coste que sorprende durante un despliegue. systemctl reload envía HUP, y el comportamiento documentado de gunicorn ante HUP consiste en recargar su configuración e iniciar nuevos workers. Cuando la aplicación se carga previamente, no vuelve a importar el código, por lo que la nueva versión no se está ejecutando aunque los procesos worker sean nuevos. Use systemctl restart después de cambiar el código, o la secuencia USR2 y después WINCH si necesita que los workers antiguos terminen de atender las solicitudes primero.
¿Cuántos workers puede ejecutar realmente un VPS de 1 GB?
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
}
]Esas cifras corresponden a un sistema inactivo que no sirve nada. Quedan unos 550 MB para los workers de la aplicación, y eso es antes de que llegue la primera petición.
Ahora divida, y hágalo con un margen pesimista. Una petición consume memoria mientras se ejecuta: una consulta que carga varios miles de filas y después renderiza una plantilla. El pico por worker suele acercarse al doble de la cifra en inactividad, así que haga los cálculos con el doble. Django con el admin, a 58 MB en inactividad, permite ejecutar cuatro workers en este sistema. Flask con SQLAlchemy, a 38 MB, permite ejecutar siete.
La sugerencia de Gunicorn, (2 x cores) + 1, presupone que el recurso escaso es la CPU y que la RAM no lo es. En un VPS pequeño ocurre lo contrario. Un vCPU compartido también proporciona menos trabajo que un núcleo completo cuando el host está ocupado. Conviene entenderlo antes de culpar al código: el tiempo de CPU robado por un vecino ruidoso aparece en top como la cifra st.
Si las vistas pasan la mayor parte del tiempo esperando a una base de datos o a una API ascendente, aquí los threads son mejores que los procesos. --worker-class gthread --workers 2 --threads 4 permite ocho peticiones simultáneas con el coste de memoria de dos workers, porque los threads comparten una única copia cargada del intérprete y del framework. El bloqueo global del intérprete implica que los threads no ayudan a una vista que consume CPU.
Asigne swap al sistema. Un VPS de 1 GB sin swap convierte un pico de memoria en un proceso terminado, mientras que un archivo de swap convierte el mismo pico en una petición 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 --systemDespués, limite la propia aplicación. MemoryMax=600M en la unidad de gunicorn hace que el kernel recupere la memoria del cgroup de la aplicación en lugar de elegir una víctima en todo el sistema. Así, una petición descontrolada no le hará perder la sesión SSH.
Comportamiento del arranque en frío y de los reinicios
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
}
]El coste de arranque se paga dos veces: en cada despliegue y en cada reinicio automático después de un fallo. Una aplicación Flask mínima está lista en unos 90 ms, y Django con el administrador habilitado tarda unos 720 ms en la misma vCPU compartida. Ambas son cifras publicadas habituales. Mida su propio entorno, porque las dependencias son el factor dominante.
cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20Las últimas líneas muestran las importaciones más lentas con sus microsegundos acumulados. Para Flask, ejecute el mismo indicador contra su módulo: python -X importtime -c "import app".
Con preload activado, el proceso maestro asume ese coste una vez y cada worker bifurcado se inicia de inmediato. Con preload desactivado, cada worker asume ese coste, y timeout de gunicorn cubre tanto el arranque como las peticiones. Si un worker no ha informado de su estado en timeout segundos, se termina y se reemplaza. Por tanto, una aplicación pesada en una vCPU compartida lenta puede quedar atrapada en un bucle de reinicios sin atender ninguna petición. El registro muestra lo siguiente:
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)Las migraciones deben pertenecer a la unidad, no al código de arranque de la aplicación. ExecStartPre se ejecuta una vez antes de que exista cualquier worker. Incluir migrate dentro de la aplicación hace que tres workers compitan por el mismo bloqueo del esquema.
La estructura del despliegue es prácticamente la misma
Gestor de procesos
Ambos frameworks se ejecutan con gunicorn, y gunicorn se ejecuta bajo 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 crea /run/site1 al iniciar y lo elimina al detenerse, por lo que la ruta del socket siempre existe con el propietario correcto. La línea umask = 0o007 en la configuración de gunicorn hace que ese socket pueda escribirse desde el grupo www-data, que es la forma en que nginx accede a él.
La unidad de Flask es el mismo archivo con una línea modificada: ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app, y sin ExecStartPre. El argumento app:app sigue el formato módulo y luego callable, por lo que el error Failed to find attribute 'app' in 'app'. significa que el módulo no define una variable con ese nombre. El trabajo programado sigue el mismo patrón, y un temporizador de systemd sustituye a cron para un comando de gestión de Django sin añadir una cola de tareas a un servidor de este tamaño.
Archivos estáticos
Django con DEBUG = False no sirve ningún archivo estático. Configure STATIC_ROOT, ejecute python manage.py collectstatic y apunte el servidor web al directorio de salida. Si omite este paso, el panel de administración se carga sin estilos y el registro se llena de Not Found: /static/admin/css/base.css.
Hay dos formas razonables de servirlos. Un bloque alias de nginx no consume recursos de la aplicación. WhiteNoise, añadido como middleware, sirve los archivos desde el worker y evita el bloque de nginx, a cambio de consumir algo de tiempo del worker por archivo. Flask sirve su propia carpeta static/ en desarrollo. En producción, apunte el proxy a esa carpeta por el mismo motivo.
Proxy inverso
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;
}
}Detrás de cualquier proxy, debe indicar a Django que la solicitud original usaba HTTPS. De lo contrario, sus comprobaciones de falsificación de solicitudes entre sitios (CSRF) rechazan sus propios formularios.
ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")Si el servidor ya ejecuta contenedores, Traefik delante de varias aplicaciones de Docker Compose realiza el mismo trabajo mediante etiquetas en el contenedor, en lugar de usar un archivo por sitio.
Qué base de datos elegir
SQLite es realmente adecuada para un único servidor de aplicaciones con una tasa de escritura de unos pocos eventos por segundo. Además, elimina un daemon completo del presupuesto de memoria. Active el registro de escritura anticipada (WAL) y establezca un tiempo de espera para operaciones ocupadas en el driver.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL;",
},
}
}Sin esas dos opciones, se encontrará con django.db.utils.OperationalError: database is locked en cuanto dos workers escriban a la vez, porque el modo de diario predeterminado bloquea las lecturas durante una escritura y el tiempo de espera predeterminado abandona la operación casi de inmediato. La explicación más extensa, incluido el punto en que SQLite deja de ser la opción adecuada, está en ejecutar SQLite en producción en un VPS.
PostgreSQL en el mismo servidor de 1 GB consume los 120 MB incluidos en el presupuesto anterior, además de un proceso backend por cada conexión persistente. El CONN_MAX_AGE de Django mantiene una conexión abierta por worker, por lo que cuatro workers implican cuatro backends. Normalmente es una buena compensación. Cuéntelos antes de establecer el número de workers. Si prefiere mantener la base de datos en un contenedor junto a la aplicación, ejecutar Docker en un VPS ofrece la misma compensación con un mínimo superior, porque el daemon y cada contenedor añaden una sobrecarga relevante en un servidor de este tamaño.
Lo que aportan las baterías y lo que cuestan
Los megabytes adicionales de Django corresponden a componentes que ya existen y funcionan juntos: el ORM con migraciones, el sistema de sesiones y autenticación, el modelo de permisos, la capa de formularios con protección CSRF, el motor de plantillas, los comandos de administración y el admin. El admin es el componente que se suele infravalorar. Es un editor de base de datos funcional para los modelos, con búsqueda y filtros, por una línea en INSTALLED_APPS.
Flask es la imagen opuesta. Incluye el enrutamiento, un objeto de solicitud, plantillas Jinja2 y un objeto de configuración. Todo lo demás es una decisión que debe tomar. Esto aporta un valor real cuando la aplicación es pequeña, porque no se carga ningún ORM si nunca se importa uno.
El problema aparece en el punto intermedio. Si añade SQLAlchemy para los modelos, Alembic para las migraciones, Flask-Login para las sesiones, Flask-WTF para los formularios y CSRF, y una extensión de administración para el back office, habrá ensamblado algo con el perfil de memoria de Django y sin su coherencia. Cada componente tiene su propio ciclo de versiones y su propia opinión sobre cómo debe integrarse la aplicación. En ese punto, Django es la opción más económica, tanto en RAM como en las horas que dedica a las actualizaciones.
Django frente a Flask: regla de decisión
Use Django cuando la aplicación tenga cuentas de usuario, contenido editable, un esquema que vaya a cambiar con frecuencia y un panel de administración que alguien vaya a utilizar. Use Flask cuando la aplicación sea una interfaz JSON sobre un almacén de datos existente o un receptor de webhooks sin HTML.
El criterio de desempate es una lista escrita. Anote todos los paquetes que instalaría en Flask para obtener las funciones que necesita. Si la lista incluye un ORM y una herramienta de migraciones, ya ha elegido Django y está pagando un coste adicional para llegar allí más despacio.
Un caso favorece realmente a Flask en hardware con pocos recursos: varios servicios pequeños en un solo equipo. Cada servicio Flask es un proceso independiente y económico bajo su propia unidad. Tres sitios Django en un VPS de 1 GB significan tener tres copias del framework residentes al mismo tiempo, y el cálculo anterior deja de funcionar. Si el resultado sigue siendo insuficiente, contratar un plan superior suele ser la solución más adecuada, y cuánto cuesta realmente un VPS al mes es una conversación más breve que reescribir una aplicación que funciona.
Modos de fallo y mensajes que verá
Los workers desaparecen y vuelven a aparecer. Gunicorn muestra [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Lo muestra tanto cuando finaliza un worker que no ha enviado su señal de actividad dentro del tiempo de espera como cuando el kernel finaliza el proceso. Distinga ambos casos con dmesg -T | grep -i "killed process". Una línea allí indica falta de memoria. Reduzca el número de workers o añada swap.
Todas las páginas devuelven 400 y el registro muestra Invalid HTTP_HOST header. El mensaje completo es Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.. Django rechaza la petición antes de que llegue a su código porque ALLOWED_HOSTS está vacío o no incluye el nombre que el proxy envió en Host.
Los formularios fallan con Origin checking failed. La página indica que falló la verificación CSRF. Esto ocurre detrás de un proxy con terminación TLS: la aplicación ve HTTP sin cifrar, construye un origen http:// y lo compara con una petición que llegó mediante https://. Configure SECURE_PROXY_SSL_HEADER y CSRF_TRUSTED_ORIGINS, y confirme que el proxy realmente envía X-Forwarded-Proto.
nginx devuelve 502 inmediatamente. El registro de errores indica la causa: connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) significa que la unidad no está en ejecución, y (13: Permission denied) significa que el socket existe, pero nginx no puede abrirlo porque la configuración de propietario y grupo es incorrecta: umask.
El panel de administración no tiene estilos. collectstatic no se ha ejecutado, o la ruta alias no coincide con STATIC_ROOT. El registro de acceso muestra respuestas 404 bajo /static/admin/.
Las escrituras fallan con poca carga. database is locked de SQLite indica que WAL está desactivado o que el tiempo de espera de ocupación es demasiado corto para dos workers que escriben al mismo tiempo.
FAQ
¿Django consume demasiados recursos en un VPS de 1 GB?
No. Django con un número reducido de workers, nginx delante y SQLite detrás funciona correctamente con 1 GB. La memoria empieza a escasear cuando añade PostgreSQL con su configuración predeterminada, una caché, un worker en segundo plano y Docker en el mismo servidor. Mida el proportional set size de un worker, duplíquelo para tener en cuenta los picos de peticiones y compare el total con la memoria disponible después de descontar el sistema operativo y la base de datos.
¿Cuántos workers de gunicorn debo ejecutar en 1 vCPU?
Empiece con tres y mida. En un VPS pequeño, la memoria suele ser el factor limitante. Divida la RAM disponible después de descontar el sistema operativo y la base de datos entre el doble del proportional set size de un worker. Si sus vistas pasan la mayor parte del tiempo esperando a una base de datos o a una API upstream, cambie a la clase de worker gthread con un número reducido de workers y varios threads por worker. Los threads comparten una única copia cargada del framework y consumen mucha menos memoria que los procesos adicionales.
¿Necesito PostgreSQL o SQLite es suficiente?
SQLite es suficiente para un servidor de aplicaciones con una tasa de escritura moderada y elimina un daemon completo del presupuesto de memoria. Habilite el registro write ahead y establezca un tiempo de espera para operaciones ocupadas. De lo contrario, las escrituras simultáneas fallan con database is locked. Cambie a PostgreSQL cuando más de una máquina tenga que escribir o cuando necesite una función que SQLite no ofrece, como compatibilidad con muchas escrituras simultáneas o control de acceso por rol.
¿Debo ejecutar uvicorn en lugar de gunicorn?
Sólo si tiene vistas asíncronas y algo real que deba esperar. Flask es una aplicación WSGI, por lo que una vista asíncrona se ejecuta en un nuevo event loop dentro del thread del worker y termina antes de que empiece la siguiente petición. Esto no proporciona más concurrencia. Las vistas asíncronas de Django necesitan un servidor ASGI para obtener alguna ventaja. Las versiones recientes de uvicorn trasladaron su clase de worker de gunicorn a un paquete independiente. Consulte la documentación actual de uvicorn en lugar de copiar un flag de clase de worker antiguo de un tutorial anterior.
¿Por qué desapareció mi worker sin un traceback?
Un proceso que el kernel termina mediante el out of memory killer recibe SIGKILL y no puede registrar nada al finalizar. Por eso, el registro de la aplicación simplemente se detiene. Gunicorn detecta la interrupción e imprime Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Confírmelo con dmesg -T | grep -i "killed process". La solución consiste en reducir el número de workers o crear un archivo de swap para que un pico de memoria provoque una petición lenta en lugar de terminar el proceso.