PowerShell 技能连载 - 凭据安全管理

适用于 PowerShell 7.0 及以上版本

在自动化脚本中硬编码密码是最常见也最危险的安全隐患之一。一段包含明文密码的脚本一旦被提交到版本控制系统,就会永久留在 Git 历史中,即使后续删除也无济于事。据统计,GitHub 上每天有数千个包含泄露凭据的提交被安全研究员发现。对于企业运维团队来说,一个泄露的服务账号密码可能意味着整个基础设施面临风险。

PowerShell 提供了完善的凭据安全管理机制。从传统的 PSCredential + SecureString 方案,到现代化的 SecretManagement 模块生态,再到凭据轮换与审计的完整生命周期管理,PowerShell 生态已经覆盖了凭据安全的各个环节。合理运用这些工具,可以确保密码永远不会以明文形式出现在脚本、日志或配置文件中。

本文将从三个维度展开:使用 PSCredential 和安全字符串进行本地凭据保护;通过 SecretManagement 模块实现跨平台密钥管理;以及构建凭据轮换与审计机制,形成完整的凭据安全闭环。

PSCredential 与安全字符串

PSCredential 是 PowerShell 中封装用户名和密码的标准对象,密码在内部以 SecureString 形式存储,不会以明文暴露在内存中。结合 Windows 的 DPAPI(数据保护 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
# 方式一:交互式创建凭据(适合手动执行场景)
$cred = Get-Credential -Message '请输入服务账号凭据'

# 方式二:通过代码创建凭据(适合自动化场景)
$plainPassword = 'My$ecureP@ssw0rd!2026'
$securePassword = $plainPassword | ConvertTo-SecureString -AsPlainText -Force
$cred = [System.Management.Automation.PSCredential]::new('svcautomation', $securePassword)

# 将凭据安全导出到文件(DPAPI 加密,仅当前用户可解密)
$cred | Export-Clixml -Path './svcautomation.cred.xml'

# 从文件导入凭据
$loadedCred = Import-Clixml -Path './svcautomation.cred.xml'

# 使用 SecureString 的 DPAPI 加密/解密
$encrypted = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString

# 将加密字符串保存到文件
$encrypted | Set-Content -Path './encrypted-password.txt'

# 在另一台机器或用户上下文中解密(需配合 Key 参数使用 AES 对称加密)
$key = (1..16 | ForEach-Object { Get-Random -Minimum 0 -Maximum 255 })
$encryptedWithKey = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString -Key $key

Write-Host "用户名: $($loadedCred.UserName)"
Write-Host "密码长度: $($loadedCred.GetNetworkCredential().Password.Length) 个字符"
Write-Host "加密字符串前 40 字符: $($encrypted.Substring(0, 40))..."

执行结果示例:

1
2
3
用户名: svcautomation
密码长度: 20 个字符
加密字符串前 40 字符: 01000000d08c9ddf0115d1118c7a00c04f...

需要注意的是,不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI 加密,加密后的数据只能在同一台机器的同一用户账户下解密。如果需要跨机器传输,必须使用 -Key 参数指定 AES 密钥。

SecretManagement 模块

Microsoft.PowerShell.SecretManagement 是微软官方推出的密钥管理框架,采用”核心模块 + 扩展保管库”的架构。核心模块提供统一的读写接口,而实际的密钥存储由扩展库实现,支持 Windows Credential Manager、Azure Key Vault、KeePass、HashiCorp Vault 等多种后端。脚本代码面向统一 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
# 安装核心模块和扩展保管库
Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser
Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser

# 注册本地 SecretStore 保管库
Register-SecretVault -Name 'LocalDevVault' -ModuleName Microsoft.PowerShell.SecretStore

# 配置保管库策略(需要密码才能访问)
Set-SecretStoreConfiguration -Authentication Password -Interaction None

# 存储各种类型的密钥
Set-Secret -Name 'DatabaseConnection' -Secret 'Server=db01;User=app;Password=S3cure!;' -Vault LocalDevVault
Set-Secret -Name 'ApiKey' -Secret 'sk-proj-abc123def456ghi789' -Vault LocalDevVault
Set-Secret -Name 'ServiceAccount' -Secret (
[System.Management.Automation.PSCredential]::new(
'DOMAIN\svcautomation',
('P@ssw0rd!' | ConvertTo-SecureString -AsPlainText -Force)
)
) -Vault LocalDevVault

# 读取密钥
$dbConn = Get-Secret -Name 'DatabaseConnection' -Vault LocalDevVault
$apiKey = Get-Secret -Name 'ApiKey' -Vault LocalDevVault
$svcCred = Get-Secret -Name 'ServiceAccount' -Vault LocalDevVault

# 列出所有密钥名称(不暴露值)
Get-SecretInfo -Vault LocalDevVault | Format-Table Name, Type

# 删除不需要的密钥
Remove-Secret -Name 'ApiKey' -Vault LocalDevVault

# 查看已注册的保管库信息
Get-SecretVault | Format-Table Name, ModuleName, IsDefault

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                Type
---- ----
DatabaseConnection SecureString
ApiKey SecureString
ServiceAccount PSCredential

Name ModuleName IsDefault
---- ---------- ---------
LocalDevVault Microsoft.PowerShell.SecretStore False

SecretManagement 的优势在于扩展性。如果未来需要从本地文件迁移到 Azure Key Vault 或 HashiCorp Vault,只需注册新的保管库并迁移数据,业务脚本中的 Get-Secret 调用完全不需要修改。

凭据轮换与审计

凭据安全不止于安全存储,还需要定期轮换密码、记录使用日志并设置过期告警。以下代码构建了一个简易的凭据生命周期管理系统,可以自动生成强密码、更新保管库中的密钥,并记录完整的审计日志。

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
# 凭据轮换与审计管理系统

# 生成强随机密码
function New-RandomPassword {
[CmdletBinding()]
[OutputType([string])]
param(
[int]$Length = 24,
[int]$NonAlphaNumeric = 6
)
$assembly = [System.Reflection.Assembly]::LoadWithPartialName('System.Web')
$password = [System.Web.Security.Membership]::GeneratePassword($Length, $NonAlphaNumeric)
return $password
}

# 凭据审计日志(追加式 JSON 日志)
function Write-CredentialAuditLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[Parameter(Mandatory)]
[ValidateSet('Create', 'Read', 'Rotate', 'Delete')]
[string]$Action,
[string]$VaultName = 'LocalDevVault',
[string]$Operator = $env:USERNAME
)
$logEntry = [ordered]@{
Timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ'
Action = $Action
SecretName = $SecretName
VaultName = $VaultName
Operator = $Operator
Machine = $env:COMPUTERNAME
}
$logPath = './credential-audit.json'
$existing = @()
if (Test-Path $logPath) {
$existing = Get-Content $logPath -Raw | ConvertFrom-Json
if ($existing -isnot [array]) { $existing = @($existing) }
}
$existing += $logEntry
$existing | ConvertTo-Json -Depth 5 | Set-Content $logPath -Encoding UTF8
}

# 凭据轮换:生成新密码并更新保管库
function Invoke-CredentialRotation {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[string]$VaultName = 'LocalDevVault'
)
$newPassword = New-RandomPassword -Length 32 -NonAlphaNumeric 8
$secureNew = $newPassword | ConvertTo-SecureString -AsPlainText -Force
Set-Secret -Name $SecretName -Secret $secureNew -Vault $VaultName
Write-CredentialAuditLog -SecretName $SecretName -Action Rotate -VaultName $VaultName
Write-Host "[$SecretName] 密码已轮换,新密码长度: $($newPassword.Length) 字符"
}

# 检查凭据是否即将过期(基于审计日志中的最后轮换时间)
function Test-CredentialExpiry {
[CmdletBinding()]
param(
[int]$ExpireDays = 90
)
$logPath = './credential-audit.json'
if (-not (Test-Path $logPath)) {
Write-Warning '审计日志文件不存在,无法检查凭据过期状态'
return
}
$logs = Get-Content $logPath -Raw | ConvertFrom-Json
$rotateLogs = $logs | Where-Object { $_.Action -eq 'Rotate' }
$latestRotations = $rotateLogs | Group-Object SecretName | ForEach-Object {
$latest = ($_.Group | Sort-Object Timestamp -Descending | Select-Object -First 1)
[PSCustomObject]@{
SecretName = $_.Name
LastRotated = [datetime]$latest.Timestamp
DaysSince = [math]::Floor((Get-Date) - [datetime]$latest.Timestamp)
ShouldRotate = ((Get-Date) - [datetime]$latest.Timestamp).TotalDays -gt $ExpireDays
}
}
$latestRotations | Format-Table SecretName, LastRotated, DaysSince, ShouldRotate
$expired = $latestRotations | Where-Object { $_.ShouldRotate }
if ($expired) {
Write-Warning "以下凭据已超过 $ExpireDays 天未轮换:$($expired.SecretName -join ', ')"
}
}

# 执行轮换并记录审计日志
Invoke-CredentialRotation -SecretName 'DatabaseConnection' -VaultName LocalDevVault
Invoke-CredentialRotation -SecretName 'ServiceAccount' -VaultName LocalDevVault

# 检查凭据过期状态
Test-CredentialExpiry -ExpireDays 90

执行结果示例:

1
2
3
4
5
6
7
[DatabaseConnection] 密码已轮换,新密码长度: 32 字符
[ServiceAccount] 密码已轮换,新密码长度: 32 字符

SecretName LastRotated DaysSince ShouldRotate
---------- ------------ --------- ------------
DatabaseConnection 2026-04-16T10:30:00.000Z 0 False
ServiceAccount 2026-04-16T10:30:01.000Z 0 False

注意事项

  • 绝不硬编码密码:脚本中出现的任何明文密码都是安全隐患,应始终通过 PSCredentialSecureStringSecretManagement 模块来管理凭据。
  • DPAPI 加密的局限性:不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI,加密数据绑定于当前机器和用户账户,无法跨机器解密。跨机器场景请使用 -Key 参数配合 AES 对称加密。
  • SecretStore 密码保护:生产环境中务必将 SecretStore 配置为需要密码访问(Set-SecretStoreConfiguration -Authentication Password),不要使用 None 模式,否则任何能登录的用户都能读取密钥。
  • 审计日志的存储安全:凭据审计日志本身也包含敏感操作记录,应存放在受保护的位置,并设置适当的 NTFS 权限或文件加密,防止未授权访问。
  • 定期轮换密码:建议建立密码轮换策略,至少每 90 天轮换一次服务账号密码。轮换后确保所有依赖该凭据的自动化任务都使用更新后的密钥。
  • 密钥传输安全:在 CI/CD 管线中使用密钥时,应通过安全的管线变量或环境变量传递,避免将密钥写入磁盘文件或打印到控制台输出中。

PowerShell 技能连载 - Windows 开发环境配置即代码

适用于 PowerShell 7.0 及以上版本

在 Linux 和 macOS 世界里,”Dotfiles” 文化早已深入人心——开发者把 shell 配置、编辑器偏好、软件包清单统统放进 Git 仓库,一条命令就能在新机器上完整还原工作环境。这种”配置即代码”(Configuration as Code)的理念不仅节省时间,更重要的是保证了多台设备之间的一致性,也让灾难恢复变得轻而易举。

Windows 平台长期以来缺少类似的标准化方案。开发者通常需要手动下载安装包、逐一点击安装向导、手动配置环境变量和注册表项。整个过程既繁琐又容易遗漏。随着 Windows Package Manager(winget)的成熟和 PowerShell 7 的普及,Windows 上也可以实现与 Unix 系统媲美的自动化环境配置流程。

本文将介绍如何使用 PowerShell 结合 winget 构建一套完整的”Windows Dotfiles”方案:自动安装常用开发工具链、管理系统配置与偏好设置,以及通过 Git 仓库实现多机同步和一键恢复。

开发工具链安装脚本

第一步是将日常开发所需的工具清单化。通过 winget 的命令行接口,我们可以用 PowerShell 脚本批量安装 VS Code、Git、Node.js、Python 等常用工具,并自动处理依赖关系。

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
# DevToolkit.ps1 - 开发工具链一键安装脚本
# 定义工具清单:包名 + 可选版本约束
$tools = @(
@{ Id = 'Git.Git'; Name = 'Git'; Source = 'winget' }
@{ Id = 'Microsoft.VisualStudioCode'; Name = 'VS Code'; Source = 'winget' }
@{ Id = 'OpenJS.NodeJS.LTS'; Name = 'Node.js LTS'; Source = 'winget' }
@{ Id = 'Python.Python.3.12'; Name = 'Python 3.12'; Source = 'winget' }
@{ Id = 'Docker.DockerDesktop'; Name = 'Docker Desktop'; Source = 'winget' }
@{ Id = 'Microsoft.WindowsTerminal'; Name = 'Windows Terminal'; Source = 'winget' }
@{ Id = 'JanDeDobbeleer.OhMyPosh'; Name = 'Oh My Posh'; Source = 'winget' }
)

