CloudWatch Eventsを使ってECSタスクを監視するツールをSAMで作る

こんにちは、SREエンジニアのたっち(@TatchNicolas)です。これまではPythonによるサーバサイド開発を担当していましたが、SREエンジニアとしてプロダクトを横断して安定性・パフォーマンス改善に取り組む担当になりました。

ヘルスチェックしにくいバッチ系のECSタスク

JX通信社では、ワークロードのほとんどをAWS LambdaまたはECSの上で動かしています。 Webサービス自体やAPIなどはALBやECSサービスのヘルスチェックを使って異常を検知したり、StatusCakeのようなサービスを使って外形監視をすることができます。

しかし、エンドポイントを持たないバッチ処理のようなタスクは、タスク自体が起動に失敗したり、途中で失敗した場合に上記の方法では検知することができません。

そこで、AWSの強力な機能の一つであるCloudWatch Eventsを使って、うまく動作しなかったECSタスクがあった場合にSlackへ通知できる仕組みをつくってみました。

CloudWatch Eventsとは

CloudWatch Eventsを使うと、イベントパターンを定義することでAWSリソースの変更をリアルタイムに取得してAWSのアクションをトリガーすることができます。 AWSリソースの変更だけでなく、スケジュールを組んで処理の自動化をすることも出来ます。 定期的に行いたいちょっとした処理をLambdaで書いてCloudWatch Eventsのcronで呼ぶ、みたいなことは実践していらっしゃる方も多いかと思います。

今回はイベントパターンを使ってECSタスクの状態変化を検知し、LambdaでSlackにアラートを投げてみたいと思います。

どうせやるならSAMで

監視のツール自体が秘伝のタレになってしまったり、依存しているAWSリソースがわからなくなってしまっては健全ではありません。

Lambdaの管理ツールはApex(記事執筆時点でメンテナンスがすでに終了)、Serverless Frameworkなど色々ありますが、今回はCloudWatch Eventsのイベントパターンもまとめて定義してしまいたいので、CloudFormationと親和性の高いSAMを採用します。

構成とコード

簡単な構成図とコードは以下のとおりです。

f:id:TatchNicolas:20190815142005p:plain
構成図

app/main.py

import json
import os

import requests

SLACK_WEBHOOK = os.environ['SLACK_WEBHOOK']
AWS_REGION = os.environ['AWS_REGION']


def lambda_handler(event, context):

    if event.get('source') != 'aws.ecs':
        print('Invalid event')
        print(event)
        return

    detail = event['detail']
    header = 'ECSタスクが異常終了または起動に失敗しました\n'

    cluster_name = detail['clusterArn'].split('/')[1]
    task_id = detail['taskArn'].split('/')[1]
    task_def = detail['taskDefinitionArn'].split('/')[1]

    url = f'https://{AWS_REGION}.console.aws.amazon.com/ecs/home?region={AWS_REGION}#/clusters/'\
        f'{cluster_name}/tasks/{task_id}/details'

    text = '\n'.join([
        header,
        f'クラスタ: *{cluster_name}*',
        f'タスク定義: *{task_def}*',
        url,
        '```',
        json.dumps(detail, indent=2),
        '```',
    ])
    attachments = {
        'text': text,
        'color': 'danger'
    }

    payload = {
        'attachments': [attachments]
    }
    requests.post(SLACK_WEBHOOK, json=payload)

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam_python37

  Sample SAM Template for Python 3.7

Globals:
  Function:
    Timeout: 3

