環境
- ソースコードは GitHub で管理
- ビルドは CodeBuild で実行し、dockerコンテナのイメージをECRに登録
- デプロイ先は EC2インスタンスクラスタの ECS
- CodePipelineで特定のブランチへのコミットをトリガーに、テスト環境へのビルド・デプロイを実行
課題
- いつビルド・デプロイが開始されたのか?
- いつデプロイが完了したのか?
- CodePipelineのマネージドコンソールを見ないとわからない
目的
- 開発メンバの誰もがテスト環境の構築状況を把握してもらいたい(デプロイ完了した?というのを無くす)
- ビルドまたはデプロイで失敗した場合に放置されないようにしたい
解決方法
- ビルド・デプロイの開始と完了(成功・失敗)を通知
- 通知先はチーム内のコミュニケーションツール Slack
構成
- CodePipelineのステータスをCloudWatchで監視
- Statusの変更があった場合に、Lambdaを起動
- Lambdaではステータス結果を、Slackへ通知
通知内容
ステータス
- CloudWatchでは、パイプラインのステータス、ステージのステータス、アクションのステータスの監視が可能
- パイプラインのステータスは、パイプラインの実行単位
- ステージのステータスは、コード取得→ビルド→デプロイでパイプラインを組んであるなら、コード取得、ビルド、デプロイ毎の単位
- アクションのステータスは、例えばビルド内のそれぞれのアクション単位
- 今回はビルド・デプロイがいつ開始され完了されたかを知りたいので、パイプライン単位のステータスを通知
マネージドコンソールのURL
- 詳細な状況をマネージドコンソールで確認できるようにするため、通知内にマネージドコンソールへのリンクをはる
- しかし、CodePipelineでは実行単位の結果のみが表示される画面が存在しない
- 実行単位の結果をマネージドコンソールから確認した場合は、View pipeline history のページがら参照可能
- 実行結果への直リンクがないため、Slackへの通知には、CodePipelineのプロジェクト単位のページへのリンクと、実行IDも通知に含め、各自Historyから詳細結果を参照できるようにする
Slack
Incoming WebHooks
- Post to Channel を入力し、
Add Incoming WebHooks integration
をクリック-
Webhook URL
: 動的に振られる。これをLambda側で呼び出す。 -
Post to Channel
: Lambda側でChannelは指定するので、特に気にしなくてもいい -
Customize Name
: Slackに表示される名前(適当に) -
Customize Icon
: Slackに表示されるアイコン(適当に)
-
Lambda
関数の作成
- 設計図: cloudwatch-alarm-to-slack-python3
基本的な情報
- 名前: (任意)
- ロール: テンプレートから新しいロールを作成
- ロール名: (任意)
- ポリシーテンプレート:
KMSの復号化アクセス権限
(defaultのまま)
※Lambda作成後、関数の中でCodePipelineの情報を読み取るので、作成したロールに AWSCodePipelineReadOnlyAccess
ポリシーを追加
SNSトリガー
- 削除
Lambda関数のコード
lambda_function.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
ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']
HOOK_URL = "https://" + boto3.client('kms').decrypt(
CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')
CODEPIPELINE_URL = "https://{0}.console.aws.amazon.com/codepipeline/home?region={0}#/view/{1}"
GITHUB_URL = "https://github.com/{0}/{1}/tree/{2}"
CODEBUILD_URL = "https://{0}.console.aws.amazon.com/codebuild/home?region={0}#/projects/{1}/view"
ECS_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/services/{2}/details"
SLACK_MESSAGE_TEXT = '''\
*{0}* `{1}` {3} <{2}|CodePipeline>
```execution_id: {4}```
{5}
'''
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# ステータスアイコン
STATE_ICONS = {
'STARTED': ':seedling:',
'FAILED': ':sweat_drops:',
'SUCCEEDED': ':rainbow::rainbow::rainbow:'
}
def lambda_handler(event, context):
logger.debug("Event: " + str(event))
region = event["region"]
pipeline = event["detail"]["pipeline"]
version = event["detail"]["version"]
execution_id = event["detail"]["execution-id"]
state = event["detail"]["state"]
url = CODEPIPELINE_URL.format(region, pipeline)
# 開始時はCodePipelineの詳細情報を追加
detail = pipeline_details(pipeline, version, region) if state == 'STARTED' else ''
# 通知内容
slack_message = {
'channel': SLACK_CHANNEL,
'text': SLACK_MESSAGE_TEXT.format(
pipeline, state, url, STATE_ICONS.get(state, ''),
execution_id, detail),
'parse': 'none'
}
# 通知
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)
# CodePipelineの詳細を取得
def pipeline_details(name, version, region):
codepipeline = boto3.client('codepipeline').get_pipeline(
name = name, version = int(version))
logger.debug("get-pipeline: " + str(codepipeline))
stages = []
for stage in codepipeline['pipeline']['stages']:
actions = []
for action in stage["actions"]:
provider = action["actionTypeId"]["provider"]
if provider == 'GitHub':
action_url = GITHUB_URL.format(
action["configuration"]["Owner"],
action["configuration"]["Repo"],
action["configuration"]["Branch"])
actions.append(":octocat: <{0}|{1}>".format(
action_url, provider))
elif provider == 'CodeBuild':
action_url = CODEBUILD_URL.format(
region,
action["configuration"]["ProjectName"])
actions.append(":codebuild: <{0}|{1}>".format(
action_url, provider))
elif provider == 'ECS':
action_url = ECS_URL.format(
region,
action["configuration"]["ClusterName"],
action["configuration"]["ServiceName"])
actions.append(":ecs: <{0}|{1}>".format(
action_url, provider))
else:
actions.append(provider)
stages.append("_{0}_ ( {1} )".format(stage["name"], ' | '.join(actions)))
return ' => '.join(stages)
環境変数
- kmsEncryptedHookUrl:
Webhook URL
を入力 - slackChannel:
#your-channel
(通知したいチャネル)
暗号化の設定
-
伝送中の暗号化のためのヘルパーの有効化
をチェック -
伝搬中に暗号化するKMSキー
暗号化キーのarnまたはエイリアスを指定(未作成の場合は、IAM > 暗号化キー から作成) - 保管時に暗号化する KMS キー: (デフォルト) aws/lambda
※キー設定後、環境変数の kmsEncryptedHookUrl の暗号化をクリック
CloudWatch
ルールの作成
-
イベントソース
- サービス名: CodePipeline
- イベントタイプ: CodePipeline Pipeline Execution State Change
- 特定の条件:
CANCELED
,FAILED
,RESUMED
,STARTED
,SUCCEEDED
-
ターゲット
- Lambda関数: (作成したLambdaの名前)
実行結果
まとめ
- 絵文字入れているのは視覚的にわかりやすくなることを期待して
- コード上にも記載してるが、ソースの取得もとは GitHub 前提で書いているので、CodeCommitやS3などが元になる場合は、適宜変更を加える
- CodePipeline から取得する情報をステージ単位、アクション単位にしたい場合は、CloudWatchのイベントタイプで、
CodePipeline Stage Execution State Change
またはCodePipeline Action Execution State Change
を選択(サンプルイベントが表示されるのでそれを参考に作成) - CodeBuild等のステータス監視も同様に「CloudWatchでステータス監視→Lambda→Slack」の流れで可能
おまけ
CodeBuild to Slacck
- CloudWatchではサービス「CodeBuild」イベントタイプ「CodeBuild Build State Change」で監視
Lambda関数のコード
lambda_function.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
ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']
HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')
CODEBUILD_URL = "https://{0}.console.aws.amazon.com/codebuild/home?{0}#/builds/{1}/view/new"
SLACK_MESSAGE_TEXT = '''\
*{0}* `{1}` <{2}|CodeBuild>
'''
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.info("Event: " + str(event))
region = event["region"]
build_id = event["detail"]["build-id"].split('/')[1]
url = CODEBUILD_URL.format(region, build_id)
project = event["detail"]["project-name"]
state = event["detail"]["build-status"]
# 通知内容
slack_message = {
'channel': SLACK_CHANNEL,
'text': SLACK_MESSAGE_TEXT.format(project, state, url),
'parse': 'none'
}
# 通知
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)
ECS to slack
- CloudWatchではサービス「ECS」イベントタイプ「状態変更」(タスクのステータス
ECS Task State Change
のみ)で監視
Lambda関数のコード
lambda_function.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
ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']
HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')
TASK_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/tasks/{2}/details"
TASK_DEFINITION_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/taskDefinitions/{1}"
CONTAINER_INSTANCE_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/containerInstances/{2}"
SLACK_MESSAGE_TEXT = '''\
*{0}* `{1}` {2} <{3}|Task> ( _<{4}|{5}>_ )
'''
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
STATE_ICONS = {
'RUNNING': ':runner::runner:',
'STOPPED': ':skull_and_crossbones:'
}
def lambda_handler(event, context):
logger.debug("HOOK_URL: " + str(HOOK_URL))
logger.debug("Event: " + str(event))
region = event["region"]
cluster_id = event["detail"]["clusterArn"].split('/')[1]
container_instance_id = event["detail"]["containerInstanceArn"].split('/')[1]
task_id = event["detail"]["taskArn"].split('/')[1]
task_definition_id = event["detail"]["taskDefinitionArn"].split('/')[1]
desired_status = event["detail"]["desiredStatus"]
last_status = event["detail"]["lastStatus"]
task_url = TASK_URL.format(region, cluster_id, task_id)
task_definition_url = TASK_DEFINITION_URL.format(region, task_definition_id.replace(':', '/'))
container_instance_url = CONTAINER_INSTANCE_URL.format(region, cluster_id, container_instance_id)
# 期待するステータスになる過程のステータスは通知しない
if desired_status != last_status:
return
# 通知内容
slack_message = {
'channel': SLACK_CHANNEL,
'text': SLACK_MESSAGE_TEXT.format(
cluster_id,
last_status,
STATE_ICONS.get(last_status, ''),
task_url,
task_definition_url,
task_definition_id)
}
# 通知
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)