話題の記事

Lambdaの内部アーキテクチャ教えます!A serverless journey: AWS Lambda under the hood #SVS405 #reinvent

昨年に続きLambdaの内部アーキテクチャを知ることができる濃〜いセッション
2019.12.18

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

CX事業本部の岩田です。

昨年re:inventから帰国後にyoutubeで見つけたセッション「A Serverless Journey: AWS Lambda Under the Hood」が非常に興味深い内容でした。今年も同様のセッションが無いかre:inventのセッション予約開始前からチェックしていたところ、なんと今年も同じタイトルのセッションがありました。これは現地で聞くしかない!ということで聞いてきましたので、改めてセッション内容をまとめてレポートします。

資料

セッション動画

スライド

A Serverless Journey: Under the Hood of AWS Lambda

例年ならSlide Shareで公開されるのですが、今年に関しては今のところSlide Shareには上がっていないようです。なお以後登場する画像のほとんどはこちらのスライドからの引用となります。

注意事項

  • 純粋なセッションレポートではありません。そのため、昨年のセッションのおさらい的な内容や事例紹介の話は省略しています
  • 一部意訳している箇所や、公式ドキュメント等セッション外から引っ張ってきている情報もあります
  • 頑張ってまとめたつもりですが、私は英語力が低めなので、もしかすると間違っている箇所があるかもしれません。もし誤訳している箇所があればコメント欄等で教えて頂けると幸いです

Lambdaの起動パターンについておさらい

セッションの内容に入る前に、まずLambdaの起動パターンについておさらいです。AWS Black Belt Online Seminar Let's Dive Deep into AWS Lambda Part1 & Part2によると、Lambdaのイベントソースは以下の3つに分類されます

  • ポーリングベースかつストリームベース
    • DynamoDB、Kinesis
  • ポーリングベースかつ非ストリームベース
    • SQS
  • 非ポーリングベース
    • 上記以外

さらにLambdaの呼び出しタイプは

  • 同期呼び出し
    • InvocationTypeがRequestResponse
  • 非同期呼び出し
    • InvocationTypeがEvent

の2つに分かれます。

AWS Architecture Blogブログではこれらを以下のような3パターンに分類しています。

※画像は上記ブログより引用

  • Synchronous(同期呼び出し)
    • InvocationTypeにRequestResponseを指定して起動するパターン
    • ELB,Cognito,API GW等のサービスから起動するパターン
  • Asynchronous(非同期呼び出し)
    • InvocationTypeにEventを指定して起動するパターン
    • S3,SNS,SES等のサービスから起動するパターン
  • Stream(ポーリングベース)
    • Kinesis,SQS,DynamoDBストリームから起動するパターン
    • Black Beltの資料で分類されている、ポーリングベースかつ非ストリームベースとポーリングベースかつストリームベースをまとめた起動パターン

本セッションはこちらの分類ベースでの話になります。この3パターンのうち、Synchronous(同期呼び出し)については昨年のセッションで内部アーキテクチャについて説明がありました。今年のセッションでは残り2つのAsynchronous(非同期呼び出し) = イベントソース、Stream(ストリーミングソース)についての詳細を知ることができます。同期呼び出しについては去年のセッションをまとめたこちらのブログを参照して下さい。

2019年VPC Lambdaが高速に!! AWS Lambdaの内部構造に迫るセッション 「SRV409 A Serverless Journey: AWS Lambda Under the Hood」 #reinvent

イベントソース(非同期呼び出し)&ストリーミングソースのLambda

ここからセッションの内容に入っていきます。イベントソース(非同期呼び出し)とストリームソースのLambda呼び出しは以下のコンポーネントが協調して実現されます。

Lambdaのコンポーネント郡
  • Poller
  • State Manager
  • Stream Tracker
  • Leasing Service

これらのコンポーネントの詳細について見ていきましょう。

Poller

Pollerはイベントデータを消費して処理するコンポーネントです。

