nisshiee.org

Nostrリレーv2を作った話

2026-03-07

前回のあらすじ

前回の記事では、サーバーレスでNostrリレーを作ろうとした話を書いた。

要約するとこうだ:

  • AWS Lambda + DynamoDB + API Gateway WebSocket APIでリレーを作った
  • DynamoDBのScanだと検索がつらい → OpenSearch Serviceを入れたけど個人リレーにはオーバーバジェット
  • SQLite on EC2に落ち着くだろう、と予告して終了

で、実際にSQLiteに移行した。検索はうまくいった。コストも下がった。めでたしめでたし・・・と思ったら、もっと致命的な問題が見つかった。

API Gateway WebSocket APIが、連続メッセージ送信に耐えられなかったのだ。

Nostrの REQ メッセージを受けると、リレーはストレージ内のヒットしたイベントを全部返す必要がある。イベントが100件あれば100回メッセージを送る。API Gateway WebSocket APIはこの「短時間に大量のメッセージをクライアントに送信する」ユースケースに耐えられなかった。エラーが返って、REQに応答できない(できる確率が極めて低い)という致命的な状態だった。

というわけで、AWS API Gatewayを使ったNostrリレーのサーバーレス実装という企画は失敗という結論になった。

遊びなのでリレーを閉じてここで終了でも良かったんだけど、稼動しているNostrリレーは多いに越したことはないので、コンセプト変えてv2をやることにした。

v2のアーキテクチャ

というわけで、v2はこうなった。

v2のアーキテクチャ図

EC2 (t4g.nano) の上で axum の WebSocket サーバーを動かし、CloudFront経由で公開する。イベントの永続化にはDynamoDBを使う。

シンプル。

気になるお値段は月額 ~$3.99 。内訳はこんな感じ:

リソース月額
EC2 t4g.nano$3.80
EBS gp3 2GB$0.19
DynamoDB無料枠内
CloudFront無料枠内
合計~$3.99

OSはAlpine Linux。軽量なのでt4g.nano(512MB RAM)でもメモリに余裕があるし、EBSも2GBで済む。v1はOpenSearchをやめてSQLite on EC2にした時点でも月約$20だったので、v2で約1/5になった。

DynamoEventStoreの設計

v2の一番面白いところはDynamoEventStoreの設計だと思う。

2層構造

InMemoryEventStoreをホットキャッシュ、DynamoDBをSingle Source of Truth(SSoT)とする2層構造にした。

クライアント ← REQ応答 ← InMemory(ホットキャッシュ)
                              ↕ 同期
                         DynamoDB(SSoT)

v1の運用実績から、アクセスを日本に絞ると大したイベント数にならないことは分かっていた。現状のデータ量では、InMemoryに全イベントデータを保持しても数十MBで済むし、REQ応答するための検索は全件スキャンでも一瞬であった。とはいえ、流石に「メモリにしかデータはありません」では消えてしまうし、時間とともに蓄積されるイベントや増える(可能性が無いとはいえない)Nostrユーザーに無策というわけにはいかないので、DynamoDBとの2層構造にすることにしたわけ。v1で蓄積したイベントがDynamoDBに入っているのでそのまま使えるというメリットもあった。

パージ前提の整合性設計

ここが設計の肝。将来的にはInMemoryストアから古いイベントをパージしていくことを想定している(今はまだ未実装)。パージが入ると「DynamoDBにはあるけどInMemoryにはない」という状態が常態化する。

REQ応答はInMemoryのみから検索するので、パージされた古いイベントはREQで取得できない。これは許容する。Nostrの世界では、リレーが全イベントを永久に保持する義務はない。

一方で、整合性として守りたいのは「クライアントに対して矛盾した応答をしない」こと。具体的には、イベントを受け取ったときの返答(新規なのか重複なのか)と、その後のREQ結果が辻褄の合う状態を維持したい。これを実現するために、イベントの種類ごとに保存フローを分けた。

Regularイベント

Nostrのプロトコル上、リレーはイベントを受け取ったとき、そのイベントを既に持っていたのか新規だったのかを返答する必要がある。

ここでのポイントは、重複チェックをInMemoryだけで行うこと。DynamoDBにはクエリしない。InMemoryにあれば重複、なければ新規扱いだ。パージで古いイベントがInMemoryから消えた後に同じイベントが再度来たら、DynamoDBには既にあるが新規として OK を返す。DynamoDBへのPutItemは冪等なので矛盾しない。

  1. InMemoryで重複チェック
  2. InMemoryにあれば → 重複(duplicate
  3. InMemoryになければ → DynamoDBに保存 → InMemoryに保存 → 新規(OK

Replaceable / Addressableイベント

こっちが厄介。Replaceableイベント(kind 0のプロフィールとか)は、同じpubkey+kindの組み合わせで最新のものだけが有効になる。Addressableも同様に、pubkey+kind+dタグで一意。

Replaceable/Addressableイベントの保存時はDynamoDBのGSIでクエリして既存イベントを確認する。

  1. DynamoDBでGSIクエリ(pk_kind or pk_kind_d)して既存イベントを取得
  2. 既存の方が新しい → 新イベントを無視(ただし、既存イベントをInMemoryに復元する)
  3. 新イベントの方が新しい → DynamoDBで古いイベントを削除 → 新イベントを保存 → InMemoryに保存

ステップ2の「既存イベントをInMemoryに復元する」がミソ。パージでInMemoryから消えていた既存イベントが、新イベントの保存をきっかけに復元される。これがないと、「古いイベントを無視した」と返答したのに、その後のREQでは最新イベントすら返せない(InMemoryにないから)という矛盾が起きる。復元することで、REQに対してちゃんと最新の状態を返せるようになる。

起動時のロード

InMemoryはプロセス再起動で空になるので、起動時にDynamoDBから直近のイベントをロードする。ただし、DynamoDBのScanはコストが高く、無料枠の範囲に収めるにはページ間にかなりのディレイを入れる必要がある。現状、ロード完了まで約20分かかる。

となると、ロードが終わるまで接続を受け付けないという選択肢は現実的ではない。そこで、ロードはバックグラウンドで行い、リレー自体は即座に接続を受け付ける設計にした。ロード完了までのREQは不完全な結果を返す可能性があるが、個人リレーなのでそこは割り切っている。

ちなみに、ロード中にイベントを受信した場合の整合性はどうなるかというと、これはパージの議論にそのまま帰着する。ロード途中のInMemoryは「まだ古いイベントが入っていない」状態であり、パージで古いイベントが消えた状態と本質的に同じだからだ。上で設計したフローがそのまま機能するので、ロードのために特別な処理は不要だった。

エピローグ

そういえば前回「来年のアドベントカレンダーに期待しよう」と書いたApple Watchの件。まだ何もできてない。DynamoDBにイベントが永続化されるようになったので土台は整ったはずなんだが・・・。

まぁ、v2のリレーがちゃんと安定稼動しているので、今のところはそれで良しとしよう。引き続き以下のURLで稼動しているので、使ってやってください。

wss://relay.nostr.nisshiee.org