[2022年最新版]Lambdaの裏側教えます!!A closer look at AWS Lambda (SVS404-R) #reinvent

[2022年最新版]Lambdaの裏側教えます!!A closer look at AWS Lambda (SVS404-R) #reinvent

Lambdaファン必見 Worker ManagerをAssignmentサービスに置き換えた話、SnapStartの裏側でSparse filesystemを利用している話など、Lambdaの裏側がどうアップデートされたのかが分かります
Clock Icon2022.12.12

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

CX事業本部@大阪の岩田です。今年のre:inventは現地参加できなかったのですが、毎年楽しみにしていたLambdaの裏側を解説するセッションSVS404(番号は年によって微妙に違います)の動画がYoutubeにアップされていたので、さっそく視聴しました。これまで言及されてきた内容に加えて新たな解説も増えているので改めてレポートをブログにまとめます。

セッション動画

セッション動画はこちらから確認可能です

前置き

セッション内でも言及されていますが、本セッションはre:invent 2018のSVS405、re:invent2019のSVS404の後継のようなセッションです。また、re:invent2020のSVS404とも関連性が深い内容となっています。Lambdaの裏側をより詳しく知りたい方は是非過去のセッションレポートも参照して下さい。これまでの進化の歴史を含めてより理解を深めることができます。

re:inventのセッションではありませんが、こちらもオススメです

Lambdaの裏側を知りたい人にオススメ Firecrackerに関する論文「Firecracker: Lightweight Virtualization for Serverless Applications」の紹介

さらに、これらの内容をまとめたものがこちらです。

知らなくても困らないけど、知ると楽しいAWS Lambdaの裏側の世界 #devio2021

では、ここからはセッションレポートになります。

Lambdaの概要についておさらい

Lambdaは最小のTCO(総保有コスト)でモダンアプリケーションを高速に開発するための優れた選択肢です。開発チームはメンテナンスコストや煩雑な作業を回避してコードに集中することができます。Lambdaは8年前のre:invent2014で発表されたサービスで、驚異的な速度で成長し続けています。現在では100万以上のユーザーがLambdaでアプリケーションを構築し、月に10兆回以上実行されています。

Lambdaは様々なアプリケーションの構築に利用されていますが、以下のような分野でLambdaにメリットを見出しているユーザーが多いようです。

  • EC2インスタンス起動時に設定を検証するようなITオートメーション用途
  • S3やKinesis、Kafkaなどと統合されたデータ処理アプリケーション
  • Webアプリケーション
  • マイクロサービスベースのイベント駆動型アプリケーション
  • 機械学習

Lambdaは2015年のGAから開発者のサーバーレスアプリケーション構築を支援するパイオニアであり続けています。

進化の一部を紹介すると以下のようなものがあります

  • 2018年
    • 最大の実行時間が15分に
  • 2019年
    • Provisioned concurrency
    • EventBridge対応
  • 2020年
    • コンテナイメージをサポート
  • 2021年
    • Lambda Extensions
    • Graviton2(Arm)対応
  • 2022年には10GBのエフェメラルストレージ対応、
    • Function URL
    • さらなる言語サポートの追加
    • レイテンシーの縮小
    • 他サービスとの統合追加

利用事例

Coca-Cola社はパンデミックによる変化をいち早く察知し、 Coca-Cola Freestyleの飲料ディスペンサーにタッチレス体験を提供したいと考え、自動販売機に触れることなくドリンクの注文と支払いができる新しいスマートフォンアプリを開発しました。このアプリケーションはLambdaで構築されているため、開発チームはセキュリティやレイテンシ、スケーラビリティに多くの時間を費やすことなく、アプリケーション開発に集中することができました。その結果この新しいアプリケーションはわずか100日で構築され、今では3万台以上のマシンがタッチレス機能を使えるようになりました。

Coca-Cola Freestyle が AWS Lambda を使用して 100 日間のタッチレスのファウンテン体験をリリース

Nielsen Marketing CloudではLambdaを使用して1日あたり2500億のイベントを処理するという信じられないような大規模な処理を行っています。これは、完全に自動化されたサーバーレスパイプラインによって品質とパフォーマンスとコストが維持されています。

Lambdaが選定された大きな理由はシステムのスケール性です。Nielsen Marketing Cloudのシステムはピーク時には55TBのデータを受信しLambdaを3000万回起動しましたがシステムは問題なく動作し、Lambdaの同時起動数は3,000にとどまりました。平常時の処理量は1TB ~ 5TB程度ですが、特別な追加作業なしにピーク時のワークロードに対応してスケールアップすることができました。

Lambdaの基礎

Lambdaというサービスは以下の優先順位にもとづいて開発されています。

  1. セキュリティ
  2. 耐久性
  3. 可用性
  4. 機能

AWSではセキュリティ=ジョブゼロという言葉を耳にすることがありますが、それはセキュリティが他の何よりも優先されるということです。

また、Lambdaは複数のAZで関数を実行し、1 つのAZでサービスが中断した場合や、万が一 AZが停止した場合でもイベントを処理できるようにするなど、耐久性と可用性を担保した運用に重点を置いています。Lambdaの開発チームでは新しい機能開発に取り組む前に、これらの基本事項をカバーしていることを確認しています。

