AWS利用料金をTeamsに自動投稿する

# 1. 目的
- AWSアカウントの管理を簡単に
AWSアカウントの利用が増えると、毎日の利用料金をチェックするのが大変になってきます。
手動で確認するのは時間がかかるだけでなく、ミスも起こりやすいです。
そこで、自動化されたシステムを使って利用料金を簡単に取得・管理し、作業の手間を減らしたいと考えています。

- チーム全体でコストを共有
利用料金の情報をチーム全員と共有することで、無駄なリソースの使用を防ぎ、コスト意識を高めることができます。
Teamsに自動で投稿する仕組みを作ることで、全員が最新のコスト状況を確認できるようになります。

# 2. システム概要
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/65b349da-c5fc-1083-1f03-e1f1ded2d77a.jpeg">

システムは以下のサービスで構成しています。
- Amazon EventBridge (Scheduler)
定期的なスケジュールに基づき、Lambda関数を自動実行します。
- AWS Lambda
利用料金データの取得と処理を行い、Teamsへ情報を送信します。
- AWS Identity and Access Management (IAM)
各サービス間のアクセス権限を適切に管理し、セキュリティを確保します。
- AWS Key Management Service (KMS)
Lambda関数で使用するシークレット情報（TeamsのWebhook URL）の暗号化に使用します。
- Amazon CloudWatch (Logs)
Lambda関数の実行ログやエラーログをCloudWatch Logsに送信し、一元管理します。
- Power Automate (WorkFlows)
受信したPOSTリクエストを基に、チームメンバーがアクセスするチャネルに、利用料金情報を表示します。

# 3. 通知例
完成したシステムの通知例です。

<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/b90c91a9-a913-4c47-974d-1bcc3a566a42.png">

# 4. 作成方法
## 4-1. WorkFlows作成
投稿するチャネルのWebhook URLを取得します。
手順は以下のページを参考にさせていただきました。

https://qiita.com/fukasawah/items/896c3638c203a973c2f0

対象のチャネルをTeamsで開き、その他のオプションから「ワークフロー」をクリックします。
<img width="45%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/08763476-c451-3847-b1d7-97a76f42f15a.png">
<img width="25%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/ca7f7846-78eb-ea4b-ef47-1603ef8d668d.png">

ワークフローの選択画面で「Webhook 要求を受信するとチャネルに投稿する」を選択します。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/638af04d-32c9-7ee7-420e-d576c3bf7978.png">

作成するワークフローの名前を入力します。今回は「AWS利用料金投稿」としました。
名前の入力が完了したら「次へ」をクリックします。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/0f07a6ba-ec5d-179c-deaf-643109e425b7.png">

投稿先のチームとチャネルを選択します。
手順通り進めていれば、初期値に対象のチームとチャネルが選択されているはずです。投稿先を変更したい場合は、ここで変更してください。

::::note warn
プライベートチャネルには投稿できないので、注意してください。
:::

投稿先の選択が完了したら「ワークフローを追加する」をクリックします。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/136ca3d4-1510-29d2-78fd-b822fecd1abb.png">

ワークフローが追加され、Webhook URLが払い出されました。
Webhook URLはこの後の作業で使用するため、コピーしておきます。
コピーが完了したら「完了」を選択してダイアログを閉じます。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/5d039ec5-e545-5fc7-3d52-e3c2c30a4828.png">


## 4.2. IAMロールの作成
Lambda用のIAMロールを作成します。

AWSコンソールで「IAM」に移動し、「ロールの作成」をクリックします。

Lambdaをサービスとして選択し、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/b0a8c229-d68a-8618-0157-b876a3f05c7f.png">

以下の許可ポリシーを選択し、「次へ」をクリックします。
- AWSAccountUsageReportAccess
- AWSBillingReadOnlyAccess
- AWSLambdaBasicExecutionRole

ロール名と説明（任意）を入力します。必要であれば、タグも追加してください。
今回のロール名は「AWSCostNotifications-LambdaExecutionRole」としました。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/6ad9ff5b-19d4-880a-8454-2f21c6e835d9.png">
ロール名と説明の入力、タグの追加が完了したら、ここまでの設定項目を確認し、「ロールを作成」をクリックします。

ロールが作成されたことを確認します。ロールのARNはこの後の作業で使用するため、コピーしておきます。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/388b503c-533e-a4c9-f430-cd754407cc2c.png">

## 4.3. KMSキーの作成・設定
Lambdaの環境変数を暗号化するためのKMSキーを作成します。

