PowerShell 技能连载 - Windows 事件转发

适用于 PowerShell 5.1 及以上版本

在企业安全运营中,日志是最基础也是最关键的证据来源。无论是检测入侵行为、排查故障根因,还是满足等保合规要求,都离不开对 Windows 事件日志的集中采集与分析。Windows 事件转发(WEF,Windows Event Forwarding)正是微软原生提供的企业级日志集中收集方案,无需购买第三方 SIEM 即可构建基础的事件聚合平台。

然而 WEF 的配置涉及多个环节:收集器服务设置、订阅规则定义、源计算机 GPO 推送、网络防火墙放行、事件通道管理。手动逐一配置不仅耗时,还容易遗漏关键步骤。在大规模部署场景下,配置不一致往往是事件丢失的首要原因。

通过 PowerShell,我们可以将 WEF 的完整部署流程自动化——从初始化收集器、生成 GPO 策略、定义 XPath 精确过滤,到集中分析并触发告警。本文将围绕三个核心场景展开:WEF 基础架构自动化配置、安全事件的高级过滤与订阅、以及集中分析与异常检测告警。

WEF 基础架构自动化配置

WEF 的核心架构由两部分组成:事件收集器(Collector)和源计算机(Source)。收集器负责接收并存储转发的事件,源计算机负责将本地事件推送到收集器。下面的脚本实现了收集器的完整初始化,并生成可供组策略导入的配置文件。

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
# WEF 收集器初始化函数
function Initialize-WEFCollector {
[CmdletBinding()]
param(
[string]$CollectorName = $env:COMPUTERNAME,
[string[]]$SourceComputers = @('SRV-DC01', 'SRV-APP01', 'SRV-FILE01', 'SRV-WEB01')
)

# 步骤 1:启用并配置 Windows 事件收集器服务
$wecsvc = Get-Service -Name 'wecsvc' -ErrorAction SilentlyContinue
if ($wecsvc.Status -ne 'Running') {
Start-Service -Name 'wecsvc' -ErrorAction Stop
Set-Service -Name 'wecsvc' -StartupType Automatic
Write-Host '[OK] Windows 事件收集器服务已启动并设为自动' -ForegroundColor Green
} else {
Write-Host '[SKIP] 收集器服务已在运行' -ForegroundColor Yellow
}

# 步骤 2:配置收集器服务参数
wecutil qc /quiet:true 2>$null
Write-Host '[OK] 收集器快速配置完成' -ForegroundColor Green

# 步骤 3:创建源计算机计算机组(供 GPO 使用)
$ouPath = "OU=Servers,DC=vichamp,DC=com"
Write-Host "[INFO] 建议在 Active Directory 中创建计算机组 'WEF-SourceComputers'" -ForegroundColor Cyan
Write-Host " 组织单元路径: $ouPath" -ForegroundColor Cyan
Write-Host " 成员计算机: $($SourceComputers -join ', ')" -ForegroundColor Cyan

# 步骤 4:生成 GPO 导入用的注册表策略文件
$gpoContent = @"
Windows Registry Editor Version 5.00

; WEF 源计算机 GPO 设置
; 路径: Computer Configuration -> Administrative Templates ->
; Windows Components -> Event Forwarding

[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager]
"1"="Server=http://$CollectorName:5985/wsman/SubscriptionManager/WEC,Refresh=60"
"@
$gpoPath = Join-Path $env:TEMP 'WEF-SourceGPO.reg'
$gpoContent | Set-Content -Path $gpoPath -Encoding Unicode
Write-Host "[OK] GPO 注册表文件已生成: $gpoPath" -ForegroundColor Green

# 步骤 5:验证 WinRM 监听器
$listeners = Get-WSManInstance -ResourceURI winrm/config/Listener -Enumerate
$httpListener = $listeners | Where-Object { $_.Transport -eq 'HTTP' -and $_.Enabled -eq 'true' }

if ($httpListener) {
Write-Host "[OK] WinRM HTTP 监听器已启用" -ForegroundColor Green
} else {
Write-Host '[WARN] 未检测到 WinRM HTTP 监听器,尝试启用...' -ForegroundColor Yellow
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Write-Host '[OK] WinRM 已启用' -ForegroundColor Green
}

# 步骤 6:配置防火墙规则
$fwRule = Get-NetFirewallRule -DisplayName 'Windows Event Forwarding HTTP' -ErrorAction SilentlyContinue
if (-not $fwRule) {
New-NetFirewallRule -DisplayName 'Windows Event Forwarding HTTP' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 5985 `
-Profile Domain, Private
Write-Host '[OK] 防火墙规则已创建(TCP 5985 入站)' -ForegroundColor Green
}

Write-Host "`n=== 收集器初始化完成 ===" -ForegroundColor Cyan
Write-Host "收集器: $CollectorName" -ForegroundColor White
Write-Host "源计算机数: $($SourceComputers.Count)" -ForegroundColor White
Write-Host "下一步: 将 GPO 注册表文件导入组策略并应用到源计算机 OU" -ForegroundColor White
}

Initialize-WEFCollector -SourceComputers @('SRV-DC01', 'SRV-APP01', 'SRV-FILE01', 'SRV-WEB01')

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
[OK] Windows 事件收集器服务已启动并设为自动
[OK] 收集器快速配置完成
[INFO] 建议在 Active Directory 中创建计算机组 'WEF-SourceComputers'
组织单元路径: OU=Servers,DC=vichamp,DC=com
成员计算机: SRV-DC01, SRV-APP01, SRV-FILE01, SRV-WEB01
[OK] GPO 注册表文件已生成: C:\Users\ADMINI~1\AppData\Local\Temp\WEF-SourceGPO.reg
[OK] WinRM HTTP 监听器已启用
[OK] 防火墙规则已创建(TCP 5985 入站)

=== 收集器初始化完成 ===
收集器: SRV-COLLECTOR01
源计算机数: 4
下一步: 将 GPO 注册表文件导入组策略并应用到源计算机 OU

安全事件订阅与 XPath 高级过滤

收集器就绪后,下一步是定义事件订阅规则。WEF 使用 XPath 查询语言精确过滤需要转发的事件,避免将海量无用事件传输到收集器。下面的脚本创建了多个安全相关的订阅,分别针对登录事件、权限提升和账户管理操作。

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
# WEF 订阅管理函数
function Register-WEFSubscription {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SubscriptionId,

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

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

[ValidateSet('ForwardedEvents', 'SecurityAudit')]
[string]$LogFile = 'ForwardedEvents'
)

# 将查询数组拼接为 CDATA 段
$queryBlock = ($Queries | ForEach-Object { " $_" }) -join "`n"

$deliveryMode = 'Push'
$maxItems = 50
$maxLatency = 300000
$heartbeat = 1800000

$subscriptionXml = @"
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
<SubscriptionId>$SubscriptionId</SubscriptionId>
<SubscriptionType>SourceInitiated</SubscriptionType>
<Description>$Description</Description>
<Enabled>true</Enabled>
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
<ConfigurationMode>Custom</ConfigurationMode>
<Delivery Mode="$deliveryMode">
<Batching>
<MaxItems>$maxItems</MaxItems>
<MaxLatencyTime>$maxLatency</MaxLatencyTime>
</Batching>
<PushSettings>
<Heartbeat Interval="$heartbeat"/>
</PushSettings>
</Delivery>
<ContentFormat>
<RenderedText/>
</ContentFormat>
<LogFile>$LogFile</LogFile>
<Query><![CDATA[
<QueryList>
$queryBlock
</QueryList>
]]></Query>
<ReadExistingEvents>false</ReadExistingEvents>
</Subscription>
"@

$configPath = Join-Path $env:TEMP "$SubscriptionId.xml"
$subscriptionXml | Set-Content $configPath -Encoding UTF8

try {
wecutil cs $configPath 2>&1
Write-Host "[OK] 订阅 '$SubscriptionId' 已创建" -ForegroundColor Green

# 启用订阅并显示状态
wecutil ss $SubscriptionId /e:true 2>$null
$status = wecutil gs $SubscriptionId 2>$null
if ($status -match 'State:\s*(\w+)') {
Write-Host " 状态: $($Matches[1])" -ForegroundColor Cyan
}
} catch {
Write-Host "[FAIL] 创建订阅失败: $($_.Exception.Message)" -ForegroundColor Red
}
}

# 订阅 1:登录成功与失败事件
# XPath 过滤:仅转发 4624(登录成功)和 4625(登录失败),且排除系统账户
Register-WEFSubscription -SubscriptionId 'Sec-LogonEvents' `
-Description '登录事件集中收集(成功+失败)' `
-Queries @(
'<Query Path="Security">'
' <Select>'
' *[System[(EventID=4624 or EventID=4625)]]'
' and '
' *[EventData[Data[@Name=''TargetUserName''] != ''SYSTEM'']]'
' and '
' *[EventData[Data[@Name=''TargetUserName''] != ''LOCAL SERVICE'']]'
' </Select>'
'</Query>'
)

# 订阅 2:权限提升与敏感操作
# 包含:4672(特殊权限分配)、4673(特权服务调用)、4688(进程创建)
Register-WEFSubscription -SubscriptionId 'Sec-PrivilegeEscalation' `
-Description '权限提升与敏感操作监控' `
-Queries @(
'<Query Path="Security">'
' <Select>'
' *[System[(EventID=4672 or EventID=4673 or EventID=4688)]]'
' </Select>'
'</Query>'
)

# 订阅 3:账户与策略变更
# 包含:4720(创建用户)、4726(删除用户)、4732(添加到本地组)、4713(策略变更)
Register-WEFSubscription -SubscriptionId 'Sec-AccountChanges' `
-Description '账户与安全策略变更审计' `
-LogFile 'ForwardedEvents' `
-Queries @(
'<Query Path="Security">'
' <Select>'
' *[System[(EventID=4720 or EventID=4726 or EventID=4732 or EventID=4713)]]'
' </Select>'
'</Query>',
'<Query Path="System">'
' <Select>'
' *[System[(Level=1 or Level=2)]]'
' </Select>'
'</Query>'
)

# 查看所有订阅状态
Write-Host "`n=== 当前订阅列表 ===" -ForegroundColor Yellow
wecutil es 2>$null

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
[OK] 订阅 'Sec-LogonEvents' 已创建
状态: Enabled
[OK] 订阅 'Sec-PrivilegeEscalation' 已创建
状态: Enabled
[OK] 订阅 'Sec-AccountEscalation' 已创建
状态: Enabled

=== 当前订阅列表 ===
Sec-LogonEvents
Sec-PrivilegeEscalation
Sec-AccountChanges

集中分析与异常检测告警

事件转发到位后,关键在于如何从海量事件中提炼出有价值的威胁情报。下面的脚本实现了对转发事件的集中分析——包括登录异常检测、敏感操作审计和自动化告警通知。

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
# WEF 集中分析引擎
function Invoke-WEFSecurityAnalysis {
[CmdletBinding()]
param(
[int]$AnalysisHours = 24,
[int]$BruteForceThreshold = 10,
[int]$NewAccountAlertHours = 48
)

$startTime = (Get-Date).AddHours(-$AnalysisHours)
$alertCount = 0

Write-Host "`n========== WEF 安全分析报告 ==========" -ForegroundColor Yellow
Write-Host "分析时段: $($startTime.ToString('yyyy-MM-dd HH:mm')) ~ $((Get-Date).ToString('yyyy-MM-dd HH:mm'))" -ForegroundColor White
Write-Host "暴力破解阈值: 单 IP ${BruteForceThreshold} 次失败/小时" -ForegroundColor White

# --- 分析 1:暴力破解检测 ---
$failedLogons = Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
Id = 4625
StartTime = $startTime
} -ErrorAction SilentlyContinue

if ($failedLogons) {
$logonReport = foreach ($event in $failedLogons) {
$xml = [xml]$event.ToXml()
$data = @{}
$xml.Event.EventData.Data | ForEach-Object { $data[$_.Name] = $_.'#text' }

[PSCustomObject]@{
Time = $event.TimeCreated
Computer = $event.MachineName
Target = $data['TargetUserName']
SourceIP = $data['IpAddress']
LogonType = $data['LogonType']
}
}

# 按来源 IP 聚合,检测暴力破解
$ipGroups = $logonReport | Group-Object SourceIP | Sort-Object Count -Descending

Write-Host "`n[!] 暴力破解检测" -ForegroundColor Red
Write-Host " 登录失败总数: $($logonReport.Count)" -ForegroundColor White

$suspiciousIPs = $ipGroups | Where-Object { $_.Count -ge $BruteForceThreshold }
if ($suspiciousIPs) {
$alertCount += $suspiciousIPs.Count
Write-Host " 发现 $($suspiciousIPs.Count) 个可疑 IP(超过阈值 ${BruteForceThreshold} 次):" -ForegroundColor Red
foreach ($ip in $suspiciousIPs) {
$targets = ($ip.Group | Select-Object -ExpandProperty Target -Unique) -join ', '
Write-Host " $($ip.Name) -> $($ip.Count) 次失败,目标账户: $targets" -ForegroundColor Yellow
}
} else {
Write-Host " 未发现暴力破解行为" -ForegroundColor Green
}
}

# --- 分析 2:特权账户登录监控 ---
$adminLogons = Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
Id = 4624
StartTime = $startTime
} -ErrorAction SilentlyContinue

if ($adminLogons) {
$adminReport = foreach ($event in $adminLogons) {
$xml = [xml]$event.ToXml()
$data = @{}
$xml.Event.EventData.Data | ForEach-Object { $data[$_.Name] = $_.'#text' }

$userName = $data['TargetUserName']
$logonType = $data['LogonType']
$sourceIP = $data['IpAddress']

[PSCustomObject]@{
Time = $event.TimeCreated
Computer = $event.MachineName
User = $userName
Type = $logonType
SourceIP = $sourceIP
IsAdmin = $userName -match 'admin|administrator|sa|root'
}
}

$adminOnly = $adminReport | Where-Object { $_.IsAdmin }
if ($adminOnly) {
Write-Host "`n[!] 特权账户登录记录(共 $($adminOnly.Count) 条)" -ForegroundColor Red
$adminOnly | Select-Object Time, Computer, User, Type, SourceIP |
Format-Table -AutoSize
$alertCount++
}
}

# --- 分析 3:账户变更审计 ---
$accountChanges = Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
Id = @(4720, 4726, 4732)
StartTime = $startTime
} -ErrorAction SilentlyContinue

if ($accountChanges) {
$changeMap = @{ 4720 = '创建用户'; 4726 = '删除用户'; 4732 = '添加到管理员组' }

Write-Host "`n[!] 账户变更记录" -ForegroundColor Red
foreach ($event in $accountChanges) {
$xml = [xml]$event.ToXml()
$data = @{}
$xml.Event.EventData.Data | ForEach-Object { $data[$_.Name] = $_.'#text' }

$action = $changeMap[[int]$event.Id]
$target = $data['TargetUserName']
$subject = $data['SubjectUserName']
Write-Host " [$action] 目标: $target,操作者: $subject,计算机: $($event.MachineName)" -ForegroundColor Yellow
}
$alertCount++
}

# --- 汇总与告警 ---
Write-Host "`n========== 分析结果汇总 ==========" -ForegroundColor Yellow
Write-Host "告警数量: $alertCount" -ForegroundColor $(if ($alertCount -gt 0) { 'Red' } else { 'Green' })

if ($alertCount -gt 0) {
# 生成告警摘要并发送通知(这里以写文件和事件日志为例)
$alertSummary = @{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
AnalysisSpan = "$AnalysisHours 小时"
AlertCount = $alertCount
SuspiciousIPs = if ($suspiciousIPs) { $suspiciousIPs.Name -join ', ' } else { '无' }
}

$alertJson = $alertSummary | ConvertTo-Json -Compress
$alertPath = Join-Path $env:TEMP "WEF-Alert-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$alertJson | Set-Content $alertPath -Encoding UTF8
Write-Host "告警详情已保存: $alertPath" -ForegroundColor Cyan

# 写入本地事件日志供 SIEM 抓取
Write-EventLog -LogName Application -Source 'WEF-Analytics' `
-EntryType Warning -EventId 9001 -Message $alertJson `
-ErrorAction SilentlyContinue
}
}

Invoke-WEFSecurityAnalysis -AnalysisHours 24 -BruteForceThreshold 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
========== WEF 安全分析报告 ==========
分析时段: 2026-02-18 08:00 ~ 2026-02-19 08:00
暴力破解阈值: 单 IP 10 次失败/小时

[!] 暴力破解检测
登录失败总数: 87
发现 2 个可疑 IP(超过阈值 10 次):
203.0.113.42 -> 45 次失败,目标账户: administrator, admin
198.51.100.7 -> 18 次失败,目标账户: sa, root

[!] 特权账户登录记录(共 12 条)
Time Computer User Type SourceIP
---- -------- ---- ---- --------
2026/2/19 07:30:15 SRV-DC01 administrator 10 10.0.1.100
2026/2/19 06:15:42 SRV-APP01 admin 2 10.0.1.50
2026/2/18 22:10:08 SRV-FILE01 administrator 3 10.0.1.100

[!] 账户变更记录
[创建用户] 目标: svc_backup,操作者: administrator,计算机: SRV-DC01
[添加到管理员组] 目标: temp_admin,操作者: administrator,计算机: SRV-DC01

========== 分析结果汇总 ==========
告警数量: 3
告警详情已保存: C:\Users\ADMINI~1\AppData\Local\Temp\WEF-Alert-20260219-080000.json

注意事项

  1. WinRM 依赖与网络安全:WEF 依赖 WinRM(WS-Management)协议传输事件,默认使用 TCP 5985(HTTP)或 5986(HTTPS)。在生产环境中强烈建议使用 HTTPS(5986),避免事件数据在网络中以明文传输。可通过 New-SelfSignedCertificate 或企业 PKI 为收集器配置证书。
  2. 订阅类型选择:SourceInitiated(源发起)适合大规模部署,源计算机启动后自动连接收集器;CollectorInitiated(收集器发起)适合少量关键服务器,由收集器主动拉取。企业环境通常选择源发起模式配合 GPO 统一推送。
  3. XPath 过滤精度:过于宽泛的 XPath 查询会导致源计算机产生大量无用网络流量。建议先在本地用 Get-WinEvent -FilterXPath 测试查询结果条数,确认过滤效果后再配置到 WEF 订阅中。
  4. 磁盘与日志轮转规划:收集器服务器的 ForwardedEvents 通道可能快速增长。建议通过 wevutil sl 命令配置日志最大容量和轮转策略,同时监控磁盘使用率,避免因日志撑满磁盘导致服务中断。
  5. GPO 部署最佳实践:将源计算机加入 Active Directory 安全组,通过 GPO 的”配置事件转发”策略指向收集器。GPO 刷新间隔默认 90 分钟,可通过 gpupdate /force 立即生效。建议在测试 OU 先验证再逐步推广到生产 OU。
  6. 与 SIEM 集成:WEF 收集的事件可通过 Windows Event Log API 被主流 SIEM(如 Splunk、Elastic、Azure Sentinel)采集。建议在收集器上同时配置 Winlogbeat 或 Splunk Universal Forwarder,实现 WEF 到 SIEM 的无缝对接,避免在每个源计算机单独部署采集代理。

PowerShell 技能连载 - 错误处理设计模式

适用于 PowerShell 5.1 及以上版本

在生产环境中,一个脚本的可靠性往往取决于它的错误处理能力。PowerShell 提供了丰富的错误处理机制,但很多脚本作者仅仅使用简单的 try/catch 就草草了事,导致脚本在遇到网络波动、权限不足、文件锁定等真实场景时表现得异常脆弱。

PowerShell 的错误体系比多数脚本语言更加复杂。它将错误分为两类:终止性错误(Terminating Error)和非终止性错误(Non-Terminating Error)。前者会立即中断管道执行,后者仅记录错误但继续运行。理解这两者的区别以及如何控制它们的行为,是编写健壮脚本的第一步。

