PowerShell 技能连载 - 加密与数据保护

适用于 PowerShell 5.1 及以上版本

在现代运维和自动化场景中,敏感数据几乎无处不在——数据库连接字符串、API 密钥、用户个人信息、财务记录等。如果这些数据以明文形式存储或传输,一旦系统被入侵或日志泄露,后果将不堪设想。加密(Encryption)是保护数据机密性的最后一道防线。

PowerShell 依托 .NET Framework / .NET 的 System.Security.Cryptography 命名空间,提供了从对称加密、非对称加密到哈希校验、数字签名的完整加密工具链。无论是在脚本中保护凭据、验证文件完整性,还是对发布脚本进行代码签名,这些能力都是编写安全自动化脚本的基础。

本文将通过三个实战场景,分别演示如何使用 AES 对称加密保护文件和字符串、利用哈希算法验证数据完整性,以及通过数字证书实现代码签名与验证。

对称加密与文件保护

对称加密使用同一把密钥进行加密和解密,适合对大量数据进行快速加解密。AES(Advanced Encryption Standard)是目前最广泛使用的对称加密算法,密钥长度支持 128/192/256 位。以下脚本封装了 AES 加密和解密的核心逻辑,并提供了文件级别的加密保护功能。

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
# AES 对称加密工具函数
function New-AesKey {
param([int]$KeySize = 256)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.KeySize = $KeySize
$aes.GenerateKey()
$aes.GenerateIV()
return @{
Key = $aes.Key
IV = $aes.IV
}
}

function Protect-StringWithAes {
param(
[Parameter(Mandatory)][string]$PlainText,
[Parameter(Mandatory)][byte[]]$Key,
[Parameter(Mandatory)][byte[]]$IV
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $Key
$aes.IV = $IV
$encryptor = $aes.CreateEncryptor()
$plainBytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
$encryptedBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)
return [Convert]::ToBase64String($encryptedBytes)
}

function Unprotect-StringWithAes {
param(
[Parameter(Mandatory)][string]$CipherText,
[Parameter(Mandatory)][byte[]]$Key,
[Parameter(Mandatory)][byte[]]$IV
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $Key
$aes.IV = $IV
$decryptor = $aes.CreateDecryptor()
$cipherBytes = [Convert]::FromBase64String($CipherText)
$decryptedBytes = $decryptor.TransformFinalBlock($cipherBytes, 0, $cipherBytes.Length)
return [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
}

# 生成密钥对
$keyInfo = New-AesKey -KeySize 256
Write-Host "AES 密钥长度: $($keyInfo.Key.Length * 8) 位"
Write-Host "初始化向量长度: $($keyInfo.IV.Length * 8) 位"

# 加密敏感字符串
$secret = "Server=db.prod.example.com;User=admin;Password=S3cretP@ss!"
$encrypted = Protect-StringWithAes -PlainText $secret -Key $keyInfo.Key -IV $keyInfo.IV
Write-Host "`n加密结果: $encrypted"

# 解密还原
$decrypted = Unprotect-StringWithAes -CipherText $encrypted -Key $keyInfo.Key -IV $keyInfo.IV
Write-Host "解密结果: $decrypted"

# 将密钥安全存储到文件(仅当前用户可访问)
$keyPath = "$env:USERPROFILE\.secrets\aes.key"
$ivPath = "$env:USERPROFILE\.secrets\aes.iv"
New-Item -ItemType Directory -Path (Split-Path $keyPath) -Force | Out-Null
[System.IO.File]::WriteAllBytes($keyPath, $keyInfo.Key)
[System.IO.File]::WriteAllBytes($ivPath, $keyInfo.IV)
Write-Host "`n密钥已保存至 $keyPath"

执行结果示例:

1
2
3
4
5
6
7
8
AES 密钥长度: 256
初始化向量长度: 128

加密结果: q8Jx3LmN9vT2kR5wY8aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg==

解密结果: Server=db.prod.example.com;User=admin;Password=S3cretP@ss!

密钥已保存至 C:\Users\admin\.secrets\aes.key

哈希与完整性验证

哈希算法将任意长度的数据映射为固定长度的摘要值,具有单向性和抗碰撞性,广泛用于数据完整性校验和消息认证。SHA-256 和 SHA-512 是目前推荐的哈希算法。HMAC(Hash-based Message Authentication Code)则在此基础上加入密钥,既能验证完整性又能验证消息来源。

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
# 文件哈希计算与完整性校验
function Get-FileHashEx {
param(
[Parameter(Mandatory)][string]$Path,
[ValidateSet('SHA256', 'SHA384', 'SHA512', 'MD5')]
[string]$Algorithm = 'SHA256'
)
if (-not (Test-Path $Path)) {
throw "文件不存在: $Path"
}
$hashAlg = [System.Security.Cryptography.HashAlgorithm]::Create($Algorithm)
$fileStream = [System.IO.File]::OpenRead((Resolve-Path $Path).Path)
try {
$hashBytes = $hashAlg.ComputeHash($fileStream)
$hashHex = [BitConverter]::ToString($hashBytes) -replace '-', ''
return @{
Path = $Path
Algorithm = $Algorithm
Hash = $hashHex
Size = $fileStream.Length
}
}
finally {
$fileStream.Dispose()
}
}

# 计算文件哈希
$result = Get-FileHashEx -Path $PSCommandPath -Algorithm SHA256
Write-Host "文件: $($result.Path)"
Write-Host "算法: $($result.Algorithm)"
Write-Host "哈希: $($result.Hash)"
Write-Host "大小: $($result.Size) 字节"

# HMAC 消息认证码
function Get-HmacHash {
param(
[Parameter(Mandatory)][string]$Message,
[Parameter(Mandatory)][string]$Secret
)
$keyBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret)
$messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message)
$hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList @(,$keyBytes)
$hashBytes = $hmac.ComputeHash($messageBytes)
return [BitConverter]::ToString($hashBytes) -replace '-', ''
}

# 模拟 API 签名验证场景
$apiPayload = '{"action":"transfer","amount":5000,"to":"account-12345"}'
$apiSecret = "my-api-secret-key-2026"
$signature = Get-HmacHash -Message $apiPayload -Secret $apiSecret
Write-Host "`nAPI 载荷: $apiPayload"
Write-Host "HMAC 签名: $signature"

# 接收方验证签名
$receivedSig = Get-HmacHash -Message $apiPayload -Secret $apiSecret
$isValid = ($signature -eq $receivedSig)
Write-Host "签名验证: $(if ($isValid) { '通过' } else { '失败' })"

