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

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

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

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

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

仪表板 JSON 模板构建

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

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

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

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

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

[int]$RefreshIntervalSeconds = 300
)

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

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

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

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

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

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

执行结果示例:

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

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

指标告警与自动通知

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

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

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

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

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

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

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

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

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

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

$actionGroupObject = New-AzMetricAlertRuleV2ActionGroup `
-ActionGroupId $actionGroupId

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

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

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

执行结果示例:

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

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

多订阅仪表板批量部署

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 定义目标订阅列表和对应的资源配置
$subscriptions = @(
@{
SubscriptionId = 'aaaa1111-bbbb-2222-cccc-3333dddd4444'
Name = 'production'
ResourceGroup = 'rg-production'
VmPrefix = 'vm-prod'
AlertEmail = 'prod-ops@contoso.com'
}
@{
SubscriptionId = 'eeee5555-ffff-6666-gggg-7777hhhh8888'
Name = 'staging'
ResourceGroup = 'rg-staging'
VmPrefix = 'vm-stg'
AlertEmail = 'staging-ops@contoso.com'
}
@{
SubscriptionId = 'iiii9999-jjjj-0000-kkkk-1111llll2222'
Name = 'development'
ResourceGroup = 'rg-dev'
VmPrefix = 'vm-dev'
AlertEmail = 'dev-ops@contoso.com'
}
)

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

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

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

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

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

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

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

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

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

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

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

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

执行结果示例:

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

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

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

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

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

注意事项

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

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

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

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

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

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

PowerShell 技能连载 - Azure Log Analytics 查询

适用于 PowerShell 7.0 及以上版本

Azure Log Analytics 是 Azure Monitor 的核心数据收集和分析引擎,它从虚拟机、容器、应用服务、网络安全组等各种数据源汇聚海量日志和指标数据。面对每天数以 GB 计的日志流,仅在门户中手动查询远远不够——你需要将查询能力嵌入到自动化脚本中,才能实现真正的持续监控和快速响应。

Kusto Query Language(KQL)是 Log Analytics 的查询语言,语法简洁但功能强大,支持跨表关联、时间序列分析、正则匹配和统计聚合。通过 PowerShell 调用 Azure Monitor REST API 执行 KQL 查询,可以把日志分析无缝集成到运维工作流中——无论是性能趋势分析、安全事件调查还是容量规划,都能用脚本自动完成并生成报告。

本文将从三个方面展开实战:先搭建 Log Analytics 查询的基础函数,处理认证和结果解析;然后深入运维分析场景,包括性能趋势、错误统计和安全事件检索;最后构建定时查询与告警机制,实现日志监控的全自动化闭环。

Log Analytics 查询基础

执行 KQL 查询的第一步是获取工作区信息并调用 Azure Monitor Query API。下面的脚本封装了一个通用查询函数,支持连接指定工作区、执行 KQL 查询,并将返回的 JSON 结果转换为结构化的 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
function Invoke-LogAnalyticsQuery {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceId,

[Parameter(Mandatory = $true)]
[string]$Query,

[Parameter(Mandatory = $false)]
[datetime]$TimeRangeStart = (Get-Date).AddHours(-24),

[Parameter(Mandatory = $false)]
[datetime]$TimeRangeEnd = (Get-Date),

[Parameter(Mandatory = $false)]
[int]$MaxResults = 10000
)

$apiVersion = "2024-02-01"
$uri = "/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.OperationalInsights/workspaces/{2}/query?api-version={3}" -f `
$script:SubscriptionId, $script:ResourceGroupName, $script:WorkspaceName, $apiVersion

# 使用 Invoke-AzRestMethod 直接调用 REST API
$body = @{
query = $Query
timespan = "$($TimeRangeStart.ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($TimeRangeEnd.ToString('yyyy-MM-ddTHH:mm:ssZ'))"
} | ConvertTo-Json

$response = Invoke-AzRestMethod -Path $uri -Method POST -Payload $body

if ($response.StatusCode -ne 200) {
Write-Error "查询失败 (HTTP $($response.StatusCode)): $($response.Content)"
return
}

$result = $response.Content | ConvertFrom-Json

# 解析表格结果为 PowerShell 对象数组
$table = $result.tables[0]
$columns = $table.columns.name
$rows = $table.rows

$output = foreach ($row in $rows) {
$obj = [ordered]@{}
for ($i = 0; $i -lt $columns.Count; $i++) {
$obj[$columns[$i]] = $row[$i]
}
[PSCustomObject]$obj
}

# 输出摘要信息
Write-Host ("查询完成,返回 {0} 行数据(耗时 {1}ms)" -f $output.Count, $result.stats.queryTimeInMs) -ForegroundColor Green
Write-Host "查询语句: $Query" -ForegroundColor DarkGray

return $output
}

# 设置工作区上下文
$script:SubscriptionId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
$script:ResourceGroupName = "rg-monitoring"
$script:WorkspaceName = "law-production"

# 确保已连接到正确的订阅
$null = Set-AzContext -SubscriptionId $script:SubscriptionId

# 查询最近 24 小时的 Heartbeat 数据,确认各服务器在线状态
$query = @"
Heartbeat
| summarize LastSeen=max(TimeGenerated) by Computer, OSType
| order by LastSeen desc
"@

$heartbeat = Invoke-LogAnalyticsQuery -WorkspaceId $script:WorkspaceId -Query $query
$heartbeat | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
查询完成,返回 12 行数据(耗时 342ms)
查询语句: Heartbeat | summarize LastSeen=max(TimeGenerated) by Computer, OSType

Computer OSType LastSeen
-------- ------ --------
prod-web-01 Linux 2026-03-24T07:58:32Z
prod-web-02 Linux 2026-03-24T07:58:28Z
prod-db-01 Linux 2026-03-24T07:57:45Z
prod-app-01 Windows 2026-03-24T07:58:10Z
prod-app-02 Windows 2026-03-24T07:58:05Z
staging-web-01 Linux 2026-03-24T07:55:12Z
dev-box-01 Linux 2026-03-24T07:30:00Z

Heartbeat 查询是 Log Analytics 的入门必备——它告诉你哪些服务器正在正常上报日志。如果某台机器的 LastSeen 时间明显落后,说明 Log Analytics Agent 可能已停止工作或网络中断。这个函数封装了 API 调用和结果解析的细节,后续所有查询都可以直接复用。

运维分析查询

掌握基础查询后,可以构建面向运维场景的分析脚本。下面的函数集成了三个常见的运维分析维度:性能趋势分析(CPU/内存)、错误日志统计、以及安全事件检索,每个维度都通过精心编写的 KQL 语句实现高效聚合。

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
function Get-LogAnalyticsOpsReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceId,

[Parameter(Mandatory = $false)]
[int]$HoursBack = 24
)

$startTime = (Get-Date).AddHours(-$HoursBack)
$endTime = Get-Date

Write-Host ("=" * 70) -ForegroundColor Cyan
Write-Host "Log Analytics 运维分析报告" -ForegroundColor Cyan
Write-Host "时间范围: $($startTime.ToString('yyyy-MM-dd HH:mm')) ~ $($endTime.ToString('yyyy-MM-dd HH:mm'))"
Write-Host ("=" * 70) -ForegroundColor Cyan

# 1. 性能趋势分析 - CPU 使用率超过 80% 的虚拟机
Write-Host "`n[1] CPU 高负载分析 (使用率 > 80%)" -ForegroundColor Yellow
$cpuQuery = @"
Perf
| where TimeGenerated > ago($($HoursBack)h)
| where ObjectName == 'Processor' and CounterName == '% Processor Time'
| where InstanceName == '_Total'
| summarize AvgCPU=avg(CounterValue), MaxCPU=max(CounterValue), P95CPU=percentile(CounterValue, 95) by Computer
| where AvgCPU > 80 or MaxCPU > 95
| order by AvgCPU desc
| project Computer, AvgCPU=round(AvgCPU, 1), MaxCPU=round(MaxCPU, 1), P95CPU=round(P95CPU, 1)
"@
$cpuResult = Invoke-LogAnalyticsQuery -WorkspaceId $WorkspaceId -Query $cpuQuery `
-TimeRangeStart $startTime -TimeRangeEnd $endTime
$cpuResult | Format-Table -AutoSize

# 2. 错误日志统计 - 按来源和计算机分组
Write-Host "`n[2] 错误事件统计 (EventLevelName == 'error')" -ForegroundColor Yellow
$errorQuery = @"
Event
| where TimeGenerated > ago($($HoursBack)h)
| where EventLevelName == 'error'
| summarize ErrorCount=count() by Computer, Source, EventID
| order by ErrorCount desc
| project Computer, Source, EventID, ErrorCount
"@
$errorResult = Invoke-LogAnalyticsQuery -WorkspaceId $WorkspaceId -Query $errorQuery `
-TimeRangeStart $startTime -TimeRangeEnd $endTime
$errorResult | Format-Table -AutoSize

# 3. 安全事件检索 - 登录失败和权限变更
Write-Host "`n[3] 安全事件分析 (登录失败 + 权限变更)" -ForegroundColor Yellow
$securityQuery = @"
SecurityEvent
| where TimeGenerated > ago($($HoursBack)h)
| where EventID in (4625, 4732, 4733, 4672)
| extend EventType = case(
EventID == 4625, "LoginFailed",
EventID == 4732, "MemberAddedToGroup",
EventID == 4733, "MemberRemovedFromGroup",
EventID == 4672, "PrivilegeLogon",
"Unknown"
)
| summarize EventCount=count() by EventType, Computer, TargetUserName
| order by EventCount desc
| project EventType, Computer, TargetUserName, EventCount
"@
$securityResult = Invoke-LogAnalyticsQuery -WorkspaceId $WorkspaceId -Query $securityQuery `
-TimeRangeStart $startTime -TimeRangeEnd $endTime
$securityResult | Format-Table -AutoSize

# 汇总信息
Write-Host "`n--- 汇总 ---" -ForegroundColor Cyan
Write-Host "CPU 高负载主机: $($cpuResult.Count) 台"
Write-Host "错误事件类型: $(($errorResult | Measure-Object).Count) 种"
Write-Host "安全事件总数: $(($securityResult | Measure-Object -Property EventCount -Sum).Sum) 条"

return @{
CPU = $cpuResult
Errors = $errorResult
Security = $securityResult
}
}

# 生成最近 24 小时的运维分析报告
$report = Get-LogAnalyticsOpsReport -WorkspaceId "law-production-01" -HoursBack 24

执行结果示例:

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
======================================================================
Log Analytics 运维分析报告
时间范围: 2026-03-23 08:00 ~ 2026-03-24 08:00
======================================================================

[1] CPU 高负载分析 (使用率 > 80%)

查询完成,返回 3 行数据(耗时 1205ms)
Computer AvgCPU MaxCPU P95CPU
-------- ------ ------ ------
prod-app-01 87.3 99.2 94.6
prod-web-02 82.1 96.8 91.3
prod-db-01 80.5 93.4 88.7

[2] 错误事件统计 (EventLevelName == 'error')

查询完成,返回 5 行数据(耗时 876ms)
Computer Source EventID ErrorCount
-------- ------ ------- ----------
prod-app-01 Application Error 1000 234
prod-web-01 WAS 5009 156
prod-db-01 MSSQL 18456 89
prod-web-02 IIS AspNet Core 1001 67
staging-web-01 Docker 1001 42

[3] 安全事件分析 (登录失败 + 权限变更)

查询完成,返回 4 行数据(耗时 1534ms)
EventType Computer TargetUserName EventCount
--------- -------- -------------- ----------
LoginFailed prod-app-01 admin 342
PrivilegeLogon prod-web-01 SYSTEM 128
LoginFailed prod-db-01 sa 87
MemberAddedToGroup prod-app-01 svc_deploy 5

--- 汇总 ---
CPU 高负载主机: 3 台
错误事件类型: 5 种
安全事件总数: 562 条

运维报告揭示了几个值得关注的信号:prod-app-01 的 CPU 平均使用率 87.3% 且伴有大量登录失败事件(342 次 admin 账户登录失败),这很可能是暴力破解攻击与资源争用的组合;prod-db-01 出现 89 次 MSSQL 18456 错误(登录认证失败)也印证了这一点。安全事件中的 MemberAddedToGroup 记录表明有 5 次 svc_deploy 账户被加入特权组的操作,需要确认是否为授权变更。

自动化报告与告警

将查询结果导出为报告和设置阈值告警是实现运维闭环的关键步骤。下面的脚本实现了定时查询、阈值判断、多渠道通知(邮件 + Webhook)以及 HTML 报告生成,可以直接集成到 Azure Automation Runbook 中作为每日巡检任务运行。

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

[Parameter(Mandatory = $false)]
[string]$ReportOutputPath = "$HOME/LogAnalyticsReport_$(Get-Date -Format 'yyyyMMdd').html",

[Parameter(Mandatory = $false)]
[string[]]$NotifyEmails = @("ops-team@contoso.com"),

[Parameter(Mandatory = $false)]
[string]$WebhookUrl,

[Parameter(Mandatory = $false)]
[double]$CpuThreshold = 85,

[Parameter(Mandatory = $false)]
[int]$LoginFailThreshold = 50
)

$alerts = @()
$now = Get-Date

# 查询 CPU 使用率
$cpuQuery = @"
Perf
| where TimeGenerated > ago(1h)
| where ObjectName == 'Processor' and CounterName == '% Processor Time'
| where InstanceName == '_Total'
| summarize AvgCPU=avg(CounterValue) by Computer
| where AvgCPU > $CpuThreshold
| project Computer, AvgCPU=round(AvgCPU, 1)
"@
$cpuAlerts = Invoke-LogAnalyticsQuery -WorkspaceId $WorkspaceId -Query $cpuQuery
foreach ($item in $cpuAlerts) {
$alerts += [ordered]@{
Severity = "Warning"
Category = "CPU"
Computer = $item.Computer
Value = "$($item.AvgCPU)%"
Threshold = "$CpuThreshold%"
Timestamp = $now.ToString("yyyy-MM-dd HH:mm:ss")
Description = "CPU 使用率持续超过阈值"
}
}