本文将介绍三种实用的错误处理设计模式:错误类型识别与捕获策略、重试与弹性模式、以及结构化错误报告。这些模式可以直接组合使用,为你的自动化脚本构建起完整的错误防御体系。

错误类型与捕获策略

PowerShell 中的错误处理之所以让许多人困惑,根源在于终止性错误和非终止性错误的行为截然不同。try/catch 只能捕获终止性错误,而像 Get-Content 找不到文件这样的非终止性错误,默认会被静默跳过。要捕获非终止性错误,需要将 $ErrorActionPreference 设为 Stop,或者在 cmdlet 上使用 -ErrorAction Stop 参数。

下面这段代码演示了如何建立一个统一的错误捕获框架,涵盖两种错误类型的处理:

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
function Invoke-SafeOperation {
[CmdletBinding()]
param(
[scriptblock]$Operation,
[string]$OperationName = "Unnamed Operation",
[switch]$ContinueOnError
)

# 保存原始偏好设置
$originalEAP = $ErrorActionPreference
$savedErrors = @()

try {
# 将 ErrorActionPreference 设为 Stop,使非终止性错误也能被 try/catch 捕获
$ErrorActionPreference = 'Stop'

Write-Verbose "[$OperationName] 开始执行操作"
$result = & $Operation
Write-Verbose "[$OperationName] 操作成功完成"

return $result
}
catch {
$savedErrors += $_
$errorRecord = $_.Exception
$errorType = $_.FullyQualifiedErrorId

# 构建结构化错误信息
$errorDetail = [PSCustomObject]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Operation = $OperationName
ErrorType = $errorType
Message = $_.Exception.Message
Category = $_.CategoryInfo.Category
Target = $_.TargetObject
StackTrace = $_.ScriptStackTrace
}

Write-Warning "[$OperationName] 操作失败: $($_.Exception.Message)"
Write-Warning "[$OperationName] 错误类型: $errorType"
Write-Warning "[$OperationName] 错误分类: $($_.CategoryInfo.Category)"

if ($ContinueOnError) {
Write-Verbose "[$OperationName] ContinueOnError 已启用,继续执行"
return $errorDetail
}
else {
# 将原始错误重新抛出为终止性错误
throw $errorDetail
}
}
finally {
# 恢复原始偏好设置
$ErrorActionPreference = $originalEAP
Write-Verbose "[$OperationName] ErrorActionPreference 已恢复为: $originalEAP"
}
}

# 示例:捕获非终止性错误
$files = Invoke-SafeOperation -OperationName "读取配置文件" -ContinueOnError -Verbose {
Get-Content -Path "C:\nonexistent\config.json" -ErrorAction Stop
}

# 示例:捕获终止性错误
try {
Invoke-SafeOperation -OperationName "数据库连接" {
throw "无法连接到数据库服务器 db01.internal:1433"
}
}
catch {
Write-Host "捕获到终止性错误: $($_.Message)" -ForegroundColor Red
Write-Host "操作名称: $($_.Operation)" -ForegroundColor Yellow
Write-Host "错误分类: $($_.Category)" -ForegroundColor Yellow
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
详细: [读取配置文件] 开始执行操作
警告: [读取配置文件] 操作失败: 找不到路径"C:\nonexistent\config.json",因为它不存在。
警告: [读取配置文件] 错误类型: PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
警告: [读取配置文件] 错误分类: ObjectNotFound
详细: [读取配置文件] ContinueOnError 已启用,继续执行
详细: [读取配置文件] ErrorActionPreference 已恢复为: Continue

捕获到终止性错误: 无法连接到数据库服务器 db01.internal:1433
操作名称: 数据库连接
错误分类: OperationStopped

重试与弹性模式

在生产环境中,瞬时故障(transient fault)是最常见的失败原因——网络闪断、数据库连接池耗尽、远程 API 限流等。这些故障通常在短暂等待后自行恢复。与其让脚本直接报错退出,不如实现自动重试机制,大幅提升脚本的成功率。

指数退避(Exponential Backoff)是最经典的重试策略:每次重试的等待时间按指数增长,避免在服务端压力最大时发起更多请求。结合断路器模式(Circuit Breaker),当连续失败次数超过阈值时直接跳过后续调用,可以防止无意义的重试浪费资源。

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
function Invoke-WithRetry {
[CmdletBinding()]
param(
[scriptblock]$ScriptBlock,
[string]$OperationName = "Operation",
[int]$MaxRetries = 3,
[int]$BaseDelaySeconds = 2,
[double]$BackoffMultiplier = 2.0,
[int]$MaxDelaySeconds = 60,
[int]$CircuitBreakerThreshold = 5
)

# 断路器状态(脚本作用域)
if (-not (Get-Variable -Name '__CircuitBreakerState' -Scope Script -ErrorAction SilentlyContinue)) {
$script:__CircuitBreakerState = @{}
}

$cbKey = $OperationName
if (-not $script:__CircuitBreakerState.ContainsKey($cbKey)) {
$script:__CircuitBreakerState[$cbKey] = @{
FailureCount = 0
IsOpen = $false
LastFailure = $null
}
}

$cb = $script:__CircuitBreakerState[$cbKey]

# 检查断路器状态
if ($cb.IsOpen) {
$timeSinceFailure = (Get-Date) - $cb.LastFailure
if ($timeSinceFailure.TotalSeconds -lt 300) {
throw "[断路器] 操作 '$OperationName' 的断路器已断开(连续失败 $($cb.FailureCount) 次)," +
"将在 $([int](300 - $timeSinceFailure.TotalSeconds)) 秒后重置。"
}
else {
Write-Verbose "[断路器] 冷却时间已过,半开状态,允许尝试"
$cb.IsOpen = $false
}
}

$attempt = 0
$lastError = $null

while ($attempt -lt $MaxRetries) {
$attempt++
try {
Write-Verbose "[$OperationName] 第 $attempt/$MaxRetries 次尝试"
$result = & $ScriptBlock

# 成功则重置断路器
$cb.FailureCount = 0
$cb.IsOpen = $false
Write-Verbose "[$OperationName] 第 $attempt 次尝试成功"
return $result
}
catch {
$lastError = $_
$cb.FailureCount++
$cb.LastFailure = Get-Date

Write-Warning "[$OperationName] 第 $attempt/$MaxRetries 次尝试失败: $($_.Exception.Message)"

# 检查是否触发断路器
if ($cb.FailureCount -ge $CircuitBreakerThreshold) {
$cb.IsOpen = $true
Write-Warning "[断路器] 连续失败 $($cb.FailureCount) 次,断路器已断开"
}

if ($attempt -lt $MaxRetries) {
# 计算指数退避延迟
$delay = [Math]::Min(
$BaseDelaySeconds * [Math]::Pow($BackoffMultiplier, $attempt - 1),
$MaxDelaySeconds
)
# 添加随机抖动,防止多个客户端同时重试(雷群效应)
$jitter = Get-Random -Minimum 0 -Maximum ($delay * 0.3)
$totalDelay = $delay + $jitter

Write-Verbose "[$OperationName] 等待 $([Math]::Round($totalDelay, 1)) 秒后重试..."
Start-Sleep -Seconds $totalDelay
}
}
}

throw "[$OperationName] 达到最大重试次数 ($MaxRetries)。最后一次错误: $($lastError.Exception.Message)"
}

# 示例:模拟不稳定的服务调用
$attemptCounter = 0
$result = Invoke-WithRetry -OperationName "调用用户API" -MaxRetries 4 -Verbose {
$script:attemptCounter++
if ($script:attemptCounter -lt 3) {
throw "HTTP 503 - 服务暂时不可用 (第 $script:attemptCounter 次调用)"
}
return @{ Status = "OK"; Users = @("Alice", "Bob", "Charlie") }
}

Write-Host "最终结果: $($result | ConvertTo-Json -Compress)"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
详细: [调用用户API] 第 1/4 次尝试
警告: [调用用户API] 第 1/4 次尝试失败: HTTP 503 - 服务暂时不可用 (第 1 次调用)
详细: [调用用户API] 等待 2.4 秒后重试...
详细: [调用用户API] 第 2/4 次尝试
警告: [调用用户API] 第 2/4 次尝试失败: HTTP 503 - 服务暂时不可用 (第 2 次调用)
详细: [调用用户API] 等待 4.7 秒后重试...
详细: [调用用户API] 第 3/4 次尝试
详细: [调用用户API] 第 3 次尝试成功

最终结果: {"Status":"OK","Users":["Alice","Bob","Charlie"]}

结构化错误报告

当脚本在无人值守模式下运行(如定时任务、CI/CD 管道)时,错误信息的质量直接决定了排障效率。零散的 Write-Error 输出难以追溯上下文,也不便于后续的监控系统集成。我们需要一个统一的错误报告机制,将错误信息以结构化方式记录到日志文件,并可选地发送通知。

下面这个模式实现了完整的错误收集、日志记录和通知流程。它将所有错误汇总为 JSON 格式的报告文件,便于后续自动化分析,同时支持通过 Webhook 发送告警通知。

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

ErrorReport([string]$ScriptName) {
$this.ScriptName = $ScriptName
$this.StartTime = Get-Date
$this.Errors = [System.Collections.Generic.List[PSObject]]::new()
$this.Warnings = [System.Collections.Generic.List[string]]::new()
$this.Success = $true
}

[void] AddError([string]$Context, [System.Management.Automation.ErrorRecord]$ErrorRecord) {
$this.Success = $false
$this.Errors.Add([PSCustomObject]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Context = $Context
Message = $ErrorRecord.Exception.Message
Category = $ErrorRecord.CategoryInfo.Category.ToString()
ErrorId = $ErrorRecord.FullyQualifiedErrorId
Target = if ($ErrorRecord.TargetObject) { $ErrorRecord.TargetObject.ToString() } else { "N/A" }
StackTrace = $ErrorRecord.ScriptStackTrace
})
}

[void] AddWarning([string]$Message) {
$this.Warnings.Add("[$((Get-Date).ToString('HH:mm:ss'))] $Message")
}

[string] ToJson() {
$this.EndTime = Get-Date
$duration = ($this.EndTime - $this.StartTime).ToString('hh\:mm\:ss')
$report = [PSCustomObject]@{
ScriptName = $this.ScriptName
StartTime = $this.StartTime.ToString('yyyy-MM-dd HH:mm:ss')
EndTime = $this.EndTime.ToString('yyyy-MM-dd HH:mm:ss')
Duration = $duration
Success = $this.Success
ErrorCount = $this.Errors.Count
WarningCount = $this.Warnings.Count
Errors = $this.Errors
Warnings = $this.Warnings
}
return ($report | ConvertTo-Json -Depth 5)
}

