LambdaとNameタグで特定システムのコストを監視してみた

AWS
この記事は約16分で読めます。

こんにちは!西川です!

AWSアカウントを複数のシステムで共有している環境において、「自分の担当システムだけのコストを把握したい」と悩んだことはないでしょうか?
本来であればコスト配分タグを活用するのがベストですが、権限の都合でタグの追加ができず、既存のNameタグしか頼りになる情報がないというケースもあります。
今回は、そんな環境上の制約を乗り越え、Lambdaを用いて特定システムのコスト監視とアラート通知を実現する方法を解説します。

本記事でわかること

  • コスト配分タグを追加せずに、特定のNameタグを持つリソースのコストを監視する方法
  • LambdaとCost Explorer APIを用いた日額・月額の監視ロジックの実装方法
  • 月額予算の複数設定と、通知の重複を防ぐ仕組み(DynamoDBなどの外部DB不要)

監視アプローチと実装のポイント

Cost Explorer API(ce:GetCostAndUsage)を利用してコストを取得します。通常のコンソール画面からはNameタグのみでの精緻な絞り込みが難しいときがありますが、LambdaからAPIを呼び出し、取得した結果からNameタグのプレフィックス(接頭辞)でフィルタリングすることで独自の集計ができるようになります。

3つの重要な実装ポイント

  1. 日額と月額のダブル監視
    日々のコストスパイク(急増)を素早く検知しつつ、月間の予算消化状況も併せて追跡します。
  2. 月額予算の複数閾値設定
    環境変数に 15,30,45,60,75,90,100,120,150 のようにカンマ区切りで複数の閾値を設定し、段階的にアラートを出せるようにしています。
  3. 通知済みの重複アラート防止
    月額アラートは外部のデータベースを使用せず、「昨日までの当月コスト」と「一昨日までの当月コスト」をAPIで取得・比較しています。これにより、閾値をまたいだタイミング(当月1回のみ)でスマートに通知するロジックとしています。

前提条件

本記事の構成を作成する上での前提条件は以下になります。

  1. Nameタグがコスト配分タグとして登録されていること。
    メンバーアカウントで確認する場合は、Cost Explorer画面の適用フィルターのタグに表示されていれば、登録されています。
  2. Nameタグの接頭辞が集計したいシステム固有の値になっていること。
    例:systemA-lambda

構成イメージ

今回構築するコスト監視システムの全体的な処理の流れは、以下の4ステップになります。

  1. 定期実行(Amazon EventBridge)
    EventBridgeのスケジューラ機能を利用して、1日1回決まった時間にLambda関数を自動で起動させます。
  2. コストデータの取得と抽出(Lambda & Cost Explorer API)
    起動したLambda関数からCost Explorer API(ce:GetCostAndUsage)を呼び出し、アカウントのコスト情報を取得します。そこから、環境変数で指定した「プレフィックス(接頭辞)」に一致するNameタグを持つリソースだけを抽出し、対象システムのコストを合算します。
  3. 予算の超過判定(Lambda)
    合算した日次および月次のコストと、環境変数で設定しておいた予算の閾値を比較します。月次予算は複数設定することができ、前日までのデータと比較することで「すでに通知済みかどうか」も判定します。
  4. アラート通知(Amazon SNS)
    判定の結果、予算を超過していた場合のみ、Amazon SNSを経由して管理者にアラート(メール等)を送信します。日次用・月次用でそれぞれ別々のSNSトピックにルーティングして通知することができます。

SNSトピックの設定

メール通知を行うSNSトピックを設定します。
また、メールアドレスのサブスクリプションも設定します。

SNSトピックの設定例

Lambdaの設定とコード

環境変数

Lambdaの環境変数に、集計対象となるNameタグのプレフィックスや、予算の閾値、通知先のSNSトピックARNを設定します。

Lambdaの環境変数の設定例

また、Cost ExplorerのAPI呼び出しはデータ量によって応答に時間がかかることがあるため、Lambdaのタイムアウト値は長め(5分など)に設定しておくことをおすすめします。

IAM権限 (カスタムポリシー)