# 查询登录失败次数
$loginQuery = @"
SecurityEvent
| where TimeGenerated > ago(1h)
| where EventID == 4625
| summarize FailCount=count() by Computer, TargetUserName
| where FailCount > $LoginFailThreshold
| project Computer, TargetUserName, FailCount
"@
$loginAlerts = Invoke-LogAnalyticsQuery -WorkspaceId $WorkspaceId -Query $loginQuery
foreach ($item in $loginAlerts) {
$alerts += [ordered]@{
Severity = "Critical"
Category = "Security"
Computer = $item.Computer
Value = "$($item.FailCount) 次"
Threshold = "$LoginFailThreshold 次"
Timestamp = $now.ToString("yyyy-MM-dd HH:mm:ss")
Description = "账户 $($item.TargetUserName) 登录失败次数异常"
}
}

# 生成 HTML 报告
$htmlBody = @"
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Log Analytics 告警报告</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
table { border-collapse: collapse; width: 100%; background: white; }
th { background: #0078d4; color: white; padding: 10px; text-align: left; }
td { padding: 8px 10px; border-bottom: 1px solid #eee; }
.Warning { color: #ff8c00; font-weight: bold; }
.Critical { color: #d13438; font-weight: bold; }
</style>
</head>
<body>
<h1>Log Analytics 告警报告</h1>
<p>生成时间: $($now.ToString('yyyy-MM-dd HH:mm:ss'))</p>
<p>告警总数: $($alerts.Count)</p>
<table>
<tr><th>级别</th><th>类别</th><th>计算机</th><th>当前值</th><th>阈值</th><th>描述</th><th>时间</th></tr>
"@

foreach ($alert in $alerts) {
$cssClass = $alert.Severity
$htmlBody += "<tr><td class='$cssClass'>$($alert.Severity)</td><td>$($alert.Category)</td><td>$($alert.Computer)</td><td>$($alert.Value)</td><td>$($alert.Threshold)</td><td>$($alert.Description)</td><td>$($alert.Timestamp)</td></tr>"
}

$htmlBody += "</table></body></html>"
$htmlBody | Out-File -FilePath $ReportOutputPath -Encoding utf8
Write-Host "HTML 报告已保存: $ReportOutputPath" -ForegroundColor Green

# 发送 Webhook 通知(如 Teams/Slack)
if ($WebhookUrl -and $alerts.Count -gt 0) {
$webhookBody = @{
text = "[Log Analytics 告警] 检测到 $($alerts.Count) 个异常项,请查看报告: $ReportOutputPath"
} | ConvertTo-Json
try {
Invoke-RestMethod -Uri $WebhookUrl -Method POST -Body $webhookBody -ContentType "application/json"
Write-Host "Webhook 通知已发送" -ForegroundColor Green
}
catch {
Write-Warning "Webhook 发送失败: $($_.Exception.Message)"
}
}

# 输出告警摘要
if ($alerts.Count -gt 0) {
Write-Host "`n触发告警 $($alerts.Count) 项:" -ForegroundColor Red
foreach ($alert in $alerts) {
$color = if ($alert.Severity -eq "Critical") { "Red" } else { "Yellow" }
Write-Host (" [{0}] {1} - {2} ({3})" -f $alert.Severity, $alert.Category, $alert.Computer, $alert.Value) -ForegroundColor $color
}
}
else {
Write-Host "`n所有指标正常,无告警触发" -ForegroundColor Green
}

return $alerts
}

# 执行告警检查并生成报告
$alertResult = Send-LogAnalyticsAlert `
-WorkspaceId "law-production-01" `
-ReportOutputPath "$HOME/LogAnalyticsReport_$(Get-Date -Format 'yyyyMMdd').html" `
-NotifyEmails @("ops-team@contoso.com", "security@contoso.com") `
-WebhookUrl "https://contoso.webhook.office.com/webhookb2/xxx" `
-CpuThreshold 85 `
-LoginFailThreshold 50

# 在 Azure Automation Runbook 中可以配合定时触发使用:
# 每小时运行一次,自动检测 CPU 和安全异常

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
查询完成,返回 2 行数据(耗时 987ms)
查询语句: Perf | where TimeGenerated > ago(1h) ...
查询完成,返回 1 行数据(耗时 1102ms)
查询语句: SecurityEvent | where TimeGenerated > ago(1h) ...
HTML 报告已保存: /home/admin/LogAnalyticsReport_20260324.html
Webhook 通知已发送

触发告警 3 项:
[Warning] CPU - prod-app-01 (87.3%)
[Warning] CPU - prod-web-02 (86.1%)
[Critical] Security - prod-app-01 (342 次)

告警脚本将查询、判断、通知和报告融为一体。CPU 超标的 Warning 告警提示运维团队关注资源瓶颈,而 Security 类别的 Critical 告警(342 次登录失败)则需要立即响应。HTML 报告以表格形式清晰呈现所有异常项,适合通过邮件分发或存档。Webhook 通知则确保 Teams 或 Slack 频道中的值班人员第一时间收到告警推送。

注意事项

  1. API 版本兼容性:本文使用 Azure Monitor Query API 版本 2024-02-01,Azure 团队会定期发布新版本。如果脚本返回 400 错误,请查阅 Azure REST API 文档确认当前可用的最新稳定版本,更新 api-version 参数即可。

  2. 查询性能优化:KQL 查询应始终在语句开头使用 TimeGenerated 过滤条件缩小数据范围。避免对大表做全表扫描,善用 summarizeproject 减少Returned列数。对于高频查询场景,建议将结果缓存到本地变量中复用。

  3. 权限与认证:查询 Log Analytics 数据至少需要工作区级别的 Reader 角色。如果在 Azure Automation Runbook 中运行,推荐使用 Managed Identity 而非服务主体密钥,避免凭据泄露风险。Runbook 托管身份只需分配 Log Analytics Reader 角色即可。

  4. 数据保留与成本:Log Analytics 工作区的默认数据保留期为 30 天,最长可配置 730 天。保留期越长存储成本越高,查询大时间范围数据也会消耗更多计算资源。建议根据合规要求合理设置保留策略,历史数据可导出到 Storage Account 做冷存储。

  5. 告警阈值调优:初始阈值不宜设置过严,否则会产生大量误报导致告警疲劳。建议先以宽松阈值运行一周,收集基线数据后再逐步收紧。对于不同环境(生产/测试/开发)应设置不同的阈值,避免开发环境的正常波动触发生产级别的告警。

  6. 敏感数据脱敏:查询结果中可能包含用户名、IP 地址、SQL 语句等敏感信息。在生成 HTML 报告或发送 Webhook 通知时,务必对敏感字段做脱敏处理。报告文件也应存储在受限访问的路径下,避免未授权人员查看。

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 及以上版本

春节长假是万家团圆的时刻,但对于 IT 运维团队来说,系统不会因为放假而停止运行。服务器、数据库、网络设备依然需要有人关注,而值班人员往往捉襟见肘——用最少的人力覆盖最长的假期,成为每年春节前的经典难题。

传统做法是安排轮班表,让值班人员定时登录系统查看状态。这种方式不仅效率低下,而且容易因为人为疏忽而遗漏关键告警。更理想的做法是构建一套自动化值守系统,让脚本替人完成日常巡检、故障处理和告警推送,值班人员只需要在真正出现异常时介入。

PowerShell 凭借其对 Windows 和 Linux(通过 PowerShell Core)的广泛支持、丰富的远程管理能力以及与 .NET 的深度融合,非常适合承担这个角色。本文将从监控系统、自动修复、告警通知三个方面,手把手搭建一个春节假期自动化值守方案。

假期值守监控系统

首先需要一套主动巡检机制,定期检查服务器的关键指标。以下脚本实现了磁盘空间、CPU 利用率和内存使用率的阈值检测,并将结果汇总为结构化对象,便于后续处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
function Start-HolidayWatch {
param(
[string[]]$ComputerName = $env:COMPUTERNAME,
[double]$DiskThresholdPercent = 10,
[double]$CpuThresholdPercent = 90,
[double]$MemoryThresholdPercent = 90,
[pscredential]$Credential
)

$results = foreach ($computer in $ComputerName) {
$params = @{
ComputerName = $computer
ErrorAction = 'Stop'
}
if ($Credential) { $params.Credential = $Credential }

try {
# 获取操作系统信息
$os = Get-CimInstance Win32_OperatingSystem @params
$freeMemoryPercent = [math]::Round(
($os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100, 2
)
$usedMemoryPercent = [math]::Round(100 - $freeMemoryPercent, 2)

# 获取磁盘信息
$disks = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' @params
$diskAlerts = foreach ($disk in $disks) {
$freePercent = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 2)
if ($freePercent -lt $DiskThresholdPercent) {
[PSCustomObject]@{
Drive = $disk.DeviceID
FreePercent = $freePercent
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
Status = 'WARNING'
}
}
}

# 获取 CPU 使用率(采样 2 秒)
$cpu = Get-CimInstance Win32_Processor @params
$cpuPercent = [math]::Round(
($cpu | Measure-Object -Property LoadPercentage -Average).Average, 2
)

[PSCustomObject]@{
Computer = $computer
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
CPUPercent = $cpuPercent
MemoryPercent = $usedMemoryPercent
DiskAlerts = $diskAlerts
CPUStatus = if ($cpuPercent -gt $CpuThresholdPercent) { 'WARNING' } else { 'OK' }
MemoryStatus = if ($usedMemoryPercent -gt $MemoryThresholdPercent) { 'WARNING' } else { 'OK' }
OverallStatus = if ($cpuPercent -gt $CpuThresholdPercent -or
$usedMemoryPercent -gt $MemoryThresholdPercent -or $diskAlerts) {
'ALERT'
} else { 'OK' }
}
}
catch {
[PSCustomObject]@{
Computer = $computer
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
OverallStatus = 'ERROR'
ErrorMessage = $_.Exception.Message
}
}
}

# 汇总报告
$alertCount = ($results | Where-Object OverallStatus -eq 'ALERT').Count
$errorCount = ($results | Where-Object OverallStatus -eq 'ERROR').Count

[PSCustomObject]@{
ScanTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
TotalHosts = $ComputerName.Count
Alerts = $alertCount
Errors = $errorCount
Details = $results
}
}

# 执行监控——可以配合计划任务每 15 分钟运行一次
$watchParams = @{
ComputerName = 'SRV-WEB01', 'SRV-DB01', 'SRV-APP01'
DiskThresholdPercent = 15
CpuThresholdPercent = 85
MemoryThresholdPercent = 90
}
$report = Start-HolidayWatch @watchParams
$report.Details | Format-Table Computer, CPUPercent, MemoryPercent, CPUStatus, MemoryStatus, OverallStatus -AutoSize

执行结果示例:

1
2
3
4
5
Computer  CPUPercent MemoryPercent CPUStatus MemoryStatus OverallStatus
-------- ---------- ------------- --------- ------------ -------------
SRV-WEB01 12.5 62.30 OK OK OK
SRV-DB01 45.8 78.15 OK OK OK
SRV-APP01 91.2 92.50 WARNING WARNING ALERT

自动修复与应急响应

监控只是第一步,更高级的玩法是让系统自动处理常见故障。下面的脚本展示了服务自动重启、日志清理和磁盘空间释放三种应急操作,每种操作都有日志记录和回滚机制。

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
function Invoke-AutoRemediation {
param(
[string]$ComputerName = $env:COMPUTERNAME,
[string[]]$CriticalServices = @('W3SVC', 'MSSQLSERVER', 'WinRM'),
[double]$LogCleanupThresholdGB = 2,
[string]$LogPath = 'C:\Logs',
[string]$TranscriptPath = 'C:\HolidayWatch\Remediation.log'
)

# 记录所有操作
Start-Transcript -Path $TranscriptPath -Append -Force
$actions = @()

foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ComputerName $ComputerName -ErrorAction Stop
if ($service.Status -ne 'Running') {
Write-Warning "服务 [$svc] 状态异常: $($service.Status),尝试启动..."
$service | Start-Service -ErrorAction Stop
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'RestartService'
Target = $svc
Result = 'SUCCESS'
}
Write-Host "服务 [$svc] 已成功启动" -ForegroundColor Green
}
}
catch {
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'RestartService'
Target = $svc
Result = "FAILED: $($_.Exception.Message)"
}
Write-Error "服务 [$svc] 启动失败: $($_.Exception.Message)"
}
}

# 清理旧日志文件
if (Test-Path $LogPath) {
$oldLogs = Get-ChildItem -Path $LogPath -Recurse -File |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }

$totalSize = ($oldLogs | Measure-Object -Property Length -Sum).Sum / 1GB
if ($totalSize -gt $LogCleanupThresholdGB) {
Write-Warning "日志目录占用 $([math]::Round($totalSize, 2)) GB,超过阈值,开始清理..."
$oldLogs | Remove-Item -Force -ErrorAction SilentlyContinue
$freed = [math]::Round($totalSize, 2)
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'CleanLogs'
Target = $LogPath
Result = "SUCCESS - 释放 ${freed} GB"
}
}
}

# 磁盘空间应急释放:清理临时文件和回收站
$systemDrive = Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"'
$freePercent = [math]::Round(($systemDrive.FreeSpace / $systemDrive.Size) * 100, 2)

