Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

AWS Lambdaをローカルで実行する - コンテナイメージ作成不要のシンプルな方法

はじめに

テックタッチの SRE チームの tabito です。AWS IaC、Rust、最近は Terraform の AWS Provider の開発へのコミットが趣味になっています。

AWS のサーバーレスサービスの代表格である AWS Lambda。Lambda 関数を開発・運用している方も多いことと思います。

Lambda 関数を開発するときには、AWS にデプロイする前に、ローカルで動作を確認しておきたいものです。Web を検索すると、ローカルでの Lambda 関数の実行方法として、たとえば、以下のような方法が紹介されています。

  • コンテナイメージを作成して、そのコンテナイメージをローカルで実行する。
  • AWS SAM を使用して、aws sam local invoke コマンドでローカル実行する。
  • LocalStack を使用して、ローカル環境で AWS のサービスをエミュレートする。

この記事では、これらとは別の方法として、AWS Lambda の動作原理を踏まえたローカルでの実行方法を紹介します。 この方法は Docker を使える環境であれば、それ以外の新しいツールをインストールすることなく、少ない手順で Lambda 関数をローカルで実行できます。CI/CD パイプラインでの Lambda 関数のテストにも便利に使えます。

Lambda 関数の動作原理

Lambda 関数の例

Lambda 関数を Node.js で実装するとしたとき、たとえば次のように関数を定義します。
(なお、この記事では Node.js のランタイムを使いますが、Python のランタイムでも同様です)

exports.handler = async (event) => {
    return {
        statusCode: 200,
        body: JSON.stringify({ message: "Hello from Lambda!" }),
    };
};

このコードを ZIP ファイルにアーカイブして S3 にアップロードし、その S3 の場所(バケット名、キー名)を指定すれば、Lambda 関数をデプロイできます。 たとえば、AWS CLI を使って次のようにデプロイできます。

> aws lambda create-function \
    --function-name my-function \
    --runtime nodejs22.x \
    --role arn:aws:iam::123456789012:role/lambda-role \
    --handler app.handler \
    --code S3Bucket=my-bucket,S3Key=my-function.zip

上のコードは関数だけです。プログラムとして動作するにはいわゆる main 関数が必要ですが、main 関数はどこにあるのでしょうか?

Lambda 関数のランタイム

実は、上の関数は「ランタイム」から呼び出されて実行されます。
Lambda 関数の実行要求(イベント)があると、イベントを AWS 基盤で起動されているランタイムと呼ばれるアプリケーションに渡そうとします。もし、ランタイムが起動していない場合には、起動がトリガーされ起動されるまで待ちます。 (ランタイムは実行要求が数分間ないと停止されます。停止された状態からの実行は「コールドスタート」と呼ばれ、ランタイムの起動時間の分だけ Lambda 関数の実行時間が延びます。)

ランタイムの役割は主に次のものです。

  • 初期化を実行する。
  • AWS Lambda ランタイム API(以下、「ランタイム API」)の /runtime/invocation/next エンドポイントに接続して、イベントが到達するのを待ち受ける。
  • イベントを受信したら、それをユーザーが用意した関数(ハンドラ)に渡して、ハンドラを実行する。
  • ハンドラの実行結果をランタイム API の /runtime/invocation/:{REQUEST_ID}/response エンドポイントに送信する。

Lambda 関数のランタイムとランタイムAPI

このように、ランタイムは、ランタイム API から見るとクライアントになっています。

ランタイムの具体的な実装例をシェルスクリプトで簡潔に記述した例が、AWS のドキュメント(チュートリアル: カスタムランタイムの構築)にあります。ランタイムの実装、動作を理解する上で非常に役立ちます。

このランタイムと、ランタイムがクライアントとして接続するランタイム API の実行環境がローカルにあれば、ローカルで Lambda 関数を実行できます。

Lambda 関数のコンテナイメージには何が入っているのか?

