コンテナ Lambda をカスタマイズして、自分好みの PHP イメージを作ろう !

~コンテナ利用者に捧げる AWS Lambda の新しい開発方式 ! ~ 第 3 回~

2021-06-02
デベロッパーのためのクラウド活用方法

Author : 下川 賢介

こんにちは、サーバーレス スペシャリストソリューションアーキテクトの下川 (@_kensh) です。

第一回 コンテナ Lambda の ”いろは”、AWS CLI でのデプロイに挑戦 !」では、AWS CLI を使ってコンテナ Lambda 関数を実際に AWS Lambda サービスにデプロイして動作確認をしてみました。
第二回 コンテナ Lambda を開発、まずは RIC と RIE を使ってみよう !」では、開発者のローカル環境でコンテナ Lambda 関数の動作確認をする方法を紹介しました。

今回はこのコンテナイメージサポート Lambda 関数のカスタムイメージ作成方法について一緒に試していきたいと思います。

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

*ハンズオン記事およびソースコードにおける免責事項 »


カスタムイメージの用途と注意点

まず、カスタムイメージを利用する状況を考えてみます。Java や Python など多くのランタイムが Lambda のサポート対象 になっています。もしそれらのランタイムを利用する場合、標準サポートされているイメージを利用することをお勧めします。ではどう言った場合にカスタムイメージを利用する必要が出てくるのでしょうか ?

カスタムイメージの使いどころ

  • Lambdaがサポートしている言語の新しいバージョンを待てない場合、新バージョンをカスタムイメージとして用意し、正式に提供されたのちに移行
  • Lambdaがサポートを打ち切った言語のバージョンを使い続けたい場合
    • Lambda のランタイムライフサイクルポリシー としてランタイム自体の長期サポート (LTS) スケジュールまではサポートするので、EOLとなったランタイムを使い続けるのはお勧めできません。
  • AWS Lambdaでサポートされていない言語を利用したい場合

それ以外のカスタムイメージの利用ユースケース

  • 企業や組織の内部統制として利用しないといけないベースイメージが定まっている
    • Lambda の実行環境としてもこの組織内ルールに従うべきかは考慮ください
  • 他のプロジェクトやサービスで利用しているイメージを利用したい
  • 特定の OS に依存する既存ライブラリなどがあり、継続利用したい場合

ご利用の注意点としては

  • RIC を利用しない場合、Runtime API との対話実装は自身で管理もしくはパートナー提供のイメージを利用する場合は、パートナー管理となる
  • ランタイム起因のトラブルに対する AWS からのテクニカルサポートは得られない
  • パートナーが提供しているイメージはパートナーからのサポート提供があるかも確認
  • カスタムイメージでは管理対象が増えることをよく考慮すること

そして、これら以外の考慮点によくカスタムイメージの利用ユースケースで挙げられるのが Lambda のコールドスタートのチューニングです。AWS 提供以外の軽量コンテナイメージをベースイメージとしてアプリケーションイメージを実装していくようなケースになります。軽量コンテナイメージを利用した起動時間の最適化は AWS のコンテナサービスを利用する場合によく用いられる方法ですが、AWS Lambda サービスに適用する場合には実際にご利用の軽量コンテナイメージと AWS 提供のコンテナイメージで実行し測定することをお勧めします。これは AWS 提供の Lambda ベースイメージは AWS Lambda サービス側でチャンクごとにキャッシュされており、起動の高速化のための最適化が行われているためです。そのためカスタムイメージを構築する場合も、AWS 提供外の軽量コンテナイメージだけでなく、AWS 提供のベースイメージをご利用可能かも検証の上、検討ください。

カスタムイメージ用のベースイメージは Amazon Linux 2 と Amazon Linux 用があるのでそれぞれご利用になりたい Tag でイメージを取得してください。

Tags Runtime Operating system
al2 provided.al2 Amazon Linux 2
alami provided Amazon Linux

