Next.js Vercel KVでレートリミットとセッション管理の話
Vercel KVを使用したレートリミットとセッション管理の実装方法の説明です。ソースコードはこちらにあります。
Vercel KVとは
Redisというインメモリのキー・バーリュー ストア(DB)です。裏ではUpstashのRedisを使用しています。主にショッピングカートのようなセッション管理やレートリミットの実装に使用します。もちろん、キャッシュとしても利用できます。
レートリミットを設置する理由
例えば、OpenAI API
を使用したチャット機能をZennに追加するとします。その場合、30メッセージ/時間と制限を加えることで、クラウドの請求額にビクビクしないようにします。
インストール
App Router
方式で説明します。upstash
のレートリミットをインストールします。
yarn add npm i @upstash/ratelimit
コーディング
Upstashのレートリミット・ライブラリを使用した方法とスクラッチ実装の2通りを説明します。
middleware
にレートリミットを実装していきます。リクエストをインターセプトし、制限を超えた場合は429エラー(Too many requests)を返します。OKならそのままリクエストをディスパッチします。
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {Ratelimit} from "@upstash/ratelimit";
import {Redis} from "@upstash/redis";
// プロジェクトにKVを接続するときにプレフィックス(KV_)を選べます。
const redis = new Redis({
url: process.env.KV_REST_API_URL ?? 'expect redis url',
token: process.env.KV_REST_API_TOKEN ?? 'expect redis token'
});
// 10秒間隔のウィンドウ。一つのウィンドウで最大10リクエストまで許容。
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "10 s")
});
export async function middleware(request: NextRequest) {
const key = request.ip ?? "127.0.0.1"; // クライアントのIPでレートリミット
// レートリミットの判定
const resp = await ratelimit.limit(key);
console.log(`success: ${resp.success}, remaining: ${resp.remaining}, reset: ${resp.reset}`)
// リミットオーバーなら HTPステータス429で返す。
return resp.success ? NextResponse.next()
: new NextResponse("Too many requests", {status: 429});
}
yarn dev
で起動します。(ローカルからクラウドのVercel KVにアクセス可能です。)
vegeta
を使用して負荷をかけてみます。1秒に2リクエスト(rate=2)で負荷をかけてみましょう。
echo 'GET http://localhost:3000/' | vegeta attack -rate=2 -duration=30s | tee results.bin | vegeta report
結果です。
5秒後(2リクエストx5秒=10リクエスト)にリミットに到達し、429エラーでリクエストはリジェクトされます。10秒後にウィンドウがスライドし、リミットが10リクエストにリセットされます。
Vegetaの出力結果です。
スループットが約1rps
になっています。成功率も約50%になっています。このことからレートリミットが機能していることが分かります。
コマンドラインから作成されたRedisキーを確認できます。
redis-cli --tls -u redis://default:xxx@yyy.kv.vercel-storage.com:35306
自作版
スクラッチで実装してみました。RedisではZSCORE
というsorted set
が利用できます。リクエストのタイムスタンプでソートをすることでウィンドウ処理を実現できます。キーにはscoreというプロパティのようなものがあり、その値でソート処理します。そのscore値にタイムスタンプを使います。
import {kv} from '@vercel/kv';
export async function rateLimit(ip: string, maxRequests: number, windowInSecs: number): Promise<any> {
const now = Date.now();
const windowStart = now - windowInSecs * 1000;
const key = `rate:${ip}`;
try {
// ウィンドウからハズれたキーを消します。
await kv.zremrangebyscore(key, 0, windowStart);
// ウィンドウ内のキーの数を数えます。
const current = await kv.zcount(key, '-inf', '+inf');
// 最大リクエスト数を超えたらリジェクトします。
let resp = {success: current < maxRequests, remaining: maxRequests - current, reset: windowInSecs};
if (resp.success) {
// リクエストをredisに追加します。
await kv.zadd(key, {score: now, member: `timestamp:${now}`});
await kv.expire(key, windowInSecs);
}
return resp;
} catch (err) {
console.error('Redis error:', err);
return {};
}
}
同様にVegetaの実行結果です。
ご覧のように、ドンピシャでスループット1.00、成功率50%になりました。秒単位でスムーズにスライドしたためでしょう。
ちなみにリミッターを外すと綺麗にスケールします。
レイテンシーを見て分かるように、リミッター(Redis)を設置すると激おそになります。リミッターの部分のelapsed time
を測定してみました。
200msとかRedisではあり得ない遅さです。理由としては、まだVercel KVがベータ版であること、書き込み処理をしていることなど考えられます。また、VPNや時間帯でもレイテンシーが変わりました。ステーブル版がリリースされたらまた検証したいと思います。
Leacky Bucket
leacky bucket
アルゴリズムも実装してみました。その名の通り、バケツの底から水が漏れ出るイメージです。バケツから水が溢れる = リクエストをリジェクトするの意味になります。一定のスピードで水が減っていきます。スライディング・ウィンドウに比べるとスムーズなトラフィックになります。
import {kv} from '@vercel/kv';
interface Bucket {
level: number;
lastUpdated: number;
}
class LeakyBucket {
capacity: number; // バケツの容量
leakRate: number; // 水が漏れるスピード
constructor(capacity: number, leakRate: number) {
this.capacity = capacity;
this.leakRate = leakRate;
}
async increment(key: string) {
const currentTime = Date.now() / 1000;
// Update the bucket's last updated timestamp and its level
let bucket = await kv.get<Bucket>(key);
let level = 0;
let lastUpdated = currentTime;
if (bucket) {
level = bucket.level; // 水かさ
lastUpdated = bucket.lastUpdated; // 前回の計算のタイムスタンプ(秒)
}
// 前回計算した時からどれだけの量の水が漏れ出たか?
const delta = this.leakRate * (currentTime - lastUpdated);
level = Math.max(0, level - delta); // 抜けた分の水かさを減らす
// If the resulting level is less than the capacity, increment the level
let resp = {
success: level < this.capacity, // 水かさ < バケツの容量
remaining: this.capacity - level, // 満杯までどれくらい?
reset: 0
};
if (resp.success) {
await kv.set(key, {
level: level + 1, // 水かさを増やす
lastUpdated: currentTime
});
}
return resp;
}
}
export default LeakyBucket;
セッション管理
話題を変えまして、KVを使用したセッション管理の方法です。
以下の3つのプログラムから構成されます。
- app/api-cookie/route.ts APIです。クッキーがまだなければ仕込みます。
- app/components/mycookie.tsx クライアント・コンポーネント。補助的な部品
- app/mysession/page.tsx サーバ・コンポーネント。ショッピングカート本体
まずは、クッキーを仕込むロジックです。クッキー(MY_TOKEN)の存在チェックをし、なければセットします。このクッキーがショッピングカートを識別するセッションキーになります。
手抜きで、ここでカートにアイテムを追加しておきます。このセッションキーをRedisのキーにしてアイテムを追加します。
import {cookies} from 'next/headers';
import {v4} from 'uuid';
import {kv} from "@vercel/kv";
import {Cart, MY_TOKEN} from "@/app/myconst";
export async function GET(request: Request) {
const cookieStore = cookies();
const token = cookieStore.get(MY_TOKEN); // クッキーを取得する
const headers: { [key: string]: string } = {}
// まだクッキーがなければ、セットします。
if (!token) {
const key = v4();
headers['Set-Cookie'] = `${MY_TOKEN}=${key}; path=/; HttpOnly; SameSite=Strict`;
// 手抜きでここでカートにアイテムを仕込みます。
// 実際はボタンを設置して、サーバアクション等でカートに追加します。
const vals: Cart[] = [{id: "1", quantity: 3}, {id: "2", quantity: 4}, {id: "3", quantity: 5}]
await kv.set(key, vals);
}
return new Response('Hello, Next.js!', {
status: 200,
headers
});
}
cookieStore
はイミュータブルなのでセットはできません。HTTPレスポンスでセットします。
次に、クライアント・コンポーネントです。このコンポーネントは先ほどのAPIを叩くためのコンポーネントです。
'use client';
import {useEffect} from "react";
import {useRouter} from 'next/navigation';
export default function SessionCookie() {
const router = useRouter()
useEffect(
() => {
fetch('/api-cookie')
.then((res) => res.text())
.then((data) => {
console.log('data', data)
router.refresh()
}
)
}, []
)
return (
<div>
setting cookie...
</div>
)
}
routerでリフレッシュします。
最後に、サーバ・コンポーネントです。ここにショッピングカートを実装します。
クッキーからセッションキーを取得し、Redisからアイテム一覧を取得します。
もしキーがない場合は、先ほどのクライアント・コンポーネントをレンダリングし、クッキー作成のAPIを叩きます。
import dynamic from 'next/dynamic';
import {cookies} from 'next/headers';
import {kv} from "@vercel/kv";
import {Cart, MY_TOKEN} from "@/app/myconst";
const SessionCookie = dynamic(() => import('@/app/components/mycookie'), {ssr: false});
export default async function MySession() {
const cookieStore = cookies();
const token = cookieStore.get(MY_TOKEN);
let items: Cart[] = [];
// Redisからカートのアイテム一覧を取得する。
if (token?.value) {
items = await kv.get<Cart[]>(token.value) ?? [];
}
return (
<div>
{!token && <SessionCookie/>}
<div>
{items.map((item) => (
<div key={item.id}>
アイテムID {item.id} : {item.quantity}個
</div>
))}
</div>
</div>
)
}
Discussion
UpStashを直接叩く方法もあるみたいです
読んでいただきありがとうございます。サーバレスはスケールしてしまうので、従量課金だとヒヤヒヤです。個人で使うにはやはりインスタンスを自分で立ち上げた方が安心感はあるかなと感じます。