AWSでCodeCommitの更新をしたら自動的にDockerイメージ作ってデプロイするの巻

# Introduction

AWS上で動作するサーバの開発案件において、Gitリポジトリ(CodeCommit)を更新すると、自動的にビルドを行い、Dockerイメージを作成し、ECSにデプロイする仕組みを作って運用したので、まとめておきます。

なお、案件スタート時点では、CodePipeline、CodeBuildサービスがTOKYOリージョンに無い状態でした(なお、CodeCommitも)。おそらく、今であれば同じことをこれらのサービスを使ってスマートに作ることができるでしょう。

したがって、本記事は記念碑以上の価値が無いかもしれませんが、折角ですので記録に残すことにします。


# 概要

## 想定シナリオ

* AWS上には、開発用アカウントと本番用アカウントの二種類があるとします。
* Gitリポジトリは開発用アカウントにしかないものとする。
* Gitリポジトリを更新したら、自動的に本番用アカウント上の運用サーバを更新したい。

## 登場人物

* 開発用アカウント側
 * CodeCommit
 * Lambda

* 本番用アカウント側
 * SNS (Simple Notification Service) (開発用アカウントから本番用アカウントへの処理を行うためSNSを経由している)
 * SQS (Simple Queue Service)
 * ECS (Elastic Container Service) (運用サーバ)
 * EC2 (ビルド用サーバ)
 * Lambda

## 処理の流れ

1. 開発用アカウント上のCodeCommitの更新をトリガーにLambda(Request)を実行する
2. Lambda(Request)は、本番用アカウントのSNS(Request)を呼び出す。以後の処理は、全て本番用アカウント
3. SNS(Request)は、SQS(Build)にビルド要求のためのメッセージを追加し、Lambda(Build)を実行する
4. Lambda(Build)はビルドのためにEC2サーバを起動する
5. EC2サーバは、SQS(Build)のメッセージを受け取る
6. EC2サーバは、CodeCommitから最新のソースを取得する
7. EC2サーバは、ビルドを行いDockerイメージを作成する
8. EC2サーバは、DockerイメージをECSのリポジトリに登録する
9. EC2サーバは、ECSのリポジトリに登録されたDockerImageをECSにデプロイする
10. EC2サーバは、SNS(Close)を呼び出す
11. SNS(Close)は、Lambda(Close)とLambda(Notify)を呼び出す
12. Lambda(Close)は、EC2サーバを終了する
13. Lambda(Notify)は、ビルドが完了したことをメール、Slack等に通知する

# 各要素

## CodeCommit

開発用アカウント側にCodeCommitのリポジトリを作成し、本番反映用のブランチ(production)を作成します。
次に、コミットした際にLambdaを起動するためのトリガーとして、以下の内容をセットします(処理の流れ1)。

|項目|値|
|:--|:--|
|イベント|既存のブランチにプッシュする|
|ブランチ名|production|
|送信先|Lambda|
|Lambda関数|Request|


## Lambda(Result)

開発用アカウント側に、本番用アカウントのSNS(Request)を呼び出すためのLambdaを作成します(処理の流れ2)。

|項目|値|
|:--|:--|
|必要な権限|AWSLambdaExecute, AmazonSNSFullAccess(Publishするだけなので本当は専用のポリシーを作るべき)|
|VPC|非VPC|
|トリガー|CodeCommit イベント(updateReference)|

```lambda_function.py
import boto3
import logging

TOPIC_ARN = 'arn:aws:sns:ap-northeast-1:<<本番用アカウント>>:request'

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

def call_to_sns():
    logger.info('call_to_sns')

    client = boto3.client('sns')
    request = {
        'TopicArn': TOPIC_ARN,
        'Message': 'build',
        'Subject': 'build'
    }
    response = client.publish(**request)
    logger.info(response)
    if response['ResponseMetadata']['HTTPStatusCode'] != 200:
        raise Exception('Fali to call to sns', response)


def lambda_handler(event, context):
    try:
        logger.info('start')
        boto3.setup_default_session(region_name='ap-northeast-1')
        call_to_sns()
        logger.info('finished')
        return 'success'
    except Exception as e:
        logger.error(e)
        return 'error'
```

