EventBridgeとLambdaによるFargateタスク状態通知
Posted On 2025-02-12
EventBridgeルールを用いてFargateタスクの起動・終了を検知し、Lambdaで状態変化のログ出力や追加処理を実行できるようにする方法を紹介。Terraformコードを利用して、ECSの状態監視から通知までを一括で構築する具体的な手順と実例を示した。
目次
はじめに
- クロスアカウントEventBridgeでEC2の状態変更通知を実現するでEC2の状態変更通知(起動、停止、削除)をクロスアカウントで実現した
- 今回はそれをECS(Fargate)で行う
- 分かりづらくなるのでシングルアカウントで実現する。マルチアカウントの場合は基本前回と同じ。
アーキテクチャー図
- 抽象化して書くとこう。Fargateのタスクを起動したり終了したりすると、EventBridgeが反応してLambdaが起動する。
ポイント
EventBridgeのイベントルールの定義がポイントかな。このように書く
# 2. ECS Task State Change イベントルール
resource "aws_cloudwatch_event_rule" "ecs_task_state_change_rule" {
name = "ecs-task-state-change-rule"
description = "ECS Task State Change rule for Fargate tasks"
#※ 必要に応じて clusterArn のフィルター条件も追加してください。
event_pattern = <<EOF
{
"source": [
"aws.ecs"
],
"detail-type": [
"ECS Task State Change"
],
"detail": {
"launchType": [
"FARGATE"
],
"clusterArn": [
"${aws_ecs_cluster.this.arn}"
]
}
}
EOF
実際に試してみる
Fargateのタスクを人工的に落としてドレインしてみる。Fargateのスポットの検証した結果をベースにする。
Lambdaに送られてくる通知
このように通知が送られてくる。
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/545993009f1f43768c33a4080ef933e0 state changed: lastStatus=DEACTIVATING, desiredStatus=STOPPED
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/ed7492378c6f47de880b83a6026c2c05 state changed: lastStatus=PROVISIONING, desiredStatus=RUNNING
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/ed7492378c6f47de880b83a6026c2c05 state changed: lastStatus=PENDING, desiredStatus=RUNNING
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/ed7492378c6f47de880b83a6026c2c05 state changed: lastStatus=ACTIVATING, desiredStatus=RUNNING
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/ed7492378c6f47de880b83a6026c2c05 state changed: lastStatus=RUNNING, desiredStatus=RUNNING
ECS Task arn:aws:ecs:ap-northeast-1:{account_id}:task/example-spot-cluster/545993009f1f43768c33a4080ef933e0 state changed: lastStatus=STOPPING, desiredStatus=STOPPED
Lambdaに送られてくるデータ
EC2と違って結構多い。
{
"version": "0",
"id": "324e261b-0557-66f9-f57b-b558ce5f48a4",
"detail-type": "ECS Task State Change",
"source": "aws.ecs",
"account": "****",
"time": "2025-02-12T10:09:49Z",
"region": "ap-northeast-1",
"resources": [
"arn:aws:ecs:ap-northeast-1:****:task/example-spot-cluster/545993009f1f43768c33a4080ef933e0"
],
"detail": {
"attachments": [
{
"id": "b3f3b956-8b02-44f6-a4e5-86f47e15ac26",
"type": "eni",
"status": "ATTACHED",
"details": [
{
"name": "subnetId",
"value": "subnet-****"
},
{
"name": "networkInterfaceId",
"value": "eni-****"
},
{
"name": "macAddress",
"value": "0a:65:47:ad:53:a1"
},
{
"name": "privateDnsName",
"value": "ip-172-18-199-58.ap-northeast-1.compute.internal"
},
{
"name": "privateIPv4Address",
"value": "172.18.199.58"
},
{
"name": "ipv6Address",
"value": "2406:da14:1df3:c305:69c1:d048:ae53:653e"
}
]
},
{
"id": "f1a93209-7df7-4ab0-8fbf-e95d1dd5bd92",
"type": "elb",
"status": "ATTACHED",
"details": []
}
],
"attributes": [
{
"name": "ecs.cpu-architecture",
"value": "x86_64"
}
],
"availabilityZone": "ap-northeast-1c",
"capacityProviderName": "FARGATE_SPOT",
"clusterArn": "arn:aws:ecs:ap-northeast-1:****:cluster/example-spot-cluster",
"connectivity": "CONNECTED",
"connectivityAt": "2025-02-12T10:01:00.524Z",
"containers": [
{
"containerArn": "arn:aws:ecs:ap-northeast-1:****:container/example-spot-cluster/545993009f1f43768c33a4080ef933e0/2654a5e5-3190-49d5-b9f4-8a9acf6e6d7c",
"lastStatus": "RUNNING",
"name": "streamlit",
"image": "aminehy/docker-streamlit-app",
"imageDigest": "sha256:b8dbc8ea8915538521730e32f48ce9b8a62a924290a38754a61109f9068049e5",
"runtimeId": "545993009f1f43768c33a4080ef933e0-0422623190",
"taskArn": "arn:aws:ecs:ap-northeast-1:****:task/example-spot-cluster/545993009f1f43768c33a4080ef933e0",
"networkInterfaces": [
{
"attachmentId": "b3f3b956-8b02-44f6-a4e5-86f47e15ac26",
"privateIpv4Address": "172.18.199.58",
"ipv6Address": "2406:da14:1df3:c305:69c1:d048:ae53:653e"
}
],
"cpu": "0"
}
],
"cpu": "512",
"createdAt": "2025-02-12T10:00:55.589Z",
"desiredStatus": "STOPPED",
"enableExecuteCommand": false,
"ephemeralStorage": {
"sizeInGiB": 20
},
"group": "service:fargate-service-spot",
"launchType": "FARGATE",
"lastStatus": "DEACTIVATING",
"memory": "2048",
"overrides": {
"containerOverrides": [
{
"name": "streamlit"
}
]
},
"platformVersion": "1.4.0",
"pullStartedAt": "2025-02-12T10:01:21.899Z",
"pullStoppedAt": "2025-02-12T10:01:46.759Z",
"startedAt": "2025-02-12T10:02:10.628Z",
"startedBy": "ecs-svc/****",
"stoppingAt": "2025-02-12T10:09:49.02Z",
"stoppedReason": "Task stopped by user",
"stopCode": "UserInitiated",
"taskArn": "arn:aws:ecs:ap-northeast-1:****:task/example-spot-cluster/545993009f1f43768c33a4080ef933e0",
"taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:****:task-definition/streamlit-task:11",
"updatedAt": "2025-02-12T10:09:49.02Z",
"version": 5
}
}
コード
Lambdaの関数(lambda_function.py)
import json
def lambda_handler(event, context):
# 受け取ったイベント全体をログ出力(デバッグ用)
print(event)
# イベントから 'detail' 部分を取得
detail = event.get('detail', {})
# ECSタスクのARNを取得
task_arn = detail.get('taskArn', 'Unknown Task ARN')
# タスクの最新の状態と、要求されている状態を取得
last_status = detail.get('lastStatus', 'Unknown Status')
desired_status = detail.get('desiredStatus', 'Unknown Desired Status')
# ログにタスクの状態変化を出力
print(f"ECS Task {task_arn} state changed: lastStatus={last_status}, desiredStatus={desired_status}")
# 必要に応じた追加処理をここに記述できます
# 例: 通知送信、DBへの記録など
return {
'statusCode': 200,
'body': json.dumps('Task state processed successfully')
}
Terraform(Lambda+EventBridge)
# 1. Lambda 関数 (必要な方のみ、すでに定義済みの場合は不要)
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda_function.py"
output_path = ".cache/lambda_function.zip"
}
resource "aws_lambda_function" "ecs_task_state_change_handler" {
function_name = "ecs-task-state-change-handler"
runtime = "python3.12"
handler = "lambda_function.lambda_handler"
role = aws_iam_role.lambda_exec.arn
filename = data.archive_file.lambda_zip.output_path
source_code_hash = filebase64sha256(data.archive_file.lambda_zip.output_path)
timeout = 5
}
# Lambda 実行ロール(最小例)
resource "aws_iam_role" "lambda_exec" {
name = "lambda_exec_role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json
}
data "aws_iam_policy_document" "lambda_assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
# ログ出力用などにポリシーをアタッチ(ここでは AWS 管理ポリシーをアタッチする簡易例)
resource "aws_iam_role_policy_attachment" "lambda_exec_attach" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# 2. ECS Task State Change イベントルール
resource "aws_cloudwatch_event_rule" "ecs_task_state_change_rule" {
name = "ecs-task-state-change-rule"
description = "ECS Task State Change rule for Fargate tasks"
#※ 必要に応じて clusterArn のフィルター条件も追加してください。
event_pattern = <<EOF
{
"source": [
"aws.ecs"
],
"detail-type": [
"ECS Task State Change"
],
"detail": {
"launchType": [
"FARGATE"
],
"clusterArn": [
"${aws_ecs_cluster.this.arn}"
]
}
}
EOF
}
# 3. イベントターゲットとして Lambda を指定
resource "aws_cloudwatch_event_target" "ecs_task_state_change_target" {
rule = aws_cloudwatch_event_rule.ecs_task_state_change_rule.name
target_id = "ecsTaskStateChangeLambda"
arn = aws_lambda_function.ecs_task_state_change_handler.arn
depends_on = [aws_lambda_permission.allow_eventbridge_to_invoke_lambda]
}
# 4. EventBridge による Lambda 呼び出しを許可
resource "aws_lambda_permission" "allow_eventbridge_to_invoke_lambda" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.ecs_task_state_change_handler.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_task_state_change_rule.arn
}
Terraform(ALB)
# 外部から与えられるパブリックサブネットのID
variable "public_subnet_ids" {
type = list(string)
description = "ALBを配置するパブリックサブネットのリスト"
}
# 外部から与えられるプライベートサブネットのID
variable "private_subnet_ids" {
type = list(string)
description = "ECSを配置するプライベートサブネットのリスト"
}
variable "vpc_id" {
type = string
description = "VPC の ID"
}
# Route53 ドメイン関係
variable "hosted_zone_name" {
type = string
description = "既存の Route53 Hosted Zone 名 (ex: example.com.)"
}
# ACM 証明書 ARN (*.example.comのようにワイルドカードつけてリクエストしておく)
variable "acm_certificate_arn" {
type = string
description = "既に登録済みの ACM 証明書 ARN(東京リージョン)"
}
# ─────────────────────────────────────────────────────────────
# ALB用Security Group
# ─────────────────────────────────────────────────────────────
resource "aws_security_group" "alb_sg" {
name = "alb-sg"
vpc_id = var.vpc_id
# インターネットの HTTP, HTTPSを許可
ingress {
description = "Allow HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
ingress {
description = "Allow HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
# アウトバンドを全許可
egress {
from_port = 0
to_port = 0
protocol = -1
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}
# ─────────────────────────────────────────────────────────────
# ALB の作成
# Public Subnet に配置し、上記 EC2 を Targets として登録
# ─────────────────────────────────────────────────────────────
resource "aws_lb" "alb" {
name = "alb"
load_balancer_type = "application"
security_groups = [aws_security_group.alb_sg.id]
subnets = var.public_subnet_ids
# 必要に応じてアクセスログの設定など適宜追加
tags = {
Name = "alb"
}
}
# HTTP リスナー (ポート80)
# すべてのリクエストをHTTPSにリダイレクトする
resource "aws_lb_listener" "http_listener" {
load_balancer_arn = aws_lb.alb.arn
port = "80"
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
# HTTPS リスナー (ポート443)
# 証明書ARNとポリシーを指定し、ターゲットグループへ転送する
resource "aws_lb_listener" "https_listener" {
load_balancer_arn = aws_lb.alb.arn
port = "443"
protocol = "HTTPS"
certificate_arn = var.acm_certificate_arn
ssl_policy = "ELBSecurityPolicy-2016-08"
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Hello ALB"
status_code = "200"
}
}
}
# ─────────────────────────────────────────────────────────────
# Route53 のHosted Zone は既存を仮定 (あるいは新規作成でもOK)
# data で取得したり、resource で作成したりする
# ─────────────────────────────────────────────────────────────
data "aws_route53_zone" "main" {
# 既存のドメインを使う例
name = var.hosted_zone_name
private_zone = false
}
# Aレコード
resource "aws_route53_record" "wildcard_record" {
zone_id = data.aws_route53_zone.main.zone_id
name = "*.${var.hosted_zone_name}"
type = "A"
alias {
name = aws_lb.alb.dns_name
zone_id = aws_lb.alb.zone_id
evaluate_target_health = false
}
}
所感
- スマートで結構いい感じ。Fargateの場合は結構いっぱい情報が送られてきてびっくりした
- スポットのロギングをいちいちヘルスチェック打ち続けなくていいらしい
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー