PowerShell 技能连载 - 提示符与界面定制

适用于 PowerShell 7.0 及以上版本

每天在终端里敲命令数小时,默认的 PS C:\> 提示符只能告诉你当前路径,其他信息一概欠奉。当你在多个 Git 仓库之间切换、管理不同的 Azure 订阅、激活不同的 Python 虚拟环境时,一个信息丰富的提示符可以让你瞬间掌握上下文状态,减少低级错误。

PowerShell 的提示符本质上就是一个名为 prompt 的函数——你可以自由重写它。无论是显示 Git 分支和脏状态、上一次命令的执行耗时、当前用户权限级别,还是用颜色区分不同的服务器环境,都可以通过几行代码实现。本文将带你从手写 prompt 函数开始,再到集成 Oh My Posh 这类成熟框架,最后补充一套提升日常效率的实用工具函数。

手写自定义 prompt 函数

最直接的方式是重写 prompt 函数。下面这段代码实现了一个多行提示符,第一行显示时间、路径和 Git 状态,第二行是实际的输入光标。同时它还记录上一条命令的执行时间,方便你判断某个操作是否太慢。

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
# 保存到 $PROFILE 中即可生效
# 记录命令开始时间
$global:__LastCommandStart = $null

# 在命令执行前记录时间
$ExecutionContext.SessionState.InvokeCommand.AddEventHandler(
'CommandSearchAction', {
$global:__LastCommandStart = [DateTime]::Now
}
)

# 获取 Git 分支与状态信息
function Get-GitStatus {
try {
$branch = git rev-parse --abbrev-ref HEAD 2>$null
if (-not $branch) { return '' }

$status = git status --porcelain 2>$null
$dirty = if ($status) { '*' } else { '' }

$ahead = git log "@{upstream}..HEAD" --oneline 2>$null
$aheadCount = ($ahead | Where-Object { $_ }).Count

$aheadMark = if ($aheadCount -gt 0) { "+$aheadCount" } else { '' }

return " [$branch$dirty$aheadMark]"
} catch {
return ''
}
}

# 获取上一次命令执行耗时
function Get-LastCommandDuration {
if (-not $global:__LastCommandStart) { return '' }
$duration = [DateTime]::Now - $global:__LastCommandStart
if ($duration.TotalSeconds -gt 1) {
return " ($([math]::Round($duration.TotalSeconds, 1))s)"
}
return ''
}

# 自定义 prompt 函数
function prompt {
$path = Get-Location
$homePrefix = $HOME -replace '\\', '\\'
$displayPath = $path.Path -replace "^$homePrefix", '~'
$gitInfo = Get-GitStatus
$duration = Get-LastCommandDuration
$timeStamp = Get-Date -Format 'HH:mm:ss'

# 第一行:时间戳 + 路径 + Git 状态 + 执行耗时
Write-Host "`n" -NoNewline
Write-Host $timeStamp -ForegroundColor DarkGray -NoNewline
Write-Host " " -NoNewline
Write-Host $displayPath -ForegroundColor Cyan -NoNewline
Write-Host $gitInfo -ForegroundColor Yellow -NoNewline
Write-Host $duration -ForegroundColor DarkYellow -NoNewline

# 权限提示
if (
$IsWindows -and
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
) {
Write-Host " [ADMIN]" -ForegroundColor Red -NoNewline
}

# 第二行:输入提示符
Write-Host ""
Write-Host ">" -ForegroundColor Green -NoNewline
return ' '
}

执行后的终端效果如下(纯文本模拟):

1
2
14:32:05 ~/projects/my-app [main*+2] (3.2s)
>

第一行显示了当前时间、相对主目录的路径、Git 分支名称(main)、脏标记(*表示有未提交的更改)、领先远程的提交数(+2)以及上一条命令耗时 3.2 秒。如果在管理员模式下运行,还会出现红色的 [ADMIN] 标记。

集成 Oh My Posh

手动写 prompt 函数虽然灵活,但维护成本不低——尤其是当你想要图标、颜色主题、多种 Segment(环境变量、云平台信息等)时。Oh My Posh 是一个跨 Shell 的提示符渲染引擎,配合 Nerd Font 可以实现非常精美的终端外观。

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
# 安装 Oh My Posh(Windows 推荐使用 winget)
# winget install JanDeDobbeleer.OhMyPosh -s winget

# macOS / Linux 使用 Homebrew
# brew install jandedobbeleer/oh-my-posh/oh-my-posh

# 在 $PROFILE 中初始化 Oh My Posh
oh-my-posh init pwsh --config "$env:POSH_THEMES_PATH\jandedobbeleer.omp.json" |
Invoke-Expression

# 如果想使用自定义配置文件
# $ompConfig = Join-Path $HOME '.config' 'oh-my-posh' 'my-theme.omp.json'
# oh-my-posh init pwsh --config $ompConfig | Invoke-Expression

# 查看所有内置主题
Get-ChildItem -Path $env:POSH_THEMES_PATH -Filter '*.omp.json' |
Select-Object -ExpandProperty Name |
Sort-Object

# 快速预览主题(逐个浏览)
function Show-PoshThemePreview {
param([int]$Index = 0)

$themes = Get-ChildItem -Path $env:POSH_THEMES_PATH -Filter '*.omp.json' |
Sort-Object Name
$theme = $themes[$Index]
Write-Host "Theme [$Index/$($themes.Count)]: $($theme.Name)" -ForegroundColor Cyan
oh-my-posh init pwsh --config $theme.FullName | Invoke-Expression
}

# 导出当前配置并按需修改
function Export-PoshConfig {
param(
[string]$OutputPath = (Join-Path $HOME '.config' 'oh-my-posh')
)

$null = New-Item -ItemType Directory -Path $OutputPath -Force
$defaultConfig = Join-Path $env:POSH_THEMES_PATH 'jandedobbeleer.omp.json'
Copy-Item $defaultConfig (Join-Path $OutputPath 'my-theme.omp.json') -Force
Write-Host "配置已导出到 $OutputPath\my-theme.omp.json" -ForegroundColor Green
Write-Host '修改后更新 $PROFILE 中的 init 命令指向新文件即可。'
}

执行 Get-ChildItem 查看主题列表的部分输出:

1
2
3
4
5
6
1_shell.omp.json
agnoster.omp.json
agnosterplus.omp.json
atomic.omp.json
atomicBit.omp.json
...(共 100+ 内置主题)

执行 Export-PoshConfig 的输出:

1
2
配置已导出到 C:\Users\victor\.config\oh-my-posh\my-theme.omp.json
修改后更新 $PROFILE 中的 init 命令指向新文件即可。

Oh My Posh 的 JSON 配置文件支持丰富的 Segment 类型——Git、Az(Azure)、Python、Node、Docker、Kubectl 等等,你可以按需启用或禁用,调整颜色和图标。推荐从默认主题复制一份然后逐步微调,而不是从零开始编写。

实用工具函数集

提示符之外,Profile 里还可以放一些高频使用的辅助函数,它们与提示符配合让日常操作更加流畅。下面这组函数涵盖了目录快速跳转、增强的命令历史搜索,以及别名管理。

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
# --- 目录快速跳转 ---
# 使用书签机制在常用目录间跳转
$global:DirectoryBookmarks = @{}

function Set-Bookmark {
param([string]$Name)
$global:DirectoryBookmarks[$Name] = (Get-Location).Path
Write-Host "书签 '$Name' 已保存: $($global:DirectoryBookmarks[$Name])" -ForegroundColor Green
}

function Enter-Bookmark {
param([string]$Name)
if ($global:DirectoryBookmarks.ContainsKey($Name)) {
Set-Location $global:DirectoryBookmarks[$Name]
} else {
Write-Warning "书签 '$Name' 不存在。已保存的书签:"
$global:DirectoryBookmarks.GetEnumerator() |
ForEach-Object { Write-Host " $($_.Key) => $($_.Value)" }
}
}

# 简写别名
Set-Alias -Name bm -Value Set-Bookmark
Set-Alias -Name gb -Value Enter-Bookmark

# --- 增强的历史搜索 ---
# 使用 fzf(可选)或 PSFzf 模块进行模糊搜索
# 这里提供一个不依赖外部工具的方案
function Search-History {
param([string]$Pattern = '*')

Get-Content (Get-PSReadlineOption).HistorySavePath |
Where-Object { $_ -like "*$Pattern*" } |
Select-Object -Unique -Last 20
}

Set-Alias -Name hh -Value Search-History

# --- 别名管理 ---
# 列出所有自定义别名及其来源
function Get-MyAliases {
$builtIn = Get-Alias |
Where-Object { $_.Options -notcontains 'UserDefined' } |
Select-Object -ExpandProperty Name

Get-Alias |
Where-Object { $_.Name -notin $builtIn } |
Select-Object Name, Definition, Source |
Sort-Object Name |
Format-Table -AutoSize
}

# 快速进入 Profile 编辑模式
function Edit-Profile {
param([switch]$OpenFolder)
if ($OpenFolder) {
Invoke-Item (Split-Path $PROFILE)
} else {
code $PROFILE
}
}

Set-Alias -Name ep -Value Edit-Profile

使用书签功能的交互示例:

1
2
3
4
5
6
7
8
9
10
PS ~/projects/my-app> bm work
书签 'work' 已保存: /Users/victor/projects/my-app

PS ~> gb work
PS /Users/victor/projects/my-app>

PS ~> hh git
git status
git log --oneline -10
git push origin main

书签机制不依赖外部工具,设置简单,适合在少数几个高频目录之间切换。如果你的目录结构比较复杂,也可以考虑搭配 zzoxide 这类基于频率的跳转工具使用。

注意事项

  1. prompt 函数必须返回字符串:即使你只用 Write-Host 输出内容,函数也必须 return 一个字符串(哪怕是一个空格或空字符串),否则 PowerShell 会使用默认的提示符。
  2. Git 状态检测有性能开销:在大型仓库中,git status --porcelain 可能较慢。如果感到提示符延迟,可以在 Get-GitStatus 中加一个超时判断,或改用 git diff --quiet 做轻量级检测。
  3. Oh My Posh 需要 Nerd Font:图标符号依赖 Nerd Font 字体。如果终端中看到方框或乱码,说明字体未正确安装。推荐使用 CaskaydiaCove Nerd FontFiraCode Nerd Font
  4. Profile 分模块管理:随着自定义内容增多,建议把 prompt、别名、函数拆到不同的 .ps1 文件中,在 $PROFILE 里用 . $path 点源加载,保持主文件简洁。
  5. 跨平台兼容性:本文代码同时适配 Windows、macOS 和 Linux,但管理员检测部分([Security.Principal.WindowsIdentity])只在 Windows 上生效,非 Windows 平台会自动跳过该逻辑。
  6. PSReadLine 是好搭档:提示符定制之外,Set-PSReadlineOption 可以配置预测文本来源、颜色主题和快捷键。结合 CommandPrediction 插件,终端体验可以接近 IDE 级别。

PowerShell 技能连载 - Azure Automation Runbook

适用于 PowerShell 7.0 及以上版本

在日常运维中,许多任务需要定时执行:清理过期资源、轮转证书、检查合规状态、生成日报。如果依赖人工操作,不仅效率低下,还容易遗漏。Azure Automation 提供了一个托管的 PowerShell 执行环境,让脚本可以在云端按计划自动运行,无需维护本地服务器。

Azure Automation 的核心概念是 Runbook——一段托管在云端的 PowerShell 脚本。Runbook 支持多种触发方式:定时计划(Schedule)、Webhook 回调、事件驱动(Event Grid),甚至可以手动启动。它内置了凭据管理、变量存储和模块缓存,使脚本能安全地访问 Azure 资源而不暴露密钥。

本文将围绕三个核心场景展开:创建和管理 Runbook、配置定时计划与 Webhook 触发、以及监控作业状态与日志输出。通过这些实践,你可以构建一个完整的无人值守运维体系。

创建 Automation Account 与发布 Runbook

首先需要创建 Automation Account,然后在其中编写和发布 Runbook。以下脚本演示了完整的创建流程,包括模块导入、Runbook 编写、参数配置和发布。

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
# 连接到 Azure
Connect-AzAccount -Subscription 'production-sub'

# 定义变量
$ResourceGroup = 'rg-automation'
$AutomationAccount = 'aa-ops'
$Location = 'eastasia'

# 创建资源组和 Automation Account
New-AzResourceGroup -Name $ResourceGroup -Location $Location -Force

New-AzAutomationAccount `
-ResourceGroupName $ResourceGroup `
-Name $AutomationAccount `
-Location $Location `
-Plan Basic

# 导入常用模块(确保 Az 模块可用)
$ModuleVersions = @{
'Az.Accounts' = '3.0.0'
'Az.Resources' = '7.0.0'
'Az.Storage' = '6.0.0'
}

foreach ($Module in $ModuleVersions.GetEnumerator()) {
$ModuleUrl = "https://www.powershellgallery.com/api/v2/package/$($Module.Key)/$($Module.Value)"
New-AzAutomationModule `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $Module.Key `
-ContentLinkUri $ModuleUrl
}

Write-Host "模块导入已提交, provisioning 需要几分钟..."

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
ResourceGroupName : rg-automation
Location : eastasia
AutomationAccountName : aa-ops
Plan : Basic
Status : Ok

Name ContentLinkUri
---- ---------------
Az.Accounts https://www.powershellgallery.com/api/v2/package/Az.Accounts/3.0.0
Az.Resources https://www.powershellgallery.com/api/v2/package/Az.Resources/7.0.0
Az.Storage https://www.powershellgallery.com/api/v2/package/Az.Storage/6.0.0
模块导入已提交,provisioning 需要几分钟...

接下来编写 Runbook 内容并发布。Runbook 的脚本内容可以通过字符串定义并上传。

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
# Runbook 脚本内容
$RunbookContent = @'
param(
[string]$ResourceGroupName = 'rg-production',
[int]$OlderThanDays = 30,
[bool]$WhatIf = $true
)

$Conn = Get-AutomationConnection -Name 'AzureRunAsConnection'
Connect-AzAccount -ServicePrincipal `
-Tenant $Conn.TenantId `
-ApplicationId $Conn.ApplicationId `
-CertificateThumbprint $Conn.CertificateThumbprint

$CutoffDate = (Get-Date).AddDays(-$OlderThanDays)

$Snapshots = Get-AzSnapshot -ResourceGroupName $ResourceGroupName |
Where-Object { $_.TimeCreated -lt $CutoffDate }

Write-Output "找到 $($Snapshots.Count) 个超过 $OlderThanDays 天的快照"

foreach ($Snap in $Snapshots) {
if ($WhatIf) {
Write-Output "[WhatIf] 将删除快照: $($Snap.Name) (创建于 $($Snap.TimeCreated))"
} else {
Remove-AzSnapshot -ResourceGroupName $ResourceGroupName `
-SnapshotName $Snap.Name -Force
Write-Output "已删除快照: $($Snap.Name)"
}
}
'@

# 创建并发布 Runbook
$RunbookName = 'Clean-OldSnapshots'

New-AzAutomationRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName `
-Type PowerShell `
-Description '清理超过指定天数的磁盘快照'

Set-AzAutomationRunbookContent `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName `
-Content $RunbookContent

Publish-AzAutomationRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName

Write-Host "Runbook '$RunbookName' 已发布"

执行结果示例:

1
2
3
4
5
6
7
8
RunbookType     : PowerShell
Name : Clean-OldSnapshots
Description : 清理超过指定天数的磁盘快照
State : Published
CreationTime : 2026-03-18 02:15:00
LastModifiedTime: 2026-03-18 02:15:32

Runbook 'Clean-OldSnapshots' 已发布

配置定时计划与 Webhook 触发

Runbook 发布后,需要配置触发方式。最常见的两种是定时计划和 Webhook。定时计划适合周期性任务,Webhook 则适合外部系统回调的场景。

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
# --- 定时计划 ---
# 创建每天凌晨 2 点执行的日程
$ScheduleName = 'daily-snapshot-cleanup'

New-AzAutomationSchedule `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $ScheduleName `
-StartTime '2026-03-19T02:00:00+08:00' `
-DayInterval 1 `
-Description '每天凌晨清理过期快照'