if ($freePercent -lt 15) {
Write-Warning "C 盘剩余空间仅 $freePercent%,执行应急清理..."
$tempPaths = @(
"$env:TEMP\*",
'C:\Windows\Temp\*',
'C:\Windows\SoftwareDistribution\Download\*'
)
foreach ($path in $tempPaths) {
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
}
# 清理回收站
Clear-RecycleBin -DriveLetter C -Force -ErrorAction SilentlyContinue

$newFree = [math]::Round(
((Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"').FreeSpace /
(Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"').Size) * 100, 2
)
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'EmergencyDiskCleanup'
Target = 'C:'
Result = "释放空间: $freePercent% -> $newFree%"
}
}

Stop-Transcript
return $actions
}

# 检测到告警后自动触发修复
$report = Start-HolidayWatch -ComputerName 'SRV-APP01'
if ($report.Details.OverallStatus -eq 'ALERT') {
Write-Host "检测到告警,启动自动修复流程..." -ForegroundColor Yellow
$remediation = Invoke-AutoRemediation -ComputerName 'SRV-APP01'
$remediation | Format-Table Time, Action, Target, Result -AutoSize
}

执行结果示例:

1
2
3
4
5
6
检测到告警,启动自动修复流程...
Time Action Target Result
---- ------ ------ ------
14:32 RestartService W3SVC SUCCESS
14:33 CleanLogs C:\Logs SUCCESS - 释放 3.45 GB
14:34 EmergencyDiskCleanup C: 释放空间: 11.20% -> 18.65%

告警通知与值班管理

监控系统发现问题、自动修复尝试完毕后,需要及时通知值班人员。以下脚本集成了邮件通知、企业微信 Webhook 和钉钉 Webhook 三种告警通道,并支持值班轮换和告警升级机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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
function Send-HolidayAlert {
param(
[Parameter(Mandatory)]
[string]$Title,

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

[ValidateSet('Mail', 'WeCom', 'DingTalk', 'All')]
[string]$Channel = 'All',

# 邮件参数
[string]$SmtpServer = 'smtp.company.com',
[int]$SmtpPort = 587,
[string]$MailFrom = 'ops-holiday@company.com',
[string[]]$MailTo = @('oncall@company.com'),

# Webhook URL
[string]$WeComWebhookUrl,
[string]$DingTalkWebhookUrl,

# 告警级别
[ValidateSet('Info', 'Warning', 'Critical')]
[string]$Severity = 'Warning'
)

$severityEmoji = @{
Info = '[INFO]'
Warning = '[WARN]'
Critical = '[CRIT]'
}
$prefix = $severityEmoji[$Severity]
$body = "$prefix $Title`n`n$Message`n`n时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

# 邮件通知
if ($Channel -in 'Mail', 'All') {
try {
Send-MailMessage -From $MailFrom -To $MailTo -Subject "$prefix $Title" `
-Body $body -SmtpServer $SmtpServer -Port $SmtpPort -Encoding UTF8
Write-Host "邮件告警已发送至: $($MailTo -join ', ')" -ForegroundColor Green
}
catch {
Write-Warning "邮件发送失败: $($_.Exception.Message)"
}
}

# 企业微信通知
if ($Channel -in 'WeCom', 'All' -and $WeComWebhookUrl) {
$wecomBody = @{
msgtype = 'text'
text = @{ content = $body }
} | ConvertTo-Json -Compress

try {
Invoke-RestMethod -Uri $WeComWebhookUrl -Method Post `
-Body $wecomBody -ContentType 'application/json' | Out-Null
Write-Host '企业微信告警已发送' -ForegroundColor Green
}
catch {
Write-Warning "企业微信发送失败: $($_.Exception.Message)"
}
}

# 钉钉通知
if ($Channel -in 'DingTalk', 'All' -and $DingTalkWebhookUrl) {
$dingBody = @{
msgtype = 'text'
text = @{ content = $body }
} | ConvertTo-Json -Compress

try {
Invoke-RestMethod -Uri $DingTalkWebhookUrl -Method Post `
-Body $dingBody -ContentType 'application/json' | Out-Null
Write-Host '钉钉告警已发送' -ForegroundColor Green
}
catch {
Write-Warning "钉钉发送失败: $($_.Exception.Message)"
}
}
}

function Get-CurrentOnCall {
param(
[hashtable]$Schedule = @{
'2026-02-08' = '张三', '2026-02-09' = '张三'
'2026-02-10' = '李四', '2026-02-11' = '李四'
'2026-02-12' = '王五', '2026-02-13' = '王五'
'2026-02-14' = '张三'
},
[hashtable]$Contacts = @{
'张三' = @{ Email = 'zhangsan@company.com'; Phone = '138-0001-0001' }
'李四' = @{ Email = 'lisi@company.com'; Phone = '138-0002-0002' }
'王五' = @{ Email = 'wangwu@company.com'; Phone = '138-0003-0003' }
}
)

$today = Get-Date -Format 'yyyy-MM-dd'
$person = $Schedule[$today]
if (-not $person) {
# 如果当天没有排班,查找最近的一天
$nearest = $Schedule.Keys | Sort-Object | Where-Object { $_ -le $today } | Select-Object -Last 1
$person = $Schedule[$nearest]
}

[PSCustomObject]@{
Date = $today
OnCall = $person
Email = $Contacts[$person].Email
Phone = $Contacts[$person].Phone
}
}

# 告警升级:Critical 级别同时通知值班人员和运维经理
$onCall = Get-CurrentOnCall
$alertParams = @{
Title = 'SRV-APP01 CPU 使用率持续超过 90%'
Message = "服务器 SRV-APP01 CPU 使用率 91.2%,内存使用率 92.5%。`n自动修复已尝试重启服务。`n当前值班: $($onCall.OnCall) ($($onCall.Phone))"
Channel = 'All'
Severity = 'Critical'
MailTo = @($onCall.Email, 'ops-manager@company.com')
}
Send-HolidayAlert @alertParams

执行结果示例:

1
2
3
4
5
6
7
邮件告警已发送至: zhangsan@company.com, ops-manager@company.com
企业微信告警已发送
钉钉告警已发送

Date OnCall Email Phone
---- ------ ----- -----
2026-02-08 张三 zhangsan@company.com 138-0001-0001

完整部署建议

将以上三个模块整合后,可以创建一个计划任务,在假期期间每 15 分钟自动执行一轮巡检。核心逻辑如下:

1
2
3
4
5
# Deploy-HolidayWatch.ps1 - 注册计划任务
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument '-File "C:\HolidayWatch\Run-Watch.ps1"'
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15)
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName 'HolidayWatch-2026' -Action $action -Trigger $trigger -Settings $settings -RunLevel Highest -Description '春节假期自动化值守任务'

注意事项

  1. 凭据安全:远程管理使用的凭据应存储在 Windows 凭据管理器或 Azure Key Vault 中,切勿以明文形式写在脚本里。可以使用 Get-Credential 交互式获取,或通过 Export-Clixml 加密存储。
  2. 网络可达性:确保执行脚本的跳板机与目标服务器之间网络畅通,WinRM(端口 5985/5986)或 SSH 已正确配置并允许远程连接。
  3. 告警风暴防护:设置告警冷却时间(如同一告警 30 分钟内不重复发送),避免因瞬时抖动产生大量重复通知淹没值班人员。
  4. 日志持久化:所有巡检和修复操作必须记录到持久化日志文件,建议同时写入本地文件和集中日志平台(如 ELK),以便假期结束后复盘。
  5. 自动修复边界:自动修复只应处理已知的安全操作(如重启服务、清理临时文件),切勿让脚本自动执行删除数据库、重启服务器等高风险操作。
  6. 节前演练:在放假前至少进行一次完整的端到端演练,包括模拟告警触发、自动修复执行、通知送达,确保每个环节都能正常工作。

PowerShell 技能连载 - Grafana 仪表板集成

适用于 PowerShell 7.0 及以上版本

在 DevOps 和 SRE 实践中,Grafana 已经成为基础设施和应用监控可视化的事实标准。通过丰富的仪表板和告警规则,运维团队可以实时洞察系统健康状态。然而,当需要管理大量仪表板、在不同环境间迁移配置、或者将监控数据与其他系统联动时,手动操作 Grafana Web 界面效率低下且难以保持一致性。

Grafana 提供了功能完善的 HTTP API,PowerShell 天然擅长与 REST API 交互。两者的结合使自动化仪表板管理成为可能:批量创建标准化的监控面板、在不同 Grafana 实例之间同步仪表板配置、定期备份仪表板定义、以及基于外部数据源动态生成面板。这种脚本化的管理方式特别适合多环境、多团队的运维场景。

本文将介绍如何使用 PowerShell 调用 Grafana HTTP API,实现仪表板的查询、创建、导出备份和批量管理操作。

连接 Grafana 并获取仪表板列表

Grafana 的 HTTP API 使用基本认证或 API Token 进行身份验证。以下代码展示了如何封装连接参数,并列出所有仪表板的基本信息。

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
# Grafana 连接配置
$grafanaConfig = @{
BaseUrl = "http://localhost:3000"
User = "admin"
Password = "admin"
}

# 构建 Basic Auth Header
$authPair = "{0}:{1}" -f $grafanaConfig.User, $grafanaConfig.Password
$authBytes = [System.Text.Encoding]::UTF8.GetBytes($authPair)
$authHeader = "Basic {0}" -f [Convert]::ToBase64String($authBytes)

# 查询所有仪表板
$searchUrl = "{0}/api/search?type=dash-db" -f $grafanaConfig.BaseUrl
$response = Invoke-RestMethod -Uri $searchUrl -Headers @{ Authorization = $authHeader } -Method Get

# 提取仪表板摘要
$dashboards = foreach ($item in $response) {
[PSCustomObject]@{
标题 = $item.title
UID = $item.uid
URI = $item.uri
类型 = $item.type
是否星标 = $item.isStarred
标签 = ($item.tags -join ", ")
}
}

$dashboards | Format-Table -AutoSize

上述脚本首先将 Grafana 的连接信息封装到哈希表中,方便后续复用。然后构建 HTTP Basic Authentication 头,调用 /api/search 接口并指定 type=dash-db 仅返回已保存的仪表板(排除文件夹等非仪表板资源)。通过 foreach 循环将返回的 JSON 数据映射为结构化的 PowerShell 对象,方便后续筛选和格式化输出。

执行结果示例:

1
2
3
4
5
6
标题                         UID              URI                              类型   是否星标 标签
---- --- --- ---- -------- ----
系统总览 abc123xy db/system-overview dash-db True operations
API 响应时间 def456gh db/api-response-time dash-db False monitoring, api
数据库性能 ghi789ij db/database-performance dash-db False database
Kubernetes 集群监控 jkl012mn db/kubernetes-cluster dash-db True k8s, operations

创建新的 Grafana 仪表板

通过 API 创建仪表板时,需要构造完整的仪表板 JSON 定义。以下示例创建一个包含系统 CPU 和内存使用率面板的监控仪表板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 定义仪表板 JSON 结构
$dashboardJson = @{
dashboard = @{
uid = "ps-auto-system"
title = "PowerShell 自动创建 - 系统监控"
tags = @("automated", "powershell", "system")
timezone = "browser"
schemaVersion = 39
refresh = "30s"
time = @{
from = "now-1h"
to = "now"
}
panels = @(
@{
id = 1
title = "CPU 使用率 (%)"
type = "stat"
gridPos = @{ h = 8; w = 6; x = 0; y = 0 }
targets = @(
@{
expr = '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
refId = "A"
legendFormat = "{{instance}}"
}
)
fieldConfig = @{
defaults = @{
unit = "percent"
thresholds = @{
steps = @(
@{ color = "green"; value = $null }
@{ color = "yellow"; value = 60 }
@{ color = "red"; value = 85 }
)
}
}
}
}
@{
id = 2
title = "内存使用率 (%)"
type = "gauge"
gridPos = @{ h = 8; w = 6; x = 6; y = 0 }
targets = @(
@{
expr = '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
refId = "A"
legendFormat = "{{instance}}"
}
)
fieldConfig = @{
defaults = @{
unit = "percent"
thresholds = @{
steps = @(
@{ color = "green"; value = $null }
@{ color = "yellow"; value = 70 }
@{ color = "red"; value = 90 }
)
}
}
}
}
)
}
overwrite = $true
}

# 发送创建请求
$createUrl = "{0}/api/dashboards/db" -f $grafanaConfig.BaseUrl
$body = $dashboardJson | ConvertTo-Json -Depth 10

$result = Invoke-RestMethod -Uri $createUrl -Headers @{
Authorization = $authHeader
"Content-Type" = "application/json"
} -Method Post -Body $body

Write-Host "仪表板创建成功"
Write-Host " URL: {0}{1}" -f $grafanaConfig.BaseUrl, $result.url
Write-Host " 版本: $($result.version)"
Write-Host " UID: $($result.uid)"

这段代码的核心是构造 Grafana 所需的仪表板 JSON 结构。dashboard 对象包含仪表板的元信息(标题、标签、时区)和面板定义。每个面板通过 gridPos 控制在仪表板网格中的位置和尺寸,targets 定义 Prometheus 查询表达式,fieldConfig 配置显示单位和阈值颜色。将 overwrite 设为 $true 允许重复执行脚本更新已有仪表板,实现幂等操作。

执行结果示例:

1
2
3
4
仪表板创建成功
URL: http://localhost:3000/d/ps-auto-system
版本: 1
UID: ps-auto-system

批量导出并备份仪表板

定期备份仪表板配置是运维最佳实践。以下脚本将所有仪表板导出为独立的 JSON 文件,并按日期归档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 创建备份目录
$backupDate = Get-Date -Format "yyyyMMdd"
$backupDir = "GrafanaBackup_$backupDate"
New-Item -Path $backupDir -ItemType Directory -Force | Out-Null

Write-Host "开始备份 Grafana 仪表板至: $backupDir"

# 获取所有仪表板
$searchUrl = "{0}/api/search?type=dash-db" -f $grafanaConfig.BaseUrl
$allDashboards = Invoke-RestMethod -Uri $searchUrl -Headers @{ Authorization = $authHeader } -Method Get

$backupResults = foreach ($dashboard in $allDashboards) {
# 获取仪表板完整定义
$detailUrl = "{0}/api/dashboards/uid/{1}" -f $grafanaConfig.BaseUrl, $dashboard.uid
$detail = Invoke-RestMethod -Uri $detailUrl -Headers @{ Authorization = $authHeader } -Method Get

# 生成安全的文件名(替换特殊字符)
$safeName = $dashboard.title -replace '[\\/:*?\"<>|]', '_'
$filePath = Join-Path $backupDir "$safeName.json"

# 导出仪表板 JSON,包含版本和元数据
$exportData = @{
meta = @{
exportedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
grafanaVersion = $detail.meta.version
uid = $dashboard.uid
}
dashboard = $detail.dashboard
}

$exportData | ConvertTo-Json -Depth 20 | Set-Content -Path $filePath -Encoding UTF8

[PSCustomObject]@{
仪表板 = $dashboard.title
UID = $dashboard.uid
面板数 = $detail.dashboard.panels.Count
文件大小 = "{0:N1} KB" -f ((Get-Item $filePath).Length / 1KB)
状态 = "已备份"
}
}

Write-Host "`n备份完成,共导出 $($backupResults.Count) 个仪表板"
$backupResults | Format-Table -AutoSize

# 生成备份摘要文件
$summaryPath = Join-Path $backupDir "_backup_summary.txt"
$summaryContent = @(
"Grafana 仪表板备份摘要"
"备份时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
"仪表板总数: $($backupResults.Count)"
"备份来源: $($grafanaConfig.BaseUrl)"
""
"仪表板列表:"
foreach ($r in $backupResults) {
" - $($r.仪表板) (UID: $($r.UID), 面板数: $($r.面板数))"
}
)
$summaryContent | Set-Content -Path $summaryPath -Encoding UTF8

这个脚本实现了完整的备份流程。首先创建以日期命名的备份目录,然后逐一获取每个仪表板的完整 JSON 定义。文件名经过特殊字符清理确保在文件系统上合法。导出的 JSON 不仅包含仪表板定义本身,还附加了导出时间、Grafana 版本等元数据,方便后续追溯。最终还生成一份文本格式的备份摘要文件,便于快速查看备份内容。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
开始备份 Grafana 仪表板至: GrafanaBackup_20251022

备份完成,共导出 4 个仪表板

仪表板 UID 面板数 文件大小 状态
-------- --- ------ -------- ----
系统总览 abc123xy 6 12.3 KB 已备份
API 响应时间 def456gh 3 5.7 KB 已备份
数据库性能 ghi789ij 5 9.1 KB 已备份
Kubernetes 集群监控 jkl012mn 8 18.4 KB 已备份

跨实例同步仪表板配置

在多环境(开发、测试、生产)部署场景中,保持 Grafana 仪表板配置一致是常见需求。以下脚本从源实例拉取仪表板并推送到目标实例。

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
# 定义源和目标 Grafana 实例
$sourceConfig = @{
BaseUrl = "http://grafana-dev.internal:3000"
User = "admin"
Password = "dev-admin-pass"
}

$targetConfig = @{
BaseUrl = "http://grafana-prod.internal:3000"
User = "admin"
Password = "prod-admin-pass"
}

# 辅助函数:构建认证头
function New-GrafanaAuthHeader {
param($User, $Password)
$pair = "{0}:{1}" -f $User, $Password
$bytes = [System.Text.Encoding]::UTF8.GetBytes($pair)
"Basic {0}" -f [Convert]::ToBase64String($bytes)
}

$sourceAuth = New-GrafanaAuthHeader @sourceConfig
$targetAuth = New-GrafanaAuthHeader @targetConfig

# 从源实例获取所有带 "sync" 标签的仪表板
$searchUrl = "{0}/api/search?type=dash-db&tag=sync" -f $sourceConfig.BaseUrl
$syncDashboards = Invoke-RestMethod -Uri $searchUrl -Headers @{ Authorization = $sourceAuth } -Method Get

Write-Host "找到 $($syncDashboards.Count) 个标记为同步的仪表板"

$syncResults = foreach ($dashboard in $syncDashboards) {
# 从源获取完整定义
$sourceUrl = "{0}/api/dashboards/uid/{1}" -f $sourceConfig.BaseUrl, $dashboard.uid
$sourceData = Invoke-RestMethod -Uri $sourceUrl -Headers @{ Authorization = $sourceAuth } -Method Get

# 构造推送请求体
$pushBody = @{
dashboard = $sourceData.dashboard
overwrite = $true
}

# 推送到目标实例
$targetUrl = "{0}/api/dashboards/db" -f $targetConfig.BaseUrl
$body = $pushBody | ConvertTo-Json -Depth 20

try {
$pushResult = Invoke-RestMethod -Uri $targetUrl -Headers @{
Authorization = $targetAuth
"Content-Type" = "application/json"
} -Method Post -Body $body

[PSCustomObject]@{
仪表板 = $dashboard.title
目标UID = $pushResult.uid
目标版本 = $pushResult.version
同步状态 = "成功"
同步时间 = Get-Date -Format "HH:mm:ss"
}
}
catch {
[PSCustomObject]@{
仪表板 = $dashboard.title
目标UID = $dashboard.uid
目标版本 = "N/A"
同步状态 = "失败: $($_.Exception.Message)"
同步时间 = Get-Date -Format "HH:mm:ss"
}
}
}

Write-Host "`n同步结果:"
$syncResults | Format-Table -AutoSize

这段代码实现了跨 Grafana 实例的仪表板同步。通过自定义函数 New-GrafanaAuthHeader 封装认证逻辑,避免重复代码。同步策略基于标签筛选:只有在源实例中标记为 sync 的仪表板才会被同步。使用 try/catch 处理推送过程中的异常,确保单条仪表板同步失败不会中断整个批处理。overwrite = $true 保证幂等性,重复执行不会创建重复仪表板。

执行结果示例:

1
2
3
4
5
6
7
8
9
找到 3 个标记为同步的仪表板

同步结果:

仪表板 目标UID 目标版本 同步状态 同步时间
-------- ------- -------- -------- --------
系统总览 abc123xy 4 成功 14:35:12
API 响应时间 def456gh 2 成功 14:35:14
数据库性能 ghi789ij 3 成功 14:35:16

注意事项

  1. 认证安全:避免在脚本中硬编码 Grafana 用户名和密码。建议使用环境变量、Azure Key Vault 或 PowerShell SecretManagement 模块存储凭据。生产环境中优先使用 Grafana Service Account Token 而非管理员账号密码。

  2. API 权限控制:为自动化脚本创建专用的 Service Account,仅授予必要的权限(如 Dashboard Viewer、Dashboard Editor),避免使用全局管理员权限。通过最小权限原则降低误操作风险。

  3. JSON 序列化深度:Grafana 仪表板的 JSON 结构嵌套层级较深,使用 ConvertTo-Json 时务必指定足够的 -Depth 参数(建议 10 以上),否则深层配置可能被截断为字符串 System.Object[]

  4. 版本管理:每次通过 API 更新仪表板都会递增版本号。建议在 CI/CD 流水线中将导出的 JSON 文件纳入 Git 版本控制,实现仪表板配置的可追溯和回滚能力。

  5. 数据源依赖:仪表板中的查询面板依赖特定的数据源(如 Prometheus、InfluxDB)。跨实例同步时需确保目标实例已配置同名数据源,否则面板将显示查询错误。可先通过 /api/datasources 接口同步数据源配置。

  6. 并发控制:批量操作仪表板时,注意 Grafana 对并发请求的速率限制。在循环中使用 Start-Sleep 添加适当间隔(如 100-200 毫秒),或使用 -ThrottleLimit 参数控制并发度,避免触发 HTTP 429 Too Many Requests 错误。

PowerShell 技能连载 - Prometheus 指标采集

适用于 PowerShell 7.0 及以上版本

在云原生可观测性体系中,Prometheus 已经成为指标采集与监控的事实标准。它的数据模型基于时间序列,每条指标由指标名称和一组键值对标签唯一标识。当我们需要在运维自动化脚本中采集系统指标、将业务应用的性能数据推送到 Prometheus Pushgateway、或者从 Prometheus Server 查询历史数据做容量规划时,直接通过 HTTP API 与 Prometheus 交互是最灵活的方式。

PowerShell 7 内置的 Invoke-RestMethod 对 JSON 的原生支持,使其非常适合与 Prometheus 的 RESTful API 和文本暴露格式(text-based exposition format)打交道。无需安装额外的 SDK,只需几行脚本就能完成指标采集、推送和查询。本文将从三个场景出发:采集本地系统指标并写入 Prometheus 格式文件、推送自定义指标到 Pushgateway、以及从 Prometheus Server 执行 PromQL 查询并分析结果。

场景一:采集本地系统指标并输出 Prometheus 格式

Prometheus 的文本暴露格式是一种人类可读的纯文本协议。每条指标以 # TYPE 声明类型,紧随其后的行是具体的指标值。下面的脚本通过 .NET 的 System.Diagnostics.ProcessPerformanceCounter 类采集 CPU、内存和磁盘指标,然后输出符合 Prometheus 标准的文本格式。

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 Get-SystemPrometheusMetrics {
[CmdletBinding()]
param(
[Parameter()]
[string]$InstanceHostname = $env:COMPUTERNAME
)

# 采集 CPU 使用率(通过 WMI/CIM,跨平台兼容)
$cpuUsage = 0
if ($IsWindows -or $PSEdition -eq 'Desktop') {
$cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue
if ($cpu) {
$cpuUsage = [math]::Round(($cpu | Measure-Object -Property LoadPercentage -Average).Average, 2)
}
} else {
# Linux/macOS:通过 top 命令获取
$topOutput = top -bn1 | Select-String '^%?Cpu'
if ($topOutput -match '(\d+\.?\d*)\s*id') {
$cpuUsage = [math]::Round(100 - [double]$Matches[1], 2)
}
}

# 采集内存使用情况
$osInfo = if ($IsWindows -or $PSEdition -eq 'Desktop') {
$os = Get-CimInstance -ClassName Win32_OperatingSystem
@{
TotalBytes = $os.TotalVisibleMemorySize * 1KB
UsedBytes = ($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) * 1KB
FreeBytes = $os.FreePhysicalMemory * 1KB
UsedPercent = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 2)
}
} else {
$memInfo = Get-Content /proc/meminfo
$total = [int]($memInfo | Select-String 'MemTotal:\s+(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }) * 1KB
$available = [int]($memInfo | Select-String 'MemAvailable:\s+(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }) * 1KB
@{
TotalBytes = $total
UsedBytes = $total - $available
FreeBytes = $available
UsedPercent = [math]::Round(($total - $available) / $total * 100, 2)
}
}

# 采集磁盘使用情况(根分区 / 系统盘)
$drive = if ($IsWindows -or $PSEdition -eq 'Desktop') {
Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' |
Sort-Object -Property Size -Descending | Select-Object -First 1
} else {
$dfOutput = df / | Select-Object -Last 1
$parts = $dfOutput -split '\s+'
[PSCustomObject]@{
Size = [int64]$parts[1] * 1KB
FreeSpace = [int64]$parts[3] * 1KB
VolumeName = '/'
}
}
$diskTotal = $drive.Size
$diskFree = if ($drive.FreeSpace -is [long]) { $drive.FreeSpace } else { $drive.FreeSpace }
$diskUsedPercent = [math]::Round(($diskTotal - $diskFree) / $diskTotal * 100, 2)

# 获取当前时间戳(Unix 纪元秒)
$timestamp = [int64][double]::Parse(
(Get-Date -UFormat '%s'), [System.Globalization.CultureInfo]::InvariantCulture
)

# 组装 Prometheus 文本格式指标
$labels = "instance=`"$InstanceHostname`""
$lines = @(
'# HELP system_cpu_usage_percent CPU usage percentage'
'# TYPE system_cpu_usage_percent gauge'
"system_cpu_usage_percent{$labels} $cpuUsage $timestamp"
''
'# HELP system_memory_total_bytes Total physical memory in bytes'
'# TYPE system_memory_total_bytes gauge'
"system_memory_total_bytes{$labels} $($osInfo.TotalBytes) $timestamp"
''
'# HELP system_memory_used_bytes Used physical memory in bytes'
'# TYPE system_memory_used_bytes gauge'
"system_memory_used_bytes{$labels} $($osInfo.UsedBytes) $timestamp"
''
'# HELP system_memory_used_percent Memory usage percentage'
'# TYPE system_memory_used_percent gauge'
"system_memory_used_percent{$labels} $($osInfo.UsedPercent) $timestamp"
''
'# HELP system_disk_total_bytes Total disk space in bytes'
'# TYPE system_disk_total_bytes gauge'
"system_disk_total_bytes{$labels} $diskTotal $timestamp"
''
'# HELP system_disk_used_percent Disk usage percentage'
'# TYPE system_disk_used_percent gauge'
"system_disk_used_percent{$labels} $diskUsedPercent $timestamp"
)

return $lines -join "`n"
}

# 采集并输出指标
$metrics = Get-SystemPrometheusMetrics
Write-Output $metrics

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# HELP system_cpu_usage_percent CPU usage percentage
# TYPE system_cpu_usage_percent gauge
system_cpu_usage_percent{instance="WEB-SVR01"} 23.45 1729137600

# HELP system_memory_total_bytes Total physical memory in bytes
# TYPE system_memory_total_bytes gauge
system_memory_total_bytes{instance="WEB-SVR01"} 34359738368 1729137600

# HELP system_memory_used_bytes Used physical memory in bytes
# TYPE system_memory_used_bytes gauge
system_memory_used_bytes{instance="WEB-SVR01"} 20132659200 1729137600

# HELP system_memory_used_percent Memory usage percentage
# TYPE system_memory_used_percent gauge
system_memory_used_percent{instance="WEB-SVR01"} 58.59 1729137600

# HELP system_disk_total_bytes Total disk space in bytes
# TYPE system_disk_total_bytes gauge
system_disk_total_bytes{instance="WEB-SVR01"} 536870912000 1729137600

# HELP system_disk_used_percent Disk usage percentage
# TYPE system_disk_used_percent gauge
system_disk_used_percent{instance="WEB-SVR01"} 72.31 1729137600

场景二:推送自定义指标到 Pushgateway

短生命周期的任务(如批处理脚本、CI/CD 构建流水线)运行时间很短,Prometheus 的默认拉取模式可能来不及采集。Pushgateway 提供了一种推送模式,允许脚本在任务完成时主动将指标推送到中间网关,等待 Prometheus 定期拉取。下面的脚本演示了如何将构建流水线的执行时长和成功率推送到 Pushgateway。

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
function Push-PrometheusMetric {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[uri]$PushgatewayUrl,

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

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

[Parameter(Mandatory)]
[double]$MetricValue,

[Parameter()]
[string]$MetricType = 'gauge',

[Parameter()]
[string]$HelpText = $MetricName,

[Parameter()]
[hashtable]$Labels
)

# 构建 Pushgateway 的推送 URL
# 路径格式:/metrics/job/<job_name>/label_key/label_value
$pushPath = "/metrics/job/$JobName"
foreach ($key in $Labels.Keys) {
$encodedKey = [uri]::EscapeDataString($key)
$encodedValue = [uri]::EscapeDataString($Labels[$key])
$pushPath += "/$encodedKey/$encodedValue"
}

$fullUrl = New-Object System.Uri -ArgumentList $PushgatewayUrl, $pushPath

# 获取 Unix 时间戳
$timestamp = [int64][double]::Parse(
(Get-Date -UFormat '%s'), [System.Globalization.CultureInfo]::InvariantCulture
)

# 构建标签字符串(不包含 job,job 已在 URL 路径中)
$labelPairs = foreach ($key in $Labels.Keys) {
"$key=`"$($Labels[$key])`""
}
$labelStr = $labelPairs -join ','

# 组装 Prometheus 文本格式
$body = @(
"# HELP $MetricName $HelpText"
"# TYPE $MetricName $MetricType"
"${MetricName}{$labelStr} $MetricValue $timestamp"
) -join "`n"

# 发送推送请求
try {
$response = Invoke-RestMethod -Method Post -Uri $fullUrl -Body $body `
-ContentType 'text/plain; version=1.0.4; charset=utf-8' `
-ErrorAction Stop

Write-Verbose "指标推送成功: $MetricName = $MetricValue -> $fullUrl"
return $true
}
catch {
Write-Error "指标推送失败: $($_.Exception.Message)"
return $false
}
}

# 示例:推送 CI/CD 构建指标
$pushgateway = 'http://prometheus-pushgateway.monitoring.svc.cluster.local:9091'
$buildLabels = @{
branch = 'main'
pipeline = 'deploy-production'
stage = 'build'
runner = 'ps-runner-01'
}

# 推送构建时长(秒)
$buildDuration = Get-Random -Minimum 120 -Maximum 480
Push-PrometheusMetric -PushgatewayUrl $pushgateway `
-JobName 'ci_build_pipeline' `
-MetricName 'ci_build_duration_seconds' `
-MetricValue $buildDuration `
-MetricType 'gauge' `
-HelpText 'Duration of CI build in seconds' `
-Labels $buildLabels

# 推送构建结果(1=成功, 0=失败)
Push-PrometheusMetric -PushgatewayUrl $pushgateway `
-JobName 'ci_build_pipeline' `
-MetricName 'ci_build_success' `
-MetricValue 1 `
-MetricType 'gauge' `
-HelpText 'Whether the CI build succeeded (1=yes, 0=no)' `
-Labels $buildLabels

Write-Host "构建指标已推送到 Pushgateway"

执行结果示例:

1
构建指标已推送到 Pushgateway

可以通过以下命令验证 Pushgateway 中存储的指标:

1
2
3
# 查询 Pushgateway 中所有指标组
$groups = Invoke-RestMethod -Uri "$pushgateway/api/v1/metrics"
$groups.data | ConvertTo-Json -Depth 5 | Select-Object -First 30

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"type": "gauge",
"help": "Duration of CI build in seconds",
"metrics": [
{
"labels": {
"branch": "main",
"instance": "",
"job": "ci_build_pipeline",
"pipeline": "deploy-production",
"runner": "ps-runner-01",
"stage": "build"
},
"value": "347"
}
]
}

场景三:查询 Prometheus Server 并分析指标数据

Prometheus 提供了丰富的 HTTP Query API,支持即时查询(instant query)和范围查询(range query)。下面的脚本封装了两个查询函数,分别用于获取某一时刻的指标快照和一段时间内的时序数据,并将结果转换为 PowerShell 对象便于后续分析。

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

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

[Parameter()]
[datetime]$Time = (Get-Date)
)

# 即时查询:获取指定时刻的指标值
$timestamp = [int64][double]::Parse(
$Time.ToUniversalTime().Subtract([datetime]::new(1970, 1, 1)).TotalSeconds,
[System.Globalization.CultureInfo]::InvariantCulture
)

$queryParams = @{
query = $Query
time = $timestamp
}

$response = Invoke-RestMethod -Method Get `
-Uri "$PrometheusUrl/api/v1/query" `
-Body $queryParams

if ($response.status -ne 'success') {
throw "Prometheus 查询失败: $($response.errorType) - $($response.error)"
}

# 将查询结果转换为 PowerShell 对象
$results = foreach ($result in $response.data.result) {
$labels = $result.metric
$value = $result.value

[PSCustomObject]@{
MetricName = $labels['__name__']
Labels = $labels
Timestamp = [datetimeoffset]::FromUnixTimeSeconds([int64]$value[0]).DateTime
Value = [double]$value[1]
}
}

return $results
}

function Invoke-PrometheusRangeQuery {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[uri]$PrometheusUrl,

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

[Parameter(Mandatory)]
[datetime]$StartTime,

[Parameter(Mandatory)]
[datetime]$EndTime,

[Parameter()]
[string]$Step = '5m'
)

# 范围查询:获取时间区间内的指标时序
$startTs = [int64][double]::Parse(
$StartTime.ToUniversalTime().Subtract([datetime]::new(1970, 1, 1)).TotalSeconds,
[System.Globalization.CultureInfo]::InvariantCulture
)
$endTs = [int64][double]::Parse(
$EndTime.ToUniversalTime().Subtract([datetime]::new(1970, 1, 1)).TotalSeconds,
[System.Globalization.CultureInfo]::InvariantCulture
)

$queryParams = @{
query = $Query
start = $startTs
end = $endTs
step = $Step
}

$response = Invoke-RestMethod -Method Get `
-Uri "$PrometheusUrl/api/v1/query_range" `
-Body $queryParams

if ($response.status -ne 'success') {
throw "Prometheus 范围查询失败: $($response.errorType) - $($response.error)"
}

# 将时序数据转换为扁平化的 PowerShell 对象列表
$results = foreach ($series in $response.data.result) {
$labels = $series.metric
$values = $series.values

foreach ($pair in $values) {
[PSCustomObject]@{
MetricName = $labels['__name__']
Instance = $labels['instance']
Job = $labels['job']
Timestamp = [datetimeoffset]::FromUnixTimeSeconds([int64]$pair[0]).DateTime
Value = [double]$pair[1]
}
}
}

return $results
}

# 即时查询:获取所有实例的 CPU 使用率
$prometheus = 'http://prometheus.monitoring.svc.cluster.local:9090'
$cpuData = Invoke-PrometheusQuery -PrometheusUrl $prometheus `
-Query '100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'

$cpuData | Select-Object MetricName, Instance, Value, Timestamp |
Sort-Object Value -Descending |
Format-Table -AutoSize

# 范围查询:获取过去 1 小时内存使用率的时序数据
$rangeData = Invoke-PrometheusRangeQuery -PrometheusUrl $prometheus `
-Query '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100' `
-StartTime (Get-Date).AddHours(-1) `
-EndTime (Get-Date) `
-Step '5m'

# 计算统计摘要
$rangeData | Group-Object Instance | ForEach-Object {
$values = $_.Group.Value
[PSCustomObject]@{
Instance = $_.Name
Min = [math]::Round(($values | Measure-Object -Minimum).Minimum, 2)
Max = [math]::Round(($values | Measure-Object -Maximum).Maximum, 2)
Avg = [math]::Round(($values | Measure-Object -Average).Average, 2)
Samples = $values.Count
}
} | Sort-Object Avg -Descending | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
MetricName                        Instance            Value Timestamp
---------- -------- ----- ---------
node_cpu_seconds_total web-svr01:9100 78.34 10/17/2025 8:00:00 AM
node_cpu_seconds_total db-svr01:9100 45.21 10/17/2025 8:00:00 AM
node_cpu_seconds_total api-svr01:9100 32.67 10/17/2025 8:00:00 AM
node_cpu_seconds_total monitor-svr01:9100 12.45 10/17/2025 8:00:00 AM

Instance Min Max Avg Samples
--------- --- --- --- -------
web-svr01:9100 55.32 82.17 68.45 12
db-svr01:9100 40.11 62.89 51.33 12
api-svr01:9100 28.45 48.22 37.61 12
monitor-svr01:9100 8.12 22.34 15.88 12

注意事项

  1. 指标命名规范:Prometheus 指标名称应遵循 namespace_subsystem_name_unit 的命名约定。例如 node_memory_MemAvailable_bytes 分别代表命名空间(node)、子系统(memory)、度量项(MemAvailable)和单位(bytes)。使用一致的命名规范能让 PromQL 查询更简洁,也便于 Grafana 面板复用。

  2. 时间戳精度与同步:推送指标时附带的时间戳必须是 Unix 纪元秒数(float64)。确保运行 PowerShell 的主机时间已通过 NTP 同步,否则 Prometheus 可能因时间偏移而拒绝数据。在推送模式下可以省略时间戳,让 Pushgateway 自动使用接收时间。

  3. Pushgateway 数据清理:Pushgateway 不会自动清除已推送的指标,即使对应的任务已经停止运行。这会导致 Prometheus 持续采集到过期的静态数据。建议在任务结束后调用 Pushgateway 的 DELETE API 清理指标组,或在推送时设置合理的标签(如 instance)以便批量清理。

  4. PromQL 注入风险:如果 PromQL 查询字符串包含用户输入(如主机名、应用名称),必须进行转义和校验,防止注入攻击。PromQL 本身不支持 SQL 式的注入,但恶意的标签值可能导致查询结果被篡改或返回大量数据耗尽 Prometheus Server 内存。

  5. 大范围查询的性能:范围查询(query_range)的 step 参数直接影响返回的数据点数量。公式为 (end - start) / step。查询 7 天的数据、step 设为 15 秒将返回约 4 万个数据点,可能使 PowerShell 的对象处理变慢。建议根据查询时长合理设置 step:1 小时用 1m,1 天用 5m,7 天用 15m

  6. 认证与网络安全:生产环境的 Prometheus 通常部署在内部网络,可能需要 mTLS 或 Bearer Token 认证。使用 Invoke-RestMethod 时通过 -Headers @{Authorization = 'Bearer <token>'} 传递令牌,通过 -SkipCertificateCheck 处理自签证书(仅限内部测试环境)。建议将凭据存储在 PowerShell SecretManagement 模块中,不要硬编码在脚本里。

PowerShell 技能连载 - 结构化日志框架

适用于 PowerShell 5.1 及以上版本

在生产环境中管理 PowerShell 脚本时,Write-HostWrite-Output 这类简单输出方式很快就会暴露出不足:日志难以检索、无法按级别过滤、缺少上下文信息,更无法与 ELK、Splunk 等日志平台对接。当脚本数量增长到数十个、运行频率从手动变为定时任务时,缺乏结构化日志体系将成为运维效率的最大瓶颈。

结构化日志(Structured Logging)是解决这些问题的核心思路。它要求每条日志都携带固定格式的元数据——时间戳、级别、模块名、消息体以及自定义属性,并以 JSON 等机器可读格式输出。这样就能用 ConvertFrom-Json 在脚本内解析,也可以直接推送到日志平台进行全文检索和可视化分析。

本文将从零构建一个轻量但完整的结构化日志框架,涵盖日志级别控制、多目标输出(控制台 + 文件 + JSON 文件)、上下文自动注入、以及运行时的动态配置。

框架核心:日志条目数据结构

一切从定义日志条目的数据结构开始。我们用一个 PowerShell class 来承载每条日志的所有字段,确保输出格式始终一致。

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
enum LogLevel {
Debug
Information
Warning
Error
Critical
}

class LogEntry {
[DateTime] $Timestamp
[LogLevel] $Level
[string] $Message
[string] $Module
[hashtable] $Properties
[string] $CorrelationId

LogEntry(
[LogLevel] $Level,
[string] $Message,
[string] $Module,
[hashtable] $Properties,
[string] $CorrelationId
) {
$this.Timestamp = Get-Date
$this.Level = $Level
$this.Message = $Message
$this.Module = $Module
$this.Properties = $Properties
$this.CorrelationId = $CorrelationId
}

[hashtable] ToHashtable() {
$ht = [ordered]@{
timestamp = $this.Timestamp.ToString('o')
level = $this.Level.ToString()
module = $this.Module
message = $this.Message
correlationId = $this.CorrelationId
}
foreach ($key in $this.Properties.Keys) {
$ht[$key] = $this.Properties[$key]
}
return $ht
}

[string] ToJson() {
return $this.ToHashtable() | ConvertTo-Json -Compress
}
}

上面的代码定义了五个日志级别和一个 LogEntry 类。ToHashtable() 方法将日志转为有序哈希表,方便进一步处理;ToJson() 则输出紧凑的 JSON 字符串,可直接写入日志文件或发送到 HTTP 端点。注意这里使用 foreach 语句遍历属性字典,将自定义字段合并到输出中。

日志写入器:支持多目标输出

有了数据结构之后,需要实现写入器(Writer)来决定日志输出到哪里。下面定义三个写入器:控制台(带颜色)、纯文本文件、以及 JSON 文件。写入器通过注册机制挂载到框架中,运行时可动态增减。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class LoggerConfig {
[LogLevel] $MinimumLevel = [LogLevel]::Information
[string] $LogDirectory = '.\logs'
[string] $ApplicationName = 'PowerShellApp'
[string] $CorrelationId = ''
[System.Collections.Generic.List[scriptblock]] $Writers

LoggerConfig() {
$this.Writers = [System.Collections.Generic.List[scriptblock]]::new()
if (-not $this.CorrelationId) {
$this.CorrelationId = [guid]::NewGuid().ToString('N').Substring(0, 8)
}
}
}

function New-Logger {
param(
[LogLevel] $MinimumLevel = [LogLevel]::Information,
[string] $LogDirectory = '.\logs',
[string] $ApplicationName = 'PowerShellApp'
)
$config = [LoggerConfig]::new()
$config.MinimumLevel = $MinimumLevel
$config.LogDirectory = $LogDirectory
$config.ApplicationName = $ApplicationName

# 控制台写入器
$config.Writers.Add({
param($Entry)
$colorMap = @{
Debug = 'DarkGray'
Information = 'White'
Warning = 'Yellow'
Error = 'Red'
Critical = 'Magenta'
}
$color = $colorMap[$Entry.Level.ToString()]
Write-Host -ForegroundColor $color (
"[$($Entry.Timestamp.ToString('HH:mm:ss'))] " +
"[$($Entry.Level)] [$($Entry.Module)] $($Entry.Message)"
)
}.GetNewClosure())

# JSON 文件写入器
$config.Writers.Add({
param($Entry, $Config)
$dir = $Config.LogDirectory
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$dateStr = (Get-Date).ToString('yyyyMMdd')
$filePath = Join-Path $dir "$($Config.ApplicationName)-$dateStr.jsonl"
$Entry.ToJson() | Add-Content -Path $filePath -Encoding UTF8
}.GetNewClosure())

return $config
}

这段代码的核心是 LoggerConfig 类:它保存全局配置(最低级别、日志目录、应用名称、关联 ID)和一个写入器列表。New-Logger 函数创建配置实例并默认注册两个写入器——控制台和 JSONL 文件。JSONL(JSON Lines)格式每行一条 JSON 记录,非常适合日志场景,追加写入效率高,读取时逐行 ConvertFrom-Json 即可。

发送日志与上下文注入

有了配置和写入器,现在实现发送日志的核心函数。它会自动过滤低于最低级别的消息,并注入运行时上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
function Send-Log {
param(
[Parameter(Mandatory)]
[LoggerConfig] $Config,

[Parameter(Mandatory)]
[LogLevel] $Level,

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

[string] $Module = 'General',

[hashtable] $Properties = @{}
)

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

# 自动注入上下文属性
$Properties['host'] = $env:COMPUTERNAME
$Properties['user'] = $env:USERNAME
$Properties['psVersion'] = $PSVersionTable.PSVersion.ToString()

$entry = [LogEntry]::new(
$Level,
$Message,
$Module,
$Properties,
$Config.CorrelationId
)

foreach ($writer in $Config.Writers) {
try {
& $writer $entry $Config
} catch {
Write-Warning "日志写入器执行失败: $($_.Exception.Message)"
}
}
}

# 便捷函数
function Write-LogDebug {
param([LoggerConfig] $Config, [string] $Message, [string] $Module = 'General', [hashtable] $Properties = @{})
Send-Log -Config $Config -Level Debug -Message $Message -Module $Module -Properties $Properties
}

function Write-LogInfo {
param([LoggerConfig] $Config, [string] $Message, [string] $Module = 'General', [hashtable] $Properties = @{})
Send-Log -Config $Config -Level Information -Message $Message -Module $Module -Properties $Properties
}

function Write-LogWarning {
param([LoggerConfig] $Config, [string] $Message, [string] $Module = 'General', [hashtable] $Properties = @{})
Send-Log -Config $Config -Level Warning -Message $Message -Module $Module -Properties $Properties
}

function Write-LogError {
param([LoggerConfig] $Config, [string] $Message, [string] $Module = 'General', [hashtable] $Properties = @{})
Send-Log -Config $Config -Level Error -Message $Message -Module $Module -Properties $Properties
}

function Write-LogCritical {
param([LoggerConfig] $Config, [string] $Message, [string] $Module = 'General', [hashtable] $Properties = @{})
Send-Log -Config $Config -Level Critical -Message $Message -Module $Module -Properties $Properties
}

Send-Log 在创建日志条目前自动注入主机名、用户名和 PowerShell 版本三个上下文字段。通过 foreach 语句遍历所有注册的写入器并逐一执行,单个写入器失败不会影响其他写入器。五个便捷函数封装了对应的日志级别,调用时无需手动指定 -Level 参数。

实战:在脚本中使用日志框架

下面模拟一个典型的文件处理脚本,展示日志框架的完整使用方式。

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
# 初始化日志框架(Debug 级别,开发阶段能看到所有日志)
$logger = New-Logger -MinimumLevel Debug -LogDirectory '.\logs' -ApplicationName 'FileProcessor'

Write-LogInfo -Config $logger -Message '文件处理任务启动' -Module 'Main' -Properties @{
taskType = 'BatchImport'
sourcePath = 'D:\Data\Incoming'
}

# 模拟处理一批文件
$files = @('report-q3.csv', 'inventory.json', 'customers.xlsx', 'orders-corrupt.bad')

foreach ($file in $files) {
Write-LogDebug -Config $logger -Message "开始处理文件: $file" -Module 'FileHandler' -Properties @{
fileName = $file
fileSize = (Get-Random -Minimum 1024 -Maximum 10485760)
}

if ($file -match 'corrupt') {
Write-LogError -Config $logger -Message "文件损坏,跳过处理: $file" -Module 'FileHandler' -Properties @{
fileName = $file
errorCode = 'CORRUPT_HEADER'
recoverable = $false
}
continue
}

Write-LogInfo -Config $logger -Message "文件处理完成: $file" -Module 'FileHandler' -Properties @{
fileName = $file
rowsProcessed = (Get-Random -Minimum 100 -Maximum 5000)
durationMs = (Get-Random -Minimum 50 -Maximum 2000)
}
}

Write-LogWarning -Config $logger -Message '批次处理结束,存在跳过的文件' -Module 'Main' -Properties @{
totalFiles = $files.Count
successCount = ($files | Where-Object { $_ -notmatch 'corrupt' }).Count
skippedCount = 1
}

Write-LogInfo -Config $logger -Message '文件处理任务完成' -Module 'Main'

模拟执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
[14:30:01] [Information] [Main] 文件处理任务启动
[14:30:01] [Debug] [FileHandler] 开始处理文件: report-q3.csv
[14:30:01] [Information] [FileHandler] 文件处理完成: report-q3.csv
[14:30:01] [Debug] [FileHandler] 开始处理文件: inventory.json
[14:30:02] [Information] [FileHandler] 文件处理完成: inventory.json
[14:30:02] [Debug] [FileHandler] 开始处理文件: customers.xlsx
[14:30:02] [Information] [FileHandler] 文件处理完成: customers.xlsx
[14:30:02] [Debug] [FileHandler] 开始处理文件: orders-corrupt.bad
[14:30:02] [Error] [FileHandler] 文件损坏,跳过处理: orders-corrupt.bad
[14:30:02] [Warning] [Main] 批次处理结束,存在跳过的文件
[14:30:02] [Information] [Main] 文件处理任务完成

同时,在 .\logs 目录下会生成类似 FileProcessor-20250925.jsonl 的文件,每行是一条完整的 JSON 日志。下面是其中一行记录的格式化展示:

1
{"timestamp":"2025-09-25T14:30:02.1234567+08:00","level":"Error","module":"FileHandler","message":"文件损坏,跳过处理: orders-corrupt.bad","correlationId":"a3f8c1d2","fileName":"orders-corrupt.bad","errorCode":"CORRUPT_HEADER","recoverable":false,"host":"WORKSTATION01","user":"admin","psVersion":"7.4.0"}

查询与分析结构化日志

结构化日志最大的优势在于可以用标准工具进行查询。下面的函数演示如何快速检索 JSONL 日志文件。

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
function Search-LogArchive {
param(
[Parameter(Mandatory)]
[string] $Path,

[ValidateSet('Debug', 'Information', 'Warning', 'Error', 'Critical')]
[string] $Level,

[string] $Module,

[string] $MessagePattern,

[DateTime] $After,

[int] $Latest = 50
)

$records = [System.Collections.Generic.List[psobject]]::new()

$lines = Get-Content -Path $Path -Encoding UTF8

foreach ($line in $lines) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }

try {
$obj = $line | ConvertFrom-Json
} catch {
continue
}

# 按条件过滤
if ($Level -and $obj.level -ne $Level) { continue }
if ($Module -and $obj.module -ne $Module) { continue }
if ($MessagePattern -and $obj.message -notmatch $MessagePattern) { continue }
if ($After -and [DateTime]$obj.timestamp -lt $After) { continue }

$records.Add($obj)
}

# 按时间排序并取最近的记录
$results = $records | Sort-Object { $_.timestamp } -Descending |
Select-Object -First $Latest

return $results
}

# 使用示例:查询今天所有 Error 级别的日志
$errors = Search-LogArchive -Path '.\logs\FileProcessor-20250925.jsonl' -Level Error

foreach ($err in $errors) {
Write-Host "[$($err.timestamp)] $($err.message) (module=$($err.module), errorCode=$($err.errorCode))"
}

查询结果示例:

1
[2025-09-25T14:30:02.1234567+08:00] 文件损坏,跳过处理: orders-corrupt.bad (module=FileHandler, errorCode=CORRUPT_HEADER)

Search-LogArchive 函数逐行读取 JSONL 文件,使用 ConvertFrom-Json 解析每条记录后按条件过滤。支持按级别、模块、消息模式和时间范围过滤,返回最近的 N 条匹配记录。由于使用 foreach 语句逐行处理,即使是几十 MB 的日志文件也能保持较低的内存占用。

动态调整日志级别

在生产环境中,经常需要在不重启服务的情况下调整日志级别来排查问题。下面实现一个简单的运行时级别切换机制。

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
function Set-LoggerLevel {
param(
[Parameter(Mandatory)]
[LoggerConfig] $Config,

[Parameter(Mandatory)]
[LogLevel] $Level
)

$oldLevel = $Config.MinimumLevel
$Config.MinimumLevel = $Level

# 级别变更本身也记录到日志中
Send-Log -Config $Config -Level Information -Message "日志级别已变更" `
-Module 'Logger' -Properties @{
oldLevel = $oldLevel.ToString()
newLevel = $Level.ToString()
changedBy = $env:USERNAME
}
}

# 正常运行时使用 Information 级别
$logger = New-Logger -MinimumLevel Information -ApplicationName 'WebMonitor'

Write-LogInfo -Config $logger -Message '站点健康检查通过' -Module 'HealthCheck'
Write-LogDebug -Config $logger -Message '这条不会输出' -Module 'HealthCheck'

# 发现异常,临时切换到 Debug 级别排查
Set-LoggerLevel -Config $logger -Level Debug

Write-LogDebug -Config $logger -Message '现在 Debug 日志可见了' -Module 'HealthCheck'

# 排查完毕,恢复到 Information
Set-LoggerLevel -Config $logger -Level Information

执行结果:

1
2
3
4
[14:35:10] [Information] [HealthCheck] 站点健康检查通过
[14:35:10] [Information] [Logger] 日志级别已变更
[14:35:10] [Debug] [HealthCheck] 现在 Debug 日志可见了
[14:35:10] [Information] [Logger] 日志级别已变更

级别切换操作本身会被记录为一条 Information 日志,包含了变更前后的级别和操作人信息,方便审计追踪。这在多人共享的运维环境中尤为重要。

注意事项

  • 关联 ID 的使用:每个 LoggerConfig 实例会自动生成一个关联 ID(Correlation ID),在分布式系统中用于串联一次完整请求的所有日志。建议在脚本入口处将此 ID 传递给所有子模块和远程调用。
  • JSONL 与普通 JSON 的选择:日志文件推荐使用 JSONL 格式(每行一条 JSON),而非 JSON 数组。JSONL 天然支持追加写入,不会因为进程中断导致文件格式损坏,也方便 Get-Content 逐行处理。
  • 写入器的异常隔离Send-Log 中对每个写入器都使用了 try/catch,确保某个输出目标(如网络端点不可达)不会导致整个日志功能失效。生产环境中务必保留这一保护机制。
  • 大日志文件的查询性能:当日志文件超过 100MB 时,Get-Content 会消耗较多内存。建议按日期滚动日志文件,或使用 System.IO.StreamReader 进行流式读取。
  • 线程安全注意事项:PowerShell 的 foreach 语句和 Add-Content 在单一线程(单一 runspace)中是安全的。但如果使用 ForEach-Object -Parallel 或 Start-Job 等并发机制,需要额外加锁保护文件写入,否则会出现内容交错。
  • 敏感信息过滤:自动注入的上下文属性(主机名、用户名)可能包含敏感信息。在将日志推送到外部系统之前,建议实现一个脱敏写入器,对指定字段进行掩码处理。

