Amazon Web Services ブログ

コンテナ開発者向けの AWS Lambda

この記事は 「 AWS Lambda for the containers developer 」(記事公開日: 2023 年 5 月 9 日)の翻訳記事です。

はじめに

AWS 上でアプリケーションを構築する際、お客様が直面する一般的な決定事項の 1 つは、 AWS Lambda で構築するのか、あるいは Amazon Elastic Container Service (Amazon ECS) や Amazon Elastic Kubernetes Service (Amazon EKS) といったようなコンテナサービスで構築するのかということがあります。この決定を下すには、コスト、スケーリング特性、開発者がハードウェアオプションをどの程度制御できるかなど、考慮すべき多くの要素があります。ファンクションモデルもサービスベースのモデルも、客観的にどちらが優れているということはありません。むしろ、アプリケーションや基盤となるプロダクトとの相性の問題です。しかし、この選択でわかりにくい側面の 1 つは、プログラミングモデルが AWS Lambda の関数中心のパラダイムと、Amazon ECS や Amazon EKS の従来のサービスベースのパラダイムとの違いです。

AWS Lambda と Amazon ECS または Amazon EKS のプログラミングモデルの違いについては、よく議論されます。しかし、プログラミングモデルとはどういう意味でしょうか? プロダクトのプログラミングモデルには 2 つの側面があります。1 つ目は、呼び出し側がアプリケーションに対してリクエストを出す方法です。もう 1 つは、アプリケーション内のコードがサービスからリクエストを受け取り、対応するレスポンスを返す方法です。この記事では、前者について説明しますが、後者にも焦点を当てます。AWS Lambda アプリケーションの内部を確認し、AWS Lambda で実行されているアプリケーションが AWS Lambda サービスと相互作用してリクエストを受け取り、それにレスポンスする内部の仕組みを理解することを目指しています。

この記事の目的は 2 つあります。まず、AWS Lambda のプログラミングモデルを紐解き、「 Lambda マジック」が実際にはアプリケーションとサービスの間の単純なやりとりであることを示します。次に、従来のコンテナのバックグラウンドを持つユーザーにとって、AWS Lambda もそれほど変わらないことを示します。すべてのコンピュートプロダクトは、アプリケーションコードとサービスの間で何らかのやりとりをします。コンピュートプロダクト間でアプリケーションを移動させることは、実際には、プロダクトのプログラミングモデルに準拠するようにアプリケーションを(できれば小さく)変更することです。

ウォークスルー

それでは、始めましょう!

ご存知のとおり、AWS Lambda はサーバー上で実行されます、また、ZIP パッケージまたは Open Container Initiative (OCI) パッケージのいずれかでアプリケーションコードを設定します。ZIP パッケージを使用して同じことを行うこともできますが (これについては最後に詳しく説明します)、この記事ではコンテナイメージを使用して AWS Lambda を設定します。ワークロードに関しては、最もシンプルなプログラミング言語の 1 つである bash スクリプトを使用してビルドします。コンテナで実行されているコードと AWS Lambda サービスのプログラミングモデルとの相互作用を実証するために、これらのサーバーにできるだけ近づきたいと考えています。

まず、次の簡単な Dockerfile を使用します。

FROM public.ecr.aws/amazonlinux/amazonlinux:2023
RUN yum install -y jq tar gzip git unzip
RUN curl -Ls "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install
ADD startup.sh /startup.sh
ADD businesscode.sh /businesscode.sh
ENTRYPOINT /startup.sh

AWS Lambda を難解なものだと思ったことがあるなら、もう一度考えてみてください。これは標準の Dockerfile で、Amazon Linux 2023 イメージを指定した FROM で開始し、そこに一連のツール ( AWS Command Line Interface [AWS CLI]、tar、git など)をインストールします。AWS Lambda は、ラップトップ(または AWS Fargate )で実行できるのと同じように、このコンテナイメージと startup.sh スクリプトを実行します。

AWS Lambda 上でコンテナを特別なものにする 3 つの側面があります。

  • コンテナの制約
  • コンテナを起動するもの
  • startup.sh スクリプト (および businesscode.sh スクリプト)で実行する内容

