PowerShell 技能连载 - PowerShell 7 新特性实战

适用于 PowerShell 7.0 及以上版本

背景

PowerShell 7 是 PowerShell 团队基于 .NET Core 重新构建的重大版本,标志着 PowerShell 从 Windows 专属工具蜕变为跨平台自动化引擎。无论你管理的服务器运行的是 Windows、Linux 还是 macOS,同一套 PowerShell 脚本都能直接运行。对于仍在使用 Windows PowerShell 5.1 的管理员来说,升级到 PowerShell 7 不仅是版本号的变更,更是开发效率的质变。

PowerShell 7 引入了大量源自 C# 和其他现代语言的新运算符与语法糖。三元运算符让条件赋值从四行缩减为一行,null 合并运算符 ?? 和 null 条件赋值 ??= 彻底改变了处理 $null 的方式,管道链操作符 &&|| 则让命令行操作如 Bash 般流畅。这些特性不是锦上添花,而是日常脚本编写中每天都在用的基础设施。

除了语法层面的改进,PowerShell 7 还带来了 ForEach-Object -Parallel 并行执行能力、结构化错误信息的 ErrorRecord 改进、以及跨平台文件路径处理等底层优化。本文将通过三个实战场景,展示这些新特性如何解决实际问题,帮助你评估升级的价值和路径。

新运算符:让脚本更简洁、更安全

PowerShell 7 引入的四组新运算符直接改变了日常脚本编写的习惯。三元运算符 ? : 替代了冗长的 if-else 赋值,null 合并运算符 ?? 让默认值处理变得优雅,null 条件赋值 ??= 避免了重复的 null 检查,管道链操作符 &&|| 则让多步操作的错误处理变得直观。下面的示例展示了这些运算符在配置管理场景中的综合运用。

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
# 场景:读取应用配置,合并默认值,根据环境执行不同的部署步骤

# 默认配置
$defaultConfig = @{
Port = 8080
MaxRetry = 3
LogLevel = "Information"
BasePath = "/opt/app"
Features = @("auth", "logging")
}

# 模拟从环境变量读取的配置(部分字段缺失)
$envConfig = @{
Port = 9090
LogLevel = $null
BasePath = $null
}

# 三元运算符:根据环境决定日志前缀
$env = $env:APP_ENV ?? "development"
$logPrefix = ($env -eq "production") ? "[PROD]" : "[DEV]"

# null 合并运算符 ?? :用默认值填充缺失的配置
$port = $envConfig.Port ?? $defaultConfig.Port
$maxRetry = $envConfig.MaxRetry ?? $defaultConfig.MaxRetry
$logLevel = $envConfig.LogLevel ?? $defaultConfig.LogLevel
$basePath = $envConfig.BasePath ?? $defaultConfig.BasePath

# null 条件赋值 ??= :只在变量为 null 时赋值
$features = $envConfig.Features
$features ??= $defaultConfig.Features

Write-Host "$logPrefix 应用配置已加载"
Write-Host " 端口: $port"
Write-Host " 最大重试: $maxRetry"
Write-Host " 日志级别: $logLevel"
Write-Host " 基础路径: $basePath"
Write-Host " 功能模块: $($features -join ', ')"

# 管道链操作符 && 和 ||
Write-Host "`n--- 部署流程 ---"

# && 前面的命令成功才执行后面的命令
# || 前面的命令失败才执行后面的命令
Test-Path $basePath && Write-Host "目录已存在: $basePath" || Write-Host "目录不存在,将使用默认路径"

# 构建最终配置对象
$appConfig = [PSCustomObject]@{
Port = $port
MaxRetry = $maxRetry
LogLevel = $logLevel
BasePath = $basePath
Features = $features
Env = $env
Prefix = $logPrefix
}

Write-Host "`n最终配置:"
$appConfig | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[DEV] 应用配置已加载
端口: 9090
最大重试: 3
日志级别: Information
基础路径: /opt/app
功能模块: auth, logging

--- 部署流程 ---
目录不存在,将使用默认路径

最终配置:

Port : 9090
MaxRetry : 3
LogLevel : Information
BasePath : /opt/app
Features : {auth, logging}
Env : development
Prefix : [DEV]

这段代码的关键在于理解每个运算符的语义。$envConfig.Port ?? $defaultConfig.Port 中,Port$envConfig 中存在且为 9090,所以 ?? 直接返回 9090,不会 fallback 到默认值。而 $envConfig.LogLevel ?? $defaultConfig.LogLevel 中,LogLevel$null,所以 ?? 返回右侧的 "Information"$features ??= $defaultConfig.Features 则是一种”惰性初始化”模式——只有当 $features$null 时才赋值,否则保留原值。管道链操作符 &&|| 的行为与 Bash 一致:Test-Path $basePath 返回 $false 后,&& 后面的命令被跳过,|| 后面的命令被执行。

并行执行:ForEach-Object -Parallel 提升批量操作效率

在 Windows PowerShell 5.1 中处理大批量操作时,唯一的做法是串行遍历——100 台服务器逐台执行,即使每台耗时 5 秒,总计也要 8 分钟以上。PowerShell 7 的 ForEach-Object -Parallel 基于 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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# 场景:批量检查远程服务器的健康状态

# 模拟服务器列表
$servers = @(
"web-prod-01", "web-prod-02", "web-prod-03",
"api-prod-01", "api-prod-02",
"db-prod-01", "db-prod-02",
"cache-prod-01", "cache-prod-02", "cache-prod-03"
)

# 模拟健康检查函数(实际环境中会调用 Invoke-RestMethod 或 Test-Connection)
function Test-ServerHealth {
param([string]$ServerName)

# 模拟网络延迟
Start-Sleep -Milliseconds (Get-Random -Min 100 -Max 500)

$statuses = @("健康", "健康", "健康", "警告", "健康", "异常")
$status = $statuses | Get-Random

return [PSCustomObject]@{
Server = $ServerName
Status = $status
CpuUsage = [math]::Round((Get-Random -Min 10 -Max 95), 1)
MemUsage = [math]::Round((Get-Random -Min 20 -Max 90), 1)
CheckAt = Get-Date -Format "HH:mm:ss"
}
}

# 串行执行(作为对比)
$serialWatch = [System.Diagnostics.Stopwatch]::StartNew()
$serialResults = $servers | ForEach-Object {
Test-ServerHealth -ServerName $_
}
$serialWatch.Stop()

Write-Host "串行执行耗时: $($serialWatch.ElapsedMilliseconds) ms"
Write-Host "串行结果数: $($serialResults.Count)"

# 并行执行(限制并发数为 4)
$parallelWatch = [System.Diagnostics.Stopwatch]::StartNew()
$parallelResults = $servers | ForEach-Object -ThrottleLimit 4 -Parallel {
# 注意:-Parallel 脚本块内无法直接访问外部变量和函数
# 需要通过 $using: 引用外部变量,或重新定义函数

# 在脚本块内定义健康检查逻辑
$statuses = @("健康", "健康", "健康", "警告", "健康", "异常")
$status = $statuses | Get-Random

# 模拟网络延迟
Start-Sleep -Milliseconds (Get-Random -Min 100 -Max 500)

[PSCustomObject]@{
Server = $_
Status = $status
CpuUsage = [math]::Round((Get-Random -Min 10 -Max 95), 1)
MemUsage = [math]::Round((Get-Random -Min 20 -Max 90), 1)
CheckAt = Get-Date -Format "HH:mm:ss"
}
}
$parallelWatch.Stop()

Write-Host "`n并行执行耗时: $($parallelWatch.ElapsedMilliseconds) ms"
Write-Host "并行结果数: $($parallelResults.Count)"

# 使用 $using: 传递外部变量的示例
$timeoutSec = 5
$parallelWithUsing = $servers | ForEach-Object -ThrottleLimit 4 -Parallel {
$timeout = $using:timeoutSec
# 可以在日志中引用外部配置
[PSCustomObject]@{
Server = $_
Timeout = $timeout
Message = "超时设置: ${timeout}s,检查完成"
}
}

# 输出加速比
$speedup = [math]::Round($serialWatch.ElapsedMilliseconds / $parallelWatch.ElapsedMilliseconds, 1)
Write-Host "`n加速比: ${speedup}x"

# 汇总健康状态
Write-Host "`n--- 健康检查汇总 ---"
$parallelResults | Group-Object Status | ForEach-Object {
Write-Host " $($_.Name): $($_.Count) 台"
}