PowerShell 技能连载 - 微服务健康检查

适用于 PowerShell 7.0 及以上版本

在微服务架构中,服务之间的依赖关系错综复杂。一个服务异常可能引发连锁故障,导致整个系统崩溃。为了及时发现问题并触发自动恢复机制,我们需要对每个服务进行持续的健康检查。健康检查通常通过 HTTP 端点暴露服务的运行状态,包括数据库连接、缓存可用性、外部 API 响应等关键指标。

Kubernetes、Docker Swarm、Consul 等容器编排和服务发现工具都依赖健康检查端点来决定流量路由和容器重启策略。对于 PowerShell 运维人员来说,能够用脚本快速探测所有微服务的健康状态,是日常巡检和故障排查的基本功。

本文将从零开始,用 PowerShell 构建一套轻量级的微服务健康检查工具:先封装单服务探测函数,再扩展为批量并行检查,最后输出结构化的健康报告。

单服务健康检查函数

我们首先封装一个通用的健康检查函数,支持 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
function Invoke-HealthCheck {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name,

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

[int]$TimeoutSeconds = 5,

[string]$ExpectedStatus = '200'
)

$result = [ordered]@{
Name = $Name
Url = $Url
Status = 'Unknown'
StatusCode = $null
LatencyMs = $null
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Error = $null
}