これらを個別にみていきましょう。

コンテナの制約

コンテナが動くマシンまたは仮想マシンによって制約が決まります。ラップトップでコンテナを起動しても、自由に使える Graphics Processing Unit(GPU)はないでしょう。AWS Fargate を使用してコンテナを起動すると、特権を持つコンテナを実行できません。すべての実行環境には制約があります。 AWS Lambda 実行環境にも独自の制約があります。

  • 実行期間は(意図的に)制限されています
  • サイズはメモリパラメータで設定され、CPU容量は比例して割り当てられます
  • コンテナは読み取り専用のルートファイルシステムで実行されます( /tmp は書き込み可能な唯一のパスです)
  • 特権を持つコンテナは実行できません
  • コンテナで GPU は使用できません

これらの制約の多くは、従来のコンテナ管理サービスやローカル実行に共通しています。実行期間の制約と読み取り専用ファイルシステムの制約は、この記事で最も関連性が高くなっており、また後で説明します。

コンテナを起動するもの

このセクションでは、サービスのプログラミングモデルの最初の側面、つまり呼び出し側がアプリケーションを呼び出す方法について説明します。すべての環境には、コンテナの起動をオーケストレーションする独自の方法があります。ラップトップでコンテナを起動したい場合は、おそらく docker run か finch run を使用するでしょう。AWS Fargate でコンテナを起動したい場合は、おそらく runTask や createService などの Amazon ECS API を使用するでしょう。AWS Lambda でコアとなるのはイベント駆動型システムであり、すべて(上記のコンテナの起動を含む)はイベントによって行われます。AWS Lambda は、さまざまな AWS サービスの何百もの異なるイベントをサポートしています。典型的なイベントは、非同期アプリケーションの一部である Amazon SQS キュー内のメッセージです。ただし、イベントは、インタラクティブなウェブアプリケーションの一部としての AWS API Gateway (または AWS Elastic Load Balancing ) HTTP 呼び出しでもかまいません。いずれにせよ、イベントは AWS Lambda で処理できるようになります (このメカニズムについては後で詳しく説明します)。1 つの AWS Lambda コンテナは、一度に最大で 1 つのイベントを処理します。ただし、コンテナの存続期間中は多数のイベントを順番に処理する場合があります。

AWS Lambda のコンテナのオーケストレーションは、受信イベントに応答して大まかに次のフローに従います。

  • コンテナの初期化がされており、イベント実行するためにアイドル状態のコンテナがある場合、AWS Lambda はそのコンテナにイベントを割り当て、そのコンテナ上で実行します。
  • コンテナの初期化がされておらず、イベント実行するためのアイドル状態のコンテナがない場合、AWS Lambda は新しいコンテナを起動します。
    • AWS Lambda は、将来のイベントで新しいコンテナを起動する必要がないように、このコンテナを 1 回の実行で停止せずに、長くキープすることを選択するかもしれません。
    • 複数のイベントが同時に発生した場合、AWS Lambda は、設定された関数またはアカウントの同時実行数とバースト制限まで、それぞれコンテナを並行して起動します。

startup.sh スクリプトで実行する内容

これまで、AWS Lambda コンテナの実行環境 (制約) と、この実行環境のライフサイクル (オーケストレーション) について説明してきました。次に、コンテナ内で実行されているコードが実際に何をするか (プログラミングモデル)について考えてみましょう。

皆さんは Lambda runtime APIs について聞いたことがあるかもしれません。これらの API について考える最も簡単な方法は、イベントを取得してイベントにレスポンスする方法をアプリケーションに提供するというものです。コンテナは、処理するイベントがあるかどうかを繰り返しチェックし、存在する場合は何らかを実行して、その作業の結果を AWS Lambda に通知する、長時間実行されるプロセスと考えてください。