カスタムイメージ用のカスタムベースイメージは以下のとおり DockerHubAmazon ECR パブリックギャラリー から取得可能です。


AWS 提供のカスタムイメージで PHP のイメージを実装する

今回は PHP のフレームワークは使わずに、できるだけ Pure な PHP で構築してみましょう。
まず、構成から見ておきます。

├── Dockerfile
├── lambda
│   └── app.php
└── runtime
    └── bootstrap

第一回 の構成から違う点は、runtime として bootstrap が配置されている点になります。

AWS Lambda ランタイムは、どのプログラミング言語でも実装できます。ランタイムは、関数が呼び出されたときに Lambda 関数のハンドラメソッドを実行するプログラムです。ランタイムは、bootstrap という名前の実行可能ファイルの形式で、関数のデプロイパッケージに含めることができます。今回は、bootstrap に PHP の呼び出しを記述することになります。詳細は後ほど説明します。

それぞれのファイルの役割

app.php
Lambda 関数本体の PHP ファイル
Dockerfile
Docker 上で PHP Lambda 関数を動作させるためのコンテナの構成情報を記述するためのファイル
bootstrap
Lambda サービスによって起動される最初の実行形式 PHP ファイル

この 3 つのファイルの中で、Dockerfile と bootstrap は初回の構築時に構成してしまえば、移行の Lambda 関数修正は app.php だけでよくなります。それぞれのファイルの作りを見ていきましょう。

PHP の Lambda 関数の本体を実装する

PHP の関数も他のランタイムの関数と同様に handler を定義する必要があります。

app.php

<?php

function handler($event, $context)
{
    echo json_encode($context);
    return response("queryStringParameters, ". json_encode($event['queryStringParameters']));
}

function response($body)
{
    $headers = array(
        "Content-Type"=>"application/json"
    );
    return json_encode(array(
        "statusCode"=>200,
        "headers"=>$headers,
        "body"=>$body
    ));
}

handler 関数は呼び出されると、イベントデータと実行コンテキスト情報を受け取り、レスポンスとしてクエリストリングを返す単純な実装になっています。

PHP ランタイム構成用の Dockerfile を実装する

Dockerfile

#Lambda base image Amazon linux 2
FROM public.ecr.aws/lambda/provided:al2 as builder 
# Set desired PHP Version
ARG php_version="7.4"
# Install environment assets
RUN yum clean all && \
    yum install -y amazon-linux-extras \
                   libcurl-devel
# Install PHP
RUN amazon-linux-extras enable php${php_version}
RUN yum install -y php-cli

# Download Composer
RUN curl -sS https://getcomposer.org/installer | /usr/bin/php -- --install-dir=/opt/ --filename=composer
# Install Guzzle, prepare vendor files
RUN mkdir /lambda-php-vendor && \
    cd /lambda-php-vendor && \
    /usr/bin/php /opt/composer require guzzlehttp/guzzle

# Prepare runtime files
COPY runtime/bootstrap /lambda-php-runtime/
RUN chmod 0755 /lambda-php-runtime/bootstrap

###### Create runtime image ######
FROM public.ecr.aws/lambda/provided:al2 as runtime
# Set desired PHP Version
ARG php_version="7.4"
# Layer 1: PHP Binaries
# Install environment assets
RUN yum clean all && \
    yum install -y amazon-linux-extras
# Install PHP
RUN amazon-linux-extras enable php${php_version}
RUN yum install -y php-cli
# Layer 2: Runtime Interface Client
COPY --from=builder /lambda-php-runtime /var/runtime
# Layer 3: Vendor
COPY --from=builder /lambda-php-vendor/vendor /opt/vendor
COPY lambda/ /var/task/

CMD [ "app.handler" ]

この Dockerfile はマルチステージに分かれています。

  • 最初のステージでは、PHP のディペンデンシーマネージャである Composer を利用して、後ほど bootstrap で利用する Guzzle という PHP の HTTP クライアント モジュールを取得しています。
  • 次のステージでは、実際に PHP のランタイムイメージを構築しています。

