PowerShell 技能连载 - Azure 成本管理与优化

适用于 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)
}
}
}

# 按资源组维度查询近 3 个月成本趋势
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"
}

# 如果提供了 Action Group ID,则附加 Action Group
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
}
}

# 为生产订阅创建预算并绑定 Action Group
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

# 1. 检测低利用率虚拟机
$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"
}
}
}

# 2. 检查未挂载的托管磁盘
$disks = Get-AzDisk | Where-Object {
$null -eq $_.ManagedBy -and $_.DiskState -eq "Unattached"
}
foreach ($disk in $disks) {
$sizeGB = $disk.DiskSizeGB
$sku = $disk.Sku.Name
# 按高级 SSD 约 $0.12/GB/月 估算
$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"
}
}

# 3. 检查未关联的公网 IP
$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"
}
}

# 4. 检查存储账户中的过期快照
$snapshots = Get-AzSnapshot | Where-Object {
$_.TimeCreated -lt (Get-Date).AddDays(-90)
}
foreach ($snap in $snapshots) {
$sizeGB = [math]::Round($snap.DiskSizeGB, 0)
# 快照按实际使用量计费,约为完整磁盘的 30%-50%
$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"
}
}

# 5. 检查空的资源组(无任何资源)
$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

# 自动清理模式:删除 Risk=Low 的资源
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"

# 确认无误后可开启自动清理
# Get-AzOptimizationReport -SubscriptionId "..." -AutoCleanup

执行结果示例:

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 风险项。

注意事项

  1. API 版本更新:本文使用 Cost Management API 版本 2024-08-01,Azure 团队会定期发布新版本。如果脚本执行报错,请先查阅 Azure REST API 文档确认当前可用的最新稳定版本,替换 api-version 参数即可。

  2. 权限最小化原则:成本查询只需 Cost Management Reader 角色,创建预算需要 Contributor 角色,而自动清理资源需要 Contributor 或更高权限。建议为不同操作分配不同的服务主体,避免单个账号权限过大。

  3. 数据延迟与精度:Azure Cost Management 的消费数据存在 8 到 24 小时的延迟,无法做到实时精确。对于需要秒级响应的场景,应结合 Azure Monitor 指标和 Log Analytics 查询来补充监控。

  4. 自动清理的防护措施:生产环境中使用 -AutoCleanup 参数前,务必先不带此参数运行一次完整报告,人工确认 Low 风险项确实可以删除。建议在 Runbook 中加入审批流程,或在清理前先对磁盘和快照做一次备份。

  5. 多订阅管理策略:对于拥有大量订阅的企业,建议使用 Azure Lighthouse 或 Management Group 进行统一管理,配合 Azure Resource Graph Query 实现跨订阅批量扫描,避免逐个订阅循环查询带来的性能瓶颈。

  6. 成本优化是持续过程:一次性清理闲置资源只是开始。建议将本文的脚本集成到 Azure Automation 中,设定每周自动运行一次并生成报告推送到 Teams 或 Slack 频道,让成本意识成为团队的日常习惯而非月底的意外惊吓。

PowerShell 技能连载 - Azure 成本管理

适用于 PowerShell 5.1 及以上版本

云上资源用起来方便,但月底账单一来往往让人心惊。Azure 提供了 Cost Management 服务,可以帮助我们追踪、分析和优化云支出。然而,通过门户网站手动查看报表既慢又不灵活,尤其当你管理多个订阅时,逐个点开查看效率极低。

PowerShell 结合 Az 模块和 Cost Management REST API,可以让我们以脚本化的方式自动采集费用数据、设置预算告警、识别闲置资源。这意味着你可以将成本管控嵌入 CI/CD 流水线,或者每天早上自动生成一份费用摘要邮件发给团队,从被动等账单变为主动管理成本。

本文将从实际场景出发,演示如何用 PowerShell 查询 Azure 订阅的消费明细、创建预算和告警规则、以及自动扫描可优化资源。所有操作均通过脚本完成,无需登录门户。

连接 Azure 并获取订阅信息

在开始之前,需要先安装 Az 模块并登录 Azure 账户。如果你已经安装过 Az 模块,可以跳过安装步骤直接连接。下面的代码展示如何连接 Azure 并列出当前账户下的所有订阅,包括每个订阅的计费状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 安装 Az 模块(如已安装可跳过)
# Install-Module -Name Az -Scope CurrentUser -Force

