クラウド破産を防ぐ:AWS Budget × Lambda による異常検知と自動リソース停止
|
AWS Budget Lambda CloudWatch Cost 監視 自動化
AWS Budgetのアラートとλをトリガーとして、費用異常を検知して自動でリソースを停止する仕組みの構築手順。個人・小規模案件での予期せぬ課金を防ぐ実践ガイド。
はじめに:クラウド破産の発生パターン
「月末に請求書を開いたら想定の10倍だった」——クラウド破産は特定の失敗パターンが繰り返される。
実際に起きやすい破産パターン:
パターンA: NAT Gateway の転送量課金
開発環境のEC2からS3・ECRへの通信がすべて NAT Gateway 経由
NAT Gateway: $0.045/GB + $0.045/時間
→ Docker イメージの pull が多い開発環境で月 $200〜$500 に膨張
パターンB: DDoS・クローラーによるトラフィック急増
CloudFront 経由でも大量リクエストは Lambda の実行回数に直撃
API Gateway + Lambda: リクエスト数課金
→ 悪意あるクローラーで Lambda が 100万回実行、月 $800
パターンC: 開発用リソースの放置
テスト用 RDS インスタンス (db.t3.medium) を停止し忘れ
→ 1ヶ月で $50〜$150、半年で $300〜$900
パターンD: S3 バケットの誤公開
公開設定のバケットに大容量ファイルを入れてしまい
外部からの GET リクエストで転送量が爆発
→ 数日で数万円の転送量課金
これらは「起きてから対処する」では遅い。「異常を検知して自動的に止める」仕組みを構築しておくことが、クラウドを安全に使う前提条件だ。
Part 1:防御の全体構成
単一の対策ではなく、検知・通知・自動停止の3層で構成する。
防御の3層構成:
Layer 1: 予防(Budget アラート)
─────────────────────────────────
日次・月次の支出を監視
80% / 100% / 予測超過 の3段階でアラート
→ SNS トピックに通知を送信
Layer 2: 通知(SNS → メール / Slack)
─────────────────────────────────
SNS サブスクリプションでメール通知
オプション: Lambda で Slack Webhook に転送
Layer 3: 自動停止(SNS → Lambda)
─────────────────────────────────
Budget の 100% 超過通知を Lambda がトリガー
タグ `auto-stop: enabled` のリソースを自動停止
保護タグ `protect: true` のリソースは停止しない
→ ECS Fargate: desired count を 0 に
→ EC2: stop-instances
→ RDS: stop-db-instance
Part 2:Terraform による全リソース構築
SNS トピックと Budget の設定
# modules/cost-guard/main.tf
variable "monthly_budget_usd" {
type = number
description = "月次予算上限(USD)"
}
variable "alert_email" {
type = string
description = "アラート通知先メールアドレス"
}
variable "project_name" {
type = string
}
# SNS トピック(通知ハブ)
resource "aws_sns_topic" "cost_alert" {
name = "${var.project_name}-cost-alert"
}
# メール通知のサブスクリプション
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.cost_alert.arn
protocol = "email"
endpoint = var.alert_email
}
# Lambda サブスクリプション(自動停止用)
resource "aws_sns_topic_subscription" "auto_stop_lambda" {
topic_arn = aws_sns_topic.cost_alert.arn
protocol = "lambda"
endpoint = aws_lambda_function.auto_stop.arn
}
# Budget の設定
resource "aws_budgets_budget" "monthly" {
name = "${var.project_name}-monthly"
budget_type = "COST"
limit_amount = tostring(var.monthly_budget_usd)
limit_unit = "USD"
time_unit = "MONTHLY"
# 80% 到達: 警告メール
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
}
# 100% 到達: 自動停止トリガー
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
}
# 予測値が 110% を超えたら: 先手を打つ
notification {
comparison_operator = "GREATER_THAN"
threshold = 110
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
}
}
Lambda 関数の IAM ロール
data "aws_iam_policy_document" "auto_stop_policy" {
# ECS: タスク数を 0 に変更する権限
statement {
effect = "Allow"
actions = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:ListServices"]
resources = ["*"]
}
# EC2: インスタンスの停止(終了ではない)
statement {
effect = "Allow"
actions = [
"ec2:DescribeInstances",
"ec2:StopInstances"
# "ec2:TerminateInstances" は含めない(停止のみ、削除はしない)
]
resources = ["*"]
}
# RDS: インスタンスの停止
statement {
effect = "Allow"
actions = [
"rds:DescribeDBInstances",
"rds:StopDBInstance"
]
resources = ["*"]
}
# CloudWatch Logs への書き込み
statement {
effect = "Allow"
actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["arn:aws:logs:*:*:*"]
}
}
resource "aws_iam_role" "auto_stop_lambda" {
name = "${var.project_name}-auto-stop-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "auto_stop" {
name = "auto-stop-policy"
role = aws_iam_role.auto_stop_lambda.id
policy = data.aws_iam_policy_document.auto_stop_policy.json
}
Lambda 関数のデプロイ
data "archive_file" "auto_stop" {
type = "zip"
source_file = "${path.module}/lambda/auto_stop.py"
output_path = "${path.module}/lambda/auto_stop.zip"
}
resource "aws_lambda_function" "auto_stop" {
filename = data.archive_file.auto_stop.output_path
source_code_hash = data.archive_file.auto_stop.output_base64sha256
function_name = "${var.project_name}-auto-stop"
role = aws_iam_role.auto_stop_lambda.arn
handler = "auto_stop.lambda_handler"
runtime = "python3.12"
timeout = 60
environment {
variables = {
DRY_RUN = "false" # "true" にするとログだけ出して実際には停止しない
AWS_REGION = var.aws_region
PROJECT_NAME = var.project_name
}
}
}
# SNS から Lambda を呼び出す権限
resource "aws_lambda_permission" "sns_invoke" {
statement_id = "AllowSNSInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.auto_stop.function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.cost_alert.arn
}
Part 3:Lambda の自動停止ロジック
タグベースの保護機構を実装することで、本番リソースの誤停止を防ぐ。
# modules/cost-guard/lambda/auto_stop.py
import boto3
import json
import logging
import os
logger = logging.getLogger()
logger.setLevel(logging.INFO)
AWS_REGION = os.environ.get("AWS_REGION", "ap-northeast-1")
PROJECT_NAME = os.environ.get("PROJECT_NAME", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ecs = boto3.client("ecs", region_name=AWS_REGION)
ec2 = boto3.client("ec2", region_name=AWS_REGION)
rds = boto3.client("rds", region_name=AWS_REGION)
def is_protected(tags: list[dict]) -> bool:
"""タグ protect=true のリソースは停止しない"""
for tag in tags:
if tag.get("Key") == "protect" and tag.get("Value", "").lower() == "true":
return True
return False
def should_auto_stop(tags: list[dict]) -> bool:
"""タグ auto-stop=enabled のリソースだけ停止対象とする"""
for tag in tags:
if tag.get("Key") == "auto-stop" and tag.get("Value", "").lower() == "enabled":
return True
return False
def stop_ecs_services():
"""ECS サービスの desired count を 0 に設定する"""
clusters = ecs.list_clusters()["clusterArns"]
for cluster_arn in clusters:
services = ecs.list_services(cluster=cluster_arn)["serviceArns"]
if not services:
continue
details = ecs.describe_services(cluster=cluster_arn, services=services)["services"]
for svc in details:
tags = svc.get("tags", [])
if is_protected(tags):
logger.info(f"PROTECTED (skip): ECS {svc['serviceName']}")
continue
if not should_auto_stop(tags):
logger.info(f"NO auto-stop tag (skip): ECS {svc['serviceName']}")
continue
if svc["desiredCount"] == 0:
logger.info(f"Already stopped: ECS {svc['serviceName']}")
continue
logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Stopping ECS: {svc['serviceName']}")
if not DRY_RUN:
ecs.update_service(
cluster=cluster_arn,
service=svc["serviceName"],
desiredCount=0
)
def stop_ec2_instances():
"""running 状態の EC2 インスタンスを停止する"""
res = ec2.describe_instances(
Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
)
instance_ids = []
for reservation in res["Reservations"]:
for inst in reservation["Instances"]:
tags = inst.get("Tags", [])
if is_protected(tags):
logger.info(f"PROTECTED (skip): EC2 {inst['InstanceId']}")
continue
if not should_auto_stop(tags):
logger.info(f"NO auto-stop tag (skip): EC2 {inst['InstanceId']}")
continue
instance_ids.append(inst["InstanceId"])
if instance_ids:
logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Stopping EC2: {instance_ids}")
if not DRY_RUN:
ec2.stop_instances(InstanceIds=instance_ids)
def stop_rds_instances():
"""available 状態の RDS インスタンスを停止する"""
dbs = rds.describe_db_instances()["DBInstances"]
for db in dbs:
if db["DBInstanceStatus"] != "available":
continue
# RDS タグは別 API で取得
arn = db["DBInstanceArn"]
tags = rds.list_tags_for_resource(ResourceName=arn).get("TagList", [])
if is_protected(tags):
logger.info(f"PROTECTED (skip): RDS {db['DBInstanceIdentifier']}")
continue
if not should_auto_stop(tags):
logger.info(f"NO auto-stop tag (skip): RDS {db['DBInstanceIdentifier']}")
continue
logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Stopping RDS: {db['DBInstanceIdentifier']}")
if not DRY_RUN:
rds.stop_db_instance(DBInstanceIdentifier=db["DBInstanceIdentifier"])
def lambda_handler(event, context):
logger.info(f"Triggered. DRY_RUN={DRY_RUN}")
logger.info(f"Event: {json.dumps(event)}")
stop_ecs_services()
stop_ec2_instances()
stop_rds_instances()
return {"statusCode": 200, "body": "Auto-stop completed"}
Part 4:タグ設計によるリソース管理
Lambda が正しく動作するには、リソースへのタグ付けルールを統一する必要がある。Terraform で管理するリソースには、モジュール変数からタグを一括適用する。
# 開発環境の ECS サービス(予算超過時に自動停止する)
resource "aws_ecs_service" "dev_api" {
name = "dev-api-service"
cluster = aws_ecs_cluster.dev.id
desired_count = 1
# ...
tags = {
Environment = "dev"
auto-stop = "enabled" # ← 自動停止の対象
protect = "false"
ManagedBy = "terraform"
}
}
# 本番環境の ECS サービス(絶対に自動停止しない)
resource "aws_ecs_service" "prd_api" {
name = "prd-api-service"
cluster = aws_ecs_cluster.prd.id
desired_count = 2
# ...
tags = {
Environment = "prd"
auto-stop = "disabled" # ← 対象外
protect = "true" # ← 二重の保護
ManagedBy = "terraform"
}
}
タグによる保護ロジックの判定フロー:
リソースを発見
↓
protect=true ?
├── YES → スキップ(ログ出力して終了)
└── NO ↓
auto-stop=enabled ?
├── NO → スキップ(ログ出力して終了)
└── YES → 停止実行(DRY_RUN=true なら実際には止めない)
Part 5:Dry-run モードで安全に検証する
初回デプロイ時は必ず DRY_RUN=true で動作確認する。
# Lambda を手動でテスト実行(SNS のダミーイベントを渡す)
aws lambda invoke \
--function-name my-project-auto-stop \
--payload '{
"Records": [{
"Sns": {
"Message": "{\"AlarmName\": \"budget-threshold\", \"NewStateValue\": \"ALARM\"}"
}
}]
}' \
--cli-binary-format raw-in-base64-out \
response.json
cat response.json
# {"statusCode": 200, "body": "Auto-stop completed"}
# CloudWatch Logs でどのリソースが対象になるか確認
aws logs tail /aws/lambda/my-project-auto-stop --follow
# [DRY-RUN] Stopping ECS: dev-api-service
# PROTECTED (skip): ECS prd-api-service
# NO auto-stop tag (skip): EC2 i-0abc123def456
Dry-run で「止まるべきリソースだけが対象になっている」ことを確認したら DRY_RUN=false に変更してデプロイする。
Part 6:Cost Anomaly Detection との組み合わせ
Budget は月次の予算ベースだが、AWS Cost Anomaly Detection は過去のパターンから外れた異常な支出を日次で検知する。 両方を組み合わせることで、月末を待たずに異常を捕捉できる。
# Cost Anomaly Detection の設定
resource "aws_ce_anomaly_monitor" "service" {
name = "${var.project_name}-anomaly-monitor"
monitor_type = "DIMENSIONAL"
monitor_dimension = "SERVICE" # サービス単位で異常を検知
}
resource "aws_ce_anomaly_subscription" "alert" {
name = "${var.project_name}-anomaly-alert"
frequency = "DAILY" # 毎日集計してアラート
monitor_arn_list = [aws_ce_anomaly_monitor.service.arn]
subscriber {
type = "SNS"
address = aws_sns_topic.cost_alert.arn
}
threshold_expression {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
values = ["10"] # $10 以上の異常支出で通知
match_options = ["GREATER_THAN_OR_EQUAL"]
}
}
}
Budget vs Cost Anomaly Detection の使い分け:
Budget アラート:
「月次の累計支出が予算の X% を超えた」
→ 月末の請求額の予測・上限管理に使う
Cost Anomaly Detection:
「昨日のEC2コストが過去7日間の平均より $50 高い」
→ パターンからの逸脱をリアルタイム近くで検知する
→ DDoS・設定ミス・意図しないスケールアウトの早期発見に有効
Conclusion:コスト管理は「仕組みで防ぐ」もの
クラウドコストの管理を「毎月請求書を確認する」という人的プロセスに依存するのは危険だ。
人的プロセスの問題:
→ 確認を忘れる
→ 旅行中・長期休暇中に異常が発生する
→ 異常に気づいた時点ですでに大きな金額になっている
仕組みによる防御:
→ Budget が 80% で警告、100% で自動停止をトリガー
→ Anomaly Detection が日次で異常パターンを検知
→ Lambda がタグを確認して安全にリソースを停止
→ Dry-run で事前に動作を検証済み
| 対策 | カバーするリスク | コスト |
|---|---|---|
| Budget アラート(メール) | 月次上限超過の検知 | 無料 |
| Budget → Lambda 自動停止 | 上限超過後のリソース暴走 | Lambda 実行コスト(月 $0.001 以下) |
| Cost Anomaly Detection | 日次の異常支出パターン | 無料(モニタリング料金なし) |
| タグによる保護機構 | 本番リソースの誤停止防止 | 無料 |
「クラウド破産」は運の問題ではなく、設計の問題だ。 防御の仕組みをコードで表現し、Terraform で管理することで、「誰かが設定し忘れる」という人的リスクも排除できる。
この記事をシェア