責任共有モデルではAWSのサービスを動かすインフラやソフトウェアにはAWSが責任を持ち、顧客はアプリケーションやデータに責任をもちます。Lambdaのようなサーバーレスアプリケーションを構築する場合AWSがクラウドのセキュリティに関するより多くのレイヤに責任を持ちます。顧客はOSのパッチやファイアウォールの設定、証明書の配布といったことを気にせずに、アプリケーションとデータにさらに集中でき、基盤となるプラットフォームの管理はAWSが担当します。

2021年12月9日、Apache log4Jの深刻なリモートコード脆弱性が世界中に発表され、多くの企業がアップデートに奔走しました。しかしLambdaの利用者はそういった対応が不要でした。Lambdaはマネージドランタイムやベースコンテナイメージにlog4jを含んでいませんでした。もし含まれていたとしてもAWS側で自動的にパッチが適用され、ユーザー側の作業は不要だったことでしょう。仮にユーザーがLambda Functionのコードのにlog4jをバンドルして利用していた場合でも、該当機能をブロックしてログを出力するという対策をCorretoの開発チームと協力して実装していました。

Lambdaの内部アーキテクチャ

ここからLambda内部のアーキテクチャに関する解説です。

まずLambdaには同期実行と非同期実行という2つの呼び出しモデルがあります。

Function URLやAPI GWからLambdaを呼び出す場合等は同期実行になります。呼び出し元がLambda Functionにリクエストを送り、Lambda Functionが処理を完了するのを待ち、レスポンスを受け取ってクライアントに返却します。

S3やEventBridgeなど何かしらルールにマッチしたときにイベントを送信するようなサービスからLambda Functionを呼び出す場合は非同期実行です。呼び出し後にLambda はイベントを内部のキューに入れ、呼び出し元に対して「イベントを受け取りました」という成功レスポンスを返却します。その後、別のプロセスがキューからイベントを読み出し、Lambda Functionに送信するため、呼び出し側のサービスからLambda Functionには直接のパスはありません。

Lambdaというサービスは多くのコンポーネントから構成されていますが、大きくコントロールプレーンとデータプレーンに分かれます。コントロールプレーンはLambdaというサービスと対話する場所です。Lambda Functionの作成、設定、更新や、コードのアップロードを行いLambdaというサービスのリソース管理を行います。Lambdaと対話するための多くの開発者向けツールが提供されており、これらはデータプレーンツールの一部とクロスオーバーしています。

データプレーンはイベントデータを取得して関数コードに引き渡すためのコンポーネントです。呼び出しを処理するために多くのコンポーネントが協調して動作します。

このLambdaのアーキテクチャを見ると、従来存在していたWorker ManagerというサービスがAssignment Serviceに置き換わっていることに気づくかもしれません。このAssignment ServiceはWorker Managerの課題を解決し、可用性を向上させるために新たに構築されました。詳細は後ほど解説します。

同期実行について

まず同期実行に関する詳細を見ていきましょう。

特定のLambda Functionを呼び出すリクエストは、まずALBでリクエストを受け付けます。そしてFrontend Invokeサービスを実行しているホスト群にリクエストが分散されます。この図は簡略化されていますが、実際にはLamdaのコンポーネントは全て複数のAZで冗長化され、透過的に動作します。

Frontend Invokeは、まずサービスの認証と認可を行います。このサービスはリクエストに関連するメタデータをロードし、最高のパフォーマンスを提供するために可能な限り多くの情報をキャッシュします。

次にFrontend InvokeはCountingサービスを呼び出し、アカウントやバースト、予約された同時実行数などに基づいて呼び出しを制限する必要があるかどうかをチェックします。CountingサービスはInvoke毎に呼び出されるため、超高スループットと1ミリ秒以下のレイテンシに最適化されています。

続いてFrontend InvokeはWorker Managerの後継であるAssignmentサービスに接続します。このサービスはWorker Managerと同様にリクエストを処理可能なWorkerホストにルーティングするための役割を担っています。

Workerはサーバーレスアーキテクチャの裏側で稼働しているサーバーです。Workerは以下のような役割を担っています。

  • Micro VMと呼ばれる軽量な仮想マシンの中に安全な実行環境を作り、Lambda Functionのコードをダウンロード&マウントし、コードを実行する
  • 複数のランタイムを管理し、設定に基づいたメモリなどの制限を設定し、仮想CPUを割り当てる
  • サービスを監視・運用するホスト上のエージェントを管理する

さらにいくつかの調整とバックグラウンドタスクの管理を行うコンポーネントがあります。これはコントロールプレーンサービスで、関数の作成を処理し、Assignmentサービスノードのライフサイクルを管理し、Frontend InvokeサービスがどのAssignmentサービスノードに呼び出しリクエストを送るべきかについて最新の情報が得られるように管理します。

AssignmentサービスはWorker上のLambda Function実行環境がどのような状態であり、最終的にどの実行環境を呼び出すべきかという情報を取得するコーディネータといえます。Lambda Functionを最初に呼び出す際にはWorker上で新しいLambda Function実行環境を構築する必要があります。そこでAssignmentサービスはPlacementサービスと対話してWorker上にLambda Function実行環境を構築し、時間ベースのリースを提供します。

PlacementサービスはMLモデルを使用して、Lambda Function実行環境をどこに配置するのが最適かを判断し、フリート利用率を高めるためにLambda Function実行環境の密度を最大化しながら機能性能を維持し、コールドパスのレイテンシーを最小化します。また、Workerのヘルスチェックも行います。

