CircleCI + AWS Lambda function + Slackを使ったChatOps環境構築

## 自己紹介

ZOZOテクノロジーズでPB(プライベートブランド)のインフラを構築しているエンジニアです。
所属としてはSREを担当しています。

## なぜ ChatOps なのか

その前にChatOpsとはどのようなものなのか知らないかたは以下をご参照ください

- ChatOpsの参考事例
  - https://www.slideshare.net/dcubeio/chat-ops-67723490
  - https://speakerdeck.com/sioncojp/folio-mobile-infra-architecture

ChatOpsを導入理由は、デプロイを早くしたい、運用系の仕事を減らしたいからです。
デプロイが早くなれば、テスト期間が短くなり、結果的にサービスの改善が早くなります。
サービスが成長している段階においては、スピード感が重要です。
また、運用系の仕事も減り、開発やその他の作業に回せる時間も増えます。
運用の時間を減らし、開発の時間を増やすのもSREのモチベーションの１つとなります。

おすすめの本：

- Hacking Growth グロースハック完全読本 
  - ショーン・エリス 
  - 固定リンク: http://amzn.asia/d/3LdCecn

- SRE サイトリライアビリティエンジニアリング ―Googleの信頼性を支えるエンジニアリングチーム 
  - 澤田 武男 
  - 固定リンク: http://amzn.asia/d/9gO5uSn


## なぜ、この構成なのか

ZOZOテクノロジーズのPB部では、既にCIrcleCIを導入し自動デプロイまでは完成していました。
「ChatOpsやりたいよね」という話があり、CircleCIのAPIを利用できないかと思った次第です。
ChatOpsの導入というと敷居が高いように感じますが、今回作ったChatOpsはそんなに難しくはありません、みなさんもぜひ導入してみてください。


## どのように構築するのか

では本題に入っていきます。

### 前提

- AWS環境を使える
  - CloudFormation (CFn)
  - Lambda Function (lambda)
  - Key Management Service (KMS)
  - API Gateway
  - AWS CLI
- CircleCIで何かしらのジョブが既に作られている
    - APIでジョブを実行します
- GithubとCircleCIを連携させている
- Slackを使用している

構築方法は基本的に以下を参考にしています。

- https://dev.classmethod.jp/cloud/aws/slack-integration-blueprint-for-aws-lambda/
- https://dev.classmethod.jp/server-side/slash-commands-to-lambda/

### Slack Commandsの作成

1. https://[slack名].slack.com/apps にアクセスして「Slash Commands」を検索
1. Slash Commands を作成する（図１）
1. 「Choose a Command」にSlackチャンネルで呼び出すコマンド名を入力
1. 「Token」をメモにコピーする（図２） 

#### 図１: Slash Commands

<img width="1222" alt="図１" src="https://qiita-image-store.s3.amazonaws.com/0/259386/42bf7a5a-9f2b-07be-ae6e-0109379c32bb.png">

#### 図２: Slash Commands

<img width="1057" alt="図２" src="https://qiita-image-store.s3.amazonaws.com/0/259386/eb685f18-fa14-11dc-6694-7b3e8b16e6bc.png">


## CloudFormation (CFn) で AWS環境を構築

1. ディレクトリとファイルの作成（図３）
1. chatops.py に（図４）の内容をコピー
  1. `＜デプロイできるSlackのチャンネル名＞` はChatOpsを実行するSlackチャンネル名に変更する
     1. 例）general
1. chatops.yaml に（図５）の内容をコピー
1. AWS CLI で CFn を実行（図６）


- 参考URL
   - CircleCI API: https://circleci.com/docs/api/v1-reference/#new-build

#### 図３： Bash

```sh
$ mkdir chatops && cd $_
$ mkdir src && touch src/chatops.py
$ touch chatops.yaml
$ tree
.
├── chatops.yaml
└── src
    └── chatops.py
```

#### 図４: chatops.py

