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
処理の流れ
- 開発用アカウント上のCodeCommitの更新をトリガーにLambda(Request)を実行する
- Lambda(Request)は、本番用アカウントのSNS(Request)を呼び出す。以後の処理は、全て本番用アカウント
- SNS(Request)は、SQS(Build)にビルド要求のためのメッセージを追加し、Lambda(Build)を実行する
- Lambda(Build)はビルドのためにEC2サーバを起動する
- EC2サーバは、SQS(Build)のメッセージを受け取る
- EC2サーバは、CodeCommitから最新のソースを取得する
- EC2サーバは、ビルドを行いDockerイメージを作成する
- EC2サーバは、DockerイメージをECSのリポジトリに登録する
- EC2サーバは、ECSのリポジトリに登録されたDockerImageをECSにデプロイする
- EC2サーバは、SNS(Close)を呼び出す
- SNS(Close)は、Lambda(Close)とLambda(Notify)を呼び出す
- Lambda(Close)は、EC2サーバを終了する
- 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) |
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) |
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を使う場合は、もろもろ修正が必要です。
また、このサーバは普段停止しており、ビルド時にのみ起動し、終了後自分で停止するような運用にしています。
まず、起動時にビルドスクリプトを動作させるように設定します。
su ec2-user /home/ec2-user/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"
# !/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
# !/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
# !/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) |
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) |
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();
};