くふうしてLambdaをデプロイする

# はじめに
こんにちは。みんなのウェディングのインフラエンジニア[横山](https://twitter.com/tatsuo4848)です。
この記事は[くふうカンパニーアドベントカレンダー](https://qiita.com/advent-calendar/2018/kufu)11日目になります。
今回は、LambdaをSAM(Serverless Application Model)でデプロイするにあたり困ったことを、くふうで解決したのでまとめていきます。

# SAM(Serverless Application Model)
SAMとはLambda、API Gateway、CloudWatch Ruleなどのリソースを管理できるツールです。
https://github.com/awslabs/serverless-application-model
これだけ聞くとCloudFormationと何が違うの？と思う方もいるかもしれません。
お気付きの通り何も違いはありません。SAMはCloudFormationの拡張という位置付けになっています。
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/transform-aws-serverless.html
よって、SAMで書いた設定ファイルを使い、ClodFormationと同様に`aws cloudformation package`や`aws cloudformation deploy`といったCLIコマンドでスタックを構築することができます。

# 課題
前述した`aws cloudformation package`というコマンドには便利な機能があります。
例えば以下のように設定ファイルの`CodeUri`を指定しておくと、該当ディレクトリ`source`配下をzipで圧縮してコマンドで指定したS3バケットに配置してくれます。

```test.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: test
Resources:
  AutoDeployStaging:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: test.lambda_handler
      Runtime: python3.6
      FunctionName: Test
      CodeUri: ./source
      Description: test
      Role: arn:aws:iam::123456789:role/test
      MemorySize: 128
      Timeout: 60
```

さらに、`CodeUri`が該当のS3オブェクトに入れ替わった設定ファイルも出力してくれます。

```packaged-test.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: test
Resources:
  AutoDeployStaging:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: test.lambda_handler
      Runtime: python3.6
      FunctionName: Test
      CodeUri: s3://temp/37ba0504e4d9a488624001dae6afc957
      Description: test
      Role: arn:aws:iam::123456789:role/test
      MemorySize: 128
      Timeout: 60
```

あとはこの`packaged-test.yml`を指定して`aws cloudformation deploy`するだけでスタックが作成、更新されます。

よって、必要な外部ライブラリを`source`配下に配置しておくと、以下の2コマンドでLambdaがデプロイできます。
1. `aws cloudformation package`
2. `aws cloudformation deploy`

これは大変便利なのですが、ここで問題になるのが外部ライブラリの配置場所です。`aws cloudformation package`では指定したディレトリ配下をzip圧縮してS3にアップロードすることしかできません。
つまり、gitで管理したい自分の書いたコードと、git管理したくない外部ライブラリを同じディレクトリ(`source`)配下に置かなきゃいけないのです。

# 解決策
`.gitignore`で解決できないかと考えましたが、前述した通り、外部ライブラリは自作のコードと同じディレクトリに配置するという制約があり、除外対象ディレクトリ名が変化するため難しかったです。
また、SAMで管理する対象が増えてきたこともあり、これを機にSAMデプロイをCodeBuildを使って自動化することにしました。
具体的な手順は以下の通りです。
1. 必要な外部ライブラリを`source/requirements.txt`に記載する。
2. requirements.txtを元に`pip install`を行う。
3. `aws cloudformation package`
4. `aws cloudformation deploy`

上記2〜4の手順をCodeBuild上で実行することにしました。
これらの手順をまとめたCodeBuild上で実行されるコードは以下の通りです。

```sam_deploy.py
import subprocess
import re
import os
import glob
import requests
import json


def post_slack(text, failed=False):
    SLACK_URL = '???'

    message = {
        "attachments": [
            {
                "color": "#e73613" if failed else "#2fc3a7",
                "text": text
            }
        ]
    }
    params = {
        'url': SLACK_URL,
        'data': json.dumps(message).encode("utf-8")
    }
    requests.post(**params)


def pip_install(path):
    requirement_files = glob.glob(f"{path}/**/*.txt", recursive=True)
    try:
        for requirement_file in requirement_files:
            requirement_path = requirement_file.replace("/requirements.txt", "")
            os.chdir(requirement_path)
            cmd = f'pip install -r requirements.txt -t ./'
            subprocess.run(cmd, shell=True, check=True)
    except subprocess.CalledProcessError as e:
        print(e.stderr)
        post_slack(e.stderr, failed=True)


def cloud_formation_update(path):
    sam = path.split('/')[-1]
    try:
        cmd = (f'aws cloudformation package --template-file {path}/{sam}.yml '
               '--s3-bucket s3バケット '
               f'--output-template-file {path}/packaged-{sam}.yml')
        subprocess.run(cmd, shell=True, check=True)
        cmd = (f'aws cloudformation deploy --template-file {path}/packaged-{sam}.yml '
               '--role-arn ロールARN '
               f'--stack-name {"".join(sam.title().split("_"))}')
        subprocess.run(cmd, shell=True, check=True)
    except subprocess.CalledProcessError as e:
        print(e.stderr)
        post_slack(e.stderr, failed=True)


if __name__ == '__main__':
    ROOT_DIR = os.getenv('CODEBUILD_SRC_DIR')
    cmd = 'git diff HEAD~ --name-only'
    modify_files = subprocess.check_output(cmd, shell=True).decode('utf-8').split()
    modify_sam_paths = set([ROOT_DIR + '/' + '/'.join(i.split('/')[0:2]) for i in modify_files if re.match(r'SAM/', i)])

    for path in modify_sam_paths:
        if not os.path.exists(path):
            continue
        pip_install(path)
        cloud_formation_update(path)
        post_slack(f"{path} deployed!")

```

ディレクトリ構成は以下の通りです。

```
SAM
├── sam_1
│   ├── sam_1.yml
│   └── source
│       ├── sam_1.py
│       └── requirements.txt
└──  sam_2
    ├── sam_2.yml
    └── source
        ├── sam_2.py
        └── requirements.txt
```

CodeBuildはマスターブランチにマージがあるたびに実行されるので、`git diff HEAD~ --name-only`で変更されたファイルを特定し、SAM関連のファイルに変更があった場合のみデプロイが行われるようにしています。


# まとめ
CodeBuildはこういったちょっとした用途の際にサクッと使えて便利だなと感じました。
弊社ブログは静的サイトなのですが、こちらでもS3へのデプロイをCodeBuildで行ってたりします。
https://blog.mwed.info/posts/change_ci.html
これからも、もっとくふうして世の中を良くしていきたいと思います。
[くふうカンパニーアドベントカレンダー](https://qiita.com/advent-calendar/2018/kufu)、12日目となる明日は[ryoster](https://twitter.com/RYO_T)さんの「業務プロセスにおいてのデザインツールを組み合わせ方や学んだこと、今後の展望とか書いてみた」です。