背景
DROBEでは、日々数千のバッチが動いているのですが、そのほとんどがSchedule Taskで実行されています
その中でコンテナを立ち上げる途中のネットワークエラーやOOMに強制終了させられるなど、稀にインフラ起因で失敗し、PHP側では検知できないケースがあり、下記のような構成で異常終了時に slack に通知を行うようにしました
特に ecs では、終了した際一定時間しかログを記録しないため、エラーの原因を追いにくく、現状の構成ではエラーの検知およびログの保存が必要でした
※ aws chatbot を用いてもっとお手軽に通知が可能なのですが、カスタマイズが難しく、今回はlambda を使って通知を実装することになりました
導入
今回は terraform を用いて環境を作ったので tf ファイルの中身を公開しつつ簡単に説明します
まずは EventBridge です
ECS の任意のイベントを検知することができます
resource "aws_cloudwatch_event_rule" "ecs_event_handler" {
name = "capture-ecs-events"
description = "Capture ECS events"
event_pattern = <<PATTERN
{
"source": [
"aws.ecs"
],
"detail-type": [
"ECS Task State Change"
],
"detail": {
"clusterArn": [
"${var.ecs_cluster_arn}"
],
"lastStatus": [
"STOPPED"
],
"containers": {
"exitCode": [{
"exists": false
}, {
"anything-but": 0
}]
},
"stoppedReason": [{
"anything-but": {
"prefix": "Scaling activity initiated by"
}
}]
}
}
PATTERN
}
event_pattern で filter をかけることができるのですが、下記のパターンを検知するようにしました
- ECS Task State Change (タスクのステートが変更した時にとぶもの)
- cluster は 本番で使っているもの
- ステータスが終了状態
- 終了コードが存在しない もしくは exitCode が 0 以外
- reason が
Scaling activity initiated by
以外で失敗している場合
次にlambda です
通知するためのSLACKのwebookのURLなどは環境変数として渡してあげるため、Lambda を作るときに 環境変数を定義しています
data "aws_iam_policy_document" "lambda_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com"
]
}
}
}
resource "aws_iam_role" "lambda_ecs_event_notification" {
name = "lambda-ecs-event-notification"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}
resource "aws_iam_role_policy_attachment" "lambda_ecs_event_notification" {
role = aws_iam_role.lambda_ecs_event_notification.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "archive_file" "lambda_ecs_event_notification" {
type = "zip"
source_file = "lambda-src/ecs_event_notification/index.js"
output_path = "lambda-src/ecs_event_notification/index.zip"
}
resource "aws_lambda_function" "ecs_event_notification" {
filename = data.archive_file.lambda_ecs_event_notification.output_path
function_name = "ecs-event-notification"
role = aws_iam_role.lambda_ecs_event_notification.arn
handler = "index.handler"
source_code_hash = data.archive_file.lambda_ecs_event_notification.output_base64sha256
runtime = "nodejs12.x"
memory_size = 128
timeout = 3
environment {
variables = {
DETAIL_URL_PREFIX = var.detail_ecs_url_prefix
SLACK_PATH = var.slack_path
}
}
}
一応ログも出すようにしました
resource "aws_cloudwatch_log_group" "lambda_ecs_event_notification" {
name = "/aws/lambda/ecs-event-notification"
retention_in_days = 1
}
resource "aws_iam_role_policy_attachment" "lambda_ecs_event_notification_logging" {
role = aws_iam_role.lambda_ecs_event_notification.name
policy_arn = aws_iam_policy.lambda_ecs_event_notification_logging.arn
}
resource "aws_iam_policy" "lambda_ecs_event_notification_logging" {
name = "lambda-ecs-event-notification-logging"
path = "/"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"${aws_cloudwatch_log_group.lambda_ecs_event_notification.arn}"
],
"Effect": "Allow"
}
]
}
EOF
}
Lambda を作ったので eventBridge の target にも入れてあげました
resource "aws_cloudwatch_event_target" "ecs_event_handler" {
rule = aws_cloudwatch_event_rule.ecs_event_handler.name
arn = aws_lambda_function.ecs_event_notification.arn
}
そしてとってもハマったのが下記で、lambda の起動には aws_lambda_permission によって↑に起動の権限を追加してあげる必要があるのですが、statement_id や principal などが typo していてもリソースは作られてしまうので、一見ちゃんと作られているけどなぜか lambda が実行されない... という事態に遭遇してだいぶ苦しみました
resource "aws_lambda_permission" "lambda_ecs_event_notification" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.ecs_event_notification.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_event_handler.arn
}
lambda のコードはこちら
eventBridge の event をうけとって整形し、slack に post しているだけです。
環境変数は事前に aws_lambda_function.ecs_event_notification で定義してあげる必要があります
const https = require('https');
exports.handler = (event, context, callback) => {
const id = event.resources[0].match(/[0-9a-zA-Z]*$/);
const detail = process.env.DETAIL_URL_PREFIX +id+'/details'
const reason = event.detail.stoppedReason
const taskArn = event.detail.taskDefinitionArn
const exitCode = event.detail.containers[0].exitCode
const command = event.detail.overrides.containerOverrides[0].command
const options = {
hostname: 'hooks.slack.com',
path: process.env.SLACK_PATH,
port: 443,
method: 'POST',
headers: {
'Content-Type' : 'application/json'
}
};
const payload = {
username:"ECS ALERT",
icon_emoji: ":ecs:",
blocks:[
{
type: "section",
text: {
type: "mrkdwn",
text:
"*TASK ARN*\n" + taskArn + "\n\n" +
"*REASON*\n" + reason + "\n\n" +
"*COMMAND*\n`" + command + "`\n\n" +
"*EXIT CODE*\n" + exitCode + "\n\n" +
"*DETAIL*\n" + detail
}
}]
};
const data = JSON.stringify(payload);
const req = https.request(options, (res) => {});
req.write(data);
req.end();
};
あまり情報が多くても不便なので、下記を通知するようにしてみました
- TASK定義
- 終了理由
- オーバーライドしたコマンド(DROBEにおいて、schedule taskは特定のタスク定義のコマンドを上書きして使っています)
- 終了コード
- 詳細へのリンク
こちらを apply し、エラーが発生すると下記のように通知が飛んできます 🙌
終わりに
lambda と slack を使って ecs の異常終了を検知する方法を紹介しました
同じような悩みを抱えている方の参考になれば幸いです