Dockerfile をマルチステージで記述するのは、ランタイム構築のために中間ファイルを生成することがありますが、これらの不要な中間ファイルを最終的な生成物に含めないためと、今回は利用していませんがクレデンシャルなどの情報を環境変数に入れる場合は、適切に揮発させるためによく使われます。

それでは、Dockerfile の中身をステップバイステップで見ていきましょう。

#Lambda base image Amazon linux 2
FROM public.ecr.aws/lambda/provided:al2 as builder 

AWS が提供するパブリックの ECR に配備されている provided:al2 のイメージをベースイメージとして指定しています。ここは DockerHub からの取得に置き換えても問題ありません。

# Set desired PHP Version
ARG php_version="7.4"
# Install environment assets
RUN yum clean all && \
    yum install -y amazon-linux-extras \
                   libcurl-devel
# Install PHP
RUN amazon-linux-extras enable php${php_version}
RUN yum install -y php-cli

今回ランタイムとして利用するのは PHP7.4 になります。(2021/05/16 時点での 7 系の Stable バージョン)
Amazon Linux 2 では、amazon-linux-extras コマンドで Extras Library から特定のトピックを有効化して、アプリケーションをインストールしたり、ソフトウェア更新できます。ここでは PHP をインストールするために amazon-linux-extras を利用しています。

Pure な PHP による Lambda 関数実装を目指しているので、最低限必要な php-cli のみインストールしています。PHP のデータベースアクセスモジュールなどは必要に応じて指定 してください。

# Download Composer
RUN curl -sS https://getcomposer.org/installer | /usr/bin/php -- --install-dir=/opt/ --filename=composer
# Install Guzzle, prepare vendor files
RUN mkdir /lambda-php-vendor && \
    cd /lambda-php-vendor && \
    /usr/bin/php /opt/composer require guzzlehttp/guzzle

PHP のディペンデンシーマネージャである Composer をダウンロードして、Guzzle HTTP クライアント を/lambda-php-vendor パスにインストールしています。

# Prepare runtime files
COPY runtime/bootstrap /lambda-php-runtime/
RUN chmod 0755 /lambda-php-runtime/bootstrap

COPY 命令は、ファイルまたはディレクトリをローカル環境からコピーし、コンテナのファイルシステムの指定のパスに追加します。そして、この COPY を利用して bootstrap ファイルを /lambda-php-runtime/ に配置して実行権限を与えています。

これで最初のステージでの作業は終了です。

次のステージでは実際に PHP のランタイム環境を実装していきます。

###### Create runtime image ######
FROM public.ecr.aws/lambda/provided:al2 as runtime
# Set desired PHP Version
ARG php_version="7.4"
# Layer 1: PHP Binaries
# Install environment assets
RUN yum clean all && \
    yum install -y amazon-linux-extras
# Install PHP
RUN amazon-linux-extras enable php${php_version}
RUN yum install -y php-cli

最初のステージとやっていることは同じなので、説明は割愛しますが、PHP のインストールを実行しています。

今回は yum を使ったシンプルなインストール方法をとったため、二つのステージに重複コードが登場しています。さらにイメージをスリムにしたり、ステージ間で重複コードが無いように Dockerfile を実装することもできます。その場合は前ステージでソースから PHP をビルドして、前ステージから COPY コマンドで生成物を取得することができます。詳しくは AWS Blog「Docker コンテナイメージを使用した PHP Lambda 関数の構築」を参照ください。

# Layer 2: Runtime Interface Client
COPY --from=builder /lambda-php-runtime /var/runtime
# Layer 3: Vendor
COPY --from=builder /lambda-php-vendor/vendor /opt/vendor
COPY lambda/ /var/task/

前ステージの生成物として /lambda-php-runtime に配置されていた bootstrapを /var/runtime に COPY しています。/var/runtime は AWS Lambda のランタイムディレクトリで、ここに配置された bootstrap ファイルが Lambda  サービスにより発見され起動されます。

