前回、n8nは2GBのVPSに 402MB で載りました(実測記事)。そのとき「次はDifyを入れてみます」と書きました。

Difyの公式要件は 2CPU / 4GB。今回の環境は2GB、しかも本番の処理と公開中のサイトが同居しています。要件を満たしていません。 それを承知で載せて、10秒ごとに測りました。

結論から書きます。載ります。ただし2,076MB要求してきます。

Difyを2GBのVPSに載せた10分間

上のオレンジ色が、物理メモリに収まらずスワップへ追い出された分です。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-api4.06GB
dify-plugin-daemon2.28GB
dify-agent-backend1.45GB
dify-web892MB
dify-sandbox848MB
dify-agent-local-sandbox779MB
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番が全世界に開きます。

正しい閉じ方

私は最初、.envEXPOSE_PLUGIN_DEBUGGING_PORT127.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のメモリうちスワップloadCPUアイドルI/O待ち
起動直後1,174MB01.620%0%
24秒1,199MB378MB3.517%45%
1分10秒1,200MB642MB4.681%91%
2分13秒1,199MB780MB4.880%91%
3分07秒1,200MB836MB5.729%77%
4分977MB627MB4.662%13%
4分20秒〜1,195MB881MB0.2〜0.498%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.4idle 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でした。

コンテナメモリ
worker326.1MiB
api296.9MiB
api_websocket207.5MiB
web162.8MiB
worker_beat38.0MiB
sandbox35.2MiB
plugin_daemon24.6MiB
db_postgres22.4MiB
weaviate16.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 を使え」と明示的に指示しています。私はそれを読む前に近道を選んでいました。

直したら、apiRunning=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のバージョンで変わるので、ヘッダー行から idwa の位置を探してから読んでいます。 列番号を決め打ちしないでください。

実験を安全に終わらせる仕掛け

固まる可能性がある実験なので、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が切れても実行されます。 実際、測定中に接続は切れました。それでも自動停止は動き、記録も残りました。

手で止めに行く前提の実験にしないこと。 これが今回いちばん役に立った準備でした。