Lambda Function実行環境が起動すると、Assignmentサービスは設定された権限で関数コードが実行されるように、IAMロールと環境変数の情報をWorkerに配布します。そしてLambda Function実行環境は各言語のランタイムを起動し、関数コードのダウンロードやコンテナイメージの実行を行いLambda FunctionのInit処理が実行されます。そしてAssignmentサービスがFrontend Invokeサービスに関数コードの実行準備が完了したことを伝え、Frontend InvokeサービスがLambda Function実行環境にイベントペイロードを送信します。この関数はhandlerを実行し、処理結果をFrontend Invokeサービス経由で呼び出し元にレスポンスします。WorkerはAssignmentサービスに呼び出しが終了し、次回からウォームスタートが可能になったことを伝えます。

その後の呼び出しに対してFrontend Invokeサービスはサービスとアカウントのクォータをチェックし、Assignmentサービスからウォームスタート可能なアイドル状態のLambda Function実行環境の情報を取得し、この実行環境にペイロードを送信してhandlerを再実行します。

Lambda Function実行環境のリース終了時刻が近づくと、Assignmentサービスはその実行環境を以後の呼び出しに利用できないようにマークします。同時にエラーチェックも行っています。Lambda Function実行環境のInitフェーズでエラーが発生した場合は、その環境を利用不可とマークしてサービスから削除します。また、定期的なリフレッシュやエラーが発生した場合、既存のWorker上の実行は継続したまま将来の呼び出しを停止します。

非同期(イベント)実行

続いて非同期(イベント)実行についてです。

非同期実行のリクエストはEvent Invoke Frontendサービスが受け付けます。同期実行にも同様のFrontend Invkeサービスがありますが、この2つのサービスは可用性とパフォーマンスを改善するために意図的に分離されています。これらのパスを分離することで、大量の非同期呼び出しによって同期呼び出しのパスが遅延しないように構築されています。非同期実行関連のコンポーネントも複数のAZに跨って構築されています。

Event Invoke Frontendサービスは呼び出し元に基づいてリクエストの認証/認可を行います。認証/認可が完了すると呼び出し元のリクエストを内部のSQSキューに送り、呼び出し元にレスポンスを返却します。

このLambdaが管理しているSQSキューはユーザーからは見えません。LambaというサービスがSQSキューを管理し、負荷やLambda Functionの同時実行数に基づいて動的にスケールアップ/スケールダウンしています。いくつかのキューは共有されていますが、いくつかのイベントは専用のキューに送信しています。これは、Lambdaが大量の非同期呼び出しをできるだけレイテンシなく処理できるようにするためのものです。

Lambdaが管理するPollerインスタンスのフリートがSQSキューからメッセージを読みとり、対象のAWSアカウントとLambda Functionを特定し、Frontend Invokeサービスにペイロードを送信してLambda Functionを同期実行します。このように全てのLambda Functionは最終的に同期実行されます。Frontend InvokeサービスはPollerにレスポンスを返却し、PollerはSQSキューからメッセージを削除します。同期実行が成功しない場合、Pollerはメッセージをキューに返します。これは実際には通常のSQSキューと同様に可視性タイムアウトで定義された時間経過後に、同じまたは別のPollerがメッセージを取得して再試行します。またLambda Destinations(非同期呼び出しの宛先指定)を設定することで、呼び出しが成功したか、所定のリトライ回数試行後に最終的に失敗したかを確認することも可能です。

非同期実行のコントロールプレーンにはQueue Managerというコンポーネントがあります。Queue Managerは、キューのバックアップを監視し、キューの作成と削除を行います。また、Leasingサービスとも連携し、どのPollerがどのキューを処理しているかを管理し、Pollerの障害を検知して障害が発生したPollerから別のPollerに処理を引き渡すこともできます。

非同期実行モデルを構築したとき、このアイデアは他の多くのサービス統合に使用できることが分かりました。イベントソースマッピングは、ソースからデータを読み取ってLambda Functionを同期実行するPollerリソースです。当初はDynamoDBストリームとKinesisだけが非同期実行モデルに対応していましたが、SQS、MSK、Amazon MQ、さらにセルフホスト型のKafkaにまでサポート範囲が広がっています。Pollerがこれらのソースからメッセージを取得し、オプションでメッセージをフィルタリングし、バッチ単位にまとめてLambda Functionに送信して処理を行います。もう少し詳しく見ていきましょう。

Producerアプリケーションは、メッセージやレコードを非同期でストリームやキューに投入します。Pollerはイベントソースに応じて異なるクライアントを持つため、種類の異なるPollerフリートが多数稼働しています。Pollerはストリームやキューからメッセージを読み取りフィルタリングやバッチ単位にまとめてLambda Functionに送信します。これはLambdaに組み込まれた非常に便利な機能で、Lambda Functionへのトラフィックを減らし、ユーザーが開発するコードを簡素化し、全体的なコストを削減するのに役立ちます。バッチ単位にまとめられたペイロードはFrontend Invokeサービスに送信され、この同期呼び出しが正常終了した場合にPollerがキューからメッセージを削除します。またEvent Destinationを設定することでSQSやSNSといったサービスにペイロードを連携することも可能です。PollerはLambdaというサービスの一部として管理されており、Lambdaの利用者はメッセージのポーリングのためだけにコンシューマー用のEC2インスタンスやコンテナを動かす必要はありません。Pollerを利用するための料金もかかりません。これは大きなメリットです。

