こんにちは、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通知するかどうかを判断させるパターンでも良いかもしれません。
まとめ
今回はすでに利用中のタスク定義に手を加えずにタスクの成功/失敗を取得するような簡単な監視の仕組みを作ってみました。
次回は、サイドカーコンテナを使って(=タスク定義に手を加えて)より詳細な監視ができる仕組みを作ってみようと思います。
参考資料