# 批量文件完整性基线
$baselineFile = "$env:TEMP\hash-baseline.csv"
$monitorPath = $PWD.Path
$files = Get-ChildItem -Path $monitorPath -Filter "*.ps1" -File
$baseline = foreach ($f in $files) {
$h = Get-FileHashEx -Path $f.FullName -Algorithm SHA256
[PSCustomObject]@{
File = $f.Name
SHA256 = $h.Hash
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}
$baseline | Export-Csv -Path $baselineFile -NoTypeInformation -Encoding UTF8
Write-Host "`n已生成 $($baseline.Count) 个文件的完整性基线: $baselineFile"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
文件: C:\Scripts\audit.ps1
算法: SHA256
哈希: A1B2C3D4E5F6789012345678901234567890ABCDEF1234567890ABCDEF123456
大小: 4096 字节

API 载荷: {"action":"transfer","amount":5000,"to":"account-12345"}
HMAC 签名: 7F8E9D0C1B2A3948567E6F5D4C3B2A10987654321F0E1D2C3B4A59687012345
签名验证: 通过

已生成 12 个文件的完整性基线: C:\Users\admin\AppData\Local\Temp\hash-baseline.csv

数字签名与证书操作

数字签名结合了非对称加密和哈希算法,不仅能验证数据的完整性,还能确认数据的来源身份。在 PowerShell 中,代码签名是脚本安全策略的核心环节——只有受信任的发布者签名的脚本才能在受限执行策略下运行。此外,证书操作还广泛用于 TLS/SSL 通信和文档签名。

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
# 查看本机代码签名证书
$codeCerts = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert
if ($codeCerts) {
foreach ($cert in $codeCerts) {
Write-Host "证书主题: $($cert.Subject)"
Write-Host "指纹: $($cert.Thumbprint)"
Write-Host "有效期: $($cert.NotBefore) ~ $($cert.NotAfter)"
Write-Host "颁发者: $($cert.Issuer)"
Write-Host "---"
}
}
else {
Write-Host "未找到代码签名证书,创建自签名证书用于测试..."
$testCert = New-SelfSignedCertificate `
-Type CodeSigningCert `
-Subject "CN=PowerShell Test Signing" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-NotAfter (Get-Date).AddYears(1)
Write-Host "已创建测试证书: $($testCert.Subject)"
Write-Host "指纹: $($testCert.Thumbprint)"
}

# 对脚本进行数字签名
function Set-ScriptSignature {
param(
[Parameter(Mandatory)][string]$ScriptPath,
[Parameter(Mandatory)][string]$CertThumbprint
)
$cert = Get-Item -Path "Cert:\CurrentUser\My\$CertThumbprint"
if (-not $cert) {
throw "未找到指纹为 $CertThumbprint 的证书"
}
$status = Get-AuthenticodeSignature -FilePath $ScriptPath
if ($status.Status -eq 'Valid') {
Write-Host "脚本已签名且签名有效: $ScriptPath"
return
}
Set-AuthenticodeSignature -FilePath $ScriptPath -Certificate $cert | Out-Null
$newStatus = Get-AuthenticodeSignature -FilePath $ScriptPath
Write-Host "签名状态: $($newStatus.Status)"
Write-Host "签名者: $($newStatus.SignerCertificate.Subject)"
}

# 证书链验证
function Test-CertificateChain {
param([Parameter(Mandatory)][string]$CertThumbprint)
$cert = Get-Item -Path "Cert:\CurrentUser\My\$CertThumbprint"
if (-not $cert) {
throw "未找到证书: $CertThumbprint"
}
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online
$chain.ChainPolicy.RevocationFlag = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot
$isValid = $chain.Build($cert)
Write-Host "证书: $($cert.Subject)"
Write-Host "链验证: $(if ($isValid) { '通过' } else { '失败' })"
if (-not $isValid) {
foreach ($element in $chain.ChainElements) {
Write-Host " 链节点: $($element.Certificate.Subject)"
}
foreach ($status in $chain.ChainStatus) {
Write-Host " 状态: $($status.StatusInformation)"
}
}
return $isValid
}

# 导出证书公钥(用于分发)
$pubCertPath = "$env:TEMP\PowerShellSigning.cer"
if ($testCert) {
Export-Certificate -Cert $testCert -FilePath $pubCertPath | Out-Null
Write-Host "`n公钥已导出至: $pubCertPath"
Write-Host "文件大小: $((Get-Item $pubCertPath).Length) 字节"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
未找到代码签名证书,创建自签名证书用于测试...
已创建测试证书: CN=PowerShell Test Signing
指纹: A1B2C3D4E5F6789012345678901234567890ABCD

签名状态: Valid
签名者: CN=PowerShell Test Signing

证书: CN=PowerShell Test Signing
链验证: 通过

公钥已导出至: C:\Users\admin\AppData\Local\Temp\PowerShellSigning.cer
文件大小: 814 字节

注意事项

  1. 密钥管理是安全的基石:再强的加密算法,如果密钥管理不当(如硬编码在脚本中、存储在无权限控制的文件中),整个安全体系形同虚设。生产环境建议使用 Windows DPAPI(ConvertTo-SecureString)、Azure Key Vault 或 HashiCorp Vault 等专业密钥管理方案。

  2. 优先使用 AES 和 SHA-256 及以上算法:DES、3DES、MD5、SHA-1 等算法已被证实存在安全弱点,新项目应一律避免使用。AES-256 是对称加密的首选,SHA-256/SHA-512 是哈希的首选。

  3. 自签名证书仅用于测试:生产环境应使用来自受信任 CA(如企业 AD CS、Let’s Encrypt、DigiCert 等)颁发的证书。自签名证书无法被其他系统自动信任,且无法提供真正的身份验证保障。

  4. 加密数据时要同时保护 IV:AES 加密中 IV(初始化向量)不需要保密,但必须不可预测且每次加密都不同。如果重复使用相同的 Key 和 IV 加密不同数据,会大幅降低安全性。

  5. HMAC 密钥长度应与哈希输出长度匹配:HMAC-SHA256 的密钥至少应为 32 字节,HMAC-SHA512 的密钥至少应为 64 字节。过短的密钥会削弱消息认证的安全性。

  6. 注意 .NET 版本差异:部分加密 API 在 .NET Framework 和 .NET(Core)之间行为略有不同。例如 Aes.Create() 在两个运行时都可用,但某些过时的算法类(如 RijndaelManaged)在 .NET 6+ 中可能已标记为过时。建议始终使用 AesSHA256 等抽象工厂方法创建加密对象。

PowerShell 技能连载 - 打印机与打印服务管理

适用于 PowerShell 5.1 及以上版本

在企业 IT 运维中,打印机管理看似简单,实则是一项高频且容易出问题的工作。新员工入职需要分配部门打印机、打印队列卡住需要及时清理、驱动更新需要在多台终端上统一部署——这些操作如果手动执行,不仅耗时而且容易出错。

Windows Server 2012 引入了 PrintManagement 模块,其中包含 PrinterPrinterDriverPrinterPortPrintJob 等一系列 cmdlet,几乎覆盖了打印机生命周期的所有管理场景。结合 PowerShell 的远程执行能力,可以轻松实现集中化的打印服务管理。

本文将从基础安装配置、批量部署映射、监控故障排查三个层面,介绍如何使用 PowerShell 构建一套完整的打印机管理方案。

打印机基础管理

打印机的基础管理包括安装驱动程序、创建 TCP/IP 打印端口以及添加打印机实例。下面这段脚本演示了完整的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
# 导入 PrintManagement 模块(Windows 8/Server 2012 及以上自带)
Import-Module PrintManagement

# 定义打印机配置参数
$PrinterConfig = @{
DriverName = 'HP Universal Printing PCL 6'
DriverInf = '\\fileserver\drivers\HP\hpcu250u.inf'
PortName = 'IP_192.168.1.100'
IPAddress = '192.168.1.100'
PrinterName = 'HP-LaserJet-Finance-3F'
Location = '3F-Finance-Dept'
Comment = '财务部3楼共享打印机'
}

# 第一步:安装打印机驱动(如果尚未安装)
$existingDriver = Get-PrinterDriver -Name $PrinterConfig.DriverName -ErrorAction SilentlyContinue

if (-not $existingDriver) {
Add-PrinterDriver -Name $PrinterConfig.DriverName -InfPath $PrinterConfig.DriverInf
Write-Host "已安装驱动: $($PrinterConfig.DriverName)" -ForegroundColor Green
} else {
Write-Host "驱动已存在,跳过安装: $($PrinterConfig.DriverName)" -ForegroundColor Yellow
}

# 第二步:创建 TCP/IP 打印端口
$existingPort = Get-PrinterPort -Name $PrinterConfig.PortName -ErrorAction SilentlyContinue

if (-not $existingPort) {
Add-PrinterPort -Name $PrinterConfig.PortName -PrinterHostAddress $PrinterConfig.IPAddress
Write-Host "已创建端口: $($PrinterConfig.PortName)" -ForegroundColor Green
} else {
Write-Host "端口已存在,跳过创建: $($PrinterConfig.PortName)" -ForegroundColor Yellow
}

# 第三步:添加打印机
$existingPrinter = Get-Printer -Name $PrinterConfig.PrinterName -ErrorAction SilentlyContinue

if (-not $existingPrinter) {
Add-Printer `
-Name $PrinterConfig.PrinterName `
-DriverName $PrinterConfig.DriverName `
-PortName $PrinterConfig.PortName `
-Location $PrinterConfig.Location `
-Comment $PrinterConfig.Comment

Write-Host "已添加打印机: $($PrinterConfig.PrinterName)" -ForegroundColor Green
} else {
Write-Host "打印机已存在: $($PrinterConfig.PrinterName)" -ForegroundColor Yellow
}

# 验证安装结果
Get-Printer -Name $PrinterConfig.PrinterName |
Select-Object Name, DriverName, PortName, Location, PrinterStatus |
Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
驱动已存在,跳过安装: HP Universal Printing PCL 6
已创建端口: IP_192.168.1.100
已添加打印机: HP-LaserJet-Finance-3F

Name : HP-LaserJet-Finance-3F
DriverName : HP Universal Printing PCL 6
PortName : IP_192.168.1.100
Location : 3F-Finance-Dept
PrinterStatus : Normal

脚本中对每一步都做了幂等检查:如果驱动、端口或打印机已经存在,则跳过而不报错。这种写法非常适合放在配置管理工具(如 DSC、Ansible)中反复执行。

批量部署与映射

在企业环境中,通常需要按部门为用户批量映射打印机。下面的脚本展示了如何根据部门映射关系表,将打印机部署到对应的工作站:

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
# 定义部门与打印机的映射关系
$PrinterMapping = @(
@{
Department = 'Finance'
Printers = @('HP-LaserJet-Finance-3F', 'HP-ColorJet-Finance-3F')
DefaultPrinter = 'HP-LaserJet-Finance-3F'
}
@{
Department = 'HR'
Printers = @('HP-LaserJet-HR-5F')
DefaultPrinter = 'HP-LaserJet-HR-5F'
}
@{
Department = 'Engineering'
Printers = @('HP-LaserJet-Eng-8F', 'Canon-Eng-LargeFormat')
DefaultPrinter = 'HP-LaserJet-Eng-8F'
}
)

# 远程打印服务器
$PrintServer = 'PRINTSRV01'

# 根据当前用户所属部门自动映射打印机
function Install-DepartmentPrinters {
param(
[string[]]$UserDepartments,
[string]$Server = 'PRINTSRV01'
)

foreach ($dept in $UserDepartments) {
$mapping = $PrinterMapping | Where-Object { $_.Department -eq $dept }

if (-not $mapping) {
Write-Warning "未找到部门 '$dept' 的打印机映射配置"
continue
}

Write-Host "`n=== 正在为部门 [$dept] 映射打印机 ===" -ForegroundColor Cyan

foreach ($printerName in $mapping.Printers) {
$fullPath = "\\$Server\$printerName"

try {
# 检查打印机是否已映射
$existing = Get-Printer -Name $fullPath -ErrorAction SilentlyContinue

if (-not $existing) {
Add-Printer -ConnectionName $fullPath
Write-Host " 已映射: $printerName" -ForegroundColor Green
} else {
Write-Host " 已存在: $printerName" -ForegroundColor Yellow
}
} catch {
Write-Host " 映射失败: $printerName - $($_.Exception.Message)" -ForegroundColor Red
}
}

# 设置默认打印机
if ($mapping.DefaultPrinter) {
$defaultPath = "\\$Server\$($mapping.DefaultPrinter)"
$defaultPrinter = Get-Printer -Name $defaultPath -ErrorAction SilentlyContinue

if ($defaultPrinter) {
# 使用 rundll32 设置默认打印机(兼容性好)
$null = rundll32.exe printui.dll,PrintUIEntry /y /n $defaultPath
Write-Host " 已设为默认: $($mapping.DefaultPrinter)" -ForegroundColor Green
}
}
}
}

# 模拟:从 Active Directory 获取用户部门信息
$userDepartment = 'Finance'

# 执行映射
Install-DepartmentPrinters -UserDepartments $userDepartment -Server $PrintServer

# 输出当前已安装的打印机列表
Write-Host "`n=== 当前已映射的打印机 ===" -ForegroundColor Cyan
Get-Printer | Where-Object { $_.Type -eq 'Connection' } |
Select-Object Name, Location, Comment |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
=== 正在为部门 [Finance] 映射打印机 ===
已映射: HP-LaserJet-Finance-3F
已映射: HP-ColorJet-Finance-3F
已设为默认: HP-LaserJet-Finance-3F

=== 当前已映射的打印机 ===
Name Location Comment
---- -------- -------
\\PRINTSRV01\HP-LaserJet-Finance-3F 3F-Finance-Dept 财务部3楼共享打印机
\\PRINTSRV01\HP-ColorJet-Finance-3F 3F-Finance-Dept 财务部3楼彩色打印机

这段脚本可以打包成登录脚本(Logon Script),通过组策略(GPO)分发给所有域用户。用户登录后自动根据其部门信息完成打印机映射和默认设置,无需手动干预。

监控与故障排查

打印队列堵塞是日常运维中常见的故障。下面这段脚本提供了队列监控、作业管理和驱动诊断的能力:

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
# 打印服务器监控脚本
$PrintServer = 'PRINTSRV01'

# 获取所有打印机及其队列状态
Write-Host "=== 打印机状态总览 ===" -ForegroundColor Cyan
$allPrinters = Get-Printer -ComputerName $PrintServer

$allPrinters |
Select-Object Name,
@{N='状态';E={
switch ($_.PrinterStatus) {
0 { 'Paused' }
1 { 'Error' }
2 { 'Pending Deletion' }
3 { 'Paper Jam' }
4 { 'Paper Out' }
5 { 'Manual Feed' }
6 { 'Offline' }
7 { 'IO Active' }
8 { 'Busy' }
9 { 'Printing' }
10 { 'Output Bin Full' }
default { 'Ready' }
}
}},
@{N='队列作业数';E={ (Get-PrintJob -PrinterName $_.Name -ErrorAction SilentlyContinue |
Measure-Object).Count }},
JobCountUntilNotification,
Location |
Format-Table -AutoSize

# 检查卡住的打印作业(超过 30 分钟仍在队列中)
Write-Host "`n=== 超时打印作业检查 ===" -ForegroundColor Cyan
$threshold = (Get-Date).AddMinutes(-30)
$stuckJobs = @()

foreach ($printer in $allPrinters) {
$jobs = Get-PrintJob -PrinterName $printer.Name -ErrorAction SilentlyContinue

foreach ($job in $jobs) {
if ($job.SubmitTime -and $job.SubmitTime -lt $threshold) {
$stuckJobs += [PSCustomObject]@{
PrinterName = $printer.Name
JobId = $job.Id
Document = $job.DocumentName
SubmitTime = $job.SubmitTime
UserName = $job.UserName
Size(KB) = [math]::Round($job.Size / 1KB, 2)
}
}
}
}

if ($stuckJobs) {
Write-Host "发现 $($stuckJobs.Count) 个超时作业:" -ForegroundColor Yellow
$stuckJobs | Format-Table -AutoSize

# 自动清理超时作业
$confirm = Read-Host "是否清理这些超时作业?(Y/N)"
if ($confirm -eq 'Y') {
foreach ($job in $stuckJobs) {
Remove-PrintJob -PrinterName $job.PrinterName -ID $job.JobId
Write-Host " 已删除作业: $($job.Document) (ID: $($job.JobId))" -ForegroundColor Green
}
}
} else {
Write-Host "没有发现超时的打印作业" -ForegroundColor Green
}

# 驱动兼容性检查
Write-Host "`n=== 打印机驱动诊断 ===" -ForegroundColor Cyan
$drivers = Get-PrinterDriver -ComputerName $PrintServer

$drivers |
Select-Object Name,
@{N='环境';E={ $_.PrinterEnvironment -join ', ' }},
@{N='驱动版本';E={ $_.DriverVersion }},
@{N='依赖文件数';E={ ($_.DependentFiles | Measure-Object).Count }} |
Format-Table -AutoSize

# 检查是否有驱动文件缺失
foreach ($driver in $drivers) {
$driverPath = Join-Path $env:SystemRoot "System32\spool\drivers"
$missingFiles = @()

if ($driver.DependentFiles) {
foreach ($file in $driver.DependentFiles) {
$fullPath = Join-Path $driverPath $file
if (-not (Test-Path $fullPath)) {
$missingFiles += $file
}
}
}

if ($missingFiles.Count -gt 0) {
Write-Host "驱动 '$($driver.Name)' 缺失 $($missingFiles.Count) 个文件:" -ForegroundColor Red
$missingFiles | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
}
}

执行结果示例:

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
=== 打印机状态总览 ===
Name 状态 队列作业数 JobCountUntilNotification Location
---- ---- ---------- ------------------------ --------
HP-LaserJet-Finance-3F Ready 2 10 3F-Finance-Dept
HP-ColorJet-Finance-3F Ready 0 10 3F-Finance-Dept
HP-LaserJet-HR-5F Offline 5 10 5F-HR-Dept
HP-LaserJet-Eng-8F Ready 0 10 8F-Engineering
Canon-Eng-LargeFormat Ready 1 10 8F-Engineering

=== 超时打印作业检查 ===
发现 3 个超时作业:
PrinterName JobId Document SubmitTime UserName Size(KB)
------------ ----- -------- ----------- -------- --------
HP-LaserJet-Finance-3F 3 Q1-Report.xlsx 2026/3/4 10:15:22 CONTOSO\zhangsan 512.5
HP-LaserJet-HR-5F 12 payroll.pdf 2026/3/4 09:30:11 CONTOSO\lisi 1024.0
HP-LaserJet-HR-5F 14 onboarding.docx 2026/3/4 10:05:45 CONTOSO\wangwu 256.75

是否清理这些超时作业?(Y/N): Y
已删除作业: Q1-Report.xlsx (ID: 3)
已删除作业: payroll.pdf (ID: 12)
已删除作业: onboarding.docx (ID: 14)

=== 打印机驱动诊断 ===
Name 环境 驱动版本 依赖文件数
---- ---- -------- ----------
HP Universal Printing PCL 6 Windows x64, Windows x86 6.0.1 24
Canon Generic Plus UFR II Driver Windows x64 3.1.0 18

这个监控脚本可以集成到定时任务中,每隔一段时间自动扫描打印服务器,发现超时作业和驱动异常时发送告警邮件或钉钉/企业微信通知。

注意事项

  • PrintManagement 模块仅在 Windows 8 / Windows Server 2012 及以上版本中可用。如果需要在更旧的系统上管理打印机,需要通过 WMI(Win32_Printer 类)或 rundll32 printui.dll 命令实现。
  • 使用 Add-Printer -ConnectionName 映射网络打印机时,需要确保 Print Server 的共享权限正确配置,否则会出现”拒绝访问”错误。建议在域环境中通过组策略统一管理权限。
  • 清理打印作业前务必确认作业确实卡住(而非用户正在打印大文件)。可以通过文件大小和提交时间综合判断,避免误删正在执行的大文档打印任务。
  • 打印机驱动安装涉及 INF 文件路径。如果驱动存放在网络共享路径上,需要确保执行脚本的账户对该共享有读取权限,且路径使用 UNC 格式(如 \\fileserver\drivers\...)。
  • 远程管理打印服务器时(使用 -ComputerName 参数),需要确保 WinRM 服务已启用,且目标服务器防火墙放行了 RPC 动态端口范围。跨子网管理时可能需要配置 WinRM 的 TrustedHosts。
  • 在大规模环境中(数百台打印机以上),建议将打印机配置信息存储在 CMDB 或数据库中,脚本从数据源读取配置而非硬编码。这样可以与企业的 IP 地址管理系统(IPAM)联动,实现打印机全生命周期管理。

PowerShell 技能连载 - Azure App Configuration 集中配置

适用于 PowerShell 7.0 及以上版本

在微服务架构中,每个服务都有自己的 appsettings.json 或环境变量文件来管理配置。随着服务数量增长,配置分散在各处的问题日益严重:修改一个数据库连接字符串需要逐个服务更新并重新部署,不同环境之间的配置差异难以追踪,紧急开关的生效时间更是无法保证。这种”配置碎片化”不仅增加了运维成本,也埋下了安全隐患。

Azure App Configuration 正是为解决这些问题而生的集中化配置管理服务。它提供统一的键值存储,支持标签(Label)实现环境隔离,内置 Feature Flag 功能支持灰度发布,并通过 Sentinel 机制实现配置的动态刷新——所有这些都不需要重启应用。结合 PowerShell 的自动化能力,我们可以将配置管理纳入 CI/CD 流水线,实现配置的版本控制、审计追踪和跨环境同步。

本文将围绕三个核心场景展开:配置存储的日常管理操作、Feature Flag 的创建与条件控制,以及配置的批量导入导出与环境间同步。掌握这些技能后,你就能用 PowerShell 构建一套完整的配置管理自动化方案。

配置存储管理

日常配置管理是最基础也是最频繁的操作。下面的脚本封装了连接 App Configuration 存储、读写键值对、使用标签隔离环境以及查询配置修订历史等常用功能,适合直接集成到运维工具链中。

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
function Invoke-AppConfigOperation {
<#
.SYNOPSIS
管理 Azure App Configuration 键值对
#>
param(
[Parameter(Mandatory)]
[string]$Endpoint,

[ValidateSet('Get', 'Set', 'Remove', 'List', 'History')]
[string]$Action = 'List',

[string]$Key,
[string]$Value,
[string]$Label,
[string]$Prefix
)

# 使用 Azure CLI 获取访问令牌
$token = (az account get-access-token --resource "https://azconfig.io" --query accessToken --output tsv)
$headers = @{
Authorization = "Bearer $token"
'Content-Type' = 'application/vnd.microsoft.appconfig.kv+json'
}

switch ($Action) {
'Get' {
$uri = "$Endpoint/kv/$($Key)?label=$Label"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
return [PSCustomObject]@{
Key = $response.key
Value = $response.value
Label = $response.label
ETag = $response.etag
}
}

'Set' {
$uri = "$Endpoint/kv/$($Key)?label=$Label"
$body = @{
key = $Key
value = $Value
label = $Label
} | ConvertTo-Json

$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Put -Body $body
Write-Host "已设置配置项: $Key = $Value (标签: $Label)" -ForegroundColor Green
return $response
}

'Remove' {
# 先获取当前 ETag
$existing = Invoke-AppConfigOperation -Endpoint $Endpoint -Action 'Get' -Key $Key -Label $Label
$uri = "$Endpoint/kv/$($Key)?label=$Label"
Invoke-RestMethod -Uri $uri -Headers $headers -Method Delete
Write-Host "已删除配置项: $Key (标签: $Label)" -ForegroundColor Yellow
}

'List' {
$filterParam = if ($Prefix) { "&key=$Prefix*" } else { '' }
$labelParam = if ($Label) { "&label=$Label" } else { '' }
$uri = "$Endpoint/kv?$filterParam$labelParam"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
$items = $response.items
return $items | ForEach-Object {
[PSCustomObject]@{
Key = $_.key
Value = $_.value
Label = $_.label
}
}
}

'History' {
$uri = "$Endpoint/revisions?key=$Key&label=$Label"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
return $response.items | ForEach-Object {
[PSCustomObject]@{
Key = $_.key
Value = $_.value
Label = $_.label
ETag = $_.etag
Timestamp = $_.last_modified
}
}
}
}
}

# 示例:按环境管理配置
$endpoint = "https://myappconfig.azconfig.io"

# 设置开发环境和生产环境的数据库连接串
Invoke-AppConfigOperation -Endpoint $endpoint -Action 'Set' `
-Key "Database:ConnectionString" -Value "Server=dev-sql;Database=MyApp" -Label "dev"

Invoke-AppConfigOperation -Endpoint $endpoint -Action 'Set' `
-Key "Database:ConnectionString" -Value "Server=prod-sql;Database=MyApp" -Label "prod"

# 列出开发环境下所有 Database 前缀的配置
Invoke-AppConfigOperation -Endpoint $endpoint -Action 'List' -Prefix "Database" -Label "dev"

# 查看某个配置项的修改历史
Invoke-AppConfigOperation -Endpoint $endpoint -Action 'History' `
-Key "Database:ConnectionString" -Label "prod"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
已设置配置项: Database:ConnectionString = Server=dev-sql;Database=MyApp (标签: dev)
已设置配置项: Database:ConnectionString = Server=prod-sql;Database=MyApp (标签: prod)

Key Value Label
--- ----- -----
Database:ConnectionString Server=dev-sql;Database=MyApp dev
Database:MaxPoolSize 50 dev
Database:CommandTimeout 30 dev

Key Value Label ETag Timestamp
--- ----- ----- ---- ---------
Database:ConnectionString Server=prod-sql;Database=MyApp prod "abc123" 2026-03-03T10:15:00Z
Database:ConnectionString Server=staging-sql;Database=MyApp prod "def456" 2026-03-01T08:30:00Z
Database:ConnectionString Server=prod-sql;Database=MyApp prod "ghi789" 2026-02-28T14:00:00Z

通过标签(Label)机制,同一个键可以对应不同环境的值。查询时指定标签即可获取对应环境的配置,无需维护多套命名规则。修订历史功能可以追溯每次配置变更,为审计和回滚提供依据。

Feature Flag 管理

Feature Flag 是实现灰度发布、A/B 测试和功能开关的核心机制。App Configuration 原生支持 Feature Flag 的存储和管理,下面演示如何用 PowerShell 创建、查询和配置带条件过滤器的 Feature Flag。

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
function Manage-FeatureFlag {
<#
.SYNOPSIS
管理 Azure App Configuration 中的 Feature Flag
#>
param(
[Parameter(Mandatory)]
[string]$Endpoint,

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

[ValidateSet('Create', 'Get', 'Enable', 'Disable', 'SetPercentage', 'SetTargeting')]
[string]$Action = 'Get',

[string]$Label,
[double]$Percentage = 50,
[string[]]$TargetGroups,
[string[]]$ExcludeUsers
)

$token = (az account get-access-token --resource "https://azconfig.io" --query accessToken --output tsv)
$headers = @{
Authorization = "Bearer $token"
'Content-Type' = 'application/vnd.microsoft.appconfig.kv+json'
}

$featureKey = ".appconfig.featureflag/$FlagName"

switch ($Action) {
'Create' {
$body = @{
key = $featureKey
value = @{
id = $FlagName
description = "Feature flag for $FlagName"
enabled = $false
conditions = @{
client_filters = @()
}
} | ConvertTo-Json -Depth 5
label = $Label
content_type = 'application/vnd.microsoft.appconfig.featureflag+json'
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Put -Body $body
Write-Host "已创建 Feature Flag: $FlagName (状态: 关闭)" -ForegroundColor Green
}

'Enable' {
$existing = Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Get
$flagValue = $existing.value | ConvertFrom-Json
$flagValue.enabled = $true

$body = @{
key = $featureKey
value = ($flagValue | ConvertTo-Json -Depth 5)
label = $Label
content_type = 'application/vnd.microsoft.appconfig.featureflag+json'
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Put -Body $body
Write-Host "已启用 Feature Flag: $FlagName" -ForegroundColor Green
}

'Disable' {
$existing = Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Get
$flagValue = $existing.value | ConvertFrom-Json
$flagValue.enabled = $false

$body = @{
key = $featureKey
value = ($flagValue | ConvertTo-Json -Depth 5)
label = $Label
content_type = 'application/vnd.microsoft.appconfig.featureflag+json'
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Put -Body $body
Write-Host "已禁用 Feature Flag: $FlagName" -ForegroundColor Yellow
}

'SetPercentage' {
# 配置百分比灰度:按用户比例逐步放开功能
$existing = Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Get
$flagValue = $existing.value | ConvertFrom-Json
$flagValue.enabled = $true
$flagValue.conditions.client_filters = @(
@{
name = 'Microsoft.Targeting'
parameters = @{
Audiences = @(
@{
Id = "rollout-group"
Inclusion = $Percentage
Exclusion = 0
}
)
DefaultRolloutPercentage = $Percentage
}
}
)

$body = @{
key = $featureKey
value = ($flagValue | ConvertTo-Json -Depth 10)
label = $Label
content_type = 'application/vnd.microsoft.appconfig.featureflag+json'
} | ConvertTo-Json -Depth 10

Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Put -Body $body
Write-Host "已设置 $FlagName 灰度比例为 ${Percentage}%" -ForegroundColor Cyan
}

'SetTargeting' {
# 配置定向投放:指定用户组可见
$existing = Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Get
$flagValue = $existing.value | ConvertFrom-Json
$flagValue.enabled = $true
$flagValue.conditions.client_filters = @(
@{
name = 'Microsoft.Targeting'
parameters = @{
Users = @()
Groups = @(
@{
Name = "beta-testers"
RolloutPercentage = 100
}
)
DefaultRolloutPercentage = 0
}
}
)

$body = @{
key = $featureKey
value = ($flagValue | ConvertTo-Json -Depth 10)
label = $Label
content_type = 'application/vnd.microsoft.appconfig.featureflag+json'
} | ConvertTo-Json -Depth 10

Invoke-RestMethod -Uri "$Endpoint/kv/$featureKey?label=$Label" `
-Headers $headers -Method Put -Body $body
Write-Host "已设置 $FlagName 定向投放: beta-testers 组 100% 可见" -ForegroundColor Cyan
}
}
}

# 创建并配置新功能的 Feature Flag
$endpoint = "https://myappconfig.azconfig.io"

# 创建开关(初始关闭)
Manage-FeatureFlag -Endpoint $endpoint -FlagName "NewDashboard" `
-Action 'Create' -Label "prod"

# 开启灰度:先对 10% 用户开放
Manage-FeatureFlag -Endpoint $endpoint -FlagName "NewDashboard" `
-Action 'SetPercentage' -Percentage 10 -Label "prod"

# 逐步放量到 50%
Manage-FeatureFlag -Endpoint $endpoint -FlagName "NewDashboard" `
-Action 'SetPercentage' -Percentage 50 -Label "prod"

# 设置定向投放:beta-testers 组全部可见
Manage-FeatureFlag -Endpoint $endpoint -FlagName "NewDashboard" `
-Action 'SetTargeting' -Label "prod"

执行结果示例:

1
2
3
4
已创建 Feature Flag: NewDashboard (状态: 关闭)
已设置 NewDashboard 灰度比例为 10%
已设置 NewDashboard 灰度比例为 50%
已设置 NewDashboard 定向投放: beta-testers 组 100% 可见

Feature Flag 的核心价值在于将功能发布与代码部署解耦。通过百分比灰度,可以按照 10% → 50% → 100% 的节奏逐步放量;通过定向投放,可以让内部测试团队或特定用户组优先体验新功能。所有调整都是实时生效的,不需要重新部署应用。

配置导入导出与同步

在多环境管理中,将配置从开发环境同步到测试、预发布和生产环境是常见的运维任务。下面的脚本支持批量导入配置、跨环境同步,并能生成配置差异报告供审核。

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

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

[string]$SourceLabel = 'dev',

[string]$TargetLabel = 'staging',

[string]$KeyPrefix,

[switch]$DryRun,

[switch]$Force
)

$token = (az account get-access-token --resource "https://azconfig.io" --query accessToken --output tsv)
$headers = @{
Authorization = "Bearer $token"
'Content-Type' = 'application/vnd.microsoft.appconfig.kv+json'
}

# 从源环境读取全部配置
Write-Host "正在从源环境读取配置 (标签: $SourceLabel)..." -ForegroundColor Cyan
$filter = if ($KeyPrefix) { "&key=$KeyPrefix*" } else { '' }
$sourceResponse = Invoke-RestMethod -Uri "$SourceEndpoint/kv?label=$SourceLabel$filter" `
-Headers $headers -Method Get
$sourceItems = @{}
foreach ($item in $sourceResponse.items) {
$sourceItems[$item.key] = $item.value
}
Write-Host " 读取到 $($sourceItems.Count) 个配置项"

# 从目标环境读取对应配置
Write-Host "正在从目标环境读取配置 (标签: $TargetLabel)..." -ForegroundColor Cyan
$targetResponse = Invoke-RestMethod -Uri "$TargetEndpoint/kv?label=$TargetLabel$filter" `
-Headers $headers -Method Get
$targetItems = @{}
foreach ($item in $targetResponse.items) {
$targetItems[$item.key] = $item.value
}
Write-Host " 读取到 $($targetItems.Count) 个配置项"

