検証環境のリソースを削除し忘れて重課金になったので、それを防止するためにKiroでアラームを鳴らしまくる仕組みを Vibe で作ってみた

検証環境のリソースを削除し忘れて重課金になったので、それを防止するためにKiroでアラームを鳴らしまくる仕組みを Vibe で作ってみた

やらかしたままでは終われない。
2025.12.19

この記事は アノテーション株式会社 AWS Technical Support Advent Calendar 2025 | Advent Calendar 2025 - Qiita 19 日目の記事です。

はじめに

猫とアポロチョコとSystems Managerが好きな m.hayakawa です。

今年も終わりに近づいております。読者の方はなにかしらやらかしたことは、なかったでしょうか。

私はありました。

というわけで、そのやらかし内容と、それを防止する仕組みを Kiro の Vibe 作ってみたので、ブログ記事にします。転んでもただでは起きないのが大事。

やらかし内容

Amazon FSx for NetApp ONTAP で表示されるCloudWatchメトリクスを確認する目的で数分だけ構築しようとしていました。

しかし、環境の削除を失念しており、気付いたらかなりの額が課金されていました。(具体的な数字は控えますが、グラフで察してください)

2025-12-08_10h35_08

再発防止策を考える

検証環境では下記の対策は実施済みの状況です。(AWS Budget が都合上使えないので、下記の仕組みを作っています)

しかし、上記の通知は一度のみであり、繰り返し通知されないことから、スルーをしてしまっていました。

私はそそっかしいので、こういったことは何度も何度も通知をさせないと、同じことが起こると思いました(実際2度目)。

Kiroを使ってみる

頭の中には下記の構成が出来上がっていました。

  • EventBridge Scheduler で 1 時間に 1 回 Lambda をキックする
  • Lambda で一日の料金をチェックする
  • 一日の料金が 1 ドルを超えていたら、Amazon SNS で Slack へ通知する
  • 上記一連の構築を CloudFormation で実行する

さて、いざこれをイチから構築しようとすると時間がかかりそうだなと思いました。

私はあまり慣れていなかったのですが、時間短縮と勉強のため、Vibe Coding というものとやってみようとなりました。

Kiro の構築はこのブログでは紹介しませんが、下記のドキュメントの「デプロイオプション 1」を元に環境を構築しました。

ステップ 1: デプロイオプションを選択する - Amazon Q Developer

さっそく Vibe してみる

Let's build ということで、下記の内容を Vibe してみます。

2025-12-10_13h05_10

プロンプト
検証環境にて毎日の料金チェックを実施したいです。現時点でほとんどの場合一日1ドルに満たない料金ですが、リソースの削除し忘れを防止するため、下記の構成をCloudFormation で構築したいです。

- EventBridge Scheduler で 1 時間に 1 回 Lambda をキックする
- Lambda で一日の料金をチェックする
  - 日付指定は実行日の前日から当日
- 一日の料金が 1 ドルを超えていたら、Amazon SNS で Slackメール へ通知する
  - 超過を検知するたびに毎回通知する
- Slack メールのメールアドレスは「<SAMPLE>@<サブドメイン>.slack.com>」です。

適したCloudFormationのyamlファイルを作成してください。CloudFormationのyamlファイルには絵文字を含めないでください。必要な情報があれば、質問をしてください。
実際のyamlファイルは要件が確定するまで作成しないでください。

なぜか初回だけ、謎のメッセージを吐いて失敗しました。

2025-12-08_14h37_32

その後、新しいセッションを作成して、test と入力した後、再度同内容を入力したら、問題なく実行できました。なんだったんだろう・・・

ということで、気を取り直して再度質問を投げたところ、下記の内容が返ってきました。

返答内容
検証環境での料金監視システムの構築について理解しました。CloudFormationテンプレートを作成する前に、いくつか確認させてください:

## 確認事項
1. AWS料金取得方法について
- Cost Explorer APIを使用して料金を取得する予定でしょうか?
- それとも他の方法(Billing APIなど)をお考えでしょうか?

