SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

Django 与 Flask 在小型 VPS 上的内存占用

了解 Django 与 Flask 在 1 至 2 GB VPS 上的真实成本:比较 Gunicorn worker 的常驻内存,并确定小型服务器实际能运行多少个 worker。

小型 VPS 上 Django 和 Flask 的资源成本

在小型 VPS 上比较 Django 和 Flask,首先要考虑内存。Django 会将对象关系映射器(ORM)、迁移机制,以及启用后包含的管理站点加载到每个启动的 worker 进程中。Flask 只加载路由器和请求对象。在 1 GB 的服务器上,这一差异决定了可以运行多少个 worker,而 worker 数量决定了可以同时处理多少个请求。

只有在您不重新实现 Django 提供的功能时,这部分成本才算是 Django 的额外开销。包含用户账户、会话和管理面板的应用适合使用 Django:每个 worker 占用的内存,就是您无需自行编写代码所付出的代价。面向已有数据存储的 JSON API 适合使用 Flask,因为那些完整功能根本不会被加载。这是适配性问题。下面的测量结果会告诉您应用属于哪一类。

一个 gunicorn worker 占用多少内存?

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

以下是在 Ubuntu 24.04、Python 3.12 环境中,为每种应用形态启动 3 个 gunicorn worker 并启用 preload 后,hello world 应用的典型公开数据。这些数据只能作为下限,因为您自己的导入内容还会增加内存占用。启用 Django admin 的 worker,其常驻内存为 96 MB,而按比例分摊后的内存占用为 58 MB。这两个数字之间的差异,就是下一节要讨论的全部内容。

在您自己的服务器上执行相同的测量。

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

安装 setproctitle。安装后,gunicorn 会将进程重命名为 gunicorn: master [site1]gunicorn: worker [site1]。这样,后续命令就能按名称查找 worker,而不必猜测进程。

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

rss 列表示常驻集大小,单位为 KB,即进程当前保留在 RAM 中的所有内存页。将所有 worker 的该列相加会得到一个偏高的数字,因为 fork 出来的 worker 会与父进程及其他 worker 共享内存页,所以同一个页面会被重复计算多次。应改为让内核提供按比例分摊的集大小(PSS)。PSS 会根据映射同一共享页面的进程数量分摊该页面。

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

使用拥有这些 worker 的用户运行该命令,或使用 sudo。应根据 PSS 制定内存预算,因为 PSS 可以正确汇总共享内存,而 RSS 不能。

Django 占用更多内存,是因为 django.setup() 的工作方式。它会导入 INSTALLED_APPS 中的每个条目,构建应用注册表,并实例化每个模型类及其每个字段对应的 Python 对象。添加 django.contrib.admin 后,Django 会执行 admin 自动发现,导入每个应用的 admin 模块,并随之加载表单和模板层。Flask worker 会导入 Werkzeug 和 Jinja2,然后停止继续加载。

需要说明的是:框架通常只占较小的一部分。导入云 SDK 或任何数值计算库的 worker,往往比 Django worker 携带更多这类依赖。请先测量真实应用,再判断问题是否出在框架上。

写时复制,以及 preload 为什么会改变数量

Gunicorn 的 master 进程会 fork 工作进程。紧接着 fork(),子进程与父进程共享所有内存页;只有任一方写入某个页面时,内核才会复制该页面。因此,Django 的模型注册表在服务器上存在一份还是四份,取决于它是在 fork 前还是 fork 后构建的。

关闭 preload_app 时,每个工作进程都会在 fork 后导入应用,因此每个进程都会创建自己的私有副本。启用该选项后,master 会导入一次应用,工作进程继承这些页面。

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 的工作方式不利于写时复制。每个对象头都保存引用计数,访问对象会写入该对象头,因此垃圾回收器遍历堆时,共享页面会逐个被复制。gc.freeze() 会将目前已分配的所有内容移入永久代,垃圾回收器不再访问该代,因此可以让更多页面继续保持共享。when_ready 是正确的钩子,因为它在 preload 完成后、首次 fork 工作进程前运行。添加该设置前后都应测量 PSS,因为节省的内存取决于应用中有多少状态是在导入期间创建的。