## SNS(Request)

本番アカウント側のSNSです。このSNSは、開発アカウント側のLambda(Result)からのメッセージを受け取る必要があるので、トピックポリシーで開発アカウントにもメッセージ発行を許可させておく必要があります。
このSNSでは、SQS(Build)にビルド要求のためのメッセージを追加し、さらにLambda(Build)を実行します(処理の流れ3)。

なお、ビルド要求をキューに積むことと、実際にビルド処理を起動するのを分ける理由は、ビルド中にCodeCommitリポジトリが更新された場合際に、重複してビルドが実行されないようにするためです。

|項目|値|
|:--|:--|
|サブスクリプション|SQS(Build)<br />Lambda(Build)|

## SQS(Build)

SNS(Request)から積まれるキューです(処理の流れ3)。

|項目|値|
|:--|:--|
|可視性タイムアウト|0秒|
|メッセージ保持期間|1時間|
|配信遅延|0秒|
|メッセージ受信待機|0秒|
|アクセス許可|Everybody(*) All SQS Actions (SQS:*)  (たぶん過剰な許可)|

## Lambda(Build)

SNS(Request)から実行されるLambdaです(処理の流れ3, 4)。
このLambdaで行っているのはEC2の起動だけです。
EC2サーバー内で、起動時にビルド用のスクリプトが実行されるようにしています。

|項目|値|
|:--|:--|
|必要な権限|AWSLambdaExecute, AmazonSNSFullAccess(Publishするだけなので本当は専用のポリシーを作るべき)|
|VPC|非VPC|
|トリガー|SNS(Request)|

```lambda_function.py
import boto3
import logging

EC2_INSTANCE_ID = 'i-ビルド用ECサーバのインスタンスid'

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

def ec2_start():
    logger.info('ec2_start')
    client = boto3.client('ec2')
    response = client.start_instances(
        InstanceIds=[
            EC2_INSTANCE_ID
        ]
    )
    logger.info(response)
    if response['ResponseMetadata']['HTTPStatusCode'] != 200:
        raise Exception('ec2 start error', response)

def lambda_handler(event, context):
    try:
        logger.info('start.')
        boto3.setup_default_session(region_name='ap-northeast-1')
        ec2_start()
        logger.info('finished.')
        return 'success'
    except Exception as e:
        logger.error(e)
        return 'error'
```

## EC2サーバ(ビルド用)

ビルド専用のサーバをセットアップします。適当なec2サーバを用意します。
なお、ここでは、OSにAmazonLinuxを使用しています。他のOSを使う場合は、もろもろ修正が必要です。

また、このサーバは普段停止しており、ビルド時にのみ起動し、終了後自分で停止するような運用にしています。

まず、起動時にビルドスクリプトを動作させるように設定します。

```rc.local(追加部分のみ)
su ec2-user /home/ec2-user/build.sh
```