AWSコンソールで「KMS」に移動し、「カスタマー管理型のキー」を選択、「キーの作成」をクリックします。

キーのタイプ：「対称」
キーの使用法：「暗号化及び複合化」
キーマテリアルオリジン：「KMS - 推奨」
リージョンごと：「単一リージョンキー」を選択し、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/d8125ddb-8f7f-4107-b6f5-e98a479b7d54.png">

エイリアスと説明（任意）を入力します。必要であれば、タグも追加してください。
今回のエイリアスは「AWSCostNotifications-LambdaKey」としました。
エイリアスと説明の入力、タグの追加が完了したら、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/4f412eb3-105f-43b2-9921-43f5bf42860f.png">

キー管理者を設定します。今回は自身のIAMユーザを追加しました。キーの削除にもチェックを入れておきます。
設定が完了したら、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/4ec5fe60-c075-4c11-96c6-9e85a146bcb3.png">

その他の設定は変更しないため、確認画面まで進みます。
ここまでの設定項目を確認し、「完了」をクリックします。

KMSキーが作成されたことを確認します。

作成したKMSキーの画面を開き、ARNをコピーします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/01e81b03-4456-44e5-8ac9-42f5a9b3630d.png">

キーポリシーの編集画面を開き、以下のポリシーを追加します。
```
{
      "Sid": "AWSCostNotifications-LambdaExecutionRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "<手順4.2でコピーしたIAMロールのARN>",
        "Service": "lambda.amazonaws.com"
      },
      "Action": [
        "kms:Decrypt",
        "kms:GenerateDataKey"
      ],
      "Resource": "<先程コピーしたKMSキーのARN>"
}
```

## 4.4. IAMポリシーの作成
KMSキーアクセス用のIAMポリシーを作成し、手順4.2で作成したLambda用のIAMロールにアタッチします。

AWSコンソールで「IAM」に移動し、「ポリシーの作成」をクリックします。

以下のポリシーを入力し、「次へ」をクリックします。
```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt",
                "kms:GenerateDataKey"
            ],
            "Resource": "<手順4.3でコピーしたKSMキーのARN>"
        }
    ]
}
```

ポリシー名と説明（任意）を入力します。必要であれば、タグも追加してください。
今回のポリシー名は「AWSCostNotifications-KMSAccessPolicy」としました。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/a1d968d7-e1e9-4c56-a56f-43b9914d9972.png">
ポリシー名と説明の入力、タグの追加が完了したら、ここまでの設定項目を確認し、「ポリシーを作成」をクリックします。

ポリシーが作成されたことを確認します。
4.2で作成したLambda用のIAMロールにアタッチします。

## 4.5. Lambda関数の作成
AWSコンソールで「Lambda」に移動し、「関数を作成」をクリックします。

「一から作成」を選択します。
関数名を入力します。今回の関数名は「AWSCostNotifications-CostNotifierFunction」としました。
ランタイムは「Python 3.13」、アーキテクチャは「x86_64」を選択します。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/55f4a7cc-d880-46b3-b5fc-296119d4ef59.png">

実行ロールは「既存のロールを使用する」を選択し、手順4.2で作成したIAMロールを選択します。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/80f405ac-d398-4834-b452-f7047adad52c.png">

「AWS KMS カスタマーマネージドキーによる暗号化を有効にする」にチェックを入れ、手順4.3で作成したKMSキーを選択します。
必要であれば、「タグを有効化」にチェックを入れ、タグも追加してください。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/e5820522-2b2c-4aae-a160-b45d1913ce32.png">

関数が作成されたことを確認します。
このまま、追加の設定をしていきます。

コードソースに以下のコードを貼り付け、デプロイします。
コードは以下のページを参考にさせていただきました。

https://zenn.dev/antyuntyun/articles/aws_cost_notification_to_teams_by_lambda

https://qiita.com/nasuvitz/items/808cd9f3e9d097e5dfd1

https://zenn.dev/michinoku/articles/108a88a1cec45f