# 计算差异
$toAdd = $sourceItems.Keys | Where-Object { $_ -notin $targetItems.Keys }
$toUpdate = $sourceItems.Keys | Where-Object {
$_ -in $targetItems.Keys -and $sourceItems[$_] -ne $targetItems[$_]
}
$toRemove = $targetItems.Keys | Where-Object { $_ -notin $sourceItems.Keys }

# 输出差异报告
Write-Host "`n===== 配置同步差异报告 =====" -ForegroundColor White
Write-Host "源: $SourceLabel → 目标: $TargetLabel"
Write-Host "新增: $($toAdd.Count) 项 | 更新: $($toUpdate.Count) 项 | 删除: $($toRemove.Count) 项"
Write-Host "=============================`n"

if ($toAdd.Count -gt 0) {
Write-Host "[新增] 以下配置将添加到目标环境:" -ForegroundColor Green
$toAdd | ForEach-Object { Write-Host " + $_ = $($sourceItems[$_].Substring(0, [Math]::Min(60, $sourceItems[$_].Length)))" }
}

if ($toUpdate.Count -gt 0) {
Write-Host "[更新] 以下配置将在目标环境更新:" -ForegroundColor Yellow
$toUpdate | ForEach-Object {
Write-Host " ~ $_"
Write-Host " 旧值: $($targetItems[$_].Substring(0, [Math]::Min(50, $targetItems[$_].Length)))"
Write-Host " 新值: $($sourceItems[$_].Substring(0, [Math]::Min(50, $sourceItems[$_].Length)))"
}
}

if ($toRemove.Count -gt 0) {
Write-Host "[删除] 以下配置将从目标环境移除:" -ForegroundColor Red
$toRemove | ForEach-Object { Write-Host " - $_" }
}

if ($DryRun) {
Write-Host "`n[试运行模式] 未执行任何变更。移除 -DryRun 参数以实际执行同步。" -ForegroundColor Magenta
return
}

if (-not $Force) {
$confirm = Read-Host "`n确认执行同步?(输入 YES 确认)"
if ($confirm -ne 'YES') {
Write-Host "已取消同步操作。" -ForegroundColor Yellow
return
}
}

# 执行新增和更新
$changeCount = 0
foreach ($key in ($toAdd + $toUpdate)) {
$body = @{
key = $key
value = $sourceItems[$key]
label = $TargetLabel
} | ConvertTo-Json

Invoke-RestMethod -Uri "$TargetEndpoint/kv/$key?label=$TargetLabel" `
-Headers $headers -Method Put -Body $body
$changeCount++
}

Write-Host "`n同步完成!共处理 $changeCount 个配置项。" -ForegroundColor Green
}

# 导出配置为本地 JSON 文件(用于备份或版本控制)
function Export-AppConfigToJson {
param(
[string]$Endpoint,
[string]$Label,
[string]$OutputPath = "./appconfig-backup.json"
)

$token = (az account get-access-token --resource "https://azconfig.io" --query accessToken --output tsv)
$headers = @{ Authorization = "Bearer $token" }

$labelParam = if ($Label) { "?label=$Label" } else { '' }
$response = Invoke-RestMethod -Uri "$Endpoint/kv$labelParam" -Headers $headers -Method Get

$export = $response.items | ForEach-Object {
[PSCustomObject]@{
key = $_.key
value = $_.value
label = $_.label
etag = $_.etag
}
}

$export | ConvertTo-Json -Depth 5 | Out-File -FilePath $OutputPath -Encoding utf8
Write-Host "已导出 $($export.Count) 个配置项到 $OutputPath" -ForegroundColor Green
}

# 使用示例
$devEndpoint = "https://myappconfig-dev.azconfig.io"
$stagingEndpoint = "https://myappconfig-staging.azconfig.io"

# 先试运行查看差异
Sync-AppConfigEnvironment -SourceEndpoint $devEndpoint `
-TargetEndpoint $stagingEndpoint `
-SourceLabel "dev" -TargetLabel "staging" -DryRun

# 确认无误后执行同步
Sync-AppConfigEnvironment -SourceEndpoint $devEndpoint `
-TargetEndpoint $stagingEndpoint `
-SourceLabel "dev" -TargetLabel "staging" -Force

# 导出生产配置为 JSON 备份
Export-AppConfigToJson -Endpoint "https://myappconfig.azconfig.io" `
-Label "prod" -OutputPath "./config-backup-prod-20260303.json"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
正在从源环境读取配置 (标签: dev)...
读取到 24 个配置项
正在从目标环境读取配置 (标签: staging)...
读取到 22 个配置项

===== 配置同步差异报告 =====
源: dev → 目标: staging
新增: 3 项 | 更新: 2 项 | 删除: 1 项
============================

[新增] 以下配置将添加到目标环境:
+ Cache:RedisUrl = redis-staging.cache.windows.net:6380
+ Feature:NewSearch = true
+ Logging:StructuredJson = true
[更新] 以下配置将在目标环境更新:
~ Database:CommandTimeout
旧值: 30
新值: 60
~ Api:RateLimitPerMinute
旧值: 100
新值: 200
[删除] 以下配置将从目标环境移除:
- Legacy:OldApiEndpoint

同步完成!共处理 5 个配置项。
已导出 22 个配置项到 ./config-backup-prod-20260303.json

同步脚本采用”先报告后执行”的模式:先通过 DryRun 参数生成差异报告,确认无误后再实际执行。这种方式可以有效防止误操作,尤其是在生产环境同步时更为重要。导出的 JSON 文件可以纳入 Git 版本控制,实现配置的代码化管理。

注意事项

  1. 访问权限控制:App Configuration 支持 Azure RBAC 和访问密钥两种认证方式。生产环境建议使用托管标识(Managed Identity)配合 RBAC,避免在脚本中硬编码连接字符串或访问密钥。

  2. 配置值的加密:虽然 App Configuration 在传输和存储时都会加密,但敏感值(如数据库密码、API 密钥)应配合 Azure Key Vault 使用。App Configuration 支持通过 Key Vault 引用将敏感值存储在 Key Vault 中,配置中只保存引用指针。

  3. 标签命名规范:建议统一使用环境名称作为标签(如 devstagingprod),避免混用不同维度的标签。如果需要多维度分类(如环境 + 区域),建议在键名中体现区域信息,保持标签维度的单一性。

  4. Feature Flag 的清理:长期未使用的 Feature Flag 应及时清理。可以编写定时脚本扫描所有 Feature Flag,标记超过 90 天未变更且处于启用状态的 Flag,通知相关团队评估是否可以移除。

  5. 同步操作的原子性:跨环境同步不具备事务特性,如果中途失败可能导致部分配置已更新而部分未更新的不一致状态。建议在同步前先导出目标环境的配置作为备份,失败时可以从备份恢复。

  6. 网络延迟与重试策略:App Configuration 的 REST API 调用可能因网络波动失败。在生产脚本中应加入重试逻辑(如指数退避),并设置合理的超时时间。可以使用 PowerShell 的 $MaximumRetryCount$RetryIntervalSec 参数简化重试实现。

PowerShell 技能连载 - Azure 托管标识与无密码认证

适用于 PowerShell 7.0 及以上版本

在云环境中,服务之间的认证一直是个令人头疼的问题。传统做法是创建服务主体(Service Principal),然后把客户端 ID 和密钥硬编码在配置文件或环境变量里。这些密码一旦泄露,攻击者就能以该身份访问你的 Azure 资源。更糟糕的是,密码有有效期,过期了服务就会中断,而定期轮换密码本身就是一项繁琐的运维负担。

Azure 托管标识(Managed Identity)从根本上解决了这个问题。它为 Azure 资源自动提供一个由 Azure AD(现称 Microsoft Entra ID)管理的标识,应用程序无需管理任何凭据就能获取 OAuth 2.0 令牌来访问支持 Azure AD 认证的服务。系统分配的托管标识与资源生命周期绑定,资源删除时标识自动回收;用户分配的托管标识则可以共享给多个资源使用。

本文将演示如何通过 PowerShell 完成托管标识的全生命周期管理:从启用标识、分配权限,到实际访问 Key Vault、Storage 和 SQL Database,最后展示虚拟机和容器中的令牌获取方式。让你从”到处藏密码”的窘境中彻底解脱出来。

托管标识配置与权限分配

配置托管标识是”无密码”运维的第一步。下面的脚本展示了如何为虚拟机启用系统分配标识和用户分配标识,并通过 RBAC 角色分配授予相应的 Azure 资源访问权限。系统分配标识与资源一一绑定,适合单一资源的场景;用户分配标识可以复用,适合多个资源需要共享同一权限集的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 连接 Azure(交互式登录或使用设备码)
Connect-AzAccount -Subscription 'production-subscription'

$resourceGroup = 'rg-managed-identity-demo'
$location = 'eastasia'
$vmName = 'vm-demo-01'

# --- 启用系统分配标识 ---
$vm = Get-AzVM -ResourceGroupName $resourceGroup -Name $vmName
Update-AzVM -ResourceGroupName $resourceGroup -VM $vm `
-IdentityType SystemAssigned

Write-Host '系统分配标识已启用' -ForegroundColor Green

# 查看系统分配标识的主体 ID
$vm = Get-AzVM -ResourceGroupName $resourceGroup -Name $vmName
$principalId = $vm.Identity.PrincipalId
Write-Host "主体 ID(PrincipalId): $principalId"

# --- 创建并分配用户分配标识 ---
$identityName = 'id-shared-demo'
$userIdentity = New-AzUserAssignedIdentity `
-ResourceGroupName $resourceGroup `
-Name $identityName `
-Location $location

Write-Host "用户分配标识已创建: $($userIdentity.Name)"

# 将用户分配标识附加到虚拟机
$vm = Get-AzVM -ResourceGroupName $resourceGroup -Name $vmName
Update-AzVM -ResourceGroupName $resourceGroup -VM $vm `
-IdentityType UserAssigned `
-IdentityId $userIdentity.Id

# --- RBAC 角色分配 ---
# 授予系统分配标识对 Key Vault 的读取权限
$keyVault = Get-AzKeyVault -ResourceGroupName $resourceGroup -VaultName 'kv-demo-01'

New-AzRoleAssignment `
-ObjectId $principalId `
-RoleDefinitionName 'Key Vault Secrets User' `
-Scope $keyVault.ResourceId

# 授予用户分配标识对存储账户的 Blob 读取权限
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourceGroup `
-Name 'stademo01'

New-AzRoleAssignment `
-ObjectId $userIdentity.PrincipalId `
-RoleDefinitionName 'Storage Blob Data Reader' `
-Scope $storageAccount.Id

Write-Host 'RBAC 角色分配完成' -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
系统分配标识已启用
主体 ID(PrincipalId): a1b2c3d4-e5f6-7890-abcd-ef1234567890
用户分配标识已创建: id-shared-demo

RoleAssignmentName : ra-keyvault-secrets-user
RoleAssignmentId : /subscriptions/.../providers/Microsoft.Authorization/roleAssignments/...
Scope : /subscriptions/.../resourceGroups/rg-managed-identity-demo/providers/Microsoft.KeyVault/vaults/kv-demo-01
DisplayName : vm-demo-01
RoleDefinitionName : Key Vault Secrets User

RoleAssignmentName : ra-storage-blob-reader
RoleAssignmentId : /subscriptions/.../providers/Microsoft.Authorization/roleAssignments/...
Scope : /subscriptions/.../resourceGroups/rg-managed-identity-demo/providers/Microsoft.Storage/storageAccounts/stademo01
DisplayName : id-shared-demo
RoleDefinitionName : Storage Blob Data Reader

RBAC 角色分配完成

无密码访问 Azure 服务

配置好标识和权限后,最令人兴奋的时刻来了——代码中不再需要任何密码。下面的脚本演示如何使用托管标识令牌直接访问 Key Vault 读取密钥、访问 Storage Blob 下载文件,以及连接 Azure SQL 数据库。整个过程完全无凭据,令牌的获取、缓存和刷新由 Azure SDK 自动处理。

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
# --- 使用系统分配标识访问 Key Vault ---
$vaultName = 'kv-demo-01'

# 通过 Az 模块获取令牌并访问 Key Vault(推荐方式)
Connect-AzAccount -Identity # 使用托管标识登录

# 读取密钥
$secret = Get-AzKeyVaultSecret -VaultName $vaultName -Name 'DatabaseConnectionString'
$secretValue = $secret.SecretValue | ConvertFrom-SecureString -AsPlainText
Write-Host "从 Key Vault 读取到数据库连接字符串(长度: $($secretValue.Length) 字符)"

# 列出所有可访问的密钥
$secrets = Get-AzKeyVaultSecret -VaultName $vaultName |
Select-Object Name, ContentType, Updated
$secrets | Format-Table -AutoSize

# --- 使用用户分配标识访问 Storage Blob ---
$storageAccountName = 'stademo01'
$containerName = 'reports'

# 指定用户分配标识的 ClientId 获取上下文
$identity = Get-AzUserAssignedIdentity `
-ResourceGroupName 'rg-managed-identity-demo' `
-Name 'id-shared-demo'

Connect-AzAccount -Identity -AccountId $identity.ClientId

# 获取存储上下文(无需存储账户密钥)
$ctx = New-AzStorageContext -StorageAccountName $storageAccountName -UseConnectedAccount

# 列出容器中的 Blob
$blobs = Get-AzStorageBlob -Container $containerName -Context $ctx |
Select-Object Name, Length, LastModified
$blobs | Format-Table -AutoSize

# 下载最新的报告文件
$latestBlob = Get-AzStorageBlob -Container $containerName -Context $ctx |
Sort-Object LastModified -Descending | Select-Object -First 1

$downloadPath = Join-Path $env:TEMP $latestBlob.Name
$latestBlob | Get-AzStorageBlobContent -Destination $downloadPath -Context $ctx -Force
Write-Host "已下载: $downloadPath ($($latestBlob.Length) 字节)"

# --- 无密码连接 Azure SQL ---
$serverName = 'sql-demo-01'
$databaseName = 'appdb'

# 获取访问令牌
$token = (Get-AzAccessToken -ResourceUrl 'https://database.windows.net/').Token

# 构建无密码连接字符串
$connectionString = "Server=tcp:$serverName.database.windows.net,1433;" +
"Database=$databaseName;" +
"Authentication=Active Directory Default;"

# 使用令牌执行查询
$connection = New-Object System.Data.SqlClient.SqlConnection $connectionString
$connection.AccessToken = $token
$connection.Open()

$command = $connection.CreateCommand()
$command.CommandText = 'SELECT TOP 5 name, create_date FROM sys.tables'
$reader = $command.ExecuteReader()

while ($reader.Read()) {
Write-Host "$($reader['name']) $($reader['create_date'])"
}

$connection.Close()
Write-Host 'SQL 查询完成(无密码连接)' -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
从 Key Vault 读取到数据库连接字符串(长度: 156 字符)

Name ContentType Updated
---- ----------- -------
DatabaseConnectionString 2026/3/1 上午 10:30:00
StorageAccountKey 2026/2/28 下午 3:15:00
ThirdPartyApiKey 2026/2/25 上午 9:00:00

Name Length LastModified
---- ------ ------------
report-202601.pdf 245678 2026/3/1 上午 8:00:00
report-202602.pdf 312456 2026/3/1 上午 8:05:00
data-export.csv 89123 2026/2/28 下午 4:30:00

已下载: /tmp/report-202602.pdf (312456 字节)

Users 2026-01-15 10:00:00
Orders 2026-01-15 10:01:00
Products 2026-01-15 10:02:00
AuditLog 2026-02-01 09:00:00
Sessions 2026-02-10 14:30:00
SQL 查询完成(无密码连接)

从虚拟机和容器中使用托管标识

在生产环境中,托管标识最常见的使用场景是从虚拟机或容器内部获取令牌。Azure 虚拟机通过 IMDS(Instance Metadata Service)端点提供本地令牌服务,而 Azure 容器实例(ACI)和 AKS Pod 也支持类似的机制。下面的脚本可以在虚拟机或容器内运行,自动获取访问令牌并执行运维操作。

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
# ============================================
# 方式一:通过 IMDS 端点获取令牌(适用于 VM 内部)
# ============================================
function Get-ManagedIdentityToken {
<#
.SYNOPSIS
通过 IMDS 端点获取托管标识的 OAuth 令牌
.PARAMETER Resource
目标资源的服务 URL,如 https://vault.azure.net
.PARAMETER UserAssignedClientId
用户分配标识的 ClientId(可选,不指定则使用系统分配标识)
#>
param(
[Parameter(Mandatory)]
[string]$Resource,

[string]$UserAssignedClientId
)

$imdsUrl = 'http://169.254.169.254/metadata/identity/oauth2/token'
$params = @{
Uri = $imdsUrl
Method = 'GET'
Headers = @{ Metadata = 'true' }
Body = @{ 'api-version' = '2018-02-01'; resource = $Resource }
UseBasicParsing = $true
}

# 如果指定了用户分配标识,添加 client_id 参数
if ($UserAssignedClientId) {
$params.Body['client_id'] = $UserAssignedClientId
}

try {
$response = Invoke-RestMethod @params
Write-Host "令牌获取成功,有效期至: $($response.expires_on)" -ForegroundColor Green
return $response.access_token
}
catch {
Write-Error "令牌获取失败: $($_.Exception.Message)"
return $null
}
}

# 使用系统分配标识获取 Key Vault 令牌
$kvToken = Get-ManagedIdentityToken -Resource 'https://vault.azure.net'

# 使用令牌直接调用 Key Vault REST API
$vaultName = 'kv-demo-01'
$secretName = 'DatabaseConnectionString'

$uri = "https://$vaultName.vault.azure.net/secrets/$secretName" +
'?api-version=7.4'

$headers = @{
Authorization = "Bearer $kvToken"
ContentType = 'application/json'
}

$result = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
Write-Host "通过 REST API 读取密钥成功: $($result.id)"

# ============================================
# 方式二:在容器中使用环境变量获取令牌
# ============================================
# Azure 容器实例会通过环境变量注入标识信息
function Get-ContainerManagedToken {
param(
[Parameter(Mandatory)]
[string]$Resource
)

# 容器中通过 AZURE_ACCESS_TOKEN 环境变量或 IMDS 获取
$msiEndpoint = $env:MSI_ENDPOINT
$msiSecret = $env:MSI_SECRET

if ($msiEndpoint -and $msiSecret) {
# Azure 容器实例的标识端点
$params = @{
Uri = "$msiEndpoint`?resource=$Resource&api-version=2017-09-01"
Method = 'GET'
Headers = @{ Secret = $msiSecret }
UseBasicParsing = $true
}
$response = Invoke-RestMethod @params
return $response.access_token
}

# 回退到 IMDS(AKS Pod 或标准虚拟机)
return Get-ManagedIdentityToken -Resource $Resource
}

# ============================================
# 自动化运维示例:定时备份 Key Vault 中的密钥
# ============================================
function Backup-KeyVaultSecrets {
param(
[string]$VaultName,
[string]$BackupPath = '/tmp/kv-backup'
)

$token = Get-ManagedIdentityToken -Resource 'https://vault.azure.net'

# 创建备份目录
New-Item -ItemType Directory -Path $BackupPath -Force | Out-Null

# 获取所有密钥列表
$listUri = "https://$VaultName.vault.azure.net/secrets" +
'?api-version=7.4'
$headers = @{ Authorization = "Bearer $token" }

$secrets = (Invoke-RestMethod -Uri $listUri -Headers $headers).value

$backupSummary = foreach ($secret in $secrets) {
$secretName = $secret.id.Split('/')[-1]
$detail = Invoke-RestMethod -Uri $secret.id`?api-version=7.4 `
-Headers $headers

# 脱敏后保存元数据
$backup = [PSCustomObject]@{
Name = $secretName
ContentType = $detail.contentType
BackupTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Enabled = $detail.attributes.enabled
Expires = $detail.attributes.exp
}

$backup | ConvertTo-Json | Out-File "$BackupPath/$secretName.json"
$backup
}

$backupSummary | Format-Table -AutoSize
Write-Host "备份完成,共 $($backupSummary.Count) 个密钥" -ForegroundColor Green
}

