はじめに — インメモリのレート制限が効かない
API にレート制限を入れるのは、セキュリティの基本中の基本です。悪意のあるリクエストや、バグによる無限ループからサービスを守るために不可欠な仕組みです。
ところが、Vercel のようなサーバーレス環境では、よくあるインメモリ方式のレート制限がまったく機能しないことがあります。IdeaSpool のコードレビューでこの問題に気づいたとき、「これは根本的に設計を変えないとまずい」と思いました。
背景 — なぜインメモリでは駄目なのか
IdeaSpool は Next.js + Supabase で構築し、Vercel にデプロイしているプロダクトです。初期実装では、レート制限をこのようにインメモリで管理していました:
// ❌ インメモリ方式(分散環境で機能しない)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 });
return true;
}
entry.count++;
return entry.count <= 10;
}
一見シンプルで問題なさそうに見えます。しかし、Vercel のサーバーレス環境では、リクエストごとに異なるインスタンスが起動する可能性があります。インスタンスAで記録したリクエスト数は、インスタンスBからは見えません。
2026年1月26日の開発日記にはこう書いています:
レート制限がインメモリだと分散環境で機能しない
この一文が、設計を見直すきっかけでした。
選択肢の検討 — Redis か Supabase か
レート制限を永続化するための選択肢は、大きく2つありました。
Redis(Upstash など)
分散環境でのレート制限といえば、Redis が定番です。Vercel との相性が良い Upstash Redis なら、サーバーレス対応の Redis をすぐに使い始められます。
メリット:
- 高速(インメモリデータストア)
- TTL(自動期限切れ)がネイティブサポート
INCR+EXPIREで原子的なカウントが可能
デメリット:
- 新たな外部サービスが増える
- 無料枠はあるが、いずれコストが発生する
- 監視・管理する対象が増える
Supabase テーブル
すでに使っている Supabase のデータベースにレート制限用のテーブルを作る方法です。
メリット:
- インフラを増やさない
- 既存の Supabase クライアントで操作できる
- データの分析・デバッグが容易(SQL で直接確認できる)
デメリット:
- Redis より遅い(ディスクベース)
- TTL の自動削除は自前で実装が必要
- 高トラフィック時のスケーラビリティ
開発日記にはこう記録しています:
レート制限の永続化方法
- Redis を使うか Supabase のテーブルを使うか
- → インフラを増やしたくなかったので Supabase テーブルを選択
- rate_limits テーブルに IP + アクション種別で記録
この判断に至るまでに、Claude Code とこんなやり取りがありました。
Zeronova
レート制限がインメモリだと Vercel の分散環境で機能しないことがわかった。Redis(Upstash)を入れるのが定石だと思うけど、IdeaSpool のためだけに新しいインフラを増やしたくない。Claude CodeSupabase をすでに使っているなら、rate_limits テーブルを作って永続化する方法があります。Redis と比べるとレイテンシは 30-80ms 程度になりますが、IdeaSpool の規模なら十分です。Zeronova
パフォーマンスが心配だけど、API 全体のレスポンスが 200-500ms なら誤差の範囲か。テーブル設計はどうする?カウンターを UPDATE する方式と、行を INSERT する方式があると思うが。Claude CodeINSERT 方式をおすすめします。複数のサーバーレスインスタンスが同時にカウンターを UPDATE すると競合状態が起きます。INSERT なら競合しません。ウィンドウ内の COUNT で判定すれば、スライディングウィンドウ方式も自然に実現できます。
この対話で「Supabase テーブル + INSERT 方式」という設計が決まりました。結果的に、運用開始から問題なく稼働しており、インフラを増やさなかった判断は正解でした。規模が大きくなれば Redis に移行するオプションは残しています。
テーブル設計
Claude Code と相談しながら設計した rate_limits テーブルはこうなりました:
create table rate_limits (
id uuid primary key default gen_random_uuid(),
ip_address text not null,
action text not null,
requested_at timestamptz not null default now()
);
-- 検索用インデックス
create index idx_rate_limits_lookup
on rate_limits (ip_address, action, requested_at);
ポイントは以下の3つです:
- IP + アクション種別で記録: 同じIPでも、API ごとにレート制限を分けられる
- リクエストごとに1行挿入: カウンターを更新するのではなく、行を追加する方式
- timestamptz で記録: ウィンドウ内のリクエスト数を
COUNTで取得できる
「カウンターの更新」ではなく「行の挿入」を選んだ理由は、競合状態(race condition)を避けるためです。複数のサーバーレスインスタンスが同時にカウンターを UPDATE すると、正確なカウントにならない可能性があります。INSERT なら競合しません。
実装 — スライディングウィンドウ方式
レート制限のアルゴリズムには、スライディングウィンドウ方式を採用しました。「直近N秒間のリクエスト数」をカウントし、閾値を超えたら拒否する方法です。
// ✅ Supabase テーブル方式
async function checkRateLimit(
ip: string,
action: string,
limit: number,
windowMs: number
): Promise<boolean> {
const windowStart = new Date(Date.now() - windowMs).toISOString();
// ウィンドウ内のリクエスト数を取得
const { count } = await supabase
.from("rate_limits")
.select("*", { count: "exact", head: true })
.eq("ip_address", ip)
.eq("action", action)
.gte("requested_at", windowStart);
if ((count ?? 0) >= limit) {
return false; // レート制限超過
}
// リクエストを記録
await supabase
.from("rate_limits")
.insert({ ip_address: ip, action });
return true;
}
呼び出し側では、アクション種別ごとに異なるレート制限を設定できます:
// 外部フェッチ系: 10回/分
const allowed = await checkRateLimit(ip, "fetch", 10, 60_000);
// 画像生成系: 30回/分
const allowed = await checkRateLimit(ip, "generate", 30, 60_000);
古いレコードの削除
レート制限テーブルにはリクエストのたびに行が追加されるため、放置するとデータが際限なく増えます。古いレコードの削除には、Supabase の pg_cron 拡張を使った定期削除が有効です。
-- 1時間以上前のレコードを削除(1時間ごとに実行)
select cron.schedule(
'clean-rate-limits',
'0 * * * *',
$$delete from rate_limits
where requested_at < now() - interval '1 hour'$$
);
pg_cron が使えない環境であれば、レート制限チェック時に古いレコードを一緒に削除する方法もあります:
// チェックのついでにクリーンアップ
await supabase
.from("rate_limits")
.delete()
.lt("requested_at", new Date(Date.now() - 3600_000).toISOString());
ただし、毎リクエストで DELETE を実行するのはオーバーヘッドになるため、確率的に実行する(10リクエストに1回など)工夫が必要です。
RLS(Row Level Security)の設定
レート制限テーブルは API route(サーバーサイド)からのみアクセスするため、RLS は厳格に設定します。
alter table rate_limits enable row level security;
-- クライアントからの直接アクセスを完全に拒否
-- API route では service_role キーを使用
RLS をデフォルト拒否にすることで、万が一クライアントサイドから rate_limits テーブルにアクセスしようとしても、データが露出することはありません。この設計パターンは、Supabase RLS の設計パターンで詳しく解説しています。
パフォーマンスの考慮
「Supabase(PostgreSQL)で毎リクエスト問い合わせて遅くないのか?」という疑問は当然です。
実測値で言うと、レート制限のチェック(SELECT COUNT + INSERT)にかかる時間は 30〜80ms 程度でした。Redis の 1〜5ms と比べると確かに遅いですが、API のレスポンス全体が 200〜500ms のオーダーであることを考えると、許容範囲内です。
| 方式 | レイテンシ | 向いているケース |
|---|---|---|
| インメモリ | < 1ms | 単一サーバー、開発環境 |
| Redis | 1-5ms | 高トラフィック、厳密なレート制限 |
| Supabase テーブル | 30-80ms | 中小規模、インフラ簡素化を優先 |
IdeaSpool のようなプロダクトでは、月間数万リクエストのオーダーです。この規模であれば Supabase テーブル方式で十分にカバーできます。
この設計が合わないケース
正直に書くと、この方式は万能ではありません。以下のケースでは Redis や専用のレート制限サービスを検討すべきです:
- リクエスト数が秒間100を超える: PostgreSQL への書き込みがボトルネックになる
- ミリ秒単位の精度が必要: DB のラウンドトリップが許容できない
- グローバル分散: 複数リージョンからのアクセスでレイテンシが問題になる
個人開発や中小規模のプロダクトであれば、「すでに使っているデータベースに寄せる」戦略は合理的です。規模が大きくなったら Redis に移行すればよく、テーブル設計の考え方(IP + アクション種別 + タイムスタンプ)はそのまま流用できます。
関連記事
レート制限はセキュリティ設計の一部です。関連するトピックも合わせてどうぞ:
- Supabase RLS の設計パターン — デフォルト拒否で安全に — データベース層のアクセス制御
- 個人開発でもやるべきセキュリティ監査チェックリスト — レート制限を含むセキュリティ全般
- Cloudflare Turnstile で認証なしフォームをボットから守る — ボット対策の別アプローチ
まとめ
インメモリのレート制限が分散環境で機能しない問題に対して、Supabase テーブルで永続化するアプローチを採用しました。
持ち帰りポイント:
- サーバーレス環境ではインメモリのレート制限が機能しない — インスタンス間で状態が共有されない
- Redis がなくても、既存のデータベースでレート制限は実装できる — INSERT 方式で競合状態を回避
- 「インフラを増やさない」は個人開発で合理的な判断 — 規模が大きくなったら Redis に移行すればよい
- IP + アクション種別 + タイムスタンプの設計は汎用的 — 方式を変えても設計思想は流用できる
IdeaSpool のコードレビューで Claude Code に指摘されなければ、この問題に気づくのはもっと遅くなっていたかもしれません。セキュリティの問題は「動いている」だけでは見つからない — 定期的なコードレビューの重要性を改めて実感した出来事でした。
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。