# 筛选异常服务器
$unhealthy = $parallelResults | Where-Object { $_.Status -ne "健康" }
if ($unhealthy) {
Write-Host "`n需要关注的服务器:"
$unhealthy | Format-Table -AutoSize
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
串行执行耗时: 3247 ms
串行结果数: 10

并行执行耗时: 812 ms
并行结果数: 10

加速比: 4.0x

--- 健康检查汇总 ---
健康: 7 台
警告: 2 台
异常: 1 台

需要关注的服务器:

Server Status CpuUsage MemUsage CheckAt
------ ------ -------- -------- -------
api-prod-01 警告 78.3 65.2 14:23:17
cache-prod-02 警告 55.9 82.4 14:23:17
db-prod-01 异常 92.1 88.7 14:23:18

并行执行的核心参数是 -ThrottleLimit,它控制同时运行的 runspace 数量。示例中设为 4,意味着 10 台服务器分 3 批执行(4+4+2),总耗时接近最长的一批而非全部之和。需要注意的关键限制是:-Parallel 脚本块运行在独立的 runspace 中,无法直接访问外部作用域的变量和函数。必须通过 $using: 语法引用外部变量(如 $using:timeoutSec),或者将函数定义在脚本块内部。这种隔离虽然增加了编码复杂度,但避免了并发访问共享状态导致的竞态条件。

跨平台兼容性:一套脚本运行在 Windows 和 Linux 上

PowerShell 7 最大的架构变化是基于 .NET Core(现为 .NET 5+)构建,使其能在 Windows、Linux 和 macOS 上原生运行。但这并不意味着所有 Windows PowerShell 脚本都能直接在 Linux 上运行——路径分隔符、注册表操作、WMI/CIM 差异等问题都需要处理。PowerShell 7 通过跨平台兼容性模块和新的 API 来弥合这些差异。

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
# 场景:编写一个跨平台的系统信息收集脚本

# 自动适应的路径处理
# PowerShell 7 对 Join-Path 和路径处理做了跨平台优化
function Get-SystemReport {
$report = [ordered]@{}

# 平台检测
$report["操作系统"] = [System.Environment]::OSVersion.Platform.ToString()
$report["机器名"] = [System.Environment]::MachineName
$report["PowerShell 版本"] = $PSVersionTable.PSVersion.ToString()
$report["是否为管理员"] = (
[System.Security.Principal.WindowsPrincipal]::new(
[System.Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole("Administrators")
) ? "是" : "否"

# 跨平台路径处理
$configDir = Join-Path $HOME "app-config"
$logDir = Join-Path $HOME "app-logs"
$dataFile = Join-Path $configDir "data.json"

$report["配置目录"] = $configDir
$report["日志目录"] = $logDir

# 创建目录(跨平台兼容)
$null = New-Item -ItemType Directory -Path $configDir -Force
$null = New-Item -ItemType Directory -Path $logDir -Force

# 跨平台环境变量访问
$report["用户主目录"] = $HOME
$report["PATH 条目数"] = ($env:PATH -split ([System.IO.Path]::PathSeparator)).Count
$report["临时目录"] = [System.IO.Path]::GetTempPath()

# 根据平台使用不同的系统信息获取方式
if ($IsWindows) {
# Windows 平台:使用 CIM 获取详细信息
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$report["系统版本"] = $os.Caption
$report["可用内存(MB)"] = [math]::Round($os.FreePhysicalMemory / 1024, 0)
$report["CPU 核心数"] = (Get-CimInstance -ClassName Win32_Processor).NumberOfLogicalProcessors
}
elseif ($IsLinux) {
# Linux 平台:读取 /proc 文件系统
$report["系统版本"] = (Get-Content /etc/os-release |
Where-Object { $_ -match "^PRETTY_NAME=" }) -replace 'PRETTY_NAME="(.+)"', '$1'
$report["可用内存(MB)"] = [math]::Round(
(Get-Content /proc/meminfo |
Where-Object { $_ -match "^MemAvailable:" } |
ForEach-Object { ($_ -split '\s+')[1] }) / 1024, 0
)
$report["CPU 核心数"] = (Get-Content /proc/cpuinfo |
Where-Object { $_ -match "^processor" }).Count
}
elseif ($IsMacOS) {
$report["系统版本"] = "macOS $(sw_vers -productVersion 2>$null)"
$report["CPU 核心数"] = [int](sysctl -n hw.ncpu 2>$null)
}

# 写入配置文件(跨平台路径)
$reportJson = $report | ConvertTo-Json -Depth 3
Set-Content -Path $dataFile -Value $reportJson -Encoding UTF8

return [PSCustomObject]$report
}

# 执行并展示结果
$systemReport = Get-SystemReport
Write-Host "`n=== 系统信息报告 ==="
$systemReport | Format-List

# 展示跨平台文件路径
Write-Host "`n=== 跨平台路径示例 ==="
$paths = @(
(Join-Path $HOME "Documents" "notes.txt"),
(Join-Path $HOME ".config" "app" "settings.json"),
(Join-Path $HOME "logs" "app-$(Get-Date -Format 'yyyyMMdd').log")
)
$paths | ForEach-Object {
Write-Host " $($_ -replace '\\', '/')"
}

# Windows 兼容性模块提示
if ($IsWindows) {
Write-Host "`n=== Windows 兼容性模块 ==="
$compatModules = @(
"Microsoft.PowerShell.Archive",
"Microsoft.PowerShell.Management",
"Microsoft.PowerShell.Security",
"CimCmdlets",
"Microsoft.WSMan.Management"
)
$compatModules | ForEach-Object {
$installed = Get-Module -ListAvailable -Name $_ -ErrorAction SilentlyContinue
$status = ($null -ne $installed) ? "已安装" : "未安装"
Write-Host " $_`: $status"
}
}

执行结果示例:

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
=== 系统信息报告 ===

操作系统 : Win32NT
机器名 : DESKTOP-WORKSTATION
PowerShell 版本 : 7.4.6
是否为管理员 : 否
配置目录 : C:\Users\admin\app-config
日志目录 : C:\Users\admin\app-logs
用户主目录 : C:\Users\admin
PATH 条目数 : 18
临时目录 : C:\Users\admin\AppData\Local\Temp\
系统版本 : Microsoft Windows 11 Pro
可用内存(MB) : 8192
CPU 核心数 : 8

=== 跨平台路径示例 ===
C:/Users/admin/Documents/notes.txt
C:/Users/admin/.config/app/settings.json
C:/Users/admin/logs/app-20251209.log

=== Windows 兼容性模块 ===
Microsoft.PowerShell.Archive: 已安装
Microsoft.PowerShell.Management: 已安装
Microsoft.PowerShell.Security: 已安装
CimCmdlets: 已安装
Microsoft.WSMan.Management: 已安装

跨平台脚本的关键在于善用 PowerShell 7 提供的抽象层。$IsWindows$IsLinux$IsMacOS 三个自动变量让平台检测变得简单直观,Join-Path[System.IO.Path] 类自动处理不同操作系统的路径分隔符。Windows 环境下,兼容性模块确保了 CIM、WSMan 等 Windows 特有的功能仍然可用。编写跨平台脚本时,核心逻辑应尽量使用 PowerShell 通用 cmdlet,将平台相关的代码隔离在 if ($IsWindows) 等条件块中。

注意事项

  1. ForEach-Object -Parallel 的脚本块运行在独立 runspace 中。这意味着外部作用域的变量、函数、别名都无法直接访问。必须使用 $using: 语法传递外部变量,或者将函数定义复制到脚本块内部。对于复杂的业务逻辑,建议将核心逻辑封装在模块中,在 -Parallel 脚本块内通过 Import-Module 加载。

  2. ????= 对空字符串的处理与 $null 不同"" ?? "default" 的结果是空字符串 "",而不是 "default"。null 合并运算符只在值为 $null 时才触发。如果你的配置允许空字符串表示”未设置”,需要额外判断,比如 ($config -eq "") ? $default : $config 或者 $config ?? $default 配合前期的空字符串清理。

  3. 三元运算符 ? : 的优先级低于管道$a | Some-Cmdlet ? "yes" : "no" 会被解析为 $a | (Some-Cmdlet ? "yes" : "no") 而非 ($a | Some-Cmdlet) ? "yes" : "no"。在管道表达式中使用三元运算符时,务必用括号明确优先级,避免逻辑错误。

  4. -ThrottleLimit 的默认值为 5,不宜盲目调大。并行数设置过高会消耗大量系统资源(每个 runspace 约占用数十 MB 内存),还可能触发目标服务器的限流机制。建议根据目标系统的承受能力和本地资源情况,将并发数控制在 4 到 16 之间。对于数据库操作等重 IO 场景,建议从较小的值开始测试。

  5. $IsWindows 等平台变量在 Windows PowerShell 5.1 中不存在。如果你的脚本需要同时兼容 5.1 和 7,应该先检测 $PSVersionTable.PSVersion.Major,或者在脚本开头通过 $IsWindows = $IsWindows -or ($PSVersionTable.PSVersion.Major -le 5) 来兼容旧版本。直接使用 $IsWindows 而不做版本检查,会导致 5.1 环境中变量不存在而报错。

  6. 管道链操作符 &&|| 依赖命令的退出码而非布尔返回值。对于返回 $false 或空数组的 cmdlet,&& 的行为可能不符合直觉。例如 Get-Item nonexistent.txt 抛出错误时,&& 后面的命令不会执行,但 Get-Item 加上 -ErrorAction SilentlyContinue 后不再抛错,&& 会认为命令成功而继续执行。理解”命令成功”和”命令不抛错”的区别,是正确使用管道链的关键。

PowerShell 技能连载 - WinGet 包管理自动化

适用于 PowerShell 7.0 及以上版本,需要 winget CLI

背景介绍

在企业 IT 运维中,软件安装和更新是一项高频且重复的工作。新员工入职时需要配置开发环境,安全合规要求定期更新终端上的软件版本,这些都给运维团队带来了巨大的工作量。传统的手动安装方式不仅效率低下,还容易出现版本不一致、配置遗漏等问题。

WinGet(Windows Package Manager)是微软官方推出的命令行包管理工具,从 Windows 10 1709 版本开始内置。它提供了类似 Linux 下 apt 或 yum 的软件管理体验,支持搜索、安装、更新和卸载应用程序。结合 PowerShell 的脚本能力,我们可以将 WinGet 的操作封装为可复用的自动化流程。

本文将通过三个实际场景,演示如何使用 PowerShell 调用 WinGet 实现软件搜索安装、批量部署以及自动化更新,帮助运维人员构建完整的 Windows 软件生命周期管理体系。

WinGet 基础操作

WinGet 提供了丰富的子命令来管理软件包。下面的脚本展示了最常用的三个操作:搜索软件包、安装软件以及列出已安装的软件。

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
# 搜索软件包
function Find-WinGetPackage {
param(
[Parameter(Mandatory)]
[string]$Keyword
)

$result = winget search $Keyword --accept-source-agreements 2>&1
$result
}

# 安装软件包
function Install-WinGetPackage {
param(
[Parameter(Mandatory)]
[string]$PackageId,

[switch]$Silent
)

$args = @(
'install'
'--id', $PackageId
'--accept-source-agreements'
'--accept-package-agreements'
)

if ($Silent) {
$args += '--silent'
}

Write-Host "正在安装: $PackageId" -ForegroundColor Cyan
winget @args 2>&1
}

# 列出已安装的软件
function Get-WinGetInstalled {
$output = winget list --accept-source-agreements 2>&1
$output
}

# 示例:搜索 VS Code
Find-WinGetPackage -Keyword "Visual Studio Code"

# 示例:安装 VS Code(静默模式)
Install-WinGetPackage -PackageId 'Microsoft.VisualStudioCode' -Silent

# 示例:查看已安装的软件列表
Get-WinGetInstalled

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
名称                         ID                                版本        来源
---------------------------------------------------------------------------
Visual Studio Code Microsoft.VisualStudioCode 1.96.2 winget
Visual Studio Code Insiders Microsoft.VisualStudioCode.Insiders 1.97.0 winget

正在安装: Microsoft.VisualStudioCode
已成功完成安装

名称 ID 版本
---------------------------------------------------------------------------
Visual Studio Code Microsoft.VisualStudioCode 1.96.2
Git for Windows Git.Git 2.47.1
PowerShell 7 Microsoft.PowerShell 7.4.6

通过上面的函数封装,我们可以将 WinGet 的命令行操作转化为结构化的 PowerShell 函数,便于后续在脚本中组合调用。--accept-source-agreements--accept-package-agreements 参数可以跳过交互式确认,实现无人值守安装。

批量安装与软件清单管理

在企业环境中,我们通常需要根据角色或团队批量安装一组软件。下面的脚本展示了如何通过 JSON 清单文件管理软件列表,并实现一键批量部署。

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
# 定义软件清单(可以保存为外部 JSON 文件)
$softwareList = @(
@{
Id = 'Microsoft.VisualStudioCode'
Name = 'Visual Studio Code'
Category = 'Editor'
}
@{
Id = 'Git.Git'
Name = 'Git for Windows'
Category = 'VCS'
}
@{
Id = 'Microsoft.PowerShell'
Name = 'PowerShell 7'
Category = 'Runtime'
}
@{
Id = 'OpenJS.NodeJS.LTS'
Name = 'Node.js LTS'
Category = 'Runtime'
}
@{
Id = 'Docker.DockerDesktop'
Name = 'Docker Desktop'
Category = 'Container'
}
) | ConvertTo-Json -Depth 3

# 将清单保存到文件
$listPath = Join-Path $HOME 'winget-software-list.json'
$softwareList | Out-File -FilePath $listPath -Encoding utf8
Write-Host "软件清单已保存到: $listPath" -ForegroundColor Green

# 从文件加载并批量安装
function Install-WinGetFromList {
param(
[Parameter(Mandatory)]
[string]$ListFile,

[string[]]$Categories
)

$packages = Get-Content $ListFile -Raw | ConvertFrom-Json

# 按类别过滤
if ($Categories) {
$packages = $packages | Where-Object { $_.Category -in $Categories }
}

$total = $packages.Count
$success = 0
$failed = 0

foreach ($pkg in $packages) {
Write-Host "`n[$($success + $failed + 1)/$total] 安装: $($pkg.Name)" -ForegroundColor Cyan

$output = winget install --id $pkg.Id `
--accept-source-agreements `
--accept-package-agreements `
--silent 2>&1

$lastLine = ($output | Select-Object -Last 1).ToString()
if ($lastLine -match '成功|successfully|已安装') {
Write-Host " -> 成功" -ForegroundColor Green
$success++
} else {
Write-Host " -> 失败: $lastLine" -ForegroundColor Red
$failed++
}
}

# 输出汇总报告
Write-Host "`n===== 安装汇总 =====" -ForegroundColor Yellow
Write-Host "总计: $total | 成功: $success | 失败: $failed"
}

# 示例:仅安装 Runtime 类别的软件
Install-WinGetFromList -ListFile $listPath -Categories 'Runtime', 'VCS'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
软件清单已保存到: C:\Users\admin\winget-software-list.json

[1/3] 安装: Git for Windows
-> 成功

[2/3] 安装: PowerShell 7
-> 成功

[3/3] 安装: Node.js LTS
-> 成功

===== 安装汇总 =====
总计: 3 | 成功: 3 | 失败: 0

使用 JSON 清单文件管理软件列表的好处是:不同团队可以维护各自的清单文件,新员工入职时只需指定对应的清单即可完成环境搭建。同时,清单文件可以纳入 Git 版本管理,便于审计和追溯软件配置变更。

软件更新检查与自动化更新

保持软件版本最新是安全运维的基本要求。下面的脚本实现了自动检测可更新的软件,并在确认后执行批量更新,同时支持将更新任务注册为 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
108
109
110
111
112
113
114
115
116
117
118
# 检查可更新的软件
function Get-WinGetUpdate {
$output = winget upgrade --accept-source-agreements 2>&1

# 解析输出获取可更新的包列表
$lines = $output -split "`n"
$updates = @()

# 找到数据行(跳过标题和分隔线)
$dataStarted = $false
foreach ($line in $lines) {
if ($line -match '^[-\s]+$') {
$dataStarted = $true
continue
}
if ($dataStarted -and $line.Trim() -and $line -notmatch '^\s*$') {
$updates += $line.Trim()
}
}

return $updates
}

# 执行更新
function Start-WinGetUpdate {
param(
[string]$PackageId,

[switch]$All,

[switch]$WhatIf
)

if ($All) {
Write-Host "检查所有可更新的软件..." -ForegroundColor Cyan
$updates = Get-WinGetUpdate

if ($updates.Count -eq 0) {
Write-Host "所有软件均为最新版本" -ForegroundColor Green
return
}

Write-Host "发现 $($updates.Count) 个可更新的软件:`n"
$updates | ForEach-Object { Write-Host " - $_" }

if ($WhatIf) {
Write-Host "`n[WhatIf 模式] 跳过实际更新" -ForegroundColor Yellow
return
}

$confirm = Read-Host "`n确认更新全部? (Y/N)"
if ($confirm -eq 'Y') {
winget upgrade --all `
--accept-source-agreements `
--accept-package-agreements 2>&1
}
}
elseif ($PackageId) {
if ($WhatIf) {
Write-Host "[WhatIf 模式] 将更新: $PackageId" -ForegroundColor Yellow
return
}

winget upgrade --id $PackageId `
--accept-source-agreements `
--accept-package-agreements 2>&1
}
}

# 定时任务:每天检查更新并记录日志
function Register-WinGetUpdateTask {
param(
[string]$Time = '03:00'
)

$scriptPath = Join-Path $HOME 'winget-auto-update.ps1'

# 生成自动更新脚本
$scriptContent = @"
`$logDir = Join-Path `$HOME 'WinGetUpdateLogs'
if (-not (Test-Path `$logDir)) {
New-Item -Path `$logDir -ItemType Directory | Out-Null
}

`$logFile = Join-Path `$logDir "update-$(Get-Date -Format 'yyyy-MM-dd').log"
"[$((Get-Date))] 开始检查更新" | Out-File `$logFile -Append

`$output = winget upgrade --all --accept-source-agreements --accept-package-agreements --silent 2>&1
`$output | Out-File `$logFile -Append

"[$((Get-Date))] 更新完成" | Out-File `$logFile -Append
"@

Set-Content -Path $scriptPath -Value $scriptContent -Encoding utf8

# 注册计划任务
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' `
-Argument "-NoProfile -File `"$scriptPath`""
$trigger = New-ScheduledTaskTrigger -Daily -At $Time
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries

Register-ScheduledTask `
-TaskName 'WinGet-AutoUpdate' `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Force

Write-Host "已注册每日更新任务,执行时间: $Time" -ForegroundColor Green
}

# 示例:检查更新(仅查看,不执行)
Start-WinGetUpdate -All -WhatIf

# 示例:注册每日凌晨 3 点自动更新任务
# Register-WinGetUpdateTask -Time '03:00'

执行结果示例:

1
2
3
4
5
6
7
8
检查所有可更新的软件...
发现 3 个可更新的软件:

- Git.Git 2.43.0 2.47.1 winget
- Microsoft.VisualStudioCode 1.95.0 1.96.2 winget
- Docker.DockerDesktop 4.34.0 4.35.1 winget

[WhatIf 模式] 跳过实际更新

将更新检查注册为 Windows 计划任务后,系统会在每天凌晨自动检查并安装更新,同时将操作日志写入文件。运维人员可以通过查看日志目录下的文件,快速了解每台机器的软件更新情况,确保安全补丁及时到位。

注意事项

  1. WinGet 版本要求:建议使用 WinGet v1.6 或更高版本,旧版本可能不支持 --accept-package-agreements 等参数。可以通过 winget --version 查看当前版本,通过 Microsoft Store 自动更新 WinGet。

  2. 管理员权限:部分软件的安装需要管理员权限。在脚本中可以使用 #requires -RunAsAdministrator 声明,或者以管理员身份运行 PowerShell 来执行安装操作,否则可能会遇到权限不足的错误。

  3. 网络环境:在企业代理环境下,WinGet 可能需要额外配置才能正常访问软件源。可以通过 winget settings 命令打开配置文件,设置代理和网络相关参数,或者配置企业内部源(如 Azure Artifacts)作为替代。

  4. 安装失败处理:某些软件不支持静默安装或存在安装依赖冲突。建议在批量部署前先在测试环境中验证清单文件,对于失败的安装记录错误日志并单独处理,避免一个失败阻塞整个部署流程。

  5. 计划任务兼容性Register-ScheduledTask 需要 Windows 8.1 及以上系统。在 Windows Server 环境中,需要确认 Task Scheduler 服务正常运行,且执行账户具有安装软件的权限。对于不支持计划任务的场景,可以考虑使用 Group Policy 的启动脚本替代。

  6. PATH 环境变量刷新:WinGet 安装完软件后可能修改了系统 PATH,但当前 PowerShell 会话不会自动感知变更。部署完成后需要重启 PowerShell 或手动刷新环境变量:$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User'),否则后续命令可能找不到新安装的可执行文件。

PowerShell 技能连载 - Azure 备份与恢复

适用于 PowerShell 7.0 及以上版本

背景

数据备份是 IT 运维的基石。无论是意外删除、硬件故障还是勒索软件攻击,可靠的备份策略都是恢复业务运行的关键。Azure Backup 是微软提供的全托管备份服务,支持虚拟机、SQL 数据库、文件共享、Blob 存储等多种资源类型,并提供 99.999999999%(11 个 9)的数据持久性保障。

传统的备份方案需要自行维护备份软件、磁带库或专用存储设备,不仅成本高昂,而且运维复杂。Azure Backup 将这些基础设施全部托管化:备份存储自动管理、加密传输与静态加密、符合 GDPR 等合规要求。运维人员只需通过 Azure 门户或 PowerShell 定义备份策略,服务便会按照计划自动执行备份作业,并在需要时一键恢复。

本文将介绍如何通过 PowerShell 管理 Azure Backup 的核心操作,包括创建恢复服务保管库与配置备份策略、启用备份与监控备份作业状态,以及数据恢复与跨区域灾难恢复。每个场景都提供可执行的代码示例和输出演示。

恢复服务保管库与备份策略配置

恢复服务保管库(Recovery Services Vault)是 Azure Backup 的管理容器,所有备份策略、恢复点和备份作业都关联到保管库。以下代码演示了如何创建保管库、设置存储冗余类型,并为虚拟机配置每日备份策略。

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
# 定义变量
$resourceGroup = 'rg-backup-demo'
$location = 'eastasia'
$vaultName = 'rsv-backup-demo'
$vmName = 'vm-prod-01'

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

# 创建恢复服务保管库
$vault = New-AzRecoveryServicesVault `
-Name $vaultName `
-ResourceGroupName $resourceGroup `
-Location $location

Write-Host "恢复服务保管库已创建: $($vault.Name)"

# 设置保管库存储冗余类型(必须在注册任何备份项之前设置)
# LocallyRedundant - 本地冗余(成本最低)
# GeoRedundant - 异地冗余(推荐生产环境)
Set-AzRecoveryServicesBackupProperty `
-Vault $vault `
-BackupStorageRedundancy GeoRedundant

Write-Host "存储冗余已设置为 GeoRedundant"

# 获取默认备份策略
$policy = Get-AzRecoveryServicesBackupPolicy `
-VaultId $vault.ID `
-Name 'DailyPolicy' -ErrorAction SilentlyContinue

if (-not $policy) {
# 如果默认策略不存在,获取策略模板并自定义
$schedulePolicy = Get-AzRecoveryServicesSchedulePolicyObject -WorkloadType AzureVM
$schedulePolicy.ScheduleRunTimes = @(
(Get-Date -Date '2025-12-05T02:00:00').ToUniversalTime()
)

$retentionPolicy = Get-AzRecoveryServicesRetentionPolicyObject -WorkloadType AzureVM
$retentionPolicy.DailySchedule.DurationCountInDays = 30
$retentionPolicy.MonthlySchedule.DurationCountInMonths = 12

$policy = New-AzRecoveryServicesProtectionPolicy `
-Name 'CustomDailyPolicy' `
-WorkloadType AzureVM `
-VaultId $vault.ID `
-SchedulePolicy $schedulePolicy `
-RetentionPolicy $retentionPolicy

Write-Host "已创建自定义备份策略: $($policy.Name)"
} else {
Write-Host "使用默认备份策略: $($policy.Name)"
}

# 启用虚拟机备份保护
$vm = Get-AzVM -Name $vmName -ResourceGroupName $resourceGroup
Enable-AzRecoveryServicesBackupProtection `
-VaultId $vault.ID `
-Policy $policy `
-Name $vm.Name `
-ResourceGroupName $vm.ResourceGroupName

Write-Host "已为虚拟机 $vmName 启用备份保护"

# 验证配置结果
$container = Get-AzRecoveryServicesBackupContainer `
-VaultId $vault.ID `
-ContainerType AzureVM `
-FriendlyName $vmName -ErrorAction SilentlyContinue

if ($container) {
Write-Host "验证成功: 虚拟机已注册到保管库"
Write-Host " 注册状态: $($container.Registered)"
Write-Host " 备份管理类型: $($container.BackupManagementType)"
}

恢复服务保管库的存储冗余类型有两种选择:本地冗余(LRS)将数据在同一数据中心内复制三份,适合开发测试环境;异地冗余(GRS)将备份数据复制到配对区域,推荐生产环境使用以获得跨区域灾难恢复能力。存储冗余类型必须在注册第一个备份项之前设置,之后无法更改。备份策略定义了备份的执行时间和保留周期,默认的每日策略会在凌晨 2 点执行备份并保留 30 天。

执行结果示例:

1
2
3
4
5
6
7
8
资源组已创建: rg-backup-demo
恢复服务保管库已创建: rsv-backup-demo
存储冗余已设置为 GeoRedundant
已创建自定义备份策略: CustomDailyPolicy
已为虚拟机 vm-prod-01 启用备份保护
验证成功: 虚拟机已注册到保管库
注册状态: Registered
备份管理类型: AzureIaasVM

启用备份与监控备份作业

除了按计划执行的定时备份外,在系统升级、重大变更前通常需要手动触发一次按需备份。以下代码演示了如何触发按需备份、查询备份作业状态以及监控备份健康度。

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
# 定义变量
$vaultName = 'rsv-backup-demo'
$resourceGroup = 'rg-backup-demo'
$vmName = 'vm-prod-01'

$vault = Get-AzRecoveryServicesVault -Name $vaultName -ResourceGroupName $resourceGroup

# 获取备份保护容器
$container = Get-AzRecoveryServicesBackupContainer `
-VaultId $vault.ID `
-ContainerType AzureVM `
-FriendlyName $vmName

# 获取备份项
$item = Get-AzRecoveryServicesBackupItem `
-VaultId $vault.ID `
-Container $container `
-WorkloadType AzureVM

# 触发按需备份
$job = Backup-AzRecoveryServicesBackupItem `
-VaultId $vault.ID `
-Item $item

Write-Host "按需备份已触发"
Write-Host " 作业名称: $($job.Name)"
Write-Host " 作业状态: $($job.Status)"
Write-Host " 开始时间: $($job.StartTime)"

# 轮询等待备份完成(最多等待 30 分钟)
$maxWaitMinutes = 30
$waitedMinutes = 0

while ($job.Status -eq 'InProgress' -and $waitedMinutes -lt $maxWaitMinutes) {
Start-Sleep -Seconds 60
$waitedMinutes++
$job = Get-AzRecoveryServicesBackupJob `
-VaultId $vault.ID `
-Job $job
Write-Host " 等待中... ($waitedMinutes 分钟) 状态: $($job.Status)"
}

if ($job.Status -eq 'Completed') {
Write-Host "`n备份完成!"
Write-Host " 耗时: $($job.Duration)"
} else {
Write-Host "`n备份未在 $maxWaitMinutes 分钟内完成,当前状态: $($job.Status)"
}

# 查看所有恢复点
$recoveryPoints = Get-AzRecoveryServicesBackupRecoveryPoint `
-VaultId $vault.ID `
-Item $item

Write-Host "`n===== 可用恢复点列表 ====="
Write-Host ("{0,-40} {1,-25} {2}" -f '恢复点名称', '备份时间', '类型')
Write-Host ('-' * 90)

foreach ($rp in $recoveryPoints) {
$rpTime = $rp.RecoveryPointTime.ToString('yyyy-MM-dd HH:mm:ss')
$rpType = if ($rp.RecoveryPointType) { $rp.RecoveryPointType } else { 'Standard' }
Write-Host ("{0,-40} {1,-25} {2}" -f $rp.Name, $rpTime, $rpType)
}

# 查询最近 7 天所有备份作业的状态汇总
$startTime = (Get-Date).AddDays(-7)
$allJobs = Get-AzRecoveryServicesBackupJob `
-VaultId $vault.ID `
-From $startTime `
-To (Get-Date) `
-Operation Backup

$completed = ($allJobs | Where-Object Status -eq 'Completed').Count
$failed = ($allJobs | Where-Object Status -eq 'Failed').Count
$inProgress = ($allJobs | Where-Object Status -eq 'InProgress').Count

Write-Host "`n===== 最近 7 天备份作业汇总 ====="
Write-Host "总作业数: $($allJobs.Count)"
Write-Host "已完成: $completed"
Write-Host "失败: $failed"
Write-Host "进行中: $inProgress"

if ($failed -gt 0) {
Write-Host "`n失败的作业详情:"
$failedJobs = $allJobs | Where-Object Status -eq 'Failed'
foreach ($fj in $failedJobs) {
Write-Host " - $($fj.DisplayName) | 开始: $($fj.StartTime) | 错误: $($fj.Error)"
}
}

按需备份使用 Backup-AzRecoveryServicesBackupItem cmdlet 触发,它会立即创建一个备份作业。由于虚拟机备份可能需要较长时间(取决于磁盘大小和数据变化量),脚本中使用了轮询机制每分钟检查一次作业状态。备份完成后,Get-AzRecoveryServicesBackupRecoveryPoint 会列出所有可用的恢复点,每个恢复点代表一个可以还原的时间点快照。最后的作业汇总功能可以帮助快速发现近期备份异常。

执行结果示例:

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
按需备份已触发
作业名称: a1b2c3d4-e5f6-7890-abcd-ef1234567890
作业状态: InProgress
开始时间: 2025-12-05 09:15:00
等待中... (1 分钟) 状态: InProgress
等待中... (2 分钟) 状态: InProgress
等待中... (3 分钟) 状态: InProgress
等待中... (4 分钟) 状态: InProgress
等待中... (5 分钟) 状态: Completed

备份完成!
耗时: 00:05:23

===== 可用恢复点列表 =====
恢复点名称 备份时间 类型
------------------------------------------------------------------------------------------
12345678-abcd-ef01-2345-678901234567 2025-12-05 09:20:23 CrashConsistent
89012345-cdef-ab12-3456-789012345678 2025-12-04 02:00:15 AppConsistent
56789012-abcdef-1234-5678-901234567890 2025-12-03 02:00:08 AppConsistent

===== 最近 7 天备份作业汇总 =====
总作业数: 8
已完成: 7
失败: 1
进行中: 0

失败的作业详情:
- vm-prod-01 | 开始: 2025-12-01 02:00:00 | 错误: AzureBackupAgent Thaw failed

数据恢复与跨区域灾难恢复

当虚拟机发生故障或数据损坏时,需要从备份恢复点还原数据。Azure Backup 支持磁盘还原和跨区域还原两种模式。以下代码演示了从恢复点还原磁盘,以及在配对区域执行跨区域灾难恢复的完整流程。

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
# 定义变量
$vaultName = 'rsv-backup-demo'
$resourceGroup = 'rg-backup-demo'
$vmName = 'vm-prod-01'
$targetRg = 'rg-restore-demo'
$storageAccountName = 'sarestore001'
$storageContainerName = 'restore-output'

$vault = Get-AzRecoveryServicesVault -Name $vaultName -ResourceGroupName $resourceGroup

# 获取备份项和恢复点
$container = Get-AzRecoveryServicesBackupContainer `
-VaultId $vault.ID `
-ContainerType AzureVM `
-FriendlyName $vmName

$item = Get-AzRecoveryServicesBackupItem `
-VaultId $vault.ID `
-Container $container `
-WorkloadType AzureVM

$recoveryPoints = Get-AzRecoveryServicesBackupRecoveryPoint `
-VaultId $vault.ID `
-Item $item

# 选择最新的应用一致性恢复点
$appConsistentRp = $recoveryPoints |
Where-Object RecoveryPointType -eq 'AppConsistent' |
Sort-Object RecoveryPointTime -Descending |
Select-Object -First 1

if (-not $appConsistentRp) {
# 如果没有应用一致性恢复点,退而使用最新的恢复点
$appConsistentRp = $recoveryPoints |
Sort-Object RecoveryPointTime -Descending |
Select-Object -First 1
Write-Host "警告: 未找到应用一致性恢复点,使用最新恢复点"
}

Write-Host "已选择恢复点:"
Write-Host " 时间: $($appConsistentRp.RecoveryPointTime)"
Write-Host " 类型: $($appConsistentRp.RecoveryPointType)"

# 确保目标资源组存在
$targetRgObj = Get-AzResourceGroup -Name $targetRg -ErrorAction SilentlyContinue
if (-not $targetRgObj) {
$targetRgObj = New-AzResourceGroup -Name $targetRg -Location $vault.Location
Write-Host "已创建目标资源组: $targetRg"
}

# 确保目标存储账户存在
$storageAccount = Get-AzStorageAccount `
-Name $storageAccountName `
-ResourceGroupName $targetRg -ErrorAction SilentlyContinue

if (-not $storageAccount) {
$storageAccount = New-AzStorageAccount `
-Name $storageAccountName `
-ResourceGroupName $targetRg `
-Location $vault.Location `
-SkuName Standard_LRS `
-Kind StorageV2

Write-Host "已创建目标存储账户: $storageAccountName"
}

# 确保容器存在
New-AzStorageContainer `
-Context $storageAccount.Context `
-Name $storageContainerName `
-ErrorAction SilentlyContinue | Out-Null

# 还原虚拟机磁盘到目标存储账户
$restoreJob = Restore-AzRecoveryServicesBackupItem `
-VaultId $vault.ID `
-RecoveryPoint $appConsistentRp `
-StorageAccountName $storageAccountName `
-StorageAccountResourceGroupName $targetRg `
-TargetResourceGroupName $targetRg

Write-Host "`n磁盘还原作业已触发"
Write-Host " 作业名称: $($restoreJob.Name)"

# 等待还原完成
while ($restoreJob.Status -eq 'InProgress') {
Start-Sleep -Seconds 30
$restoreJob = Get-AzRecoveryServicesBackupJob `
-VaultId $vault.ID `
-Job $restoreJob
}

if ($restoreJob.Status -eq 'Completed') {
Write-Host "`n磁盘还原完成!"
$restoreDetails = Get-AzRecoveryServicesBackupJobDetail `
-VaultId $vault.ID `
-Job $restoreJob

Write-Host "还原磁盘列表:"
foreach ($disk in $restoreDetails.Properties.RestoredDiskList) {
Write-Host " - $($disk.DiskName) ($($disk.DiskType))"
}
} else {
Write-Host "`n还原失败,状态: $($restoreJob.Status)"
}

# 跨区域灾难恢复(仅适用于 GeoRedundant 存储冗余的保管库)
Write-Host "`n===== 跨区域灾难恢复 ====="

$vaultStorageConfig = Get-AzRecoveryServicesVaultProperty `
-VaultId $vault.ID

if ($vaultStorageConfig.BackupStorageRedundancy -eq 'GeoRedundant') {
Write-Host "保管库已启用异地冗余,支持跨区域还原"

# 获取配对区域的恢复点
$secondaryRegionRp = Get-AzRecoveryServicesBackupRecoveryPoint `
-VaultId $vault.ID `
-Item $item `
-SecondaryRegion

Write-Host "配对区域可用恢复点数: $($secondaryRegionRp.Count)"

if ($secondaryRegionRp.Count -gt 0) {
$crossRegionRp = $secondaryRegionRp |
Sort-Object RecoveryPointTime -Descending |
Select-Object -First 1

Write-Host "最新配对区域恢复点:"
Write-Host " 时间: $($crossRegionRp.RecoveryPointTime)"
Write-Host " 类型: $($crossRegionRp.RecoveryPointType)"

# 在配对区域执行还原
$pairedRegion = if ($vault.Location -eq 'eastasia') { 'southeastasia' } else { 'eastasia' }
$crossRestoreRg = "rg-dr-restore-$pairedRegion"

Write-Host "`n将在配对区域 $pairedRegion 执行跨区域还原..."
Write-Host "目标资源组: $crossRestoreRg"
Write-Host "(跨区域还原因需要数据传输,耗时可能较长)"
}
} else {
Write-Host "保管库存储冗余为 $($vaultStorageConfig.BackupStorageRedundancy)"
Write-Host "跨区域还原需要 GeoRedundant 存储冗余"
}

磁盘还原模式比完整 VM 还原更灵活,还原后的磁盘以 VHD 格式保存在指定的存储账户中,可以选择将其附加到现有虚拟机作为数据磁盘、用它创建新的虚拟机,或者仅提取特定文件。跨区域灾难恢复是 Azure Backup 的高级功能:当主区域发生全面故障时,可以从配对区域的备份副本还原数据。此功能要求保管库使用异地冗余(GRS)存储,并在配对区域有可用的恢复点。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
已选择恢复点:
时间: 2025-12-05 02:00:15
类型: AppConsistent
已创建目标资源组: rg-restore-demo
已创建目标存储账户: sarestore001

磁盘还原作业已触发
作业名称: b2c3d4e5-f6a7-8901-bcde-f12345678901

磁盘还原完成!
还原磁盘列表:
- vm-prod-01_OsDisk_1 (PremiumSSD)
- vm-prod-01_DataDisk_0 (PremiumSSD)

===== 跨区域灾难恢复 =====
保管库已启用异地冗余,支持跨区域还原
配对区域可用恢复点数: 3
最新配对区域恢复点:
时间: 2025-12-05 02:00:15
类型: AppConsistent

将在配对区域 southeastasia 执行跨区域还原...
目标资源组: rg-dr-restore-southeastasia
(跨区域还原因需要数据传输,耗时可能较长)

注意事项

  • 存储冗余不可逆:恢复服务保管库的存储冗余类型(LRS 或 GRS)必须在注册第一个备份项之前设置。一旦有备份项注册到保管库,就无法再更改冗余类型。对于生产环境,建议始终选择异地冗余(GRS)以获得跨区域容灾能力。
  • 软删除保护期:Azure Backup 默认启用了软删除功能,删除备份数据后会保留 14 天。在此期间数据仍可恢复,但保管库也无法彻底删除。如需立即清除(例如测试环境),需要先禁用软删除再执行删除操作。
  • 恢复点类型差异:崩溃一致性(CrashConsistent)恢复点等同于虚拟机突然断电时的状态,可能丢失内存中未写入磁盘的数据。应用一致性(AppConsistent)恢复点通过 VSS 等机制确保应用数据完整性,优先选择应用一致性恢复点进行还原。
  • 跨区域还原的前提条件:跨区域灾难恢复要求保管库使用 GRS 存储冗余,且并非所有区域对都支持跨区域还原。在制定灾备方案前,需先确认保管库所在区域的配对区域,并验证配对区域的恢复点是否可用。
  • 还原时的网络考量:磁盘还原会将 VHD 写入指定的存储账户,确保该存储账户与目标虚拟机在同一区域,否则会产生跨区域数据传输费用和额外的还原时间。建议还原时使用与源虚拟机同区域的临时存储账户。
  • RBAC 权限要求:执行备份和恢复操作需要至少”备份操作员”(Backup Operator)角色权限。还原虚拟机还需要目标资源组的”虚拟机参与者”(Virtual Machine Contributor)权限,以及目标存储账户的”存储账户贡献者”权限。建议为运维团队创建自定义角色,仅授予必要的备份和还原权限。

PowerShell 技能连载 - Pester 单元测试与 Mock

适用于 PowerShell 5.1 及以上版本

背景

在软件开发中,单元测试是保障代码质量的第一道防线。PowerShell 生态中最流行的测试框架是 Pester,它提供了一套简洁优雅的 DSL(领域特定语言),让你可以用 DescribeContextIt 三层结构组织测试用例,用 Should 断言验证预期结果。无论是简单的工具函数还是复杂的自动化脚本,Pester 都能为它们编写清晰可维护的测试。

然而,真实环境中的 PowerShell 脚本往往涉及大量外部依赖:调用 REST API、读写文件系统、查询数据库、操作 Windows 注册表。如果在测试中真正去调用这些外部资源,测试就会变得缓慢、脆弱、不可重复——API 服务可能宕机、文件路径可能不存在、数据库连接可能超时。Pester 的 Mock 机制正是为了解决这个问题,它允许你在测试中替换掉外部依赖,让测试专注于验证代码逻辑本身,而非依赖的可用性。

本文将从 Pester 的基础测试结构讲起,逐步介绍如何使用 Mock 隔离外部依赖,最后演示如何在 CI 流水线中集成测试覆盖率报告,形成完整的自动化测试闭环。

基础:Pester 测试结构与 Should 断言

Pester 测试文件通常以 .Tests.ps1 结尾,采用 Describe -> Context -> It 三层嵌套结构。Describe 描述被测功能模块,Context 划分测试场景,It 定义具体的测试用例。Should 是 Pester 的断言关键字,支持 BeBeNullOrEmptyThrowBeLike 等丰富的匹配器,覆盖了日常测试的绝大多数场景。

以下代码演示了一个完整的 Pester 基础测试,包含字符串处理函数、数组操作函数以及异常处理函数的测试用例。

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
# 被测函数:一组工具函数
function ConvertTo-TitleCase {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
return (Get-Culture).TextInfo.ToTitleCase($Text.ToLower())
}

function Get-Summary {
param([int[]]$Numbers)
if ($Numbers.Count -eq 0) { throw '数组不能为空' }
$sorted = $Numbers | Sort-Object
return @{
Min = $sorted[0]
Max = $sorted[-1]
Average = [math]::Round(($Numbers | Measure-Object -Average).Average, 2)
Sum = ($Numbers | Measure-Object -Sum).Sum
}
}

function Protect-SensitiveData {
param([string]$InputObject, [int]$VisibleChars = 4)
if ([string]::IsNullOrEmpty($InputObject)) {
throw [ArgumentNullException]::new('InputObject')
}
$masked = '*' * [math]::Max(0, $InputObject.Length - $VisibleChars)
$visible = $InputObject.Substring([math]::Max(0, $InputObject.Length - $VisibleChars))
return $masked + $visible
}

# Pester 测试文件:Utils.Tests.ps1
Describe 'ConvertTo-TitleCase' {
It '应将英文单词转为首字母大写' {
ConvertTo-TitleCase -Text 'hello world' | Should -Be 'Hello World'
}

It '应处理全大写输入' {
ConvertTo-TitleCase -Text 'POWERShell IS great' | Should -Be 'Powershell Is Great'
}

It '空字符串应返回空字符串' {
ConvertTo-TitleCase -Text '' | Should -Be ''
}

It '空白字符串应返回空字符串' {
ConvertTo-TitleCase -Text ' ' | Should -Be ''
}
}

Describe 'Get-Summary' {
Context '正常输入' {
It '应正确计算最小值、最大值、平均值和总和' {
$result = Get-Summary -Numbers @(3, 7, 1, 9, 5)

$result.Min | Should -Be 1
$result.Max | Should -Be 9
$result.Average | Should -Be 5
$result.Sum | Should -Be 25
}

It '应正确处理负数' {
$result = Get-Summary -Numbers @(-3, -1, -7)

$result.Min | Should -Be -7
$result.Max | Should -Be -1
$result.Sum | Should -Be -11
}
}

Context '边界情况' {
It '单个数字应返回相同的值' {
$result = Get-Summary -Numbers @(42)

$result.Min | Should -Be 42
$result.Max | Should -Be 42
$result.Average | Should -Be 42
}

It '空数组应抛出异常' {
{ Get-Summary -Numbers @() } | Should -Throw '数组不能为空'
}
}
}

Describe 'Protect-SensitiveData' {
It '应遮盖除最后四位以外的字符' {
Protect-SensitiveData -InputObject '1234567890' | Should -Be '******7890'
}

It '短于可见位数时应全部遮盖' {
Protect-SensitiveData -InputObject 'AB' -VisibleChars 4 | Should -Be '**AB'
}

It '空值应抛出 ArgumentNullException' {
{ Protect-SensitiveData -InputObject '' } |
Should -Throw -ExceptionType ([ArgumentNullException])
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Describing ConvertTo-TitleCase
[+] 应将英文单词转为首字母大写 28ms
[+] 应处理全大写输入 9ms
[+] 空字符串应返回空字符串 4ms
[+] 空白字符串应返回空字符串 3ms
Describing Get-Summary
Context 正常输入
[+] 应正确计算最小值、最大值、平均值和总和 31ms
[+] 应正确处理负数 12ms
Context 边界情况
[+] 单个数字应返回相同的值 8ms
[+] 空数组应抛出异常 14ms
Describing Protect-SensitiveData
[+] 应遮盖除最后四位以外的字符 11ms
[+] 短于可见位数时应全部遮盖 6ms
[+] 空值应抛出 ArgumentNullException 9ms
Tests passed: 9, Failed: 0, Skipped: 0, NotRun: 0

这段测试展示了 Pester 的基本用法。Should -Be 用于精确匹配,Should -Throw 用于验证异常,Should -Throw -ExceptionType 可以同时断言异常类型。Context 块将测试场景分组,使测试输出更具可读性。每个 It 块是一个独立的测试用例,测试之间互不干扰,即使某个测试失败也不会影响其他测试的执行。

进阶:使用 Mock 隔离外部依赖

真实脚本通常包含大量外部调用:读取配置文件、请求 Web API、操作数据库。在单元测试中直接调用这些外部资源会导致测试变慢且不稳定。Pester 的 Mock 命令可以在测试作用域内替换指定的 cmdlet 或函数,返回你预设的结果,从而将测试与外部环境完全解耦。

以下代码演示了如何 Mock Get-Content(文件读取)、Invoke-RestMethod(网络请求)和 Write-EventLog(事件日志)三种常见依赖。

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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# 被测函数:从配置文件和远程 API 获取系统状态并记录日志
function Get-SystemHealthStatus {
param(
[string]$ConfigPath = './config.json',
[string]$ApiEndpoint = 'https://api.example.com/health'
)

# 步骤 1: 读取本地配置文件
if (-not (Test-Path $ConfigPath)) {
Write-Warning "配置文件 $ConfigPath 不存在,使用默认值"
$config = @{ TimeoutSeconds = 30; RetryCount = 3 }
} else {
$raw = Get-Content -Path $ConfigPath -Raw
$config = $raw | ConvertFrom-Json
}

# 步骤 2: 请求远程健康检查 API
try {
$response = Invoke-RestMethod -Uri $ApiEndpoint -Method Get `
-TimeoutSec $config.TimeoutSeconds
} catch {
Write-EventLog -LogName 'Application' -Source 'HealthMonitor' `
-EntryType Error -EventId 1001 -Message "API 请求失败: $_"
return @{
Status = 'Error'
Message = "无法连接到 $ApiEndpoint"
Details = $_.Exception.Message
}
}

# 步骤 3: 根据响应判断健康状态
$isHealthy = $response.Status -eq 'Healthy' -and
$response.Uptime -gt 99.0

if ($isHealthy) {
Write-EventLog -LogName 'Application' -Source 'HealthMonitor' `
-EntryType Information -EventId 1000 `
-Message "系统状态正常,运行时间: $($response.Uptime)%"
}

return @{
Status = if ($isHealthy) { 'Healthy' } else { 'Degraded' }
Uptime = $response.Uptime
CheckedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# Pester 测试:使用 Mock 隔离文件、网络和日志依赖
Describe 'Get-SystemHealthStatus' {
BeforeAll {
# 导入被测函数(实际项目中通过 dot-sourcing 或模块导入)
# . ./Get-SystemHealthStatus.ps1
}

Context '配置文件存在且 API 返回健康状态' {
BeforeAll {
# Mock 文件系统
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 60, "RetryCount": 5}'
}

# Mock 网络 API
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 99.95
Version = '2.4.1'
}
}

# Mock 事件日志
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It '应返回 Healthy 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Healthy'
$result.Uptime | Should -Be 99.95
}

It '应从配置文件读取超时设置' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Get-Content -Times 1 -Exactly
}

It '应写入信息级别的事件日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 1 -Exactly `
-ParameterFilter { $EntryType -eq 'Information' }
}
}

Context '配置文件不存在时使用默认值' {
BeforeAll {
Mock Test-Path { $false }
Mock Get-Content { }
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 99.5
Version = '2.4.1'
}
}
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It '不应调用 Get-Content' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Get-Content -Times 0 -Exactly
}

It '仍应正常完成健康检查' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Healthy'
}
}

Context 'API 请求失败时的错误处理' {
BeforeAll {
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 10, "RetryCount": 1}'
}
Mock Invoke-RestMethod {
throw [System.Net.WebException]::new('连接超时')
}
Mock Write-EventLog { }
}

It '应返回 Error 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Error'
$result.Message | Should -BeLike '*无法连接到*'
}

It '应写入错误级别的事件日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 1 -Exactly `
-ParameterFilter { $EntryType -eq 'Error' -and $EventId -eq 1001 }
}

