AWS SDK クライアントは singleton で実装しよう!Lambda の EMFILE(Too many open files)エラー対応
最近LambdaでEMFILE(Too many open files)エラーが発生したので、原因と対処方法を紹介します。
背景
リアルタイム通知やチャットなどでは、クライアントと API Gateway が WebSocket通信を行い、Lambda がその接続に対してメッセージを送る構成がよく使われます。
フロントアプリ(クライアント)
↕ WebSocket (wss://)
API Gateway
↕ 同期呼び出し
Lambda 関数
↕
DynamoDB(接続ID管理)
こちらのチュートリアルで紹介されているような構成です。
このような構成では、Lambda 側でメッセージ送信に API Gateway 用のクライアント が用いられます。
何が起きたか
以下のようなコードでEMFILEエラーが発生しました。
export const handler = async (event) => {
...
try{
const client = new ApiGatewayManagementApi({ endpoint });
await client.postToConnection(...);
...
}catch(err){
logger.error(err);
throw WebsocketError.SEND_MESSAGE_FAILED();
}
...
};
エラーメッセージ
Error: Too many open files
SystemError: uv_os_homedir returned EMFILE (too many open files)
このコードでは、API Gateway 用のクライアントを使ってメッセージ送信しています。しかし、リクエストごとにクライアントを作るような設計になっています。もし多くのリクエストを受け付けた場合にファイルディスクリプタの枯渇が発生します。
今回は次の両方で FD を消費していました。
- 認証情報の読み込み(AWS クレデンシャル解決のためのファイル読み込みなど)
- Management API への HTTP 通信(ソケット)
スタックトレースを確認すると、 認証用のホームディレクトリ取得処理getHomeDirで落ちていることが分かりました。
処理の流れとしては以下です。
- リクエストのたびに API Gatewayのクライアントを作っていた
- 各クライアントは初回利用時に認証解決(getHomeDir → クレデンシャルファイル読み込み)を行い、さらに HTTP 通信でソケットを開く
- クライアントを再利用しないため、FD の使用が積み上がり、ある時点で上限に達した
- そのタイミングで次の認証読み込み(getHomeDir)が走り、EMFILE エラーが発生
こちらの記事ではファイルディスクリプタの消費量の確認方法が紹介されています。
エラーの再現
このエラーは平常時は発生しません。リクエスト数が増えた場合のみ発生します。そこで一度このエラーを再現させてみることにしました。EMFILEエラーの説明を見る限りでは短時間で多くのリクエストを受け付けた場合に発生するエラーのように思えました。なので、5秒間で150回近くのリクエストを送ってみましたが、再現しません。そこで、2時間で5000回のリクエストを送ってみると再現しました。長時間にわたって継続負荷を与えることで再現するようです。
原因と改善
原因は「クライアントをリクエストごとに毎回生成して作りすぎたこと」です。
これはAPI Gateway クライアントを singleton にすることで解決できます。
Lambdaではリクエストが続く間、毎回コンテナ(実行環境)が起動するのでなく同じコンテナが使い回されます。なのでグローバル変数としてAPI Gatewayのクライアントを保持していたら、それを使い回すことができます。
singletonにするために以下のような実装にしました。
const client = new ApiGatewayManagementApi({ endpoint });
exports.handler = async () => {
await client.postToConnection(...);
};
実装後、エラーの再現で行なった手順と同じように負荷をかけてみました。ファイルディスクリプタの消費量を確認するために、Lambdaから「Lambda Insights 拡張モニタリング」を有効化しました。実装前は、エラー再現時に上限いっぱいのfd_use: 1024となっていたファイルディスクリプタの消費量が、実装後は最も高い時でfd_use: 27と大幅に減っていることが確認できました。
try/catch では捕捉できなかった
ちなみに今回のエラーは、try/catchで捕捉できませんでした。catch文にエラーハンドリングを書いていたのに、それが実行されなかったのです。
一見すると次のように書いていれば捉えられそうに見えます。
export const handler = async (event) => {
try{
const client = new ApiGatewayManagementApi({ endpoint });
await client.postToConnection(...);
...
}catch(err){
logger.error(err);
throw WebsocketError.SEND_MESSAGE_FAILED();
}
...
};
しかし今回の EMFILE エラーでは catch されず、Unhandled Promise Rejection として Lambda ランタイムがログを残していました。
理由
少し挙動が難しかったので、AIに解説をしてもらいました。
postToConnection()を await していても、エラーは SDK 内部の別の Promise チェーンで発生している- 認証解決は「初回リクエスト時」に SDK 内の credential provider によって非同期で行われる
- その中で
loadSharedConfigFilesが呼ばれ、その先頭でgetCredentialsFilepath()→getHomeDir()が同期的に実行される - EMFILE はここで throw されるが、この throw は「認証解決用の内部 Promise」を reject する
- その reject が、呼び出し元の
postToConnection()が返す Promise にまで正しく伝わっておらず、結果として Unhandled Promise Rejection になる
イメージしやすいサンプル
「await していない Promise の reject は try/catch で拾えない」のと同じ構造です。SDK内部でこのような処理があったようです。
function asyncError() {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("something failed")), 100);
});
}
async function main() {
try {
asyncError(); // await していない → この Promise の reject は catch に来ない
console.log("処理は続く");
} catch (e) {
console.log("catch:", e.message); // 実行されない
}
}
main();
〆
- AWS SDK のクライアントは可能な限り singleton にしよう!
- SDK 内部の非同期エラーは try/catch に届かないことがある






