CloudFormationのLambda-backed カスタムリソースでLambdaのBoto3を自動で最新化する

# 背景
AWSのLambda FunctionをPython3で書いて実行するとき、Boto3のドキュメントには使用方法が書かれているのに、いざ実行してみるとメソッドが存在していないと怒られることがあります。
例えば、RDSのstop_db_clusterは、LambdaのBoto3では実行できませんでした。(2019/5/30現在)

```python:sample
def stop_aurora_cluster(cluster_identifier):
	rds = boto3.client('rds')
	response = rds.stop_db_cluster(DBClusterIdentifier=cluster_identifier)
```
上記関数をLambda Functionで実行すると、以下のような結果となり、失敗します。

```text
'RDS' object has no attribute 'stop_db_cluster': AttributeError
(略)
AttributeError: 'RDS' object has no attribute 'stop_db_cluster'
```
しかし[boto3のドキュメントには、stop_db_clusterが定義されています。](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/rds.html?highlight=rds#RDS.Client.stop_db_cluster
)
メソッドは存在するのに、Lambdaからは使えないという状況です。

何故このようなことが起こるのかというと、Lambdaに搭載されているBoto3のバージョンが、Boto3の最新バージョンに比べて古いからです。
ただ、Lambda Layersを使えば、最新のBoto3を利用できます。
詳しくは以下のリンク先が参考になりますが、Lambdaに標準搭載されているBoto3を、より新しいバージョンのBoto3のLambda Layersで上書きするという方法です。
(参考) [Lambda Layers で最新の AWS SDK を使用する](https://qiita.com/hayao_k/items/b9750cc8fa69d0ce91b0)

このように、最新のBoto3を使いたい場合、都度、上記リンク先の方法でLambda Layersを作る必要がありますが、これは面倒な作業です。
zipを作ってマネジメントコンソールでアップロードするのも、zipをS3にアップロードしてCloudFormationのテンプレートを実行するのも、手作業が多くて非常に面倒です。

- **Lambda Functionをデプロイする度にLambda Layersを手動でデプロイし直すのは嫌だ！**
- **なんとか自動でやりたい！**

ということで、

- **最新のBoto3 Lambda Layersを自動で生成するLambda Function**

を作ってしまいました。

このLambda FunctionをAWS CloudFormationの**Lambda-backed カスタムリソース** (CloudFormationのテンプレート実行時に、テンプレート内でLambda Functionを実行できる機能) で実行すれば、毎回、自動で最新のBoto3 Lambda Layersを生成し、別のLambda Functionにアタッチすることができるようになります。

# CloudFormationテンプレート(YAML)
S3 Bucketを作っておきます。
ぶっちゃけそのS3 BucketもCloudFormationで作れば良いのですが、今回は割愛しています。
## IAM Role
Lambda Functionで実行するs3:PutObjectを許可します。

```yaml
DeployLatestBoto3LambdaLayerRole:
    Type: AWS::IAM::Role
    Properties: 
        RoleName: "DeployLatestBoto3LambdaLayerRole"
        AssumeRolePolicyDocument:
            Version: '2012-10-17'
            Statement:
            - Effect: Allow
              Principal:
                Service:
                - lambda.amazonaws.com
              Action:
              - sts:AssumeRole
        Path: "/"
        ManagedPolicyArns:
            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        Policies:
            - PolicyName: "DeployLatestBoto3LambdaLayerPolicy"
              PolicyDocument: 
                Version: "2012-10-17"
                Statement: 
                - Effect: Allow
                  Sid: "DeployLatestBoto3LambdaLayerPolicy"
                  Action:
                    - s3:PutObject
                  Resource: "*"
```

## Lambda Function (Boto3 Lambda Layersを生成するLambda Function)
まず、**Lambda-backed カスタムリソース**のお作法として、cfnresponse.send()でシグナルを返すというものがありますので、必ず書くようにします。
Pythonのコードにcfnresponse.send()を書き忘れますと、CloudFormationのテンプレートを実行したとき、カスタムリソースの作成時にLambda Functionからシグナルが返らずに1時間待ちとなって、果てには作成に失敗します。
さらに、ロールバック時にもLambda Functionが実行され、そこでもシグナルが返らずに1時間待ちとなって、**合計2時間待ち**を食らいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/dca28ee7-f7ee-f2c3-8596-384b3afe1571.png)

次に、以下のコードでどのようにLambda Layersを生成しているかですが、まず、Lambda FunctionがLinux上で動いていることを利用し、Lambda Functionに書き込み権限がある/tmpに、subprocessとpipを駆使して、最新Boto3を書き込んでいます。
その後Boto3をzipに圧縮しますが、Lambda Functionが動くLinuxではzipコマンドが使えなかったため、Pythonのshutilを使って圧縮しています。
(ちなみにgzip等は使えるようです。何故標準zipが使えないかはよく分かりません。。。)

最後の処理で、出来上がったzipファイルをS3にアップロードします。

