Lambda FunctionsをCloudFormationでデプロイするbashスクリプト

Lambda FunctionをCloudFormationでデプロイするときの考え方は、やってみて以下がしっくりきました。

> Lambda関数は別ファイルとして保存し、aws cloudformation packageしてaws cloudformation deployする
> （[CloudFormationでAWS Lambdaを作成・更新する際のベストプラクティス](https://qiita.com/kihoair/items/fe204fc8582946646d07)）

実際の実施時には、デプロイ後に不要になるS3バケットに置いたソースコードファイルや、中間ファイル的に作成されるテンプレートファイルなどの削除もしているので、それを含めた手順をメモしておきます。またその手順が繰り返し行っているとちょっと手間に思えてきたので、 bash スクリプトにまとめてしまうことにします。

# AWS CLIでのLambda Functionのデプロイ

AWS CLIでLambda Functionをデプロイする手順を確認しておきます（もしスクリプトが使えればいい、動作は分かる/興味がないようであれば、飛ばして先に進んでください）。

## 準備

1. ソースコードファイルを作成します。
2. `AWS::Lambda::Function` リソースをデプロイするCloudFormationテンプレートを書きます。

作成したファイルは、例えば次のように配置します。

~~~
(カレントディレクトリ)
   +-- template/
   |    +-- my-lambda-stack-001.yml (テンプレートファイル)
   +-- source/
        +-- my-lambda-function-001/
             +-- lambda-function.py (関数本体)
~~~

テンプレートは例えばこんな感じ。ポイントは、Lambda Functionの `CODE` には作成したソースコードのあるディレクトリを指定することです。テンプレートからの相対パスで記述し、デリミタは `/` を（もしWindows環境で実行するとしても）使います。

なおLambda Functionと同時に専用のIAM Roleをデプロイすることが多いと思いますが、既存ロールを使う場合は `MyLambdaRole` は削除して `role` は既存ロールの `arn` に置換えてください。

~~~
AWSTemplateFormatVersion: '2010-09-09'
Description: "My Lambda Function"
Resources:

  MyLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      RoleName: my_lambda_role

  MyLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code: ../source/my-lambda-function-001
      FunctionName: my-lambda-function-001
      Handler: lambda_function.lambda_handler
      Role: !GetAtt MyLambdaRole.Arn
      Runtime: python3.8
~~~

## デプロイの実行

1. デプロイに使用する一時的なS3バケットを作成する。
2. `aws cloudformation package` を実行する。コードファイルがアップロードされ一時的なテンプレートが生成される。
3. `aws cloudformation create-stack` を実行する。生成された一時的なテンプレートを使用する。
4. 生成された一時的なテンプレートを削除する。
5. スタックのデプロイ完了を待つ。
6. 作成した一時的なS3バケットを削除する。

作業はCloudFormationテンプレートのあるディレクトリで行います。以下の例では、先頭の `$ ` はコマンドプロンプトと考えてください。

まず一時的に使用するS3バケットを作成します。バケット名は適当な文字列と日時などを組合わせると、既存のものと重なりにくいでしょう。

~~~
$ LAMBDA_BUCKET="YOURNAME-lambda-deploy-`date +%Y%m%d-%H%M%S`"
$ aws s3 mb "s3://${LAMBDA_BUCKET}"
~~~

次に `aws cloudformation package` を実行します。これはLambda Functionのコードなど、ファイルで作成されたものを指定されたS3バケットにアップロードし、そのS3バケット名とオブジェクト名を反映したテンプレートを生成してくれます。 `my-lambda-stack-001.yml` を元に `my-lambda-stack-001-packaged.yml` を生成させることにします。

~~~
$ TEMPLATE_FILE=my-lambda-stack-001.yml
$ PACKAGED_FILE=my-lambda-stack-001-packaged.yml
$ aws cloudformation package --s3-bucket ${LAMBDA_BUCKET} --template-file ${TEMPLATE} --output-template-file ${PACKAGED}
~~~

生成されたテンプレート中では `Code` の指定値がS3のバケット名とオブジェクト名に置換えられています。これがアップロードされたコードファイルを指しています。 `aws cloudformation create-stack` を、生成されたテンプレートファイル `my-lambda-stack-001-packaged.yml` を指定して実行します。スタック名は `my-lambda-stack-001` とします。

なお、テンプレートファイルは `file://フルパス` の形で指定する必要があります。またここではIAM Roleをテンプレート内で同時にデプロイしている想定なので `--capabilities CAPABILITY_NAMED_IAM` が必要になっています。

~~~
$ STACK_NAME=my-lambda-stack-001
$ aws cloudformation create-stack --stack-name ${STACK_NAME} --template-body "file://`pwd`/${PACKAGED_FILE}" --capabilities CAPABILITY_NAMED_IAM
~~~

スタックが作成されてしまえば、生成されたテンプレートファイルは不要でしょう。削除することにします。

~~~
$ rm ${PACKAGED_FILE}
~~~

あとはAWSコンソール上でCloudFormationスタックの進行を確認します。デプロイが完了する（状態が"CREATE COMPLETE"になる）のを待ちます。

デプロイが完了してしまえば、作成したS3バケットと、そこにアップロードされたLambda Function用のコードファイルは不要でしょう。削除することにします。

~~~
aws s3 rb s3://${LAMBDA_BUCKET} --force
~~~

これで終了です。

# スクリプト化する

以下を前提にします。

* CloudFormationスタック名を引数で受け取ります。
* CloudFormationテンプレートは名は `スタック名.yml` とします。
* AWSプロファイルが作成されており、デフォルトのもの以外を使うときは `AWS_PROFILE` 環境変数に使用するプロファイル名が設定されているものとします。

以下がスクリプトです。

~~~deploy.sh
#!bash

LAMBDA_BUCKET_PREFIX=yourname_lambda_bucket

STACK_NAMES=$@
LAMBDA_BUCKET="tsukamoto-challenge-`date +%Y%m%d-%H%M%S`"

#--- function definitions ---
create_temporary_bucket() {
    echo `date "+%Y/%m/%d %H:%M:%S"` S3 buckect: create "${LAMBDA_BUCKET}" started.
    aws s3 mb "s3://${LAMBDA_BUCKET}" > /dev/null
    echo `date "+%Y/%m/%d %H:%M:%S"` S3 buckect: create "${LAMBDA_BUCKET}" finished.
}

create_stacks() {
    for STACK_NAME in $STACK_NAMES
    do
        echo `date "+%Y/%m/%d %H:%M:%S"` CloudFormation Stack: create "${STACK_NAME}" started.
        TEMPLATE_FILE="${STACK_NAME}.yml"
        PACKAGED_FILE="${STACK_NAME}-packaged.yml"
        aws cloudformation package --template-file ${TEMPLATE_FILE} --output-template-file ${PACKAGED_FILE} --s3-bucket "$LAMBDA_BUCKET" > /dev/null
        aws cloudformation create-stack --stack-name ${STACK_NAME} --template-body "file://`pwd`/${PACKAGED_FILE}" --capabilities CAPABILITY_NAMED_IAM > /dev/null
        rm ${PACKAGED_FILE} > /dev/null
        echo `date "+%Y/%m/%d %H:%M:%S"` CloudFormation Stack: create "${STACK_NAME}" finished.
    done
}

wait_stacks() {
    for STACK_NAME in $STACK_NAMES
    do
        STACK_STATUS=`aws cloudformation list-stacks | grep STACKSUMMARIES | grep ${STACK_NAME} | head -1 | cut -f 7`
        if [ "${STACK_STATUS}" = "CREATE_IN_PROGRESS" ]; then
            echo `date "+%Y/%m/%d %H:%M:%S"` CloudFormation Stack: "${STACK_NAME}" is ${STACK_STATUS}, wait.
            aws cloudformation wait stack-create-complete --stack-name ${STACK_NAME} > /dev/null
            STACK_STATUS=`aws cloudformation list-stacks | grep STACKSUMMARIES | grep ${STACK_NAME} | head -1 | cut -f 7`
            echo `date "+%Y/%m/%d %H:%M:%S"` CloudFormation Stack: "${STACK_NAME}" is ${STACK_STATUS}.
        else
            echo `date "+%Y/%m/%d %H:%M:%S"` CloudFormation Stack: "${STACK_NAME}" is ${STACK_STATUS}.
        fi
    done
}

remove_temporary_bucket() {
    echo `date "+%Y/%m/%d %H:%M:%S"` S3 buckect: "${LAMBDA_BUCKET}" exists.
    aws s3 rb s3://${LAMBDA_BUCKET} --force > /dev/null
    echo `date "+%Y/%m/%d %H:%M:%S"` S3 buckect: "${LAMBDA_BUCKET}" removed.
}

#--- main ---

echo `date "+%Y/%m/%d %H:%M:%S"` deploy.sh started.

create_temporary_bucket
create_stacks
wait_stacks
remove_temporary_bucket

echo `date "+%Y/%m/%d %H:%M:%S"` deploy.sh: finished.
exit 0

~~~

スクリプトをCloudFormationテンプレートと同じディレクトリに置きます。例えば（冒頭のディレクトリ構成を元にすれば）次のような配置になります。

~~~
(カレントディレクトリ)
   +-- template/
   |    +-- deploy.sh               (上記スクリプトファイル)
   |    +-- my-lambda-stack-001.yml (テンプレートファイル)
   +-- source/
        +-- my-lambda-function-001/
             +-- lambda-function.py (関数本体)
~~~

コマンドラインでCloudFormationテンプレートのディレクトリに移動し、 `deplo.sh` を実行します。引数で、作成するスタック名を指定します。

~~~
$ ./deploy.sh スタック名
~~~

スクリプトは `スタック名.yml` ファイルを使用テンプレートとして、手作業時と同様に以下の手順を実行します。

1. デプロイに使用する一時的なS3バケットを作成する。
2. `aws cloudformation package` を実行する。コードファイルがアップロードされ一時的なテンプレートが生成される。
3. `aws cloudformation create-stack` を実行する。生成された一時的なテンプレートを使用する。
4. 生成された一時的なテンプレートを削除する。
5. スタックのデプロイ完了を待つ。
6. 作成した一時的なS3バケットを削除する。

なお、スタック名を複数指定しても構いません。

~~~
$ ./deploy.sh スタック名1 スタック名2 ...
~~~

この時は、上の手順の2～4を各スタックに対して行った後、5ですべてのスタックが終了するのを待って、6でS3バケットを削除する動作になります。

# 参考

以下の情報を参考にしました。

* [CloudFormationでAWS Lambdaを作成・更新する際のベストプラクティス - Qiita](https://qiita.com/kihoair/items/fe204fc8582946646d07)
* [S3 バケットへのローカルアーティファクトのアップロード - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/using-cfn-cli-package.html)

Lambda Function用のテンプレートの作成は、以下を参考にしました。

* [AWS::Lambda::Function - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-function.html)
* [CloudFormationでLambda関数デプロイ(超初歩) - Qiita](https://qiita.com/da-sugi/items/d2ba119a0e35ad1b352e)

----
本ページ内容は筆者が参照の便のためにある時点でまとめた個人的なメモです。内容を保証するものではなく、また筆者の所属組織等とは一切かかわりがありません。