見出し画像

ECSタスクの異常終了の検知で、「stoppedReason」を複数除外指定する方法

はじめに

株式会社ココペリのSREグループの尾嵜成真です。
SREとして日々ココペリサービスの信頼性の向上に命を捧げております!(`・ω・´)ゞ
弊社ではECSタスクが異常終了した際に検知して、迅速に対応できるように監視しております。
ECSタスクの終了には様々な停止理由があり、タスクのローリングアップデートなど特に問題のないタスク停止なども存在しています。
SREの観点からすると、ノイズアラートはなるべく排除して本当に必要なアラートのみを通知させたいので、タスクの停止理由に応じて監視の有無も考慮したいですね(^-^)b
ということで、今回はEventBridgeとLambdaを使用して適切なECS監視を実現させた方法を解説していきたいと思います!

本構成でできること

  • ECSタスクが異常終了した際に検知してSlackに通知させる

  • ECSタスクの異常終了の検知で特定の停止理由を複数除外指定して、不要な通知をさせないようにする

  • ECSタスクの異常終了をCloudWatchLogsで保管する

構成図

構成内容

  1. ECSタスクが停止する。

  2. EventBridgeがタスク停止を検知する。(EventBridgeがなぜ2個あるのかは後述)

  3. EventBridgeをトリガーにLambdaを実行する。

  4. Lambdaで特定の停止理由を除外して、CloudWatchLogsにログを出力する。

  5. CloudWatchLogsのメトリクスフィルターをトリガーにCloudWatchAlarmのアクションが実行する。

  6. CloudWatchAlarmからSNS、ChatBotを使用してSlackに通知させる。(SNS、ChatBotの解説は省略)

EventBridgeが2つになる理由

  • 検知したいECSタスクの停止理由について

    • ECSタスクの終了に関しては色々な停止理由が存在します。

    • ECSタスクのイベントログにおいて、以下の停止条件時に検知するように設定します。

  • EventBridgeのイベントパターンについて

    • 理想としてはEventBridgeで上記の条件をフィルタリングして通知させることができれば良いのですが、現在のEventBridgeのイベントパターンでは、複数の条件分岐を柔軟に組み合わせることができない仕様となっております。
      ある程度の融通は効くのですが例えば上記の条件のように、

      • exitCodeが0以外 または exitCodeが存在しないとき

      • exitCodeが0以外 かつ stoppedReasonが「Scaling activity initiated by」または「container instance is in DRAINING state」または「ECS is performing maintenance on the underlying infrastructure hosting the task」ではないとき
        のようなパターンには対応できないです。

  • 解決方法

    • EventBridgeで複数の条件分岐を指定したい場合はLambdaで対応します。
      EventBridgeが2つある理由としては、
      1つ目のEventBridgeで、「exitCode ≠ 0」 2つ目のEventBridgeで、「exitCode が存在しない」
      をそれぞれ検知して、後続のLambdaでそれぞれのstoppedReasonをフィルタリングするという仕組みを作るためです。
      こうすることでEventBridge単体では処理できなかった、複数の条件分岐での処理を実現することができます。

CloudFormation コード内容

構成はCloudFormationでデプロイ管理してます。

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  ClusterArn:
    Type: String

Resources:
  # Lambdaでフィルタリングされたタスク停止ログ
  TaskStoppedLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 365
      LogGroupName: !Sub "/aws/events/${AWS::StackName}"

  LogEventsPolicy:
    Type: AWS::Logs::ResourcePolicy
    Properties:
      PolicyName: !Sub "${AWS::StackName}-LogEventsPolicy"
      PolicyDocument: !Sub |
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Sid": "LogEventsPolicy",
              "Effect": "Allow",
              "Principal": {
                "Service": [
                  "delivery.logs.amazonaws.com",
                  "events.amazonaws.com"
                ]
              },
              "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
              ],
              "Resource": "${TaskStoppedLogGroup.Arn}"
            }
          ]
        }

  TaskStoppedEventMetricFilter:
    Type: AWS::Logs::MetricFilter
    Properties:
      FilterPattern: ""
      LogGroupName: !Ref TaskStoppedLogGroup
      MetricTransformations:
        - MetricName: TaskStoppedEvent
          MetricNamespace: LogMetrics
          MetricValue: '1'

  TaskStoppedEventLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${AWS::StackName}-lambda-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: TaskStoppedEventLambdaExecutionPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:${AWS::Partition}:logs:*:*:*'
                
  # Lambdaの実行ログ
  TaskStoppedEventLambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 365
      LogGroupName: !Sub '/aws/lambda/${AWS::StackName}-lambda'

  TaskStoppedEventLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-lambda
      Timeout: 60
      Handler: index.lambda_handler
      Role: !GetAtt TaskStoppedEventLambdaExecutionRole.Arn
      Runtime: "python3.9"
      Environment:
        Variables:
          LOG_GROUP_NAME: !Sub '/aws/events/${AWS::StackName}'
      Code:
        ZipFile: |
          import json
          import boto3
          import os
          import time

          def lambda_handler(event, context):
              logs = boto3.client('logs')
              stopped_reason = event['detail']['stoppedReason']
              
              # 特定のstopped_reasonの場合を除いてログに出力する
              if ("container instance is in DRAINING state" not in stopped_reason and
                  "ECS is performing maintenance on the underlying infrastructure hosting the task" not in stopped_reason):
                  
                  log_group_name = os.environ['LOG_GROUP_NAME']
                  log_stream_name = context.aws_request_id
          
                  # ログストリームを作成
                  try:
                      logs.create_log_stream(logGroupName=log_group_name, logStreamName=log_stream_name)
                  except logs.exceptions.ResourceAlreadyExistsException:
                      pass
          
                  print("タスク停止ログをCloudWatchLogに出力します")
                  print("イベント詳細:", json.dumps(event))
         
                  # ログエントリを作成し、ログを送信
                  log_entry = {
                      'logGroupName': log_group_name,
                      'logStreamName': log_stream_name,
                      'logEvents': [
                          {
                              'timestamp': int(round(time.time() * 1000)),
                              'message': json.dumps(event)
                          }
                      ]
                  }
                  logs.put_log_events(**log_entry)

              else:
                  print("アラートを発生させないstopped_reasonのため、タスク停止ログをCloudWatchLogに出力しません")
                  print("イベント詳細:", json.dumps(event))

              return {
                  'statusCode': 200,
                  'body': json.dumps('Successfully processed ECS event.')
              }

  TaskStoppedEventLambdaInvokePermission1:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt TaskStoppedEventLambda.Arn
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt TaskStoppedEventRule1.Arn
  
  TaskStoppedEventLambdaInvokePermission2:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt TaskStoppedEventLambda.Arn
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt TaskStoppedEventRule2.Arn
      
    # タスク稼働停止時の監視
  TaskStoppedEventCloudWatchAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "TaskStoppedAlarm-${AWS::StackName}"
      AlarmDescription: 'ECSタスクの起動に失敗しました'
      ComparisonOperator: GreaterThanOrEqualToThreshold
      EvaluationPeriods: 1
      MetricName: TaskStoppedEvent
      Namespace: LogMetrics
      Period: 300
      Statistic: Maximum
      Threshold: 1
      TreatMissingData: notBreaching
      AlarmActions:
        - !ImportValue SlackNotificationTopicWarningArn

  # EventBridgeの実行失敗時の監視
  FailedInvocationsCloudWatchAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "FailedInvocationsAlarm-${AWS::StackName}"
      AlarmDescription: 'EventBridgeでルール実行が失敗しました'
      ComparisonOperator: "GreaterThanOrEqualToThreshold"
      EvaluationPeriods: 1
      MetricName: "FailedInvocations"
      Namespace: "AWS/Events"
      Period: 300
      Statistic: Maximum
      Threshold: 1
      TreatMissingData: notBreaching
      AlarmActions:
        - !ImportValue SlackNotificationTopicWarningArn

  # ECSタスクの稼働が停止したときに、exitCodeが0(正常停止)以外でかつ、ローリングアップデートでの終了を除くとき
  # それ以外のstoppedReasonはLambdaで処理する
  TaskStoppedEventRule1:
    Type: AWS::Events::Rule
    Properties:
      Name: TaskStoppedEventRule1
      Description: Triggered when task stopped and exitCode is not 0
      EventPattern: !Sub |
        {
          "source": ["aws.ecs"],
          "detail-type": ["ECS Task State Change"],
          "detail": {
            "clusterArn": ["${ClusterArn}"],
            "desiredStatus": ["STOPPED"],
            "lastStatus": ["STOPPED"],
            "containers": {
              "exitCode": [{
                "anything-but": 0
              }]
            },
            "stoppedReason": [{
              "anything-but": {
                "prefix": "Scaling activity initiated by"
              }
            }]
          }
        }
      State: ENABLED
      Targets:
        - Id: "TargetId1"
          Arn: !GetAtt TaskStoppedEventLambda.Arn

  # ECSタスクの稼働が停止したときに、exitCodeが存在しないときかつ、ローリン**グアップデートでの終了を除くとき
  # それ以外のstoppedReasonはLambdaで処理する
  TaskStoppedEventRule2:
    Type: AWS::Events::Rule
    Properties:
      Name: TaskStoppedEventRule2
      Description: Triggered when task stopped and exitCode is not exists
      EventPattern: !Sub |
        {
          "source": ["aws.ecs"],
          "detail-type": ["ECS Task State Change"],
          "detail": {
            "clusterArn": ["${ClusterArn}"],
            "desiredStatus": ["STOPPED"],
            "lastStatus": ["STOPPED"],
            "containers": {
              "exitCode": [{
                "exists": false
              }]
            },
            "stoppedReason": [{
              "anything-but": {
                "prefix": "Scaling activity initiated by"
              }
            }]
          }
        }
      State: ENABLED
      Targets:
        - Id: "TargetId2"
          Arn: !GetAtt TaskStoppedEventLambda.Arn

各リソースの解説

  • TaskStoppedLogGroup
    Lambdaでフィルタリングされた適切なタスク停止のログが出力されるロググループです。
    Lambdaによって、フィルタリングされたタスクのイベントログが検知するたびに、1つずつログストリームが作成されてそのログが出力されるようになります。
    実際にアラートが発生してログを分析する際は、ここのログを確認することになります。
    ECS上では停止したタスクのログはすぐに無くなってしまうので、ここでログを保管することで分析をすることができます。

  • TaskStoppedEventMetricFilter
    CloudWatchLogsのメトリクスフィルターです。
    上記のロググループに出力された際に、 CloudWatchAlarmに通知させるようにします。

FilterPattern: ""
  • これは全てのログに対して検知するようにしています。
    上記のロググループに出力されたログはフィルタリングされた適切なログなので、全てのログに対して通知させるようにします。

  • TaskStoppedEventLambdaLogGroup
    Lambdaで処理された内容のログが出力されるロググループです。
    Lambdaの処理でデバッグがしやすいようにログを出力してます。

  • TaskStoppedEventLambda
    EventBridgeをトリガーに実行されるLambdaです。 EventBridgeから取得したイベントログを見て、条件分岐で特定のstopped_reasonの場合以外はロググループに出力するようにします。
    以下のの処理でstopped_reasonで除外したいエラー文を必要に応じて記載します。 ここでは、2つのエラー文を除外するような処理となっています。

if ("container instance is in DRAINING state" not in stopped_reason and "ECS is performing maintenance on the underlying infrastructure hosting the task" not in stopped_reason):
  • TaskStoppedEventCloudWatchAlarm
    TaskStoppedEventMetricFilter で検知したメトリクスをトリガーにアラームを設定しています。
    通知先はSNSからChatbotに連携してSlackに通知するように設定しています。
    SNSは外部スタックからインポートする形となっています。

  • FailedInvocationsCloudWatchAlarm
    EventBridgeの実行失敗時に発生するFailedInvocationsを検知した際のアラームです。
    まれにEventBridge自体が失敗する可能性があるため、こちらの監視をしておくとより信頼性が高まります。

  • TaskStoppedEventRule1
    exitCode ≠ 0 のイベントパターンのEventBridgeです。検知したらLambdaを実行させます。
    EventBridgeでは1つの組み合わせた条件分岐は可能なため、stoppedReasonが「Scaling activity initiated by」だけ除外するように設定しています。
    これはLambdaの実行回数をなるべく減らして少しでもコストを削減したいために設定しています。
    stoppedReasonに関しては、EventBridgeで除外せずLambda内で全部処理しても問題はありません。処理内容的にはそちらの方がわかりやすいかもしれません。

  • TaskStoppedEventRule2
    exitCodeが存在しないイベントパターンのEventBridgeです。内容は1と同じです。

実際にやってみた

アラートが鳴るパターンと鳴らないパターン、それぞれで実際の挙動を確認してみます。

アラートが鳴るパターン

タスク定義のイメージ名をECRに存在しない名前にしてサービスを更新してみます。
タスク起動時にイメージがありませんとエラーが出てアラートが鳴ればOKです。

1.タスク定義のイメージ名を適当な名前に変更します。

2.上記のタスク定義でタスクを起動してみます。
3.タスクの起動が失敗していることを確認できました。

4.Lambdaの実行ログを確認します。
Lambdaがタスク停止を検知して、CloudWatchLogsに出力しています。

5.出力されたCloudWatchLogsを確認します。
出力された時間にイベントログが出力されていることを確認できました。

イベントログを確認すると、stoppedReasonにECS上で出たエラー文と同じ内容が出ています。

"stoppedReason": "CannotPullContainerError: pull image manifest has been retried 1 time(s): failed to resolve ref [docker.io/library/hogehoge:latest:](<http://docker.io/library/hogehoge:latest:>) pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed",

6.Slackに通知が来ていることを確認します。
無事にアラート通知がきました!

アラートが鳴らないパターン

ECSインスタンスをドレインしてタスクを終了させます。
ドレインで終了する場合はアラートが鳴らないようにフィルタリングしているので、アラートが鳴らなければOKです。

1.インスタンスをドレインして、タスクを終了させます。

2.Lambdaの実行ログを確認してみます。

通知が鳴らないようになっていますね。
イベントログを確認すると、stoppedReasonに除外するエラー文が含まれていることを確認しました。

"stoppedReason": "Service ozaki-test-service: container instance is in DRAINING state",

まとめ

監視導入時は単一の条件のみをフィルタリングしてアラートを発生させるようにしていましたが、アラートが洪水のように押し寄せてきて、溺れてしまいそうになったのですぐに改良することにしました( ´-﹏-` )
ノイズアラートはなるべく避けたいところですが、フィルタリングしすぎると返って本当に大事なアラートを検知できないみたいなことになりかねないので、そこは注意してバランスを取って監視設定したいところですね!
この記事でみなさんのサービスの信頼性が少しでも高まることができれば幸いです。
ではまた!

いいなと思ったら応援しよう!