It '错误信息应包含异常详情' {
$result = Get-SystemHealthStatus

$result.Details | Should -BeLike '*连接超时*'
}
}

Context 'API 返回降级状态' {
BeforeAll {
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 30, "RetryCount": 3}'
}
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 95.0
Version = '2.4.1'
}
}
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It 'Uptime 低于阈值时应返回 Degraded 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Degraded'
$result.Uptime | Should -Be 95.0
}

It '不应写入信息级别的日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 0 -Exactly `
-ParameterFilter { $EntryType -eq 'Information' }
}
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Describing Get-SystemHealthStatus
Context 配置文件存在且 API 返回健康状态
[+] 应返回 Healthy 状态 24ms
[+] 应从配置文件读取超时设置 18ms
[+] 应写入信息级别的事件日志 12ms
Context 配置文件不存在时使用默认值
[+] 不应调用 Get-Content 9ms
[+] 仍应正常完成健康检查 15ms
Context API 请求失败时的错误处理
[+] 应返回 Error 状态 11ms
[+] 应写入错误级别的事件日志 8ms
[+] 错误信息应包含异常详情 7ms
Context API 返回降级状态
[+] Uptime 低于阈值时应返回 Degraded 状态 13ms
[+] 不应写入信息级别的日志 6ms
Tests passed: 10, Failed: 0, Skipped: 0, NotRun: 0

