AWS SAMとCircleCIでアプリのバックエンドをつくった話

# AWS SAMってなーに?
AWS サーバーレスアプリケーションモデル (SAM、Serverless Application Model) とは、
> サーバーレスアプリケーション構築用のオープンソースフレームワークです。迅速に記述可能な構文で関数、API、データベース、イベントソースマッピングを表現できます。リソースごとにわずか数行で、任意のアプリケーションを定義して YAML を使用してモデリングできます。デプロイ中、SAM が SAM 構文を AWS CloudFormation 構文に変換および拡張することで、サーバーレスアプリケーションの構築を高速化することができます。
引用: [AWS サーバーレスアプリケーションモデル](https://aws.amazon.com/jp/serverless/sam/)

つまり、サーバレスなアプリケーションを割と簡単に構築可能にします。
サーバレスに特化したCloudFormation（というか拡張版CloudFormation）

## やりたいこと
* モバイルアプリのバックエンドをAWSサービスで実現したい！（APIとかDBとか）
    * API Gateway + Lambda + DynamoDB
* できるだけコードベースで実現したい！（Infrastructure as Code）
    * AWS SAM
* 単体テスト自動化とかやってみたい！
    * Jest
* CI/CDとかもやってみたい！
    * CircleCI

## つくったもの
#### :muscle:実際にAWS SAMで構築したアーキテクチャ全体
![aws-Page-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101355/0efc28c4-5894-25ec-4d00-8fd276fb0170.png)

<details><summary>SAM Templete（クリックで展開）</summary><div>

```yaml:templete.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: An AWS Serverless Specification template for KameraYohou.

Globals:
  Function:
    Runtime: nodejs10.x
    Timeout: 3
    MemorySize: 128
    Description: Functions Created by SAM template yaml

Resources:
  # API Gateway
  kameraApi:
    Type: AWS::Serverless::Api
    Properties:
      # Auth: ApiAuth
      Name: KameraYohouAPI
      StageName: develop
      EndpointConfiguration: REGIONAL
      Auth:
        ApiKeyRequired: true

  # Lambda
  getSubjects:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: getSubjects
      Policies:
        - DynamoDBReadPolicy:
            TableName: subject
      Events:
        putSubjectApi:
            Type: Api
            Properties:
                Path: /subjects
                Method: GET
                RestApiId: !Ref kameraApi
  registerSubject:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: registerSubject
      Policies:
        - DynamoDBCrudPolicy:
            TableName: subject
      Events:
        putSubjectApi:
            Type: Api
            Properties:
                Path: /subjects
                Method: POST
                RestApiId: !Ref kameraApi
  disableSubject:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: index.handler
      CodeUri: disableSubject
      Policies:
        - DynamoDBCrudPolicy:
            TableName: subject
      Events:
        putSubjectApi:
            Type: Api
            Properties:
                Path: /subjects/{subject_id}
                Method: DELETE
                RestApiId: !Ref kameraApi
  getSpots:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: getSpots
      Policies:
        - DynamoDBReadPolicy:
            TableName: spot
      Events:
        getSpotApi:
            Type: Api
            Properties:
                Path: /spots
                Method: GET
                RestApiId: !Ref kameraApi

  # DynamoDB
  spot:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: spot
      PrimaryKey:
        Name: spot_id
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5
  subject:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: subject
      PrimaryKey:
        Name: subject_id
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5

  # API Key
  FlutterApiKey:
    Type: AWS::ApiGateway::ApiKey
    DependsOn: kameraApi
    Properties:
      Name: flutter-api-key
      Enabled: true
      StageKeys:
        - RestApiId: !Ref kameraApi
          StageName: !Ref kameraApidevelopStage
  FlutterApiUsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn: kameraApi
    Properties:
      ApiStages:
      - ApiId: !Ref kameraApi
        Stage: !Ref kameraApidevelopStage
      Throttle:
        BurstLimit: 20
        RateLimit: 10
      UsagePlanName: flutter-api-usage-plan
  FlutterApiUsagePlanKey:
    Type: AWS::ApiGateway::UsagePlanKey
    DependsOn:
      - FlutterApiKey
      - FlutterApiUsagePlan
    Properties :
      KeyId: !Ref FlutterApiKey
      KeyType: API_KEY
      UsagePlanId: !Ref FlutterApiUsagePlan

Outputs:
  ApiGatewayID:
    Description: The API Gateway ID
    Value: !Ref kameraApi
  ApiKeyID:
    Description: The API Key ID
    Value: !Ref FlutterApiKey
```
</div></details>



#### :muscle:テストやCI/CDを実行するフロー
![aws-Page-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101355/b649c9f5-169e-b0ea-c447-cc35d4b00808.png)


<details><summary>CircleCI Config（クリックで展開）</summary><div>

```yaml:config.yml
version: 2.1
orbs:
  node: circleci/node@1.1.6

jobs:
  unit-test:
    executor: node/default
    steps:
      - checkout
      - node/with-cache:
          steps:
            - run: npm install
      - run: npm test
      - store_test_results:
          path: coverage

  build-test:
    docker:
      - image: cleartone1216/aws-sam-cli:0.0.2
    steps:
      - checkout
      - run: sam validate
      - run: sam build
      - run: sam deploy

  post-output:
    docker:
      - image: cleartone1216/aws-sam-cli:0.0.2
    steps:
      - checkout
      - run: chmod +x ./.circleci/getResult.sh
      - run: ./.circleci/getResult.sh

workflows:
  version: 2
  pull-request:
    jobs:
      - unit-test
      - build-test:
          requires:
            - unit-test
      - post-output:
          requires:
            - build-test
```
</div></details>



## むずかしかったこと
#### :confounded:CircleCI上でAWS SAM CLIを実行する
CircleCI上でどうしても[aws-sam-cli](https://github.com/awslabs/aws-sam-cli)が使いたかったので、実行可能なCircleCI Orbを利用してみる。
https://circleci.com/orbs/registry/orb/circleci/aws-serverless

すると、テスト実行ごとにいろんなツールがインストールされて時間がめちゃくちゃかかる :clock1:
（自分の環境では、全体で2分。そのうちツール導入で1分半）

ほかに手法がないか調べたが、疲れちゃったので自分でコンテナを作ることに :whale2:

```dockerfile:Dockerfile
FROM python:3.8.1

RUN pip install awscli aws-sam-cli
RUN apt update && apt install -y nodejs npm jq && apt clean -y
LABEL com.circleci.preserve-entrypoint=true
```

ビルドした内容をDocker Hubにあげて、CircleCIから参照することで解決 :white_check_mark:
無いなら作る、エンジニアの基本かも。

#### :confounded:SAM実行ユーザのIAM Policy
[導入ガイド](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install-windows.html)では「管理者ユーザを用意します」とあるが、[最小権限の原則](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/best-practices.html#grant-least-privilege)に反するため必要最低限のユーザを作成。
情報収集不足のせいか、どのポリシーが必要なのかが全く分からず。。。
仕方がないので、実行➡権限エラー:collision:➡権限付与➡実行➡権限エラー:collision:...を繰り返してみた。

結果以下のようなポリシーを作成してIAMユーザに付与。
ざっくりまとめると、

* S3 (Get,Put)
* Lambda (CRUD,Configuration)
* IAM (RoleのCRUD, PolicyのCRUD, アタッチデタッチ)
* DynamoDB (Create, Delete, Describe)
* CloudFormation (ChangeSet, Template, Stackまわり)
* API Gateway (method)



<details><summary>SAM CLI用IAM Policy（クリックで展開）</summary><div>

```json:AWSSAM-EnableDeploy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "lambda:UpdateFunctionConfiguration",
                "lambda:UpdateFunctionCode",
                "lambda:GetFunctionConfiguration",
                "lambda:GetFunction",
                "lambda:DeleteFunction",
                "lambda:CreateFunction",
                "lambda:AddPermission",
                "iam:PutRolePolicy",
                "iam:PassRole",
                "iam:ListPolicies",
                "iam:GetRolePolicy",
                "iam:GetRole",
                "iam:DetachRolePolicy",
                "iam:DeleteRolePolicy",
                "iam:DeleteRole",
                "iam:CreateRole",
                "iam:AttachRolePolicy",
                "dynamodb:DescribeTable",
                "dynamodb:DeleteTable",
                "dynamodb:CreateTable",
                "cloudformation:ValidateTemplate",
                "cloudformation:GetTemplateSummary",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:DescribeStacks",
                "cloudformation:DescribeStackEvents",
                "cloudformation:DescribeChangeSet",
                "cloudformation:CreateChangeSet",
                "apigateway:PUT",
                "apigateway:POST",
                "apigateway:PATCH",
                "apigateway:GET",
                "apigateway:DELETE"
            ],
            "Resource": "*"
        }
    ]
}
```
</div></details>




#### :confounded:APIエンドポイントとAPI Keyの共有
エンドポイントのURLやAPI Keyが変更されるたびに、開発チーム内で共有するのが大変だったのでいい方法ないかと思案 :thinking:	
そこで、CircleCI上でデプロイ成功したタイミングで、Slackに通知するようにした。
（セキュリティ的な観点は一旦考慮しない）

流れとしては、

* AWS SAMのtemplete.yamlにOutputsとして、「通知したい内容」を記載
* aws-cliの`aws cloudformation describe-stacks`コマンドから「通知したい内容」を取得
* Slack Incoming Webhookを叩く

問題点としては、template.yaml内でAPI GatewayのAPI Keyを取得する仕様がないこと :construction:	
そこで、「通知したい内容」にAPI KeyのIDを含めてから、
aws-cliの`aws apigateway get-api-key`でKeyの中身を取得することにした。

これら一連の流れをシェルスクリプトにまとめて、CircleCI内で実行。

```bash:get-api-info.sh
#!/bin/bash
ApiKeyID=`aws cloudformation describe-stacks --stack-name KameraLambda | jq -r '.Stacks[].Outputs | map(select(.OutputKey == "ApiKeyID")) | .[].OutputValue'`
ApiGatewayID=`aws cloudformation describe-stacks --stack-name KameraLambda | jq -r '.Stacks[].Outputs | map(select(.OutputKey == "ApiGatewayID")) | .[].OutputValue'`
ApiKey=`aws apigateway get-api-key --api-key $ApiKeyID --include-value | jq -r .value`

curl -X POST --data-urlencode "payload={\"text\": \"API_BASE_URL=https://${ApiGatewayID}.execute-api.ap-northeast-1.amazonaws.com/develop
\n API_KEY=${ApiKey}\"}" $Slack_URL
```
※$Slack_URLはCircleCI上の環境変数で定義

## これからやること
#### :running: CircleCI上でAWS LambdaのInvokeテスト

現状だと、テストはJestのみのUnitテストなので、aws-sam-cliの`sam local invoke`でLambdaの機能テストも実施したい。
`sam local`の実行にはDocker環境が必要なので、CircleCI上でDockerコンテナが動くようにする、ということが課題になりそう。

## 参考文献
* [AWS SAM Developper Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html)
* [SAM Sample Application](https://github.com/aws-samples/aws-lambda-sample-applications)