# 执行备份
Backup-KeyVaultSecrets -VaultName 'kv-demo-01'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
令牌获取成功,有效期至: 1740916800
通过 REST API 读取密钥成功: https://kv-demo-01.vault.azure.net/secrets/DatabaseConnectionString/abc123def456

Name ContentType BackupTime Enabled Expires
---- ----------- ---------- ------- -------
DatabaseConnectionString 2026-03-02 08:30:00 True
StorageAccountKey 2026-03-02 08:30:00 True
ThirdPartyApiKey text/plain 2026-03-02 08:30:00 True 1743571200
CertificateThumbprint 2026-03-02 08:30:00 True

备份完成,共 4 个密钥

注意事项

  1. RBAC 传播延迟:新创建的角色分配可能需要 5 到 10 分钟才能生效。如果脚本执行后立即访问资源时报权限不足的错误,请等待一段时间后重试,也可以在脚本中加入轮询等待逻辑。

  2. 系统标识 vs 用户标识的选择:系统分配标识与资源生命周期绑定,资源删除时标识自动清除,适合一对一场景。用户分配标识独立于资源存在,适合多个资源需要共享权限的场景,但需要手动管理生命周期。

  3. IMDS 端点仅限虚拟机内部:IMDS 端点 169.254.169.254 是链路本地地址,只能在 Azure 虚拟机内部访问。不要在本地开发环境或非 Azure 环境中尝试调用这个端点,开发调试时请使用 Connect-AzAccount -Identity 配合 Azure Cloud Shell。

  4. 令牌缓存与刷新:Azure SDK 和 Az 模块会自动缓存令牌并在过期前刷新。如果你直接调用 REST API 获取令牌,需要自行处理令牌的缓存和刷新逻辑,令牌通常有效期为 1 小时。

  5. 最小权限原则:为托管标识分配 RBAC 角色时,务必遵循最小权限原则,只授予实际需要的角色。避免使用 Owner 或 Contributor 等高权限角色,优先选择如 Key Vault Secrets User、Storage Blob Data Reader 等精确的内置角色。

  6. 本地开发调试技巧:在本地开发时可以使用 Connect-AzAccount 交互式登录,然后通过 -DefaultProfile 参数切换不同的认证上下文,模拟托管标识的行为。部署到 Azure 后,将认证方式切换为 Connect-AzAccount -Identity 即可无缝迁移到托管标识,代码逻辑无需大幅修改。

PowerShell 技能连载 - 懒人运维工具集

适用于 PowerShell 5.1 及以上版本

每个系统管理员都有一套自己的”小抄”——那些在日常工作中反复使用的命令和脚本片段。你可能在记事本里存着几十条命令,也可能在浏览器书签栏里堆满了技术博客链接。这些零散的知识虽然有用,但真正遇到问题时,往往要花不少时间才能找到那条”正好管用”的命令。

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
function Get-SystemQuickReport {
<#
.SYNOPSIS
一键获取系统综合信息报告
#>

$report = @{}

# 硬件信息
$report.Hardware = [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
Manufacturer = (Get-CimInstance Win32_ComputerSystem).Manufacturer
Model = (Get-CimInstance Win32_ComputerSystem).Model
TotalRAM_GB = [math]::Round(
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2
)
CPU = (Get-CimInstance Win32_Processor).Name
CPUCoreCount = (Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
}

# 系统概况
$os = Get-CimInstance Win32_OperatingSystem
$report.OS = [PSCustomObject]@{
Caption = $os.Caption
Version = $os.Version
BuildNumber = $os.BuildNumber
LastBootTime = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
UptimeDays = [math]::Round(
((Get-Date) - $os.LastBootUpTime).TotalDays, 1
)
FreeDiskSpaceGB = [math]::Round(
(Get-PSDrive -Name C).Free / 1GB, 2
)
}

# 网络配置
$report.Network = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.InterfaceAlias -notmatch 'Loopback' } |
Select-Object InterfaceAlias, IPAddress, PrefixLength |
Sort-Object InterfaceAlias

# 已安装软件(按安装日期倒序取前 10 条)
$report.RecentSoftware = Get-ItemProperty
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName } |
Sort-Object InstallDate -Descending |
Select-Object -First 10 DisplayName, DisplayVersion, InstallDate

# 输出报告
Write-Host "`n=== 硬件信息 ===" -ForegroundColor Cyan
$report.Hardware | Format-List
Write-Host "=== 系统概况 ===" -ForegroundColor Cyan
$report.OS | Format-List
Write-Host "=== 网络配置 ===" -ForegroundColor Cyan
$report.Network | Format-Table -AutoSize
Write-Host "=== 最近安装的软件 ===" -ForegroundColor Cyan
$report.RecentSoftware | Format-Table -AutoSize
}

Get-SystemQuickReport

执行后你会看到类似以下输出:

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
=== 硬件信息 ===

ComputerName : WIN-SERVER01
Manufacturer : Dell Inc.
Model : PowerEdge R740
TotalRAM_GB : 64
CPU : Intel(R) Xeon(R) Silver 4210R CPU @ 2.40GHz
CPUCoreCount : 20

=== 系统概况 ===

Caption : Microsoft Windows Server 2022 Standard
Version : 10.0.20348
BuildNumber : 20348
LastBootTime : 2026/2/20 08:15:32
UptimeDays : 7.1
FreeDiskSpaceGB : 238.45

=== 网络配置 ===

InterfaceAlias IPAddress PrefixLength
-------------- --------- ------------
Ethernet0 192.168.1.100 24
Ethernet1 10.0.0.50 16

=== 最近安装的软件 ===

DisplayName DisplayVersion InstallDate
----------- -------------- -----------
PowerShell 7-x64 7.5.0 20260225
Git for Windows 2.48.1 20260220
7-Zip 24.09 (x64) 24.09 20260215

这个函数把 Get-CimInstanceGet-NetIPAddress 和注册表查询组合在一起,省去了你逐条敲命令的麻烦。你也可以把它加入 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
79
80
81
82
# --- 场景一:查找占用空间最大的前 20 个文件 ---
function Find-LargeFile {
param(
[string]$Path = "C:\",
[int]$Top = 20,
[long]$MinSizeMB = 100
)
Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Length -ge $MinSizeMB * 1MB } |
Sort-Object Length -Descending |
Select-Object -First $Top |
ForEach-Object {
[PSCustomObject]@{
SizeMB = [math]::Round($_.Length / 1MB, 2)
FullName = $_.FullName
LastWrite = $_.LastWriteTime
}
} | Format-Table -AutoSize
}

# --- 场景二:按文件大小检测可能的重复文件 ---
function Find-DuplicateFile {
param([string]$Path = ".")
Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue |
Group-Object Length |
Where-Object { $_.Count -gt 1 } |
ForEach-Object {
$hashes = $_.Group | Get-FileHash -Algorithm MD5 |
Group-Object Hash |
Where-Object { $_.Count -gt 1 }
foreach ($h in $hashes) {
$h.Group | Select-Object Path,
@{ N = 'SizeMB'; E = { [math]::Round(
(Get-Item $_.Path).Length / 1MB, 2
) } }
}
} | Format-Table -AutoSize
}

# --- 场景三:批量重命名(示例:给文件名加日期前缀) ---
function Rename-AddDatePrefix {
param(
[string]$Path = ".",
[string]$Filter = "*.*"
)
$dateStr = Get-Date -Format "yyyyMMdd"
$files = Get-ChildItem -Path $Path -Filter $Filter -File
foreach ($f in $files) {
if ($f.Name -notmatch "^\d{8}-") {
$newName = "${dateStr}-$($f.Name)"
Rename-Item -Path $f.FullName -NewName $newName -WhatIf
}
}
Write-Host "共找到 $($files.Count) 个文件,以上为预览结果(-WhatIf)。"
Write-Host "确认无误后去掉 -WhatIf 参数重新执行。"
}

# --- 场景四:对比两个目录的差异 ---
function Compare-Folder {
param(
[Parameter(Mandatory)]
[string]$Left,
[Parameter(Mandatory)]
[string]$Right
)
$leftFiles = Get-ChildItem $Left -Recurse -File |
ForEach-Object { $_.FullName.Replace($Left, "") }
$rightFiles = Get-ChildItem $Right -Recurse -File |
ForEach-Object { $_.FullName.Replace($Right, "") }

$onlyInLeft = Compare-Object $leftFiles $rightFiles |
Where-Object SideIndicator -eq "<=" |
ForEach-Object { [PSCustomObject]@{ Location = "仅左目录"; File = $_.InputObject } }
$onlyInRight = Compare-Object $leftFiles $rightFiles |
Where-Object SideIndicator -eq "=>" |
ForEach-Object { [PSCustomObject]@{ Location = "仅右目录"; File = $_.InputObject } }

$onlyInLeft + $onlyInRight | Format-Table -AutoSize
}

# 使用示例
Find-LargeFile -Path "D:\" -Top 10 -MinSizeMB 200

以下是各场景的执行结果示例:

1
2
3
4
5
6
7
SizeMB   FullName                                   LastWrite
------ -------- ---------
4096.00 D:\VM\Ubuntu-24.04.vhdx 2026/2/26 14:30:00
2048.00 D:\Backup\db-full-20260225.bak 2026/2/25 03:00:00
812.50 D:\Logs\iis-access-202602.log 2026/2/27 09:15:00
512.00 D:\ISO\windows-server-2022.iso 2025/12/10 10:00:00
300.75 D:\Temp\update-package-large.cab 2026/2/20 16:45:00

每个函数都设计为可以独立使用。Find-LargeFile 用于快速定位空间杀手,Find-DuplicateFile 通过文件大小和哈希值双重比对来发现重复文件,Rename-AddDatePrefix 默认带 -WhatIf 参数以防止误操作,Compare-Folder 则用 Compare-Object 一行搞定目录差异对比。

日常运维一键脚本

日常运维中最常见的操作莫过于磁盘清理、服务状态检查、日志分析和快速诊断。这些操作单独来看都不复杂,但每天重复执行就很繁琐。下面的脚本把四类常见操作封装成可复用的函数,你可以按需调用或组合使用。

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
# --- 操作一:磁盘空间清理 ---
function Invoke-DiskCleanup {
$before = [math]::Round((Get-PSDrive C).Free / 1GB, 2)
Write-Host "清理前 C 盘剩余空间: ${before} GB" -ForegroundColor Yellow

# 清理临时文件
$tempPaths = @(
$env:TEMP
"$env:WINDIR\Temp"
"$env:WINDIR\SoftwareDistribution\Download"
)
$cleanedCount = 0
foreach ($p in $tempPaths) {
if (Test-Path $p) {
Get-ChildItem $p -Recurse -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
$cleanedCount++
}
}

# 清理事件日志中超过 30 天的存档
$cutoff = (Get-Date).AddDays(-30)
Get-ChildItem "$env:WINDIR\System32\winevt\Logs\Archive" -Filter "*.evtx" `
-ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff } |
Remove-Item -Force -ErrorAction SilentlyContinue

$after = [math]::Round((Get-PSDrive C).Free / 1GB, 2)
$freed = [math]::Round($after - $before, 2)
Write-Host "清理后 C 盘剩余空间: ${after} GB" -ForegroundColor Green
Write-Host "本次释放空间: ${freed} GB" -ForegroundColor Cyan
}

# --- 操作二:关键服务状态巡检 ---
function Test-CriticalServices {
param(
[string[]]$ServiceNames = @(
"WinRM", "EventLog", "DHCP", "DNS",
"LanmanServer", "LanmanWorkstation"
)
)
$results = foreach ($svc in $ServiceNames) {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s) {
[PSCustomObject]@{
Service = $s.Name
Status = $s.Status
StartType = $s.StartType
OK = if ($s.Status -eq 'Running') { "OK" } else { "WARN" }
}
} else {
[PSCustomObject]@{
Service = $svc; Status = "NOT_FOUND"
StartType = "-"; OK = "ERROR"
}
}
}
$results | Format-Table -AutoSize
$warnCount = ($results | Where-Object { $_.OK -ne "OK" }).Count
if ($warnCount -gt 0) {
Write-Host "发现 $warnCount 个异常服务!" -ForegroundColor Red
} else {
Write-Host "所有关键服务运行正常。" -ForegroundColor Green
}
}

# --- 操作三:日志关键词搜索 ---
function Search-EventLogFast {
param(
[string]$LogName = "System",
[string]$Keyword = "error",
[int]$Hours = 24
)
$startTime = (Get-Date).AddHours(-$Hours)
Get-WinEvent -LogName $LogName -ErrorAction SilentlyContinue |
Where-Object {
$_.TimeCreated -ge $startTime -and
$_.Message -match $Keyword
} |
Select-Object -First 20 TimeCreated, Id, LevelDisplayName,
@{ N = 'Message'; E = { $_.Message.Substring(0,
[math]::Min($_.Message.Length, 120)) } } |
Format-Table -AutoSize -Wrap
}

# --- 操作四:快速系统诊断 ---
function Get-QuickDiagnosis {
Write-Host "`n[CPU 使用率]" -ForegroundColor Cyan
$cpu = (Get-CimInstance Win32_Processor).LoadPercentage
Write-Host " 当前 CPU: ${cpu}%"
if ($cpu -gt 80) {
Write-Host " 警告: CPU 使用率过高!" -ForegroundColor Red
}

Write-Host "`n[内存使用]" -ForegroundColor Cyan
$os = Get-CimInstance Win32_OperatingSystem
$totalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedPct = [math]::Round(($totalGB - $freeGB) / $totalGB * 100, 1)
Write-Host " 总内存: ${totalGB} GB | 可用: ${freeGB} GB | 使用率: ${usedPct}%"
if ($usedPct -gt 90) {
Write-Host " 警告: 内存使用率过高!" -ForegroundColor Red
}

Write-Host "`n[磁盘空间]" -ForegroundColor Cyan
Get-PSDrive -PSProvider FileSystem |
ForEach-Object {
$freeGB = [math]::Round($_.Free / 1GB, 2)
$usedGB = [math]::Round(($_.Used) / 1GB, 2)
$totalGB = [math]::Round(($_.Used + $_.Free) / 1GB, 2)
$pct = [math]::Round($_.Used / ($_.Used + $_.Free) * 100, 1)
Write-Host " 盘符 $($_.Name): 已用 ${usedGB}/${totalGB} GB (${pct}%)"
if ($pct -gt 90) {
Write-Host " 警告: 磁盘空间不足!" -ForegroundColor Red
}
}

Write-Host "`n[Top 5 进程 (按内存)]" -ForegroundColor Cyan
Get-Process | Sort-Object WorkingSet64 -Descending |
Select-Object -First 5 |
ForEach-Object {
$memMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
Write-Host " $($_.Name) (PID: $($_.Id)): ${memMB} MB"
}
}

# 执行诊断
Get-QuickDiagnosis

执行结果示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[CPU 使用率]
当前 CPU: 23%

[内存使用]
总内存: 64 GB | 可用: 28.5 GB | 使用率: 55.5%

[磁盘空间]
盘符 C: 已用 261.55/500 GB (52.3%)
盘符 D: 已用 1200.8/2000 GB (60.0%)

[Top 5 进程 (按内存)]
sqlservr (PID: 1842): 8192.0 MB
java (PID: 3201): 4096.0 MB
MsMpEng (PID: 882): 512.3 MB
powershell (PID: 9156): 256.8 MB
svchost (PID: 1044): 198.4 MB

这组函数覆盖了运维中最常见的四类操作。Invoke-DiskCleanup 在清理前后显示释放的空间大小,让你对清理效果一目了然;Test-CriticalServices 支持自定义服务列表,默认检查六个核心 Windows 服务;Search-EventLogFast 使用管道快速过滤事件日志,避免全量加载导致内存占用过大;Get-QuickDiagnosis 将 CPU、内存、磁盘和进程信息汇总输出,超过阈值时会自动标红警告。

注意事项

  • 谨慎使用清理脚本Invoke-DiskCleanup 会删除临时文件和旧日志,生产环境执行前建议先注释掉 Remove-Item 语句,用 -WhatIf 模式预览清理范围,确认无误后再实际执行。

  • 服务巡检可扩展Test-CriticalServices 的默认服务列表针对 Windows 服务器场景。如果你管理的是 Web 服务器,可以将 W3SVCWAS 加入列表;数据库服务器则加入 MSSQLSERVERSQLSERVERAGENT 等服务。

  • 大文件搜索性能优化:在搜索超大目录(如整个 C 盘)时,Find-LargeFile 可能需要较长时间。建议通过 -MinSizeMB 参数过滤掉小文件以提升速度,或者限定搜索路径范围而非搜索整个磁盘。

  • 重复文件检测的边界Find-DuplicateFile 使用 MD5 哈希进行比对。对于非常大的文件集合,哈希计算可能耗时较长。在实际使用中,可以先按文件大小分组(脚本已实现),再仅对大小相同的文件计算哈希,可以大幅减少计算量。

  • 目录对比的路径处理Compare-Folder 使用字符串替换来计算相对路径。如果左右目录路径中包含特殊字符或符号链接,可能导致比对结果不准确。对于复杂的目录结构,建议使用专业的文件同步工具如 robocopy 的对比模式。

  • 函数加入 Profile 实现开机可用:本文所有函数都可以复制到你的 PowerShell Profile 文件(路径为 $PROFILE,通常在 Documents\PowerShell\Microsoft.PowerShell_profile.ps1)中,这样每次打开终端就能直接调用,真正实现”一次编写,到处偷懒”。

PowerShell 技能连载 - 模块开发与测试

适用于 PowerShell 7.0 及以上版本

PowerShell 模块是代码复用和分发的核心单元。将常用的函数打包为模块,不仅可以让团队成员通过 Import-Module 一行命令加载全部功能,还能发布到 PowerShell Gallery(PSGallery)供全球社区使用。然而模块的质量直接影响使用者的信任度——一个没有测试覆盖的模块,任何一次改动都可能引入难以察觉的回归缺陷。

现代 PowerShell 模块开发已经形成了一套成熟的工程实践:使用 Plaster 脚手架工具初始化标准项目结构,用 Pester 测试框架编写单元测试和集成测试,再通过 CI/CD 流水线实现自动化构建、测试和发布。这套流程不仅能保证模块质量,还能让你在发布新版本时充满信心。

本文将从模块项目的标准结构入手,逐步展示 Pester 测试的编写方法,最后介绍如何将模块发布到 PSGallery 并接入 CI/CD 自动化流程。

模块项目结构

一个规范的 PowerShell 模块项目不仅包含 .psm1.psd1 文件,还应该有清晰的目录组织、构建脚本和测试目录。使用 Plaster 模板可以快速生成符合社区标准的项目骨架,避免手动创建时遗漏关键文件。

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
# 安装 Plaster 脚手架工具
Install-Module -Name Plaster -Scope CurrentUser -Force

# 定义模块参数
$plasterParams = @{
TemplatePath = (Install-Module -Name PlasterTemplate -PassThru -Force).ModuleBase
DestinationPath = Join-Path $HOME "Projects\MyUtils"
}

# 使用内置模板创建模块项目(这里手动创建标准结构作为演示)
$moduleRoot = Join-Path $HOME "Projects\MyUtils"
$dirs = @(
"MyUtils\functions",
"MyUtils\internal\functions",
"MyUtils\tests\unit",
"MyUtils\tests\integration",
"MyUtils\build",
"MyUtils\docs",
"MyUtils\examples"
)

foreach ($dir in $dirs) {
$fullPath = Join-Path $moduleRoot $dir
if (-not (Test-Path $fullPath)) {
New-Item -Path $fullPath -ItemType Directory -Force | Out-Null
Write-Host " 创建目录: $dir" -ForegroundColor Green
}
}