这段测试的核心价值在于:整个测试过程中没有真正的文件 I/O、网络请求或事件日志写入。Mock Get-Content 拦截了文件读取操作,返回预设的 JSON 字符串;Mock Invoke-RestMethod 模拟了 API 响应,可以自由控制返回值甚至抛出异常;Mock Write-EventLog 阻止了日志写入,同时可以通过 Should -Invoke 验证日志是否被正确调用。-ParameterFilter 参数进一步精确匹配调用参数,确保函数在正确的场景下调用了正确的日志级别和事件 ID。每个 Context 块定义独立的 Mock 行为,场景之间完全隔离,不会互相干扰。

实战:测试覆盖率与 CI 流水线集成

在团队协作中,仅编写测试是不够的,还需要量化测试质量并将其纳入持续集成(CI)流程。Pester 支持生成测试覆盖率报告(CodeCoverage)和 JUnit/XML 格式的测试结果,这些输出可以被 Azure DevOps、GitHub Actions、Jenkins 等 CI 平台直接解析和展示。以下代码演示了如何在 CI 脚本中配置 Pester 的覆盖率收集和结果输出。

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
# 文件:run-tests.ps1 —— CI 流水线中的 Pester 运行脚本
# 用法:./run-tests.ps1 -CodeCoverageThreshold 80

param(
[double]$CodeCoverageThreshold = 80,
[string]$TestPath = './tests',
[string]$SourcePath = './src',
[string]$OutputPath = './test-results'
)

# 确保输出目录存在
if (-not (Test-Path $OutputPath)) {
New-Item -Path $OutputPath -ItemType Directory | Out-Null
}

# 查找所有测试文件和源代码文件
$testFiles = Get-ChildItem -Path $TestPath -Filter '*.Tests.ps1' -Recurse
$sourceFiles = Get-ChildItem -Path $SourcePath -Filter '*.ps1' -Recurse |
Where-Object { $_.Name -notlike '*.Tests.ps1' }

Write-Host "发现 $($testFiles.Count) 个测试文件"
Write-Host "发现 $($sourceFiles.Count) 个源代码文件"
Write-Host "覆盖率阈值: $CodeCoverageThreshold%"

# 配置并执行 Pester 测试
$pesterConfig = New-PesterConfiguration

# 基本设置
$pesterConfig.Run.Path = $TestPath
$pesterConfig.Run.PassThru = $true
$pesterConfig.Run.Exit = $true

# 测试结果输出(JUnit XML 格式,CI 平台通用)
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = Join-Path $OutputPath 'test-results.xml'
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'

# 代码覆盖率设置
$pesterConfig.CodeCoverage.Enabled = $true
$pesterConfig.CodeCoverage.Path = $sourceFiles.FullName
$pesterConfig.CodeCoverage.OutputPath = Join-Path $OutputPath 'coverage.xml'
$pesterConfig.CodeCoverage.OutputFormat = 'JaCoCo'
$pesterConfig.CodeCoverage.CoveragePercentTarget = $CodeCoverageThreshold

# 输出设置
$pesterConfig.Output.Verbosity = 'Detailed'

# 执行测试
Write-Host "`n===== 开始执行 Pester 测试 =====`n"
$result = Invoke-Pester -Configuration $pesterConfig

# 输出汇总信息
Write-Host "`n===== 测试汇总 ====="
Write-Host "总测试数: $($result.TotalCount)"
Write-Host "通过: $($result.PassedCount)"
Write-Host "失败: $($result.FailedCount)"
Write-Host "跳过: $($result.SkippedCount)"

if ($result.CodeCoverage) {
$coveragePercent = [math]::Round($result.CodeCoverage.CoveragePercent, 2)
Write-Host "`n===== 覆盖率报告 ====="
Write-Host "已覆盖命令: $($result.CodeCoverage.CoveredCommands.Count)"
Write-Host "未覆盖命令: $($result.CodeCoverage.MissedCommands.Count)"
Write-Host "覆盖率: $coveragePercent%"
Write-Host "目标阈值: $CodeCoverageThreshold%"

if ($coveragePercent -ge $CodeCoverageThreshold) {
Write-Host '覆盖率检查: PASS' -ForegroundColor Green
} else {
Write-Host "覆盖率检查: FAIL (差 $($CodeCoverageThreshold - $coveragePercent) 个百分点)" `
-ForegroundColor Red
}

# 列出覆盖率最低的文件(帮助开发者优先补充测试)
Write-Host "`n===== 需要优先补充测试的文件 ====="
$missedByFile = $result.CodeCoverage.MissedCommands |
Group-Object -Property File |
Sort-Object Count -Descending |
Select-Object -First 5

foreach ($entry in $missedByFile) {
$missed = $entry.Count
$fileName = Split-Path $entry.Name -Leaf
Write-Host " $fileName - $missed 条未覆盖命令"
}
}

Write-Host "`n测试结果已输出到: $OutputPath"
Write-Host " test-results.xml (JUnit 格式,供 CI 平台解析)"
Write-Host " coverage.xml (JaCoCo 格式,供覆盖率工具分析)"

执行结果示例:

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
发现 12 个测试文件
发现 8 个源代码文件
覆盖率阈值: 80%

===== 开始执行 Pester 测试 =====

Describing Get-SystemHealthStatus
Context 配置文件存在且 API 返回健康状态
[+] 应返回 Healthy 状态 24ms
[+] 应从配置文件读取超时设置 18ms
[+] 应写入信息级别的事件日志 12ms
Context 配置文件不存在时使用默认值
[+] 不应调用 Get-Content 9ms
[+] 仍应正常完成健康检查 15ms
Context API 请求失败时的错误处理
[+] 应返回 Error 状态 11ms
[+] 应写入错误级别的事件日志 8ms
[+] 错误信息应包含异常详情 7ms
Context API 返回降级状态
[+] Uptime 低于阈值时应返回 Degraded 状态 13ms
[+] 不应写入信息级别的日志 6ms
Describing ConvertTo-TitleCase
[+] 应将英文单词转为首字母大写 28ms
[+] 应处理全大写输入 9ms
[+] 空字符串应返回空字符串 4ms
[+] 空白字符串应返回空字符串 3ms

===== 测试汇总 =====
总测试数: 14
通过: 14
失败: 0
跳过: 0

===== 覆盖率报告 =====
已覆盖命令: 87
未覆盖命令: 12
覆盖率: 87.88%
目标阈值: 80%
覆盖率检查: PASS

===== 需要优先补充测试的文件 =====
utils-network.ps1 - 5 条未覆盖命令
utils-logging.ps1 - 4 条未覆盖命令
utils-config.ps1 - 3 条未覆盖命令

测试结果已输出到: ./test-results
test-results.xml (JUnit 格式,供 CI 平台解析)
coverage.xml (JaCoCo 格式,供覆盖率工具分析)

这段脚本展示了完整的 CI 集成方案。New-PesterConfiguration 创建了一个可编程的配置对象,比命令行参数更灵活。CodeCoverage.Path 指定需要统计覆盖率的源代码文件(而非测试文件),CoveragePercentTarget 设定覆盖率阈值,低于该值时 Exit = $true 会使进程以非零退出码退出,从而让 CI 流水线标记为失败。测试结果以 JUnit XML 格式输出,这是几乎所有 CI 平台都支持的标准格式;覆盖率报告以 JaCoCo 格式输出,可以被 ReportGenerator 等工具转换为可读的 HTML 报告。脚本末尾还智能地列出了覆盖率最低的文件,帮助开发者有针对性地补充测试。

注意事项

  1. Mock 的作用域遵循层级隔离规则。在 Context A 中定义的 Mock 不会影响 Context B。子块可以覆盖父块的 Mock,离开子块后自动恢复父块的 Mock。利用这一特性可以在父 Describe 中定义通用 Mock,在子 Context 中按需覆盖特定行为,减少重复代码。

  2. Should -Invoke 的计数默认限定在当前 It 块所在作用域。Pester v5 中调用次数的统计范围是当前 ContextDescribe。如果需要在更大范围统计,使用 -Scope 参数(如 -Scope Describe)指定统计层级。不注意作用域可能导致断言次数与预期不符。

  3. 覆盖率统计只计算被测试实际执行的代码路径。100% 的命令覆盖率不等于 100% 的分支覆盖率。如果函数中有 if/else 分支但测试只覆盖了 if 分支,未覆盖的 else 分支中的命令会被标记为未覆盖。对于复杂条件逻辑,建议设计多个测试用例分别覆盖不同分支。

  4. 避免过度 Mock 导致测试失去实际意义。如果被测函数的每个内部调用都被 Mock 掉了,测试实际上只是在验证 Mock 框架本身。原则是只 Mock 外部边界(网络、文件、数据库),内部业务逻辑不应被 Mock。同时,过度使用 Should -Invoke 会让测试与实现细节强耦合,重构函数时测试会大面积失败。

  5. Pester v5 与 v4 的语法差异需要注意。v5 中 Assert-MockCalled 已被 Should -Invoke 取代,Assert-VerifiableMock 不再需要。Invoke-Pester 的参数也发生了重大变化,v5 推荐使用 New-PesterConfiguration 创建配置对象。如果项目中有遗留的 v4 测试代码,建议使用 Invoke-Pester -EnableExit 兼容模式逐步迁移。

  6. **CI 流水线中务必设置 Exit = $true**。这确保当测试失败或覆盖率不达标时,Pester 进程以非零退出码退出,CI 平台才能正确识别构建失败。否则即使所有测试都失败了,流水线仍会显示为绿色通过,失去持续集成的意义。同时建议将测试结果文件作为 CI 构建产物(artifact)归档,方便团队查看历史趋势。

PowerShell 技能连载 - Azure Bastion 远程连接

适用于 PowerShell 5.1 及以上版本

Azure Bastion 是微软在 Azure 平台上提供的全托管 PaaS 服务,它通过 SSL/TLS 隧道为虚拟机提供安全的 RDP 和 SSH 连接,无需在虚拟机上暴露公共 IP 地址或开放入站端口。在企业混合云环境中,Bastion 充当了”跳板机”的安全替代方案,所有远程会话流量都经过加密且不经过公网,从根本上降低了暴力破解和端口扫描的风险。

传统的远程连接方式要求运维人员先通过 VPN 或跳板机接入内网,再使用 RDP/SSH 客户端连接目标虚拟机。这种方式不仅配置复杂,而且跳板机本身也面临安全威胁。Azure Bastion 将这一流程简化为浏览器直连或 CLI 命令行操作,同时与企业目录服务(Entra ID)深度集成,支持条件访问策略和多因素认证(MFA)。

本文将介绍如何通过 PowerShell 和 Azure CLI 管理 Azure Bastion 资源,包括部署 Bastion 主机、建立远程会话、查看连接会话日志以及批量审计 Bastion 配置。每个场景都提供可执行的代码示例和输出演示。

部署 Azure Bastion 主机

Azure Bastion 需要部署在专门的子网(AzureBastionSubnet)中,并且要求关联一个 Standard SKU 的公共 IP 地址。以下代码演示了如何通过 PowerShell 在现有虚拟网络中部署 Bastion 主机,包括前置条件的检查和资源创建的完整流程。

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
# 定义变量
$resourceGroup = 'rg-bastion-demo'
$location = 'eastasia'
$vnetName = 'vnet-demo'
$bastionName = 'bastion-demo'
$publicIpName = 'pip-bastion-demo'

# 确保虚拟网络中存在 AzureBastionSubnet 子网
$vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $resourceGroup

$bastionSubnet = $vnet.Subnets | Where-Object { $_.Name -eq 'AzureBastionSubnet' }

if (-not $bastionSubnet) {
# Bastion 子网要求至少 /26 的地址空间
$subnetConfig = Add-AzVirtualNetworkSubnetConfig `
-Name 'AzureBastionSubnet' `
-AddressPrefix '10.0.1.0/26' `
-VirtualNetwork $vnet

$vnet | Set-AzVirtualNetwork | Out-Null
Write-Host "已创建 AzureBastionSubnet 子网"
} else {
Write-Host "AzureBastionSubnet 子网已存在,跳过创建"
}

# 创建 Standard SKU 公共 IP
$publicIp = New-AzPublicIpAddress `
-ResourceGroupName $resourceGroup `
-Name $publicIpName `
-Location $location `
-AllocationMethod Static `
-Sku Standard

Write-Host "公共 IP 已创建: $($publicIp.IpAddress)"

# 创建 Bastion 主机
$bastion = New-AzBastion `
-ResourceGroupName $resourceGroup `
-Name $bastionName `
-PublicIpAddressRgName $resourceGroup `
-PublicIpAddressName $publicIpName `
-VirtualNetworkRgName $resourceGroup `
-VirtualNetworkName $vnetName

Write-Host "Bastion 主机部署完成"
Write-Host "名称: $($bastion.Name)"
Write-Host "SKU: $($bastion.Sku.Text)"
Write-Host "状态: $($bastion.ProvisioningState)"

Azure Bastion 的子网名称必须是 AzureBastionSubnet,这是一个硬性要求,不能自定义。子网的最小地址空间为 /26(64 个地址),因为 Azure 会预留部分地址用于内部服务。Bastion 主机的 SKU 分为 Basic、Standard 和 Developer 三种,Standard 和 Developer SKU 支持自定义端口和原生 SSH 客户端连接。

执行结果示例:

1
2
3
4
5
6
AzureBastionSubnet 子网已存在,跳过创建
公共 IP 已创建: 20.205.83.47
Bastion 主机部署完成
名称: bastion-demo
SKU: Standard
状态: Succeeded

通过 Bastion 建立 SSH 远程会话

Standard 和 Developer SKU 的 Azure Bastion 支持原生 SSH 客户端连接,这意味着可以直接在 PowerShell 终端中发起 SSH 会话,无需打开浏览器。Azure CLI 提供了 az network bastion ssh 命令来实现这一功能,以下代码演示了如何封装调用逻辑并管理连接过程。

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
function Connect-BastionSsh {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroup,

[Parameter(Mandatory)]
[string]$BastionName,

[Parameter(Mandatory)]
[string]$VmName,

[ValidateSet('aad', 'password', 'ssh-key')]
[string]$AuthType = 'ssh-key',

[string]$Username = 'azureuser',

[string]$SshKeyPath = "$env:USERPROFILE\.ssh\id_rsa"
)

Write-Host "正在通过 Bastion 建立 SSH 连接..."
Write-Host " Bastion: $BastionName"
Write-Host " 目标 VM: $VmName"
Write-Host " 认证方式: $AuthType"

$commonArgs = @(
'network', 'bastion', 'ssh',
'--resource-group', $ResourceGroup,
'--name', $BastionName,
'--target-resource-id',
"/subscriptions/$(Get-AzContext).Subscription.Id/resourceGroups/$ResourceGroup/providers/Microsoft.Compute/virtualMachines/$VmName",
'--auth-type', $AuthType,
'--username', $Username
)

if ($AuthType -eq 'ssh-key') {
$commonArgs += @('--ssh-key', $SshKeyPath)
}

# 启动交互式 SSH 会话
& az @commonArgs
}

# 使用 SSH 密钥认证连接 Linux 虚拟机
Connect-BastionSsh `
-ResourceGroup 'rg-bastion-demo' `
-BastionName 'bastion-demo' `
-VmName 'vm-linux-01' `
-AuthType 'ssh-key' `
-Username 'azureuser'

这段代码封装了 Connect-BastionSsh 函数,支持三种认证方式:Entra ID(AAD)认证、密码认证和 SSH 密钥认证。函数内部通过 Azure CLI 的 az network bastion ssh 命令建立隧道,使用 & 调用运算符将控制权交给 SSH 客户端,用户可以在交互式会话中执行远程命令。对于 Windows 虚拟机,只需将 ssh 替换为 rdp 即可通过 RDP 协议连接。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
正在通过 Bastion 建立 SSH 连接...
Bastion: bastion-demo
目标 VM: vm-linux-01
认证方式: ssh-key
Opening SSH session to vm-linux-01 via Bastion bastion-demo...

Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-1019-azure x86_64)

Last login: Wed Dec 3 06:22:15 2025 from 10.0.1.4
azureuser@vm-linux-01:~$

查看 Bastion 会话日志与连接审计

Azure Bastion 的 Standard 和 Developer SKU 支持将远程会话日志导出到存储账户,这在安全合规场景中非常重要。以下代码演示了如何查询 Bastion 的活跃会话、导出会话日志以及生成连接审计报告。

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
function Get-BastionSessionReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroup,

[Parameter(Mandatory)]
[string]$BastionName
)

# 获取 Bastion 资源详情
$bastion = Get-AzBastion -ResourceGroupName $ResourceGroup -Name $BastionName

$report = [ordered]@{
BastionName = $bastion.Name
Location = $bastion.Location
Sku = $bastion.Sku.Text
Provisioning = $bastion.ProvisioningState
EnableTunneling = $bastion.EnableTunneling
EnableIpConnect = $bastion.EnableIpConnect
EnableShareableLink = $bastion.EnableShareableLink
DnsName = $bastion.DnsName
}

Write-Host "`n===== Bastion 配置报告 ====="
foreach ($key in $report.Keys) {
Write-Host ("{0,-25} : {1}" -f $key, $report[$key])
}

# 获取 Bastion 关联的虚拟网络信息
$vnetId = $bastion.IpConfigurations.Subnet.Id -replace '/subnets/.*$', ''
$vnetName = ($vnetId -split '/')[-1]
$vnetRg = (($vnetId -split '/')[4])

$vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $vnetRg
$bastionSubnet = $vnet.Subnets | Where-Object { $_.Name -eq 'AzureBastionSubnet' }

Write-Host "`n===== 网络配置 ====="
Write-Host ("{0,-25} : {1}" -f '关联虚拟网络', $vnetName)
Write-Host ("{0,-25} : {1}" -f '子网地址范围', $bastionSubnet.AddressPrefix)

# 列出同一虚拟网络中所有可连接的虚拟机
$connectedVms = @()
$allSubnets = $vnet.Subnets | Where-Object { $_.Name -ne 'AzureBastionSubnet' }

foreach ($subnet in $allSubnets) {
$nicIds = $subnet.IpConfigurations.Id | ForEach-Object {
($_ -split '/ipConfigurations/')[0]
} | Select-Object -Unique

foreach ($nicId in $nicIds) {
$nicName = ($nicId -split '/')[-1]
$nicRg = (($nicId -split '/')[4])
$nic = Get-AzNetworkInterface -Name $nicName -ResourceGroupName $nicRg -ErrorAction SilentlyContinue

if ($nic -and $nic.VirtualMachine) {
$vmName = ($nic.VirtualMachine.Id -split '/')[-1]
$vmRg = (($nic.VirtualMachine.Id -split '/')[4])
$vm = Get-AzVM -Name $vmName -ResourceGroupName $vmRg -ErrorAction SilentlyContinue

if ($vm) {
$osType = if ($vm.StorageProfile.OSDisk.OSType -eq 'Windows') { 'Windows/RDP' } else { 'Linux/SSH' }
$connectedVms += [PSCustomObject]@{
VmName = $vmName
OsType = $osType
Subnet = $subnet.Name
PrivateIp = ($nic.IpConfigurations | Where-Object { $_.PrivateIpAddress }).PrivateIpAddress
}
}
}
}
}

Write-Host "`n===== 可连接的虚拟机 ====="
Write-Host ("{0,-20} {1,-15} {2,-20} {3}" -f 'VM 名称', '操作系统/协议', '子网', '私有 IP')
Write-Host ('-' * 75)

foreach ($vm in $connectedVms) {
Write-Host ("{0,-20} {1,-15} {2,-20} {3}" -f $vm.VmName, $vm.OsType, $vm.Subnet, $vm.PrivateIp)
}

return $connectedVms
}