2. 通知の詳細について
- Slack通知の内容に含めたい情報はありますか?(料金額、前日比、リソース詳細など)
- 通知のタイトルや形式に希望はありますか?

3. Lambda実行環境について
- Pythonランタイムで問題ないでしょうか?
- 特定のバージョンの指定はありますか?

4. IAM権限について
- Cost Explorer APIへのアクセス権限
- SNSへの発行権限
- CloudWatch Logsへの書き込み権限
- これらの権限設定で問題ないでしょうか?

5. エラーハンドリングについて
- API呼び出し失敗時の動作はどうしますか?
- Lambda実行エラー時の通知は必要でしょうか?

6. 料金の通貨について
- USD建てで問題ないでしょうか?

これらの詳細が確定しましたら、適切なCloudFormationテンプレートを作成いたします。

Credits used: 0.1
Elapsed time: 7s

おおっと、これだけで 0.1 クレジットを使うんですね。検証の場合は使い切ることはなさそうですが、それなりに使用量は考慮したほうがよさそうです。

では、下記の内容を返してみます。

プロンプト
1. Cost Explorer API を使用してください
2. 超過料金額、前日比、超過したリソース詳細を含めてください。タイトルは「料金が超過しました。」にしてください。
3. Pythonの最新バージョンを指定します
4. 提案いただいた権限をすべて付与したIAM権限を作成してください。
5. API呼び出し失敗時にもSlackへ通知してください。
6. USD建てで問題ありません。

下記が返ってきました。

返答内容
要件を確認いたしました。CloudFormationテンプレートを作成します。

Created cost-monitoring-stack.yaml

CloudFormationテンプレートを作成しました。このテンプレートには以下の機能が含まれています:

主要コンポーネント
EventBridge Scheduler: 1時間に1回Lambdaを実行 Lambda関数: Cost Explorer APIで料金チェック、閾値超過時にSNS通知 SNS: Slackメール通知 IAM Role: 必要な権限を付与

主な機能
前日の料金を取得し、1ドル閾値と比較
超過時の通知内容:
超過料金額
前日比較
サービス別詳細(上位10サービス)
API呼び出し失敗時もエラー通知
Python 3.12使用
デプロイ方法
aws cloudformation create-stack \
  --stack-name cost-monitoring \
  --template-body file://cost-monitoring-stack.yaml \
  --parameters ParameterKey=SlackEmail,ParameterValue=your-email@subdomain.slack.com \
  --capabilities CAPABILITY_NAMED_IAM
パラメータでSlackメールアドレスと閾値(デフォルト1.0ドル)を設定できます。SNSサブスクリプション確認メールが届くので、確認してください。
cost-monitoring-stack.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Daily cost monitoring system with EventBridge Scheduler, Lambda, and SNS notifications'

Parameters:
  SlackEmail:
    Type: String
    Description: 'Slack email address for notifications'
    Default: 'sample@subdomain.slack.com'
  CostThreshold:
    Type: Number
    Description: 'Daily cost threshold in USD'
    Default: 1.0