```python:Lambdaコード
import os
import boto3
import json
import requests
import logging
import botocore.exceptions
import time
import random
from datetime import datetime, timedelta, date
from base64 import b64decode

logger = logging.getLogger()
logger.setLevel(logging.INFO)

formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')

if not logger.handlers:
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)

ce_client = boto3.client('ce', region_name='ap-northeast-1')
kms_client = boto3.client('kms')

def main(event, context) -> None:
    try:
        logger.info("Processing AWS billing started.")
        
        decrypted_url = decrypt(os.environ['TEAMS_WEBHOOK_URL'], context.function_name)
        
        total_cost, svc_costs = get_costs(ce_client)
        
        title, detail = create_message(total_cost, svc_costs)
        post_to_teams(title, detail, svc_costs, decrypted_url)
        logger.info("Processing AWS billing completed successfully.")
        
    except Exception:
        logger.exception("An error occurred during processing.")

def decrypt(enc_url: str, func_name: str) -> str:
    try:
        logger.info("Starting URL decryption.")
        
        decrypted = kms_client.decrypt(
            CiphertextBlob=b64decode(enc_url),
            EncryptionContext={'LambdaFunctionName': func_name}
        )['Plaintext'].decode('utf-8')

        logger.info("URL decryption completed.")
        return decrypted

    except Exception:
        logger.exception("Error occurred during URL decryption.")
        raise

def retry_with_backoff(func, retries=5, backoff_in_seconds=1, factor=2):
    current_try = 0
    while current_try <= retries:
        try:
            return func()
        except botocore.exceptions.ClientError as error:
            error_code = error.response['Error']['Code']
            if error_code in ['ThrottlingException', 'LimitExceededException']:
                sleep = backoff_in_seconds * (factor ** current_try) + random.uniform(0, 1)
                logger.warning(f"Rate limit exceeded. Retrying in {sleep:.2f} seconds...")
                time.sleep(sleep)
                current_try += 1
            else:
                raise
    logger.error("Max retries exceeded.")
    raise Exception("Max retries exceeded.")

def get_costs(client: boto3.client) -> tuple[dict, list[dict]]:
    start, end, is_prev_month = get_range()
    
    def fetch_cost_data():
        logger.info(f"Fetching cost data: {start} - {end}")
        
        return client.get_cost_and_usage(
            TimePeriod={'Start': start, 'End': end},
            Granularity='MONTHLY',
            Metrics=['UnblendedCost'],
            GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}]
        )
    
    try:
        response = retry_with_backoff(fetch_cost_data)
        logger.info("Cost data fetched successfully.")

        svc_costs = [
            {'service': grp['Keys'][0], 'billing': grp['Metrics']['UnblendedCost']['Amount']}
            for grp in response['ResultsByTime'][0].get('Groups', [])
            if float(grp['Metrics']['UnblendedCost']['Amount']) > 0.0
        ]

        total_amount = sum(float(grp['Metrics']['UnblendedCost']['Amount']) for grp in response['ResultsByTime'][0].get('Groups', []))
        
        total_cost = {
            'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
            'end': response['ResultsByTime'][0]['TimePeriod']['End'],
            'billing': f"{total_amount:.2f}",
        }
        
        return total_cost, svc_costs

    except Exception:
        logger.exception("Error occurred while fetching cost data.")
        raise

def create_message(total_cost: dict, svc_costs: list[dict]) -> tuple[str, str]:
    start_str, end_str, is_prev_month = get_range()
    start = datetime.strptime(start_str, "%Y-%m-%d").strftime("%m/%d")
 
    if is_prev_month:
        end = datetime.strptime(end_str, "%Y-%m-%d").strftime("%m/%d")
    else:
        end = (datetime.strptime(end_str, "%Y-%m-%d") - timedelta(days=1)).strftime("%m/%d")
 
    title = f"{start}～{end}の請求額は、{float(total_cost['billing']):.2f} USDです。"
    detail = '\n'.join(f'　・{s["service"]}: {float(s["billing"]):.2f} USD' for s in svc_costs)
 
    return title, detail

def post_to_teams(title: str, detail: str, services: list[dict], url: str) -> None:
    try:
        logger.info("Preparing Teams message.")
        
        svc_details = []
        for item in services:
            svc_name = item['service']
            billing = round(float(item['billing']), 2)
            if billing == 0.0:
                continue
            svc_details.append({'name': f'{billing:.2f} USD', 'value': svc_name})

        sorted_svc_details = sorted(svc_details, key=lambda x: float(x['name'].rstrip(' USD')), reverse=True)

        card_content = create_card(title, sorted_svc_details)
        payload = {
            "type": "message",
            "attachments": [
                {
                    "contentType": "application/vnd.microsoft.card.adaptive",
                    "content": card_content
                }
            ]
        }

        response = requests.post(url, headers={"Content-Type": "application/json"}, data=json.dumps(payload))
        response.raise_for_status()

        logger.info(f"Message posted to Teams successfully: {response.status_code}")

    except requests.exceptions.RequestException:
        logger.exception("Error occurred while posting to Teams.")
        raise

def create_card(title: str, facts: list[dict[str, str]]) -> dict:
    return {
        "type": "AdaptiveCard",
        "version": "1.4",
        "body": [
            {
                "type": "Container",
                "items": [
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "size": "Medium",
                                        "weight": "Bolder",
                                        "text": title
                                    },
                                    {
                                        "type": "TextBlock",
                                        "size": "Small",
                                        "text": "サービス別利用金額(金額降順)",
                                        "isSubtle": True
                                    },
                                    {
                                        "type": "ColumnSet",
                                        "columns": [
                                            {
                                                "type": "Column",
                                                "width": "auto",
                                                "items": [
                                                    {
                                                        "type": "TextBlock",
                                                        "size": "Small",
                                                        "text": fact['value'],
                                                        "wrap": True
                                                    }
                                                    for fact in facts
                                                ]
                                            },
                                            {
                                                "type": "Column",
                                                "width": "auto",
                                                "items": [
                                                    {
                                                        "type": "TextBlock",
                                                        "size": "Small",
                                                        "weight": "Bolder",
                                                        "text": fact['name'],
                                                        "wrap": True
                                                    }
                                                    for fact in facts
                                                ]
                                            }
                                        ]
                                    }
                                ]
                            }
                        ]
                    }
                ]
            }
        ],
        "$schema": "https://adaptivecards.io/schemas/adaptive-card.json"
    }

def get_range() -> tuple[str, str, bool]:
    today = date.today()
    start = today.replace(day=1).isoformat()
    end = today.isoformat()
    is_prev_month = False

    if today.day == 1:
        last_day = today - timedelta(days=1)
        start = last_day.replace(day=1).isoformat()
        end = last_day.isoformat()
        is_prev_month = True

    return start, end, is_prev_month
```
今回のコードでは、requestsモジュールが必要となります。
requestsモジュールのLambda Layerを作成し、Lambda関数に追加します。

