メインコンテンツまでスキップ

Momentoを使った分散型集中型レートリミッターの構築

レート制限とは何か?

レート制限とは、ネットワーク・トラフィックを制限するための戦略です。一定の時間枠内で誰かがアクションを繰り返せる回数に上限を設けます。あなたがツイッターのニュースフィードを見ていようと、ライブビデオをストリーミングしていようと、レートリミッターとやりとりしている可能性はゼロではありません。レートリミッターはあなたを監視し、あなたのトラフィックについて決定を下し、あなたがあまりに騒ぎ始めると正当にあなたを停止させます。

レートリミッターの用途は?

レート制限の必要性は、あらゆるサービスの健全性と品質を維持するという基本的な要件に由来します。これがなければ、リソースは簡単に過負荷になり、サービスの低下や完全な失敗につながります。これは、分散システム、ウェブサービス、マルチテナント・アプリケーションなど、クライアントからのリクエストの量や頻度が大きく変化するアプリケーションでは特に重要です。また、分散型サービス拒否(DDoS)攻撃など、ある種のサイバー攻撃を防御する上でも重要な要素となります。

レート制限の一般的な使用例には以下のようなものがあります:

  • API管理: さまざまなAPIを提供するプラットフォームでは、単一のユーザーやサービスが帯域幅を独占するのを防ぎ、すべてのユーザーがリソースに公平にアクセスできるようにするために、レート制限が極めて重要です。

  • Eコマースサイト ブラックフライデー・セールのようなトラフィックの多いイベントの際、レート制限を行うことでユーザーリクエストの流入を制御し、ウェブサイトのクラッシュを防ぐことができます。

  • オンラインゲームサーバー レート制限は、プレイヤーが一定時間内に実行できるアクションの数を制限することで、不正行為を緩和し、公平な競技場を確保し、ゲームの整合性を維持するのに役立ちます。

Momentoを使った分散型レートリミッターの構築

分散レートリミッターを作成し、個々のユーザーのトランザクション/分(TPM)を効果的に管理したいとします。 私たちのアプローチは、MomentoのincrementupdateTTL APIを利用すします。この方法は効率的であるだけでなく、非常に正確であることが証明されています。

私たちのレートリミッターの中核をなすのは、ユーザー毎分の粒度に基づいてレート制限を行うことを可能にするキーメカニズムです。キーは、ユーザーまたはエンティティの組み合わせと現在の分を使用して構築されます。このキーは、ユーザーが1分間に行えるトランザクション数を追跡し、制限する上で極めて重要な役割を果たします。

レートリミッターは、各ユーザがリクエストしたときに、各ユーザのユニーク キーの値をインクリメントし、1分間の最初のリクエストのTTL(time-to-live) を60秒に設定します。これは重要です。キーは、与えられた1分間の目的を 果たした後は意味がないので、期限切れにしたいからです。

レートリミッターの流れはこうです:

  • user_id-current_minuteの値をインクリメントします。返された値が 1 の場合、その分におけるそのユーザの最初のリクエストであることを示します。Momento の increment API はアトミックであることが保証されています。この戻り値が 1 の場合、updateTTL` API を使用してそのキーの TTL を 60 秒に設定します。
  • もしその値が、レートリミッター用に設定されたTPMの制限値より小さければ、 リクエストを許可し、そうでなければスロットルをかけます。

早速、実装に取りかかりましょう。このコードでは、思考プロセスを説明するコメントに注目してください。

import {
CacheClient,
CacheIncrementResponse,
CacheUpdateTtlResponse,
Configurations,
CreateCacheResponse,
CredentialProvider,
} from '@gomomento/sdk';

// since our rate limiting buckets are per minute, we expire keys every minute
export const RATE_LIMITER_TTL_MILLIS = 60000;

export class MomentoRateLimiter {
_client: CacheClient;
_limit: number;
_cacheName: string;

constructor(client: CacheClient, limit: number, cacheName: string) {
this._client = client;
this._limit = limit;
this._cacheName = cacheName;
}

/**
* Generates a unique key for a user (baseKey) for the current minute. This key will server as the backend
* cache key where we will store the amount of calls that have been made by a user for a given minute.
* @param baseKey
*/
generateMinuteKey(baseKey: string): string {
const currentDate = new Date();
const currentMinute = currentDate.getMinutes();
return `${baseKey}_${currentMinute}`;
}

public async isLimitExceeded(id: string): Promise<boolean> {
const currentMinuteKey = this.generateMinuteKey(id);
// we do not pass a TTL to this; we don't know if the key for this user was present or not
const resp = await this._client.increment(
this._cacheName,
currentMinuteKey
);

switch (resp.type) {
case CacheIncrementResponse.Success: {
if (resp.value() <= this._limit) {
// if returned value is 1, we know this was the first request in this minute for the given user. So
// we set the TTL for this minute's key to 60 seconds now.
if (resp.value() === 1) {
const updateTTLResp = await this._client.updateTtl(
this._cacheName,
currentMinuteKey,
RATE_LIMITER_TTL_MILLIS
);
switch (updateTTLResp.type) {
case CacheUpdateTtlResponse.Set:
break;
case CacheUpdateTtlResponse.Miss:
case CacheUpdateTtlResponse.Error: {
console.error(
`Failed to update TTL; this minute's user requests might be overcounted, key: ${currentMinuteKey}`
);
break;
}
}
}
return false;
}
break;
}

case CacheIncrementResponse.Error:
throw new Error(resp.message());
}

return true;
}
}

async function main() {
const cacheClient = await CacheClient.create({
configuration: Configurations.Laptop.v1(),
credentialProvider:
CredentialProvider.fromEnvironmentVariable('MOMENTO_API_KEY'),
defaultTtlSeconds: 60,
});

const tpmLimit = 1;
const cacheName = 'rate-limiter';

const createCacheResp = await cacheClient.createCache(cacheName);
switch (createCacheResp.type) {
case CreateCacheResponse.AlreadyExists:
console.log(`${cacheName} cache already exists`);
break;
case CreateCacheResponse.Success:
console.log(`${cacheName} cache created`);
break;
case CreateCacheResponse.Error:
throw new Error(createCacheResp.message());
}

const momentoRateLimier = new MomentoRateLimiter(
cacheClient,
tpmLimit,
cacheName
);

const limitExceeded = await momentoRateLimier.isLimitExceeded('user-id');
if (!limitExceeded) {
// do work for user
console.log('Successfully called work and request was allowed');
} else {
console.warn('Request was throttled');
}
}

main()
.then()
.catch((err: Error) => console.error(err.message));

私たちはもっと多くを望んでいます!

  • SDKのサンプルを使ってMomentoのレートリミッターを操作し、競合をシミュレートしたり、レートリミッターにリクエストをスロットルさせたりすることができます。
  • ブログを読むでは、レートリミッターのさまざまなヒューリスティックを分析し、他のアプローチとも比較しています。