# 生成 Bastion 会话审计报告
Get-BastionSessionReport `
-ResourceGroup 'rg-bastion-demo' `
-BastionName 'bastion-demo'

这段代码通过 Get-AzBastion 获取 Bastion 主机的配置详情,然后遍历关联虚拟网络中除 AzureBastionSubnet 以外的所有子网,查找关联的虚拟机并列出其网络信息。通过审计报告可以快速了解哪些虚拟机可以通过 Bastion 访问,以及每台虚拟机的连接协议类型。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
===== Bastion 配置报告 =====
BastionName : bastion-demo
Location : eastasia
Sku : Standard
Provisioning : Succeeded
EnableTunneling : True
EnableIpConnect : True
EnableShareableLink : False
DnsName : bst-7a3b2c1d-0001.bastion.azure.com

===== 网络配置 =====
关联虚拟网络 : vnet-demo
子网地址范围 : 10.0.1.0/26

===== 可连接的虚拟机 =====
VM 名称 操作系统/协议 子网 私有 IP
---------------------------------------------------------------------------
vm-linux-01 Linux/SSH subnet-workload 10.0.0.4
vm-linux-02 Linux/SSH subnet-workload 10.0.0.5
vm-win-sql01 Windows/RDP subnet-database 10.0.2.4

批量审计多个 Bastion 实例的配置合规性

在大型企业环境中,可能有多个 Azure 订阅和资源组中部署了 Bastion 实例。安全团队需要定期审计所有 Bastion 实例的配置,确保符合企业安全基线。以下代码演示了如何批量检查所有 Bastion 实例的关键配置项。

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
function Test-BastionCompliance {
[CmdletBinding()]
param(
[string[]]$ResourceGroups
)

$results = @()

# 如果未指定资源组,扫描当前订阅中所有 Bastion 实例
if (-not $ResourceGroups) {
$allBastions = Get-AzBastion
} else {
$allBastions = @()
foreach ($rg in $ResourceGroups) {
$allBastions += Get-AzBastion -ResourceGroupName $rg -ErrorAction SilentlyContinue
}
}

Write-Host "发现 $($allBastions.Count) 个 Bastion 实例,开始合规性检查...`n"

foreach ($bastion in $allBastions) {
$compliance = [ordered]@{
Name = $bastion.Name
ResourceGroup = $bastion.ResourceGroupName
Location = $bastion.Location
Sku = $bastion.Sku.Text
}

# 检查项 1: SKU 是否为 Standard 或 Developer
$skuOk = $bastion.Sku.Text -in @('Standard', 'Developer')
$compliance['SKU 检查'] = if ($skuOk) { 'PASS' } else { 'WARN: Basic SKU' }

# 检查项 2: 是否启用隧道模式(支持原生客户端)
$tunnelOk = $bastion.EnableTunneling -eq $true
$compliance['原生客户端'] = if ($tunnelOk) { 'PASS' } else { 'WARN: 未启用' }

# 检查项 3: 是否启用 IP 连接(通过 IP 地址直连)
$ipConnectOk = $bastion.EnableIpConnect -eq $true
$compliance['IP 连接'] = if ($ipConnectOk) { 'PASS' } else { 'WARN: 未启用' }

# 检查项 4: 是否禁用可共享链接(安全考虑)
$shareOk = $bastion.EnableShareableLink -ne $true
$compliance['共享链接'] = if ($shareOk) { 'PASS' } else { 'WARN: 已启用' }

$results += [PSCustomObject]$compliance
}

# 输出汇总表格
Write-Host "===== Bastion 配置合规性报告 ====="
Write-Host "扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "实例总数: $($results.Count)`n"

foreach ($item in $results) {
Write-Host "--- $($item.Name) ($($item.ResourceGroup)) ---"
Write-Host " 位置: $($item.Location)"
Write-Host " SKU: $($item.Sku)"
Write-Host " SKU 检查: $($item.'SKU 检查')"
Write-Host " 原生客户端: $($item.'原生客户端')"
Write-Host " IP 连接: $($item.'IP 连接')"
Write-Host " 共享链接: $($item.'共享链接')"
Write-Host ""
}

# 统计合规率
$passCount = ($results | Where-Object {
$_.'SKU 检查' -eq 'PASS' -and
$_.'原生客户端' -eq 'PASS' -and
$_.'IP 连接' -eq 'PASS' -and
$_.'共享链接' -eq 'PASS'
}).Count

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

Write-Host "===== 汇总 ====="
Write-Host "完全合规实例: $passCount / $total"
Write-Host "合规率: $complianceRate%"

return $results
}

# 审计指定资源组中的 Bastion 实例
Test-BastionCompliance -ResourceGroups @('rg-bastion-demo', 'rg-prod-network')

这段代码定义了 Test-BastionCompliance 函数,对每个 Bastion 实例执行四项合规性检查:SKU 级别是否符合要求、是否启用原生客户端隧道、是否启用 IP 直连以及是否禁用了可共享链接。函数输出详细的逐项检查结果和整体合规率统计,方便安全团队快速定位不合规的实例。

执行结果示例:

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
发现 2 个 Bastion 实例,开始合规性检查...

===== Bastion 配置合规性报告 =====
扫描时间: 2025-12-03 10:30:00
实例总数: 2

--- bastion-demo (rg-bastion-demo) ---
位置: eastasia
SKU: Standard
SKU 检查: PASS
原生客户端: PASS
IP 连接: PASS
共享链接: PASS

--- bastion-prod (rg-prod-network) ---
位置: eastasia
SKU: Basic
SKU 检查: WARN: Basic SKU
原生客户端: WARN: 未启用
IP 连接: WARN: 未启用
共享链接: PASS

===== 汇总 =====
完全合规实例: 1 / 2
合规率: 50.0%

注意事项

  • 子网命名不可更改:Azure Bastion 要求子网名称必须严格为 AzureBastionSubnet,且最小地址空间为 /26。部署后不能重命名子网,如需调整必须删除并重新创建 Bastion 资源。
  • SKU 功能差异:Basic SKU 仅支持通过 Azure 门户浏览器连接,不支持原生 SSH 客户端、隧道模式和 IP 连接。建议生产环境使用 Standard 或 Developer SKU,以获得完整的 CLI 自动化能力。
  • 部署时间较长:Bastion 主机的创建通常需要 5-10 分钟,不要在自动化脚本中假设它是即时可用的。建议在部署后通过轮询 ProvisioningState 确认资源就绪再发起连接。
  • SSH 密钥路径兼容性:在 Windows 上默认 SSH 密钥路径为 $env:USERPROFILE\.ssh\id_rsa,在 Linux/macOS 上为 $env:HOME/.ssh/id_rsa。跨平台脚本中应使用 Join-Path 拼接路径,避免硬编码分隔符。
  • 会话超时与断连:Bastion 的浏览器会话有非活动超时限制(通常为 10-20 分钟),原生 SSH 客户端连接则遵循 SSH 协议自身的 ServerAliveInterval 设置。建议在 SSH 配置中添加 ServerAliveInterval 60 保持连接活跃。
  • 成本控制:Bastion 按小时计费(Standard SKU 约 $0.19/小时,各区域价格不同),无论是否有活跃会话。开发测试环境建议使用 Developer SKU(免费但限制并发连接数),或在不使用时删除 Bastion 资源以节省成本。

PowerShell 技能连载 - 流式输出与进度显示

适用于 PowerShell 5.1 及以上版本

背景

在执行长时间运行的 PowerShell 脚本时,用户最怕的不是等太久,而是不知道还要等多久。默认情况下,foreach 循环处理数百个对象时,控制台安静得仿佛脚本已经卡死。这种”黑盒体验”不仅让运维人员焦虑,也使得自动化流水线难以判断任务的实时进度。

PowerShell 提供了 Write-Progress cmdlet 来在控制台顶部显示进度条,配合 $ProgressPreference 变量可以精细控制进度显示的行为。与此同时,PowerShell 的管道流式输出机制允许你在处理数据的过程中逐条输出结果,而不必等到整个集合处理完毕。流式输出与进度显示相结合,就能构建出既高效又友好的脚本体验。

本文将从 Write-Progress 的基本用法出发,介绍 $ProgressPreference 的各种取值及效果,再通过实战案例演示如何在批量处理场景中同时实现流式输出和实时进度反馈。

基础:使用 Write-Progress 显示进度条

Write-Progress 是 PowerShell 内置的进度显示 cmdlet,它接受 -Activity(主标题)、-Status(当前状态文本)、-PercentComplete(百分比)和 -CurrentOperation(当前操作详情)等参数。下面通过一个模拟批量处理文件的场景来展示其基本用法。

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
# 模拟批量处理文件并显示进度条
$fileList = @(
"report-q1.xlsx"
"report-q2.xlsx"
"report-q3.xlsx"
"report-q4.xlsx"
"summary.docx"
"budget-2025.xlsx"
"forecast.csv"
"inventory.json"
"audit-trail.log"
"config-backup.xml"
)

$total = $fileList.Count
$index = 0

foreach ($file in $fileList) {
$index++
$percent = [math]::Floor(($index / $total) * 100)

Write-Progress -Activity "批量处理文件" `
-Status "正在处理 ($index / $total): $file" `
-PercentComplete $percent `
-CurrentOperation "校验文件完整性"

# 模拟处理耗时
Start-Sleep -Milliseconds 300

# 流式输出处理结果
$result = [PSCustomObject]@{
File = $file
Status = "已完成"
Size = "$(Get-Random -Minimum 10 -Maximum 9999) KB"
Timestamp = Get-Date -Format "HH:mm:ss"
}
Write-Output $result
}

# 关闭进度条
Write-Progress -Activity "批量处理文件" -Completed

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
File              Status  Size     Timestamp
---- ------ ---- ---------
report-q1.xlsx 已完成 2341 KB 09:15:32
report-q2.xlsx 已完成 1876 KB 09:15:32
report-q3.xlsx 已完成 3210 KB 09:15:33
report-q4.xlsx 已完成 987 KB 09:15:33
summary.docx 已完成 5432 KB 09:15:34
budget-2025.xlsx 已完成 1567 KB 09:15:34
forecast.csv 已完成 8234 KB 09:15:34
inventory.json 已完成 456 KB 09:15:35
audit-trail.log 已完成 7891 KB 09:15:35
config-backup.xml 已完成 123 KB 09:15:35

这段代码展示了 Write-Progress 的核心用法。-Activity 定义进度条的主标题,-Status 显示当前正在处理哪一项,-PercentComplete 控制进度条的填充比例。注意最后的 Write-Progress -Completed 调用——它会清除控制台顶部的进度条,否则进度条会一直停留在屏幕上。每个文件处理完后立即通过 Write-Output 输出结果对象,实现了”边处理边输出”的流式效果。

进阶:使用 $ProgressPreference 控制进度行为

$ProgressPreference 是一个全局偏好变量,决定了 PowerShell 如何处理 Write-Progress 调用。它有四个有效取值:Continue(默认,显示进度条)、SilentlyContinue(不显示但继续执行)、Ignore(完全忽略进度输出)和 Inquire(每次显示进度前暂停询问用户)。不同的取值在不同场景下各有用处。

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
# 展示 $ProgressPreference 四种取值的效果
$names = @("Alpha", "Beta", "Gamma", "Delta", "Epsilon")

function Invoke-HeavyTask {
param([string[]]$Items, [string]$Mode)

$prevPref = $ProgressPreference
$ProgressPreference = $Mode

$total = $Items.Count
$index = 0

foreach ($item in $Items) {
$index++
$percent = [math]::Floor(($index / $total) * 100)

Write-Progress -Activity "模式: $Mode" `
-Status "处理项: $item" `
-PercentComplete $percent

Start-Sleep -Milliseconds 200
Write-Output " [$Mode] 完成处理: $item"
}

Write-Progress -Activity "模式: $Mode" -Completed
$ProgressPreference = $prevPref
}

# 模式 1: Continue(默认行为,显示进度条)
Write-Output "=== Continue 模式(显示进度条)==="
Invoke-HeavyTask -Items $names -Mode "Continue"

# 模式 2: SilentlyContinue(静默执行,不显示进度条)
Write-Output ""
Write-Output "=== SilentlyContinue 模式(隐藏进度条)==="
Invoke-HeavyTask -Items $names -Mode "SilentlyContinue"

# 模式 3: 在 CI/CD 中临时关闭进度条以提升性能
Write-Output ""
Write-Output "=== 模拟 CI/CD 环境:批量安装模块 ===="
$modules = @("Pester", "PSScriptAnalyzer", "platyPS", "InvokeBuild")
$ProgressPreference = "SilentlyContinue"

foreach ($mod in $modules) {
Write-Output " 正在安装模块: $mod ... (模拟)"
Start-Sleep -Milliseconds 150
Write-Output " 完成: $mod"
}

$ProgressPreference = "Continue"

Write-Output ""
Write-Output "所有模式演示完毕"

执行结果示例:

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
=== Continue 模式(显示进度条)===
[Continue] 完成处理: Alpha
[Continue] 完成处理: Beta
[Continue] 完成处理: Gamma
[Continue] 完成处理: Delta
[Continue] 完成处理: Epsilon

=== SilentlyContinue 模式(隐藏进度条)===
[SilentlyContinue] 完成处理: Alpha
[SilentlyContinue] 完成处理: Beta
[SilentlyContinue] 完成处理: Gamma
[SilentlyContinue] 完成处理: Delta
[SilentlyContinue] 完成处理: Epsilon

=== 模拟 CI/CD 环境:批量安装模块 ====
正在安装模块: Pester ... (模拟)
完成: Pester
正在安装模块: PSScriptAnalyzer ... (模拟)
完成: PSScriptAnalyzer
正在安装模块: platyPS ... (模拟)
完成: platyPS
正在安装模块: InvokeBuild ... (模拟)
完成: InvokeBuild

所有模式演示完毕

这段代码的关键点在于函数内部先保存 $ProgressPreference 的原始值,执行完毕后再恢复。这种”保存-修改-恢复”模式可以避免偏好变量被永久修改后影响后续代码。SilentlyContinue 模式在 CI/CD 自动化场景中特别重要——Write-Progress 的控制台渲染本身有性能开销,在处理数千个对象时可能使脚本总耗时增加 20% 以上。关闭进度条可以显著提升批量操作的执行速度。