# 连接 Azure 账户
Connect-AzAccount

# 获取所有订阅信息
$subscriptions = Get-AzSubscription
foreach ($sub in $subscriptions) {
$info = [ordered]@{
Name = $sub.Name
Id = $sub.Id
State = $sub.State
TenantId = $sub.TenantId
}
New-Object -TypeName PSCustomObject -Property $info
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name      : Production
Id : a1b2c3d4-e5f6-7890-abcd-ef1234567890
State : Enabled
TenantId : 11111111-2222-3333-4444-555555555555

Name : Development
Id : b2c3d4e5-f6a7-8901-bcde-f12345678901
State : Enabled
TenantId : 11111111-2222-3333-4444-555555555555

Name : Sandbox
Id : c3d4e5f6-a7b8-9012-cdef-123456789012
State : Disabled
TenantId : 11111111-2222-3333-4444-555555555555

连接成功后,我们就能基于订阅维度查询各项费用数据。建议在生产环境中使用服务主体(Service Principal)进行非交互式登录,避免手动输入凭据。

查询订阅消费明细

Azure Cost Management 提供了 REST API,可以通过 Invoke-AzRestMethod 调用来获取指定时间范围的费用数据。下面的函数封装了查询逻辑,支持按日期范围和服务类型筛选,返回格式化的费用报表。

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
function Get-AzCostDetail {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId,

[Parameter(Mandatory = $false)]
[DateTime]$StartDate = (Get-Date).AddDays(-30),

[Parameter(Mandatory = $false)]
[DateTime]$EndDate = (Get-Date),

[Parameter(Mandatory = $false)]
[int]$Top = 10
)

# 选择目标订阅上下文
$null = Set-AzContext -SubscriptionId $SubscriptionId

# 构建 Cost Management REST API 请求
$apiVersion = "2023-11-01"
$uri = "/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=$apiVersion"

# 构建请求体,按服务维度分组汇总
$body = @{
type = "ActualCost"
timeframe = "Custom"
timePeriod = @{
from = $StartDate.ToString("yyyy-MM-dd")
to = $EndDate.ToString("yyyy-MM-dd")
}
dataset = @{
granularity = "None"
aggregation = @{
totalCost = @{
name = "Cost"
function = "Sum"
}
}
grouping = @(
@{
type = "Dimension"
name = "ServiceName"
}
)
}
} | ConvertTo-Json -Depth 10

# 调用 REST API
$response = Invoke-AzRestMethod -Path $uri -Method POST -Payload $body

if ($response.StatusCode -ne 200) {
Write-Error "查询失败,状态码:$($response.StatusCode)"
Write-Error $response.Content
return
}

# 解析返回结果
$result = $response.Content | ConvertFrom-Json
$columns = $result.properties.columns
$rows = $result.properties.rows

# 按费用降序排列,取前 N 条
$sortedRows = $rows | Sort-Object { $_[0] } -Descending | Select-Object -First $Top

foreach ($row in $sortedRows) {
$costUSD = [math]::Round($row[0], 2)
$serviceName = $row[1]
$currency = $row[2]

$detail = [ordered]@{
ServiceName = $serviceName
CostUSD = $costUSD
Currency = $currency
}
New-Object -TypeName PSCustomObject -Property $detail
}
}

# 查询最近 30 天费用 Top 10 服务
Get-AzCostDetail -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -Top 10

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
ServiceName                   CostUSD Currency
----------- -------- --------
Virtual Machines 342.58 USD
Azure SQL Database 128.30 USD
Storage Accounts 67.45 USD
App Service 52.00 USD
Azure Kubernetes Service 41.20 USD
Virtual Network 12.80 USD
Azure Monitor 8.60 USD
Key Vault 3.20 USD
Azure DNS 1.50 USD
Public IP Addresses 0.90 USD

从结果可以看到,虚拟机是最大的开支来源,占了总费用的近一半。这类数据可以帮助我们快速定位成本优化的重点方向。如果需要更细粒度的分析,可以把 grouping 维度改为 ResourceGroupNameResourceType

