Node.jsのAsyncLocalStorageによる非同期チェーンでのリクエストIDの安全な伝播
James Reed
Infrastructure Engineer · Leapcell

はじめに
現代のマイクロサービスアーキテクチャや複雑なNode.jsアプリケーションでは、単一のユーザーリクエストが、複数の関数、モジュール、さらには外部サービスにまたがる非同期操作の連鎖を引き起こすことがよくあります。これらの操作が展開されるにつれて、コンテキスト、特に初期リクエストの一意の識別子を維持することが、効果的なロギング、デバッグ、およびトレーシングのために不可欠になります。リクエストのジャーニーを一貫した方法で追跡する手段がないと、個別のログからイベントのシーケンスをまとめることは困難な課題となり、デバッグ時間の延長とシステムオブザーバビリティの低下につながります。明示的なパラメーター渡しを伴う従来の С abordagemはすぐに煩雑でエラーを引き起こしやすくなり、関数シグネチャを乱雑にし、関心の分離を侵害します。ここでNode.jsのAsyncLocalStorageが登場し、非同期コールチェーン全体にリクエスト固有のデータを安全に伝播するための強力でエレガントなソリューションを提供します。
非同期コンテキストとAsyncLocalStorageの理解
実践に入る前に、主要な概念について共通の理解を確立しましょう。
非同期コンテキスト
Node.jsでは、実行フローは本質的に非同期です。ネットワークリクエスト、ファイルI/O、データベースクエリなどの操作は、メインスレッドをブロックしません。代わりに、後で実行されるコールバックをスケジュールします。このノンブロッキングの性質がNode.jsを効率的にしていますが、コンテキスト管理には課題も生じます。関数が非同期操作を開始すると、awaitの後やコールバックで実行される後続のコードは、イベントループの異なる「ティック」で実行される可能性があり、元の呼び出しのコンテキストを失う可能性があります。
リクエストID
リクエストIDは、各受信リクエスト(例:HTTPリクエスト)に割り当てられる一意の識別子です。このIDは相関キーとして機能し、その特定のリクエストの処理の一部として実行されたすべてのログと操作をリンクします。分散トレーシングと根本原因分析に不可欠なツールです。
AsyncLocalStorage
AsyncLocalStorageは、非同期操作全体でコンテキストを管理するために導入されたコアNode.js APIです。非同期コールチェーンのローカルスレッドストレージと考えてください。非同期コンテキストにローカルなデータを保存できます。このデータは、await呼び出し、setTimeout、Promise、およびその他の非同期境界を介して自動的に伝播されます。これは、AsyncLocalStorageインスタンスを起動し、値を保存でき、そのコンテキスト内の後続の非同期操作は、明示的に渡すことなく、その同じ値にアクセスできることを意味します。
AsyncLocalStorageの仕組み
AsyncLocalStorageは、Node.jsの内部非同期フックを活用してその魔法を実現します。asyncLocalStorage.run(store, callback, ...args)を呼び出すと、新しい非同期コンテキストが作成されます。callback内で開始された非同期操作は、このコンテキストを継承します。つまり、asyncLocalStorage.getStore()は、提供したstoreオブジェクトを返します。その非同期操作が完了すると、コンテキストは自動的にロールバックされます。このコンテキスト伝播は、すべての関数シグネチャを変更したり、コンテキストオブジェクトを明示的に渡したりする必要なしに機能します。
AsyncLocalStorageによるリクエストID伝播の実装
HTTPリクエストのライフサイクル全体でリクエストIDを伝播するためにAsyncLocalStorageを使用する方法を説明しましょう。
基本セットアップ
まず、AsyncLocalStorageをインポートする必要があります。
const { AsyncLocalStorage } = require('async_hooks'); // AsyncLocalStorageのインスタンスを作成 const asyncLocalStorage = new AsyncLocalStorage();
ミドルウェアアプローチ
HTTPリクエストとAsyncLocalStorageを統合する最も一般的で効果的な方法は、ミドルウェア(例:Express.jsアプリケーション)を介して行うことです。ミドルウェアは、次の担当となります。
- リクエストIDを生成または抽出する。
- asyncLocalStorageコンテキスト内で残りのリクエスト処理ロジックを実行し、リクエストIDを保存する。
const express = require('express'); const { AsyncLocalStorage } = require('async_hooks'); const crypto = require('crypto'); // 一意のIDを生成するため const app = express(); const asyncLocalStorage = new AsyncLocalStorage(); // リクエストIDを割り当てて伝播するミドルウェア app.use((req, res, next) => { // 各受信リクエストに一意のリクエストIDを生成 const requestId = crypto.randomBytes(16).toString('hex'); // AsyncLocalStorageコンテキストにrequestIdを保存 asyncLocalStorage.run({ requestId: requestId }, () => { // 簡単なアクセスのため、リクエストオブジェクトにアタッチ(オプションですが便利) req.requestId = requestId; console.log(`[${requestId}] Incoming Request: ${req.method} ${req.url}`); next(); // 次のミドルウェアまたはルートハンドラに進む }); }); // 非同期操作をシミュレートするサンプルサービス const someService = { doSomethingAsync: async () => { await new Promise(resolve => setTimeout(resolve, 100)); // 非同期作業をシミュレート const store = asyncLocalStorage.getStore(); console.log(`[${store?.requestId || 'N/A'}] Service doing something asynchronously.`); return 'Operation completed!'; }, // doSomethingAsyncを呼び出す可能性のある別の非同期操作 doAnotherThing: async (data) => { const store = asyncLocalStorage.getStore(); console.log(`[${store?.requestId || 'N/A'}] Service received data: ${data}`); const result = await someService.doSomethingAsync(); return `Another thing done: ${result}`; } }; // コンテキストアクセスを示すルートハンドラ app.get('/data', async (req, res) => { // AsyncLocalStorageからストアデータ(requestIdを含む)を取得 const store = asyncLocalStorage.getStore(); const currentRequestId = store?.requestId || 'UNKNOWN'; console.log(`[${currentRequestId}] Handler received request.`); try { const serviceResult = await someService.doAnotherThing('some input data'); res.json({ message: `Data fetched successfully, request ID: ${currentRequestId}`, serviceResult }); } catch (error) { console.error(`[${currentRequestId}] Error processing request:`, error); res.status(500).json({ error: 'Internal server error' }); } }); const PORT = 3000; app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); });
コードの説明
- asyncLocalStorage.run({ requestId: requestId }, () => { ... });: これはソリューションの中核です。各受信リクエストに対して新しい- AsyncLocalStorageコンテキストを作成します。- { requestId: requestId }オブジェクトは、このコンテキスト内で利用可能になる「ストア」です。- ()=> { ... }`関数内で開始される後続のすべての非同期操作は、このストアにアクセスできます。
- req.requestId = requestId;:- AsyncLocalStorageはIDを安全に伝播しますが、- reqオブジェクトにIDを配置すると、現在のミドルウェアスタック内で即座に同期アクセスが可能になり、- AsyncLocalStorage.getStore()が非同期伝播に優先される前に簡単なロギングに便利です。
- asyncLocalStorage.getStore():- someService.doSomethingAsyncと- app.get('/data')内で、- asyncLocalStorage.getStore()を呼び出します。このメソッドは、- asyncLocalStorage.runによって現在の非同期コンテキストに設定されたストアオブジェクト(- { requestId: ... })を返します。- doSomethingAsyncが- awaitの後に呼び出され、コンテキストスイッチが含まれる可能性がある場合でも、- AsyncLocalStorageは正しい- requestIdが取得されることを保証します。
- ロギング: アプリケーション全体でconsole.logステートメントにrequestIdがどのように使用されているかに注意してください。これにより、ログに明確な相関関係が提供されます。
アプリケーションシナリオ
- リクエストトレーシング: 分散トレーシングシステムの中核であり、サービス間のログをリンクするための一意のIDを提供します。
- コンテキスト化されたロギング: リクエスト処理内のすべてのログメッセージにリクエストIDを自動的に含め、デバッグのためのログを指数関数的に有用にします。
- ユーザー情報: リクエストIDを超えて、ユーザーID、認証トークン、またはテナントIDを保存して、コンテキストに応じたアクセス制御を提供したり、データをパーソナライズしたりできます。
- パフォーマンスモニタリング: AsyncLocalStorageでリクエストの開始時刻を追跡して、アプリケーションのさまざまな部分にわたるレイテンシーを計算できます。
結論
AsyncLocalStorageは、特にリクエストID伝播のような重要な懸念事項において、Node.jsでの非同期コンテキスト管理のためのゲームチェンジャーです。非同期境界を越えてコンテキストデータを転送するための安全でパフォーマンスの高い、非侵襲的なメカニズムを提供することにより、複雑なNode.jsアプリケーションのオブザーバビリティ、デバッグ可能性、および保守性を大幅に向上させます。AsyncLocalStorageを採用することは、開発者を明示的なコンテキスト渡しという厄介なタスクから解放し、 ビジネスロジックに集中しながら、アプリケーションの動作に関するより豊かで相関化された洞察を楽しむことができます。これは、複雑な非同期コールチェーンを通じてリクエスト固有のコンテキストを安全に伝播するための決定的なソリューションです。