Tweet
Logo
    ECSの異常終了をお手軽検知
    ⚡

    ECSの異常終了をお手軽検知

    背景

    DROBEでは、日々数千のバッチが動いているのですが、そのほとんどがSchedule Taskで実行されています

    ECS の Schedule Task (Fargate) と Laravel

    DROBE では cron として実行したい Application にまつわる処理を ECS の Schedule Task を利用して運用しています

    info.drobe.co.jp

    ECS の Schedule Task (Fargate) と Laravel

    その中でコンテナを立ち上げる途中のネットワークエラーやOOMに強制終了させられるなど、稀にインフラ起因で失敗し、PHP側では検知できないケースがあり、下記のような構成で異常終了時に slack に通知を行うようにしました

    image

    特に ecs では、終了した際一定時間しかログを記録しないため、エラーの原因を追いにくく、現状の構成ではエラーの検知およびログの保存が必要でした

    image

    ※ aws chatbot を用いてもっとお手軽に通知が可能なのですが、カスタマイズが難しく、今回はlambda を使って通知を実装することになりました

    導入

    今回は terraform を用いて環境を作ったので tf ファイルの中身を公開しつつ簡単に説明します

    まずは EventBridge です

    ECS の任意のイベントを検知することができます

    event_pattern で filter をかけることができるのですが、下記のパターンを検知するようにしました

    • ECS Task State Change (タスクのステートが変更した時にとぶもの)
    • cluster は 本番で使っているもの
    • ステータスが終了状態
    • 終了コードが存在しない もしくは exitCode が 0 以外
    • reason が Scaling activity initiated by 以外で失敗している場合

    次にlambda です

    通知するためのSLACKのwebookのURLなどは環境変数として渡してあげるため、Lambda を作るときに 環境変数を定義しています

    一応ログも出すようにしました

    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 で定義してあげる必要があります

    あまり情報が多くても不便なので、下記を通知するようにしてみました

    • TASK定義
    • 終了理由
    • オーバーライドしたコマンド(DROBEにおいて、schedule taskは特定のタスク定義のコマンドを上書きして使っています)
    • 終了コード
    • 詳細へのリンク

    こちらを apply し、エラーが発生すると下記のように通知が飛んできます 🙌

    image

    終わりに

    lambda と slack を使って ecs の異常終了を検知する方法を紹介しました

    同じような悩みを抱えている方の参考になれば幸いです

    © 2025 DROBE All rights reserved.
    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
    }
    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
    }
    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();
    };