AWS上で動いているシステムがあって、さらに定期実行したいPHPで書かれたスクリプトをどこかで実行することになった。
適当なマシン上で定期実行することもできるが、今回AWSを使っているのでAWS Lambdaで動かすことにしてみた。
以下ではスクリプトのサンプルとして、「AWS Lambdaの実行リージョンと同じリージョンにある、そのアカウントが持つEC2インスタンスのIDのリストを取得する」ものを実行することにする。
事前調査
AWS Lambdaをまだ使ったことがなかったので、最初に目的通りできそうか調査を行った。
- Lambda上で実行したものは最大15分で強制終了させられる。
- つまり最大15分で終了するインスタンスであると解釈することができる。実際のところAmazon Linuxが動いているようだ。
 - 今のところそんなに時間が掛かるスクリプトはない想定だが、それ以上掛かる場合はAWS Batch辺りを使うのだろう。
 
 - マシンサイジングできるのはメモリ量だけ。メモリ量は128MBから3008MBまで64MB刻みで割り当てることが可能。
 - 現在Lambdaの標準ランタイムとして用意されているのはNode.js, Python, Ruby, Java, Go, .NETであり、PHPは用意されていない。しかし、カスタムランタイムを作ってやって渡してやればPHPも実行可能になる。また他の言語でもAmazon Linux上で動くものなら実行できるようにできると考えられる。
 - PHPのカスタムランタイムは自分で作っても良いが、すでに提供してくれているところもあるので、問題なければこれを使えば良い。結局自分で作ることはなかったので作り方は調べていないがこのリンク先にあるshファイルを見れば良いと思う。
 - トリガーにCloudwatch Eventsを使用すると時刻をトリガーにできるので定期実行可能。
 - Lambdaには無期限の無料利用枠があり、割と頑張らないと無料枠から脱出できない。
 
というわけで行けそうなので進める。
デフォルト構成の調査
AWSマネジメントコンソールを使って、Lambda関数を"一から作成"の「カスタムランタイム」の「デフォルトのブートストラップを使用する」で1つ作ってみた。
「関数コード」を見ると、bootstrap, hello.sh, README.mdの計3ファイルが生成されたのが見える。
README.mdを読んでわかることは、
- まず読むべきドキュメントは https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html
 - 使いたい(自分で作った、あるいは誰かが提供している)カスタムランタイムはレイヤーに設定する。
 - このLambda関数が実行される際に、実際に実行されるのは「関数コード」でルートにあるbootstrapである。
 