Lambda 関数をデプロイする方法には、ランタイムから実行される関数を ZIP ファイルにアーカイブして S3 アップロードする方法の他に、コンテナイメージを使う方法があります。そして、作成したコンテナイメージをローカルで起動して、Lambda 関数を実行できます。このようなローカル実行ができる仕組みを追跡します。
(なお、本記事の主題の方法はこの方法ではなく、このあとに紹介するコンテナイメージの作成が不要な方法です。ここで得た知見を、主題の方法に生かします。)

コンテナイメージの作成

コンテナイメージを作成して Lambda 関数をデプロイするときには、次のような Dockerfile を用意して、コンテナイメージを作ります。

FROM public.ecr.aws/lambda/nodejs:22

COPY app.js ${LAMBDA_TASK_ROOT}

CMD [ "app.handler" ]

この Dockerfile では、AWS が提供する Node.js の Lambda ランタイムのベースイメージを FROM で指定しています。COPY で、ユーザーが用意した関数(この例では app.js)をコンテナイメージの ${LAMBDA_TASK_ROOT} ディレクトリ(デフォルトは /var/task)にコピーし、CMD でランタイムに実行される関数(app というファイルの中にある handler という関数)を指定しています。

コンテナイメージの構造

この Dockerfile では、CMD に関数が指定されており、コンテナを実行すればこの関数が実行されるのだろう、とナイーブには思います。しかし、handler というのは main 関数ではなく、Lambda 関数ではハンドラの関数がランタイムによって呼び出されて実行されるものでした。

このコンテナイメージがどういう構造になっているのか、調べてみましょう。

一般的に、DockerfileCMDは、実行する「コマンド」を指定するものとは限らないことに注意が必要です。DockerfileENTRYPOINTが指定されていない場合には、CMDはコンテナ起動時に実行するコマンドになります。しかし、ENTRYPOINTが指定されている場合には、CMDENTRYPOINTに指定されたコマンドを実行するときの引数を指定するものになります。

この Dockerfile には、ENTRYPOINT が指定されていません。しかし、ベースイメージの中で ENTRYPOINT が指定されており、それが有効です。 ベースイメージの ENTRYPOINT は次のように確認できます。

> docker inspect public.ecr.aws/lambda/nodejs:22 | jq .[0].Config.Entrypoint

[
  "/lambda-entrypoint.sh"
]

この /lambda-entrypoint.sh の内容は次のように確認できます。

> docker run --rm --name lambda-nodejs -d public.ecr.aws/lambda/nodejs:22 dummy
> docker cp lambda-nodejs:/lambda-entrypoint.sh -

()