preload 有一个部署时容易被忽略的代价。systemctl reload 会发送 HUP,而 gunicorn 在 HUP 时的文档行为是重新加载配置并启动新的工作进程。应用启用 preload 后,它不会重新导入代码,因此即使工作进程是新的,运行的仍不是新版本。代码变更后使用 systemctl restart;如果需要先让旧工作进程处理完现有请求,再使用 USR2,然后执行 WINCH

1 GB VPS 实际上能运行多少个 worker?

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

这些是服务器没有提供任何服务时的空闲数据。留给应用 worker 的内存约为 550 MB,而且这还没有计入第一个请求到达后的开销。

接下来进行计算,并按保守值计算。请求运行期间会占用内存:例如加载几千行数据的 queryset,然后执行模板渲染。每个 worker 的峰值内存通常接近空闲值的两倍,因此应按两倍进行预算。Django 加载 admin 后,空闲内存为 58 MB,在此服务器上可运行 4 个 worker。Flask 使用 SQLAlchemy 时为 38 MB,可运行 7 个 worker。

Gunicorn 的 (2 x cores) + 1 建议假设 CPU 是稀缺资源,而 RAM 不是。在小型 VPS 上,实际情况正好相反。当主机繁忙时,一个共享 vCPU 能提供的计算能力还不到一个完整 CPU 核心的水平。在将问题归咎于代码前,应先了解这一点:嘈杂邻居导致的 CPU steal time 会在 top 中显示为 st 数值。

如果视图主要在等待数据库或上游 API,这里使用线程比使用进程更合适。--worker-class gthread --workers 2 --threads 4 可在只承担 2 个 worker 内存开销的情况下处理 8 个并发请求,因为线程共享同一份已加载的解释器和框架。由于存在全局解释器锁,线程无法帮助需要消耗 CPU 的视图。

为服务器配置 swap。没有 swap 的 1 GB VPS 会将内存峰值转化为进程被终止;使用 swapfile 后,同样的峰值只会导致请求变慢。

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

然后限制应用本身的资源。Gunicorn 单元中的 MemoryMax=600M 表示由内核从应用的 cgroup 回收内存,而不是在整台服务器上任意选择进程终止。因此,即使某个请求失控,也不会导致 SSH 会话被终止。

冷启动和重启行为

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

启动成本需要支付两次:每次部署时一次,以及每次崩溃后自动重启时一次。最小化的 Flask 应用大约在 90 ms 内就绪;在相同的共享 vCPU 上,启用 admin 的 Django 大约需要 720 ms。这两个数值都是常见的公开数据。请测量您自己的环境,因为真正占用时间的主要是依赖项。

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

最后几行会列出累计耗时最长的导入操作,单位为微秒。对于 Flask,请对您的模块使用相同的 flag:python -X importtime -c "import app"

启用 preload 后,master 只需支付一次启动成本,之后每个 fork 出的 worker 都能立即启动。禁用 preload 后,每个 worker 都要单独支付这项成本,而 gunicorn 的 timeout 同时涵盖启动和请求处理。worker 如果在 timeout 秒内没有完成注册,就会被终止并替换。因此,运行在速度较慢的共享 vCPU 上的重量级应用可能陷入重启循环,始终无法处理任何请求。日志如下:

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

迁移应放在 unit 中,而不是应用启动代码中。ExecStartPre 会在任何 worker 创建之前运行一次。将 migrate 放在应用内部,会导致 3 个 worker 争用同一个 schema 锁。

部署形态几乎相同

进程管理器

两个框架都运行在 gunicorn 下,而 gunicorn 运行在 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 会在启动时创建 /run/site1,在停止时删除它,因此套接字路径始终存在,并且所有者正确。gunicorn 配置中的 umask = 0o007 行会让该套接字可由 www-data 组写入,nginx 正是通过此方式访问它。

Flask 单元使用相同的文件,只需修改一行:ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app,并且不包含 ExecStartPreapp:app 参数的格式是模块名加可调用对象,因此错误 Failed to find attribute 'app' in 'app'. 表示您的模块未定义同名变量。定时任务也遵循相同模式;对于这种规模的服务器,使用 systemd timer 替代 cron 执行 Django 管理命令,无需额外部署任务队列。

