AWS Chalice プロジェクトを CloudFormation でデプロイすると Lambda 関数名がランダムに設定されてしまう、を回避する

# はじめに
Lambda ベースのアプリケーション開発を効率化できる Python フレームワークの AWS Chalice[^chalice]、面倒な設定はフレームワークにお任せしてコードにひたすら集中することができとても重宝しています。また、アプリケーション内で多くの関数が必要になったり、他の AWS サービスとの連携が増えたりしても、CloudFormation でのスマートな構成管理も可能[^chalicecfn]で安心です。でも、、、Chalice プロジェクトを CloudFormation で構成管理するときにはちょっとした困りごとが。。。それは、Lambda 関数名の扱いです。

例えば以下のように、`helloworld` というアプリケーションで `my_function` という関数名の Lambda 関数[^purelambda]を定義することを考えます。

```python:app.py
from chalice import Chalice

app = Chalice(app_name='helloworld')

@app.lambda_function()
def my_function(event, context):
    return {'hello': 'world'}
```
これを、Chalice のコマンド `chalice deploy` でデプロイすると、Chalice が自動的に命名してくれる Lambda 関数名は `{app_name}-{stage}-{func_name}` となります。
![chalice1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/d08f4661-3979-4492-a675-8ec8e2fa9801.png)
では、同じ Lambda 関数を、CloudFormation を使って `helloworld-dev` という名前のスタックにデプロイしてみます。コマンドは、少し複雑になりますが以下の通りです。

```bash
chalice package out/
aws cloudformation package \
	--template-file out/sam.json \
	--s3-bucket 'パッケージを出力するバケット名' \
	--output-template-file out/packaged.yaml
aws cloudformation deploy \
	--stack-name helloworld-dev \
	--template-file out/packaged.yaml \
	--capabilities CAPABILITY_NAMED_IAM
```
このとき、Chalice と CloudFormation が自動的に命名してくれる関数名は、
`{app_name}-{stage}-{func_name をアッパーキャメルケースに変換したもの}-{ランダム文字列}`
となって、`chalice deploy` で作られる名前と違うだけでなく、名前にランダム文字列が入ってしまいます。
![chalice2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/efa0290a-2812-2daf-b8b5-739c6d3ce693.png)
これ、Chalice で作った Lambda 関数を、AWS CLI や Step Functions などから直接 `invoke` したいときには、関数名を推測できず少し面倒です。マネージメントコンソールや AWS CLI で生成された関数名を確認するか、もしくは、`chalice package --merge-template` でマージする CloudFormation のテンプレート内で `Outputs` に関数名を出力して機械的に参照できるようにする、など、何らかの手間が必要になります。

そこで今回は、Chalice プロジェクトを CloudFormation を使ってデプロイしても、`chalice deploy` で設定されるのと同じ Lambda 関数名（すなわち、`{app_name}-{stage}-{func_name}`）が設定されるよう、対策してみます。