非同期実行モデルのコントロールプレーンにはState ManagerとStream Trackerというサービスが存在します。どちらのサービスが利用されるかはイベントソースに依存しますが、これらのサービスはPollerを特定のイベントやストリームソースに割り当て、Pollerに障害が発生している場合は別のPollerに割り当てを変更します。これらの機能とPollerフリートを利用することで膨大な数のイベントソースがサポートされます。

イベントソースマッピングには様々な設定が可能です。フィルタリング、バッチウインドウの設定、部分的なバッチエラー... これらを設定することでLambdaを利用したデータ処理がより簡単に実装できます。

ストレージサービスとしてのLambda

ここからスピーカーがChris Greenwoodさんに交代します。彼は元々EBSチームに在籍していましたが、数年前からLambdaチームにジョインしました。そしてLambdaにおける状態管理に関する課題がEBSで経験した問題と類似していることに気づきました。Lambdaはサーバーレスなサービスであり、開発者はストレージについて意識する必要がありません。ある側面ではLambdaはサーバーレスなストレージサービスであると考えることができます。ここからはLambdaというサービスを改善するためにストレージの側面から学んだ3つの教訓について解説していきます。

1つ目の教訓は データへのアクセスパターンがデータプレーンにおけるデータのストレージレイアウトを駆動するべきだということです。2つ目はステートの共有はパフォーマンスとストレージの利用にとって重要であり、最も優れたステートの共有はステートを共有しないことによってもたらされるということです。これについては後ほど詳しく解説します。最後の3つ目は、適切に設計されたストレージサービスは多くの場合、呼び出し元に対するプレゼンテーションレイヤーも適切に設計されているということです。

教訓1

Lambdaというサービスを提供し、呼び出しを処理するには3つのステート管理が必要です。

  • 1つ目は呼び出し側が提供するJSONペイロード、またはイベントソースが提供するインプットです
  • 2つ目に関数コードが必要です。関数コードはLambda Functionを作成/更新する際に開発者が提供します。
  • そして3つ目は仮想マシンが稼働する場所です。1つ目のインプットと2つ目の関数コードが仮想マシン上で出会い、サービスや呼び出しに必要な環境が形成されます。

これまで説明してきたFrontend InvokeとPollerはインプットを正しい仮想マシンに正しく届けるための役割を果たしています。ここからはコードに焦点を当てて解説していきます。

Lambdaがローンチされた当初、関数コードを仮想マシンに展開する処理はS3からコードをダウンロードし 各実行環境にDLするというシンプルなものでした。Lambdaの各実行環境はS3からDLしたZIPファイルを仮想マシン内に展開しコードの準備ができ次第仮想マシンはインプットを処理し、コードを実行するという流れです。これは問題なく動作し、今日でもLambda関数の大部分はこの方法で動作しています。

しかし、2020年にコンテナイメージ形式のパッケージをサポートすることになり、ステート管理の考え方が大きく変化しました。ZIPパッケージの場合、関数コードのサイズは250MBに制限されていますが、コンテナイメージ形式のパッケージは250MBよりはるかに大きいことが普通です。Lambdaチームはコンテナイメージ形式のパッケージサイズとして最大10GBまでサポートすることを決めました。しかし、従来のコード配信アーキテクチャではコードをLambda実行環境に展開するのに必要な時間がコードのペイロードサイズに比例して増加してしまいます。コンテナイメージ形式のパッケージサイズ上限として最大10GBまでサポートするためにはこのアーキテクチャは受け入れがたいものでした。Lambda実行環境にコードを配信するためのアーキテクチャを見直す必要に迫られたのです。

多くのコンテナー化されたワークロードでは、アプリケーションが実際に要求を処理するときにコンテナイメージのサブセットを読み取ることが知られています。2016 年のFAST カンファレンスで公開された論文によると、圧縮/非圧縮のコンテナイメージの合計サイズと、ワークロードを処理するときに実際にコンテナによって読み取られるデータの量には大きな差異があります。Lambdaチームはリクエストを処理するために必要なコンテナイメージの一部だけをダウンロードしてLambda実行環境で利用できればより高速にリクエストに対応するLambda実行環境が構築できることに気づきました。ではどのようにコンテナイメージを部分的に実行環境に配信すればよいのか?Lambdaチームはコンテナイメージのためにストレージのレイアウトを変更する必要がありました。

Dockerファイルからコンテナイメージを構築すると、Dockerはレイヤーと呼ばれる一連のtarファイルを生成します。LambdaはこれらのレイヤーをDLしファイルシステムに展開します。Lambda実行環境はこの展開後のデータをファイルシステムとして認識します。

Lambdaのストレージサービスはレイヤー展開後のファイルシステムをチャンクに分割します。チャンクとはブロックデバイスの1つの論理範囲を表す小さなデータのことで、複数のチャンクによってブロックデバイス全体とファイルシステム全体が構成されます。チャンキングが強力なのは、コンテンツをサブイメージの粒度で保存し、そのサブイメージの粒度でコンテンツにアクセスできる点です。

