AWS の請求金額を Teams の チャネル に POST してみました 【Account毎-Daily編】


## 概要

[この記事（Pythonで CostExplorer を利用してアカウント毎の請求情報を Teams に POST してみました）](https://qiita.com/turupon/items/5c58e013dbdf5f4a7de9) のPythonプログラムを Azure Functions に登録して自動実行させ、その結果を会社の情報共有ツールとして利用している Temas のチャネルに POST し、少しでもAWSの利用料金削減につながれば、、、、、、と。

### 実行環境

macOS Monterey 12.3.1 
python 3.8.12
Azure CLI 2.34.1
Azure Functions Core Tools 4.0.3971 

---

## 事前の準備

### Azure上で必要なものを作成

```
## 使用するテナントのAzure環境へのログイン
$ az login --tenant <tenant_id>

## 使用サブスクリプションを定義します
$ az account set --subscription '<Subscription名>'

## リソースグループの作成
$ az group create --resource-group <ResourceGroup名> --location japaneast

## ストレージアカウントの作成
$ az storage account create --name <StorageAccount名> --resource-group <ResourceGroup名> --location japaneast --sku Standard_LRS

## ストレージ・コンテナの作成
$ az storage container create --account-name <StorageAccount名> --name <container名>
```

### 実行するプログラム用の ServicePrincipal の作成

[この記事](https://jpazureid.github.io/blog/azure-active-directory/azuread-clientsecrets-202104/) にありますように、ServicePrincipal のクライアントシークレットの有効期限はデフォルトで１年、最大２年までの設定となります。そこで Azure CLI から以下のような手順で、有効期限 29年 での ServicePrincial を作成します（現時点では問題なく作成できました）。

```
## 最初は２年で作成します（ロール割当なしで）
$ az ad sp create-for-rbac --name <ServicePrincial名> --skip-assignment --years 2

## 作成された内容を取得できることを確認します（いきなり29年で作成した場合、取得できません、、、）
$ az ad sp list --display-name <ServicePrincial名>

## その後、29年で再作成します（ロール割当なしで）
$ az ad sp create-for-rbac --name <ServicePrincial名> --skip-assignment --years 29
{
  "appId": "xxxxxxxx-xxxx-3333-8888-xxxxxxxxxxxx",      --> 関数アプリの構成で AZURE_CLIENT_ID として登録
  "displayName": "<ServicePrincial名>",
  "name": "xxxxxxxx-xxxx-4633-8080-xxxxxxxxxxxx",
  "password": "hogehogehogehogehogehogehogehogege",     --> 関数アプリの構成で AZURE_CLIENT_SECRET として登録
  "tenant": "zzzzzzzz-cccc-4444-7757-zzzzzzzzzzzz"      --> 関数アプリの構成で AZURE_TENANT_ID として登録
}

## 必要なスコープに必要なロールを割り与えます
### スコープ：Subscription　ロール：Reader
$ APP_ID=$(az ad sp list --display-name <ServicePrincial名> --query '[].{ID:appId}' --output tsv)
$ SUB_ID=$(az account list --query "[?isDefault].id" -o tsv)
$ az role assignment create --assignee $APP_ID --scope /subscriptions/$SUB_ID --role Reader

### スコープ：containers　ロール：Contributor
$ az role assignment create \
    --assignee $APP_ID \
    --role "Storage Blob Data Contributor" \
    --scope /subscriptions/$SUB_ID/resourceGroups/<ResouceGroup名>/providers/Microsoft.Storage/storageAccounts/<StorageAccount名>/blobServices/default/containers/<containers名>
```

### プログラムの実行結果を通知するためのTeams設定

Teamsのチャネルにて、実行結果を通知してもらうために、[この記事](https://qiita.com/SDN/items/3754ae1e8960df01cc11) の「事前準備」の手順を実施し、Webhook の設定を行います。このときに発行される WebhookのURL をコピーしておきます。このURLは、関数アプリの構成で ENDPOINT_TECH として登録するときに必要となります。

### Functionsの作成

```
## Functions（関数アプリ）の作成
$ az functionapp list-runtimes
$ az functionapp create ---resource-group <ResourceGroup名> --name <Functions名> --storage-account <StorageAccount名> --runtime python --runtime-version 3.8 --consumption-plan-location japaneast --os-type Linux --functions-version 4

## 作成した関数アプリの構成設定
### 関数アプリ実行のための ServicePrincipal 情報の定義
$ az functionapp config appsettings set -n <Functions名> -g <ResourceGroup名> \
    --settings "AZURE_TENANT_ID=zzzzzzzz-cccc-4444-7777-zzzzzzzzzzzz"
$ az functionapp config appsettings set -n <Functions名> -g <ResourceGroup名> \
    --settings "AZURE_CLIENT_ID=xxxxxxxx-xxxx-3333-8888-xxxxxxxxxxxx"
$ az functionapp config appsettings set -n <Functions名> -g <ResourceGroup名> \
    --settings "AZURE_CLIENT_SECRET=hogehogehogehogehogehogehogehogege"

### AWS CostExplorer を親アカウントから実行するための IAM 情報の定義
$ az functionapp config appsettings set -n AWS-CostDaily -g rg-AWSCostExplorer \
    --settings "AWS_COST_ALL_ID=AAAAAAAAAAAAAAAAAAAA"
$ az functionapp config appsettings set -n AWS-CostDaily -g rg-AWSCostExplorer \
    --settings "AWS_COST_ALL_KEY=XXXXXXXXXXXXXXX/YYYYYYYYYYYYY/ZZZZZZZZZZ"

### 関数アプリの実行結果をTeamsのチャネルにPOST（Webhook)するための定義
$ az functionapp config appsettings set -n <Functions名> -g <ResourceGroup名> \
    --settings "ENDPOINT_TECH=https://nnn.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz"

### 定義情報の確認（ランタイム バージョンの表示）
$ az functionapp config appsettings list -n <Functions名> -g <ResourceGroup名> -o table
```

### Functionsのためのローカル環境の作成

```
## Functionのプロジェクトディレクトリの作成
(base)$ mkdir Azure_Functions
(base)$ cd Azure_Functions
(base)$ mkdir CostDaily
(base)$ cd CostDaily

## プロジェクト用のPython仮想環境の構築
(base)$ python -m venv .venv
(.venv) (base)$ source .venv/bin/activate
(.venv) (base)$ python --version
Python 3.8.12

## Functionのプロジェクトの作成
(.venv) (base)$ func init CostPerAccount --python
Found Python version 3.8.12 (python3).
Writing requirements.txt
Writing .funcignore
Writing getting_started.md
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /Users/ituru/MyDevelops/AWS_CostExplorer/Azure_Functions/CostDaily/CostPerAccount/.vscode/extensions.json

## Functionの作成
(.venv) (base)$ cd CostPerAccount
(.venv) (base)$ func new                           
Select a number for template:
1. Azure Blob Storage trigger
2. Azure Cosmos DB trigger
3. Durable Functions activity
4. Durable Functions entity
5. Durable Functions HTTP starter
6. Durable Functions orchestrator
7. Azure Event Grid trigger
8. Azure Event Hub trigger
9. HTTP trigger
10. Kafka output
11. Kafka trigger
12. Azure Queue Storage trigger
13. RabbitMQ trigger
14. Azure Service Bus Queue trigger
15. Azure Service Bus Topic trigger
16. Timer trigger
Choose option: 16
Timer trigger
Function name: [TimerTrigger] CostPerAccountDaily
Writing /Users/ituru/MyDevelops/AWS_CostExplorer/Azure_Functions/CostDaily/CostPerAccount/CostPerAccountDaily/readme.md
Writing /Users/ituru/MyDevelops/AWS_CostExplorer/Azure_Functions/CostDaily/CostPerAccount/CostPerAccountDaily/__init__.py
Writing /Users/ituru/MyDevelops/AWS_CostExplorer/Azure_Functions/CostDaily/CostPerAccount/CostPerAccountDaily/function.json
The function "CostPerAccountDaily" was created successfully from the "Timer trigger" template.

## ディレクトリ構成の確認
(.venv) (base)$ tree -a      
.
├── .funcignore
├── .gitignore
├── .vscode
│   └── extensions.json
├── CostPerAccountDaily
│   ├── __init__.py
│   ├── function.json
│   └── readme.md
├── getting_started.md
├── host.json
├── local.settings.json
└── requirements.txt

2 directories, 10 files
```

---

## 実行するPythonプログラムの組込み

### 実行プログラム

```python:./CostPerAccountDaily/__init__.py
import logging
import azure.functions as func
import os
import time
from datetime import datetime, timedelta, date, timezone
import boto3
import pandas as pd
import json
import requests
from pandas_datareader.data import get_quote_yahoo


# teams_endpoint = 'Microsoft Teamsの チャネルエンドポイント(Webhook)'
TEAMS_ENDPOINT = {
    "TECH_ALL": os.environ['ENDPOINT_TECH']
}

# 親アカウントの認証情報
ACCOUNT_ENV = {
    "BILLING_ID": os.environ['AWS_COST_ALL_ID'],
    "BILLING_KEY": os.environ['AWS_COST_ALL_KEY']
}


# 実行月の初日を取得
def get_begin_of_month() -> str:
    return date.today().replace(day=1).isoformat()

# 実行日を取得
def get_today() -> str:
    return date.today().isoformat()

# 実行日の前日取得
def get_yesterday() -> str:
    return (date.today()- timedelta(1)).isoformat()

# 請求金額取得対象期間の取得
def get_total_cost_date_range() -> (str, str):
    start_date = get_begin_of_month()
    end_date = get_today()

    # get_cost_and_usage()のstartとendに同じ日付は指定不可のため、
    # 「今日が1日」なら、「先月1日から今月1日（今日）」までの範囲にする
    if start_date == end_date:
        end_of_month = datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=-1)
        begin_of_month = end_of_month.replace(day=1)
        return begin_of_month.date().isoformat(), end_date
    return start_date, end_date


# 請求金額データの取得
def get_boto3_session():
    session = boto3.session.Session(aws_access_key_id=ACCOUNT_ENV['BILLING_ID'], aws_secret_access_key=ACCOUNT_ENV['BILLING_KEY'])
    return session

# 請求金額データの取得
def get_billing_data(session):

    # データ取得期間の取得
    (start, end) = get_total_cost_date_range()

    # 請求データ（アカウント毎の日々の請求データ）の取得
    client = session.client('ce')
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': start,
            'End' :  end,
        },
        Granularity='DAILY',
        Metrics=[
            'NetUnblendedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'LINKED_ACCOUNT'
            }
        ]
    )
    # pprint.pprint(response)
    return response


# 組織の全アカウント情報の取得：辞書型
def get_organization_accounts(session):

    # 親組織へ接続する
    organizations = session.client('organizations')

    # 全組織のアカウント情報を取得する
    pagenator = organizations.get_paginator('list_accounts')
    response_iterator = pagenator.paginate()

    # アカウント情報用リストの定義
    row_id = []
    row_name = []

    # 各アカウントの Account ID と Account Name の取得
    for response in response_iterator:
        for acct in response['Accounts']:

            # 取得するアカウント情報のリスト化
            row_id.append(acct['Id'])
            row_name.append(acct['Name'])

    # 取得した全アカウント情報を辞書型へ
    accounts = pd.DataFrame({"Id":row_id, "Name":row_name})
    return accounts


#　組織の全Accountの請求金額データの集計
def get_account_all_billings(crate):

    # boto3 セッションオープン
    session = get_boto3_session()

    # 組織の全アカウント情報の取得
    accounts = get_organization_accounts(session)

    # 請求金額データの取得
    response = get_billing_data(session)

    # 整形する請求データの入れ物
    merged_cost = pd.DataFrame(
        columns=['Id']
    )

    # 請求データの整形
    for item in response['ResultsByTime']:

        # ['Groups']のデータ取得（アカウント毎の請求データ）
        normalized_json = pd.json_normalize(item['Groups'])

        # アカウントIDの取得
        split_keys = pd.DataFrame(
            normalized_json['Keys'].tolist(),
            columns=['Id']
        )
        
        # アカウントID毎の請求金額の取得
        cost = pd.concat(
            [split_keys, normalized_json['Metrics.NetUnblendedCost.Amount']],
            axis=1
        )

        # 請求金額データのカラムタイプの変更 object -> float
        cost['Metrics.NetUnblendedCost.Amount'] = cost['Metrics.NetUnblendedCost.Amount'].astype('float')
        # 請求金額を日本円に変換
        cost['Metrics.NetUnblendedCost.Amount'] = round(cost['Metrics.NetUnblendedCost.Amount']*crate, 0)

        # ['TimePeriod']['Start']のデータ取得（請求日データ）
        renamed_cost = cost.rename(
            columns={'Metrics.NetUnblendedCost.Amount': item['TimePeriod']['Start']}
        )

        # アカウント毎に請求データと請求日データのマージ（右側への列追加）
        merged_cost = pd.merge(merged_cost, renamed_cost, on='Id', how='right')

    # アカウントIDをキーにしてアカウント名を列結合
    merged_cost = pd.merge(merged_cost, accounts, on="Id", how="left")
    # アカウント名の列を先頭列に作成
    merged_cost.insert(loc=0, column='AccountName', value=merged_cost['Name'])
    # 不要カラムの削除
    merged_cost.drop(['Id', 'Name'], axis=1, inplace=True)

    # 各行の合計を最後の列に追加
    merged_cost['Total'] = round(merged_cost.sum(numeric_only=True, axis=1), 0) 

    # ['Total']列からか請求金額の総合計を求める
    grand_total = round(merged_cost['Total'].sum(), 0)  

    # 合計で降順ソートし、インデックスをリセットする
    merged_cost.sort_values(by='Total',ascending=False, inplace=True)
    merged_cost.reset_index(drop=True, inplace=True)

    return merged_cost, grand_total


# 請求金額データの通知表示への変更
def post_billings(merged_cost, grand_total, crate):

    # タイトルメッセージの編集
    title_msg = "総合計 : " + "{:,}".format(grand_total) + "　　　　USD-JPY : " +  str(crate)
    logging.info(title_msg)

    # POSTするためのデータ分割
    cost_df_1 = merged_cost.iloc[:8,:]
    cost_df_2 = merged_cost.iloc[8:,:]
    # print(cost_df_1, "\n")
    # print(cost_df_2, "\n")

    # インデックスとしてアカウント名を指定
    merged_cost.set_index("AccountName", inplace=True)
    cost_df_1.set_index("AccountName", inplace=True)
    cost_df_2.set_index("AccountName", inplace=True)

    # 行と列の入替え
    merged_cost = merged_cost.transpose()
    cost_df_1 = cost_df_1.transpose()
    cost_df_2 = cost_df_2.transpose()
    
    # Total行（最後の行）の取得 = アカウント毎の請求合計の一覧データの取得
    df_total = merged_cost.tail(1)
    df_total = df_total.transpose()

    # インデックス（日付）で降順ソートする
    cost_df_1.sort_index(ascending=False, inplace=True)
    cost_df_2.sort_index(ascending=False, inplace=True)

    # POSTするためにマークダウン形式に変更
    post_str0 = df_total.to_markdown(floatfmt=",.0f")
    post_str1 = cost_df_1.to_markdown(floatfmt=",.0f")
    post_str2 = cost_df_2.to_markdown(floatfmt=",.0f")

    # TeamsEndpointへのPOST（請求データはマークダウン形式で渡す）
    teams_endpoint_post_summary(post_str0, post_str1, post_str2, title_msg, get_yesterday())


# TeamsEndpointへのデータPOST
def teams_endpoint_post_summary(str0, str1, str2, title_mdg, day0):
    # Microsoft Teams へ送信する下ごしらえ
    request = {
        'title': '【 AWS : ' + day0 + ' 】　' + title_mdg,
        'text': str0
    }

    # 累計請求情報を Microsoft Teams へ送信する
    response = requests.post(TEAMS_ENDPOINT['TECH_ALL'], json.dumps(request))
    logging.info(response)

    # アカウント毎の日別請求情報（上位８アカウント分）を Microsoft Teams へ送信する
    request = {'text': str1}
    response = requests.post(TEAMS_ENDPOINT['TECH_ALL'], json.dumps(request))
    logging.info(response)

    # アカウント毎の日別請求情報（上位９アカウント目以降）を Microsoft Teams へ送信する
    request = {'text': str2}
    response = requests.post(TEAMS_ENDPOINT['TECH_ALL'], json.dumps(request))
    logging.info(response)


# 現在の USD - JPY の為替レートの取得
def get_currency_rate() :
    res1 = get_quote_yahoo('USDJPY=X')
    res2 = res1["price"].values
    crate = res2[0] 
    return crate


# メイン
def main(mytimer: func.TimerRequest) -> None:
    # 日本時間の取得
    JST = timezone(timedelta(hours=+9), 'JST')
    today = datetime.now(JST)

    if mytimer.past_due:
        logging.info('■ ■ ■ The timer is past due! ■ ■ ■')

    logging.info('■ ■ ■ Python timer trigger function (AWS-CostPerAccountDaily) ran at %s ■ ■ ■', today)
   
    # 処理の開始
    start = time.time()

    # 今の為替レートを取得する
    crate = get_currency_rate()
    # 組織の全Accountの請求金額データの日別集計
    merged_cost, grand_total = get_account_all_billings(crate)
    # 請求金額データの通知表示への変更
    post_billings(merged_cost, grand_total, crate)
    
    # 処理の終了
    generate_time = time.time() - start

    # 処理時間をロギング
    logging.info("処理時間:{0}".format(generate_time) + " [sec]")
```

### 実行に必要なPythonパッケージの定義

```./requirements.txt
azure-functions
boto3
requests
pandas
pandas-datareader
tabulate
```

---

## Azure Functions をローカル環境でテスト


### 実行スケジュール

タイマートリガーの設定変更を行わず、デフォルト設定値の通り ５分に１回実行されます。

```./CostPerAccountDaily/function.json
{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "name": "mytimer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 */5 * * * *"
    }
  ]
}
```

### プログラムの実行

```
## Pythonパッケージのインストール
(.venv) (base)$ pip install -r requirements.txt

##  Azure Functions 構成情報のローカルに設定
(.venv) (base)$ func azure functionapp fetch-app-settings <Functions名>
App Settings:
Loading FUNCTIONS_WORKER_RUNTIME = *****
Loading FUNCTIONS_EXTENSION_VERSION = *****
Loading AzureWebJobsStorage = *****
Loading WEBSITE_CONTENTAZUREFILECONNECTIONSTRING = *****
Loading WEBSITE_CONTENTSHARE = *****
Loading APPINSIGHTS_INSTRUMENTATIONKEY = *****
Loading AZURE_TENANT_ID = *****
Loading AZURE_CLIENT_ID = *****
Loading AZURE_CLIENT_SECRET = *****
Loading ENDPOINT_TECH = *****
Loading AWS_COST_ALL_ID = *****
Loading AWS_COST_ALL_KEY = *****

Connection Strings:

## ローカル環境での実行 --- デフォルト:５分に１回実行されます
(.venv) (base)$ source ../.venv/bin/activate
(.venv) (base)$ func start --verbose
Found Python version 3.8.12 (python3).

                  %%%%%%
                 %%%%%%
            @   %%%%%%    @
          @@   %%%%%%      @@
       @@@    %%%%%%%%%%%    @@@
     @@      %%%%%%%%%%        @@
       @@         %%%%       @@
         @@      %%%       @@
           @@    %%      @@
                %%
                %


Azure Functions Core Tools
Core Tools Version:       4.0.3971 Commit hash: d0775d487c93ebd49e9c1166d5c3c01f3c76eaaf  (64-bit)
Function Runtime Version: 4.0.1.16815
         :
        省略
         :
[2022-08-31T04:31:04.709Z] Host started (558ms)
[2022-08-31T04:31:04.710Z] Job host started
[2022-08-31T04:31:04.753Z] Executing 'Functions.CostPerAccountDaily' (Reason='Timer fired at 2022-08-31T13:31:04.7144780+09:00', Id=e9e75082-099b-46fc-aaee-1d971a46203e)
[2022-08-31T04:31:04.755Z] Trigger Details: UnscheduledInvocationReason: IsPastDue, OriginalSchedule: 2022-08-31T13:30:00.0000000+09:00
[2022-08-31T04:31:04.895Z] Successfully processed FunctionLoadRequest, request ID: 163911ca-f12d-4f91-b261-82e2ca5e32bd, function ID: 5c20178f-ad5c-48da-8906-18dc5df970d9,function Name: CostPerAccountDaily
[2022-08-31T04:31:04.924Z] Received FunctionInvocationRequest, request ID: 163911ca-f12d-4f91-b261-82e2ca5e32bd, function ID: 5c20178f-ad5c-48da-8906-18dc5df970d9, function name: CostPerAccountDaily, invocation ID: e9e75082-099b-46fc-aaee-1d971a46203e, function type: sync, sync threadpool max workers: 1000
[2022-08-31T04:31:04.933Z] ■ ■ ■ The timer is past due! ■ ■ ■
[2022-08-31T04:31:04.934Z] ■ ■ ■ Python timer trigger function (AWS-CostPerAccountDaily) ran at 2022-08-31 13:31:04.922844+09:00 ■ ■ ■
         :
    プログラムが実行されると、、、、
         :
[2022-08-31T04:31:07.530Z] 総合計 : 352,353.0　　　　USD-JPY : 138.512
[2022-08-31T04:31:09.219Z] Host lock lease acquired by instance ID '0000000000000000000000002CF30641'.
[2022-08-31T04:31:10.197Z] <Response [200]>
[2022-08-31T04:31:12.317Z] <Response [200]>
[2022-08-31T04:31:14.802Z] <Response [200]>
[2022-08-31T04:31:14.803Z] 処理時間:9.87919807434082 [sec]
[2022-08-31T04:31:14.838Z] Executed 'Functions.CostPerAccountDaily' (Succeeded, Id=e9e75082-099b-46fc-aaee-1d971a46203e, Duration=10111ms)

※ CTLR+C で処理を中断します。
```

結果に問題なければ、以下の Azureへのデプロイにすすんでください。

---

## Azureへのデプロイ

### 実行スケジュール（タイマートリガー）の設定

毎日 17:20:00（JST）（毎日 8:25:00（UTC））　に実行するように変更します。

```./CostPerAccountDaily/function.json
{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "name": "mytimer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 20 8 * * *"
    }
  ]
}
```

### デプロイの実行
 
```
## Pythonパッケージのインストール
(.venv) (base)$ func azure functionapp publish <Functions名>
Getting site publishing info...
Creating archive for current directory...
Performing remote build for functions project.
Deleting the old .python_packages directory
Uploading 9.65 KB [###############################################################################]
Remote build in progress, please wait...
         :
        省略
         :
Uploading built content /home/site/artifacts/functionappartifact.squashfs for linux consumption function app...
Resetting all workers for aws-costdaily.azurewebsites.net
Deployment successful. deployer = Push-Deployer deploymentPath = Functions App ZipDeploy. Extract zip. Remote build.
Remote build succeeded!
Syncing triggers...
Functions in AWS-CostDaily:
    CostPerAccountDaily - [timerTrigger]
```

あとは、時間が来たら、Teamsの所定のチャネルに 毎日AWSの利用料金の通知が飛んでくることを確認するだけです。

---

### まとめ

AWSの請求金額通知なのに、、使いにくいけれど使い慣れた Azure Functions で自作のPythonプログラムを稼働させることができました。今度は、同じプログラムを AWS Lambda で実装してみたいと思います。

### 参考記事
以下の記事を参考にさせていただきました。
[Azure Functions ランタイム バージョンをターゲットにする方法](https://docs.microsoft.com/ja-jp/azure/azure-functions/set-runtime-version?tabs=azurecli)
[Azure Functions をローカルでコーディングしてテストする](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-develop-local)
[Azure Functions Core Tools の操作](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-run-local?tabs=v4%2Cmacos%2Ccsharp%2Cportal%2Cbash)