イベントソース(非同期呼び出し)の場合は、(顧客もしくはAWSの管理する)SQSからイベントデータを読み取ります。非同期呼び出しの場合、Pollerの主な役割は以下の通りです。

  • SQSから取得したペイロードを渡してLambda Functionを同期呼び出しする
  • 障害発生時の再試行を管理する
  • 定期的にハートビートを送信する
  • Lambda Function実行結果のデータをDestination configurationで指定されたAWSサービスに配信する

ストリーミングソースの場合、Pollerはシャードをサブスクライブし、シャードからイベントデータを読み取ります。ストリーミングソースの場合、Pollerの主な役割は以下の通りです。

  • シャードをサブスクライブし、対象Lambda Functionのメタデータ、バッチサイズの変更を監視する
  • ストリーミングソースからデータを受信し、バッチルール(バッチサイズ、バッチウインドウの設定)に従ってLambda Functionを起動する
  • スロットリングが発生した場合にリトライをおこなう
  • 定期的にハートビートを送信する
  • ストリーミングソースが無効化または削除されたときにPollerの割り当てを削除する

State ManagerとStream Tracker

State ManagerとStream Trackerはスケーリングを管理するコンポーネントです。Poller及びイベントソース、ストリーミングソースを管理します。

イベントソースの場合はState Managerがスケーリングを管理します。Lambda Functionの同時実行数に応じて新たにキューを生成し、Leasing Service経由でPollerの割り当てを行います。また、イベントソースの状態遷移を監視し、dwell time(Lambdaのサービスキューで経過した時間)等のメトリクスを作成します。

ストリーミングソースの場合はStream Trackerがスケーリングを管理します。Stream Trackerはストリーミングソースを管理するAPIを公開します。また、Stream TrackerはListShardsの結果に基づいてLeasing Service経由でPollerのリースを行い、リースの変更が必須となるLambda Functionの変更やストリーミングソースの変更が発生していないかを定期的にチェックします。

Leasing Service

Leasing Serviceはイベントソースもしくはストリーミングソースで動作するようにPollerの割り当てを行います。イベントソースの場合はState Managerによって指定された数のPollerを割り当てます。イベントを処理するためにキューごとに割り当てるPollerの数を調整し、Pollerの割り当てと解放を繰り返します。また、定期的にPollerのヘルスチェックを行い、Pollerがunhealthyとマークされた場合は新たにPollerの割り当てを行います。

ストリーミングソースの場合、Pollerの割り当てと解放を行うためのAPIをStream Trackerに対して公開します。

具体例

ここからは具体例を見ながら各コンポーネントがどのように連携しているかの具体例を見ていきます。

イベントソース(非同期呼び出し)

前述の通りSQS、SNS、S3等のAWSリソースをイベントソースに指定している場合は非同期呼び出しになります。非同期呼び出しは以下のようなシーケンスで実行されます。

Lambdaの非同期実行パス
  • ユーザーがALBに対してLambdaの非同期実行(InvocationType=Eventを指定)をリクエストする
  • ALBがFront End Invokeにリクエストを転送
  • Front End InvokeがSQSにメッセージを送信(SendMessage)する
  • 対象のキューをポーリングしているPollerがSQSからメッセージを受信(ReceiveMessage)する
  • PollerがFront End Invokeに対してLambdaの同期呼び出しを要求する
  • この後の流れは同期呼び出しと同様

SQS等のAWSリソースがイベントソースとして指定されている場合も同様の流れになります。

Event Destinationsの動作について

11/25のアップデートでLambdaにEvent Destinationsの設定が追加されました。

Introducing AWS Lambda Destinations

Event Destinationsを利用することでLambda Functionの実行結果(成功/失敗)に応じて、指定されたAWSリソースにメッセージを配信することができます。

Event Destinationsのフロー

Event Destinationsを設定すると、以下のようなシーケンスでLambda Functionの実行結果が指定されたAWSリソースまで配信されます。

  • ユーザーがALBにリクエストを発行〜Pollerが同期呼び出しを行うまでは先ほどの非同期呼び出しのシーケンスと同様
  • 同期呼び出しの結果(成功/失敗)に応じてEvent Destinationsで指定されたリソースに対してPollerがメッセージを配信する
  • PollerはSQSからメッセージを削除する(DeleteMessage)

スケールアップ&スケールダウンについて