Resources:
  # SNS Topic for notifications
  CostAlertTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: cost-monitoring-alerts
      DisplayName: Cost Monitoring Alerts

  # SNS Subscription for Slack email
  SlackEmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref CostAlertTopic
      Protocol: email
      Endpoint: !Ref SlackEmail

  # IAM Role for Lambda function
  CostMonitoringLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: cost-monitoring-lambda-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: CostExplorerAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ce:GetCostAndUsage
                  - ce:GetUsageReport
                  - ce:GetCostCategories
                  - ce:GetDimensionValues
                  - ce:GetReservationCoverage
                  - ce:GetReservationPurchaseRecommendation
                  - ce:GetReservationUtilization
                  - ce:GetUsageReport
                Resource: '*'
        - PolicyName: SNSPublishAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !Ref CostAlertTopic

  # Lambda function for cost monitoring
  CostMonitoringFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: cost-monitoring-function
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt CostMonitoringLambdaRole.Arn
      Timeout: 300
      Environment:
        Variables:
          SNS_TOPIC_ARN: !Ref CostAlertTopic
          COST_THRESHOLD: !Ref CostThreshold
      Code:
        ZipFile: |
          import json
          import boto3
          import os
          from datetime import datetime, timedelta
          from decimal import Decimal

          def lambda_handler(event, context):
              try:
                  # Initialize clients
                  ce_client = boto3.client('ce')
                  sns_client = boto3.client('sns')

                  # Get environment variables
                  sns_topic_arn = os.environ['SNS_TOPIC_ARN']
                  cost_threshold = float(os.environ['COST_THRESHOLD'])

                  # Calculate date range (yesterday to today)
                  today = datetime.now().date()
                  yesterday = today - timedelta(days=1)

                  # Get cost data for yesterday
                  yesterday_response = ce_client.get_cost_and_usage(
                      TimePeriod={
                          'Start': yesterday.strftime('%Y-%m-%d'),
                          'End': today.strftime('%Y-%m-%d')
                      },
                      Granularity='DAILY',
                      Metrics=['BlendedCost'],
                      GroupBy=[
                          {
                              'Type': 'DIMENSION',
                              'Key': 'SERVICE'
                          }
                      ]
                  )

                  # Get cost data for day before yesterday for comparison
                  day_before_yesterday = yesterday - timedelta(days=1)
                  previous_day_response = ce_client.get_cost_and_usage(
                      TimePeriod={
                          'Start': day_before_yesterday.strftime('%Y-%m-%d'),
                          'End': yesterday.strftime('%Y-%m-%d')
                      },
                      Granularity='DAILY',
                      Metrics=['BlendedCost']
                  )

                  # Calculate total cost for yesterday
                  yesterday_total = 0
                  service_costs = []

                  if yesterday_response['ResultsByTime']:
                      for group in yesterday_response['ResultsByTime'][0]['Groups']:
                          service_name = group['Keys'][0]
                          cost = float(group['Metrics']['BlendedCost']['Amount'])
                          yesterday_total += cost
                          if cost > 0:
                              service_costs.append({
                                  'service': service_name,
                                  'cost': cost
                              })

                  # Calculate previous day total for comparison
                  previous_day_total = 0
                  if previous_day_response['ResultsByTime']:
                      previous_day_total = float(
                          previous_day_response['ResultsByTime'][0]['Total']['BlendedCost']['Amount']
                      )

                  # Check if cost exceeds threshold
                  if yesterday_total > cost_threshold:
                      # Calculate difference from previous day
                      cost_difference = yesterday_total - previous_day_total
                      difference_sign = '+' if cost_difference >= 0 else ''

                      # Sort services by cost (descending)
                      service_costs.sort(key=lambda x: x['cost'], reverse=True)

                      # Create detailed message
                      message = f"料金が超過しました。\n\n"
                      message += f"昨日の料金: ${yesterday_total:.2f} USD\n"
                      message += f"前日比: {difference_sign}${cost_difference:.2f} USD\n"
                      message += f"閾値: ${cost_threshold:.2f} USD\n"
                      message += f"超過額: ${yesterday_total - cost_threshold:.2f} USD\n\n"
                      message += "サービス別料金詳細:\n"

                      for service in service_costs[:10]:  # Top 10 services
                          message += f"- {service['service']}: ${service['cost']:.2f} USD\n"

                      if len(service_costs) > 10:
                          message += f"... その他 {len(service_costs) - 10} サービス\n"

                      message += f"\n日付: {yesterday.strftime('%Y-%m-%d')}"

                      # Send SNS notification
                      sns_client.publish(
                          TopicArn=sns_topic_arn,
                          Subject="料金が超過しました。",
                          Message=message
                      )

                      print(f"Cost alert sent: ${yesterday_total:.2f} USD exceeds threshold ${cost_threshold:.2f} USD")
                  else:
                      print(f"Cost within threshold: ${yesterday_total:.2f} USD")

                  return {
                      'statusCode': 200,
                      'body': json.dumps({
                          'message': 'Cost monitoring completed successfully',
                          'yesterday_cost': yesterday_total,
                          'threshold': cost_threshold,
                          'alert_sent': yesterday_total > cost_threshold
                      })
                  }

              except Exception as e:
                  error_message = f"料金監視でエラーが発生しました。\n\nエラー詳細: {str(e)}\n日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"

                  # Send error notification
                  try:
                      sns_client = boto3.client('sns')
                      sns_topic_arn = os.environ['SNS_TOPIC_ARN']
                      sns_client.publish(
                          TopicArn=sns_topic_arn,
                          Subject="料金監視システムエラー",
                          Message=error_message
                      )
                  except:
                      pass  # If SNS also fails, just log the error

                  print(f"Error in cost monitoring: {str(e)}")
                  raise e

  # IAM Role for EventBridge Scheduler
  SchedulerExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: cost-monitoring-scheduler-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: LambdaInvokeAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource: !GetAtt CostMonitoringFunction.Arn

  # EventBridge Schedule for hourly execution
  CostMonitoringSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: cost-monitoring-hourly-schedule
      Description: 'Hourly schedule for cost monitoring Lambda function'
      FlexibleTimeWindow:
        Mode: 'OFF'
      ScheduleExpression: 'rate(1 hour)'
      State: ENABLED
      Target:
        Arn: !GetAtt CostMonitoringFunction.Arn
        RoleArn: !GetAtt SchedulerExecutionRole.Arn