try {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$response = Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSeconds `
-UseBasicParsing -ErrorAction Stop
$stopwatch.Stop()

$result.StatusCode = [int]$response.StatusCode
$result.LatencyMs = $stopwatch.ElapsedMilliseconds
$result.Status = if ($response.StatusCode -eq $ExpectedStatus) {
'Healthy'
} else {
'Degraded'
}
}
catch {
$stopwatch.Stop()
$result.Status = 'Unhealthy'
$result.LatencyMs = $stopwatch.ElapsedMilliseconds
$result.Error = $_.Exception.Message
}

[PSCustomObject]$result
}
1
2
3
4
5
6
7
8
9
PS> Invoke-HealthCheck -Name '用户服务' -Url 'https://api.example.com/users/health'

Name : 用户服务
Url : https://api.example.com/users/health
Status : Healthy
StatusCode : 200
LatencyMs : 47
Timestamp : 2025-09-23 08:15:32
Error :

批量并行健康检查

在生产环境中,微服务数量通常有几十甚至上百个。逐个串行检查效率太低,我们利用 PowerShell 的 ForEach-Object -Parallel(PowerShell 7+)实现并行探测,并设置合理的并发度和超时时间。

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
$services = @(
@{ Name = 'API 网关'; Url = 'https://api.example.com/gateway/health' }
@{ Name = '用户服务'; Url = 'https://api.example.com/users/health' }
@{ Name = '订单服务'; Url = 'https://api.example.com/orders/health' }
@{ Name = '支付服务'; Url = 'https://api.example.com/payment/health' }
@{ Name = '库存服务'; Url = 'https://api.example.com/inventory/health' }
@{ Name = '通知服务'; Url = 'https://api.example.com/notification/health' }
)

# 批量并行检查,最大并发 8 个
$healthResults = $services | ForEach-Object -ThrottleLimit 8 -Parallel {
# 在并行运行空间中重新定义函数
function Invoke-HealthCheck {
param($Name, $Url, $TimeoutSeconds = 5)
$result = [ordered]@{
Name = $Name
Url = $Url
Status = 'Unknown'
StatusCode = $null
LatencyMs = $null
Timestamp = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Error = $null
}
try {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$r = Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSeconds `
-UseBasicParsing -ErrorAction Stop
$sw.Stop()
$result.StatusCode = [int]$r.StatusCode
$result.LatencyMs = $sw.ElapsedMilliseconds
$result.Status = if ($r.StatusCode -eq 200) { 'Healthy' } else { 'Degraded' }
}
catch {
$sw.Stop()
$result.Status = 'Unhealthy'
$result.LatencyMs = $sw.ElapsedMilliseconds
$result.Error = $_.Exception.Message
}
[PSCustomObject]$result
}