创建预算和告警规则

光看费用报表还不够,我们需要在超支之前收到告警。Azure Cost Management 支持创建预算(Budget),并在消费达到阈值时触发告警。下面的脚本演示如何创建月度预算,并设置两个告警阈值:当实际消费达到 80% 和 100% 时分别发送通知。

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
function Set-AzCostBudget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId,

[Parameter(Mandatory = $true)]
[string]$BudgetName,

[Parameter(Mandatory = $true)]
[decimal]$Amount,

[Parameter(Mandatory = $false)]
[string[]]$NotificationEmails = @("admin@contoso.com"),

[Parameter(Mandatory = $false)]
[string]$TimeZone = "UTC"
)

$null = Set-AzContext -SubscriptionId $SubscriptionId

$apiVersion = "2023-11-01"
$uri = "/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/budgets/$BudgetName?api-version=$apiVersion"

$now = Get-Date
$year = $now.Year
$month = $now.Month

# 构建预算请求体
$body = @{
properties = @{
timePeriod = @{
startDate = "$year-$($month.ToString('00'))-01"
endDate = "$($year + 1)-12-31"
}
timeGrain = "Monthly"
amount = $Amount
currentSpend = @{
amount = 0
unit = "USD"
}
notifications = @{
Notification80 = @{
enabled = $true
operator = "GreaterThan"
threshold = 80
contactEmails = $NotificationEmails
}
Notification100 = @{
enabled = $true
operator = "GreaterThan"
threshold = 100
contactEmails = $NotificationEmails
}
}
}
} | ConvertTo-Json -Depth 10

$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 " 月度预算金额: `$$Amount USD"
Write-Host " 告警阈值: 80%, 100%"
Write-Host " 通知邮箱: $($NotificationEmails -join ', ')"
Write-Host " 预算 ID: $($result.name)"
}
else {
Write-Error "预算创建失败,状态码:$($response.StatusCode)"
Write-Error $response.Content
}
}

# 创建月度 500 美元的预算
Set-AzCostBudget `
-SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" `
-BudgetName "Monthly-Production-Budget" `
-Amount 500 `
-NotificationEmails @("admin@contoso.com", "finance@contoso.com")

执行结果示例:

1
2
3
4
5
预算 'Monthly-Production-Budget' 创建成功!
月度预算金额: $500 USD
告警阈值: 80%, 100%
通知邮箱: admin@contoso.com, finance@contoso.com
预算 ID: Monthly-Production-Budget

预算创建后,Azure 会在每月消费累计达到 400 美元(80%)时发邮件提醒你注意控制开支,达到 500 美元(100%)时再次告警。你也可以结合 Logic Apps 或 Azure Functions,在告警触发后自动执行扩缩容或关停非关键资源。

扫描可优化的闲置资源

很多云成本的浪费来自于闲置或低利用率资源。下面的脚本会扫描常见类型的闲置资源,包括未挂载的磁盘、未分配的公网 IP、停止的虚拟机以及空闲的 SQL 数据库,生成一份优化建议报告。

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
function Find-AzIdleResources {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId
)

$null = Set-AzContext -SubscriptionId $SubscriptionId
$findings = @()

# 1. 检查停止状态的虚拟机
$vms = Get-AzVM -Status
foreach ($vm in $vms) {
$powerState = ($vm.Statuses | Where-Object { $_.Code -like "PowerState/*" }).Code
if ($powerState -eq "PowerState/deallocated") {
$estimatedMonthly = [math]::Round(
($vm.HardwareProfile.VmSize -match "Standard_" -and $true) * 0
)
$rgName = $vm.ResourceGroupName
$findings += [ordered]@{
ResourceType = "Virtual Machine"
ResourceName = $vm.Name
ResourceGroup = $rgName
Status = "Deallocated"
Suggestion = "已释放但仍保留配置,如不再使用建议删除"
Priority = "Medium"
}
}
elseif ($powerState -eq "PowerState/stopped") {
$findings += [ordered]@{
ResourceType = "Virtual Machine"
ResourceName = $vm.Name
ResourceGroup = $vm.ResourceGroupName
Status = "Stopped (not deallocated)"
Suggestion = "停止但未释放,仍在计费!建议 deallocate 或删除"
Priority = "High"
}
}
}

