Azure のコストの実績をリソース グループごとに集計して通知する
はじめに
Azure 環境をリソース グループ単位で利用者に払い出している場合、各利用者に対して月次の利用料を通知する必要があるケースがあります。こちらを Azure の組み込み機能で実現するのは困難なので、課金データをエクスポートし、Azure Functions で集計、Logic Apps で通知する仕組みを作ります。
課金データのエクスポート
課金データはサブスクリプション単位でストレージ アカウントに保存することができます。
設定方法はこちらに詳細なチュートリアルがあるので、割愛します。
以下のように月次で実績データを CSV 形式で、Gzip 圧縮にてエクスポートするように設定しています。
ストレージには以下のように格納されます。最後の GUID は RunID と呼ばれるエクスポート実行タスクの ID のようです。
CSV の場合、以下のように 1 日あたりの各リソースの使用量と単価、請求額などが記載されています。各項目の詳細はリンクから確認できます。
また、併せて以下のようなログ情報が記録された manifest.json が格納されています。
Azure Functions で集計
こちらを Azure Functions で集計していきます。
まずマネージド ID を有効化して、課金データを格納したストレージに対して [閲覧者] と [ストレージ BLOB 共同作成者] を付与します。 ※ 以下はサブスクリプション単位で付与しています。
[Timeer Trigger] で毎月 10 日に実行する関数を作成します。
※ BLOB トリガーのほうが便利のような気がしたのですが、複数ファイル配置された場合の挙動が不明確であったため、確実に実行できる Timer Trigger にしています。
Azure Functions で実行する PowerShell スクリプトのサンプルはこちらです。
ポイントとなる処理を説明していきます。
# 先月の開始日と終了日を取得
$LastMonthStart = (Get-Date).AddMonths(-1).ToString("yyyyMM01")
$LastMonthEnd = (Get-Date).AddDays(- (Get-Date).Day).ToString("yyyyMMdd")
- BLOB パスに yyyymm01-yyymm31 のように期間が含まれるため、そちらを算出
### Step 1: BLOB ダウンロードと解凍処理 ### ### ### ### ### ### ### ### ### ### ###
# Azure にログイン(マネージド ID を使用)
Connect-AzAccount -Identity
# ストレージアカウントのキーなしで認証する(マネージド ID 使用)
$StorageAccount = Get-AzStorageAccount -ResourceGroupName $resourceGroup -Name $StorageAccountName
$Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -UseConnectedAccount
# 指定したパスに一致する BLOB をリストアップ
$Blobs = Get-AzStorageBlob -Container $ContainerName -Context $Context | Where-Object { $_.Name -like "$BlobPathPrefix*" }
# BLOB ダウンロードと解凍処理
foreach ($Blob in $Blobs) {
$BlobName = $Blob.Name
$DownloadPath = "$DownloadFolder\$($BlobName -replace '[\/]', '_')" # ファイル名を整形
Write-Host "ファイルをダウンロード: $BlobName -> $DownloadPath"
Get-AzStorageBlobContent -Container $ContainerName -Blob $BlobName -Destination $DownloadPath -Context $Context -Force
Write-Host "ダウンロード完了: $DownloadPath"
# `.gz` のみ解凍処理を実施
if ($DownloadPath -like "*.gz") {
$ExtractedCsvPath = "$ExtractFolder\$($BlobName -replace '[\/]', '_')"
$ExtractedCsvPath = $ExtractedCsvPath -replace '\.gz$', ''
Write-Host "解凍中: $DownloadPath -> $ExtractedCsvPath"
# Gzip 解凍
$FileStream = [System.IO.File]::OpenRead($DownloadPath)
$DecompressedStream = New-Object System.IO.Compression.GzipStream $FileStream, ([System.IO.Compression.CompressionMode]::Decompress)
$FileOutput = [System.IO.File]::Create($ExtractedCsvPath)
$DecompressedStream.CopyTo($FileOutput)
$DecompressedStream.Close()
$FileStream.Close()
$FileOutput.Close()
Write-Host "解凍完了: $ExtractedCsvPath"
}
}
- ストレージ アカウントに対してマネージド ID で認証
- ファイルが分割されて保存される場合があるため、対象パス内の BLOB ファイルをリストアップ (RunID の GUID が含まれてしまうため、like で比較)
- 取得したリストのファイルをダウンロード
- .gz 拡張子のファイルのみ解凍処理
- 上記のダウンロードと解凍処理を繰り返し
### Step 2: CSV マージ処理 ### ### ### ### ### ### ### ### ### ### ###
Write-Host "CSV マージ処理を開始..."
# extracted フォルダにあるすべての CSV ファイルを取得
$CsvFiles = Get-ChildItem -Path $ExtractFolder -Filter "*.csv" | Select-Object -ExpandProperty FullName
# 変数初期化
$AllData = @()
foreach ($CsvFile in $CsvFiles) {
Write-Host "CSV 読み込み: $CsvFile"
# オブジェクトとして CSV を取り込み、マージ (なのでヘッダ列が重複することはない)
$Data = Import-Csv -Path $CsvFile
$AllData += $Data
}
# CSV に書き出し
if ($AllData.Count -gt 0) {
Write-Host "マージ完了。CSV に出力: $MergedCsvPath"
$AllData | Export-Csv -Path $MergedCsvPath -NoTypeInformation -Encoding UTF8
Write-Host "CSV マージ処理完了: $MergedCsvPath"
} else {
Write-Host "マージ対象の CSV が見つかりませんでした。"
}
- 解凍した CSV ファイルを順次読み込みマージ
- 1 つの CSV ファイルに書き出し
### Step 3: manifest ファイルチェック ### ### ### ### ### ### ### ### ### ### ###
# _tmp フォルダを検索し、manifest.json のファイルを取得
$ManifestFile = Get-ChildItem -Path "_tmp" -Filter "*manifest.json" -File -Recurse | Select-Object -ExpandProperty FullName
# manifest.json が見つからない場合の処理
if (-not $ManifestFile) {
Write-Host "_tmp フォルダ内に manifest.json が見つかりませんでした。スクリプトを終了します。"
exit 1
}
Write-Host "使用する manifest.json: $ManifestFile"
# manifest.json を読み込む
$ManifestData = Get-Content -Path $ManifestFile | ConvertFrom-Json
# manifest.json に記載されている総データ行数を計算
$ExpectedRowCount = $ManifestData.byteCount
Write-Host "manifest.json に記載されている合計行数: $ExpectedRowCount"
# 実際のマージ後 CSV の行数を取得
$ActualRowCount = (Get-Content -Path $MergedCsvPath | Measure-Object -Line).Lines -1
Write-Host "マージ後の CSV ファイルの行数: $ActualRowCount"
# 行数を比較
if ($ActualRowCount -eq $ExpectedRowCount) {
Write-Host "行数が一致しました: $ActualRowCount 行"
} else {
Write-Host "行数が一致しません! (期待値: $ExpectedRowCount, 実際: $ActualRowCount)"
}
- manifest.json に記録されたレコード数を取得
- マージ後の CSV ファイルのレコード数を取得
- 比較して一致していることを確認
### Step 4: 集計処理 ### ### ### ### ### ### ### ### ### ### ###
# 最終的な CSV を読み込む
$BillingData = Import-Csv -Path $MergedCsvPath
# costInBillingCurrency by resourceGroupName で集計
$Summary = $BillingData | Group-Object -Property resourceGroupName | ForEach-Object {
[PSCustomObject]@{
ResourceGroup = $_.Name
TotalCost = [math]::Round(($_.Group | Measure-Object -Property costInBillingCurrency -Sum).Sum, 2)
}
}
# JSON データを出力
$Summary | ConvertTo-Json -Depth 2 | Set-Content -Path $OutputJson -Encoding UTF8
Write-Host "save $OutputJson"
- マージした CSV ファイルを読み込み、リソース グループ単位で利用料を集計
- TotalCost は小数点以下 2 桁で四捨五入
- 集計結果を JSON 形式でファイルに記録
### Step 5: BLOB アップロード ### ### ### ### ### ### ### ### ### ### ###
Write-Host "JSON ファイルをアップロード: $OutputBlobName"
Set-AzStorageBlobContent -Container $ContainerName -File $OutputJson -Blob $OutputBlobName -Context $Context -Force
Write-Host "アップロード完了: $OutputBlobName"
- 集計結果ファイルを BLOB ストレージに格納
- 保管場所は指定したコンテナ (サンプルでは billing) の [json] パス、ファイル名は [20250101-billing_summary.txt] のように対象期間の初日を付与して保存
Logic Apps でファイルを取得し通知
作成した課金集計ファイルと連絡先ファイルを元に通知の仕組みを作っていきます。なお、連絡先ファイルのフォーマット以下の通りです。
Logic Apps を作成し、こちらもマネージド ID を有効化、ストレージ アカウントに対して閲覧者と ストレージ BLOB データ閲覧者のロールを付与します。 ※ 以下ではサブスクリプションに付与しています。
まず Recurrence トリガーで、1 ヶ月ごとに実行するように設定します。
次に課金集計ファイル用の BLOB ファイルのパスを指定します。関数で concat('/billing/json/',formatDateTime(addToTime(utcNow(), -1, 'month'), 'yyyyMM01'),'-billing_summary.txt')
を指定しています。
BLOB ファイルを取得し、取得したファイルコンテンツを Parse JSON で以降のコネクタでデータとして使用できるようにします。接続はマネージド ID を指定します。Schema は [サンプルのペイロードを使用してスキーマを作成する] から実際のファイルを貼り付ければ自動的に作成できます。
For each で連絡先ファイルの Body 内 (resourceGroup と mailAddress の組み合わせ) でループ処理します。併せて For each を逐次処理にしています。
次に課金集計ファイルの Body 内 (resourceGroup と TotalCost の組み合わせ) でループ処理します。併せて For each を逐次処理にしています。
Condition を使用し、For each で取得している連絡先ファイルと課金集計ファイルの resourceGroup が一致するかチェックします。なお、この部分はデザイナーでは指定できなかったので、コード ビューで For each に指定した名前に合わせて、@items('For_each_-_mailAddressList')['resourceGroup']
と @items('For_each_-_Billing')['resourceGroup']
を設定しています。
True の場合にメール送信をしています。こちらの To の mailAddress もデザイナーだと指定できないため、For each に指定した名前に合わせてコードビューで @{items('For_each_-_mailAddressList')['mailAddress']
指定しています。
宛先にフォーマットは任意ですが、それっぽいレイアウトにするためにコード編集して以下のように指定しています。
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
}
.container {
max-width: 600px;
margin: auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.header {
background-color: #0078D4;
color: white;
padding: 10px;
text-align: center;
font-size: 18px;
font-weight: bold;
border-radius: 8px 8px 0 0;
}
.content {
padding: 15px;
background-color: white;
border-radius: 0 0 8px 8px;
}
.total-cost {
font-size: 20px;
font-weight: bold;
color: #0078D4;
}
.footer {
font-size: 12px;
color: #555;
text-align: center;
padding-top: 10px;
}
</style>
<div class="container">
<div class="header">
Billing Report - Monthly Cost Summary
</div>
<div class="content">
<p>先月の使用料:</p>
<p class="total-cost">USD @{items('For_each_-_Billing')['TotalCost']}</p>
</div>
<div class="footer">
This is an automated notification from xxx Team.
</div>
</div>
ここまでの Logic Apps を以下の ARM テンプレートにまとめています。デプロイ後、ストレージ アカウントと Office 365 の接続情報の更新が必要です。
実行結果
こちらを一通りの流れで実行すると、指定した利用者のメールアドレス宛に以下のメールが届くようになります。
まとめ
要件は比較的分かりやすいのですが、実装してみるとかなり手間がかかる内容でした。Logic Apps を使用せず、そのまま Azure Functions の中で Office 365 の API 叩いてメール出した方がシンプルかもしれないです。コストの見え方や通知したい内容は異なると思いますので、利用する場合は適宜カスタマイズください。
Discussion