なお、これはREADME.mdではなく後で実行してみてわかったことだが、ルートにbootstrapがなければレイヤーに含まれるbootstrapファイルが実行されるようだ。
続けてbootstrapを確認。
# !/bin/sh
set -euo pipefail
# Handler format: <script_name>.<function_name>
#
# The script file <script_name>.sh  must be located at the root of your
# function's deployment package, alongside this bootstrap executable.
source $(dirname "$0")/"$(echo $_HANDLER | cut -d. -f1).sh"
while true
do
    # Request the next event from the Lambda runtime
    HEADERS="$(mktemp)"
    EVENT_DATA=$(curl -v -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
    INVOCATION_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
    # Execute the handler function from the script
    RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")
    # Send the response to Lambda runtime
    curl -v -sS -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$INVOCATION_ID/response" -d "$RESPONSE"
done
つまり、
- http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next をGETする。
 - そのレスポンスヘッダの中にLambda-Runtime-Aws-Request-Idがあるので、その値を取得しINVOCATION_IDとする。
 - 任意のスクリプトを実行する。
 - http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$INVOCATION_ID/response にスクリプトの実行結果をPOSTする。
 - ここまでをwhileで延々とループさせる。
 
というのがbootstrapがやっていることであると読める。
AWSマネジメントコンソール上だと「関数コード」で設定できるハンドラというものがある。ここで設定した値は環境変数_HANDLERに入る。
ハンドラ名は"hello.handler"が初期設定である。そのため、bootstrapの
source $(dirname "$0")/"$(echo $_HANDLER | cut -d. -f1).sh"行および、
RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")行はhello.shのhandler関数を呼んでいることになる。
hello.shの中身は以下なので、スクリプトの実行結果はJSONを期待されているようだ。
function handler () {
    EVENT_DATA=$1
    RESPONSE="{\"statusCode\": 200, \"body\": \"Hello from Lambda!\"}"
    echo $RESPONSE
}
結局こちらでやるべきことは以下となる。
- レイヤーに https://github.com/stackery/php-lambda-layer に書かれているものを設定する。PHP 7.3なら「arn:aws:lambda:(リージョン):887080169480:layer:php73:3」。
 - bootstrapを修正してPHPのスクリプトを呼ぶようにする。
 - PHPのスクリプトはbootstrapから呼べる場所に置く。
 
Lambda側へ渡したいファイルの作成
というわけでbootstrapをPHPのスクリプトを呼ぶように修正してみる。
# !/bin/sh
set -euo pipefail
while true
do
    # Request the next event from the Lambda runtime
    HEADERS="$(mktemp)"
    EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
    INVOCATION_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
    # Execute the handler
    /opt/bin/php -c "${LAMBDA_TASK_ROOT}/php.ini" "${LAMBDA_TASK_ROOT}/${_HANDLER}.php"
    if [ $? -eq 0 ]; then
      RESPONSE="{\"statusCode\": 200, \"body\": \"Success\"}"
    else
      RESPONSE="{\"statusCode\": 500, \"body\": \"Error\"}"
    fi
    # Send the response to Lambda runtime
    curl -sS -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$INVOCATION_ID/response" -d "$RESPONSE" > /dev/null
done
上記の通り/opt/bin/php -c "${LAMBDA_TASK_ROOT}/php.ini" "${LAMBDA_TASK_ROOT}/${_HANDLER}.php"としたので、スクリプトファイルはbootstrapと同じディレクトリに"(ハンドラ名).php"という名前で置くことになる。
もっとも、ファイル名は変化するわけではないのでハンドラ名なんて使わなくても良いのだが、設定必須項目が使われないのもちょっと、ということで。
ここでphp.iniも使うようにしている。
これは今回のサンプルスクリプトがsimplexml.soとjson.soを使うので、それらをロードする必要があるためである。
simplexml.soとjson.soは https://github.com/stackery/php-lambda-layer に書かれている通りカスタムランタイム側で用意してくれているので、これらをロードすれば良い。
内容は以下となる。extension_dirでsoファイルが置かれているディレクトリを指定しないとロードできなかった。
これもbootstrapと同じディレクトリに配置する。
extension_dir=/opt/lib/php/7.3/modules
extension=simplexml
extension=json
説明をbootstrap側に戻す。
スクリプトの実行結果はスクリプトの実行時のリターンコードが0かどうかで中身を変えているだけに今回はしてある。
他にも元のbootstrapと比べて特に欲しくない情報は出力しないようにしている。
これは標準出力や標準エラー出力へのすべての書き出しがCloudwatch Logsに出力されるからである。
逆に言えば、スクリプト側ではログ出力したい情報は標準出力か標準エラー出力に書き出すようにしておくと良い。
また、 http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next をGETした際のレスポンスボディ(EVENT_DATA変数に格納されるもの。JSONである)はまったく使わずに握り潰している。
今回のサンプルスクリプトファイルの内容は以下である。
先に書いた通り、単に「AWS Lambdaの実行リージョンと同じリージョンにある、そのアカウントが持つEC2インスタンスのIDのリストを取得する」だけのものとさせてもらっている。
<?php
require 'aws/aws-autoloader.php';
use Aws\Ec2\Ec2Client;
$ec2Client = new Ec2Client([
  'version' => 'latest',
  'region'  => $_ENV['AWS_REGION'],
]);
$reservations = $ec2Client->describeInstances()['Reservations'];
foreach ($reservations as $reservation) {
  echo $reservation['Instances'][0]['InstanceId'] . "\n";
}
?>
このスクリプトのファイル名をscript.phpという名前にしたので、ハンドラ名はscriptとなる。
AWS SDK for PHPを呼んでいるがインストールは https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/getting-started_installation.html の一番下、「ZIPファイルを使用したインストール」で行っている。
展開位置はbootstrapと同じディレクトリであり、つまりbootstrap, php.ini, script.phpの3ファイルが置かれているディレクトリにawsディレクトリが作られている。
なお、私はPHPをほとんど触ったことないのでComposerの使い方とか知らないが、一般にはComposerを使うものだと思われる。
Lambda側へファイルを渡すための準備
修正したbootstrapやphp.ini, script.php、それに展開したAWS SDK for PHPはLambda側に置く必要がある。
AWSマネジメントコンソールを使うなら「関数コード」にてzipにして渡したりできる。
今回はCloudformationを使う。その場合、zipをS3に置いておく必要がある。
zipにする時の注意だが、bootstrapはLinuxファイルシステムにおける実行権限がついていなければならない。
特にWindows上で作業する場合は注意すること。WSLを使って作業するなどで問題ないと思われるが。
また、ルートディレクトリがzip書庫内に含まれていてはならない。
私は以下のコマンドで圧縮している。
ここでlambda-phpはbootstrapやphp.ini, script.php, 展開したAWS SDK for PHPが置かれているディレクトリとする。
$ cd lambda-php; zip -r ../src.zip .; cd -
この作成したzip(ここではsrc.zipという名前にしている)をS3にアップロードするが、こちら側の注意点としては https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html のS3Bucketの項に書かれている通り、Cloudformationを実行する(=Lambda関数が作成される)リージョンと同じリージョンのバケットを使用しなければならないことである。
また、バケットは別のAWSアカウントのものでも良いが、その場合はCloudformationを実行するアカウントからsrc.zipがアクセス権限上ダウンロード可能になっていないとならない。簡単にはパブリックアクセス可能にしておくなど。
Cloudformationテンプレートの作成
Lambda関数の一式をCloudformationで作成するにあたり、 https://github.com/stackery/php-lambda-layer にはAWS SAMを使った例が出ている。
これをやりたいことに合わせて適当に修正したtemplate.ymlというファイルにしたものが以下である。
(先に言っておくと私はこの方法を使用していないので、やり方だけ書く)
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Resources:
  IamRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      Policies:
        -
          PolicyName: "CreateLogPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-function:*"
        -
          PolicyName: "ScriptPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action:
                  - "ec2:DescribeInstances"
                Resource: "*"
  ServerlessFunction:
    Type: "AWS::Serverless::Function"
    Properties:
      FunctionName: !Sub "${AWS::StackName}-function"
      CodeUri: src
      Runtime: provided
      Handler: script
      Role: !GetAtt IamRole.Arn
      MemorySize: 128
      Timeout: 10
      Layers:
        - !Sub "arn:aws:lambda:${AWS::Region}:887080169480:layer:php73:3"
      Events:
        event:
          Type: Schedule
          Properties:
            Schedule: "cron(*/5 * * * ? *)"
Eventsは5分ごとに定期実行するための設定にしてある。なお、実際には20から30秒程度遅れて実行されるようだ。実行環境の起動に掛かる時間だろうか。
スケジュールはcronの時刻設定書式が使用できるが、 https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/ScheduledEvents.html の通り一般的なcronのものの書式とは違いがあり、年を指定でき、日か曜日の使わない方は"?"にするなどの必要がある。
IAM roleはCloudwatch Logsにログを出力するのを許可するもの(CreateLogPolicy)と、スクリプトで必要なEC2インスタンスの一覧を取得するのを許可するもの(ScriptPolicy)を設定してある。
カスタムランタイムを使用する場合、通常Runtimeを"provided"にしてLayersに使用するカスタムランタイムを設定する。
また、与えるメモリ量は最小の128MB、強制終了までの時間は10秒に設定している。
AWS CLIがインストールされた環境で、aws cloudformation package --s3-bucket (deployを実行するのと同じリージョンにある適当な存在するバケット名) --template-file template.yml --output-template-file output.ymlを実行するとCodeUriで指定したディレクトリの中にあるファイルを再帰的にzip圧縮してS3の--s3-bucketで指定したバケットにアップロードしてくれ(ファイル名はzipファイルのmd5のように見える)、Cloudformationに食わせられるテンプレートファイルを--output-template-fileに指定した名前で生成してくれる。
ただ、このzip作成時にbootstrapに自動的に実行権限を付けてくれたら嬉しかったのだがそうはいかなかった。
生成されたoutput.ymlの内容は以下である。
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Resources:
  IamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: /
      Policies:
      - PolicyName: CreateLogPolicy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - logs:CreateLogGroup
            Resource:
              Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*
          - Effect: Allow
            Action:
            - logs:CreateLogStream
            - logs:PutLogEvents
            Resource:
              Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-function:*
      - PolicyName: ScriptPolicy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - ec2:DescribeInstances
            Resource: '*'
  ServerlessFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName:
        Fn::Sub: ${AWS::StackName}-function
      CodeUri: s3://xxxxxxxxxxxx/6d54284568d5c9f126c866bc6483835d
      Runtime: provided
      Handler: script
      Role:
        Fn::GetAtt:
        - IamRole
        - Arn
      MemorySize: 128
      Timeout: 10
      Layers:
      - Fn::Sub: arn:aws:lambda:${AWS::Region}:887080169480:layer:php73:3
      Events:
        event:
          Type: Schedule
          Properties:
            Schedule: cron(*/5 * * * ? *)
この生成されたoutput.ymlを使用してaws cloudformation deploy --template-file output.yml --capabilities CAPABILITY_IAM --stack-name (適当なスタック名)を実行すると、Cloudformationを使用したデプロイが実行される。
https://github.com/stackery/php-lambda-layer ではこれらのコマンドはAWS SAM CLIを使用して実行されているが、インストールしてみてsam --helpするとsam packageはaws cloudformation packageの、sam deployはaws cloudformation deployのエイリアスであることが分かるので、たぶんSAM CLIをインストールする必要は実際にはないと思われる。
生成されたoutput.ymlはSAMテンプレート形式で記述されており、Cloudformationで実行時にTransformによりCloudformationテンプレート形式に変換される。
実行しないと実際に何になるのかがわからないのがちょっと嫌だったので、実行後にAWSマネージメントコンソールのCloudformationのところで見られる変換後のテンプレートを参考に、最初からCloudformationテンプレート形式で書くことにした、というのが先に書いた通り上記のSAMテンプレートを使用する方式を使わなかった理由である。
aws cloudformation packageを使っていないので、上記「Lambda側へファイルを渡すための準備」の通りに圧縮して作ったsrc.zipをS3(以下の例では「xxxxxxxxxxxx-(リージョン名)」というバケット)にアップロードしてある。
というわけで作成したCloudformationテンプレートが以下である。
AWSTemplateFormatVersion: "2010-09-09"
Resources:
  IamRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      Policies:
        -
          PolicyName: "CreateLogPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-function:*"
        -
          PolicyName: "ScriptPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action:
                  - "ec2:DescribeInstances"
                Resource: "*"
  LambdaFunction:
    Type: "AWS::Lambda::Function"
    Properties:
      FunctionName: !Sub "${AWS::StackName}-function"
      Code:
        S3Bucket: !Sub "xxxxxxxxxxxx-${AWS::Region}"
        S3Key: src.zip
      Handler: script
      MemorySize: 128
      Timeout: 10
      Role: !GetAtt IamRole.Arn
      Runtime: provided
      Layers:
        - !Sub "arn:aws:lambda:${AWS::Region}:887080169480:layer:php73:3"
  EventsRule:
    Type: "AWS::Events::Rule"
    Properties:
      ScheduleExpression: "cron(*/5 * * * ? *)"
      Targets:
        -
          Id: !Sub "${AWS::StackName}-rule-target"
          Arn: !GetAtt LambdaFunction.Arn
  LambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:invokeFunction"
      Principal: "events.amazonaws.com"
      FunctionName: !Ref LambdaFunction
      SourceArn: !GetAtt EventsRule.Arn
SAMテンプレートのAWS::Serverless::Functionタイプが、CloudformationテンプレートだとAWS::Lambda::Function, AWS::Events::Rule, AWS::Lambda::Permissionの3つになる感じか。
これをaws cloudformation deploy --template-file (テンプレートファイル名) --capabilities CAPABILITY_IAM --stack-name (適当なスタック名)を実行すると、こちらでもCloudformationを使用したデプロイが実行される。
AWSマネジメントコンソールを使うなら"スタックの作成"でテンプレートファイルをアップロードし、「スタックの名前」を入力して「AWS CloudFormationによってIAMリソースが作成される場合があることを承認します。」のチェックを入れて「スタックの作成」を行えば同じことになる。
ここまでやってCloudwatch Logsを見ると5分置きに実行されているのがわかる。