Outputs:
  SNSTopicArn:
    Description: 'ARN of the SNS topic for cost alerts'
    Value: !Ref CostAlertTopic
    Export:
      Name: !Sub '${AWS::StackName}-SNSTopicArn'

  LambdaFunctionArn:
    Description: 'ARN of the cost monitoring Lambda function'
    Value: !GetAtt CostMonitoringFunction.Arn
    Export:
      Name: !Sub '${AWS::StackName}-LambdaFunctionArn'

  ScheduleArn:
    Description: 'ARN of the EventBridge schedule'
    Value: !GetAtt CostMonitoringSchedule.Arn
    Export:
      Name: !Sub '${AWS::StackName}-ScheduleArn'

なんだかとてもよさそうなので、そのままデプロイしてもらいましょう。

プロンプト
では、下記パラメータで実際にデプロイをしてください。
CostThreshold=1.0
SlackEmail=<SAMPLE>@<サブドメイン>.slack.com>

2025-12-10_13h12_12

現在、諸事情で PowerShell で AWS コマンドを実行できないようにしているため、結果的にデプロイに失敗しましたが、失敗後のアフターケアも回答してくれるのは、素直にすごいなと思いました。

では、マネジメントコンソールから検証環境へデプロイしてみます。

CloudFormation のコンソールから、当該の yaml ファイルをアップロードし、パラメータとして Slack の通知先のメールアドレスを入力し、スタックを作成したところ、問題なく作成できました。

2025-12-10_13h14_47

Slack に飛んでくる Amazon SNS トピックのサブスクライブも同時に済ませておきましょう。

2025-12-10_13h19_47

動作チェック

Lambda 関数: DailyCostChecker の CloudWatch Logs を確認したところ、コストは取れていると見えました。

Lambdaログ
2025-12-10T13:18:05.551+09:00
INIT_START Runtime Version: python:3.12.v99 Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:42569371fb03ced3fe5b4d63763662bf9b13a5d4299cb453cc55a71374SAMPLE
2025-12-10T13:18:05.859+09:00
START RequestId: bd6938f4-54a9-4851-804f-709a7bSAMPLE Version: $LATEST
2025-12-10T13:18:09.396+09:00
Cost within threshold: $0.82 USD
2025-12-10T13:18:09.399+09:00
END RequestId: bd6938f4-54a9-4851-804f-709a7bSAMPLE
2025-12-10T13:18:09.399+09:00
REPORT RequestId: bd6938f4-54a9-4851-804f-709a7bSAMPLE Duration: 3539.60 ms Billed Duration: 3845 ms Memory Size: 128 MB Max Memory Used: 86 MB Init Duration: 304.81 ms