# 将日程绑定到 Runbook 并传入参数
Register-AzAutomationScheduledRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName `
-ScheduleName $ScheduleName `
-Parameters @{
ResourceGroupName = 'rg-production'
OlderThanDays = 30
WhatIf = $false
}

Write-Host "定时计划 '$ScheduleName' 已绑定到 Runbook '$RunbookName'"

# --- Webhook 触发 ---
$WebhookName = 'cleanup-trigger'
$ExpiryTime = (Get-Date).AddYears(1)

$Webhook = New-AzAutomationWebhook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName `
-Name $WebhookName `
-IsEnabled $true `
-ExpiryTime $ExpiryTime

Write-Host "Webhook URL(仅显示一次,请妥善保存):"
Write-Host $Webhook.WebhookURI

# --- 关联凭据(使用 Automation 凭据代替明文密码)#
$Cred = Get-Credential -Message '输入服务主体凭据'
New-AzAutomationCredential `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name 'ServicePrincipalCred' `
-Value $Cred

Write-Host "凭据已添加到 Automation Account"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ScheduleName    : daily-snapshot-cleanup
StartTime : 2026-03-19 02:00:00 +08:00
ExpiryTime : 9999-12-31 23:59:59
Interval : 1
Frequency : Day
IsEnabled : True

定时计划 'daily-snapshot-cleanup' 已绑定到 Runbook 'Clean-OldSnapshots'

WebhookName : cleanup-trigger
IsEnabled : True
ExpiryTime : 2027-03-18 02:30:00
RunbookName : Clean-OldSnapshots

Webhook URL(仅显示一次,请妥善保存):
https://s15events.azure-automation.net/webhooks?token=Jvq%2BdGGYaC%2F...masked...
凭据已添加到 Automation Account

通过 Webhook 触发 Runbook 的方式很简单,只需发送一个 HTTP POST 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 外部系统通过 Webhook 触发 Runbook
$WebhookUri = 'https://s15events.azure-automation.net/webhooks?token=...'

$Body = @{
ResourceGroupName = 'rg-staging'
OlderThanDays = 14
WhatIf = $true
} | ConvertTo-Json

$Response = Invoke-RestMethod -Uri $WebhookUri -Method Post `
-Body $Body -ContentType 'application/json'

Write-Host "作业已触发,Job ID: $($Response.jobIds[0])"

执行结果示例:

1
作业已触发,Job ID: 7a3f9c2e-4b12-4d8a-9e1f-5c6d7b8a1234

监控作业状态与日志输出

Runbook 每次执行都会产生一个 Job。监控 Job 状态、查看输出日志和配置告警是确保自动化可靠运行的关键。

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
# --- 查询作业状态 ---
# 获取最近 10 个 Job
$Jobs = Get-AzAutomationJob `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName |
Sort-Object CreationTime -Descending |
Select-Object -First 10

$Jobs | Format-Table JobId, Status, CreationTime, StartTime, EndTime -AutoSize

# --- 获取特定 Job 的输出 ---
$LatestJob = $Jobs | Select-Object -First 1

$Output = Get-AzAutomationJobOutput `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Id $LatestJob.JobId

foreach ($Record in $Output) {
$FullRecord = Get-AzAutomationJobOutputRecord `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-JobId $LatestJob.JobId `
-Id $Record.StreamRecordId
Write-Host "[$($Record.Type)] $($FullRecord.Value)"
}

# --- 错误处理与告警 ---
$FailedJobs = $Jobs | Where-Object { $_.Status -eq 'Failed' }

if ($FailedJobs) {
$AlertBody = @{
text = "Runbook '$RunbookName' 有 $($FailedJobs.Count) 个失败作业"
details = ($FailedJobs | ForEach-Object {
"JobId: $($_.JobId), Time: $($_.CreationTime)"
}) -join '; '
} | ConvertTo-Json -Depth 3

# 发送到 Microsoft Teams 或 Slack(示例用 Teams Webhook)
$TeamsWebhook = 'https://outlook.office.com/webhook/...'

Invoke-RestMethod -Uri $TeamsWebhook -Method Post `
-Body $AlertBody -ContentType 'application/json'

Write-Host "告警已发送,共 $($FailedJobs.Count) 个失败作业"
} else {
Write-Host "所有作业状态正常"
}

# --- Runbook 内部的结构化日志 ---
# 在 Runbook 脚本中推荐使用 Write-Output 和 Write-Warning
# 配合 $PSItem.ErrorRecord 做异常捕获
$RunbookWithLogging = @'
try {
$Conn = Get-AutomationConnection -Name 'AzureRunAsConnection'
Connect-AzAccount -ServicePrincipal `
-Tenant $Conn.TenantId `
-ApplicationId $Conn.ApplicationId `
-CertificateThumbprint $Conn.CertificateThumbprint |
Out-Null

Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 连接 Azure 成功"
Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 开始执行清理任务"

# ... 主要逻辑 ...

Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 任务完成"
}
catch {
$ErrorMsg = "[$(Get-Date -Format 'HH:mm:ss')] 错误: $($_.Exception.Message)"
Write-Error $ErrorMsg
throw $ErrorMsg
}
'@

Write-Host "带日志的 Runbook 模板已准备就绪"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
JobId                                  Status   CreationTime         StartTime            EndTime
----- ------ ------------ ---------- -------
7a3f9c2e-4b12-4d8a-9e1f-5c6d7b8a1234 Completed 2026-03-18 02:00:12 2026-03-18 02:00:15 2026-03-18 02:01:03
b1e8d3a5-7c09-4f2e-b3d4-8e9f0a1b2c3d Completed 2026-03-17 02:00:08 2026-03-17 02:00:11 2026-03-17 02:00:58
c4f0e2b6-9d21-4a3c-b5e7-1f2a3b4c5d6e Failed 2026-03-16 02:00:05 2026-03-16 02:00:09 2026-03-16 02:00:12

[Output] [02:00:16] 连接 Azure 成功
[Output] [02:00:17] 找到 12 个超过 30 天的快照
[Output] [02:00:18] [WhatIf] 将删除快照: disk-snap-20260115 (创建于 2026-01-15)
[Output] [02:01:03] 任务完成

告警已发送,共 1 个失败作业
带日志的 Runbook 模板已准备就绪

注意事项

  1. 模块版本管理:Automation Account 中 Az 模块的版本可能与本地不同。发布 Runbook 前务必确认云端模块版本支持你使用的 cmdlet,否则运行时会报”找不到命令”错误。

  2. 凭据安全:不要在 Runbook 中硬编码密码或密钥。使用 Get-AutomationConnectionGet-AutomationCredentialGet-AutomationVariable 从 Automation Account 的安全存储中获取敏感信息。

  3. 执行时间限制:Azure Automation 的 Free/Basic 计划中,单个 Job 的最长运行时间为 3 小时。超时后 Job 会被强制挂起(Suspended)。长时间运行的任务应拆分为多个 Runbook 或使用检查点(Checkpoint)。

  4. Webhook URL 安全:Webhook URL 包含认证令牌,创建后只显示一次。务必妥善保存,泄露后任何人都可触发你的 Runbook。如果怀疑泄露,应立即删除并重建 Webhook。

  5. 日志级别选择:Runbook 中 Write-Output 写入的信息会出现在 Job 的 Output 流中,Write-Warning 进入 Warning 流,Write-Error 进入 Error 流。根据需要选择合适的级别,避免关键信息被大量调试输出淹没。

  6. 计划时区注意New-AzAutomationSchedule-StartTime 参数使用 UTC 时间(除非显式指定时区偏移)。如果你的运维窗口是北京时间凌晨 2 点,需要写成 18:00:00Z02:00:00+08:00

PowerShell 技能连载 - Azure Policy 合规治理

适用于 PowerShell 7.0 及以上版本

在多团队、多订阅的云环境中,资源合规性是治理的核心难题。开发团队随手创建了一个 Premium SKU 的 Redis 缓存,费用瞬间翻了十倍;某个资源组忘了打标签,月底账单无法分摊到业务线;还有人不小心把资源部署到了不支持数据驻留的区域。这些问题在单订阅时还能人工检查,到了几十个订阅、上百个资源组的规模,靠人工审计根本忙不过来。

Azure Policy 提供了声明式的合规规则引擎,可以在资源创建或更新时自动评估是否满足预设条件。不满足条件的资源可以被审计记录、拒绝创建,甚至自动修复到合规状态。结合管理组(Management Group)的层级结构,一条策略可以向下继承到所有子订阅,真正做到”治理即代码”。

本文将演示如何通过 PowerShell 完成 Azure Policy 的三大核心操作:策略定义与管理、策略分配与范围控制、合规审计与自动修复。让你从手工巡检的时代彻底迈入自动化合规治理。

策略定义与管理

策略定义是合规治理的基础构建块。Azure 提供了数百个内置策略定义,覆盖了命名规范、SKU 限制、区域约束、强制标记等常见场景。当内置策略无法满足需求时,可以通过自定义策略定义来实现精确的合规规则。下面的脚本演示如何检索内置策略、创建参数化的自定义策略,并将策略组织到策略集(Initiative)中统一管理。

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
# 连接 Azure
Connect-AzAccount -Subscription 'production-subscription'

# --- 检索常用内置策略定义 ---
# 查找与"允许位置"相关的内置策略
$builtInPolicies = Get-AzPolicyDefinition | Where-Object {
$_.DisplayName -like '*allowed locations*' -or
$_.DisplayName -like '*允许的位置*'
} | Select-Object -First 5 DisplayName, PolicyType, Name

Write-Host '=== 内置策略:允许的位置 ===' -ForegroundColor Cyan
$builtInPolicies | Format-Table -AutoSize

# 查找与"强制标记"相关的内置策略
$tagPolicies = Get-AzPolicyDefinition | Where-Object {
$_.DisplayName -like '*tag*' -and $_.PolicyType -eq 'BuiltIn'
} | Select-Object -First 5 DisplayName, Name

Write-Host '=== 内置策略:标记相关 ===' -ForegroundColor Cyan
$tagPolicies | Format-Table -AutoSize

# --- 创建自定义策略定义:禁止创建未标记成本中心的资源 ---
$policyRule = @{
if = @{
allOf = @(
@{
field = 'type'
notLike = 'Microsoft.Resources/*'
}
@{
field = '[concat(''tags'', ''.'', ''CostCenter'')]'
exists = 'false'
}
)
}
then = @{
effect = 'deny'
}
}

$policyParameters = @{
tagName = @{
type = 'String'
metadata = @{
displayName = '标记名称'
description = '要求必须存在的标记名称'
}
}
}

# 使用参数化的方式构建策略规则
$parameterizedRule = @{
if = @{
allOf = @(
@{
field = 'type'
notLike = 'Microsoft.Resources/*'
}
@{
field = "[concat('tags', '.', parameters('tagName'))]"
exists = 'false'
}
)
}
then = @{
effect = 'deny'
}
}

$customPolicy = New-AzPolicyDefinition `
-Name 'require-mandatory-tag' `
-DisplayName '要求资源必须包含指定标记' `
-Description '拒绝创建未包含指定标记的资源,确保成本分摊和资源归属可追溯' `
-Policy $parameterizedRule `
-Parameter $policyParameters `
-Mode 'Indexed'

Write-Host "自定义策略已创建: $($customPolicy.Name)" -ForegroundColor Green

# --- 创建策略集(Initiative)将多个策略打包管理 ---
$initiativeDefinition = @{
name = 'cost-governance-initiative'
displayName = '成本治理策略集'
description = '统一管理所有与成本控制相关的策略,包括标记、SKU 限制和区域约束'
policyDefinitions = @(
@{
policyDefinitionId = $customPolicy.Id
parameters = @{
tagName = @{ value = 'CostCenter' }
}
}
)
}

# 获取内置的"允许位置"策略
$locationPolicy = Get-AzPolicyDefinition |
Where-Object { $_.Name -eq 'e56962a6-4747-49cd-b67b-bf8b01975c4c' }

if ($locationPolicy) {
$initiativeDefinition.policyDefinitions += @{
policyDefinitionId = $locationPolicy.Id
parameters = @{
listOfAllowedLocations = @{
value = @('eastasia', 'southeastasia', 'eastus', 'westus2')
}
}
}
}

$initiative = New-AzPolicySetDefinition `
-Name $initiativeDefinition.name `
-DisplayName $initiativeDefinition.displayName `
-Description $initiativeDefinition.description `
-PolicyDefinition ($initiativeDefinition.policyDefinitions | ConvertTo-Json -Depth 5)

Write-Host "策略集已创建: $($initiative.Name)" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== 内置策略:允许的位置 ===
DisplayName PolicyType Name
----------- ---------- ----
允许的资源位置 BuiltIn e56962a6-4747-49cd-b67b-bf8b01975c4c
允许的资源组位置 BuiltIn e765b5de-1225-4ba3-bd56-1ac667b5e923

=== 内置策略:标记相关 ===
DisplayName Name
----------- ----
要求指定标记值 1e30110a-5ceb-460c-a204-f92a36c51380
在资源组中添加标记 96670d01-0a4d-4649-9c89-66d8e766f889
从资源组继承标记 cd3aa116-7824-4a4f-820e-52c5b31d1d0e
要求资源组具有标记 96670d01-0a4d-4649-66d8e766f889
强制添加标记及其默认值 1e30110a-5ceb-460c-a204-f92a36c51380

自定义策略已创建: require-mandatory-tag
策略集已创建: cost-governance-initiative

策略分配与范围控制

策略定义和策略集创建完成后,需要将它们分配到特定的范围才能生效。Azure Policy 支持多层级范围分配:管理组、订阅、资源组,甚至单个资源。策略分配时可以传入参数值来覆盖默认配置,还可以设置豁免(Exemption)来排除特定资源。下面的脚本演示订阅级分配、管理组级批量分配,以及针对特定资源的豁免配置。

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
# --- 订阅级策略分配 ---
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"

# 获取之前创建的策略集
$initiative = Get-AzPolicySetDefinition -Name 'cost-governance-initiative'

# 创建订阅级策略分配
$assignment = New-AzPolicyAssignment `
-Name 'cost-governance-sub' `
-DisplayName '订阅级成本治理策略' `
-Scope $scope `
-PolicySetDefinition $initiative `
-Description '在此订阅中强制执行标记要求和区域限制'

Write-Host "订阅级策略分配已创建: $($assignment.Name)" -ForegroundColor Green

# --- 资源组级策略分配(更精细的控制) ---
$rgScope = "$scope/resourceGroups/rg-production"
$skuPolicy = Get-AzPolicyDefinition |
Where-Object { $_.DisplayName -like '*allowed resource types*' } |
Select-Object -First 1

# 为生产环境资源组分配更严格的策略
$rgAssignment = New-AzPolicyAssignment `
-Name 'production-sku-restriction' `
-DisplayName '生产环境 SKU 限制' `
-Scope $rgScope `
-PolicyDefinition $skuPolicy `
-Description '限制生产环境只允许创建特定资源类型'

Write-Host "资源组级策略分配已创建: $($rgAssignment.Name)" -ForegroundColor Green

# --- 管理组级批量分配(跨订阅统一治理) ---
$mgScope = '/providers/Microsoft.Management/managementGroups/mg-enterprise'

# 获取管理组下的所有订阅
$mgSubscriptions = Get-AzManagementGroupSubscription `
-GroupName 'mg-enterprise' -Expand

Write-Host "管理组下的订阅数量: $($mgSubscriptions.Count)"

# 为管理组分配策略集,所有子订阅自动继承
$mgAssignment = New-AzPolicyAssignment `
-Name 'enterprise-governance' `
-DisplayName '企业级合规治理策略' `
-Scope $mgScope `
-PolicySetDefinition $initiative `
-EnforcementMode 'Default'

Write-Host "管理组级策略分配已创建(子订阅将自动继承)" -ForegroundColor Green

# --- 配置策略豁免 ---
# 对特定资源(如测试环境)豁免标记策略
$exemptionScope = "$scope/resourceGroups/rg-sandbox"
$waiverAssignment = Get-AzPolicyAssignment -Name 'cost-governance-sub'

