Azure の使用料金を Teams の チャネル に POST してみました 【Subscription-Daily編】


## 概要
[この記事（Python で Azure の サブスクリプション単位で使用料金 を取得してみました）](https://qiita.com/turupon/items/c0e9796c27dec6b04f93) のPythonプログラムを Azure Functions に登録して自動実行させ、その結果を会社の情報共有ツールとして利用している Temas のチャネルに POST し、少しでもAzureの使用料金削減につながれば、、、、、、と。

### 実行環境
macOS Big Sur 11.1 
python 3.8.3
Azure CLI 2.28.0
Azure Functions Core Tools 3.0.3785 
Azure Function Runtime Version: 3.2.0.0

---

## 事前の準備

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

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

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

## 最初は２年で作成します（ロール割当なしで）
$ 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-4633-8080-xxxxxxxxxxxx",      --> 関数アプリの構成で AZURE_CLIENT_ID として登録
  "displayName": "<ServicePrincial名>",
  "name": "xxxxxxxx-xxxx-4633-8080-xxxxxxxxxxxx",
  "password": "hogehogehogehogehogehogehogehogege",     --> 関数アプリの構成で AZURE_CLIENT_SECRET として登録
  "tenant": "zzzzzzzz-cccc-4645-5757-zzzzzzzzzzzz"      --> 関数アプリの構成で AZURE_TENANT_ID として登録
}

## 必要なスコープに必要なロールを割り与えます
## 今回は複数Subscriptionの利用料金を取得したいので、スコープ：Subscription　ロール：Reader とします
$ APP_ID=$(az ad sp list --display-name <ServicePrincial名> --query '[].{ID:appId}' --output tsv)
$ az role assignment create --assignee $APP_ID --scope /subscriptions/<xxx-SubscriptionID> --role Reader
$ az role assignment create --assignee $APP_ID --scope /subscriptions/<yyy-SubscriptionID> --role Reader
$ az role assignment create --assignee $APP_ID --scope /subscriptions/<zzz-SubscriptionID> --role Reader
```

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

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

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

```
## 使用サブスクリプションを定義します
$ 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

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

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

### 関数アプリの実行結果を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 Functions
(base)$ cd Functions

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

## Functionのプロジェクトの作成
(.venv) (base)$ func init CostSummary --python
Writing requirements.txt
Writing .funcignore
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /Users/ituru/MyDevelops/AzureCostManagement/Functions/CostSummary/.vscode/extensions.json

## Functionの作成
(.venv) (base)$ cd CostSummary
(.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] CostSummaryDaily
Writing /Users/ituru/MyDevelops/AzureCostManagement/Functions/CostSummary/CostSummaryDaily/readme.md
Writing /Users/ituru/MyDevelops/AzureCostManagement/Functions/CostSummary/CostSummaryDaily/__init__.py
Writing /Users/ituru/MyDevelops/AzureCostManagement/Functions/CostSummary/CostSummaryDaily/function.json
The function "CostSummaryDaily" was created successfully from the "Timer trigger" template.

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

2 directories, 10 files
```

---

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

### 実行プログラム

```python:./CostSummaryDaily/__init__.py
import logging
import azure.functions as func
import os
import time
import json
from datetime import datetime, timezone, timedelta
from azure.identity import DefaultAzureCredential
from azure.mgmt.resource import SubscriptionClient
from azure.mgmt.costmanagement import CostManagementClient
import requests
import pandas as pd


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


# 接続しているテナントのサブスクリプションを操作するオブジェクトを取得
def GetSubscriptionObject():
    subscription_client = SubscriptionClient(
        credential=DefaultAzureCredential()
    )
    return subscription_client


# CostManagement情報 を操作するオブジェクトを取得
def GetCostManagementObject():
    costmgmt_client = CostManagementClient(
        credential=DefaultAzureCredential()
    )
    return costmgmt_client


# 指定した Subscription について CostManagement からコストを取得
def GetCostManagement(costmgmt_client, subs_id):

    # Query costmanagement
    SCOPE = '/subscriptions/{}'.format(subs_id)
    costmanagement = costmgmt_client.query.usage(
        SCOPE,
        {
            "type": "Usage",
            "timeframe": "MonthToDate",
            "dataset": {
                "granularity": "None",
                "aggregation": {
                    "totalCost": {
                        "name": "PreTaxCost",
                        "function": "Sum"
                    }
                },
                "grouping": [
                    {
                        "type": "Dimension",
                        "name": "ResourceGroup"
                    }
                ]
            }
        }
    )
    return costmanagement


