背景
最近組織内の全アカウントのリソース情報を収集するタスクをやっていますが、一日多くて一回走らせるくらいの処理なのでとりあえずAWS Lambdaを採用しました。結果ファイルディスクリプタ(ファイル記述子、本文ではFDと略称する)上限というものを知るようになりました。
引用WikiPedia:
ファイル記述子(ファイルきじゅつし、英語: file descriptor)とは、コンピュータプログラミングにおいてファイルへの参照を抽象化したキーである。
Lambdaにおいては、tcpコネクト(ex. EC2のクライアント)を作ったり、S3からファイルを開いたりするとFDが消耗されますね。
本筋に戻ります。例えば、全組織には100個のAWSアカウントが存在するとしましょう。加えて15個のリージョンが使用可能です。
全アカウントの全リージョンにおいてEC2インスタンスの情報が欲しいという要望に対して、Lambda関数ではこういう感じでアカウントとリージョンごとにEC2のクライアントを作成し、describeコマンドでインスタンス情報を引っ張って来ます。
const client = new AWS.EC2Client({
region: region,
credentials: assumedRole
});
let listEC2sCommand = new AWS.DescribeInstancesCommand({});
let response = await client.send(listEC2sCommand);
簡単計算をしてみましょう!全アカウント、全リージョンでこの処理を並行すれば、1500のクライアントが作られて、ざっくり1500個のFDが必要ですね。まあ、大した数字じゃないはずだ、今時のコンピューターはすごいですよね。
Lambda quotas
残念ながら、上記のプログラムはLambdaでは通用しません!なぜなら設定とは一切関係なく、一実行環境につき1024個のFD上限が存在するからです。公式のLambda quotasというLambdaの各種制限を記載したサイトを見てみましょう。
よく見逃されますが、実行時間とメモリー以外にもたくさんの制限がLambdaに存在します。同じ関数に対して並行できる実行環境の数とか、環境変数のサイズ上限とか、bundleサイズの上限とかいろいろあります。これらの制限はユーザ側で設定できず、すべての関数で共通します。
普通に考えたら、Lambdaの活用シーンの中にFD上限を気づかせるものってそんなに多いわけでもないですね。
- S3にアップロードされたファイルへ反応し処理する
- トラフィック量が予測しづらい、或いは時間によって偏りがすごい場合で弾力性のあるAPIを構築する
これらのシーンでは、FDの使用量が極めて少なく、Lambdaを使うとしても、EC2やECSでインスタンスを立ち上がるより遥かに安いにも拘わらず使用感はほぼ一緒です。
が、前節のようにコネクトしまくったり、S3からたくさんのファイルをもらって処理したりすると、あっという間に到達できる上限値ではあります。それと、実行環境をたくさん作ればいいじゃん!にはならない場合もあります。
例えばRDBとも接続して、ファイル処理を並行させたい場合、RDBとの接続数もかなり限られてどうしても一実行環境内でFDを酷使しないといけないとかがあります。
気をつかうべきポイント
どうしても一実行環境でFDたくさん使いたい場合、以下のポイントを押さえた方がいいかなと、筆者は思いました。
- FDの使用数をLambda Insightで把握する
- 適切な分割をする
- 用済みのFDはちゃんと回収する
一点目のFD使用数について、馴染みのない人も多いでしょう。CDKとかでLambda関数を作る際に自動的にロググループが作成されたりしますが、FD使用数が見られるLambda insightはデフォでオフなわけです。LambdaのFD使用数の確認法については、素晴らしい記事がすでに世に出ているので本記事では割愛します(素晴らしい記事)。
二点目に関して、同じ実行環境での並行は(一部)やめましょう!の話です。最初の例に戻りますが、もともとこの図のように、すべてのコネクトを並行していますが、これなら余裕でFDパンクになります。
これならどうですかね?リージョンでコネクトを分散しています。つまり、リージョン1でコネクトを作り、用済みになったコネクトをちゃんと回収してから、リージョン2のコネクトを作りまじめます。実行時間がリージョンの数倍遅くなりますが、一コネクト数秒で考えると全然許容範囲内ですね。
「回収」という言葉を使いましたが、これは特に注意を払わなければなりません。特にNode.jsランタイムでは、FD未回収のままLambdaからExitみたいなコードが結構見ます。一実行環境で使うFD数が極めて少ない場合、それはそれでいいかもしれないが、一実行環境で使うFD数がそれなりの数である場合は忘れずに回収しましょう!(そうしないと、FDがパンクする以外、たくさんのコネクトがどこかのコンテナで宙ぶらりん自然消滅待ちして次の実行にも邪魔する可能性がありますからね…うーん最悪)
先ほどのコードに回収のコードを足すとこうなります:
const client = new AWS.EC2Client({
region: region,
credentials: assumedRole
});
let listEC2sCommand = new AWS.DescribeInstancesCommand({});
let response = await client.send(listEC2sCommand);
// ...many process...
+ await client.destroy();
このメソッドのコメントも結構面白いこと書いてますね:
「通常これ(destroy)をやらなくてもいいが、Node.jsでは明示的に不要なクライアントをシャットダウンした方がベスト。そうしないと、コネクトがサーバに強制停止されるまで長い間で存続する可能性がある。」
サーバレスについて思ったこと(ポエムコーナー)
サーバレスってこういう面倒くさいことを考えせずに気軽に使えるのがコンセプトなはずなのに、結局そこら辺のサーバと一緒なんだと実感しましたね。しかも、普通のインスタンスなら、SSHしてスレッドかプロセスを全部強制シャットダウンすればいいはずなのに、Lambdaだと全部限られた実行時間内で片付けないといけません。これはプログラム本体にだけでなく、デバッグ、つまり開発者の体験にも影響する問題です。
コストダウンの代償として自由度じゃなく裏にあるコンピューターへのコントロールを失ったと言った方が正しいでしょう。
例えば、Lambdaのコールドスタートとウォームスタートという言い方がありますが、ウォームスタートなら初期化せず前の実行環境を流用しているからコールドスタートより速いです。(少しややこしいが)コールドスタートを強制する手段はありますが、ウォームスタートでどの実行環境を流用するかをユーザが選定できるわけでもなく、AWSが勝手にやってくれて、こういうこともあり得ます:
前の実行環境でFDを使い切ってしかも回収せずでしたが、次の実行に流用されて、同じ関数なのにキャパシティーが全然違います。最初から使えるFDがなくもちろん実行結果も異なります。
これのせいで路頭に迷う開発者は世界のどこかにいるでしょう。同じ関数をサーバレスで二回走らせて、まったく独立だと思いきや全然そうじゃないと、世にも奇妙なことが起こりましたね。
一実行環境のリソースを使いきれないくらい増やせば問題ないですが、現実問題それが無理で、じゃあせめて開発者にはもうちょっと優しくしてほしいです。
筆者の欲を言えば、Lambdaもある程度強制シャットダウンの権限を開発者に委ねたほうががいいんじゃないかなと思ったりします。さらに欲張りしちゃうと、BIG RED BUTTONで一撃ですべての実行環境をゼロに帰すのがベストかと。