具体例を見ていきましょう。Lambda実行環境が起動した直後、ファイルシステム上には何も存在しない状態からスタートします。その後lsコマンドで/配下をリストアップするとファイルシステムのinodeを読み込むことになります。Lambda実行環境はそのinodeをイメージの特定のチャンクにマップし、このチャンクをフェッチしてinodeの読み込みに対応します。その後Javaのバイナリを起動・実行するために別のinodeを読み込むと、必要なデータは同じチャンク上に存在するかもしれません。つまり、そのinodeを読み込むために追加のコンテンツをロードする必要はありません。次にhandlerクラスを読み込む際、このクラスが同じチャンク上に存在しない場合は必要に応じて追加のチャンクをロードします。

これが教訓の1つ目です。LambdaはVMがファイルシステムに対して行うリクエストに対応するために必要なコンテンツを配信しますが、逆に必要のないコンテンツは配信しないということです。ユーザーのアクセスパターンを理解し、特にコンテナイメージは一般的にアクセスが少ないという事実を利用して、データプレーン内のストレージレイアウトを効率化しパフォーマンスを向上させました。

教訓2

次にコンテナイメージについて気づいたことは、コンテナイメージは類似のコンテナイメージとコンテンツを共有することが多いということです。

Lambdaの顧客はまずOSのベースレイヤーから始めて、その上にLambdaが提供するJavaランタイムをインストールし、さらにその上に自分たちの関数コードを配置します。Lambdaのデータプレーンはどのデータが共有され、どのデータがユーザー固有であるかを理解することで、コードの配信を最適化できます。しかし、データの重複排除は2つの理由によって困難です。

1つ目はストレージレイヤーがファイルベースではなくブロックベースであることに関連しています。同じ状態を持つ2つのファイルシステムのコピーがブロックレベルでコンテンツを共有するためには、ファイルシステム上のレイヤーを決定論的にフラット化しそのファイルシステムをブロックデバイス上に決定論的にシリアライズする必要があります。しかし多くのファイルシステムはこれを行いません。ファイルシステムによっては同じレイヤーのセットを何度もフラット化してディスクにシリアライズすると、ディスク上で異なる表現になります。この場合ディスク上で重複排除ができなくなります。この問題を解決するためにLambdaチームはext4ファイルシステムでLambdaのための決定論的なフラット化と決定論的なシリアライズを保証するために、いくつかの作業を行ないました。これによりLambdaに必要なシリアライゼーション処理が実現したのです。

重複排除が困難な2つ目の理由は暗号化キーの管理に関連します。Lambdaの基盤はペイロードを暗号化するためにリージョン・顧客別の暗号化キーが必要になります。しかしキーが異なれば、同じ内容でも暗号化されたペイロードが異なります。暗号化されたペイロードが異なれば元は同一のコンテンツであっても重複排除が行えません。

重複排除の恩恵を受けるためには暗号化されたペイロードが同じになるように鍵を共有する必要があります。そのための簡単な方法は、共通の鍵を共通のキーストアに保管することですが、それでは複数の顧客や複数のリソースにまたがるスケーリングの単一障害点になってしまいます。Lambdaが必要とするのは論理的に同じ鍵でありながら、共有鍵の作成と永続化をより簡単なものにすることです。この問題を解決するために収束型暗号と呼ばれる技術が利用されています。

チャンクイメージを暗号化する場合、まず平文のチャンクを用意します。これはフラット化されたファイルシステムの1セグメントを表します。そして、Lambdaサービスによって決定論的に生成されたextraと呼ばれる追加データを追加します。このチャンクのハッシュを計算し、そのハッシュがチャンクごとに一意のキーになります。このキーを使ってチャンクとextraの両方を暗号化します。暗号化チャンクにextraを含めることで保存中または複合中にチャンクが変更されていないことを検証できます。このようにファイルシステムの各チャンクについて、暗号化されたチャンクをチャンクストアに書き込み、それぞれのキーを追跡しています。書き込みが完了したら、チャンクストア内のキーとチャンクコンテンツへのポインタの両方を含むマニフェストを作成します。最後に、顧客固有のKMSキーでマニフェストを暗号化し、マニフェストを永続化します。

この暗号化方式にはいくつかの素晴らしい点があります。まず1つ目、同じextraを使用すると毎回同じチャンクから同じキーが生成されます。これによってコンテンツを安全に重複排除することができます。そして重要なことは、各イメージ作成プロセスを共有鍵に依存することなく独立して進めることができる点です。

2つ目に、暗号化方式を変更することなくextraを変更するだけで、2つのチャンクの内容が同じであっても異なる暗号化キーを使用するよう強制することができます。これは例えば同一のコンテンツが異なるAWSリージョンに保存されている場合に、異なる暗号化キーを使用することを保証するために役立ちます。

ここまでが教訓の2つ目です。キャッシュの性能を向上させ、システム全体の性能を高めると同時に依存関係にある共有リソースを最小限に抑えます。

教訓3

3つ目の教訓は関数コードとインプットがVMと出会う場所についてです。