# 2. 检查未挂载的磁盘
$disks = Get-AzDisk
foreach ($disk in $disks) {
if ($null -eq $disk.ManagedBy -and $disk.DiskState -eq "Unattached") {
$sizeGB = $disk.DiskSizeGB
$sku = $disk.Sku.Name
$findings += [ordered]@{
ResourceType = "Managed Disk"
ResourceName = $disk.Name
ResourceGroup = $disk.ResourceGroupName
Status = "Unattached ($sizeGB GB, $sku)"
Suggestion = "未挂载的磁盘,删除可节省约 `$$([math]::Round($sizeGB * 0.05, 2))/月"
Priority = "High"
}
}
}

# 3. 检查未关联的公网 IP
$publicIps = Get-AzPublicIpAddress
foreach ($pip in $publicIps) {
if (-not $pip.IpConfiguration) {
$findings += [ordered]@{
ResourceType = "Public IP Address"
ResourceName = $pip.Name
ResourceGroup = $pip.ResourceGroupName
Status = "Unassociated"
Suggestion = "未关联的公网 IP,删除可节省约 `$3.65/月"
Priority = "Medium"
}
}
}

# 4. 检查暂停的 SQL 数据库
$sqlServers = Get-AzSqlServer
foreach ($server in $sqlServers) {
$databases = Get-AzSqlDatabase -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName
foreach ($db in $databases) {
if ($db.Status -eq "Paused") {
$findings += [ordered]@{
ResourceType = "SQL Database"
ResourceName = "$($server.ServerName)/$($db.DatabaseName)"
ResourceGroup = $db.ResourceGroupName
Status = "Paused"
Suggestion = "已暂停但仍保留,确认是否需要恢复或删除"
Priority = "Low"
}
}
}
}

# 按优先级排序输出
$priorityOrder = @{ "High" = 1; "Medium" = 2; "Low" = 3 }
$sorted = $findings | Sort-Object { $priorityOrder[$_.Priority] }

foreach ($item in $sorted) {
New-Object -TypeName PSCustomObject -Property $item
}

Write-Host "`n共发现 $($findings.Count) 个可优化项,其中 High 优先级 $(($findings | Where-Object { $_.Priority -eq 'High' }).Count) 个。" -ForegroundColor Yellow
}

# 扫描生产订阅的闲置资源
Find-AzIdleResources -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
ResourceType     : Virtual Machine
ResourceName : web-server-old
ResourceGroup : rg-legacy
Status : Stopped (not deallocated)
Suggestion : 停止但未释放,仍在计费!建议 deallocate 或删除
Priority : High

ResourceType : Managed Disk
ResourceName : web-server-old_OsDisk
ResourceGroup : rg-legacy
Status : Unattached (128 GB, Premium_LRS)
Suggestion : 未挂载的磁盘,删除可节省约 $6.40/
Priority : High

ResourceType : Public IP Address
ResourceName : pip-test-unused
ResourceGroup : rg-sandbox
Status : Unassociated
Suggestion : 未关联的公网 IP,删除可节省约 $3.65/
Priority : Medium

ResourceType : SQL Database
ResourceName : sql-prod/legacy-reporting
ResourceGroup : rg-database
Status : Paused
Suggestion : 已暂停但仍保留,确认是否需要恢复或删除
Priority : Low

共发现 4 个可优化项,其中 High 优先级 2 个。

扫描结果清楚地列出了每个闲置资源的类型、名称、所在资源组和优化建议。High 优先级的项应当优先处理,尤其是”停止但未释放”的虚拟机,它们虽然处于关机状态但仍然在产生计算费用。

生成月度成本摘要报告

将前面的查询和分析整合起来,可以生成一份结构化的月度成本摘要报告,方便通过邮件或即时消息发送给团队。

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
function New-AzCostSummaryReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$SubscriptionIds,

[Parameter(Mandatory = $false)]
[int]$DaysBack = 30
)

$reportLines = @()
$startDate = (Get-Date).AddDays(-$DaysBack)
$endDate = Get-Date