静态文件

使用 DEBUG = False 的 Django 完全不会提供静态文件。设置 STATIC_ROOT,运行 python manage.py collectstatic,然后让 Web 服务器指向输出目录。跳过这一步后,管理后台会在没有样式的情况下加载,日志则会不断出现 Not Found: /static/admin/css/base.css

提供静态文件有两种合理方式。nginx 的 alias 块不会占用应用资源。将 WhiteNoise 添加为中间件后,文件由 worker 提供,可以省去 nginx 配置块,但每个文件都会额外占用少量 worker 时间。Flask 在开发环境中会提供自己的 static/ 目录;在生产环境中,出于相同原因,应让代理指向该目录。

反向代理

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

无论使用哪种代理,都必须告知 Django 原始请求使用 HTTPS,否则其跨站请求伪造(CSRF)检查会拒绝您自己的表单。

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

如果服务器已经运行容器,在多个 Docker Compose 应用前使用 Traefik 可以完成相同工作:使用容器标签进行配置,而不是为每个站点维护一个文件。

选择数据库

对于单台应用服务器,如果写入速率仅为每秒几次,SQLite 完全够用,并且可以从内存预算中移除一个完整的 daemon。启用预写式日志(WAL),并为驱动设置 busy timeout。

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

如果缺少这两个选项,两个 worker 首次同时写入时就会遇到 django.db.utils.OperationalError: database is locked,因为默认日志模式会在写入期间阻塞读取,而默认超时几乎会立即放弃。更完整的说明,包括 SQLite 何时不再适用,见在 VPS 生产环境中运行 SQLite

在同一台 1 GB 服务器上运行 PostgreSQL,会占用上述预算中的 120 MB,此外每个持久连接还会对应一个 backend 进程。Django 的 CONN_MAX_AGE 会为每个 worker 保持一个连接,因此 4 个 worker 意味着 4 个 backend。这通常是合理的取舍,但设置 worker 数量前应先计算资源。如果您希望将数据库与应用一起放在容器中,在 VPS 上运行 Docker也是类似的取舍,但资源下限更高,因为 daemon 和每个容器都会增加开销,而在这种规模下这些开销不可忽略。

Django 的额外内存占用换来了什么,以及代价是什么

Django 增加的内存占用对应的是一组已经存在且能够协同工作的功能:带迁移功能的 ORM、会话和身份验证系统、权限模型、带 CSRF 保护的表单层、模板引擎、管理命令以及 admin。admin 是经常被低估的部分。它是一个可直接使用的模型数据库编辑器,支持搜索和筛选,只需在 INSTALLED_APPS 中添加一行配置。

Flask 则完全相反。您会获得路由、请求对象、Jinja2 模板和配置对象。其他功能都由您自行选择。对于小型应用,这确实有价值,因为如果您从未导入 ORM,就不会加载 ORM。

问题出现在两者之间。为模型添加 SQLAlchemy,为迁移添加 Alembic,为会话添加 Flask-Login,为表单和 CSRF 添加 Flask-WTF,再为后台管理添加一个 admin 扩展后,您组装出的系统拥有与 Django 相近的内存占用,却没有 Django 的一致性。每个组件都有自己的发布周期,也都有自己对应用集成方式的看法。到了这个阶段,Django 才是更低成本的选择:既节省 RAM,也减少您在升级上花费的时间。

Django 与 Flask:决策规则

如果应用包含账户、可编辑内容、会持续变化的架构,以及实际会有人打开的后台管理界面,请使用 Django。如果应用只是基于现有数据存储提供 JSON 接口,或是一个不包含 HTML 的 webhook 接收器,请使用 Flask。

决胜依据是一份书面清单。列出在 Flask 中为获得所需功能而准备安装的每个软件包。如果清单中包含 ORM 和迁移工具,那么您实际上已经选择了 Django,只是在额外付出成本,缓慢地实现同样的结果。

在小型硬件上,有一种情况确实更适合 Flask:一台主机运行多个小型服务。每个 Flask 服务都是一个独立的低开销进程,并由自己的 unit 管理。在一台 1 GB VPS 上运行 3 个 Django 站点,意味着框架会同时常驻 3 份,此前的计算方式也不再适用。如果计算结果始终显示资源不足,升级到更大的方案通常才是合理的解决办法,而VPS 每月的实际成本比重写一个正常运行的应用更容易讨论。