```python

import boto3
import json
import logging
import os
import urllib
import pprint

from base64 import b64decode

ENCRYPTED_EXPECTED_TOKEN = os.environ['kmsEncryptedToken']
CIRCLECI_TOKEN = os.environ['circleciToken']
BASE_URL = os.environ['baseUrl']

kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob=b64decode(
   ENCRYPTED_EXPECTED_TOKEN))['Plaintext'].decode()

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

def circle_trigger_job(BRANCH, JOB):
   circleci_url = BASE_URL + '/tree/' + urllib.parse.quote(BRANCH) + '?circle-token=' + CIRCLECI_TOKEN
   payload = {
       'build_parameters': {
           'CIRCLE_JOB': JOB
       }
   }

   method = "POST"
   headers = {"Content-Type" : "application/json"}
   json_data = json.dumps(payload).encode("utf-8")

   request = urllib.request.Request(circleci_url, data=json_data, method=method, headers=headers)
   with urllib.request.urlopen(request) as response:
       string = response.read().decode('utf-8')
       list = string.split()
       return list[list.index(':build_url') + 1]

def respond(err, res=None):
   return {
       'statusCode': '400' if err else '200',
       'attachments': [
           {
               "text": err.message if err else res
           }
       ],
       'headers': {
           'Content-Type': 'application/json',
       },
       'response_type': 'in_channel'
   }

def lambda_handler(event, context):
   print(event)
   params = urllib.parse.parse_qs(event['body'],encoding='utf-8')
   token = params['token'][0]
   if token != expected_token:
       logger.error("Request token (%s) does not match expected", token)
       return respond(Exception('Invalid request token'))

   user = params['user_name'][0]
   command = params['command'][0]
   channel = params['channel_name'][0]
   text = params['text'][0].split(" ")
   branch = text[0]
   job = text[1]
   response = ''

   if channel == '＜デプロイできるSlackのチャンネル名＞':
       response = 'build_url: ' + circle_trigger_job(branch, job).strip(',"')
   else:
       response = 'use in `＜デプロイできるSlackのチャンネル名＞` slack channel'

   print(response)
   return respond(None, "%s \n user: %s" % (response, user)
```

#### 図５： chatops.yaml

```yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
  kmsEncryptedToken:
    Type: String
    Default: ''
  circleciToken:
    Type: String
    Default: ''
  baseUrl:
    Type: String
    Default: ''
Resources:
  Api: 
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      DefinitionBody:
        swagger: 2.0
        info:
          title: chatops
          description: chatops
          version: 1.0.0
        schemes:
          - https
        basePath: /Prod
        paths:
          /chatops:
            post:
              consumes:
                - application/json
              produces:
                - application/json
              responses:
                "200":
                  description: 200 response
                  schema:
                    $ref: "#/definitions/Empty"
              x-amazon-apigateway-integration:
                responses:
                  default:
                    statusCode: 200
                    responseTemplates:
                      application/json: ""
                uri:
                  Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations
                passthroughBehavior: when_no_templates
                httpMethod: POST
                type: aws
                requestTemplates:
                  application/x-www-form-urlencoded: |
                    {
                        "body": $input.json("$")
                    }
        definitions:
          Empty:
            type: object
            title: Empty Schema

  LambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: chatops.lambda_handler
      Runtime: python3.6
      CodeUri: ./src
      Environment:
        Variables:
          kmsEncryptedToken: !Ref kmsEncryptedToken
          circleciToken: !Ref circleciToken
          baseUrl: !Ref baseUrl
      Policies:
        - !Ref KMSDecriptPolicy
        - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
      Events:
        PostResource:
          Type: Api
          Properties:
            RestApiId: !Ref Api
            Path: /chatops
            Method: POST

  KMSDecriptPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      Description: "Policy for kms decrypt"
      Path: "/"
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - Effect: "Allow"
            Action: "kms:Decrypt"
            Resource: "*"

  KeyId:
    Type: AWS::KMS::Key
    Properties:
      KeyPolicy:
        Version: "2012-10-17"
        Id: key-default-1
        Statement:
          - Sid: Enable IAM User Permissions
            Effect: Allow
            Principal:
              AWS: !Join 
                - ''
                - - 'arn:aws:iam::'
                  - !Ref 'AWS::AccountId'
                  - ':root'
            Action: 'kms:*'
            Resource: '*'

  KeyAlias:
    Type: AWS::KMS::Alias
    Properties:
      AliasName: alias/key-alias-for-chatops
      TargetKeyId:
        !Ref KeyId
```