Composer を使用してモジュールをインストールすると、vendor/autoload.php も同時に生成され、このファイルを bootstrap で require することで、vendor 配下のモジュールをオートロードできるようになります。ここでは /opt/vendor を指定していますが、require するパスと整合性があれば参照可能な任意の場所で動作します。

Lambda 関数の本体は /var/task に配置します。

ここで登場した /var/runtime および、 /var/task は Lambda サービスによって環境変数として定義 されていますので、bootstrap からも環境変数を介してアクセスすることになります。

CMD [ "app.handler" ]

最後に、bootstrap に Lambda 関数のハンドラーがどのファイルのどの関数なのかを伝えます。CMD として渡すと bootstrap 側で _HANDLER 環境変数として取得できます。

bootstrap に渡す内容は任意に組むことができますが、標準ランタイムの渡し方と一致させている方が混乱が少ないでしょう。app.php 内の handler 関数というエントリーポイント実装の場合、app.handler と渡すのが一般的です。

bootstrap を実装する

bootstrap は Lambda サービスによって起動される最初の実行形式 PHP ファイルとなります。

bootstrap

#!/usr/bin/php
<?php

// This invokes Composer's autoloader so that we'll be able to use Guzzle and any other 3rd party libraries we need.
$vendor_dir = '/opt/vendor';
require $vendor_dir . '/autoload.php';

// This is the request processing loop. Barring unrecoverable failure, this loop runs until the environment shuts down.
do {
    // Ask the runtime API for next to handle.
    $next = getNext();
    try {
        // Obtain the function name from the _HANDLER environment variable and ensure the function's code is available.
        $handlerFunction = explode(".", getenv('_HANDLER'));
        require_once getenv('LAMBDA_TASK_ROOT') . '/' . $handlerFunction[0] . '.php';
        try {
            // Execute the desired function and obtain the response.
            $response = $handlerFunction[1]($next['event'], $next['context']);
            // Submit the response back to the runtime API.
            sendResponse($next['context']['aws_request_id'][0], $response);
        } catch (Throwable $e) {
            // Submit the error back to the runtime API.
            sendError($next['context']['aws_request_id'][0], $e);
        }
    } catch (Throwable $e) {
        // Submit the init error back to the runtime API.
        sendInitError($e);
    }
} while (true);

function getNext()
{
    $client = new \GuzzleHttp\Client();
    $response = $client->get('http://' . getenv('AWS_LAMBDA_RUNTIME_API') . '/2018-06-01/runtime/invocation/next');
    return [
      'event' => json_decode((string) $response->getBody(), true),
      'context' => [
        'function_name' => getenv('AWS_LAMBDA_FUNCTION_NAME'),
        'function_version' => getenv('AWS_LAMBDA_FUNCTION_VERSION'),
        'invoked_function_arn' => $response->getHeader('Lambda-Runtime-Invoked-Function-Arn'),
        'memory_limit_in_mb' => getenv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE'),
        'aws_request_id' => $response->getHeader('Lambda-Runtime-Aws-Request-Id'),
        'log_group_name' => getenv('AWS_LAMBDA_LOG_GROUP_NAME'),
        'log_stream_name' => getenv('AWS_LAMBDA_LOG_STREAM_NAME'),
        'deadline_ms' => $response->getHeader('Lambda-Runtime-Deadline-Ms'),
        'trace_id' => $response->getHeader('Lambda-Runtime-Trace-Id'),
        'x_amzn_trace_id' => getenv('_X_AMZN_TRACE_ID'),
        'identity' => $response->getHeader('Lambda-Runtime-Cognito-Identity'),
        'client_context' => $response->getHeader('Lambda-Runtime-Client-Context')
      ]
    ];
}

function sendResponse($requestId, $response)
{
    $client = new \GuzzleHttp\Client();
    $client->post(
    'http://' . getenv('AWS_LAMBDA_RUNTIME_API') . '/2018-06-01/runtime/invocation/' . $requestId . '/response',
       ['body' => $response]
    );
}