Lambdaの仮想化アーキテクチャの歴史を簡単に説明すると、Lambdaは1つのリージョンで数百万のアクティブなLambda Functionを実行するために大規模なEC2ハードウェアのフリートを運用しています。サービス開始当初、LambdaはこのハードウェアにT2インスタンスをプロビジョニングし、各テナントにT2インスタンスを割り当てて、そのテナントの実行環境の1つまたは複数を実行していました。ルーティングレイヤーがリクエストを処理する際、既存の実行環境を確認し、存在しない場合はコードをダウンロードしてLambda Functionを実行するのに必要な情報を持つT2インスタンスを初期化していました。規模が大きくなってくると、ある時点で使用されているインスタンスも多くなり、あるLabmbda Functionのためにプロビジョニングされたり、別のLambda Functionへのサイクルのために使用されなくなったりしているインスタンスも多くなるのです。

コード管理については管理対象のステートをスケールアップすることが目標でしたが、VMフリートに関してはスケールダウンすることが目標になります。メモリやディスクリソースのプロビジョニングをできるだけ少なくし、Lambda Functionのオーバーヘッドを削減することで効率性を高めることが目標です。こうしたスケールダウンの必要性から、2018年にLambdaの基盤にFirecrackerを導入することになりました。

FirecrackerはLambdaがベアメタルWorkerインスタンス上で動作するVMM(仮想マシンマネージャ)です。LinuxのKVMを使用して各Worker上で数百、数千のマイクロVMを管理します。FirecrackerによってLambdaはVMレベルの環境隔離というセキュリティ面での恩恵を受けつつ、マルチテナントにおける安全な環境分離を実現しています。FirecrackerはマイクロVMのゲストカーネルとホストカーネル間のIOを処理します。各マイクロVM内にはランタイムや関数コード、Lambda Extensionsを含むLambdaユーザーおなじみの実行環境が含まれています。

FirecrackerによってLambdaは環境間の安全な境界を維持しながら、スケールダウンとサイズ適正化の両方を実現できました。各環境にT2インスタンス全体を割り当てる代わりにもっと小さなMicro VMを各実行環境に割り当てることができました。VMあたりのオーバーヘッドを減らすことで、VM のフリーとはより効率的になりVMの立ち上げや廃棄されたVMの再利用といったオペレーションもより効率的になりました。

Firecracker導入前はフリートのイメージは以下のような形でした。

Firacracker導入後は以下のような形になります。

このFirecrackerへの移行が教訓の3つ目です。

高レベルのアーキテクチャでは、暗号化されたチャンクイメージと、そのイメージを利用するMicro VMがありますが、使用可能なコンテナイメージやファイルシステムをMicro VMに公開する必要があります。この点で、コンテナイメージ形式のパッケージサポートはZIP形式のパッケージよりも難しい仕事でした。

単一ホスト上の単一のMicro VMを例に考えてみましょう。ZIPパッケージの場合はLambdaの基盤がランタイムを所有して管理するので、Micro VMに対して実行環境を開始するために必要最低限の少量のコードを提供することができます。

しかし、コンテナイメージ形式のパッケージを利用する場合はゲスト環境全体が顧客によって提供され、ゲスト環境から見るとチャンクストアからのフェッチや暗号化といったコンテナイメージの管理は隠蔽されています。

EC2ベースの仮想化スタックではこれ以上の改善は望めませんでしたが、Firecrackerを利用するアーキテクチャに移行することで、Lambdaサービス基盤のコードを顧客のVMの外側のホスト上で実行するための場所が生まれました。そこでLambdaチームは仮想ファイルシステムドライバを構築しWorkerからMicro VMにext4ファイルシステムを提供するようにしました。

Micro VMがファイルシステムに対してリクエストを発行すると、VMの外にあるLambdaのサービス基盤が処理を行います。ファイルシステムの実装はマニフェストを解釈して、VMから出力されるinodeの読み取りをイメージ内のチャンクアクセスに決定論的にマッピングします。そしてKMSと連携してマニフェストを安全に復号化し、チャンクのメタデータをキャッシュした上でファイルシステム内のローカルオーバーレイからファイルシステムの権限を提供します。ファイルシステムは読み取りを行う際にWorkerホストのローカルキャッシュからより大きなAZキャッシュ、S3の権威あるチャンクストアまで様々な層のキャッシュを参照します。このチャンクの復号とキャッシュ機構は顧客やLambda Function実行環境からはシンプルなファイルシステムのIFの背後に抽象化されています。

ここまででLambda Function呼び出しのインプット管理からコードとコンテナイメージの管理、VMへのコードデプロイまでLambdaのストレージ改善がほぼ完了しました。しかし、Lambdaチームは顧客のニーズに応えるためにこの状態管理作業とこれらの教訓のすべてを活用するさらなる機会に気づきました。

SnapStartについて

Lambda Functionの呼び出しの99%は既に構築済みのLambda Function実行環境によって処理されています。しかし、長時間のアイドル状態や実行環境のスケールアップにより、呼び出しに対応するための新たな実行環境をスピンアップしなければならない場合があります。これがコールドスタートです。そしてコールドスタートはLambda Functionのレイテンシに大きな悪影響を与える可能性があります。

新しい実行環境を立ち上げるにはMicro VMを起動する、コードをダウンロードする、コードを解凍するといったステップが含まれます。ランタイムを開始しLambda Functionの初期化コードを実行するというステップも重要な要素になります。ランタイムの起動とLambda Functionの初期化というステップは、特にJavaのように言語レベルの仮想マシンを起動する必要がある言語において、コールドスタートのペナルティが大きくなります。そしてこの時間とコストはすべての実行環境に対して発生します。コールドスタートは稀ですが、カスタマーエクスペリエンスに大きな影響を与える可能性があり、サービスオーナーにとっては重要な問題です。