#### 図６: Bash

```sh

$ cd chatops

# S3バケットの作成
$ aws  --profile ＜プロファイル＞ --region ＜リージョン＞ \
   s3 mb s3://chatops-＜リージョン＞-＜AWSアカウントID＞

# デプロイ用ファイルの作成
$ aws --profile ＜プロファイル＞ --region ＜リージョン＞ \
   cloudformation package \
   --template-file $(pwd)/chatops.yaml \
   --output-template-file $(pwd)/output-chatops.yaml \
   --s3-bucket chatops-＜リージョン＞-＜AWSアカウントID＞

# デプロイ
$ aws --profile ＜プロファイル＞ --region ＜リージョン＞ \
   cloudformation deploy \
   --template-file $(pwd)/output-chatops.yaml \
   --stack-name chatops \
   --capabilities CAPABILITY_IAM
```

## CircleCIでトークンを作成

1. CircleCIにログイン: `https://circleci.com/gh/<ユーザ名>/<githubのリポジトリ名>/edit#api`
1. 「Create Token」を押して、トークンを作成（図７）
  1. `First choose a scope and then create a label.`: ALL
  1. `Token label`: all
1. 作成されたトークンの値をメモにコピー

#### 図７: CircleCI

![図７](https://qiita-image-store.s3.amazonaws.com/0/259386/371a0f4f-8296-59f6-6769-a70b5a38b520.png)

## AWS CLI (AWS KMS) でトークンを作成

1. Slack Commands 作成時にメモにコピーしたトークンを用意
1. AWS CLIを使ってトークンを暗号化（図８）
  1. AWS KMSに作成された鍵を使ってトークンを暗号化します
1. 図８のコマンド実行結果にある「CiphertextBlob」の値をメモにコピー

#### 図８: Bash

```sh

$ aws --profile ＜プロファイル＞ --region ＜リージョン＞ \
   kms encrypt \
   --key-id alias/key-alias-for-chatops \
   --plaintext ＜Slack Commands 作成時のトークン＞

{
    "CiphertextBlob": "XXXX", # これ
    "KeyId": "XXXX"
}

```

## AWS Lambda に環境変数を設定

1. Lambdaの「Environment variables」にある以下の環境変数に値を入力（図９）
  1. baseUrl: `https://circleci.com/api/v1.1/project/github/<ユーザ名>/<Githubのリポジトリ名>`
  1. circleciToken: `<CircleCIで作成したトークン>`
  1. kmsEncryptedToken: `<aws cliで作成したトークン>`

#### 図９: Lambda

![図９](https://qiita-image-store.s3.amazonaws.com/0/259386/6f675d25-aeaa-1438-60b4-d83d80d01b91.png)


## Slash Commands で API Gateway のURLを入力

1. API Gatewayに移動し「chatops」という名前のAPIをクリック
  1. Stage → Prod → ChatOps → POST と移動して URL をコピー（図１０）
1. 最初に作ったSlash Commandsに移動
  1. URLに上記のURLを貼り付け（図１１）
  1. 「Show this command in the autocomplete list」にチェックを入れる（図１２）
  1. 「Save Integration」を押して完了

#### 図１０: API Gateway

![図１０](https://qiita-image-store.s3.amazonaws.com/0/259386/2c38bf2e-0ab0-22e1-86f1-893d8a0cebd7.png)


#### 図１１: Slash Commands

![図１１](https://qiita-image-store.s3.amazonaws.com/0/259386/74d44fd8-a184-142d-a74c-60c7d9ea70a2.png)

#### 図１２: Slash Commands

![図１２](https://qiita-image-store.s3.amazonaws.com/0/259386/424d8189-bf0e-b89b-68a6-173cc8940485.png)


## Slackで確認

1. chatops.py で書き直した「＜デプロイできるSlackのチャンネル名＞」のチャンネルで以下を実行
  1. `/chatops ＜Githubのブランチ名＞　＜CircleCI Job名＞`
1. レスポンスが返ってきてURLにアクセスして、CircleCIのジョブが実行されていることを確認