[void] Save([string]$OutputPath) {
$json = $this.ToJson()
$dir = Split-Path -Parent $OutputPath
if ($dir -and -not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$json | Out-File -FilePath $OutputPath -Encoding UTF8
}

[void] SendWebhook([string]$WebhookUrl, [string]$Status) {
$color = if ($this.Success) { "36a64f" } else { "ff0000" }
$summary = if ($this.Success) {
"脚本 $($this.ScriptName) 执行成功"
} else {
"脚本 $($this.ScriptName) 执行失败,共 $($this.Errors.Count) 个错误"
}

$body = @{
text = $summary
} | ConvertTo-Json -Depth 3

try {
Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $body `
-ContentType "application/json" -TimeoutSec 10
}
catch {
Write-Warning "Webhook 通知发送失败: $($_.Exception.Message)"
}
}
}

# 使用示例:模拟一个批量运维任务
$report = [ErrorReport]::new("ServerMaintenance")

$report.AddWarning("磁盘清理将在非工作时间执行")

# 模拟处理多台服务器
$servers = @("web01", "web02", "db01", "cache01")
foreach ($server in $servers) {
try {
$ErrorActionPreference = 'Stop'
# 模拟远程操作(db01 模拟失败)
if ($server -eq "db01") {
throw "连接超时: $server 端口 5432 无响应"
}
$report.AddWarning("$server 操作完成")
}
catch {
$report.AddError("服务器维护 - $server", $_)
}
}

# 生成报告
$reportPath = Join-Path $env:TEMP "error-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$report.Save($reportPath)

Write-Host "=== 执行摘要 ==="
Write-Host "脚本: $($report.ScriptName)"
Write-Host "成功: $($report.Success)"
Write-Host "错误数: $($report.Errors.Count)"
Write-Host "警告数: $($report.Warnings.Count)"
Write-Host "报告已保存至: $reportPath"

执行结果示例:

1
2
3
4
5
6
=== 执行摘要 ===
脚本: ServerMaintenance
成功: False
错误数: 1
警告数: 5
报告已保存至: /tmp/error-report-20260218-080000.json

生成的 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
{
"ScriptName": "ServerMaintenance",
"StartTime": "2026-02-18 08:00:01",
"EndTime": "2026-02-18 08:00:02",
"Duration": "00:00:01",
"Success": false,
"ErrorCount": 1,
"WarningCount": 5,
"Errors": [
{
"Timestamp": "2026-02-18 08:00:02",
"Context": "服务器维护 - db01",
"Message": "连接超时: db01 端口 5432 无响应",
"Category": "OperationStopped",
"ErrorId": "MyError",
"Target": "N/A"
}
],
"Warnings": [
"[08:00:01] 磁盘清理将在非工作时间执行",
"[08:00:01] web01 操作完成",
"[08:00:01] web02 操作完成",
"[08:00:02] cache01 操作完成"
]
}

注意事项

  1. 理解错误类型差异try/catch 只能捕获终止性错误。对于非终止性错误(如文件未找到),必须配合 $ErrorActionPreference = 'Stop'-ErrorAction Stop 才能进入 catch 块。这是 PowerShell 错误处理中最常见的踩坑点。

  2. finally 块不可或缺:无论是否发生错误,finally 块都会执行。它适合用来释放资源(关闭数据库连接、删除临时文件)和恢复被修改的全局状态(如 $ErrorActionPreference),确保脚本不会因为异常退出而留下脏状态。

  3. 重试策略需要上限:指数退避的延迟时间必须设置上限(MaxDelaySeconds),否则在极端情况下等待时间会无限增长。同时,添加随机抖动(jitter)可以有效防止多个客户端在同一时刻同时重试,避免”雷群效应”。

  4. 断路器防止雪崩:当底层服务完全不可用时,断路器模式可以在连续失败达到阈值后直接跳过调用,而不是继续无意义地重试。设置一个冷却期(如 5 分钟),冷却过后进入”半开”状态允许一次试探性调用,成功则重置断路器。

  5. 错误报告要包含上下文:仅有错误消息是不够的。结构化报告应包含时间戳、操作名称、错误分类、调用栈和目标对象等信息。这些上下文能将排障时间从小时级缩短到分钟级,尤其在无人值守的定时任务场景中。

  6. Webhook 通知要处理自身失败:发送告警通知本身也可能失败(网络问题、URL 失效等)。通知失败不应导致脚本崩溃,应使用 try/catch 包裹并降级为本地日志记录。通知机制是锦上添花,不能喧宾夺主。

PowerShell 技能连载 - Azure Front Door 与 CDN 管理

适用于 PowerShell 7.0 及以上版本

Azure Front Door 是微软 Azure 平台上的云原生应用交付控制器(ADC),集成了全球 CDN、智能七层路由、Web 应用防火墙(WAF)和 SSL 卸载等核心能力。对于面向全球用户的 Web 应用来说,Front Door 能通过遍布全球的边缘节点,将用户请求自动路由到最近的后端实例,显著降低访问延迟并提升可用性。

在实际运维中,Front Door 的配置往往涉及多个层面:后端池定义、健康探测、路由规则、WAF 策略、缓存行为和自定义域名绑定。当环境数量增多(开发、预发布、生产)或者需要频繁调整规则时,纯手工在 Azure 门户中操作不仅效率低下,还容易因为人为疏漏导致配置漂移。

通过 PowerShell 的 Az.FrontDoor 模块,我们可以将 Front Door 的完整配置脚本化,实现基础设施即代码(IaC)的管理方式。本文将围绕三个核心场景展开:Front Door 配置管理、WAF 安全策略配置,以及缓存与性能优化,帮助你用 PowerShell 系统化地管理全球流量入口。

Front Door 配置管理

下面的脚本演示了如何创建 Front Door 配置文件,包括后端池定义、健康探测设置和路由规则的完整流程。每个步骤都通过 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
# 连接 Azure 并选择订阅
Connect-AzAccount -Subscription 'MySubscription'

# 定义变量
$resourceGroup = 'rg-frontdoor-demo'
$location = 'global'
$frontDoorName = 'fd-demo-001'

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

# 定义后端地址池
$backend1 = New-AzFrontDoorBackendObject `
-Address 'app-eastasia.azurewebsites.net' `
-HttpPort 80 `
-HttpsPort 443 `
-Priority 1 `
-Weight 50

$backend2 = New-AzFrontDoorBackendObject `
-Address 'app-eastus.azurewebsites.net' `
-HttpPort 80 `
-HttpsPort 443 `
-Priority 2 `
-Weight 50

$backendPool = New-AzFrontDoorBackendPoolObject `
-Name 'primaryPool' `
-FrontDoorName $frontDoorName `
-ResourceGroupName $resourceGroup `
-Backend $backend1, $backend2

# 定义健康探测
$healthProbe = New-AzFrontDoorHealthProbeSettingObject `
-Name 'appHealthProbe' `
-Path '/health' `
-Protocol Https `
-IntervalInSeconds 30

# 定义负载均衡设置
$loadBalancing = New-AzFrontDoorLoadBalancingSettingObject `
-Name 'appLoadBalancing' `
-SampleSize 4 `
-SuccessfulSamplesRequired 2

# 定义前端端点
$frontendEndpoint = New-AzFrontDoorFrontendEndpointObject `
-Name 'frontendEndpoint1' `
-HostName "$frontDoorName.azurefd.net"

# 定义路由规则
$routingRule = New-AzFrontDoorRoutingRuleObject `
-Name 'defaultRouting' `
-FrontDoorName $frontDoorName `
-ResourceGroupName $resourceGroup `
-FrontendEndpointName 'frontendEndpoint1' `
-BackendPoolName 'primaryPool' `
-AcceptedProtocol Https `
-PatternsToMatch '/*' `
-EnabledState Enabled

# 创建 Front Door 实例
$frontDoor = New-AzFrontDoor `
-ResourceGroupName $resourceGroup `
-Name $frontDoorName `
-BackendPool $backendPool `
-HealthProbeSetting $healthProbe `
-LoadBalancingSetting $loadBalancing `
-FrontendEndpoint $frontendEndpoint `
-RoutingRule $routingRule

Write-Host "Front Door 创建完成:$($frontDoor.Name)"
Write-Host "前端访问地址:https://$frontDoorName.azurefd.net"

执行结果示例:

1
2
3
4
5
6
7
8
9
Front Door 创建完成:fd-demo-001
前端访问地址:https://fd-demo-001.azurefd.net

ResourceGroupName : rg-frontdoor-demo
Name : fd-demo-001
Type : Microsoft.Network/frontdoors
Location : global
ProvisioningState : Succeeded
Cname : fd-demo-001.azurefd.net

可以看到,Front Door 的创建过程是声明式的:我们先定义后端池、健康探测和负载均衡策略等子资源,然后将它们组合成一个完整的 Front Door 实例。global 区域意味着 Front Door 会在全球所有边缘节点自动部署。

WAF 安全策略配置

Web 应用防火墙(WAF)是 Front Door 安全体系的核心组件。下面的脚本展示了如何创建 WAF 策略,配置自定义规则、托管规则集和速率限制,为应用提供多层安全防护。

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
# 定义 WAF 策略变量
$wafPolicyName = 'waf-demo-policy'
$resourceGroup = 'rg-frontdoor-demo'

# 创建自定义规则:阻断特定恶意 User-Agent
$customRule1 = New-AzFrontDoorWafCustomRuleObject `
-Name 'blockBadBots' `
-RuleType MatchRule `
-MatchCondition @{
MatchVariable = 'RequestHeader'
Selector = 'User-Agent'
OperatorProperty = 'Contains'
MatchValue = @('BadBot', 'Scraper', 'MaliciousCrawler')
NegateCondition = $false
} `
-Action Block `
-Priority 100

# 创建自定义规则:仅允许特定国家/地区访问
$customRule2 = New-AzFrontDoorWafCustomRuleObject `
-Name 'geoFilterRule' `
-RuleType MatchRule `
-MatchCondition @{
MatchVariable = 'SocketAddr'
OperatorProperty = 'GeoMatch'
MatchValue = @('CN', 'HK', 'TW', 'SG', 'JP')
NegateCondition = $true
} `
-Action Block `
-Priority 200

# 创建速率限制规则:防止暴力破解
$rateLimitRule = New-AzFrontDoorWafCustomRuleObject `
-Name 'rateLimitLogin' `
-RuleType RateLimitRule `
-MatchCondition @{
MatchVariable = 'RequestUri'
OperatorProperty = 'Contains'
MatchValue = @('/api/login', '/api/auth')
} `
-RateLimitDurationInMinutes 1 `
-RateLimitThreshold 20 `
-Action Block `
-Priority 300

# 创建托管规则集(OWASP Top 10 防护)
$managedRuleSet = New-AzFrontDoorWafManagedRuleObject `
-Type 'Microsoft_DefaultRuleSet' `
-Version '2.1'

$botRuleSet = New-AzFrontDoorWafManagedRuleObject `
-Type 'Microsoft_BotManagerRuleSet' `
-Version '1.0'

# 组合创建 WAF 策略
$wafPolicy = New-AzFrontDoorWafPolicy `
-ResourceGroupName $resourceGroup `
-Name $wafPolicyName `
-CustomRule $customRule1, $customRule2, $rateLimitRule `
-ManagedRule $managedRuleSet, $botRuleSet `
-EnabledState Enabled `
-Mode Prevention

Write-Host "WAF 策略创建完成:$($wafPolicy.Name)"
Write-Host "运行模式:$($wafPolicy.Mode)"
Write-Host "自定义规则数量:$($wafPolicy.CustomRules.Count)"
Write-Host "托管规则集数量:$($wafPolicy.ManagedRules.Count)"

# 将 WAF 策略关联到 Front Door 前端端点
$frontDoorName = 'fd-demo-001'
$frontendEndpoint = Get-AzFrontDoorFrontendEndpoint `
-ResourceGroupName $resourceGroup `
-FrontDoorName $frontDoorName `
-Name 'frontendEndpoint1'

# 更新前端端点以关联 WAF 策略
$wafPolicyId = $wafPolicy.Id
Update-AzFrontDoorFrontendEndpoint `
-ResourceGroupName $resourceGroup `
-FrontDoorName $frontDoorName `
-Name 'frontendEndpoint1' `
-WebApplicationFirewallPolicyLink $wafPolicyId

Write-Host "WAF 策略已关联到前端端点"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
WAF 策略创建完成:waf-demo-policy
运行模式:Prevention
自定义规则数量:3
托管规则集数量:2
WAF 策略已关联到前端端点

PolicyId : /subscriptions/xxxx/resourceGroups/rg-frontdoor-demo/providers/Microsoft.Network/frontdoorwebappfirewallpolicies/waf-demo-policy
EnabledState : Enabled
Mode : Prevention
CustomRules : {blockBadBots, geoFilterRule, rateLimitLogin}
ManagedRules : {Microsoft_DefaultRuleSet-2.1, Microsoft_BotManagerRuleSet-1.0}

WAF 策略的三层防护各司其职:自定义规则处理业务特定逻辑(如地域访问控制),速率限制防御暴力攻击,托管规则集则覆盖 OWASP Top 10 等常见攻击模式。Prevention 模式会直接阻断恶意请求,调试阶段可以切换为 Detection 仅记录日志。

缓存与性能优化

合理的缓存策略能大幅减少后端负载,同时提升用户访问速度。下面的脚本展示了如何配置缓存规则、启用压缩,以及生成流量分析报告。

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
# 定义变量
$resourceGroup = 'rg-frontdoor-demo'
$frontDoorName = 'fd-demo-001'

# 获取当前 Front Door 配置
$frontDoor = Get-AzFrontDoor `
-ResourceGroupName $resourceGroup `
-Name $frontDoorName

# 创建带缓存的路由规则(静态资源)
$cachedBackendPool = $frontDoor.BackendPools[0]
$cachedFrontend = $frontDoor.FrontendEndpoints[0]

# 更新路由规则,添加缓存配置
$routingRuleWithCache = New-AzFrontDoorRoutingRuleObject `
-Name 'staticAssetsRouting' `
-FrontDoorName $frontDoorName `
-ResourceGroupName $resourceGroup `
-FrontendEndpointName $cachedFrontend.Name `
-BackendPoolName $cachedBackendPool.Name `
-AcceptedProtocol Http, Https `
-PatternsToMatch '/static/*', '/images/*', '/css/*', '/js/*' `
-EnabledState Enabled `
-DefaultCacheBehavior `
-CacheDuration 'P30D' `
-ForwardingProtocol HttpsOnly `
-CacheKeyQueryParameterStripAll

# 创建 API 路径的动态缓存规则
$apiCacheRule = New-AzFrontDoorRoutingRuleObject `
-Name 'apiCacheRouting' `
-FrontDoorName $frontDoorName `
-ResourceGroupName $resourceGroup `
-FrontendEndpointName $cachedFrontend.Name `
-BackendPoolName $cachedBackendPool.Name `
-AcceptedProtocol Https `
-PatternsToMatch '/api/products/*', '/api/catalog/*' `
-EnabledState Enabled `
-DefaultCacheBehavior `
-CacheDuration 'PT5M' `
-ForwardingProtocol HttpsOnly

# 查看当前 Front Door 的流量统计
$endTime = Get-Date
$startTime = $endTime.AddDays(-7)

# 获取 Front Door 的指标数据
$metrics = Get-AzMetric `
-ResourceId $frontDoor.Id `
-MetricName 'RequestCount', 'BackendRequestLatency', 'CacheHitRatio' `
-StartTime $startTime `
-EndTime $endTime `
-TimeGrain 'PT1H' `
-AggregationType Total, Average

# 生成流量分析报告
$report = [PSCustomObject]@{
时间范围 = "$startTime ~ $endTime"
总请求数 = ($metrics | Where-Object { $_.Name -eq 'RequestCount' } |
ForEach-Object { $_.Timeseries.Data.TimeseriesDataValues } |
Measure-Object -Property Total -Sum).Sum
平均延迟ms = [math]::Round(
($metrics | Where-Object { $_.Name -eq 'BackendRequestLatency' } |
ForEach-Object { $_.Timeseries.Data.TimeseriesDataValues } |
Measure-Object -Property Average -Average).Average, 2)
缓存命中率pct = [math]::Round(
($metrics | Where-Object { $_.Name -eq 'CacheHitRatio' } |
ForEach-Object { $_.Timeseries.Data.TimeseriesDataValues } |
Measure-Object -Property Average -Average).Average * 100, 2)
}

Write-Host "`n=== Front Door 周报 ==="
$report | Format-List

# 列出所有路由规则及缓存状态
Write-Host "`n=== 当前路由规则 ==="
foreach ($rule in $frontDoor.RoutingRules) {
$cacheStatus = if ($rule.Properties.RoutingRuleProperties.CacheConfiguration) {
"已启用 (有效期: $($rule.Properties.RoutingRuleProperties.CacheConfiguration.CacheDuration))"
} else {
'未启用'
}
[PSCustomObject]@{
规则名称 = $rule.Name
匹配路径 = ($rule.Properties.RoutingRuleProperties.PatternsToMatch -join ', ')
缓存状态 = $cacheStatus
协议 = $rule.Properties.RoutingRuleProperties.AcceptedProtocols -join ', '
} | Format-Table -AutoSize
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=== Front Door 周报 ===

时间范围 : 2026-02-10 00:00:00 ~ 2026-02-17 00:00:00
总请求数 : 1284350
平均延迟ms : 23.67
缓存命中率pct : 87.43

=== 当前路由规则 ===

规则名称 匹配路径 缓存状态 协议
-------- -------- -------- ----
defaultRouting /* 未启用 Https
staticAssetsRouting /static/*, /images/*, /css/*, /js/* 已启用 (有效期: P30D) Http, Https
apiCacheRouting /api/products/*, /api/catalog/* 已启用 (有效期: PT5M) Https

从报告中可以看到,静态资源缓存 30 天能覆盖绝大部分场景,而 API 数据用 5 分钟短缓存可以在数据新鲜度和性能之间取得平衡。缓存命中率 87% 意味着绝大部分请求在边缘节点就已响应,无需回源。

注意事项

  1. Front Door Standard/Premium 与经典版差异:经典版使用 Az.FrontDoor 模块,而 Standard/Premium 版使用 Az.Cdn 模块(对应 Microsoft.CDN/profiles 资源)。迁移前需确认当前使用的版本,两者的 cmdlet 和配置结构完全不同。

  2. WAF 策略的 Detection 模式调试:上线新规则前,建议先将 WAF 切换为 Detection 模式运行 3-7 天,观察日志中的误报情况。避免直接启用 Prevention 模式导致合法流量被阻断。

  3. 缓存键的查询参数处理CacheKeyQueryParameterStripAll 会忽略所有查询参数,即 /api/data?a=1/api/data?b=2 返回同一份缓存。如果 API 返回内容依赖查询参数(如分页、搜索),需要改用白名单模式。

  4. 后端健康探测间隔不宜过短IntervalInSeconds 设为 30 秒是推荐值,低于 10 秒会增加后端负载,尤其是后端为 Serverless 或按调用计费的服务时,探测流量本身会产生额外费用。

  5. SSL 证书的自动续期:Front Door 托管的 .azurefd.net 域名自带 SSL,但自定义域名需要绑定证书。建议使用 Azure Key Vault 管理证书并启用自动轮换,避免证书过期导致服务中断。

  6. 配置变更的传播延迟:Front Door 的配置变更需要在全球边缘节点传播,通常需要 5-10 分钟生效。自动化脚本中应加入等待逻辑(如轮询 ProvisioningState),不要假设配置立即生效。

PowerShell 技能连载 - Kubernetes 管理自动化

适用于 PowerShell 7.0 及以上版本

Kubernetes 已成为容器编排的事实标准,几乎所有的云原生应用都运行在 K8s 集群之上。虽然 kubectl 是日常操作的主要命令行工具,但在企业自动化场景中,运维团队往往需要将 Kubernetes 操作集成到更大规模的工作流里——比如批量部署微服务、定期巡检集群健康状态、自动化 Helm Release 管理、以及跨集群的配置同步。纯靠手敲 kubectl 命令既容易出错,也无法做到可重复、可审计。

PowerShell 凭借强大的对象管道和脚本编排能力,是构建 K8s 自动化工作流的理想胶水语言。通过调用 kubectl CLI 并解析其 JSON 输出,PowerShell 可以将集群资源管理、应用部署、健康巡检等操作封装为结构化的脚本模块。结合 .NET 的 Kubernetes 客户端 SDK,还能实现更细粒度的 API 交互。

本文将通过三个实战场景展示如何用 PowerShell 实现 Kubernetes 管理自动化:集群连接与资源管理、部署自动化、以及运维巡检工具集。每个场景都提供了可运行的脚本模板,帮助你快速搭建自己的 K8s 运维工具箱。

集群连接与资源管理

管理多个 Kubernetes 集群时,频繁切换上下文是日常操作。下面的脚本封装了 kubeconfig 上下文切换、资源查询和状态汇总功能,让你在 PowerShell 中高效管理多个集群的 Pod、Deployment 和 Service 资源。

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
# K8s 集群管理辅助函数集

function Get-K8sContexts {
<# 获取所有可用的 K8s 上下文 #>
$Raw = kubectl config get-contexts -o name 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Error "无法获取 K8s 上下文,请确认 kubeconfig 已配置"
return @()
}
return $Raw | Where-Object { $_.Trim() }
}

function Switch-K8sContext {
param([Parameter(Mandatory)][string]$ContextName)
kubectl config use-context $ContextName 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host "已切换到上下文: $ContextName" -ForegroundColor Green
} else {
Write-Error "切换上下文失败: $ContextName"
}
}

function Get-K8sResourceSummary {
param(
[string]$Namespace = 'default',
[string]$Context
)

if ($Context) { Switch-K8sContext $Context }

# 查询 Pod 状态汇总
$Pods = kubectl get pods -n $Namespace -o json 2>$null |
ConvertFrom-Json

$PodSummary = $Pods.items | Group-Object status.phase |
Select-Object @{N='Phase'; E={$_.Name}}, Count

# 查询 Deployment 状态
$Deployments = kubectl get deployments -n $Namespace -o json 2>$null |
ConvertFrom-Json

$DeployStatus = $Deployments.items | ForEach-Object {
$Replicas = $_.status.replicas ?? 0
$Ready = $_.status.readyReplicas ?? 0
$Updated = $_.status.updatedReplicas ?? 0
[PSCustomObject]@{
Name = $_.metadata.name
Replicas = $Replicas
Ready = $Ready
Updated = $Updated
Available = $_.status.availableReplicas ?? 0
Status = if ($Ready -eq $Replicas -and $Replicas -gt 0) { 'Healthy' } else { 'Degraded' }
}
}

# 查询 Service 端点
$Services = kubectl get services -n $Namespace -o json 2>$null |
ConvertFrom-Json

$SvcInfo = $Services.items | ForEach-Object {
$Ports = ($_.spec.ports | ForEach-Object { "$($_.port):$($_.targetPort)/$($_.protocol)" }) -join ', '
[PSCustomObject]@{
Name = $_.metadata.name
Type = $_.spec.type
Ports = $Ports
IP = if ($_.spec.type -eq 'LoadBalancer') {
$_.status.loadBalancer.ingress[0].ip ?? 'Pending'
} else {
$_.spec.clusterIP
}
}
}

Write-Host "`n=== Pod 状态汇总 (Namespace: $Namespace) ===" -ForegroundColor Cyan
$PodSummary | Format-Table -AutoSize

Write-Host "=== Deployment 状态 ===" -ForegroundColor Cyan
$DeployStatus | Format-Table -AutoSize

Write-Host "=== Service 列表 ===" -ForegroundColor Cyan
$SvcInfo | Format-Table -AutoSize
}

# 使用示例
Write-Host "当前可用上下文:" -ForegroundColor Yellow
Get-K8sContexts

# 切换到生产集群并查看资源概况
Get-K8sResourceSummary -Namespace 'production' -Context 'prod-cluster'

执行结果示例:

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
当前可用上下文:
prod-cluster
staging-cluster
dev-cluster

已切换到上下文: prod-cluster

=== Pod 状态汇总 (Namespace: production) ===
Phase Count
----- -----
Running 24
Pending 2
Succeeded 5

=== Deployment 状态 ===
Name Replicas Ready Updated Available Status
---- -------- ----- ------- --------- ------
api-gateway 3 3 3 3 Healthy
user-service 2 2 2 2 Healthy
order-service 3 2 3 2 Degraded
payment-service 2 2 2 2 Healthy

=== Service 列表 ===
Name Type Ports IP
---- ---- ----- --
api-gateway LoadBalancer 80:8080/TCP 203.0.113.50
user-service ClusterIP 8080:8080/TCP 10.96.0.10
order-service ClusterIP 8080:8080/TCP 10.96.0.20
payment-service ClusterIP 8443:443/TCP 10.96.0.30

部署自动化

手动执行 kubectl applykubectl rollout 在管理少量应用时尚可应对,但当微服务数量超过几十个时,就需要自动化部署流水线。下面的脚本演示了如何用 PowerShell 生成部署 YAML、执行滚动更新、监控发布状态、以及在出现问题时快速回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# K8s 部署自动化工具

function New-K8sDeploymentManifest {
param(
[Parameter(Mandatory)][string]$AppName,
[Parameter(Mandatory)][string]$Image,
[int]$Replicas = 2,
[int]$Port = 8080,
[hashtable]$Labels = @{},
[string]$Namespace = 'default'
)

$AllLabels = @{ app = $AppName } + $Labels

$Manifest = @{
apiVersion = 'apps/v1'
kind = 'Deployment'
metadata = @{
name = $AppName
namespace = $Namespace
labels = $AllLabels
}
spec = @{
replicas = $Replicas
selector = @{ matchLabels = @{ app = $AppName } }
template = @{
metadata = @{ labels = $AllLabels }
spec = @{
containers = @(
@{
name = $AppName
image = $Image
ports = @(@{ containerPort = $Port })
resources = @{
requests = @{ cpu = '100m'; memory = '128Mi' }
limits = @{ cpu = '500m'; memory = '512Mi' }
}
readinessProbe = @{
httpGet = @{ path = '/health'; port = $Port }
initialDelaySeconds = 5
periodSeconds = 10
}
livenessProbe = @{
httpGet = @{ path = '/health'; port = $Port }
initialDelaySeconds = 15
periodSeconds = 20
}
}
)
}
}
}
}

return $Manifest
}

function Start-K8sRollingUpdate {
param(
[Parameter(Mandatory)][string]$AppName,
[Parameter(Mandatory)][string]$NewImage,
[string]$Namespace = 'default',
[int]$TimeoutSeconds = 300
)

Write-Host "开始滚动更新: $AppName -> $NewImage" -ForegroundColor Cyan

# 设置新镜像
kubectl set image "deployment/$AppName" `
"$AppName=$NewImage" -n $Namespace 2>$null | Out-Null

if ($LASTEXITCODE -ne 0) {
Write-Error "设置镜像失败"
return $false
}

# 等待滚动更新完成
Write-Host "等待滚动更新完成 (超时: ${TimeoutSeconds}s)..." -ForegroundColor Yellow
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)

while ((Get-Date) -lt $Deadline) {
$Status = kubectl rollout status "deployment/$AppName" `
-n $Namespace --timeout=30s 2>&1

if ($LASTEXITCODE -eq 0) {
Write-Host "滚动更新成功: $AppName" -ForegroundColor Green
return $true
}

# 显示当前 Pod 状态
$Pods = kubectl get pods -n $Namespace -l "app=$AppName" -o json 2>$null |
ConvertFrom-Json

$Pods.items | ForEach-Object {
$Phase = $_.status.phase
$Containers = $_.status.containerStatuses
$Ready = ($Containers | Where-Object { $_.ready }).Count
$Total = $Containers.Count
Write-Host " Pod: $($_.metadata.name) | Phase: $Phase | Ready: $Ready/$Total"
}

Start-Sleep -Seconds 5
}

Write-Warning "滚动更新超时,准备回滚..."
Undo-K8sRollout -AppName $AppName -Namespace $Namespace
return $false
}

function Undo-K8sRollout {
param(
[Parameter(Mandatory)][string]$AppName,
[string]$Namespace = 'default',
[int]$Revision = 0
)

if ($Revision -eq 0) {
Write-Host "回滚到上一版本: $AppName" -ForegroundColor Yellow
kubectl rollout undo "deployment/$AppName" -n $Namespace 2>$null
} else {
Write-Host "回滚到修订版本 $Revision: $AppName" -ForegroundColor Yellow
kubectl rollout undo "deployment/$AppName" -n $Namespace --to-revision=$Revision 2>$null
}

if ($LASTEXITCODE -eq 0) {
Write-Host "回滚成功" -ForegroundColor Green
# 查看部署历史
kubectl rollout history "deployment/$AppName" -n $Namespace
} else {
Write-Error "回滚失败"
}
}

# 生成部署清单并应用
$Manifest = New-K8sDeploymentManifest `
-AppName 'web-frontend' `
-Image 'registry.example.com/web-frontend:v2.3.0' `
-Replicas 3 `
-Port 8080 `
-Labels @{ tier = 'frontend'; env = 'production' } `
-Namespace 'production'

$YamlPath = '/tmp/web-frontend-deployment.yaml'
$Manifest | ConvertTo-Json -Depth 10 | Set-Content $YamlPath
kubectl apply -f $YamlPath

# 执行滚动更新
Start-K8sRollingUpdate `
-AppName 'web-frontend' `
-NewImage 'registry.example.com/web-frontend:v2.4.0' `
-Namespace 'production' `
-TimeoutSeconds 300

执行结果示例:

1
2
3
4
5
6
7
8
开始滚动更新: web-frontend -> registry.example.com/web-frontend:v2.4.0
等待滚动更新完成 (超时: 300s)...
Pod: web-frontend-7d9b8f6c4d-abc12 | Phase: Running | Ready: 1/1
Pod: web-frontend-7d9b8f6c4d-def34 | Phase: Running | Ready: 1/1
Pod: web-frontend-8a2c3e7f5b-ghi56 | Phase: ContainerCreating | Ready: 0/1
Pod: web-frontend-8a2c3e7f5b-jkl78 | Phase: Running | Ready: 1/1
Pod: web-frontend-8a2c3e7f5b-mno90 | Phase: Running | Ready: 1/1
滚动更新成功: web-frontend

运维巡检工具集

Kubernetes 集群的日常运维需要定期检查节点健康、资源水位、异常 Pod 和事件告警。下面是一套完整的巡检脚本,可以一次性生成集群健康报告,适合集成到定时任务或 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
# K8s 集群运维巡检工具集

function Invoke-K8sClusterHealthCheck {
param(
[string]$Context,
[string]$OutputPath = "./k8s-health-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').txt"
)

if ($Context) { Switch-K8sContext $Context }

$Report = [System.Text.StringBuilder]::new()
$null = $Report.AppendLine("=" * 60)
$null = $Report.AppendLine("K8s 集群健康巡检报告 - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $Report.AppendLine("=" * 60)

# 1. 节点状态检查
$null = $Report.AppendLine("`n--- 节点状态 ---")
$Nodes = kubectl get nodes -o json 2>$null | ConvertFrom-Json

foreach ($Node in $Nodes.items) {
$Name = $Node.metadata.name
$Conditions = $Node.status.conditions
$Ready = ($Conditions | Where-Object { $_.type -eq 'Ready' }).status -eq 'True'
$MemoryPressure = ($Conditions | Where-Object { $_.type -eq 'MemoryPressure' }).status -eq 'True'
$DiskPressure = ($Conditions | Where-Object { $_.type -eq 'DiskPressure' }).status -eq 'True'

$StatusIcon = if ($Ready -and -not $MemoryPressure -and -not $DiskPressure) { 'OK' } else { 'WARN' }
$null = $Report.AppendLine("[$StatusIcon] $Name | Ready: $Ready | MemoryPressure: $MemoryPressure | DiskPressure: $DiskPressure")

# 节点资源使用
$Allocatable = $Node.status.allocatable
$null = $Report.AppendLine(" CPU: $($Allocatable.cpu) | Memory: $($Allocatable.memory)")
}

# 2. 异常 Pod 扫描(所有命名空间)
$null = $Report.AppendLine("`n--- 异常 Pod ---")
$AllPods = kubectl get pods -A -o json 2>$null | ConvertFrom-Json

$UnhealthyPods = $AllPods.items | Where-Object {
$_.status.phase -notin @('Running', 'Succeeded') -or
($_.status.containerStatuses | Where-Object { $_.restartCount -gt 5 }).Count -gt 0
}

if ($UnhealthyPods) {
foreach ($Pod in $UnhealthyPods) {
$Ns = $Pod.metadata.namespace
$Name = $Pod.metadata.name
$Phase = $Pod.status.phase
$Restarts = ($Pod.status.containerStatuses | Measure-Object -Property restartCount -Sum).Sum
$null = $Report.AppendLine(" [ALERT] $Ns/$Name | Phase: $Phase | Restarts: $Restarts")
}
} else {
$null = $Report.AppendLine(" 所有 Pod 运行正常")
}

# 3. 资源使用报告(通过 metrics-server)
$null = $Report.AppendLine("`n--- 资源使用 Top 10 ---")
$TopPods = kubectl top pods -A --sort-by=memory --no-headers 2>$null

if ($LASTEXITCODE -eq 0) {
$Rank = 1
$TopPods | Select-Object -First 10 | ForEach-Object {
$null = $Report.AppendLine(" #$Rank $_")
$Rank++
}
} else {
$null = $Report.AppendLine(" metrics-server 未安装或不可用,跳过资源使用统计")
}

# 4. 最近事件告警
$null = $Report.AppendLine("`n--- 最近告警事件 (Warning) ---")
$Events = kubectl get events -A --field-selector type=Warning -o json 2>$null |
ConvertFrom-Json

$RecentWarnings = $Events.items |
Sort-Object { [datetime]$_.lastTimestamp } -Descending |
Select-Object -First 10

foreach ($Evt in $RecentWarnings) {
$Time = $Evt.lastTimestamp
$Ns = $Evt.metadata.namespace
$Msg = $Evt.message
$Involved = "$($Evt.involvedObject.kind)/$($Evt.involvedObject.name)"
$null = $Report.AppendLine(" [$Time] $Ns/$Involved - $Msg")
}

# 输出报告
$ReportContent = $Report.ToString()
$ReportContent | Set-Content $OutputPath -Encoding UTF8
Write-Host $ReportContent
Write-Host "`n报告已保存到: $OutputPath" -ForegroundColor Green

# 返回摘要对象,便于后续自动化处理
return [PSCustomObject]@{
TotalNodes = $Nodes.items.Count
UnhealthyPods = $UnhealthyPods.Count
WarningEvents = $RecentWarnings.Count
ReportPath = $OutputPath
}
}

# 执行巡检
$HealthResult = Invoke-K8sClusterHealthCheck -Context 'prod-cluster'

# 根据巡检结果触发告警
if ($HealthResult.UnhealthyPods -gt 0 -or $HealthResult.WarningEvents -gt 5) {
$AlertMsg = "K8s 巡检告警: 异常Pod=$($HealthResult.UnhealthyPods), 告警事件=$($HealthResult.WarningEvents)"
Write-Host $AlertMsg -ForegroundColor Red
# 可在此处接入钉钉、飞书、Slack 等通知渠道
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
============================================================
K8s 集群健康巡检报告 - 2026-02-13 09:30:00
============================================================

--- 节点状态 ---
[OK] k8s-node-01 | Ready: True | MemoryPressure: False | DiskPressure: False
CPU: 8 | Memory: 32762308Ki
[OK] k8s-node-02 | Ready: True | MemoryPressure: False | DiskPressure: False
CPU: 8 | Memory: 32762308Ki
[WARN] k8s-node-03 | Ready: True | MemoryPressure: True | DiskPressure: False
CPU: 8 | Memory: 32762308Ki

--- 异常 Pod ---
[ALERT] production/order-service-6b8d4f-x2k9l | Phase: CrashLoopBackOff | Restarts: 17
[ALERT] staging/api-gateway-5c7a2e-m4n7p | Phase: Pending | Restarts: 0

--- 资源使用 Top 10 ---
#1 production/redis-cache-0 512Mi 250m
#2 production/elasticsearch-0 480Mi 350m
#3 production/order-service-7d9b 256Mi 150m
#4 production/user-service-8a2c 128Mi 80m
#5 monitoring/prometheus-0 380Mi 200m

--- 最近告警事件 (Warning) ---
[2026-02-13T09:28:00Z] production/Pod/order-service-6b8d4f-x2k9l - Back-off restarting failed container
[2026-02-13T09:25:00Z] staging/Pod/api-gateway-5c7a2e-m4n7p - Insufficient cpu (3) to schedule pod
[2026-02-13T09:20:00Z] production/Node/k8s-node-03 - Node is experiencing memory pressure

报告已保存到: ./k8s-health-report-20260213-093000.txt

注意事项

  1. kubectl 前置依赖:所有脚本都依赖 kubectl 命令行工具,运行前需确保已安装并与目标集群版本兼容(建议客户端版本不低于集群版本的 1 个小版本)。可通过 kubectl version --client 检查客户端版本,集群端需网络可达且 kubeconfig 配置正确。

  2. JSON 输出解析:脚本中大量使用 kubectl -o json 配合 ConvertFrom-Json 解析 K8s 资源。当集群资源量非常大(例如上万 Pod)时,JSON 反序列化可能消耗较多内存。建议在大型集群中结合 -l 标签选择器或 --field-selector 缩小查询范围。

  3. 滚动更新超时策略Start-K8sRollingUpdate 中的超时时间应根据应用启动速度合理设置。Java 等慢启动应用可能需要 5-10 分钟才能通过就绪探针检查,而 Go/Node.js 应用通常在 30 秒内就绪。超时时间过短会导致误判失败并触发不必要的回滚。

  4. metrics-server 部署:资源使用统计功能依赖 metrics-server 组件,部分托管集群(如 EKS、GKE)默认安装,但自建集群需要手动部署。如果巡检脚本中 kubectl top 命令返回错误,请先通过 kubectl apply -f 部署 metrics-server 清单。

  5. 命名空间与权限控制:脚本中的 Get-K8sResourceSummary 默认只查询指定命名空间。在 RBAC 严格的生产集群中,ServiceAccount 可能只被授权访问部分命名空间。建议为巡检脚本创建专用的 ServiceAccount 和 ClusterRole,仅授予只读权限(getlistwatch),避免使用高权限账户运行自动化脚本。

  6. kubeconfig 安全管理:多集群环境下,kubeconfig 文件中包含各集群的认证凭据(证书或 Token)。切勿将 kubeconfig 提交到代码仓库,应通过安全的密钥管理方案(如 HashiCorp Vault、Azure Key Vault)分发凭据,并定期轮换 ServiceAccount Token。

PowerShell 技能连载 - 流式处理与管道优化

适用于 PowerShell 7.0 及以上版本

管道的代价与机遇

PowerShell 的管道(Pipeline)是其最具标志性的设计之一。不同于传统 Shell 将文本在进程间传递,PowerShell 管道传递的是完整的 .NET 对象,这意味着每条命令的输出可以携带类型信息、方法和属性。然而,这种强大能力的背后隐藏着性能陷阱:当数据量从几百条增长到几十万条时,不当的管道用法可能导致内存飙升、执行时间倍增,甚至脚本完全失去响应。

理解管道的内部工作原理是写出高效脚本的前提。PowerShell 使用延迟枚举(Deferred Enumeration)机制,理论上可以逐条处理数据而不必将其全部加载到内存。但在实际编写中,很多常见写法会无意间打破这种流式特性,将整个数据集一次性收集到内存中。本文将从性能对比、流式处理技巧和高级管道模式三个维度,帮助你掌握管道优化的核心方法。

管道性能对比:数组累加 vs 管道 vs List

下面的脚本用三种方式完成相同的任务——生成 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
48
49
50
51
52
53
54
# 三种方式处理 10 万条数据的性能对比
$count = 100000

# 方式一:数组累加(+=)——每次都创建新数组,性能最差
$result1 = @()
$sw1 = [System.Diagnostics.Stopwatch]::StartNew()
for ($i = 1; $i -le $count; $i++) {
if ($i % 2 -eq 0) {
$result1 += "Item-$i"
}
}
$sw1.Stop()
$time1 = $sw1.Elapsed.TotalMilliseconds
$mem1 = [System.GC]::GetTotalMemory($true)

# 方式二:管道 + Where-Object —— 延迟枚举,内存友好
$sw2 = [System.Diagnostics.Stopwatch]::StartNew()
$result2 = 1..$count | Where-Object { $_ % 2 -eq 0 } | ForEach-Object { "Item-$_" }
$sw2.Stop()
$time2 = $sw2.Elapsed.TotalMilliseconds
$mem2 = [System.GC]::GetTotalMemory($true)

# 方式三:List<T> + foreach —— 最佳性能
$list = [System.Collections.Generic.List[string]]::new()
$sw3 = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($i in 1..$count) {
if ($i % 2 -eq 0) {
$list.Add("Item-$i")
}
}
$sw3.Stop()
$time3 = $sw3.Elapsed.TotalMilliseconds
$mem3 = [System.GC]::GetTotalMemory($true)

# 输出对比结果
[PSCustomObject]@{
'方式' = '数组累加 (+=)'
'耗时(ms)' = [math]::Round($time1, 2)
'结果数量' = $result1.Count
} | Format-Table -AutoSize

[PSCustomObject]@{
'方式' = '管道 Where-Object'
'耗时(ms)' = [math]::Round($time2, 2)
'结果数量' = $result2.Count
} | Format-Table -AutoSize

[PSCustomObject]@{
'方式' = 'List<T> + foreach'
'耗时(ms)' = [math]::Round($time3, 2)
'结果数量' = $list.Count
} | Format-Table -AutoSize

Write-Host "`n结论: List<T> + foreach 最快,管道适中,数组累加最慢。"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
方式            耗时(ms)  结果数量
---- -------- --------
数组累加 (+=) 28456.73 50000

方式 耗时(ms) 结果数量
---- -------- --------
管道 Where-Object 1250.38 50000

方式 耗时(ms) 结果数量
---- -------- --------
List<T> + foreach 186.52 50000

结论: List<T> + foreach 最快,管道适中,数组累加最慢。

从结果可以看出,数组累加的方式由于每次 += 操作都会创建一个全新的数组并拷贝所有已有元素,时间复杂度呈 O(n^2) 增长,在数据量较大时性能急剧恶化。管道方式虽然引入了命令调用的开销,但利用了延迟枚举机制,内存占用可控。而 List<T> 配合 foreach 循环则是性能最优的选择,适合对性能要求极高的场景。

流式处理技巧:逐行处理大文件

在处理大型日志文件、CSV 导出或海量数据集时,流式处理(Streaming)能够显著降低内存峰值。核心思路是让数据在管道中逐条流动,而不是先将所有数据收集到一个大数组中再统一处理。

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
# 流式处理大文件:逐行读取、过滤、输出,内存占用恒定
$logfile = "/var/log/system.log"

# 错误写法:一次性读取全部内容,文件很大时内存暴涨
# $allLines = Get-Content $logfile # 全部加载到内存
# $errors = $allLines | Where-Object { $_ -match 'ERROR' }

# 正确写法:使用 -ReadCount 0 让管道逐行流式处理
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$errorCount = 0
$uniqueModules = [System.Collections.Generic.HashSet[string]]::new()

Get-Content $logfile -ReadCount 0 |
Where-Object { $_ -match '\[ERROR\]' } |
ForEach-Object {
$errorCount++
if ($_ -match '\[(\w+)\]') {
$null = $uniqueModules.Add($Matches[1])
}
# 只输出前 5 条作为预览
if ($errorCount -le 5) {
$_
}
}

$sw.Stop()
Write-Host "`n--- 统计结果 ---"
Write-Host "扫描耗时: $($sw.Elapsed.TotalSeconds.ToString('F2')) 秒"
Write-Host "错误总数: $errorCount"
Write-Host "涉及模块: $($uniqueModules.Count) 个"
Write-Host "模块列表: $($uniqueModules -join ', ')"

# 流式处理 CSV 文件的技巧:使用 Import-Csv 配合管道
Write-Host "`n--- 流式 CSV 处理示例 ---"
$csvData = @"
Name,Department,Salary
Alice,Engineering,15000
Bob,Marketing,12000
Charlie,Engineering,18000
Diana,Marketing,11000
Eve,Engineering,20000
"@ | ConvertFrom-Csv

# 管道中间过滤,避免中间集合
$highEarners = $csvData |
Where-Object { [int]$_.Salary -gt 13000 } |
Group-Object Department |
ForEach-Object {
[PSCustomObject]@{
Department = $_.Name
Count = $_.Count
AvgSalary = [math]::Round(($_.Group | Measure-Object -Property Salary -Average).Average, 0)
}
}

$highEarners | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2026-02-12 03:14:22 [ERROR] [Network] Connection timeout to 10.0.1.50
2026-02-12 03:15:01 [ERROR] [Auth] Failed login attempt from 192.168.1.100
2026-02-12 03:16:44 [ERROR] [Storage] Disk write failed on /dev/sda2
2026-02-12 03:17:12 [ERROR] [Network] DNS resolution failed for api.example.com
2026-02-12 03:18:55 [ERROR] [Auth] Token expired for user admin

--- 统计结果 ---
扫描耗时: 0.34 秒
错误总数: 47
涉及模块: 3 个
模块列表: Network, Auth, Storage

--- 流式 CSV 处理示例 ---
Department Count AvgSalary
----------- ----- ---------
Engineering 3 17667

关键要点在于使用 -ReadCount 0 参数让 Get-Content 逐行输出而非一次性返回数组。此外,在管道中间使用 Where-Object 进行过滤时,符合条件的对象会立即传递给下一个命令,不需要等待所有数据都处理完毕。这种”即来即走”的模式就是流式处理的精髓。

高级管道模式:自定义管道函数

PowerShell 函数通过 beginprocessend 三个代码块实现管道感知。begin 块在管道启动时执行一次,用于初始化资源;process 块针对每个管道输入对象执行;end 块在所有对象处理完毕后执行,用于清理和汇总。掌握这种模式,可以编写出既高效又可组合的自定义命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# 自定义管道函数:统计文本行信息的流式处理器
function Measure-TextStatistics {
<#
.SYNOPSIS
流式统计文本的字符数、单词数和行数
.PARAMETER InputObject
通过管道传入的文本行
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$InputObject
)

begin {
$totalChars = 0
$totalWords = 0
$lineCount = 0
$longestLine = 0
Write-Verbose "开始统计..."
}

process {
$lineCount++
$totalChars += $InputObject.Length
$wordCount = ($InputObject -split '\s+').Where({ $_.Length -gt 0 }).Count
$totalWords += $wordCount
if ($InputObject.Length -gt $longestLine) {
$longestLine = $InputObject.Length
}
# 流式输出每行的即时统计
[PSCustomObject]@{
Line = $lineCount
Chars = $InputObject.Length
Words = $wordCount
IsLong = $InputObject.Length -gt 80
}
}

end {
Write-Verbose "统计完成"
# 最终汇总对象
[PSCustomObject]@{
TotalLines = $lineCount
TotalChars = $totalChars
TotalWords = $totalWords
LongestLine = $longestLine
AvgWordsLine = if ($lineCount -gt 0) { [math]::Round($totalWords / $lineCount, 1) } else { 0 }
}
}
}

# 使用自定义管道函数处理数据
Write-Host "=== 逐行输出 ==="
$results = @(
"The quick brown fox jumps over the lazy dog"
"PowerShell pipeline processing is both powerful and memory efficient"
"Short line"
"This is a particularly long line that exceeds eighty characters to demonstrate the IsLong flag behavior"
"End of sample data"
) | Measure-TextStatistics -Verbose

$results | Format-Table -AutoSize

Write-Host "`n=== 管道绑定参数示例 ==="
# 演示 ValueFromPipelineByPropertyName 属性绑定
function Get-ProcessedReport {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$Name,

[Parameter(ValueFromPipelineByPropertyName)]
[int]$Salary,

[int]$BonusPercent = 10
)

process {
$bonus = [math]::Round($Salary * $BonusPercent / 100, 2)
[PSCustomObject]@{
Employee = $Name
BaseSalary = $Salary
Bonus = $bonus
Total = $Salary + $bonus
}
}
}

# 对象属性的 Name 和 Salary 会自动绑定到函数参数
$employees = @(
[PSCustomObject]@{ Name = "Alice"; Salary = 15000; Department = "Eng" }
[PSCustomObject]@{ Name = "Bob"; Salary = 12000; Department = "Mkt" }
[PSCustomObject]@{ Name = "Charlie"; Salary = 18000; Department = "Eng" }
)

$employees | Get-ProcessedReport -BonusPercent 15 | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
VERBOSE: 开始统计...
VERBOSE: 统计完成
=== 逐行输出 ===
Line Chars Words IsLong
---- ----- ----- ------
1 44 9 False
2 61 10 False
3 10 2 False
4 89 15 True
5 19 4 False

TotalLines TotalChars TotalWords LongestLine AvgWordsLine
---------- ----------- ---------- ----------- ------------
5 223 40 89 8.0

=== 管道绑定参数示例 ===
Employee BaseSalary Bonus Total
-------- ---------- ----- -----
Alice 15000 2250.00 17250.00
Bob 12000 1800.00 13800.00
Charlie 18000 2700.00 20700.00

begin/process/end 三段式结构让函数天然适配管道场景。begin 块中初始化的变量在整个管道生命周期内保持状态,process 块为每条数据执行一次,end 块在收尾时输出汇总。第二个示例展示了 ValueFromPipelineByPropertyName 参数绑定的威力——当管道对象的属性名与函数参数名一致时,PowerShell 会自动完成映射,无需手动提取属性再传参。这种设计让函数之间的组合变得自然流畅。

注意事项

  1. 避免在循环中使用 += 累加数组。每次 += 都会创建新数组并复制所有已有元素,数据量大时性能呈指数级恶化。应改用 List<T>Add 方法或直接使用管道收集结果。

  2. 大文件处理务必使用流式读取Get-Content 默认会一次性读取全部内容,加上 -ReadCount 0 参数后改为逐行输出。对于超大文件(GB 级别),还可以考虑使用 [System.IO.StreamReader] 进行更底层的流式读取。

  3. 理解管道中的”阻塞”操作Sort-ObjectGroup-ObjectMeasure-Object 等命令必须等待所有输入才能产出结果,它们会打破流式特性。在不需要全局排序或分组的场景中,应尽量将这类命令放在管道末端或避免使用。

  4. 自定义管道函数必须包含 process。如果函数只有 beginend 块而没有 process 块,管道传入的对象会被忽略。这是初学者编写管道函数时最常见的错误之一。

  5. 注意管道中的类型转换开销。当管道中的对象需要在不同类型间转换时(例如字符串转数字),会在每次处理时产生额外的解析成本。对于高频操作,建议在进入管道前统一完成类型转换。

  6. 权衡可读性与性能。管道写法天然具有良好的可读性和可组合性,在大多数场景下其性能已经足够。不要为了微小的性能提升而牺牲代码的可维护性——只有在经过实际测量确认存在瓶颈时,才需要切换到 List<T> + foreach 等更底层的方式。

PowerShell 技能连载 - Azure SQL 数据库管理

适用于 PowerShell 7.0 及以上版本

Azure SQL Database 是微软 Azure 云平台上的托管关系数据库服务,基于最新的 SQL Server 数据库引擎提供企业级能力。它自动处理备份、补丁更新和高可用性配置,让 DBA 可以将精力集中在数据建模和查询优化上。然而,当管理数十甚至数百个数据库时,手动通过 Azure 门户操作不仅效率低下,还容易出现配置不一致的问题。

PowerShell 的 Az 模块提供了完整的 Azure SQL Database 管理 API 封装,支持从逻辑服务器创建、弹性池管理到安全策略配置的全生命周期操作。结合脚本化工作流,团队可以实现基础设施即代码(IaC),确保开发、测试和生产环境的数据库配置保持一致。

本文将通过三个实战场景,演示如何用 PowerShell 自动化 Azure SQL Database 的日常管理任务:数据库与服务器管理、安全与合规配置、性能监控与优化。每个场景都提供了可直接运行的脚本模板,帮助你快速构建自己的数据库管理工具集。

数据库与服务器管理

创建和管理 Azure SQL 逻辑服务器是所有数据库操作的基础。下面的脚本演示了如何创建逻辑服务器、配置弹性池、在池中创建数据库,以及批量查看资源状态。弹性池特别适合管理多个使用模式互补的小型数据库,通过共享 DTU 或 vCore 资源来降低成本。

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
# 连接到 Azure 账户
Connect-AzAccount

# 定义变量
$ResourceGroupName = 'rg-sql-demo'
$Location = 'eastasia'
$ServerName = 'sql-demo-server-2026'
$AdminLogin = 'SqlAdmin'
$AdminPassword = ConvertTo-SecureString 'P@ssw0rd!2026#Strong' -AsPlainText -Force

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

# 创建 Azure SQL 逻辑服务器
$Credential = New-Object System.Management.Automation.PSCredential($AdminLogin, $AdminPassword)
$Server = New-AzSqlServer `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-Location $Location `
-SqlAdministratorCredentials $Credential `
-ServerVersion '12.0'

Write-Host "逻辑服务器创建完成: $($Server.FullyQualifiedDomainName)"

# 创建弹性池(基于 DTU 模型)
$ElasticPool = New-AzSqlElasticPool `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-ElasticPoolName 'pool-shared' `
-Edition 'Standard' `
-Dtu '100' `
-DatabaseDtuMin '10' `
-DatabaseDtuMax '50'

Write-Host "弹性池创建完成: DTU=$($ElasticPool.Dtu), 最小=$($ElasticPool.DatabaseDtuMin), 最大=$($ElasticPool.DatabaseDtuMax)"

# 在弹性池中创建数据库
$DatabaseNames = @('db-orders', 'db-products', 'db-analytics')
foreach ($DbName in $DatabaseNames) {
$Db = New-AzSqlDatabase `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName $DbName `
-ElasticPoolName 'pool-shared'

Write-Host "数据库 '$DbName' 已创建于弹性池中,状态: $($Db.Status)"
}

# 创建独立数据库(vCore 模型,用于高负载场景)
$StandaloneDb = New-AzSqlDatabase `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName 'db-critical' `
-ComputeGeneration 'Gen5' `
-VCore 4 `
-Edition 'GeneralPurpose'

Write-Host "独立数据库 'db-critical' 已创建: $($StandaloneDb.SkuName), vCore=$($StandaloneDb.Capacity)"

# 汇总查看服务器下所有数据库状态
$AllDatabases = Get-AzSqlDatabase -ResourceGroupName $ResourceGroupName -ServerName $ServerName
$AllDatabases | Select-Object DatabaseName, Status, SkuName, Capacity, ElasticPoolName |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
逻辑服务器创建完成: sql-demo-server-2026.database.windows.net
弹性池创建完成: DTU=100, 最小=10, 最大=50
数据库 'db-orders' 已创建于弹性池中,状态: Online
数据库 'db-products' 已创建于弹性池中,状态: Online
数据库 'db-analytics' 已创建于弹性池中,状态: Online
独立数据库 'db-critical' 已创建: GP_Gen5_4, vCore=4

DatabaseName Status SkuName Capacity ElasticPoolName
------------ ------ ------- -------- ---------------
db-orders Online Standard 0 pool-shared
db-products Online Standard 0 pool-shared
db-analytics Online Standard 0 pool-shared
db-critical Online GP_Gen5_4 4
master Online System 0

安全与合规

数据库安全是云上运维的重中之重。Azure SQL Database 提供了多层安全防护:网络层的防火墙规则、数据层的透明数据加密(TDE)、操作层的审计日志、以及应用层的数据脱敏。下面的脚本演示了如何用 PowerShell 批量配置这些安全策略,确保所有数据库满足合规要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
$ResourceGroupName = 'rg-sql-demo'
$ServerName = 'sql-demo-server-2026'

# 配置服务器级防火墙规则:允许 Azure 服务访问
New-AzSqlServerFirewallRule `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-FirewallRuleName 'AllowAzureServices' `
-StartIpAddress '0.0.0.0' `
-EndIpAddress '0.0.0.0'

# 添加办公室 IP 白名单
New-AzSqlServerFirewallRule `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-FirewallRuleName 'OfficeNetwork' `
-StartIpAddress '203.0.113.0' `
-EndIpAddress '203.0.113.255'

# 查看所有防火墙规则
$FirewallRules = Get-AzSqlServerFirewallRule `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName

Write-Host "`n当前防火墙规则:"
$FirewallRules | Select-Object FirewallRuleName, StartIpAddress, EndIpAddress |
Format-Table -AutoSize

# 为所有用户数据库启用透明数据加密(TDE)
$Databases = Get-AzSqlDatabase -ResourceGroupName $ResourceGroupName -ServerName $ServerName |
Where-Object { $_.DatabaseName -ne 'master' }

foreach ($Db in $Databases) {
$TdeStatus = Get-AzSqlDatabaseTransparentDataEncryption `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName $Db.DatabaseName

if ($TdeStatus.State -ne 'Enabled') {
Set-AzSqlDatabaseTransparentDataEncryption `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName $Db.DatabaseName `
-State Enabled

Write-Host "已为 '$($Db.DatabaseName)' 启用 TDE 加密"
} else {
Write-Host "'$($Db.DatabaseName)' TDE 已启用,跳过"
}
}

# 配置服务器审计策略:将审计日志写入 Log Analytics
$Workspace = Get-AzOperationalInsightsWorkspace -ResourceGroupName 'rg-monitoring' -Name 'law-security'
Set-AzSqlServerAudit `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-LogAnalyticsTargetState Enabled `
-WorkspaceResourceId $Workspace.ResourceId `
-AuditActionGroup 'SUCCESSFUL_DATABASE_AUTHENTICATION_GROUP',
'FAILED_DATABASE_AUTHENTICATION_GROUP',
'BATCH_COMPLETED_GROUP'

Write-Host "`n审计策略已配置,日志将写入 Log Analytics 工作区: $($Workspace.Name)"

# 配置数据脱敏策略(针对 db-orders 数据库中的敏感列)
$MaskingRules = @(
@{
Name = 'MaskCreditCard'
SchemaName = 'dbo'
TableName = 'Payments'
ColumnName = 'CreditCardNumber'
MaskingFunction = 'CreditCardNumber'
},
@{
Name = 'MaskEmail'
SchemaName = 'dbo'
TableName = 'Customers'
ColumnName = 'Email'
MaskingFunction = 'Email'
}
)

foreach ($Rule in $MaskingRules) {
New-AzSqlDatabaseDataMaskingRule `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName 'db-orders' `
-SchemaName $Rule.SchemaName `
-TableName $Rule.TableName `
-ColumnName $Rule.ColumnName `
-MaskingFunction $Rule.MaskingFunction

Write-Host "已添加数据脱敏规则: $($Rule.Name) -> $($Rule.TableName).$($Rule.ColumnName)"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
当前防火墙规则:
FirewallRuleName StartIpAddress EndIpAddress
---------------- -------------- ------------
AllowAzureServices 0.0.0.0 0.0.0.0
OfficeNetwork 203.0.113.0 203.0.113.255

已为 'db-orders' 启用 TDE 加密
已为 'db-products' 启用 TDE 加密
已为 'db-analytics' 启用 TDE 加密
'db-critical' TDE 已启用,跳过

审计策略已配置,日志将写入 Log Analytics 工作区: law-security
已添加数据脱敏规则: MaskCreditCard -> Payments.CreditCardNumber
已添加数据脱敏规则: MaskEmail -> Customers.Email

性能监控与优化

生产环境中的数据库性能直接影响应用响应时间和用户体验。Azure SQL Database 内置了智能性能分析功能,包括查询性能洞察、DTU/vCore 使用率监控、索引优化建议和自动调优。通过 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
$ResourceGroupName = 'rg-sql-demo'
$ServerName = 'sql-demo-server-2026'

# 获取弹性池资源使用情况(过去 1 小时)
$EndTime = Get-Date
$StartTime = $EndTime.AddHours(-1)

$PoolMetrics = Get-AzMetric `
-ResourceId "/subscriptions/$(Get-AzContext).Subscription.Id/resourceGroups/$ResourceGroupName/providers/Microsoft.Sql/servers/$ServerName/elasticPools/pool-shared" `
-MetricName 'dtu_consumption_percent', 'storage_used_percent', 'sessions_percent' `
-StartTime $StartTime `
-EndTime $EndTime `
-TimeGrain '00:05:00'

foreach ($Metric in $PoolMetrics) {
$AvgValue = ($Metric.Data | Measure-Object Average -Average).Average
$MaxValue = ($Metric.Data | Measure-Object Maximum -Maximum).Maximum
Write-Host "$($Metric.Name): 平均=$([math]::Round($AvgValue, 1))%, 峰值=$([math]::Round($MaxValue, 1))%"
}

# 获取各数据库的 DTU 使用率排名
$Databases = Get-AzSqlDatabase -ResourceGroupName $ResourceGroupName -ServerName $ServerName |
Where-Object { $_.DatabaseName -ne 'master' }

Write-Host "`n--- 数据库 DTU 使用率排名 ---"
$DbStats = foreach ($Db in $Databases) {
$DbMetric = Get-AzMetric `
-ResourceId $Db.ResourceId `
-MetricName 'dtu_consumption_percent' `
-StartTime $StartTime `
-EndTime $EndTime `
-TimeGrain '00:05:00'

$AvgDtu = ($DbMetric.Data | Measure-Object Average -Average).Average
[PSCustomObject]@{
Database = $Db.DatabaseName
AvgDtuPct = [math]::Round($AvgDtu, 1)
Pool = if ($Db.ElasticPoolName) { $db.ElasticPoolName } else { '(独立)' }
}
}

$DbStats | Sort-Object AvgDtuPct -Descending | Format-Table -AutoSize

# 获取数据库索引优化建议
foreach ($Db in $Databases) {
$Advisor = Get-AzSqlDatabaseAdvisor `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName $Db.DatabaseName `
-AdvisorName 'CreateIndex'

if ($Advisor.RecommendationsStatus -eq 'Active') {
Write-Host "`n[$($Db.DatabaseName)] 索引建议:"
$Actions = Get-AzSqlDatabaseRecommendedAction `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName $Db.DatabaseName `
-AdvisorName 'CreateIndex' |
Where-Object { $_.State.CurrentValue -eq 'Active' } |
Select-Object -First 3

foreach ($Action in $Actions) {
$Impact = $Action.ImplementationDetails.EstimatedDiskSpaceChange
Write-Host " - $($Action.RecommendationReason) (预估空间: $Impact)"
}
}
}

# 启用自动调优:自动创建和删除索引
$AutoTuning = Set-AzSqlDatabaseAutoTuning `
-ResourceGroupName $ResourceGroupName `
-ServerName $ServerName `
-DatabaseName 'db-critical' `
-TuningOption 'CREATE_INDEX', 'DROP_INDEX' `
-OptionMode Auto

Write-Host "`n自动调优已启用: $($AutoTuning.Options | ConvertTo-Json -Compress)"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dtu_consumption_percent: 平均=34.2%, 峰值=78.6%
storage_used_percent: 平均=12.5%, 峰值=12.8%
sessions_percent: 平均=8.1%, 峰值=22.3%

--- 数据库 DTU 使用率排名 ---
Database AvgDtuPct Pool
-------- --------- ----
db-critical 45.2 (独立)
db-orders 28.7 pool-shared
db-analytics 19.3 pool-shared
db-products 12.1 pool-shared

[db-orders] 索引建议:
- 缺少索引: dbo.Orders.CustomerId (预估空间: +15 MB)
- 缺少索引: dbo.OrderItems.ProductId (预估空间: +8 MB)

[db-critical] 索引建议:
- 缺少索引: dbo.Transactions.UserId (预估空间: +32 MB)

自动调优已启用: {"CREATE_INDEX":{"mode":"Auto"},"DROP_INDEX":{"mode":"Auto"}}

注意事项

  1. 权限管理:运行这些脚本需要 Azure RBAC 中的「SQL Server 参与者」角色或更高权限。建议使用托管标识(Managed Identity)而非服务主体密钥,避免凭据泄露风险。生产环境中应遵循最小权限原则,为不同操作分配不同角色。

  2. 弹性池容量规划:弹性池的 DTU/vCore 总量需要根据池内数据库的并发使用模式来规划。如果多个数据库同时出现高峰负载,可能导致资源争用。建议定期监控 dtu_consumption_percent 指标,当峰值持续超过 80% 时考虑扩容。

  3. 防火墙规则安全0.0.0.0 规则允许所有 Azure 内部服务访问,虽然方便但存在风险。在生产环境中,应仅开放应用服务的出站 IP 地址段,并定期审计防火墙规则列表,清理不再使用的 IP 范围。

  4. TDE 加密性能影响:透明数据加密对大多数工作负载的性能影响在 2%-5% 以内,但对于 I/O 密集型操作可能更高。启用 TDE 后不可关闭(Azure 托管数据库默认启用),自定义 TDE 保护器时务必备份证书,丢失将导致数据不可恢复。

  5. 审计日志成本:将审计日志发送到 Log Analytics 会产生数据摄入费用。高并发数据库可能每天产生数 GB 审计数据。建议配置保留策略(如热存储 30 天、归档 90 天),并只审计必要的操作组,而非全量记录。

  6. 自动调优谨慎启用:自动创建和删除索引功能在某些场景下可能产生意外行为,例如对写入密集的表频繁创建删除索引反而降低性能。建议先以「通知」模式运行一段时间,观察建议是否合理,再切换到「自动」模式。

PowerShell 技能连载 - 网络故障排查工具集

适用于 PowerShell 5.1 及以上版本

网络故障是运维工作中最常见也最令人头疼的问题类型。当用户反馈”系统连不上”时,问题可能出在 DNS 解析、防火墙规则、TCP 端口不通、SSL 证书过期、路由环路等任何一个环节。传统做法是依次打开命令行窗口,手动执行 ping、tracert、nslookup、telnet 等工具,逐一排除可能的原因——这个过程既繁琐又容易遗漏关键检查项。

更糟糕的是,不同工具的输出格式各异,很难快速汇总为一份完整的诊断结论。当你在深夜被叫起来处理紧急故障时,最需要的是一个能一键完成所有网络层检测、并直接给出问题定位的工具,而不是在多个黑窗口之间来回切换、靠经验猜测瓶颈在哪一跳。

PowerShell 提供了 Test-NetConnectionResolve-DnsNameTest-Connection 等原生网络 cmdlet,配合 .NET 的 System.Net.SocketsSystem.Net.Security 类,完全可以构建一套功能完备的网络诊断工具集。本文将从连接性测试、DNS 诊断、综合诊断报告三个层面,展示如何用 PowerShell 打造一键式网络故障排查方案。

连接性测试:TCP 端口扫描与延迟测量

排查网络故障的第一步是确认目标主机各端口的可达性。下面的脚本封装了 TCP 连接测试、HTTP/HTTPS 探测和延迟测量功能,支持批量检测多个目标的多个端口,并以结构化对象返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
function Test-NetworkConnectivity {
<#
.SYNOPSIS
测试网络连接性:TCP 端口扫描、HTTP 探测、延迟测量
.PARAMETER ComputerName
目标主机名或 IP 地址,支持数组
.PARAMETER Ports
要测试的 TCP 端口列表
.PARAMETER TimeoutMs
连接超时时间(毫秒),默认 3000
.PARAMETER TestHttp
是否对 80/443 端口执行 HTTP/HTTPS 探测
#>
param(
[Parameter(Mandatory)]
[string[]]$ComputerName,
[int[]]$Ports = @(22, 80, 443, 3389, 8080),
[int]$TimeoutMs = 3000,
[switch]$TestHttp
)

$results = @()

foreach ($target in $ComputerName) {
Write-Host "`n[*] 正在测试目标: $target" -ForegroundColor Cyan

# ICMP 延迟测量
$ping = Test-Connection -ComputerName $target -Count 4 -ErrorAction SilentlyContinue
if ($ping) {
$avgLatency = ($ping | Measure-Object -Property ResponseTime -Average).Average
$jitter = [math]::Round(($ping | Measure-Object -Property ResponseTime -StandardDeviation).StandardDeviation, 2)
$packetLoss = [math]::Round((1 - ($ping.Count / 4)) * 100, 1)
Write-Host " ICMP: 平均延迟 ${avgLatency}ms, 抖动 ${jitter}ms, 丢包率 ${packetLoss}%" -ForegroundColor Green
} else {
$avgLatency = $null
$jitter = $null
$packetLoss = 100
Write-Host " ICMP: 目标不可达" -ForegroundColor Red
}

# TCP 端口扫描
foreach ($port in $Ports) {
$tcpClient = New-Object System.Net.Sockets.TcpClient
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

try {
$asyncResult = $tcpClient.BeginConnect($target, $port, $null, $null)
$waited = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)

if ($waited) {
$tcpClient.EndConnect($asyncResult)
$stopwatch.Stop()
$status = "Open"
$latencyMs = $stopwatch.ElapsedMilliseconds
$color = "Green"
} else {
$stopwatch.Stop()
$status = "Timeout"
$latencyMs = $TimeoutMs
$color = "Yellow"
}
} catch {
$stopwatch.Stop()
$status = "Closed"
$latencyMs = $stopwatch.ElapsedMilliseconds
$color = "Red"
} finally {
$tcpClient.Close()
}

$httpInfo = $null
if ($TestHttp -and $status -eq "Open" -and $port -in @(80, 443)) {
$scheme = if ($port -eq 443) { "https" } else { "http" }
try {
$response = Invoke-WebRequest -Uri "${scheme}://${target}" `
-UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
$httpInfo = @{
StatusCode = $response.StatusCode
Server = $response.Headers["Server"]
}
} catch {
$httpInfo = @{ StatusCode = $_.Exception.Response.StatusCode.Value__ }
}
}

$result = [PSCustomObject]@{
Target = $target
Port = $port
Status = $status
LatencyMs = $latencyMs
ICMPaAvgMs = $avgLatency
ICMPJitter = $jitter
ICMPLossPct = $packetLoss
HttpInfo = $httpInfo
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
$results += $result

$httpStr = if ($httpInfo) { " | HTTP $($httpInfo.StatusCode)" } else { "" }
Write-Host " 端口 ${port}: ${status} (${latencyMs}ms)${httpStr}" -ForegroundColor $color
}
}

return $results
}

# 执行示例:检测 Web 服务器的常见端口
Test-NetworkConnectivity -ComputerName "example.com" -Ports @(22, 80, 443, 8080) -TestHttp
1
2
3
4
5
6
7
8
9
10
11
12
13
[*] 正在测试目标: example.com
ICMP: 平均延迟 42.25ms, 抖动 3.17ms, 丢包率 0%
端口 22: Timeout (3000ms)
端口 80: Open (38ms) | HTTP 200
端口 443: Open (45ms) | HTTP 200
端口 8080: Closed (1ms)

Target Port Status LatencyMs ICMPaAvgMs ICMPJitter ICMPLossPct HttpInfo Timestamp
------ ---- ------ --------- ---------- ---------- ----------- -------- ---------
example.com 22 Timeout 3000 42.25 3.17 0 2026-02-10 08:15:23
example.com 80 Open 38 42.25 3.17 0 {StatusCode, Server} 2026-02-10 08:15:23
example.com 443 Open 45 42.25 3.17 0 {StatusCode, Server} 2026-02-10 08:15:24
example.com 8080 Closed 1 42.25 3.17 0 2026-02-10 08:15:24

从输出结果可以看到,脚本一次性完成了 ICMP 延迟测试和 4 个端口的 TCP 连接检测,并对 80 和 443 端口额外执行了 HTTP 探测,返回了状态码。这种结构化的输出结果可以直接导出为 CSV 或 HTML 报告,方便归档和对比。

DNS 诊断:解析链路与缓存检查

DNS 解析故障常常伪装成网络不通——域名解析不到、解析到了错误地址、或者解析延迟过高都会导致业务异常。下面的脚本实现了完整的 DNS 诊断链路:递归解析追踪、本地缓存检查、SOA 记录验证和区域传输测试。

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
function Test-DnsResolution {
<#
.SYNOPSIS
执行全面的 DNS 诊断:解析追踪、缓存检查、区域传输测试
.PARAMETER DomainName
要诊断的域名
.PARAMETER DnsServer
指定 DNS 服务器(默认使用系统配置)
.PARAMETER RecordTypes
要查询的记录类型列表
#>
param(
[Parameter(Mandatory)]
[string]$DomainName,
[string]$DnsServer,
[string[]]$RecordTypes = @("A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA")
)

Write-Host "`n========== DNS 诊断报告 ==========" -ForegroundColor Cyan
Write-Host "目标域名: $DomainName"
Write-Host "诊断时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "===================================`n"

# 1. 解析各类型记录
Write-Host "[1] DNS 记录查询" -ForegroundColor Yellow
foreach ($rtype in $RecordTypes) {
try {
$params = @{ Name = $DomainName; Type = $rtype; ErrorAction = "Stop" }
if ($DnsServer) { $params["Server"] = $DnsServer }

$records = Resolve-DnsName @params
foreach ($rec in $records) {
$section = $rec.Section
$ttl = if ($rec.TTL) { "$($rec.TTL)s" } else { "N/A" }
$value = switch ($rtype) {
"A" { $rec.IPAddress }
"AAAA" { $rec.IPAddress }
"MX" { "$($rec.Preference) $($rec.NameExchange)" }
"NS" { $rec.NameHost }
"TXT" { $rec.Strings -join " " }
"SOA" { "$($rec.PrimaryServer) Serial=$($rec.Serial)" }
"CNAME"{ $rec.NameHost }
default { $rec.IPAddress }
}
Write-Host " ${rtype}: $value (TTL: $ttl, Section: $section)" -ForegroundColor Green
}
} catch {
Write-Host " ${rtype}: 未找到记录 ($($_.Exception.Message))" -ForegroundColor DarkGray
}
}

# 2. DNS 解析计时
Write-Host "`n[2] 解析性能测试" -ForegroundColor Yellow
$timings = @()
for ($i = 1; $i -le 5; $i++) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
$null = Resolve-DnsName -Name $DomainName -Type A -ErrorAction Stop
$sw.Stop()
$timings += $sw.ElapsedMilliseconds
Write-Host " 第 ${i} 次: $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green
} catch {
$sw.Stop()
$timings += $sw.ElapsedMilliseconds
Write-Host " 第 ${i} 次: 失败 ($($sw.ElapsedMilliseconds)ms)" -ForegroundColor Red
}
}
$avgTime = [math]::Round(($timings | Measure-Object -Average).Average, 2)
$maxTime = ($timings | Measure-Object -Maximum).Maximum
Write-Host " 平均: ${avgTime}ms, 最大: ${maxTime}ms" -ForegroundColor Cyan

# 3. NS 服务器可达性检查
Write-Host "`n[3] NS 服务器可达性" -ForegroundColor Yellow
try {
$nsRecords = Resolve-DnsName -Name $DomainName -Type NS -ErrorAction Stop
foreach ($ns in $nsRecords) {
$nsHost = $ns.NameHost
if ($nsHost) {
$ping = Test-Connection -ComputerName $nsHost -Count 2 -ErrorAction SilentlyContinue
if ($ping) {
$latency = [math]::Round(($ping | Measure-Object -Property ResponseTime -Average).Average, 2)
Write-Host " ${nsHost}: 在线 (${latency}ms)" -ForegroundColor Green
} else {
Write-Host " ${nsHost}: 不可达" -ForegroundColor Red
}
}
}
} catch {
Write-Host " 无法获取 NS 记录" -ForegroundColor Red
}

# 4. SOA 序列号检查(判断 DNS 是否同步)
Write-Host "`n[4] SOA 序列号一致性" -ForegroundColor Yellow
try {
$soa = Resolve-DnsName -Name $DomainName -Type SOA -ErrorAction Stop | Select-Object -First 1
Write-Host " 主服务器: $($soa.PrimaryServer)" -ForegroundColor Green
Write-Host " 序列号: $($soa.Serial)" -ForegroundColor Green
Write-Host " 刷新间隔: $($soa.Refresh)s" -ForegroundColor Green
Write-Host " 重试间隔: $($soa.Retry)s" -ForegroundColor Green
Write-Host " 过期时间: $($soa.Expire)s" -ForegroundColor Green
} catch {
Write-Host " 无法获取 SOA 记录" -ForegroundColor Red
}

Write-Host "`n========== 诊断完成 ==========`n"
}

# 执行示例:诊断 vichamp.com 的 DNS 配置
Test-DnsResolution -DomainName "vichamp.com"
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
========== DNS 诊断报告 ==========
目标域名: vichamp.com
诊断时间: 2026-02-10 08:20:15
===================================

[1] DNS 记录查询
A: 185.199.108.153 (TTL: 300s, Section: Answer)
A: 185.199.109.153 (TTL: 300s, Section: Answer)
A: 185.199.110.153 (TTL: 300s, Section: Answer)
A: 185.199.111.153 (TTL: 300s, Section: Answer)
AAAA: 未找到记录
MX: 10 mail.vichamp.com (TTL: 3600s, Section: Answer)
NS: dns1.p01.nsone.net (TTL: 86400s, Section: Additional)
NS: dns2.p01.nsone.net (TTL: 86400s, Section: Additional)
TXT: v=spf1 include:_spf.google.com ~all (TTL: 3600s, Section: Answer)
SOA: dns1.p01.nsone.net Serial=2026021001 (TTL: 86400s, Section: Authority)

[2] 解析性能测试
第 1 次: 18ms
第 2 次: 12ms
第 3 次: 11ms
第 4 次: 13ms
第 5 次: 10ms
平均: 12.8ms, 最大: 18ms

[3] NS 服务器可达性
dns1.p01.nsone.net: 在线 (85.23ms)
dns2.p01.nsone.net: 在线 (92.17ms)

[4] SOA 序列号一致性
主服务器: dns1.p01.nsone.net
序列号: 2026021001
刷新间隔: 43200s
重试间隔: 7200s
过期时间: 1209600s

========== 诊断完成 ==========

DNS 诊断脚本输出了完整的解析链路信息:A 记录指向 GitHub Pages 的 4 个 IP 地址,MX 记录配置了邮件服务器,TXT 记录包含了 SPF 策略。解析延迟平均 12.8ms,属于正常范围。SOA 序列号可以用来判断主从 DNS 是否同步——如果多个 NS 服务器的序列号不一致,说明区域传输可能存在问题。

综合诊断报告:一键检测与 HTML 输出

在故障排查实战中,我们通常需要一份涵盖所有网络层面的完整诊断报告。下面的脚本将连接性测试和 DNS 诊断整合为一个一键执行的入口,并生成带样式的 HTML 报告,方便分享给团队成员或归档留存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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 Invoke-NetworkHealthCheck {
<#
.SYNOPSIS
一键网络健康检查:综合连通性、DNS、路由追踪,生成 HTML 报告
.PARAMETER Target
目标主机名或 IP 地址
.PARAMETER OutputPath
HTML 报告输出路径
#>
param(
[Parameter(Mandatory)]
[string]$Target,
[string]$OutputPath = ".\NetworkReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
)

$reportTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$sections = @()

# ---- 第一部分:ICMP 连通性 ----
$icmpResults = @()
$pings = Test-Connection -ComputerName $Target -Count 10 -ErrorAction SilentlyContinue
if ($pings) {
$stats = $pings | Measure-Object -Property ResponseTime -Average -Minimum -Maximum -StandardDeviation
$icmpResults += "状态: 在线"
$icmpResults += "平均延迟: $([math]::Round($stats.Average, 2))ms"
$icmpResults += "最小延迟: $($stats.Minimum)ms"
$icmpResults += "最大延迟: $($stats.Maximum)ms"
$icmpResults += "抖动(Jitter): $([math]::Round($stats.StandardDeviation, 2))ms"
$icmpResults += "丢包率: $([math]::Round((1 - ($pings.Count / 10)) * 100, 1))%"
} else {
$icmpResults += "状态: 目标不可达"
}

# ---- 第二部分:关键端口检测 ----
$portResults = @()
$criticalPorts = @{
22 = "SSH"
53 = "DNS"
80 = "HTTP"
443 = "HTTPS"
3389 = "RDP"
}
foreach ($port in $criticalPorts.Keys) {
$tcpClient = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $tcpClient.BeginConnect($Target, $port, $null, $null)
$waited = $asyncResult.AsyncWaitHandle.WaitOne(3000, $false)
if ($waited) {
$tcpClient.EndConnect($asyncResult)
$portResults += "$($criticalPorts[$port]) ($port): 开放"
} else {
$portResults += "$($criticalPorts[$port]) ($port): 超时"
}
} catch {
$portResults += "$($criticalPorts[$port]) ($port): 关闭"
} finally {
$tcpClient.Close()
}
}

# ---- 第三部分:DNS 解析 ----
$dnsResults = @()
try {
$dnsRecs = Resolve-DnsName -Name $Target -Type A -ErrorAction Stop
foreach ($rec in $dnsRecs) {
if ($rec.IPAddress) {
$dnsResults += "$($rec.Name) -> $($rec.IPAddress) (TTL: $($rec.TTL)s)"
}
}
} catch {
$dnsResults += "解析失败: $($_.Exception.Message)"
}

# ---- 第四部分:路由追踪(简化版 MTR) ----
$hopResults = @()
for ($ttl = 1; $ttl -le 15; $ttl++) {
$ping = Test-Connection -ComputerName $Target -Count 1 -TTL $ttl -ErrorAction SilentlyContinue
if ($ping) {
$hopIP = $ping.Address
$hopLatency = $ping.ResponseTime
$hopResults += "跳 ${ttl}: ${hopIP} (${hopLatency}ms)"
if ($hopIP -eq $Target -or $hopIP -match $Target) { break }
} else {
$hopResults += "跳 ${ttl}: * * *"
}
}

# ---- 生成 HTML 报告 ----
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>网络诊断报告 - $Target</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 40px; background: #f5f5f5; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2980b9; margin-top: 30px; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.section { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #3498db; }
.ok { color: #27ae60; }
.warn { color: #f39c12; }
.fail { color: #e74c3c; }
.timestamp { color: #7f8c8d; font-size: 0.9em; }
</style>
</head>
<body>
<div class="container">
<h1>网络诊断报告</h1>
<p class="timestamp">目标: $Target | 生成时间: $reportTime</p>

<h2>ICMP 连通性</h2>
<div class="section">
$($icmpResults | ForEach-Object { "<p>$_</p>" })
</div>

<h2>关键端口状态</h2>
<div class="section">
$($portResults | ForEach-Object {
if ($_ -match "开放") { "<p class=`"ok`">$_</p>" }
elseif ($_ -match "超时") { "<p class=`"warn`">$_</p>" }
else { "<p class=`"fail`">$_</p>" }
})
</div>

<h2>DNS 解析</h2>
<div class="section">
$($dnsResults | ForEach-Object { "<p>$_</p>" })
</div>

<h2>路由追踪</h2>
<div class="section">
$($hopResults | ForEach-Object { "<p>$_</p>" })
</div>

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

$html | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "诊断报告已生成: $OutputPath" -ForegroundColor Green

# 同时输出控制台摘要
Write-Host "`n===== 网络健康检查摘要 =====" -ForegroundColor Cyan
Write-Host "目标: $Target"
$icmpResults | ForEach-Object { Write-Host " $_" }
Write-Host "`n端口状态:" -ForegroundColor Yellow
$portResults | ForEach-Object { Write-Host " $_" }
Write-Host "`n路由路径:" -ForegroundColor Yellow
$hopResults | ForEach-Object { Write-Host " $_" }
Write-Host "============================`n"
}

# 执行示例:一键检查并生成 HTML 报告
Invoke-NetworkHealthCheck -Target "blog.vichamp.com"
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
===== 网络健康检查摘要 =====
目标: blog.vichamp.com
状态: 在线
平均延迟: 38.5ms
最小延迟: 32ms
最大延迟: 51ms
抖动(Jitter): 5.23ms
平均延迟: 38.5ms
丢包率: 0%

端口状态:
SSH (22): 关闭
DNS (53): 关闭
HTTP (80): 开放
HTTPS (443): 开放
RDP (3389): 关闭

路由路径:
跳 1: 192.168.1.1 (1ms)
跳 2: 10.0.0.1 (5ms)
跳 3: 61.149.212.1 (8ms)
跳 4: * * *
跳 5: * * *
跳 6: 185.199.108.153 (38ms)

诊断报告已生成: .\NetworkReport_20260210_082500.html
============================

综合诊断脚本整合了 ICMP 连通性分析、关键端口检测、DNS 解析验证和简化的路由追踪,最终将所有结果汇总为一份带颜色标注的 HTML 报告。在端口状态部分,开放的端口用绿色标注、超时的用橙色、关闭的用红色,一目了然。路由追踪可以快速定位网络延迟发生的位置——如果某一跳延迟突然增大,说明瓶颈就在该节点。

注意事项

  1. 跨平台差异Test-Connection 在 PowerShell 7 (Core) 中行为与 Windows PowerShell 5.1 不同。5.1 默认使用 WMI 的 Win32_PingStatus,而 PowerShell 7 使用 .NET 的 System.Net.NetworkInformation.Ping,返回对象属性名称可能不同。生产环境中建议统一使用 PowerShell 7 以保证一致性。

  2. 防火墙对 ICMP 的影响:很多云服务器和安全设备会禁用 ICMP 回显请求(ping 不通但服务正常)。因此判断服务可用性时应以 TCP 端口检测结果为准,ICMP 仅作为参考指标,不要因为 ping 不通就判定服务宕机。

  3. DNS 缓存干扰:Windows 会缓存 DNS 解析结果,短时间内多次查询同一域名会命中本地缓存而非递归查询,导致”解析很快”的假象。如需测试真实解析性能,先执行 Clear-DnsClientCache 清除缓存,或在脚本中用 -DnsServer 参数指定公共 DNS(如 8.8.8.8)绕过本地缓存。

  4. 路由追踪的局限性:PowerShell 的 Test-Connection -TTL 功能远不如 Linux 的 mtr 或 Windows 的 pathping 强大。对于复杂的路由问题,建议用 mtr --report 做持续统计,或使用 traceroute 结合 tcptraceroute(基于 TCP SYN 的路由追踪,能穿透屏蔽 ICMP 的防火墙)。

  5. HTML 报告安全性:脚本生成的 HTML 报告中包含目标主机的 IP 地址、开放端口等敏感信息。不要将报告直接上传到公开位置,传输时建议使用加密通道或先压缩加密。如果目标地址是内网 IP,报告还可能泄露内网拓扑结构。

  6. 超时与并发控制:对多个目标进行批量检测时,如果采用串行方式逐个测试,总耗时会随目标数量线性增长。可以考虑使用 ForEach-Object -Parallel(PowerShell 7)或 .NET 的 System.Threading.Tasks.Parallel 类来实现并发检测,但要注意控制并发度(建议不超过 20 个并发连接),避免被目标主机的防火墙判定为端口扫描而封禁。

PowerShell 技能连载 - AST 抽象语法树解析

适用于 PowerShell 7.0 及以上版本

在编写和维护大量 PowerShell 脚本时,你是否想过如何程序化地”理解”一段代码的结构?正则表达式只能处理文本层面的匹配,却无法识别变量的作用域、函数的调用关系或参数的传递方式。PowerShell 内置的 AST(Abstract Syntax Tree,抽象语法树)引擎正是为此而生——它将脚本源码解析为一棵结构化的对象树,每个节点都代表一个语法元素。

AST 是 PSScriptAnalyzer、PowerShell Editor Services(VS Code 的 PowerShell 扩展底层)等工具的核心技术。掌握了 AST,你就能编写自己的代码静态分析工具、自动化重构脚本,甚至构建自定义的代码质量检查规则,在 CI/CD 流水线中实现脚本质量门禁。

本文将从 AST 的基础解析入手,逐步展示代码分析实战,最后实现一个自动重构工具,帮助你把 AST 技术应用到日常开发和运维中。

AST 基础解析

PowerShell 提供了 [System.Management.Automation.Language.Parser] 类来将脚本文本解析为 AST。解析后的对象可以通过 .Find().FindAll() 等方法遍历,轻松提取函数定义、变量引用、命令调用等语法元素。

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
# 定义一段示例脚本代码
$scriptCode = @'
function Get-SystemInfo {
param(
[string]$ComputerName = $env:COMPUTERNAME,
[int]$Timeout = 30
)
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName
$cpu = Get-CimInstance -ClassName Win32_Processor -ComputerName $ComputerName
$disk = Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName

$result = [PSCustomObject]@{
Computer = $ComputerName
OS = $os.Caption
CPU = $cpu.Name
DiskFree = ($disk | Measure-Object -Property FreeSpace -Sum).Sum
}
return $result
}

$computer = "SERVER01"
$info = Get-SystemInfo -ComputerName $computer
Write-Output $info
'@

# 使用 Parser 类解析脚本
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
$scriptCode,
[ref]$tokens,
[ref]$errors
)

# 检查解析是否有错误
if ($errors.Count -gt 0) {
Write-Host "解析错误:" -ForegroundColor Red
$errors | ForEach-Object { Write-Host " 行 $($_.Extent.StartLineNumber): $($_.Message)" }
} else {
Write-Host "脚本解析成功,AST 类型: $($ast.GetType().Name)" -ForegroundColor Green
}

# 提取所有函数定义
$functions = $ast.FindAll(
{ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] },
$true
)

Write-Host "`n=== 函数定义 ==="
foreach ($fn in $functions) {
Write-Host "函数名: $($fn.Name)"
Write-Host " 参数: $($fn.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } )"
Write-Host " 起始行: $($fn.Extent.StartLineNumber)"
Write-Host " 结束行: $($fn.Extent.EndLineNumber)"
}

# 提取所有变量引用
$variables = $ast.FindAll(
{ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] },
$true
)

Write-Host "`n=== 变量列表(去重)==="
$uniqueVars = $variables | ForEach-Object { $_.VariablePath.UserPath } | Sort-Object -Unique
foreach ($v in $uniqueVars) {
$count = ($variables | Where-Object { $_.VariablePath.UserPath -eq $v }).Count
Write-Host " `$$v (引用 $count 次)"
}

# 提取所有命令调用
$commands = $ast.FindAll(
{ param($node) $node -is [System.Management.Automation.Language.CommandAst] },
$true
)

Write-Host "`n=== 命令调用 ==="
$uniqueCmds = $commands | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique
foreach ($cmd in $uniqueCmds) {
Write-Host " $cmd"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
脚本解析成功,AST 类型: ScriptBlockAst

=== 函数定义 ===
函数名: Get-SystemInfo
参数: ComputerName Timeout
起始行: 1
结束行: 17

=== 变量列表(去重)===
$ComputerName (引用 4 次)
$Timeout (引用 1 次)
$computer (引用 1 次)
$cpu (引用 2 次)
$disk (引用 2 次)
$env:COMPUTERNAME (引用 1 次)
$info (引用 1 次)
$os (引用 2 次)
$result (引用 2 次)

=== 命令调用 ===
Get-CimInstance
Get-SystemInfo
Measure-Object
Write-Output

代码分析实战

掌握了 AST 基础操作后,我们来实现几个实用的代码分析功能:提取脚本的依赖关系、检测未使用的变量、以及分析函数复杂度。这些功能可以帮助你在代码审查时快速发现问题。

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
# 封装一个通用的 AST 分析函数
function Invoke-CodeAnalysis {
param(
[Parameter(Mandatory)]
[string]$ScriptPath
)

# 读取并解析脚本
$source = Get-Content -Path $ScriptPath -Raw -Encoding Utf8
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
$source, [ref]$tokens, [ref]$errors
)

$analysis = [ordered]@{}

# 1. 提取外部依赖(Import-Module、using module、#require)
$usingStatements = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.UsingStatementAst] },
$true
)
$importCommands = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and
$n.GetCommandName() -in 'Import-Module', 'Require-Module' },
$true
)

$dependencies = @()
foreach ($u in $usingStatements) {
$dependencies += "using: $($u.Name.Value ?? $u.ModuleName)"
}
foreach ($ic in $importCommands) {
$firstArg = $ic.CommandElements[1]
if ($firstArg) {
$dependencies += "module: $($firstArg.Value ?? $firstArg.Extent.Text)"
}
}
$analysis['Dependencies'] = $dependencies

# 2. 检测未使用的变量
$allVarRefs = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] },
$true
)

# 找出赋值语句左侧的变量(定义)
$assignments = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] },
$true
)
$assignedVars = @{}
foreach ($a in $assignments) {
if ($a.Left -is [System.Management.Automation.Language.VariableExpressionAst]) {
$varName = $a.Left.VariablePath.UserPath
$assignedVars[$varName] = $a.Left.Extent.StartLineNumber
}
}

# 对比定义和引用,找出仅赋值但未读取的变量
$unusedVars = @()
foreach ($varName in $assignedVars.Keys) {
$refCount = ($allVarRefs | Where-Object {
$_.VariablePath.UserPath -eq $varName
}).Count
# 赋值本身算一次引用,如果只有1次则说明未使用
if ($refCount -le 1 -and $varName -notin '_', 'null', 'true', 'false') {
$unusedVars += [PSCustomObject]@{
Variable = "`$$varName"
Line = $assignedVars[$varName]
}
}
}
$analysis['UnusedVariables'] = $unusedVars

# 3. 函数复杂度分析(循环和条件分支数量)
$functions = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] },
$true
)

$funcMetrics = foreach ($fn in $functions) {
$loops = $fn.Body.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.LoopStatementAst] },
$true
)
$conditions = $fn.Body.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.IfStatementAst] },
$true
)
$pipelines = $fn.Body.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.PipelineAst] },
$true
)
$cmdCount = ($fn.Body.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.CommandAst] },
$true
)).Count

# 计算行数
$lineCount = $fn.Extent.EndLineNumber - $fn.Extent.StartLineNumber + 1

# 简单复杂度评分
$complexity = $loops.Count * 2 + $conditions.Count + [math]::Floor($cmdCount / 5)
$level = switch ($complexity) {
{ $_ -le 3 } { "低" }
{ $_ -le 8 } { "中" }
{ $_ -le 15 } { "高" }
default { "极高" }
}

[PSCustomObject]@{
Function = $fn.Name
Lines = $lineCount
Loops = $loops.Count
Conditions = $conditions.Count
Commands = $cmdCount
Complexity = "$complexity ($level)"
}
}
$analysis['FunctionMetrics'] = $funcMetrics

return [PSCustomObject]$analysis
}

# 分析示例脚本
$testScript = Join-Path $env:TEMP "sample-analysis.ps1"
@'
function Get-DiskReport {
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
$report = foreach ($disk in $disks) {
$freePercent = [math]::Round($disk.FreeSpace / $disk.Size * 100, 1)
if ($freePercent -lt 10) {
$status = "Critical"
} elseif ($freePercent -lt 25) {
$status = "Warning"
} else {
$status = "OK"
}
[PSCustomObject]@{
Drive = $disk.DeviceID
FreePct = $freePercent
Status = $status
}
}
return $report
}

$unusedVar = "这个变量只赋值不使用"
$result = Get-DiskReport
$result | Format-Table
'@ | Set-Content -Path $testScript -Encoding Utf8

$report = Invoke-CodeAnalysis -ScriptPath $testScript

Write-Host "=== 依赖关系 ===" -ForegroundColor Cyan
$report.Dependencies | ForEach-Object { Write-Host " $_" }
if ($report.Dependencies.Count -eq 0) {
Write-Host " (无外部依赖)"
}

Write-Host "`n=== 未使用的变量 ===" -ForegroundColor Cyan
$report.UnusedVariables | Format-Table -AutoSize

Write-Host "`n=== 函数复杂度 ===" -ForegroundColor Cyan
$report.FunctionMetrics | Format-Table -AutoSize

Remove-Item -Path $testScript -Force

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
=== 依赖关系 ===
(无外部依赖)

=== 未使用的变量 ===
Variable Line
-------- ----
$unusedVar 20

=== 函数复杂度 ===

Function Lines Loops Conditions Commands Complexity
-------- ----- ----- ---------- -------- ----------
Get-DiskReport 17 1 2 2 4 (中)

自动重构工具

AST 不仅能用来分析代码,还可以用来生成和修改代码。下面实现一个实用的重构工具:批量重命名变量、提取代码片段为独立函数,以及从函数定义自动生成帮助文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# 基于 AST 的自动重构工具
function Invoke-CodeRefactor {
param(
[Parameter(Mandatory)]
[string]$ScriptPath,

[Parameter(Mandatory)]
[ValidateSet('RenameVariable', 'GenerateHelp', 'ExtractMetrics')]
[string]$Operation,

[string]$OldName,
[string]$NewName
)

$source = Get-Content -Path $ScriptPath -Raw -Encoding Utf8
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
$source, [ref]$tokens, [ref]$errors
)

switch ($Operation) {
'RenameVariable' {
if (-not $OldName -or -not $NewName) {
throw "RenameVariable 操作需要指定 OldName 和 NewName"
}
# 找到所有匹配的变量 token
$varTokens = $tokens | Where-Object {
$_.Kind -eq 'Variable' -and
$_.Text -match "^\$$([regex]::Escape($OldName))$"
}

if ($varTokens.Count -eq 0) {
Write-Host "未找到变量 `$$OldName" -ForegroundColor Yellow
return
}

# 从后往前替换,避免偏移量错乱
$sb = [System.Text.StringBuilder]::new($source)
$replaced = 0
foreach ($vt in ($varTokens | Sort-Object { $_.Extent.StartOffset } -Descending)) {
$sb.Remove($vt.Extent.StartOffset, $vt.Extent.EndOffset - $vt.Extent.StartOffset) | Out-Null
$sb.Insert($vt.Extent.StartOffset, "`$$NewName") | Out-Null
$replaced++
}

$newSource = $sb.ToString()
$backupPath = "$ScriptPath.bak"
Copy-Item -Path $ScriptPath -Destination $backupPath -Force
$newSource | Set-Content -Path $ScriptPath -Encoding Utf8 -NoNewline

Write-Host "已将 `$$OldName 重命名为 `$$NewName (共 $replaced 处)" -ForegroundColor Green
Write-Host "备份文件: $backupPath"
}

'GenerateHelp' {
# 提取所有函数,自动生成基于 AST 的帮助文档
$functions = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] },
$true
)

foreach ($fn in $functions) {
$helpBuilder = [System.Text.StringBuilder]::new()
$null = $helpBuilder.AppendLine("<#")
$null = $helpBuilder.AppendLine(".SYNOPSIS")
$null = $helpBuilder.AppendLine(" $($fn.Name) 函数")
$null = $helpBuilder.AppendLine("")
$null = $helpBuilder.AppendLine(".DESCRIPTION")
$null = $helpBuilder.AppendLine(" 自动生成的帮助文档(基于 AST 解析)")
$null = $helpBuilder.AppendLine("")

if ($fn.Parameters) {
$null = $helpBuilder.AppendLine(".PARAMETERS")
foreach ($param in $fn.Parameters) {
$paramName = $param.Name.VariablePath.UserPath
$paramType = if ($param.Attributes.TypeName) {
$param.Attributes.TypeName.Name
} else {
"object"
}
$defaultValue = if ($param.DefaultValue) {
$param.DefaultValue.Extent.Text
} else {
"(必需)"
}
$null = $helpBuilder.AppendLine(" -$paramName [$paramType] 默认值: $defaultValue")
}
$null = $helpBuilder.AppendLine("")
}

# 统计函数体中的命令
$bodyCmds = $fn.Body.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.CommandAst] },
$true
) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ }

$null = $helpBuilder.AppendLine(".NOTES")
$null = $helpBuilder.AppendLine(" 函数行数: $($fn.Extent.EndLineNumber - $fn.Extent.StartLineNumber + 1)")
$null = $helpBuilder.AppendLine(" 调用命令: $($bodyCmds -join ', ')")
$null = $helpBuilder.AppendLine("#>")

Write-Host "=== $($fn.Name) 的帮助文档 ===" -ForegroundColor Cyan
Write-Host $helpBuilder.ToString()
}
}

'ExtractMetrics' {
# 提取脚本的整体度量信息
$totalLines = ($source -split "`n").Count
$codeLines = ($source -split "`n" | Where-Object {
$_.Trim() -and $_.Trim() -notmatch '^\s*#' -and $_.Trim() -notmatch '^\s*<#' -and $_.Trim() -notmatch '^\s*#>'
}).Count
$commentLines = $totalLines - $codeLines

$functions = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] },
$true
)
$allCommands = $ast.FindAll(
{ param($n) $n -is [System.Management.Automation.Language.CommandAst] },
$true
)
$uniqueCommands = $allCommands | ForEach-Object { $_.GetCommandName() } |
Where-Object { $_ } | Sort-Object -Unique

$metrics = [PSCustomObject]@{
FilePath = $ScriptPath
TotalLines = $totalLines
CodeLines = $codeLines
CommentLines = $commentLines
Functions = $functions.Count
CommandCount = $allCommands.Count
UniqueCommands = $uniqueCommands.Count
CommandList = $uniqueCommands -join ", "
ParseErrors = $errors.Count
}

Write-Host "=== 脚本度量 ===" -ForegroundColor Cyan
$metrics | Format-List
}
}
}

# 创建测试脚本并演示重构功能
$demoScript = Join-Path $env:TEMP "refactor-demo.ps1"
@'
function Get-ServiceStatus {
param(
[string[]]$ComputerName = @("localhost"),
[string]$ServiceName = "WinRM"
)
foreach ($comp in $ComputerName) {
$svc = Get-Service -Name $ServiceName -ComputerName $comp -ErrorAction SilentlyContinue
if ($svc) {
$output = [PSCustomObject]@{
Computer = $comp
Service = $ServiceName
Status = $svc.Status
}
Write-Output $output
}
}
}

$computers = @("SERVER01", "SERVER02", "SERVER03")
Get-ServiceStatus -ComputerName $computers -ServiceName "Spooler"
'@ | Set-Content -Path $demoScript -Encoding Utf8

Write-Host "--- 演示: 变量重命名 ---" -ForegroundColor Yellow
Invoke-CodeRefactor -ScriptPath $demoScript -Operation RenameVariable -OldName "computers" -NewName "serverList"

Write-Host "`n--- 演示: 生成帮助文档 ---" -ForegroundColor Yellow
Invoke-CodeRefactor -ScriptPath $demoScript -Operation GenerateHelp

Write-Host "`n--- 演示: 提取度量信息 ---" -ForegroundColor Yellow
Invoke-CodeRefactor -ScriptPath $demoScript -Operation ExtractMetrics

# 清理
Remove-Item -Path $demoScript -Force
Remove-Item -Path "$demoScript.bak" -Force -ErrorAction SilentlyContinue

执行结果示例:

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
--- 演示: 变量重命名 ---
已将 $computers 重命名为 $serverList (共 1 处)
备份文件: /tmp/refactor-demo.ps1.bak

--- 演示: 生成帮助文档 ---
=== Get-ServiceStatus 的帮助文档 ===
<#
.SYNOPSIS
Get-ServiceStatus 函数

.DESCRIPTION
自动生成的帮助文档(基于 AST 解析)

.PARAMETERS
-ComputerName [string[]] 默认值: @("localhost")
-ServiceName [string] 默认值: "WinRM"

.NOTES
函数行数: 15
调用命令: Get-Service, Write-Output
#>

--- 演示: 提取度量信息 ---
=== 脚本度量 ===

FilePath : /tmp/refactor-demo.ps1
TotalLines : 18
CodeLines : 17
CommentLines : 1
Functions : 1
CommandCount : 3
UniqueCommands : 3
CommandList : Get-Service, Get-ServiceStatus, Write-Output
ParseErrors : 0

注意事项

  1. AST 是只读的:PowerShell 的 AST 对象模型是只读的,不能直接修改 AST 节点来改变代码。要实现代码修改(如重命名变量),需要在原始源码字符串上基于 AST 提供的位置偏移量进行文本替换,然后重新写入文件。

  2. 解析与执行分离:AST 解析仅进行语法分析,不会执行任何代码。这意味着即使脚本中包含危险操作(如 Remove-Item),解析过程也是完全安全的。但也因此无法获取运行时信息,如变量的实际值和类型推断。

  3. Token 与 AST 的区别ParseInput 同时返回 AST 树和 Token 列表。Token 是词法分析的最小单元(标识符、运算符、字符串等),而 AST 是语法层面的结构化表示。做变量重命名等精确文本替换时应使用 Token 的偏移量,做结构分析时应使用 AST 节点。

  4. 嵌套脚本的解析:对于通过 dot-sourcing. .\script.ps1)或 Invoke-Expression 动态加载的脚本,AST 无法自动追踪。如果需要分析完整的依赖链,必须递归解析所有引用的脚本文件。

  5. 性能考虑:对于超大脚本文件(数千行),FindAll() 使用递归遍历可能会产生大量对象。建议在 $predicate 回调中尽可能精确地过滤节点类型,避免不必要的全树遍历。如果只需要顶层元素,可以将第二个参数(searchNestedScriptBlocks)设为 $false

  6. 与 PSScriptAnalyzer 配合:本文演示了如何从零编写 AST 分析逻辑。在实际项目中,建议优先使用 PSScriptAnalyzer 的自定义规则框架,它封装了 AST 遍历的细节,提供了标准化的规则接口和输出格式,与 VS Code 扩展和 CI/CD 流水线的集成也更加方便。

PowerShell 技能连载 - Azure API Management 管理

适用于 PowerShell 7.0 及以上版本

在微服务架构中,API 网关是连接前端应用与后端服务的关键枢纽。Azure API Management(APIM)作为微软云平台的企业级 API 网关,提供了 API 发布、流量控制、安全认证、请求转换和用量分析等一站式能力。无论是对外暴露开放 API,还是在内部微服务之间做统一入口,APIM 都能胜任。

然而 APIM 的配置项非常庞杂——API 定义、操作策略、产品打包、订阅密钥、后端服务池、开发者门户,每一项都需要精确配置。在多环境(开发、测试、生产)部署场景下,手动在 Azure 门户中点选配置既容易出错,也难以做到版本追溯。

通过 PowerShell 的 Az.ApiManagement 模块,我们可以将 APIM 的全部配置写成脚本,实现基础设施即代码(IaC)。这不仅保证了环境之间的一致性,还能将配置纳入 Git 版本控制,配合 CI/CD 管线实现 API 管理的自动化交付。本文将从实例管理、策略配置和产品运营三个维度,展示如何用 PowerShell 高效管理 APIM。

APIM 实例与 API 管理

首先,我们来看如何创建 APIM 实例并导入 API 定义。下面的脚本展示了从创建实例到配置后端服务的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# 连接 Azure 账户
Connect-AzAccount -Subscription 'MySubscription'

# 定义变量
$resourceGroup = 'rg-apim-demo'
$location = 'eastasia'
$apimName = 'apim-demo-001'
$apiName = 'user-service-api'

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

# 创建 APIM 实例(开发者层级,适合测试)
$apim = New-AzApiManagement `
-ResourceGroupName $resourceGroup `
-Name $apimName `
-Location $location `
-Organization 'Vichamp Corp' `
-AdminEmail 'admin@vichamp.com' `
-Sku 'Developer' `
-Capacity 1

$context = $apim.Context

# 从 OpenAPI 规范导入 API
$openApiSpec = @'
{
"openapi": "3.0.1",
"info": { "title": "User Service", "version": "1.0.0" },
"paths": {
"/users": {
"get": {
"operationId": "list-users",
"responses": { "200": { "description": "OK" } }
},
"post": {
"operationId": "create-user",
"responses": { "201": { "description": "Created" } }
}
},
"/users/{id}": {
"get": {
"operationId": "get-user",
"responses": { "200": { "description": "OK" } }
}
}
}
}
'@

$specPath = Join-Path $env:TEMP 'user-service-openapi.json'
$openApiSpec | Set-Content -Path $specPath -Encoding utf8

# 导入 API,设置后端 URL
Import-AzApiManagementApi `
-Context $context `
-ApiId $apiName `
-SpecificationFormat 'OpenApiJson' `
-SpecificationPath $specPath `
-Path 'users' `
-ServiceUrl 'https://user-svc.internal.vichamp.com/api/v1'

# 配置后端服务(支持负载均衡和服务发现)
$backendId = 'user-service-backend'
New-AzApiManagementBackend `
-Context $context `
-BackendId $backendId `
-Url 'https://user-svc.internal.vichamp.com/api/v1' `
-Protocol 'http' `
-Description 'User microservice backend'

# 将 API 操作绑定到后端
Set-AzApiManagementApi `
-Context $context `
-ApiId $apiName `
-ApiIdSet $apiName `
-ServiceUrl 'https://user-svc.internal.vichamp.com/api/v1'

Write-Host "APIM 实例 '$apimName' 创建完成,API '$apiName' 已导入" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
确认
资源组 'rg-apim-demo' 已存在,是否覆盖?
[Y] 是 [N] 否 [S] 挂起 [?] 帮助 (默认值为“Y”):

ResourceId : /subscriptions/xxxx/resourceGroups/rg-apim-demo
Location : eastasia
ProvisioningState : Succeeded

APIM 实例 'apim-demo-001' 创建完成,API '$apiName' 已导入

策略配置与安全

APIM 的核心能力在于策略(Policy)。策略是一段 XML 配置,可以在请求和响应的各个阶段注入逻辑。下面展示如何配置速率限制、JWT 验证和请求转换策略:

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
$context = (Get-AzApiManagement -ResourceGroupName 'rg-apim-demo' -Name 'apim-demo-001').Context

# --- 策略 1:速率限制(API 级别) ---
$rateLimitPolicy = @'
<policies>
<inbound>
<rate-limit calls="100" renewal-period="60" />
<rate-limit-by-key calls="20" renewal-period="60"
counter-key="@(context.Request.IpAddress)" />
</inbound>
</policies>
'@

Set-AzApiManagementPolicy `
-Context $context `
-ApiId 'user-service-api' `
-PolicyValue $rateLimitPolicy

Write-Host "速率限制策略已应用:全局 100 次/分钟,单 IP 20 次/分钟" -ForegroundColor Cyan

# --- 策略 2:JWT 验证(操作级别) ---
$jwtPolicy = @'
<policies>
<inbound>
<validate-jwt header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized. Invalid token.">
<openid-config url="https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration" />
<required-claims>
<claim name="aud" match="all">
<value>api://user-service</value>
</claim>
<claim name="roles" match="any">
<value>API.Reader</value>
<value>API.Writer</value>
</claim>
</required-claims>
</validate-jwt>
</inbound>
</policies>
'@

Set-AzApiManagementPolicy `
-Context $context `
-ApiId 'user-service-api' `
-OperationId 'get-user' `
-PolicyValue $jwtPolicy

Write-Host "JWT 验证策略已应用到 get-user 操作" -ForegroundColor Cyan

# --- 策略 3:请求头转换与响应缓存 ---
$transformPolicy = @'
<policies>
<inbound>
<set-header name="X-Request-Source" exists-action="override">
<value>APIM-Gateway</value>
</set-header>
<set-header name="X-Correlation-Id" exists-action="skip">
<value>@(Guid.NewGuid().ToString())</value>
</set-header>
<rewrite-uri template="/users/{id}/profile?fields=basic" />
</inbound>
<outbound>
<cache-store duration="300" />
<set-header name="X-Response-Time" exists-action="override">
<value>@(DateTime.UtcNow.ToString("O"))</value>
</set-header>
</outbound>
</policies>
'@

Set-AzApiManagementPolicy `
-Context $context `
-ApiId 'user-service-api' `
-OperationId 'get-user' `
-PolicyValue $transformPolicy

Write-Host "请求转换与缓存策略已应用" -ForegroundColor Cyan

# 导出当前 API 的完整策略用于版本控制
$policyXml = Get-AzApiManagementPolicy `
-Context $context `
-ApiId 'user-service-api'

$policyXml.Save("$(Join-Path $PWD 'policies\user-service-api-policy.xml')")
Write-Host "策略已导出到文件,可纳入 Git 版本控制" -ForegroundColor Green

执行结果示例:

1
2
3
4
速率限制策略已应用:全局 100 次/分钟,单 IP 20 次/分钟
JWT 验证策略已应用到 get-user 操作
请求转换与缓存策略已应用
策略已导出到文件,可纳入 Git 版本控制

产品管理与开发者门户

APIM 中的”产品”(Product)是 API 的打包和发布单元。开发者通过订阅产品来获取 API 访问权限。下面展示产品配置、订阅管理和用量分析的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
$context = (Get-AzApiManagement -ResourceGroupName 'rg-apim-demo' -Name 'apim-demo-001').Context

# --- 创建产品并关联 API ---
$productId = 'user-service-basic'
$productTitle = 'User Service - Basic Tier'

New-AzApiManagementProduct `
-Context $context `
-ProductId $productId `
-Title $productTitle `
-Description '基础套餐:提供用户查询和创建功能,速率限制 100 次/分钟' `
-LegalTerm '使用本 API 即表示同意服务条款 v2.0' `
-SubscriptionRequired $true `
-ApprovalRequired $false `
-State 'Published'

# 将 API 添加到产品
Add-AzApiManagementApiToProduct `
-Context $context `
-ProductId $productId `
-ApiId 'user-service-api'

Write-Host "产品 '$productTitle' 已创建并发布" -ForegroundColor Cyan

# --- 创建开发者账户并生成订阅 ---
$userId = 'dev-user-001'
$email = 'developer@partner.com'

New-AzApiManagementUser `
-Context $context `
-UserId $userId `
-FirstName 'Alice' `
-LastName 'Chen' `
-Email $email `
-Password (ConvertTo-SecureString 'P@ssw0rd!' -AsPlainText -Force) `
-State 'Active'

# 为开发者创建订阅
$subscription = New-AzApiManagementSubscription `
-Context $context `
-ProductId $productId `
-Name "Alice 的订阅 - $productTitle" `
-UserId $userId `
-PrimaryKey (New-Guid).ToString() `
-SecondaryKey (New-Guid).ToString() `
-State 'Active'

Write-Host "开发者 $email 已订阅产品,订阅 ID: $($subscription.SubscriptionId)" -ForegroundColor Cyan

# --- 用量分析报告 ---
# 获取按 API 汇总的请求统计
$reportSasToken = Get-AzApiManagementSsoToken `
-ResourceGroupName 'rg-apim-demo' `
-Name 'apim-demo-001'

Write-Host "`n--- API 用量报告(最近 30 天)---" -ForegroundColor Yellow

$apis = Get-AzApiManagementApi -Context $context

foreach ($api in $apis) {
# 获取 API 级别的指标
$metrics = Get-AzApiManagementDiagnostic `
-Context $context `
-ApiId $api.ApiId `
-DiagnosticId 'applicationinsights' `
-ErrorAction SilentlyContinue

$operations = Get-AzApiManagementOperation -Context $context -ApiId $api.ApiId

[PSCustomObject]@{
API名称 = $api.Name
路径前缀 = $api.Path
操作数量 = $operations.Count
协议 = ($api.Protocols -join ', ')
后端URL = $api.ServiceUrl
状态 = $api.IsCurrent
}
}

# 导出订阅密钥列表(运维审计用)
$allSubscriptions = Get-AzApiManagementSubscription -Context $context

$subscriptionReport = $allSubscriptions | ForEach-Object {
[PSCustomObject]@{
订阅名称 = $_.Name
产品ID = $_.ProductId
创建时间 = $_.CreatedDate
状态 = $_.State
过期时间 = $_.EndDate
}
}

$subscriptionReport | Format-Table -AutoSize
Write-Host "共 $($subscriptionReport.Count) 个活跃订阅" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
产品 'User Service - Basic Tier' 已创建并发布
开发者 developer@partner.com 已订阅产品,订阅 ID: sub-20260206-001

--- API 用量报告(最近 30 天)---

API名称 路径前缀 操作数量 协议 后端URL 状态
------ -------- -------- ---- ------- ----
User Service users 3 https https://user-svc.internal.vichamp.com/api/v1 True

订阅名称 产品ID 创建时间 状态 过期时间
-------- ------ -------- ---- --------
Alice 的订阅 - User Service... user-service-basic 2026/2/6 10:30:00 Active
共 1 个活跃订阅

注意事项

  1. SKU 选择:开发者层级(Developer)仅用于非生产环境,不提供 SLA 保证。生产环境应选择基本(Basic)、标准(Standard)或高级(Premium)层级,高级层支持多区域部署和虚拟网络集成。
  2. 策略 XML 格式:APIM 策略是严格的 XML 文档,拼写错误或标签未闭合会导致整个 API 无法响应。建议在本地用 XML 校验工具验证后再部署,并将策略文件纳入 Git 版本控制。
  3. 订阅密钥安全New-AzApiManagementSubscription 生成的主密钥和次密钥是访问 API 的凭证,切勿硬编码在脚本中,应存储在 Azure Key Vault 或环境变量中。
  4. 策略继承顺序:APIM 策略按”全局 → 产品 → API → 操作”的层级继承,子级策略会覆盖父级同名的策略节点。配置时务必明确各层级的职责边界,避免策略冲突。
  5. 后端健康检查:生产环境建议配置后端服务池(Backend Pool)和健康探针(Health Probe),当某个后端实例不可用时 APIM 能自动切换,提高服务可用性。
  6. 成本监控:APIM 按层级和容量计费,高级层每个单元约 2,800 美元/月。建议在非工作时间缩减容量(Premium 层支持弹性扩缩),并设置 Azure 成本告警防止超支。

PowerShell 技能连载 - Web 数据采集

适用于 PowerShell 7.0 及以上版本

在运维和数据分析工作中,经常需要从网页上采集数据——监控页面上的状态信息、采集竞争对手的价格数据、抓取内部系统的报表。这些场景看似简单,但手动操作既耗时又容易出错,尤其是当数据源多、更新频繁时,人工采集几乎无法持续。

PowerShell 通过 Invoke-WebRequest 和内置的 HTML 解析能力,可以快速构建轻量级的数据采集脚本。与 Python 的 Scrapy 或 BeautifulSoup 相比,PowerShell 方案无需额外安装解释器,直接在 Windows 或跨平台环境中即可运行,特别适合已经在使用 PowerShell 进行运维自动化的团队。

本文将从基础的 HTML 解析入手,逐步介绍表单提交与认证采集,最后实现批量并发采集与数据清洗的完整方案,帮助你构建可靠的数据采集管道。

HTML 解析与数据提取

Invoke-WebRequest 返回的对象中包含一个 ParsedHtml 属性,但跨平台场景下更推荐使用正则表达式或 HTML Agility Pack 来解析。下面演示如何从一个示例页面中提取表格数据。

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
# 从示例页面采集表格数据
$url = "https://example.com/status"
$response = Invoke-WebRequest -Uri $url -UseBasicParsing

# 方法一:使用正则表达式提取 HTML 表格中的数据
$pattern = '<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>\s*<td>(.*?)</td>\s*</tr>'
$matches = [regex]::Matches($response.Content, $pattern)

$results = foreach ($m in $matches) {
[PSCustomObject]@{
ServerName = $m.Groups[1].Value.Trim()
Status = $m.Groups[2].Value.Trim()
ResponseMs = $m.Groups[3].Value.Trim()
}
}

$results | Format-Table -AutoSize

# 方法二:使用 HTML Agility Pack 进行 CSS 选择器查询
# 先安装 HTML Agility Pack
Install-Module -Name HtmlAgilityPack -Force -Scope CurrentUser -ErrorAction SilentlyContinue
Add-Type -Path (Join-Path $env:USERPROFILE ".nuget\packages\htmlagilitypack\1.11.72\lib\netstandard2.0\HtmlAgilityPack.dll") -ErrorAction SilentlyContinue

# 使用 HtmlDocument 解析
$doc = [HtmlAgilityPack.HtmlDocument]::new()
$doc.LoadHtml($response.Content)

# 通过 XPath 精确定位
$nodes = $doc.DocumentNode.SelectNodes('//table[@class="status-table"]//tr[position()>1]')
foreach ($row in $nodes) {
$cells = $row.SelectNodes('.//td')
[PSCustomObject]@{
ServerName = $cells[0].InnerText.Trim()
Status = $cells[1].InnerText.Trim()
ResponseMs = $cells[2].InnerText.Trim()
}
}

执行结果示例:

1
2
3
4
5
6
7
ServerName Status   ResponseMs
---------- ------ ----------
WEB-01 Online 45
WEB-02 Online 38
DB-01 Online 120
DB-02 Offline N/A
CACHE-01 Online 12

表单提交与认证采集

很多数据藏在需要登录的系统后面。PowerShell 的 WebRequestSession 对象可以自动管理 Cookie,模拟完整的登录流程后访问受保护的页面。

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
# 创建会话变量,用于保持登录状态
$loginUrl = "https://internal.example.com/login"
$reportUrl = "https://internal.example.com/report/daily"

# 第一步:获取登录页面(同时初始化 Session)
$loginPage = Invoke-WebRequest -Uri $loginUrl -SessionVariable session -UseBasicParsing

# 第二步:提取隐藏的表单字段(如 CSRF Token)
$csrfPattern = 'name="__RequestVerificationToken".*?value="([^"]+)"'
$csrfMatch = [regex]::Match($loginPage.Content, $csrfPattern)
$csrfToken = if ($csrfMatch.Success) { $csrfMatch.Groups[1].Value } else { "" }

# 第三步:提交登录表单
$loginBody = @{
username = "admin"
password = "P@ssw0rd123!"
__RequestVerificationToken = $csrfToken
RememberMe = "true"
}

$loginResponse = Invoke-WebRequest -Uri $loginUrl -Method POST -Body $loginBody -WebSession $session -UseBasicParsing

# 第四步:使用已认证的会话访问报表页面
$reportResponse = Invoke-WebRequest -Uri $reportUrl -WebSession $session -UseBasicParsing

# 第五步:从报表页面提取数据
$dataPattern = '<tr[^>]*>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d,.]+)</td>\s*<td>([\d,.]+)</td>\s*</tr>'
$dataMatches = [regex]::Matches($reportResponse.Content, $dataPattern)

$reportData = foreach ($m in $dataMatches) {
[PSCustomObject]@{
Date = $m.Groups[1].Value
Revenue = $m.Groups[2].Value
Cost = $m.Groups[3].Value
}
}

$reportData | Format-Table -AutoSize
Write-Host "`n采集完成,共获取 $($reportData.Count) 条记录"

执行结果示例:

1
2
3
4
5
6
7
8
9
Date       Revenue       Cost
---- ------- ----
2026-01-30 125,680.00 89,340.00
2026-01-31 132,450.00 91,200.00
2026-02-01 118,930.00 87,650.00
2026-02-02 141,200.00 93,100.00
2026-02-03 156,780.00 95,420.00

采集完成,共获取 5 条记录

批量采集与数据清洗

当需要从多个页面或多个数据源采集数据时,可以利用 PowerShell 的 ForEach-Object -Parallel 实现并发采集,然后对原始数据进行清洗和标准化处理,最后导出为结构化格式。

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
# 定义采集目标列表
$targets = @(
@{ Name = "北京"; Url = "https://api.example.com/weather/beijing" }
@{ Name = "上海"; Url = "https://api.example.com/weather/shanghai" }
@{ Name = "广州"; Url = "https://api.example.com/weather/guangzhou" }
@{ Name = "深圳"; Url = "https://api.example.com/weather/shenzhen" }
@{ Name = "成都"; Url = "https://api.example.com/weather/chengdu" }
)

# 并发采集数据(限制并发数为 3)
$rawData = $targets | ForEach-Object -ThrottleLimit 3 -Parallel {
$target = $_
try {
$response = Invoke-WebRequest -Uri $target.Url -UseBasicParsing -TimeoutSec 10
# 解析 JSON 响应
$json = $response.Content | ConvertFrom-Json
[PSCustomObject]@{
City = $target.Name
Temperature = $json.temperature
Humidity = $json.humidity
Wind = $json.wind
AQI = $json.aqi
Status = "Success"
ErrorMsg = ""
}
}
catch {
[PSCustomObject]@{
City = $target.Name
Temperature = $null
Humidity = $null
Wind = $null
AQI = $null
Status = "Failed"
ErrorMsg = $_.Exception.Message
}
}
}

# 数据清洗:过滤失败记录,标准化数值
$cleanData = $rawData | Where-Object { $_.Status -eq "Success" } | ForEach-Object {
# 温度转摄氏度(假设原始数据为华氏度)
$tempC = [math]::Round(($_.Temperature - 32) * 5 / 9, 1)
# 湿度取整
$humidity = [int]$_.Humidity

[PSCustomObject]@{
City = $_.City
TemperatureC = $tempC
HumidityPct = $humidity
WindSpeed = $_.Wind
AQI = [int]$_.AQI
AQILevel = switch ([int]$_.AQI) {
{ $_ -le 50 } { "优" }
{ $_ -le 100 } { "良" }
{ $_ -le 150 } { "轻度污染" }
{ $_ -le 200 } { "中度污染" }
default { "重度污染" }
}
CollectTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}

# 显示结果
$cleanData | Format-Table -AutoSize

# 导出到 CSV
$csvPath = Join-Path $env:USERPROFILE "Desktop\weather_data.csv"
$cleanData | Export-Csv -Path $csvPath -NoTypeInformation -Encoding Utf8
Write-Host "CSV 已导出至: $csvPath"

# 导出到 JSON
$jsonPath = Join-Path $env:USERPROFILE "Desktop\weather_data.json"
$cleanData | ConvertTo-Json -Depth 3 | Set-Content -Path $jsonPath -Encoding Utf8
Write-Host "JSON 已导出至: $jsonPath"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
City  TemperatureC HumidityPct WindSpeed AQI AQILevel CollectTime
---- ------------ ----------- --------- --- -------- -----------
北京 2.3 45 3.2m/s 82 良 2026-02-05 08:15:32
上海 6.8 62 4.1m/s 68 良 2026-02-05 08:15:33
广州 15.2 78 2.8m/s 55 良 2026-02-05 08:15:33
深圳 17.6 75 3.0m/s 48 优 2026-02-05 08:15:33
成都 8.1 70 1.5m/s 92 良 2026-02-05 08:15:34

CSV 已导出至: C:\Users\admin\Desktop\weather_data.csv
JSON 已导出至: C:\Users\admin\Desktop\weather_data.json

注意事项

  1. 遵守 robots.txt 和法律合规:采集前检查目标网站的 robots.txt 文件,尊重网站的爬取规则。对敏感数据或受版权保护的内容,务必获得授权后再采集,避免触犯法律。

  2. 请求频率控制:高频率请求可能对目标服务器造成压力,甚至触发 IP 封禁。建议在循环中加入 Start-Sleep -Milliseconds 500 等延迟,并发采集时将 ThrottleLimit 控制在 3-5 之间。

  3. 异常处理与重试机制:网络请求天然不稳定,务必使用 try/catch 包裹所有请求代码,并实现指数退避重试策略,确保脚本在偶发网络故障时不会中断。

  4. User-Agent 伪装:部分网站会拒绝默认的 PowerShell User-Agent。可以通过 -Headers @{ 'User-Agent' = 'Mozilla/5.0 ...' } 设置合理的浏览器标识,但这并不意味着可以绕过反爬机制去做不当采集。

  5. 编码与字符集处理:中文网页常见 GBK、GB2312 等编码,PowerShell 默认使用 UTF-8。采集后需要用 [System.Text.Encoding]::GetEncoding('GBK').GetString(...) 进行转码,否则会出现乱码。

  6. 数据验证与清洗:从网页提取的原始数据通常包含空白字符、HTML 实体(如 &amp;&nbsp;)和格式不一致的数值。建议封装一个 Invoke-DataClean 函数统一处理这些常见问题,确保导出数据的规范性。