实战:带子进度和 ETA 的多层进度显示

当任务包含嵌套结构时(例如处理多个服务器,每台服务器上又有多个检查项),单层进度条无法清晰表达层次关系。Write-Progress 支持 -ParentId 参数来创建嵌套进度条,同时我们可以手动计算预估剩余时间(ETA),让进度信息更加完整。下面模拟一个多服务器安全巡检的场景。

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
# 多层嵌套进度条 + ETA 预估
$servers = @(
@{ Name = "WEB-01"; IP = "192.168.1.10" }
@{ Name = "WEB-02"; IP = "192.168.1.11" }
@{ Name = "DB-01"; IP = "192.168.1.20" }
@{ Name = "DB-02"; IP = "192.168.1.21" }
@{ Name = "CACHE-01"; IP = "192.168.1.30" }
)

$checks = @("端口扫描", "服务状态", "磁盘空间", "内存使用", "安全补丁")

$startTime = Get-Date
$serverIndex = 0
$totalServers = $servers.Count
$allResults = @()

foreach ($server in $servers) {
$serverIndex++
$serverPercent = [math]::Floor(($serverIndex / $totalServers) * 100)
$elapsed = (Get-Date) - $startTime
$avgPerServer = $elapsed.TotalSeconds / $serverIndex
$remaining = [math]::Ceiling($avgPerServer * ($totalServers - $serverIndex))
$eta = (Get-Date).AddSeconds($remaining).ToString("HH:mm:ss")

$statusText = "服务器 $serverIndex/$totalServers - 预计剩余 ${remaining}s (ETA: $eta)"

# 父进度条
Write-Progress -Id 1 -Activity "服务器安全巡检" `
-Status $statusText `
-PercentComplete $serverPercent

$checkIndex = 0
$totalChecks = $checks.Count
$serverHealthy = $true

foreach ($check in $checks) {
$checkIndex++
$checkPercent = [math]::Floor(($checkIndex / $totalChecks) * 100)

# 子进度条(ParentId = 1)
Write-Progress -Id 2 -ParentId 1 -Activity "检查: $check" `
-Status "$($server.Name) ($($server.IP)) - 步骤 $checkIndex/$totalChecks" `
-PercentComplete $checkPercent

# 模拟检查结果
$isPass = (Get-Random -Maximum 10) -gt 2
if (-not $isPass) {
$serverHealthy = $false
}

$result = [PSCustomObject]@{
Server = $server.Name
IP = $server.IP
Check = $check
Result = if ($isPass) { "通过" } else { "告警" }
Detail = if ($isPass) { "正常" } else { "需要关注" }
CheckTime = Get-Date -Format "HH:mm:ss"
}
$allResults += $result

Start-Sleep -Milliseconds 150
}

# 关闭子进度条
Write-Progress -Id 2 -ParentId 1 -Activity "检查完毕" -Completed
}

# 关闭父进度条
Write-Progress -Id 1 -Activity "服务器安全巡检" -Completed

# 输出汇总报告
Write-Output "==========================================="
Write-Output " 服务器安全巡检报告"
Write-Output " 扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "==========================================="
Write-Output ""

foreach ($result in $allResults) {
$mark = if ($result.Result -eq "通过") { "[OK]" } else { "[!!]" }
Write-Output " $mark $($result.Server) | $($result.Check) | $($result.Detail)"
}

Write-Output ""
$passCount = @($allResults | Where-Object { $_.Result -eq "通过" }).Count
$warnCount = @($allResults | Where-Object { $_.Result -eq "告警" }).Count
Write-Output "-------------------------------------------"
Write-Output " 总计: $($allResults.Count) 项检查"
Write-Output " 通过: $passCount 项"
Write-Output " 告警: $warnCount 项"
Write-Output " 通过率: $([math]::Floor($passCount / $allResults.Count * 100))%"
Write-Output "-------------------------------------------"

执行结果示例:

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
===========================================
服务器安全巡检报告
扫描时间: 2025-12-02 09:30:45
===========================================

[OK] WEB-01 | 端口扫描 | 正常
[OK] WEB-01 | 服务状态 | 正常
[OK] WEB-01 | 磁盘空间 | 正常
[OK] WEB-01 | 内存使用 | 正常
[OK] WEB-01 | 安全补丁 | 正常
[OK] WEB-02 | 端口扫描 | 正常
[!!] WEB-02 | 服务状态 | 需要关注
[OK] WEB-02 | 磁盘空间 | 正常
[OK] WEB-02 | 内存使用 | 正常
[OK] WEB-02 | 安全补丁 | 正常
[OK] DB-01 | 端口扫描 | 正常
[OK] DB-01 | 服务状态 | 正常
[!!] DB-01 | 磁盘空间 | 需要关注
[OK] DB-01 | 内存使用 | 正常
[!!] DB-01 | 安全补丁 | 需要关注
[OK] DB-02 | 端口扫描 | 正常
[OK] DB-02 | 服务状态 | 正常
[OK] DB-02 | 磁盘空间 | 正常
[OK] DB-02 | 内存使用 | 正常
[OK] DB-02 | 安全补丁 | 正常
[OK] CACHE-01 | 端口扫描 | 正常
[!!] CACHE-01 | 服务状态 | 需要关注
[OK] CACHE-01 | 磁盘空间 | 正常
[OK] CACHE-01 | 内存使用 | 正常
[OK] CACHE-01 | 安全补丁 | 正常

-------------------------------------------
总计: 25 项检查
通过: 21 项
告警: 4 项
通过率: 84%
-------------------------------------------

这段代码使用了 Write-Progress-Id-ParentId 参数来创建两层嵌套进度条。外层进度条(Id=1)显示服务器级别的整体进度和 ETA 预估,内层进度条(Id=2,ParentId=1)显示当前服务器上各项检查的进度。ETA 的计算基于已用时间和已完成数量的平均值:$avgPerServer = $elapsed.TotalSeconds / $serverIndex,再乘以剩余数量得到预估秒数。注意每个服务器处理完毕后必须用 -Completed 关闭子进度条,否则子进度条会叠加显示。

注意事项

  1. Write-Progress 在重定向输出时会被忽略。当脚本输出被重定向到文件(如 script.ps1 > output.txt)或在后台作业(Start-Job)中执行时,Write-Progress 调用不会产生任何可见效果,但调用本身仍然存在性能开销。在这些场景下,应主动将 $ProgressPreference 设为 SilentlyContinue 以避免无谓的进度条渲染。

  2. 进度条会显著降低大量迭代的性能Write-Progress 每次调用都会触发控制台 UI 更新,当循环次数达到数千甚至数万时,进度条本身的开销可能超过实际业务逻辑。建议在循环中加入计数器,每处理 N 条记录才更新一次进度条(例如每 100 条更新一次),而不是每条记录都调用 Write-Progress

  3. -ParentId 嵌套层级不宜过深。PowerShell 控制台最多支持两层进度条(父和子),PowerShell 7 的终端理论上支持更多层,但超过三层后 UI 会变得混乱且难以阅读。如果任务确实有多层结构,建议只在最外层和当前处理层显示进度,中间层级通过 -Status 文本信息来表达。

  4. $ProgressPreference 的作用域遵循 PowerShell 作用域规则。在函数内修改 $ProgressPreference 默认只影响当前函数作用域,不会传播到调用者。但如果在脚本顶层修改,则会影响该脚本内所有后续代码。最佳实践是在函数内采用”保存-修改-恢复”模式,在脚本开头统一设置则用 try/finally 确保异常时也能恢复原始值。

  5. Write-Progress -SecondsRemaining 参数可以替代手动 ETA 计算。除了手动计算预估时间外,Write-Progress 自带 -SecondsRemaining 参数,PowerShell 会将其显示在进度条右侧。但这个参数只是你传入的一个数值,PowerShell 不会自动计算——你仍然需要自己根据已用时间和已完成比例来推算剩余秒数。

  6. 在 VS Code 集成终端中进度条显示可能异常。VS Code 的 PowerShell 集成终端对 VT100 转义序列的支持有限,Write-Progress 可能表现为闪烁或不完整的进度条。在开发调试阶段,建议直接在 Windows Terminal 或 PowerShell ISE 中运行脚本以获得最佳进度条显示效果,或者将 $ProgressPreference 设为 SilentlyContinue 改用 Write-Host 输出简洁的文本进度信息。

PowerShell 技能连载 - Azure 容器实例管理

适用于 PowerShell 7.0 及以上版本

Azure Container Instances(ACI)是 Azure 提供的无服务器容器运行服务,无需预配虚拟机或配置 Kubernetes 集群,就能在云端快速启动容器。对于运维人员来说,这意味着可以用最低的基础设施开销来运行批处理任务、CI/CD 作业或临时服务。

在日常运维场景中,我们经常需要快速部署一个容器来验证功能、处理数据或提供临时 API。传统方式需要登录 Azure 门户、手动点击多项配置,效率低下且容易出错。通过 PowerShell 的 Az 模块,可以将这些操作全部自动化,从创建资源组到部署容器、再到监控和清理,一条流水线搞定。

本文将介绍如何使用 PowerShell 完成 ACI 的基础操作、安全部署和监控清理,帮助你建立一套可复用的容器管理自动化方案。

ACI 基础操作:创建资源组与容器组

第一步是准备 Azure 环境并创建容器实例。以下脚本演示了登录 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
# 登录 Azure 账户
Connect-AzAccount

# 定义变量
$ResourceGroupName = 'aci-demo-rg'
$Location = 'eastus'
$ContainerGroupName = 'demo-container-group'
$Image = 'mcr.microsoft.com/aci/helloworld'

# 创建资源组
$rg = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
Write-Host "资源组 '$($rg.ResourceGroupName)' 创建完成,位置:$($rg.Location)"

# 创建容器组
$containerGroup = New-AzContainerGroup `
-ResourceGroupName $ResourceGroupName `
-Name $ContainerGroupName `
-Image $Image `
-OsType 'Linux' `
-Cpu 1 `
-MemoryInGB 1 `
-Port 80 `
-IpAddressType 'Public'

Write-Host "容器组 '$ContainerGroupName' 已创建"

# 查询容器组状态
$status = Get-AzContainerGroup `
-ResourceGroupName $ResourceGroupName `
-Name $ContainerGroupName

Write-Host "容器状态:$($status.ProvisioningState)"
Write-Host "公共 IP:$($status.IpAddress)"
Write-Host "FQDN:$($status.Fqdn)"

执行后,你将看到类似如下的输出:

1
2
3
4
5
资源组 'aci-demo-rg' 创建完成,位置:eastus
容器组 'demo-container-group' 已创建
容器状态:Succeeded
公共 IP:20.51.100.42
FQDN:demo-container-group.eastus.azurecontainer.io

创建成功后,可以通过返回的公共 IP 或 FQDN 直接访问容器内运行的服务。Get-AzContainerGroup 命令可以随时查询容器的最新状态,方便集成到监控脚本中。

容器部署:环境变量与密钥管理

实际生产中,容器通常需要配置环境变量和敏感信息。ACI 支持通过安全挂载 Azure Key Vault 中的密钥,避免在代码或命令行中暴露凭据。

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
# 定义安全引用变量
$ResourceGroupName = 'aci-demo-rg'
$ContainerGroupName = 'secure-app-group'

# 创建 Key Vault(如果不存在)
$VaultName = 'aci-demo-kv'
$kv = Get-AzKeyVault -VaultName $VaultName -ErrorAction SilentlyContinue
if (-not $kv) {
$kv = New-AzKeyVault `
-VaultName $VaultName `
-ResourceGroupName $ResourceGroupName `
-Location 'eastus'
Write-Host "Key Vault '$VaultName' 已创建"
}

# 向 Key Vault 添加数据库连接字符串
$secretValue = ConvertTo-SecureString `
-String 'Server=db.example.com;Database=appdb;User=appuser;Pwd=Str0ngP@ss!' `
-AsPlainText -Force

Set-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'DatabaseConnectionString' `
-SecretValue $secretValue

Write-Host "密钥已写入 Key Vault"

# 使用环境变量部署容器
$envVars = @(
@{ Name = 'APP_ENV'; Value = 'production' }
@{ Name = 'LOG_LEVEL'; Value = 'info' }
@{ Name = 'WORKERS'; Value = '4' }
)

$containerGroup = New-AzContainerGroup `
-ResourceGroupName $ResourceGroupName `
-Name $ContainerGroupName `
-Image 'nginx:latest' `
-OsType 'Linux' `
-Cpu 1 `
-MemoryInGB 1.5 `
-Port 80 `
-IpAddressType 'Public' `
-EnvironmentVariable $envVars

Write-Host "容器 '$ContainerGroupName' 已部署,含环境变量配置"

执行结果示例:

1
2
3
Key Vault 'aci-demo-kv' 已创建
密钥已写入 Key Vault
容器 'secure-app-group' 已部署,含环境变量配置

通过 Key Vault 管理敏感信息是 Azure 的最佳实践。环境变量适合传入非敏感的配置项(如运行模式、日志级别),而数据库密码、API 密钥等则应存储在 Key Vault 中,通过托管标识或安全引用注入容器,确保凭据不会出现在脚本或日志中。

容器监控、日志收集与自动清理

容器的生命周期管理同样重要。以下脚本演示了如何获取容器日志、检查资源使用情况,以及在任务完成后自动清理资源。

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
# 监控和清理变量
$ResourceGroupName = 'aci-demo-rg'
$ContainerGroupName = 'demo-container-group'

# 获取容器日志(最近 50 行)
$logs = Get-AzContainerInstanceLog `
-ResourceGroupName $ResourceGroupName `
-ContainerGroupName $ContainerGroupName `
-ContainerName $ContainerGroupName `
-Tail 50

Write-Host "=== 容器日志(最近 50 行)==="
Write-Host $logs

# 列出资源组中所有容器组的状态
$allContainers = Get-AzContainerGroup -ResourceGroupName $ResourceGroupName

foreach ($cg in $allContainers) {
$startTime = $cg.Containers[0].InstanceView.CurrentState.StartTime
Write-Host ("容器组:{0,-30} 状态:{1,-12} IP:{2}" -f `
$cg.Name, `
$cg.ProvisioningState, `
$cg.IpAddress)
}

# 自动清理:删除运行超过 24 小时的容器组
$cutoff = (Get-Date).AddHours(-24)

foreach ($cg in $allContainers) {
$startTime = $cg.Containers[0].InstanceView.CurrentState.StartTime
if ($startTime -and $startTime -lt $cutoff) {
Write-Host "清理过期容器组:$($cg.Name)(启动于 $startTime)"
Remove-AzContainerGroup `
-ResourceGroupName $ResourceGroupName `
-Name $cg.Name `
-Confirm:$false
}
}

Write-Host "`n清理完成。剩余容器组:"
$remaining = Get-AzContainerGroup -ResourceGroupName $ResourceGroupName
$remaining | ForEach-Object { Write-Host " - $($_.Name)" }

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
=== 容器日志(最近 50 行)===
Starting web server on port 80...
Serving content from /usr/local/apache2/htdocs/
GET / 200 15ms
GET /favicon.ico 404 2ms

容器组:demo-container-group 状态:Succeeded IP:20.51.100.42
容器组:secure-app-group 状态:Succeeded IP:20.51.100.88
清理过期容器组:demo-container-group(启动于 2025-11-30 06:30:00)

清理完成。剩余容器组:
- secure-app-group

自动清理机制可以有效控制成本。ACI 按容器运行时间计费,如果不及时清理临时容器,费用会持续累积。建议将清理脚本配置为定时任务(如 Azure Automation Runbook),每天自动扫描并回收超期的容器实例。

注意事项

  1. 权限要求:执行 ACI 操作需要 Azure 订阅的 Contributor 角色权限,建议创建专用服务主体(Service Principal)并授予最小权限,避免在日常脚本中使用管理员账户。

  2. 区域可用性:ACI 支持的 Azure 区域和 SKU 类型有限,部署前请确认目标区域是否支持你需要的特性(如 GPU 容器、虚拟网络集成等)。

  3. 资源配额限制:每个订阅对 ACI 有默认配额限制(如 CPU 核心数、容器组数量),大规模部署前可通过 Get-AzVMUsage 检查当前使用量,必要时提交配额提升申请。

  4. 密钥轮换:Key Vault 中的密钥应建立定期轮换机制,可结合 Azure Key Vault 的自动轮换策略,避免长期使用同一套凭据。

  5. 日志持久化:ACI 容器重启后本地日志会丢失,生产环境应将日志输出到 Azure Log Analytics 或外部存储,通过容器环境变量配置日志转发地址。

  6. 成本控制:ACI 按秒计费,适合短期任务和突发负载。如果容器需要长期运行(超过 1 周),建议迁移到 Azure Kubernetes Service(AKS),综合成本更低。

PowerShell 技能连载 - 动态参数

适用于 PowerShell 5.1 及以上版本

背景

在编写 PowerShell 高级函数时,我们通常使用 param() 块声明静态参数。这些参数在函数定义时就已确定,无论调用时传入什么值,参数集合始终不变。然而,有些场景需要根据运行时条件动态地添加参数——例如根据用户选择的路径类型显示不同的验证集,或者仅在指定了某个开关参数后才暴露额外的配置选项。

PowerShell 提供了”动态参数”(Dynamic Parameters)机制来满足这一需求。动态参数是通过实现 IDynamicParameters 接口或使用 <DynamicParam> 块来定义的,它们在运行时根据函数内其他参数的值决定是否出现。这意味着用户在 Tab 补全时只会看到当前上下文中有效的参数,从而获得更精准的智能提示。

本文将从基础到进阶,逐步介绍如何在 PowerShell 中创建和使用动态参数,涵盖 [DynamicParam()] 块的写法、RuntimeDefinedParameter 对象的构造、参数验证属性的添加,以及在高级函数中结合 $PSBoundParameters 判断上下文的实战技巧。

基础:第一个动态参数

动态参数的核心是 [DynamicParam()] 块。在这个块中,我们使用 RuntimeDefinedParameterDictionaryRuntimeDefinedParameter 来构造参数。下面是一个最简单的例子:当用户指定了 -Path 参数后,动态暴露一个 -Encoding 参数供选择。

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
function Get-FileContent {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)

# 定义动态参数块
DynamicParam {
# 创建参数字典
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()

# 只有当 Path 被指定时,才添加 Encoding 参数
if ($PSBoundParameters.ContainsKey('Path')) {
# 定义验证集
$validateSet = [System.Management.Automation.ValidateSetAttribute]::new(
@('UTF8', 'ASCII', 'Unicode', 'UTF32')
)

# 创建参数属性集合
$attributes = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attributes.Add([System.Management.Automation.ParameterAttribute]::new())
$attributes.Add($validateSet)

# 构造 RuntimeDefinedParameter
$encodingParam = [System.Management.Automation.RuntimeDefinedParameter]::new(
'Encoding',
[string],
$attributes
)

$paramDictionary.Add('Encoding', $encodingParam)
}

return $paramDictionary
}

Process {
# 从动态参数中取值
$encoding = if ($PSBoundParameters.ContainsKey('Encoding')) {
$PSBoundParameters['Encoding']
} else {
'UTF8'
}

Write-Output "正在以 $encoding 编码读取文件: $Path"

if (Test-Path -Path $Path) {
$content = Get-Content -Path $Path -Encoding $encoding -Raw
Write-Output "文件大小: $($content.Length) 字符"
} else {
Write-Warning "文件不存在: $Path"
}
}
}

# 调用示例
Get-FileContent -Path "C:\temp\test.txt" -Encoding UTF8

执行结果示例:

1
2
正在以 UTF8 编码读取文件: C:\temp\test.txt
文件大小: 2048 字符

这段代码的关键在于 DynamicParam 块。它通过 $PSBoundParameters 检查 -Path 是否已被赋值,只有在确认后才创建 -Encoding 参数。RuntimeDefinedParameter 的构造函数接收三个参数:参数名、类型和属性集合。我们为它添加了 ParameterAttribute(使其成为函数参数)和 ValidateSetAttribute(限制可选值)。在 Process 块中,同样通过 $PSBoundParameters 来获取动态参数的值。

进阶:根据参数值动态生成验证集

静态的 ValidateSet 只能硬编码可选值,而动态参数的真正威力在于可以根据运行时数据生成验证集。下面的例子演示了:当用户输入一个目录路径后,动态参数会扫描该目录下的子文件夹,并将其作为 -SubFolder 参数的可选值。

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
function Get-ProjectArtifact {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProjectRoot
)

DynamicParam {
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()

# 获取 ProjectRoot 的值
$projectRootValue = $PSBoundParameters['ProjectRoot']

if ($projectRootValue -and (Test-Path -Path $projectRootValue -PathType Container)) {
# 扫描目录下的子文件夹
$subFolders = @(Get-ChildItem -Path $projectRootValue -Directory -ErrorAction SilentlyContinue)

$folderNames = @()
foreach ($folder in $subFolders) {
$folderNames += $folder.Name
}

if ($folderNames.Count -gt 0) {
# 用扫描结果构造验证集
$validateSet = [System.Management.Automation.ValidateSetAttribute]::new($folderNames)

$attributes = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$paramAttr = [System.Management.Automation.ParameterAttribute]::new()
$paramAttr.Mandatory = $true
$paramAttr.HelpMessage = "选择项目中的子目录"
$attributes.Add($paramAttr)
$attributes.Add($validateSet)

$subFolderParam = [System.Management.Automation.RuntimeDefinedParameter]::new(
'SubFolder',
[string],
$attributes
)

$paramDictionary.Add('SubFolder', $subFolderParam)
}
}

return $paramDictionary
}

Process {
$subFolder = $PSBoundParameters['SubFolder']
$targetPath = Join-Path -Path $ProjectRoot -ChildPath $subFolder

Write-Output "项目根目录: $ProjectRoot"
Write-Output "选择子目录: $subFolder"
Write-Output "完整路径: $targetPath"
Write-Output ""

$files = @(Get-ChildItem -Path $targetPath -File -ErrorAction SilentlyContinue)
Write-Output "该目录包含 $($files.Count) 个文件:"

foreach ($file in $files) {
$sizeKB = [math]::Round($file.Length / 1KB, 2)
Write-Output (" {0,-40} {1,10} KB" -f $file.Name, $sizeKB)
}
}
}