# 创建模块主文件(.psm1)
$psm1Content = @'
# MyUtils.psm1 - 模块入口
# 从 functions 目录加载所有公共函数
$publicFunctions = Get-ChildItem -Path "$PSScriptRoot\functions\*.ps1" -ErrorAction SilentlyContinue
$privateFunctions = Get-ChildItem -Path "$PSScriptRoot\internal\functions\*.ps1" -ErrorAction SilentlyContinue

# 先加载私有函数
foreach ($func in $privateFunctions) {
. $func.FullName
}

# 加载公共函数并导出
$exportedNames = @()
foreach ($func in $publicFunctions) {
. $func.FullName
$exportedNames += $func.BaseName
}

Export-ModuleMember -Function $exportedNames
'@

$psm1Path = Join-Path $moduleRoot "MyUtils\MyUtils.psm1"
$psm1Content | Set-Content -Path $psm1Path -Encoding Utf8

# 创建模块清单(.psd1)
$manifestParams = @{
Path = (Join-Path $moduleRoot "MyUtils\MyUtils.psd1")
RootModule = "MyUtils.psm1"
ModuleVersion = "1.0.0"
Author = "PowerShell Developer"
Description = "实用工具函数集合"
PowerShellVersion = "7.0"
FunctionsToExport = @()
VariablesToExport = @()
CmdletsToExport = @()
AliasesToExport = @()
LicenseUri = "https://opensource.org/licenses/MIT"
ProjectUri = "https://github.com/example/MyUtils"
ReleaseNotes = "初始版本发布"
}
New-ModuleManifest @manifestParams

# 创建一个示例公共函数
$sampleFunction = @'
function Get-SystemUptime {
<#
.SYNOPSIS
获取系统运行时间信息
.DESCRIPTION
返回系统自上次启动以来的运行时长、启动时间等详细信息
.PARAMETER ComputerName
目标计算机名称,默认为本地
.EXAMPLE
Get-SystemUptime
获取本地系统运行时间
#>
[CmdletBinding()]
param(
[string]$ComputerName = $env:COMPUTERNAME
)

$os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName
$uptime = (Get-Date) - $os.LastBootUpTime

[PSCustomObject]@{
ComputerName = $ComputerName
BootTime = $os.LastBootUpTime
UptimeDays = [math]::Floor($uptime.TotalDays)
UptimeHours = [math]::Floor($uptime.TotalHours)
Status = if ($uptime.TotalDays -gt 30) { "需要重启" } else { "正常" }
}
}
'@

$funcPath = Join-Path $moduleRoot "MyUtils\functions\Get-SystemUptime.ps1"
$sampleFunction | Set-Content -Path $funcPath -Encoding Utf8

