AWS Lambda で PHP のカスタムランタイムのコンテナイメージを作ってみるメモ

もう結構前のことですが、AWS Lambda に zip ではなくコンテナイメージでデプロイ出来るようになったとのことです。

ので試してみました。残骸はこちら

コンテナイメージは AWS から提供されている Amazon Linux2 ベースのものをカスタマイズすると簡単に作成できます。

あるいは Lambda Runtime Interface Clients を使えば自前の debian や alpine ベースのイメージでも実装できます。例えば NodeJS だと次の npm モジュールを使って実装できます。

ただ、AWS 提供のコンテナイメージも、Lambda Runtime Interface Clients も、PHP の実装はありません。PHPer なので PHP で試したいので、PHP 用のカスタムランタイムのコンテナイメージを作成しました。

PHP 用のカスタムランタイムのコンテナイメージ

カスタムランタイムは次の API を使用して実装します。

PHP 用に次のスクリプトを bootstrap というファイル名で作成しまいた。

<?php
require __DIR__ . '/../vendor/autoload.php';

new class () {
    private string $baseUrl;

    public function __construct()
    {
        $runtimeApi = getenv('AWS_LAMBDA_RUNTIME_API');
        if (strlen($runtimeApi) == 0) {
            throw new LogicException('Missing Runtime API Server configuration.');
        }

        $this->baseUrl = "http://$runtimeApi/2018-06-01";

        // CMD で渡されるコマンドライン引数からハンドラ名を得る
        $argv = $_SERVER['argv'];
        if (count($argv) < 2) {
            throw new LogicException('No handler specified.');
        }

        $appRoot = getcwd();
        $handlerName = $argv[1];

        // ハンドラ名をファイルとして require 戻り値をクロージャーとして得る
        $function = require "$appRoot/$handlerName";

        do {
            list ($invocationId, $payload) = $this->getNextRequest();
            try {
                // クロージャーを実行
                $response = $function($payload);
                $this->sendResponse($invocationId, $response);
            } catch (Throwable $ex) {
                $this->handleFailure($invocationId, $ex);
            }
        } while (true);
    }

    private function getNextRequest(): array
    {
        $url = "$this->baseUrl/runtime/invocation/next";
        $client = new GuzzleHttp\Client();
        $response = $client->get($url);
        $invocationId = $response->getHeaderLine('lambda-runtime-aws-request-id');
        $payload = json_decode($response->getBody(), true);
        return [$invocationId, $payload];
    }

    private function sendResponse(string $invocationId, $response): void
    {
        $url = "$this->baseUrl/runtime/invocation/$invocationId/response";
        $payload = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $client = new GuzzleHttp\Client();
        $client->post($url, [
            'headers' => [
                'Content-Type' => 'application/json',
            ],
            'body' => $payload,
        ]);
    }

    private function handleFailure(string $invocationId, Throwable $exception): void
    {
        $url = "$this->baseUrl/runtime/invocation/$invocationId/error";
        $data = [
            'errorType' => get_class($exception),
            'errorMessage' => $exception->getMessage(),
        ];
        $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $client = new GuzzleHttp\Client();
        $client->post($url, [
            'headers' => [
                'Content-Type' => 'application/json',
            ],
            'body' => $payload,
        ]);
    }
};

コマンドラインで指定されたファイルを require してそのファイルが返したクロージャーを実行しています。require するファイルは次の要領で作成します。

<?php
return function ($payload) {

    // なにかする

    return $result;
};

イメージのための Dockerfile は次のような内容です。ENTRYPOINT で前述の bootstrap を実行し、CMD で require するファイル名を指定します。

FROM php:alpine

COPY --from=composer /usr/bin/composer /usr/bin/composer

COPY composer.* /home/app/
WORKDIR /home/app/
RUN composer install --prefer-dist --no-dev --no-progress -o -a

COPY bin/      /home/app/bin/
COPY handlers/ /home/app/handlers/

ENTRYPOINT [ "/home/app/bin/bootstrap" ]
RUN chmod +x /home/app/bin/bootstrap

CMD [ "handlers/index.php" ]

なお Lambda の定義時に ENTRYPOINT や CMD はオーバーライドできます。例えば次のように Terraform テンプレートで指定できます(この例では Dockerfile で指定してるとおりなので意味ないですが)。

resource "aws_lambda_function" "func" {
  function_name = "${var.tag}-func"
  role          = aws_iam_role.lambda.arn
  timeout       = 10
  memory_size   = 128
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.php.repository_url}:${var.docker_tag}"
  image_config {
    command           = ["handlers/index.php"]
    entry_point       = ["/home/app/bin/bootstrap"]
    working_directory = "/home/app/"
  }
}

なので Web アプリケーション用に作ったコンテナイメージに Lambda 用の bootstrap を仕込んでおき、デフォルトの ENTRYPOINT と CMD は Web アプリケーション用、Lambda の定義時には ENTRYPOINT や CMD を差し替える、といったことが可能です(Web アプリケーション用と Lambda 用に別のイメージを作る必要は無い)。

Lambda Runtime Interface Emulator でローカル実行

Lambda Runtime Interface Emulator でローカル環境でのテスト実行が可能です。

Lambda Runtime Interface Emulator は aws-lambda-rie というワンバイナリの実行可能ファイルです。あらかじめイメージに Lambda Runtime Interface Emulator を仕込んでおいても良いし、あるいは実行時にホストから差し込んでも OK です。

# ホストから aws-lambda-rie を差し込んで実行する例
wget https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie
chmod +x ./aws-lambda-rie
docker build -t ore-no-image .
docker run --rm -p 9000:8080 \
    -v "$PWD/aws-lambda-rie:/aws-lambda-rie:ro" \
    --entrypoint /aws-lambda-rie ore-no-image \
    bin/bootstrap handlers/index.php

次のように Lambda が実行できます。

curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"hello":"php"}'
#=> {"statusCode":200,"headers":{"Content-Type":"text/plain","x-php-version":"8.0.6"},"body":{"payload":{"hello":"php"}}}

さいごに

試しに PHP でやってみましたが、あえて AWS Lambda で PHP を動かすことはまず無いと思います。 (Lambda Runtime Interface Clients の PHP 実装も出来て Packagist で公開されれば話は別かもしれないですが)

Lambda 以外でも使用しているイメージを Lambda に流用したい、というケースが多いと思うので(例えば ECS Service で実行する Web アプリケーションのイメージを Lambda でも使いたい、とか)、 AWS 提供のイメージをカスタマイズするよりは、Docker Hub のオフィシャルのイメージから作成した独自のイメージに Lambda Runtime Interface Clients を入れる、という形で、構築が容易で素早く開始できる ECS Run Task のような感覚で使えそうです。