```yaml
DeployLatestBoto3LambdaLayerLambdaFunction:
    Description: "To make the Latest boto3 Lambda Layers and upload it to s3 bucket"
    Type: "AWS::Lambda::Function"
    Properties: 
      Code:
        ZipFile: !Sub |
            import boto3
            import subprocess
            import shutil
            import cfnresponse
            
            # Lambdaハンドラ
            def lambda_handler(event, context):
                try:
                    # 引数(event)チェック
                    if event['RequestType'] == 'Delete':
                        cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                        return
                        
                    # mkdir /tmp/python
                    mkdir_command = subprocess.run(["mkdir", "-p", "/tmp/pip/python"], stdout = subprocess.PIPE, stderr = subprocess.PIPE)
            
                    # pip install -t /tmp/python/ boto3
                    boto3_install_command = subprocess.run(["pip", "install", "-t", "/tmp/pip/python", "boto3"], stdout = subprocess.PIPE, stderr = subprocess.PIPE)
            
                    # ls -al /tmp/python | grep boto3- | grep .dist-info
                    ls_boto3_command = subprocess.Popen(["ls", "/tmp/pip/python"], stdout = subprocess.PIPE, stderr = subprocess.PIPE)
                    grep_boto3_command = subprocess.Popen(["grep", "boto3-"], stdin = ls_boto3_command.stdout, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
                    dist_boto3_command = subprocess.Popen(["grep", ".dist-info"], stdin = grep_boto3_command.stdout, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
                    dist_boto3_command_stdout = dist_boto3_command.communicate()[0]
                    boto3_version_str = dist_boto3_command_stdout.decode('utf-8').replace('boto3-', '').replace('.dist-info', '').replace('\n', '')
                    
                    ls_boto3_command.stdout.close()
                    grep_boto3_command.stdout.close()
                    dist_boto3_command.stdout.close()
                    
                    # /tmp/pythonをzipに圧縮
                    boto3_zip_path = '/tmp/boto-'+boto3_version_str
                    shutil.make_archive(boto3_zip_path, 'zip', root_dir='/tmp/pip')
                    
                    # ls /tmp/
                    ls_boto3_command = subprocess.run(["ls", "/tmp/"], stdout = subprocess.PIPE, stderr = subprocess.PIPE)
            
                    # boto3-x.x.xxx.zip をS3にアップロード
                    s3Resource = boto3.resource('s3')
                    s3FileUpload = s3Resource.meta.client.upload_file(Filename=boto3_zip_path+'.zip', Bucket=event['ResourceProperties']['S3Bucket'], Key='boto3-'+boto3_version_str+'.zip')
            
                    # 戻り値の生成
                    responseData = {
                        'S3Bucket': event['ResourceProperties']['S3Bucket'],
                        'S3Key': 'boto3-'+boto3_version_str+'.zip'
                    }
                    cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
                
                except Exception:
                    # 戻り値の生成
                    cfnresponse.send(event, context, cfnresponse.FAILED, {})
      FunctionName: DeployLatestBoto3LambdaLayer
      Handler: index.lambda_handler
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Role: !GetAtt DeployLatestBoto3LambdaLayerRole.Arn
      Runtime: "python3.6"
      Timeout: 300
      Tags: 
      - Key: Name
        Value: 'DeployLatestBoto3LambdaLayer'
      - Key: CloudformationArn
        Value: !Ref 'AWS::StackId'
```

## カスタムリソース (Boto3 Lambda Layersを生成するLambda Functionを呼び出す)
Lambda Functionが書ければ、カスタムリソースはこれだけです。
ServiceTokenでLambda Functionを指定して呼び出します。
S3Bucketは、Lambda Functionの内部で利用するための独自パラメータです。

```yaml
LatestBoto3LambdaLayerCustomResource:
  Type: Custom::LatestBoto3LambdaLayerCustomResource
  Properties:
    ServiceToken: !GetAtt DeployLatestBoto3LambdaLayerLambdaFunction.Arn
    S3Bucket: 'hogehoge'
```

## Lambda Layers
カスタムリソースの実行が正常に完了すると、Lambda LayersのzipファイルがアップロードされたS3 Bucket名と、zipファイルのパスを示すS3Keyが返ります。
これらの値をContentのパラメータに代入します。

```yaml
LatestBoto3LambdaLayer:
    Type: "AWS::Lambda::LayerVersion"
    Properties:
      Description: !GetAtt LatestBoto3LambdaLayerCustomResource.S3Key
      CompatibleRuntimes: 
        - python3.6
        - python3.7
      Content: 
        S3Bucket: !GetAtt LatestBoto3LambdaLayerCustomResource.S3Bucket
        S3Key: !GetAtt LatestBoto3LambdaLayerCustomResource.S3Key
      LayerName: 'boto3-python-layer'
```

# テンプレート実行結果
CloudFormationテンプレート内で、Lambda Layersを生成するLambda FunctionがLambda-backed カスタムリソースとして実行され、結果、最新のBoto3を含むLambda Layersがアップロードされました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/db1820c0-f44a-b1d0-e34f-8f75b2662d95.png)

# まとめ
**Lambda Layersを生成するLambda FunctionをLambda-backed カスタムリソースで実行する**ことで、Lambda Layersを手作業で作ることなく、自動生成できるようになりました。
手作業でのzip圧縮&アップロードがなくなり、AttributeErrorも回避でき、非常に快適となります。

また、Lambda Function内でLinuxのコマンドが実行できること、特に**pipが実行できること**は、覚えておけば今後何かと役に立ちそうです。
Boto3に限らず、あらゆるパッケージをCI/CDパイプラインの中で自動的にLambda Layers化できると、非常に捗ります。

なお、Lambda-backed カスタムリソースをupdate-stackの度に毎回実行する方法については、[CloudFormationでスタック内の特定リソースを毎回変更する方法 (超簡単で地味に実現)](https://qiita.com/nasuvitz/items/e632d9fd373c89af3f9c)に記載した超地味なやり方で実現しています。