念のため、AWS CLI での結果も確認してみます(余談:ちょっとしたワンライナーコマンドも生成AIですぐに作れてしまうのはありがたい)。

プロンプト
# 前日と当日のサービス別コスト(上位5つ + 合計)
aws ce get-cost-and-usage \
  --time-period Start=$(date -d "yesterday" +%Y-%m-%d),End=$(date -d "tomorrow" +%Y-%m-%d) \
  --granularity DAILY \
  --metrics BlendedCost \
  --group-by Type=DIMENSION,Key=SERVICE \
  --output json | jq -r '
    .ResultsByTime[] | 
    .TimePeriod.Start as $date |
    .Groups | 
    map(select(.Keys[0] != "Tax")) |
    map(.Metrics.BlendedCost.Amount |= tonumber) |
    sort_by(.Metrics.BlendedCost.Amount) | 
    reverse |
    (
      ["Date: " + $date, "", ""],
      ["", "", ""],
      ["Rank", "Service", "Cost (USD)"],
      ["----", "-------", "----------"],
      (.[0:5] | to_entries | .[] | 
        [(.key + 1 | tostring), .value.Keys[0], (.value.Metrics.BlendedCost.Amount | . * 100 | round / 100 | tostring)]
      ),
      ["", "", ""],
      ["", "TOTAL (All Services)", (map(.Metrics.BlendedCost.Amount) | add | . * 100 | round / 100 | tostring)]
    ) | @tsv
  ' | column -t -s $'\t'

Date: 2025-12-09                                 
Rank              Service                        Cost (USD)
----              -------                        ----------
1                 Kiro                           0.65
2                 AWS Security Hub               0.12
3                 AWS Key Management Service     0.02
4                 Amazon GuardDuty               0.02
5                 Amazon Simple Storage Service  0.01
                  TOTAL (All Services)           0.82
Date: 2025-12-10                                 
Rank              Service                        Cost (USD)
----              -------                        ----------
1                 Kiro                           0.65
                  TOTAL (All Services)           0.65

タイムラグがある関係で、コマンド実行日のコストはすべて取れていなさそうですが、前日分のコストは正常に取れていそうです。

それでは、0.3ドルほど課金を増やすために、EC2(c5.4xlarge)を30分ほど起動し、終了してみます。翌朝に通知は来るでしょうか。


翌朝10時以降に Slack 通知がされました!

2025-12-11_10h45_05

Lambda の実行ログも超過したメッセージが記録されています。

Lambdaログ
2025-12-11T10:17:41.068+09:00
Cost alert sent: $1.63 USD exceeds threshold $1.00 USD

よく見たら Cost Explorer の料金も若干増えていますね。EventBridge スケジューラーの実行間隔も調整した方が良さそうです。例えば、午前10時、午前11時、午後16時、午後17時、午後18時などにしてもいいかもしれませんね。

ちょっとした変更も Kiro ならやってくれる。

プロンプト
マネジメントコンソール上でデプロイを行い、問題なく稼働していることを確認しました。
EventBridgeの実行スケジュールを下記のように変更したいです。CloudFormationを新規ファイルで作り直してください。

- 日本時間 10:00
- 日本時間 11:00
- 日本時間 16:00
- 日本時間 17:00
- 日本時間 18:00

元のyamlファイルの差分を返信に記載してほしいです。
返答内容
指定された時間でのスケジュール実行に変更したCloudFormationテンプレートを作成します。

Created: cost-monitoring-stack-updated.yaml

元のyamlファイルからの主な差分
変更されたセクション
1. EventBridge Scheduleの変更