手順は以下のページを参考にさせていただきました。

https://qiita.com/__DASHi__/items/b03d3efd401260d8add4#3-lambda-layer%E3%82%92lambda%E9%96%A2%E6%95%B0%E3%81%AB%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B

環境変数を設定します。

キーに「TEAMS_WEBHOOK_URL」、値に「手順4.1で作成したWebhook URL」を設定します。
環境変数はKMSキーで暗号化してください。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/a4d5cc9b-7239-4e11-b576-bffd74b344e1.png">
その他の暗号化の設定の説明は割愛します。

## 4.6. EventBridgeの設定
AWSコンソールで「EventBridge」に移動し、「ルールを作成」をクリックします。

名前と説明（任意）を入力します。
今回の名前は「AWSCostNotifications-ScheduleRule」としました。
イベントバスは「default」、ルールタイプは「イベントパターンを持つルール」を選択し、「続行してルールを作成する」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/5e6051f1-5740-405b-8db7-55b0ff3eccf5.png">

スケジュールを定義します。
今回は毎日9時と17時（日本時間）に通知してほしいため、「0 0,8 ? * * *」と定義しました。
定義が完了したら、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/4854df69-1af1-48a0-8b9a-cdb9de72b12f.png">

ターゲットとして、手順4.5で作成したLambda関数を選択します。
選択が完了したら、「次へ」をクリックします。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570295/5175ba22-5d99-4bfb-aae4-25e5206d4232.png">

必要であれば、タグも追加してください。
ここまでの設定項目を確認し、「ルールを作成」をクリックします。

# 5. テストと検証
システム全体が正しく動作することを確認するために、以下のテストを実施します。
## 5.1. Lambda関数の動作確認
- 手動テスト
Lambdaコンソールから手動で関数を実行し、Teamsにメッセージが投稿されるか確認します。
- ログの確認
CloudWatch LogsでLambda関数のログを確認し、エラーがないことを確認します。

## 5.2. EventBridgeの動作確認
- スケジュールテスト
EventBridgeのルールを手動でトリガーし、Lambda関数が正しく起動するか確認します。
- 定期実行の確認
実際のスケジュールに基づいて定期的に実行されることを確認します。

## 5.3. 通知内容の検証
- メッセージの内容確認
Teamsに投稿されるメッセージが正確な利用料金データを反映しているか確認します。
- フォーマットの確認
メッセージのフォーマットが読みやすく、必要な情報が含まれているか確認します。

# 6. まとめ
本記事では、EventBridge、Lambda、Power Automateを使用して、AWSの利用料金をTeamsに自動投稿するシステムの構築方法について解説しました。
本記事が少しでもお役に立てれば幸いです。