Azure VMが長期間起動し続けていたらSlackに通知する

# はじめに
Azureに限った話しではないですが、何かしらの検証作業終了後に利用したVMを停止し忘れることがあります。
オンプレの仮想環境であれば後日気付いて停止すれば問題ないことが多いです。
が、Cloudの場合、システム的には問題なくてもVM利用料が課金され続けてしまいます。
利用料金が高額になって怒られたくないので稼働しっぱなしのVMがあったらSlackに通知して気付くようにしたいと思います。

# 使用サービス
今回はAzure Functionsでリソースの状態を確認する処理を作成するのですが、それに付随して別サービスも使用されます。
* Azure Functions
* Azure App Service
* Azure Insights
* Storage Account
* Log Analytics

Slackへの投稿にはSlackアプリのIncoming Webhookを使います。

# Incoming Webhookの設定
Slack app directoryから[Incoming Webhook](https://slack.com/apps/A0F7XDUAZ--incoming-webhook-?tab=more_info)を追加します。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/98a40d88-fdb1-d1f0-dc16-d8cc50106e61.png)

投稿するチャンネルを選択します。
今回は自分にDMです。
通知専用チャンネルを用意してもいいですね。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/5d0da9b4-7d84-fe53-ca42-a21d6dd1c055.png)

アプリの名前やアイコンを設定し、保存します。
Webhook URLはAzure Functions側で後ほど使用します。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/03780e78-7835-e784-03cc-9f5d396ac9e9.png)

# Azure Functionsの作成
Azure Functionsを作成していきます。
ランタイムは`PowerShell Core`を使用します。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/85545772-5a98-4590-34b0-25f21a73c9f0.png)

もろもろ選択していって、作成。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/740bde52-339c-9b56-3e9b-219b484b8f01.png)



## 関数の作成
日次で通知したいのでTimmer triggerにして毎日10:00に実行するようにします。
スケジュールは[NCRONTAB式](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-timer?tabs=in-process&pivots=programming-language-csharp#ncrontab-expressions)で指定します。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/481dfbda-ff43-b0d3-eafc-abaadb34803b.png)

実行処理を書いていきます。
一部環境変数（`$env:xxxxx`）になっている箇所はAzure Functionsの構成で設定した値を読み取っています。
設定は後ほど。
ついでに所有者なしのディスクも検知するようにします。

```powershell
# Input bindings are passed in via param block.
param($Timer)

# Azureにログイン
Connect-AzAccount -Identity -SubscriptionId $env:SUBSCRIPTIONID

# 起動中のVM取得
$runningVMs = Get-AzVM -Status | Where { $_.PowerState -eq "VM running" }

Write-Information "稼働中のVM：$(${runningVMs}.Name)"

# ログ開始日時取得
$startTime = (Get-Date).AddDays(-$env:LOG_RETENTION)

Write-Information "ログ参照開始日時：$(${startTime})"

# VM起動の成功ログを取得
$ActivityLogs = Get-AzLog -StartTime $startTime -ResourceProvider "Microsoft.Compute" -Status "Succeeded" | Where { $_.Authorization.Action -eq "Microsoft.Compute/virtualMachines/start/action" } | Sort -Descending $_.EventTimestamp

$AlertMessage = "*稼働中のVirtual Machine* `n"
$VMMessage = $null
# 起動中のVM毎に起動時間を調べる
foreach ($runVM in $runningVMs) {
    $ActivityLog = $ActivityLogs.Where({ $_.ResourceId -match "$(${runVM}.Name)" }) | Sort -Top 1

    Write-Information "$(${runVM}.Name)の稼働時間確認開始"

    # ログ保持期間を超えて起動している
    if (!$ActivityLog) {
        $VMMessage += "・$(${runVM}.Name)（$(${runVM}.HardwareProfile.VmSize)）：$(${env:LOG_RETENTION})日より前から起動しています。`n"
        Write-Information "$(${runVM}.Name)の起動ログが未存在"
        continue
    }

    $timeSpan = New-TimeSpan -Start $ActivityLog.EventTimestamp
    $eventTime = $ActivityLog.EventTimestamp.ToLocalTime().Tostring("yyyy/MM/dd HH:mm:ss")
    Write-Information "$(${runVM}.Name)の起動開始日時：${eventTime}"

    # 通知日数を超過
    if ($timeSpan.Days -ge $env:ALERT_DAY) {
        $VMMessage += "・$(${runVM}.Name)（$(${runVM}.HardwareProfile.VmSize)）： ${eventTime} から起動しています。（$(${timeSpan}.Days)日経過）`n"
        continue
    }

}

if (!$VMMessage) {
    $VMMessage = "・:innocent:対象なし`n"
}

Write-Information $VMMessage
$AlertMessage += $VMMessage

# 未割当のディスクを取得
$UnattachedDisks = Get-AzDisk | where { $_.DiskState -eq "Unattached" }
$DiskMessage = $null

foreach ($UnattachedDisk in $UnattachedDisks) {
    $DiskMessage += "・$(${UnattachedDisk}.Name) $(${UnattachedDisk}.DiskSizeGB)GB $(${UnattachedDisk}.Sku.Name)"
}

Write-Information $DiskMessage

$AlertMessage += "*未割当のDisk* `n"

if (!$DiskMessage) {
    $DiskMessage = "・:innocent:対象なし`n"
}

$AlertMessage += $DiskMessage
Write-Information $AlertMessage

# Slackにメッセージ投稿
$text = ConvertTo-Json @{text = $AlertMessage }
$body = [System.Text.Encoding]::UTF8.GetBytes($text)

Invoke-RestMethod -Method Post -Body $body -Uri "$(${env:SLACK_WEBHOOK})"

exit
```

## アプリファイル
デフォルトだとAzure Powershellが使用できないので`requirements.psd1`の`#`を削除してコメントを解除します。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/1f823ac4-25be-b18c-af74-339a2d42b100.png)


## 構成
通知させる閾値やサブスクリプションID等追加していきます。
* ALERT_DAY
    * VM稼働期間の日数指定（今回は起動中のVMが存在したらすべて通知するように`ALERT_DAY`を0日としています。）
* LOG_RETENTION
    * Activity Logの読み取り開始日（処理実行日の過去何日からログを読み取るかの指定）
* SLACK_WEBHOOK
    * メッセージをpostするWebhook URL
* SUBSCRIPTION_ID
    * 対象サブスクリプションID
* WEBSITE_TIME_ZONE
    * タイムゾーンの指定（Azure Functions（App serice）で用意されている設定）

![Functions_setting3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/f61ee907-b728-552f-2220-8ad9df010250.png)

## システムマネージドID
Azure FunctionsがVM等のリソースを見るためにマネージドIDで権限付与します。
今回はまるっとサブスクリプションに共同作成者を設定。
閲覧者でも良かったですね。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/078f410a-be2c-e908-8596-5bcdc1db9972.png)

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/038b03d5-aef9-2058-095a-654230d5b4cc.png)



# 実行してみる
関数 > コードとテスト からテスト実行できます。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/392ed328-d2b4-b95d-7aaa-9a87faec5952.png)

結果はこんな感じです。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/703ad6de-fa3c-47b8-2759-ed0b38af4b73.png)

状態が違うパターン。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/604991/4aa68d07-3a0f-3a56-83f3-dcfc18a250fa.png)

# おわりに
これで停止し忘れても安心です。