State ManagerとLeasing Serviceがスケールアップとスケールダウンを管理します。前述のように、State Managerはキューおよび関連するPollerをスケールアップして、受信したイベントをLambda Functionsに設定された同時実行数まで処理する責務があります。 Leasing ServiceはPollerへの割り当てを取得および解放する責務があります。

スケールアップ&スケールダウンに関するシーケンスは以下の通りです。

非同期実行されるLambdaのスケールアップ&ダウン
  • State ManagerがSQSのメッセージをチェックする
  • State ManagerがSQSの変更を検知し、かつLambda Functionの同時実行数が大きい場合はLeasing ServiceにPollerの割り当てを要求し、新しくキューを作成する。
  • Lambda Functionの同時実行数が小さい場合は既存のキューをそのまま利用する
  • Leasing ServiceはDynamoDBにPollerの割り当て情報を書き込む
  • State Managerは指定されたリソースに対して複数のPollerを割り当てることで冗長性を確保する。
  • PollerはDynamoDBから割り当て情報を読み取り、キューのポーリングを開始する

エラー制御について

前述のようにLeasing Serviceは定期的にPollerのヘルスチェックを行い、ハートビートを送信していないPollerを検出した場合は、新しいPollerの再割り当てを行います。

このシーケンスは以下の通りです。

非同期実行されるLambdaのエラーハンドリング
  • イベントソースを処理するように割り当て済みのPollerが停止する
  • Leasing Serviceは一点期間内にPollerからハートビートが送信されているかをチェックする
  • ハートビートが送信されていない場合、Leasing Serviceは新たなPollerの割り当てを行う
  • 新しいPollerが割り当て情報を読み取り、イベントソースの処理を開始する

リトライポリシーについて

New AWS Lambda controls for stream processing and asynchronous invocations

元々Lambdaのリトライポリシーは変更することができず、リトライ回数やリトライ期間は全て共通の設定でしたが、この機能がリリースされたことでユースケースに応じてリトライポリシーを制御できるようになりました。

  • イベントのライフタイムが0秒から6時間の範囲で設定可能に
  • リトライ回数は0〜2の範囲で設定可能に

例えばリトライ回数が1 、DLQが設定されたLambda Functionが失敗する際のシーケンスは以下の通りです

非同期実行されるLambdaのリトライポリシー
  • ユーザーがALBにリクエストを発行〜Pollerが同期呼び出しを行うまでは前述の同期呼び出しのシーケンスと同様
  • Front End Invokeからエラーが返却される
  • Pollerは待ち時間経過後に再度同期呼び出しを試行する
  • Front End Invokeからエラーが返却される
  • PollerはDLQにメッセージを送信する(SendMessage)
  • PollerはSQSからメッセージを削除する(DeleteMessage)

ストリーミングソースの処理について

続いてKinesisやDynamoDBのようなストリーミングソースからの実行パスです。シーケンスは以下のようになります。

ストリームベースのLambda実行パス
  • ユーザーがKinesisにレコードを追加する
  • Kinesisのシャードに割り当てられたPollerがレコードを取得する
  • Front End Invokeに対してLambdaの同期呼び出しを行う

ストリーミングソースの呼び出しが有効化されるまでのシーケンスは以下の通りです。

ストリームベースのLambda実行パス
  • Stream Trackerがイベントソース(Kinesisのシャード等)の状態を読み取る
  • Stream TrackerがLeasing Serviceに対してPollerの割り当てを要求する
  • 割り当てられたPollerはイベントソースのシャードのサブスクライブを開始する

スケールアップ&スケールダウンについて

ストリーミングソースが有効になっている場合、アクティブなシャード数から必要なPollerの数を計算/決定します。Kinesisがシャードを追加作成すると、Srream Trackerはストリームの処理に必要な同時実行数を計算し、Leasing Serviceに追加のPollerを要求します。Poller上ではシャードからデータを受信するためのスレッドが複数動作しており、シャードは、Pollerの負荷が均等になるように割り当てられます。

さらに、大規模なストリーム処理をサポートするために、ストリーミングイベントソースの並列化係数をサポートするようになりました。このアップデートにより1つのシャードを複数のPollerで同時に処理できるようになり、顧客側でPoller数をスケールアップさせることが可能になりました。

