适用于 PowerShell 7.0 及以上版本
随着企业云上工作负载的增长,Azure 账单往往会以意想不到的速度膨胀。开发测试环境忘记关闭、过度配置的虚拟机规格、长期闲置的存储账户,这些看似微小的浪费累积起来可能占云总支出的 20% 到 30%。手动在 Azure 门户中逐项检查既耗时又容易遗漏,尤其是管理多个订阅的团队。
FinOps(云财务运营)实践提倡将成本管理融入工程流程,而非仅仅交给财务部门事后核算。PowerShell 在这一领域有着天然优势:它能调用 Azure Cost Management API 获取精细化费用数据、自动创建预算和告警规则、扫描资源利用率并生成优化建议。将成本治理自动化后,团队可以从被动应对账单转变为主动控制支出。
本文将围绕三个核心场景展开:按资源组和标签维度查询成本趋势、设置多级预算告警并集成 Action Group 通知、以及基于利用率数据生成资源优化建议和自动清理方案。所有脚本均基于 Az PowerShell 模块,可直接集成到 Azure Automation 或 CI/CD 流水线中。
成本查询与趋势分析
了解钱花在哪里是成本优化的第一步。Azure Cost Management 提供了丰富的查询维度,但通过门户操作只能做单次查询,难以实现跨订阅、跨时间段的趋势对比。下面的脚本封装了一个成本分析函数,支持按资源组、标签、服务类型等维度聚合,并自动计算环比变化趋势。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| function Get-AzCostTrend { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$SubscriptionId,
[Parameter(Mandatory = $false)] [ValidateSet("ResourceGroupName", "ServiceName", "ResourceType", "TagKey")] [string]$GroupBy = "ResourceGroupName",
[Parameter(Mandatory = $false)] [string]$TagKey,
[Parameter(Mandatory = $false)] [int]$MonthsBack = 3 )
$null = Set-AzContext -SubscriptionId $SubscriptionId $results = @()
for ($i = 0; $i -lt $MonthsBack; $i++) { $periodStart = (Get-Date).AddMonths(-($i + 1)).ToString("yyyy-MM-01") $periodEnd = (Get-Date).AddMonths(-$i).ToString("yyyy-MM-01")
$dimensionName = $GroupBy if ($GroupBy -eq "TagKey" -and $TagKey) { $dimensionName = "Tag_$TagKey" }
$body = @{ type = "ActualCost" timeframe = "Custom" timePeriod = @{ from = $periodStart to = $periodEnd } dataset = @{ granularity = "None" aggregation = @{ totalCost = @{ name = "Cost" function = "Sum" } } grouping = @( @{ type = "Dimension" name = $GroupBy } ) } } | ConvertTo-Json -Depth 10
$apiVersion = "2024-08-01" $uri = "/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=$apiVersion" $response = Invoke-AzRestMethod -Path $uri -Method POST -Payload $body
if ($response.StatusCode -ne 200) { Write-Warning "查询 $periodStart ~ $periodEnd 失败: $($response.StatusCode)" continue }
$data = $response.Content | ConvertFrom-Json $monthLabel = (Get-Date $periodStart).ToString("yyyy-MM")
foreach ($row in $data.properties.rows) { $results += [ordered]@{ Period = $monthLabel Group = $row[1] CostUSD = [math]::Round($row[0], 2) Currency = $row[2] } } }
$sorted = $results | Sort-Object Group, Period foreach ($item in $sorted) { New-Object -TypeName PSCustomObject -Property $item }
$groups = $results | Select-Object -ExpandProperty Group -Unique Write-Host "`n--- 环比变化摘要 ---" -ForegroundColor Cyan foreach ($grp in $groups) { $grpData = $results | Where-Object { $_.Group -eq $grp } | Sort-Object Period if ($grpData.Count -ge 2) { $latest = $grpData[-1].CostUSD $previous = $grpData[-2].CostUSD $change = if ($previous -gt 0) { [math]::Round(($latest - $previous) / $previous * 100, 1) } else { 0 } $arrow = if ($change -gt 0) { "+" } else { "" } Write-Host (" {0,-30} {1} -> {2} ({3}{4}%)" -f ` $grp, "`$$previous", "`$$latest", $arrow, $change) } } }
Get-AzCostTrend ` -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ` -GroupBy "ResourceGroupName" ` -MonthsBack 3
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| Period : 2025-12 Group : rg-production CostUSD : 456.78 Currency : USD
Period : 2026-01 Group : rg-production CostUSD : 489.23 Currency : USD
Period : 2026-02 Group : rg-production CostUSD : 523.10 Currency : USD
Period : 2025-12 Group : rg-development CostUSD : 123.45 Currency : USD
Period : 2026-01 Group : rg-development CostUSD : 118.90 Currency : USD
Period : 2026-02 Group : rg-development CostUSD : 132.67 Currency : USD
Period : 2025-12 Group : rg-staging CostUSD : 67.80 Currency : USD
Period : 2026-01 Group : rg-staging CostUSD : 71.20 Currency : USD
Period : 2026-02 Group : rg-staging CostUSD : 245.50 Currency : USD
--- 环比变化摘要 --- rg-production $489.23 -> $523.10 (+6.9%) rg-development $118.90 -> $132.67 (+11.6%) rg-staging $71.20 -> $245.50 (+244.8%)
|
环比数据能快速暴露异常增长。上例中 rg-staging 资源组在 2 月份费用暴涨了 244.8%,这通常意味着有人在该环境部署了高规格资源或忘记释放测试实例。结合标签维度查询还能进一步定位到具体团队或项目。
预算设置与告警通知
发现成本异常后,需要建立机制防止问题反复发生。Azure Budgets 支持设置多级阈值告警,配合 Action Group 可以将通知推送到邮件、Teams、Webhook 甚至触发自动化 Runbook。下面的脚本实现了预算创建、多阈值告警配置,并支持对接 Action Group。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| function New-AzBudgetAlert { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$SubscriptionId,
[Parameter(Mandatory = $true)] [string]$BudgetName,
[Parameter(Mandatory = $true)] [decimal]$MonthlyAmount,
[Parameter(Mandatory = $false)] [decimal[]]$ThresholdPercentages = @(50, 80, 100, 120),
[Parameter(Mandatory = $false)] [string[]]$ContactEmails = @("finops-team@contoso.com"),
[Parameter(Mandatory = $false)] [string]$ActionGroupId,
[Parameter(Mandatory = $false)] [string]$TimeGrain = "Monthly" )
$null = Set-AzContext -SubscriptionId $SubscriptionId
$notifications = @{} foreach ($pct in $ThresholdPercentages) { $thresholdName = "Notification$pct" $notificationDef = @{ enabled = $true operator = "GreaterThan" threshold = $pct contactEmails = $ContactEmails contactRoles = @("Owner", "Contributor") locale = "zh-cn" }
if ($ActionGroupId) { $notificationDef.contactGroups = @($ActionGroupId) }
$notifications[$thresholdName] = $notificationDef }
$now = Get-Date $body = @{ properties = @{ category = "Cost" timePeriod = @{ startDate = $now.ToString("yyyy-MM-01") endDate = $now.AddYears(1).ToString("yyyy-MM-01") } timeGrain = $TimeGrain amount = $MonthlyAmount notifications = $notifications } location = "global" } | ConvertTo-Json -Depth 10
$apiVersion = "2024-08-01" $uri = "/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/budgets/$BudgetName`?api-version=$apiVersion"
$response = Invoke-AzRestMethod -Path $uri -Method PUT -Payload $body
if ($response.StatusCode -in @(200, 201)) { $result = $response.Content | ConvertFrom-Json Write-Host "预算 '$BudgetName' 配置成功" -ForegroundColor Green Write-Host " 月度预算: `$$MonthlyAmount USD" Write-Host " 告警阈值: $($ThresholdPercentages -join '%, ')%" Write-Host " 通知邮箱: $($ContactEmails -join ', ')" if ($ActionGroupId) { Write-Host " Action Group: $ActionGroupId" } Write-Host " 有效期: $($now.ToString('yyyy-MM-01')) ~ $($now.AddYears(1).ToString('yyyy-MM-01'))"
Write-Host "`n 各阈值触发金额:" -ForegroundColor Yellow foreach ($pct in $ThresholdPercentages) { $triggerAmount = [math]::Round($MonthlyAmount * $pct / 100, 2) Write-Host " $pct% -> `$$triggerAmount" } } else { Write-Error "预算配置失败 (HTTP $($response.StatusCode))" Write-Error $response.Content } }
New-AzBudgetAlert ` -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ` -BudgetName "prod-monthly-budget" ` -MonthlyAmount 800 ` -ThresholdPercentages @(50, 80, 100, 120) ` -ContactEmails @("finops-team@contoso.com", "dev-lead@contoso.com") ` -ActionGroupId "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-monitoring/providers/microsoft.insights/actionGroups/ag-cost-alert"
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12
| 预算 'prod-monthly-budget' 配置成功 月度预算: $800 USD 告警阈值: 50%, 80%, 100%, 120% 通知邮箱: finops-team@contoso.com, dev-lead@contoso.com Action Group: /subscriptions/a1b2c3d4-.../actionGroups/ag-cost-alert 有效期: 2026-03-01 ~ 2027-03-01
各阈值触发金额: 50% -> $400 80% -> $640 100% -> $800 120% -> $960
|
多级阈值的设计逻辑是:50% 作为信息通知,提醒团队本月消费已过半;80% 是预警级别,需要开始审查是否有异常资源;100% 表示预算已用尽,必须采取行动;120% 则是严重超支警报,可配合 Action Group 触发自动化 Runbook 来关停非关键资源。
资源优化建议与自动清理
成本管理不只是看报表和设告警,更重要的是采取行动。下面的脚本结合 Azure Resource Graph 和资源利用率指标,全面扫描可优化的资源,生成带有费用估算的优化报告,并支持对确认闲置的资源执行自动清理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
| function Get-AzOptimizationReport { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$SubscriptionId,
[Parameter(Mandatory = $false)] [switch]$AutoCleanup,
[Parameter(Mandatory = $false)] [int]$VmCpuThresholdPercent = 5,
[Parameter(Mandatory = $false)] [int]$DaysToCheck = 7 )
$null = Set-AzContext -SubscriptionId $SubscriptionId $optimizations = @() $totalSavings = 0
$vms = Get-AzVM -Status foreach ($vm in $vms) { $powerState = ($vm.Statuses | Where-Object { $_.Code -like "PowerState/*" }).Code $rg = $vm.ResourceGroupName $vmSize = $vm.HardwareProfile.VmSize
if ($powerState -eq "PowerState/stopped") { $optimizations += [ordered]@{ Type = "VM-StoppedNotDeallocated" Resource = $vm.Name ResourceGroup = $rg Detail = "SKU: $vmSize, 状态: Stopped" MonthlySaving = "~`$[根据SKU估算]" Action = "Deallocate 或删除" Risk = "Medium" } } }
$disks = Get-AzDisk | Where-Object { $null -eq $_.ManagedBy -and $_.DiskState -eq "Unattached" } foreach ($disk in $disks) { $sizeGB = $disk.DiskSizeGB $sku = $disk.Sku.Name $saving = [math]::Round($sizeGB * 0.12, 2) $totalSavings += $saving
$optimizations += [ordered]@{ Type = "Disk-Unattached" Resource = $disk.Name ResourceGroup = $disk.ResourceGroupName Detail = "$sizeGB GB ($sku)" MonthlySaving = "`$$saving" Action = "删除磁盘" Risk = "Low" } }
$publicIps = Get-AzPublicIpAddress | Where-Object { -not $_.IpConfiguration } foreach ($pip in $publicIps) { $saving = 3.65 $totalSavings += $saving
$optimizations += [ordered]@{ Type = "PublicIP-Unassociated" Resource = $pip.Name ResourceGroup = $pip.ResourceGroupName Detail = "SKU: $($pip.Sku.Name), 未绑定资源" MonthlySaving = "`$$saving" Action = "释放公网 IP" Risk = "Low" } }
$snapshots = Get-AzSnapshot | Where-Object { $_.TimeCreated -lt (Get-Date).AddDays(-90) } foreach ($snap in $snapshots) { $sizeGB = [math]::Round($snap.DiskSizeGB, 0) $saving = [math]::Round($sizeGB * 0.05, 2) $totalSavings += $saving
$optimizations += [ordered]@{ Type = "Snapshot-Expired" Resource = $snap.Name ResourceGroup = $snap.ResourceGroupName Detail = "$sizeGB GB, 创建于 $($snap.TimeCreated.ToString('yyyy-MM-dd'))" MonthlySaving = "`$$saving" Action = "删除过期快照" Risk = "Low" } }
$resourceGroups = Get-AzResourceGroup foreach ($rg in $resourceGroups) { $resources = Get-AzResource -ResourceGroupName $rg.ResourceGroupName -ErrorAction SilentlyContinue if ($resources.Count -eq 0) { $optimizations += [ordered]@{ Type = "ResourceGroup-Empty" Resource = $rg.ResourceGroupName ResourceGroup = $rg.ResourceGroupName Detail = "空资源组,无任何资源" MonthlySaving = "`$0" Action = "删除空资源组" Risk = "Low" } } }
Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host "Azure 资源优化报告" -ForegroundColor Cyan Write-Host "订阅: $SubscriptionId" Write-Host "扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-Host ("=" * 70) -ForegroundColor Cyan
$riskOrder = @{ "High" = 1; "Medium" = 2; "Low" = 3 } $sorted = $optimizations | Sort-Object { $riskOrder[$_.Risk] }
foreach ($item in $sorted) { New-Object -TypeName PSCustomObject -Property $item }
Write-Host "`n发现 $($optimizations.Count) 个优化项,预计每月可节省 `$$([math]::Round($totalSavings, 2))" -ForegroundColor Yellow
if ($AutoCleanup) { $lowRiskItems = $optimizations | Where-Object { $_.Risk -eq "Low" } Write-Host "`n[自动清理模式] 处理 Low 风险项..." -ForegroundColor Yellow
foreach ($item in $lowRiskItems) { switch ($item.Type) { "Disk-Unattached" { Remove-AzDisk -ResourceGroupName $item.ResourceGroup -DiskName $item.Resource -Force Write-Host " 已删除磁盘: $($item.Resource)" -ForegroundColor Green } "PublicIP-Unassociated" { Remove-AzPublicIpAddress -ResourceGroupName $item.ResourceGroup -Name $item.Resource -Force Write-Host " 已释放公网 IP: $($item.Resource)" -ForegroundColor Green } "Snapshot-Expired" { Remove-AzSnapshot -ResourceGroupName $item.ResourceGroup -SnapshotName $item.Resource -Force Write-Host " 已删除快照: $($item.Resource)" -ForegroundColor Green } "ResourceGroup-Empty" { Remove-AzResourceGroup -Name $item.ResourceGroup -Force Write-Host " 已删除空资源组: $($item.ResourceGroup)" -ForegroundColor Green } } } Write-Host "`n自动清理完成,共处理 $($lowRiskItems.Count) 项" -ForegroundColor Green } }
Get-AzOptimizationReport ` -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| ====================================================================== Azure 资源优化报告 订阅: a1b2c3d4-e5f6-7890-abcd-ef1234567890 扫描时间: 2026-03-06 09:15:00 ======================================================================
Type : VM-StoppedNotDeallocated Resource : web-legacy-01 ResourceGroup : rg-legacy Detail : SKU: Standard_D4s_v3, 状态: Stopped MonthlySaving : ~$[根据SKU估算] Action : Deallocate 或删除 Risk : Medium
Type : Disk-Unattached Resource : web-legacy-01_OsDisk_1 ResourceGroup : rg-legacy Detail : 128 GB (Premium_LRS) MonthlySaving : $15.36 Action : 删除磁盘 Risk : Low
Type : Disk-Unattached Resource : data-disk-temp ResourceGroup : rg-sandbox Detail : 512 GB (Standard_LRS) MonthlySaving : $61.44 Action : 删除磁盘 Risk : Low
Type : PublicIP-Unassociated Resource : pip-dev-test-03 ResourceGroup : rg-sandbox Detail : SKU: Basic, 未绑定资源 MonthlySaving : $3.65 Action : 释放公网 IP Risk : Low
Type : Snapshot-Expired Resource : snap-vm-migration-202508 ResourceGroup : rg-backup Detail : 256 GB, 创建于 2025-08-15 MonthlySaving : $12.80 Action : 删除过期快照 Risk : Low
Type : ResourceGroup-Empty Resource : rg-old-project ResourceGroup : rg-old-project Detail : 空资源组,无任何资源 MonthlySaving : $0 Action : 删除空资源组 Risk : Low
发现 6 个优化项,预计每月可节省 $93.25
|
这份报告清晰地列出了每项资源的浪费类型、可节省金额和建议操作。Medium 风险的停止但未释放虚拟机需要人工确认后再操作,Low 风险的未挂载磁盘、未关联公网 IP 和过期快照则可以放心批量清理。加上 -AutoCleanup 参数后,脚本会自动处理所有 Low 风险项。
注意事项
API 版本更新:本文使用 Cost Management API 版本 2024-08-01,Azure 团队会定期发布新版本。如果脚本执行报错,请先查阅 Azure REST API 文档确认当前可用的最新稳定版本,替换 api-version 参数即可。
权限最小化原则:成本查询只需 Cost Management Reader 角色,创建预算需要 Contributor 角色,而自动清理资源需要 Contributor 或更高权限。建议为不同操作分配不同的服务主体,避免单个账号权限过大。
数据延迟与精度:Azure Cost Management 的消费数据存在 8 到 24 小时的延迟,无法做到实时精确。对于需要秒级响应的场景,应结合 Azure Monitor 指标和 Log Analytics 查询来补充监控。
自动清理的防护措施:生产环境中使用 -AutoCleanup 参数前,务必先不带此参数运行一次完整报告,人工确认 Low 风险项确实可以删除。建议在 Runbook 中加入审批流程,或在清理前先对磁盘和快照做一次备份。
多订阅管理策略:对于拥有大量订阅的企业,建议使用 Azure Lighthouse 或 Management Group 进行统一管理,配合 Azure Resource Graph Query 实现跨订阅批量扫描,避免逐个订阅循环查询带来的性能瓶颈。
成本优化是持续过程:一次性清理闲置资源只是开始。建议将本文的脚本集成到 Azure Automation 中,设定每周自动运行一次并生成报告推送到 Teams 或 Slack 频道,让成本意识成为团队的日常习惯而非月底的意外惊吓。