# 方針
調べると、github の公式リポジトリの Issue に同様の事例が挙がっていました。
[Use function name in the SAM template generation with package command · Issue #935 · aws/chalice](https://github.com/aws/chalice/issues/935)

2018年に投稿されたもので、Chalice 自体のソースコードに手を入れる対策も提案されているのですが、現時点で反映はされていません。3年以上経っても反映されていないことには理由があるはずで、関連する言及もあります[^issue]。要は、CloudFormation では、リソースに明示的に固定の名前を付けないほうがいいよね、というような考え方なのですが、、、明示的に固定の名前を付けたいときも、やっぱりありますよね。
他方、この Issue では別の提案もされていて、それは、`chalice package` で出力される CloudFormation テンプレートを Python スクリプトで後処理して、テンプレートに `Outputs` を自動追加してしまう、というものです。

そこで今回はもう一歩踏み込んで、`chalice package` で出力される CloudFormation テンプレートを Python スクリプトで後処理して、テンプレートに Lambda 関数名（`FunctionName`）を自動追加してしまう、というやり方で対策してみたいと思います[^risk]。

# 実装
上記 Issue で提案されている Python スクリプト[^sample]では、CloudFormation テンプレートの処理に troposphere という Python ライブラリ[^troposphere]を使っていますが、今回はこのライブラリは使わず、CloudFormation テンプレート（JSON or YAML）を `dict` として読み込んで直接処理します。

やることを割り切ってしまえば実装は単純で、`chalice package` が出力する `sam.json`（もしくは `sam.yaml`）に Lambda 関数名（`FunctionName`）を自動追加する Python スクリプト「`augment.py`」は、以下のようになります。

```python:augment.py
import sys
import json, yaml
import codecs
import argparse

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('template_path')
    args = parser.parse_args()
    return _run(args)

def _run(args):
    template_path = args.template_path
    template = _load_template(template_path)
    _inject_funcname(template)
    _write_template(template_path, template)
    return 0

def _load_template(path):
    data = {}
    with open(path, 'r') as f:
        if path.endswith('.json'):
            data = json.load(f)
        else:
            data = yaml.safe_load(f)
    return data

def _inject_funcname(template):
    for lid, res in template.get('Resources', {}).items():
        prop = res.get('Properties', {})
        arr1 = prop.get('Handler', '').split('.')
        if arr1[0] == 'app':
            data = {'func': arr1[1]}
            for s in prop.get('Tags', {}).get('aws-chalice', '').split(':'):
                arr2 = s.split('=')
                data[arr2[0]] = arr2[1]
            if 'app' in data and 'stage' in data:
                res['Properties']['FunctionName'] = (
                    '{}-{}-{}'.format(data['app'], data['stage'], data['func'])
                )

def _write_template(path, template):
    with codecs.open(path, 'w', 'utf-8') as f:
        if path.endswith('.json'):
            json.dump(template, f, indent=2)
        else:
            yaml.dump(template, f)

if __name__ == '__main__':
    sys.exit(main())
```
`_inject_funcname` 関数で CloudFormation テンプレートを更新しているのですが、更新の手がかりは以下です。

- Chalice がテンプレートに出力してくれる `Handler` に含まれる関数名（`app.{func_name}`）
- Chalice がテンプレートに出力してくれる `Properties.Tags.aws-chalice` に含まれるアプリケーション名とステージ（`version={version}:stage={stage}:app={app_name}`）

試しに、[はじめに](#はじめに) で提示したプロジェクトで `chalice package` すると出力される CloudFormation テンプレート `sam.json` について、`augment.py` での処理前後を比較してみます（`my_function` に関する部分だけ）。

```json:更新前のsam.json
    "MyFunction": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "Runtime": "python3.7",
        "Handler": "app.my_function",
        "CodeUri": "./deployment.zip",
        "Tags": {
          "aws-chalice": "version=1.26.2:stage=dev:app=helloworld"
        },
        "Tracing": "PassThrough",
        "Timeout": 60,
        "MemorySize": 128,
        "Role": {
          "Fn::GetAtt": [
            "DefaultRole",
            "Arn"
          ]
        }
      }
    }
```

```json:更新後のsam.json
    "MyFunction": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "Runtime": "python3.7",
        "Handler": "app.my_function",
        "CodeUri": "./deployment.zip",
        "Tags": {
          "aws-chalice": "version=1.26.2:stage=dev:app=helloworld"
        },
        "Tracing": "PassThrough",
        "Timeout": 60,
        "MemorySize": 128,
        "Role": {
          "Fn::GetAtt": [
            "DefaultRole",
            "Arn"
          ]
        },
        "FunctionName": "helloworld-dev-my_function"
      }
    }
```

テンプレート内の（かつ Lambda 関数リソース内の）情報だけで `FunctionName` をうまく設定できる、ということが分かると思います。

# 使い方
[はじめに](#はじめに) で提示したプロジェクトを例にすると、デプロイするためのコマンドは以下となります[^deploy]。`chalice package` の直後に `augment.py` を実行しています。

```bash:helloworldをデプロイ
chalice package out/
python tool/augment.py out/sam.json
aws cloudformation package \
	--template-file out/sam.json \
	--s3-bucket 'パッケージを出力するバケット名' \
	--output-template-file out/packaged.yaml
aws cloudformation deploy \
	--stack-name helloworld-dev \
	--template-file out/packaged.yaml \
	--capabilities CAPABILITY_NAMED_IAM
```

デプロイの結果、スタックに `{app_name}-{stage}-{func_name}` の名前が付いた Lambda 関数が確かに作成されました。
![chalice3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/346f4299-cb62-b3fd-e241-359801b30db1.png)
ちなみに、既存の CloudFormation テンプレート（この例では `resources.yaml` としています）と統合してデプロイする場合には、コマンドは以下のようになります。

```bash:helloworldを既存テンプレートと統合してデプロイ
chalice package --merge-template resources.yaml out/
python tool/augment.py out/sam.yaml
aws cloudformation package \
	--template-file out/sam.yaml \
	--s3-bucket 'パッケージを出力するバケット名' \
	--output-template-file out/packaged.yaml
aws cloudformation deploy \
	--stack-name helloworld-dev \
	--template-file out/packaged.yaml \
	--capabilities CAPABILITY_NAMED_IAM
```

# おわりに
リソースのバッティングが起きないよう気をつける必要はありますが、Chalice で作ったプロジェクトも CloudFormation でスマートに構成管理しながら、`chalice deploy` での命名規則と同じ推測可能な Lambda 関数名を設定できるようになりました。直接 `invoke` しやすくて便利です。


[^chalice]: [Documentation — AWS Chalice](https://aws.github.io/chalice/)
[^chalicecfn]: [AWS CloudFormation Support — AWS Chalice](https://aws.github.io/chalice/topics/cfn.html)
[^purelambda]: [Pure Lambda Functions — AWS Chalice](https://aws.github.io/chalice/topics/purelambda.html)
[^issue]: `'The general caveat of explicitly naming CloudFormation resources is that you lose the ability to perform updates on the stack that require replacement of the resource.'` [kadrach commented on 17 Aug 2018](https://github.com/aws/chalice/issues/935#issuecomment-413709943)
[^risk]: Chalice プロジェクト外に重複する Lambda 関数名が存在していたとき等に問題が発生し得ます。ご注意ください。
[^sample]: `For example something like this:` [stealthycoin commented on 26 Sep 2018](https://github.com/aws/chalice/issues/935#issuecomment-424474586)
[^troposphere]: [cloudtools/troposphere: troposphere - Python library to create AWS CloudFormation descriptions](https://github.com/cloudtools/troposphere)
[^deploy]: Makefile や CodeBuild に設定して使ってます。