この高レベルのメンタルモデルを念頭に置いて、上記のフローを実装する startup.sh スクリプトを作成します。この例でのビジネスニーズは AWS Lambda が Hugo で作成したウェブサイトの GitHub リポジトリをクローンし、それを JavaScript アーティファクトのセットに組み込み、その結果を Amazon Simple Storage Service (Amazon S3) バケットにコピーすることです。整理しやすいように、ビジネスロジックは businesscode.sh というスクリプトに取り込んでいます。startup.sh スクリプトは businesscode.sh を呼び出し、AWS Lambda プログラミングモデルとビジネスロジックの間の架け橋として機能します。ビジネスロジックは AWS Lambda について何も知る必要はありません。

重要: このユースケース自体は重要ではありません。実際のコマンドやその機能よりも、フローとコードの実行方法に注目してください。

startup.sh は次のとおりです。

#!/bin/sh
set -euo pipefail

###############################################################
# The container initializes before processing the invocations #
###############################################################

echo Installing the latest version of Hugo...
cd /tmp
export LATESTHUGOBINARYURL=$(curl -s https://api.github.com/repos/gohugoio/hugo/releases/latest | jq -r '.assets[].browser_download_url' | grep Linux-64bit.tar.gz | grep extended)
export LATESTHUGOBINARYARTIFACT=${LATESTHUGOBINARYURL##*/}
curl -LO $LATESTHUGOBINARYURL
tar -zxvf $LATESTHUGOBINARYARTIFACT
./hugo version

###############################################
# Processing the invocations in the container #
###############################################

while true
do
  # Create a temporary file
  HEADERS="$(mktemp)"
  # Get an event. The HTTP request will block until one is received
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
  # Extract request ID by scraping response headers received above
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

  ############################
  # Run my arbitrary program #
  ############################

  /businesscode.sh

  ############################

  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d '{"statusCode": 200}'

done

businesscode.sh は次のとおりです。

#!/bin/sh
set -euo pipefail

rm -rf hugo-sample-site

git clone https://github.com/${AWS_LAMBDA_FOR_THE_CONTAINERS_DEVELOPER_BLOG_GITHUB_USERNAME}/aws-lambda-for-the-containers-developer-blog
cd aws-lambda-for-the-containers-developer-blog/hugo_web_site
/tmp/hugo
aws s3 cp ./public/ s3://${AWS_LAMBDA_FOR_THE_CONTAINERS_DEVELOPER_BLOG_BUCKET}/ --recursive

startup.sh スクリプトは、コンテナの起動時にのみ実行されるセクションから始まります。スクリプトのこの部分 (init) が、AWS Lambda コンテナのコールドスタートを決定します。この例では、実行時に Hugo バイナリの最新バージョンをダウンロードします。このバイナリのセットアップを前述の Dockerfile に追加することもできますが、それではファイルの最新バージョンが必要になるたびにイメージを再構築する必要があります。ここでは、AWS Lambda がコンテナを起動するたびに初期化コードを実行するという利点を生かして、最新バージョンの Hugo を動的に導入しています。特定のユースケースによって、コードの一部を Dockerfile に配置するか、初期化フェーズ、またはビジネスロジックに含めるかが決まります。

/tmp フォルダーは Lambda コンテナ内の唯一の書き込み可能なフォルダーであるため、このフォルダー内で操作する必要があることに注意してください。このため、Dockerfile でいくつかのツールをインストールする方が簡単でした。

スクリプトの次のセクション(「Processing the invocations in the container」ラベル)では、コードが無限ループに入り、コンテナが存続している間、続きます。このコードは、処理するイベントがあるかどうかを常に(ローカルの AWS Lambda runtime API endpoint に対して curl を使用して)チェックします。そして、これこそが AWS Lambda のマジックです。各実行環境内でランタイム API エンドポイントを公開し、管理します。ポーリングされたエンドポイントの受信時にイベントを返し、待機中のイベントがない場合、AWS Lambda はイベントが到着するまで実行環境を一時停止します。各イベントを受信すると、コードはそのイベントを取得し、そのイベントでスクリプトの次の部分(「 Run my arbitrary program 」ラベル)を実行し続けます。これはスクリプトの AWS Lambda に依存しない部分であり、ここでビジネスロジック(businesscode.sh スクリプト)を実行します。この部分は AWS Lambda 実行タイムアウト(最大 15 分まで設定可能)の影響を受けます。つまり、「 Run my arbitrary program 」セクションの一部として実行されるコードは、設定されたタイムアウトを超えて実行することはできません。

ビジネスロジックが完了すると、スクリプトは HTTP POST を介して同じエンドポイントにメッセージを返し、イベント処理が完了したことを AWS Lambda サービスに通知します。コードが何かを返していれば、AWS Lambda は何を返しているかは気にしないことに注意してください。このスクリプトでは {“statusCode”: 200} を返します。これは、API ゲートウェイを使用してこの関数をトリガーしており、API Gateway はそのコードが返されることを期待しているためです。代わりに 「hey I am done」 のようなテキストを返すこともできますし、AWS Lambda はそれで問題ありませんでした( API Gateway ほどではありません)。

コンテナの存続期間を AWS Lambda タイムアウトと混同しないでください。前者は、コンテナが起動後もループを実行し続ける時間を定義します。この存続期間は AWS Lambda の「契約」には含まれていないため、開発者はコンテナがシャットダウンされるまでの存続するのか想定すべきではありません。AWS Lambda タイムアウトは確かに契約の一部であり、この記事の執筆時点( 2023 年 5 月 9 日時点)では最大 15 分まで設定可能です。
ランタイム API からイベントを受信したときに、コンテナのコードがそのレスポンスをポストするまでに設定されたタイムアウトよりも長くかかった場合、リクエストはタイムアウトとして呼び出し元に返され、コンテナは再起動されます。

このスクリプトで注目すべきもう 1 つの点は、実際のイベント情報を格納するイベントのペイロードを無視していることです。つまり、関心があるのはイベントトリガーだけで、トリガーがもたらすものには関心がありません。HEADERS を解析し、リクエスト ID を抽出し、ループの最後でそのリクエスト ID をイベントを処理した AWS Lambda サービスに通知します。より古典的なイベント駆動型アーキテクチャでは、ペイロードを解析し、それを使用してビジネスロジックがリクエストに対して何をするかを制御していたでしょう。

この図は、上記のコードのセクションを視覚的に表現したものです。

これまでの内容をまとめましょう

次は、この AWS Lambda をデプロイおよび実行したときに内部で何が起こるかを大まかに説明します。

上記の Dockerfile からコンテナイメージを構築し、そのイメージを使用して AWS Lambda 関数を作成します。次に、この AWS Lambda に API Gateway エンドポイントと Amazon SQS キューの 2 つのトリガーを設定します。この段階では、何も実行されておらず、コンテナも起動されていません。

では、ターミナルからのリクエスト (curl <api gateway endpoint>) で API Gateway エンドポイントにアクセスしましょう。API Gateway はその HTTP リクエストを AWS Lambda イベントに変換し、イベントに応答してコンテナを起動します。コンテナは初期化フェーズ(この例では Hugo バイナリを取得) を経ます。次に、イベントループに入り、AWS Lambda が保持しているイベントを取得し、コンテナが空くのを待ちます。コンテナは数秒かけてリポジトリをクローンし、サイトを構築し、そのコンテンツを Amazon S3 にコピーします。完了すると、コンテナは HTTP POST を介してコードの実行が終了したことを AWS Lambda ランタイムに通知します。AWS Lambda は API Gateway に同期的に通知し、ターミナルにはプロンプトが返されます(スクリプト内の HTTP POST のメッセージでは本文を返さないため、レスポンスはありません)。

このユースケースでは、Lambda を何かしらのビルドシステムとして使用しているため、このプロセスには約 30 秒かかることに注意してください。これは、従来の同期リクエスト / レスポンスのパターンでの AWS Lambda の使用方法とは異なる場合があります。もしそうなら、「ビジネスロジック」はおそらくもっと無駄になるでしょう。ミリ秒単位でレスポンスするウェブサービスを考えてみてください。繰り返しになりますが、このユースケースは、Lambda の仕組みの内部で何が起こるかを説明するためにのみ使用します。

この時点で、コンテナは次のイベントのためにランタイム API にコールバックし、ランタイムのレスポンスを待っています。これで、コンテナは別のイベントが発生するまで一時停止され、その間は料金を支払う必要はありません。ここでメッセージをキューに送信すると、AWS Lambda はアクティブでアイドル状態の実行環境があることを認識し、コンテナの一時停止を解除してイベントをそのコンテナにルーティングします。コンテナは、ランタイム API の呼び出しに対するレスポンスとしてイベントを受け取り、ビジネスロジックを実行して結果を返すという同じプロセスを経ます。

この場合、イベントのペイロードは API Gateway によって生成されたイベントとは異なりますが、このユースケースでは、コンテナに渡されたイベントを読み取ることすらしないため、気にしません。トリガーだけを気にし、イベントのコンテンツそのものは気にしません。

しばらくして、リクエストが受信されなくなると、AWS Lambda は上記のコンテナをシャットダウンし、関数の裏で実行されているコンテナはなくなります。この時点で、API Gateway エンドポイントに 100 件の同時リクエストを送信します。AWS Lambda は 100 個のリクエストを受信し、100 個のコンテナを同時起動し、それらのリクエストを処理します (つまり、すべて初期化を行います「コールドスタート」)。リクエストが処理され、サイトが 100 回構築およびコピーされると、100 個のコンテナは不特定の期間で実行され続け、実行中のループを介してより多くのイベントを取得できるようになります( AWS Lambda が再び停止することを決定するまで)。

Lambda の外部でのコンテナイメージの実行

これを読んでいる方なら、この記事で使用してきた Dockerfile が実際に見られる従来の Dockerfile と何ら変わりないことにお気づきかもしれません。最大の特徴は、startup.sh スクリプトがコンテナを初期化する方法と、AWS Lambda API とのやり取りにあります (ループ内でのイベント取得と結果送信の両方)。この部分は、AWS Lambda プログラミングモデルの非常に特有なものです。とは言うものの、ビジネスロジック (businesscode.sh) がプログラミングモデル (startup.sh) から分離されるようにこれらのスクリプトを作成しました。このため、AWS Lambda の仕様をバイパスしてビジネスロジックを直接起動することで、同じコンテナイメージを使用して別の場所で実行するのが簡単になります。これを実現する簡単な方法は、次の Docker コマンドを使用してローカルで実行することです。

docker run -v /tmp:/tmp --rm --entrypoint /businesscode.sh <container_image:tag>

エントリーポイントを微調整して businesscode.sh スクリプトを指定するだけで済みました。

勘のいい方であれば、ローカルフォルダをコンテナの /tmp フォルダにマッピングしていることに気づき、なぜなのか疑問に思っているかもしれません。初期化フェーズをバイパスしたため、コンテナイメージは起動時に Hugo バイナリをインストールしません。代わりに、ラップトップの /tmp にある既存のバイナリから動的に渡しています。実際のシナリオでは、Hugo バイナリをコンテナイメージにビルドするか、ダウンロードコードを startup.sh から分割して AWS Lambda コンテキスト外で実行できるようにすることができます。繰り返しますが、この例はデモンストレーションを目的としており、実際の世界への適用するかどうかはユースケースによって異なります。

ちょっと待ってください。これは私たちが知っていて愛用している AWS Lambda ではありません!

その通りです。約束どおり、今回は実行モデルとプログラミングモデルを組み合わせた AWS Lambda の低レベルの仕組みを紹介するツアーでした。AWS Lambda を知っている、またはすでに使用したことがある場合は、これらの詳細からは抽象化されているかと思います。興味深いのは、AWS Lambda がリリース時にこれらの抽象化の最高峰からスタートし、このブログ記事で説明したことを完全に可視化するための徐々にサポートを導入した経緯です。ここで説明した内容を、皆さんが知っていたり聞いたりする高レベルの抽象化とどのように一致させるのでしょうか。 この記事で説明した内容から、ご存知の AWS Lambda まで発展させていきましょう。

ほとんどの開発者は、ビジネスコードを書いている間、ループや HTTP の GET や POST を扱うことを望んでいません。ここで、Lambda でよく見かける抽象化や規則、つまり AWS Lambda Runtime Interface Client (RIC) の出番です。RIC はイベントをインターセプトするループを実装するユーティリティ(バイナリまたはライブラリ)で、特定のプログラミング言語向けに AWS が提供しています。これらのイベントをコードに渡す方法は、プログラム関数に渡されるオブジェクトを介して行われます。RIC は上記の HEADERS と BODY を取得します。イベントの内容と実行環境のコンテキストを解析し、それらをオブジェクトとして関数に渡します。つまり、コンテナは RIC をメインプログラムとして起動し、イベントごとに RIC はイベント情報を使って関数を呼び出します。この規則に従うと、開発者はエンドポイントを呼び出したり、HEADERS や BODY を解析したりしなくても、関数内で直接イベントを検索できます。

RIC が実装するロジックの一部である bash スクリプト(startup.sh)を効果的に組み込んでいます。この例では「関数」の規則を真似したくないことに注意してください。なぜなら、「これは bash で RIC を再実装する方法です」という側面よりも、「これは何らかの特徴を持つ通常のコンテナです」という側面を増やしたかったからです。これについては、Lambda ドキュメントにあるチュートリアル(この記事のきっかけとなった) がまさにそのためのもので 、メインスクリプトにインポートする bash 関数を構築する方法を示しています!

AWS Lambda は Function as a Service (FaaS) ですが、AWS Lambda のコンテキストにおける関数の概念全体は、開発者がすっきりと使えるように抽象化したコンテナ内の ループと 2 つの curl 操作の上に構築した規約にすぎません。

RIC の話題に戻りますが、独自のコンテナイメージを構築したい場合は RIC スタンドアロン (選択された言語用)と、構築に使用できる AWS Lambda マネージドのベースイメージ(RIC などを含む)の両方を用意しています。何を選択するかにかかわらず、コンテナイメージを使用するときは、そのメンテナンスを行う必要があります。つまり、ファンクション用の最新のイメージをデプロイする必要があります。

別のメカニズムとして、より高いレベルの抽象化は、カスタムランタイムとビジネスロジックを ZIP ファイルとしてパッケージ化し、関数が実行されるオペレーティングシステムを AWS に管理させることです。

究極の抽象化とマネージドエクスペリエンスを実現するには、ビジネスロジックのみを ZIP ファイルとしてパッケージ化し、お客様に代わって AWS にランタイム全体の整理と管理を任せることができます。前に説明したように、これが AWS Lambda のスタート地点であり、スタックの柔軟性と制御性を高めるためには長いプロセスがありました。レイヤーのサポートを追加し始め、最終的にはコンテナイメージのサポートを追加しました。

長年にわたって、Lambda コミュニティは上記の Lambda プログラミングモデルをさらに抽象化してきました。そのような抽象化の 1 つが、顧客が従来のウェブアプリケーション Lambda を実行できるようにする Lambda Web Adapter です。この Web Adapter は、Lambda プログラミングモデルと、ポートでリッスンする従来のウェブアプリケーションフレームワークとの間のインターフェースとなるカスタムランタイムと捉えることができます。このモデルを使用することで、Lambda のイベント駆動型の性質が抽象化され、インフラストラクチャがプログラミングモデルから事実上切り離されます。

このプロトタイプをご自身でテストしてみてください

実際に試したいと思われる好奇心旺盛な人のために、このプロトタイプを再作成するのに必要なすべてのコードとセットアップ手順を含む GitHub リポジトリを作成しました。自分で試したい場合は、このリンクにアクセスしてください。

まとめ

この記事では、AWS Lambda をいつもとは異なる観点から説明しました。これまで使用した具体的な例とユースケースは従来のものではなく、実際の AWS Lambda シナリオに対応していない可能性もありますが、この記事が、お客様がサービスの内部動作について理解を深めるのに役立つことを願っています。また、AWS Lambda と従来のコンテナシステムの違いをさらに明確にしていただければ幸いです。違いを理解すると、最初に感じていたほど風変わりなものではありません。

翻訳はソリューションアーキテクトの多田が担当しました。原文はこちらです。