1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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)
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)
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();
};
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?