```build.sh
#!/bin/sh
ENVIRONMENT=production

# ビルド要求QUEUEのURLを取得する
SQS_QUEUE_URL=$(aws sqs get-queue-url --queue-name "(SQS(Build)の名前)" --output text)

# 一休み
# ビルドサーバを停止させるときには、このすきにbuild.shプロセスをkillする
sleep 300

# QUEUEの中身のメッセージ数を取得
N_OF_MESSAGES=$(aws sqs get-queue-attributes --queue-url ${SQS_QUEUE_URL} --attribute-names ApproximateNumberOfMessages | grep "Approximate" | grep -oP "[0-9]+")

TAG=""
while [ ${N_OF_MESSAGES} -gt 0 ]
do
    # SQS(Build)のメッセージを全部受け取る(処理の流れ5)
    while [ ${N_OF_MESSAGES} -gt 0 ]
    do
        HANDLE=$(aws sqs receive-message --queue-url ${SQS_QUEUE_URL} | grep ReceiptHandle | grep -oP '(?<=ReceiptHandle":[\s\t]")([^"])*')
        aws sqs delete-message --queue-url ${SQS_QUEUE_URL} --receipt-handle "${HANDLE}"
        N_OF_MESSAGES=$(aws sqs get-queue-attributes --queue-url ${SQS_QUEUE_URL} --attribute-names ApproximateNumberOfMessages | grep "Approximate" | grep -oP "[0-9]+")
    done

    # CodeCommitから最新ソースを取得(処理の流れ6)
    cd /home/ec2-user/codecommit    # CodeCommitからデプロイするディレクトリは予め用意しておく
    git checkout ${ENVIRONMENT}
    git pull

    # ビルドを行う(処理の流れ7, 8)
    #   ビルドを行ったイメージには、現在時刻とlatestという二つのタグが付けられるようにしている
    TAG=$(date -u "+%Y%m%d%H%M%S")
    sh docker_make.sh ${ENVIRONMENT} ${TAG}  # Dockerイメージを作成する

    # ビルド中に別の要求が来ているかもしれないので、もう一度点検する
    N_OF_MESSAGES=$(aws sqs get-queue-attributes --queue-url ${SQS_QUEUE_URL} --attribute-names ApproximateNumberOfMessages | grep "Approximate" | grep -oP "[0-9]+")
done

# ビルドが実際に行われたかチェックし、行われていればECRを更新する
if [ "${TAG}" != "" ]; then
  # ecr内に現在時刻のタグを持つイメージがあるかどうかの判定
  if [ $(aws ecr list-images --repository-name=${ENVIRONMENT} | grep "imageTag" | grep -c "${TAG}") -gt 0 ]; then
    # 新しく作成されたDockerイメージでECSを更新する (処理の流れ9)
    sh task_update.sh ${ENVIRONMENT} ${TAG}   # タスクを更新
    sh service_update.sh ${ENVIRONMENT}       # サービス更新
  fi
fi

# サーバ停止要求を送る(処理の流れ10)
TOPIC_ARN=$(aws sns list-topics | grep "(SNS(Close)の名前)" | grep -oP '(?<=TopicArn":[\s\t]")([^"])*')
aws sns publish --topic-arn ${TOPIC_ARN} --message "stop" --subject "stop"
```

```docker_make.sh
#!/bin/sh

if [ $# -lt 2 ]; then
  echo "docker_make.sh environment tag"
  exit
fi
ENVIRONMENT=$1
TAG=$2

`aws ecr get-login --no-include-email`

# 実行中のアカウントID
ACCOUNT_ID=$(aws sts get-caller-identity | grep Account | grep -oE '[0-9]+')

# リポジトリ名
REPOSITORY=sample

# 現在latestタグが付いているイメージのidを取得
OLD_IMAGE_ID=$(docker images ${REPOSITORY}:latest -q)

# 新たにlatestタグを付けてイメージを作成
docker build -t ${REPOSITORY}:latest --build-arg environment=${ENVIRONMENT} .

# 新たなlatestタグのイメージのidを取得
NEW_IMAGE_ID=$(docker images ${REPOSITORY}:latest -q)

# イメージが更新されているか?
if [ "${OLD_IMAGE_ID}" != "${NEW_IMAGE_ID}" ]; then
  # イメージに現在時刻、latest、それぞれにアカウントID等の情報を付与したものの4種類のタグを付け、ECRに登録する
  docker tag ${REPOSITORY}:latest ${REPOSITORY}:${TAG}
  docker tag ${REPOSITORY}:${TAG} ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${ENVIRONMENT}/${REPOSITORY}:${TAG}
  docker push ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${ENVIRONMENT}/${REPOSITORY}:${TAG}
  docker tag ${REPOSITORY}:latest ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${ENVIRONMENT}/${REPOSITORY}:latest
  docker push ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${ENVIRONMENT}/${REPOSITORY}:latest
fi
```

