CircleCI と serverless を使いブランチごとに Lambda/API Gateway 環境を構築する

ブランチごとに確認環境がほしかったので、作っていく。

前提として、

- [serverless-express](https://github.com/awslabs/aws-serverless-express) を使ったWebアプリ。
- ドメインについては Route53 により管理されている。

という状態で説明する。

## プラグインの利用

プラグインを2つ使う

- serverless-domain-manager: 環境ごとに独自のドメインを割り当てることができる。
- serverless-kms-secrets: OAuth の secret など、機密情報を暗号化するためにKMSの利用をする。このプラグインはKMSの暗号化を便利に扱うことができる。機密情報がないのであれば、不要。

```
yarn add -D serverless-domain-manager serverless-kms-secrets
```

## serverless.yml の調整

- ブランチごとに、S3のバケットがあると鬱陶しいので、`deploymentBucket` を指定する。
- 平文の利用が可能な環境変数については、 `config/sls-config.yml` に設定を外出する。このファイルは環境ごとに、用意しておきS3にアップロードし、deploy時にダウンロードして使うようにする。(後述)
- 暗号化済みの環境変数は `config/secure.yml` に格納する。扱いについては、`config/sls-config.yml` と同様。

```yaml:serverless.yml
service:
  name: ${self:custom.name}

provider:
  name: aws
  region: ap-northeast-1
  runtime: nodejs6.10
  stage: ${opt:stage, 'dev'}
  deploymentBucket:
    name: ${self:custom.name}.${self:provider.region}.deploy
  environment:
    XXX: ${self:custom.config.XXX, ''}
    YYY: ${self:custom.config.YYY, ''}
    SECURE_ZZZ: ${self:custom.kmsSecrets.secrets.ZZZ}
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - "KMS:Decrypt"
      Resource: ${self:custom.kmsSecrets.keyArn}

custom:
  name: project-name
  serverless-kms-secrets:
    secretsFile: config/secure.yml
  customDomain:
    domainName: ${self:provider.stage}.sls.example.com
    basePath: ''
    stage: ${self:provider.stage}
    certificateName: '*.sls.example.com'
    createRoute53Record: true
  config: ${file(./config/sls-config.yml)}
  kmsSecrets: ${file(./config/secure.yml)}

plugins:
  - serverless-domain-manager
  - serverless-kms-secrets

functions:
  render:
    handler: server.render
    events:
      - http:
          path: '/'
          method: 'get'
          private: false
      - http:
          path: '{proxy+}'
          method: 'get'
          private: false
```

## 一部環境変数の暗号化

Lambda の環境変数は、機密なものであればKMSを利用して暗号化することを推奨している。そこで、serverless-kms-secrets を利用して暗号化を行う。

予めKMSで暗号化キーを作成しておく。

`config/secure.yml` を作成し、以下のようなファイルを作る

```yaml:config/secure.yml
keyArn: 'arn:aws:kms:ap-northeast-1:xxxx:key/xxxxxxxxxxxx'
secrets: {}
```

ZZZ という環境変数を暗号化する場合は、

```
sls encrypt --name="ZZZ" --value="value"
```

のようにして暗号化を行うことが出来る。

暗号化された情報は、`config/secure.yml` (`serverless.yml` の `custom.serverless-kms-secrets.secretsFile` で設定されたファイル) に格納される。`serverless.yml` では、暗号化された `config/secure.yml` 内の情報を、環境変数として利用する。

利用時は、以下のように情報を復号化して使う。

```js
import { KMS } from 'aws-sdk'

const kms = new KMS()

const encryptedBuf = Buffer.from(process.env['SECURE_ZZZ'], 'base64')
const cipherText = {CiphertextBlob: encryptedBuf}
kms.decrypt(cipherText).promise().then((data) => {
  // 値の取り出し
  const value = data.Plaintext.toString('ascii')
})
```

## 環境ごとに設定ファイルを準備する

デプロイの戦略を以下とする。

- master branch については、production 用の設定を取り出す
- develop branch については　staging 用の設定を取り出す
- その他の branch については、 preview 用の設定を取り出す

ということで、上記3つの環境ごとに設定ファイル (`sls-config.yml`, `secure.yml`) を用意して、環境名ごとにprefixを分けてS3 Bucketに格納しておく。

## デプロイ用スクリプトの作成

デプロイに使うスクリプトを用意する。

- ステージ名には `-/_` あたりの文字が入ると厄介なので (API Gateway, Lambda, CloudFormation あたりのどれかでいずれかの文字を受け付けない) 除去したものを利用する。 `123-test-branch` とかであれば、 `123testbranch` が stage 名となる。
- パーミッションは755にしておく。

```bash:scripts/deploy.sh
#!/bin/bash

set -eu

export NODE_ENV="preview"

if [ "${CIRCLE_BRANCH}" == "master" ]; then
    export NODE_ENV="production"
elif [ "${CIRCLE_BRANCH}" == "develop" ]; then
    export NODE_ENV="staging"
fi

STAGE=${CIRCLE_BRANCH//[-\/]/}
echo stage=${STAGE}

# get config file from S3
aws s3 cp s3://xxx/${NODE_ENV}/sls-config.yml config/
aws s3 cp s3://xxx/${NODE_ENV}/secure.yml config/

# make Route53 configuration
./node_modules/.bin/sls create_domain --stage=$STAGE
# deploy
./node_modules/.bin/sls deploy --stage=$STAGE
# remove old lambda functions
./scripts/remove_old_stage.sh
```

上のスクリプトで、デプロイを行うと、どんどん関数が増えちゃうので、リモートブランチを消したら、次のデプロイのタイミングで環境も消すスクリプトを作っておく。

```bash:scripts/remove_old_stage.sh
#!/bin/bash

set -eu

STACKS=`aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE CREATE_FAILED UPDATE_COMPLETE ROLLBACK_COMPLETE ROLLBACK_FAILED UPDATE_ROLLBACK_FAILED --max-items 1000 | jq '.StackSummaries[].StackName' -r | grep '^project-name-'`
BRANCHES=`git branch -r | sed -e "s/^[[:space:]]*origin\///" | grep -v "^HEAD" | uniq`

echo "stacks----"
echo "${STACKS}"
echo "branches----"
echo "${BRANCHES}"

while read line
do
    found=0

    lineStage=`echo $line | sed -e 's/^project-name-//'`

    while read branch
    do
        stage=${branch//[-\/]/}

        if [[ "$lineStage" = "$stage" ]]; then
            found=1
            break;
        fi
    done <<END
$BRANCHES
END

    if [ $found -eq 0 ]; then
      ./node_modules/.bin/sls remove --stage=$lineStage || true
      ./node_modules/.bin/sls delete_domain --stage=$lineStage || true
    fi

done <<END
$STACKS
END
```

## .circleci/config.yml の調整

### docker image を用意する

以下が動作する docker image を作っておく

- nodejs6
- python
- pip
- jq
- awscli

### 設定

```yaml:.circleci/config.yml
version: 2
jobs:
  build:
    docker:
      - image: my_docker_image
    parallelism: 1
    working_directory: ~/project-name
    steps:
      - checkout

      - restore_cache:
          key: project-name-yarn-{{ checksum "yarn.lock" }}

      - run:
          name: Install node modules
          command: yarn install

      - run:
          name: lint
          command: yarn run lint

      - run:
          name: unit
          command: yarn run unit

      - save_cache:
          key: project-name-yarn-{{ checksum "yarn.lock" }}
          paths:
            - ~/.yarn-cache

      - run:
          name: deploy
          command: scripts/deploy.sh
```

## 完了

これで、各ブランチごとに `stage名.sls.example.com` が展開される。
serverless-domain-manager による環境の展開については、Route53、CloudFront の設定に時間がかかるため、初回展開時には40分ほどリクエストできるようになるまで時間がかかるとのこと。