$reportLines += "=" * 60
$reportLines += "Azure 成本管理月度摘要报告"
$reportLines += "生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$reportLines += "统计周期: $($startDate.ToString('yyyy-MM-dd')) 至 $($endDate.ToString('yyyy-MM-dd'))"
$reportLines += "=" * 60

$totalCostAll = 0

foreach ($subId in $SubscriptionIds) {
$sub = Get-AzSubscription -SubscriptionId $subId
$reportLines += ""
$reportLines += "--- 订阅: $($sub.Name) ($subId) ---"

try {
$costs = Get-AzCostDetail -SubscriptionId $subId -StartDate $startDate -EndDate $endDate -Top 5
$subTotal = 0

foreach ($item in $costs) {
$reportLines += " [$($item.ServiceName)] `$$($item.CostUSD) $($item.Currency)"
$subTotal += $item.CostUSD
}

$reportLines += " 小计: `$$([math]::Round($subTotal, 2))"
$totalCostAll += $subTotal
}
catch {
$reportLines += " [错误] 无法获取费用数据: $($_.Exception.Message)"
}
}

$reportLines += ""
$reportLines += "=" * 60
$reportLines += "所有订阅合计: `$$([math]::Round($totalCostAll, 2)) USD"
$reportLines += "=" * 60

# 输出到控制台
$reportLines | Write-Host

# 可选:保存到文件
$reportPath = ".\AzureCostSummary_$(Get-Date -Format 'yyyyMMdd').txt"
$reportLines | Out-File -FilePath $reportPath -Encoding UTF8
Write-Host "`n报告已保存至: $reportPath" -ForegroundColor Cyan
}

# 为多个订阅生成月度报告
New-AzCostSummaryReport -SubscriptionIds @(
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
) -DaysBack 30

执行结果示例:

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
============================================================
Azure 成本管理月度摘要报告
生成时间: 2025-11-19 10:30:00
统计周期: 2025-10-20 至 2025-11-19
============================================================

--- 订阅: Production (a1b2c3d4-e5f6-7890-abcd-ef1234567890) ---
[Virtual Machines] $342.58 USD
[Azure SQL Database] $128.30 USD
[Storage Accounts] $67.45 USD
[App Service] $52.00 USD
[Azure Kubernetes Service] $41.20 USD
小计: $631.53

--- 订阅: Development (b2c3d4e5-f6a7-8901-bcde-f12345678901) ---
[Virtual Machines] $85.20 USD
[Storage Accounts] $12.30 USD
[App Service] $22.00 USD
[Azure Database for PostgreSQL] $18.50 USD
[Virtual Network] $5.60 USD
小计: $143.60

============================================================
所有订阅合计: $775.13 USD
============================================================

报告已保存至: .\AzureCostSummary_20251119.txt

注意事项

  1. API 版本兼容性:Cost Management REST API 版本迭代较快,本文使用 2023-11-01。如果遇到错误,请查阅 Azure 官方文档确认当前可用的最新稳定版本,并相应更新脚本中的 api-version 参数。

  2. 权限要求:执行成本查询需要订阅级别的 Microsoft.CostManagement/query/action 权限,创建预算需要 Microsoft.Consumption/budgets/write 权限。建议为自动化服务主体分配”成本管理读者”(Cost Management Reader)或”参与者”(Contributor)角色。

  3. 数据延迟:Azure Cost Management 的消费数据存在约 8-24 小时的延迟。如果你需要实时监控,建议结合 Azure Monitor 的指标告警,对关键资源(如虚拟机 CPU 利用率)设置实时阈值。

  4. 停止与释放的区别:Azure 虚拟机”停止”(Stopped)状态仍会收取计算费用,只有”释放”(Deallocated)状态才停止计费。在脚本中调用 Stop-AzVM 时务必加上 -StayProvisioned:$false 参数确保释放资源。

  5. 多订阅批量操作:在遍历多个订阅时,每次都需要通过 Set-AzContext 切换上下文。如果订阅数量很多,建议使用 -AsJob 参数将查询并行化,或者利用 Azure Resource Graph 进行跨订阅查询,效率更高。

  6. 费用估算的准确性:脚本中对闲置资源的费用估算是近似值,实际费用取决于 SKU、区域和预留实例折扣等因素。做最终决策前,建议在 Azure 门户的定价计算器中验证具体金额。