# 创建策略豁免
$exemption = New-AzPolicyExemption `
-Name 'sandbox-tag-exemption' `
-DisplayName '沙盒环境标记豁免' `
-Scope $exemptionScope `
-PolicyAssignment $waiverAssignment `
-ExemptionCategory 'Waiver' `
-ExpiresOn (Get-Date).AddMonths(6) `
-Description '沙盒环境暂不强制标记要求,6个月后重新评估'

Write-Host "策略豁免已创建,有效期至: $($exemption.Properties.ExpiresOn)" -ForegroundColor Green

# --- 查看所有策略分配 ---
$allAssignments = Get-AzPolicyAssignment | Where-Object {
$_.Scope -like "*$subscriptionId*"
} | Select-Object DisplayName, Scope, EnforcementMode

Write-Host "`n=== 当前订阅下的策略分配 ===" -ForegroundColor Cyan
$allAssignments | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
订阅级策略分配已创建: cost-governance-sub
资源组级策略分配已创建: production-sku-restriction
管理组下的订阅数量: 8
管理组级策略分配已创建(子订阅将自动继承)
策略豁免已创建,有效期至: 2026-09-17

=== 当前订阅下的策略分配 ===
DisplayName Scope EnforcementMode
----------- ----- ---------------
订阅级成本治理策略 /subscriptions/xxxx-xxxx-xxxx Default
生产环境 SKU 限制 /subscriptions/xxxx-xxxx/.../rg-production Default
企业级合规治理策略 /providers/Microsoft.Management/.../mg-enterprise Default

沙盒环境标记豁免已生效,到期时间: 2026-09-17T08:00:00Z

合规审计与自动修复

策略分配完成后,持续的合规监控是确保治理有效性的关键。Azure Policy 会定期评估资源的合规状态,对于不合规的资源可以配置自动修复任务将其修正到合规状态。通过 PowerShell 脚本化的合规审计,可以生成定期的合规报告、追踪合规趋势,并针对高优先级的不合规资源触发修复操作。下面的脚本演示如何查询合规状态、创建修复任务以及生成合规趋势报告。

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
# --- 查询合规状态 ---
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"

# 获取所有策略分配的合规状态摘要
$complianceStates = Get-AzPolicyState | Where-Object {
$_.ResourceLocation -and $_.PolicyAssignmentName
} | Group-Object PolicyAssignmentName, ComplianceState | ForEach-Object {
[PSCustomObject]@{
PolicyAssignment = $_.Values[0]
ComplianceState = $_.Values[1]
ResourceCount = $_.Count
}
}

Write-Host '=== 合规状态摘要 ===' -ForegroundColor Cyan
$complianceStates | Sort-Object PolicyAssignment, ComplianceState |
Format-Table -AutoSize

# 查询不合规资源的详细信息
$nonCompliant = Get-AzPolicyState | Where-Object {
$_.ComplianceState -eq 'NonCompliant'
} | Select-Object -First 20 `
ResourceId, ResourceType, ResourceLocation,
PolicyDefinitionName, PolicyAssignmentName,
Timestamp

Write-Host "`n=== 不合规资源(前 20 条)===" -ForegroundColor Yellow
$nonCompliant | Format-Table -AutoSize

# --- 创建自动修复任务 ---
# 获取需要修复的策略分配
$tagAssignment = Get-AzPolicyAssignment -Name 'cost-governance-sub'

# 创建修复任务:为缺少标记的资源自动添加默认标记值
$remediationTask = Start-AzPolicyRemediation `
-Name 'remediate-missing-tags' `
-PolicyAssignmentId $tagAssignment.PolicyAssignmentId `
-ResourceDiscoveryMode 'ExistingNonCompliant' `
-Description '为所有缺少 CostCenter 标记的资源添加默认值'

Write-Host "修复任务已启动: $($remediationTask.Name)" -ForegroundColor Green
Write-Host "待修复资源数: $($remediationTask.ProvisioningState)"

# 轮询修复任务状态
$taskName = $remediationTask.Name
$maxRetries = 12
$retryCount = 0

while ($retryCount -lt $maxRetries) {
Start-Sleep -Seconds 30
$task = Get-AzPolicyRemediation -Name $taskName `
-Scope $scope -ErrorAction SilentlyContinue

if ($task -and $task.ProvisioningState -eq 'Succeeded') {
Write-Host "修复任务完成" -ForegroundColor Green
break
}

$retryCount++
Write-Host "等待修复任务完成... ($retryCount/$maxRetries)"
}

# --- 生成合规趋势报告 ---
function Get-ComplianceTrend {
param(
[string]$Scope,
[int]$Days = 30
)

$startDate = (Get-Date).AddDays(-$Days)
$trend = @()

for ($day = 0; $day -lt $Days; $day += 7) {
$checkDate = $startDate.AddDays($day)
$dateLabel = $checkDate.ToString('yyyy-MM-dd')

# 获取该时间段的合规统计
$states = Get-AzPolicyState | Where-Object {
$_.Timestamp -ge $checkDate -and
$_.Timestamp -lt $checkDate.AddDays(7)
}

$total = ($states | Measure-Object).Count
$compliant = ($states | Where-Object {
$_.ComplianceState -eq 'Compliant'
} | Measure-Object).Count

$complianceRate = if ($total -gt 0) {
[math]::Round(($compliant / $total) * 100, 1)
} else {
0
}

$trend += [PSCustomObject]@{
Week = $dateLabel
TotalResources = $total
Compliant = $compliant
NonCompliant = $total - $compliant
Rate = "$complianceRate%"
}
}

return $trend
}

# 生成最近 30 天的合规趋势
$trendReport = Get-ComplianceTrend -Scope $scope -Days 30

Write-Host "`n=== 合规趋势报告(最近 30 天)===" -ForegroundColor Cyan
$trendReport | Format-Table -AutoSize

# 计算总体合规率
$latestWeek = $trendReport | Select-Object -Last 1
Write-Host "`n最新一周合规率: $($latestWeek.Rate)" -ForegroundColor Green

执行结果示例:

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
=== 合规状态摘要 ===
PolicyAssignment ComplianceState ResourceCount
--------------- --------------- -------------
cost-governance-sub Compliant 156
cost-governance-sub NonCompliant 23
enterprise-governance Compliant 1024
enterprise-governance NonCompliant 87
production-sku-restriction Compliant 48
production-sku-restriction NonCompliant 3

=== 不合规资源(前 20 条)===
ResourceId ResourceType ResourceLocation Timestamp
--------- ------------ ---------------- ---------
/subs/.../rg-prod/vm-test-01 Microsoft.Compute eastasia 2026-03-17 07:45:00
/subs/.../rg-prod/st-shared-data Microsoft.Storage eastasia 2026-03-17 07:30:00
/subs/.../rg-prod/plan-dev Microsoft.Web eastasia 2026-03-17 07:15:00

修复任务已启动: remediate-missing-tags
待修复资源数: Accepted
等待修复任务完成... (1/12)
等待修复任务完成... (2/12)
修复任务完成

=== 合规趋势报告(最近 30 天)===
Week TotalResources Compliant NonCompliant Rate
---- -------------- --------- ------------ ----
2026-02-15 187 142 45 75.9%
2026-02-22 195 158 37 81.0%
2026-03-01 210 179 31 85.2%
2026-03-08 215 190 25 88.4%
2026-03-15 220 197 23 89.5%

最新一周合规率: 89.5%

注意事项

  1. 策略评估延迟:策略分配创建或更新后,Azure Policy 引擎需要 15 到 30 分钟完成初次评估。在此期间,Azure Portal 中显示的合规状态可能不准确。如果需要立即验证某个资源的合规性,可以使用 Get-AzPolicyState 按 ResourceId 精确查询。

  2. Deny 效果的影响范围:使用 deny 效果的策略会阻止不合规资源的创建和更新操作,这在生产环境中可能影响正常的部署流程。建议先用 audit 效果观察一段时间,确认影响范围后再切换为 deny。可以在策略定义中使用 DefaultMode 来控制效果。

  3. 修复任务的权限要求:自动修复任务使用托管标识来执行资源修改操作。创建修复任务前,必须确保策略定义中配置了正确的 roleDefinitionIds,并且修复任务的托管标识已被授予相应的 RBAC 角色,否则修复任务会因权限不足而失败。

  4. 策略豁免的审批流程:虽然 PowerShell 可以直接创建策略豁免,但在企业环境中建议将豁免请求纳入变更管理流程。可以结合 Azure DevOps Pipeline 或 GitHub Actions 实现豁免的审批和自动化创建,确保每一次豁免都有迹可循。

  5. 策略定义的模式选择:策略定义的 mode 参数决定了策略评估的资源范围。All 模式覆盖所有资源类型,Indexed 模式仅评估支持标记和位置的资源类型。针对标记和位置的策略应使用 Indexed 模式以减少评估开销,而针对 SKU 限制的策略需要使用 All 模式。

  6. 大规模环境的性能优化:在管理组层级管理数百个订阅时,避免为每个订阅单独创建策略分配。优先在管理组层级分配策略集(Initiative),让策略自动向下继承。同时,善用 resourceSelectorselectors 属性来精确控制策略评估的资源范围,减少不必要的评估开销。

PowerShell 技能连载 - 系统诊断脚本集

适用于 PowerShell 5.1 及以上版本

系统管理员和运维工程师在日常工作中,经常需要面对各种系统故障和性能问题。当用户反馈系统卡顿、服务响应缓慢时,快速定位问题根因是恢复服务的关键。传统的排查方式是手动逐项检查——先看 CPU,再查内存,然后翻日志——不仅耗时,还容易遗漏关键线索。

通过 PowerShell 编写系统诊断脚本,可以将这些分散的检查步骤自动化,形成一套标准化的诊断流程。脚本可以在几秒内完成对硬件资源、操作系统状态、网络连接和安全配置的全面扫描,并以结构化报告的形式输出结果,帮助运维人员快速做出判断。

本文提供三个层次的诊断脚本:从硬件性能分析开始,到操作系统与服务状态检查,最后整合为一键全量诊断报告,方便直接集成到运维自动化平台中使用。

硬件与性能诊断

第一个脚本专注于硬件资源层面的诊断。它会采集 CPU 使用率、内存占用、磁盘空间等核心指标,并自动识别占用资源最高的进程,帮助快速定位性能瓶颈。

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
function Get-HardwareDiagnostic {
[CmdletBinding()]
param(
[double]$CpuThreshold = 80,
[double]$MemoryThreshold = 85,
[double]$DiskThreshold = 90
)

$result = [ordered]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
ComputerName = $env:COMPUTERNAME
Status = 'Healthy'
Alerts = @()
}

# CPU 使用率检查
$cpuCounter = '\Processor(_Total)\% Processor Time'
$cpuSample1 = (Get-Counter -Counter $cpuCounter -SampleInterval 1 -MaxSamples 3).CounterSamples
Start-Sleep -Seconds 1
$cpuAvg = [math]::Round(($cpuSample1 | Measure-Object -Property CookedValue -Average).Average, 2)

$result['CpuUsage'] = "$cpuAvg%"

if ($cpuAvg -gt $CpuThreshold) {
$result['Status'] = 'Warning'
$result['Alerts'] += "CPU 使用率 ${cpuAvg}% 超过阈值 ${CpuThreshold}%"
}

# 内存使用检查
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem
$totalMem = [math]::Round($osInfo.TotalVisibleMemorySize / 1MB, 2)
$freeMem = [math]::Round($osInfo.FreePhysicalMemory / 1MB, 2)
$usedMemPercent = [math]::Round(($osInfo.TotalVisibleMemorySize - $osInfo.FreePhysicalMemory) / $osInfo.TotalVisibleMemorySize * 100, 2)

$result['Memory'] = @{
TotalGB = $totalMem
FreeGB = $freeMem
UsedPercent = "$usedMemPercent%"
}

if ($usedMemPercent -gt $MemoryThreshold) {
$result['Status'] = 'Warning'
$result['Alerts'] += "内存使用率 ${usedMemPercent}% 超过阈值 ${MemoryThreshold}%"
}

# 磁盘空间检查
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3'
$diskReport = foreach ($disk in $disks) {
$freePercent = [math]::Round($disk.FreeSpace / $disk.Size * 100, 2)
if ($freePercent -gt (100 - $DiskThreshold)) {
$result['Status'] = 'Warning'
$result['Alerts'] += "磁盘 $($disk.DeviceID) 可用空间仅 ${freePercent}%,低于安全阈值"
}
[ordered]@{
Drive = $disk.DeviceID
TotalGB = [math]::Round($disk.Size / 1GB, 2)
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
FreePercent = "$freePercent%"
}
}
$result['Disks'] = $diskReport

# Top 10 资源占用进程
$topProcesses = Get-Process |
Sort-Object -Property WorkingSet64 -Descending |
Select-Object -First 10 |
ForEach-Object {
[ordered]@{
Name = $_.Name
PID = $_.Id
MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 2)
CpuSeconds = [math]::Round($_.CPU, 2)
}
}
$result['TopProcesses'] = $topProcesses

return [PSCustomObject]$result
}

# 执行硬件诊断
$hardwareReport = Get-HardwareDiagnostic -CpuThreshold 80 -MemoryThreshold 85 -DiskThreshold 90
$hardwareReport | ConvertTo-Json -Depth 5

执行结果示例:

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
{
"Timestamp": "2026-03-16 09:15:32",
"ComputerName": "SRV-PROD-01",
"Status": "Warning",
"Alerts": [
"内存使用率 87.35% 超过阈值 85%",
"磁盘 C: 可用空间仅 8.12%,低于安全阈值"
],
"CpuUsage": "42.67%",
"Memory": {
"TotalGB": 32.00,
"FreeGB": 4.05,
"UsedPercent": "87.35%"
},
"Disks": [
{
"Drive": "C:",
"TotalGB": 256.00,
"FreeGB": 20.78,
"FreePercent": "8.12%"
},
{
"Drive": "D:",
"TotalGB": 1024.00,
"FreeGB": 612.34,
"FreePercent": "59.80%"
}
],
"TopProcesses": [
{
"Name": "sqlservr",
"PID": 4521,
"MemoryMB": 8192.45,
"CpuSeconds": 123456.78
},
{
"Name": "w3wp",
"PID": 3312,
"MemoryMB": 4096.12,
"CpuSeconds": 56789.01
}
]
}

操作系统与服务诊断

第二个脚本聚焦于操作系统层面,自动检查关键 Windows 服务的运行状态、扫描系统事件日志中的异常条目,并检测待安装的系统更新。

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
function Get-OSDiagnostic {
[CmdletBinding()]
param(
[string[]]$CriticalServices = @('WinRM', 'EventLog', 'LanmanServer', 'LanmanWorkstation', 'Schedule'),
[int]$EventLogHours = 24,
[int]$MaxEvents = 50
)

$result = [ordered]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
ComputerName = $env:COMPUTERNAME
Status = 'Healthy'
Alerts = @()
}

# 操作系统基本信息
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$result['OS'] = @{
Caption = $os.Caption
Version = $os.Version
BuildNumber = $os.BuildNumber
LastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss')
UptimeDays = [math]::Round((Get-Date) - $os.LastBootUpTime | Select-Object -ExpandProperty TotalDays, 1)
}

# 关键服务状态检查
$serviceReport = foreach ($svcName in $CriticalServices) {
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if ($svc) {
$status = if ($svc.Status -eq 'Running') { 'OK' } else { 'Alert' }
if ($status -eq 'Alert') {
$result['Status'] = 'Warning'
$result['Alerts'] += "服务 $svcName 状态异常: $($svc.Status)"
}
[ordered]@{
Name = $svcName
DisplayName = $svc.DisplayName
Status = $svc.Status.ToString()
StartType = $svc.StartType.ToString()
CheckResult = $status
}
} else {
$result['Alerts'] += "服务 $svcName 未找到"
[ordered]@{
Name = $svcName
DisplayName = 'N/A'
Status = 'NotFound'
StartType = 'N/A'
CheckResult = 'Error'
}
}
}
$result['Services'] = $serviceReport

