ちょっと話題の記事

Lambda パフォーマンスチューニング

2019.09.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

渡辺です。 そろそろ冬籠もりの準備をする季節です。

Developers.IO Cafe のインフラはLambdaを軸としたサーバレスアーキテクチャです。 Lambdaはサーバレスの中核として非常に使い勝手の良いサービスですが、制限と上手く向き合うことも必要です。 特にコールドスタートはパフォーマンスに直結する大きな課題です。

本エントリーでは、カフェのバックエンドLambdaで実施したパフォーマンスチューニングについて解説します。

コールドスタートとLambda

Lambdaを扱う以上、 コールドスタート は避けられない問題です。

一般的に、サーバレスアーキテクチャでは、サービスが利用されていない時間帯は、コンピュータリソースを使わないように設計されています。 言い換えると、サービスが最初に利用される場合、サービスをスケールする場合、サービスがアップデートされた場合などに、 サービスが都度起動 します。 サービスが起動すれば、しばらくは再利用されます。 したがって、連続的にサービスが利用されている限り、起動コストが問題となることはありません。 しかし、サービスがしばらく利用されていないとサービス(Lambdaで言えば実行コンテナ)は停止します。 そして、再利用時に起動するため、再起動コストが問題となります。 この 停止状態から起動すること を「コールドスタート」と呼びます。

コールドスタートの解決方法として、Lambdaを定期実行する方法が知られています。 停止すると起動に時間がかかるならば、停止させないようにすればいいという考え方です。 しかし、これは本質的な解決になりません。本来利用しないコンピュータリソースも浪費したくありません。 また、 何時Lambdaの実行コンテナが増えたり減ったりしてもおかしくないため、突然のコールドスタートは避けられない のです。 定期実行は、「最終手段」や「悪あがき」的なテクニックであり、最初から依存するのは愚の骨頂です。

コールドスタートは遅いのか?

コールドスタートでどのくらいの時間がかかるのか 測定 してみましょう。 HelloWorld的なプログラムをLambdaで実行してみました。

HelloWorld Lambdaを実行した時、実行時間は 98msec でした(CloudWatch Logsで確認)。 このHelloWorld Lambdaを別のLambdaから実行した時間は、 959msec でした(2回目以降はほぼLambdaの実行時間と同じ)。 つまり、 861msecがコールドスタートのオーバヘッド です。

非常にシンプルなLambdaであれば、コールドスタートのオーバーヘッドは1秒未満です。 もし、コールドスタートで1秒の起動時間が問題になるとしたら、Lambdaを選択するというアーキテクチャ選定がそもそも間違いです。

コールドスタートのパフォーマンスチューニング

先日、とあるカフェのAPIで、遅延を感じるようになりました。 色々と心当たりはあるので、改善してみました。

パフォーマンスを測定する

パフォーマンスチューニングをするならば、測定をしてからはじめなければ意味がありません。 とあるAPIでは次のように測定結果がでました。

HelloWorld Lambdaと同様に、対象のLambdaを実行するLambdaを用意し、全体の実行時間とLambda本体の実行時間を比較します。

実行時間 処理時間 備考
3,812 2,166 コールド 1,646
2,371 2,256
2,154 2,154
1,555 1,491
3,881 2,198 コールド 1,613
2,479 2,383
2,115 2,024
1,717 1,662
4,061 2,417 コールド 1,644
2,459 2,360
2,194 2,122
1,487 1,409
4,136 2,493 コールド 1,643
2,529 2,414
2,199 2,110
1,661 1,590

コールドスタート時は4秒前後かかっており、「改善が必要」という感覚が裏付けられました。 コールドスタートのオーバーヘッドは1.6〜1.7秒程度です。 処理時間はバラツキはあります、1.5〜2秒程度です。

HelloWorldに近づけながら、現実的な妥協点を探ります。

割当てメモリを増やす

パフォーマンスチューニングの初手で コードに手を入れるのは悪手 です。 環境設定のみでパフォーマンスが充分に改善できるならばベストです。

札束で解決 しましょう!

Lambdaではメモリの割り当てを簡単に変更できます。 元の価格が安いためコストが100倍になったところで痛くも痒くもありません。

デフォルト(128MB)を512MBにすると、こんな効果がありました。

実行時間 処理時間 備考
2,951 1,170 コールド 1,781
952 910
988 945
1,078 1,040
2,919 1,200 コールド 1,719
1,059 1,100
963 1,000
1,247 1,300
2,751 1,055 コールド 1,696
967 925
1,044 1,005
1,094 1,054

実行速度が1,500〜2,500msとバラツキがありましたが、1,000ms付近に収束しています。 コールドスタートが発生しなければ充分なパフォーマンス となりました。

一方、コールドスタート時のオーバヘッドに改善はありません。 気持ち大きくなっていますが、誤差かメモリ量が多くなったためコンテナの展開に時間がかかるのかな?という感じです。 この辺を気にしてしまうと、迷走するので、一端忘れましょう。

なお、メモリ量は512MB以外にも試しました。 今回、128MB、256MB、512MBと増やすと明らかにパフォーマンスがあがりました。 しかし、それ以上のメモリでパフォーマンス向上は見られませんでした。 この辺りは、プログラムの性質にも依存しますが、カフェのLambdaでは、512MBがちょうど良さそうです。

依存ライブラリを減らす

カフェのLambdaはNode.jsで実装しています。 Lambda関数自体が非常に多いので、共通モジュールはLayerに追加して参照しています。 また、Node.jsのランタイムで組み込まれているaws-sdkでは利用したい関数が含まれておらず、最新のaws-sdkもLayerにバンドルしていました。 最近、ヘビーなモジュールを追加した記憶があります・・・。

外部モジュールの展開は非常にコストが高く、コールドスタートのパフォーマンスを悪化 させます。

はじめに、デフォルトのaws-sdkのバージョンを再確認したところ、最新版をバンドルする必要がなくなりました。 また、一部のモジュールが非常に大きく、しかし、一部の関数でしか利用していません。 そこで、共通ライブラリとして持つLayerを分割し、スリム化を図りました。 元の共通ライブラリLayerはzipで23MBほどありましたが、これを2MBと19MBに分割出来ました。

対象のLambda関数の 依存モジュールが20MB近く減った ことになります。

測定です。

実行時間 処理時間 備考
1,962 1,300 コールド 662
922 900
990 1,000
1,041 1,100
1,927 1,192 コールド 735
932 892
907 877
970 938
1,927 1,215 コールド 712
924 894
1,425 1,388
1,043 1,008

処理時間は気持ち早くなったかな程度ですが、コールドスタート時のオーバーヘッドが劇的に改善しました。 HelloWorld、つまりモジュールを追加していない場合のオーバーヘッドが861msecだったことを考慮すると、これ以上の改善は望めない可能性が高いです。

実行時間としては0.8〜1.0秒程度、コールドスタート時で2秒程度となり、改善前の1.5〜4秒から大きく改善しています。

ロジックの改善

今回、ここまでのパフォーマンスチューニングで充分な成果を得ることができました。 これ以上のチューニングとなると、ロジック自体の改善が必要になってきます。 コード的には外部Lambdaに非同期実行するなど、かなり大がかりな修正をしない限りはパフォーマンス向上は望めなさそうです。 したがって、今回のチューニングはここで終了とします。

まとめ

Lambdaを扱うならば、コールドスタートから目を背けないでください。 パフォーマンスを測定し、ボトルネックを改善すれば、サーバレスの明るい未来が待っています。

Lambdaのチューニング時は、 実行メモリ依存モジュール に着目してみましょう。 それでもダメな時、定期的に殴りましょう....。