Travis CI + AWS CodeDeploy + Slack

# Travis CI + AWS CodeDeploy + Slack

この記事は [mediba Advent Calendar 2017](https://qiita.com/advent-calendar/2017/mediba) の10日目です。

@mediba-moritake です。[au Webポータル](https://auone.jp/)や[UQライフ](https://portal.uqmobile.jp/)のサーバーサイド開発をしています。
今回は[UQライフ](https://portal.uqmobile.jp/)の一部で使用している[Travis CI](https://travis-ci.com/)、 [AWS CodeDeploy](https://aws.amazon.com/jp/codedeploy/)、 [Slack](https://slack.com/intl/ja-jp) を使用した、PHP アプリケーション(Amazon Linux, Nginx, PHP-FPM, PHP5.6)のデプロイを紹介したいと思います。

# AWS CodeDeploy とは

- EC2 インスタンスに対するアプリケーションのデプロイを自動化するデプロイサービスです。

# 概要

- 今回は下記 GitHub ブランチへのマージで、各環境へのデプロイを行います。
    - *deployment/production*
    - *deployment/staging*
    - *deployment/develop*

![traisci_codedeploy_slack.png](https://qiita-image-store.s3.amazonaws.com/0/140713/a9dc5d40-de05-e3a5-8f04-3350e5fb3c63.png)

# AWS CodeDeploy

## AWS管理コンソール

### IAM

- AWS CodeDeploy を使用する IAM ユーザーを作成する。

```json
{
  "Version": "2012-10-17",
  "Statement" : [
    {
      "Effect" : "Allow",
      "Action" : [
        "autoscaling:*",
        "codedeploy:*",
        "ec2:*",
        "elasticloadbalancing:*",
        "iam:AddRoleToInstanceProfile",
        "iam:CreateInstanceProfile",
        "iam:CreateRole",
        "iam:DeleteInstanceProfile",
        "iam:DeleteRole",
        "iam:DeleteRolePolicy",
        "iam:GetInstanceProfile",
        "iam:GetRole",
        "iam:GetRolePolicy",
        "iam:ListInstanceProfilesForRole",
        "iam:ListRolePolicies",
        "iam:ListRoles",
        "iam:PassRole",
        "iam:PutRolePolicy",
        "iam:RemoveRoleFromInstanceProfile", 
        "s3:*"
      ],
      "Resource" : "*"
    }    
  ]
}
```
### CodeDeploy

- AWS CodeDeploy アプリケーションを作成する。
    - アプリケーション名に **環境** を定義する。
        - *uqlife-production*
        - *uqlife-staging*
        - *uqlife-development*
- AWS CodeDeploy デプロイグループ名を作成する。
    - デプロイグループ名に **サーバーロール** を定義する。
        - *uqlife-api-deployment-group*
        - *uqlife-web-deployment-group*
- デプロイタイプ
    - `インプレースデプロイ` を選択する。デプロイタイプの詳細は [こちら](http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/welcome.html#welcome-deployment-overview)
- 環境設定
    - `Amazon EC2 インスタンス` のタググループを指定する。
    - タググループに対応したインスタンスを抽出するような **キー**、**値** を選択する。
        - 事前にEC2インスタンスにタグを追加しておく必要があります。
- デプロイ設定
    - `CodeDeployDefault.OneAtATime` を選択する。デプロイ設定の詳細は [こちら](http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/deployment-configurations.html#deployment-configurations-predefined)

## CodeDeploy Agent インストール/設定

- AWS CodeDeploy を使用できるようになるソフトウェアパッケージ。AWS CodeDeploy でデプロイしたいインスタンスにインストール/設定する。

```bash
$ wget https://aws-codedeploy-ap-northeast-1.s3.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto
```

- インストール直後の実行ユーザーは **root** のため、**deploy** ユーザーに変更する。
 
```bash
$ sudo service codedeploy-agent stop
$ sed -i 's/""/"deploy"/g' /etc/init.d/codedeploy-agent
$ sudo chown deploy:deploy -R /opt/codedeploy-agent/
$ sudo chown deploy:deploy -R /var/log/aws/
$ sudo service codedeploy-agent start
```

- 設定ファイル
    - デフォルトの設定から変更する必要はなさそうです。設定ファイルの詳細は [こちら](http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-agent-configuration.html)

```yaml:/etc/codedeploy-agent/conf/codedeployagent.yml
:log_aws_wire: false
:log_dir: '/var/log/aws/codedeploy-agent/'
:pid_dir: '/opt/codedeploy-agent/state/.pid/'
:program_name: codedeploy-agent
:root_dir: '/opt/codedeploy-agent/deployment-root'
:verbose: false
:wait_between_runs: 1
:proxy_uri:
:max_revisions: 5
```

## Travis CI

- `.travis.yml` を編集します。Travis CI の CodeDeploy 設定は [こちら](https://docs.travis-ci.com/user/deployment/codedeploy/)
- **Production** 環境のみ記述していますが、下記項目が環境毎に異なるように **Staging**/**Develop** 環境分も記述します。
    - *access_key_id*
    - *secret_access_key*
    - *application*
    - *branch*

```yaml:.travis.yml
deploy:
   - provider: codedeploy
    access_key_id: $prd_codedeploy_access_key_id
    secret_access_key: $prd_codedeploy_secret_access_key
    application: uqlife-production
    deployment_group: uqlife-api-deployment-group
    region: ap-northeast-1
    on:
      repo: mediba/uqlife
      branch: deployment/production
  - provider: codedeploy
    access_key_id: $prd_codedeploy_access_key_id
    secret_access_key: $prd_codedeploy_secret_access_key
    application: uqlife-production
    deployment_group: uqlife-web-deployment-group
    region: ap-northeast-1
    on:
      repo: mediba/uqlife
      branch: deployment/production
```

## アプリケーション仕様ファイル(AppSpec ファイル)

- `appspec.yml` ファイルを作成します。アプリケーション仕様ファイル(AppSpec ファイル)の詳細は[こちら](http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-appspec-file.html)

```yaml:appspec.yml
version: 0.0
os: linux
files:
  - source: /
    destination: /var/www/uqlife-codedeploy/base
  - source: /codedeploy
    destination: /usr/local/src/codedeploy
permissions:
  - object: /var/www/uqlife-codedeploy
    owner: deploy
    group: deploy
    mode: 755
  - object: /usr/local/src/codedeploy
    owner: deploy
    group: deploy
    mode: 755
hooks:
  AfterInstall:
    - location: codedeploy/deploy.sh
      timeout: 600
```

- `appspec.yml` 処理概要
    - `/var/www/uqlife-codedeploy/base` ディレクトリに GitHub リポジトリの全ソースを配置する。
    - `/usr/local/src/codedeploy` ディレクトリにデプロイスクリプトを配置する。
    - `AfterInstall` ライフサイクルイベントにて、`deploy.sh` を実行する。CodeDeploy Agent の実行ユーザー(今回の場合だと deploy ユーザー)で実行されます。

## デプロイスクリプト

- `deploy.sh` を実装します。
- アクセス出来る環境変数は [こちら](http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html#reference-appspec-file-structure-environment-variable-availability)

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

NOW=$(date +%Y%m%d%H%M%S)
mkdir -p /var/www/uqlife-codedeploy/releases/${NOW}
cp -r /var/www/uqlife-codedeploy/base/* /var/www/uqlife-codedeploy/releases/${NOW}

# Composer インストール
export HOME=/home/deploy
export COMPOSER_HOME=/var/www/uqlife-codedeploy/releases/${NOW}/src/
php /var/www/uqlife-codedeploy/releases/${NOW}/src/composer.phar install --no-dev

# 権限設定
sudo chown -Rf nginx:nginx /var/www/uqlife-codedeploy/releases/${NOW}/src/*
sudo chmod -R 775 /var/www/uqlife-codedeploy/releases/${NOW}/src/*

# OPcache リセット
sh -c "echo '<?php opcache_reset(); ?>' > /var/www/html/opcache_reset.php"
curl -s http://localhost/opcache_reset.php
rm -f /var/www/html/opcache_reset.php

# APIシンボリックリンク設定
if [ "$DEPLOYMENT_GROUP_NAME" == "uqlife-api-deployment-group" ]; then
  ln -fns /var/www/uqlife-codedeploy/releases/${NOW}/src/api /var/www/uqlife/api
fi

# Webシンボリックリンク設定
if [ "$DEPLOYMENT_GROUP_NAME" == "uqlife-web-deployment-group" ]; then
  ln -fns /var/www/uqlife-codedeploy/releases/${NOW}/src/web /var/www/uqlife/web
fi

# 古い releases 削除
RELEASES=(`ls -x /var/www/uqlife-codedeploy/releases`)
if [ ${#RELEASES[@]} -gt 5 ]; then
  OLD_RELEASES=(`ls /var/www/uqlife-codedeploy/releases | sort -n | head -n $((${#RELEASES[@]} - 5))`)
  for i in ${OLD_RELEASES[@]}; do
    rm -rf /var/www/uqlife-codedeploy/releases/${i}
  done
fi

exit 0
```

# Slack通知

## 再びAWS 管理コンソール

### SNS

- SNS の新しいトピックを作成する。

### CodeDeploy

- CodeDeploy のデプロイグループでトリガーを作成する。
    - イベント
        - Deployment Status(all)
    - Amazon SNS トピック
        - 先ほど作成したSNS トピックを選択する。
### IAM

- Lambda 実行用 IAM ロールを作成する。
    - `AWSLambdaBasicExecutionRole` ポリシーを付与する。

### Lambda

- Lambda 関数の作成を行う。
    - 設計図(Blueprints)から `cloudwatch-alarm-to-slack-python3` を選択し、適宜修正する。
        - CloudWatch アラームをSlack通知する実装となっているため、`message` 変数をCodeDeploy 用に修正します。
        - ロールは、先ほど作成した IAM ロールを選択する。
        - SNS トピックは、先ほど作成した SNS トピックを選択する。
- 環境変数 `slackChannel`、`kmsEncryptedHookUrl` を適宜設定する。

```py3:codedeploy-to-slack.py
import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError


# The base-64 encoded, encrypted key (CiphertextBlob) stored in the kmsEncryptedHookUrl environment variable
ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
# The Slack channel to send a message to stored in the slackChannel environment variable
SLACK_CHANNEL = os.environ['slackChannel']

HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info("Event: " + str(event))
    message = json.loads(event['Records'][0]['Sns']['Message'])
    logger.info("Message: " + str(message))

    region = message['region']
    account_id = message['accountId']
    event_trigger_name = message['eventTriggerName']
    application_name = message['applicationName']
    deployment_id = message['deploymentId']
    deployment_group_name = message['deploymentGroupName']
    create_time = message['createTime']
    complete_time = message['completeTime']
    status = message['status']

    if status == 'FAILED':
        slackColor = 'danger'
    else:
        slackColor = 'good'

    slack_message = {
        'channel': SLACK_CHANNEL,
        'attachments': [
            {
                'fallback': 'Required plain-text summary of the attachment.',
                'color': slackColor,
                'title': 'Execute AWS CodeDeploy',
                'fields': [
                    {
                        'title': 'status',
                        'value': status
                    },
                    {
                        'title': 'applicationName',
                        'value': application_name
                    },
                    {
                        'title': 'deploymentGroupName',
                        'value': deployment_group_name
                    },
                    {
                        'title': 'deploymentId',
                        'value': deployment_id
                    },
                    {
                        'title': 'createTime',
                        'value': create_time
                    }
                ]
            }
        ]
    }

    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)
```

- 同時に カスタマーマスターキー(CMK)を作成し、先ほど作成したIAM ロールに下記ポリシーをアタッチする。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1443036478000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<your KMS key ARN>"
            ]
        }
    ]
}
```

# まとめ

- GitHub の Pull Request マージでデプロイを実現することが出来ます。
- アプリケーション名に **環境** を定義することで、各環境に対応したデプロイを実現することが出来ます。
- デプロイグループ名に **サーバーロール** を定義することで、サーバーロールに対応したデプロイを実現することが出来ます。
- CodeDeploy Agent の実行ユーザーを変更したが、root でも問題ないかもしれない。
- 既存のデプロイシステムが存在し、運用上の問題がないのであれば、AWS CodeDeploy に乗り換えるメリットはないかもしれない。

# 今後の CodeDeploy

- [AWS CodeDeploy がローカルのテストとデバッグをサポート](https://aws.amazon.com/jp/about-aws/whats-new/2017/11/aws-codedeploy-supports-local-testing-and-debugging/)
- [AWS Lambda でトラフィックの移行と AWS CodeDeploy を使用した段階的デプロイをサポート](https://aws.amazon.com/jp/about-aws/whats-new/2017/11/aws-lambda-supports-traffic-shifting-and-phased-deployments-with-aws-codedeploy/)

# ご参考

- http://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/welcome.html