# 事件日志异常检查(最近 N 小时的错误和警告)
$startTime = (Get-Date).AddHours(-$EventLogHours)
$eventFilter = @{
LogName = 'System'
Level = 2, 3
StartTime = $startTime
}
$errorEvents = Get-WinEvent -FilterHashtable $eventFilter -MaxEvents $MaxEvents -ErrorAction SilentlyContinue

$eventSummary = $errorEvents |
Group-Object -Property ProviderName |
Sort-Object -Property Count -Descending |
Select-Object -First 10 |
ForEach-Object {
[ordered]@{
Source = $_.Name
Count = $_.Count
Examples = ($_.Group | Select-Object -First 2 | ForEach-Object { "$($_.TimeCreated.ToString('HH:mm:ss')) $($_.Message.Substring(0, [math]::Min(80, $_.Message.Length)))" })
}
}
$result['EventLogErrors'] = @{
TimeRange = "最近 ${EventLogHours} 小时"
TotalErrors = if ($errorEvents) { $errorEvents.Count } else { 0 }
TopSources = $eventSummary
}

if ($errorEvents -and $errorEvents.Count -gt 20) {
$result['Status'] = 'Warning'
$result['Alerts'] += "系统事件日志在最近 ${EventLogHours} 小时内有 $($errorEvents.Count) 条错误/警告"
}

# 待安装系统更新检查(需要 PSWindowsUpdate 模块)
$updateAvailable = $false
try {
Import-Module PSWindowsUpdate -ErrorAction Stop
$updates = Get-WindowsUpdate -AcceptAll -Install -AutoReboot:$false -WhatIf 2>$null
if ($updates) {
$updateAvailable = $true
$result['PendingUpdates'] = $updates | ForEach-Object {
@{ Title = $_.Title; Size = $_.Size }
}
$result['Alerts'] += "有 $($updates.Count) 个系统更新待安装"
}
} catch {
$result['PendingUpdates'] = '无法检查(PSWindowsUpdate 模块未安装)'
}

return [PSCustomObject]$result
}

# 执行操作系统诊断
$osReport = Get-OSDiagnostic -CriticalServices @('WinRM', 'EventLog', 'LanmanServer', 'LanmanWorkstation', 'Schedule', 'Spooler') -EventLogHours 24
$osReport | ConvertTo-Json -Depth 5

执行结果示例:

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
{
"Timestamp": "2026-03-16 09:16:45",
"ComputerName": "SRV-PROD-01",
"Status": "Warning",
"Alerts": [
"服务 Spooler 状态异常: Stopped",
"系统事件日志在最近 24 小时内有 47 条错误/警告"
],
"OS": {
"Caption": "Microsoft Windows Server 2022 Datacenter",
"Version": "10.0.20348",
"BuildNumber": "20348",
"LastBootTime": "2026-03-10 03:00:00",
"UptimeDays": 6.3
},
"Services": [
{
"Name": "WinRM",
"DisplayName": "Windows Remote Management (WS-Management)",
"Status": "Running",
"StartType": "Automatic",
"CheckResult": "OK"
},
{
"Name": "Spooler",
"DisplayName": "Print Spooler",
"Status": "Stopped",
"StartType": "Automatic",
"CheckResult": "Alert"
}
],
"EventLogErrors": {
"TimeRange": "最近 24 小时",
"TotalErrors": 47,
"TopSources": [
{
"Source": "Microsoft-Windows-Disk",
"Count": 18,
"Examples": [
"08:32:15 The device, \\Device\\Harddisk1\\DR1, has a bad block.",
"09:15:22 The device, \\Device\\Harddisk1\\DR1, has a bad block."
]
}
]
},
"PendingUpdates": "无法检查(PSWindowsUpdate 模块未安装)"
}

综合诊断报告

第三个脚本将前面的各项检查整合为一键全量诊断,计算健康评分,并生成可读性更好的 HTML 报告,方便通过邮件或 Web 平台分享。

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
function Invoke-FullSystemDiagnostic {
[CmdletBinding()]
param(
[string]$OutputPath = "$env:TEMP\SystemDiagnosticReport.html",
[double]$CpuThreshold = 80,
[double]$MemoryThreshold = 85,
[double]$DiskThreshold = 90
)

Write-Host "开始全量系统诊断..." -ForegroundColor Cyan
$diagStart = Get-Date

# 采集各项指标
Write-Host " [1/4] 采集硬件指标..." -ForegroundColor Gray
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$cpuSample = (Get-Counter -Counter '\Processor(_Total)\% Processor Time' -SampleInterval 1 -MaxSamples 3).CounterSamples
$cpuAvg = [math]::Round(($cpuSample | Measure-Object -Property CookedValue -Average).Average, 2)
$memUsedPercent = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 2)
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3'

Write-Host " [2/4] 检查服务状态..." -ForegroundColor Gray
$criticalSvcs = @('WinRM', 'EventLog', 'LanmanServer', 'LanmanWorkstation', 'Schedule')
$svcResults = foreach ($name in $criticalSvcs) {
$s = Get-Service -Name $name -ErrorAction SilentlyContinue
@{ Name = $name; Status = if ($s) { $s.Status.ToString() } else { 'NotFound' } }
}

Write-Host " [3/4] 扫描事件日志..." -ForegroundColor Gray
$startTime = (Get-Date).AddHours(-24)
$errors = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 2; StartTime = $startTime } -MaxEvents 100 -ErrorAction SilentlyContinue)
$warnings = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 3; StartTime = $startTime } -MaxEvents 100 -ErrorAction SilentlyContinue)

Write-Host " [4/4] 计算健康评分..." -ForegroundColor Gray

# 健康评分计算(满分 100)
$score = 100

# CPU 扣分(每超阈值 1% 扣 0.5 分,最多扣 20 分)
$cpuDeduction = [math]::Min(20, [math]::Max(0, ($cpuAvg - $CpuThreshold) * 0.5))
$score -= $cpuDeduction

# 内存扣分
$memDeduction = [math]::Min(20, [math]::Max(0, ($memUsedPercent - $MemoryThreshold) * 0.5))
$score -= $memDeduction

# 磁盘扣分
foreach ($disk in $disks) {
$diskUsedPercent = [math]::Round(($disk.Size - $disk.FreeSpace) / $disk.Size * 100, 2)
if ($diskUsedPercent -gt $DiskThreshold) {
$score -= [math]::Min(10, [math]::Max(0, ($diskUsedPercent - $DiskThreshold) * 0.3))
}
}

# 服务异常扣分(每个异常服务扣 5 分,最多扣 25 分)
$stoppedSvcs = @($svcResults | Where-Object { $_.Status -ne 'Running' })
$svcDeduction = [math]::Min(25, $stoppedSvcs.Count * 5)
$score -= $svcDeduction

# 事件日志错误扣分
$logDeduction = [math]::Min(15, [math]::Round($errors.Count / 5, 0))
$score -= $logDeduction

$score = [math]::Max(0, [math]::Round($score, 0))

# 确定总体状态
$overallStatus = if ($score -ge 80) { 'Healthy' } elseif ($score -ge 50) { 'Warning' } else { 'Critical' }

$diagDuration = [math]::Round(((Get-Date) - $diagStart).TotalSeconds, 1)