初期化プロセスについて少し抽象的に考えると、このプロセスが実行していることは、Micro VMに必要なコードをデプロイし、サービスや呼び出しの準備ができるようにすることです。そこで初期化時間の短縮という問題を解決するために、Lambdaのストレージサービスとしての側面から得た教訓を応用して、コードを実行中のVMにデプロイするプロセスを完全に回避することができたらどうでしょう?コードをデプロイする代わりに、関数コードを実行中のMicro VMをアーティファクトとしてホストにデプロイすることができたらどうでしょうか?

これを行うのがLambda SnapStartです!

SnapStartはLambda Function実行環境のスナップショット管理を自動化し、Lambda Functionのレイテンシーを大幅に改善します。SnapStartは現在Correto Java 11ランタイムで利用可能です。この機能はMicro VMのスナップショットとリストアをサポートするFirecrackerプロジェクトによって支えられています。

※セッション内では特に言及されていませんが、SnapStartにはFirecrackerのIncremental snapshotsが利用されているようです。キーノートでSnapStartについて発表された際に言及されてています。Incremental snapshotsによってスナップショットのサイズがフルスナップショットの10~20%程度に収まるようです。

SnapStartではLambda Function実行環境のライフサイクルが異なり、関数のコードや設定を更新して新しいバージョンを公開すると、Lambdaのサービス基盤が非同期に実行環境を作成してコードや設定をダウンロードし、Lambda Functionを初期化して呼び出しの準備ができるところまで持っていきます。そして決定的なのは、まだ呼び出しが発生していないことです。Lambdaのサービス基盤がMicro VMのスナップショットのチャンクを取得て暗号化し、マニフェストとチャンクの内容を永続化します。そしてLambda Functionの呼び出しを処理するために新しい実行環境が必要な場合、スナップショットから新しいMicro VMをリストアします。短いリストアフェーズの後に実行環境はトラフィックを処理できるようになります。

SnapStartによって最大で90%程度コールドスタートのレイテンシが改善が見込めます。SnapStartの仕様に合わせて簡単なコード修正が必要なこともありますが、ほとんどのLambda FunctionがSnapStartを利用するだけでコールドスタートのレイテンシを大きく改善できます。

このコールドスタートの改善は、コードを実行中のMicro VMにデプロイするという処理を、実行中のMicro VMをホスト上にデプロイするという処理に置き換えることで可能になったのです。つまり、Lambdaはストレージサービスとしての側面から学んだいくつかの教訓を採用し、サービスのパフォーマンス効率と全体的な使用感を向上させたということです。

Assignmentサービスについて

ここからまたスピーカーがJulian Woodさんに戻り、Worker Managerの後継であるAssignmentサービスについての解説になります。

Frontend InvokeサービスとWorkerの間の調整役であるWorker Managerは、Frontend Invokeが呼び出しを実行環境まで持っていくのを助け、さらに実行環境のライフサイクルを管理するという非常に重要な仕事を担っていましたが、いくつか問題を抱えていました。まず最初にこのサービスは少し肥大化していました。それに加えて、各Worker Managerは冗長性がありませんでした。Woker Managerはのホスト上のどの実行環境が呼び出しを担当するかのリストをメモリ上に保持していましたが、個々の実行環境の状態はたった一つの Worker Managerインスタンスがメモリ上に保持しているだけでした。

この例では紫色のWorker Managerが多くのWorkerホスト上の紫の実行環境全体を管理しています。そしてWorker Managerを管理するためにコントロールプレーンサービスを用意しています。もしWorker Managerに障害が発生したら、Worker Managerが管理していた実行環境はすべて孤立してしまいます。

Frontend InvokeサービスはLambda Functionの呼び出しを処理し続けますが、新しい呼び出しを行う場合、他のWorker Managerに利用可能な実行環境を問い合わせる必要があります。しかし他のWorker Managerは準備済みの実行環境について知らないのでPlacementサービスは新しい実行環境を作成しなければならず、実際には準備済みの実行環境があるにも関わらずコールドスタートを発生させてしまいます。また、このことは孤立した実行環境を回収するまでの間ホスト・ネットワークや、追加の実行環境を構築するために、より多くのキャパシティが必要になることも意味しています。

Lambdaが複数のAZにトラフィックを分散させる方法について考えると、問題はさらに深刻になります。特に小規模なリージョンでは影響が大きいです。例えばAZ1の実行環境の一部はAZ2のWorker Managerに管理されています。非常に稀なケースですがAZ2でゾーン障害が発生した場合、AZ2のWorker Managerが管理する実行環境が全て利用できなくなり、リージョン内の実行環境の1/3のキャパシティが失われてしまいます。さらにAZ2 のWorker Managerに登録されている実行環境は AZ1とAZ3にもあり、これらの実行環境は稼働しているもののWorker Managerの障害により利用不可となり、AZ1とAZ3それぞれで3分の1づつの実行環境が利用不可となります。つまりAZ2の障害によってリージョン内の実行環境の55%が利用できないことになります。Lambdaはは、この種の障害に備えた予備のキャパシティがありますが、これは膨大なキャパシティが必要になりますし、実行環境の再作成が必要になればコールドスタートによって顧客体験を悪化させてしまいます。

