1. はじめに
部署のAzure検証環境を管理しています。検証環境ではメンバーが自由にリソースを作成できる分、検証終了後も削除されず、そのまま残り続けることが良くあります。
Azure Cost Management を使えばコスト状況は確認できますが、日常的にチェックする人は多くありません。全体に向けて削除を依頼しても、見る人は見る一方で、見ない人は見ないままです。
そこで今回は、リソースグループ単位のコスト情報を毎日メールで通知する仕組みを作成しました。利用者がコストを確認しに行かなくても自然と目に入る状態を作り、不要なリソースへの気付きを促すことが目的です。
本記事では Azure Automation の Runbook と Cost Management API を使い、リソースグループ単位のコストを毎日通知する仕組みを紹介します。
Azure の検証環境や開発環境において、コスト管理を効率化したい方の参考になれば幸いです。
2. 全体像
まず実際に届くメールと、それを動かしている構成を紹介します。
2-1. 通知内容
毎日17時に実行され、次の3点をHTMLメールで配信します。
- サブスクリプション全体の先月実績、今月実績、今月末予想
- リソースグループ別の今月実績と今月末予想(今月末予想が1万円を超えるものだけ、金額順)
- 過去90日累計のリソースグループ別コスト上位20件
2-2. システム構成
Runbookが処理の大半を担い、メール送信だけLogic Appsに任せています。Microsoft 365への認証をコネクタ側に寄せられるためです。
PowerShell でのスクリプトを想定していたため、Azure Functions ではなく、Az モジュールを標準で搭載している Automation Account にしていますが、特に機能やコストに差はないと思います。
3. 実装
実装した順に、Runbookの準備、コストの取得、集計、メール送信の4つに分けて説明します。
設定した機能やコード全ては解説していません。メイン部分のみピックアップしています。
3-1. Automation Account と Runbook の準備
Runbookは PowerShell 7 で作成し、Automation Account のシステム割り当てマネージドIDに次の権限を付与します。
| 権限 | スコープ | 用途 |
|---|---|---|
閲覧者 |
サブスクリプション | Cost Management API の呼び出し |
ロジック アプリの共同作成者 |
Logic Apps | コールバックURLの取得 |
組み込みロールには コスト管理の閲覧者 もありますが、これだとリソースグループ内が空かどうか確認できないため、閲覧者 を付与しています(4-4で後述)。Logic Apps 側はコールバックURLの取得のために付けています。
最小限に絞るなら、次のアクションを持つカスタムロールでも足りるはずです(こちらは未検証)。
-
サブスクリプション:
Microsoft.CostManagement/query/action,Microsoft.Resources/subscriptions/read,Microsoft.Resources/subscriptions/resources/read -
Logic Apps:
Microsoft.Logic/workflows/triggers/listCallbackUrl/action
宛先やLogic Apps の名前はコードに埋め込まず、Automation変数に逃がしています。
| 変数名 | 内容 |
|---|---|
mailAddressFrom / mailAddressTo
|
送信元・送信先 |
logicName / logicNameResourceGroup
|
Logic Apps の名前とリソースグループ |
HolidayFlag |
祝日に通知をスキップするか |
あとは毎日17時のスケジュールをリンクします。
(参考)Runbook コード全文
########################################################################################################
# メイン処理開始
########################################################################################################
# エラー発生時は確実に処理を中断し、Runbook を失敗させる
$ErrorActionPreference = 'Stop'
Write-Output ('GetCostSummary Start')
########################################################################################################
# 祝日チェック処理
# 祝日の場合はスクリプトを終了する
# https://holidays-jp.github.io/api/v1/date.json
########################################################################################################
# Automation変数から祝日チェックフラグを取得
$holidayFlag = Get-AutomationVariable -Name 'HolidayFlag'
# 祝日チェックが有効な場合
if ($holidayFlag -eq $true) {
$NowDate = (Get-Date -AsUTC).AddHours(9).ToString("yyyy-MM-dd")
# 日本の祝日APIから祝日情報を取得
$holidayJson = Invoke-WebRequest 'https://holidays-jp.github.io/api/v1/date.json' -UseBasicParsing
if ($holidayJson.StatusCode -eq 200) {
$jsonData = $holidayJson.Content | ConvertFrom-Json
# 本日が祝日の場合は処理を終了
if ($jsonData.($NowDate) -ne $null) {
Write-Output ('Today is a holiday')
Write-Output ('GetCleanupResource End')
exit 0
}
}
}
########################################################################################################
# Azure接続処理
# マネージドIDを使用してAzureに接続し、サブスクリプション情報を取得
########################################################################################################
try {
# マネージドIDでAzureに接続
Connect-AzAccount -Identity
# サブスクリプション情報を取得
$subscriptions = Get-AzSubscription
# サブスクリプションIDを取得(最初のサブスクリプション)
$SubscriptionName = $subscriptions[0].Name
$SubscriptionId = $subscriptions[0].Id
if ($SubscriptionName -eq $null -or $SubscriptionName -eq '' ) {
Write-Error ('get SubscriptionId Failure')
exit 1
}
Write-Output ('SubscriptionId = [{0}]' -f $SubscriptionName)
}
catch {
Write-Error -Message $_
throw $_
}
########################################################################################################
# Automation変数の取得(メール送信用)
########################################################################################################
# 送信元メールアドレスを取得
$mailAddressFrom = Get-AutomationVariable -Name 'mailAddressFrom'
if ($mailAddressFrom -eq $null -or $mailAddressFrom -eq '') {
Write-Error ('get Variable(mailAddressFrom) Failure')
exit 1
}
Write-Output ('mailAddressFrom = [{0}]' -f $mailAddressFrom)
# 送信先メールアドレスを取得
$mailAddressTo = Get-AutomationVariable -Name 'mailAddressTo'
if ($mailAddressTo -eq $null -or $mailAddressTo -eq '') {
Write-Error ('get Variable(mailAddressTo) Failure')
exit 1
}
Write-Output ('mailAddressTo = [{0}]' -f $mailAddressTo)
########################################################################################################
# Logic Apps設定の取得
# メール送信用のLogic AppのコールバックURLを取得
########################################################################################################
# Logic App名を取得
$logicAppName = Get-AutomationVariable -Name 'logicName'
if ($logicAppName -eq $null -or $logicAppName -eq '') {
Write-Error ('get Variable(logicAppName) Failure')
exit 1
}
Write-Output ('logicAppName = [{0}]' -f $logicAppName)
# Logic Appのリソースグループ名を取得
$logicNameResourceGroup = Get-AutomationVariable -Name 'logicNameResourceGroup'
if ($logicNameResourceGroup -eq $null -or $logicNameResourceGroup -eq '') {
Write-Error ('get Variable(logicNameResourceGroup) Failure')
exit 1
}
Write-Output ('logicNameResourceGroup = [{0}]' -f $logicNameResourceGroup)
# Logic AppのコールバックURLを取得(手動トリガー用)
$logicAppCallbackUrl = Get-AzLogicAppTriggerCallbackUrl -ResourceGroupName $logicNameResourceGroup `
-Name $logicAppName `
-TriggerName manual
########################################################################################################
# レポート出力用のメッセージ初期化
# HTMLフォーマットでリソース情報を構築
########################################################################################################
$lostResourceOutput = New-Object System.Collections.ArrayList
# CSS style設定
$lostResourceOutput.Add("<body style=""font-family: Roboto ,BIZ UDPゴシック,sans-serif;"">") | Out-Null
########################################################################################################
# リソースグループのコストデータの取得
########################################################################################################
$today = (Get-Date).ToUniversalTime().AddHours(9)
$thisMonthStart = Get-Date -Year $today.Year -Month $today.Month -Day 1
$lastMonthStart = $thisMonthStart.AddMonths(-1)
$lastMonthEnd = $thisMonthStart.AddDays(-1)
$thisMonthEnd = $thisMonthStart.AddMonths(1).AddDays(-1)
$daysElapsed = ($today - $thisMonthStart).Days + 1
$daysInMonth = ($thisMonthEnd - $thisMonthStart).Days + 1
$token = (Get-AzAccessToken -ResourceUrl "https://management.azure.com").Token
# x-ms-command-name / ClientType を付与すると Cost Management API のスロットリング枠が
# 大きく緩和される(既定のプログラム発行トークンは「スコープあたり毎分4回」と非常に厳しいため)。
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
"x-ms-command-name" = "CostSummaryRunbook"
"ClientType" = "CostSummaryRunbook"
}
# 空でないリソースグループを取得
$nonEmptyRGNames = (Get-AzResource).ResourceGroupName | Select-Object -Unique
# 数値を「**万円」表記にする関数
function Format-Yen($n) {
if ($n -ge 1e8) { "{0:N1} 億円" -f ($n/1e8) }
elseif ($n -ge 1e4) { "{0:N1} 万円" -f ($n/1e4) }
elseif ($n -ge 1e3) { "{0:N1} 万円" -f ($n/1e4) }
else { "{0:N0} 円" -f $n }
}
########################################################################################################
# Cost Management API 呼び出し共通関数
# リトライは行わない。失敗した場合はそのまま例外を投げ、Runbook を失敗させる。
########################################################################################################
function Invoke-CostQuery {
param(
[string]$Uri,
[string]$Body
)
# リトライは行わない。失敗(429含む)した場合はそのまま例外を投げ、
# Runbook を失敗させる方針とする。
try {
return Invoke-RestMethod -Uri $Uri -Method Post -Headers $headers -Body $Body
}
catch {
$statusCode = $null
try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = $null }
Write-Error ("Invoke-CostQuery failed (StatusCode={0}): {1}" -f $statusCode, $_.Exception.Message)
throw $_
}
}
# サブスクリプション全体のコスト取得
# 失敗時は文字列を返さず throw する(呼び出し側の数値計算を壊さないため)
function Get-SubscriptionCost($from, $to) {
$uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
$body = @{
type = "ActualCost"; timeframe = "Custom"
timePeriod = @{ from = $from.ToString("yyyy-MM-dd"); to = $to.ToString("yyyy-MM-dd") }
dataset = @{
granularity = "None"
aggregation = @{ totalCost = @{ name = "Cost"; function = "Sum" } }
}
} | ConvertTo-Json -Depth 10
$res = Invoke-CostQuery -Uri $uri -Body $body
if ($res.properties.rows.Count -gt 0) { return [double]$res.properties.rows[0][0] } else { return [double]0 }
}
# リソースグループ別コスト取得
# 失敗時は例外をそのまま伝播させ、Runbook を失敗させる
function Get-CostByRG($from, $to) {
$uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
$body = @{
type = "ActualCost"; timeframe = "Custom"
timePeriod = @{ from = $from.ToString("yyyy-MM-dd"); to = $to.ToString("yyyy-MM-dd") }
dataset = @{
granularity = "None"
aggregation = @{ totalCost = @{ name = "Cost"; function = "Sum" } }
grouping = @(@{ type = "Dimension"; name = "ResourceGroupName" })
}
} | ConvertTo-Json -Depth 10
$res = Invoke-CostQuery -Uri $uri -Body $body
if ($null -eq $res.properties -or $null -eq $res.properties.rows) {
throw "Get-CostByRG: Empty response (properties/rows is null)"
}
$cols = $res.properties.columns.name
return $res.properties.rows | ForEach-Object { @{ RG = $_[$cols.IndexOf("ResourceGroupName")]; Cost = [double]$_[$cols.IndexOf("Cost")] } }
}
# 数値かどうかを安全に判定する関数
function Test-IsNumeric($v) {
if ($null -eq $v) { return $false }
return ($v -is [double] -or $v -is [int] -or $v -is [long] -or $v -is [decimal] -or $v -is [single])
}
# サブスクリプションコスト取得
# 失敗(例外)時はメール送信を中断する
try {
$subLastMonth = Get-SubscriptionCost $lastMonthStart $lastMonthEnd
Start-Sleep -Seconds 20
$subCurrentMonth = Get-SubscriptionCost $thisMonthStart $today
Start-Sleep -Seconds 20
}
catch {
Write-Error "サブスクリプションコストの取得に失敗したため処理を中断します: $($_.Exception.Message)"
throw $_
}
# 念のため数値チェック(非数値なら計算を行わず中断)
if (-not (Test-IsNumeric $subCurrentMonth) -or -not (Test-IsNumeric $subLastMonth)) {
Write-Error ("コスト値が数値ではないため処理を中断します: subLastMonth=[{0}] subCurrentMonth=[{1}]" -f $subLastMonth, $subCurrentMonth)
exit 1
}
$subForecast = $subCurrentMonth / $daysElapsed * $daysInMonth
# HTML出力 - サブスクリプションコストサマリー
$lostResourceOutput.Add('<h3>【サブスクリプション 総コストサマリ】</h3>') | Out-Null
$lostResourceOutput.Add("<p style=""color: #666666;font-size: 0.8em""> $($today.ToString('yyyy-MM-dd'))時点</p>") | Out-Null
$lostResourceOutput.Add('<table border=1 style="border-collapse: collapse;">') | Out-Null
$lostResourceOutput.Add("<tr><th style=""background-color: #A0A0A0; color: white; text-align: left;"">サブスクリプション</th><td style=""text-align:right; padding:0px 4px"">$SubscriptionName</td></tr>") | Out-Null
$lostResourceOutput.Add("<tr><th style=""background-color: #A0A0A0; color: white; text-align: left;"">先月実績</th><td style=""text-align:right; padding:0px 4px"">$(Format-Yen $subLastMonth)</td></tr>") | Out-Null
$lostResourceOutput.Add("<tr><th style=""background-color: #A0A0A0; color: white; text-align: left;"">今月実績(本日時点)</th><td style=""text-align:right; padding:0px 4px"">$(Format-Yen $subCurrentMonth)</td></tr>") | Out-Null
$lostResourceOutput.Add("<tr><th style=""background-color: #A0A0A0; color: white; text-align: left;"">今月末予想</th><td style=""text-align:right;padding:0px 4px""><font color=""red"">$(Format-Yen $subForecast)</font></td></tr>") | Out-Null
$lostResourceOutput.Add('</table><br>') | Out-Null
# HTML出力 - 説明および注意書き
$lostResourceOutput.Add(('本メールは、{0}サブスクリプションにおいて、現在も課金が発生しているAzureリソースについて、' -f $SubscriptionName)) | Out-Null
$lostResourceOutput.Add('<font color=red>リソースグループ単位での総額コストが高い順に一覧化</font>したものです。<br/>') | Out-Null
$lostResourceOutput.Add('(対象:今月末予想コストが1万円を超えるリソースグループ)<br/>') | Out-Null
$lostResourceOutput.Add('<br><b>本一覧により、各検証リソースがどの程度コストを消費しているかを把握・認知いただくこと目的</b>としています。<br/>') | Out-Null
$lostResourceOutput.Add('<br>不要、または現在利用していないリソースについては、停止や削除の対応をお願いいたします。<br/>') | Out-Null
$lostResourceOutput.Add('また、IaC等により再実装が容易であり、長期間(数か月程度)残存しているリソースについては、') | Out-Null
$lostResourceOutput.Add("<b style=""color:#24afe4;"">ベストエフォート</b>で削除をご検討ください。<br>") | Out-Null
$lostResourceOutput.Add('継続的な見直しにより、サブスクリプション全体のコスト最適化を図りたいと考えています。') | Out-Null
# 今月の累積コストをリソースグループ別に取得
# 直前のサブスクリプションコスト取得から間隔を空ける(毎分4回制限対策)
Start-Sleep -Seconds 20
$monthlyByRG = @{}
Get-CostByRG $thisMonthStart $today | Where-Object { $_.RG -in $nonEmptyRGNames } | ForEach-Object { $monthlyByRG[$_.RG] = $_.Cost }
# 今月末予想が1万円を超えるリソースグループを抽出する
$targetRGs = $monthlyByRG.GetEnumerator() |
Where-Object { ($_.Value / $daysElapsed * $daysInMonth) -ge 10000 } |
Sort-Object { $_.Value / $daysElapsed * $daysInMonth } -Descending
# HTML出力 - リソースグループ別コスト
$lostResourceOutput.Add("<p style=""color: #666666;font-size: 0.8em"">集計日: $($today.ToString('yyyy-MM-dd'))</p>") | Out-Null
if ($targetRGs.Count -eq 0) {
$lostResourceOutput.Add('<p>現在予想1万円超えるリソースグループはありませんでした。ご協力いただきありがとうございます。</p>') | Out-Null
} else {
$lostResourceOutput.Add('<table border="1" style="border-collapse: collapse;">') | Out-Null
$lostResourceOutput.Add('<tr style="background-color: #A0A0A0; color: white;"><th>リソースグループ名</th><th>今月実績(本日時点)</th><th>今月末予想</th></tr>') | Out-Null
foreach ($rg in $targetRGs) {
$forecast = $rg.Value / $daysElapsed * $daysInMonth
$lostResourceOutput.Add('<tr>') | Out-Null
$lostResourceOutput.Add("<td style=""padding: 1px 3px; max-width: 400px; "">$($rg.Key)</td>") | Out-Null
$lostResourceOutput.Add("<td style=""text-align:right;white-space: nowrap;"">$(Format-Yen $rg.Value)</td>") | Out-Null
$lostResourceOutput.Add("<td style=""text-align:right;white-space: nowrap;""><font color=""red"">$(Format-Yen $forecast)</font></td>") | Out-Null
$lostResourceOutput.Add('</tr>') | Out-Null
}
$lostResourceOutput.Add('</table>') | Out-Null
}
# 前のAPI呼び出しの後(レート制限緩和のため待機: スコープあたり毎分4回制限対策)
Start-Sleep -Seconds 20
# リソースグループ別累計コスト上位20
$allRGCost = @{}
Get-CostByRG $today.AddDays(-90) $today | Where-Object { $_.RG -in $nonEmptyRGNames } | ForEach-Object { $allRGCost[$_.RG] = $_.Cost }
$top20RGs = $allRGCost.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 20
# HTML出力 - リソースグループ別累計コスト上位20
if ($top20RGs.Count -ne 0) {
$lostResourceOutput.Add('<br><br><p>【参考】 リソースグループ別コスト上位20(過去90日累計)</p>') | Out-Null
$lostResourceOutput.Add('<p><small>※現時点で削除済み・空のリソースグループは除外しています。既に課金対象のリソースを削除していても<br> リソースグループが空でなければ表示されています。ご了承ください。</small></p>') | Out-Null
$lostResourceOutput.Add('<table border="1" style="border-collapse: collapse;">') | Out-Null
$lostResourceOutput.Add('<tr style="background-color: #A0A0A0; color: white;"><th>リソースグループ名</th><th>過去90日累計</th></tr>') | Out-Null
foreach ($rg in $top20RGs) {
$lostResourceOutput.Add("<tr><td style=""max-width: 400px; word-break: break-all;"">$($rg.Key)</td><td style=""text-align: right;"">$(Format-Yen $rg.Value)</td></tr>") | Out-Null
}
$lostResourceOutput.Add('</table>') | Out-Null
} else {
Write-Warning "top20RGs is empty: allRGCost.Count=$($allRGCost.Count), nonEmptyRGNames.Count=$($nonEmptyRGNames.Count)"
}
$lostResourceOutput.Add('</body>') | Out-Null
$message = Out-String -InputObject $lostResourceOutput
########################################################################################################
# Logic Apps経由でメール送信
# リソース情報をJSON形式でLogic Appに送信し、メール送信を実行
########################################################################################################
# 送信用のJSONオブジェクトを作成
$objOut = [PSCustomObject]@{
cleanupResource = $message
mailAddress = $mailAddressTo
mailSubject = "【毎日17時 自動通知】Azureリソース棚卸依頼($SubscriptionName)"
}
$jsonStr = $objOut | ConvertTo-Json
# メッセージが存在する場合、Logic AppのコールバックURLにPOSTリクエストを送信
if ($message -ne '') {
$ret = Invoke-WebRequest -Uri $logicAppCallbackUrl.Value `
-Body $jsonStr `
-ContentType 'application/json;charset=utf-8' `
-Method 'Post' `
-UseBasicParsing
Write-Output $message
}
########################################################################################################
# 処理終了
########################################################################################################
Write-Output ('notifyCostSummary End')
3-2. Cost Management API からコストを取得する
マネージドIDで取得したトークンをヘッダーに載せ、Query API を呼びます。
Connect-AzAccount -Identity
$subscriptions = Get-AzSubscription
$token = (Get-AzAccessToken -ResourceUrl "https://management.azure.com").Token
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
"x-ms-command-name" = "CostSummaryRunbook"
"ClientType" = "CostSummaryRunbook"
}
# POST https://management.azure.com/subscriptions/<サブスクリプションID>/providers/Microsoft.CostManagement/query?api-version=2023-11-01
$uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
リソースグループ別に取るときは grouping に ResourceGroupName を指定します(grouping 値をいれなければサブスクリプションのコストが返される)。
$body = @{
type = "ActualCost"
timeframe = "Custom"
timePeriod = @{ from = $from.ToString("yyyy-MM-dd"); to = $to.ToString("yyyy-MM-dd") }
dataset = @{
granularity = "None"
aggregation = @{ totalCost = @{ name = "Cost"; function = "Sum" } }
grouping = @(@{ type = "Dimension"; name = "ResourceGroupName" })
}
} | ConvertTo-Json -Depth 10
構成した Header と Body で Query API の呼び出します。
Invoke-RestMethod -Uri $Uri -Method Post -Headers $headers -Body $Body
3-3. 取得したデータを集計する
月末予想は、今月の実績を1日あたりに均し、当月の日数分に引き伸ばした単純な日割りです。
今月末予想 = 今月実績(1日〜今日) ÷ 経過日数 × 当月の日数
$thisMonthStart = Get-Date -Year $today.Year -Month $today.Month -Day 1
$thisMonthEnd = $thisMonthStart.AddMonths(1).AddDays(-1)
$daysElapsed = ($today - $thisMonthStart).Days + 1 # 今日を含む経過日数
$daysInMonth = ($thisMonthEnd - $thisMonthStart).Days + 1 # 当月の日数
$subForecast = $subCurrentMonth / $daysElapsed * $daysInMonth
全てのリソースグループを一覧化して並べても読まれないため、今月末予想が1万円を超えるものだけに絞っています。
$targetRGs = $monthlyByRG.GetEnumerator() |
Where-Object { ($_.Value / $daysElapsed * $daysInMonth) -ge 10000 } |
Sort-Object { $_.Value / $daysElapsed * $daysInMonth } -Descending
金額は円単位だと桁が読みづらいので、万円に丸めています。
function Format-Yen($n) {
if ($n -ge 1e8) { "{0:N1} 億円" -f ($n / 1e8) }
elseif ($n -ge 1e4) { "{0:N1} 万円" -f ($n / 1e4) }
else { "{0:N0} 円" -f $n }
}
3-4. HTMLを組み立ててLogic Appsで送信する
Runbook 内で配列 $message に HTML を積み、最後に文字列化します。メールクライアントでの崩れを避けるため、スタイルはすべてインラインで指定しています。
コールバックURLを取得してPOSTします。3-1 で Logic Apps に付けた権限がここで必要になります。
$logicAppCallbackUrl = Get-AzLogicAppTriggerCallbackUrl `
-ResourceGroupName $logicNameResourceGroup `
-Name $logicAppName `
-TriggerName manual
$objOut = [PSCustomObject]@{
cleanupResource = $message # メールのHTML本文
mailAddress = $mailAddressTo
mailSubject = "【毎日17時 自動通知】Azureリソース棚卸依頼($SubscriptionName)"
}
Invoke-WebRequest -Uri $logicAppCallbackUrl.Value `
-Body ($objOut | ConvertTo-Json) `
-ContentType 'application/json;charset=utf-8' `
-Method 'Post' `
-UseBasicParsing
Logic Apps 側は HTTP リクエストトリガーで受け取り、Office 365 Outlookコネクタの「メールの送信 (V2)」で送るだけです。コネクタの認証は事前にポータルで通しておきます。
(参考)Logic Apps のコード全文
{
"definition": {
"triggers": {
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"cleanupResource": { "type": "string" },
"mailAddress": { "type": "string" },
"mailSubject": { "type": "string" }
}
}
}
}
},
"actions": {
"メールの送信_(V2)": {
"type": "ApiConnection",
"inputs": {
"host": { "connection": { "name": "@parameters('$connections')['office365']['connectionId']" } },
"method": "post",
"path": "/v2/Mail",
"body": {
"To": "@{triggerBody()?['mailAddress']}",
"Subject": "@triggerBody()?['mailSubject']",
"Body": "@{triggerBody()?['cleanupResource']}",
"Importance": "Normal"
}
}
}
}
}
}
4. 実装で詰まった点・工夫した点
実装中に引っかかった点と、毎日読んでもらうために足した工夫を4つ挙げます。
4-1. Automation Account は UTC で動く
Automation の実行環境はUTCです。Get-Date はそのままだとUTCを返すため、日本時間の日付として扱うには9時間足す必要があります。
集計期間は「今月1日から今日まで」のように日付から組み立てているので、ここがずれると月初や月末に誤った期間で集計してしまいます。今回は17時実行なので日付が変わる場面は限られますが、境界で事故らないよう、日付を作る前に必ず日本時間へ直すようにしました。
$today = (Get-Date).ToUniversalTime().AddHours(9)
集計期間の起点と終点は、すべてこの $today から算出しています。
4-2. HTMLメールで枠線まで赤くなる
金額のセルだけ赤くしようと <td style="color:red"> と書いたところ、表の枠線まで赤くなりました。CSSの border-color は初期値が currentcolor で、枠線の色を明示していないと文字色を継承するようです。
紛らわしいのは、この現象がメールクライアントによって出たり出なかったりする点です。旧バージョンのOutlookでは枠線が黒のままで、新しい環境で確認したときに初めて再現しました。
<td> には色を指定せず、中の文字だけを囲んで解決しています。
<td style="text-align:right;"><font color="red">18.6 万円</font></td>
4-3. 祝日は通知しない
読む必要のない日に届くと、通知そのものが読まれなくなります。祝日APIを見てスキップしています。
if ($holidayFlag -eq $true) {
$NowDate = (Get-Date -AsUTC).AddHours(9).ToString("yyyy-MM-dd")
$holidayJson = Invoke-WebRequest 'https://holidays-jp.github.io/api/v1/date.json' -UseBasicParsing
if ($holidayJson.StatusCode -eq 200) {
$jsonData = $holidayJson.Content | ConvertFrom-Json
if ($null -ne $jsonData.($NowDate)) { exit 0 }
}
}
4-4. 今月の状況だけでなく、今までの累積コストも表示
当初は今月の実績と月末予想だけを載せていました。ただ、それだけだと「今月はこれくらいか」で終わってしまいます。放置し続けたリソースが、これまでにいくら積み上げてきたのかは伝わりません。
そこで、過去90日の累計コストによるリソースグループ別の上位20件を並べる欄を足しました。直近3か月ぶんのサマリです。今月の予想が小さくても、累計で上位に来るリソースグループは、長期間そのまま放置されているということになります。
$nonEmptyRGNames = (Get-AzResource).ResourceGroupName | Select-Object -Unique
Cost Management API は削除済みリソースグループの実績も返します。過去90日の集計では一覧が過去の残骸で埋まるため、現存するものだけに絞ります。
また Get-AzResourceGroup ではなく Get-AzResource を使っているのは、中身が空のリソースグループも除外するためです。リソースを消してもリソースグループだけ残っている場合、実績は残り続けてしまうため、すでに片付けた人は対象外になるようにしました。
「今月いくらか」は判断を促しますが、「これまでいくら使ったか」は放置そのもののコストを可視化します。消す判断の後押しとしては、こちらのほうが効きました。
5. 運用してみてどうだったか
稼働させたのは2026年2月の下旬です。それ以前は月40万円から60万円の間で上下していましたが、稼働後は下がり続け、直近3か月は20万円前後で落ち着いています。ピークだった2025年12月と比べると3分の1以下です。
とはいえ、この仕組みだけが要因とは言い切れませんが・・・。
6. まとめ
今回は、Azure 検証環境のコストをリソースグループ単位で毎日通知する仕組みを作成しました。
検証環境では不要なリソースが残り続けることがあり、それに伴ってコストも増加していたところ、利用者がコストを確認しに行かなくても自然と目に入る仕組みを作ることで、不要なリソースへの気付きを促しました。
運用開始後は不要なリソースの整理が進み、コストも以前と比べて大きく改善しています。今回の仕組みだけが要因とは言い切れませんが、継続的な可視化には一定の効果があったと感じています。
管理者として環境の課題を見つけ、その解消に取り組んできましたが、後から振り返ると、これは FinOps の考え方に近い活動だったのだと感じています。現在は FinOps の学習も進めながら、実際の環境へ改善内容を反映することを続けています。
今後も運用の中で得られた知見があれば、記事としてまとめていきたいと思います。