New AWS Lambda scaling controls for Kinesis and DynamoDB event sources

シーケンスは以下の通りです。

ストリームベースのLambdaのスケールアップ&ダウン
  • Stream TrackerがlistShardsAPIを呼び出しシャード数の変更を検出する
  • Stream TrackerはLeasing Service経由でPollerの数を調整する
  • Leasing ServiceはDynamoDBにPollerの割り当て情報を書き込む
  • PollerはDynamoDBから新しい割り当て情報を読み取り、シャードのサブスクライブを開始する

エラー制御について

ストリーミングソースのLambdaはエクスポネンシャルバックオフとDLQ(Dead Letter Queue)をサポートしています。Lambdaはストリーミングソースを順番に処理し、処理が失敗したバッチはレコードの有効期限が切れるまで処理をリトライします。リトライ回数と、エラー時のバッチ分割を指定することで、より少ないレコード数でリトライを実施できます。

リトライとバッチ分割、DLQが動作するシーケンスは以下の通りです

ストリームベースのLambdaのエラー制御
  • ユーザーがKinesisにレコードを追加する
  • Kinesisのシャードに割り当てられたPollerがレコードを取得する
  • Front End Invokeに対してLambdaの同期呼び出しを行う
  • 処理が失敗し、Front End InvokeがPollerにエラーを返却する
  • Pollerは失敗したバッチを2つのバッチに分割し、再度Front End Invokeに対してLambdaの同期呼び出しを行う
  • 2つのバッチのうち、1つが成功した場合
  • Front End InvokeがPollerに成功を返却する
  • Front End InvokeがPollerにエラーを返却する
  • Pollerはリトライ後もエラーが返却されたバッチをDLQに送信する

Lambdaがどのように予測可能なパフォーマンスを実現しているか

ここからはスピーカーが交代し、Lambdaのスケールアウトに関する考え方についての解説です。

LambdaのProvisioned Concurrencyについて

re:invent期間中にProvisioned Concurrencyの設定がリリースされました。このProvisioned Concurrencyをどのように設計したのか?根底にある考え方について解説します。

なぜProvisioned Capacityではないのか

レイテンシーへの対策として、同時実行数ではなくキャパシティを指定するようなアプローチも考えられます。例えばLambda実行環境をプロビジョニングするために5つのホストを予約するとか、300個のコアを予約するといったアプローチです。しかし、Lambdaでは同時実行数を指定する方式が選択されています。これには3つの理由があります。

  1. まず1つ目の理由はキャパシティはハードウェアに依存することが分かっているからです。特定のワークロードに必要なキャパシティはハードウェアのパフォーマンスに依存します。ハードウェアが新しいCPU、より高速なメモリに変更されると、それに応じて必要なキャパシティも変わります。そのため、適切なキャパシティを選択するにはハードウェアのパフォーマンスについて熟知しておく必要があります。サーバーレスアーキテクチャを選択する顧客にハードウェのパフォーマンスに対する理解を求めるべきではありません。
  2. 2つ目の理由は耐障害性です。キャパシティモデルではホストやAZの障害を考慮し、AZ障害が発生した際も十分なキャパシティが得られるように設計することが求められます。一方で同時実行数モデルはどうでしょうか?ホスト障害やAZ障害はLambdaというサービスの基盤が検出して対応するものであり、ユーザー側は障害に備えて同時実行数を追加でプロビジョニングしておく必要はありません。
  3. 3つ目の理由は、キャパシティモデルではビジネスロジックの変更がキャパシティの使用効率に影響を与える可能性があることです。例えばキャパシティ管理に取り組むチームと、顧客向けにビジネスロジックの開発に取り組みチームの2つのがチームが存在する場合、これらのチームが密接に連携する必要があります。ビジネスロジックの実装チームが機能を追加していくと、プロビジョニング済みのキャパシティから得られるスループットは変化していきます。

なぜProvisioned Rateではないのか

