1
0

Lambda実装で意識すべきこと

Last updated at Posted at 2023-10-03

実行頻度が低ければ、あまり問題になることはないかもしれません。
ですが負荷試験をやるようなシステムの場合、途端にエラー祭りとなる可能性があります。

ホワイトペーパーにある基本的な内容にはなりますが、自身が経験したことも踏まえ、整理したいと思います。

Lambdaの実行環境は Node.js 前提で話しています(これしか経験ない)
時折紹介するコード例は全て typescript です

ライフサイクル

Lambdaはサーバーレスのコンピューティングサービスです。
そのため、といっていいかわかりませんが、開発したコードが実行されるサーバーが常時起動されているわけではないです。
(後述しますが、常時起動設定が可能なProvisioned Concurrencyという機能も存在します)

以下の流れでLambdaは実行されます。

  1. 関数実行のリクエストが送信されると実行環境の初期化が行われる
  2. ハンドラ関数として定義し export した箇所が実行される
  3. 関数の呼び出し後、しばらく放置されると(確か5分くらい)実行環境が破棄される

補足:ハンドラ関数

import AWS from "aws-sdk";

const funcA = () => {
    ...
};

// lambdaHandler がハンドラ関数名となる
export const lambdaHandler = (event: any, context: any, callback: any) => {
    ...
};

上記一連のライフサイクルはAWS公式で、

  • INITフェーズ:実行環境の初期化
  • INVOKEフェーズ:ハンドラ関数の実行
  • SHUTDOWNフェーズ:実行環境の破棄

という説明がされています。
image.png
必ず上記プロセスとなるわけではなく、SHUTDOWNフェーズに移行する前に再度該当のLambdaプロセスが実行されるとINITフェーズはスキップされ、INVOKEフェーズから実行が開始されます。

で、これの何に注意すべきかというと、
例えばコーディングの箇所によって以下の違いが発生します。

// INITフェーズのみ実行
import AWS from "aws-sdk";
import { ClassA, ClassB } from "user-class";

// INITフェーズのみインスタンスを生成する
const instanceA = new ClassA();

export const lambdaHandler = (event: any, context: any, callback: any) => {
    // 毎実行インスタンスを生成する
    const instanceB = new ClassB();
};

Lambda のライフサイクルを理解しないまま実装していた場合、以下のことが懸念されます。

  • instanceAは毎回初期化する必要があり、本来はハンドラ関数内に書くべき処理かもしれない
  • instanceBは一度初期化してしまえばOKで、本来はハンドラ関数外に書くべき処理かもしれない
  • instanceAの初期化が実行結果に直結する場合、INITフェーズのあり/なしが絡んで実行結果の不整合が再現率100%とならず問題の検知が遅れるかもしれない
  • instanceBの初期化が重い処理だった場合、無駄に負荷があがり性能のボトルネックになるかもしれない

少し大袈裟ですが、単体テストレベルでは検知できない問題を抱える可能性は十分にあります。

逆にこの仕様をうまく活用することもできると思います。
例えば、以下のような実装です。

・INITフェーズに非同期の重い処理をまとめて実行しておき Promise を取得する
・INVOKEフェーズで Promise を await して初期化を完了する
・SHUTDOWNされるまで init() は再実行しない

import AWS from "aws-sdk";
import { ClassA } from "user-class";

const instanceA = new ClassA();
const promise = instanceA.init(); // 重い

export const lambdaHandler = async (event: any, context: any, callback: any) => {
    if (!promise) await promise // 待機処理
};

といった感じで、Lambda のライフサイクルを意識する/しないで、コードの見方がかなり変わってきます。
後段のテストにおけるバグを未然に防ぐことにもつながるかと思います。

INITフェーズは10秒上限であることも無視できないポイントです。(知りませんでした)
INITフェーズに寄せすぎてもこの問題が発生する可能性があるので、実行タイミングのバランスや後述する Layerサイズの削減などを考える必要もあります。

コールドスタート

INITフェーズを含む実行はコールドスタートと呼ばれています。

INVOKEフェーズは0.1秒の処理時間だとしても、INITフェーズに5秒かかる場合もあります。
その機能の要件によると思いますが、 REST API としてLambdaを稼働させるような場合、コールドスタートの性能も無視できません。

大きく分けて3つの対応方針がある認識です。

Layerサイズを削減する

これが根本的な改善方法です。

  • 不要なモジュールをLayerに追加していないか
  • 共通化するほどではないヘビーなモジュールをLayerに追加していないか

以下に検証いただいている記事があり、影響度がよくわかります。

Lambdaのメモリ性能を上げる

コード修正はせずLambda自体の計算能力を上げるという考え方です。

以下に検証いただいている記事があります。

ただ、記事にも書かれていますがコールドスタートの改善は見込めないかもしれません。
(自分が検証した際もコールドスタートの改善は確認できませんでした)

ですが全体的な応答性能の向上策として、ソースコードに手を加えずに済むコストパフォーマンスの高い手段だと思います。

Provisioned Concurrencyを適用する

これは最終手段というか、Lambdaプロセスを常時起動させてINITフェーズにかかる時間を丸々なくす、という考え方です。

ですが、例えば Provisioned Concurrency の設定を10にしている場合、11の同時リクエストが発生すると11個目はコールドスタートになります。

また、Lambdaは実行時間によって課金されますので、常時起動の分コストもかかります。

細かい話になると、Provisioned Concurrency の設定においては Lambda のバージョン指定が必要です。
この指定は Latest ではできないため、Lambda のバージョン発行が必要となります。
なので最初に考えるべき手段ではないです。


