Azure の予算消費状況を Slack へ通知する

# やりたいこと
弊社では、パブリッククラウドとして基本的に Azure を採用しています。メンバーの自己学習やちょっとした検証用に、サブスクリプションを用意しており各自の予算枠が設定されています。各自のリソースグループで予算を設定して、予算の消費額や予想額がしきい値を超えた場合にメールで通知するように設定していますが、しきい値の設定次第では時既に遅し的なことになることがあります。
日々の消費額を Azure Portal で確認するのも手間なので、Slack の任意のチャンネルに通知する仕組みをつくってみました。この記事ではその内容を紹介します。

# 構成
といっても Function App で予算の消費状況を Python SDK で取得して、Slack へ Webhook で通知するだけです。Function App は Timer Trigger の関数を作成し、1日1回 Slack へ通知するようにしています。

```mermaid
graph LR
  FunctionApp-->|post to a channel|Slack
  FunctionApp-->|get bedget status|Budget
```

# Azure リソース
作成するリソースは以下です。Application Insights はデバッグ用途なので必須ではありませんが作っておくと便利です。
* Function App
    * Python コード実行用
    * Timer Trigger の関数を作成し、1日1回 Slack へ Post します
* App Sercice Plan
    * Function App 用
* Storage Account
    * Function App 用
* Application Insights
    * Function App 用
    * 作業中の動作確認などでログを確認する時に使います

# リソース作成
Terraform で作成します。
インストールなどの導入手順は情報がたくさんあると思うので、ここでは書きません。Chat-GPT に聞いても答えてくれると思います！

使用する Provider などです。version は任意で設定してください （ここでは執筆時点の最新を使っています）。
``` main.tf
terraform {
  required_version = ">=0.1.2"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.47.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "2.36.0"
    }      
  }
}

provider "azurerm" {
  feature {}
}

provider "azurerm" {
  features {}
}

locals {
  subscription_id = "SUBSCRIPTION_ID"
  rg_name         = "RESOURCE_GROUP_NAME"
  suffix          = "SUFFIX"
  identifier      = "APP_IDENTIFIER"

  slack_app = {
    name = "app-slack-cost-notification-${local.identifier}"
  }

  storageaccount = {
    functions = {
      name = join("", ["st", replace(local.suffix, "-", "")])
    }
  }

  app_services_plan = {
    functions = {
      name = "asp-${local.suffix}"
    }
  }

  application_insights = {
    name = "appi-${local.suffix}"
  }
}

data "azuread_client_config" "current" {}

data "azurerm_resource_group" "main_rg" {
  name = local.rg_name
}
```

Slack の際に、予算消費状況を取得するためサービスプリンシパルを作成します。`Billing Reader (請求閲覧者)` の組み込みロールを付与しています。
``` create_slack_notify_app.tf
resource "azuread_application" "slack_notification_app" {
  display_name = local.slack_app.name
  owners       = [data.azuread_client_config.current.object_id]
}

resource "azuread_application_password" "slcak_notification_pswd" {
  application_object_id = azuread_application.slack_notification_app.object_id
}

output "app_slack_notification_pswd" {
  value     = azuread_application_password.slcak_notification_pswd.value
  sensitive = true
}

resource "azuread_service_principal" "slack_notification_srv_prcpl" {
  application_id               = azuread_application.slack_notification_app.application_id
  app_role_assignment_required = false
  owners                       = [data.azuread_client_config.current.object_id]
}

resource "azurerm_role_assignment" "slack_notification_app_role" {
  scope                = "/subscriptions/${local.subscription_id}/resourceGroups/${local.rg_name}/"
  role_definition_name = "Billing Reader"
  principal_id         = azuread_service_principal.slack_notification_srv_prcpl.id
}
```

