AWSのAppSyncをブランチごとに独立して自動デプロイする

AppSyncをチーム開発に向けて自動デプロイを行いました。
ブランチごとに独立したデプロイになるとPRの際に動作確認を行いやすいです。特にServerlessフレームワークでのAppSyncはDynamoDBなどもすべてセットでデプロイされるため、データ構造、データを含めて独立して完結したデプロイとなります。便利！


ワークフローサイクルとして

1. サーバサイドのコードに変更を入れる
2. ブランチにコミットプッシュする
3. PR作成
4. 他のメンバーがPRでのデプロイに対して動作レビューとコードレビュー

ということを行えるようになります。

# Serverlessの準備
Serverlessの準備を行います。serverlessパッケージと`serverless-appsync-plugin`をインストールしておきます。

```bash
$ npm init -y
$ npm install --only=dev serverless serverless-appsync-plugin
```

`serverless.yml`を作成します。（コードのほとんどを [@reimen](https://github.com/reimen)さんからもらいました :bow: ）

```yml:serverless.yml
service: my-first-app # NOTE: update this with your service name

plugins:
  - serverless-appsync-plugin

provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, self:custom.defaultStage}
  region: ap-northeast-1
  timeout: 30
  stackTags:
    Project: ${self:service}
  deploymentBucket:
    name: serverless-deployment
  environment:
    TZ: Asia/Tokyo
    SERVICE: ${self:service}
    STAGE: ${self:provider.stage}

custom:
  defaultStage: develop
  accountId: xxxxxxxxxxxxxx
  appSync:
    name: ${self:service}-${self:provider.stage}
    authenticationType: API_KEY
    mappingTemplatesLocation: mapping-templates
    mappingTemplates:
      - dataSource: Lambda
        type: Query
        field: users
        request: "users-request-mapping-template.vtl"
        response: "users-response-mapping-template.vtl"
    schema: schema.graphql
    dataSources:
      - type: AMAZON_DYNAMODB
        name: Main
        description: 'Main Table'
        config:
          tableName: '${self:service}-${self:provider.stage}-main'
          serviceRoleArn: { Fn::GetAtt: [AppSyncDynamoDBServiceRole, Arn] }
      - type: AWS_LAMBDA
        name: Lambda
        description: 'Lambda Function'
        config:
          functionName: graphql
          lambdaFunctionArn: { Fn::GetAtt: [GraphqlLambdaFunction, Arn] }
          serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }

functions:
  graphql:
    handler: handler.graphqlHandler

resources:
  Resources:
    MainTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        TableName: '${self:service}-${self:provider.stage}-main'
        AttributeDefinitions:
          - AttributeName: pk
            AttributeType: S
          - AttributeName: sk
            AttributeType: S
        KeySchema:
          - AttributeName: pk
            KeyType: HASH
          - AttributeName: sk
            KeyType: RANGE
        BillingMode: PAY_PER_REQUEST
    AppSyncDynamoDBServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: "Dynamo-${self:service}-${self:provider.stage}"
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            -
              Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          -
            PolicyName: "Dynamo-${self:service}-${self:provider.stage}-Policy"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                -
                  Effect: "Allow"
                  Action:
                    - "dynamodb:Query"
                    - "dynamodb:BatchWriteItem"
                    - "dynamodb:GetItem"
                    - "dynamodb:DeleteItem"
                    - "dynamodb:PutItem"
                    - "dynamodb:Scan"
                    - "dynamodb:UpdateItem"
                  Resource:
                    - "arn:aws:dynamodb:ap-northeast-1:${self:custom.accountId}:table/${self:service}-${self:provider.stage}-*"
    AppSyncLambdaServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: "Lambda-${self:service}-${self:provider.stage}"
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            -
              Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          -
            PolicyName: "Lambda-${self:service}-${self:provider.stage}-Policy"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                -
                  Effect: "Allow"
                  Action:
                    - "lambda:invokeFunction"
                  Resource:
                    - "arn:aws:lambda:ap-northeast-1:${self:custom.accountId}:function:${self:service}-${self:provider.stage}-*"
```

# S3バケットの作成

サーバレスを置くためのバケットを作成しておきます。

```
$ aws s3api create-bucket --bucket serverless-deployment-yousan --create-bucket-configuration LocationConstraint=ap-northeast-1
{
    "Location": "http://serverless-deployment-yousan.s3.amazonaws.com/"
}
```

参考: https://docs.aws.amazon.com/cli/latest/reference/s3api/create-bucket.html


# ローカルでのデプロイ
ローカルでデプロイのテストを行います。

```
$ $(npm bin)/sls deploy

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service appsync-service.zip file to S3 (44.2 KB)...
Serverless: Validating template...
Serverless: Creating Stack...
...
```


![image.png](https://qiita-image-store.s3.amazonaws.com/0/47327/8b9a23ef-9479-a855-4946-9242fa2b535f.png)

デプロイ完了しました。（上記に出ているスタックは削除済みです）

GraphQLに接続して確認できるか試しておきます。

接続に必要な`api keys`と`appsync endpoints`は控えておきます。


`api key`は`X-Api-Key`として設定しておきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47327/e7f78ff6-54b7-78c2-0fa2-b9da5bb90ab9.png)


![image.png](https://qiita-image-store.s3.amazonaws.com/0/47327/f5aaa8cb-f95b-7f6c-a32e-5a7e90c78829.png)

上記は[Altair GraphQL Clinent](https://altair.sirmuel.design/)です。

# ブランチごとに独立したデプロイにする
ブランチごとに独立したServerlessのデプロイとなるようにします。
Serverlessの場合にはステージという概念があるので、ブランチごとに個別のステージを割り当てるようにします。

Gitのワークフローは[GitFlow](https://datasift.github.io/gitflow/IntroducingGitFlow.html)＋プルリクエストでマージ方式を採用しています。そのためよく使うブランチは下記の三種類です。

- master (とreleaseブランチ） => 本番環境用
- develop => 開発用
- feature/xxx => 機能開発用。それぞれのPRが送られる単位。

このうち`feature`ブランチを独立してデプロイさせます。
AWSのServerlessフレームワークで使われるCloudFormationでは、名前に64文字までという制限があります。またブランチ名にスラッシュがあるとそのままではステージ名に使えません。
そこで現在のブランチ名からハッシュ値に変換するようにしました。

```bash:branch.sh
# 現在のブランチ名を取得する
BRANCH=`git rev-parse --abbrev-ref HEAD`

if type "md5" > /dev/null 2>&1; then
  BRANCH_HASH=$(echo ${BRANCH} | md5 | cut -c 1-6) # ブランチ名からのハッシュ値を取得。スタック名に使用する。
else
  # CircleCIのubuntuはmd5sumコマンドを使う（本当はelseifしたい)
  BRANCH_HASH=$(echo ${BRANCH} | md5sum | cut -c 1-6) # ブランチ名からのハッシュ値を取得。スタック名に使用する。
fi
```

上記のコードで `${BRANCH_HASH}` を使うと、現在位置のブランチから一意なハッシュが作成されます。このハッシュを元にステージを決定してデプロイを行います。
また後ほどCircleCI(のnode用コンテナ、Ubuntu）で自動化を行う場合、`md5`コマンドが見つからないと言われるためにMac環境とUbuntuで動くように`md5sum`を切り替えしています。

```bash
$ $(npm bin)/sls deploy --region ap-northeast-1 --stage ${BRANCH_HASH} 
```

```bash:deploy_branch.sh
#!/usr/bin/env bash
# ブランチにコミットがあった場合にCFnでデプロイを行います。

set -xe

# ファイルがある場所に移動
cd -- "$(dirname "$BASH_SOURCE")"


# 現在のブランチ名を取得する
BRANCH=`git rev-parse --abbrev-ref HEAD`
# DEPLOY_BASE='/home/'
# DEPLOY_PATH=${DEPLOY_BASE}${BRANCH} # ファイルを設置する場所 e.g. feature/201904/yousan/deploy-scripts

if type "md5" > /dev/null 2>&1; then
  BRANCH_HASH=$(echo ${BRANCH} | md5 | cut -c 1-6) # ブランチ名からのハッシュ値を取得。スタック名に使用する。
else
  # CircleCIのubuntuはmd5sumコマンドを使う（本当はelseifしたい)
  BRANCH_HASH=$(echo ${BRANCH} | md5sum | cut -c 1-6) # ブランチ名からのハッシュ値を取得。スタック名に使用する。
fi


cd ../
# デプロイしつつ、Slackに通知するためにファイルにも出力する
./node_modules/.bin/sls deploy --region ap-northeast-1 --stage ${BRANCH_HASH} | tee bin/output.txt
```

これで手元からデプロイを行います。

```bash
$ ./bin/deploy_branch.sh
```

# CircleCIで自動デプロイセットアップ
GitHubに連携してCircleCIでデプロイされるようにします。

[はじめてのCircleCI2.0 - Qiita](https://qiita.com/sawadashota/items/09b37b303e63f3b897a9 "はじめてのCircleCI2.0 - Qiita")

注意点としてCircleCIの`Node`のデフォルトのイメージでは動きませんでした。
デフォルトの`node 6.10`ではバージョンが低いため、新しいイメージを指定する必要があります。
今回は`9.11.2`を使っています。
[公式イメージのバージョンを確認することができます。](https://hub.docker.com/r/circleci/node)

```yaml:config.yml
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2
jobs:
  build:
    docker:
      # specify the version you desire here
      - image: circleci/node:9.11.2

      # Specify service dependencies here if necessary
      # CircleCI maintains a library of pre-built images
      # documented at https://circleci.com/docs/2.0/circleci-images/
      # - image: circleci/mongo:3.4.4

    working_directory: ~/repo

    steps:
      - checkout

      # Download and cache dependencies
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-

      - run: yarn install

      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

      # run tests!
      - run: yarn test

      - run: ./bin/deploy_branch.sh

```

重要なのは最後の方にある`./bin/deploy_branch.sh`です。これでデプロイされるようになります。

# CircleCIで動かすための手順
上記でCircleCIでセットアップを行いましたがうまく動きませんでした。
aws-cliの認証が通らないためです。

<img width="1234" alt="Screen Shot 2019-04-11 at 16.01.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/47327/6b7a1a45-9bf3-4b8f-674d-3e2016800566.png">

IAMユーザーを作成しておきます。
CloudFormationからもIAMユーザーを作成する必要があるため、ポリシーの設定で作成の許可を行います。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "EditSpecificServiceRole",
            "Effect": "Allow",
            "Action": [
                "iam:*"
            ],
            "Resource": "*"
        }
    ]
}
```

ユーザー作成時にアクセスキーができたらCircleCIに保存しておきます。

CicleCIの設定からEnvironmentとして設定します。

<img width="1052" alt="Screen Shot 2019-04-11 at 16.16.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/47327/f30c5a96-8070-4e1e-1771-1e3cf67bcf66.png">

以前はAWS専用の環境変数が用意されていたのですが、2019年4月現在は共通の環境変数として登録すれば良くなったようです。

CircleCI 1.0 の AWS Permissions で設定した AWS Credentials をローテーションする
https://blog.manabusakai.com/2019/04/change-aws-credentials-on-circleci/

# Slackへの通知
CircleCIで自動デプロイされるので、エンドポイントやキーを割り当てる必要があります。
CircleCIのデプロイ結果を確認すればよいのですがそれだと面倒なので、必要な情報をSlackで通知するようにします。

`deploy_branch.sh`の結果が扱いにくいため（値を見やすくするため、改行で区切られている。）、`tee`でファイル(output.txt)に書き出し、別のスクリプトで読み込ませます。


```bash:deploy_branch.sh
...(前略)...

./node_modules/.bin/sls deploy --region ap-northeast-1 --stage ${BRANCH_HASH} | tee bin/output.txt

./slack.sh
```

その結果を読み込んでSlackで通知させます。

```bash:slack.sh
#!/usr/bin/env bash
# デプロイされたURLをSlackに通知するスクリプト
# 使い方は
# $ ./slack_deployed.sh https://example.com
# のように、通知したいURLを後ろにつけて呼び出す

set -xe
# ファイルがある場所に移動
cd -- "$(dirname "$BASH_SOURCE")"

# 引数を変数に格納
#API_KEYS=${1:-not_set}
#ENDPOINT_URL=${2:-endpoint}
#OUTPUT=${1:-notset}
SLACK_URL=https://hooks.slack.com/services/T0HQ9xxJQKU/xxxx/K32b1xxxxxxxxxxxxxxxxxxxx

# Slackに出力したいServerlessのレスポンスを加工
# Slackで出力するときに複数行だとcurlで送れないので、すべてセミコロンでくっつける
OUTPUT=`cat output.txt | grep -A2 appsync | grep -v "\-\-" | tr '\n' ' : '`


set +xe # デバッグ出力をオフにする
echo -e "\n\n"
printf '🎉\e[34m Success! \e[m🎉\n'
#printf '\e[34m The deployed URL: \e[m '${ENDPOINT_URL}"\n"

# デプロイ結果をSlackに通知
curl -X POST -H 'Content-type: application/json' \
  --data '{"text": "Thank you for your commit!  Your change makes us strong.\n'"${OUTPUT}"'",
           "username": "Deploy bot",
           "channel": "#graphql-notify",
           "icon_emoji": ":tada:",
           }' \
    ${SLACK_URL}
```

以上でGitでコミットプッシュすれば自動でデプロイされます。

# その他まだやりたいこと
1. masterブランチとdevelopブランチ、releaseブランチもそれぞれ作るようにしたい
2. ハッシュじゃなくて記号をハイフンとかでサニタイズと文字数制限いれたものでも良さそう
3. Slack通知をslsコマンドの出力フォーマットを変えて整形したい
2. GraphQLのエンドポイント名をハッシュじゃなくてわかりやすすいものにできないかな
3. ブランチのマージ時に古いデプロイを消したい（CFnは200個までの制限あり）