こちらも3つの理由があります。

  1. まず1つ目。DynamoDBストリームやKinesisから起動するLambdaはバッチという単位で複数のレコードをまとめて処理しますが、計測の指標にRPS(Request Per Second)を使用した場合、バッチサイズを考慮した計測になりません。1秒間に100リクエストが発生するワークロードで、バッチサイズを1に設定した場合とバッチサイズを100に設定した場合では処理量は全く異なります。そのためRPSという指標はLambdaで採用する指標としては不適切です。
  2. 次に2つ目の理由としてRPSは処理のコストを考慮しません。REST APIを例に考えてみます。例えばAmazon.comで過去5年間に購入した物の一覧を取得するAPI(LIST API)の処理コストは大きくなります。それに対して主キーを指定して1つの物を取得するAPI(GET API)の処理コストは非常に小さくなります。そのためGET APIのRPSとLIST APIのRPSはシステム規模を表す指標としては大きく異なります。一方同時実行数という指標はレイテンシやコストを考慮した指標です。
  3. 最後にRPSはリソース競合の影響を考慮しませんが、同時実行数はリソース競合の影響により変化する指標です。これはサーバーレスで大規模なシステムを構築する際の大きなアドバンテージです。

分散システムのスループットについて

実際の例を見てみましょう。

分散システムのスループット例

横軸はシステムへの処理要求数で、縦軸は完了した処理の数です。Lambdaで構築したステートレスなシステムは、理想的にはこの黄色線のように推移します。しかし、ほとんどの分散システムは、競合が発生し得るポイントをいくつか持っています。例えばデータベースのレコードや、キューへのアクセス等です。これらの競合ポイントにより、システムのパフォーマンスは低下します。

アムダールの法則という法則があります。システムの同時実行数や並列処理の数が増えていくと、逐次実行しかできない処理の影響でシステム全体のスループットは頭打ちになるという考え方です。これはre:inventの昼食会場に似ています。昼食会場のホールには多くのビュッフェテーブルがありますが、昼食会場への入り口は1つだけで、そこでは入場のためにバッチのチェックが必要です。これはアムダールの法則のモデルそのもので、ビュッフェテーブルを増やすことで並列度を上げることはできますが、最終的には入り口のドアがボトルネックになり、スループットは入り口のドアを通過できる人数に制限されます。これがスケーリングを停止させるリソースの競合です。

アムダールの法則は少し楽観的です。アムダールの法則に従うと、システムのスループットは青色線のように推移するはずですが、実際の分散システムでは緑線のように遷移します。先程の昼食の例で考えてみます。順番待ちに並ぶ全ての人が綺麗に列を作って、スムーズに連続して真っ直ぐドアに入れば競合はありませんが、実際には色々な方向から人が訪れてドアを通ろうとします。左から来た人と右から来た人が同時にドアに入ろうとすることもあるでしょう。これによってドアのスループットは低下します。これは実際の分散システムでも発生する事象です。

分散システムで用いられるアルゴリズムとして有名なものにpaxosがあります。paxosには同時実行数が増えるにつれてスループットが低下するというエッジケースが存在します。他の分散システム向けプロトコルでもpaxosと同様の事象が発生し、管理は非常に困難です。そのため、現実世界ほほとんどのシステムは、あるポイントを境にスループットが低下します。このシステムのスループットが最大化するポイントをSweet Spotと呼びます。Lambd Functionごとの同時実行数制限とProvisioned Concurrencyの機能を組み合わせることで、システムのSweet Spotを特定し、システムのスループットをSweet Spot近辺に維持することが容易になります。

レイテンシについて

リトルの法則によると、システムの同時実行数 = 到着率 × レイテンシ(待ち時間)となります。

リトルの法則に隠された側面は、従来型のアーキテクチャではスケーラブルでレイテンシの低い分散型システムを構築するのは難しいということです。なぜならレイテンシは同時実行数に依存する傾向があるからです。従来型のアーキテクチャでは、同時実行数が上がるとレイテンシーも増加し、さらに同時実行数が上がります。このループによりシステムは不安定になります。Lambd Functionごとの同時実行数制限とProvisioned Concurrencyの機能を組み合わせることで、このような状況に陥ることを回避できます。

分散システムのSweetスポット