function sendError($requestId, $error)
{
    $response = json_encode(array('errorMessage' => $error->getMessage(), 'errorType' => get_class($error)));
    $client = new \GuzzleHttp\Client();
    $client->post(
    'http://' . getenv('AWS_LAMBDA_RUNTIME_API') . '/2018-06-01/runtime/invocation/' . $requestId . '/error',
       [
           'body' => $response,
           'headers' => [
            'Lambda-Runtime-Function-Error-Type' => 'Unhandled'
            ]
        ]
    );
}

function sendInitError($error)
{
    $response = json_encode(array('errorMessage' => $error->getMessage(), 'errorType' => get_class($error)));
    $client = new \GuzzleHttp\Client();
    $client->post(
    'http://' . getenv('AWS_LAMBDA_RUNTIME_API') . '/2018-06-01/runtime/init/error',
       [
           'body' => $response,
           'headers' => [
            'Lambda-Runtime-Function-Error-Type' => 'Unhandled'
            ]
        ]
    );
}

こちらが bootstrap ファイルです。
ステップバイステップで見ていきましょう。

#!/usr/bin/php
<?php

すでに PHP は Dockerfile に記述した通り、yum によりインストール済みなので参照パスを書いています。

// This invokes Composer's autoloader so that we'll be able to use Guzzle and any other 3rd party libraries we need.
$vendor_dir = '/opt/vendor';
require $vendor_dir . '/autoload.php';

vendor 配下のモジュールをオートロードしています。

// This is the request processing loop. Barring unrecoverable failure, this loop runs until the environment shuts down.
do {
    // Ask the runtime API for next to handle.
    $next = getNext();
    try {
        // Obtain the function name from the _HANDLER environment variable and ensure the function's code is available.
        $handlerFunction = explode(".", getenv('_HANDLER'));
        require_once getenv('LAMBDA_TASK_ROOT') . '/' . $handlerFunction[0] . '.php';
        try {
            // Execute the desired function and obtain the response.
            $response = $handlerFunction[1]($next['event'], $next['context']);
            // Submit the response back to the runtime API.
            sendResponse($next['context']['aws_request_id'][0], $response);
        } catch (Throwable $e) {
            // Submit the error back to the runtime API.
            sendError($next['context']['aws_request_id'][0], $e);
        }
    } catch (Throwable $e) {
        // Submit the init error back to the runtime API.
        sendInitError($e);
    }
} while (true);

イベントループになりますが、前回 紹介した RIC の説明に使った図を見ながらがわかりやすいので再掲します。

img_new-lambda-container-development-2_01

図 : ランタイム API との対話

このイベントループで毎回、getNext() を呼んでいます。これで Lambda 関数の次の呼び出しを待つことができます。この getNext() からは Lambda 関数のハンドラ実行に必要なイベントソースから渡される Event データと、Lambda 実行時のコンテキスト情報が取得できます。

_HANDLER 環境変数では Dockerfile の CMD に指定された Lambda 関数のエントリーポイントが取得できます。これは例えば “app.handler” のような値です。これをドットを区切りに PHP ファイルの読み込みと PHP 関数の実行対象として使用します。

もし、app.handler で例外がスローされた場合は、Lambda ランタイム API の error API にエラー情報を送信し、そうでない場合は、response API に実行結果を送信します。 API Gateway の場合、Lambda 関数のハンドラから JSON 形式で HTTP Status コードを返せますが仮にこれが 200 以外だとしても Lambda 関数の実行自体は成功していることにご注意ください。あくまでもランタイム言語としてのエラーがハンドラ関数内で処理されずに最上位までスローされた場合に、bootstrap 側で捕捉し Lambda ランタイム API にエラー通知する仕組みとなっています。

そもそも、ハンドラ関数にたどり着くまでに、bootstrap 側で例外が発生した場合は、init/error API にエラーを報告します。