# サブスクリプションIDを指定しリソースグループ毎に CostManagement情報を取得
def GetSubscriptionCsotManagement(day0):

    # サブスクリプションを操作するオブジェクトの取得
    subscription_list = GetSubscriptionObject()    

    # CostManagementを操作するオブジェクトの取得
    costmgmt_client = GetCostManagementObject()

    # 取得コストの キーと値
    row_key = ["Subscription", "UsageCost"]
    row_value = []

    # サブスクリプション毎に CostManagement からコストを取得
    for n, subs in enumerate(subscription_list.subscriptions.list()):
        logging.info("\nサブスクリプション ： {}".format(subs.display_name))
        costmanagement = GetCostManagement(costmgmt_client, subs.subscription_id)

        # rowsカラムデータを取得し、コストの合計値の取得
        SubTotalCost = sum(cost[0] for cost in costmanagement.rows)

        # 表示させるコストデータのリスト化
        if SubTotalCost > 0 :   
            val = [subs.display_name, round(SubTotalCost)]
            row_value.append(val)

    # 取得したコスト情報を辞書型に変換
    row_dict = [dict(zip(row_key,item)) for item in row_value]

    # コストで降順ソートする
    rows = sorted(row_dict, key=lambda x:x['UsageCost'], reverse=True)
    logging.info(rows)

    # 取得したデータを DataFrame化から markdown 形式に変換
    df=pd.DataFrame(rows)
    teames_post_str = df.to_markdown()

    # コストの合計値の取得
    TotalCost = sum(cost[1] for cost in row_value)
    logging.info("\n コスト合計: ¥{:,}".format(TotalCost) + "\n")
    costmgmt_client.close()
    subscription_list.close()

    # TeamsEndpointへのPOST
    teams_endpoint_post_summary(teames_post_str, '{:,}'.format(TotalCost), day0)


# TeamsEndpointへのデータPOST（まとめ）
def teams_endpoint_post_summary(teames_post_str, TotalCost, day0):
    # Microsoft Teams へ送信する下ごしらえ
    request = {
        'title':  '【 Azure : ' + day0 + ' 】 合計： ¥ ' + TotalCost,
        'text': teames_post_str
    }

    # 環境変数の確認
    try :
        endpointurl = TEAMS_ENDPOINT['TECH_ALL']    
    except KeyError :
        logging.info("\n ENDPOINTの環境変数が定義されていません！")
        return

    # Microsoft Teams へ送信する
    response = requests.post(endpointurl, json.dumps(request))
    logging.info(response)


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 (func-CostSummary) ran at %s ■ ■ ■', today)
   
    start = time.time()
    GetSubscriptionCsotManagement(today.strftime("%Y-%m-%d"))
    generate_time = time.time() - start

    logging.info("処理時間:{0}".format(generate_time) + " [sec]")
```

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

```requirements.txt
azure-functions
azure-identity
azure-mgmt-resource
azure-mgmt-costmanagement
tabulate
requests
pandas
```

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

毎日 17:20:00（JST）（毎日 8:20:00（UTC））　に実行

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

---

## ローカル環境でのテスト

### プログラムの実行
 
```
## 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 = *****

Connection Strings:

## ローカル環境での実行
(.venv) (base)$ source ../.venv/bin/activate
(.venv) (base)$ func start --verbose
Found Python version 3.8.3 (python3).

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


Azure Functions Core Tools
Core Tools Version:       3.0.3785 Commit hash: db6fe71b2f05d09757179d5618a07bba4b28826f  (64-bit)
Function Runtime Version: 3.2.0.0
         :
        省略
         :
[2021-10-02T03:48:00.398Z] Host started (526ms)
[2021-10-02T03:48:00.398Z] Job host started
[2021-10-02T03:48:04.946Z] Host lock lease acquired by instance ID '000000000000000000000000FFFFFFFF'.
         :
        省略
         :
```

---

## Azureへのデプロイ

### デプロイの実行
 
```
(.venv) (base)$ func azure functionapp publish <Functions名>
Uploading 7.01 KB [###############################################################################]
Remote build in progress, please wait...
         :
        省略
         :
Uploading built content /home/site/artifacts/functionappartifact.squashfs for linux consumption function app...
Resetting all workers for func-costsummary.azurewebsites.net
Deployment successful.
Remote build succeeded!
Syncing triggers...
Functions in func-CostSummary:
    CostSummaryDaily - [timerTrigger]
```

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

---

### まとめ
いろいろと手こずりましたが、無事 Azure Functions で自作のPythonプログラムを稼働させることができました。Azure Function は手間がかかって、、、、、AWSのサクッと感がほしいです。

### 参考記事
以下の記事を参考にさせていただきました。感謝申し上げます。
[MacにPython3をインストールし環境構築【決定版】](https://qiita.com/7110/items/1aa5968022373e99ae28)