レイテンシーはSweet Spotを境に上昇していきます。全てのサーバーをビジー状態に保ち、コストを最適化したければSweet Spot近辺でシステムを運用する必要があります。この場合、Sweet Spotを超えないように注意が必要です。仮にSweet Spotを超えた場合はどのように対応すれば良いでしょうか?選択肢は2つです。

  1. リクエストを拒否する。これはシステム運用の観点では良い対応ですが、ビジネス観点からは良い対応とは言えません。
  2. スケールアップする。サーバーやロードバランサーといったリソースを追加し、システム全体の処理能力をスケールアップすることができます。

それぞれの選択肢についてもう少し深堀りしてみます。

スケールアップ

スケールアップには時間がかかります。処理の内容次第ですが、Lambda実行環境がスケールアップするとコールドスタートが発生し、ミリ秒~秒レベルの遅延が発生します。これがコンテナやEC2になると数秒~数分の遅延になります。オンプレなら...

AWS Auto Scalingは素晴らしい技術ですが、CPUやメモリといったメトリクスをベースにスケールアップするため、前述のキャパシティモデルにおける課題が残ります。Lambdaは同時実行数ベースでスケールアップすることが可能です。繰り返しになりますが、同時実行数というのはシステムの負荷を評価するためのベストな指標です。

リクエストを拒否する

従来型のシステムではシステムの負荷を間接的に評価する指標に基づいてリクエストを拒否します。そのためにシステムのベンチマークテストを行い、xxRPSのような指標を設定し、この指標を超えないように設定を行います。しかし大小様々なバッチサイズが混在するようなワークロードでは、この指標をベースにシステム負荷を正しく評価することは困難です。LambdaはFunctionごとに設定可能な同時実行数の上限という直接的な指標によってリクエストを拒否することが可能です。

優れたWebサービスを構築するためには

Lambdaと従来型アーキテクチャのレイテンシーモデルの違い

レイテンシーの小さな優れたWebシステムを構築するためには以下の3つの要素が重要になります。

  1. 1つ目は負荷をコントロールすることです。ボトルネックの要因が何であれ、スループットが下がり始める転換点を超えないように制御しましょう。
  2. 2つ目は高速にスケールすることです。コストを最適化するためにSweet Spot近辺を維持したいと考えるでしょう。Sweet Spotを超えてスループットが下がり始める転換点に近づいた場合は、速やかにスケールアップを行い、キャパシティを追加できることが重要です。
  3. 最後に事前プロビジョニング可能なことです。レイテンシーにシビアなシステムにとって、スケールアップ処理は期待値よりも遅い場合があります。Provisioned Concurrencyを利用することで、Lambdaでも事前プロビジョニングが実現できるようになりました。

システムの負荷が上がり、スループットが下がり始める転換点直前に達したとき、従来型のアーキテクチャではAuto Scalingや、運用チームによるリソース追加で対応します。これらの対応は時間がかかるため、転換点を超えてレイテンシーが大きくなりがちです。LambdaのProvisioned Concurrencyはより優れたモデルで、レイテンシは多少上がりますが、従来型のアーキテクチャのような大きな遅延にはなりません。

Lambdaの環境分離について

ここからトピックが変わり、FirecrackerがどのようにLambdaの環境分離を提供しているかについての説明になります。FirecrackerはAWSが開発し、オープンソース化したVMM(Virtual Machine Monitor)です。Firecrackerによって同一ハードウェア上で動作するLambda Functionの環境分離が実現されています。メモリを128M割り当てたLambda Functionは専用のハードウェア上で動作しているわけではなく、同じハードウェア上で多くの異なるワークロードが実行されており、仮想化技術によって環境分離を実現しています。

Lambdaというサービスのスタックはこのような構成になっています。

Lambda実行環境のスタック

最上位のスタックはユーザーのワークロードです。ユーザーが作成したコードやLambda Layersが含まれます。その下はLambda実行環境にビルトインされたランタイムで、Java,Node.js,Python等の環境を提供しています。そしてLinuxカーネルがあります。このLinuxカーネルは不要な機能を削除して最小化されたLinuxカーネルです。そしてFirecrackerです。