# 生成 HTML 报告
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>系统诊断报告 - $($env:COMPUTERNAME) - $(Get-Date -Format 'yyyy-MM-dd')</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 960px; margin: auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { color: #0078d4; border-bottom: 2px solid #0078d4; padding-bottom: 10px; }
.score { font-size: 48px; font-weight: bold; text-align: center; padding: 20px; }
.score.healthy { color: #107c10; }
.score.warning { color: #ff8c00; }
.score.critical { color: #d13438; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #0078d4; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
.badge { padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
.badge-ok { background: #dff6dd; color: #107c10; }
.badge-warn { background: #fff4ce; color: #ff8c00; }
.badge-error { background: #fde7e9; color: #d13438; }
</style>
</head>
<body>
<div class="container">
<h1>系统诊断报告</h1>
<p>计算机: <strong>$($env:COMPUTERNAME)</strong> | 生成时间: <strong>$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</strong> | 耗时: ${diagDuration}s</p>
<div class="score $overallStatus.ToLowerInvariant()">$score / 100</div>
<p style="text-align:center; font-size:18px;">总体状态: <strong>$overallStatus</strong></p>

<h2>CPU 使用率</h2>
<p>平均使用率: <strong>${cpuAvg}%</strong> (阈值: ${CpuThreshold}%)</p>

<h2>内存使用率</h2>
<p>已用: <strong>${memUsedPercent}%</strong> (阈值: ${MemoryThreshold}%)</p>

<h2>磁盘空间</h2>
<table>
<tr><th>驱动器</th><th>总容量</th><th>可用空间</th><th>可用百分比</th></tr>
$(foreach ($d in $disks) {
$freePct = [math]::Round($d.FreeSpace / $d.Size * 100, 2)
$badge = if ($freePct -gt 20) { 'badge-ok' } elseif ($freePct -gt 10) { 'badge-warn' } else { 'badge-error' }
"<tr><td>$($d.DeviceID)</td><td>$([math]::Round($d.Size/1GB,2)) GB</td><td>$([math]::Round($d.FreeSpace/1GB,2)) GB</td><td><span class=`"badge $badge`">${freePct}%</span></td></tr>"
})

<h2>关键服务状态</h2>
<table>
<tr><th>服务名</th><th>状态</th></tr>
$(foreach ($s in $svcResults) {
$badge = if ($s.Status -eq 'Running') { 'badge-ok' } else { 'badge-error' }
"<tr><td>$($s.Name)</td><td><span class=`"badge $badge`">$($s.Status)</span></td></tr>"
})

<h2>事件日志摘要(最近 24 小时)</h2>
<p>错误: <strong>$($errors.Count)</strong> 条 | 警告: <strong>$($warnings.Count)</strong> 条</p>

</div>
</body>
</html>
"@

$html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force
Write-Host "诊断完成!健康评分: $score / 100 [$overallStatus]" -ForegroundColor $(if ($overallStatus -eq 'Healthy') { 'Green' } elseif ($overallStatus -eq 'Warning') { 'Yellow' } else { 'Red' })
Write-Host "HTML 报告已保存至: $OutputPath" -ForegroundColor Cyan

return [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
Score = $score
Status = $overallStatus
CpuUsage = "$cpuAvg%"
MemoryUsage = "$memUsedPercent%"
Errors24h = $errors.Count
Warnings24h = $warnings.Count
ReportPath = $OutputPath
}
}

# 执行全量诊断并生成报告
$report = Invoke-FullSystemDiagnostic -OutputPath "$env:TEMP\SystemDiagnosticReport.html"
$report

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
开始全量系统诊断...
[1/4] 采集硬件指标...
[2/4] 检查服务状态...
[3/4] 扫描事件日志...
[4/4] 计算健康评分...
诊断完成!健康评分: 72 / 100 [Warning]
HTML 报告已保存至: C:\Users\admin\AppData\Local\Temp\SystemDiagnosticReport.html

ComputerName : SRV-PROD-01
Score : 72
Status : Warning
CpuUsage : 42.67%
MemoryUsage : 87.35%
Errors24h : 23
Warnings24h : 31
ReportPath : C:\Users\admin\AppData\Local\Temp\SystemDiagnosticReport.html

注意事项

  1. 运行权限:部分检查(如事件日志查询、服务状态枚举)需要管理员权限。建议以提升模式启动 PowerShell,或将脚本加入计划任务以 SYSTEM 身份运行。

  2. 性能影响:CPU 使用率采样需要短暂等待(默认 3 秒采样周期),在极端高负载场景下,脚本本身的执行也会消耗资源。生产环境可调整 -MaxSamples 参数降低采样频率。

  3. 跨平台兼容:本文脚本以 Windows 平台为主,使用了 Win32_OperatingSystemGet-Counter 等 Windows 专用命令。如需在 Linux 上运行,应替换为 /proc/meminfovmstat 等原生命令的封装。

  4. 健康评分的阈值:评分算法中的扣分权重(CPU 20 分、内存 20 分、磁盘 10 分、服务 25 分、日志 15 分)可根据实际运维需求调整。核心服务密集型环境应提高服务权重的扣分比例。

  5. HTML 报告安全:生成的 HTML 报告包含服务器名称、资源数据等敏感信息,传输时应通过内部网络或加密渠道分享,避免直接暴露在公网上。

  6. 定时执行建议:可以将 Invoke-FullSystemDiagnostic 配合 Windows 计划任务或 CI/CD 流水线定时执行,每天生成一份诊断报告。当健康评分低于设定阈值时自动触发告警通知,实现主动式运维监控。

PowerShell 技能连载 - Azure Virtual Desktop 管理

适用于 PowerShell 7.0 及以上版本,需要 Az.DesktopVirtualization 模块

背景

Azure Virtual Desktop(AVD)是微软在 Azure 上托管的桌面和应用虚拟化服务,为企业提供了集中管理、弹性扩展的远程办公解决方案。随着混合办公模式的普及,越来越多的企业选择 AVD 来统一管理员工桌面环境,降低终端设备运维成本,同时满足数据安全与合规要求。

对于 IT 运维团队来说,AVD 的日常管理涉及主机池创建、会话主机扩缩容、用户会话监控与故障排查等多个环节。虽然 Azure Portal 提供了图形界面,但在批量操作、自动化流水线和 CI/CD 场景中,PowerShell 脚本依然是最高效的手段。微软提供的 Az.DesktopVirtualization 模块覆盖了 AVD 几乎全部管理操作,让我们可以用脚本实现从创建到运维的全生命周期管理。

本文将介绍三个典型的 AVD 管理场景:主机池与工作区配置、会话主机扩缩容、以及用户会话管理与诊断。每个场景都附带可直接运行的 PowerShell 脚本示例。

AVD 主机池与工作区管理

主机池(Host Pool)是 AVD 的核心资源,定义了一组提供桌面或远程应用的虚拟机。工作区(Workspace)则是将应用组呈现给用户的逻辑容器。下面的脚本演示如何一步到位地创建主机池、配置应用组,并将其注册到工作区。

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
# 安装并导入 AVD 管理模块
Install-Module -Name Az.DesktopVirtualization -Force -Scope CurrentUser
Import-Module Az.DesktopVirtualization

# 连接到 Azure 账户
Connect-AzAccount

# 定义变量
$ResourceGroup = "rg-avd-prod"
$Location = "eastasia"
$HostPoolName = "hp-desktop-prod"
$WorkspaceName = "ws-prod"

# 确保资源组存在
$rg = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $rg) {
New-AzResourceGroup -Name $ResourceGroup -Location $Location
Write-Host "已创建资源组: $ResourceGroup"
}

# 创建主机池(池化桌面,深度优先负载均衡)
$hostPool = New-AzWvdHostPool `
-ResourceGroupName $ResourceGroup `
-Name $HostPoolName `
-Location $Location `
-HostPoolType Pooled `
-LoadBalancerType DepthFirst `
-PreferredAppGroupType Desktop `
-MaxSessionLimit 10 `
-ValidationEnv:$false

Write-Host "主机池已创建: $($hostPool.Name)"

# 创建桌面应用组(自动随主机池创建)
$appGroup = Get-AzWvdDesktop -ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName

# 创建工作区并注册桌面应用组
$workspace = New-AzWvdWorkspace `
-ResourceGroupName $ResourceGroup `
-Name $WorkspaceName `
-Location $Location

Register-AzWvdApplicationGroup `
-ResourceGroupName $ResourceGroup `
-WorkspaceName $WorkspaceName `
-ApplicationGroupPath $appGroup.Id

Write-Host "工作区已创建并注册桌面应用组: $($workspace.Name)"

执行结果示例:

1
2
3
已创建资源组: rg-avd-prod
主机池已创建: hp-desktop-prod
工作区已创建并注册桌面应用组: ws-prod

创建完成后,可以在 Azure Portal 中看到主机池和工作区已关联。用户通过 AVD 客户端登录时,工作区中的应用组会自动呈现为可用的桌面会话。

会话主机管理与扩缩容

主机池创建后,需要向其中添加会话主机(Session Host)——也就是实际运行用户桌面的 Azure 虚拟机。在生产环境中,根据负载自动扩缩容是控制成本的关键。以下脚本展示了手动添加会话主机以及配置自动扩缩方案。

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
# 查看当前主机池中的会话主机
$ResourceGroup = "rg-avd-prod"
$HostPoolName = "hp-desktop-prod"

$sessionHosts = Get-AzWvdSessionHost `
-ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName

$sessionHosts | Format-Table Name, Status, Sessions, AllowNewSession -AutoSize

# 为会话主机开启 Drain 模式(禁止新会话接入,便于维护)
$drainHost = "hp-desktop-prod/avd-vm-001"
Update-AzWvdSessionHost `
-ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName `
-Name $drainHost `
-AllowNewSession:$false

Write-Host "已对 $drainHost 启用 Drain 模式"

# 配置自动扩缩计划(基于时间段调整最大/最小主机数)
$scalingPlan = New-AzWvdScalingPlan `
-ResourceGroupName $ResourceGroup `
-Name "scaling-weekday" `
-Location "eastasia" `
-TimeZone "China Standard Time" `
-Schedule @(
@{
name = "weekday-workhours"
daysOfWeek = @("Monday", "Tuesday", "Wednesday", "Thursday", "Friday")
rampUpStartTime = @{ hour = 8; minute = 0 }
rampUpLoadBalancingAlgorithm = "BreadthFirst"
rampUpMinimumHostsPct = 30
rampUpCapacityThresholdPct = 60
peakStartTime = @{ hour = 9; minute = 0 }
peakLoadBalancingAlgorithm = "DepthFirst"
rampDownStartTime = @{ hour = 18; minute = 0 }
rampDownLoadBalancingAlgorithm = "DepthFirst"
rampDownMinimumHostsPct = 10
rampDownCapacityThresholdPct = 90
rampDownForceLogoffUsers = $false
offPeakStartTime = @{ hour = 20; minute = 0 }
offPeakLoadBalancingAlgorithm = "DepthFirst"
}
)

# 将扩缩计划关联到主机池
$hostPool = Update-AzWvdHostPool `
-ResourceGroupName $ResourceGroup `
-Name $HostPoolName `
-ScalingPlanId $scalingPlan.Id

Write-Host "自动扩缩计划已关联到主机池: $HostPoolName"

执行结果示例:

1
2
3
4
5
6
7
8
Name                                            Status  Sessions AllowNewSession
---- ------ -------- ---------------
hp-desktop-prod/avd-vm-001 Available 3 True
hp-desktop-prod/avd-vm-002 Available 5 True
hp-desktop-prod/avd-vm-003 Available 2 True

已对 hp-desktop-prod/avd-vm-001 启用 Drain 模式
自动扩缩计划已关联到主机池: hp-desktop-prod

Drain 模式是维护窗口的必备操作——开启后该主机不再接受新会话,但已连接的用户不受影响。运维人员可以等待现有会话自然结束,或通知用户保存工作后手动注销,再对虚拟机执行补丁更新。自动扩缩计划则根据工作时间段自动调整主机数量,避免夜间空闲虚拟机持续产生费用。

用户会话管理与故障排查

当用户报告远程桌面连接异常时,快速定位问题是运维效率的关键。Az.DesktopVirtualization 模块提供了会话查询、断开连接、强制注销等操作,结合诊断功能可以大幅缩短故障响应时间。

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
# 查看主机池中所有活跃会话
$ResourceGroup = "rg-avd-prod"
$HostPoolName = "hp-desktop-prod"

$userSessions = Get-AzWvdUserSession `
-ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName

$userSessions | Format-Table `
@{N="用户";E={$_.ActiveDirectoryUserName}}, `
@{N="会话主机";E={($_.Name -split "/")[1]}}, `
@{N="会话状态";E={$_.SessionState}}, `
@{N="创建时间";E={$_.CreateTime}} `
-AutoSize

# 查找特定用户的会话
$targetUser = "user01@vichamp.com"
$targetSession = $userSessions | Where-Object {
$_.ActiveDirectoryUserName -eq $targetUser
}

if ($targetSession) {
Write-Host "找到用户会话: $($targetSession.Name)"

# 断开用户会话(不注销,用户可重新连接)
Remove-AzWvdUserSession `
-ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName `
-SessionHostName ($targetSession.Name -split "/")[1] `
-Id ($targetSession.Name -split "/")[2] `
-Force

Write-Host "已断开用户 $targetUser 的会话"
} else {
Write-Host "未找到用户 $targetUser 的活跃会话"
}

# 获取主机池诊断信息
$diagnostic = Get-AzWvdHostPool `
-ResourceGroupName $ResourceGroup `
-Name $HostPoolName

# 检查各会话主机的运行状态
foreach ($host in (Get-AzWvdSessionHost `
-ResourceGroupName $ResourceGroup `
-HostPoolName $HostPoolName)) {

$vmName = ($host.Name -split "/")[1]
$vm = Get-AzVM -ResourceGroupName $ResourceGroup -Name $vmName -Status

$powerState = ($vm.Statuses | Where-Object {
$_.Code -match "PowerState"
}).DisplayStatus

Write-Host ("{0,-25} AVD状态: {1,-12} VM电源: {2,-12} 会话数: {3}" -f `
$vmName, $host.Status, $powerState, $host.Sessions)
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
用户                      会话主机         会话状态  创建时间
---- ---- -------- --------
user01@vichamp.com avd-vm-001 Active 2026-03-13T09:15:22Z
user02@vichamp.com avd-vm-001 Active 2026-03-13T09:32:45Z
user03@vichamp.com avd-vm-002 Active 2026-03-13T10:01:33Z

找到用户会话: hp-desktop-prod/avd-vm-001/user01@vichamp.com
已断开用户 user01@vichamp.com 的会话

avd-vm-001 AVD状态: Available VM电源: VM running 会话数: 4
avd-vm-002 AVD状态: Available VM电源: VM running 会话数: 6
avd-vm-003 AVD状态: Available VM电源: VM running 会话数: 2

会话查询和诊断是排查 AVD 问题的第一步。当用户反馈桌面卡顿或无法连接时,先检查会话状态和主机负载,再决定是断开会话、注销用户还是扩容主机池,形成标准化的故障处理流程。

注意事项

  1. 模块版本兼容性Az.DesktopVirtualization 模块更新频繁,建议定期执行 Update-Module Az.DesktopVirtualization 获取最新功能和修复。部分 cmdlet 在不同版本间参数名称可能有变化,升级后需回归测试现有脚本。

  2. 权限要求:执行 AVD 管理操作需要 Azure RBAC 角色授权。推荐使用”Desktop Virtualization Contributor”内置角色,而非 Owner,以遵循最小权限原则。生产环境中应通过 Azure PIM(Privileged Identity Management)实现即时提权。

  3. Drain 模式使用时机:在执行安全补丁、应用更新或故障排查前,务必先开启 Drain 模式。注意 AllowNewSession:$false 只阻止新连接,不会断开已有会话。如需强制迁移用户,应先通知用户保存工作,再使用 Remove-AzWvdUserSession 注销。

  4. 自动扩缩计划配置:扩缩计划的 rampUpMinimumHostsPct 参数决定了启动阶段最少开启的主机百分比,设置过低会导致用户在高峰时段排队等待;设置过高则增加闲置成本。建议根据历史负载数据调优,并利用 AVD Insights 监控仪表盘持续观察效果。

  5. 会话主机网络要求:AVD 会话主机必须加入 Azure AD(或 Microsoft Entra ID)域,并开通到 AVD 控制平面的出站 HTTPS(443 端口)连接。如果使用自定义 VNet,需确保 DNS 解析和网络安全组规则正确配置,否则主机注册会失败。

  6. 诊断日志:建议在主机池上启用 Azure Monitor 诊断设置,将连接日志、错误日志、管理日志发送到 Log Analytics 工作区。结合 KQL 查询可以实现连接失败告警、用户登录趋势分析等高级运维场景,大幅缩短 MTTR(平均恢复时间)。

PowerShell 技能连载 - Plaster 项目脚手架

适用于 PowerShell 5.1 及以上版本

每次创建新的 PowerShell 模块项目,都需要手动建立目录结构、编写模块清单(.psd1)、创建入口脚本(.psm1)、添加 Pester 测试文件,甚至还要配置 CI/CD 流水线。这些重复劳动不仅耗时,而且容易出现结构不一致的问题,尤其在多人协作的团队中更为突出。

Plaster 是由 PowerShell 社区开发的正式项目脚手架工具,它的灵感来源于 Node.js 的 Yeoman 和 Python 的 Cookiecutter。Plaster 使用 XML 模板来定义完整的项目结构,通过声明式的方式描述文件、目录、参数和条件逻辑,一键生成标准化的项目骨架。

借助 Plaster,团队可以将最佳实践固化到模板中——无论是代码规范、测试框架配置,还是 Git 钩子和 CI 流水线——每次创建新项目时都能保持结构一致,大幅降低遗漏关键配置的风险。

Plaster 模板基础

Plaster 模板的核心是一个 plasterManifest.xml 文件,它定义了模板的元数据、用户参数和文件生成规则。下面是一个最基础的模块模板示例。

首先安装 Plaster 模块:

1
Install-Module -Name Plaster -Scope CurrentUser -Force

然后创建模板目录结构和清单文件:

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
# 创建模板目录
$templatePath = "$HOME\PlasterTemplates\BasicModule"
New-Item -Path $templatePath -ItemType Directory -Force

# 创建 plasterManifest.xml
$manifestContent = @'
<?xml version="1.0" encoding="utf-8"?>
<plasterManifest
schemaVersion="1.1"
templateType="Project"
xmlns="http://www.microsoft.com/schemas/PowerShell/Plaster/v1">

<metadata>
<name>BasicModuleTemplate</name>
<id>9a4e5d6f-7b8c-4a3b-2c1d-0e9f8a7b6c5d</id>
<version>1.0.0</version>
<title>基础 PowerShell 模块模板</title>
<description>生成一个标准结构的 PowerShell 模块项目</description>
<author>DevOps Team</author>
<tags>Module, Pester, CI</tags>
</metadata>

<parameters>
<parameter name="ModuleName"
type="text"
prompt="请输入模块名称" />

<parameter name="ModuleDescription"
type="text"
prompt="请输入模块描述" />

<parameter name="AuthorName"
type="text"
prompt="请输入作者名称"
default="$env:USERNAME" />

<parameter name="ModuleVersion"
type="text"
prompt="请输入初始版本号"
default="0.1.0" />
</parameters>

<content>
<message>&#10;&#10;正在创建基础模块项目...&#10;</message>

<!-- 创建目录结构 -->
<file source=""
destination="src\Functions\Public" />
<file source=""
destination="src\Functions\Private" />
<file source=""
destination="tests" />
<file source=""
destination="docs" />

<!-- 生成模块入口文件 -->
<templateFile
source="module.psm1.t"
destination="src\{{PLASTER_PARAM_ModuleName}}.psm1" />

<!-- 生成模块清单 -->
<templateFile
source="manifest.psd1.t"
destination="src\{{PLASTER_PARAM_ModuleName}}.psd1" />

<!-- 生成测试文件 -->
<templateFile
source="test.Tests.ps1.t"
destination="tests\{{PLASTER_PARAM_ModuleName}}.Tests.ps1" />

<message>&#10;模块项目已成功创建!&#10;</message>
</content>
</plasterManifest>
'@

$manifestContent | Set-Content -Path "$templatePath\plasterManifest.xml" -Encoding UTF8

同时需要创建模板文件。以模块入口文件模板 module.psm1.t 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 创建模块入口模板
$psm1Template = @'
# {{PLASTER_PARAM_ModuleName}} 模块入口
# 由 Plaster 于 {{PLASTER_Date}} 生成

$PublicFunctions = @( Get-ChildItem -Path "$PSScriptRoot\Functions\Public\*.ps1" -ErrorAction SilentlyContinue )
$PrivateFunctions = @( Get-ChildItem -Path "$PSScriptRoot\Functions\Private\*.ps1" -ErrorAction SilentlyContinue )

foreach ($import in @($PublicFunctions + $PrivateFunctions)) {
try {
. $import.FullName
} catch {
Write-Error "无法导入函数 $($import.FullName): $_"
}
}

Export-ModuleMember -Function $PublicFunctions.BaseName
'@

$psm1Template | Set-Content -Path "$templatePath\module.psm1.t" -Encoding UTF8

调用模板创建项目:

1
2
$destPath = "$HOME\Projects\MyNewModule"
Invoke-Plaster -TemplatePath $templatePath -DestinationPath $destPath

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
请输入模块名称: MyStorageToolkit
请输入模块描述: 存储管理工具集
请输入作者名称 [victorwoo]:
请输入初始版本号 [0.1.0]:

正在创建基础模块项目...

创建目录: src\Functions\Public
创建目录: src\Functions\Private
创建目录: tests
创建目录: docs
创建文件: src\MyStorageToolkit.psm1
创建文件: src\MyStorageToolkit.psd1
创建文件: tests\MyStorageToolkit.Tests.ps1

模块项目已成功创建!

高级模板功能

基础模板能满足简单需求,但实际团队项目中往往需要根据用户选择来决定生成哪些内容。Plaster 支持 choice(单选)、multichoice(多选)和条件判断,让模板具备灵活的定制能力。

下面是一个带有高级功能的模板清单片段:

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
# 高级模板的 parameters 和 content 部分
$advancedManifest = @'
<parameters>
<parameter name="ModuleName"
type="text"
prompt="请输入模块名称" />

<parameter name="ModuleDescription"
type="text"
prompt="请输入模块描述" />

<parameter name="License"
type="choice"
prompt="请选择许可证类型"
default="1"
store="text">
<choice label="&amp;MIT"
help="MIT 许可证"
value="MIT" />
<choice label="&amp;Apache"
help="Apache 2.0 许可证"
value="Apache" />
<choice label="&amp;None"
help="不添加许可证"
value="None" />
</parameter>

<parameter name="Options"
type="multichoice"
prompt="请选择要包含的功能(空格选择,回车确认)"
default="0,1"
store="text">
<choice label="&amp;Pester 测试框架"
help="添加 Pester 测试"
value="Pester" />
<choice label="&amp;GitHub Actions CI"
help="添加 GitHub Actions 工作流"
value="GitHubActions" />
<choice label="&amp;VS Code 配置"
help="添加 .vscode 目录"
value="VSCode" />
<choice label="&amp;帮助文档"
help="添加帮助文档模板"
value="HelpDocs" />
</parameter>

<parameter name="Editor"
type="choice"
prompt="请选择编辑器配置"
default="0"
store="text">
<choice label="&amp;VS Code"
help="Visual Studio Code"
value="VSCode" />
<choice label="&amp;其他"
help="不生成编辑器配置"
value="None" />
</parameter>
</parameters>
'@

$advancedManifest | Set-Content -Path "$templatePath\advanced_params.xml" -Encoding UTF8

使用条件逻辑控制文件生成:

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
# 条件生成的 content 片段
$contentSnippet = @'
<content>
<!-- 条件生成:仅当选择 Pester 时创建测试 -->
<condition condition="$PLASTER_PARAM_Options -contains 'Pester'">
<file source="" destination="tests" />
<templateFile source="test.Tests.ps1.t"
destination="tests\{{PLASTER_PARAM_ModuleName}}.Tests.ps1" />
<message>已添加 Pester 测试框架配置</message>
</condition>

<!-- 条件生成:仅当选择 GitHub Actions 时创建 CI 配置 -->
<condition condition="$PLASTER_PARAM_Options -contains 'GitHubActions'">
<file source="" destination=".github\workflows" />
<templateFile source="ci.yml.t"
destination=".github\workflows\ci.yml" />
<message>已添加 GitHub Actions CI 配置</message>
</condition>

<!-- 条件生成:仅当选择 VS Code 时创建编辑器配置 -->
<condition condition="$PLASTER_PARAM_Editor -eq 'VSCode'">
<file source="" destination=".vscode" />
<templateFile source="settings.json.t"
destination=".vscode\settings.json" />
<templateFile source="extensions.json.t"
destination=".vscode\extensions.json" />
<message>已添加 VS Code 配置</message>
</condition>

<!-- 条件生成:仅当选择帮助文档时创建 -->
<condition condition="$PLASTER_PARAM_Options -contains 'HelpDocs'">
<file source="" destination="docs" />
<templateFile source="readme.md.t"
destination="docs\{{PLASTER_PARAM_ModuleName}}.md" />
<message>已添加帮助文档模板</message>
</condition>
</content>
'@

$contentSnippet | Set-Content -Path "$templatePath\content_snippet.xml" -Encoding UTF8

运行高级模板时,交互界面如下:

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
请输入模块名称: MyStorageToolkit
请输入模块描述: 存储管理工具集

请选择许可证类型
[1] MIT
[2] Apache
[3] None
选择 [1]: 1

请选择要包含的功能(空格选择,回车确认)
[x] 1. Pester 测试框架
[x] 2. GitHub Actions CI
[ ] 3. VS Code 配置
[ ] 4. 帮助文档
选择 [0,1]:

请选择编辑器配置
[1] VS Code
[2] 其他
选择 [1]: 1

创建目录: tests
创建文件: tests\MyStorageToolkit.Tests.ps1
已添加 Pester 测试框架配置
创建目录: .github\workflows
创建文件: .github\workflows\ci.yml
已添加 GitHub Actions CI 配置
创建目录: .vscode
创建文件: .vscode\settings.json
创建文件: .vscode\extensions.json
已添加 VS Code 配置

团队模板实战

将模板纳入团队协作流程的关键在于模板的版本管理和共享分发。下面展示一个完整的团队模块模板,并将模板打包发布到内部 PowerShell 仓库。

首先创建完整的模板项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 团队模板项目初始化
$teamTemplatePath = "$HOME\PlasterTemplates\TeamStandardModule"
$directories = @(
"$teamTemplatePath\src\Functions\Public"
"$teamTemplatePath\src\Functions\Private"
"$teamTemplatePath\src\Classes"
"$teamTemplatePath\tests\Unit"
"$teamTemplatePath\tests\Integration"
"$teamTemplatePath\examples"
"$teamTemplatePath\docs"
"$teamTemplatePath\.github\workflows"
"$teamTemplatePath\.vscode"
)

foreach ($dir in $directories) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}

创建 GitHub Actions CI 工作流模板文件 ci.yml.t

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
$ciTemplate = @'
name: CI - {{PLASTER_PARAM_ModuleName}}

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
test:
runs-on: {{PLASTER_PARAM RunnerType}}
strategy:
matrix:
powershell-version: ['7.2', '7.4']

steps:
- uses: actions/checkout@v4

- name: 安装 PowerShell ${{ '{{' }} matrix.powershell-version {{ '}}' }}
uses: PowerShell/setup-pwsh@v1

- name: 安装依赖模块
shell: pwsh
run: |
Install-Module -Name Pester -Force -Scope CurrentUser
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser

- name: 执行 Pester 测试
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Output.Verbosity = 'Detailed'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src/*.psm1'
Invoke-Pester -Configuration $config

- name: 执行 Script Analyzer
shell: pwsh
run: |
Invoke-ScriptAnalyzer -Path './src' -Severity Warning
'@

$ciTemplate | Set-Content -Path "$teamTemplatePath\ci.yml.t" -Encoding UTF8

将模板打包为 PowerShell 模块,方便团队安装:

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
# 创建模板分发模块的清单
$moduleDir = "$HOME\Projects\TeamPlasterTemplates"
New-Item -Path $moduleDir -ItemType Directory -Force | Out-Null

# 将模板文件复制到模块目录中
Copy-Item -Path $teamTemplatePath -Destination "$moduleDir\Templates" -Recurse -Force

# 创建模块入口脚本,提供快速调用函数
$moduleScript = @'
function Install-TeamModuleTemplate {
<#
.SYNOPSIS
使用团队标准模板创建新的 PowerShell 模块项目
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$DestinationPath,

[string]$TemplatePath = "$PSScriptRoot\Templates\TeamStandardModule"
)

if (-not (Get-Module -ListAvailable -Name Plaster)) {
throw "请先安装 Plaster 模块: Install-Module -Name Plaster -Scope CurrentUser"
}

Invoke-Plaster -TemplatePath $TemplatePath -DestinationPath $DestinationPath
}

Export-ModuleMember -Function Install-TeamModuleTemplate
'@

$moduleScript | Set-Content -Path "$moduleDir\TeamPlasterTemplates.psm1" -Encoding UTF8

# 生成模块清单
$manifestParams = @{
Path = "$moduleDir\TeamPlasterTemplates.psd1"
RootModule = "TeamPlasterTemplates.psm1"
ModuleVersion = "1.0.0"
Author = "DevOps Team"
Description = "团队标准 PowerShell 模块 Plaster 模板"
FunctionsToExport = @("Install-TeamModuleTemplate")
RequiredModules = @("Plaster")
PowerShellVersion = "5.1"
}
New-ModuleManifest @manifestParams

Write-Host "团队模板模块已创建: $moduleDir" -ForegroundColor Green

发布到内部 NuGet 仓库或复制到团队共享路径后,团队成员即可一键使用:

1
2
3
4
5
6
# 安装团队模板模块
Install-Module -Name TeamPlasterTemplates -Repository InternalPSGallery -Scope CurrentUser

# 使用团队模板创建新项目
Import-Module TeamPlasterTemplates
Install-TeamModuleTemplate -DestinationPath "$HOME\Projects\NewProject"

执行结果示例:

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
请输入模块名称: NetworkMonitor
请输入模块描述: 网络监控与分析工具集
请输入作者名称 [victorwoo]:
请输入初始版本号 [0.1.0]:

请选择许可证类型
选择 [1]: 1 (MIT)

请选择要包含的功能(空格选择,回车确认)
[x] 1. Pester 测试框架
[x] 2. GitHub Actions CI
[x] 3. VS Code 配置
[x] 4. 帮助文档

创建目录: src\Functions\Public
创建目录: src\Functions\Private
创建目录: src\Classes
创建目录: tests\Unit
创建目录: tests\Integration
创建目录: examples
创建目录: .github\workflows
创建文件: .github\workflows\ci.yml
创建目录: .vscode
创建文件: .vscode\settings.json
创建目录: docs
创建文件: docs\NetworkMonitor.md

模块项目 NetworkMonitor 已创建完成!
目录结构:
src/
Functions/
Public/
Private/
Classes/
NetworkMonitor.psm1
NetworkMonitor.psd1
tests/
Unit/
Integration/
NetworkMonitor.Tests.ps1
examples/
docs/
.github/workflows/ci.yml
.vscode/

注意事项

  1. 模板清单必须使用 UTF-8 编码。Plaster 解析 XML 时对编码敏感,如果清单文件包含中文且未使用 UTF-8 编码,会导致解析失败或乱码。保存文件时务必指定 -Encoding UTF8

  2. 模板变量语法为双花括号。Plaster 使用 {{PLASTER_PARAM_Name}} 引用参数,{{PLASTER_Date}} 获取当前日期。注意这与 PowerShell 本身的 $variable 语法不同,不要混淆。

  3. 条件表达式使用 PowerShell 语法<condition> 标签中的 condition 属性值会被当作 PowerShell 表达式求值,因此可以使用 -contains-eq-and-or 等运算符组合复杂条件。

  4. 模板文件扩展名建议使用 .t 后缀。例如 module.psm1.tci.yml.t。这样可以让编辑器识别它们为模板而非实际脚本,避免 IDE 的语法检查报错。

  5. 测试模板时使用 -Force 参数Invoke-Plaster 在目标目录已存在文件时会跳过覆盖。开发调试模板时加上 -Force 参数可以强制覆盖,方便反复测试。

  6. 团队共享模板建议打包为 PowerShell 模块。将模板文件嵌入到 .psm1 模块中并通过内部 NuGet 仓库分发,可以统一版本管理,避免团队成员使用过时模板。结合 RequiredModules 声明 Plaster 依赖,确保安装即用。

PowerShell 技能连载 - Azure 容器注册表管理

适用于 PowerShell 7.0 及以上版本

Azure Container Registry(ACR)是微软 Azure 平台提供的托管私有 Docker 镜像仓库服务。与 Docker Hub 的公开镜像不同,ACR 让企业可以在 Azure 区域内就近存储和管理容器镜像,享受低延迟的镜像拉取体验和网络隔离的安全保障。无论是运行在 Azure Kubernetes Service(AKS)中的编排集群,还是 App Service 的容器化 Web 应用,都可以直接从 ACR 拉取镜像来部署。

然而,镜像管理远不止”推送和拉取”这么简单。随着版本迭代,注册表中会积累大量过时镜像,占用存储空间并增加攻击面。安全团队要求对所有镜像进行漏洞扫描,运维团队需要配置最小权限的访问控制和跨区域复制策略。如果用 Azure Portal 手动处理这些任务,在面对数十个注册表和成百上千个镜像标签时,效率会非常低下。

本文将通过三个部分介绍如何用 PowerShell 自动化管理 ACR:首先是基础操作,包括注册表的创建、镜像的推送拉取和标签管理;其次是镜像清理与安全扫描,自动化识别并移除未使用的镜像层;最后是访问控制与复制策略,通过 RBAC 和 Webhook 将 ACR 深度集成到 CI/CD 流水线中。

ACR 基础管理与镜像操作

ACR 的日常操作包括注册表的创建、登录凭据获取、镜像推送和标签管理。下面的脚本展示了从零开始创建一个 ACR 实例,并通过 Docker CLI 完成镜像的推送和标签操作。注意 az acr 命令虽然功能强大,但很多操作可以直接用 Az PowerShell 模块完成,无需依赖 Azure CLI。

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
# 安装并导入 Az 模块
Install-Module -Name Az.ContainerRegistry -Force -Scope CurrentUser
Import-Module Az.ContainerRegistry

# 连接 Azure 账户
Connect-AzAccount -Subscription 'production-subscription'

# 定义变量
$resourceGroup = 'rg-acr-demo'
$location = 'eastasia'
$acrName = 'acrdemo2026'

# 创建资源组
New-AzResourceGroup -Name $resourceGroup -Location $location -Force

# 创建 ACR(Basic / Standard / Premium 三个 SKU)
$registry = New-AzContainerRegistry `
-ResourceGroupName $resourceGroup `
-Name $acrName `
-Sku Standard `
-EnableAdminUser $false

Write-Host "ACR 创建完成: $($registry.LoginServer)" -ForegroundColor Green

# 获取 ACR 的登录凭据(用于 Docker 登录)
$cred = Get-AzContainerRegistryCredential `
-ResourceGroupName $resourceGroup `
-Name $acrName

# 使用 Docker CLI 登录
$secPassword = ConvertTo-SecureString $cred.Password -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($cred.Username, $secPassword)

# 通过 az acr login 方式登录(推荐,无需暴露密码)
az acr login --name $acrName

# 构建并推送镜像
$webappImage = 'my-webapp:v1.0.3'
$taggedImage = "$($registry.LoginServer)/$webappImage"

docker tag $webappImage $taggedImage
docker push $taggedImage

Write-Host "镜像推送完成: $taggedImage" -ForegroundColor Green

# 列出注册表中的所有仓库
$repos = Get-AzContainerRegistryRepository `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup

foreach ($repo in $repos) {
$tags = Get-AzContainerRegistryTag `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-RepositoryName $repo.Name

Write-Host "仓库: $($repo.Name) | 标签数: $($tags.Count)"
}

# 为现有镜像添加新标签(用于版本别名)
$newTag = "$($registry.LoginServer)/my-webapp:stable"
docker tag $taggedImage $newTag
docker push $newTag

Write-Host "stable 标签已推送" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ACR 创建完成: acrdemo2026.azurecr.io
Login Succeeded
The push refers to repository [acrdemo2026.azurecr.io/my-webapp]
a1b2c3d4e5f6: Pushed
f6e5d4c3b2a1: Pushed
v1.0.3: digest: sha256:9f8a7b6c... size: 2847
镜像推送完成: acrdemo2026.azurecr.io/my-webapp:v1.0.3
仓库: my-webapp | 标签数: 2
仓库: api-gateway | 标签数: 5
仓库: background-worker | 标签数: 3
The push refers to repository [acrdemo2026.azurecr.io/my-webapp]
a1b2c3d4e5f6: Layer already exists
f6e5d4c3b2a1: Layer already exists
stable: digest: sha256:9f8a7b6c... size: 2847
stable 标签已推送

镜像清理与安全扫描

随着项目迭代,ACR 中会堆积大量未被任何服务引用的旧镜像。这些镜像不仅浪费存储(Premium SKU 每 GB 每月约 $0.003),还可能包含已知漏洞。下面的脚本实现了自动化的镜像清理策略:先识别超过保留期限的镜像标签,然后执行 ACR 的 AQUA 安全扫描(Premium SKU 支持),最后生成一份漏洞摘要报告。

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
# 镜像清理策略:删除超过 30 天且无 "keep" 标签的旧版本
$acrName = 'acrdemo2026'
$resourceGroup = 'rg-acr-demo'
$retentionDays = 30
$cutoffDate = (Get-Date).AddDays(-$retentionDays)

$repos = Get-AzContainerRegistryRepository `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup

$deletedTags = @()
$keptTags = @()

foreach ($repo in $repos) {
$tags = Get-AzContainerRegistryTag `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-RepositoryName $repo.Name

foreach ($tag in $tags) {
$createdTime = $tag.CreatedTime

# 保留 latest 和 stable 标签
if ($tag.Name -in @('latest', 'stable')) {
$keptTags += "$($repo.Name):$($tag.Name)"
continue
}

# 删除超过保留期限的标签
if ($createdTime -lt $cutoffDate) {
Write-Host "删除过期标签: $($repo.Name):$($tag.Name) " +
"(创建于 $createdTime)" -ForegroundColor Yellow

Remove-AzContainerRegistryTag `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-RepositoryName $repo.Name `
-Name $tag.Name

$deletedTags += "$($repo.Name):$($tag.Name)"
} else {
$keptTags += "$($repo.Name):$($tag.Name)"
}
}
}

Write-Host "`n清理汇总:" -ForegroundColor Cyan
Write-Host " 已删除: $($deletedTags.Count) 个标签"
Write-Host " 已保留: $($keptTags.Count) 个标签"

# --- 安全扫描(需要 Premium SKU)---
# 使用 Defender for Cloud 的容器扫描 API
Write-Host "`n开始安全扫描..." -ForegroundColor Cyan

$scanResults = @()

foreach ($repo in $repos) {
$tags = Get-AzContainerRegistryTag `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-RepositoryName $repo.Name

foreach ($tag in $tags[0..2]) {
# 通过 REST API 获取扫描结果
$subId = (Get-AzContext).Subscription.Id
$uri = "https://management.azure.com/subscriptions/$subId" +
"/resourceGroups/$resourceGroup/providers" +
"/Microsoft.ContainerRegistry/registries/$acrName" +
"/scopeMaps?api-version=2023-07-01"

$imageRef = "$($repo.Name):$($tag.Name)"

# 模拟扫描结果(实际使用 Defender for Cloud API)
$scanResults += [PSCustomObject]@{
Image = $imageRef
Critical = Get-Random -Minimum 0 -Maximum 5
High = Get-Random -Minimum 1 -Maximum 12
Medium = Get-Random -Minimum 3 -Maximum 25
Low = Get-Random -Minimum 5 -Maximum 40
ScanDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}
}

# 输出漏洞报告
$scanResults | Format-Table -AutoSize

# 标记高危镜像
$criticalImages = $scanResults | Where-Object { $_.Critical -gt 0 }
if ($criticalImages) {
Write-Host "`n警告: 发现 $($criticalImages.Count) 个含严重漏洞的镜像!" `
-ForegroundColor Red
$criticalImages | ForEach-Object {
Write-Host " CRITICAL: $($_.Image) - $($_.Critical) 个严重漏洞" `
-ForegroundColor Red
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
删除过期标签: api-gateway:v1.0.0 (创建于 2026-01-15 10:23:45)
删除过期标签: api-gateway:v1.0.1 (创建于 2026-01-20 14:30:12)
删除过期标签: background-worker:v2.1.0 (创建于 2026-02-01 08:15:30)

清理汇总:
已删除: 3 个标签
已保留: 8 个标签

开始安全扫描...

Image Critical High Medium Low ScanDate
----- --------- ---- ------- --- --------
my-webapp:latest 0 2 8 15 2026-03-11 06:00:00
api-gateway:v1.2.0 2 5 12 28 2026-03-11 06:00:12
background-worker:v2.3.1 1 3 6 10 2026-03-11 06:00:25

警告: 发现 2 个含严重漏洞的镜像!
CRITICAL: api-gateway:v1.2.0 - 2 个严重漏洞
CRITICAL: background-worker:v2.3.1 - 1 个严重漏洞

访问控制与区域复制

在企业环境中,ACR 的访问权限管理至关重要。过度授权会导致镜像泄露,权限不足又会阻塞 CI/CD 流水线。Azure 通过 RBAC(基于角色的访问控制)提供了精细化的权限模型。同时,对于全球分布的应用,ACR 的地理复制功能可以让各区域的 Kubernetes 集群就近拉取镜像,显著减少部署延迟。下面的脚本展示了 RBAC 配置、区域复制和 Webhook 集成的自动化方法。

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
# === RBAC 权限管理 ===
$acrName = 'acrdemo2026'
$resourceGroup = 'rg-acr-demo'

# 获取 ACR 的资源 ID
$acr = Get-AzContainerRegistry `
-ResourceGroupName $resourceGroup `
-Name $acrName
$acrId = $acr.Id

# 为 AKS 集群分配 AcrPull 权限(只读拉取)
$aksIdentity = (Get-AzAksCluster `
-ResourceGroupName 'rg-aks-prod' `
-Name 'aks-prod-eastasia').IdentityProfile.kubeletidentity.ObjectId

New-AzRoleAssignment `
-ObjectId $aksIdentity `
-RoleDefinitionName 'AcrPull' `
-Scope $acrId

Write-Host "AKS 集群已获得 AcrPull 权限" -ForegroundColor Green

# 为 CI/CD 服务主体分配 AcrPush 权限(推送和拉取)
$spObjectId = (Get-AzADServicePrincipal `
-DisplayName 'sp-cicd-pipeline').Id

New-AzRoleAssignment `
-ObjectId $spObjectId `
-RoleDefinitionName 'AcrPush' `
-Scope $acrId

Write-Host "CI/CD 服务主体已获得 AcrPush 权限" -ForegroundColor Green

# === 区域复制(Premium SKU)===
# 将 ACR 复制到多个 Azure 区域
$replicaLocations = @(
@{ Location = 'westus2'; Name = 'replica-westus2' }
@{ Location = 'westeurope'; Name = 'replica-westeurope' }
)

foreach ($replica in $replicaLocations) {
$existingReplica = Get-AzContainerRegistryReplication `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-Name $replica.Name -ErrorAction SilentlyContinue

if (-not $existingReplica) {
New-AzContainerRegistryReplication `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-Location $replica.Location `
-Name $replica.Name

Write-Host "复制已创建: $($replica.Location)" -ForegroundColor Green
} else {
Write-Host "复制已存在: $($replica.Location)" -ForegroundColor Yellow
}
}

# 查看所有复制状态
$replications = Get-AzContainerRegistryReplication `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup

$replications | Select-Object Name, Location, ProvisioningState, Status |
Format-Table -AutoSize

# === Webhook 集成 ===
# 创建 Webhook:镜像推送时通知 CI/CD 系统
$webhookUri = 'https://jenkins.internal.company.com/webhook/acr'
$webhookName = 'wh-push-notification'

New-AzContainerRegistryWebhook `
-RegistryName $acrName `
-ResourceGroupName $resourceGroup `
-Name $webhookName `
-Uri $webhookUri `
-Action @('push', 'delete') `
-Status Enabled `
-Scope @('my-webapp:*', 'api-gateway:*')

Write-Host "Webhook 已创建: $webhookName" -ForegroundColor Green

# 测试 Webhook 连通性
Invoke-AzContainerRegistryWebhook `
-ResourceGroupName $resourceGroup `
-RegistryName $acrName `
-Name $webhookName

# 查看最近 Webhook 事件
Get-AzContainerRegistryWebhookEvent `
-ResourceGroupName $resourceGroup `
-RegistryName $acrName `
-WebhookName $webhookName |
Select-Object Id, Action, Status, Timestamp |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AKS 集群已获得 AcrPull 权限
CI/CD 服务主体已获得 AcrPush 权限
复制已创建: westus2
复制已创建: westeurope

Name Location ProvisioningState Status
---- -------- ----------------- ------
replica-eastasia eastasia Succeeded Ready
replica-westus2 westus2 Succeeded Ready
replica-westeurope westeurope Succeeded Syncing

Webhook 已创建: wh-push-notification
Webhook 测试触发成功 (HTTP 200)

Id Action Status Timestamp
-- ------ ------ ---------
00000000-0000-0000-0000-000000000001 push 200 2026-03-11 06:30:12
00000000-0000-0000-0000-000000000002 push 200 2026-03-11 06:30:13

注意事项

  1. SKU 选择:Basic SKU 适合开发测试(10 GB 存储),Standard 适合生产(100 GB),Premium 支持地理复制和高级安全扫描功能。根据实际需求选择,避免为不需要的功能付费。

  2. Admin 账户安全:生产环境中务必禁用 Admin 用户(-EnableAdminUser $false),改用 Azure AD 认证和托管标识。Admin 账户的密码是固定凭据,一旦泄露难以追踪来源。

  3. 镜像清理的不可逆性Remove-AzContainerRegistryTag 删除标签后无法恢复。建议先在测试环境验证清理逻辑,确认过滤条件正确后再对生产注册表执行。可以在脚本中先列出待删除标签,经人工确认后再执行删除。

  4. 区域复制的成本:每个复制区域都会产生独立的存储和带宽费用。Premium SKU 本身已包含第一个区域,额外区域按实际数据量计费。跨区域复制有短暂的同步延迟(通常在几分钟内),部署敏感版本时需注意这个时间窗口。

  5. Webhook 可靠性:Webhook 通知采用”至少一次”投递策略,可能收到重复事件。消费端应实现幂等处理(根据事件 ID 去重),避免同一个镜像推送触发两次部署。如果目标服务不可用,ACR 会重试但最终可能丢弃事件。

  6. Az.ContainerRegistry 模块版本:确保使用 4.0.0 及以上版本的 Az.ContainerRegistry 模块,早期版本不支持 Get-AzContainerRegistryRepositoryGet-AzContainerRegistryTag 等命令。如果遇到”找不到命令”的错误,执行 Update-Module Az.ContainerRegistry 升级。

PowerShell 技能连载 - 后台任务与并发执行

适用于 PowerShell 7.0 及以上版本

在日常运维工作中,我们经常遇到需要同时处理大量独立任务的场景。比如批量检查 100 台服务器的连通性、并行下载数十个日志文件、同时对多个数据库执行查询。如果逐个串行执行,100 台服务器每台超时 5 秒就需要等待超过 8 分钟;而合理使用并发执行,可以将总耗时压缩到几秒到几十秒。

PowerShell 历经多年发展,提供了多种并发执行机制。最早期的 Start-Job 基于独立的 PowerShell 进程,开销大但隔离性好;后来的 ThreadJob 模块改用 .NET 线程,启动更快、内存占用更低;PowerShell 7 引入了 ForEach-Object -Parallel,让并发处理像管道一样简单;而底层的 Runspaces 机制则提供了最精细的控制能力。

本文将从易到难,依次介绍这四种并发机制的核心用法、适用场景和注意事项,帮助你根据实际需求选择最合适的方案。

Start-Job 与 ThreadJob:经典后台任务

Start-Job 是 PowerShell 内置的后台任务机制,每个任务在独立的 PowerShell 进程中运行,天然具备良好的隔离性。ThreadJob 是社区贡献的模块(PowerShell 7 中已内置),改用 .NET 线程而非进程,启动速度更快、资源开销更小。

下面的示例演示如何创建后台任务、跟踪执行进度并收集结果:

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
# 定义需要检查的服务器列表
$servers = @(
'server-01.example.com'
'server-02.example.com'
'server-03.example.com'
'server-04.example.com'
'server-05.example.com'
)

# 方式一:使用 Start-Job(独立进程,开销较大)
$processJobs = foreach ($server in $servers) {
Start-Job -ScriptBlock {
param($target)
$result = Test-Connection -TargetName $target -Count 2 -Quiet
[PSCustomObject]@{
Server = $target
Online = $result
Response = if ($result) { 'OK' } else { 'Timeout' }
}
} -ArgumentList $server -Name "Ping_$($server)"
}

# 方式二:使用 ThreadJob(线程级,开销更小)
$threadJobs = foreach ($server in $servers) {
Start-ThreadJob -ScriptBlock {
param($target)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$result = Test-Connection -TargetName $target -Count 2 -Quiet
$sw.Stop()
[PSCustomObject]@{
Server = $target
Online = $result
Duration = $sw.ElapsedMilliseconds
}
} -ArgumentList $server -Name "TPing_$($server)"
}

# 跟踪任务状态
Write-Host "=== 任务状态 ==="
Get-Job | Format-Table Name, State, HasMoreData -AutoSize

# 等待所有 ThreadJob 完成(超时 30 秒)
Wait-Job -Job $threadJobs -Timeout 30 | Out-Null

# 收集结果
Write-Host "`n=== ThreadJob 结果 ==="
$results = $threadJobs | Receive-Job
$results | Format-Table Server, Online, Duration -AutoSize

# 清理已完成任务
Get-Job | Remove-Job

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
=== 任务状态 ===

Name State HasMoreData
---- ----- -----------
Ping_server-01.example.com Running True
Ping_server-02.example.com Running True
Ping_server-03.example.com Running True
Ping_server-04.example.com Running True
Ping_server-05.example.com Running True
TPing_server-01.example.com Completed True
TPing_server-02.example.com Completed True
TPing_server-03.example.com Completed True
TPing_server-04.example.com Completed True
TPing_server-05.example.com Completed True

=== ThreadJob 结果 ===

Server Online Duration
------ ----- --------
server-01.example.com True 23
server-02.example.com True 25
server-03.example.com False 5012
server-04.example.com True 18
server-05.example.com True 31

从结果可以看到,ThreadJob 的任务已经快速完成,而 Start-Job 的任务仍在运行中。这是因为 Start-Job 每个任务都会启动一个全新的 pwsh 进程,初始化开销远大于线程。

ForEach-Object -Parallel:管道式并发

PowerShell 7 引入了 ForEach-Object -Parallel 参数,这是最简洁的并发处理方式。它基于 ThreadJob 实现,在管道中即可实现并行处理,非常适合对集合元素进行并发操作。

-ThrottleLimit 参数控制最大并发数,避免资源耗尽;$using: 语法允许在并行代码块中引用外部变量。

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
# 模拟一批需要处理的 URL
$urls = @(
'https://httpbin.org/delay/1'
'https://httpbin.org/delay/2'
'https://httpbin.org/delay/1'
'https://httpbin.org/delay/3'
'https://httpbin.org/delay/1'
'https://httpbin.org/delay/2'
'https://httpbin.org/delay/1'
'https://httpbin.org/delay/2'
)

# 外部变量:请求超时和重试次数
$timeoutSec = 10
$maxRetries = 2

# 并行请求,限制并发数为 4
$measure = Measure-Command {
$results = $urls | ForEach-Object -ThrottleLimit 4 -Parallel {
$url = $_
$timeout = $using:timeoutSec
$retries = $using:maxRetries

$attempt = 0
$success = $false
$sw = [System.Diagnostics.Stopwatch]::StartNew()

while ($attempt -lt $retries -and -not $success) {
$attempt++
try {
$response = Invoke-WebRequest -Uri $url -TimeoutSec $timeout `
-UseBasicParsing -ErrorAction Stop
$success = $true
$sw.Stop()
}
catch {
$sw.Stop()
if ($attempt -ge $retries) {
return [PSCustomObject]@{
Url = $url
Status = "Failed"
Attempt = $attempt
Duration = $sw.ElapsedMilliseconds
Error = $_.Exception.Message
}
}
$sw.Start()
}
}

[PSCustomObject]@{
Url = $url
Status = "$($response.StatusCode)"
Attempt = $attempt
Duration = $sw.ElapsedMilliseconds
}
}

$results | Format-Table Url, Status, Attempt, Duration -AutoSize
}

Write-Host "`n总耗时: $($measure.TotalSeconds.ToString('F1')) 秒"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
Url                                  Status Attempt Duration
--- ------ ------- --------
https://httpbin.org/delay/1 200 1 1023
https://httpbin.org/delay/2 200 1 2015
https://httpbin.org/delay/1 200 1 1018
https://httpbin.org/delay/3 200 1 3012
https://httpbin.org/delay/1 200 1 1025
https://httpbin.org/delay/2 200 1 2020
https://httpbin.org/delay/1 200 1 1019
https://httpbin.org/delay/2 200 1 2015

总耗时: 6.1

8 个请求如果串行执行需要约 13 秒(1+2+1+3+1+2+1+2),并行执行(并发数 4)只用了 6.1 秒,效率提升超过 50%。注意 $using: 语法是在并行代码块中访问外部变量的唯一方式,直接引用外部变量会导致作用域错误。

Runspaces:高级并发控制

Runspaces 是 PowerShell 底层的执行环境抽象,每个 PowerShell 实例都运行在一个 Runspace 中。通过直接创建和管理 Runspace 池,我们可以获得最精细的并发控制——包括动态调整并发数、异步回调、自定义初始 Session State 等。

下面演示如何创建 Runspace 池、分发任务并异步收集结果:

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
# 任务列表:批量获取多个远程主机的系统信息
$targets = 1..20 | ForEach-Object { "host-$($_.ToString('00'))" }

# 创建初始 Session State(可预加载模块和变量)
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()

# 创建 Runspace 池,最大 8 个并发
$runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(
1, 8, $initialSessionState, $Host
)
$runspacePool.Open()

# 定义任务脚本
$scriptBlock = {
param($computerName)
$sw = [System.Diagnostics.Stopwatch]::StartNew()

# 模拟远程查询(实际可用 Invoke-Command 或 CIM 查询)
Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 2000)

$sw.Stop()
[PSCustomObject]@{
ComputerName = $computerName
Status = 'Online'
CpuUsage = [math]::Round((Get-Random -Minimum 5 -Maximum 95), 1)
MemUsage = [math]::Round((Get-Random -Minimum 20 -Maximum 90), 1)
QueryMs = $sw.ElapsedMilliseconds
}
}

# 创建并启动所有任务
$runspaces = foreach ($target in $targets) {
$powershell = [System.Management.Automation.PowerShell]::Create().AddScript($scriptBlock).AddArgument($target)
$powershell.RunspacePool = $runspacePool

[PSCustomObject]@{
PowerShell = $powershell
Handle = $powershell.BeginInvoke()
Target = $target
}
}

# 异步等待并收集结果
$completed = [System.Collections.Generic.List[PSObject]]::new()
$totalMs = [System.Diagnostics.Stopwatch]::StartNew()

foreach ($rs in $runspaces) {
try {
$result = $rs.PowerShell.EndInvoke($rs.Handle)
if ($result) {
$completed.Add($result[0])
}
}
catch {
$completed.Add([PSCustomObject]@{
ComputerName = $rs.Target
Status = "Error: $($_.Exception.Message)"
CpuUsage = 0
MemUsage = 0
QueryMs = 0
})
}
finally {
$rs.PowerShell.Dispose()
}
}

$totalMs.Stop()

# 输出结果(按查询耗时排序)
$completed | Sort-Object QueryMs | Format-Table ComputerName, Status, CpuUsage, MemUsage, QueryMs -AutoSize
Write-Host "`n完成 $($completed.Count)/$($targets.Count) 台,总耗时: $($totalMs.ElapsedMilliseconds) ms"

# 释放 Runspace 池资源
$runspacePool.Close()
$runspacePool.Dispose()

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ComputerName Status  CpuUsage MemUsage QueryMs
------------ ------ -------- -------- -------
host-01 Online 12.3 45.2 134
host-05 Online 67.8 52.1 287
host-08 Online 34.5 61.3 412
host-03 Online 89.2 38.7 523
host-12 Online 45.6 72.4 678
host-17 Online 23.1 55.9 789
host-02 Online 56.7 41.8 901
host-19 Online 78.3 63.2 1024
host-06 Online 41.2 47.5 1156
host-10 Online 62.4 58.3 1289
host-15 Online 15.7 69.1 1345
host-04 Online 53.8 43.6 1501
host-09 Online 71.2 51.7 1623
host-14 Online 38.9 65.4 1789
host-20 Online 82.1 37.2 1834
host-07 Online 29.4 54.8 1901
host-11 Online 47.3 62.9 1945
host-13 Online 58.6 49.3 1967
host-16 Online 36.1 71.8 1988
host-18 Online 64.9 44.1 1999

完成 20/20 台,总耗时: 5203 ms

20 台主机串行执行需要约 20 秒(平均每台 1 秒),使用 Runspace 池(8 并发)仅用 5.2 秒完成。Runspaces 方案的优势在于可以对执行环境进行深度定制——比如预加载特定模块、设置执行策略、注入共享变量等。

注意事项

  1. 选择合适的并发机制:简单场景优先使用 ForEach-Object -Parallel(代码最简洁);需要精细控制并发数和生命周期时使用 Start-ThreadJob;需要最大性能和自定义环境时才使用 Runspaces;Start-Job 仅在需要完全进程隔离时使用。

  2. 合理设置并发数:并发数并非越高越好。网络 IO 密集型任务可以设置较高并发(如 16-32),CPU 密集型任务建议设置为逻辑核心数。过高的并发会导致上下文切换开销激增,反而降低性能。

  3. 注意变量作用域ForEach-Object -Parallel 中无法直接访问外部变量,必须使用 $using: 前缀。Start-JobStart-ThreadJob 通过 -ArgumentList 传参。Runspaces 需要通过 InitialSessionState 或参数注入共享数据。

  4. 错误处理不能省:并发任务中的异常不会自动传播到主线程。必须使用 try/catch 包裹每个任务逻辑,并在 Receive-JobEndInvoke 时检查错误。忽略错误处理会导致静默失败,排查困难。

  5. 资源清理是必须的Start-Job 创建的进程、ThreadJob 创建的线程、Runspaces 创建的运行空间,都必须在用完后显式清理。使用 Remove-Job 清理 Job,使用 Dispose() 释放 Runspace 和 PowerShell 对象,避免内存泄漏。

  6. 避免共享可变状态:并发任务中修改同一个集合或变量会导致竞态条件。应让每个任务返回独立的结果对象,最后在主线程中统一汇总。如果必须共享状态,请使用 .NET 的线程安全集合(如 ConcurrentDictionary)。

PowerShell 技能连载 - WinRM 高级配置与排错

适用于 PowerShell 5.1 及以上版本

WinRM 为什么总是连不上?

WinRM(Windows Remote Management)是 PowerShell Remoting 的底层协议,基于 WS-Management 标准。它允许管理员在远程计算机上执行命令、传输文件和管理配置。在企业环境中,WinRM 是批量运维的核心基础设施——从 Ansible 的 Windows 模块到 Azure Arc 的本地代理,都依赖它正常工作。

然而在实际部署中,WinRM 的”能连上”往往需要多个条件同时满足:服务运行、监听器配置正确、防火墙放行、认证协议匹配、权限授予。其中任何一个环节出问题,都会导致连接失败,而错误信息往往晦涩难懂,比如 Access is deniedThe WinRM client cannot process the requestWS-Man could not connect

本文将从配置加固、连接排错、受限端点三个方面,系统讲解 WinRM 的高级管理技巧,帮助你快速定位和解决远程管理中的常见故障。

WinRM 配置与安全加固

在正式使用 WinRM 之前,合理的初始配置至关重要。默认的 Enable-PSRemoting -Force 虽然能快速启用,但在生产环境中还需要关注监听器类型、传输加密和服务账户限制等方面。

以下脚本展示了 WinRM 的一次性安全配置流程,包括检查服务状态、配置 HTTPS 监听器以及限制远程访问的服务账户:

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
# 第一步:确保 WinRM 服务运行并设为自动启动
$service = Get-Service -Name WinRM -ErrorAction SilentlyContinue
if ($service.Status -ne 'Running') {
Start-Service -Name WinRM
Write-Host "WinRM 服务已启动"
}
Set-Service -Name WinRM -StartupType Automatic

# 第二步:检查现有监听器
$listeners = Get-ChildItem -Path WSMan:\localhost\Listener
Write-Host "当前监听器数量: $($listeners.Count)"

foreach ($listener in $listeners) {
$protocol = Get-Item -Path "$($listener.PSPath)\Transport" |
Select-Object -ExpandProperty Value
$port = Get-Item -Path "$($listener.PSPath)\Port" |
Select-Object -ExpandProperty Value
$addr = Get-Item -Path "$($listener.PSPath)\Address" |
Select-Object -ExpandProperty Value
Write-Host " 协议: $protocol 端口: $port 地址: $addr"
}

# 第三步:创建 HTTPS 监听器(需要先准备证书)
$cert = Get-ChildItem -Path Cert:\LocalMachine\My |
Where-Object {
$_.EnhancedKeyUsageList.FriendlyName -contains 'Server Authentication' -and
$_.NotAfter -gt (Get-Date)
} |
Sort-Object -Property NotAfter -Descending |
Select-Object -First 1

if ($cert) {
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS `
-Address * -CertificateThumbprint $cert.Thumbprint -Force
Write-Host "HTTPS 监听器已创建,证书指纹: $($cert.Thumbprint)"

# 配置防火墙规则放行 5986 端口
$fwRule = Get-NetFirewallRule -DisplayName 'WinRM HTTPS' -ErrorAction SilentlyContinue
if (-not $fwRule) {
New-NetFirewallRule -DisplayName 'WinRM HTTPS' `
-Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow
Write-Host "防火墙规则已添加 (TCP 5986)"
}
} else {
Write-Warning "未找到有效的服务器认证证书,跳过 HTTPS 监听器创建"
}

# 第四步:限制允许远程连接的用户组
Set-Item -Path WSMan:\localhost\Config\MaxConcurrentOperationsPerUser -Value 50
Set-Item -Path WSMan:\localhost\Service\MaxConnections -Value 100
Write-Host "并发限制已配置(每用户 50,总计 100)"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
WinRM 服务已启动
当前监听器数量: 1
协议: HTTP 端口: 5985 地址: *
Directory: WSMan:\localhost\Listener

Type Keys Name
---- ---- ----
Container {Transport=HTTPS, Address=*} Listener_2026309012345

HTTPS 监听器已创建,证书指纹: A1B2C3D4E5F6789012345678901234ABCD...
防火墙规则已添加 (TCP 5986)
并发限制已配置(每用户 50,总计 100)

连接排错工具集

当 WinRM 连接失败时,手动逐项排查非常耗时。下面这个诊断脚本将常见的检查步骤整合在一起,能够快速定位问题所在——从网络连通性到认证协议,再到 WinRM 服务配置,一目了然。

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 Test-WinRMConnection {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComputerName,

[ValidateSet('HTTP', 'HTTPS')]
[string]$Transport = 'HTTPS',

[System.Management.Automation.PSCredential]$Credential
)

$port = if ($Transport -eq 'HTTPS') { 5986 } else { 5985 }
$results = [ordered]@{}

# 检查 1:DNS 解析
try {
$ip = [System.Net.Dns]::GetHostAddresses($ComputerName) |
Select-Object -First 1 -ExpandProperty IPAddressToString
$results['DNS 解析'] = "成功 ($ip)"
} catch {
$results['DNS 解析'] = "失败: $($_.Exception.Message)"
}

# 检查 2:TCP 端口连通性
$tcp = New-Object System.Net.Sockets.TcpClient
try {
$tcp.Connect($ComputerName, $port)
$results['TCP 端口'] = "成功 ($port 开放)"
} catch {
$results['TCP 端口'] = "失败: 端口 $port 不可达"
} finally {
$tcp.Close()
}

# 检查 3:TrustedHosts 配置
$trustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($trustedHosts -eq '*' -or $trustedHosts -split ',' -contains $ComputerName) {
$results['信任主机'] = "已信任 (当前值: $trustedHosts)"
} else {
$results['信任主机'] = "未信任 (当前值: $trustedHosts)"
}

# 检查 4:WinRM 测试连接
$sessionOption = New-PSSessionOption -SkipCACheck -SkipCNCheck
$testParams = @{
ComputerName = $ComputerName
SessionOption = $sessionOption
ErrorAction = 'Stop'
}
if ($Transport -eq 'HTTPS') {
$testParams['UseSSL'] = $true
}
if ($Credential) {
$testParams['Credential'] = $Credential
}

try {
$session = New-PSSession @testParams
$results['远程会话'] = '成功'
Remove-PSSession -Session $session
} catch {
$results['远程会话'] = "失败: $($_.Exception.Message)"
}

# 输出诊断报告
Write-Host "`n========== WinRM 连接诊断报告 ==========" -ForegroundColor Cyan
Write-Host "目标: $ComputerName`n传输: $Transport`n端口: $port`n"
foreach ($key in $results.Keys) {
$status = $results[$key]
$icon = if ($status -match '成功|已信任') { '[OK]' } else { '[!!]' }
$color = if ($status -match '成功|已信任') { 'Green' } else { 'Red' }
Write-Host ("{0,-12} {1} {2}" -f "[$key]", $icon, $status) -ForegroundColor $color
}
Write-Host "========================================" -ForegroundColor Cyan

return $results
}

# 使用示例
Test-WinRMConnection -ComputerName 'SRV01.contoso.com' -Transport HTTPS

执行结果示例:

1
2
3
4
5
6
7
8
9
10
========== WinRM 连接诊断报告 ==========
目标: SRV01.contoso.com
传输: HTTPS
端口: 5986

[DNS 解析] [OK] 成功 (10.0.1.50)
[TCP 端口] [OK] 成功 (5986 开放)
[信任主机] [!!] 未信任 (当前值: )
[远程会话] [!!] 失败: The WinRM client cannot process the request because the server name is not in the TrustedHosts list.
========================================

上面的诊断结果清楚地指出了问题:目标服务器不在 TrustedHosts 列表中。对于非域环境,需要将远程主机名加入信任列表,或者使用 HTTPS 配合正确的证书验证。

常见的错误码及其含义如下:

错误码 含义 解决方向
0x80070005 Access Denied 检查凭据、用户组权限
0x80338012 WinRM 服务未运行 远程启动 WinRM 服务
0x80338125 证书验证失败 检查证书有效性或使用 SkipCACheck
0x80338104 WinRM 无法处理请求 检查 TrustedHosts 和认证协议

Constrained Endpoint:受限会话配置

在多人协作的运维团队中,直接给所有成员完整的远程 PowerShell 权限风险很高。Constrained Endpoint(受限端点)允许你创建自定义的会话配置,精确控制远程用户可以执行哪些命令——这既满足了权限最小化原则,又为审计提供了完整的操作日志。

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
# 定义受限会话的角色能力(Role Capability)
$roleCapDir = "$env:ProgramFiles\WindowsPowerShell\RoleCapabilities"
if (-not (Test-Path $roleCapDir)) {
New-Item -Path $roleCapDir -ItemType Directory -Force
}

# 创建角色能力文件
$roleCapParams = @{
Path = Join-Path $roleCapDir 'Maintenance.psrc'
VisibleCmdlets = @(
'Get-Process', 'Get-Service', 'Restart-Service',
'Get-EventLog', 'Get-WinEvent',
'Get-Volume', 'Get-Partition'
)
VisibleFunctions = @('Get-SystemUptime', 'Get-DiskHealth')
VisibleProviders = @('FileSystem')
VisibleExternalCommands = @('whoami.exe', 'hostname.exe')
ScriptsToProcess = @()
AliasDefinitions = @(
@{ Name = 'gsv'; Value = 'Get-Service' }
)
FunctionDefinitions = @(
@{
Name = 'Get-SystemUptime'
ScriptBlock = {
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$uptime = (Get-Date) - $os.LastBootUpTime
[PSCustomObject]@{
LastBoot = $os.LastBootUpTime
UptimeDays = [math]::Round($uptime.TotalDays, 2)
}
}
}
)
}
New-PSRoleCapabilityFile @roleCapParams
Write-Host "角色能力文件已创建: Maintenance.psrc"

# 创建受限会话配置
$cred = Get-Credential -Message '输入受限端点的运行账户'
$sessionParams = @{
Name = 'Maintenance'
RunAsCredential = $cred
RoleDefinitions = @{ 'CONTOSO\ServerOps' = @{ RoleCapabilities = 'Maintenance' } }
TranscriptDirectory = 'C:\Transcripts'
RunAsVirtualAccount = $true
MaximumReceivedDataSizePerCommandMB = 10
MaximumReceivedObjectSizeMB = 5
SessionType = 'RestrictedRemoteServer'
}
try {
Register-PSSessionConfiguration @sessionParams -Force
Write-Host "受限端点 'Maintenance' 注册成功"

# 验证配置
$config = Get-PSSessionConfiguration -Name Maintenance
Write-Host " 运行账户: $($config.RunAsUser)"
Write-Host " 虚拟账户: $($config.RunAsVirtualAccount)"
Write-Host " 审计目录: $($config.TranscriptDirectory)"

# 重启 WinRM 使配置生效
Restart-Service -Name WinRM -Force
Write-Host "WinRM 服务已重启,配置生效"
} catch {
Write-Error "注册受限端点失败: $($_.Exception.Message)"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
角色能力文件已创建: Maintenance.psrc

Location: C:\Program Files\WindowsPowerShell\RoleCapabilities

Name Value
---- -----
VisibleCmdlets {Get-Process, Get-Service, Restart-Service, Get-EventLog...}
VisibleFunctions {Get-SystemUptime, Get-DiskHealth}
VisibleProviders {FileSystem}

cmdlet Register-PSSessionConfiguration at command pipeline position 1
Supply values for the following parameters:

受限端点 'Maintenance' 注册成功
运行账户: CONTOSO\SvcMaintenance
虚拟账户: True
审计目录: C:\Transcripts
WinRM 服务已重启,配置生效

注册完成后,团队成员可以通过以下方式连接受限端点:

1
Enter-PSSession -ComputerName SRV01 -ConfigurationName Maintenance

连接后只能使用预定义的命令集,所有操作都会被记录到 C:\Transcripts 目录下的转录文件中,方便事后审计。

注意事项

  1. HTTPS 监听器必须有有效证书。如果使用自签名证书,客户端需要通过 SkipCACheck 选项跳过 CA 验证,但这会降低安全性。在生产环境中应使用企业 CA 或公共 CA 签发的证书,确保证书的主题名称(或 SAN)与服务器主机名匹配。

  2. TrustedHosts 是安全风险点。将 TrustedHosts 设为 * 等于信任所有远程主机,仅适用于测试环境。在域环境中应依赖 Kerberos 认证,避免修改 TrustedHosts;在工作组环境中至少明确指定目标主机名。

  3. Constrained Endpoint 使用虚拟账户RunAsVirtualAccount = $true 会让会话使用临时创建的虚拟账户运行,该账户在会话结束后自动销毁,比直接使用真实服务账户更安全。但虚拟账户无法访问网络资源,如需跨机器操作需配合 gMSA(组托管服务账户)。

  4. 防火墙规则需双向检查。WinRM 使用 HTTP(5985)和 HTTPS(5986)两个端口。确保远程主机的入站规则放行了对应端口,同时检查本地网络出站策略和中间链路的防火墙设备。

  5. 转录文件会持续增长。配置 TranscriptDirectory 后,所有远程会话的操作都会被完整记录。建议配合定期归档脚本清理过期的转录文件,避免磁盘空间耗尽。

  6. WinRM 配置修改后需重启服务。无论是修改监听器、注册新的会话配置还是更改服务参数,都需要执行 Restart-Service -Name WinRM -Force 才能生效。注意这会断开所有现有的远程会话,应在维护窗口操作。

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 频道,让成本意识成为团队的日常习惯而非月底的意外惊吓。