Node.js APIのレート制限とサーキットブレーカーによる強化
Wenhao Wang
Dev Intern · Leapcell

はじめに
現代のWebアプリケーションの世界では、APIはさまざまなサービスを接続し、ユーザーにデータを提供するバックボーンとして機能しています。しかし、APIのオープンな性質は、潜在的な脆弱性や過負荷にもさらされます。悪意のある攻撃、バグのあるクライアントサイドコード、あるいは正当であっても高トラフィックによるリクエストの急増が、Node.js APIを圧倒するシナリオを想像してみてください。これは、パフォーマンスの低下、サービスの利用不能、そして最終的にはユーザーエクスペリエンスの悪化につながる可能性があります。これらの課題に対処し、より回復力のあるシステムを構築するために、2つの強力なパターン、すなわちレート制限とサーキットブレーカーが登場します。この記事では、これらのメカニズムの重要性を探り、その根本原理を掘り下げ、Node.jsでの実装を実演し、さまざまな脅威からAPIをどのように保護できるかについて説明します。
コアコンセプトの説明
実装の詳細に入る前に、議論を定義するコアコンセプトを明確にしましょう。
- レート制限: これは、定義された時間枠内でユーザーまたはクライアントがAPIに対して行うことができるリクエストの数を制御するメカニズムです。その主な目的は、悪用を防ぎ、公平なリソース割り当てを確保し、APIが過負荷になるのを防ぐことです。これは、クラブの用心棒のようなもので、過密を防ぐために一度に一定数しか人を通しません。
- サーキットブレーカー: 電気のサーキットブレーカーに触発されたこのパターンは、失敗する可能性が高い操作を繰り返し実行しようとするのをシステムが防ぎます。失敗したサービスを継続的に叩く代わりに、サーキットブレーカーが開き、指定された期間、失敗したコンポーネントからトラフィックをそらします。タイムアウト後、クローズを試み、サービスが回復したかどうかを確認するために限られた数行のリクエストを許可します。これにより、カスケード障害を防ぎ、失敗したサービスが回復する時間を与えます。
レート制限の理解と実装
レート制限はAPIの安定性にとって非常に重要です。それがないと、単一のクライアントがサーバーリソースを独占し、他のすべてのユーザーに影響を与える可能性があります。その原則と、一般的なミドルウェアを使用したNode.jsでの実装を探りましょう。
レート制限の原則
レート制限は通常、特定のソース(IPアドレス、APIキー、またはユーザーIDによって識別される)からのリクエストを追跡し、時間枠内で定義された制限を超えた場合に後続のリクエストをブロックすることを含みます。一般的なアルゴリズムには次のようなものがあります。
- 固定ウィンドウカウンター: 固定時間ウィンドウのカウンターが維持されるシンプルなアプローチ。そのウィンドウ内のすべてリクエストがカウンターをインクリメントします。ウィンドウが期限切れになると、カウンターはリセットされます。
- スライディングウィンドウログ: この方法は、各リクエストのタイムスタンプのログを保持します。新しいリクエストが到着すると、ログ内の現在のウィンドウに収まるリクエストの数をチェックします。これにより、固定ウィンドウよりもスムーズな制限が提供されます。
- トークンバケット: リクエストはバケットから「トークン」を消費します。トークンは一定のレートで補充されます。バケットが空の場合、リクエストは拒否されます。これにより、平均レートを強制しながら、バーストトラフィックが可能になります。
Node.jsでのレート制限の実装
Node.jsの場合、express-rate-limit
は広く使用されている堅牢なミドルウェアです。Expressアプリケーションとの統合が簡単です。
まず、パッケージをインストールします。
npm install express-rate-limit
次に、Expressアプリケーションに実装します。
const express = require('express'); const rateLimit = require('express-rate-limit'); const app = express(); const port = 3000; // すべてのリクエストに適用 const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15分 max: 100, // 各IPをwindowMsごとに100リクエストに制限 message: 'Too many requests from this IP, please try again after 15 minutes', standardHeaders: true, // `RateLimit-*` ヘッダーにレート制限情報を返します legacyHeaders: false, // `X-RateLimit-*` ヘッダーを無効にします }); // 特定のルート、例えばログインエンドポイントに適用 const loginLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1時間 max: 5, // 各IPのログイン試行を1時間あたり5回に制限 message: 'Too many login attempts from this IP, please try again after an hour', handler: (req, res) => { res.status(429).json({ error: 'Too many login attempts, please try again later.' }); }, standardHeaders: true, legacyHeaders: false, }); // グローバルリミッターをすべてのルートに適用 app.use(globalLimiter); app.get('/', (req, res) => { res.send('Welcome to the homepage!'); }); app.post('/login', loginLimiter, (req, res) => { // ここにログインロジック res.send('Login successful!'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
この例では、globalLimiter
は15分ごとにIPごとに100リクエストのグローバル制限を適用します。loginLimiter
は、1時間あたりIPごとに5回のログイン試行のみを許可し、ルートの感度に基づいて制限を調整する方法を示しています。
レート制限のアプリケーションシナリオ
- DDoS対策: 単一IPからのリクエスト数を制限することで、単純なサービス妨害攻撃を緩和できます。
- ブルートフォース攻撃対策: ログイン試行やパスワードリセットリクエストを制限することで、攻撃者が認証情報を推測するのを防ぐのに役立ちます。
- API悪用対策: 単一クライアントが過剰なリソースを消費しないようにし、すべてのユーザーのサービス品質を維持します。
- コスト管理: リクエストごとにコストが発生するAPI(サードパーティサービスなど)の場合、レート制限は使用状況の管理に役立ちます。
サーキットブレーカーの理解と実装
レート制限は高トラフィックから保護しますが、サーキットブレーカーは失敗した依存関係から保護します。
サーキットブレーカーの原則
サーキットブレーカーは通常、3つの状態を持ちます。
- クローズ: これは初期状態です。リクエストは通常どおり通過します。障害が発生した場合、ブレーカーはそれらを監視します。障害率がしきい値を超えると、オープン状態に移行します。
- オープン: この状態では、保護された操作へのすべてリクエストはすぐに失敗します(ファストフェイル)。これにより、障害が発生しているサービスをさらに圧倒するのを防ぎ、呼び出し元にエラーを迅速に返します。設定可能なタイムアウト後、ハーフオープン状態に移行します。
- ハーフオープン: 限られた数のテストリクエストが保護された操作に許可されます。これらのリクエストが成功した場合、サーキットブレーカーはサービスが回復したとみなし、クローズ状態に戻ります。失敗した場合、オープン状態に戻り、タイムアウトを再開します。
Node.jsでのサーキットブレーカーの実装
Node.jsには、opossum
のようなサーキットブレーカーを実装するためのライブラリがいくつかあります。
まず、opossum
をインストールします。
npm install opossum
外部API呼び出しを保護するために使用する方法の例を次に示します。
const CircuitBreaker = require('opossum'); const axios = require('axios'); // HTTPリクエストに使用 // サーキットブレーカーのオプション const options = { timeout: 5000, // 関数が5秒以上かかる場合、失敗をトリガーします errorThresholdPercentage: 50, // リクエストの50%が失敗した場合、サーキットをトリップします resetTimeout: 10000, // 10秒後、サーキットを`half-open`に移動します }; // 失敗する可能性のある関数を定義します(例:外部API呼び出し) async function callExternalService() { console.log('Attempting to call external service...'); try { const response = await axios.get('http://localhost:8080/data'); // 外部サービスのエンドポイントに置き換えてください if (response.status !== 200) { throw new Error(`External service responded with status: ${response.status}`); } console.log('External service call successful!'); return response.data; } catch (error) { console.error('External service call failed:', error.message); throw error; // サーキットブレーカーに失敗を通知するために再スローします } } // 関数を中心にサーキットブレーカーを作成します const breaker = new CircuitBreaker(callExternalService, options); // ログ記録とデバッグのためにサーキットブレーカーイベントをリッスンします breaker.on('open', () => console.warn('Circuit breaker OPEN! External service is likely down.')); breaker.on('halfOpen', () => console.log('Circuit breaker HALF-OPEN. Probing external service...')); breaker.on('close', () => console.log('Circuit breaker CLOSED. External service recovered.')); breaker.on('fallback', (error) => console.error('Circuit breaker in fallback mode:', error.message)); // Expressルートでの使用例 const express = require('express'); const app = express(); const port = 3000; app.get('/protected-data', async (req, res) => { try { const data = await breaker.fire(); res.json(data); } catch (error) { // サーキットが開いている場合、このエラーはすぐにトリガーされます // または、基盤となるサービスが失敗しており、フォールバックが提供されていない場合 res.status(503).json({ error: 'Service temporarily unavailable. Please try again later.' }); } }); // テスト目的のダミー外部サービス const mockExternalService = express(); mockExternalService.get('/data', (req, res) => { // 障害を断続的にシミュレート if (Math.random() < 0.6) { // 60%の確率で失敗 console.log('Mock external service failing...'); return res.status(500).json({ message: 'Internal Server Error from mock service' }); } console.log('Mock external service succeeding...'); res.json({ message: 'Data from external service' }); }); mockExternalService.listen(8080, () => { console.log('Mock External Service listening on port 8080'); }); app.listen(port, () => { console.log(`Main API server listening at http://localhost:${port}`); });
この例では、breaker.fire()
は callExternalService
の実行を試みます。callExternalService
が頻繁に失敗すると(この場合は50%)、サーキットが開き、breaker.fire()
への後続の呼び出しは、正常でない外部サービスへの継続的な呼び出しを防ぐために、すぐにエラーをスローします。resetTimeout
後、ハーフオープン状態に移行し、サービスが回復したかどうかを確認するために数回の試行を行います。
サーキットブレーカーに fallback
関数を定義することもできます。これは、サーキットが開いている場合や、プライマリ関数が失敗して他にエラーハンドラがない場合に実行されます。これにより、機能の正常な低下が可能になります。
// ... (前のコード) ... // フォールバック関数を追加 breaker.fallback(async () => { console.log('Using fallback data!'); return { message: 'Fallback data: Service is currently unavailable, but here is some cached info.' }; }); // ... (残りのコード) ...
サーキットブレーカーのアプリケーションシナリオ
- マイクロサービスアーキテクチャ: 相互接続されたサービス間のカスケード障害を防ぐために不可欠です。1つのマイクロサービスがダウンしても、システム全体がダウンすることはありません。
- サードパーティAPI統合: 依存している外部サービスの停止やパフォーマンス低下からアプリケーションを保護します。
- データベース接続: アプリケーションが応答しないデータベースへのクエリを継続的に再試行するのを防ぎます。
- リソース保護: 失敗したサービスが一時的にリクエストを停止させることで回復する機会を与えます。
結論
レート制限とサーキットブレーカーの両方を実装することは、単なるベストプラクティスではなく、堅牢でスケーラブルで回復力のあるNode.js APIを構築するための基本的な要件です。レート制限はAPIの最前線の防御として機能し、公平な使用を保証し過負荷を防ぎ、サーキットブレーカーは失敗した依存関係に対する重要な回復力を提供し、カスケード障害を防ぎ正常な低下を促進します。これらのパターンを戦略的に適用することで、アプリケーションの安定性と信頼性を大幅に向上させ、逆境条件下でもユーザーに一貫した肯定的なエクスペリエンスを提供できます。これらのパターンでAPIを保護することは、長期的な運用成功への投資です。