これらの灰色部分は個々のLambda Function専用の環境です。ユーザーからはこれらの構造を知ることはできませんが、実際はこのように各Lambda FunctionがFirecrackerまでのコンポーネントを占有しています。これはFirecrackerのプロセスが非常に軽量なため実現可能なアーキテクチャです。そしてFirecrackerはLinuxカーネルの機能であるKVM(Kernel-based Virtual Machine)を利用してセキュリティの境界を提供します。

KVMの役割

スタック内でのKVMの役割はハードウェアへのアクセスです。Intel,AMD,ARMのCPUが持つ仮想化支援機構を利用してこれを実現します。仮想マシンごとに隔離されたメモリ領域とページテーブルを提供し、ページフォルトを管理します。

また、ハードウェアの抽象化も提供します。CPUメーカーは独自のハードウェア仮想化支援機構を持っており、それらは概念上の互換性を持ちますが、実際の詳細では全く互換性がありません。KVMは、これら種類の異なるハードウェアすべてにわたって抽象化された標準的なコントロールセットを提供します。

Firecrackerの役割

KVMが仮想化の機能を提供するのであれば、Firecrackerはどのような役割を果たすのでしょうか?

  1. 1つ目の役割はKVMを構成することです。KVMのAPI経由で仮想マシンの作成と仮想OSの起動を行います。
  2. 2つ目はハードウェアのエミュレーションを提供することです。実際のハードウェアは数十万個のデバイスを持っていませんが、Firecrackerが物理的なハードウェアを抽象化し、各仮想マシンに対して仮想的なハードウェアを提供します。これにより、1つのハードウェア上で数十万ものLambda Functionが実行可能になります。
  3. 3つ目はパフォーマンスの分離です。全ての仮想マシンが一定ライン(ボトルネックになる)以上リソースを使用することを防ぎます。これはメモリ、CPU、ストレージ、NW帯域などのあらゆるリソースが含まれます。

またFirecrackerはサーバーレス環境向けの最適化を提供しています。VMMのオーバーヘッドは5M程度で、仮想マシンは100ミリ秒レベルの速度で起動します。

デバイス仮想化の詳細について

デバイス仮想化の詳細は以下のような構成になっています。

Firecrackerによるデバイス仮想化

Firecracker環境はホストOS上の個別のプロセスで、Lambda Functionと1:1で紐づきます。そして各仮想マシンとの間に少量の共有メモリを持ち、VirtIOというプロトコルを経由して仮想マシンとのやりとりを行います。VirtIOはゲストカーネル内のデバイスドライバと協調し、ゲストカーネルが起動するために必要なハードウェアを仮想的に提供します。

Lambda FunctionからゲストOSに書き込みを行う場合のシーケンスは以下のようになります

  • ゲストOSがVirtIOドライバに対して書き込みを要求する
  • VirtIOドライバがカーネルのリングバッファ上の共有メモリに要求をPUSHする
  • Firecrackerが書き込み要求をPULLし、物理ディスクに対して書き込みを行う

書き込み完了の応答はこの逆の流れでゲストOSに伝わります。

Firecrackerのその他の役割

Lambda実行環境はFirecrackerの各プロセスを使用して、非常に制限されたサンドボックス環境へラップされています。CPUが提供する仮想化支援機能によってLambda Function間の環境分離は実現されていますが、さらにFirecrackerはcgroups,chroot,seccomp等の機能を使用してコンテナセキュリティのベストプラクティスに沿った形で別レイヤの境界を構築します。これにより多層防御が実現されています。

Lambda実行環境のセキュリティ分離

仮想化によって強力な環境分離が実現されているところに、さらにFireckrackerが追加の仮想化と環境分離を提供します。Firecrackerはオープンソースで公開されているため、この辺りの環境分離の仕組みに興味があればGitHub上でコードやドキュメントを確認することが可能です。

まとめ

昨年に続いて非常に内容の濃いセッションでした。昨年は初のre:invent参加ということもあり、帰国してからセッションの存在を知ったのですが、今年は現地でセッションに参加できてよかったです。セッション後の質疑応答ではスピーカーに直接気になっていた質問をぶつけることができ、とてもいい経験になりました。また来年同様のセッションがあれば是非参加したいと思います。