Lambdaには AWSLambdaBasicExecutionRole に加え、以下のカスタムポリシーをアタッチします。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "CostExplorerAccess",
            "Effect": "Allow",
            "Action": [
                "ce:GetCostAndUsage"
            ],
            "Resource": "*"
        },
        {
            "Sid": "SNSPublish",
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": [
                "arn:aws:sns:ap-northeast-1:123456789012:your-sns-cost-alert-daily-topic",
                "arn:aws:sns:ap-northeast-1:123456789012:your-sns-cost-alert-monthly-topic"
            ]
        }
    ]
}

Lambda関数コード (Python)

以下のコードをLambdaにデプロイします。
※あくまで参考のコードですので、実際の運用環境に合わせて動作確認を行ってください。

"""
AWS Lambda: Cost Explorer 予算超過アラート通知

【概要】
Cost Explorerを使用して、特定のNameタグ(プレフィックス)を持つリソースのコストを取得し、
日次および月次の予算を超過していた場合にAmazon SNS経由で通知を行います。
月次予算の通知については、外部データベース(DynamoDB等)を使わずに「当月1回のみ」
通知されるように、「昨日までのコスト」と「一昨日までのコスト」を比較して閾値判定を行っています。

【環境変数】
- PREFIX                : 検索対象とするNameタグのプレフィックス(例: "my-project-")
- DAILY_BUDGET          : 日次コストの予算閾値(数値 例: 10.0)
- MONTHLY_BUDGET        : 月次コストの予算閾値。カンマ区切りで複数指定可能(例: "100, 500, 1000")
- DAILY_SNS_TOPIC_ARN   : 日次予算超過時の通知先SNSトピックARN
- MONTHLY_SNS_TOPIC_ARN : 月次予算超過時の通知先SNSトピックARN
"""

import boto3
import os
from datetime import datetime, timedelta
from botocore.config import Config
from botocore.exceptions import ClientError
import time
import random

# ===== 設定 =====
PREFIX = os.environ['PREFIX']
DAILY_BUDGET = float(os.environ['DAILY_BUDGET'])

# 環境変数の "100, 200, 500" のようなカンマ区切り文字列をリスト化し、数値の昇順に並べ替える
MONTHLY_BUDGETS = sorted([float(x.strip()) for x in os.environ['MONTHLY_BUDGET'].split(',')])

DAILY_SNS_TOPIC_ARN = os.environ['DAILY_SNS_TOPIC_ARN']
MONTHLY_SNS_TOPIC_ARN = os.environ['MONTHLY_SNS_TOPIC_ARN']

config = Config(
    retries={
        'max_attempts': 10,
        'mode': 'adaptive'
    }
)

ce = boto3.client('ce', region_name='us-east-1', config=config)
sns = boto3.client('sns')

# ===== Cost Explorer呼び出し =====
def call_ce_with_retry(params):
    for attempt in range(10):
        try:
            return ce.get_cost_and_usage(**params)
        except ClientError as e:
            code = e.response['Error']['Code']
            if code in ['ThrottlingException', 'TooManyRequestsException']:
                sleep_time = (2 ** attempt) + random.uniform(0, 1)
                print(f"Throttle detected. Retry in {sleep_time:.2f}s")
                time.sleep(sleep_time)
            else:
                raise
    raise Exception("Max retries exceeded")

# ===== コスト取得 =====
def get_cost(start, end):
    total = 0.0
    next_token = None

    while True:
        params = {
            'TimePeriod': {
                'Start': start,
                'End': end
            },
            'Granularity': 'DAILY',
            'Metrics': ['UnblendedCost'],
            'GroupBy': [
                {
                    'Type': 'TAG',
                    'Key': 'Name'
                }
            ]
        }

        if next_token:
            params['NextPageToken'] = next_token

        response = call_ce_with_retry(params)

        for result in response['ResultsByTime']:
            for group in result['Groups']:
                raw_key = group['Keys'][0] if group['Keys'] else ''

                # Name$xxxx → xxxx に変換
                tag_value = raw_key.split('$', 1)[-1] if '$' in raw_key else raw_key

                if tag_value.startswith(PREFIX):
                    amount = float(group['Metrics']['UnblendedCost']['Amount'])
                    total += amount

        next_token = response.get('NextPageToken')
        if not next_token:
            break

    return total