Resources:
  ECSMonitor:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: app/
      Handler: main.lambda_handler
      Runtime: python3.7
      Environment:
        Variables:
          SLACK_WEBHOOK: https://hooks.slack.com/services/xxxxxxxx/xxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxx
      Events:
        CommandNotFound:
          Type: CloudWatchEvent
          Properties:
            Pattern:
              !Sub |
              {
                "detail-type": [
                  "ECS Task State Change"
                ],
                "source": [
                  "aws.ecs"
                ],
                "detail": {
                  "clusterArn": [
                    "arn:aws:ecs:ap-northeast-1:123456789012:cluster/cluster-to-monitor-a",
                    "arn:aws:ecs:ap-northeast-1:123456789012:cluster/cluster-to-monitor-b"
                  ],
                  "containers": {
                    "exitCode": [
                      1,
                      127
                    ]
                  }
                }
              }
        TaskFailedToStart:
          Type: CloudWatchEvent
          Properties:
            Pattern:
              !Sub |
                {
                  "source": [
                    "aws.ecs"
                  ],
                  "detail-type": [
                    "ECS Task State Change"
                  ],
                  "detail": {
                    "clusterArn": [
                        "arn:aws:ecs:ap-northeast-1:123456789012:cluster/cluster-to-monitor-a",
                        "arn:aws:ecs:ap-northeast-1:123456789012:cluster/cluster-to-monitor-b"
                    ],
                    "stopCode": [
                      "TaskFailedToStart"
                    ]
                  }
                }

template.yaml のほうも、リージョン名やAWSアカウントの部分も疑似パラメータにできそうですが 面倒くさかった わかりやすさを優先してハードコードしちゃってます。

実装していてハマったのは、CloudFormationでイベントパターンを定義しているところです。

最近はCloudFormationをJSONで書くケースは稀かもしれませんが、イベントパターンはYAMLで書いた場合に意図せぬ挙動をすることがあります。

YAMLではクオートをつけない文字列は数値として扱われるのが基本ですが、コンテナの終了コードをイベントパターンに定義する場合、デプロイした際に数値型が文字列型に変換されてしまいます。

つまり、以下のようなイベントパターン定義がCloudFormation中にあっても、

# (略)
EventPattern:
  source:
  - aws.ecs
  detail-type:
  - ECS Task State Change
  detail:
    clusterArn:
    - arn:aws:ecs:ap-northeast-1:123456789012:cluster/samplestack
    containers:
      exitCode:
      # クォートで囲っていないので数値として扱われることを期待
      - 1
      - 127
# (略)

このようなJSONとして、exitCode の値が数値型でなく文字列型としてCloudWatch Eventsに設定されてしまいます。

{
  "detail-type": [
    "ECS Task State Change"
  ],
  "source": [
    "aws.ecs"
  ],
  "detail": {
    "clusterArn": [
      "arn:aws:ecs:ap-northeast-1:123456789012:cluster/samplestack"
    ],
    "containers": {
      "exitCode": [
        "1",
        "127"
      ]
    }
  }
}

すると、CloudWatchが発行するイベントJSONではexitCodeは数値型で渡されるので、イベントパターン定義と比較されるときに 127"127"イコールにはならず、イベント発火の条件にマッチしない結果になってしまいます。 しかし、テンプレート自体をJSONで書くのもツラいので、前述のサンプルの通りイベントパターンの部分のみをJSONで書くようにして回避できました。

また、記事執筆時点でワイルドカードや否定の条件には対応していないようでした。 そのため、「終了コードが0以外」のような条件を指定するのはやや難しいようです。

上記の例ではCloudWatch側でコンテナの終了コードを条件として指定し、Lambdaの発火をコントロールしました。 コストを受け入れられるのであればイベントパターンの条件をクラスタARNのみにして、Lambda側でイベントで送られてくるJSONの内容を元にSlack通知するかどうかを判断させるパターンでも良いかもしれません。

まとめ

今回はすでに利用中のタスク定義に手を加えずにタスクの成功/失敗を取得するような簡単な監視の仕組みを作ってみました。

次回は、サイドカーコンテナを使って(=タスク定義に手を加えて)より詳細な監視ができる仕組みを作ってみようと思います。

参考資料

  • Amazon CloudWatch Events イベントパターン
  • AWS CLI test-event-pattern
    • イベントのJSONとイベントパターンのJSONを入力として、マッチするかどうかをチェックできます。
    • 実施にイベントを起こしてAWS上で検証すると時間もお金もかかってしまうので、取りたいイベントのJSONを保存しておいて手元で検証するのが良いでしょう。