小規模VPSのDjangoとFlask、メモリ比較
1〜2 GBのVPSでDjangoとFlaskは何GB使うのかを比較します。gunicornワーカー1つあたりのRSSとPSS、現実的に動かせるワーカー数をUbuntu 24.04とPython 3.12で確認します。
小規模 VPS での Django と Flask のコスト
小規模 VPS で Django と Flask のどちらを使うかは、まずメモリの問題です。Django は、起動する各ワーカープロセスにオブジェクト関係マッパー(ORM)、マイグレーション機能、さらに有効にした場合は管理サイトも読み込みます。Flask が読み込むのは、ルーターとリクエストオブジェクトです。1 GB のサーバーでは、この違いによって収容できるワーカー数が決まります。ワーカー数によって、同時に処理できるリクエスト数も決まります。
Django が提供する機能を自分で再実装しない場合に限り、このコストが問題になります。ユーザーアカウント、セッション、管理画面を備えたアプリケーションには Django が適しています。ワーカーごとの RAM は、自分で書かずに済むコードの対価です。すでに運用しているデータストアの前段に置く JSON API には Flask が適しています。Django の機能を使わないなら、それらを読み込む必要がないためです。これは適合性の問題です。以下の測定結果から、アプリケーションがどちらに適しているかを判断できます。
gunicorn のワーカー1つが使用するメモリ量
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、gunicorn ワーカー3つ、preload 有効の条件で、各構成の hello world アプリについて一般的に公開されている値です。独自の import による使用量が上乗せされるため、下限として扱ってください。admin を有効にした Django ワーカーは、常駐メモリが 96 MB で、メモリの比例配分値は 58 MB です。この2つの値の差が、次のセクションの主題です。
同じ測定を自分の環境でも実行してください。
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitlesetproctitleをインストールします。これが存在すると、gunicorn はプロセス名を gunicorn: master [site1] と gunicorn: worker [site1] に変更します。これにより、次のコマンドで推測に頼らず、名前でワーカーを検索できます。
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')rss列はキロバイト単位の常駐セットサイズです。プロセスが現在 RAM に保持しているすべてのメモリページを示します。ワーカー全体でこの値を合計すると、実際より大きな値になります。fork されたワーカーは親プロセスや兄弟ワーカーとページを共有するため、同じページが複数回カウントされるからです。代わりに、カーネルから比例セットサイズ (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ワーカーを所有するユーザーとして実行するか、sudo を使用してください。予算の基準にする列は PSS です。PSS は正しく合計できますが、RSS はできません。
Django の使用量が大きいのは、django.setup()の処理があるためです。INSTALLED_APPSの各エントリを import し、アプリケーションレジストリを構築し、すべてのモデルクラスと、その各フィールドに対応する Python オブジェクトを生成します。django.contrib.adminを追加すると admin の自動検出が実行され、各アプリの adminモジュールが import されます。その結果、フォーム層とテンプレート層も読み込まれます。Flask のワーカーは Werkzeug と Jinja2 を import して終了します。
正直な注意点が1つあります。フレームワークが占める割合は、しばしば小さいものです。cloud SDK や数値計算用ライブラリを import するワーカーでは、Django よりもそれらの使用量のほうが大きくなることがあります。フレームワークが問題だと判断する前に、実際のアプリを測定してください。
Copy on write と preload によって数が変わる理由
Gunicorn の master プロセスは worker を fork します。fork() の直後は、子プロセスがすべてのメモリページを親プロセスと共有し、一方が書き込んだ場合にだけカーネルがそのページをコピーします。そのため、Django の model registry がサーバー上に 1 つ存在するのか 4 つ存在するのかは、それが fork の前後どちらで構築されたかによって決まります。
preload_app が無効な場合、各 worker は fork 後にアプリケーションを import するため、それぞれが独自のコピーを構築します。有効な場合は、master がアプリケーションを 1 回だけ import し、worker はそのメモリページを継承します。
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 は copy on write と相性がよくありません。すべてのオブジェクトヘッダーには参照カウントがあり、オブジェクトに触れるとそのヘッダーへ書き込みが発生します。そのため、ガベージコレクターがヒープを走査すると、共有ページが 1 ページずつコピーされます。gc.freeze() は、それまでに割り当てられたすべてのオブジェクトを、ガベージコレクターが以後走査しない永続世代へ移します。これにより、より多くのページを共有したまま維持できます。when_ready が適切なフックである理由は、preload の後、最初の worker が fork される前に実行されるためです。節約量はアプリケーションの import 時状態の量によって変わるため、追加前後の PSS を測定してください。
preload には、デプロイ時に問題となるコストが 1 つあります。systemctl reload は HUP を送信し、gunicorn は HUP で設定を再読み込みして新しい worker を起動する動作を文書化しています。アプリケーションが preload されている場合、コードは再度 import されません。そのため、worker プロセスが新しくなっていても、新しいリリースは実行されません。コードを変更した後は systemctl restart を使用してください。古い worker を先に処理完了させる必要がある場合は、USR2、続けて WINCH を実行します。
1 GB VPS では、現実的に何個の worker を実行できますか?
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 あたりのピーク使用量は、アイドル時の約 2 倍になることが一般的です。そのため、2 倍の値で見積もります。このサーバーでは、admin を有効にした Django がアイドル時に 58 MB を使用する場合、worker は 4 個です。SQLAlchemy を使用する Flask が 38 MB を使用する場合は、7 個です。
Gunicorn の (2 x cores) + 1 の提案は、CPU が不足し、RAM は不足しないことを前提にしています。小規模な VPS では、実際には逆です。共有 vCPU が 1 個の場合、ホストが混雑していると、1 コア分未満の処理能力しか得られないこともあります。コードを疑う前に、この点を理解しておく必要があります。近隣テナントの高負荷による CPU steal time は、top では st の値として現れます。
ビューの処理の大半がデータベースや上流 API の応答待ちであれば、ここではプロセスよりスレッドが適しています。--worker-class gthread --workers 2 --threads 4 を使用すると、worker 2 個分のメモリ使用量で、同時に 8 個のリクエストを処理できます。インタープリターとフレームワークを読み込んだコピーを、スレッド間で共有するためです。global interpreter lock のため、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 unit に MemoryMax=600M を設定すると、システム全体から停止対象を選ぶのではなく、kernel がアプリケーションの cgroup からメモリを回収します。そのため、暴走したリクエストによって SSH セッションまで失われることを防げます。
コールドスタートと再起動の動作
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
}
]起動コストは、デプロイのたびに 1 回、クラッシュ後の自動再起動のたびに 1 回発生します。最小構成の Flask アプリは約 90 ms で起動でき、admin を有効にした Django は、同じ共有 vCPU 上で約 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末尾には、累積マイクロ秒で計測した、最も遅い import が一覧表示されます。Flask では、同じフラグをモジュールに対して実行します: python -X importtime -c "import app"。
preload を有効にすると、master がこのコストを 1 回だけ負担し、fork された各 worker はすぐに起動します。preload を無効にすると、各 worker がこのコストを負担します。また、gunicorn の timeout は、リクエスト処理だけでなく起動も対象にします。timeout 秒以内に応答しなかった worker は強制終了され、新しい worker に置き換えられます。そのため、重いアプリを低速な共有 vCPU で実行すると、何も処理しないまま再起動を繰り返す状態になることがあります。ログには次のように表示されます。
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)マイグレーションはアプリケーションの起動コードではなく、unit に定義します。ExecStartPre は、worker が作成される前に 1 回だけ実行されます。アプリ内に migrate を記述すると、3 つの worker が同じスキーマロックをめぐって競合します。
デプロイ構成はほぼ同じです
プロセスマネージャー
どちらのフレームワークも 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.targetRuntimeDirectory=site1 は起動時に /run/site1 を作成し、停止時に削除します。そのため、ソケットのパスには常に適切な所有者が設定された状態で存在します。gunicorn の設定にある umask = 0o007 行によって、そのソケットが www-data グループから書き込み可能になります。これにより nginx からソケットへ接続できます。
Flask の unit は、1 行だけを ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app に変更し、ExecStartPre を指定しない点を除けば同じファイルです。app:app の引数はモジュール、次に callable の順です。そのため、Failed to find attribute 'app' in 'app'. というエラーは、モジュールにその名前の変数が定義されていないことを示します。定期的な処理も同じパターンにできます。この規模のサーバーにタスクキューを追加せず、Django の管理コマンドには cron の代わりに systemd timer を使うこともできます。
静的ファイル
DEBUG = False を使用する Django は、静的ファイルをまったく配信しません。STATIC_ROOT を設定し、python manage.py collectstatic を実行して、Web サーバーから出力ディレクトリを参照するようにします。この手順を省くと、管理画面はスタイルなしで読み込まれ、ログには Not Found: /static/admin/css/base.css が大量に記録されます。
静的ファイルを配信する方法は、妥当なものが 2 つあります。nginx の alias ブロックを使えば、アプリに処理負荷はかかりません。middleware として WhiteNoise を追加すると、worker からファイルを配信できるため nginx のブロックは不要になります。ただし、ファイルごとに worker の処理時間が少し増えます。Flask は開発環境では独自の static/ フォルダーを配信します。本番環境では、同じ理由から 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;
}
}どの proxy の背後で動作させる場合も、元のリクエストが HTTPS だったことを Django に伝える必要があります。そうしないと、cross site request forgery (CSRF) チェックで自分のフォームが拒否されます。
ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")すでにコンテナを実行している場合は、複数の Docker Compose アプリの前段に Traefik を置くことで同じ役割を実現できます。サイトごとにファイルを用意する代わりに、コンテナのラベルを使用します。
データベースの選択
SQLite は、書き込み頻度が毎秒数回程度の単一アプリケーションサーバーであれば、十分に実用的です。メモリ予算からデーモンを 1 つ丸ごと減らせる利点もあります。write ahead logging (WAL) を有効にし、driver に busy timeout を設定してください。
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL;",
},
}
}この 2 つのオプションを設定しないと、2 つの worker が同時に書き込んだ最初の時点で django.db.utils.OperationalError: database is locked に遭遇します。デフォルトの journal mode は書き込み中に reader をブロックし、デフォルトの timeout はほぼ直ちに処理を諦めるためです。SQLite が適切な選択肢ではなくなる条件を含む詳しい説明は、VPS で SQLite を本番運用するにあります。
同じ 1 GB のサーバーで PostgreSQL を動かすと、上記の予算から 120 MB が追加で必要になります。さらに、永続接続ごとに backend process が 1 つ必要です。Django の CONN_MAX_AGE は worker ごとに接続を 1 つ開いたままにするため、worker が 4 つなら backend は 4 つです。通常は十分に見合う構成です。ただし、worker 数を設定する前に必要な数を計算してください。アプリの隣にあるコンテナでデータベースを運用したい場合は、VPS で Docker を実行するという選択肢もあります。ただし、デーモンと各コンテナのオーバーヘッドがこの規模では無視できないため、必要なリソースの下限は高くなります。
バッテリーがもたらすものと、そのコスト
Django の追加メガバイトは、すでに存在し、連携して動作する機能の集合です。マイグレーションに対応した ORM、セッションおよび認証システム、権限モデル、CSRF 保護を備えたフォーム層、テンプレートエンジン、管理コマンド、そして admin が含まれます。特に過小評価されがちなのが admin です。admin はモデル用の実用的なデータベースエディターであり、検索とフィルターも利用できます。必要なのは INSTALLED_APPS の 1 行だけです。
Flask はその対極です。ルーティング、リクエストオブジェクト、Jinja2 テンプレート、設定オブジェクトが提供されます。それ以外はすべて自分で選択します。アプリケーションが小さい場合、これは実際の価値になります。ORM をインポートしなければ、ORM の読み込みも発生しないためです。
問題は中間的な構成です。モデル用に SQLAlchemy、マイグレーション用に Alembic、セッション用に Flask-Login、フォームと CSRF 用に Flask-WTF、管理画面用に admin 拡張機能を追加すると、Django と同程度のメモリ使用量で、Django の一貫性を持たない構成になります。各コンポーネントには独自のリリースサイクルがあり、アプリケーションの接続方法についてもそれぞれ異なる方針があります。ここが、RAM 使用量とアップグレードにかかる時間の両方で、Django のほうが低コストになる分岐点です。
Django と Flask の使い分け
アカウント、編集可能なコンテンツ、今後も変更が続くスキーマ、そして実際に利用する管理画面が必要なアプリには Django を使います。既存のデータストアに対する JSON インターフェースや、HTML を返さない webhook 受信処理には Flask を使います。
判断に迷う場合は、必要な機能を Flask で実現するために導入するパッケージをすべて書き出します。その一覧に ORM とマイグレーションツールが含まれるなら、すでに Django を選んでいます。Django と同じ構成に、より多くの作業をかけて到達しようとしているためです。
小規模なハードウェアでは、1 台のマシン上で複数の小さなサービスを動かす場合に Flask が有利です。各 Flask サービスを、専用の unit 配下で動く軽量な独立プロセスにできます。1 GB の VPS で Django サイトを 3 つ動かすと、フレームワークが 3 つ常駐するため、前述の計算は成り立たなくなります。何度計算してもリソースが不足するなら、上位プランへの変更が現実的な解決策です。動作しているアプリケーションを書き直すより、VPS の実際の月額費用を確認するほうが早く済みます。
表示される文字列から障害を切り分ける
ワーカーが終了して再起動します。 Gunicorn に [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory? と表示されます。これは、タイムアウトのハートビートを送信できなかったワーカーを終了した場合と、カーネルがプロセスを終了した場合の両方で表示されます。dmesg -T | grep -i "killed process" で区別します。そこに該当する行があればメモリ不足です。ワーカー数を減らすか、swap を追加します。
すべてのページが 400 を返し、ログに Invalid HTTP_HOST header と表示されます。 完全なメッセージは Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS. です。ALLOWED_HOSTS が空であるか、プロキシが Host で渡した名前を含んでいないため、Django がコードに到達する前にリクエストを拒否しています。
フォームが Origin checking failed で失敗します。 ページには CSRF 検証に失敗したと表示されます。これは TLS 終端を担うプロキシの背後で発生します。アプリケーションは接続を平文 HTTP と認識し、http:// のオリジンを生成します。その後、https:// で到着したリクエストと比較します。SECURE_PROXY_SSL_HEADER と CSRF_TRUSTED_ORIGINS を設定し、プロキシが実際に X-Forwarded-Proto を送信していることを確認します。
nginx が直ちに 502 を返します。 エラーログに原因が示されます。connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) は unit が起動していないことを意味し、(13: Permission denied) はソケットは存在するものの nginx が開けないことを意味します。これは umask とグループの設定が原因です。
管理画面のスタイルが適用されません。 collectstatic が実行されていないか、alias のパスが STATIC_ROOT と一致していません。アクセスログには /static/admin/ 配下の 404 が記録されます。
負荷が低い状態でも書き込みに失敗します。 SQLite の database is locked は、WAL が無効になっているか、2 つのワーカーが同時に書き込む場合に busy timeout が短すぎることを示します。
FAQ
Django は 1 GB の VPS では重すぎますか?
いいえ。ワーカー数を少なくし、前段に nginx、背後に SQLite を置けば、Django は 1 GB で問題なく動作します。同じサーバーで PostgreSQL をデフォルト設定のまま追加し、キャッシュ、バックグラウンドワーカー、Docker まで動かすと、メモリに余裕がなくなります。ワーカー 1 つの proportional set size を測定し、リクエスト集中に備えて 2 倍にします。その値を、オペレーティングシステムとデータベースの使用後に残るメモリと比較してください。
1 vCPU で gunicorn のワーカーをいくつ実行すべきですか?
まず 3 つで開始し、測定してください。小規模な VPS では通常、メモリが制約になります。そのため、オペレーティングシステムとデータベースの使用後に残る RAM を、ワーカー 1 つの proportional set size の 2 倍で割ります。ビューの処理の大半がデータベースや上流 API の応答待ちになる場合は、ワーカー数を少なくして各ワーカーで複数のスレッドを使う gthread ワーカークラスに切り替えてください。スレッドはロード済みのフレームワークを 1 つ共有するため、プロセスを追加するよりメモリ使用量を大幅に抑えられます。
PostgreSQL は必要ですか。それとも SQLite で十分ですか?
書き込み頻度が中程度の単一アプリケーションサーバーであれば、SQLite で十分です。SQLite なら専用デーモンを動かさずに済むため、メモリ予算にも余裕ができます。write ahead logging を有効にし、busy timeout を設定してください。そうしないと、同時書き込みが database is locked で失敗します。複数のマシンから書き込む必要が生じた場合や、同時に大量の書き込みを行う機能、ロール単位のアクセス制御など、SQLite にない機能が必要になった場合は PostgreSQL に移行してください。
gunicorn ではなく uvicorn を実行すべきですか?
async ビューがあり、実際に待機すべき処理がある場合に限ります。Flask は WSGI アプリケーションです。そのため、async ビューはワーカースレッド内の新しいイベントループで実行され、次のリクエストが開始される前に終了します。これによる同時実行性の向上はありません。Django の async ビューを有効に活用するには、ASGI サーバーが必要です。最近の uvicorn のリリースでは gunicorn ワーカークラスが別パッケージに移動しました。そのため、古いチュートリアルにある古いワーカークラスのフラグをコピーせず、現在の uvicorn ドキュメントを確認してください。
トレースバックがないのにワーカーが消えたのはなぜですか?
カーネルの out of memory killer によってプロセスが強制終了されると、SIGKILL を受け取るため、終了時に何もログへ記録できません。そのため、アプリケーションログが突然止まります。Gunicorn はこの空白を検知し、Worker (pid:1234) was sent SIGKILL! Perhaps out of memory? を出力します。dmesg -T | grep -i "killed process" で確認してください。ワーカー数を減らすか、swapfile を作成するのが対策です。メモリ使用量の急増を、プロセスの強制終了ではなく処理の低速化に変えられます。