# ===== SNS通知 =====
def send_alert(topic_arn, subject, message):
    sns.publish(
        TopicArn=topic_arn,
        Subject=subject,
        Message=message
    )

# ===== Lambda本体 =====
def lambda_handler(event, context):
    today = datetime.utcnow().date()
    yesterday = today - timedelta(days=1)
    
    # ===== 日次(前日分) =====
    daily_cost = get_cost(str(yesterday), str(today))

    # ===== 月次 =====
    first_day = today.replace(day=1)
    monthly_cost_now = 0.0
    monthly_cost_before = 0.0
    
    # Cost Explorerは仕様上 Start と End を同じ日付にできないため、日付によって取得処理を分ける
    if today.day == 1:
        # 当月1日はまだ当月分のデータがないためスキップ
        pass
    elif today.day == 2:
        # 2日の場合は比較対象(前日までの月間累計)を0とする
        monthly_cost_now = get_cost(str(first_day), str(today))
        monthly_cost_before = 0.0
    else:
        # 3日以降は「昨日までの当月コスト」と「一昨日までの当月コスト」を取得する
        monthly_cost_now = get_cost(str(first_day), str(today))
        monthly_cost_before = get_cost(str(first_day), str(yesterday))

    print("=== Cost Report ===")
    print(f"Daily cost: {daily_cost:.2f}")
    print(f"Monthly cost (as of yesterday): {monthly_cost_now:.2f}")
    print("===================")

    # ===== 日次アラート判定 =====
    if daily_cost > DAILY_BUDGET:
        print(f"[Daily] 日次予算({DAILY_BUDGET:.2f})を超過しています。通知を送信します。")
        message = (
            f"[Daily Budget Exceeded]\n"
            f"Cost: {daily_cost:.2f}\n"
            f"予算: {DAILY_BUDGET:.2f}\n"
        )
        send_alert(DAILY_SNS_TOPIC_ARN, "Daily Cost Alert", message)
    else:
        print(f"[Daily] 日次コストは予算({DAILY_BUDGET:.2f})内です。")

    # ===== 月次アラート判定 =====
    exceeded_budgets = []
    
    # 複数設定された月次予算の閾値それぞれに対して状況を判定し、ログ出力する
    print("\n=== Monthly Budget Check ===")
    for budget in MONTHLY_BUDGETS:
        if monthly_cost_now > budget:
            if monthly_cost_before <= budget:
                print(f"[Monthly] 予算 {budget:.2f} を新規に超過しました(通知対象)")
                exceeded_budgets.append(budget)
            else:
                print(f"[Monthly] 予算 {budget:.2f} を超過していますが、既に通知済みのためスキップします")
        else:
            print(f"[Monthly] コストは予算 {budget:.2f} を超過していません")
    print("============================")

    # 1つ以上の閾値を新しく超えていた場合のみ通知を送信
    if exceeded_budgets:
        # 複数の閾値を同時に超えた場合は、一番高い閾値を基準にする
        max_exceeded_budget = max(exceeded_budgets)
        
        message = (
            f"[Monthly Budget Exceeded]\n"
            f"Cost: {monthly_cost_now:.2f}\n"
            f"超過した予算: {max_exceeded_budget:.2f}\n"
            f"(設定されている全予算: {', '.join(map(str, MONTHLY_BUDGETS))})\n"
        )
        
        send_alert(MONTHLY_SNS_TOPIC_ARN, "Monthly Cost Alert", message)

    return {
        "daily_cost": daily_cost,
        "monthly_cost": monthly_cost_now,
        "exceeded_budgets": exceeded_budgets
    }

トリガー設定

Lambdaを定期的に起動するため、トリガーを設定します。
トリガーの設定例。(毎日日本時間の15時に起動)

メール通知

設定した予算を超過した場合、メールで通知されます。

メールの通知例

まとめ

Nameタグのプレフィックスによる集計処理を実装することで、コスト配分タグ追加権限の制約がある環境でも柔軟なコスト監視ができるようになります 。

コメント

タイトルとURLをコピーしました