if [ $# -ne 1 ]; then
  echo "entrypoint requires the handler name to be the first argument" 1>&2
  exit 142
fi
export _HANDLER="$1"

RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/local/bin/aws-lambda-rie $RUNTIME_ENTRYPOINT
else
  exec $RUNTIME_ENTRYPOINT
fi

CMD に指定した文字列は、1番目の引数 $1 と解釈されて、_HANDLER 環境変数に設定されています。また、環境変数 AWS_LAMBDA_RUNTIME_API(ランタイム API のエンドポイントを格納)が設定されていない場合には、ランタイムのブートストラップスクリプト(初期設定・起動スクリプト)/var/runtime/bootstrap を引数に指定して、/usr/local/bin/aws-lambda-rie というアプリケーションを実行しています。実行しているのは Runtime Interface Emulator (RIE) と呼ばれるもので、ランタイム API のエミュレータです。引数にブートストラップスクリプトを指定して RIE を実行することで、RIE をランタイム API としてブートストラップスクリプトを実行します。

(AWS 上では環境変数 AWS_LAMBDA_RUNTIME_API が指定されるので、RIE を介さず、直接ブートストラップスクリプトが実行されます。)

同様にしてブートストラップスクリプト /var/runtime/bootstrap の中身を見ると、次のコマンドが実行されているのがわかります。

exec /var/lang/bin/nodejs /var/runtime/index.mjs

実行されている /var/runtime/index.mjs の中には、次のようなコードが見つかります。

  • /runtime/invocation/next のエンドポイントに GET のリクエストをするコード
      async nextInvocation() {
        if (this.useAlternativeClient) {
          const options = {
            hostname: this.hostname,
            port: this.port,
            path: "/2018-06-01/runtime/invocation/next",
            method: "GET",
            agent: this.agent
          };
          return new Promise((resolve, reject) => {
            let request = this.http.request(options, (response) => {
              let data = "";
              response.setEncoding("utf-8").on("data", (chunk) => {
                data += chunk;
              }).on("end", () => {
                resolve({
                  bodyJson: data,
                  headers: response.headers
                });
              });
            });
            request.on("error", (e) => {
              reject(e);
            }).end();
          });
        }
        return this.nativeClient.next();
      }
  • /runtime/invocation/:{RequestId}/response のエンドポイントに POST のリクエストをするコード
      getStreamForInvocationResponse(id, callback, options) {
        const ret = createResponseStream({
          httpOptions: {
            agent: this.agent,
            http: this.http,
            hostname: this.hostname,
            method: "POST",
            port: this.port,
            path: "/2018-06-01/runtime/invocation/" + encodeURIComponent(id) + "/response",
            highWaterMark: options?.highWaterMark
          }
        });
        return {
          request: ret.request,
          responseDone: ret.responseDone.then((_) => {
            if (callback) {
              callback();
            }
          })
        };
      }

これらのコードは、ランタイムAPIにリクエストをするランタイムの機能に対応しています。

これらの構造を踏まえると、コンテナイメージを実行すると、RIEとランタイムが起動されることがわかります。

コンテナイメージのLambda関数をローカルで実行する

作成したコンテナイメージを実行すると RIE とランタイムが起動され、RIE はポート 8080 を開いて、ユーザーからの Lambda 関数の実行リクエストを受け付けます。次のように、Dockerfile から作成したコンテナを実行すると、ポート 9000 から RIE にリクエストを送ることができます。

> docker build -t my-lambda .
> docker run -p 9000:8080 my-lambda

localhost:9000 に、次のようにリクエストを送ると、ハンドラに記述したレスポンスが返ってくるのが確認できます。

> curl -X POST localhost:9000/2015-03-31/functions/function/invocations -d {}

{"statusCode":200,"body":"{\"message\":\"Hello from Lambda!\"}"}

このように、Dockerfile から作成してコンテナイメージには、DockerfileFROM で指定したベースイメージを通じて、コンテナイメージの中にランタイムと RIE が含まれています。つまり、ベースイメージにはローカルで Lambda を実行するために必要なものがそろっていて、それらを使って、ローカルでも Lambda 関数を実行できるのです。

一方、ZIP ファイルを使う方法は、ランタイムから実行される関数のみを ZIP ファイルにアーカイブして S3 にアップロードします。この Lambda 関数が実行されるときには、指定されたランタイムを AWS 基盤側で起動し、そのランタイムの中にユーザーが作成した関数を取り込みます。そして、そのランタイムがユーザーが用意した関数を実行します。

なお、ZIP ファイルを使う場合でも、実行する関数とともにユーザーが自らランタイムを用意するということができます。RustGo 言語 で Lambda 関数を使う場合には、ランタイム(ランタイム API のクライアント)を含むブートストラップモジュールを作成します。このとき、ランタイムとしては provided.al2023 を指定します。ブートストラップモジュールにランタイム API のクライアントの機能は含まれているので、このランタイムにはその機能の提供がありません。

ローカルで Lambda 関数を実行するシンプルな方法

コンテナイメージを作成して Lambda 関数をデプロイする方法では、作成したコンテナイメージを実行することでランタイムや RIE が起動し、ローカルでその Lambda 関数を実行できました。しかし、この方法では、ユーザーが用意する関数を修正するたびにコンテナイメージを再作成して、コンテナを実行し直す手間がかかります。

ここまでで述べたように、ベースイメージには Lambda 関数をローカルで実行する環境が整っています。そこで、ベースイメージからコンテナイメージを作成するのではなく、ベースイメージをそのまま使って、Lambda 関数をローカルで実行できるようにしてみます。

Lambdaのベースイメージをそのまま docker run で実行する

今、カレントディレクトリの直下にある code というディレクトリに、上の Lambda 関数で実行される関数が app.js というファイルにあるとします。Dockerfile では、${LAMBDA_TASK_ROOT} ディレクトリ(デフォルトは /var/task)に関数を記述したファイルをコピーしていました。そこで、ローカルのスクリプトのあるディレクトリをコンテナの /var/task にマウントして、次のようにベースイメージを指定して docker run を実行します。

> docker run -p 9000:8080 \
    -v $(pwd)/code:/var/task:ro \
  public.ecr.aws/lambda/nodejs:22 app.handler

ベースイメージには、ランタイムと RIE が含まれており、その ENTRYPOINT はランタイムと RIE を起動するようになっていたので、Dockerfile からコンテナイメージを作成してそれを実行した場合と同じことが実現できています。docker コマンドを使っていますが、コンテナイメージを作成していないところに違いがあります。

このようにしてランタイムと RIE が起動した状態で、エンドポイントにリクエストを送ってみます。

> curl -X POST localhost:9000/2015-03-31/functions/function/invocations -d '{}'

{"statusCode":200,"body":"{\"message\":\"Hello from Lambda!\"}"}

ハンドラに記述したレスポンスが返ってきました。

この方法ではコンテナイメージの再作成が必要ありません。この方法でも、関数のコードを変更した場合にはランタイムを起動し直す必要がありますが(Ctrl-C で docker run の実行を止めて再度同じコマンド実行する)、コンテナイメージの再作成が必要ないため、短時間で再起動ができます。

実行するコマンドを短くしたければ、次のような docker-compose.yml を作成しておきます。

services:
  lambda:
    image: public.ecr.aws/lambda/nodejs:22
    ports:
      - "9000:8080"
    volumes:
      - ./code:/var/task:ro
    command: app.handler

そして、次のような docker compose コマンドを起動すれば、上の docker コマンドを実行した状態と同じになります。

> docker compose up -d

app.js を更新したときに、再起動したい場合には、次のようにできます。

> docker compose restart

このようにしておけば、短いコマンドで起動や再起動を実行できます。

この方法は、CI/CD のパイプラインにおける Lambda 関数のテストにも使えます。弊社では、この方法を使った Lambda@Edge の AWS へのデプロイ前のテストを、デプロイパイプラインの中で実行しています。基本的な機能のテストは単体テストでカバーできますが、ビルド時に環境に依存するパラメータが埋め込まれることがあります(環境変数が使えない Lambda@Edge では特にその必要性があります)。そのような場合に、ビルド済みの Lambda 関数をデプロイ前にテストすれば、環境依存のパラメータが正しく埋め込まれたかを確認できます。

Lambda Layer を使う場合

ここで紹介した方法は、Lambda 関数の実行の仕組みと、コンテナイメージを使ったデプロイ方法を踏まえたものでした。Lambda Layer を使う場合も、コンテナイメージを使う場合の対処方法が役に立ちます。

コンテナイメージを使う場合の Lambda Layer の扱い方については、AWS のブログ(コンテナイメージ内でLambda レイヤーと拡張機能を動作させる)に詳しく解説されています。このブログにあるように、Lambda Layer のファイルは /opt に配置すれば、Lambda 関数から利用できます。

そこで、カレントディレクトリの直下に opt ディレクトリを作成し、その中に Layer のファイルを配置します。 そして、docker run コマンドを次のように実行します。

> docker run -p 9000:8080 \\
    -v $(pwd)/code:/var/task:ro \\
    -v $(pwd)/opt:/opt:ro \\
  public.ecr.aws/lambda/nodejs:22 app.handler

このようにして、Lambda Layer を使う場合でも、コンテナイメージを作成せずに、ローカルで Lambda 関数を実行できます。

まとめ

AWS Lambda 関数をローカルで実行する方法として、Docker があれば実行できるシンプルな方法を紹介しました。

この方法は、コードが格納されているディレクトリをマウントしながら Lambda のベースイメージに対して docker run を実行するだけです。他と比較しても新たなツールのインストールを必要とせず、Lambda 関数の開発・テストが効率的に行えるようになります。ひいては CI/CD パイプラインでの自動テストやローカル開発環境の迅速なフィードバックが可能になると考えます。ぜひお試しください。