このようにして、Lambda のランタイム API と bootstrap に記述されたランタイムクライアントが対話することにより、Lambda 関数が実行されています。


カスタム PHP イメージを実行する

構成を再確認しておきます。

├── Dockerfile
├── lambda
│   └── app.php
└── runtime
    └── bootstrap

カレントディレクトリが Dockerfile と同じディレクトリになっていることを確認してください。
docker がインストールされていない場合は、ローカル環境に Docker Desktop をインストールしてください。

docker CLI を利用したビルドおよび実行をしてみます。

$ docker build -t phplambda:latest .
$ docker run -p 9000:8080 phplambda:latest
$ curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"queryStringParameters":{"foo":"bar"}}'  

docker コマンドを利用したビルドの方法は 第一回 で紹介していますので詳しい流れは参照ください。また、RIE でのローカルテスト方法については 第二回 で紹介しています。RIE は AWS が提供する provided:al2 のベースイメージに標準で含まれています。

ローカル実行結果を見てみましょう。

{
    "statusCode": 200,
    "headers": {
        "Content-Type": "application/json"
    },
    "body": "queryStringParameters, {\"foo\":\"bar\"}"
}

このような結果が RIE から返ってくれば成功です。

実際に AWS クラウドへのデプロイは ECR にプッシュして、Lambda 関数をデプロイする必要があります。詳しい方法は 第一回 で紹介していますので是非試してみてください。


まとめ

AWS Lambda のカスタムイメージを使用して、標準ランタイムではない PHP で関数コードを実装できることを確認しました。これにより AWS Lambda サービスがサポートしていないランタイムでもコンテナを利用して利用できることがわかりました。

もちろん、標準ランタイムの方が AWS がベースイメージに対するセキュリティパッチを定期的に提供してくれるなどのメリットもありますので、プロジェクトの運用やセキュリティポリシーも考慮した上でご利用ください。(Zip 形式の Lambda 関数と違い、すでにデプロイ済みのランタイムに対するセキュリティパッチ適用までは行いません。更新されたベースイメージを基に再度アプリケーションイメージをビルドする必要があります。)

今回は Pure PHP による Lambda 関数の実装をしましたが、PHP のアプリケーションフレームワークである Laravel を利用した構築については AWS Blog 「サーバーレス LAMP スタック – Part 4: サーバーレス Laravel アプリの構築」を参照ください。また、Laravel チームによって構築された Laravel Vapor というサーバーレスデプロイプラットフォームを利用して、WEB サービスを AWS クラウド上で構築することもできます。Laravel Vapor は Laravel チームによって構築された有料サービスです。

好きなプログラミング言語を上手に活用して効率の良い開発ができれば良いですね。

次回以降ではさらに、コンテナサポート Lambda 関数の特徴や利点を追っていきたいと思います。


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

photo_shimokawa-kensuke

下川 賢介 (@_kensh)
アマゾン ウェブ サービス ジャパン株式会社
シニア サーバーレススペシャリスト ソリューションアーキテクト

Serverless Specialist Solutions Architect として AWS Japan に勤務。
Serverless の大好きな特徴は、ビジネスロジックに集中できるところ。
ビジネスオーナーにとってインフラの管理やサービスの冗長化などは、ビジネスのタイプに関わらず必ず必要になってくる事柄です。
でもどのサービス、どのビジネスにでも必要ということは、逆にビジネスの色はそこには乗って来ないということ。
フルマネージドなサービスを使って関数までそぎ落とされたロジックレベルの管理だけでオリジナルのサービスを構築できるという Serverless の特徴は技術者だけでなく、ビジネスに多大な影響を与えています。
このような Serverless の嬉しい特徴をデベロッパーやビジネスオーナーと一緒に体験し、面白いビジネスの実現を支えるために日々活動しています。

AWS のベストプラクティスを毎月無料でお試しいただけます

さらに最新記事・デベロッパー向けイベントを検索

下記の項目で絞り込む
絞り込みを解除 ≫
フィルタ
フィルタ
1

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する