ECSタスクの異常終了の検知で、「stoppedReason」を複数除外指定する方法
はじめに
株式会社ココペリのSREグループの尾嵜成真です。
SREとして日々ココペリサービスの信頼性の向上に命を捧げております!(`・ω・´)ゞ
弊社ではECSタスクが異常終了した際に検知して、迅速に対応できるように監視しております。
ECSタスクの終了には様々な停止理由があり、タスクのローリングアップデートなど特に問題のないタスク停止なども存在しています。
SREの観点からすると、ノイズアラートはなるべく排除して本当に必要なアラートのみを通知させたいので、タスクの停止理由に応じて監視の有無も考慮したいですね(^-^)b
ということで、今回はEventBridgeとLambdaを使用して適切なECS監視を実現させた方法を解説していきたいと思います!
本構成でできること
ECSタスクが異常終了した際に検知してSlackに通知させる
ECSタスクの異常終了の検知で特定の停止理由を複数除外指定して、不要な通知をさせないようにする
ECSタスクの異常終了をCloudWatchLogsで保管する
構成図
構成内容
ECSタスクが停止する。
EventBridgeがタスク停止を検知する。(EventBridgeがなぜ2個あるのかは後述)
EventBridgeをトリガーにLambdaを実行する。
Lambdaで特定の停止理由を除外して、CloudWatchLogsにログを出力する。
CloudWatchLogsのメトリクスフィルターをトリガーにCloudWatchAlarmのアクションが実行する。
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",
まとめ
監視導入時は単一の条件のみをフィルタリングしてアラートを発生させるようにしていましたが、アラートが洪水のように押し寄せてきて、溺れてしまいそうになったのですぐに改良することにしました( ´-﹏-` )
ノイズアラートはなるべく避けたいところですが、フィルタリングしすぎると返って本当に大事なアラートを検知できないみたいなことになりかねないので、そこは注意してバランスを取って監視設定したいところですね!
この記事でみなさんのサービスの信頼性が少しでも高まることができれば幸いです。
ではまた!