```task_update.sh
#!/bin/sh

if [ $# -lt 2 ]; then
  echo "task_update.sh environment tag"
  exit
fi

ENVIRONMENT=$1
TAG=$2

# 現在のタスク定義を取得
aws ecs describe-task-definition --task-definition ${ENVIRONMENT}-task > /tmp/task.json

# タスクのimageのタグを引数で与えられたTAGに更新する
echo -n "aws ecs register-task-definition --family ${ENVIRONMENT}-task --container-definitions '" > /tmp/task.cmd
cat /tmp/task.json | jq -M '.taskDefinition.containerDefinitions' \
    | sed -re "s|(\"image\":[^:]*):[0-9a-z]*|\1:${TAG}|" \
    | jq . -Mc | tr '\n' ' ' >> /tmp/task.cmd
echo -n "'" >> /tmp/task.cmd
echo -n " --task-role-arn " >> /tmp/task.cmd
cat /tmp/task.json |jq -M '.taskDefinition.taskRoleArn' | tr '\n' ' ' >> /tmp/task.cmd
echo -n " --volumes '" >> /tmp/task.cmd
cat /tmp/task.json |jq -Mc '.taskDefinition.volumes' | tr '\n' ' ' >> /tmp/task.cmd
echo "'" >> /tmp/task.cmd

sh /tmp/task.cmd
```

```service_update.sh
#!/bin/sh

if [ $# -lt 1 ]; then
  echo "service_update.sh environment"
  exit
fi

ENVIRONMENT=$1

# 最新のタスク定義family:revisionを取得
REV=$(aws ecs describe-task-definition --task-definition ${ENVIRONMENT}-task | jq -M '.taskDefinition.revision')
TASK="${ENVIRONMENT}-task:${REV}"

# サービス名を取得
CLUSTER="${ENVIRONMENT}-cluster"
SERVICE=$(aws ecs list-services --cluster ${CLUSTER} | grep -oP '(?<=[0-9]:service/)([a-z0-9-]+)')

# サービスのタスクを更新
aws ecs update-service --cluster ${CLUSTER} --service ${SERVICE} --task-definition ${TASK}
```

## SNS(Close)

このSNSは、EC2サーバー(ビルド用)をクローズするLambdaと、ビルド完了を通知するLambdaの二つを呼び出します(処理の流れ11)。

|項目|値|
|:--|:--|
|サブスクリプション|Lambda(Close)<br />Lambda(Notify)|


## Lambda(Close)

Lambda(Build)の反対で、EC2サーバーを終了させるためのものです。

|項目|値|
|:--|:--|
|必要な権限|AWSLambdaExecute, AmazonEC2FullAccess(Closeするだけなので本当は専用のポリシーを作るべき)|
|VPC|非VPC|
|トリガー|SNS(Close)|

```lambda_function.py
import boto3
import logging

EC2_INSTANCE_ID = 'i-ビルド用ECサーバのインスタンスid'

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

def ec2_stop():
    logger.info('ec2_stop')
    client = boto3.client('ec2')
    response = client.stop_instances(
        InstanceIds=[
            EC2_INSTANCE_ID
        ]
    )
    logger.info(response)
    if response['ResponseMetadata']['HTTPStatusCode'] != 200:
        raise Exception('ec2 start error', response)

def lambda_handler(event, context):
    try:
        logger.info('start.')
        boto3.setup_default_session(region_name='ap-northeast-1')
        ec2_stop()
        logger.info('finished.')
        return 'success'
    except Exception as e:
        logger.error(e)
        return 'error'
```

## Lambda(Notify)

ビルド終了をSlackに通知するLambdaです。これだけ、諸所の事情によりNode.jsで書かれています。
(事情は忘れてしまった・・・)

|項目|値|
|:--|:--|
|必要な権限|AWSLambdaExecute|
|VPC|非VPC|
|トリガー|SNS(Close)|

```index.js
console.log('Loading function');

const https = require('https');
const url = require('url');
const slack_url = 'https://hooks.slack.com/services/hogehoge';
const slack_req_opts = url.parse(slack_url);
slack_req_opts.method = 'POST';
slack_req_opts.headers = {'Content-Type': 'application/json'};

exports.handler = function(event, context) {
    var req = https.request(slack_req_opts, function (res) {
        if (res.statusCode === 200) {
            context.succeed('posted to slack');
        } else {
            context.fail('status code: ' + res.statusCode);
        }
    });

    req.on('error', function(e) {
        console.log('problem with request: ' + e.message);
        context.fail(e.message);
    });

    var str = "*Server is updated";
    req.write(JSON.stringify({text: str}));
    req.end();
};
```