Invoke-HealthCheck -Name $_.Name -Url $_.Url
}

# 按状态排序输出:不健康 > 降级 > 健康
$statusOrder = @{ Healthy = 2; Degraded = 1; Unhealthy = 0 }
$healthResults | Sort-Object { $statusOrder[$_.Status] } | Format-Table -AutoSize
1
2
3
4
5
6
7
8
Name       Url                                                    Status     StatusCode LatencyMs Timestamp           Error
---- --- ------ ---------- --------- --------- -----
支付服务 https://api.example.com/payment/health Unhealthy 0 5012 2025-09-23 08:16:01 Unable to connect...
库存服务 https://api.example.com/inventory/health Degraded 503 234 2025-09-23 08:16:01
API 网关 https://api.example.com/gateway/health Healthy 200 38 2025-09-23 08:16:01
用户服务 https://api.example.com/users/health Healthy 200 47 2025-09-23 08:16:01
订单服务 https://api.example.com/orders/health Healthy 200 62 2025-09-23 08:16:01
通知服务 https://api.example.com/notification/health Healthy 200 85 2025-09-23 08:16:01

生成结构化健康报告

检查结果可以导出为 JSON 或 HTML 报告,方便集成到监控告警系统。下面的脚本将结果输出为 JSON 文件,并生成一份简洁的 HTML 报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function Export-HealthReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCustomObject[]]$Results,

[string]$OutputPath = './health-report'
)

$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'

# 导出 JSON 报告
$jsonPath = Join-Path $OutputPath "health-$timestamp.json"
$Results | ConvertTo-Json -Depth 5 | Out-File -FilePath $jsonPath -Encoding utf8

# 统计摘要
$healthyCount = ($Results | Where-Object Status -eq 'Healthy').Count
$degradedCount = ($Results | Where-Object Status -eq 'Degraded').Count
$unhealthyCount = ($Results | Where-Object Status -eq 'Unhealthy').Count
$totalCount = $Results.Count

$summary = @{
Total = $totalCount
Healthy = $healthyCount
Degraded = $degradedCount
Unhealthy = $unhealthyCount
CheckTime = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Overall = if ($unhealthyCount -gt 0) { 'CRITICAL' }
elseif ($degradedCount -gt 0) { 'WARNING' }
else { 'OK' }
}

Write-Host "`n========== 健康检查报告 ==========" -ForegroundColor Cyan
Write-Host "检查时间: $($summary.CheckTime)"
Write-Host "服务总数: $totalCount"
Write-Host "健康: $healthyCount" -ForegroundColor Green
Write-Host "降级: $degradedCount" -ForegroundColor Yellow
Write-Host "不健康: $unhealthyCount" -ForegroundColor Red
Write-Host "总体状态: $($summary.Overall)" -ForegroundColor $(
if ($summary.Overall -eq 'OK') { 'Green' }
elseif ($summary.Overall -eq 'WARNING') { 'Yellow' }
else { 'Red' }
)
Write-Host "===================================`n"

# 导出摘要 JSON
$summaryPath = Join-Path $OutputPath "summary-$timestamp.json"
$summary | ConvertTo-Json | Out-File -FilePath $summaryPath -Encoding utf8

Write-Host "JSON 报告已保存: $jsonPath"
Write-Host "摘要已保存: $summaryPath"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
PS> Export-HealthReport -Results $healthResults -OutputPath './reports'

========== 健康检查报告 ==========
检查时间: 2025-09-23 08:17:15
服务总数: 6
健康: 4
降级: 1
不健康: 1
总体状态: CRITICAL
===================================

JSON 报告已保存: /reports/health-20250923-081715.json
摘要已保存: /reports/summary-20250923-081715.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
26
27
28
29
30
31
32
33
# 配置文件 services.json 示例
$configContent = @{
services = @(
@{
name = 'API 网关'
url = 'https://api.example.com/gateway/health'
timeoutSeconds = 3
latencyWarnMs = 200
latencyCritMs = 1000
}
@{
name = '用户服务'
url = 'https://api.example.com/users/health'
timeoutSeconds = 5
latencyWarnMs = 500
latencyCritMs = 2000
}
@{
name = '订单服务'
url = 'https://api.example.com/orders/health'
timeoutSeconds = 10
latencyWarnMs = 1000
latencyCritMs = 5000
}
)
notify = @{
webhookUrl = 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
enabled = $true
}
}

$configPath = './services.json'
$configContent | ConvertTo-Json -Depth 5 | Out-File -FilePath $configPath -Encoding utf8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
PS> Get-Content ./services.json | ConvertFrom-Json | ConvertTo-Json -Depth 3

{
"services": [
{
"name": "API 网关",
"url": "https://api.example.com/gateway/health",
"timeoutSeconds": 3,
"latencyWarnMs": 200,
"latencyCritMs": 1000
},
{
"name": "用户服务",
"url": "https://api.example.com/users/health",
"timeoutSeconds": 5,
"latencyWarnMs": 500,
"latencyCritMs": 2000
},
{
"name": "订单服务",
"url": "https://api.example.com/orders/health",
"timeoutSeconds": 10,
"latencyWarnMs": 1000,
"latencyCritMs": 5000
}
],
"notify": {
"webhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
"enabled": true
}
}

接下来从配置文件加载并执行检查,根据延迟阈值自动判定告警级别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 加载配置并执行检查
$config = Get-Content ./services.json -Raw | ConvertFrom-Json

$checkResults = foreach ($svc in $config.services) {
$check = Invoke-HealthCheck -Name $svc.name -Url $svc.url `
-TimeoutSeconds $svc.timeoutSeconds

# 根据延迟阈值判定告警级别
$alertLevel = 'OK'
if ($check.Status -eq 'Unhealthy') {
$alertLevel = 'CRITICAL'
}
elseif ($check.LatencyMs -gt $svc.latencyCritMs) {
$alertLevel = 'CRITICAL'
}
elseif ($check.LatencyMs -gt $svc.latencyWarnMs) {
$alertLevel = 'WARNING'
}

$check | Add-Member -NotePropertyName 'AlertLevel' `
-NotePropertyValue $alertLevel -PassThru
}

$checkResults | Format-Table Name, Status, AlertLevel, LatencyMs -AutoSize
1
2
3
4
5
Name       Status   AlertLevel LatencyMs
---- ------ ---------- ---------
API 网关 Healthy OK 35
用户服务 Healthy WARNING 612
订单服务 Healthy OK 78

注意事项

  1. 超时设置要合理:健康检查的超时时间不宜过长,通常 3-5 秒即可。过长的超时会导致批量检查时总耗时增加,也会拖慢容器编排系统的故障检测速度。

  2. 并行运行的函数作用域隔离ForEach-Object -Parallel 在独立的运行空间中执行,外部定义的函数和变量不会自动继承。需要在并行块内重新定义函数,或使用 $using: 传递变量。

  3. HTTPS 证书验证:内部服务的健康检查端点可能使用自签名证书。在测试环境中可以通过 -SkipCertificateCheck 参数跳过验证,但生产环境应配置受信任的 CA 证书。

  4. 配置文件管理:服务列表和阈值应维护在 JSON 或 YAML 配置文件中,纳入版本控制。避免在脚本中硬编码服务地址,便于环境迁移和 CI/CD 集成。

  5. 告警去重与静默:当某个服务持续不健康时,应避免重复发送告警。建议在告警逻辑中加入静默窗口(如 5 分钟内同一服务不重复告警),减少告警疲劳。

  6. 日志持久化:每次健康检查的结果应持久化存储,用于趋势分析和容量规划。可以将 JSON 报告写入时序数据库(如 InfluxDB)或对象存储(如 S3),配合 Grafana 等可视化工具查看历史趋势。

PowerShell 技能连载 - 文件系统监控

适用于 PowerShell 5.1 及以上版本

在文件服务器和开发环境中,监控文件变化是常见的需求:自动编译源代码变更、检测配置文件被篡改、审计共享文件夹的访问记录。.NET 的 FileSystemWatcher 类提供了操作系统级别的文件变更通知,不需要轮询扫描。结合 PowerShell 的事件处理机制,可以构建实时的文件监控和自动响应系统。

本文将介绍 FileSystemWatcher 的配置、事件处理和实际应用场景。

基本文件监控

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
# 创建文件系统监控器
$watcher = [System.IO.FileSystemWatcher]::new()
$watcher.Path = "C:\Projects\MyApp"
$watcher.Filter = "*.*"
$watcher.IncludeSubdirectories = $true
$watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor
[System.IO.NotifyFilters]::LastWrite -bor
[System.IO.NotifyFilters]::Size

# 注册事件处理
$action = {
$changeType = $Event.SourceEventArgs.ChangeType
$fullPath = $Event.SourceEventArgs.FullPath
$name = $Event.SourceEventArgs.Name
$timestamp = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss.fff")

$logEntry = "$timestamp | $changeType | $fullPath"
$logEntry | Out-File "C:\Logs\FileChanges.log" -Append

# 根据变更类型输出不同颜色
switch ($changeType) {
"Created" { Write-Host $logEntry -ForegroundColor Green }
"Changed" { Write-Host $logEntry -ForegroundColor Yellow }
"Deleted" { Write-Host $logEntry -ForegroundColor Red }
"Renamed" { Write-Host "$logEntry (from $($Event.SourceEventArgs.OldName))" -ForegroundColor Cyan }
}
}

# 注册各类事件
Register-ObjectEvent -InputObject $watcher -EventName "Created" -Action $action -SourceIdentifier "File.Created" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Changed" -Action $action -SourceIdentifier "File.Changed" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Deleted" -Action $action -SourceIdentifier "File.Deleted" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Renamed" -Action $action -SourceIdentifier "File.Renamed" | Out-Null

# 启用引发事件
$watcher.EnableRaisingEvents = $true
Write-Host "文件监控已启动:$($watcher.Path)" -ForegroundColor Green
Write-Host "监控子目录:$($watcher.IncludeSubdirectories)" -ForegroundColor Cyan
Write-Host "按 Ctrl+C 停止监控"

# 查看已注册的事件
Get-EventSubscriber | Where-Object { $_.SourceIdentifier -like "File.*" } |
Select-Object SourceIdentifier, Action |
Format-Table -AutoSize

# 停止监控(在需要时运行)
# $watcher.EnableRaisingEvents = $false
# Get-EventSubscriber -SourceIdentifier "File.*" | Unregister-Event
# $watcher.Dispose()

执行结果示例:

1
2
3
4
5
6
7
8
9
文件监控已启动:C:\Projects\MyApp
监控子目录:True
按 Ctrl+C 停止监控
SourceIdentifier Action
---------------- ------
File.Created System.Management.Automation.PSEventJob
File.Changed System.Management.Automation.PSEventJob
File.Deleted System.Management.Automation.PSEventJob
File.Renamed System.Management.Automation.PSEventJob

配置文件变更监控

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
# 监控特定配置文件变化并自动验证
function Start-ConfigFileMonitor {
param(
[Parameter(Mandatory)]
[string]$ConfigPath,

[string]$LogPath = "C:\Logs\ConfigChanges.log"
)

$configDir = Split-Path $ConfigPath -Parent
$configFile = Split-Path $ConfigPath -Leaf

$watcher = [System.IO.FileSystemWatcher]::new($configDir, $configFile)
$watcher.NotifyFilter = [System.IO.NotifyFilters]::LastWrite -bor
[System.IO.NotifyFilters]::Size

$action = {
$filePath = $Event.SourceEventArgs.FullPath
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

# 读取变更后的文件内容
Start-Sleep -Milliseconds 100 # 等待文件写入完成

try {
$content = Get-Content $filePath -Raw -ErrorAction Stop
$json = $content | ConvertFrom-Json -ErrorAction Stop

# 验证配置结构
$required = @("appName", "version", "database")
$missing = $required | Where-Object {
-not ($json.PSObject.Properties.Name -contains $_)
}

if ($missing) {
$msg = "$timestamp | INVALID | $filePath | 缺少字段:$($missing -join ', ')"
Write-Host $msg -ForegroundColor Red
} else {
$msg = "$timestamp | VALID | $filePath | version=$($json.version)"
Write-Host $msg -ForegroundColor Green
}

$msg | Out-File $using:LogPath -Append

} catch {
$msg = "$timestamp | PARSE_ERROR | $filePath | $($_.Exception.Message)"
Write-Host $msg -ForegroundColor Red
$msg | Out-File $using:LogPath -Append
}
}

Register-ObjectEvent -InputObject $watcher -EventName "Changed" `
-Action $action -SourceIdentifier "Config.Watch" | Out-Null

$watcher.EnableRaisingEvents = $true
Write-Host "配置文件监控已启动:$ConfigPath" -ForegroundColor Green

return $watcher
}

# 启动监控
# $configWatcher = Start-ConfigFileMonitor -ConfigPath "C:\MyApp\appsettings.json"

执行结果示例:

1
2
3
配置文件监控已启动:C:\MyApp\appsettings.json
2025-09-09 14:30:15 | VALID | C:\MyApp\appsettings.json | version=2.5.0
2025-09-09 14:35:22 | INVALID | C:\MyApp\appsettings.json | 缺少字段:database

实用监控工具集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 综合文件监控仪表板
function Start-FileMonitorDashboard {
param(
[string[]]$Paths = @("C:\Projects", "C:\Config"),
[int]$DurationMinutes = 30
)

$logFile = "C:\Logs\FileMonitor_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$stats = [System.Collections.Concurrent.ConcurrentDictionary[string, int]]::new()
$stats["Created"] = 0
$stats["Changed"] = 0
$stats["Deleted"] = 0
$stats["Renamed"] = 0

$watchers = @()
$endTime = (Get-Date).AddMinutes($DurationMinutes)

foreach ($path in $Paths) {
if (-not (Test-Path $path)) {
Write-Host "路径不存在:$path" -ForegroundColor Red
continue
}

$watcher = [System.IO.FileSystemWatcher]::new()
$watcher.Path = $path
$watcher.IncludeSubdirectories = $true
$watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor
[System.IO.NotifyFilters]::LastWrite

$eventAction = {
$changeType = $Event.SourceEventArgs.ChangeType.ToString()
$fullPath = $Event.SourceEventArgs.FullPath
$timestamp = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss")

# 更新统计
$stats = $using:stats
$stats.AddOrUpdate($changeType, 1, { param($k, $v) $v + 1 })

# 过滤临时文件
if ($fullPath -notmatch '\.(tmp|bak|~)$') {
"$timestamp|$changeType|$fullPath" | Out-File $using:logFile -Append
}
}

"Created", "Changed", "Deleted", "Renamed" | ForEach-Object {
Register-ObjectEvent -InputObject $watcher -EventName $_ `
-Action $eventAction -SourceIdentifier "Monitor.$($_).$($path.GetHashCode())" | Out-Null
}

