こんにちは、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を採用します。
構成とコード
簡単な構成図とコードは以下のとおりです。
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を保存しておいて手元で検証するのが良いでしょう。