Python のコードを実行する Function App を作成します。
``` create_function.tf
resource "azurerm_storage_account" "st_func" {
  name                     = local.storageaccount.functions.name
  resource_group_name      = data.azurerm_resource_group.main_rg.name
  location                 = data.azurerm_resource_group.main_rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_service_plan" "srv_plan_func" {
  name                = local.app_services_plan.functions.name
  resource_group_name = data.azurerm_resource_group.main_rg.name
  location            = data.azurerm_resource_group.main_rg.location
  os_type             = "Linux"
  sku_name            = "Y1"
}

resource "azurerm_application_insights" "appi_func" {
  name                = local.application_insights.name
  location            = data.azurerm_resource_group.main_rg.location
  resource_group_name = data.azurerm_resource_group.main_rg.name
  application_type    = "other"
}

resource "azurerm_linux_function_app" "func_linux" {
  name                = "func-lin-${local.suffix}"
  resource_group_name = data.azurerm_resource_group.main_rg.name
  location            = data.azurerm_resource_group.main_rg.location

  storage_account_name       = azurerm_storage_account.st_func.name
  storage_account_access_key = azurerm_storage_account.st_func.primary_access_key
  service_plan_id            = azurerm_service_plan.srv_plan_func.id

  site_config {
    application_insights_connection_string = azurerm_application_insights.appi_func.connection_string
    application_insights_key               = azurerm_application_insights.appi_func.instrumentation_key

    application_stack {
      python_version = "3.9"
    }
  }
}
```

Terraform コードの準備ができたら、以下のコマンドでプロビジョニングします。
```
terraform init
terraform apply
```

# Python コードのデプロイ
リソースを作成できたので、Function App 上で動作する Python コードを作成します。今回作成したのは以下のような形です。
各環境変数は Function App の、Application Settings に設定しています。Slack の Webhook の URL は事前に設定して取得してください。
``` __init__.py
import datetime
import json
import logging
import math
import os
import requests
import azure.functions as func
from azure.identity import ClientSecretCredential
from azure.mgmt.consumption import ConsumptionManagementClient

TENANT_ID = os.environ["TENANT_ID"]
SUBSCRIPTION_ID = os.environ["SUBSCRIPTION_ID"]
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
RG_NAME = os.environ["RG_NAME"]
BUDGET_NAME = os.environ["BUDGET_NAME"]
WEB_HOOK_URL = os.environ["WEB_HOOK_URL"]


def main(mytimer: func.TimerRequest) -> None:
    utc_timestamp = datetime.datetime.utcnow().replace(
        tzinfo=datetime.timezone.utc).isoformat()
    logging.info('Python timer trigger function ran at %s', utc_timestamp)

    # login with service principal
    credential = ClientSecretCredential(tenant_id=TENANT_ID,
                                        client_id=CLIENT_ID,
                                        client_secret=CLIENT_SECRET)

    # create consumption management client
    consumption_client = ConsumptionManagementClient(
        credential=credential, subscription_id=SUBSCRIPTION_ID)

    # get budget
    budget = consumption_client.budgets.get(
                scope='/subscriptions/{}/resourceGroups/{}'.format(SUBSCRIPTION_ID, RG_NAME),
                budget_name=BUDGET_NAME)

    amount = math.floor(float(budget.amount))
    current_amount = math.floor(float(budget.current_spend.amount))
    unit = budget.current_spend.unit

    msg = f'''The amount of budget consumption in {RG_NAME} today is below:
{current_amount} / {amount} [{unit}]
    '''
    logging.info(msg)

    # post to slack channel
    requests.post(WEB_HOOK_URL, data = json.dumps({
        'text': msg,
        'username': 'Budget Consumption Notification',
        'link_names': 1
    }))
```

TimerTrigger なので、その設定は以下です。`schedule` の部分が Timer 設定で、指定は UTC で行います。以下の設定だと日本時間の午前9時に実行されます。
``` function.json
{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "name": "mytimer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 0 0 * * *"
    }
  ]
}

```

Function App の設定もひととおり行ったので、実際にデプロイします。デプロイには、Azure Function Core Tools を使います。事前にインストールが必要です。
```
func azure functionapp publish {FUNCTION_APP_NAME} --subscription {SUBSCRIPTION_ID}
```

デプロイが完了すると、指定した時刻に Slack に予算消費状況の通知がなされるようになります。

# ということで
Function App の TimerTrigger を使ってリソースグループ単位での予算消費状況を Slack へ通知する仕組みを作成しました。
HttpTrigger の関数をつくることは多いですが TimerTrigger は初めてだったので、今回で方法を確立できたかなと思います！

以上です。