$watcher.EnableRaisingEvents = $true
$watchers += $watcher
Write-Host "已启动监控:$path" -ForegroundColor Green
}

Write-Host "`n监控运行中,时长 $DurationMinutes 分钟..." -ForegroundColor Cyan
Write-Host "日志文件:$logFile`n"

# 定期显示统计
while ((Get-Date) -lt $endTime) {
Start-Sleep -Seconds 30
Write-Host "--- $(Get-Date -Format 'HH:mm:ss') 统计 ---" -ForegroundColor Cyan
foreach ($key in @("Created", "Changed", "Deleted", "Renamed")) {
Write-Host " $key : $($stats[$key])"
}
}

# 清理
foreach ($w in $watchers) {
$w.EnableRaisingEvents = $false
$w.Dispose()
}
Get-EventSubscriber -SourceIdentifier "Monitor.*" | Unregister-Event

Write-Host "`n监控已停止。最终统计:" -ForegroundColor Yellow
foreach ($key in @("Created", "Changed", "Deleted", "Renamed")) {
Write-Host " $key : $($stats[$key])"
}
}

# 启动 5 分钟监控演示
# Start-FileMonitorDashboard -Paths @("C:\Projects") -DurationMinutes 5

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
已启动监控:C:\Projects
已启动监控:C:\Config

监控运行中,时长 30 分钟...
日志文件:C:\Logs\FileMonitor_20250909_143000.log

--- 14:30:30 统计 ---
Created : 5
Changed : 12
Deleted : 2
Renamed : 1

监控已停止。最终统计:
Created : 23
Changed : 67
Deleted : 8
Renamed : 3

注意事项

  1. 缓冲区溢出:高频文件变更可能超出 FileSystemWatcher 内部缓冲区(默认 8KB),增大 InternalBufferSize 属性可缓解
  2. 重复事件:许多编辑器保存文件时会触发多个 Changed 事件,需要用去重逻辑(如时间窗口内相同路径合并)
  3. 网络驱动器:FileSystemWatcher 对网络路径(UNC)的监控不稳定,建议在文件所在服务器本地运行
  4. 资源释放:监控结束或脚本退出前务必调用 Dispose() 释放系统资源
  5. 权限要求:监控进程需要对目标目录有读取权限,某些系统目录可能需要管理员权限
  6. 事件队列:如果事件处理脚本执行过慢,事件会堆积在队列中,避免在事件处理中做耗时操作

PowerShell 技能连载 - 进程管理进阶

适用于 PowerShell 5.1 及以上版本(Windows)

进程管理是系统运维的基本功——排查 CPU 飙高、清理僵尸进程、监控关键服务、控制进程资源占用。虽然任务管理器能做基本的进程管理,但 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
# 按条件筛选进程
Write-Host "=== CPU 占用 Top 10 ===" -ForegroundColor Cyan
Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 |
Select-Object Name, Id,
@{N='CPU(s)'; E={[math]::Round($_.CPU, 2)}},
@{N='内存MB'; E={[math]::Round($_.WorkingSet64 / 1MB, 1)}} |
Format-Table -AutoSize

Write-Host "`n=== 内存占用 Top 10 ===" -ForegroundColor Cyan
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 |
Select-Object Name, Id,
@{N='内存MB'; E={[math]::Round($_.WorkingSet64 / 1MB, 1)}},
@{N='CPU(s)'; E={[math]::Round($_.CPU, 2)}} |
Format-Table -AutoSize

# 查找特定进程的所有实例
$nodeProcesses = Get-Process -Name "node" -ErrorAction SilentlyContinue
if ($nodeProcesses) {
Write-Host "`nNode.js 进程($($nodeProcesses.Count) 个):" -ForegroundColor Cyan
$nodeProcesses | Format-Table Id, CPU,
@{N='内存MB'; E={[math]::Round($_.WorkingSet64 / 1MB, 1)}},
StartTime -AutoSize
}

# 按用户筛选进程
function Get-ProcessByUser {
param([string]$UserName)

Get-CimInstance Win32_Process |
ForEach-Object {
$owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction SilentlyContinue
if ($owner.User -eq $UserName) {
[PSCustomObject]@{
PID = $_.ProcessId
Name = $_.Name
User = $owner.User
MemoryMB = [math]::Round($_.WorkingSetSize / 1MB, 1)
CmdLine = if ($_.CommandLine.Length -gt 80) { $_.CommandLine.Substring(0, 80) + "..." } else { $_.CommandLine }
}
}
} | Sort-Object MemoryMB -Descending
}

Get-ProcessByUser -UserName "admin" | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
=== CPU 占用 Top 10 ===
Name Id CPU(s) 内存MB
---- -- ------ ------
chrome 12345 1250.3 512.5
node 12346 456.7 256.3
msedge 12347 345.2 198.7
vscode 12348 234.5 345.2
powershell 12349 123.4 85.6

=== 内存占用 Top 10 ===
Name Id 内存MB CPU(s)
---- -- ------ ------
chrome 12345 512.5 1250.3
vscode 12348 345.2 234.5
node 12346 256.3 456.7

Node.js 进程(3 个):
Id CPU 内存MB StartTime
-- --- ------ ---------
12346 456.7 256.3 2025-07-22 06:00:00
12350 12.3 64.1 2025-07-22 07:30:00
12351 5.6 32.5 2025-07-22 08:15:00

进程监控与告警

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
# 监控关键进程资源使用
function Watch-CriticalProcesses {
param(
[string[]]$ProcessNames = @("sqlservr", "w3wp", "MyApp"),
[double]$MemoryThresholdMB = 1024,
[double]$CPUThreshold = 80,
[int]$IntervalSeconds = 60
)

while ($true) {
$timestamp = Get-Date -Format "HH:mm:ss"
Write-Host "`n[$timestamp] 进程监控" -ForegroundColor Cyan

foreach ($name in $ProcessNames) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue

if (-not $procs) {
Write-Host " $name : 未运行" -ForegroundColor Red
continue
}

foreach ($proc in $procs) {
$memMB = [math]::Round($proc.WorkingSet64 / 1MB, 1)
$cpuS = [math]::Round($proc.CPU, 2)

$memAlert = $memMB -gt $MemoryThresholdMB
$status = if ($memAlert) { "ALERT" } else { "OK" }
$color = if ($memAlert) { "Red" } else { "Green" }

Write-Host " $name (PID:$($proc.Id)) : 内存 ${memMB}MB CPU ${cpuS}s [$status]" -ForegroundColor $color
}
}

Start-Sleep -Seconds $IntervalSeconds
}
}

# 启动监控(Ctrl+C 停止)
# Watch-CriticalProcesses -MemoryThresholdMB 512

# 一次性检查并告警
function Test-ProcessHealth {
$alerts = @()

# 检查关键服务进程
$critical = @(
@{ Name = "Winmgmt"; DisplayName = "WMI 服务" },
@{ Name = "svchost"; DisplayName = "系统服务" },
@{ Name = "lsass"; DisplayName = "安全认证" }
)

foreach ($svc in $critical) {
$proc = Get-Process -Name $svc.Name -ErrorAction SilentlyContinue
if (-not $proc) {
$alerts += "[$($svc.DisplayName)] 进程未找到"
}
}

# 检查内存压力
$os = Get-CimInstance Win32_OperatingSystem
$memPct = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 1)
if ($memPct -gt 85) {
$alerts += "系统内存使用率 ${memPct}%,超过 85% 阈值"
}

# 检查僵尸进程
$zombies = Get-Process | Where-Object { $_.Responding -eq $false }
if ($zombies) {
$alerts += "$($zombies.Count) 个无响应进程:$($zombies.Name -join ', ')"
}

if ($alerts) {
Write-Host "健康检查发现 $($alerts.Count) 个问题:" -ForegroundColor Red
$alerts | ForEach-Object { Write-Host " ! $_" -ForegroundColor Red }
} else {
Write-Host "所有进程健康" -ForegroundColor Green
}
}

Test-ProcessHealth

执行结果示例:

1
2
3
4
5
6
[08:30:15] 进程监控
sqlservr (PID:2048) : 内存 1024.5MB CPU 345.2s [OK]
w3wp (PID:3072) : 内存 512.3MB CPU 123.4s [OK]
MyApp (PID:4096) : 内存 1536.7MB CPU 567.8s [ALERT]

所有进程健康

进程树与命令行

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
# 获取进程树(父子关系)
function Get-ProcessTree {
param([int]$ParentPid = $PID)

$allProcesses = Get-CimInstance Win32_Process

function Build-Tree {
param([int]$Pid, [int]$Indent = 0)

$proc = $allProcesses | Where-Object { $_.ProcessId -eq $Pid }
if ($proc) {
$prefix = " " * $Indent + "+--"
$memMB = [math]::Round($proc.WorkingSetSize / 1MB, 1)
Write-Host "$prefix $($proc.Name) (PID:$Pid, ${memMB}MB)" -ForegroundColor $(if ($Indent -eq 0) { "Cyan" } else { "White" })

$children = $allProcesses | Where-Object { $_.ParentProcessId -eq $Pid }
foreach ($child in $children) {
Build-Tree -Pid $child.ProcessId -Indent ($Indent + 1)
}
}
}

Build-Tree -Pid $ParentPid
}

Write-Host "当前 PowerShell 进程树:" -ForegroundColor Cyan
Get-ProcessTree -ParentPid $PID

# 获取进程的完整命令行
function Get-ProcessCommandLine {
param([Parameter(Mandatory)][int[]]$ProcessId)

foreach ($pid in $ProcessId) {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId = $pid"
if ($proc) {
[PSCustomObject]@{
PID = $proc.ProcessId
Name = $proc.Name
CmdLine = $proc.CommandLine
ExePath = $proc.ExecutablePath
}
}
}
}

# 查看所有 Web 服务器进程的命令行
$webProcs = Get-Process -Name "w3wp" -ErrorAction SilentlyContinue
if ($webProcs) {
Get-ProcessCommandLine -ProcessId $webProcs.Id |
Select-Object PID, Name, CmdLine |
Format-List
}

执行结果示例:

1
2
3
4
5
6
7
8
9
当前 PowerShell 进程树:
+-- pwsh (PID:5120, 85.6MB)
+-- node (PID:5234, 32.1MB)
+-- node (PID:5456, 16.5MB)

PID : 3072
Name : w3wp.exe
CmdLine : c:\windows\system32\inetsrv\w3wp.exe -ap "MyAppPool" -v "v4.0" ...
ExePath : c:\windows\system32\inetsrv\w3wp.exe

安全终止进程

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
# 安全的进程终止函数
function Stop-ProcessSafe {
param(
[Parameter(Mandatory)]
[string]$Name,

[int]$GracePeriodSeconds = 10,

[switch]$Force
)

$processes = Get-Process -Name $Name -ErrorAction SilentlyContinue
if (-not $processes) {
Write-Host "未找到进程:$Name" -ForegroundColor Yellow
return
}

foreach ($proc in $processes) {
Write-Host "正在停止 $($proc.Name) (PID:$($proc.Id))..." -ForegroundColor Cyan

if ($Force) {
$proc.Kill()
Write-Host " 已强制终止" -ForegroundColor Red
continue
}

# 尝试优雅关闭
try {
$proc.CloseMainWindow() | Out-Null
$proc.WaitForExit($GracePeriodSeconds * 1000)

if (-not $proc.HasExited) {
Write-Host " 优雅关闭超时,强制终止..." -ForegroundColor Yellow
$proc.Kill()
}

Write-Host " 已停止" -ForegroundColor Green
} catch {
Write-Host " 停止失败:$($_.Exception.Message)" -ForegroundColor Red
}
}
}

# 按端口查找并终止占用进程
function Stop-ProcessByPort {
param([Parameter(Mandatory)][int]$Port)

$connections = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue
if (-not $connections) {
Write-Host "端口 $Port 未被占用" -ForegroundColor Green
return
}

$pids = $connections | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($pid in $pids) {
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "端口 $Port 被占用:$($proc.Name) (PID:$pid)" -ForegroundColor Yellow
Stop-Process -Id $pid -Force
Write-Host " 已终止进程" -ForegroundColor Green
}
}
}

Stop-ProcessByPort -Port 8080

执行结果示例:

1
2
3
4
5
6
正在停止 node (PID:5234)...
已停止
正在停止 node (PID:5456)...
已停止
端口 8080 被占用:node (PID:6001)
已终止进程

注意事项

  1. CPU 属性含义Process.CPU 是进程自启动以来消耗的 CPU 时间(秒),不是当前使用率。计算使用率需要两次采样
  2. 管理员权限:终止其他用户的进程或访问某些进程信息需要管理员权限
  3. 进程名匹配Get-Process -Name 不含扩展名(node 而非 node.exe),且大小写不敏感
  4. 僵尸进程Responding 属性检测进程是否有响应,但只适用于有窗口的进程
  5. WMI 查询Get-CimInstance Win32_Process 可以获取命令行参数和父进程信息,Get-Process 不能
  6. 资源释放:终止进程后,内存和网络端口可能不会立即释放,需要短暂等待