# 调用示例(假设 C:\MyProject 下有 src、docs、tests 子目录)
Get-ProjectArtifact -ProjectRoot "C:\MyProject" -SubFolder src

执行结果示例:

1
2
3
4
5
6
7
8
9
10
项目根目录: C:\MyProject
选择子目录: src
完整路径: C:\MyProject\src

该目录包含 5 个文件:
index.ts 2.34 KB
utils.ts 1.87 KB
config.json 0.52 KB
main.ts 4.15 KB
types.d.ts 0.98 KB

这个例子展示了动态参数的核心优势——Tab 补全会根据实际文件系统状态提供候选项。当你输入 -SubFolder 后按 Tab,PowerShell 会自动列出 srcdocstests 等实际存在的目录名。代码中通过 Get-ChildItem -Directory 获取子目录列表,再将名称数组传给 ValidateSetAttribute,实现运行时验证集的动态生成。注意 Mandatory = $true 的设置方式——通过操作 ParameterAttribute 对象的属性来控制参数行为。

实战:构建条件化的动态参数集

在实际开发中,我们经常需要根据多个条件组合来决定暴露哪些参数。例如,一个数据导出工具可能需要根据 -Format 参数的值(CSV、JSON、XML)来动态显示不同的配置选项。下面演示如何构建多条件联动的动态参数集。

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
function Export-DataReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('CSV', 'JSON', 'XML')]
[string]$Format,

[Parameter(Mandatory)]
[string]$OutputPath
)

DynamicParam {
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$formatValue = $PSBoundParameters['Format']

# 创建动态参数的辅助函数
function Add-DynamicParam {
param(
[string]$Name,
[type]$Type,
[System.Collections.ObjectModel.Collection[System.Attribute]]$Attributes
)
$rp = [System.Management.Automation.RuntimeDefinedParameter]::new($Name, $Type, $Attributes)
$paramDictionary.Add($Name, $rp)
}

function New-ParamAttributes {
param([bool]$Mandatory = $false, [string]$HelpMessage = '')
$attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$p = [System.Management.Automation.ParameterAttribute]::new()
$p.Mandatory = $Mandatory
if ($HelpMessage) { $p.HelpMessage = $HelpMessage }
$attrs.Add($p)
return $attrs
}

# CSV 格式的专有参数
if ($formatValue -eq 'CSV') {
$csvAttrs = New-ParamAttributes -Mandatory $false -HelpMessage 'CSV 分隔符'
$csvAttrs.Add([System.Management.Automation.ValidateSetAttribute]::new(@(',', ';', '`t', '|')))
Add-DynamicParam -Name 'Delimiter' -Type [string] -Attributes $csvAttrs

$noHeaderAttrs = New-ParamAttributes -HelpMessage '是否包含表头'
Add-DynamicParam -Name 'NoHeader' -Type [switch] -Attributes $noHeaderAttrs
}

# JSON 格式的专有参数
if ($formatValue -eq 'JSON') {
$indentAttrs = New-ParamAttributes -HelpMessage 'JSON 缩进层级'
$indentAttrs.Add([System.Management.Automation.ValidateRangeAttribute]::new(0, 8))
Add-DynamicParam -Name 'Indent' -Type [int] -Attributes $indentAttrs

$compressAttrs = New-ParamAttributes -HelpMessage '压缩输出'
Add-DynamicParam -Name 'Compress' -Type [switch] -Attributes $compressAttrs
}

# XML 格式的专有参数
if ($formatValue -eq 'XML') {
$rootAttrs = New-ParamAttributes -Mandatory $false -HelpMessage 'XML 根节点名称'
Add-DynamicParam -Name 'RootName' -Type [string] -Attributes $rootAttrs

$indentXmlAttrs = New-ParamAttributes -HelpMessage 'XML 缩进'
Add-DynamicParam -Name 'IndentXml' -Type [switch] -Attributes $indentXmlAttrs
}

return $paramDictionary
}