function Install-DevTool {
param(
[Parameter(Mandatory)]
[hashtable]$Tool
)

$installed = winget list --id $Tool.Id --accept-source-agreements 2>$null
if ($installed -match $Tool.Id) {
Write-Host "[已安装] $($Tool.Name)" -ForegroundColor Green
return
}

Write-Host "[安装中] $($Tool.Name) ($($Tool.Id))" -ForegroundColor Cyan
$result = winget install --id $Tool.Id `
--accept-package-agreements `
--accept-source-agreements `
--silent 2>&1

if ($LASTEXITCODE -eq 0) {
Write-Host "[完成] $($Tool.Name) 安装成功" -ForegroundColor Green
} else {
Write-Host "[失败] $($Tool.Name): $result" -ForegroundColor Red
}
}

# 批量安装并统计结果
$stats = @{ Success = 0; Skipped = 0; Failed = 0 }

foreach ($tool in $tools) {
$before = $stats.Success + $stats.Failed
Install-DevTool -Tool $tool

$after = winget list --id $tool.Id --accept-source-agreements 2>$null
if ($after -match $tool.Id) {
if ($before -eq ($stats.Success + $stats.Failed)) {
$stats.Skipped++
} else {
$stats.Success++
}
} else {
$stats.Failed++
}
}

Write-Host "`n--- 安装报告 ---" -ForegroundColor Yellow
Write-Host "新安装: $($stats.Success) | 已存在: $($stats.Skipped) | 失败: $($stats.Failed)"

上面的脚本会逐个检查每个工具是否已经安装,避免重复安装,并给出清晰的彩色输出和最终统计报告。你可以根据个人需求增删 $tools 数组中的条目。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[已安装] Git
[已安装] VS Code
[安装中] Node.js LTS (OpenJS.NodeJS.LTS)
[完成] Node.js LTS 安装成功
[安装中] Python 3.12 (Python.Python.3.12)
[完成] Python 3.12 安装成功
[已安装] Docker Desktop
[安装中] Windows Terminal (Microsoft.WindowsTerminal)
[完成] Windows Terminal 安装成功
[安装中] Oh My Posh (JanDeDobbeleer.OhMyPosh)
[完成] Oh My Posh 安装成功

--- 安装报告 ---
新安装: 4 | 已存在: 3 | 失败: 0

系统配置与偏好设置

安装完工具只是第一步,更关键的是将各项配置也纳入版本管理。下面的脚本演示了如何通过 PowerShell 管理注册表项、PowerShell Profile 文件和环境变量,把这些偏好设置也变成可追踪、可复现的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# SystemConfig.ps1 - 系统配置与偏好设置脚本
# 定义 Dotfiles 仓库路径
$DotfilesRoot = Join-Path $HOME 'dotfiles'
$BackupDir = Join-Path $DotfilesRoot 'backups'

# 创建备份目录
if (-not (Test-Path $BackupDir)) {
New-Item -Path $BackupDir -ItemType Directory -Force | Out-Null
}

# --- 1. Windows 注册表配置 ---
$regSettings = @{
# 显示文件扩展名
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\HideFileExt' = 0
# 显示隐藏文件
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\Hidden' = 1
# 关闭 Bing 搜索
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Search\BingSearchEnabled' = 0
}

foreach ($entry in $regSettings.GetEnumerator()) {
$path = Split-Path $entry.Key
$name = Split-Path $entry.Key -Leaf
if (-not (Test-Path $path)) {
New-Item -Path $path -Force | Out-Null
}
Set-ItemProperty -Path $path -Name $name -Value $entry.Value -Type DWord
Write-Host "[注册表] $name = $($entry.Value)" -ForegroundColor Cyan
}

# --- 2. PowerShell Profile 管理 ---
$profileContent = @'
# ===== PowerShell Profile - 由 dotfiles 管理 =====
# Oh My Posh 主题
oh-my-posh init pwsh --config "$env:POSH_THEMES_PATH\jandebuhr.omp.json" | Invoke-Expression

# 常用别名
Set-Alias -Name ll -Value Get-ChildItem
Set-Alias -Name which -Value Get-Command

# 自定义函数:快速进入项目目录
function proj { Set-Location "$env:USERPROFILE\Projects\$args" }

# PSReadLine 配置
Set-PSReadLineOption -PredictiveSource History
Set-PSReadLineOption -EditMode Windows
'@

$profileDir = Split-Path $PROFILE
if (-not (Test-Path $profileDir)) {
New-Item -Path $profileDir -ItemType Directory -Force | Out-Null
}

# 备份现有 Profile
if (Test-Path $PROFILE) {
$backupFile = Join-Path $BackupDir 'Microsoft.PowerShell_profile.ps1.bak'
Copy-Item $PROFILE $backupFile -Force
Write-Host "[备份] Profile 已备份到 $backupFile" -ForegroundColor Yellow
}

Set-Content -Path $PROFILE -Value $profileContent -Encoding UTF8
Write-Host "[Profile] 已写入 $PROFILE" -ForegroundColor Green

# --- 3. 环境变量设置 ---
$envConfig = @{
'DOTFILES_ROOT' = $DotfilesRoot
'PROJECTS_HOME' = Join-Path $HOME 'Projects'
'EDITOR' = 'code'
}

foreach ($entry in $envConfig.GetEnumerator()) {
[Environment]::SetEnvironmentVariable(
$entry.Key, $entry.Value, 'User'
)
Write-Host "[环境变量] $($entry.Key) = $($entry.Value)" -ForegroundColor Cyan
}

Write-Host "`n[完成] 系统配置已全部应用,重启终端生效" -ForegroundColor Green

这段脚本把注册表修改、Profile 文件生成和环境变量设置集中在一起。每次执行都会自动备份旧配置,保证操作可回滚。将这段脚本放入 dotfiles 仓库后,只要 git pull 再执行一次就能在新机器上还原所有偏好。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
[注册表] HideFileExt = 0
[注册表] Hidden = 1
[注册表] BingSearchEnabled = 0
[备份] Profile 已备份到 C:\Users\dev\dotfiles\backups\Microsoft.PowerShell_profile.ps1.bak
[Profile] 已写入 C:\Users\dev\Documents\PowerShell\Microsoft.PowerShell_profile.ps1
[环境变量] DOTFILES_ROOT = C:\Users\dev\dotfiles
[环境变量] PROJECTS_HOME = C:\Users\dev\Projects
[环境变量] EDITOR = code

[完成] 系统配置已全部应用,重启终端生效

配置恢复与多机同步

前两个脚本解决了安装和配置的问题,但真正的价值在于多机同步。下面的脚本实现了配置导出和一键恢复功能,配合 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
# SyncDotfiles.ps1 - 配置导出、同步与恢复
# Dotfiles 仓库路径
$DotfilesRoot = Join-Path $HOME 'dotfiles'
$ExportsDir = Join-Path $DotfilesRoot 'exports'

function Export-CurrentConfig {
<# 将当前环境导出为可追踪的清单文件 #>

if (-not (Test-Path $ExportsDir)) {
New-Item -Path $ExportsDir -ItemType Directory -Force | Out-Null
}

# 导出 winget 已安装软件列表
Write-Host "[导出] 正在生成 winget 软件清单..." -ForegroundColor Cyan
winget export --output (Join-Path $ExportsDir 'winget-packages.json') `
--accept-source-agreements 2>$null

# 同时生成可读的文本版本
winget list --accept-source-agreements |
Out-File (Join-Path $ExportsDir 'winget-list.txt') -Encoding UTF8

# 导出环境变量
$userEnv = [Environment]::GetEnvironmentVariables('User') |
GetEnumerator() | Sort-Object Name
$userEnv | ConvertTo-Json |
Out-File (Join-Path $ExportsDir 'user-env.json') -Encoding UTF8

# 导出 PowerShell 模块列表
Get-Module -ListAvailable |
Select-Object Name, Version, ModuleBase |
Sort-Object Name |
ConvertTo-Json |
Out-File (Join-Path $ExportsDir 'ps-modules.json') -Encoding UTF8

# 导出 Profile 文件
if (Test-Path $PROFILE) {
Copy-Item $PROFILE (Join-Path $ExportsDir 'profile.ps1') -Force
}

Write-Host "[完成] 配置已导出到 $ExportsDir" -ForegroundColor Green
}

function Restore-DevEnvironment {
<# 从 dotfiles 仓库一键恢复开发环境 #>

# 1. 从 winget 清单批量安装
$manifestPath = Join-Path $ExportsDir 'winget-packages.json'
if (Test-Path $manifestPath) {
Write-Host "[恢复] 从 winget 清单安装软件..." -ForegroundColor Cyan
winget import --import-file $manifestPath `
--accept-package-agreements `
--accept-source-agreements
}

# 2. 恢复 Profile
$profileBackup = Join-Path $ExportsDir 'profile.ps1'
if (Test-Path $profileBackup) {
$profileDir = Split-Path $PROFILE
if (-not (Test-Path $profileDir)) {
New-Item -Path $profileDir -ItemType Directory -Force | Out-Null
}
Copy-Item $profileBackup $PROFILE -Force
Write-Host "[恢复] PowerShell Profile 已还原" -ForegroundColor Green
}

# 3. 恢复环境变量
$envPath = Join-Path $ExportsDir 'user-env.json'
if (Test-Path $envPath) {
$envVars = Get-Content $envPath | ConvertFrom-Json
foreach ($prop in $envVars.PSObject.Properties) {
[Environment]::SetEnvironmentVariable(
$prop.Name, $prop.Value.ToString(), 'User'
)
}
Write-Host "[恢复] 环境变量已还原" -ForegroundColor Green
}

# 4. 安装 PowerShell 模块
$requiredModules = @('PSReadLine', 'Terminal-Icons', 'z')
foreach ($mod in $requiredModules) {
if (-not (Get-Module -ListAvailable -Name $mod)) {
Install-Module -Name $mod -Force -Scope CurrentUser
Write-Host "[模块] 已安装 $mod" -ForegroundColor Cyan
}
}

Write-Host "`n[完成] 开发环境已完整恢复,请重启终端" -ForegroundColor Green
}

# 使用方式:
# 导出: . .\SyncDotfiles.ps1; Export-CurrentConfig
# 恢复: . .\SyncDotfiles.ps1; Restore-DevEnvironment

这个脚本提供了两个核心函数:Export-CurrentConfig 将当前环境的所有关键配置导出为 JSON 清单文件,Restore-DevEnvironment 则从这些清单文件一键还原。配合 Git 仓库,你在任何 Windows 机器上只需 git clone 你的 dotfiles 仓库,然后运行恢复函数即可。

执行结果示例(导出模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[导出] 正在生成 winget 软件清单...
[完成] 配置已导出到 C:\Users\dev\dotfiles\exports

> Get-ChildItem C:\Users\dev\dotfiles\exports

Directory: C:\Users\dev\dotfiles\exports

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 2026/4/15 10:30 24576 winget-packages.json
-a--- 2026/4/15 10:30 8192 winget-list.txt
-a--- 2026/4/15 10:30 1024 user-env.json
-a--- 2026/4/15 10:30 3072 ps-modules.json
-a--- 2026/4/15 10:30 2048 profile.ps1

执行结果示例(恢复模式):

1
2
3
4
5
6
7
8
9
[恢复] 从 winget 清单安装软件...
Found 12 packages in the import file.
已安装 7 个包, 已跳过 5 个包。
[恢复] PowerShell Profile 已还原
[恢复] 环境变量已还原
[模块] 已安装 Terminal-Icons
[模块] 已安装 z

[完成] 开发环境已完整恢复,请重启终端

注意事项

  1. winget 前置条件:winget 随 Windows 11 和 Windows 10 (1809+) 的 App Installer 分发。如果系统没有 winget,需要先从 Microsoft Store 安装”应用安装程序”,或在 GitHub 的 microsoft/winget-cli 仓库手动下载 MSIX 包。

  2. 管理员权限:部分软件(如 Docker Desktop、某些注册表项的修改)需要以管理员身份运行 PowerShell。建议在脚本开头加入 #Requires -RunAsAdministrator 声明,或在启动时检测权限并提示用户提权。

  3. 网络与代理:winget 和 PowerShell Gallery 在国内网络环境下可能较慢。建议提前配置代理:$env:HTTPS_PROXY = 'http://127.0.0.1:7890',或将 NuGet 源替换为国内镜像。

  4. 版本锁定:winget-packages.json 导出的清单会锁定具体版本号。如果希望在恢复时始终获取最新版,可以改为从文本清单逐条 winget install 而非使用 winget import,以获取最新可用版本。

  5. 敏感信息处理:dotfiles 仓库中不要存放 API Key、Token 等敏感信息。环境变量中涉及密钥的部分应使用 Windows Credential Manager 或 .env 文件管理,并在 .gitignore 中排除。

  6. 幂等性设计:所有脚本都应设计为可重复执行而不产生副作用——安装前先检测是否已存在,配置前先备份旧值。这样即使执行失败也可以安全地重新运行。

PowerShell 技能连载 - 基础设施测试

适用于 PowerShell 7.0 及以上版本,需要 Pester v5+ 模块

随着基础设施即代码(IaC)的广泛采用,越来越多的团队使用 PowerShell DSC、Ansible、Terraform 等工具来定义和管理服务器配置。然而,写好了配置代码并不等于部署一定正确——就像应用代码需要单元测试一样,基础设施代码同样需要一套系统化的测试策略来保证部署结果与预期一致。

基础设施测试与传统软件测试有所不同:它验证的不是函数的输入输出,而是操作系统服务是否运行、端口是否监听、文件权限是否合规、注册表键值是否正确。这类测试通常在部署完成后执行,作为 CI/CD 流水线的最后防线,一旦发现异常就阻断发布或触发回滚。

Pester 是 PowerShell 生态中最强大的测试框架,其描述式语法(Describe / Context / It)天然适合编写基础设施验证用例。本文将从 Pester 基础设施测试框架的搭建、合规性测试套件的编写、到部署验证管道的集成,完整展示如何用 PowerShell 构建一套可靠的基础设施测试体系。

Pester 基础设施测试框架

第一个场景是搭建基础设施测试的基本框架。我们用 Pester 编写针对服务器配置的测试用例,验证关键资源(服务、端口、文件、注册表)是否存在且处于期望状态。测试用例可以参数化,方便在不同环境(dev / staging / prod)中复用。

下面的脚本定义了一套基础设施测试,通过外部 JSON 配置文件描述期望状态,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
98
99
100
101
102
103
104
105
106
107
function Invoke-InfrastructureTest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ExpectedStatePath,

[ValidateSet('Minimal', 'Standard', 'Comprehensive')]
[string]$TestDepth = 'Standard',

[string]$OutputPath = 'TestResults.xml'
)

$stateConfig = Get-Content -Path $ExpectedStatePath -Raw | ConvertFrom-Json

$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Detailed'

if ($OutputPath) {
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $OutputPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'
}

$container = New-PesterContainer -ScriptBlock {
param($Config, $Depth)

Describe "基础设施状态验证 - $($Config.environmentName)" {
BeforeAll {
$testStartTime = Get-Date
Write-Host " 测试开始时间: $testStartTime" -ForegroundColor DarkGray
}

# 验证 Windows 服务状态
Context "Windows 服务检查" {
foreach ($svc in $Config.services) {
It "服务 '$($svc.name)' 应处于 $($svc.expectedStatus) 状态" {
$service = Get-Service -Name $svc.name -ErrorAction SilentlyContinue
$service | Should -Not -BeNullOrEmpty -Because "服务 '$($svc.name)' 必须存在"
$service.Status.ToString() | Should -Be $svc.expectedStatus
}
}
}

# 验证网络端口监听
Context "网络端口检查" {
foreach ($port in $Config.ports) {
It "端口 $($port.number)/$($port.protocol) 应处于监听状态" {
$protocol = if ($port.protocol -eq 'UDP') { 'Udp' } else { 'Tcp' }
$listener = Get-NetTCPConnection -LocalPort $port.number `
-ErrorAction SilentlyContinue |
Where-Object { $_.State -eq 'Listen' }

$listener | Should -Not -BeNullOrEmpty `
-Because "端口 $($port.number) 必须有进程监听"
}
}
}

# 验证文件和目录
Context "文件系统检查" {
foreach ($file in $Config.files) {
if ($file.type -eq 'Directory') {
It "目录 '$($file.path)' 应该存在" {
Test-Path -Path $file.path -PathType Container |
Should -BeTrue -Because "目录 '$($file.path)' 必须存在"
}
}
else {
It "文件 '$($file.path)' 应该存在且内容包含 '$($file.expectedContent)'" {
Test-Path -Path $file.path -PathType Leaf |
Should -BeTrue -Because "文件 '$($file.path)' 必须存在"

if ($file.expectedContent) {
$content = Get-Content -Path $file.path -Raw
$content | Should -Match $file.expectedContent
}
}
}
}
}

# 深度测试:注册表和权限(仅 Standard 和 Comprehensive 级别)
if ($Depth -in @('Standard', 'Comprehensive')) {
Context "注册表键值检查" {
foreach ($reg in $Config.registryKeys) {
It "注册表 '$($reg.path)\$($reg.name)' 应为 '$($reg.expectedValue)'" {
$actual = Get-ItemProperty -Path $reg.path -Name $reg.name `
-ErrorAction SilentlyContinue
$actual.$($reg.name) | Should -Be $reg.expectedValue
}
}
}
}

AfterAll {
$duration = (Get-Date) - $testStartTime
Write-Host " 测试耗时: $($duration.ToString('mm\:ss\.fff'))" -ForegroundColor DarkGray
}
}
} -Data @{ Config = $stateConfig; Depth = $TestDepth }

$pesterConfig.Run.Container = $container
$result = Invoke-Pester -Configuration $pesterConfig

return $result
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS> Invoke-InfrastructureTest -ExpectedStatePath .\expected-state.json -TestDepth Standard

Starting discovery in 1 files.
Discovery finished in 0.23s.
[+] D:\infra-tests\expected-state.json 1.82s (7.3ms|1.81s)
[+] 基础设施状态验证 - production
[+] Windows 服务检查
[+] 服务 'W3SVC' 应处于 Running 状态
[+] 服务 'MSSQLSERVER' 应处于 Running 状态
[+] 网络端口检查
[+] 端口 443/TCP 应处于监听状态
[+] 端口 1433/TCP 应处于监听状态
[+] 文件系统检查
[+] 文件 'D:\app\appsettings.json' 应该存在且内容包含 'Production'
[+] 注册表键值检查
[+] 注册表 'HKLM:\SOFTWARE\MyApp\Server' 应为 '10.0.1.50'
Tests passed: 6, Failed: 0, Skipped: 0, NotRun: 0
TestResults.xml written to TestResults.xml

合规性测试套件

第二个场景是构建一套完整的合规性测试套件,用于安全基线检查和 CIS Benchmark 验证。与基础的状态检查不同,合规性测试更关注安全策略的落地情况,如密码策略、审计日志、防火墙规则、用户权限等,并支持环境间差异检测。

下面的脚本定义了一套可扩展的合规性测试框架,支持按分类执行,输出详细的合规报告:

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

[string[]]$Categories = @('AccountPolicy', 'AuditPolicy', 'Firewall', 'UserRights'),

[string]$EnvironmentName = 'default',

[string]$ReportPath = 'ComplianceReport.json'
)

$baseline = Get-Content -Path $BaselinePath -Raw | ConvertFrom-Json
$complianceResults = [System.Collections.Generic.List[PSObject]]::new()
$totalPassed = 0
$totalFailed = 0

foreach ($category in $Categories) {
Write-Host "`n--- 检查分类: $category ---" -ForegroundColor Cyan

$checks = $baseline.$category
if (-not $checks) {
Write-Host " 跳过(基线中无 '$category' 配置)" -ForegroundColor DarkGray
continue
}

foreach ($check in $checks) {
$testResult = $null
$actualValue = $null

try {
switch ($category) {
'AccountPolicy' {
$netAccounts = net accounts |
Out-String | ForEach-Object {
if ($_ -match "$($check.key)\s*:\s*(.+)") { $Matches[1].Trim() }
}
$actualValue = $netAccounts
$testResult = ($actualValue -eq $check.expectedValue)
}

'AuditPolicy' {
$auditLine = auditpol /get /subcategory:"$($check.subcategory)" |
Select-String -Pattern $check.subcategory
if ($auditLine -match 'Success') {
$actualValue = 'Enabled'
}
else {
$actualValue = 'Disabled'
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'Firewall' {
$rule = Get-NetFirewallRule -DisplayName $check.ruleName `
-ErrorAction SilentlyContinue
if ($check.property -eq 'Enabled') {
$actualValue = if ($rule.Enabled -eq $true) { 'True' } else { 'False' }
}
elseif ($check.property -eq 'Direction') {
$actualValue = $rule.Direction.ToString()
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'UserRights' {
$tempFile = [System.IO.Path]::GetTempFileName()
secedit /export /cfg $tempFile /quiet | Out-Null
$secContent = Get-Content -Path $tempFile -Raw
Remove-Item -Path $tempFile -Force

if ($secContent -match "$($check.privilege)\s*=\s*(.+)") {
$actualValue = $Matches[1].Trim()
}
$testResult = ($actualValue -eq $check.expectedValue)
}
}
}
catch {
$actualValue = "Error: $($_.Exception.Message)"
$testResult = $false
}

$result = [PSCustomObject]@{
Category = $category
CheckId = $check.id
Description = $check.description
Expected = $check.expectedValue
Actual = $actualValue
Passed = [bool]$testResult
Severity = $check.severity
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}

$complianceResults.Add($result)

if ($testResult) {
$totalPassed++
Write-Host " [通过] [$($check.id)] $($check.description)" -ForegroundColor Green
}
else {
$totalFailed++
$color = if ($check.severity -eq 'Critical') { 'Red' } else { 'Yellow' }
Write-Host " [失败] [$($check.id)] $($check.description)" -ForegroundColor $color
Write-Host " 期望: $($check.expectedValue) 实际: $actualValue" -ForegroundColor DarkGray
}
}
}

# 输出摘要
$total = $totalPassed + $totalFailed
$passRate = if ($total -gt 0) { [math]::Round(($totalPassed / $total) * 100, 1) } else { 0 }

Write-Host "`n========== 合规性摘要 ==========" -ForegroundColor Cyan
Write-Host " 环境: $EnvironmentName"
Write-Host " 总检查项: $total"
Write-Host " 通过: $totalPassed 失败: $totalFailed"
Write-Host " 合规率: $passRate%"
Write-Host "================================"

# 写入报告文件
$report = [PSCustomObject]@{
Environment = $EnvironmentName
GeneratedAt = Get-Date -Format 'o'
TotalChecks = $total
Passed = $totalPassed
Failed = $totalFailed
PassRate = $passRate
Results = $complianceResults
}
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $ReportPath
Write-Host "报告已保存至: $ReportPath" -ForegroundColor DarkGray

return $report
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PS> Invoke-ComplianceTestSuite -BaselinePath .\cis-baseline.json -EnvironmentName prod -ReportPath .\prod-compliance.json

--- 检查分类: AccountPolicy ---
[通过] [ACC-001] 密码最短长度应为 14 个字符
[失败] [ACC-002] 密码最长使用期限应为 90
期望: 90 实际: 180
--- 检查分类: AuditPolicy ---
[通过] [AUD-001] 登录审核应启用
[通过] [AUD-002] 对象访问审核应启用
--- 检查分类: Firewall ---
[通过] [FW-001] 防火墙域配置文件应启用
[失败] [FW-002] RDP 入站规则应禁用
期望: False 实际: True
--- 检查分类: UserRights ---
[通过] [USR-001] 本地管理员组仅包含授权账户

========== 合规性摘要 ==========
环境: prod
总检查项: 7
通过: 5 失败: 2
合规率: 71.4%
================================
报告已保存至: .\prod-compliance.json

部署验证管道

第三个场景是将基础设施测试集成到部署管道中,实现部署后自动运行验证、基于测试结果判断是否回滚、以及生成可读的测试报告。这是将测试从”手动执行”升级为”自动化防线”的关键一步。

下面的脚本封装了完整的部署验证流程,支持自定义通过阈值、回滚触发、以及 HTML 报告生成:

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

[double]$PassThreshold = 100.0,

[string]$RollbackScriptPath,

[string]$ReportOutputDir = 'ValidationReports',

[string]$DeploymentId = "deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
)

$null = New-Item -ItemType Directory -Path $ReportOutputDir -Force
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "`n========== 部署验证管道 ==========" -ForegroundColor Cyan
Write-Host " 部署ID: $DeploymentId"
Write-Host " 测试路径: $TestPath"
Write-Host " 通过阈值: $PassThreshold%"
Write-Host " 启动时间: $timestamp"
Write-Host "====================================`n"

# 执行 Pester 测试
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = $TestPath
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Normal'

$junitPath = Join-Path $ReportOutputDir "$DeploymentId-junit.xml"
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $junitPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'

$result = Invoke-Pester -Configuration $pesterConfig

# 计算通过率
$totalTests = $result.TotalCount
$passedTests = $result.PassedCount
$failedTests = $result.FailedCount
$passRate = if ($totalTests -gt 0) {
[math]::Round(($passedTests / $totalTests) * 100, 2)
}
else {
0
}

Write-Host "`n--- 测试结果 ---" -ForegroundColor Cyan
Write-Host " 总计: $totalTests 通过: $passedTests 失败: $failedTests"
Write-Host " 通过率: $passRate% (阈值: $PassThreshold%)"

$passed = $passRate -ge $PassThreshold

# 生成 HTML 报告
$htmlPath = Join-Path $ReportOutputDir "$DeploymentId-report.html"
$failedDetails = $result.Tests |
Where-Object { $_.Result -eq 'Failed' } |
ForEach-Object {
"<li>$($_.Name) <br/><pre>$($_.ErrorRecord)</pre></li>"
}

$statusColor = if ($passed) { '#28a745' } else { '#dc3545' }
$statusText = if ($passed) { 'PASSED' } else { 'FAILED' }

$html = @"
<!DOCTYPE html>
<html>
<head><title>部署验证报告 - $DeploymentId</title></head>
<body style='font-family:Segoe UI,sans-serif;margin:40px'>
<h1 style='color:$statusColor'>部署验证: $statusText</h1>
<p>部署ID: $DeploymentId | 时间: $timestamp</p>
<p>总计: $totalTests | 通过: $passedTests | 失败: $failedTests | 通过率: $passRate%</p>
<h3>失败用例:</h3>
<ul>$($failedDetails -join "`n")</ul>
</body></html>
"@
$html | Set-Content -Path $htmlPath -Encoding UTF8
Write-Host " HTML 报告: $htmlPath" -ForegroundColor DarkGray

# 回滚判断
if (-not $passed) {
Write-Host "`n[失败] 通过率 $passRate% 低于阈值 $PassThreshold%" -ForegroundColor Red

if ($RollbackScriptPath -and (Test-Path $RollbackScriptPath)) {
Write-Host "[回滚] 正在执行回滚脚本: $RollbackScriptPath" -ForegroundColor Yellow
try {
& $RollbackScriptPath
Write-Host "[回滚] 回滚完成。" -ForegroundColor Yellow
}
catch {
Write-Host "[回滚失败] $($_.Exception.Message)" -ForegroundColor Red
}
}
else {
Write-Host "[警告] 未指定回滚脚本,请手动处理。" -ForegroundColor Yellow
}
}
else {
Write-Host "`n[通过] 部署验证通过,所有测试均在阈值范围内。" -ForegroundColor Green
}

return [PSCustomObject]@{
DeploymentId = $DeploymentId
Passed = $passed
TotalTests = $totalTests
PassedTests = $passedTests
FailedTests = $failedTests
PassRate = $passRate
HtmlReportPath = $htmlPath
JUnitReportPath = $junitPath
}
}

执行结果示例:

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
PS> Invoke-DeploymentValidation -TestPath .\infra-tests\ -PassThreshold 90 -RollbackScriptPath .\rollback.ps1 -DeploymentId deploy-20260414

========== 部署验证管道 ==========
部署ID: deploy-20260414
测试路径: D:\infra-tests\
通过阈值: 90%
启动时间: 2026-04-14 11:00:00
====================================

Discovery finished in 0.45s.
[+] D:\infra-tests\service.tests.ps1 2.31s (12 tests passed)
[+] D:\infra-tests\firewall.tests.ps1 1.08s (8 tests passed)
[-] D:\infra-tests\registry.tests.ps1 0.92s (19 passed, 1 failed)

--- 测试结果 ---
总计: 40 通过: 39 失败: 1
通过率: 97.5% (阈值: 90%)
HTML 报告: ValidationReports\deploy-20260414-report.html

[通过] 部署验证通过,所有测试均在阈值范围内。

DeploymentId : deploy-20260414
Passed : True
TotalTests : 40
PassedTests : 39
FailedTests : 1
PassRate : 97.5
HtmlReportPath : ValidationReports\deploy-20260414-report.html
JUnitReportPath : ValidationReports\deploy-20260414-junit.xml

注意事项

  1. Pester 版本兼容性:本文使用 Pester v5 的 New-PesterConfigurationNew-PesterContainer API,与 v3/v4 语法不兼容。可通过 Get-Module Pester -ListAvailable 检查已安装版本,如需升级使用 Install-Module Pester -Force -SkipPublisherCheck

  2. 测试用例参数化:基础设施测试最大的挑战是环境差异。建议将所有环境特定的值(服务器名、端口、路径)抽取到外部 JSON 配置文件中,测试脚本本身只包含断言逻辑,这样同一套测试可以在不同环境中复用。

  3. 合规性基线维护:CIS Benchmark 和安全基线会随版本更新,基线 JSON 文件应纳入 Git 版本控制。每次基线更新后,先用 -ReportOnly 模式在非生产环境试运行,确认新规则的误报率可接受后再应用到生产环境。

  4. 部署验证的幂等性:验证管道可能因网络抖动等原因产生偶发失败。建议对关键测试加入重试逻辑(Set-ItResult -Pending 后重试一次),同时在设置 PassThreshold 时留出合理余量(如 95% 而非 100%),避免单次偶发失败阻断整个发布。

  5. 回滚脚本的可靠性:回滚脚本本身也需要测试和验证。建议将回滚脚本纳入版本控制,并在 staging 环境中定期演练。回滚操作应有详细的日志记录,包括回滚前后的状态快照,便于事后审计。

  6. 测试报告的持久化:部署验证生成的 JUnit XML 和 HTML 报告应上传到 CI/CD 平台的制品库(如 Azure Pipelines 的 Publish Test Results 任务),保留至少 90 天。长期积累的测试数据可以帮助识别反复失败的测试用例,从而优化测试套件的稳定性。

PowerShell 技能连载 - Azure AI Search 集成

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

Azure AI Search(原名 Azure 认知搜索)是微软 Azure 云平台中的企业级搜索服务,近年来随着大语言模型的爆发,它在检索增强生成(RAG)架构中扮演着越来越关键的角色。传统企业内部拥有海量非结构化文档,从技术手册到合同范本,从会议纪要到产品规格,这些知识长期”沉睡”在文件服务器和 SharePoint 中,难以被高效检索和利用。Azure AI Search 通过集成文本分词、语义排序和向量检索能力,为这些数据赋予了”可搜索”的生命力。

将 PowerShell 与 Azure AI Search 结合,能够实现搜索服务的全生命周期自动化管理。运维团队可以用脚本一键创建索引、批量导入文档、配置向量字段,甚至搭建完整的 RAG 管道,而无需在 Azure 门户中手动点选。这种方式特别适合 CI/CD 集成和大规模知识库的定期更新场景,让企业知识管理真正做到”基础设施即代码”。

本文将通过三个核心示例,逐步演示如何用 PowerShell 管理 Azure AI Search 的搜索服务与索引结构、实现文档导入与向量化处理,以及构建关键词加向量的混合搜索与 RAG 管道。

搜索服务与索引管理

创建搜索服务并定义索引结构是使用 Azure AI Search 的第一步。下面的脚本展示了如何通过 PowerShell 创建搜索服务、获取管理密钥,然后定义一个支持全文检索和向量检索的复合索引结构。索引中包含文档标题、内容、分类标签等文本字段,以及用于语义搜索的向量字段,同时配置了中文分词器以提升中文内容的检索精度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# 安装并导入必要的模块
Install-Module -Name Az.Search -Force -Scope CurrentUser
Install-Module -Name Az.Resources -Force -Scope CurrentUser
Import-Module Az.Search
Import-Module Az.Resources

# 连接到 Azure 账户
Connect-AzAccount

# 定义变量
$resourceGroupName = 'rg-knowledge-base'
$serviceName = 'knowledge-search-svc'
$location = 'eastus'
$sku = 'basic'

# 创建资源组(如果不存在)
New-AzResourceGroup -Name $resourceGroupName -Location $location -Force

# 创建 Azure AI Search 服务
$searchService = New-AzSearchService `
-ResourceGroupName $resourceGroupName `
-Name $serviceName `
-Sku $sku `
-Location $location `
-PartitionCount 1 `
-ReplicaCount 1

Write-Host "搜索服务创建完成: $($searchService.Name)"

# 获取搜索服务的管理密钥
$adminKey = (Get-AzSearchAdminKeyPair `
-ResourceGroupName $resourceGroupName `
-ServiceName $serviceName).Primary

# 定义索引结构(包含全文搜索字段和向量字段)
$indexDefinition = @{
name = 'documents-index'
fields = @(
@{
name = 'id'
type = 'Edm.String'
key = $true
searchable = $false
},
@{
name = 'title'
type = 'Edm.String'
searchable = $true
analyzer = 'zh-Hans.microsoft'
},
@{
name = 'content'
type = 'Edm.String'
searchable = $true
analyzer = 'zh-Hans.microsoft'
},
@{
name = 'category'
type = 'Edm.String'
searchable = $true
filterable = $true
facetable = $true
},
@{
name = 'tags'
type = 'Collection(Edm.String)'
searchable = $true
filterable = $true
facetable = $true
},
@{
name = 'contentVector'
type = 'Collection(Edm.Single)'
searchable = $true
dimensions = 1536
vectorSearchConfiguration = 'vectorConfig'
},
@{
name = 'createdDate'
type = 'Edm.DateTimeOffset'
filterable = $true
sortable = $true
}
)
vectorSearch = @{
algorithmConfigurations = @(
@{
name = 'vectorConfig'
kind = 'hnsw'
parameters = @{
m = 4
efSearch = 500
efConstruction = 400
}
}
)
}
semantic = @{
configurations = @(
@{
name = 'semanticConfig'
prioritizedFields = @{
titleField = @{ name = 'title' }
contentFields = @(
@{ name = 'content' }
)
}
}
)
}
}

# 使用 REST API 创建索引
$searchUrl = "https://$serviceName.search.windows.net/indexes?api-version=2024-07-01"
$headers = @{
'api-key' = $adminKey
'Content-Type' = 'application/json'
}

$response = Invoke-RestMethod `
-Uri $searchUrl `
-Method Put `
-Headers $headers `
-Body ($indexDefinition | ConvertTo-Json -Depth 10)

Write-Host "索引 '$($indexDefinition.name)' 创建成功"

执行结果示例:

1
2
搜索服务创建完成: knowledge-search-svc
索引 'documents-index' 创建成功

文档导入与向量化

索引创建完成后,下一步是将文档数据批量导入索引。在现代 RAG 架构中,每个文档不仅需要存储原始文本,还需要计算其向量表示(Embedding),以便后续进行语义搜索。下面的脚本演示了如何读取本地文件、调用 Azure OpenAI Embedding API 生成向量、然后批量将文档推送到 Azure AI Search 索引中。

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
# 配置 Azure OpenAI Embedding 端点
$embeddingEndpoint = 'https://your-aoai.openai.azure.com/'
$embeddingKey = (Get-Secret -Name 'AzureOpenAI-Key' -AsPlainText)
$embeddingDeployment = 'text-embedding-ada-002'

# 定义函数:调用 Embedding API 生成向量
function Get-Embedding {
param(
[Parameter(Mandatory)]
[string]$Text
)

$url = "$embeddingEndpoint/openai/deployments/$embeddingDeployment/embeddings?api-version=2024-06-01"
$headers = @{
'api-key' = $embeddingKey
'Content-Type' = 'application/json'
}
$body = @{
input = $Text
} | ConvertTo-Json

$response = Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body
return $response.data[0].embedding
}

# 定义函数:批量导入文档到搜索索引
function Import-DocumentsToIndex {
param(
[Parameter(Mandatory)]
[string]$ServiceName,

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

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

[Parameter(Mandatory)]
[array]$Documents
)

$url = "https://$ServiceName.search.windows.net/indexes/$IndexName/docs/index?api-version=2024-07-01"
$headers = @{
'api-key' = $AdminKey
'Content-Type' = 'application/json'
}

# 构建批量操作请求
$batch = @()
foreach ($doc in $Documents) {
$batch += @{ '@search.action' = 'uploadOrMerge' } + $doc
}

$body = @{ value = $batch } | ConvertTo-Json -Depth 10

$response = Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body
return $response
}

# 读取本地文档目录并生成导入数据
$documentsPath = '/data/knowledge-base'
$files = Get-ChildItem -Path $documentsPath -Include '*.md', '*.txt' -Recurse

$importBatch = @()
$counter = 0

foreach ($file in $files) {
$counter++
$content = Get-Content -Path $file.FullName -Raw -Encoding UTF8

Write-Host "处理文档 [$counter/$($files.Count)]: $($file.Name)"

# 生成内容向量(截取前 8000 字符避免 token 限制)
$textForEmbedding = $content.Substring(0, [Math]::Min($content.Length, 8000))
$vector = Get-Embedding -Text $textForEmbedding

$importBatch += @{
id = $file.BaseName.GetHashCode().ToString('x8')
title = $file.BaseName
content = $content
category = $file.Directory.Name
tags = @($file.Extension.TrimStart('.'), $file.Directory.Name)
contentVector = $vector
createdDate = $file.CreationTime.ToString('o')
}

# 每 100 篇批量提交一次
if ($importBatch.Count -ge 100) {
$result = Import-DocumentsToIndex `
-ServiceName $serviceName `
-AdminKey $adminKey `
-IndexName 'documents-index' `
-Documents $importBatch
Write-Host "批量导入完成,状态: $($result.value[0].status)"
$importBatch = @()
}
}

# 提交剩余文档
if ($importBatch.Count -gt 0) {
$result = Import-DocumentsToIndex `
-ServiceName $serviceName `
-AdminKey $adminKey `
-IndexName 'documents-index' `
-Documents $importBatch
Write-Host "最终批次导入完成,文档数: $($importBatch.Count)"
}

Write-Host "全部文档导入完毕,共处理 $counter 篇"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
处理文档 [1/247]: powershell-best-practices.md
处理文档 [2/247]: azure-vm-management.md
处理文档 [3/247]: security-baseline-audit.md
...
处理文档 [100/247]: container-orchestration.md
批量导入完成,状态: true
处理文档 [101/247]: devops-pipeline.md
...
处理文档 [200/247]: api-gateway-config.md
批量导入完成,状态: true
处理文档 [201/247]: monitoring-setup.md
...
处理文档 [247/247]: network-troubleshooting.md
最终批次导入完成,文档数: 47
全部文档导入完毕,共处理 247 篇

混合搜索与 RAG 管道

Azure AI Search 最强大的能力之一是支持关键词搜索和向量搜索的组合查询,即”混合搜索”。混合搜索能够同时利用精确的关键词匹配和语义级别的相似度计算,显著提升搜索的召回率和准确率。将混合搜索结果喂给大语言模型,就构成了一个完整的 RAG(检索增强生成)管道,让 LLM 基于企业私有数据生成可靠的回答。

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
# 定义函数:执行混合搜索(关键词 + 向量)
function Invoke-HybridSearch {
param(
[Parameter(Mandatory)]
[string]$ServiceName,

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

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

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

[int]$Top = 5
)

# 生成查询文本的向量
$queryVector = Get-Embedding -Text $QueryText

$url = "https://$ServiceName.search.windows.net/indexes/$IndexName/docs/search?api-version=2024-07-01"
$headers = @{
'api-key' = $AdminKey
'Content-Type' = 'application/json'
}

# 构建混合查询:同时包含关键词和向量搜索
$searchBody = @{
search = $QueryText
top = $Top
select = @('id', 'title', 'content', 'category', 'tags', 'createdDate')
vectorQueries = @(
@{
kind = 'vector'
vector = $queryVector
fields = 'contentVector'
k = $Top
}
)
semanticConfiguration = 'semanticConfig'
queryType = 'semantic'
captions = 'extractive'
answers = 'extractive'
} | ConvertTo-Json -Depth 5

$response = Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $searchBody
return $response
}

# 定义函数:RAG 管道 - 检索增强生成
function Invoke-RAGPipeline {
param(
[Parameter(Mandatory)]
[string]$Question,

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

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

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

Write-Host "用户问题: $Question"
Write-Host ('=' * 60)

# 第一步:检索相关文档
Write-Host "[步骤 1] 执行混合搜索..."
$searchResult = Invoke-HybridSearch `
-ServiceName $ServiceName `
-AdminKey $AdminKey `
-IndexName $IndexName `
-QueryText $Question `
-Top 5

Write-Host "检索到 $($searchResult.value.Count) 篇相关文档"

# 第二步:构建上下文
$context = ($searchResult.value | ForEach-Object {
"标题: $($_.title)`n分类: $($_.category)`n内容: $($_.content.Substring(0, [Math]::Min($_.content.Length, 500)))..."
}) -join "`n---`n"

# 第三步:调用 LLM 生成回答
Write-Host "[步骤 2] 调用大语言模型生成回答..."
$llmEndpoint = 'https://your-aoai.openai.azure.com/'
$llmKey = (Get-Secret -Name 'AzureOpenAI-Key' -AsPlainText)
$llmDeployment = 'gpt-4o'

$systemPrompt = @(
'你是一个企业知识库助手。'
'请仅根据以下检索到的文档内容回答用户问题。'
'如果文档中没有相关信息,请明确说明。'
'回答时请标注引用来源的文档标题。'
) -join ''

$chatUrl = "$llmEndpoint/openai/deployments/$llmDeployment/chat/completions?api-version=2024-06-01"
$chatHeaders = @{
'api-key' = $llmKey
'Content-Type' = 'application/json'
}
$chatBody = @{
messages = @(
@{ role = 'system'; content = $systemPrompt },
@{ role = 'user'; content = "参考文档:`n$context`n`n问题:$Question" }
)
temperature = 0.3
max_tokens = 1000
} | ConvertTo-Json -Depth 5

$llmResponse = Invoke-RestMethod -Uri $chatUrl -Method Post -Headers $chatHeaders -Body $chatBody
$answer = $llmResponse.choices[0].message.content

# 第四步:输出结果
Write-Host "[步骤 3] 生成结果:"
Write-Host ('=' * 60)
Write-Host $answer
Write-Host ('=' * 60)

# 输出引用来源
Write-Host "`n参考文档:"
$searchResult.value | ForEach-Object -Begin { $i = 1 } -Process {
$score = if ($_. '@search.score') { $_.'@search.score'.ToString('F4') } else { 'N/A' }
Write-Host " [$i] $($_.title) (相关度: $score, 分类: $($_.category))"
$i++
}

return @{
Answer = $answer
Sources = $searchResult.value | Select-Object -Property title, category
DocCount = $searchResult.value.Count
}
}

# 执行 RAG 查询示例
$result = Invoke-RAGPipeline `
-Question '如何在生产环境中配置 PowerShell 远程管理的安全基线?' `
-ServiceName $serviceName `
-AdminKey $adminKey `
-IndexName 'documents-index'

执行结果示例:

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
用户问题: 如何在生产环境中配置 PowerShell 远程管理的安全基线?
============================================================
[步骤 1] 执行混合搜索...
检索到 5 篇相关文档
[步骤 2] 调用大语言模型生成回答...
[步骤 3] 生成结果:
============================================================
根据检索到的文档,生产环境中配置 PowerShell 远程管理安全基线需要关注以下几个方面:

1. **启用 WinRM over HTTPS**(来源:security-baseline-audit.md)
生产环境必须使用 HTTPS 传输,避免凭据在网络上明文传输...

2. **配置 Just Enough Administration (JEA)**(来源:powershell-best-practices.md)
通过 JEA 端点限制远程用户可执行的命令集...

3. **设置 Constrained Language Mode**(来源:security-hardening-guide.md)
在非管理员会话中启用受限语言模式,阻止危险的 .NET 调用...
============================================================

参考文档:
[1] security-baseline-audit.md (相关度: 0.9234, 分类: security)
[2] powershell-best-practices.md (相关度: 0.8871, 分类: operations)
[3] security-hardening-guide.md (相关度: 0.8653, 分类: security)
[4] winrm-https-config.md (相关度: 0.8412, 分类: networking)
[5] jea-endpoint-setup.md (相关度: 0.8198, 分类: security)

注意事项

  1. API 版本兼容性:Azure AI Search 的 REST API 版本迭代较快,本文使用 2024-07-01 版本。向量搜索和语义排序等高级功能在不同 API 版本中的行为可能存在差异,升级前务必查阅官方迁移指南并在测试环境中验证。

  2. 向量维度必须匹配:索引中向量字段的 dimensions 参数必须与 Embedding 模型输出维度严格一致。例如 text-embedding-ada-002 输出 1536 维,text-embedding-3-small 输出 1536 维(默认),但可配置为更低的维度。导入文档和查询时使用的 Embedding 模型也必须相同。

  3. 批量导入的大小限制:单次批量请求的文档数上限为 1000 篇,请求体总大小不超过大约 16 MB。对于大型文档,建议先进行文本分块(chunking),将长文档拆分为 500-1000 token 的片段后再分别生成向量并导入。

  4. 搜索服务定价层级:语义排序和向量搜索功能需要 Standard 及以上定价层级才可使用。Basic 层级支持基础的全文检索,但不支持向量字段和语义查询。生产环境建议使用 Standard 2 或更高层级以获得最佳性能。

  5. 密钥安全与托管身份:示例中使用管理密钥直接认证,仅适合演示和开发环境。生产环境应使用 Azure 托管身份(Managed Identity)或基于角色的访问控制(RBAC)来管理搜索服务的访问权限,避免在脚本中硬编码密钥。可配合 Get-Secret 从 Azure Key Vault 获取凭据。

  6. 混合搜索结果排序:混合搜索会将关键词匹配得分和向量相似度得分进行融合排序。如果发现纯关键词搜索结果反而优于混合搜索,可以尝试调整 semanticConfiguration 的权重或使用 scoringProfiles 自定义评分逻辑,确保业务场景下的搜索质量最优。

PowerShell 技能连载 - 可观测性与分布式追踪

适用于 PowerShell 7.0 及以上版本

在现代运维和 DevOps 实践中,可观测性(Observability)已成为系统可靠性的基石。与传统的被动监控不同,可观测性强调从系统外部输出推断内部状态的能力,其核心由三大支柱构成:日志(Logging)记录离散事件、指标(Metrics)量化系统状态、追踪(Tracing)串联请求链路。当 PowerShell 脚本从简单的自动化任务演进为跨系统编排工具时,缺乏可观测性就意味着排障时如同大海捞针。

在云原生环境中,一个运维操作可能横跨多个子系统:先调用 Azure API 获取资源列表,再通过 SSH 配置远程服务器,最后更新 CMDB 数据库。如果其中某个环节失败,仅凭零散的 Write-Host 输出几乎无法定位根因。通过引入结构化日志、指标采集和分布式追踪,我们可以为每条执行链路建立完整的”数字指纹”,让问题排查从猜测变为精确诊断。

本文将从三个层面逐步构建 PowerShell 脚本的可观测性体系:首先搭建统一的日志框架,确保所有脚本输出格式一致且可检索;然后实现性能指标采集,量化脚本的资源消耗与执行效率;最后引入分布式追踪机制,打通跨脚本、跨进程的调用链路。

结构化日志框架

良好的日志系统是可观测性的起点。与随意使用 Write-Host 不同,结构化日志要求每条日志都包含时间戳、级别、消息体和上下文信息,并以 JSON 格式输出,便于后续被 ELK、Splunk 或 Azure Log Analytics 等平台直接消费。

以下代码构建了一个轻量级的结构化日志模块,支持多级别控制、上下文注入和双通道输出(控制台 + 文件):

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
# 构建结构化日志框架
enum LogLevel {
Debug = 0
Information = 1
Warning = 2
Error = 3
Critical = 4
}

function New-StructuredLogger {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogName,

[string]$LogDirectory = './logs',

[LogLevel]$MinimumLevel = [LogLevel]::Information,

[hashtable]$DefaultContext = @{}
)

# 确保日志目录存在
if (-not (Test-Path $LogDirectory)) {
New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null
}

$logFile = Join-Path $LogDirectory "$LogName-$(Get-Date -Format 'yyyyMMdd').json"

# 返回日志记录器对象
[PSCustomObject]@{
Name = $LogName
LogFile = $logFile
MinimumLevel = $MinimumLevel
DefaultContext = $DefaultContext
} | Add-Member -MemberType ScriptMethod -Name 'Write' -Value {
param(
[LogLevel]$Level,
[string]$Message,
[hashtable]$AdditionalContext = @{},
[System.Management.Automation.ErrorRecord]$ErrorRecord = $null
)

# 级别过滤
if ($Level -lt $this.MinimumLevel) { return }

$entry = [ordered]@{
timestamp = (Get-Date).ToString('o')
level = $Level.ToString()
logger = $this.Name
message = $Message
context = [ordered]@{}
}

# 合并默认上下文和附加上下文
foreach ($key in $this.DefaultContext.Keys) {
$entry.context[$key] = $this.DefaultContext[$key]
}
foreach ($key in $AdditionalContext.Keys) {
$entry.context[$key] = $AdditionalContext[$key]
}

# 异常信息
if ($ErrorRecord) {
$entry.exception = @{
type = $ErrorRecord.Exception.GetType().FullName
message = $ErrorRecord.Exception.Message
stackTrace = $ErrorRecord.ScriptStackTrace
}
}

$jsonLine = $entry | ConvertTo-Json -Depth 5 -Compress

# 写入文件
$jsonLine | Out-File -FilePath $this.LogFile -Append -Encoding utf8

# 控制台着色输出
$color = switch ($Level) {
([LogLevel]::Debug) { 'Gray' }
([LogLevel]::Information) { 'White' }
([LogLevel]::Warning) { 'Yellow' }
([LogLevel]::Error) { 'Red' }
([LogLevel]::Critical) { 'Magenta' }
default { 'White' }
}
Write-Host "[$($Level.ToString().ToUpper())] $Message" -ForegroundColor $color
} -Force -PassThru
}

# 创建日志记录器实例
$logger = New-StructuredLogger `
-LogName 'deployment-pipeline' `
-MinimumLevel ([LogLevel]::Debug) `
-DefaultContext @{ environment = 'production'; host = $env:COMPUTERNAME }

# 记录不同级别的日志
$logger.Write([LogLevel]::Information, '开始部署流程', @{ version = '2.4.1' })
$logger.Write([LogLevel]::Debug, '加载配置文件', @{ configPath = '/etc/app/config.yaml' })
$logger.Write([LogLevel]::Warning, '检测到内存使用率偏高', @{ memoryUsage = '82%' })

# 模拟一个异常场景
try {
throw '数据库连接超时'
} catch {
$logger.Write([LogLevel]::Error, '部署流程中断', @{ step = 'database-migration' }, $_)
}

执行结果示例:

1
2
3
4
[INFORMATION] 开始部署流程
[DEBUG] 加载配置文件
[WARNING] 检测到内存使用率偏高
[ERROR] 部署流程中断

每条日志以 JSON Lines 格式追加到文件中,可被日志平台直接索引。DefaultContext 参数允许注入环境名、主机名等全局上下文,避免在每条日志中重复传递。通过调整 MinimumLevel,可以在生产环境中过滤掉 Debug 级别的日志,减少存储开销。

性能指标采集

指标是可观测性的量化核心。与日志不同,指标关注的是可聚合的数值数据,如执行时长、内存增量、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
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
# 性能指标采集器
function New-MetricsCollector {
[CmdletBinding()]
param(
[string]$ServiceName = 'powershell-script',
[string]$OutputPath = './metrics'
)

if (-not (Test-Path $OutputPath)) {
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
}

$metrics = [System.Collections.Concurrent.ConcurrentDictionary[string, PSObject]]::new()

$collector = [PSCustomObject]@{
ServiceName = $ServiceName
StartTime = Get-Date
OutputPath = $OutputPath
}

# 计数器:递增统计
$collector | Add-Member -MemberType ScriptMethod -Name 'Increment' -Value {
param([string]$MetricName, [int64]$Value = 1, [hashtable]$Tags = @{})

$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$metrics.AddOrUpdate(
$MetricName,
{ [PSCustomObject]@{ type = 'counter'; value = $Value; tags = $Tags; updatedAt = $now } },
{ param($key, $existing)
$existing.value += $Value
$existing.updatedAt = $now
$existing
}
)
} -Force

# 计时器:测量执行时长
$collector | Add-Member -MemberType ScriptMethod -Name 'Measure' -Value {
param(
[string]$MetricName,
[scriptblock]$Action,
[hashtable]$Tags = @{}
)

$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$result = & $Action
$stopwatch.Stop()

$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$metric = [PSCustomObject]@{
type = 'timer'
value = $stopwatch.ElapsedMilliseconds
unit = 'ms'
tags = $Tags
updatedAt = $now
}
$metrics[$MetricName] = $metric

$result
} -Force

# 仪表盘:记录当前值
$collector | Add-Member -MemberType ScriptMethod -Name 'Gauge' -Value {
param([string]$MetricName, [double]$Value, [string]$Unit = '', [hashtable]$Tags = @{})

$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$metrics[$MetricName] = [PSCustomObject]@{
type = 'gauge'
value = $Value
unit = $Unit
tags = $Tags
updatedAt = $now
}
} -Force

# 导出为 OpenTelemetry 兼容格式
$collector | Add-Member -MemberType ScriptMethod -Name 'Export' -Value {
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$exportFile = Join-Path $this.OutputPath "$($this.ServiceName)-metrics-$timestamp.json"

$exportData = @{
resource = @{
'service.name' = $this.ServiceName
'service.version' = '1.0.0'
'host.name' = $env:COMPUTERNAME
}
scopeMetrics = @(
@{
scope = @{ name = 'powershell-metrics' }
metrics = @()
}
)
timestamp = (Get-Date).ToString('o')
}

foreach ($key in $metrics.Keys) {
$m = $metrics[$key]
$metricEntry = [ordered]@{
name = $key
type = $m.type
value = $m.value
}
if ($m.unit) { $metricEntry.unit = $m.unit }
if ($m.tags.Count -gt 0) { $metricEntry.tags = $m.tags }
$exportData.scopeMetrics[0].metrics += $metricEntry
}

$exportData | ConvertTo-Json -Depth 5 | Out-File -FilePath $exportFile -Encoding utf8
Write-Host "指标已导出到:$exportFile"
Write-Host "共采集 $($metrics.Count) 项指标"
} -Force

$collector
}

# 使用示例:采集脚本执行的各项指标
$metrics = New-MetricsCollector -ServiceName 'backup-job'

# 计数器:统计处理的文件数量
1..10 | ForEach-Object { $metrics.Increment('files.processed') }
$metrics.Increment('errors.total', 2)

# 计时器:测量数据库备份耗时
$dbBackupTime = $metrics.Measure('db.backup.duration', {
Start-Sleep -Milliseconds 150
'backup completed'
}, @{ database = 'app_db' })

# 计时器:测量文件上传耗时
$metrics.Measure('upload.duration', {
Start-Sleep -Milliseconds 80
}, @{ destination = 'azure-blob' })

# 仪表盘:记录当前内存使用
$mem = [System.GC]::GetTotalMemory($false)
$metrics.Gauge('memory.usage.bytes', $mem, 'bytes')
$metrics.Gauge('disk.free.percent', 67.3, 'percent', @{ drive = 'C:' })

# 导出所有指标
$metrics.Export()

执行结果示例:

1
2
指标已导出到:./metrics/backup-job-metrics-20260410-080000.json
共采集 5 项指标

导出的 JSON 遵循 OpenTelemetry 的资源-作用域-指标层级结构,可以被 Prometheus、Grafana 或 Jaeger 等工具链直接消费。Measure 方法使用 System.Diagnostics.Stopwatch 提供毫秒级精度的计时,适用于需要精确度量的 I/O 操作和 API 调用场景。

分布式追踪实现

分布式追踪是可观测性三大支柱中最复杂但也最有价值的部分。一个 Trace 由多个 Span 组成,每个 Span 代表一个操作单元。当 PowerShell 脚本编排多个外部系统调用时,通过 Span 和 Trace ID 将它们串联起来,就能在 Jaeger 或 Zipkin 中看到完整的调用拓扑。

以下代码实现了一个兼容 OpenTelemetry 概念的追踪框架,支持嵌套 Span、上下文传播和 OTLP 格式导出:

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
191
192
193
194
195
196
197
198
# 分布式追踪实现
function New-TraceContext {
[CmdletBinding()]
param(
[string]$TraceId = (1..32 | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) -join '',
[string]$ParentSpanId = $null
)

$spanId = (1..16 | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) -join ''

[PSCustomObject]@{
traceId = $TraceId
spanId = $spanId
parentSpanId = $ParentSpanId
startTime = [DateTimeOffset]::UtcTime
attributes = [ordered]@{}
events = [System.Collections.Generic.List[PSObject]]::new()
status = 'OK'
}
}

function New-Tracer {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ServiceName,

[string]$ExporterPath = './traces'
)

if (-not (Test-Path $ExporterPath)) {
New-Item -ItemType Directory -Path $ExporterPath -Force | Out-Null
}

# Span 栈:支持嵌套调用
$spanStack = [System.Collections.Stack]::new()
$completedSpans = [System.Collections.Generic.List[PSObject]]::new()

$tracer = [PSCustomObject]@{
ServiceName = $ServiceName
ExporterPath = $ExporterPath
}

# 开始一个新的 Span
$tracer | Add-Member -MemberType ScriptMethod -Name 'StartSpan' -Value {
param(
[string]$Name,
[hashtable]$Attributes = @{}
)

$parentSpanId = if ($spanStack.Count -gt 0) { $spanStack.Peek().spanId } else { $null }
$traceId = if ($spanStack.Count -gt 0) { $spanStack.Peek().traceId } else { $null }

$span = New-TraceContext -TraceId $traceId -ParentSpanId $parentSpanId
$span | Add-Member -MemberType NoteProperty -Name 'name' -Value $Name

foreach ($key in $Attributes.Keys) {
$span.attributes[$key] = $Attributes[$key]
}

# 注入系统信息
$span.attributes['service.name'] = $this.ServiceName
$span.attributes['host.name'] = $env:COMPUTERNAME

$spanStack.Push($span)
$span
} -Force

# 添加事件到当前 Span
$tracer | Add-Member -MemberType ScriptMethod -Name 'AddEvent' -Value {
param([string]$EventName, [hashtable]$Attributes = @{})

if ($spanStack.Count -eq 0) {
Write-Warning '没有活跃的 Span,无法添加事件'
return
}

$currentSpan = $spanStack.Peek()
$currentSpan.events.Add([PSCustomObject]@{
name = $EventName
timestamp = [DateTimeOffset]::UtcTime.ToString('o')
attributes = $Attributes
})
} -Force

# 结束当前 Span
$tracer | Add-Member -MemberType ScriptMethod -Name 'EndSpan' -Value {
param([string]$Status = 'OK')

if ($spanStack.Count -eq 0) {
Write-Warning '没有活跃的 Span 可以结束'
return
}

$span = $spanStack.Pop()
$endTime = [DateTimeOffset]::UtcTime
$duration = ($endTime - $span.startTime).TotalMilliseconds

$span.status = $Status

$completedSpan = [ordered]@{
traceId = $span.traceId
spanId = $span.spanId
parentSpanId = $span.parentSpanId
name = $span.name
startTime = $span.startTime.ToString('o')
endTime = $endTime.ToString('o')
durationMs = [math]::Round($duration, 2)
status = $span.status
attributes = $span.attributes
events = $span.events.ToArray()
resource = [ordered]@{
'service.name' = $this.ServiceName
'service.version' = '1.0.0'
'telemetry.sdk.name' = 'powershell-custom'
}
}

$completedSpans.Add($completedSpan)
$completedSpan
} -Force

# 导出为 OTLP JSON 格式
$tracer | Add-Member -MemberType ScriptMethod -Name 'Export' -Value {
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$exportFile = Join-Path $this.ExporterPath "$($this.ServiceName)-trace-$timestamp.json"

$otlpExport = @{
resourceSpans = @(
@{
resource = @{
attributes = @(
@{ key = 'service.name'; value = @{ stringValue = $this.ServiceName } }
)
}
scopeSpans = @(
@{
scope = @{ name = 'powershell-tracer' }
spans = $completedSpans
}
)
}
)
}

$otlpExport | ConvertTo-Json -Depth 8 | Out-File -FilePath $exportFile -Encoding utf8
Write-Host "追踪数据已导出到:$exportFile"
Write-Host "共记录 $($completedSpans.Count) 个 Span"

# 输出调用链摘要
Write-Host "`n--- 调用链摘要 ---"
foreach ($s in $completedSpans) {
$indent = if ($s.parentSpanId) { ' ' } else { '' }
Write-Host "$indent[$($s.status)] $($s.name) - $($s.durationMs)ms"
}
} -Force

$tracer
}

# 模拟一个完整的多层调用链
$tracer = New-Tracer -ServiceName 'deployment-pipeline'

# 根 Span:整个部署流程
$rootSpan = $tracer.StartSpan('deploy.release', @{ version = '2.4.1'; environment = 'production' })

# 子 Span 1:拉取代码
$pullSpan = $tracer.StartSpan('git.pull', @{ repository = 'app-service' })
Start-Sleep -Milliseconds 50
$tracer.AddEvent('checkout_complete', @{ branch = 'main'; commit = 'a1b2c3d' })
$tracer.EndSpan()

# 子 Span 2:运行测试
$testSpan = $tracer.StartSpan('test.run', @{ framework = 'pytest' })
$tracer.AddEvent('test_started', @{ totalTests = 142 })
Start-Sleep -Milliseconds 120

# 孙 Span:代码覆盖率分析
$coverageSpan = $tracer.StartSpan('test.coverage', @{ threshold = '80%' })
Start-Sleep -Milliseconds 30
$tracer.AddEvent('coverage_calculated', @{ lineCoverage = '87.3%'; branchCoverage = '82.1%' })
$coverageResult = $tracer.EndSpan()

$tracer.AddEvent('test_completed', @{ passed = 140; failed = 2 })
$testResult = $tracer.EndSpan()

# 子 Span 3:部署到生产
$deploySpan = $tracer.StartSpan('deploy.push', @{ target = 'k8s-cluster-01' })
$tracer.AddEvent('rolling_update_started', @{ replicas = 3 })
Start-Sleep -Milliseconds 80
$tracer.AddEvent('health_check_passed')
$deployResult = $tracer.EndSpan()

# 结束根 Span
$tracer.EndSpan()

# 导出追踪数据
$tracer.Export()

执行结果示例:

1
2
3
4
5
6
7
8
9
追踪数据已导出到:./traces/deployment-pipeline-trace-20260410-080000.json
共记录 5 个 Span

--- 调用链摘要 ---
[OK] deploy.release - 284.37ms
[OK] git.pull - 52.14ms
[OK] test.run - 153.89ms
[OK] test.coverage - 33.27ms
[OK] deploy.push - 83.41ms

通过 spanStack 实现的嵌套机制,子 Span 自动继承父 Span 的 traceIdspanId,形成完整的调用树。导出格式兼容 OTLP JSON,可以直接发送到 OpenTelemetry Collector,再由 Collector 转发到 Jaeger、Zipkin 或 Tempo 等后端进行可视化分析。

注意事项

  1. 日志级别与环境匹配:开发环境建议将 MinimumLevel 设为 Debug,生产环境至少设为 Information。过于详细的日志在生产环境会产生大量 I/O 开销,尤其是在高频循环中记录日志时需格外谨慎。

  2. JSON 序列化深度ConvertTo-Json 默认深度仅为 2,嵌套的 hashtablePSCustomObject 会被截断为字符串。在日志和指标导出中务必指定 -Depth 5 或更高,否则结构化数据会丢失。

  3. Trace ID 的唯一性:示例中使用 Get-Random 生成 Trace/Span ID 仅供演示,生产环境建议使用 [guid]::NewGuid().ToString('N') 或基于时间戳的确定性算法,确保全局唯一性。

  4. 大对象内存压力:长时间运行的脚本中,$completedSpans 列表可能累积大量数据。建议设置导出阈值(如每 1000 个 Span 自动导出并清空),或在 EndSpan 时直接流式写入文件,避免内存持续增长。

  5. 线程安全考量ConcurrentDictionary 用于指标存储是线程安全的,但 Span 的栈操作(Push/Pop)不是。如果你的脚本使用 ForEach-Object -Parallel,每个 runspace 应拥有独立的 Tracer 实例,最后再合并导出。

  6. 与 OpenTelemetry Collector 集成:生产环境中,建议将导出的 JSON 文件通过 Filelog Receiver 或 HTTP Receiver 发送到 OpenTelemetry Collector,由 Collector 统一处理采样、批处理和路由,而不是让每个脚本直接对接后端存储。

PowerShell 技能连载 - WinGet 高级包管理

适用于 PowerShell 7.0 及以上版本,Windows 10/11 内置 WinGet

从安装工具到企业级管理平台

随着 Windows Package Manager(WinGet)的持续迭代,它已经从一个简单的命令行安装工具成长为 Windows 平台软件生命周期管理的核心组件。微软在 2025 年为 WinGet 引入了 winget configurewinget export 等新能力,使其在配置即代码(Configuration as Code)方向上迈出了关键一步。对于运维团队而言,WinGet 不再只是”装软件的工具”,而是可以纳入 CI/CD 流水线的基础设施自动化组件。

在企业环境中,软件版本管理、环境一致性和更新回滚是最让人头疼的三件事。开发团队抱怨”我本地能跑但你那里不行”,安全团队要求所有软件必须及时打补丁,运维团队则担心某个更新导致大面积故障。这些需求都可以通过 PowerShell 与 WinGet 的深度集成来解决。

本文将从三个高级场景出发:软件版本审计与对比、YAML 配置驱动的声明式部署、更新自动化与安全回滚,展示如何用 PowerShell 构建企业级的 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
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
function Compare-WinGetBaseline {
<#
.SYNOPSIS
将本机已安装软件与基准版本清单对比,输出偏差报告
.PARAMETER BaselinePath
基准 JSON 文件路径,包含期望的软件版本
.PARAMETER ExportCurrent
将当前已安装软件导出为基准文件(用于首次建立基准)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$BaselinePath,
[switch]$ExportCurrent
)

# 采集当前已安装软件
$rawOutput = winget list --accept-source-agreements 2>&1
$installed = $rawOutput | Where-Object {
$_ -match '^\S' -and
$_ -notmatch '^Name\s+Id' -and
$_ -notmatch '^\d+ packages'
} | ForEach-Object {
$parts = $_ -split '\s{2,}'
if ($parts.Count -ge 3) {
[PSCustomObject]@{
Name = $parts[0].Trim()
Id = $parts[1].Trim()
Version = if ($parts.Count -ge 4) { $parts[2].Trim() } else { '未知' }
Available = if ($parts.Count -ge 4) { $parts[3].Trim() } else {
if ($parts.Count -eq 3) { $parts[2].Trim() } else { '' }
}
}
}
}

# 导出模式:将当前状态保存为基准
if ($ExportCurrent) {
$baseline = $installed | ForEach-Object {
[ordered]@{ Id = $_.Id; Version = $_.Version }
}
$baseline | ConvertTo-Json -Depth 3 | Set-Content $BaselinePath -Encoding UTF8
Write-Host "基准已导出至 $BaselinePath ($($baseline.Count) 个软件包)" `
-ForegroundColor Green
return
}

# 对比模式:加载基准并与当前状态比较
$baselineRaw = Get-Content $BaselinePath -Raw | ConvertFrom-Json
$baselineMap = @{}
foreach ($item in $baselineRaw) {
$baselineMap[$item.Id] = $item.Version
}

$results = foreach ($pkg in $installed) {
if ($baselineMap.ContainsKey($pkg.Id)) {
$expected = $baselineMap[$pkg.Id]
$status = if ($pkg.Version -eq $expected) { '匹配' }
elseif ([version]::TryParse($pkg.Version, [ref]$null) -and
[version]::TryParse($expected, [ref]$null)) {
if ([version]$pkg.Version -gt [version]$expected) { '更新' }
else { '过旧' }
} else { '不同' }
[PSCustomObject]@{
Name = $pkg.Name
Id = $pkg.Id
Current = $pkg.Version
Expected = $expected
Status = $status
}
}
}

$results | Sort-Object Status | Format-Table -AutoSize
$summary = $results | Group-Object Status | ForEach-Object {
"$($_.Name): $($_.Count)"
}
Write-Host "版本偏差汇总: $($summary -join ', ')" -ForegroundColor Cyan
}

# 首次建立基准
# Compare-WinGetBaseline -BaselinePath './baseline.json' -ExportCurrent

# 后续对比检查
# Compare-WinGetBaseline -BaselinePath './baseline.json'

上面的脚本核心逻辑是:先用 winget list 采集当前已安装软件,再将结果与基准 JSON 文件逐项对比。首次使用时通过 -ExportCurrent 参数将当前状态保存为基准文件,后续巡检时直接运行对比即可。状态分为”匹配”、”更新”、”过旧”和”不同”四种,其中”更新”表示本机版本比基准还新(可能是自行升级的未记录变更),”过旧”则是需要关注的版本偏差。

1
2
3
4
5
6
7
8
9
10
Name                Id                            Current  Expected Status
---- -- ------- -------- ------
7-Zip 7zip.7zip 24.09 24.09 匹配
Visual Studio Code Microsoft.VisualStudioCode 1.98.2 1.97.0 更新
Git Git.Git 2.47.1 2.48.0 过旧
PowerShell Microsoft.PowerShell 7.5.0 7.5.0 匹配
Node.js LTS OpenJS.NodeJS.LTS 22.14.0 22.12.0 更新
Docker Desktop Docker.DockerDesktop 4.34.0 4.35.0 过旧

版本偏差汇总: 匹配: 2, 更新: 2, 过旧: 2

YAML 配置驱动的声明式部署

在企业环境中,软件部署配置通常需要纳入版本控制,并与团队共享。YAML 格式比 JSON 更易读、支持注释,非常适合作为声明式部署配置的载体。下面的脚本读取 YAML 配置文件,确保目标机器的软件状态与声明的期望状态一致。

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
function Invoke-WinGetDesiredState {
<#
.SYNOPSIS
读取 YAML 声明式配置,确保软件状态与期望一致
.PARAMETER ConfigPath
YAML 配置文件路径
.PARAMETER ReportOnly
仅生成偏差报告,不执行实际安装或卸载
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ConfigPath,
[switch]$ReportOnly
)

# 读取 YAML 配置(需要 powershell-yaml 模块或手动解析)
$configContent = Get-Content $ConfigPath -Raw

# 简易 YAML 解析:提取 packages 节点下的条目
$lines = $configContent -split "`n"
$desiredPackages = @{}
$currentSection = ''

foreach ($line in $lines) {
if ($line -match '^\s*#') { continue }
if ($line -match '^packages:') {
$currentSection = 'packages'
continue
}
if ($currentSection -eq 'packages' -and
$line -match '^\s{2}(\S+):\s*$') {
$pkgId = $Matches[1]
$desiredPackages[$pkgId] = @{ State = 'present'; Version = '' }
$currentSection = "pkg_$pkgId"
}
if ($line -match '^\s{4}state:\s*(\S+)') {
$desiredPackages[$pkgId]['State'] = $Matches[1]
}
if ($line -match '^\s{4}version:\s*(\S+)') {
$desiredPackages[$pkgId]['Version'] = $Matches[1]
}
if ($line -match '^[^\s]') {
$currentSection = ''
}
}

# 获取当前已安装软件
$rawOutput = winget list --accept-source-agreements 2>&1
$installedIds = @()
$installedMap = @{}

$rawOutput | Where-Object {
$_ -match '^\S' -and
$_ -notmatch '^Name\s+Id' -and
$_ -notmatch '^\d+ packages'
} | ForEach-Object {
$parts = $_ -split '\s{2,}'
if ($parts.Count -ge 2) {
$id = $parts[1].Trim()
$installedIds += $id
$installedMap[$id] = if ($parts.Count -ge 4) {
$parts[2].Trim()
} else { '未知' }
}
}

# 计算偏差并执行收敛
$actions = @()

foreach ($id in $desiredPackages.Keys) {
$desired = $desiredPackages[$id]
if ($desired.State -eq 'present') {
if ($id -notin $installedIds) {
$actions += [PSCustomObject]@{
Action = '安装'
Id = $id
Target = $desired.Version
}
} elseif ($desired.Version -and
$installedMap[$id] -ne $desired.Version) {
$actions += [PSCustomObject]@{
Action = '版本调整'
Id = $id
Target = "$($installedMap[$id]) -> $($desired.Version)"
}
}
} elseif ($desired.State -eq 'absent' -and $id -in $installedIds) {
$actions += [PSCustomObject]@{
Action = '卸载'
Id = $id
Target = $installedMap[$id]
}
}
}

Write-Host "配置偏差分析完成,共 $($actions.Count) 项操作:" `
-ForegroundColor Cyan
$actions | Format-Table -AutoSize

if ($ReportOnly) {
Write-Host "[报告模式] 不执行实际操作" -ForegroundColor DarkYellow
return
}

foreach ($act in $actions) {
switch ($act.Action) {
'安装' {
$args = @('install', '--id', $act.Id,
'--accept-package-agreements',
'--accept-source-agreements')
if ($desiredPackages[$act.Id].Version) {
$args += @('--version', $desiredPackages[$act.Id].Version)
}
Write-Host "安装 $($act.Id)..." -ForegroundColor Yellow -NoNewline
winget @args 2>&1 | Out-Null
}
'卸载' {
Write-Host "卸载 $($act.Id)..." -ForegroundColor Yellow -NoNewline
winget uninstall --id $act.Id 2>&1 | Out-Null
}
'版本调整' {
Write-Host "调整 $($act.Id) 版本..." -ForegroundColor Yellow -NoNewline
winget install --id $act.Id --version `
$desiredPackages[$act.Id].Version `
--force --accept-package-agreements `
--accept-source-agreements 2>&1 | Out-Null
}
}
if ($LASTEXITCODE -eq 0) {
Write-Host " 完成" -ForegroundColor Green
} else {
Write-Host " 失败 (退出码: $LASTEXITCODE)" -ForegroundColor Red
}
}
Write-Host "`n状态收敛完成" -ForegroundColor Green
}

这段脚本实现了一个声明式配置引擎:读取 YAML 配置文件中声明的期望状态(presentabsent),与本机实际状态对比,计算出需要执行的操作列表(安装、卸载、版本调整),然后逐一收敛。-ReportOnly 模式下仅展示偏差报告,不执行实际操作,适合在 CI 流水线中作为审计步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
配置偏差分析完成,共 4 项操作:

Action Id Target
------ -- ------
安装 Microsoft.VisualStudioCode 1.98.2
安装 Docker.DockerDesktop 4.35.0
卸载 SomeApp.Legacy 2.1.0
版本调整 Git.Git 2.47.1 -> 2.48.0

安装 Microsoft.VisualStudioCode... 完成
安装 Docker.DockerDesktop... 完成
卸载 SomeApp.Legacy... 完成
调整 Git.Git 版本... 完成

状态收敛完成

更新自动化与安全回滚

自动更新是双刃剑:及时打补丁能堵住安全漏洞,但一次不兼容的更新可能让整个团队停工。下面的脚本在执行自动更新前会先创建软件快照,如果更新后出现问题,可以从快照恢复到更新前的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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
function Invoke-WinGetSafeUpdate {
<#
.SYNOPSIS
安全更新:更新前快照,支持回滚
.PARAMETER SnapshotDir
快照存储目录
.PARAMETER MaxRetries
单个软件包更新失败后的最大重试次数
.PARAMETER RollbackOnFailure
任一软件更新失败时自动回滚所有已更新的软件
#>
[CmdletBinding()]
param(
[string]$SnapshotDir = (Join-Path $env:TEMP 'WinGetSnapshots'),
[int]$MaxRetries = 2,
[switch]$RollbackOnFailure
)

if (-not (Test-Path $SnapshotDir)) {
New-Item -ItemType Directory -Path $SnapshotDir | Out-Null
}

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$snapshotPath = Join-Path $SnapshotDir "snapshot_$timestamp.json"

# 第一步:创建当前软件版本快照
Write-Host "[1/4] 正在创建软件版本快照..." -ForegroundColor Cyan
$rawOutput = winget list --accept-source-agreements 2>&1
$snapshot = $rawOutput | Where-Object {
$_ -match '^\S' -and
$_ -notmatch '^Name\s+Id' -and
$_ -notmatch '^\d+ packages'
} | ForEach-Object {
$parts = $_ -split '\s{2,}'
if ($parts.Count -ge 3) {
[ordered]@{
Name = $parts[0].Trim()
Id = $parts[1].Trim()
Version = if ($parts.Count -ge 4) {
$parts[2].Trim()
} else { '未知' }
}
}
}

$snapshot | ConvertTo-Json -Depth 3 | Set-Content $snapshotPath -Encoding UTF8
Write-Host "快照已保存: $snapshotPath ($($snapshot.Count) 个软件包)" `
-ForegroundColor Green

# 第二步:扫描可更新的软件
Write-Host "`n[2/4] 正在扫描可更新的软件..." -ForegroundColor Cyan
$upgradeOutput = winget upgrade 2>&1
$upgradable = $upgradeOutput | Where-Object {
$_ -match '^\S' -and
$_ -notmatch '^Name\s+Id' -and
$_ -notmatch '^\d+ upgrades'
} | ForEach-Object {
$parts = $_ -split '\s{2,}'
if ($parts.Count -ge 4) {
[PSCustomObject]@{
Name = $parts[0].Trim()
Id = $parts[1].Trim()
OldVer = $parts[2].Trim()
NewVer = $parts[3].Trim()
}
}
}

if (-not $upgradable) {
Write-Host "所有软件已是最新版本,无需更新" -ForegroundColor Green
return
}

Write-Host "发现 $($upgradable.Count) 个可更新软件包:" -ForegroundColor Yellow
$upgradable | Format-Table Name, Id, OldVer, NewVer -AutoSize

# 第三步:逐个执行更新并记录结果
Write-Host "`n[3/4] 开始安全更新..." -ForegroundColor Cyan
$results = @()
$failed = @()

foreach ($pkg in $upgradable) {
$success = $false
$attempt = 0

while (-not $success -and $attempt -lt $MaxRetries) {
$attempt++
Write-Host " 更新 $($pkg.Id) ($($pkg.OldVer) -> $($pkg.NewVer))" `
-ForegroundColor Yellow -NoNewline
if ($attempt -gt 1) {
Write-Host " [重试 $attempt]" -ForegroundColor DarkYellow -NoNewline
}

winget upgrade --id $pkg.Id --accept-package-agreements `
--accept-source-agreements 2>&1 | Out-Null

if ($LASTEXITCODE -eq 0) {
Write-Host " 完成" -ForegroundColor Green
$success = $true
$results += [PSCustomObject]@{
Id = $pkg.Id
OldVer = $pkg.OldVer
NewVer = $pkg.NewVer
Status = '成功'
Attempts = $attempt
}
} else {
Write-Host " 失败" -ForegroundColor Red
}
}

if (-not $success) {
$failed += $pkg
$results += [PSCustomObject]@{
Id = $pkg.Id
OldVer = $pkg.OldVer
NewVer = $pkg.NewVer
Status = '失败'
Attempts = $MaxRetries
}
}
}

# 第四步:失败时自动回滚
Write-Host "`n[4/4] 更新结果汇总:" -ForegroundColor Cyan
$results | Format-Table Id, OldVer, NewVer, Status, Attempts -AutoSize

if ($failed -and $RollbackOnFailure) {
Write-Host "`n检测到更新失败,正在回滚到快照版本..." -ForegroundColor Red
$snapshotData = Get-Content $snapshotPath -Raw | ConvertFrom-Json
$updatedIds = $results | Where-Object { $_.Status -eq '成功' } |
Select-Object -ExpandProperty Id

foreach ($id in $updatedIds) {
$entry = $snapshotData | Where-Object { $_.Id -eq $id }
if ($entry -and $entry.Version -ne '未知') {
Write-Host " 回滚 $($entry.Id) 到 $($entry.Version)..." `
-ForegroundColor Yellow -NoNewline
winget install --id $entry.Id --version $entry.Version `
--force --accept-package-agreements `
--accept-source-agreements 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " 完成" -ForegroundColor Green
} else {
Write-Host " 回滚失败" -ForegroundColor Red
}
}
}
Write-Host "回滚流程结束" -ForegroundColor Cyan
} elseif ($failed) {
Write-Host "`n提示: 更新失败的软件可使用以下命令手动回滚:" -ForegroundColor Yellow
Write-Host " 回滚快照文件: $snapshotPath" -ForegroundColor DarkYellow
foreach ($f in $failed) {
Write-Host " winget install --id $($f.Id) " + `
"--version $($f.OldVer) --force" -ForegroundColor DarkYellow
}
}

Write-Host "`n快照文件: $snapshotPath" -ForegroundColor DarkGray
Write-Host "可使用以下命令查看历史快照: Get-ChildItem $SnapshotDir" `
-ForegroundColor DarkGray
}

这个脚本的四步流程设计体现了”先快照、再更新、后回滚”的安全策略。第一步将当前所有软件版本写入 JSON 快照文件;第二步扫描可更新软件列表;第三步逐个执行更新,每个软件包失败后自动重试最多 MaxRetries 次;第四步如果开启了 -RollbackOnFailure,则将所有已成功更新的软件回滚到快照版本。快照文件以时间戳命名,保存在临时目录下,可用于后续手动恢复。

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
[1/4] 正在创建软件版本快照...
快照已保存: /tmp/WinGetSnapshots/snapshot_20260409_080000.json (48 个软件包)

[2/4] 正在扫描可更新的软件...
发现 5 个可更新软件包:

Name Id OldVer NewVer
---- -- ------ ------
7-Zip 7zip.7zip 24.09 24.10
Visual Studio Code Microsoft.VisualStudioCode 1.98.2 1.99.0
Git Git.Git 2.48.0 2.49.0
PowerShell Microsoft.PowerShell 7.5.0 7.5.1
Docker Desktop Docker.DockerDesktop 4.35.0 4.36.0

[3/4] 开始安全更新...
更新 7zip.7zip (24.09 -> 24.10) 完成
更新 Microsoft.VisualStudioCode (1.98.2 -> 1.99.0) 完成
更新 Git.Git (2.48.0 -> 2.49.0) 完成
更新 Microsoft.PowerShell (7.5.0 -> 7.5.1) 完成
更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) 失败
更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) [重试 1] 失败
更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) [重试 2] 失败

[4/4] 更新结果汇总:

Id OldVer NewVer Status Attempts
-- ------ ------ ------ --------
7zip.7zip 24.09 24.10 成功 1
Microsoft.VisualStudioCode 1.98.2 1.99.0 成功 1
Git.Git 2.48.0 2.49.0 成功 1
Microsoft.PowerShell 7.5.0 7.5.1 成功 1
Docker.DockerDesktop 4.35.0 4.36.0 失败 2

提示: 更新失败的软件可使用以下命令手动回滚:
回滚快照文件: /tmp/WinGetSnapshots/snapshot_20260409_080000.json
winget install --id Docker.DockerDesktop --version 4.35.0 --force

快照文件: /tmp/WinGetSnapshots/snapshot_20260409_080000.json
可使用以下命令查看历史快照: Get-ChildItem /tmp/WinGetSnapshots

注意事项

  • 版本号解析:部分软件使用非标准版本号格式(如 1.0.0-beta.3),直接用 [version] 类型转换会失败,对比时需要做容错处理。示例代码中对解析失败的版本使用了字符串直接比较
  • YAML 解析依赖:完整的生产级 YAML 解析建议安装 powershell-yaml 模块(Install-Module powershell-yaml),示例中使用了简易的行解析逻辑以避免外部依赖,但不支持复杂的嵌套结构
  • 快照存储位置:示例中快照保存在 $env:TEMP 目录,系统清理时可能被删除。生产环境建议将快照目录设置为持久化路径,如 $HOME\WinGetSnapshots,或推送到远程存储(如 S3、Azure Blob)
  • 回滚局限性winget install --version 回滚依赖软件源保留旧版本安装包,部分软件(如 Chrome、Firefox)的旧版本可能已从源中移除,导致回滚失败。关键软件建议在快照时同时下载离线安装包
  • 并发安全:多台机器同时运行大规模更新可能给内部软件源带来压力,建议在脚本中加入随机延迟(Start-Sleep -Seconds (Get-Random -Max 300))进行错峰
  • 日志与审计:建议将每次更新操作的结果追加写入日志文件(Export-CsvAdd-Content),定期归档,方便事后审计和问题追溯

PowerShell 技能连载 - Browser Use 浏览器自动化

适用于 PowerShell 7.0 及以上版本

背景

2026 年,浏览器自动化技术已经从简单的网页测试工具发展为 AI Agent 执行任务的核心能力。以 Browser Use 为代表的框架让 AI 模型能够直接操控浏览器完成复杂任务,例如自动填表、数据采集、跨站点流程编排等。Playwright 和 Selenium 作为两大主流浏览器自动化引擎,都提供了成熟的 API,而 PowerShell 凭借其强大的对象管道和 .NET 生态集成能力,成为串联这些工具的绝佳胶水语言。

在实际运维和数据处理场景中,我们经常需要从没有 API 的内部系统中提取数据、定时提交报表、或批量执行重复的网页操作。传统做法依赖手动操作或录制宏脚本,维护成本高且容易出错。PowerShell 结合 Playwright 可以编写声明式的自动化脚本,配合 AI 模型甚至能实现”说一句话,浏览器自动完成操作”的智能体验。

本文将分三个层次介绍 PowerShell 浏览器自动化:从 Playwright 基础操作,到数据采集与表单自动化,再到 AI 驱动的智能浏览器操作,帮助读者逐步掌握这项实用技能。

Playwright 自动化基础

Playwright 是微软开发的跨浏览器自动化框架,原生支持 Chromium、Firefox 和 WebKit。PowerShell 可以通过 .NET 互操作直接调用 Playwright 的 NuGet 包,实现从浏览器启动到元素操作的全流程控制。

以下代码演示了 Playwright 的安装、浏览器启动、页面导航、等待元素加载、点击按钮、读取文本等基本操作:

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
# 安装 Playwright NuGet 包和浏览器二进制文件
Install-Module -Name Microsoft.Playwright -Scope CurrentUser -Force
dotnet tool install --global Microsoft.Playwright.CLI
playwright install chromium

# 导入模块并启动浏览器
using module Microsoft.Playwright

$playwright = [Microsoft.Playwright.Program]::CreatePlaywright()
$browser = $playwright.Chromium.LaunchAsync(@{ headless = $true }).GetAwaiter().GetResult()

# 创建新页面并导航到目标网站
$page = $browser.NewPageAsync().GetAwaiter().GetResult()
$page.GotoAsync("https://example.com").GetAwaiter().GetResult()

# 等待页面标题加载并获取标题文本
$page.WaitForSelectorAsync("h1").GetAwaiter().GetResult() | Out-Null
$title = $page.TextContentAsync("h1").GetAwaiter().GetResult()
Write-Host "页面标题: $title"

# 查找链接并获取所有链接地址
$links = $page.QuerySelectorAllAsync("a").GetAwaiter().GetResult()
foreach ($link in $links) {
$href = $link.GetAttributeAsync("href").GetAwaiter().GetResult()
$text = $link.TextContentAsync().GetAwaiter().GetResult()
Write-Host " 链接: $text -> $href"
}

# 关闭浏览器释放资源
$browser.CloseAsync().GetAwaiter().GetResult()
$playwright.Dispose()

执行结果示例:

1
2
页面标题: Example Domain
链接: More information... -> https://www.iana.org/domains/example

在实际项目中,推荐使用 try-finally 块确保浏览器资源被正确释放,避免残留进程占用系统内存。

数据采集与表单自动化

网页数据提取和表单自动填写是浏览器自动化最常见的应用场景。PowerShell 可以将采集到的结构化数据直接转化为对象,利用管道进行筛选和导出,这是其他脚本语言难以比拟的优势。

下面的脚本展示了从网页表格提取数据、自动填写搜索表单、以及将页面保存为截图和 PDF 的完整流程:

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
using module Microsoft.Playwright

$playwright = [Microsoft.Playwright.Program]::CreatePlaywright()
$browser = $playwright.Chromium.LaunchAsync(@{ headless = $true }).GetAwaiter().GetResult()
$page = $browser.NewPageAsync().GetAwaiter().GetResult()

# 导航到包含表格的页面
$page.GotoAsync("https://example.com/tables").GetAwaiter().GetResult()
$page.WaitForSelectorAsync("table").GetAwaiter().GetResult() | Out-Null

# 提取表格数据并转化为 PowerShell 对象
$rows = $page.QuerySelectorAllAsync("table tbody tr").GetAwaiter().GetResult()
$tableData = foreach ($row in $rows) {
$cells = $row.QuerySelectorAllAsync("td").GetAwaiter().GetResult()
[PSCustomObject]@{
Name = $cells[0].TextContentAsync().GetAwaiter().GetResult().Trim()
Value = $cells[1].TextContentAsync().GetAwaiter().GetResult().Trim()
Status = $cells[2].TextContentAsync().GetAwaiter().GetResult().Trim()
}
}

# 筛选状态为 Active 的记录并导出为 CSV
$tableData | Where-Object { $_.Status -eq "Active" } | Export-Csv -Path "active_items.csv" -NoTypeInformation -Encoding utf8
Write-Host "已导出 $($tableData.Count) 条记录"

# 自动填写搜索表单
$page.GotoAsync("https://example.com/search").GetAwaiter().GetResult()
$page.FillAsync("#search-input", "PowerShell automation").GetAwaiter().GetResult()
$page.SelectOptionAsync("#category", "technology").GetAwaiter().GetResult()
$page.ClickAsync("#search-button").GetAwaiter().GetResult()

# 等待搜索结果加载
$page.WaitForSelectorAsync(".result-item").GetAwaiter().GetResult() | Out-Null

# 截取搜索结果页面的屏幕截图
$page.ScreenshotAsync(@{ path = "search-results.png"; fullPage = $true }).GetAwaiter().GetResult() | Out-Null
Write-Host "截图已保存到 search-results.png"

# 将当前页面导出为 PDF
$page.PdfAsync(@{ path = "search-results.pdf"; format = "A4" }).GetAwaiter().GetResult() | Out-Null
Write-Host "PDF 已保存到 search-results.pdf"

$browser.CloseAsync().GetAwaiter().GetResult()
$playwright.Dispose()

执行结果示例:

1
2
3
已导出 12 条记录
截图已保存到 search-results.png
PDF 已保存到 search-results.pdf

通过 PSCustomObject 将网页数据结构化后,可以无缝使用 Where-ObjectSort-ObjectExport-Csv 等 PowerShell 原生命令进行后续处理,实现从采集到入库的一体化流程。

AI 驱动的浏览器操作

2026 年最具变革性的趋势是将大语言模型(LLM)与浏览器自动化结合,让 AI 理解页面语义后自动决定操作步骤。PowerShell 在这个场景中扮演调度器的角色:调用 LLM API 分析页面结构,解析返回的指令,再驱动浏览器执行具体动作。

以下脚本展示了如何用 PowerShell 调用本地 Ollama 模型分析网页元素,实现 AI 驱动的智能浏览器操作:

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
using module Microsoft.Playwright

# 配置 Ollama API 端点
$ollamaEndpoint = "http://localhost:11434/api/generate"
$modelName = "qwen2.5:7b"

function Invoke-OllamaChat {
param([string]$Prompt)
$body = @{
model = $modelName
prompt = $Prompt
stream = $false
} | ConvertTo-Json -Depth 5

$response = Invoke-RestMethod -Uri $ollamaEndpoint -Method Post -Body $body -ContentType "application/json"
return $response.response
}

# 启动浏览器并导航到目标页面
$playwright = [Microsoft.Playwright.Program]::CreatePlaywright()
$browser = $playwright.Chromium.LaunchAsync(@{ headless = $false }).GetAwaiter().GetResult()
$page = $browser.NewPageAsync().GetAwaiter().GetResult()
$page.GotoAsync("https://news.example.com").GetAwaiter().GetResult()

# 提取页面可交互元素的摘要信息
$elements = $page.QuerySelectorAllAsync("a, button, input, select").GetAwaiter().GetResult()
$elementSummary = foreach ($el in $elements[0..19]) {
$tag = $el.EvaluateHandleAsync("e => e.tagName").GetAwaiter().GetResult().JsonValue
$text = $el.TextContentAsync().GetAwaiter().GetResult()
$role = $el.GetAttributeAsync("role").GetAwaiter().GetResult()
" <$tag> text='$($text.Trim().Substring(0, [Math]::Min(50, $text.Trim().Length)))' role='$role'"
}
$summaryText = $elementSummary -join "`n"

# 让 AI 分析页面并推荐操作
$aiPrompt = @"
你是一个浏览器自动化助手。以下是页面上的可交互元素:

$summaryText

用户任务:找到今天浏览量最高的科技新闻标题。
请告诉我应该点击哪个元素(用序号表示),以及后续操作建议。
只返回简洁的操作指令,不要解释。
"@

$aiResponse = Invoke-OllamaChat -Prompt $aiPrompt
Write-Host "AI 建议: $aiResponse"

# 根据 AI 建议提取操作索引并执行点击
if ($aiResponse -match "点击.*?(\d+)") {
$index = [int]$Matches[1]
if ($index -lt $elements.Count) {
$elements[$index].ClickAsync().GetAwaiter().GetResult() | Out-Null
Write-Host "已执行 AI 建议的点击操作(元素 #$index)"
}
}

# 等待新页面加载后提取标题
Start-Sleep -Seconds 2
$articleTitle = $page.TitleAsync().GetAwaiter().GetResult()
Write-Host "当前页面标题: $articleTitle"

$browser.CloseAsync().GetAwaiter().GetResult()
$playwright.Dispose()

执行结果示例:

1
2
3
AI 建议: 点击元素 #3,这是"科技"分类链接。进入后点击第一个新闻标题即可。
已执行 AI 建议的点击操作(元素 #3)
当前页面标题: 最新科技新闻 - News Example

这种方式将 LLM 的语义理解能力与 Playwright 的精确操作能力结合,实现了”自然语言到浏览器操作”的闭环。在生产环境中,建议加入操作确认机制和异常回退逻辑,确保 AI 的操作可预测且可追溯。

注意事项

  1. 资源管理:Playwright 的浏览器实例是重量级资源,务必使用 try-finally 块确保 CloseAsyncDispose 被调用,避免残留进程消耗系统资源。

  2. 异步处理:Playwright 的 .NET API 大量使用 async/await 模式,在 PowerShell 中需要通过 .GetAwaiter().GetResult() 同步等待。避免在循环中频繁调用,可改用批量操作减少开销。

  3. 等待策略:页面加载时间受网络状况影响较大,推荐使用 WaitForSelectorAsync 替代固定的 Start-Sleep,既保证元素可用,又避免不必要的等待。

  4. 无头模式选择:开发调试时使用 headless = $false 以便观察浏览器行为,生产环境使用 headless = $true 提升性能。在 Linux 服务器上运行时需要安装额外的系统依赖库。

  5. AI 操作安全性:让 LLM 直接控制浏览器操作具有不确定性,务必对 AI 返回的指令进行校验(如索引范围检查、URL 白名单过滤),防止误操作导致数据丢失或安全风险。

  6. 反爬虫应对:部分网站会检测自动化工具并限制访问。可通过设置 User-Agent、使用浏览器上下文隔离、控制请求频率等方式降低被识别的概率,同时确保遵守目标网站的服务条款。

PowerShell 技能连载 - Azure Monitor 仪表板自动化

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

在云原生运维中,Azure Monitor 仪表板是将海量监控数据转化为可视化洞察的核心工具。通过仪表板,运维团队可以一目了然地掌握虚拟机 CPU 利用率、存储账户延迟、应用网关吞吐量等关键指标,从而快速定位性能瓶颈和潜在故障。然而,当企业规模扩展到数十个订阅、上百个资源时,手动在 Azure 门户中拖拽创建仪表板不仅耗时,而且难以保证一致性。

PowerShell 提供了完整的 Azure Dashboard JSON 模板操控能力,结合 Az.Monitor 模块,我们可以将仪表板的创建、修改和部署完全纳入基础设施即代码(IaC)流程。这意味着每套环境都能拥有标准化的监控视图,变更可追溯、可审计、可回滚,大幅降低人为失误风险。

本文将围绕三个核心场景展开:动态构建仪表板 JSON 模板、配置指标告警与自动通知、以及跨订阅批量部署标准化仪表板,帮助你建立一套完整的 Azure Monitor 仪表板自动化工作流。

仪表板 JSON 模板构建

Azure Dashboard 的底层是一个 JSON 文档,定义了每个磁贴(tile)的类型、位置、大小和数据源。我们可以用 PowerShell 哈希表和 ConvertTo-Json 动态生成这个结构,实现参数化的仪表板模板。

以下函数封装了仪表板 JSON 的构建逻辑,支持自定义标题、订阅 ID 和资源组参数,并预置了 CPU 和内存两个监控磁贴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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
function New-AzDashboardTemplate {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$DashboardName,

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

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

[int]$RefreshIntervalSeconds = 300
)

$cpuTile = @{
position = @{ x = 0; y = 0; colSpan = 6; rowSpan = 4 }
metadata = @{
type = 'Extension/Microsoft_Azure_Monitoring/PartType/MetricsChartPart'
inputs = @(
@{
name = 'query'
value = @{
id = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Compute/virtualMachines"
chartType = 0
metrics = @(
@{ name = 'Percentage CPU'; resourceId = "/subscriptions/$SubscriptionId" }
)
timespan = @{ duration = 'PT1H' }
interval = 'PT5M'
}
}
)
settings = @{
content = @{
options = @{
chart = @{
groupBy = $null
topRows = 10
}
}
}
}
}
}

$memTile = @{
position = @{ x = 6; y = 0; colSpan = 6; rowSpan = 4 }
metadata = @{
type = 'Extension/Microsoft_Azure_Monitoring/PartType/MetricsChartPart'
inputs = @(
@{
name = 'query'
value = @{
id = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Compute/virtualMachines"
chartType = 0
metrics = @(
@{ name = 'Available Memory Bytes'; resourceId = "/subscriptions/$SubscriptionId" }
)
timespan = @{ duration = 'PT1H' }
interval = 'PT5M'
}
}
)
}
}

$dashboard = @{
id = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Portal/dashboards/$DashboardName"
name = $DashboardName
type = 'Microsoft.Portal/dashboards'
location = 'global'
tags = @{ createdBy = 'PowerShell-Automation'; createdAt = (Get-Date -Format 'yyyy-MM-dd') }
properties = @{
lenses = @{
'0' = @{
order = 0
parts = @($cpuTile, $memTile)
}
}
metadata = @{
model = @{
editable = $true
timeRange = @{
value = @{ relative = @{ duration = 24; timeUnit = 1 } }
type = 'MsPortalFx.Composition.Configuration.ValueTypes.TimeRangeType.Relative'
}
filter = @{
value = $null
type = 'MsPortalFx.Composition.Configuration.ValueTypes.FilterType.Callout'
}
}
}
}
}

# 深度设置为 10 层,确保嵌套结构完整输出
$dashboard | ConvertTo-Json -Depth 10
}

# 生成仪表板 JSON
$json = New-AzDashboardTemplate `
-DashboardName 'prod-vm-monitor' `
-SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' `
-ResourceGroupName 'rg-production'

$json | Out-File -FilePath './prod-vm-monitor-dashboard.json' -Encoding utf8
Write-Host "仪表板 JSON 已生成,文件大小:$((Get-Item './prod-vm-monitor-dashboard.json').Length) 字节"

执行结果示例:

1
仪表板 JSON 已生成,文件大小:2847 字节

生成的 JSON 文件可以直接通过 Azure 门户导入,也可以用 New-AzPortalDashboard 或 REST API 部署到指定资源组。通过修改 $cpuTile$memTile 中的指标名称,你可以快速扩展出网络吞吐量、磁盘 I/O 等更多监控视图。

指标告警与自动通知

仪表板负责展示,告警负责驱动行动。在 Azure Monitor 体系中,指标告警规则(Metric Alert Rule)配合操作组(Action Group),可以在指标突破阈值时自动触发邮件、Webhook、短信等通知渠道。以下脚本演示了完整的告警链路创建流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# 先确保已登录并选择正确的订阅
# Connect-AzAccount
# Set-AzContext -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

$ResourceGroup = 'rg-production'
$ActionGroupName = 'ag-ops-team'
$AlertRuleName = 'alert-vm-cpu-high'

# 创建操作组:邮件通知 + Webhook
$emailReceiver = New-AzActionGroupReceiver `
-Name 'ops-email' `
-EmailAddress 'ops-team@contoso.com'

$webhookReceiver = New-AzActionGroupReceiver `
-Name 'incident-webhook' `
-WebhookUri 'https://hooks.example.com/azure-alerts'

$actionGroup = Set-AzActionGroup `
-ResourceGroupName $ResourceGroup `
-Name $ActionGroupName `
-ShortName 'OpsTeam' `
-Receiver $emailReceiver, $webhookReceiver

Write-Host "操作组已创建:$($actionGroup.Name)"

# 获取操作组的 ARM ID,告警规则需要引用
$actionGroupId = $actionGroup.Id

# 创建指标告警规则:CPU 使用率超过 85% 持续 5 分钟触发
$targetVm = Get-AzVM -ResourceGroupName $ResourceGroup -Name 'vm-web-01'
$vmResourceId = $targetVm.Id

$criteria = New-AzMetricAlertRuleV2Criteria `
-MetricName 'Percentage CPU' `
-TimeAggregation 'Average' `
-Operator 'GreaterThan' `
-Threshold 85

$actionGroupObject = New-AzMetricAlertRuleV2ActionGroup `
-ActionGroupId $actionGroupId

# 设置告警规则的维度过滤(可选)
$dimension = New-AzMetricAlertRuleV2DimensionSelection `
-DimensionName 'VMName' `
-ValuesToInclude '*'

$alert = Add-AzMetricAlertRuleV2 `
-Name $AlertRuleName `
-ResourceGroupName $ResourceGroup `
-WindowSize 'PT5M' `
-Frequency 'PT1M' `
-TargetResourceId $vmResourceId `
-Condition $criteria `
-ActionGroup $actionGroupObject `
-Severity 2 `
-Description "VM $($targetVm.Name) CPU 使用率超过 85% 持续 5 分钟"

Write-Host "告警规则已创建:$($alert.Name)"
Write-Host "目标资源:$vmResourceId"
Write-Host "阈值条件:Average Percentage CPU > 85%"
Write-Host "评估窗口:5 分钟,评估频率:1 分钟"

执行结果示例:

1
2
3
4
5
操作组已创建:ag-ops-team
告警规则已创建:alert-vm-cpu-high
目标资源:/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-production/providers/Microsoft.Compute/virtualMachines/vm-web-01
阈值条件:Average Percentage CPU > 85%
评估窗口:5 分钟,评估频率:1 分钟

告警触发后,Azure 会同时向 ops-team@contoso.com 发送告警邮件,并向 https://hooks.example.com/azure-alerts 推送 Webhook 请求。你可以将 Webhook 对接到企业微信、飞书、Slack 等即时通讯平台,实现秒级告警触达。

多订阅仪表板批量部署

在企业级场景中,运维团队通常需要管理多个订阅(开发、测试、预生产、生产),每个订阅都应部署一套标准化的监控仪表板。手动逐个部署显然不现实,下面通过参数化模板和循环部署来解决这个问题:

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
# 定义目标订阅列表和对应的资源配置
$subscriptions = @(
@{
SubscriptionId = 'aaaa1111-bbbb-2222-cccc-3333dddd4444'
Name = 'production'
ResourceGroup = 'rg-production'
VmPrefix = 'vm-prod'
AlertEmail = 'prod-ops@contoso.com'
}
@{
SubscriptionId = 'eeee5555-ffff-6666-gggg-7777hhhh8888'
Name = 'staging'
ResourceGroup = 'rg-staging'
VmPrefix = 'vm-stg'
AlertEmail = 'staging-ops@contoso.com'
}
@{
SubscriptionId = 'iiii9999-jjjj-0000-kkkk-1111llll2222'
Name = 'development'
ResourceGroup = 'rg-dev'
VmPrefix = 'vm-dev'
AlertEmail = 'dev-ops@contoso.com'
}
)

$deployResults = [System.Collections.Generic.List[PSObject]]::new()

foreach ($sub in $subscriptions) {
Write-Host "`n--- 正在处理订阅:$($sub.Name) ---" -ForegroundColor Cyan

# 切换到目标订阅
$context = Set-AzContext -SubscriptionId $sub.SubscriptionId
Write-Host " 已切换到订阅:$($context.Subscription.Name)"

# 生成仪表板 JSON
$dashboardName = "$($sub.Name)-standard-dashboard"
$dashboardJson = New-AzDashboardTemplate `
-DashboardName $dashboardName `
-SubscriptionId $sub.SubscriptionId `
-ResourceGroupName $sub.ResourceGroup

# 将 JSON 部署为 Azure 仪表板
$tempFile = New-TemporaryFile
$dashboardJson | Out-File -FilePath $tempFile.FullName -Encoding utf8

# 使用 REST API 部署仪表板
$token = (Get-AzAccessToken).Token
$uri = "https://management.azure.com/subscriptions/$($sub.SubscriptionId)/resourceGroups/$($sub.ResourceGroup)/providers/Microsoft.Portal/dashboards/$dashboardName`?api-version=2020-09-01-preview"

$headers = @{
Authorization = "Bearer $token"
'Content-Type' = 'application/json'
}

$response = Invoke-RestMethod -Uri $uri -Method Put -Headers $headers -Body $dashboardJson
Write-Host " 仪表板已部署:$dashboardName"

# 为该订阅创建操作组和告警规则
$agName = "ag-$($sub.Name)-ops"
$emailReceiver = New-AzActionGroupReceiver -Name 'team-email' -EmailAddress $sub.AlertEmail
$actionGroup = Set-AzActionGroup `
-ResourceGroupName $sub.ResourceGroup `
-Name $agName `
-ShortName "$($sub.Name)Ops" `
-Receiver $emailReceiver

Write-Host " 操作组已创建:$agName"

# 记录部署结果
$deployResults.Add([PSCustomObject]@{
Subscription = $sub.Name
Dashboard = $dashboardName
ActionGroup = $agName
Status = 'Deployed'
DeployTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}

# 输出部署汇总
Write-Host "`n========== 部署汇总 ==========" -ForegroundColor Yellow
$deployResults | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--- 正在处理订阅:production ---
已切换到订阅:production-subscription
仪表板已部署:production-standard-dashboard
操作组已创建:ag-production-ops

--- 正在处理订阅:staging ---
已切换到订阅:staging-subscription
仪表板已部署:staging-standard-dashboard
操作组已创建:ag-staging-ops

--- 正在处理订阅:development ---
已切换到订阅:development-subscription
仪表板已部署:development-standard-dashboard
操作组已创建:ag-dev-ops

========== 部署汇总 ==========
Subscription Dashboard ActionGroup Status DeployTime
------------ --------- ----------- ------ ----------
production production-standard-dashboard ag-production-ops Deployed 2026-04-07 10:30:15
staging staging-standard-dashboard ag-staging-ops Deployed 2026-04-07 10:30:42
development development-standard-dashboard ag-dev-ops Deployed 2026-04-07 10:31:08

这个脚本的核心思路是将订阅特定的参数(资源组名、VM 前缀、告警邮箱)外置到配置数组中,然后通过循环统一调用前面定义的模板生成函数。你可以进一步将 $subscriptions 数组替换为从 CSV 或 Azure Key Vault 读取的配置,实现真正的配置与代码分离。

注意事项

  1. Az.Monitor 模块版本:建议使用 Az.Monitor 4.0 以上版本,旧版本的 Add-AzMetricAlertRuleV2 参数名和行为有差异。安装前先执行 Get-InstalledModule Az.Monitor 确认当前版本。

  2. 仪表板 JSON 深度问题:Azure Dashboard 的 JSON 嵌套层级较深(通常 8-10 层),使用 ConvertTo-Json 时必须指定 -Depth 10,否则内层数据会被截断为字符串。

  3. REST API Token 有效期:通过 Get-AzAccessToken 获取的 Bearer Token 默认有效期 1 小时。如果批量部署涉及大量订阅,建议在循环内每次都重新获取 Token,避免中途过期导致 401 错误。

  4. 告警规则配额限制:每个 Azure 订阅的指标告警规则数量有上限(默认 5000 条),跨订阅批量创建前应检查 Get-AzMetricAlertRuleV2 的返回数量,避免超出配额。

  5. 操作组的 Webhook 超时:Azure Action Group 发送 Webhook 时,超时时间为 10 秒。如果你的下游服务响应较慢,建议在中间加一个队列服务(如 Azure Functions + Service Bus),避免 Webhook 调用失败。

  6. 仪表板权限控制:通过 PowerShell 创建的仪表板默认只有创建者有编辑权限。如果需要团队成员共同维护,应通过 Azure RBAC 为资源组级别的 Microsoft.Portal/dashboards 资源分配 ContributorReader 角色。

PowerShell 技能连载 - 容器编排自动化

适用于 PowerShell 7.0 及以上版本,需要 Docker Desktop 或 Podman

在现代 DevOps 工作流中,容器已经成为应用部署的标准载体。无论是微服务架构、CI/CD 流水线,还是本地开发环境,容器的使用无处不在。然而,当容器数量从几个增长到几十个甚至上百个时,手动管理就变得既低效又容易出错。如何用脚本化的方式编排和管理这些容器,就成了每个运维工程师必须面对的课题。

PowerShell 凭借其强大的对象管道、丰富的模块生态以及与 .NET 的深度集成,为容器编排提供了一种独特的自动化思路。与 Bash 脚本相比,PowerShell 能够直接操作结构化数据(如 JSON、YAML),将 Docker CLI 的文本输出转化为可查询的对象,从而实现更精细、更可靠的容器生命周期管理。

本文将围绕三个典型场景展开:使用 PowerShell 动态生成 Docker Compose 配置并管理多服务生命周期;构建容器健康检查与自动恢复机制;以及实现多环境批量容器部署工具,支持蓝绿部署和快速回滚。

Docker Compose 管理

在日常开发中,我们经常需要根据不同的环境(开发、测试、生产)生成不同的 Compose 配置。手动维护多份 YAML 文件不仅繁琐,还容易导致配置漂移。下面的脚本展示了如何用 PowerShell 动态生成 Docker Compose 文件,并统一管理服务的启动、停止和状态查询。

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
function New-DockerComposeConfig {
param(
[Parameter(Mandatory)]
[ValidateSet('dev', 'staging', 'prod')]
[string]$Environment,

[int]$ReplicaCount = 1,
[int]$MemoryLimitMb = 512
)

$envConfig = @{
dev = @{ HostPort = 8080; Tag = 'latest'; LogLevel = 'Debug' }
staging = @{ HostPort = 8081; Tag = 'rc'; LogLevel = 'Information' }
prod = @{ HostPort = 80; Tag = 'stable'; LogLevel = 'Warning' }
}

$cfg = $envConfig[$Environment]

$compose = @{
version = '3.8'
services = @{
webapp = @{
image = "myapp/webapp:$($cfg.Tag)"
ports = @("$($cfg.HostPort):80")
environment = @(
"ASPNETCORE_ENVIRONMENT=$Environment"
"LOG_LEVEL=$($cfg.LogLevel)"
)
deploy = @{
replicas = $ReplicaCount
resources = @{
limits = @{ memory = "${MemoryLimitMb}M" }
reservations = @{ memory = "$([Math]::Floor($MemoryLimitMb / 2))M" }
}
restart_policy = @{
condition = 'on-failure'
max_attempts = 3
}
}
healthcheck = @{
test = @('CMD', 'curl', '-f', 'http://localhost/health')
interval = '30s'
timeout = '10s'
retries = 3
}
volumes = @('./data:/app/data')
networks = @('app-network')
}
redis = @{
image = 'redis:7-alpine'
ports = @('6379:6379')
volumes = @('redis-data:/data')
networks = @('app-network')
healthcheck = @{
test = @('CMD', 'redis-cli', 'ping')
interval = '15s'
timeout = '5s'
retries = 3
}
}
}
networks = @{
'app-network' = @{ driver = 'bridge' }
}
volumes = @{
'redis-data' = @{}
}
}

$compose | ConvertTo-Yaml | Set-Content "docker-compose.$Environment.yml"
Write-Host "已生成 docker-compose.$Environment.yml" -ForegroundColor Green
return "docker-compose.$Environment.yml"
}

function Invoke-DockerComposeLifecycle {
param(
[Parameter(Mandatory)]
[string]$ComposeFile,
[ValidateSet('up', 'down', 'status')]
[string]$Action = 'up'
)

switch ($Action) {
'up' {
Write-Host "启动服务: $ComposeFile" -ForegroundColor Cyan
docker compose -f $ComposeFile up -d --remove-orphans
Write-Host "服务已启动,等待健康检查..." -ForegroundColor Green
Start-Sleep -Seconds 5
docker compose -f $ComposeFile ps
}
'down' {
Write-Host "停止服务: $ComposeFile" -ForegroundColor Yellow
docker compose -f $ComposeFile down --volumes --remove-orphans
Write-Host "服务已停止并清理" -ForegroundColor Green
}
'status' {
docker compose -f $ComposeFile ps
Write-Host "`n--- 资源使用 ---" -ForegroundColor Cyan
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
}
}
}

# 使用示例:生成开发环境配置并启动
$composeFile = New-DockerComposeConfig -Environment dev -ReplicaCount 2 -MemoryLimitMb 256
Invoke-DockerComposeLifecycle -ComposeFile $composeFile -Action up

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
已生成 docker-compose.dev.yml
启动服务: docker-compose.dev.yml
[+] Running 3/3
✔ Network dev_app-network Created
✔ Container dev-redis-1 Started
✔ Container dev-webapp-1 Started
✔ Container dev-webapp-2 Started
服务已启动,等待健康检查...
NAME IMAGE STATUS PORTS
dev-webapp-1 myapp/webapp:latest Up 5 seconds 0.0.0.0:8080->80/tcp
dev-webapp-2 myapp/webapp:latest Up 5 seconds 0.0.0.0:8080->80/tcp
dev-redis-1 redis:7-alpine Up 5 seconds 0.0.0.0:6379->6379/tcp

容器健康检查与自动恢复

在生产环境中,容器可能会因为内存溢出、依赖服务不可用或网络抖动等原因意外退出。如果缺乏自动化的监控和恢复机制,服务中断往往会持续到人工介入才得以解决。下面的脚本实现了一套轻量级的容器健康巡检系统,能够自动检测异常容器、触发重启,并在资源使用接近阈值时发出告警。

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
function Get-ContainerHealthReport {
param(
[string]$Filter = '',
[int]$CpuThreshold = 80,
[int]$MemoryThresholdPercent = 85
)

$containers = docker ps -a --format '{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}' |
ForEach-Object {
$parts = $_ -split '\|'
[PSCustomObject]@{
Id = $parts[0]
Name = $parts[1]
Status = $parts[2]
Image = $parts[3]
}
}

if ($Filter) {
$containers = $containers | Where-Object { $_.Name -match $Filter }
}

foreach ($c in $containers) {
$inspect = docker inspect $c.Id | ConvertFrom-Json
$state = $inspect.State
$health = $state.Health

$report = [PSCustomObject]@{
Name = $c.Name
Image = $c.Image
Running = $state.Running
Health = if ($health) { $health.Status } else { 'N/A' }
Restarting = $state.Restarting
ExitCode = $state.ExitCode
StartedAt = $state.StartedAt
}

# 获取资源使用情况
$stats = docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' $c.Id
if ($stats) {
$statParts = $stats -split '\|'
$report | Add-Member -NotePropertyName 'CpuPct' `
-NotePropertyValue ($statParts[0] -replace '%', '').Trim()
$report | Add-Member -NotePropertyName 'MemPct' `
-NotePropertyValue ($statParts[2] -replace '%', '').Trim()
}

$report
}
}

function Repair-UnhealthyContainers {
param(
[int]$MaxRestartAttempts = 3,
[int]$CooldownSeconds = 30,
[switch]$DryRun
)

$report = Get-ContainerHealthReport
$unhealthy = $report | Where-Object {
-not $_.Running -or $_.Health -eq 'unhealthy'
}

if (-not $unhealthy) {
Write-Host "所有容器状态正常" -ForegroundColor Green
return
}

foreach ($c in $unhealthy) {
$action = if (-not $c.Running) { '启动' } else { '重启' }

if ($DryRun) {
Write-Host "[模拟] 将$action 容器: $($c.Name) (状态: $($c.Status))" `
-ForegroundColor Yellow
continue
}

Write-Host "$action 容器: $($c.Name)..." -ForegroundColor Cyan
docker restart $c.Name

Start-Sleep -Seconds $CooldownSeconds

$newState = docker inspect $c.Name --format '{{.State.Running}}'
if ($newState -eq 'true') {
Write-Host " 容器 $($c.Name) 已恢复运行" -ForegroundColor Green
} else {
Write-Host " 容器 $($c.Name) 恢复失败,需要人工介入" -ForegroundColor Red
}
}
}

# 使用示例:巡检并自动恢复
Get-ContainerHealthReport | Format-Table -AutoSize
Repair-UnhealthyContainers -DryRun

执行结果示例:

1
2
3
4
5
6
7
8
Name          Image              Running Health     Restarting ExitCode CpuPct MemPct
---- ----- ------- ------ ---------- -------- ------ ------
dev-webapp-1 myapp/webapp:latest True healthy False 0 2.3% 15.2%
dev-webapp-2 myapp/webapp:latest True healthy False 0 1.8% 14.7%
dev-redis-1 redis:7-alpine True healthy False 0 0.5% 8.1%
test-api-1 myapp/api:rc False unhealthy False 137 N/A N/A

[模拟] 将重启 容器: test-api-1 (状态: Exited (137) 2 minutes ago)

批量容器部署工具

蓝绿部署是一种经典的零停机发布策略,它通过维护两套完全相同的生产环境(蓝和绿),在发布新版本时将流量从旧环境切换到新环境,从而实现无缝升级。结合 PowerShell 的参数化能力,我们可以构建一个支持多环境配置、蓝绿部署和一键回滚的批量部署工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
class ContainerDeployment {
[string]$ProjectName
[string]$Environment
[hashtable]$Services = @{}

ContainerDeployment([string]$ProjectName, [string]$Environment) {
$this.ProjectName = $ProjectName
$this.Environment = $Environment
}

[void] AddService([string]$Name, [string]$Image, [string[]]$Ports) {
$this.Services[$Name] = @{
Image = $Image
Ports = $Ports
CreatedAt = (Get-Date).ToString('o')
}
}

[string] Deploy([string]$Slot) {
$slotPrefix = "$($this.ProjectName)-$Slot"
Write-Host "部署到 $Slot 槽位: $slotPrefix" -ForegroundColor Cyan

foreach ($svc in $this.Services.GetEnumerator()) {
$containerName = "$slotPrefix-$($svc.Key)"
$portMap = ($svc.Value.Ports | ForEach-Object { '-p'; $_ }) -join ' '

# 拉取最新镜像
Write-Host " 拉取镜像: $($svc.Value.Image)" -ForegroundColor Gray
docker pull $svc.Value.Image | Out-Null

# 停止并移除旧容器
$existing = docker ps -aq -f "name=$containerName"
if ($existing) {
docker stop $existing | Out-Null
docker rm $existing | Out-Null
}

# 启动新容器
$runCmd = "docker run -d --name $containerName $portMap $($svc.Value.Image)"
$containerId = Invoke-Expression $runCmd
Write-Host " 已启动: $containerName ($($containerId.Substring(0,12)))" `
-ForegroundColor Green
}

return $slotPrefix
}

[void] SwitchTraffic([string]$NewSlot) {
$oldSlot = if ($NewSlot -eq 'blue') { 'green' } else { 'blue' }
$oldPrefix = "$($this.ProjectName)-$oldSlot"
$newPrefix = "$($this.ProjectName)-$NewSlot"

Write-Host "`n切换流量: $oldSlot -> $NewSlot" -ForegroundColor Yellow

# 验证新槽位容器健康
$newContainers = docker ps -f "name=$newPrefix" --format '{{.Names}}'
foreach ($name in $newContainers) {
$health = docker inspect $name --format '{{.State.Health.Status}}'
if ($health -ne 'healthy') {
Write-Host " 容器 $name 状态为 $health,中止切换" -ForegroundColor Red
return
}
}

# 停止旧槽位容器(保持数据卷)
$oldContainers = docker ps -f "name=$oldPrefix" --format '{{.Names}}'
foreach ($name in $oldContainers) {
Write-Host " 停止旧容器: $name" -ForegroundColor Gray
docker stop $name | Out-Null
}

Write-Host "流量已切换到 $NewSlot 槽位" -ForegroundColor Green
}

[void] Rollback() {
$activeSlot = $this.GetActiveSlot()
$targetSlot = if ($activeSlot -eq 'blue') { 'green' } else { 'blue' }

Write-Host "回滚到 $targetSlot 槽位..." -ForegroundColor Yellow

# 启动目标槽位的容器
$stopped = docker ps -aq -f "status=exited" -f "name=$($this.ProjectName)-$targetSlot"
foreach ($id in $stopped) {
docker start $id | Out-Null
$name = docker inspect $id --format '{{.Name}}' | ForEach-Object { $_ -replace '^/', '' }
Write-Host " 已恢复: $name" -ForegroundColor Green
}

# 停止当前槽位
$active = docker ps -f "name=$($this.ProjectName)-$activeSlot" --format '{{.Names}}'
foreach ($name in $active) {
docker stop $name | Out-Null
}

Write-Host "回滚完成,活跃槽位: $targetSlot" -ForegroundColor Green
}

hidden [string] GetActiveSlot() {
$blue = docker ps -q -f "name=$($this.ProjectName)-blue" 2>$null
$green = docker ps -q -f "name=$($this.ProjectName)-green" 2>$null
if ($blue) { return 'blue' }
if ($green) { return 'green' }
return 'none'
}
}

# 使用示例
$deploy = [ContainerDeployment]::new('myapp', 'prod')
$deploy.AddService('web', 'myapp/web:stable', @('80:80'))
$deploy.AddService('api', 'myapp/api:stable', @('8080:8080'))
$deploy.AddService('worker', 'myapp/worker:stable', @())

$deploy.Deploy('blue')
$deploy.SwitchTraffic('blue')

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
部署到 blue 槽位: myapp-blue
拉取镜像: myapp/web:stable
已启动: myapp-blue-web (a1b2c3d4e5f6)
拉取镜像: myapp/api:stable
已启动: myapp-blue-api (f6e5d4c3b2a1)
拉取镜像: myapp/worker:stable
已启动: myapp-blue-worker (1a2b3c4d5e6f)

切换流量: green -> blue
停止旧容器: myapp-green-web
停止旧容器: myapp-green-api
停止旧容器: myapp-green-worker
流量已切换到 blue 槽位

注意事项

  • Docker 权限:在 Linux 上运行时,确保当前用户已加入 docker 用户组,否则需要在命令前加 sudo。生产环境建议使用 Rootless Docker 以降低安全风险。

  • ConvertTo-Yaml 模块:生成 Compose 文件依赖 powershell-yaml 模块,可通过 Install-Module -Name powershell-yaml -Scope CurrentUser 安装。如果无法安装,也可以用 ConvertTo-Json 生成 JSON 格式的配置。

  • 健康检查延迟:容器启动后需要一定时间才能通过健康检查,Start-Sleep 的等待时间应根据应用的启动速度调整,避免误判。建议在脚本中加入轮询逻辑,而不是简单的固定等待。

  • 蓝绿部署数据一致性:蓝绿切换时数据库迁移是一个常见陷阱。如果新版本包含破坏性的数据库变更,需要确保迁移脚本兼容新旧版本,否则回滚将变得不可行。建议将数据库迁移与容器部署解耦。

  • 资源限制:Docker Desktop 在 macOS 和 Windows 上的资源配额受虚拟机限制,脚本中设置的 memory 限制不能超过 Docker Desktop 分配的总内存。可通过 Docker Desktop 设置面板调整。

  • 日志与监控:脚本中的 docker stats 只能获取实时快照数据。如需长期监控和历史数据查询,建议集成 Prometheus + Grafana 或 Docker 原生的 docker logs --since 进行日志聚合分析。

PowerShell 技能连载 - MCP 协议集成

适用于 PowerShell 7.0 及以上版本

MCP(Model Context Protocol)是 Anthropic 于 2024 年底发布的开放协议,旨在为大语言模型(LLM)提供标准化的上下文获取和工具调用接口。通过 MCP,AI 应用可以统一地连接数据源、调用外部工具、访问资源,而不必为每个集成编写专用的适配代码。协议本身基于 JSON-RPC 2.0,传输层支持 stdio 和 SSE(Server-Sent Events)两种模式,非常适合构建可组合的 AI 工具生态。

对于 PowerShell 用户来说,MCP 带来了一个令人兴奋的可能性:我们可以用 PowerShell 脚本直接构建 MCP 服务端,将系统管理能力以标准化工具的形式暴露给 AI 助手;同时也能编写 MCP 客户端,让 PowerShell 脚本调用任何符合 MCP 规范的 AI 服务和工具集。这种双向集成使得 PowerShell 成为 AI 工具链中一等公民。

本文将通过三个实际示例,展示如何用 PowerShell 7 搭建 MCP 服务端、编写 MCP 客户端,以及打包一套实用的系统管理工具集,帮助读者快速上手 MCP 与 PowerShell 的结合使用。

搭建 MCP 服务端

MCP 服务端的核心职责是:监听客户端请求、注册可用的工具(tools)和资源(resources)、处理工具调用并返回结果。以下代码用 PowerShell 实现了一个基于 stdio 传输的最小化 MCP 服务端,支持协议握手、工具列举和工具调用三个核心能力。

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
using namespace System.Collections.Generic
using namespace System.Text.Json

# MCP 服务端核心类
class McpServer {
[string] $ServerName
[string] $Version
[List[hashtable]] $Tools
[Dictionary[string, scriptblock]] $Handlers

McpServer([string]$Name, [string]$Ver) {
$this.ServerName = $Name
$this.Version = $Ver
$this.Tools = [List[hashtable]]::new()
$this.Handlers = [Dictionary[string, scriptblock]]::new()
}

# 注册工具
[void] RegisterTool(
[string]$ToolName,
[string]$Description,
[hashtable]$Parameters,
[scriptblock]$Handler
) {
$toolDef = @{
name = $ToolName
description = $Description
inputSchema = @{
type = 'object'
properties = $Parameters
required = @($Parameters.Keys)
}
}
$this.Tools.Add($toolDef)
$this.Handlers[$ToolName] = $Handler
}

# 处理 JSON-RPC 请求
[string] HandleRequest([string]$JsonLine) {
$request = $JsonLine | ConvertFrom-Json -AsHashtable
$response = @{ jsonrpc = '2.0'; id = $request.id }

switch ($request.method) {
'initialize' {
$response.result = @{
protocolVersion = '2025-03-26'
capabilities = @{ tools = @{} }
serverInfo = @{
name = $this.ServerName
version = $this.Version
}
}
}
'tools/list' {
$response.result = @{ tools = $this.Tools }
}
'tools/call' {
$toolName = $request.params.name
$args = $request.params.arguments
if ($this.Handlers.ContainsKey($toolName)) {
$result = & $this.Handlers[$toolName] $args
$response.result = @{
content = @(
@{ type = 'text'; text = ($result | ConvertTo-Json -Depth 5) }
)
}
} else {
$response.error = @{
code = -32601
message = "Tool not found: $toolName"
}
}
}
default {
$response.error = @{
code = -32601
message = "Method not found: $($request.method)"
}
}
}
return $response | ConvertTo-Json -Depth 10 -Compress
}

# 启动 stdio 监听循环
[void] Start() {
Write-Host "[$($this.ServerName)] MCP Server started on stdio" `
-ForegroundColor Green
while ($null -ne ($line = [Console]::In.ReadLine())) {
$reply = $this.HandleRequest($line)
[Console]::Out.WriteLine($reply)
[Console]::Out.Flush()
}
}
}

# 创建服务端实例并注册示例工具
$server = [McpServer]::new('ps-mcp-server', '1.0.0')

$server.RegisterTool(
'get-process-info',
'获取指定进程的详细信息',
@{ name = @{ type = 'string'; description = '进程名称' } },
{
param($args)
$procs = Get-Process -Name $args.name -ErrorAction SilentlyContinue
if ($procs) {
$procs | Select-Object Name, Id, CPU, WorkingSet64, Path |
Format-Table -AutoSize | Out-String
} else {
"未找到进程: $($args.name)"
}
}
)

$server.RegisterTool(
'calculate',
'执行数学表达式计算',
@{
expression = @{ type = 'string'; description = '数学表达式' }
},
{
param($args)
$result = Invoke-Expression $args.expression
"计算结果: $($args.expression) = $result"
}
)

# 如需 stdio 模式,取消下行注释
# $server.Start()

上述代码定义了一个 McpServer 类,通过 RegisterTool 方法注册工具及其处理脚本块,HandleRequest 方法根据 JSON-RPC 方法名分发处理。启动后在 stdio 上逐行读取 JSON 请求并返回 JSON 响应。以下模拟了客户端发送 initialize 和 tools/list 请求后服务端返回的响应:

1
2
3
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{}},"serverInfo":{"name":"ps-mcp-server","version":"1.0.0"}}}

{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"get-process-info","description":"获取指定进程的详细信息","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"进程名称"}},"required":["name"]}},{"name":"calculate","description":"执行数学表达式计算","inputSchema":{"type":"object","properties":{"expression":{"type":"string","description":"数学表达式"}},"required":["expression"]}}]}}

编写 MCP 客户端

有了服务端之后,我们需要一个客户端来连接它、发现可用工具并发起调用。以下代码实现了一个 MCP 客户端,通过启动服务端进程并以 stdin/stdout 管道与之通信,完整走通握手、列举、调用的流程。

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
class McpClient {
[System.Diagnostics.Process] $Process
[int] $RequestId = 0
[hashtable] $ServerCapabilities
[array] $AvailableTools

# 启动服务端进程
[void] Connect([string]$Command, [string[]]$Arguments) {
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $Command
$psi.Arguments = $Arguments -join ' '
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.CreateNoWindow = $true

$this.Process = [System.Diagnostics.Process]::Start($psi)
Write-Host "已连接到 MCP 服务端 (PID: $($this.Process.Id))" `
-ForegroundColor Cyan

# 发送 initialize 请求
$initResult = $this.SendRequest('initialize', @{
protocolVersion = '2025-03-26'
capabilities = @{}
clientInfo = @{
name = 'ps-mcp-client'
version = '1.0.0'
}
})
$this.ServerCapabilities = $initResult.capabilities
Write-Host "服务端: $($initResult.serverInfo.name) v$($initResult.serverInfo.version)"

# 发送 initialized 通知
$this.SendNotification('notifications/initialized', @{})

# 缓存可用工具列表
$toolsResult = $this.SendRequest('tools/list', @{})
$this.AvailableTools = $toolsResult.tools
Write-Host "已发现 $($this.AvailableTools.Count) 个工具:"
foreach ($tool in $this.AvailableTools) {
Write-Host (" - {0}: {1}" -f $tool.name, $tool.description) `
-ForegroundColor Yellow
}
}

# 发送 JSON-RPC 请求并等待响应
[hashtable] SendRequest([string]$Method, [hashtable]$Params) {
$this.RequestId++
$request = @{
jsonrpc = '2.0'
id = $this.RequestId
method = $Method
params = $Params
}
$jsonLine = $request | ConvertTo-Json -Depth 10 -Compress
$this.Process.StandardInput.WriteLine($jsonLine)
$this.Process.StandardInput.Flush()

$responseLine = $this.Process.StandardOutput.ReadLine()
$response = $responseLine | ConvertFrom-Json -AsHashtable
if ($response.error) {
throw "MCP Error [$($response.error.code)]: $($response.error.message)"
}
return $response.result
}

# 发送 JSON-RPC 通知(无 id,无响应)
[void] SendNotification([string]$Method, [hashtable]$Params) {
$notification = @{
jsonrpc = '2.0'
method = $Method
params = $Params
}
$jsonLine = $notification | ConvertTo-Json -Depth 10 -Compress
$this.Process.StandardInput.WriteLine($jsonLine)
$this.Process.StandardInput.Flush()
}

# 调用指定工具
[string] InvokeTool([string]$ToolName, [hashtable]$Arguments) {
$result = $this.SendRequest('tools/call', @{
name = $ToolName
arguments = $Arguments
})
$textParts = $result.content | Where-Object { $_.type -eq 'text' }
return ($textParts | ForEach-Object { $_.text }) -join "`n"
}

# 断开连接
[void] Disconnect() {
if ($this.Process -and -not $this.Process.HasExited) {
$this.Process.StandardInput.Close()
$this.Process.WaitForExit(5000)
if (-not $this.Process.HasExited) {
$this.Process.Kill()
}
}
Write-Host "已断开 MCP 连接" -ForegroundColor DarkGray
}
}

客户端通过 Connect 方法启动服务端进程并完成协议握手,InvokeTool 方法封装了工具调用的细节,调用者只需传入工具名和参数即可获取结果。下面演示了使用客户端调用工具的典型流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
已连接到 MCP 服务端 (PID: 45832)
服务端: ps-mcp-server v1.0.0
已发现 2 个工具:
- get-process-info: 获取指定进程的详细信息
- calculate: 执行数学表达式计算

PS> $client.InvokeTool('get-process-info', @{ name = 'pwsh' })

Name Id CPU WorkingSet64 Path
---- -- --- ------------ ----
pwsh 45832 3.2145 83251200 /usr/local/bin/pwsh
pwsh 45901 1.7823 79216640 /usr/local/bin/pwsh

PS> $client.InvokeTool('calculate', @{ expression = '2 ** 10 + sqrt(144)' })
计算结果: 2 ** 10 + sqrt(144) = 1012

实用系统管理工具集

理解了 MCP 的客户端-服务端架构后,我们可以把日常系统管理中常用的操作封装成 MCP 工具集,让 AI 助手能够直接调用这些能力。以下代码注册了一组涵盖系统信息、文件操作和网络诊断的实用工具。

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
$sysServer = [McpServer]::new('ps-sys-tools', '1.0.0')

# 工具 1:系统概览
$sysServer.RegisterTool(
'system-overview',
'获取操作系统、CPU、内存等系统概览信息',
@{},
{
param($args)
$os = [System.Environment]::OSVersion
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$mem = Get-CimInstance Win32_OperatingSystem
$totalMem = [math]::Round($mem.TotalVisibleMemorySize / 1MB, 2)
$freeMem = [math]::Round($mem.FreePhysicalMemory / 1MB, 2)
$usedPct = [math]::Round(($totalMem - $freeMem) / $totalMem * 100, 1)
@{
OS = "$($os.Platform) $($os.Version)"
MachineName = [System.Environment]::MachineName
Processor = $cpu.Name
CPUUtilization = "$([math]::Round($cpu.LoadPercentage, 1))%"
TotalMemory_GB = $totalMem
FreeMemory_GB = $freeMem
MemoryUsage = "$usedPct%"
PowerShellVersion= $PSVersionTable.PSVersion.ToString()
Uptime = (Get-Date) - $mem.LastBootUpTime | Out-String
}
}
)

# 工具 2:搜索文件内容
$sysServer.RegisterTool(
'search-files',
'在指定目录中搜索包含指定文本的文件',
@{
directory = @{ type = 'string'; description = '搜索目录路径' }
pattern = @{ type = 'string'; description = '搜索的文本模式' }
extension = @{
type = 'string'
description = '文件扩展名过滤(可选)'
}
},
{
param($args)
$params = @{
Path = $args.directory
Pattern = $args.pattern
Recurse = $true
}
if ($args.extension) {
$params.Include = "*.$($args.extension)"
}
$results = Select-String @params |
Select-Object -First 20 |
ForEach-Object {
@{
File = $_.Path
Line = $_.LineNumber
Match = $_.Line.Trim()
}
}
if (-not $results) {
return @{ message = '未找到匹配结果'; matches = @() }
}
@{ matches = $results; totalFound = $results.Count }
}
)

# 工具 3:网络连通性诊断
$sysServer.RegisterTool(
'network-diag',
'对指定主机执行网络连通性诊断(Ping + 端口检测)',
@{
hostname = @{ type = 'string'; description = '目标主机名或 IP' }
ports = @{
type = 'string'
description = '要检测的端口,逗号分隔(如 80,443,22)'
}
},
{
param($args)
$results = @{}

# Ping 测试
$ping = Test-Connection -TargetName $args.hostname `
-Count 4 -ErrorAction SilentlyContinue
if ($ping) {
$latencies = $ping | ForEach-Object { $_.Latency }
$results.ping = @{
success = $true
avgMs = [math]::Round(($latencies | Measure-Object -Average).Average, 2)
minMs = ($latencies | Measure-Object -Minimum).Minimum
maxMs = ($latencies | Measure-Object -Maximum).Maximum
}
} else {
$results.ping = @{ success = $false; message = '主机不可达' }
}

# 端口检测
$portResults = @{}
foreach ($port in $args.port -split ',') {
$port = $port.Trim()
$tcp = [System.Net.Sockets.TcpClient]::new()
try {
$connect = $tcp.ConnectAsync($args.hostname, [int]$port)
$wait = $connect.Wait(3000)
$portResults[$port] = if ($wait -and $connect.IsCompletedSuccessfully) {
'OPEN'
} else {
'CLOSED/TIMEOUT'
}
} finally {
$tcp.Close()
}
}
$results.ports = $portResults
$results
}
)

# 工具 4:服务状态查询
$sysServer.RegisterTool(
'get-service-status',
'查询指定服务的运行状态',
@{
name = @{ type = 'string'; description = '服务名称(支持通配符)' }
},
{
param($args)
$services = Get-Service -Name $args.name -ErrorAction SilentlyContinue
if (-not $services) {
return @{ message = "未找到服务: $($args.name)" }
}
$services | ForEach-Object {
@{
Name = $_.Name
DisplayName= $_.DisplayName
Status = $_.Status.ToString()
StartType = $_.StartType.ToString()
}
}
}
)

Write-Host "已注册 $($sysServer.Tools.Count) 个系统管理工具" `
-ForegroundColor Green
Write-Host "工具列表:"
$sysServer.Tools | ForEach-Object {
Write-Host (" {0,-20} {1}" -f $_.name, $_.description)
}

注册完成后,AI 助手可以通过 MCP 协议调用这些工具。例如,当用户询问”这台服务器的内存够用吗”时,AI 会自动调用 system-overview 工具获取实时数据并给出分析。以下是一次典型调用的输出:

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
PS> $result = $sysServer.HandleRequest(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"network-diag","arguments":{"hostname":"example.com","ports":"80,443,22"}}}'
)

$result | ConvertFrom-Json | ConvertTo-Json -Depth 5

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"ping\":{\"success\":true,\"avgMs\":23.45,\"minMs\":21,\"maxMs\":28},\"ports\":{\"80\":\"OPEN\",\"443\":\"OPEN\",\"22\":\"CLOSED/TIMEOUT\"}}"
}
]
}
}

PS> $result2 = $sysServer.HandleRequest(
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"system-overview","arguments":{}}}'
)

$result2 | ConvertFrom-Json -AsHashtable | ForEach-Object {
$_.result.content[0].text | ConvertFrom-Json
}

OS : Unix 15.4.0
MachineName : workstation01
Processor : Apple M3 Pro
CPUUtilization : 12.3%
TotalMemory_GB : 36.0
FreeMemory_GB : 14.27
MemoryUsage : 60.4%
PowerShellVersion: 7.5.0
Uptime : 3.14:27:08.3421000

注意事项

  1. 传输模式选择:stdio 模式适合进程间通信(AI 助手启动 PowerShell 子进程),SSE 模式适合跨网络通信(远程服务器上的 MCP 服务)。Windows 环境下推荐 stdio,Linux 服务端部署可考虑 SSE。

  2. 安全沙箱:MCP 工具本质上是在执行任意 PowerShell 代码,必须在受控环境中运行。生产部署时应限制工具的执行权限,避免注册如 Invoke-Expression 等高风险操作,或使用 constrained language mode 加固。

  3. 超时处理:网络诊断等工具可能耗时较长,建议在客户端实现超时机制(如 WaitHandle.AsyncWaitHandle.WaitOne(5000)),避免长时间阻塞导致 JSON-RPC 管道挂死。

  4. 错误传播:MCP 规范定义了标准错误码(如 -32600 Invalid Request、-32601 Method not found),服务端应遵循这些错误码返回,客户端应正确捕获并呈现错误信息,不要将原始异常暴露给 AI。

  5. 工具描述的重要性description 字段是 AI 模型选择工具的主要依据。描述应当清晰、具体、包含关键的使用前提。例如”获取指定进程的详细信息”比”查询进程”更有利于模型准确匹配用户意图。

  6. 序列化深度:PowerShell 对象通常嵌套层级较深(如 CIM 实例、进程对象),使用 ConvertTo-Json 时务必指定足够的 -Depth 参数(建议 5 以上),否则深层属性会被截断为字符串”$ref”,导致 AI 收到不完整的数据。