您将看到的错误信息及其故障模式

Worker 消失后又重新出现。 Gunicorn 输出 [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?。Worker 因未按时发送心跳而被终止时会输出该信息,内核终止进程时也会输出。使用 dmesg -T | grep -i "killed process" 区分这两种情况。该日志中的一行表示内存不足,因此请减少 worker 数量或增加 swap。

每个页面都返回 400,日志显示 Invalid HTTP_HOST header 完整消息是 Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.。Django 会在请求到达您的代码前拒绝请求,因为 ALLOWED_HOSTS 为空,或不包含代理在 Host 中传递的名称。

表单提交失败并显示 Origin checking failed 页面显示 CSRF 验证失败。此问题会发生在 TLS 终止代理之后:应用看到的是普通 HTTP,生成的 http:// 来源与通过 https:// 到达的请求进行比较时不一致。设置 SECURE_PROXY_SSL_HEADERCSRF_TRUSTED_ORIGINS,并确认代理确实发送 X-Forwarded-Proto

nginx 立即返回 502。 错误日志会说明原因:connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) 表示单元未运行,(13: Permission denied) 表示套接字存在,但 nginx 无法打开它;这通常与 umask 及组设置有关。

管理后台没有样式。 collectstatic 尚未运行,或 alias 路径与 STATIC_ROOT 不匹配。访问日志会显示 /static/admin/ 下存在 404 响应。

负载较低时写入失败。 SQLite 的 database is locked 表示 WAL 未启用,或 busy timeout 太短,无法处理两个 worker 同时写入的情况。

FAQ

Django 对 1 GB VPS 来说太重吗?

不重。使用少量 worker,前置 nginx,后端使用 SQLite 时,Django 可以在 1 GB 内存的 VPS 上稳定运行。将 PostgreSQL 保持默认配置,再在同一台主机上添加缓存、后台 worker 和 Docker 后,内存就会变得紧张。测量一个 worker 的比例集大小,将其乘以 2 以应对请求峰值,然后将总量与操作系统和数据库占用后剩余的内存进行比较。

在一颗 vCPU 上应运行多少个 gunicorn worker?

从 3 个开始,然后进行测量。在小型 VPS 上,内存通常是主要限制。因此,用操作系统和数据库占用后剩余的 RAM,除以单个 worker 比例集大小的 2 倍。如果视图大多在等待数据库或上游 API,请改用 gthread worker class,运行少量 worker,并为每个 worker 配置多个线程。线程共享一份已加载的框架副本,所需内存远少于增加进程。

我需要 PostgreSQL,还是 SQLite 就够了?

对于写入速率适中的单应用服务器,SQLite 已经够用,而且无需将一个完整的 daemon 计入内存预算。启用预写式日志记录并设置 busy timeout,否则并发写入会因 database is locked 失败。当需要由多台机器写入,或需要 SQLite 不提供的功能(例如高负载并发写入或按角色进行访问控制)时,再迁移到 PostgreSQL。

我应该使用 uvicorn,而不是 gunicorn 吗?

只有在你使用 async 视图,并且确实有需要等待的异步操作时才应该这样做。Flask 是 WSGI 应用,因此 async 视图会在 worker 线程内创建新的事件循环,并在下一个请求开始前完成,不会带来额外的并发能力。Django 的 async 视图需要 ASGI 服务器才能发挥作用。近期的 uvicorn releases 已将其 gunicorn worker class 移至单独的软件包,因此应阅读当前的 uvicorn 文档,不要从旧教程中复制旧版 worker class flag。

为什么我的 worker 消失了,却没有 traceback?

被内核 out of memory killer 终止的进程会收到 SIGKILL,退出时无法记录任何信息,因此应用日志会直接停止。Gunicorn 会发现这一间隔,并输出 Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?。使用 dmesg -T | grep -i "killed process" 确认。解决方法是减少 worker 数量,或创建 swapfile,使内存峰值导致请求变慢,而不是进程终止。

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