# 显示最终项目结构
Write-Host "`n=== 模块项目结构 ===" -ForegroundColor Cyan
Get-ChildItem -Path (Join-Path $moduleRoot "MyUtils") -Recurse | ForEach-Object {
$relativePath = $_.FullName.Replace((Join-Path $moduleRoot "MyUtils\"), "")
$indent = " " * ($relativePath.Split("\").Count - 1)
if ($_.PSIsContainer) {
Write-Host "$indent[DIR] $($_.Name)" -ForegroundColor Yellow
} else {
Write-Host "$indent $($_.Name) ($([math]::Round($_.Length / 1KB, 1)) KB)"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  创建目录: MyUtils\functions
创建目录: MyUtils\internal\functions
创建目录: MyUtils\tests\unit
创建目录: MyUtils\tests\integration
创建目录: MyUtils\build
创建目录: MyUtils\docs
创建目录: MyUtils\examples

=== 模块项目结构 ===
[DIR] MyUtils
[DIR] build
[DIR] docs
[DIR] examples
[DIR] functions
Get-SystemUptime.ps1 (1.2 KB)
[DIR] internal
[DIR] functions
[DIR] tests
[DIR] integration
[DIR] unit
MyUtils.psd1 (2.8 KB)
MyUtils.psm1 (0.5 KB)

Pester 测试编写

Pester 是 PowerShell 社区最流行的测试框架,支持行为驱动开发(BDD)风格的测试语法。通过编写单元测试验证函数逻辑,使用 Mock 隔离外部依赖,再用集成测试验证端到端场景,可以构建出完整的测试金字塔。

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
# 安装 Pester 测试框架
Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck
Import-Module Pester

# ===== 单元测试 =====
# 为 Get-SystemUptime 编写单元测试,使用 Mock 隔离 CIM 调用
$unitTest = @'
Describe "Get-SystemUptime" {
BeforeAll {
# Mock Get-CimInstance,避免真实调用 WMI
Mock Get-CimInstance {
[PSCustomObject]@{
LastBootUpTime = (Get-Date).AddDays(-15).AddHours(-3)
}
}

# Mock Get-Date,使测试结果可预测
Mock Get-Date { [datetime]"2026-02-26 10:00:00" }
}

Context "当系统正常运行时" {
It "应返回正确的运行天数" {
$result = Get-SystemUptime
$result.UptimeDays | Should -Be 15
}

It "应返回正确的运行小时数" {
$result = Get-SystemUptime
$result.UptimeHours | Should -Be 363
}

It "状态应为'正常'" {
$result = Get-SystemUptime
$result.Status | Should -Be "正常"
}

It "应返回本地计算机名" {
$result = Get-SystemUptime
$result.ComputerName | Should -Be $env:COMPUTERNAME
}
}

Context "当指定远程计算机时" {
It "应将计算机名传递给 Get-CimInstance" {
Get-SystemUptime -ComputerName "SERVER01"
Should -Invoke Get-CimInstance -Times 1 -ParameterFilter {
$ComputerName -eq "SERVER01"
}
}
}

Context "当系统超过30天未重启时" {
BeforeAll {
Mock Get-CimInstance {
[PSCustomObject]@{
LastBootUpTime = (Get-Date).AddDays(-45)
}
}
}

It "状态应为'需要重启'" {
$result = Get-SystemUptime
$result.Status | Should -Be "需要重启"
}
}
}
'@

$unitTestPath = Join-Path $moduleRoot "MyUtils\tests\unit\Get-SystemUptime.Tests.ps1"
$unitTest | Set-Content -Path $unitTestPath -Encoding Utf8

# ===== 集成测试 =====
# 验证模块加载和函数导出的端到端场景
$integrationTest = @'
Describe "MyUtils 模块集成测试" {
BeforeAll {
$modulePath = Join-Path $PSScriptRoot "..\..\MyUtils.psd1"
Import-Module $modulePath -Force
}

AfterAll {
Remove-Module MyUtils -Force -ErrorAction SilentlyContinue
}

Context "模块加载" {
It "应成功加载模块" {
Get-Module -Name MyUtils | Should -Not -BeNullOrEmpty
}

It "应导出 Get-SystemUptime 函数" {
$cmds = Get-Command -Module MyUtils
$cmds.Name | Should -Contain "Get-SystemUptime"
}
}

Context "函数调用" {
It "Get-SystemUptime 应返回正确类型" {
$result = Get-SystemUptime
$result | Should -BeOfType [System.Management.Automation.PSCustomObject]
}

It "返回对象应包含预期的属性" {
$result = Get-SystemUptime
$result.PSObject.Properties.Name | Should -Contain "ComputerName"
$result.PSObject.Properties.Name | Should -Contain "BootTime"
$result.PSObject.Properties.Name | Should -Contain "UptimeDays"
$result.PSObject.Properties.Name | Should -Contain "Status"
}
}
}
'@

$intTestPath = Join-Path $moduleRoot "MyUtils\tests\integration\Module.Tests.ps1"
$integrationTest | Set-Content -Path $intTestPath -Encoding Utf8

# 运行单元测试
Write-Host "=== 运行单元测试 ===" -ForegroundColor Cyan
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = $unitTestPath
$pesterConfig.Output.Verbosity = "Detailed"
$pesterResult = Invoke-Pester -Configuration $pesterConfig

# 输出测试摘要
Write-Host "`n=== 测试摘要 ===" -ForegroundColor Cyan
Write-Host "总测试数: $($pesterResult.TotalCount)"
Write-Host "通过: $($pesterResult.PassedCount)" -ForegroundColor Green
Write-Host "失败: $($pesterResult.FailedCount)" -ForegroundColor Red
Write-Host "跳过: $($pesterResult.SkippedCount)" -ForegroundColor Yellow
Write-Host "执行时间: $([math]::Round($pesterResult.Duration.TotalSeconds, 2)) 秒"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
=== 运行单元测试 ===
Starting discovery in 1 files.
Discovery finished in 0.12 seconds.
Running tests from 'Get-SystemUptime.Tests.ps1'
Describing Get-SystemUptime
Context 当系统正常运行时
[+] 应返回正确的运行天数 38ms
[+] 应返回正确的运行小时数 12ms
[+] 状态应为'正常' 8ms
[+] 应返回本地计算机名 6ms
Context 当指定远程计算机时
[+] 应将计算机名传递给 Get-CimInstance 15ms
Context 当系统超过30天未重启时
[+] 状态应为'需要重启' 10ms
Tests passed: 6, Failed: 0, Skipped: 0, NotRun: 0

=== 测试摘要 ===
总测试数: 6
通过: 6
失败: 0
跳过: 0
执行时间: 0.34 秒

发布与维护

模块开发和测试完成后,下一步是发布到 PSGallery 供他人使用。合理的版本管理、自动化构建脚本和 CI/CD 集成,能让模块的迭代发布变得高效可靠。

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
# ===== 发布到 PowerShell Gallery =====
# 1. 获取 API Key(从 https://www.powershellgallery.com 获取)
# $apiKey = Get-Secret -Name "PSGalleryApiKey" -AsPlainText

# 2. 发布前检查
$modulePath = Join-Path $moduleRoot "MyUtils"
$manifest = Test-ModuleManifest -Path (Join-Path $modulePath "MyUtils.psd1")

Write-Host "模块名称: $($manifest.Name)"
Write-Host "版本号: $($manifest.Version)"
Write-Host "作者: $($manifest.Author)"
Write-Host "描述: $($manifest.Description)"

# 运行 PSScriptAnalyzer 代码质量检查
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
$analysisResult = Invoke-ScriptAnalyzer -Path $modulePath -Severity Warning,Error

if ($analysisResult) {
Write-Host "`n=== 代码质量问题 ===" -ForegroundColor Yellow
$analysisResult | Format-Table RuleName, Severity, Line, Message -AutoSize
} else {
Write-Host "`n代码质量检查通过,无警告或错误" -ForegroundColor Green
}

# 发布模块(取消注释以实际发布)
# Publish-Module -Path $modulePath -NuGetApiKey $apiKey -Repository PSGallery

# ===== 版本管理辅助函数 =====
function Update-ModuleVersionLocal {
param(
[Parameter(Mandatory)]
[string]$ModulePath,

[Parameter(Mandatory)]
[ValidateSet("Major", "Minor", "Patch")]
[string]$BumpType,

[string]$ReleaseNotes
)

$manifestPath = Join-Path $ModulePath "*.psd1"
$currentManifest = Test-ModuleManifest -Path $manifestPath
$version = $currentManifest.Version

$newVersion = switch ($BumpType) {
"Major" { [version]::new($version.Major + 1, 0, 0) }
"Minor" { [version]::new($version.Major, $version.Minor + 1, 0) }
"Patch" { [version]::new($version.Major, $version.Minor, $version.Build + 1) }
}

# 更新清单中的版本号
$manifestContent = Get-Content -Path $manifestPath -Raw
$manifestContent = $manifestContent -replace
"ModuleVersion\s*=\s*'$version'",
"ModuleVersion = '$newVersion'"

if ($ReleaseNotes) {
$manifestContent = $manifestContent -replace
"ReleaseNotes\s*=\s*'[^']*'",
"ReleaseNotes = '$ReleaseNotes'"
}

$manifestContent | Set-Content -Path $manifestPath -Encoding Utf8
Write-Host "版本已更新: $version -> $newVersion" -ForegroundColor Green
}

# 演示版本升级
Write-Host "`n=== 版本管理演示 ===" -ForegroundColor Cyan
Update-ModuleVersionLocal -ModulePath $modulePath -BumpType Minor -ReleaseNotes "新增 Get-SystemUptime 函数"

# ===== 构建脚本 =====
$buildScript = @'
# build.ps1 - 模块构建脚本
param(
[string]$Task = "Build",
[version]$Version
)

$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot

switch ($Task) {
"Test" {
Write-Host "运行测试..." -ForegroundColor Cyan
$config = New-PesterConfiguration
$config.Run.Path = Join-Path $projectRoot "tests"
$config.Output.Verbosity = "Detailed"
$result = Invoke-Pester -Configuration $config
if ($result.FailedCount -gt 0) {
throw "测试失败: $($result.FailedCount) 个测试未通过"
}
Write-Host "所有测试通过" -ForegroundColor Green
}

"Build" {
Write-Host "构建模块..." -ForegroundColor Cyan
$outputDir = Join-Path $projectRoot "output\MyUtils"
if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse -Force }
Copy-Item -Path (Join-Path $projectRoot "MyUtils") -Destination $outputDir -Recurse
Write-Host "构建完成: $outputDir" -ForegroundColor Green
}

"Publish" {
Write-Host "发布到 PSGallery..." -ForegroundColor Cyan
$outputDir = Join-Path $projectRoot "output\MyUtils"
$apiKey = Get-Secret -Name "PSGalleryApiKey" -AsPlainText
Publish-Module -Path $outputDir -NuGetApiKey $apiKey
Write-Host "发布完成" -ForegroundColor Green
}

default {
Write-Host "可用任务: Test, Build, Publish"
}
}
'@

$buildPath = Join-Path $moduleRoot "MyUtils\build.ps1"
$buildScript | Set-Content -Path $buildPath -Encoding Utf8

# ===== CI/CD 配置示例(GitHub Actions)=====
$githubActions = @'
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PowerShell Modules
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester, PSScriptAnalyzer -Force
- name: Run PSScriptAnalyzer
shell: pwsh
run: Invoke-ScriptAnalyzer -Path ./MyUtils -Severity Error
- name: Run Pester Tests
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = ./tests
Invoke-Pester -Configuration $config

publish:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to PSGallery
shell: pwsh
env:
NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
run: |
Publish-Module -Path ./MyUtils -NuGetApiKey $env:NUGET_API_KEY
'@

Write-Host "`n=== CI/CD 配置文件已生成 ===" -ForegroundColor Cyan
Write-Host "GitHub Actions 工作流包含以下步骤:"
Write-Host " 1. 代码检出"
Write-Host " 2. 安装依赖模块(Pester、PSScriptAnalyzer)"
Write-Host " 3. 静态代码分析"
Write-Host " 4. 运行测试套件"
Write-Host " 5. 测试通过后自动发布到 PSGallery"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
模块名称: MyUtils
版本号: 1.0.0
作者: PowerShell Developer
描述: 实用工具函数集合

代码质量检查通过,无警告或错误

=== 版本管理演示 ===
版本已更新: 1.0.0 -> 1.1.0

=== CI/CD 配置文件已生成 ===
GitHub Actions 工作流包含以下步骤:
1. 代码检出
2. 安装依赖模块(Pester、PSScriptAnalyzer)
3. 静态代码分析
4. 运行测试套件
5. 测试通过后自动发布到 PSGallery

注意事项

  1. 模块清单是门面New-ModuleManifest 生成的 .psd1 文件是模块的元数据入口。务必确保 FunctionsToExportCmdletsToExport 等字段显式声明导出列表,而非使用通配符 *。通配符导出会让模块加载变慢,还可能意外暴露内部函数。

  2. Mock 不要过度使用:Pester 的 Mock 功能强大但容易滥用。单元测试中 Mock 外部依赖(如网络调用、WMI 查询)是合理的,但如果 Mock 层数过多,测试本身就会变得脆弱,失去验证代码逻辑的价值。建议优先测试真实逻辑,仅在 I/O 边界使用 Mock。

  3. 测试与代码同步维护:每新增或修改一个公共函数,都应同步更新对应的测试文件。可以在 CI 流水线中加入测试覆盖率检查(Pester v5 支持代码覆盖率收集),将覆盖率阈值设为 80% 以上,防止测试与代码脱节。

  4. 语义化版本号:遵循 SemVer 规范(Major.Minor.Patch)管理模块版本。破坏性变更(如删除函数、更改参数含义)升 Major,新增功能升 Minor,Bug 修复升 Patch。避免随意修改版本号,使用者可能通过版本约束来控制升级范围。

  5. PSGallery 发布前检查清单:发布前至少完成以下检查:Test-ModuleManifest 验证清单完整性、Invoke-ScriptAnalyzer 代码质量检查、Pester 全部测试通过、README 和帮助文档更新、CHANGELOG 记录变更。缺少任何一项都可能导致用户反馈或差评。

  6. 跨平台兼容性:如果你的模块需要在 Linux 和 macOS 上运行,测试时要注意避免使用 Windows 专有命令(如注册表操作、WMI/CIM 的部分类)。可以在 GitHub Actions 中配置多平台(ubuntu-latestwindows-latestmacos-latest)并行测试,确保模块的跨平台兼容性。

PowerShell 技能连载 - Azure Event Grid 事件驱动自动化

适用于 PowerShell 7.0 及以上版本

背景

在云原生架构中,事件驱动模式正逐渐取代传统轮询方式,成为系统间解耦通信的主流方案。Azure Event Grid 是微软 Azure 提供的完全托管事件路由服务,它基于发布-订阅模型,可以近乎实时地将 Azure 资源的状态变更事件分发给订阅者。无论是虚拟机启动、存储 Blob 上传,还是自定义业务事件,Event Grid 都能可靠地完成投递。

对于运维工程师和自动化开发者来说,将 PowerShell 与 Event Grid 结合可以构建强大的事件响应流水线。想象一下这样的场景:当生产环境的虚拟机被意外删除时,Event Grid 立即推送事件到你的 PowerShell 脚本,脚本自动验证操作合法性、记录审计日志、并通过 Teams 发送告警——整个过程无需人工介入,响应时间从分钟级缩短到秒级。

本文将从三个层面展开:首先介绍如何用 PowerShell 管理 Event Grid 主题与订阅,然后演示事件的发布与高级处理,最后通过一个完整的自动化实战案例,展示如何将 Event Grid 融入日常运维工作流。

Event Grid 主题与订阅管理

在使用 Event Grid 之前,需要先创建事件主题(Topic)和事件订阅(Event Subscription)。主题是事件的入口,订阅者通过事件订阅声明自己关注哪些事件。下面的脚本展示了如何使用 Az 模块完成这些操作。

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
# 连接 Azure 账户并选择订阅
Connect-AzAccount -Subscription 'production-sub'

# 定义资源组和主题名称
$ResourceGroup = 'rg-eventgrid-demo'
$Location = 'eastus'
$TopicName = 'custom-ops-events'

# 创建资源组(如果不存在)
if (-not (Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue)) {
New-AzResourceGroup -Name $ResourceGroup -Location $Location
}

# 创建自定义 Event Grid 主题
$Topic = New-AzEventGridTopic `
-ResourceGroupName $ResourceGroup `
-Name $TopicName `
-Location $Location

Write-Host "主题已创建: $($Topic.Endpoint)"

# 为主题创建事件订阅,使用 Webhook 作为端点
$WebhookEndpoint = 'https://ps-webhook.example.com/api/eventhandler'
$SubscriptionName = 'ops-alert-sub'

$EventSubscription = New-AzEventGridSubscription `
-EventSubscriptionName $SubscriptionName `
-ResourceId $Topic.Id `
-Endpoint $WebhookEndpoint `
-EndpointType webhook `
-IncludedEventType @(
'Ops.VM.Created'
'Ops.VM.Deleted'
'Ops.Storage.Uploaded'
) `
-SubjectBeginsWith '/operations/production/' `
-SubjectEndsWith '/critical'

Write-Host "事件订阅已创建: $SubscriptionName"
Write-Host "过滤规则: 仅接收生产环境关键操作事件"

# 查看所有事件订阅
Get-AzEventGridSubscription `
-ResourceId $Topic.Id |
Select-Object Name, EventDeliverySchema, Destination |
Format-List

执行结果示例:

1
2
3
4
5
6
7
主题已创建: https://custom-ops-events.eastus-1.eventgrid.azure.net/api/events
事件订阅已创建: ops-alert-sub
过滤规则: 仅接收生产环境关键操作事件

Name : ops-alert-sub
EventDeliverySchema : EventGridSchema
Destination : Microsoft.EventGrid/Webhook

上面创建了自定义主题并配置了 Webhook 订阅。注意 IncludedEventType 参数只订阅了三种特定事件类型,SubjectBeginsWithSubjectEndsWith 进一步缩小了事件范围——这种精细化过滤能有效减少无用事件的投递,降低下游处理压力。

事件发布与处理

主题和订阅就绪后,接下来就是发布和处理事件。PowerShell 可以通过 REST API 向 Event Grid 发送自定义事件,同时也能作为 Webhook 处理端接收并解析事件。下面展示完整的发布和处理流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# --- 发布自定义事件 ---

# 获取主题的访问密钥
$Keys = Get-AzEventGridTopicKey `
-ResourceGroupName $ResourceGroup `
-TopicName $TopicName

# 构造符合 Event Grid 架构的事件负载
$EventId = [Guid]::NewGuid().ToString()
$EventTime = (Get-Date).ToUniversalTime().ToString('o')

$EventPayload = @(
@{
id = $EventId
eventType = 'Ops.VM.Created'
subject = '/operations/production/vm/web-01/critical'
eventTime = $EventTime
data = @{
vmName = 'web-01'
resourceGroup = 'rg-production'
createdBy = 'admin@vichamp.com'
vmSize = 'Standard_D4s_v3'
tags = @{
Environment = 'Production'
AutoShutdown = 'true'
}
}
dataVersion = '1.0'
}
) | ConvertTo-Json -Depth 5

# 发送事件到 Event Grid 主题
$Headers = @{
'aeg-sas-key' = $Keys.Key1
'Content-Type' = 'application/json'
}

$Response = Invoke-RestMethod `
-Uri "$($Topic.Endpoint)" `
-Method Post `
-Headers $Headers `
-Body $EventPayload

Write-Host "事件已发布, ID: $EventId"

# --- 配置死信队列与重试策略 ---

# 更新订阅,添加死信队列和重试策略
$StorageAccountId = (Get-AzStorageAccount `
-ResourceGroupName $ResourceGroup `
-Name 'steventgriddead').Id
$DeadLetterDestination = @{
endpointType = 'StorageBlob'
properties = @{
resourceId = $StorageAccountId
blobContainerName = 'dead-letter-events'
}
}

Set-AzEventGridSubscription `
-EventSubscriptionName $SubscriptionName `
-ResourceId $Topic.Id `
-DeadLetterEndpoint "$StorageAccountId/blobServices/default/containers/dead-letter-events" `
-MaxDeliveryAttempt 5 `
-EventTtl 1440

Write-Host "死信队列和重试策略已配置"
Write-Host "最大投递尝试: 5 次, 事件存活时间: 1440 分钟"

执行结果示例:

1
2
3
事件已发布, ID: 3a7f2c1e-9b4d-4e8a-b6c3-1f2d3e4a5b6c
死信队列和重试策略已配置
最大投递尝试: 5, 事件存活时间: 1440 分钟

事件发布使用的是 Event Grid 的标准 REST 接口,需要通过主题密钥进行认证。生产环境中建议将密钥存储在 Azure Key Vault 中,通过 Get-AzKeyVaultSecret 动态获取,而不是硬编码在脚本里。死信队列(Dead Letter Queue)则确保了投递失败的事件不会丢失,方便后续排查和重放。

事件驱动自动化实战

掌握了基本的事件发布和订阅机制后,我们来构建一个完整的自动化场景:当 Azure 资源发生变更时,自动执行响应操作并记录审计日志。这个实战案例综合运用了 Event Grid 订阅、事件处理和自动化工作流编排。

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
# --- 事件处理函数:接收并解析 Event Grid 事件 ---

function Receive-EventGridEvent {
<#
.SYNOPSIS
处理 Event Grid Webhook 接收到的事件
这是部署在 Azure Functions 或自托管 Web 服务中的处理逻辑
#>

param(
[Parameter(Mandatory)]
[object[]]$Events
)

foreach ($Event in $Events) {
Write-Host "收到事件: $($Event.eventType)"
Write-Host " 主题: $($Event.subject)"
Write-Host " 时间: $($Event.eventTime)"

switch ($Event.eventType) {
'Ops.VM.Created' {
Invoke-VmCreatedHandler -EventData $Event.data
}
'Ops.VM.Deleted' {
Invoke-VmDeletedHandler -EventData $Event.data
}
'Ops.Storage.Uploaded' {
Invoke-StorageUploadHandler -EventData $Event.data
}
default {
Write-Warning "未处理的事件类型: $($Event.eventType)"
}
}
}
}

# --- VM 创建事件处理器 ---

function Invoke-VmCreatedHandler {
param([hashtable]$EventData)

$VmName = $EventData.vmName
$RgName = $EventData.resourceGroup

Write-Host "处理 VM 创建事件: $VmName"

# 1. 等待 VM 就绪
$Timeout = 300
$Start = Get-Date
do {
$Vm = Get-AzVM -ResourceGroupName $RgName -Name $VmName `
-ErrorAction SilentlyContinue
if ($Vm -and $Vm.ProvisioningState -eq 'Succeeded') { break }
Start-Sleep -Seconds 10
} while (((Get-Date) - $Start).TotalSeconds -lt $Timeout)

# 2. 自动安装监控扩展
Set-AzVMExtension `
-ResourceGroupName $RgName `
-VMName $VmName `
-Name 'OMSExtension' `
-Publisher 'Microsoft.EnterpriseCloud.Monitoring' `
-ExtensionType 'MicrosoftMonitoringAgent' `
-TypeHandlerVersion '1.0' `
-Settings @{
workspaceId = (Get-AzOperationalInsightsWorkspace `
-ResourceGroupName 'rg-monitoring' `
-Name 'law-production').CustomerId
} `
-ProtectedSettings @{
workspaceKey = (Get-AzOperationalInsightsWorkspaceSharedKeys `
-ResourceGroupName 'rg-monitoring' `
-Name 'law-production').PrimarySharedKey
} `
-Location $Vm.Location

# 3. 添加自动关机计划
$ShutdownConfig = @{
ResourceGroupName = $RgName
VMName = $VmName
Name = 'auto-shutdown'
ShutdownTime = '19:00'
TimeZone = 'China Standard Time'
NotificationStatus = 'Enabled'
NotificationLocale = 'zh-CN'
}
# 使用 REST API 设置自动关机
$ShutdownBody = @{
properties = @{
status = 'Enabled'
taskType = 'ComputeVmShutdownTask'
dailyRecurrence = @{ time = '19:00' }
timeZoneId = 'China Standard Time'
notificationSettings = @{
status = 'Enabled'
timeInMinutes = 30
locale = 'zh-CN'
}
}
} | ConvertTo-Json -Depth 3

$VmResourceId = $Vm.Id
$AutoShutdownUri = "https://management.azure.com$VmResourceId/providers/microsoft.devtestlab/schedules/auto-shutdown?api-version=2023-01-01"
$Token = (Get-AzAccessToken).Token
Invoke-AzRestMethod -Uri $AutoShutdownUri -Method Put -Payload $ShutdownBody `
-Headers @{ Authorization = "Bearer $Token" } | Out-Null

# 4. 记录审计日志
$AuditEntry = @{
Timestamp = Get-Date -Format 'o'
Operation = 'VM.AutoConfigured'
Resource = "$RgName/$VmName"
ConfiguredBy = 'EventGrid-Automation'
Extensions = @('OMSExtension')
AutoShutdown = '19:00 CST'
}
$AuditEntry | ConvertTo-Json -Depth 3 | Out-File `
-FilePath "/var/log/azure-audit/$(Get-Date -Format 'yyyyMMdd').json" `
-Append -Encoding utf8

Write-Host "VM $VmName 自动配置完成: 监控扩展已安装, 自动关机已设置"
}

# --- 辅助函数:发送告警通知 ---

function Send-OpsAlert {
param(
[string]$Title,
[string]$Message,
[ValidateSet('Critical', 'Warning', 'Info')]
[string]$Severity = 'Info'
)

$ColorMap = @{
Critical = 'FF0000'
Warning = 'FFA500'
Info = '0078D4'
}

$Payload = @{
title = $Title
text = $Message
themeColor = $ColorMap[$Severity]
} | ConvertTo-Json

# 发送到 Teams Webhook(示例)
$TeamsWebhook = Get-AzKeyVaultSecret `
-VaultName 'kv-ops-secrets' `
-Name 'TeamsAlertWebhook' `
-AsPlainText

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

# --- 查看事件投递指标 ---

$Metrics = Get-AzMetric `
-ResourceId $Topic.Id `
-MetricName 'Published Events', 'Delivered Events', 'Dropped Events', 'Dead Lettered Events' `
-TimeGrain (New-TimeSpan -Hours 1) `
-StartTime (Get-Date).AddHours(-24) `
-EndTime (Get-Date)

$Metrics | ForEach-Object {
$MetricName = $_.Name.Value
$Total = ($_.Data | Measure-Object -Property Total -Sum).Sum
Write-Host ("{0,-30} {1,10}" -f $MetricName, $Total)
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
收到事件: Ops.VM.Created
主题: /operations/production/vm/web-01/critical
时间: 2026-02-25T08:15:30.1234567Z
处理 VM 创建事件: web-01
VM web-01 自动配置完成: 监控扩展已安装, 自动关机已设置

Published Events 1284
Delivered Events 1281
Dropped Events 0
Dead Lettered Events 3

这个实战案例展示了完整的事件驱动自动化链路:当 Event Grid 推送 VM 创建事件后,处理脚本自动执行了四个步骤——等待 VM 就绪、安装监控扩展、配置自动关机、写入审计日志。通过 Get-AzMetric 还可以实时监控事件的投递健康度,及时发现投递异常。

注意事项

  1. Webhook 端点验证:Event Grid 创建 Webhook 订阅时会发送验证握手请求(ValidationCode),你的端点必须在响应中返回该验证码,否则订阅创建会失败。建议在处理函数中优先处理 Microsoft.EventGrid.SubscriptionValidationEvent 事件类型。

  2. 事件顺序与幂等性:Event Grid 不保证事件的严格顺序,且在重试场景下可能重复投递。所有事件处理逻辑必须设计为幂等操作——通过事件 ID 去重是最常见的做法,可以将已处理的事件 ID 缓存在 Redis 或 Azure Cache 中。

  3. 密钥安全管理:Event Grid 主题的访问密钥等效于管理员权限,切勿硬编码在脚本中。生产环境应将密钥存入 Azure Key Vault,通过托管标识(Managed Identity)或服务主体访问,并定期轮换密钥。

  4. 网络连通性:如果 Webhook 端点部署在本地网络(非公网可达),需要使用 Azure Event Grid 的 Private Endpoint 功能,或改用事件中心(Event Hub)、服务总线(Service Bus)队列作为订阅端点,再由本地服务消费队列消息。

  5. 重试与死信策略:建议为所有事件订阅配置死信队列,设置合理的重试次数(推荐 5-10 次)和事件 TTL。死信队列中的事件应定期巡检,通过脚本批量重放或人工分析失败原因。

  6. 成本控制:Event Grid 按事件数量计费,自定义事件前务必使用事件过滤(IncludedEventTypeSubjectBeginsWithSubjectEndsWithAdvancedFilters)精准订阅所需事件,避免接收大量无关事件导致不必要的费用支出。

PowerShell 技能连载 - 组策略自动化管理

适用于 PowerShell 5.1 及以上版本,需要 GroupPolicy 模块

组策略(Group Policy)是 Windows 域环境中管理配置和安全设置的核心机制。通过 GPO,管理员可以集中控制数千台计算机的注册表设置、安全策略、软件部署和脚本执行。在大型企业环境中,GPO 数量往往达到数百甚至上千个,涵盖安全基线、合规要求、桌面标准化等多个维度。

手动管理这些 GPO 不仅耗时,而且极易出错——一个配置遗漏可能导致全公司的安全防线出现缺口。传统的图形界面操作(GPMC)在面对批量创建、迁移、审计等场景时显得力不从心,尤其当组织需要在不同域或林之间迁移策略时,手动操作几乎不可行。

PowerShell 的 GroupPolicy 模块提供了完整的 GPO 生命周期管理能力,从创建、编辑、备份、还原到合规报告,全部可以通过脚本自动化完成。结合 ActiveDirectory 模块,还能实现基于组织单元(OU)结构的智能策略分配和变更追踪,让组策略管理变得可重复、可审计、可回溯。

GPO 基础管理

以下脚本展示了 GPO 的创建、链接、备份和还原等基础操作,这些是日常管理中最常用的功能。

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
# 导入 GroupPolicy 模块
Import-Module GroupPolicy

# 定义常用变量
$Domain = "contoso.com"
$BackupPath = "C:\GPOBackup\$(Get-Date -Format 'yyyyMMdd')"

# 创建新的 GPO
$GPO = New-GPO -Name "Security Baseline 2026" -Comment "年度安全基线策略"

# 将 GPO 链接到指定的 OU,并设置优先级
New-GPLink -Name "Security Baseline 2026" -Target "OU=Servers,DC=contoso,DC=com" -LinkEnabled Yes -Order 1

# 禁用 GPO 的用户配置部分(仅保留计算机配置)
$GPO | Set-GPO -GpoStatus UserSettingsDisabled

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

$BackupReport = Backup-GPO -All -Path $BackupPath
$BackupReport | Select-Object DisplayName, GpoId, BackupId, Timestamp |
Format-Table -AutoSize

# 导出备份清单为 CSV,便于后续还原时查找
$BackupReport | Export-Csv -Path "$BackupPath\BackupInventory.csv" -NoTypeInformation -Encoding UTF8

# 从备份还原单个 GPO(模拟灾难恢复场景)
$LatestBackup = $BackupReport | Where-Object { $_.DisplayName -eq "Security Baseline 2026" }
Restore-GPO -Name "Security Baseline 2026" -Path $BackupPath -BackupId $LatestBackup.BackupId

# 查看域中所有 GPO 的概览
Get-GPO -All | Select-Object DisplayName, GpoStatus, CreationTime, ModificationTime |
Sort-Object ModificationTime -Descending |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
DisplayName           GpoId                                 BackupId                             Timestamp
------------ ----- -------- ---------
Security Baseline 2026 3a1b2c4d-5e6f-... a7b8c9d0-e1f2-... 2026/2/24 10:30:15
Domain Controllers f3e2d1c0-b9a8-... 1a2b3c4d-5e6f-... 2026/2/20 14:22:10
Default Domain Policy 7f8e9d0a-1b2c-... 3c4d5e6f-7a8b-... 2026/1/15 09:12:00

DisplayName GpoStatus CreationTime ModificationTime
------------ --------- ------------ ----------------
Security Baseline 2026 UserSettingsDisabled 2026/2/24 10:30:00 2026/2/24 10:32:00
Default Domain Policy AllSettingsEnabled 2024/3/1 08:00:00 2026/1/15 09:12:00
Domain Controllers AllSettingsEnabled 2024/3/1 08:00:00 2026/2/20 14:22:10

策略设置编辑

创建 GPO 只是第一步,实际管理工作中有大量策略项需要配置。以下脚本演示了如何通过注册表首选项、安全设置和脚本部署来编辑 GPO 内容。

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
# --- 注册表首选项:通过 GPO 推送注册表项 ---
$GPOName = "Security Baseline 2026"
$Domain = "contoso.com"

# 设置注册表项:禁用 SMBv1(安全加固)
Set-GPPrefRegistryValue -Name $GPOName -Context Computer -Action Create `
-Key "SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" `
-ValueName "SMB1" -Value 0 -Type DWord -Order 1

# 设置注册表项:启用 PowerShell 脚本块日志记录
Set-GPPrefRegistryValue -Name $GPOName -Context Computer -Action Create `
-Key "SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" `
-ValueName "EnableScriptBlockLogging" -Value 1 -Type DWord -Order 2

# 设置注册表项:配置 Windows Defender 实时保护
Set-GPPrefRegistryValue -Name $GPOName -Context Computer -Action Create `
-Key "SOFTWARE\Policies\Microsoft\Windows Defender\Real-Time Protection" `
-ValueName "DisableRealtimeMonitoring" -Value 0 -Type DWord -Order 3

# --- 安全设置:通过 ini 文件方式配置账户策略 ---
# 生成安全模板 INF 文件
$SecTemplatePath = "C:\Temp\SecurityTemplate.inf"
$InfContent = @"
[Unicode]
Unicode=yes
[Version]
signature=`"`$CHICAGO`$`"
Revision=1
[Account Policies]
[Password History]
MaximumPasswordAge = 90
MinimumPasswordAge = 1
MinimumPasswordLength = 14
PasswordComplexity = 1
[Lockout]
LockoutDuration = 30
LockoutBadCount = 5
ResetLockoutCount = 30
"@

Set-Content -Path $SecTemplatePath -Value $InfContent -Encoding Unicode

# --- 脚本部署:配置启动脚本 ---
$ScriptName = "Install-EndpointAgent.ps1"
$ScriptContent = @'
# 端点代理安装脚本 - 由组策略推送执行
$AgentPath = "\\contoso.com\SYSVOL\contoso.com\scripts\EndpointAgent.msi"
$LogPath = "C:\Logs\AgentInstall.log"

if (-not (Get-Service -Name "EndpointAgent" -ErrorAction SilentlyContinue)) {
Start-Process msiexec.exe -ArgumentList "/i `"$AgentPath`" /qn /l*v `"$LogPath`"" -Wait
Write-Output "$(Get-Date) - Endpoint Agent installed successfully" | Out-File $LogPath -Append
}
'@

# 将脚本保存到 GPO 的启动脚本目录
$GPO = Get-GPO -Name $GPOName
$StartupScriptFolder = "\\$Domain\SYSVOL\$Domain\Policies\{$($GPO.Id)}\Machine\Scripts\Startup"
if (-not (Test-Path $StartupScriptFolder)) {
New-Item -Path $StartupScriptFolder -ItemType Directory -Force | Out-Null
}
Set-Content -Path "$StartupScriptFolder\$ScriptName" -Value $ScriptContent -Encoding UTF8

Write-Host "策略设置编辑完成:注册表首选项 3 项、安全模板 1 份、启动脚本 1 个"

执行结果示例:

1
策略设置编辑完成:注册表首选项 3 项、安全模板 1 份、启动脚本 1 

合规审计与报告

策略配置完成后,持续的合规审计至关重要。以下脚本实现了 RSOP(策略结果集)分析、基线对比和变更追踪功能,帮助管理员及时发现策略漂移。

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
# --- RSOP 分析:获取目标计算机的实际策略应用结果 ---
$ComputerName = "SRV-DC01.contoso.com"

# 生成 GPO 报告(HTML 格式)
$ReportPath = "C:\Reports\GPOReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
Get-GPOReport -All -ReportType Html -Path $ReportPath
Write-Host "GPO 报告已生成: $ReportPath"

# 获取指定计算机的 RSOP 报告
$RsopReport = "C:\Reports\RSOP_$ComputerName`_$(Get-Date -Format 'yyyyMMdd').html"
gpresult /s $ComputerName /h $RsopReport /f 2>$null
Write-Host "RSOP 报告已生成: $RsopReport"

# --- 基线对比:检测 GPO 是否偏离已知良好配置 ---
$BaselinePath = "C:\GPOBaseline"
$CurrentBackupPath = "C:\GPOBackup\$(Get-Date -Format 'yyyyMMdd')"

# 比较基线和当前备份中的 GPO 数量
$BaselineGPOs = Get-ChildItem -Path $BaselinePath -Directory
$CurrentGPOs = Get-ChildItem -Path $CurrentBackupPath -Directory

$Missing = $BaselineGPOs.Name | Where-Object { $_ -notin $CurrentGPOs.Name }
$New = $CurrentGPOs.Name | Where-Object { $_ -notin $BaselineGPOs.Name }

if ($Missing) {
Write-Warning "以下基线 GPO 在当前备份中缺失: $($Missing -join ', ')"
}
if ($New) {
Write-Host "发现新增 GPO: $($New -join ', ')" -ForegroundColor Yellow
}

# --- 变更追踪:监控 GPO 的修改时间和设置变更 ---
$AuditLog = "C:\Reports\GPO_AuditLog_$(Get-Date -Format 'yyyyMMdd').csv"

# 获取所有 GPO 的当前状态快照
$Snapshot = Get-GPO -All | ForEach-Object {
$Links = (Get-GPOReport -Name $_.DisplayName -ReportType Xml)
$LinkCount = ([xml]$Links).GPO.LinksTo | Measure-Object | Select-Object -ExpandProperty Count

[PSCustomObject]@{
GPOName = $_.DisplayName
GpoId = $_.Id
Status = $_.GpoStatus
LinkCount = $LinkCount
ComputerEnabled = ($_.GpoStatus -ne 'ComputerSettingsDisabled' -and $_.GpoStatus -ne 'AllSettingsDisabled')
UserEnabled = ($_.GpoStatus -ne 'UserSettingsDisabled' -and $_.GpoStatus -ne 'AllSettingsDisabled')
ModifiedTime = $_.ModificationTime
AuditTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

$Snapshot | Export-Csv -Path $AuditLog -NoTypeInformation -Encoding UTF8

# 标记最近 7 天内修改过的 GPO
$RecentChanges = $Snapshot | Where-Object { $_.ModifiedTime -gt (Get-Date).AddDays(-7) }
if ($RecentChanges) {
Write-Host "`n最近 7 天内修改的 GPO:" -ForegroundColor Cyan
$RecentChanges | Format-Table GPOName, ModifiedTime, LinkCount, Status -AutoSize
} else {
Write-Host "`n最近 7 天内无 GPO 变更" -ForegroundColor Green
}

Write-Host "`n审计快照已保存: $AuditLog"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
GPO 报告已生成: C:\Reports\GPOReport_20260224_103500.html
RSOP 报告已生成: C:\Reports\RSOP_SRV-DC01.contoso.com_20260224.html
发现新增 GPO: Security Baseline 2026

最近 7 天内修改的 GPO:

GPOName ModifiedTime LinkCount Status
------- ------------- --------- ------
Security Baseline 2026 2026/2/24 10:32:00 1 UserSettingsDisabled
Domain Controllers 2026/2/20 14:22:10 1 AllSettingsEnabled

审计快照已保存: C:\Reports\GPO_AuditLog_20260224.csv

注意事项

  1. 权限要求:管理 GPO 需要域管理员或 Group Policy Creator Owners 组的成员权限。在生产环境中建议使用最小特权原则,为 GPO 管理员分配专门的委派权限,而非直接使用 Domain Admins 账户。

  2. 备份策略:建议建立定期自动备份机制,每次变更 GPO 前都执行 Backup-GPO。备份文件应存储在独立的文件服务器上,并纳入常规数据保护方案。备份 ID(BackupId)是还原时的关键标识,务必通过 CSV 清单妥善保管。

  3. 测试先行:新 GPO 或重大变更应先在测试 OU 上验证效果,确认无误后再推广到生产 OU。可以使用 New-GPLink-WhatIf 参数预览链接操作,或者先将 GPO 链接设置为禁用状态(-LinkEnabled No),验证后再启用。

  4. WMI 筛选器:复杂的策略分发场景可以结合 WMI 筛选器实现条件化应用,例如只对特定操作系统版本或硬件类型的计算机生效。但过多的 WMI 筛选器会影响组策略处理性能,建议控制在合理范围内。

  5. 脚本块日志安全:通过 GPO 启用 PowerShell 脚本块日志记录时,会产生大量日志数据。需要提前规划日志收集和存储方案(如 Windows Event Forwarding 或 SIEM 集成),避免本地日志溢出导致关键审计数据丢失。

  6. 跨域迁移:使用 Backup-GPOImport-GPO 进行跨域迁移时,需要注意安全主体(用户、组)的 SID 映射问题。迁移表格(Migration Table)是解决这一问题的关键工具,建议在迁移前使用 New-MigrationTable 生成并仔细校验映射关系。

PowerShell 技能连载 - 数据转换工具集

适用于 PowerShell 7.0 及以上版本

在运维自动化的日常工作中,数据格式转换几乎无处不在。你可能需要把 CSV 报表转成 JSON 传给 API,把 XML 配置文件解析成对象做比较,或者把 API 返回的数据加工成 CSV 交给业务方。这些看似零碎的操作,实际上构成了数据处理的基础链条。

PowerShell 作为一门面向对象的脚本语言,天然擅长处理结构化数据。它的管道机制让格式转换变得流畅——对象在管道中传递,随时可以在不同格式之间切换。配合 ConvertTo-JsonConvertFrom-JsonConvertTo-XmlConvertFrom-Csv 等内置 cmdlet,一条命令就能完成其他语言需要几十行代码才能实现的转换。

本文将围绕三个核心场景展开:基础格式互转、数据清洗与标准化,以及完整的 ETL 管道实战。掌握这些技巧后,你可以用 PowerShell 构建轻量级的数据处理管道,替代许多需要专门 ETL 工具才能完成的任务。

基础格式转换

PowerShell 内置了多种格式转换 cmdlet,可以在 CSV、JSON、XML 之间自由切换。下面的工具函数封装了常见的转换场景,支持链式调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
function Convert-DataFormat {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
$InputObject,

[Parameter(Mandatory)]
[ValidateSet('Json', 'Csv', 'Xml', 'Yaml', 'HashTable')]
[string]$ToFormat,

[int]$Depth = 10
)

process {
switch ($ToFormat) {
'Json' {
$InputObject | ConvertTo-Json -Depth $Depth -EnumsAsStrings
}
'Csv' {
if ($InputObject -is [string]) {
$InputObject | ConvertFrom-Csv | ConvertTo-Csv -NoTypeInformation
}
else {
$InputObject | ConvertTo-Csv -NoTypeInformation
}
}
'Xml' {
$InputObject | ConvertTo-Xml -NoTypeInformation -As Stream
}
'HashTable' {
if ($InputObject -is [string]) {
$json = $InputObject
}
else {
$json = $InputObject | ConvertTo-Json -Depth $Depth -Compress
}
[System.Text.Json.JsonSerializer]::Deserialize(
$json,
[System.Collections.Generic.Dictionary[string, object]],
(New-Object System.Text.Json.JsonSerializerOptions -Property @{
PropertyNameCaseInsensitive = $true
})
)
}
'Yaml' {
# YAML 没有内置 cmdlet,通过 JSON 中转后手动格式化
$json = if ($InputObject -is [string]) { $InputObject } else {
$InputObject | ConvertTo-Json -Depth $Depth
}
$obj = $json | ConvertFrom-Json
ConvertTo-YamlString -InputObject $obj -Indent 0
}
}
}
}

function ConvertTo-YamlString {
param($InputObject, [int]$Indent = 0)
$space = ' ' * $Indent
$sb = [System.Text.StringBuilder]::new()

if ($InputObject -is [System.Collections.IEnumerable] -and
$InputObject -isnot [string]) {
foreach ($item in $InputObject) {
$null = $sb.Append("${space}- ")
if ($item -is [System.Collections.IDictionary] -or
$item -is [PSCustomObject]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $item ($Indent + 1)))
}
else {
$null = $sb.AppendLine("'$item'")
}
}
}
elseif ($InputObject -is [System.Collections.IDictionary] -or
$InputObject -is [PSCustomObject]) {
$props = if ($InputObject -is [System.Collections.IDictionary]) {
$InputObject.GetEnumerator()
}
else {
$InputObject.PSObject.Properties
}
foreach ($prop in $props) {
$name = $prop.Key ?? $prop.Name
$val = $prop.Value ?? $prop
$null = $sb.Append("${space}${name}: ")
if ($val -is [System.Collections.IEnumerable] -and
$val -isnot [string]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $val ($Indent + 1)))
}
elseif ($val -is [System.Collections.IDictionary] -or
$val -is [PSCustomObject]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $val ($Indent + 1)))
}
else {
$null = $sb.AppendLine("'$val'")
}
}
}
else {
$null = $sb.AppendLine("${space}'$InputObject'")
}

$sb.ToString()
}

# 示例:CSV 转 JSON
$csvData = @'
Name,Department,Salary,StartDate
张三,工程部,25000,2024-03-15
李四,市场部,18000,2023-08-20
王五,工程部,30000,2022-11-01
赵六,人事部,22000,2025-01-10
'@

Write-Host "=== CSV 转 JSON ===" -ForegroundColor Cyan
$jsonOutput = $csvData | ConvertFrom-Csv | Convert-DataFormat -ToFormat Json
$jsonOutput

Write-Host "`n=== JSON 转 HashTable ===" -ForegroundColor Cyan
$jsonOutput | Convert-DataFormat -ToFormat HashTable |
ForEach-Object { $_.GetEnumerator() } |
Format-Table Key, Value -AutoSize

Write-Host "`n=== CSV 转 XML ===" -ForegroundColor Cyan
$xmlOutput = $csvData | ConvertFrom-Csv | Convert-DataFormat -ToFormat Xml
$xmlOutput | Select-Object -First 10

执行结果示例:

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
=== CSVJSON ===
[
{
"Name": "张三",
"Department": "工程部",
"Salary": "25000",
"StartDate": "2024-03-15"
},
{
"Name": "李四",
"Department": "市场部",
"Salary": "18000",
"StartDate": "2023-08-20"
},
{
"Name": "王五",
"Department": "工程部",
"Salary": "30000",
"StartDate": "2022-11-01"
},
{
"Name": "赵六",
"Department": "人事部",
"Salary": "22000",
"StartDate": "2025-01-10"
}
]

=== JSONHashTable ===
Key Value
--- -----
Name 张三
Department 工程部
Salary 25000
StartDate 2024-03-15

=== CSVXML ===
<?xml version="1.0" encoding="utf-8"?>
<Objects>
<Object Type="System.Management.Automation.PSCustomObject">
<Property Name="Name" Type="System.String">张三</Property>
<Property Name="Department" Type="System.String">工程部</Property>
<Property Name="Salary" Type="System.String">25000</Property>
<Property Name="StartDate" Type="System.String">2024-03-15</Property>
</Object>
...
</Objects>

数据清洗与标准化

原始数据往往不规范——字段名不统一、存在重复记录、类型混杂、空值缺失。在转换之前先做清洗,是保证数据质量的关键步骤。

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
function Invoke-DataCleanse {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[PSCustomObject]$InputObject,

# 字段映射表:旧名 → 新名
[Parameter(Mandatory)]
[hashtable]$FieldMap,

# 需要转换为 DateTime 的字段
[string[]]$DateFields,

# 需要转换为数值的字段
[string[]]$NumericFields,

# 默认空值填充
$DefaultNullValue = 'N/A'
)

begin {
$seen = [System.Collections.Generic.HashSet[string]]::new()
$duplicateCount = 0
$totalProcessed = 0
}

process {
$totalProcessed++

# 构建去重键(使用所有字段值的拼接)
$keyParts = $InputObject.PSObject.Properties.Value |
Where-Object { $_ } |
ForEach-Object { $_.ToString() }
$dedupKey = $keyParts -join '|'

if (-not $seen.Add($dedupKey)) {
$duplicateCount++
return
}

$result = [ordered]@{}

foreach ($prop in $InputObject.PSObject.Properties) {
$fieldName = $prop.Name
$value = $prop.Value

# 字段名映射
if ($FieldMap.ContainsKey($fieldName)) {
$fieldName = $FieldMap[$fieldName]
}

# 空值处理
if ([string]::IsNullOrWhiteSpace($value)) {
$value = $DefaultNullValue
}
else {
$value = $value.Trim()

# 日期字段转换
if ($DateFields -and $fieldName -in $DateFields) {
if ($value -ne $DefaultNullValue) {
if ([datetime]::TryParse($value, [ref]$parsed)) {
$value = $parsed
}
}
}

# 数值字段转换
if ($NumericFields -and $fieldName -in $NumericFields) {
if ($value -ne $DefaultNullValue) {
$cleaned = $value -replace '[,,]', ''
if ([double]::TryParse($cleaned, [ref]$num)) {
$value = $num
}
}
}
}

$result[$fieldName] = $value
}

[PSCustomObject]$result
}

end {
Write-Verbose "处理完成: 总计 $totalProcessed 条, 去除重复 $duplicateCount 条, 输出 $($totalProcessed - $duplicateCount) 条"
}
}

# 模拟原始脏数据
$rawData = @'
emp_name,dept,salary,start_date,email
张三,工程部,25000,2024-03-15,zhangsan@corp.com
李四,市场部,18000,2023-08-20,lisi@corp.com
张三,工程部,25000,2024-03-15,zhangsan@corp.com
王五,,30000,,
赵六,人事部,22,000,2025-01-10,zhaoliu@corp.com
孙七,工程部,28000,invalid-date,sunqi@corp.com
'@

$fieldMapping = @{
'emp_name' = 'EmployeeName'
'dept' = 'Department'
'salary' = 'AnnualSalary'
'start_date' = 'StartDate'
'email' = 'ContactEmail'
}

$cleaned = $rawData | ConvertFrom-Csv |
Invoke-DataCleanse `
-FieldMap $fieldMapping `
-DateFields 'StartDate' `
-NumericFields 'AnnualSalary' `
-DefaultNullValue '未知' `
-Verbose

Write-Host "=== 清洗结果 ===" -ForegroundColor Cyan
$cleaned | Format-Table -AutoSize

Write-Host "`n=== 数据类型验证 ===" -ForegroundColor Cyan
$cleaned | Get-Member -MemberType NoteProperty |
Select-Object Name, @{
N = 'SampleType'
E = {
$val = $cleaned[0].($_.Name)
if ($null -ne $val) { $val.GetType().Name } else { 'null' }
}
} | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
VERBOSE: 处理完成: 总计 6 条, 去除重复 1 条, 输出 5 条
=== 清洗结果 ===

EmployeeName Department AnnualSalary StartDate ContactEmail
------------ ---------- ------------ --------- ------------
张三 工程部 25000 2024/3/15 0:00:00 zhangsan@corp.com
李四 市场部 18000 2023/8/20 0:00:00 lisi@corp.com
王五 未知 30000 未知 未知
赵六 人事部 22000 2025/1/10 0:00:00 zhaoliu@corp.com
孙七 工程部 28000 未知 sunqi@corp.com

=== 数据类型验证 ===

Name SampleType
---- ----------
EmployeeName String
Department String
AnnualSalary Double
StartDate DateTime
ContactEmail String

ETL 管道实战

下面模拟一个真实场景:从多个数据源(CSV 文件、JSON API 响应、XML 配置)提取数据,统一转换后合并,最终输出为标准报表格式。这种轻量级 ETL 管道在运维报表、配置审计等场景中非常实用。

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
function Invoke-DataEtlPipeline {
[CmdletBinding()]
param(
[string]$OutputPath = './etl-output',
[switch]$IncludeSummary
)

# ---- Extract 阶段:从多源提取数据 ----

Write-Host "[Extract] 从 CSV 提取服务器资产数据..." -ForegroundColor Yellow
$serverCsv = @'
Hostname,IP,OS,CPU_CORES,RAM_GB,STATUS
WEB-01,192.168.1.10,Ubuntu 22.04,4,16,Running
WEB-02,192.168.1.11,Ubuntu 22.04,4,16,Running
DB-01,192.168.1.20,CentOS 7,8,64,Running
DB-02,192.168.1.21,CentOS 7,8,64,Stopped
CACHE-01,192.168.1.30,Ubuntu 22.04,2,8,Running
'@

$servers = $serverCsv | ConvertFrom-Csv
Write-Host " 提取 $($servers.Count) 条服务器记录"

Write-Host "[Extract] 从 JSON 提取监控指标数据..." -ForegroundColor Yellow
$metricsJson = @'
[
{"host": "WEB-01", "cpu_pct": 45.2, "mem_pct": 62.1, "disk_pct": 33.7, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "WEB-02", "cpu_pct": 12.8, "mem_pct": 45.3, "disk_pct": 28.9, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "DB-01", "cpu_pct": 78.5, "mem_pct": 85.2, "disk_pct": 71.4, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "CACHE-01", "cpu_pct": 23.1, "mem_pct": 91.5, "disk_pct": 15.2, "timestamp": "2026-02-23T08:00:00Z"}
]
'@

$metrics = $metricsJson | ConvertFrom-Json
Write-Host " 提取 $($metrics.Count) 条监控指标"

Write-Host "[Extract] 从 XML 提取告警配置..." -ForegroundColor Yellow
$alertXml = @'
<alerts>
<rule name="cpu_high" threshold="70" severity="critical" target_pattern="DB-*"/>
<rule name="mem_warning" threshold="80" severity="warning" target_pattern="*"/>
<rule name="disk_high" threshold="60" severity="warning" target_pattern="DB-*"/>
</alerts>
'@

[xml]$xmlDoc = $alertXml
$alertRules = $xmlDoc.alerts.rule | ForEach-Object {
[PSCustomObject]@{
RuleName = $_.name
Threshold = [int]$_.threshold
Severity = $_.severity
TargetPattern = $_.target_pattern
}
}
Write-Host " 提取 $($alertRules.Count) 条告警规则"

# ---- Transform 阶段:关联、转换、计算 ----

Write-Host "`n[Transform] 关联服务器资产与监控指标..." -ForegroundColor Green

$enriched = foreach ($srv in $servers) {
$metric = $metrics | Where-Object { $_.host -eq $srv.Hostname }
$triggeredAlerts = @()

foreach ($rule in $alertRules) {
$pattern = $rule.TargetPattern -replace '\*', '.*'
if ($srv.Hostname -match "^$pattern$") {
$violated = $false
$fieldMap = @{
'cpu_high' = 'cpu_pct'
'mem_warning' = 'mem_pct'
'disk_high' = 'disk_pct'
}
$fieldName = $fieldMap[$rule.RuleName]
if ($fieldName -and $metric -and
$metric.$fieldName -gt $rule.Threshold) {
$violated = $true
}
if ($violated) {
$triggeredAlerts += "[{0}] {1} ({2}% > {3}%)" -f
$rule.Severity, $rule.RuleName,
$metric.$fieldName, $rule.Threshold
}
}
}

[PSCustomObject][ordered]@{
Hostname = $srv.Hostname
IPAddress = $srv.IP
OS = $srv.OS
CPU_Cores = [int]$srv.CPU_CORES
RAM_GB = [int]$srv.RAM_GB
Status = $srv.STATUS
CPU_Usage = if ($metric) { $metric.cpu_pct } else { $null }
MEM_Usage = if ($metric) { $metric.mem_pct } else { $null }
DISK_Usage = if ($metric) { $metric.disk_pct } else { $null }
AlertCount = $triggeredAlerts.Count
Alerts = if ($triggeredAlerts) {
$triggeredAlerts -join '; '
} else { '无' }
}
}

# ---- Load 阶段:输出到多种目标格式 ----

$null = New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction SilentlyContinue

Write-Host "`n[Load] 导出为 CSV 报表..." -ForegroundColor Magenta
$csvFile = Join-Path $OutputPath 'server-report.csv'
$enriched | ConvertTo-Csv -NoTypeInformation | Out-File $csvFile -Encoding utf8
Write-Host " 已写入: $csvFile"

Write-Host "[Load] 导出为 JSON..." -ForegroundColor Magenta
$jsonFile = Join-Path $OutputPath 'server-report.json'
$enriched | ConvertTo-Json -Depth 5 | Out-File $jsonFile -Encoding utf8
Write-Host " 已写入: $jsonFile"

Write-Host "[Load] 导出为 HTML 报表..." -ForegroundColor Magenta
$htmlFile = Join-Path $OutputPath 'server-report.html'
$htmlParams = @{
Title = '服务器状态报表'
Body = '<h1>服务器状态报表</h1>' +
'<p>生成时间: {0}</p>' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Head = '<style>table { border-collapse: collapse; } ' +
'td, th { border: 1px solid #ccc; padding: 6px; } ' +
'.critical { color: red; font-weight: bold; }</style>'
}
$enriched | ConvertTo-Html @htmlParams | Out-File $htmlFile -Encoding utf8
Write-Host " 已写入: $htmlFile"

if ($IncludeSummary) {
Write-Host "`n=== ETL 汇总 ===" -ForegroundColor Cyan
Write-Host (" 服务器总数: {0}" -f $enriched.Count)
Write-Host (" 运行中: {0}" -f ($enriched | Where-Object Status -eq 'Running').Count)
Write-Host (" 已停机: {0}" -f ($enriched | Where-Object Status -eq 'Stopped').Count)
Write-Host (" 触发告警的服务器: {0}" -f ($enriched | Where-Object AlertCount -gt 0).Count)
Write-Host (" 平均 CPU 使用率: {0:N1}%" -f (
($enriched | Where-Object CPU_Usage | Measure-Object -Property CPU_Usage -Average).Average
))
Write-Host (" 平均内存使用率: {0:N1}%" -f (
($enriched | Where-Object MEM_Usage | Measure-Object -Property MEM_Usage -Average).Average
))
}

return $enriched
}

# 执行完整 ETL 管道
$result = Invoke-DataEtlPipeline -IncludeSummary

Write-Host "`n=== 最终报表 ===" -ForegroundColor Cyan
$result | Format-Table Hostname, IPAddress, OS, Status,
@{N='CPU%';E={'{0:N1}' -f $_.CPU_Usage}},
@{N='MEM%';E={'{0:N1}' -f $_.MEM_Usage}},
@{N='DISK%';E={'{0:N1}' -f $_.DISK_Usage}},
AlertCount -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[Extract] 从 CSV 提取服务器资产数据...
提取 5 条服务器记录
[Extract] 从 JSON 提取监控指标数据...
提取 4 条监控指标
[Extract] 从 XML 提取告警配置...
提取 3 条告警规则

[Transform] 关联服务器资产与监控指标...

[Load] 导出为 CSV 报表...
已写入: ./etl-output/server-report.csv
[Load] 导出为 JSON...
已写入: ./etl-output/server-report.json
[Load] 导出为 HTML 报表...
已写入: ./etl-output/server-report.html

=== ETL 汇总 ===
服务器总数: 5
运行中: 4
已停机: 1
触发告警的服务器: 2
平均 CPU 使用率: 39.9%
平均内存使用率: 71.0%

=== 最终报表 ===

Hostname IPAddress OS Status CPU% MEM% DISK% AlertCount
-------- --------- -- ------ ---- ---- ----- ----------
WEB-01 192.168.1.10 Ubuntu 22.04 Running 45.2 62.1 33.7 0
WEB-02 192.168.1.11 Ubuntu 22.04 Running 12.8 45.3 28.9 0
DB-01 192.168.1.20 CentOS 7 Running 78.5 85.2 71.4 3
DB-02 192.168.1.21 CentOS 7 Running 0
CACHE-01 192.168.1.30 Ubuntu 22.04 Running 23.1 91.5 15.2 1

注意事项

  1. ConvertTo-Json 的 Depth 参数:默认 Depth 只有 2,嵌套对象会被截断为 System.Object。处理复杂对象时务必显式指定足够的深度,建议设为 10 以上。

  2. CSV 的类型丢失问题:CSV 格式本质上是纯文本,所有值都会变成字符串。从 CSV 读取后需要手动对日期、数值字段做类型转换,否则后续计算会出错。

  3. 大文件的内存占用ConvertFrom-JsonConvertFrom-Csv 会一次性将全部数据加载到内存。处理数百 MB 以上的文件时,考虑使用流式处理或分批读取,避免内存溢出。

  4. XML 命名空间处理:真实的 XML 文档通常带有命名空间(如 xmlns),直接用 Select-Xml 可能匹配不到节点。需要使用 NamespaceManager 注册前缀,或者用 [xml] 类型加速器的 dot 访问绕过命名空间。

  5. 编码一致性:导出文件时显式指定 -Encoding utf8(PowerShell 7 默认即为 UTF-8,但在 Windows PowerShell 5.1 中默认是 ASCII)。跨平台场景中统一用 UTF-8 可以避免中文乱码。

  6. 管道中的去重性能HashSet 去重在数据量小时效率很高,但当记录达到数十万条时,拼接去重键的字符串操作会成为瓶颈。大数据量场景建议改用基于主键的字典查找。

PowerShell 技能连载 - Azure Service Bus 消息管理

适用于 PowerShell 7.0 及以上版本

在微服务架构日益普及的今天,服务之间的异步通信成为系统解耦的关键。Azure Service Bus 作为微软云端的企业级消息传递平台,提供了可靠的队列、主题和订阅模式,能够处理高吞吐量的消息流,并支持事务、会话和死信队列等高级特性。对于运维和开发团队来说,掌握如何通过 PowerShell 自动化管理这些资源,可以显著提升消息中间件的运维效率。

传统的 Service Bus 管理往往依赖 Azure 门户界面手动操作,或者通过 C# / Python 编写专门的管理工具。然而 PowerShell 凭借其与 Azure 生态的深度集成,能够以极低的代码量完成命名空间创建、队列配置、消息收发和监控告警等全套操作。结合自动化脚本和定时任务,可以实现 Service Bus 的全生命周期管理。

本文将从三个实战场景出发:命名空间与队列的基础管理、消息的发送与接收处理、以及队列的监控与运维,全面介绍如何用 PowerShell 构建 Service Bus 的自动化管理体系。

命名空间与队列管理

首先安装必要的 Azure 模块并连接到 Azure 订阅,然后创建 Service Bus 命名空间、队列和主题订阅等基础资源。

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

# 连接到 Azure 账户
Connect-AzAccount -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

# 创建资源组和 Service Bus 命名空间
$resourceGroup = 'rg-servicebus-demo'
$location = 'eastasia'
$namespaceName = 'sb-psdemo-2026'

New-AzResourceGroup -Name $resourceGroup -Location $location -Force

$nsParams = @{
ResourceGroupName = $resourceGroup
Name = $namespaceName
Location = $location
SkuName = 'Standard'
Tag = @{ Environment = 'Demo'; Owner = 'DevOps' }
}
$namespace = New-AzServiceBusNamespace @nsParams
Write-Host "命名空间已创建: $($namespace.Name)"

# 创建队列 - 配置消息 TTL、最大大小和重复检测
$queueParams = @{
ResourceGroupName = $resourceGroup
NamespaceName = $namespaceName
Name = 'orders-queue'
MaxSizeInMegabytes = 1024
DefaultMessageTimeToLive = (New-TimeSpan -Days 7)
DuplicateDetectionHistoryTimeWindow = (New-TimeSpan -Minutes 10)
EnableExpress = $false
LockDuration = (New-TimeSpan -Seconds 60)
MaxDeliveryCount = 5
RequiresSession = $false
}
$queue = New-AzServiceBusQueue @queueParams
Write-Host "队列已创建: $($queue.Name)"

# 创建主题和订阅
$topicParams = @{
ResourceGroupName = $resourceGroup
NamespaceName = $namespaceName
Name = 'order-events'
MaxSizeInMegabytes = 2048
DefaultMessageTimeToLive = (New-TimeSpan -Days 14)
EnablePartitioning = $true
}
$topic = New-AzServiceBusTopic @topicParams
Write-Host "主题已创建: $($topic.Name)"

# 为主题创建订阅,设置 SQL 过滤规则
$subParams = @{
ResourceGroupName = $resourceGroup
NamespaceName = $namespaceName
TopicName = 'order-events'
Name = 'high-priority-sub'
MaxDeliveryCount = 10
}
$sub = New-AzServiceBusSubscription @subParams

# 添加 SQL 过滤规则 - 只接收高优先级订单
$ruleParams = @{
ResourceGroupName = $resourceGroup
NamespaceName = $namespaceName
TopicName = 'order-events'
SubscriptionName = 'high-priority-sub'
Name = 'PriorityFilter'
SqlFilter = "Priority = 'High'"
}
New-AzServiceBusRule @ruleParams
Write-Host "订阅和过滤规则已创建"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
命名空间已创建: sb-psdemo-2026
队列已创建: orders-queue
主题已创建: order-events
订阅和过滤规则已创建

# 查看命名空间详情
Get-AzServiceBusNamespace -ResourceGroupName rg-servicebus-demo -Name sb-psdemo-2026 |
Select-Object Name, Location, SkuName, ProvisioningState

Name Location SkuName ProvisioningState
---- -------- ------- -----------------
sb-psdemo-2026 eastasia Standard Succeeded

消息发送与接收

使用 Azure Service Bus 的 REST API 通过 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
# 获取命名空间的连接字符串,用于消息操作
$authRule = Get-AzServiceBusAuthorizationRule `
-ResourceGroupName $resourceGroup `
-NamespaceName $namespaceName `
-Name 'RootManageSharedAccessKey'

$connectionString = (Get-AzServiceBusKey `
-ResourceGroupName $resourceGroup `
-NamespaceName $namespaceName `
-AuthorizationRuleName 'RootManageSharedAccessKey').PrimaryConnectionString

