1台のVPSならNATS、RabbitMQ、Kafka、Postgresのどれ?
1台のVPSで選ぶべきメッセージキューを比較します。配送保証、メモリとディスク使用量、再起動時の動作、滞留確認コマンド、Postgresを選ぶ条件が分かります。
1 台のサーバーでの簡潔な答え
1 台の VPS でメッセージキューを使うかどうかは、速度ではなく配送保証に関する判断です。1 台構成では、ブローカーがボトルネックになることはほとんどありません。通常は、アプリケーションコード、データベース、単一のディスクが先に限界に達します。障害時の動作を受け入れられるツールを選び、実際に使用するサーバーで測定してください。
検討する順序として、多くの読者には次の 4 つの選択肢が適しています。
- すでに運用しているデータベースを使います。Postgres と
SELECT ... FOR UPDATE SKIP LOCKEDの組み合わせは実用的なジョブキューとして機能し、新たに監視するプロセスも増えません。 - 各メッセージを確認応答が必要な作業単位として扱い、回数を限定して再試行した後、人が確認できる場所に退避したい場合は RabbitMQ を使います。
- システムの複数の部分が反応するイベントをメッセージとして扱う場合は NATS を使います。再起動後も保持する必要があるイベントには JetStream を有効にします。
- 下流のツールが Kafka プロトコルだけに対応している場合は Kafka を使います。1 台のサーバーでは、それが現在ほぼ唯一の採用理由です。
以降では、その判断理由を説明します。小規模な VPS で各選択肢がメモリとディスクに与える負荷、サーバーが再起動したときの動作、そしてユーザーが問題を感じる前に滞留を確認できる正確なコマンドを扱います。
実際の配送保証が意味すること
最大1回とは、ブローカーがメッセージを渡した後、そのメッセージを追跡しないことです。コンシューマーが接続されていない場合や、処理の途中でコンシューマーが停止した場合、メッセージは失われ、何も報告されません。
最低1回とは、処理が成功した後にコンシューマーが確認応答(ack)を送信することです。その ack が届くまで、ブローカーはメッセージを保持し、再度配信します。再配信が発生するため、ハンドラーはべき等でなければなりません。同じメッセージを2回処理しても、カードへの請求が2回発生してはいけません。エンドツーエンドでの厳密な1回限りの処理を、ブローカーだけで実現することはできません。これは、独自のデータベースに一意のキーを用意することで実現します。
リプレイは別の特性です。キューは、メッセージを確認応答済みにすると、そのメッセージを破棄します。ログは保持期間の間メッセージを保存するため、新しいコンシューマーは先頭から処理を開始し、すべての履歴を読み取れます。Kafka と NATS JetStream はログです。RabbitMQ はキューです。この違いは、スループットよりも多くのアーキテクチャに影響します。
デッドレタリングとは、繰り返し処理に失敗するメッセージを別の場所へ移すことです。これがないと、問題のあるメッセージが無限にループします。そのループは、壊れたワーカーではなく、ビジー状態のワーカーのように見えます。
Postgres から始め、ブローカーの必要性を実証させる
単一アプリケーションのワークロードの多くは、1 日数千件のバックグラウンドジョブです。これは 1 つのテーブルに収まります。
CREATE TABLE job (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
run_after timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);ワーカーはトランザクション内で 1 件のジョブを取得します。
BEGIN;
SELECT id, payload
FROM job
WHERE run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;FOR UPDATE SKIP LOCKED がすべての要です。取得する行をロックし、別のトランザクションがすでにロックしている行をスキップするため、2 つのワーカーが同じジョブを取得することはありません。ワーカーがクラッシュすると、Postgres はそのトランザクションを中止してロックを解放するため、その行は次のワーカーから見えるようになります。すでに利用している永続性の仕組みだけで、少なくとも 1 回の配信、attempts を増やすことによる再試行、デッドレターテーブルを実現できます。バックログの確認も 1 つのクエリで済みます。SELECT count(*) FROM job WHERE run_after <= now();
機能しなくなる場面もあります。取得と削除はどちらも書き込みであるため、ジョブの処理速度が高いと不要な行バージョンが残り、キューテーブルでは肥大化が autovacuum の処理速度を上回るという典型的な問題が起きます。長時間のジョブではさらに悪化します。処理時間いっぱいトランザクションを開いたままにすると、データベース全体の vacuum の基準点が進まなくなるためです。ポーリングには遅延が加わります。また、LISTEN と NOTIFY を使えばポーリングはなくせますが、書き込みはなくなりません。ジョブテーブルが最も頻繁に使用されるテーブルになった場合や、別のサービスが同じイベントを必要とする場合は、処理を外部に移します。この選択はデータベース自体のデプロイ方法にも関係するため、ブローカーを隣に追加する前に、データベースを Docker で実行するかホスト上で実行するかを決めてください。
Redis は、すでに実行している可能性があるもう 1 つの選択肢です。Redis Streams は、XADD と XREADGROUP によるコンシューマーグループ、グループごとの保留リスト、停止したコンシューマーから処理を引き取る XAUTOCLAIM を提供します。小規模で高速です。ただし、1 台構成での注意点があります。一般的な appendfsync everysec 設定では、電源障害によって約 1 秒分の書き込みが失われる可能性があります。キャッシュの無効化には問題ありませんが、決済には適していません。アプリケーションが VPS 上の本番環境で SQLite を使用する単一プロセスで構成されている場合も、同じ取得・削除パターンを使用できます。ただし、SQLite には SKIP LOCKED に相当する機能がなく、すべてのワーカーが 1 つの書き込みロックで直列化されます。
NATS core: メモリを使わない subject ルーティング
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
nats:2.14 -m 82222026 年 8 月時点で、現行のサーバー系列は 2.14 です。-m 8222 を有効にすると HTTP 監視ポートが開きます。このポートはデフォルトで無効で、認証もないため、前述のとおり localhost にバインドしてください。
Core NATS は最大 1 回配送で、何も保存しません。パブリッシャーが orders.created のような subject に送信すると、フィルターが一致するすべてのサブスクライバーにコピーが配信されます。購読者がいなければメッセージは破棄され、パブリッシャーにエラーは返りません。パブリッシャーの処理は、サーバーがバイト列を受け付けた時点で完了するためです。キューグループでは、複数のサブスクライバーが同じグループ名を共有します。サーバーはメッセージごとにメンバーを 1 つ選択します。これにより、キューを保存せずに処理を分担できます。
使用量は、サブスクリプションの状態と各接続の書き込みバッファーで決まります。そのため、メッセージ量ではなく接続数に応じて増えます。ディスク上にデータが蓄積されることもありません。再起動時の動作もこれに従います。処理中のメッセージは失われ、クライアントは自動的に再接続します。待機が必要な復旧手順はありません。
監視すべきバックログはありません。そのため、損失を監視します。サブスクライバーのソケット読み取りがサーバーの書き込みより遅いと、そのクライアント用のサーバーバッファーが満杯になります。書き込み期限までにクライアントが追いつかなければ、サーバーは接続全体を閉じ、カウンターをインクリメントします。
curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'値が増え続ける slow_consumers は、メッセージが破棄されていることを示します。そのため、1 回読み取るのではなく、アラートの対象にしてください。Core NATS は、短時間で価値が失われるメッセージに適しています。たとえば、メトリクス、プレゼンス更新、次のイベントで置き換えられるキャッシュ無効化などです。
NATS JetStream: 同一プロセスでの durable stream と replay
JetStream は別の製品ではありません。同じバイナリに含まれるサブシステムで、1 つの flag で有効化できます。
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
-v nats-data:/data \
nats:2.14 -js -sd /data -m 8222-sd /data は store directory を指定します。省略すると、JetStream は /tmp 配下にデータを保存します。名前のとおり、永続性はありません。CLI で stream を作成します。この CLI は nats-box image に含まれています。
docker run --rm -it --network host natsio/nats-box:latest \
nats stream add ORDERS \
--subjects 'orders.>' \
--storage file \
--retention limits \
--max-age 72h \
--max-bytes=1073741824 \
--discard old \
--defaultsここで指定する各制限は、小規模サーバーで重要な意味を持ちます。メモリ stream は crash 後に残らないため、--storage file がデータを保持します。--max-bytes=1073741824 は stream の上限を 1 GiB の byte count で設定し、--discard old は上限に達したときに新しい書き込みを拒否せず、最も古い message を削除します。上限を設定しないと、1 つの暴走した publisher が disk を使い切ります。その時点で、同じ disk を共有する database も停止します。
durable consumer は stream 内の自身の位置を保持し、restart 後もその位置を維持します。consumer に --max-deliver を設定すると、常に失敗する message が永遠に再配信されることを防げます。message の配信回数が上限に達すると、JetStream は $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> に advisory を publish します。この subject を subscribe すれば、RabbitMQ が機能として提供する dead letter path を構築できます。ただし、その処理は自分で実装する必要があります。
backlog を確認するには、保存された message 数に nats stream report を、consumer ごとの未処理の acknowledgement と未処理 message 数に nats consumer report ORDERS を実行します。監視対象にするべきなのは未処理 message 数です。disk 使用量は store directory に対して du -sh を実行すると確認でき、retention limit によって削除されるまで増加します。
RabbitMQ: 各メッセージを acknowledge し、失敗したメッセージを退避する
docker run -d --name rabbitmq \
-p 5672:5672 -p 127.0.0.1:15672:15672 \
-v rabbitmq-data:/var/lib/rabbitmq \
rabbitmq:4-management2026 年 8 月時点の現行シリーズは 4.3 です。ポート 5672 は AMQP(Advanced Message Queuing Protocol)、15672 は管理インターフェースです。管理インターフェースは localhost に限定し、SSH トンネル経由でアクセスします。
キューは x-queue-type 引数を quorum に設定して宣言します。デフォルトは引き続き classic です。Quorum queue は常に durable で、他の処理より先にデータをディスクへ書き込むため、1 ノード構成でも durable と transient の組み合わせを考慮せず、明確に 1 つの動作になります。dead-letter 先は policy で設定します。
docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
'{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
--apply-to queues --priority 7メッセージが dead-letter になる理由は 4 つあります。consumer が basic.reject または basic.nack で拒否し、requeue が false に設定されている場合、メッセージ単位の TTL(time to live)が期限切れになった場合、キューが長さの上限に達した場合、quorum queue の delivery limit を超えた場合です。RabbitMQ 4.0 以降、この上限のデフォルトは 20 です。そのため、handler が例外を発生させて nack する処理では、20 回リトライした後にメッセージがループせず dead-letter exchange に渡されます。
RabbitMQ は、小規模な VPS ではメモリ使用量によって予想外の動作をすることがあります。デフォルトの high watermark は利用可能な RAM の 0.6 です。node がこの値を超えると、RabbitMQ は publish 中のすべての接続をブロックします。アプリケーションにエラーは返りません。publish が戻らない状態になるため、アプリケーションコードではハングとして見えます。起動ログには node が計算した値が出力されます。
Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total空き容量がデフォルトの 50 MB を下回ると、ディスクアラームも同じように publisher をブロックします。Quorum queue には追加の計算も必要です。ドキュメントでは、メッセージごとに少なくとも 32 bytes のメモリ上のメタデータ、つまり 30,000 メッセージあたり約 1 MB を見積もり、さらに有効な write-ahead log サイズの少なくとも 3 倍の RAM を推奨しています。WAL の上限はデフォルトで 512 MiB なので、この推奨だけで 1.5 GB が必要です。2 GB のサーバーでは、デフォルトで収まると考えず、rabbitmq.conf で上限を下げます。
raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5バックログは 2 つの数値で表され、その組み合わせから発生している障害を判断できます。
docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledgedmessages_ready は consumer を待機しています。messages_unacknowledged は配信済みですが、ack されていません。unacknowledged の数が増加する一方で ready の数が横ばいの場合、worker がジョブを取得した後に処理を完了していません。単にキューの処理が遅れている場合とは別の問題です。
1 台のサーバーで Kafka を運用する場合と、採用すべきでなくなる境界
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.propertiesこれは、2026 年 8 月時点の Kafka 4.3.1 を KRaft モード(Kafka Raft。Kafka 4.0 で ZooKeeper に置き換わった組み込みコントローラー)で実行するクイックスタートです。コンテナで実行する場合は apache/kafka:4.3.1 です。
開始スクリプトは、export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" を自分で設定していない場合に設定します。そのため、ブローカーはメッセージを 1 件も保存する前に 1 GB の Java ヒープを確保し、読み取り元となるページキャッシュ用に、それとは別の空き RAM も必要とします。2 GB の VPS では、アプリケーションは残りのメモリを JVM と奪い合うことになります。
次に注意すべきなのが保持期間です。log.retention.hours のデフォルト値は 168 で、7 日間を意味します。log.retention.bytes のデフォルト値は -1 で、サイズ制限がないことを意味します。Kafka は、すべてのコンシューマーが読み取ったかどうかにかかわらず、設定された期間全体にわたってメッセージを保持します。これは Kafka を選ぶ理由となる機能ですが、小容量のディスクでは障害の原因にもなります。そのため、問題が発生する前にトピックごとのバイト数上限を設定してください。
ここからが重要な点です。ブローカーが 1 台だけの場合、レプリケーション係数は 1 です。そのため、acks=all は 1 台のディスクに対する 1 回の fsync になります。得られる耐久性は 1 台のマシンの耐久性であり、JVM ブローカーとコントローラーの運用コストも発生します。パーティションは、存在しない複数のブローカー間で並列性を確保する機能です。レプリケーション、ラック認識など、クラスタ全体向けの機能も利用されないままです。JetStream なら、同じサーバー上で同等の永続的な再生機能を、より少ないメモリで利用できます。それでも Kafka を選ぶ理由は 2 つあります。下流のツールが Kafka プロトコルにしか対応していない場合(Debezium による変更データキャプチャや、分析基盤へのローダーなど)と、本番トポロジーを小規模に再現する場合です。クラスタへの拡張を予定しているなら、追加のマシンを購入する計画が必要です。それまでは、1 台のノードで k3s を実行する場合と同じで、1 台分の信頼性に対してクラスタの複雑さを負担することになります。
Kafka におけるバックログは、コンシューマーラグです。
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-groupLAG 列を確認してください。これは各パーティションについて、LOG-END-OFFSET から CURRENT-OFFSET を引いた値です。他のパーティションが横ばいのまま 1 つのパーティションだけでラグが増加している場合は、キーの偏りが原因と考えられます。同じキーを持つすべてのメッセージは同じパーティションに配置され、1 つのコンシューマーだけが処理するためです。
再起動時の動作
Core NATS は、復旧対象がないため、処理中のデータをすべて失い、すぐに復帰します。JetStream はストアディレクトリからストリームとコンシューマーの位置を再読み込みするため、コンシューマーは停止前のオフセットから処理を再開します。RabbitMQ はディスクから quorum queue を復旧します。一方、classic の一時キューと、永続配信モードを指定せずに公開されたメッセージは失われます。Kafka は起動時にログセグメントを再生します。正常でないシャットダウンの後は、小容量のディスクでも復旧スキャンに数分かかることがあり、その間ブローカーは接続を受け付けません。
あらかじめ設定しておくべき点が2つあります。コンテナに再起動ポリシー(restart: unless-stopped)を設定するか、systemd の unit を有効にします。これにより、カーネルのアップグレード後に再起動しても、ブローカーが自動的に復帰します。次に、起動順序を処理します。アプリケーションの起動から20秒後にブローカーが ready になる構成では、最初の接続が拒否されます。一部のクライアントライブラリは再試行せずに終了します。ブローカーが ready になるまで依存サービスの起動を遅らせる Compose の healthcheckで、アプリケーションの起動をブローカーに連動させます。
自分の VPS におけるコストは、引用値ではなく実測で判断します
公開されているスループット値は、通常、ローカル NVMe を備えたマルチコアサーバーなど、手元にないハードウェアで測定されています。これらは上限値として扱い、自分の VPS で測定してください。
docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_dataまずブローカーがアイドル状態のときに実行し、次に実際のトラフィックを流した状態で再度実行します。両者の差が、ブローカーをアプリケーションと同じ環境で運用できるかどうかを判断する数値です。おおまかなスループットの下限を確認するには、他人のブログ記事ではなく、各プロジェクトが提供する負荷生成ツールを使用します。NATS には nats bench pub test --msgs 100000 --clients 2、Kafka には bin/kafka-producer-perf-test.sh、RabbitMQ には PerfTest を使用します。負荷生成ツールを同じ VPS で実行すると、ブローカーと負荷生成ツールを合わせた性能が測定されます。この点を数値の報告時に明記すれば問題ありません。
すべてのブローカーに共通する上限があります。ここで扱う永続化オプションはすべて fsync の完了を待機するため、ネットワーク接続ストレージを使用する VPS ではディスクが上限を決めます。ブローカーを変更しても、この上限は変わりません。
3つのワークロードと、それぞれに適したメッセージキュー
- 1つの Web アプリケーションで実行するバックグラウンドジョブです。メール送信、画像のリサイズ、webhook の配信などが該当します。まずは Postgres と
SKIP LOCKEDから始めます。メッセージごとの ack、配信回数の上限、検査可能な dead letter queue が必要になり、それらの処理を自前で実装したくない場合は、quorum queue を備えた RabbitMQ に移行します。ジョブテーブルがデータベースで最も頻繁にアクセスされるテーブルになった場合も同様です。
- 複数の内部サービスが反応するイベントです。メッセージを失っても、新しいメッセージですぐに置き換えられるものが該当します。ルーティングには subject を使い、処理の分担が必要な場合は queue group を使う Core NATS を選びます。再起動後も保持する必要がある一部の subject には JetStream stream を追加し、それ以外はメモリ上に置きます。
- コンシューマーが先頭から読み取るイベントログです。監査証跡、read model の再構築、後からの分析基盤への供給などに使います。file storage と明示的な byte 上限を設定した JetStream を選びます。下流のツールが Kafka プロトコルを必要とする場合に限り Kafka を選び、その互換性の対価として JVM heap を受け入れます。
1台のサーバーで誤った選択をした場合のコストは、スループットではありません。午前3時に復旧作業を行い、メッセージがまだ存在するかを確認しなければならないことです。その点を基準に選択してください。
FAQ
2 GB の VPS で Kafka を実行できますか?
起動はしますが、メモリに余裕がありません。bin/kafka-server-start.sh は明示的に変更していない場合に KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" を設定するため、メッセージを保存する前に JVM が 1 GB を確保します。さらに Kafka は、そのメモリとは別にページキャッシュ用の空きメモリを必要とします。同じサーバーでアプリケーションとデータベースも実行すると、swap が発生します。また、レプリケーション係数が 1 になるため、acks=all は 1 台のディスクで 1 回 fsync するだけです。つまり、Kafka の運用コストを負担しても、Kafka の耐久性モデルは得られません。同じハードウェアで、はるかに少ないメモリで永続的な再生を実現するなら、NATS JetStream の方が適しています。
すでに Postgres を運用している場合でも、メッセージキューは必要ですか?
多くの場合、必要ありません。トランザクション内で SELECT ... FOR UPDATE SKIP LOCKED を使って job テーブルを読み取ると、少なくとも 1 回の配信、安全な並行ワーカー、再試行、デッドレターテーブルを実現できます。監視する追加サービスは不要で、すでに取得しているバックアップも利用できます。別の仕組みに移行すべき兆候は明確です。キューテーブルが最も負荷の高い書き込み先になり、autovacuum が遅れている場合、長時間実行されるジョブがトランザクションを開いたままにしてデータベース全体の vacuum を妨げる場合、または別のサービスが同じイベントを独立して消費する必要がある場合です。
バックグラウンドジョブには NATS JetStream と RabbitMQ のどちらを使うべきですか?
メッセージごとの確認応答、配信制限、デッドレターへのルーティングを組み込みの動作として必要とするなら、RabbitMQ を使います。Quorum queue は常に永続化されます。配信制限の既定値は RabbitMQ 4.0 から 20 です。また、ポリシーによって、配信制限に達したメッセージをデッドレター交換に送信し、そこから取り出して調査できます。同じイベントを後で別のコンシューマーが再生する必要があるなら、JetStream を使います。ストリームは確認応答後もメッセージを保持しますが、キューは保持しないためです。JetStream では --max-deliver を設定し、$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> の advisory を基にデッドレター処理を自分で構築します。
コンシューマーの遅延状況を確認するにはどうすればよいですか?
各ブローカーには確認用のコマンドがあります。RabbitMQ では rabbitmqctl list_queues name messages messages_ready messages_unacknowledged により、コンシューマーを待っている処理と、配信済みだが確認応答されていない処理を分けて確認できます。JetStream では nats consumer report <stream> により、コンシューマーごとの未処理メッセージと未確認応答数を確認できます。Kafka では、kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> がパーティションごとに LAG 列を出力します。Core NATS には読み取るべきバックログがありません。何も保存しないためです。そのため、代わりに http://localhost:8222/varz の slow_consumers カウンターを監視します。この値は、コンシューマーの遅延を理由にサーバーが切断した接続数を示します。これはメッセージ損失を意味します。