削除:
# EventBridge Schedule for hourly execution
CostMonitoringSchedule:
  Type: AWS::Scheduler::Schedule
  Properties:
    Name: cost-monitoring-hourly-schedule
    Description: 'Hourly schedule for cost monitoring Lambda function'
    FlexibleTimeWindow:
      Mode: 'OFF'
    ScheduleExpression: 'rate(1 hour)'
    State: ENABLED
    Target:
      Arn: !GetAtt CostMonitoringFunction.Arn
      RoleArn: !GetAtt SchedulerExecutionRole.Arn

追加:
# 5つの個別スケジュール
CostMonitoringSchedule10AM:  # 10:00 JST (01:00 UTC)
CostMonitoringSchedule11AM:  # 11:00 JST (02:00 UTC)  
CostMonitoringSchedule4PM:   # 16:00 JST (07:00 UTC)
CostMonitoringSchedule5PM:   # 17:00 JST (08:00 UTC)
CostMonitoringSchedule6PM:   # 18:00 JST (09:00 UTC)

2. Outputsセクションの変更
削除:
ScheduleArn:
  Description: 'ARN of the EventBridge schedule'
  Value: !GetAtt CostMonitoringSchedule.Arn

追加:
ScheduleArns:
  Description: 'ARNs of the EventBridge schedules'
  Value: !Sub 
    - '${Schedule1}, ${Schedule2}, ${Schedule3}, ${Schedule4}, ${Schedule5}'
    - Schedule1: !GetAtt CostMonitoringSchedule10AM.Arn
      # ... 他4つのスケジュール

時間設定について
日本時間(JST)からUTCへの変換:
JST 10:00 → UTC 01:00
JST 11:00 → UTC 02:00
JST 16:00 → UTC 07:00
JST 17:00 → UTC 08:00
JST 18:00 → UTC 09:00

新しいテンプレートで既存スタックを更新するか、新規デプロイしてください。

早速、既存スタックを更新し、EventBridge スケジューラーを確認したところ、5つのスケジュールが登録されていました。

2025-12-11_10h59_10

ひとつのスケジュールで cron 式で工夫できそうなのでさらに指示をしました。

プロンプト
良いですね!ですが、EventBridgeスケジュールが5つ作られるのはやや冗長かと思います。ひとつのEventBridgeスケジュールでcron式を上手に使うことは考慮できませんか?
返答内容
おっしゃる通りです!cron式では複数の時間を一つの式で指定できます。1つのEventBridgeスケジュールで対応できるように修正します。

2 edits to file

cost-monitoring-stack-updated.yaml

完璧です!1つのEventBridgeスケジュールで複数の実行時間を指定するように修正しました。

修正内容
cron式の活用:

ScheduleExpression: 'cron(0 1,2,7,8,9 * * ? *)'
この式は以下の意味です:

0 = 分(0分)
1,2,7,8,9 = 時(UTC時間で1,2,7,8,9時)
* = 日(毎日)
* = 月(毎月)
? = 曜日(指定なし)
* = 年(毎年)
実行時間(日本時間)
10:00 JST (01:00 UTC)
11:00 JST (02:00 UTC)
16:00 JST (07:00 UTC)
17:00 JST (08:00 UTC)
18:00 JST (09:00 UTC)
これで5つのスケジュールリソースが1つに集約され、管理がシンプルになりました。リソース数も削減されてコスト効率も向上します。

EventBridge スケジューラーを確認したところ、1つのスケジュールで集約されていました。

2025-12-12_11h13_04

こういった変更もサクッとできるのは Kiro のすごさかなと思います。

まとめ

AWS Cost Explorer API の反映上、即効性のあるアラームとはなりませんが、数日放置するといったことはなくなることが期待できると思います。

やらかしがきっかけではあったものの、ほんの数回やり取りをするだけで、AWS の環境を構築できてしまう Kiro のすごさを感じたことはプラスになったと思いたいです。

今年の禊は今年の内にということで、筆を置きたいと思います。来年こそはやらかしませんように。

この記事をシェアする

FacebookHatena blogX

関連記事