前回、n8nは2GBのVPSに 402MB で載りました(実測記事)。そのとき「次はDifyを入れてみます」と書きました。
Difyの公式要件は 2CPU / 4GB。今回の環境は2GB、しかも本番の処理と公開中のサイトが同居しています。要件を満たしていません。 それを承知で載せて、10秒ごとに測りました。
結論から書きます。載ります。ただし2,076MB要求してきます。

上のオレンジ色が、物理メモリに収まらずスワップへ追い出された分です。881MB。 そして下のグラフ、起動から4分間はI/O待ちが最大91%。この間、SSHの接続が切れました。
測定条件
- サーバー:さくらのVPS 2Gプラン / Ubuntu 24.04 LTS
- 同居しているもの:定期的にデータを取得して予測を回すバッチ処理と、静的サイト1本
- Dify:1.17.0(公式リポジトリの
docker/をそのまま使用、ベクトルDBは既定のweaviate) - 記録:10秒間隔・自作スクリプト。
free/vmstat/psと cgroup のmemory.currentを読むだけ - 時間:10分間(起動から自動停止まで)
前回までに43時間のベースラインと、n8nを載せた前後の数字が取ってあります。「載せる前」が分かっているので、増えた分がそのままDifyのコストになります。
先に:ディスクが10.6GB増える
メモリの話の前に、こちらのほうが先に効きます。
イメージ取得前 12,971MB
イメージ取得後 23,572MB
差分 +10,601MB
イメージだけで10.6GB。 n8nは2.47GBだったので、4.3倍です。
内訳の上位はこうなっています。
| イメージ | サイズ |
|---|---|
| dify-api | 4.06GB |
| dify-plugin-daemon | 2.28GB |
| dify-agent-backend | 1.45GB |
| dify-web | 892MB |
| dify-sandbox | 848MB |
| dify-agent-local-sandbox | 779MB |
| postgres / squid / nginx / weaviate / redis ほか | 計 1.25GB |
dify-api の1本だけで4GB。n8nのイメージ全体(2.47GB)より大きい。
なお docker images に出る数字を単純に足すと約14GBになりますが、実際のディスク増加は10.6GBでした。レイヤーが共有されるぶん、足し算は3.4GB多く見積もります。
立ち上がるのは16コンテナ
n8nは1本でした。Difyは16本です。
api api_websocket worker worker_beat web nginx
db_postgres redis weaviate plugin_daemon
sandbox local_sandbox agent_backend
ssrf_proxy agent_ssrf_proxy init_permissions
起動する前に、この一覧を出しておくことを強く勧めます。
docker compose config --services
理由は次の項です。
起動前に必ず:ホストのポートを奪われないか確認する
Difyの標準構成は、コンテナの中に自前のnginxを持っていて、ホストの80番と443番を要求します。
docker compose config --format json | python3 -c '
import sys,json
d=json.load(sys.stdin)
for name,svc in sorted(d["services"].items()):
for x in svc.get("ports") or []:
print(" ", name, x.get("host_ip","0.0.0.0"), x.get("published"), "->", x.get("target"))
'
結果はこうでした。
nginx 0.0.0.0 80 -> 80
nginx 0.0.0.0 443 -> 443
plugin_daemon 0.0.0.0 5003 -> 5003
すでにWebサイトを配っているサーバーで、これをそのまま起動してはいけません。 80/443の奪い合いになります。加えて plugin_daemon の5003番が全世界に開きます。
正しい閉じ方
私は最初、.env の EXPOSE_PLUGIN_DEBUGGING_PORT に 127.0.0.1:5003 と書く方法を取りました。Composeの "${EXPOSE_PLUGIN_DEBUGGING_PORT}:5003" という記述にバインドアドレスごと差し込む、という小技です。
これは失敗します。 後述します。
正解は docker-compose.override.yaml を置くことです。
services:
nginx:
ports: !override
- "127.0.0.1:8088:80"
plugin_daemon:
ports: !override
- "127.0.0.1:5003:5003"
!override タグが要点です。 Composeはオーバーライドファイルの ports: を既定では追記します。 ただ書き足すだけでは80番も443番も残ったままです。!override を付けて初めてリストが置き換わります。
書いたら、起動する前に必ず検証してください。
docker compose config --format json | python3 -c '
import sys,json
d=json.load(sys.stdin)
bad=[(k,x.get("host_ip","0.0.0.0"),x.get("published"))
for k,v in d["services"].items() for x in (v.get("ports") or [])
if x.get("host_ip") not in ("127.0.0.1","::1")]
print("外部公開:", bad or "なし")
'
「なし」を確認してから up してください。
管理画面はSSHトンネル越しに開きます。
ssh -L 8088:127.0.0.1:8088 ユーザー名@サーバーのアドレス
本番と同居させるための2つの防御
このサーバーには止められない処理が載っています。Difyが暴走したときに、そちらが巻き添えになる構成では実験できません。 2段構えにしました。
1. 本番プロセスをOOM Killerの対象から外す
sudo mkdir -p /etc/systemd/system/<本番のサービス名>.service.d
printf '[Service]\nOOMScoreAdjust=-800\n' \
| sudo tee /etc/systemd/system/<本番のサービス名>.service.d/oom.conf
sudo systemctl daemon-reload && sudo systemctl restart <本番のサービス名>
nginxにも同じものを入れました。-800 は「メモリが尽きても、これは最後まで殺すな」という重み付けです。
2. Difyだけを1つの枠に閉じ込める
コンテナごとに --memory を付ける方法もありますが、16本あります。まとめて1つの上限に入れるほうが確実です。
sudo tee /etc/systemd/system/dify.slice > /dev/null << 'EOF'
[Unit]
Description=Dify experiment memory budget
[Slice]
MemoryMax=1200M
MemorySwapMax=1024M
EOF
sudo systemctl daemon-reload && sudo systemctl start dify.slice
そのうえで、16本すべてをこの枠に入れます。オーバーライドファイルに cgroup_parent を並べるだけです。
SVCS=$(docker compose config --services | sort)
{ echo "services:"
for s in $SVCS; do printf ' %s:\n cgroup_parent: dify.slice\n' "$s"; done
} > docker-compose.override.yaml
先にサービス一覧を変数へ取っておくのが要点です。
> docker-compose.override.yamlは実行前にファイルを作るので、forの中でdocker compose configを呼ぶと書きかけの自分自身を読んで失敗します。 私はこれで一度つまずきました。
なぜ1,200MBかというと、空きが約1,480MBだったからです。本番サイトが同居している2GBのVPSで、実験ツールに空きメモリを全部渡す人はいません。 全体の6割を与えて入るかどうか、が答えるべき問いだと考えました。
結果:静止するまで4分、そのあとは安定する
| 経過 | Difyのメモリ | うちスワップ | load | CPUアイドル | I/O待ち |
|---|---|---|---|---|---|
| 起動直後 | 1,174MB | 0 | 1.62 | 0% | 0% |
| 24秒 | 1,199MB | 378MB | 3.51 | 7% | 45% |
| 1分10秒 | 1,200MB | 642MB | 4.68 | 1% | 91% |
| 2分13秒 | 1,199MB | 780MB | 4.88 | 0% | 91% |
| 3分07秒 | 1,200MB | 836MB | 5.72 | 9% | 77% |
| 4分 | 977MB | 627MB | 4.66 | 2% | 13% |
| 4分20秒〜 | 1,195MB | 881MB | 0.2〜0.4 | 98% | 0% |
3つの局面がはっきり分かれました。
局面1(〜30秒):CPUで殴られる。 idle 0 / wa 0。16本の初期化そのものです。
局面2(25秒〜4分):I/O待ちに変わる。 スワップへの追い出しが始まった瞬間から wa が跳ね上がり、最大91%。ここでSSHが切れました。 ロードアベレージは5.72。
局面3(4分〜):静止する。 load 0.2〜0.4、idle 98%、wa 0%。以降6分間まったく動きません。
4分の正体はデータベースのマイグレーションだった
グラフの4分地点にある急落を、api のログと突き合わせました。
Running migrations
Preparing database migration...
Starting database migration.
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
Database migration successful!
[INFO] Starting gunicorn 26.1.0
[INFO] Listening at: http://0.0.0.0:5001
Database migration successful! のタイムスタンプが、あの急落と秒単位で一致しました。
10秒刻みの記録では、上位プロセスが flask(マイグレーション)から gunicorn(本体)へ入れ替わる瞬間がそのまま写っています。
通常なら数秒で終わる処理に、約4分かかっていました。 スワップの中で動かしていたからです。
定常状態のコスト
| 値 | |
|---|---|
| 物理メモリ上 | 1,195MB |
| スワップ上 | 881MB |
| Dify合計 | 2,076MB |
| システム全体の使用量 | 1,498MB |
| 空き(available) | 468MB |
2GBのマシンで、Difyだけが2,076MB要求しています。
docker stats でコンテナ別に見ると、物理メモリ上にいるのは合計1,160.7MiBでした。
| コンテナ | メモリ |
|---|---|
| worker | 326.1MiB |
| api | 296.9MiB |
| api_websocket | 207.5MiB |
| web | 162.8MiB |
| worker_beat | 38.0MiB |
| sandbox | 35.2MiB |
| plugin_daemon | 24.6MiB |
| db_postgres | 22.4MiB |
| weaviate | 16.2MiB |
| その他6本 | 計 31.0MiB |
docker stats に出るのは物理メモリ上の分だけです。 スワップに落ちた881MBはここに出てきません。この表だけを見て「Difyは1.1GBか」と判断すると、実態の半分強しか見ていないことになります。
なお、この状態で管理画面(/)にアクセスしたところ HTTPステータス307、応答2.84秒でした。応答はします。ただし何のワークフローも作っていない、完全にアイドルの状態でこの数字です。
記事3の訂正1:メモリ上限はOOMからは守るが、スラッシングからは守らない
前回の記事で、私はこう書きました。
--memory=768mが要点です。(中略)上限を課しておけば、あふれるのはコンテナの中だけで済みます。
半分しか正しくありませんでした。
今回、cgroupの記録はこうなっています。
memory.peak 1,200MB(上限ちょうど)
memory.swap.peak 884MB
max 69,079 ← 上限に当たって回収した回数
oom 0
oom_kill 0
oom_kill は0。1本も殺されていません。 そのかわり、上限に達するたびにカーネルがページを回収し、それが 69,079回。
MemoryMax はプロセスを殺しません。強制的にメモリを回収します。 つまり枠の中で、ページを追い出しては読み直す動作を数万回繰り返す。殺されなかったから、固まったのです。
- 上限はOOMからは守る — 実際、同居している本番の処理も公開中のサイトも、一度も止まりませんでした
- 上限はスラッシングからは守らない — 枠の中で窒息し、そのディスクI/Oでサーバー全体が巻き添えになります
「メモリ上限を付ければ同居は安全」という前回の書き方は、この一文を足して読んでください。
記事3の訂正2:/var/lib/docker を見ても分からない
前回、「/var/lib/docker の中身だけで2.1GB」と書きました。測る場所として不適切でした。
/var/lib/docker 5.9M
/var/lib/containerd 13G
Driver=overlayfs
Docker 29系は overlay2 ではなく overlayfs ドライバを使い、イメージの実体をcontainerdの管理下に置きます。/var/lib/docker は5.9MBしかありません。
正しくは docker system df を使ってください。
docker system df
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 1 1 2.473GB 0B (0%)
Docker自身に申告させるほうが確実です。なお、df で見たディスク全体の増加(10GB→13GB)という前回の数字自体は正しく、訂正が要るのは内訳を見た場所のほうです。
私の失敗:ポートを閉じようとして、自分でDifyを壊した
最初の起動では、16本のうち api だけが延々と再起動を繰り返しました。
api restarting Restarting (1) 29 seconds ago
このとき私は「メモリ不足だ」と考えかけました。 空きは足りていない。スワップは880MB使われている。一番重いコンテナが落ちている。状況は完全にそう見えます。
確認したら、違いました。
docker inspect docker-api-1 \
--format "ExitCode={{.State.ExitCode}} Restarts={{.RestartCount}} OOMKilled={{.State.OOMKilled}}"
# ExitCode=1 Restarts=17 OOMKilled=false
OOMKilled=false。 メモリで殺されたわけではない。ログを見ると原因が明記されていました。
pydantic_core.ValidationError: 1 validation error for DifyConfig
PLUGIN_REMOTE_INSTALL_PORT
Value error, PLUGIN_REMOTE_INSTALL_PORT must be a bare port number,
got '127.0.0.1:5003'. ... bind loopback via a docker-compose.override.yaml
instead of overloading this var.
前述の「小技」です。EXPOSE_PLUGIN_DEBUGGING_PORT にホストIPごと突っ込んだところ、Difyは同じ値を PLUGIN_REMOTE_INSTALL_PORT としても読んでおり、そちらはポート番号単体でなければならなかった。 設定検証で弾かれ、起動のたびに落ちていました。
エラーメッセージが「docker-compose.override.yaml を使え」と明示的に指示しています。私はそれを読む前に近道を選んでいました。
直したら、api は Running=true / Restarts=0 で安定しました。この記事の数字は、すべて直した後の測定です。
ここが今回いちばんの教訓です
もし OOMKilled を確認せずに、見た目だけで「2GBではDifyのAPIが落ちる」と書いていたら、それは誤報でした。 同じことは16GBのサーバーでも起きたはずです。
「メモリ不足に見える障害」の多くは、メモリ不足ではありません。 コンテナが落ちたら、まずこの1行を叩いてください。
docker inspect <コンテナ名> --format "{{.State.OOMKilled}} {{.State.ExitCode}}"
結論:2GBで動く。ただし勧めない
動いた
- 16コンテナすべて起動。
apiも含めて安定稼働 - 管理画面は応答する(307 / 2.84秒)
- 同居している本番の処理も、公開中のサイトも一度も止まらなかった
- 静止状態では
load 0.2/CPUアイドル 98%
それでも勧めない
- 常時881MBをスワップに置いている。 メモリ上のワーキングセットではなく、ディスクの上です
- 空きは468MB。 ここから実際にワークフローを組み、モデルを呼び、データを取り込む余地はほとんどありません
- 起動に4分。 再起動のたびに4分間サーバーが重くなります
- その4分間はI/O待ち91%。 SSHが切れました。同居しているサービスがある環境で、これは許容しづらい
- ディスクを10.6GB持っていく
「起動して、アイドルで放置する」ところまでは2GBで届きます。使い始めたら足りません。
必要そうな線
今回の実測から言えるのは、アイドルで約2.1GBということです。これはワークフロー0本、ナレッジ0件、アクセス0の数字です。
公式が4GBを要求している理由は、実際に使ったときの余地を含めてのことでしょう。2GBは、スワップという名の借金でようやく起動できる水準でした。
- n8nだけなら2GBで足りる(前回の実測:402MB、空き1.1GB)
- Difyを実用するなら4GB以上を見たほうがよさそうです。ただし「4GBなら快適か」は測っていないので、これは推測です
次に測ること
推測のままにはしません。 次はメモリを増やした環境で同じ手順・同じ間隔で測り、「何GBから快適になるのか」を数字で出します。
同じ10秒刻みのスクリプトを使うので、今回のデータとそのまま比較できます。
「AI用途のVPSは何GB必要か」という問いに、推測ではなく実測で答えるところまで持っていきます。
使った計測スクリプト
追加パッケージは不要です。free / vmstat / ps と cgroup のファイルを読むだけです。
#!/usr/bin/env bash
set -u
OUT="$HOME/dify-run.csv"
N="${1:-72}"
S="/sys/fs/cgroup/dify.slice"
echo "ts,used_mb,avail_mb,swap_used_mb,slice_mb,slice_swap_mb,load1,cpu_idle,cpu_wa,p1,p1_rss,p2,p2_rss,p3,p3_rss" > "$OUT"
for i in $(seq 1 "$N"); do
ts=$(date '+%H:%M:%S')
mem=$(free -m | awk '/^Mem:/{print $3","$7}')
swp=$(free -m | awk '/^Swap:/{print $3}')
sm=$(awk '{printf "%.0f",$1/1048576}' "$S/memory.current" 2>/dev/null); sm=${sm:-0}
ss=$(awk '{printf "%.0f",$1/1048576}' "$S/memory.swap.current" 2>/dev/null); ss=${ss:-0}
ld=$(awk '{print $1}' /proc/loadavg)
cpu=$(vmstat 1 2 | awk 'NR==2{for(i=1;i<=NF;i++){if($i=="id")ci=i; if($i=="wa")cw=i}} END{print (ci?$ci:"")","(cw?$cw:"")}')
top=$(ps -eo comm=,rss= --sort=-rss | head -3 | tr -d ',' \
| awk '{printf "%s,%.1f,", $1, $2/1024}' | sed 's/,$//')
echo "$ts,$mem,$swp,$sm,$ss,$ld,$cpu,$top" >> "$OUT"
sleep 8
done
前回のスクリプトに cpu_wa(I/O待ち)と cgroup の使用量を足したものです。
wa 列は今回いちばん効きました。これがないと「CPUが忙しいのか、ディスク待ちで塞がっているのか」が区別できません。 前回の測定ではそこを取りこぼしていました。
vmstat の列位置はカーネルやprocpsのバージョンで変わるので、ヘッダー行から id と wa の位置を探してから読んでいます。 列番号を決め打ちしないでください。
実験を安全に終わらせる仕掛け
固まる可能性がある実験なので、8分後に自分で止まるようにしてから起動しました。
sudo nohup bash -c '
sleep 480
cd /path/to/dify/docker
docker compose ps -a > /home/ubuntu/dify-final.txt 2>&1
docker stats --no-stream >> /home/ubuntu/dify-final.txt 2>&1
docker compose down >> /home/ubuntu/dify-final.txt 2>&1
' > /dev/null 2>&1 &
sudo nohup ... & で端末から切り離してあるので、SSHが切れても実行されます。 実際、測定中に接続は切れました。それでも自動停止は動き、記録も残りました。
手で止めに行く前提の実験にしないこと。 これが今回いちばん役に立った準備でした。