AWS Lambdaの環境がどのようになっているか、ユーザが用意したLambdaファンクションがどんな感じで実行されるかってあたりを可能な限り詳しく説明したいと思います。
はじめに
この投稿は2020年9月29日の21時から開催予定のイベント(ライブストリーミング)で話す内容です。
serverless-newworld.connpass.com
もし間に合えば、かつ時間があればぜひライブ配信のほうにも参加ください。
(2020.09.30 update)
上記イベントのアーカイブを公開しています。
サーバーレスアンチパターン今昔物語 第五夜 - 解体新書 -
チャンネル登録してくれると更新通知も飛ぶのでよろしくです。
また、次回は10/22に開催します。次回はAmplifyについて話す予定です。
サーバーレスアンチパターン今昔物語 第六夜 - connpass
新たにアップデートだけ紹介する回も始めます。9月分は10/5なのでよろしければこちらもどうぞ。
Monthly AWS Serverless Update 202010 - connpass
大前提
まず、いろんなところでも言及していますが大事なことなので改めて。
AWS Lambdaは水平方向へのスケーラビリティを特徴とするサービスです。リクエストが来たらそれを処理するLambda関数のコンテナ(インスタンス)を作成し、処理を実行します。
ここで言うインスタンスとはプログラミング言語で言うところの『インスタンス』という意味に近く日本語で『実体』とか訳されているものであり、EC2インスタンスのことではありません。ここ誤解のないように。ドキュメントにあわせてインスタンスという単語を使っていますが、Lambdaにおいてはコンテナのことです。そしてあとのほうに出てきますが、内部的には実行環境とかSandboxと呼ばれてたりもします。いろんなドキュメントや資料で表記が揺れている状態ですね。なのでこのブログでも揺れていますがほぼイコールのものだと読み替えてください。この投稿ではではなるだけ『実行環境』という言い方をしていこうと思います。
なお、Sandboxって呼称については関数の中から色々と覗いてみるとその名残が見えたりします。
このLambda関数のインスタンスは1つあたり同時に1イベントしか処理しません。例えばAmazon API Gatewayをイベントソースとしている場合、APIへのリクエストが同時に100来た場合はそのバックエンドとなるLambda関数のインスタンス100個で処理されます。つまり、1インスタンス(=1コンテナ)で複数のイベントを同時に処理することはありません。ここ重要。ただし、必ずしも毎回100コンテナが1から作成されて起動されるわけではありません。ここで話に出てくるのがコールドスタート/ウォームスタートです。
コールドスタート/ウォームスタート
というわけでまずはこのあたりの話から始めていきましょう。まず、Lambda関数に対してリクエストが行われたときに何がおきるか以下に示しました。
- 実行環境の作成
- デプロイパッケージのロード
- デプロイパッケージの展開
- ランタイム起動・初期化
- 関数/メソッドの実行
- コンテナの破棄
ここでいうデプロイパッケージとはユーザが作成した関数コードならびにそれを実行するために依存するライブラリなどをまとめたZipファイルのことです。このデプロイパッケージはユーザのS3バケットに保存して参照させることも可能ですし、直接アップロードすることも可能です。なお、直接アップロードした場合はサービス側のS3バケットに暗号化されて保存されます。つまり、どちらのパターンであってもLambda関数のデプロイパッケージはS3上にあるということです。
まず、リクエストが届くとユーザが指定したランタイムの実行環境が作成されます。コンテナです。要はこれがインスタンス化だと思ってください。
そして次にそのLambda関数で実行する実際の関数コード、つまりデプロイパッケージがS3からダウンロードされます。
続いて、このデプロイパッケージはZipファイルとなっているのでこれを解凍して展開します。
そして、ランタイムの起動が行われ、初期化されます。グローバルスコープの処理もこのタイミングで実行されます。また、ここでの実行はDurationには含まれませんが一定の時間でタイムアウトします。
最後に、Lambda関数のエントリポイントとして指定されているハンドラメソッドが実行されます。
このプロセスをすべて実行するのが皆さんの大嫌いな『コールドスタート』です。なお、以前はVPCを利用する際は上記の1の前にENIの作成処理も行われていました。
一方、関数・メソッドの実行までの部分というのはLambda関数の設定を変更したり、実行するコードの内容が変更されない限りは毎回同じことを繰り返すことになります。それはさすがに無駄だよねってことで作成した実行環境をそのまま次回以降のリクエストでも使えるようにしているのが『ウォームスタート』です。
全体の実行時間のうち、『コールドスタート』に含まれる時間についてはDurationに含まれない部分が多いため、この『コールドスタート』そのものに厳密にどのくらいの時間がかかっているかはDurationを見てもわかりません。
頑張って計測するならば、End to endで計測して差分の値から推測するしかないわけです。また、End to endで計測するといってもイベントソースとしてAPI Gatewayが手前にいる場合はAPI Gatewayのエンドポイントに到達するまでの遅延も考慮する必要があります。X-Rayも有効です。
さて、この『ウォームスタート』が毎回行われると嬉しいんですが、そうすると作成した実行環境をずっと維持しておく必要があります。それはさすがにリソースの無駄になってしまうのである程度の期間実行されなくなったものについては破棄されます。なお、どのくらいの時間が経過すると破棄されるかについてはいろんな状況によって決められているため一定ではありません。
また、関数の終了時に関数の中から生成するなどした実行中のバックグラウンドプロセスがある場合はLambdaはそのプロセスをfreezeさせ、次回関数を呼び出した際に再開します。ただし、これは実行環境が再利用される場合だけですので、保証はされていません。
加えて、この場合バックグラウンドプロセスは残っていても処理は行われていない状態です。
このように、起動コスト効率化のために行われる『ウォームスタート』ですが、ウォームスタートできる実行環境がない場合はもちろん、あってもその数以上のリクエストを同時に処理する必要が発生した場合は新しく実行環境が作成・起動されます。
コントロールプレーン/データプレーン
さて、ではそんなLambda関数のインスタンスが実行される基盤について話をしていきたいと思います。
まず、AWS Lambdaのサービスは大きくコントロールプレーンとデータプレーンにわけられます。
コントロールプレーンとは関数管理のAPI(CreateFunction、UpdateFunctionCodeなど)を提供していたり、AWSのすべてのサービスとのインテグレーションを管理しています。あとはコンソールとかですね。
一方のデータプレーンはInvoke APIのコントロールです。Lambda関数が新たにInvokeされると実行環境を新たに割り当てる、もしくは既存の実行環境を選択するといったことを行っています。
関数の実行環境はmicroVM上で実行されています。
このmicroVMはAWSアカウントごとに専有で割り当てられ、同一アカウント内では関数をまたがって利用されます。つまり、複数の実行環境は1つのmicroVM上で実行できると言えます。一方、実行環境のほうは異なる関数間で共有されることはなく、microVMはAWSアカウントをまたいで共有されることもありません。
microVMはAWSが所有し管理する環境上に用意されており、EC2が利用されています。また、この環境はWorkerと呼ばれています。
アイソレーション
実行環境のアイソレーションについて簡単に説明します。まず、実行環境は前述のとおりコンテナベースで用意されているので、アイソレーションもコンテナテクノロジに準じています。また、他の環境に属するデータへのアクセスおよび変更は不可。
具体的には以下のようなものを使って実装されています。
- cgroup: 実行環境ごとにCPU、メモリ、ディスク帯域、ネットワーク帯域へのリソースアクセスを制限
- namespace: プロセスID、ユーザID、ネットワーク・インターフェースとその他のリソースをグルーピング。各実行環境は専有のnamespaceで実行される
- seccomp-bpf: 実行環境内から利用されうるsyscallを制限
- iptables/routing tables: 実行環境をお互い隔離
- chroot: ファイルシステムへの特定範囲でのアクセスを提供
cgroupやnamespaceは有名だと思いますが、seccompってのはsecure computing modeのことでLinuxのsandboxを提供するセキュリティ機構です。seccomp-bpfはそのextensionでシステムコールのフィルタリングを可能にするものですね。bpfはBerkeley Packet Filterのことですね。最近、Linuxではいろんなところで使われつつあるものです。パケットフィルタって名前ですが、それだけではなくて実際にはトレーシングなんかでも使われてますね。chrootも昔からあるもので有名ですね。ルートディレクトリを別のディレクトリに変更するものでリソースの隔離とかに使われます。簡単に言うと、これを使うとそのプロセスはその範囲外のファイルにはアクセスできなくなります。このあたりはあまり詳細にふれるとマサカリ飛んできそうなのでこの辺にしておきます。
そして、各実行環境には以下の内容が含まれます。
- 関数のコードおよび依存ライブラリなど。つまりデプロイパッケージの中身
- Lambda layer。これも関数コードに準じる
- 組み込みもしくはカスタムのランタイム
- Amazon Linuxベースの最小限のユーザーランド
そして、AWS LambdaではあるAWSアカウントは複数の実行環境を1つのmicroVM上で実行できますが、AWSアカウント間で共有や再利用されることはありません。
さて、AWS LambdaではmicroVMを隔離するにあたって、2つの異なるメカニズムを利用しています。歴史的に利用してきたといってもいいかもしれません。それはEC2インスタンスとFirecrackerです。
FirecrackerはAWSが2018年に公開したOSSのハイパーバイザでコンテナやサーバーレスのワークロードに特化してデザインされています。
EC2モデルではmicroVMとEC2インスタンスは1:1でマッピングされており、Firecrackerの場合はEC2のベアメタルインスタンスで稼働しており、実行環境とmicroVMが1:1でマッピングされているもののmicroVM自体はベアメタルインスタンス上に複数存在することになります。そして、Firecrackerの場合はmicroVMを実行しているベアメタルインスタンス自体は複数AWSアカウントで共有されます。一方で、FirecrackerからユーザのワークロードまではDedicatedに割り当てられます。つまり各関数用のFirecrackerのプロセスはホストOS上ではそれぞれ別プロセスになります。
KVMはハードウェア仮想化のためのプログラムで低レベルな仮想化を行っています。例えばメモリ管理やページングなどはKVMが担当しています。言ってみればハードウェアを抽象化してると言えます。
一方で、Firecrackerはデバイスのエミュレーション、パフォーマンスアイソレーションあたりを担当します。高速に動作してオーバーヘッドが少ないのが特徴で、サーバーレスのワークロードに最適化されています。
Firecrackerそのものの詳細についてはこちらに論文があるので興味ある人はどうぞ。
AWS Lambdaのコンポーネント群
ここからはAWS Lambda内部の全体的なコンポーネント群に関して簡単にかいつまんで話します。
AWS Lambdaと一言で言っても内部的に大きくいくつかのコンポーネントにわかれているのは想像に難くないと思います。
代表的なものを以下にあげておきます。これらは基本的に先述のデータプレーンと呼ばれるほうに属します。一方、コントロールプレーンにはAPIとかLambdaのコンソールとかSAM CLIが含まれます。
- Front End: いわゆるUI的な意味でのフロントエンドではない。同期呼び出し、非同期呼び出しの両方をオーケストレーションしたりする
- Counting Service: 同時実行数の制限を行うための同時実行数のリージョン別のビューを提供
- Worker Manager: 実行環境のアイドル状態とビジー状態をトラッキングし、呼び出しリクエストを利用可能な実行環境へとスケジューリング
- Worker: 実行環境のこと。つまりコンテナでありLambda関数インスタンス(いろんな言い方あってややこしい…)。ユーザのコード実行のためのセキュアな環境を提供
- Placement Service: 集積度を最大化するように効率的にWorkerを配置
- Poller: イベントを消費し、処理するようにする
- State Manager / Stream Tracker: Pollerとイベントもしくはストリームリソースを管理してスケーリングを処理
- Leasing Service: 特定のイベントまたはストリーミングソースで作業するためにPollerを割り当てたり開放したり
Front Endはリクエストされた関数の実行環境を特定し、ペイロードをその実行環境へと渡す仕事もします。
なお、インターネットごしのロードバランサーへのトラフィックはTLSで保護されていて、Lambdaサービス内のトラフィックは単一リージョンにおけるインターナルなVPCを通って処理されます。
最後のLeasing Serviceは定期的にPollerのAssignmentのヘルスチェックも行っています。UnhealthyなものやHeart beatが切れたものを検知して新しいPollerを割り当てたりもします。
なお、実際にはFront Endの手前にApplication Load Balancer(ALB)が存在しています。
さて、ここからは代表的な挙動をみていきます。
同期実行かつ初回呼び出し(コールドスタート)、もしくはスケーリング
呼び出しタイプがRequestResponseで実行される場合かつコールドスタートが発生する場合とスケールする場合ですね。ちなみにスケールする場合も当然コールドスタートが発生します。
このときの挙動としては、
- ALB経由でFront Endにリクエストが届く
- Front EndがWorker Managerに対してReserve Sandboxをリクエスト
- Worker Managerは利用可能なWarm SandboxがなかったらPlacementに対してWorkerを要求し、アサインされたらWorkerを初期化
- WorkerがInvokeされる
こんな感じです。これが冒頭で説明したリクエストが発生したときに内部で何が起きているか、の全体的な部分です。
同期実行かつ再利用(ウォームスタート)
次は同じく同期実行ですが、ウォームスタートする場合ですね。
- ALB経由でFront Endに届く
- Front EndがWorker Managerに対してReserve Sandboxをリクエスト
- ウォーム状態のWorkerがリターンされるのでそれをInvoke
非同期実行
今度は呼び出しタイプがEventの場合です。このときは非同期にLambda関数が実行されます。
- ALB経由でFront Endにリクエスト
- Front EndがSQSにSendMessage
- PollerがそのQueueを見てRevceiveMessageおよびDeleteMessage
- PollerがFront Endに対してInvokeを実行(あとは同期実行と同じ)
ポイントは非同期実行の場合はリクエストを受け取ってからSQSを挟んで処理しているってことです。よくあるSQSを使ったキューイング処理と同じですね。なお、このキューはサービス側で管理されているものでありユーザには見えません。
スケールアップ
- State ManagerがSQSキュー上でWorkを探索
- State Managerが変更を読み取ってLeaing Service経由でPollerの割当を作成(Poller Assignment Dataに書き込み)。このとき関数の同時実行数の設定が大きい場合、StateManagerは新たにdedicatedなキューを作成する。小さい場合は既存のキューを使う。
なお、State Managerは複数のPollerを用意することで冗長性を確保しています。また、Poller自身はPoller Assignment DataからそのAssignmentを読み取ります。
エラーハンドリング
非同期実行時のエラーハンドリングについてです。とあるイベントソースを処理するためのPollerがいる状態です。
- Pollerの割り当てられた処理がストップしてしまう(何らかの理由で)
- Leasing Serviceがヘルスチェックしていて、期待した間隔でheartbeatがなくなったがAssignmentを見つける。
- そのAssignmentをUnhealty availbleにする
- 他のPollerがAssignmentを取って処理を開始する
リトライ
非同期の場合にLambda関数の実行がエラーになった場合のリトライの話です。
- 普通に非同期で実行(ALB → Front End → SQS → Poller...っていうやつ)
- 実行がエラーになったらPollerはリトライポリシーの設定に従いリトライを行う
- それでもダメだった場合はPollerがDLQのキューにsendMessege
- Pollerが元のキューからメッセージを削除
だいたいこんな感じです。非同期実行時の話として今回はイベントソースがストリーミングじゃない場合の話をしましたが、Kinesis Data StreamやDynamoDB Streamといったストリームをイベントソースとして使う場合はStream Trackerがストリームの状況(新規/更新/削除など)をトラッキングします。また、実行するPollerの数を決めるためにアクティブなシャード数をチェックします。新しいシャードが追加された場合はStream Trackerが同時実行数を計算する処理を走らせます。
その他
その他の細かいところをいくつか。
/tmpは他の実行環境をまたがってアクセスできません。
実行環境に割り当てる前にメモリを消去しています。消去というかゴシゴシするというか。これは異なるアカウントの関数との間でメモリの中身が共有されてしまうことを防止するためです。もちろん、同一実行環境上での同一関数の再呼び出し、つまりウォームスタートの場合はこの処理は行いません。
ちなみに、不安なのであれば関数の終了前にメモリ内の暗号化ならびにwipeの処理を実装することも可能なのでやってみてもいいかもですね。
ネットワーク
AWS Lambda のすべてのコンピューティングインフラストラクチャは、サービスが所有する VPC 内で実行されています。Lambda関数の呼び出しにはAPI経由しか用意されておらず、関数が実行される実行環境への直接的なネットワークアクセスはありません。
ユーザがVPC(カスタマー VPC)を利用するLambda 関数を設定すると、カスタマー VPC 内に Elastic Network Interfaces(ENI)が作成され、クロスアカウント接続が行われます。この状態でもLambda関数の実行自体は引き続きサービスのVPCで実行され、カスタマー VPC を介したネットワーク経由のリソースのみにアクセスする形になります。
関数用に作成されたすべてのネットワークインターフェースは、VPC サブネットに関連付けられ、IP アドレスを消費します。 ユーザはサブネット内の IP アドレス空間の管理、アカウントレベルのネットワークインターフェイスの制限、新規ネットワークインターフェイス作成の API レート制限に達する可能性、そして冒頭で言及した実行時にENIを作る時間コストがかかるという問題がありました。
これが以前のVPCを利用する際の状況でした。それが大きく改善されたのが昨年(2019年)の秋です。
それより少し遡ること2017年にAWSにおいて内部ネットワークのための内部サービスとしてAWS Hyperplaneというものがローンチされています。これはあくまでも内部サービスなのでユーザが利用することはありません。これを活用したAWSのサービスとしてはAmazon Elastic File System、AWS Managed NAT、AWS Network Load Balancer、AWS PrivateLinkといったものがあります。
そして、AWS Lambdaもこれをもとにした大きな改善を2019年にリリースしました。
VPC のネットワークインターフェースをHyperplane ENI にマッピングして、関数はそれを使用して接続するようになりました。
そして大きなポイントとして、以前と異なりネットワークインターフェースの作成はLambda 関数が作成されるか、VPC 設定が更新されるときに発生するようになりました。
関数が呼び出されると、実行環境は事前に作成されたネットワークインターフェイスを使用するだけよくなったのでコールドスタートでのネットワークインターフェイスの作成と接続で発生していた遅延が劇的に削減されたわけです。
また、このネットワークインターフェイスは実行環境全体で共有されるため、関数ごとに必要なネットワークインターフェイスの数も大幅に減りました。
ネットワーク・インターフェースはアカウント内の関数にまたがるすべての一意のセキュリティグループとサブネットの組み合わせごとに必要です。ただし、この組み合わせがアカウント内の複数の関数で共有されている場合、関数間で同じネットワークインターフェイスを再利用します。
関数のスケーリングは、ネットワークインターフェースの数に直接関係しなくなり、Hyperplane ENI は、多数の同時関数実行をサポートするようにスケールできるようになっています。
まとめ
という感じで、AWS Lambdaの中身について多少なりともお伝えできたと思いますがぶっちゃけ利用者はこんなの知る必要ないと個人的には思います。知る必要ないというか意識する必要ないというか。
説明しておいてなんですが、このあたりを追うのではなく、Lambdaを使って何を作るか、Lambdaをどう活用するかっていうアプリケーション的なところに意識を向けたほうがいいと思っています。
唯一、コールドスタートのときの話だけ頭に入れておくといいでしょう。