やはりコールドスタートの改善においてはLayerサイズの見直しが一番重要だと思います。

同時実行数

実装とは関係ない話ですが大事です。

Lambdaのデフォルトの同時実行数は 1,000 です。
極端な話、1000 を超える同時リクエストが発生した場合、超えた分についてはTooManyRequestsExceptionが返却され始めます。

上限が足りない場合はAWSに上限解放の申請をすることで対応できます。
問題はスケールアウトの数にも上限が存在することです。

例えば上限解放をして 2,000 のキャパシティを確保したとしても
1,500/分 を超えるリクエスト数となった場合は Throttling になります。

これは(東京リージョンの場合ですが)初期のバーストクレジットが 1,000 で、ここまでは一気に環境をプロビジョニングできますが、その後は 500/分 の速度でしかプロビジョニングできないためです。

AWS公式でわかりやすいアニメーションが提供されています。

以下のようなケースでこの問題が発生することになるかと思います。

  • スパイク時にINITフェーズを経由する Lambda プロセスが大量に発生して処理が滞留し、同時実行数が跳ね上がるケース
  • スパイクの想定負荷がそもそも Lambda のスケールアウト性能を超えるケース

スケールアウトの上限について知っていないと

「なぜ同時実行数の上限に達していないのに Throttling 発生してるの...?」

となりかねませんので、注意です。

これを回避するには...Provisioned Concurrencyを設定するしかないと思います。

リトライ設計

これもめちゃくちゃ大事です。

AWSインフラの場合、各種マイクロサービスを連携させていきますが、「通信時に数万分の一程度でネットワーク起因のエラーが発生する」ということは認識した上で設計する必要があります。

月間1億のリクエストが発生する機能として、1/50,000 でネットワーク起因のエラーが発生すると仮定すると、単純計算で 2,000件/月のシステムエラーが検知されることになります。

ネットワーク起因のエラーに隠れてしまい、他の重要なエラー情報の検知が漏れるかもしれません。

クリティカルな機能になるほどシステムエラーを許容できなくなってくると思いますので、
適切なリトライ設計は重要です。

ではどのようにリトライを考えるべきか?

簡単な例を用いて検討してみたいと思います。

リトライ設計の検討

処理の内容としては
・SQSトリガーでLambdaが起動
・Lambdaはメッセージ内容をDBに書き込み、その他処理に必要な情報をParameterStoreから取得
といった感じです。
image.png
考慮すべきポイントは赤字の部分です。

Queue

キューのメッセージ処理に関するリトライはデッドレターキューの設定で実現できます。
キュー作成時にデッドレターキューオプションが指定できますが、最大受信数がメッセージを再処理する回数となります。
image.png
つまりこの場合は初回を含め2回処理を許容することになるので「リトライ回数:1」となります。

Lambda側で想定外エラーが起きて処理が失敗してしまった場合は、リトライで成功が見込まれるので、デッドレターキューに落ちる件数が減ります。

これは実装の話ではないので、AWSインフラの設定に関する考慮になりますね。

DBConnect

Lambda <-> RDS 間の通信が発生することになりますので、接続エラーが起こりえます。(というか起きます)

なのでここも1回でもリトライの処理を実装するかしないかで、エラー発生率が大幅に変わります。

また、タイムアウトの設定値も検討する必要があります。
例えば mysql であれば、オプションに接続タイムアウト値の設定が可能です。

注意点として、Lambda のタイムアウト値は30秒としているので「リトライ回数 × タイムアウト値」が30秒を超える設定はよくありません。
リトライ/タイムアウトの設定は上位機能から漏斗状の設定になっていることが望ましいです。

これはアプリケーション側の実装で考慮すべき点です。

3.getParameter

別のAWSリソースへアクセスするため、ここも考慮が必要です。

例として getParameter を挙げていますが、これは aws-sdk 提供の機能になります。
aws-sdk ではオプションとして リトライ/タイムアウト値 の設定が可能です。

こちらもDB接続と同様、Lambda のタイムアウト値を考慮した設定をすることになります。

全体

そもそも commit の後に getParameter を持ってきていいのか?ということも考える必要があります。

ここまで紹介したリトライ改善をした場合、getParameter でリトライ閾値を超えてシステムエラーになるとメッセージの再処理が実行されます。
しかし、システムエラーは commit が完了した後に発生しています。
その場合メッセージを再処理すると設計によっては二重登録となってしまう可能性もあります。

なので少なくとも getParameter は commit の前で実行する方がいいです。

もっと言うと「重複処理を排除/許容」するようにして、冪等性を担保する Lambda 設計にできれば重複実行による憂いを軽減することができます。

ステートレスな設計が大事です。

冪等性

Lambda関数における冪等性を担保した設計とは

イベントの繰り返しを識別し、保存するデータの重複や矛盾を防ぐ(= 結果が変わらない)設計

という説明で差し支えないかと思います。
何回リトライしてもデータの不整合は発生しない、と言えるような設計にすることが望まれています。

これはシンプルなリトライ設計にも寄与します。

ただ、設計の段階で十分に検討されていないと後から修正が難しくなってしまうことが多いかと思うので、気をつけていきたいです。

おわりに

上記内容を全て知らぬまま Lambda の開発を進めていました。
結果、性能テストでとんでもなく苦労しました...(._.)
と同時に非常に勉強になりました。

本記事が少しでも誰かの役に立てばうれしい。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0