Process {
# 构造模拟数据
$data = @(
[PSCustomObject]@{ Name = '服务器A'; IP = '192.168.1.10'; Status = '运行中'; CPU = '45%' }
[PSCustomObject]@{ Name = '服务器B'; IP = '192.168.1.11'; Status = '已停止'; CPU = '0%' }
[PSCustomObject]@{ Name = '服务器C'; IP = '192.168.1.12'; Status = '运行中'; CPU = '78%' }
)

Write-Output "导出格式: $Format"
Write-Output "输出路径: $OutputPath"

# 获取所有动态参数的值
$delimiter = if ($PSBoundParameters.ContainsKey('Delimiter')) { $PSBoundParameters['Delimiter'] } else { ',' }
$noHeader = $PSBoundParameters.ContainsKey('NoHeader') -and $PSBoundParameters['NoHeader'].IsPresent
$indent = if ($PSBoundParameters.ContainsKey('Indent')) { $PSBoundParameters['Indent'] } else { 2 }
$compress = $PSBoundParameters.ContainsKey('Compress') -and $PSBoundParameters['Compress'].IsPresent
$rootName = if ($PSBoundParameters.ContainsKey('RootName')) { $PSBoundParameters['RootName'] } else { 'Data' }
$indentXml = $PSBoundParameters.ContainsKey('IndentXml') -and $PSBoundParameters['IndentXml'].IsPresent

switch ($Format) {
'CSV' {
Write-Output "分隔符: $delimiter"
Write-Output "包含表头: $(-not $noHeader)"
Write-Output ""
$csvLines = @()
if (-not $noHeader) {
$csvLines += ($data[0].PSObject.Properties.Name -join $delimiter)
}
foreach ($item in $data) {
$values = @()
foreach ($prop in $item.PSObject.Properties) {
$values += $prop.Value
}
$csvLines += ($values -join $delimiter)
}
$csvLines | Out-String | Write-Output
}
'JSON' {
Write-Output "缩进层级: $indent"
Write-Output "压缩模式: $compress"
Write-Output ""
$jsonDepth = 5
if ($compress) {
$data | ConvertTo-Json -Compress -Depth $jsonDepth | Write-Output
} else {
$data | ConvertTo-Json -Depth $jsonDepth | Write-Output
}
}
'XML' {
Write-Output "根节点: $rootName"
Write-Output "缩进: $indentXml"
Write-Output ""
foreach ($item in $data) {
$line = "<Item"
foreach ($prop in $item.PSObject.Properties) {
$line += " $($prop.Name)=`"$($prop.Value)`""
}
$line += " />"
Write-Output $line
}
}
}

Write-Output ""
Write-Output "数据已准备完毕,共 $($data.Count) 条记录"
}
}

# 调用示例:CSV 格式
Export-DataReport -Format CSV -OutputPath "C:\Reports\servers.csv" -Delimiter ','

Write-Output "`n" + ("=" * 60) + "`n"

# 调用示例:JSON 格式
Export-DataReport -Format JSON -OutputPath "C:\Reports\servers.json" -Indent 4 -Compress:$false

执行结果示例:

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
导出格式: CSV
输出路径: C:\Reports\servers.csv
分隔符: ,
包含表头: True

Name,IP,Status,CPU
服务器A,192.168.1.10,运行中,45%
服务器B,192.168.1.11,已停止,0%
服务器C,192.168.1.12,运行中,78%

数据已准备完毕,共 3 条记录


============================================================

导出格式: JSON
输出路径: C:\Reports\servers.json
缩进层级: 4
压缩模式: False

[
{
"Name": "服务器A",
"IP": "192.168.1.10",
"Status": "运行中",
"CPU": "45%"
},
{
"Name": "服务器B",
"IP": "192.168.1.11",
"Status": "已停止",
"CPU": "0%"
},
{
"Name": "服务器C",
"IP": "192.168.1.12",
"Status": "运行中",
"CPU": "78%"
}
]

数据已准备完毕,共 3 条记录

这段代码根据 $Format 的值动态注册不同的参数组。CSV 格式下出现 -Delimiter-NoHeader;JSON 格式下出现 -Indent-Compress;XML 格式下出现 -RootName-IndentXml。为了提高代码可读性,我们在 DynamicParam 块内定义了两个辅助函数 Add-DynamicParamNew-ParamAttributes,将样板代码封装起来。在 Process 块中,通过 $PSBoundParameters.ContainsKey() 逐一检查每个动态参数是否被传入,并提供合理的默认值。注意 switch 类型的动态参数需要通过 .IsPresent 来判断。

注意事项

  1. $PSBoundParameters 在 DynamicParam 块中是只读快照DynamicParam 块执行时,PowerShell 尚未完成所有参数的绑定,因此 $PSBoundParameters 只包含在该时刻已经绑定的参数。如果函数有多个参数且存在依赖关系,务必考虑参数解析的顺序——位置参数先于命名参数被绑定。

  2. 动态参数不会出现在 Get-Help 的输出中。由于动态参数在运行时才生成,Get-Help 无法静态分析到它们。如果需要让用户了解动态参数的存在,应在函数的 .EXTERNALHELP 或基于注释的帮助中手动记录,或者在 ParameterAttributeHelpMessage 属性中写清楚说明文字。

  3. ValidateSet 的候选项不宜过多。当动态参数基于文件系统或数据库查询生成验证集时,如果候选项数量达到数百甚至数千,Tab 补全体验会变得很差,验证集的构造也会带来性能开销。建议对候选项数量设置上限,或者改用 ArgumentCompleter 进行异步补全。

  4. 动态参数的类型必须精确匹配RuntimeDefinedParameter 的第二个参数是参数的 .NET 类型。如果你希望接受数组输入,应使用 [string[]] 而非 [string];如果是开关参数,必须使用 [switch] 类型,否则参数绑定器无法正确解析 -ParamName 这种无值的写法。

  5. **在 BeginProcessEnd 块中访问动态参数需要通过 $PSBoundParameters**。动态参数不会像静态参数那样自动赋值给同名变量。你需要手动从 $PSBoundParameters 字典中提取值,或者使用 $MyInvocation.MyCommand.Parameters 来遍历所有参数(包括动态参数)的元数据。

  6. **调试动态参数时善用 Get-Command -Syntax**。在控制台中输入 Get-Command Export-DataReport -Syntax 可以看到当前函数的完整参数签名,包括动态参数。但由于动态参数依赖于运行时状态,你无法在不提供前置参数的情况下看到动态参数。因此,在开发阶段,建议在 DynamicParam 块中加入 Write-Debug 语句,通过 -Debug 开关观察参数字典的构建过程。

PowerShell 技能连载 - ARM 模板部署

适用于 PowerShell 5.1 及以上版本

Azure Resource Manager(ARM)模板是微软 Azure 平台原生的基础设施即代码(Infrastructure as Code,IaC)解决方案。通过 JSON 格式声明式地定义云资源,团队可以在版本控制系统中追踪每一次基础设施变更,实现与应用代码同等严谨的发布流程。

在实际运维中,手动点击 Azure 门户创建资源既容易出错,也难以在多环境间保持一致。ARM 模板配合 PowerShell 的 Az 模块,能够一键完成从资源组、虚拟网络到虚拟机的完整环境搭建,并且天然支持幂等部署——无论执行多少次,最终状态始终一致。

本文将从零开始演示如何用 PowerShell 编写、参数化和部署 ARM 模板,涵盖模板验证、增量部署以及多环境参数管理等常见场景。

基础:部署一个简单的 ARM 模板

我们先从最简单的存储账户部署开始。ARM 模板是一个 JSON 文件,包含 $schemacontentVersionresources 等固定节。下面是一个最小化的存储账户模板和对应的参数文件。

首先创建模板文件:

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
$templateJson = @'
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string",
"metadata": {
"description": "存储账户名称,全局唯一"
}
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]",
"metadata": {
"description": "资源位置,默认为资源组所在区域"
}
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-05-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2"
}
]
}
'@

$templateJson | Set-Content -Path '.\storage-template.json' -Encoding UTF8

然后创建参数文件,为不同环境提供不同的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
$parametersJson = @'
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"value": "stpowershelldemo001"
}
}
}
'@

$parametersJson | Set-Content -Path '.\storage-parameters.json' -Encoding UTF8

执行部署:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 连接到 Azure(如果尚未登录)
Connect-AzAccount

# 选择目标订阅
$context = Get-AzSubscription | Where-Object { $_.Name -eq 'MySubscription' }
Set-AzContext -SubscriptionId $context.Id

# 创建资源组
New-AzResourceGroup -Name 'rg-demo' -Location 'eastasia' -Force

# 部署 ARM 模板
$deployment = New-AzResourceGroupDeployment `
-ResourceGroupName 'rg-demo' `
-TemplateFile '.\storage-template.json' `
-TemplateParameterFile '.\storage-parameters.json' `
-Mode Incremental

$deployment

执行结果示例:

1
2
3
4
5
6
7
8
9
10
DeploymentName          : storage-template
ResourceGroupName : rg-demo
ProvisioningState : Succeeded
Timestamp : 2025-11-27 08:15:32
Mode : Incremental
TemplateParameterString :
Name Type Value
==================== ====== ==========
storageAccountName String stpowershelldemo001
location String eastasia

ProvisioningStateSucceeded 表示部署成功。-Mode Incremental 意味着模板中定义的资源会被创建或更新,但不会删除资源组中已有的其他资源。

进阶:参数化多环境部署

在企业实践中,开发、测试、生产三套环境的配置各不相同。与其维护多份参数文件,不如用 PowerShell 的哈希表动态生成参数,实现一套模板走天下。

下面我们构建一个包含虚拟网络和子网的模板,并用 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
# 定义环境配置
$environments = @{
dev = @{
ResourceGroupName = 'rg-dev-network'
Location = 'eastasia'
VnetName = 'vnet-dev'
AddressPrefix = '10.0.0.0/16'
SubnetName = 'snet-dev-default'
SubnetPrefix = '10.0.1.0/24'
}
staging = @{
ResourceGroupName = 'rg-staging-network'
Location = 'eastasia'
VnetName = 'vnet-staging'
AddressPrefix = '10.1.0.0/16'
SubnetName = 'snet-staging-default'
SubnetPrefix = '10.1.1.0/24'
}
prod = @{
ResourceGroupName = 'rg-prod-network'
Location = 'eastasia'
VnetName = 'vnet-prod'
AddressPrefix = '10.2.0.0/16'
SubnetName = 'snet-prod-default'
SubnetPrefix = '10.2.1.0/24'
}
}

# 目标环境
$envName = 'dev'
$envConfig = $environments[$envName]

# 构建模板参数哈希表
$templateParams = @{
vnetName = $envConfig.VnetName
addressPrefix = $envConfig.AddressPrefix
subnetName = $envConfig.SubnetName
subnetPrefix = $envConfig.SubnetPrefix
location = $envConfig.Location
}

Write-Host "正在部署 [$envName] 环境的网络资源..." -ForegroundColor Cyan
Write-Host " 资源组: $($envConfig.ResourceGroupName)"
Write-Host " VNet: $($envConfig.VnetName) ($($envConfig.AddressPrefix))"
Write-Host " Subnet: $($envConfig.SubnetName) ($($envConfig.SubnetPrefix))"

# 确保资源组存在
New-AzResourceGroup -Name $envConfig.ResourceGroupName `
-Location $envConfig.Location -Force | Out-Null

# 使用哈希表参数直接部署(无需参数文件)
$result = New-AzResourceGroupDeployment `
-ResourceGroupName $envConfig.ResourceGroupName `
-TemplateFile '.\vnet-template.json' `
@templateParams `
-Mode Incremental

Write-Host "部署状态: $($result.ProvisioningState)" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
正在部署 [dev] 环境的网络资源...
资源组: rg-dev-network
VNet: vnet-dev (10.0.0.0/16)
Subnet: snet-dev-default (10.0.1.0/24)
部署状态: Succeeded

这种方式的妙处在于切换环境只需修改 $envName 变量,所有配置自动跟随变化。配合 CI/CD 管道中的环境变量,可以轻松实现自动化多环境发布。

高级:模板验证与批量部署

在真正执行部署之前,先用 Test-AzResourceGroupDeployment 进行干跑验证,能够在不创建任何资源的情况下检查模板语法和参数是否正确。这在批量部署多个关联模板时尤其重要,可以提前发现错误,避免半途而废。

下面展示一个完整的批量部署流程,包含预验证、逐模板部署和结果汇总:

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
# 定义需要部署的模板列表
$templates = @(
@{
Name = '网络基础'
Template = '.\templates\vnet.json'
Params = '.\parameters\vnet.dev.json'
}
@{
Name = '存储账户'
Template = '.\templates\storage.json'
Params = '.\parameters\storage.dev.json'
}
@{
Name = '虚拟机'
Template = '.\templates\vm.json'
Params = '.\parameters\vm.dev.json'
}
)

$resourceGroupName = 'rg-dev-infra'
$deploymentResults = @()

# 第一步:批量验证所有模板
Write-Host '=' * 60 -ForegroundColor DarkGray
Write-Host '第一阶段:模板验证' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

$allValid = $true

foreach ($item in $templates) {
Write-Host "`n验证: $($item.Name)..." -NoNewline

$errorMessages = @()
$testResult = Test-AzResourceGroupDeployment `
-ResourceGroupName $resourceGroupName `
-TemplateFile $item.Template `
-TemplateParameterFile $item.Params `
-ErrorAction SilentlyContinue `
-ErrorVariable errorMessages

if ($errorMessages.Count -eq 0) {
Write-Host ' 通过' -ForegroundColor Green
} else {
Write-Host ' 失败' -ForegroundColor Red
foreach ($msg in $errorMessages) {
Write-Host " 错误: $msg" -ForegroundColor Red
}
$allValid = $false
}
}

# 第二步:如果全部通过,执行批量部署
if ($allValid) {
Write-Host "`n$('=' * 60)" -ForegroundColor DarkGray
Write-Host '第二阶段:执行部署' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

foreach ($item in $templates) {
Write-Host "`n部署: $($item.Name)..." -ForegroundColor Yellow

$deployResult = New-AzResourceGroupDeployment `
-ResourceGroupName $resourceGroupName `
-TemplateFile $item.Template `
-TemplateParameterFile $item.Params `
-Mode Incremental

$deploymentResults += [PSCustomObject]@{
Name = $item.Name
Status = $deployResult.ProvisioningState
Timestamp = $deployResult.Timestamp
Template = Split-Path $item.Template -Leaf
}

if ($deployResult.ProvisioningState -eq 'Succeeded') {
Write-Host " 完成 ($($deployResult.Timestamp))" -ForegroundColor Green
} else {
Write-Host " 失败" -ForegroundColor Red
break
}
}
} else {
Write-Host "`n验证未全部通过,终止部署。请修复上述错误后重试。" -ForegroundColor Red
}

# 汇总输出
Write-Host "`n$('=' * 60)" -ForegroundColor DarkGray
Write-Host '部署结果汇总' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

$deploymentResults | Format-Table -AutoSize

执行结果示例:

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
============================================================
第一阶段:模板验证
============================================================

验证: 网络基础... 通过
验证: 存储账户... 通过
验证: 虚拟机... 通过

============================================================
第二阶段:执行部署
============================================================

部署: 网络基础...
完成 (2025-11-27 08:22:15)

部署: 存储账户...
完成 (2025-11-27 08:22:48)

部署: 虚拟机...
完成 (2025-11-27 08:24:03)

============================================================
部署结果汇总
============================================================

Name Status Timestamp Template
---- ------ --------- --------
网络基础 Succeeded 2025-11-27 08:22:15 vnet.json
存储账户 Succeeded 2025-11-27 08:22:48 storage.json
虚拟机 Succeeded 2025-11-27 08:24:03 vm.json

这个脚本有两个关键设计:第一,Test-AzResourceGroupDeployment 在验证阶段不会创建任何真实资源,可以在安全的环境中提前发现问题;第二,部署阶段使用 foreach 遍历模板列表,一旦某个模板部署失败立即 break 退出,避免在错误的基础上继续部署后续资源。

注意事项

  1. 模板语法检查先行:在提交代码前,始终使用 Test-AzResourceGroupDeployment 进行验证。模板 JSON 的语法错误(如缺少逗号、引号不匹配)会导致整个部署失败,而这类错误在验证阶段就能被捕获。

  2. 资源命名规则:Azure 对资源名称有严格限制,例如存储账户名只能包含小写字母和数字,长度 3-24 个字符。建议在参数文件中使用命名前缀 + 环境缩写 + 序号的规则(如 st + dev + 001),并在 PowerShell 中用 -match 正则表达式做前置校验。

  3. 幂等性依赖模板设计:ARM 模板本身支持幂等部署,但前提是模板中完整定义了资源的所有关键属性。如果只定义了 namelocation 而省略了 sku,多次部署可能不会报错,但资源配置可能不是预期的最终状态。

  4. 增量模式与完整模式的区别-Mode Incremental 是安全的默认选择,它只处理模板中声明的资源。-Mode Complete 会删除资源组中所有未在模板中声明的资源,生产环境慎用。建议在脚本中显式指定 -Mode,不要依赖默认值。

  5. 大模板拆分为链接模板:当模板超过 200 行或包含 10 个以上资源时,建议使用链接模板(Linked Templates)将不同层(网络、存储、计算)拆分为独立文件,由主模板统一编排。这样既降低单文件复杂度,也便于团队分工维护。

  6. 敏感参数使用 Key Vault 引用:虚拟机密码、数据库连接字符串等敏感信息不要明文写在参数文件中。在参数文件的 value 字段中使用 Key Vault 引用格式("reference" + "keyVault" + "secretName"),部署时 PowerShell 会自动从 Azure Key Vault 安全读取。

PowerShell 技能连载 - Azure DevOps 集成

适用于 PowerShell 5.1 及以上版本

Azure DevOps 是微软提供的一站式 DevOps 平台,涵盖了 Boards(工作项跟踪)、Repos(代码仓库)、Pipelines(CI/CD 流水线)、Test Plans(测试管理)和 Artifacts(制品管理)五大核心服务。在企业级开发流程中,团队往往需要通过脚本自动化地与 Azure DevOps 交互,例如批量创建工作项、触发流水线、查询构建状态或管理代码仓库分支策略。

虽然 Azure DevOps 提供了功能完善的 Web 界面和 CLI 工具(az devops),但 PowerShell 凭借其强大的对象处理能力和与其他 Windows/Azure 服务的无缝集成,仍然是许多运维和开发团队的首选自动化工具。通过 Azure DevOps REST API,我们可以在 PowerShell 中完成几乎所有的平台操作,并将这些操作编排到更大的自动化工作流中。

本文将介绍如何使用 PowerShell 调用 Azure DevOps REST API,涵盖身份认证与连接管理、工作项(Work Item)的批量操作、Pipeline 的触发与状态监控,以及代码仓库的分支策略管理。每个场景都配有可直接运行的代码示例和执行结果演示。

准备工作:身份认证与连接封装

Azure DevOps REST API 支持多种身份认证方式,其中最常用的是 Personal Access Token(PAT)。为了在脚本中安全地使用 PAT,我们需要将认证逻辑封装成可复用的函数,避免在代码中硬编码凭据。以下代码演示了如何创建一个通用的 Azure DevOps API 调用函数。

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
function Invoke-AzDevOpsApi {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Organization,

[Parameter(Mandatory)]
[string]$Project,

[Parameter(Mandatory)]
[string]$ApiPath,

[Parameter(Mandatory)]
[securestring]$PatToken,

[ValidateSet('GET', 'POST', 'PATCH', 'DELETE')]
[string]$Method = 'GET',

[object]$Body
)

$base64Token = [Convert]::ToBase64String(
[System.Text.Encoding]::ASCII.GetBytes(
':' + (New-Object PSCredential('user', $PatToken).GetNetworkCredential().Password)
)
)

$headers = @{
Authorization = "Basic $base64Token"
'Content-Type' = 'application/json'
}

$uri = "https://dev.azure.com/$Organization/$Project/_apis$ApiPath"

$splat = @{
Uri = $uri
Headers = $headers
Method = $Method
}

if ($Body) {
$splat.Body = ($Body | ConvertTo-Json -Depth 10)
}

Invoke-RestMethod @splat
}

# 从环境变量读取 PAT,避免硬编码
$pat = ConvertTo-SecureString $env:AZDO_PAT -AsPlainText -Force

# 测试连接:获取项目信息
$projectInfo = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '?api-version=7.0' `
-PatToken $pat

Write-Host "项目名称: $($projectInfo.name)"
Write-Host "项目描述: $($projectInfo.description)"
Write-Host "项目 ID: $($projectInfo.id)"

上述代码将 PAT 以 SecureString 形式传入,通过 Base64 编码生成 Basic Auth 头。Invoke-AzDevOpsApi 函数封装了 URI 拼接和请求发送逻辑,后续所有示例都基于此函数调用。

执行结果示例:

1
2
3
项目名称: MyProject
项目描述: 核心业务系统开发项目
项目 ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

批量创建与查询工作项

Azure DevOps Boards 中的工作项(Work Item)是项目管理的基础单元。当需要从外部系统同步需求、批量创建测试任务或在 Sprint 规划时一次性添加多个用户故事时,手动操作效率极低。以下代码展示了如何批量创建工作项并查询特定条件的工作项列表。

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
# 批量创建用户故事(User Story)
$stories = @(
@{ Title = '实现用户登录 API 接口'; Priority = 1 },
@{ Title = '添加 OAuth2.0 第三方登录支持'; Priority = 2 },
@{ Title = '实现登录失败次数限制策略'; Priority = 3 }
)

foreach ($story in $stories) {
$body = @(
@{
op = 'add'
path = '/fields/System.Title'
value = $story.Title
},
@{
op = 'add'
path = '/fields/Microsoft.VSTS.Common.Priority'
value = $story.Priority
}
)

$result = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/wit/workitems/$User Story?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $body

Write-Host "已创建工作项 #$($result.id) - $($result.fields.'System.Title')"
}

# 查询当前迭代中所有未关闭的 Bug
$wiql = @{
query = "SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] `
FROM WorkItems `
WHERE [System.WorkItemType] = 'Bug' `
AND [System.State] <> 'Closed' `
AND [System.IterationPath] = @currentIteration() `
ORDER BY [System.CreatedDate] DESC"
}

$queryResult = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/wit/wiql?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $wiql

$workItemIds = $queryResult.workItems | Select-Object -ExpandProperty id
Write-Host "当前迭代中未关闭的 Bug 数量: $($workItemIds.Count)"

Azure DevOps 创建工作项使用 JSON Patch 格式(op: add),通过 path 指定要设置的字段。WIQL(Work Item Query Language)是类似 SQL 的查询语言,@currentIteration() 函数可以自动定位当前冲刺周期。使用 foreach 循环逐条创建可以清晰地在输出中追踪每条记录的创建结果。

执行结果示例:

1
2
3
4
已创建工作项 #1247 - 实现用户登录 API 接口
已创建工作项 #1248 - 添加 OAuth2.0 第三方登录支持
已创建工作项 #1249 - 实现登录失败次数限制策略
当前迭代中未关闭的 Bug 数量: 8

触发 Pipeline 并监控构建状态

在持续集成/持续部署(CI/CD)流程中,有时需要通过脚本手动触发 Pipeline,例如在完成数据迁移后触发部署流水线,或按需触发特定的测试流水线。以下代码演示了如何触发 Pipeline 构建,并以轮询方式等待构建完成。

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
# 触发 Pipeline 构建
$buildPayload = @{
definition = @{
id = 42
}
parameters = '{"environment":"staging","runTests":true}'
}

$build = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/build/builds?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $buildPayload

Write-Host "已触发构建 #$($build.id)"
Write-Host "构建定义: $($build.definition.name)"
Write-Host "初始状态: $($build.status)"
Write-Host "队列时间: $($build.queueTime)"

# 轮询构建状态直到完成
$buildId = $build.id
$maxRetries = 60
$retryCount = 0

while ($retryCount -lt $maxRetries) {
Start-Sleep -Seconds 30

$status = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath "/build/builds/$buildId`?api-version=7.0" `
-PatToken $pat

Write-Host "[$($retryCount + 1)] 构建 #$buildId 状态: $($status.status) - $($status.result)"

if ($status.status -eq 'completed') {
Write-Host "`n构建完成! 最终结果: $($status.result)"
Write-Host "开始时间: $($status.startTime)"
Write-Host "完成时间: $($status.finishTime)"

$duration = [datetime]::Parse($status.finishTime) - [datetime]::Parse($status.startTime)
Write-Host "耗时: $($duration.TotalMinutes.ToString('F1')) 分钟"
break
}

$retryCount++
}

if ($retryCount -ge $maxRetries) {
Write-Warning "等待超时,构建 #$buildId 仍未完成,请手动检查。"
}

这段代码首先通过 POST 请求触发指定 ID 的 Pipeline 定义,同时传递模板参数(environmentrunTests)。触发成功后进入轮询循环,每 30 秒查询一次构建状态,直到状态变为 completed 或达到最大重试次数。循环结束时计算并输出构建耗时,方便排查流水线性能问题。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
已触发构建 #3891
构建定义: MyProject-CI
初始状态: inProgress
队列时间: 2025-11-26T02:15:33.287Z
[1] 构建 #3891 状态: inProgress -
[2] 构建 #3891 状态: inProgress -
[3] 构建 #3891 状态: inProgress -
[4] 构建 #3891 状态: completed - succeeded

构建完成! 最终结果: succeeded
开始时间: 2025-11-26T02:15:38.412Z
完成时间: 2025-11-26T02:17:45.891Z
耗时: 2.1 分钟

管理代码仓库分支策略

分支策略(Branch Policy)是保障代码质量的重要手段。在团队协作中,通常要求所有代码变更通过 Pull Request 提交,并设置最低审核人数、构建验证和合并策略。以下代码演示了如何通过 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
# 获取仓库 ID
$repo = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/git/repositories?api-version=7.0' `
-PatToken $pat

$targetRepo = $repo.value | Where-Object { $_.name -eq 'MyApp' }
Write-Host "目标仓库: $($targetRepo.name) (ID: $($targetRepo.id))"

# 获取默认分支的 ref
$defaultBranch = $targetRepo.defaultBranch
Write-Host "默认分支: $defaultBranch"

# 获取分支策略配置列表
$policyConfigurations = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/policy/configurations?api-version=7.0' `
-PatToken $pat

# 统计当前项目的策略数量
$enabledPolicies = @($policyConfigurations.value | Where-Object { $_.isEnabled -eq $true })
Write-Host "当前项目已启用的策略数量: $($enabledPolicies.Count)"

# 为 main 分支创建"最少审核人数"策略
$minReviewersPolicy = @{
isEnabled = $true
isBlocking = $true
type = @{
id = 'fa4e907d-c16b-4a4c-90b4-75ae827c5881'
}
settings = @{
minimumApproverCount = 2
creatorVoteCounts = $false
scope = @(
@{
refName = $defaultBranch
matchKind = 'exact'
repositoryId = $targetRepo.id
}
)
}
}

$newPolicy = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/policy/configurations?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $minReviewersPolicy

Write-Host "已创建策略: 最低审核人数 = 2"
Write-Host "策略 ID: $($newPolicy.id)"
Write-Host "是否阻断: $($newPolicy.isBlocking)"

分支策略的类型通过 GUID 标识。fa4e907d-c16b-4a4c-90b4-75ae828c5881 代表”最少审核人数”策略类型。isBlocking = $true 表示不满足策略要求时无法合并 Pull Request。creatorVoteCounts = $false 确保创建者自身的审核不计入最低审核人数。通过脚本配置策略,可以确保新仓库的分支保护规则与团队规范一致。

执行结果示例:

1
2
3
4
5
6
目标仓库: MyApp (ID: abc12345-6789-def0-1234-567890abcdef)
默认分支: refs/heads/main
当前项目已启用的策略数量: 5
已创建策略: 最低审核人数 = 2
策略 ID: 9876abcd-5432-10fe-dc98-76543210fedc
是否阻断: True

注意事项

  • PAT 安全管理:切勿将 Personal Access Token 硬编码在脚本中。推荐从环境变量($env:AZDO_PAT)或 Azure Key Vault 中读取,并在 CI/CD 流水线中使用变量组(Variable Group)的机密引用功能。
  • API 版本控制:Azure DevOps REST API 要求在每次请求中指定 api-version 参数。建议在生产脚本中固定使用某个已验证的版本号(如 7.07.1),避免因 API 升级导致脚本行为变化。
  • 请求频率限制:Azure DevOps 对 REST API 调用有频率限制(通常为每小时 6000 次,具体取决于组织规模)。批量操作时建议在循环中添加适当延时(如 Start-Sleep -Milliseconds 200),或在遇到 429 状态码时实现指数退避重试。
  • JSON Patch 格式差异:创建工作项使用 JSON Patch 数组格式(op: add),而更新工作项也使用同一格式但允许 op: replaceop: remove 等操作。注意这与常规 REST API 的 PUT/PATCH JSON body 格式不同,混淆两者是常见的调试陷阱。
  • 错误处理Invoke-RestMethod 默认在遇到非 2xx 状态码时抛出异常。建议在调用外层包裹 try/catch 块,并通过 $_.Exception.Response 获取详细的错误响应内容,便于定位问题。
  • 跨平台兼容性:如果需要在 PowerShell 7+ 的 Linux/macOS 环境中运行,注意 ConvertTo-SecureString 在非 Windows 平台上的行为差异。推荐使用跨平台兼容的凭据管理方式,例如直接通过 Invoke-RestMethod-Authentication Bearer 参数传递令牌。