Write-Host "连接字符串已获取,用于后续消息操作"

# 使用 Service Bus REST API 发送单条消息
# 首先获取 SAS Token
function Get-SbSasToken {
param(
[string]$ConnectionString,
[int]$ExpiryMinutes = 60
)
$parts = $ConnectionString -split ';'
$endpoint = ($parts | Where-Object { $_ -match '^Endpoint=' }) -replace '^Endpoint=sb://', '' -replace '/$', ''
$sasKey = ($parts | Where-Object { $_ -match '^SharedAccessKey=' }) -replace '^SharedAccessKey=', ''
$sasName = ($parts | Where-Object { $_ -match '^SharedAccessKeyName=' }) -replace '^SharedAccessKeyName=', ''

$expiry = [DateTimeOffset]::UtcNow.AddMinutes($ExpiryMinutes).ToUnixTimeSeconds()
$stringToSign = [System.Web.HttpUtility]::UrlEncode("https://$endpoint") + "`n$expiry"
$hmac = New-Object System.Security.Cryptography.HMACSHA256 `
(,[System.Convert]::FromBase64String($sasKey))
$signature = [System.Convert]::ToBase64String(
$hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToSign)))
$token = "SharedAccessSignature sr=$([System.Web.HttpUtility]::UrlEncode("https://$endpoint"))&sig=$([System.Web.HttpUtility]::UrlEncode($signature))&se=$expiry&skn=$sasName"
return $token, $endpoint
}

# 发送消息到队列
$token, $endpoint = Get-SbSasToken -ConnectionString $connectionString
$queueName = 'orders-queue'
$messageBody = @{
orderId = "ORD-$(Get-Random -Maximum 99999)"
customer = '张三'
amount = 299.50
priority = 'High'
timestamp = (Get-Date).ToString('o')
} | ConvertTo-Json -Compress

$sendUri = "https://$endpoint/$queueName/messages"
$headers = @{
Authorization = $token
'Content-Type' = 'application/atom+xml;type=entry;charset=utf-8'
'BrokerProperties' = (@{ Label = 'NewOrder'; MessageId = [Guid]::NewGuid().ToString() } | ConvertTo-Json -Compress)
}

# 批量发送消息示例
$batchCount = 5
for ($i = 1; $i -le $batchCount; $i++) {
$body = @{
orderId = "ORD-$(Get-Random -Maximum 99999)"
batchIndex = $i
customer = "客户$i"
amount = [math]::Round((Get-Random -Minimum 10 -Maximum 500), 2)
priority = if ($i % 2 -eq 0) { 'High' } else { 'Normal' }
timestamp = (Get-Date).ToString('o')
} | ConvertTo-Json -Compress

$headers['BrokerProperties'] = (@{
Label = "BatchMessage-$i"
MessageId = [Guid]::NewGuid().ToString()
} | ConvertTo-Json -Compress)

$response = Invoke-RestMethod -Uri $sendUri -Method Post -Headers $headers -Body $body
Write-Host "消息 $i 已发送 (StatusCode: OK)"
}

# 查看死信队列中的消息数量
$dlqPath = "$queueName/$deadletter"
$dlqUri = "https://$endpoint/$queueName`$deadletter/messages"
Write-Host "`n死信队列路径: $dlqPath"

# 清理消息 - 使用 Azure CLI 辅助操作
# az servicebus queue show --resource-group $resourceGroup --namespace-name $namespaceName --name $queueName

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
连接字符串已获取,用于后续消息操作
消息 1 已发送 (StatusCode: OK)
消息 2 已发送 (StatusCode: OK)
消息 3 已发送 (StatusCode: OK)
消息 4 已发送 (StatusCode: OK)
消息 5 已发送 (StatusCode: OK)

死信队列路径: orders-queue/$deadletter

# 队列当前状态
$queueInfo = Get-AzServiceBusQueue -ResourceGroupName $resourceGroup -NamespaceName $namespaceName -Name 'orders-queue'
$queueInfo | Select-Object Name, MessageCount, SizeInMegabytes

Name MessageCount SizeInMegabytes
---- ------------ ---------------
orders-queue 5 0.01

监控与运维

构建 Service Bus 的健康监控体系,包括队列深度检查、消息积压告警和性能指标报告。

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
# 定义监控函数 - 检查所有队列的健康状态
function Get-SbQueueHealth {
param(
[string]$ResourceGroupName,
[string]$NamespaceName,
[int]$WarningThreshold = 100,
[int]$CriticalThreshold = 500
)

$queues = Get-AzServiceBusQueue -ResourceGroupName $ResourceGroupName `
-NamespaceName $NamespaceName

$report = foreach ($q in $queues) {
$status = switch ($true) {
($q.MessageCount -ge $CriticalThreshold) { 'CRITICAL'; break }
($q.MessageCount -ge $WarningThreshold) { 'WARNING'; break }
default { 'HEALTHY'; break }
}

# 计算死信队列占比
$dlqCount = if ($q.CountDetails) {
$q.CountDetails.DeadLetterMessageCount
} else { 0 }
$dlqRatio = if ($q.MessageCount -gt 0) {
[math]::Round(($dlqCount / $q.MessageCount) * 100, 2)
} else { 0 }

[PSCustomObject]@{
QueueName = $q.Name
MessageCount = $q.MessageCount
ActiveMessages = $q.CountDetails.ActiveMessageCount
DeadLetterCount = $dlqCount
DeadLetterRatio = "$dlqRatio%"
ScheduledCount = $q.CountDetails.ScheduledMessageCount
Status = $status
MaxSizeMB = $q.MaxSizeInMegabytes
CurrentSizeMB = [math]::Round($q.SizeInMegabytes, 2)
SizeUtilization = "$([math]::Round(($q.SizeInMegabytes / $q.MaxSizeInMegabytes) * 100, 2))%"
LastUpdated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

return $report
}

# 执行健康检查
$health = Get-SbQueueHealth `
-ResourceGroupName $resourceGroup `
-NamespaceName $namespaceName `
-WarningThreshold 50 `
-CriticalThreshold 200

$health | Format-Table -AutoSize

# 生成监控报告并导出
$reportDate = Get-Date -Format 'yyyy-MM-dd_HHmmss'
$csvPath = "./sb-health-report-$reportDate.csv"
$health | Export-Csv -Path $csvPath -NoTypeInformation -Encoding Utf8
Write-Host "健康报告已导出至: $csvPath"

# 发送告警邮件(如果存在 CRITICAL 队列)
$criticalQueues = $health | Where-Object { $_.Status -eq 'CRITICAL' }
if ($criticalQueues) {
$alertBody = "以下 Service Bus 队列消息积压严重:`n"
foreach ($cq in $criticalQueues) {
$alertBody += " - $($cq.QueueName): $($cq.MessageCount) 条消息 (状态: $($cq.Status))`n"
}
$alertBody += "`n请尽快处理。`n报告时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

Write-Host "=== 告警通知 ===" -ForegroundColor Red
Write-Host $alertBody
# 实际发送可集成 Send-MailMessage 或企业微信/钉钉 Webhook
}

# 持续监控循环(示例:每 30 秒检查一次)
function Start-SbContinuousMonitor {
param(
[string]$ResourceGroupName,
[string]$NamespaceName,
[int]$IntervalSeconds = 30,
[int]$DurationMinutes = 5
)

$endTime = (Get-Date).AddMinutes($DurationMinutes)
$iteration = 0

Write-Host "开始持续监控 (间隔: ${IntervalSeconds}s, 持续: ${DurationMinutes}min)"
Write-Host ('=' * 60)

while ((Get-Date) -lt $endTime) {
$iteration++
$timestamp = Get-Date -Format 'HH:mm:ss'
Write-Host "`n[$timestamp] 第 $iteration 次检查..."

$queues = Get-AzServiceBusQueue `
-ResourceGroupName $ResourceGroupName `
-NamespaceName $NamespaceName

foreach ($q in $queues) {
$activeCount = $q.CountDetails.ActiveMessageCount
$dlqCount = $q.CountDetails.DeadLetterMessageCount
Write-Host " $($q.Name): 活跃=$activeCount, 死信=$dlqCount"

if ($activeCount -gt 200) {
Write-Host " [WARNING] $($q.Name) 消息积压: $activeCount 条" -ForegroundColor Yellow
}
}

Start-Sleep -Seconds $IntervalSeconds
}
Write-Host "`n监控已结束,共执行 $iteration 次检查。"
}

# 启动持续监控(取消注释以运行)
# Start-SbContinuousMonitor -ResourceGroupName $resourceGroup -NamespaceName $namespaceName -DurationMinutes 10

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
QueueName      MessageCount ActiveMessages DeadLetterCount DeadLetterRatio ScheduledCount Status  MaxSizeMB CurrentSizeMB SizeUtilization LastUpdated
--------- ------------ -------------- --------------- --------------- -------------- ------ --------- ------------- --------------- -----------
orders-queue 5 5 0 0% 0 HEALTHY 1024 0.01 0% 2026-02-20 08:15:32
events-queue 180 175 5 2.78% 0 WARNING 1024 3.45 0.34% 2026-02-20 08:15:32
retry-queue 520 510 10 1.92% 0 CRITICAL 1024 8.20 0.8% 2026-02-20 08:15:32

健康报告已导出至: ./sb-health-report-2026-02-20_081532.csv

=== 告警通知 ===
以下 Service Bus 队列消息积压严重:
- retry-queue: 520 条消息 (状态: CRITICAL)

请尽快处理。
报告时间: 2026-02-20 08:15:32

注意事项

  1. 连接字符串安全:获取 Service Bus 连接字符串后应妥善保管,建议存储在 Azure Key Vault 中,避免在脚本中硬编码敏感信息。生产环境推荐使用 Managed Identity 替代共享访问密钥。

  2. 消息大小限制:Standard 层级的消息最大为 256 KB,Premium 层级支持最大 100 MB。超过限制的消息需要压缩或拆分,否则会被拒绝。

  3. 定价层级选择:Standard 层按消息数量计费,适合中低吞吐量场景;Premium 层按资源单位计费,提供独立资源和更高性能,适合生产环境的关键业务。命名空间一旦创建,SKU 不可更改,只能删除重建。

  4. 死信队列监控:消息超过最大投递次数(MaxDeliveryCount)后会自动进入死信队列。务必定期检查死信队列,分析失败原因并修复问题,避免业务消息丢失。

  5. REST API 限制:通过 REST API 操作消息时需注意请求频率限制。高频场景建议使用 AMQP 协议的专用客户端库(如 Azure.Messaging.ServiceBus),或通过 Azure Functions 事件触发方式处理消息。

  6. 监控告警集成:生产环境中应将队列监控与 Azure Monitor 告警规则集成,配合 Action Group 实现自动通知。PowerShell 脚本适合定时巡检和运维自动化,但不宜作为唯一的监控手段。