そしてこれは膨大な数の追加リクエストに対応するために、Placementサービスを十分に拡張する必要があることも意味しています。

そこでLambdaチームはWorker Managerの置き換えが必要と考え、Assignmentサービスを構築しました。1つのAZ内の単一のWorker Managerが複数のAZの実行環境を管理するのではなく、AZに分散した3つのノードパーティションで構成されるAssignmentサービスを構築しました。

一つのパーティションは、それぞれ異なるAZに存在する1つのリーダーノードと2つのフォロワーから構成されます。各Assignmentサービスは複数のパーティションをホストします。

Assignmentのサービスのパーティションメンバーは、外部のジャーナルログサービスを使用して実行環境に関するデータを複製します。リーダーがジャーナルに書き込み、フォロワーがジャーナルからログストリームを読み、実行環境割り当ての最新状況を把握します。またメンバーもこのログを利用できます。Frontend Invokeサービスはリーダーと通信し、リーダーはPlacementサービスと通信して新しい実行環境を作り、Workerの割り当てを追跡してログに書き込み、フォロワーがそれを読みとります。もしリーダーノードに障害が発生しても、フォロワーがすぐに引き継ぐことができますし、どの実行環境がその後の呼び出しに利用できるかという状態も失われません。つまり、AZ障害時に稼働中のAZの実行環境が孤立することはなく、コールドスタート回数や必要なアイドルキャパシティ、Placementサービスの負荷が減少します。

Assignmentサービスは安定性が非常に優れています。システムが安定性と状態を自ら維持するため、外部サービスによるフェイルオーバー機能を必要とせず、サービスの稼働とリクエストに対する応答を維持することができます。安定性の向上はLambdaで常に目指しているものです。フォロワーの入れ替えや、システム維持のためにAssignmentサービスノードの追加・削除が必要な場合、最も古い実行環境の時点からジャーナルログストリームを読み込んで、パーティションが所有するすべてのアサインの状態を素早く最新にするためブートストラップすることが可能です。

AssignmentサービスはホストネットワークやAZの障害にも冗長性を持ちます。また、機能の一部を別の場所に移すことでサービスをスリム化することにも成功しています。Assignmentサービスはパフォーマンスとレイテンシー、メモリ安全性のためにRustで開発されており、レイテンシーを大幅に削減しながらホスト上で実行できる1秒あたりのトランザクション数を3倍にすることができました。

Utilizaion(稼働率)について

サービスですべての効率を高めることができたということは使用率が向上することも意味します。これはLambda の実行コストに直接影響し、一連のリソースが与えられた場合にワークロードをどれだけ効率的に実行できるかということです。

Lambdaは関数のフットプリントが小さく、リソースの曲線に合わせてワークロードを分散できるため、コードを実行するために最も効率的な選択肢となります。Lambdaでは関数がアイドル状態ではなく実行されている時間に対してだけ料金を支払います。アイドル状態を最小限にするのがLambdaチームの仕事なので、Lambda内部では余剰リソースが生まれないようにサーバーを忙しくして、できるだけサーバーを再利用するように最適化しています。

そしてLambdaをより効率的に実行しLambd Functionのパフォーマンスを向上させるためにWorkerの利用を継続的に最適化しています。

Lambdaの内部にはこのようなリソースの割り当てをサポートするためのシステムがあります。時間をかけて必要なリソースを分析し、ワークロードを最適に分配したり、需要のカーブに合わせたキャパシティをプロビジョニングできるようなシステムもあります。実行環境に負荷を均等に分散させることが最善の方法だと思うかもしれませんが、実はそれは効率性を見逃しています。サービス提供側であるLambdaチームとしては余剰リソースが存在する状況というのは費用対効果が悪いためです。キャッシュの局所性やオートスケール可能性の観点からも無理の無い範囲で一部の実行環境に負荷を集中させた方が良いでしょう。

利用効率が悪くなるのは1つのWorkerホスト上で単一のワークロードを稼働させるケースです。ワークロードは特定のアクセスパターンを持っていて、リソースの使用効率が悪くなるため、同一のホスト上に多くの異なるワークロードを詰め込むことでワークロードに相関性を持たせない方がWorkerホスト全体の利用効率は良くなります。

Lambdaチームはさらに一歩進んで機械学習を用いてワークロードを最適にまとめて配置し、競合を最小限に抑えて使用率を最大化するとともに、異なるLambda Function間で共通データを安全にキャッシュして、全体的なパフォーマンスを向上させるようにしています。Lambdaチームには、著名な教授や研究科学者のチームがあり、このワークロード配置問題に取り組んでいます。

このように、Lambdaはクラウド上でワークロードを実行するための最適な場所として構築され、特に状態に関する難しい分散コンピューティングの問題を処理し、最新のアプリケーションを最小の総所有コストで構築する最速の方法を提供するものなのです。

まとめ

今年もLambdaの裏側について色々と知ることができました。Worker Managerがいつの間にかAssignmentサービスに置き換わっていた話などは普段Lambdaを利用指定しているだけでは絶対に分からないようなアップデートだと思いますが、こういった情報が知れるのもre:inventならではの魅力ですね。個人的にはFirecrackerのスナップショットについてもう少し深堀りして学習していきたいので、また別のブログでスナップショット周りの詳細について紹介できればと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.