PowerShell 技能连载 - Azure Monitor 告警自动化

适用于 PowerShell 7.0 及以上版本

Azure Monitor 是 Azure 的统一监控平台,负责收集、分析和处理来自云资源与应用的指标和日志数据。随着云基础设施规模的不断增长,运维团队需要面对成百上千个资源的健康状态监控需求,手动在门户中逐个配置告警规则既繁琐又容易遗漏关键阈值。

通过 PowerShell 与 Az 模块的结合,我们可以将告警规则的创建、通知动作组的配置以及告警历史查询全部脚本化。这不仅大幅提升了部署效率,还让告警策略成为代码的一部分,可以纳入版本控制和 CI/CD 流水线进行审计与回滚。

本文将围绕三个核心场景展开:指标告警规则的批量创建、基于 Log Analytics 日志查询的告警与通知配置、以及告警生命周期管理与健康合规审计。掌握这些技巧后,你可以轻松实现可观测性即代码(Observability as Code)的实践。

创建指标告警规则

在 Azure Monitor 中,指标告警(Metric Alert)是最常用的监控手段之一。当某个资源的性能指标(如 CPU 使用率、内存占用、磁盘 I/O)超过预设阈值时,系统会自动触发告警。下面我们通过 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
function New-AzMetricAlertRule {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$ResourceGroupName,

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

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

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

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

[Parameter()]
[string]$Operator = 'GreaterThan',

[Parameter()]
[string]$TimeAggregation = 'Average',

[Parameter()]
[string]$WindowSize = 'PT5M',

[Parameter()]
[string]$EvaluationFrequency = 'PT1M',

[Parameter()]
[string]$Severity = '2'
)

$condition = New-AzMetricAlertRuleV2Criteria `
-MetricName $MetricName `
-TimeAggregation $TimeAggregation `
-Operator $Operator `
-Threshold $Threshold

$params = @{
ResourceGroupName = $ResourceGroupName
Name = $AlertName
TargetResourceId = $TargetResourceId
Criterion = $condition
WindowSize = $WindowSize
EvaluationFrequency = $EvaluationFrequency
Severity = $Severity
AutoMitigate = $true
}

if ($PSCmdlet.ShouldProcess($AlertName, '创建指标告警规则')) {
Add-AzMetricAlertRuleV2 @params
}
}

# 定义标准告警模板
$alertTemplates = @(
@{
Name = 'cpu-high'
Metric = 'Percentage CPU'
Threshold = 85
Window = 'PT5M'
Severity = '2'
}
@{
Name = 'memory-high'
Metric = 'Available Memory Bytes'
Threshold = 1073741824 # 1 GB
Window = 'PT5M'
Severity = '2'
}
@{
Name = 'disk-read-latency'
Metric = 'Average Read Latency'
Threshold = 30 # 毫秒
Window = 'PT10M'
Severity = '3'
}
)

# 获取目标资源并批量创建告警
$vms = Get-AzVM -ResourceGroupName 'prod-rg'
foreach ($vm in $vms) {
$resourceId = $vm.Id
foreach ($template in $alertTemplates) {
New-AzMetricAlertRule `
-ResourceGroupName 'prod-rg' `
-TargetResourceId $resourceId `
-AlertName "$($vm.Name)-$($template.Name)" `
-MetricName $template.Metric `
-Threshold $template.Threshold `
-WindowSize $template.Window `
-Severity $template.Severity
}
Write-Host "已为 VM [$($vm.Name)] 创建 $($alertTemplates.Count) 条告警规则"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
已为 VM [web-frontend-01] 创建 3 条告警规则
已为 VM [web-frontend-02] 创建 3 条告警规则
已为 VM [api-backend-01] 创建 3 条告警规则
已为 VM [db-primary-01] 创建 3 条告警规则

Location Name Severity IsEnabled
-------- ---- -------- ---------
eastasia web-frontend-01-cpu-high 2 True
eastasia web-frontend-01-memory-high 2 True
eastasia web-frontend-01-disk-read-latency 3 True

通过模板化的方式,我们为每个虚拟机统一创建了 CPU、内存和磁盘延迟三个维度的告警。AutoMitigate 参数设为 $true 表示当指标恢复到阈值以下时告警会自动解除,避免告警堆积。

日志查询告警与动作组配置

指标告警关注的是实时数值,而日志查询告警(Log Alert)则更适合检测复杂模式和趋势。结合 Log Analytics 的 Kusto 查询语言(KQL),我们可以编写更灵活的检测逻辑。同时,动作组(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
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
function New-AzLogAlertWithActionGroup {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroupName,

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

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

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

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

[Parameter()]
[int]$ThresholdValue = 0,

[Parameter()]
[string]$Operator = 'GreaterThan',

[Parameter()]
[string]$TimeRange = 'PT1H',

[Parameter()]
[string]$EvaluationFrequency = 'PT30M',

[Parameter()]
[string]$Severity = '2'
)

# 创建日志查询计划
$schedule = New-AzScheduledQueryRuleScheduleObject `
-FrequencyInMinutes ([int]$EvaluationFrequency.TrimStart('PT','M')) `
-TimeWindowInMinutes ([int]$TimeRange.TrimStart('PT','H') * 60)

# 创建评估条件
$condition = New-AzScheduledQueryRuleConditionObject `
-Query $Query `
-TimeAggregationMethod 'Count' `
-Operator $Operator `
-Threshold $ThresholdValue

# 创建告警规则并关联动作组
$params = @{
ResourceGroupName = $ResourceGroupName
Name = $AlertName
Scopes = @($WorkspaceId)
Severity = $Severity
Enabled = $true
EvaluationSchedule = $schedule
ConditionAllOf = $condition
ActionGroupId = $ActionGroupId
}

New-AzScheduledQueryRule @params
}

# 创建动作组(邮件 + Webhook 通知)
$actionEmail = @{
Name = 'notify-ops-team'
EmailAddress = 'ops-team@contoso.com'
}
$actionWebhook = @{
Name = 'trigger-incident-system'
ServiceUri = 'https://hooks.incident.contoso.com/api/alert'
}

$actionGroup = Set-AzActionGroup `
-ResourceGroupName 'monitoring-rg' `
-Name 'ops-oncall-action-group' `
-EmailReceiver $actionEmail `
-WebhookReceiver $actionWebhook

# 检测 HTTP 5xx 错误激增的日志告警
$errorQuery = @"
let threshold = 50;
AppRequests
| where TimeGenerated > ago(1h)
| where ResultCode startswith '5'
| summarize ErrorCount = count() by bin(TimeGenerated, 5m)
| where ErrorCount > threshold
| order by TimeGenerated desc
"@

New-AzLogAlertWithActionGroup `
-ResourceGroupName 'monitoring-rg' `
-AlertName 'http-5xx-spike-detection' `
-Query $errorQuery `
-WorkspaceId '/subscriptions/xxxx/resourceGroups/monitoring-rg/providers/Microsoft.OperationalInsights/workspaces/prod-la' `
-ActionGroupId $actionGroup.Id `
-ThresholdValue 0 `
-Operator 'GreaterThan' `
-TimeRange 'PT1H' `
-EvaluationFrequency 'PT15M' `
-Severity '1'

Write-Host "日志告警规则已创建,动作组 ID: $($actionGroup.Id)"

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                 : http-5xx-spike-detection
Enabled : True
Severity : 1
Query : let threshold = 50;AppRequests| where ...
EvaluationFrequency : PT15M
WindowSize : PT1H
ActionGroup : /subscriptions/xxxx/resourceGroups/monitoring-rg/providers/microsoft.insights/actionGroups/ops-oncall-action-group

日志告警规则已创建,动作组 ID: /subscriptions/xxxx/resourceGroups/monitoring-rg/providers/microsoft.insights/actionGroups/ops-oncall-action-group

日志告警与动作组的组合使得告警触发后能同时发送邮件通知运维团队,并通过 Webhook 自动触发事件管理系统创建工单。Severity = '1' 表示这是高严重级别告警,确保在通知策略中获得最高优先级处理。

告警管理与健康检查

告警规则创建完成后,持续的运维管理同样重要。我们需要定期审查告警历史、在计划维护期间临时静默告警、以及审计告警规则是否符合组织的安全合规要求。以下脚本提供了一套完整的告警生命周期管理工具集。

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

[Parameter()]
[datetime]$Since = (Get-Date).AddDays(-7)
)

# 查询近期的告警历史
$alerts = Get-AzAlert -ResourceGroupName $ResourceGroupName `
-TimeRange "Custom" `
-StartTime $Since `
-EndTime (Get-Date)

$summary = $alerts | Group-Object Severity | ForEach-Object {
[PSCustomObject]@{
Severity = switch ($_.Name) {
'Sev0' { 'Critical'; break }
'Sev1' { 'Error'; break }
'Sev2' { 'Warning'; break }
'Sev3' { 'Informational'; break }
default { $_.Name }
}
Count = $_.Count
}
}

# 获取所有告警规则并检查合规性
$rules = Get-AzMetricAlertRuleV2 -ResourceGroupName $ResourceGroupName
$compliantRules = $rules | Where-Object {
$_.Criteria.AllOf.Count -gt 0 -and
$_.WindowSize -match '^PT[0-9]+M$' -and
$_.Actions.Count -gt 0
}

[PSCustomObject]@{
Period = "$($Since.ToString('yyyy-MM-dd')) ~ $((Get-Date).ToString('yyyy-MM-dd'))"
TotalAlerts = $alerts.Count
SeverityBreakdown = $summary
TotalRules = $rules.Count
CompliantRules = $compliantRules.Count
ComplianceRate = if ($rules.Count -gt 0) {
[math]::Round($compliantRules.Count / $rules.Count * 100, 1)
} else { 0 }
}
}

# 批量静默告警(用于计划维护窗口)
function Disable-AzAlertRulesForMaintenance {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string[]]$ResourceGroupNames,

[Parameter()]
[string]$Reason = '计划维护窗口'
)

$disabledRules = @()

foreach ($rg in $ResourceGroupNames) {
$rules = Get-AzMetricAlertRuleV2 -ResourceGroupName $rg
foreach ($rule in $rules) {
if ($PSCmdlet.ShouldProcess($rule.Name, "禁用告警规则($Reason)")) {
Update-AzMetricAlertRuleV2 `
-ResourceGroupName $rg `
-Name $rule.Name `
-Enabled $false
$disabledRules += $rule.Name
}
}
}

Write-Host "已禁用 $($disabledRules.Count) 条告警规则。原因:$Reason"
Write-Host "维护完成后请运行 Enable-AzAlertRulesForMaintenance 重新启用。"

return $disabledRules
}

# 生成健康报告
$report = Get-AzAlertHealthReport -ResourceGroupName 'prod-rg'
$report | Format-List

Write-Host "`n--- 告警严重级别分布 ---"
$report.SeverityBreakdown | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Period            : 2025-12-31 ~ 2026-01-07
TotalAlerts : 47
SeverityBreakdown : {@{Severity=Critical; Count=3}, @{Severity=Error; Count=12}, @{Severity=Warning; Count=28}, @{Severity=Informational; Count=4}}
TotalRules : 24
CompliantRules : 21
ComplianceRate : 87.5

--- 告警严重级别分布 ---
Severity Count
-------- -----
Critical 3
Error 12
Warning 28
Informational 4

健康报告展示了过去一周的告警概况,包括各级别的告警数量和告警规则的合规率。合规检查验证了每条规则是否配置了有效的检测条件、合理的时间窗口和关联的动作组。合规率低于 100% 的规则需要人工审查并补充缺失的配置项。

注意事项

  1. Az 模块版本要求:本文使用的 Az.MonitorAz.ScheduledQueryRule 模块需要 4.0 以上版本。建议运行 Update-Module Az.Monitor 确保使用最新版本,旧版本的部分参数名和 API 行为存在差异。

  2. 权限配置:执行告警规则管理操作需要订阅级别的 Monitoring Contributor 角色权限。如果仅需要查询告警历史,Monitoring Reader 角色即可满足。生产环境中应遵循最小权限原则分配角色。

  3. 告警阈值调优:初次部署告警规则后,建议先设置较宽的阈值并配合低严重级别运行一周,收集实际触发数据后再逐步收紧阈值。过于敏感的阈值会导致告警疲劳,降低团队对真实问题的响应速度。

  4. 动作组测试:创建动作组后务必使用 Test-AzActionGroup 或手动触发测试告警,验证邮件和 Webhook 通知链路是否畅通。很多告警沉默事故的根因都是动作组配置错误导致通知未能送达。

  5. 维护窗口静默:批量禁用告警规则时,建议将禁用操作记录到变更管理系统中,并设置自动提醒在维护结束后重新启用。也可以利用告警处理规则(Alert Processing Rule)的静默功能替代直接禁用,这样不需要修改原始规则配置。

  6. 日志查询性能:Log Analytics 查询的执行成本与扫描的数据量成正比。在高频评估(如每 5 分钟一次)的场景下,查询中应尽量添加时间过滤条件和索引列过滤,避免全表扫描导致额外的计费开销和延迟。

PowerShell 技能连载 - DSC v3 配置即代码

适用于 PowerShell 7.0 及以上版本

在基础设施即代码(IaC)的实践中,配置管理一直是最核心也最容易出问题的环节。传统的 DSC v2 依赖本地配置管理器(LCM)和 MOF 文档,虽然功能强大但架构笨重,调试困难,且难以与现代 GitOps 流水线无缝对接。微软推出的 DSC v3 彻底重新设计了架构,将配置引擎与资源提供者解耦,采用 JSON 作为配置文档格式,并原生支持跨平台运行。

DSC v3 的设计哲学是”配置即代码”(Configuration as Code)。配置文档以 JSON 格式存储,天然适合纳入 Git 版本控制;资源提供者可以基于任何语言开发(PowerShell、Python、Go 均可),通过标准化的 JSON Schema 接口与 DSC 引擎通信。这种松耦合架构让 DSC v3 能够轻松融入 CI/CD 流水线,与 Azure Machine Configuration、Ansible、Terraform 等工具协同工作。

本文将从实战角度出发,演示如何用 DSC v3 编写 JSON 配置文档、开发自定义 PowerShell 资源,以及构建配置漂移检测机制,帮助你建立可靠的配置即代码工作流。

DSC v3 配置文档编写

DSC v3 使用 JSON 格式的配置文档来声明系统的期望状态。配置文档包含资源实例的列表,每个实例通过 type 指定资源类型,通过 properties 定义期望的配置值。下面的代码展示如何编写一份完整的 DSC v3 配置文档,并利用资源发现功能验证配置的合法性。

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
# 定义 DSC v3 配置文档(JSON 格式)
$configDocument = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json'
contentVersion = '1.0.0'
resources = @(
@{
type = 'Microsoft.Windows/Registry'
name = 'EnableLongPaths'
properties = @{
keyPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem'
valueName = 'LongPathsEnabled'
valueData = @{
dword = 1
}
ensure = 'Present'
}
}
@{
type = 'Microsoft.Windows/Feature'
name = 'InstallSSH'
properties = @{
name = 'OpenSSH.Server'
ensure = 'Present'
includeAllSubFeature = $false
}
}
@{
type = 'Microsoft/Process'
name = 'EnsureSSHService'
properties = @{
path = '/usr/sbin/sshd'
running = $true
}
}
)
}

# 将配置保存为 JSON 文件
$configPath = Join-Path $env:TEMP 'dsc-v3-server-config.json'
$configDocument | ConvertTo-Json -Depth 10 | Set-Content -Path $configPath -Encoding UTF8

Write-Host "配置文档已保存至: $configPath"

# 列出当前可用的 DSC 资源类型
Write-Host "`n--- 已注册的 DSC 资源 ---"
dsc resource list 2>$null | ForEach-Object {
$r = $_ | ConvertFrom-Json
Write-Host (" {0,-40} {1}" -f $r.type, $r.version)
}

# 验证配置文档的合法性
Write-Host "`n--- 验证配置文档 ---"
$validation = dsc config validate -p $configPath 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "配置文档验证通过" -ForegroundColor Green
$validation | ConvertFrom-Json | ForEach-Object {
Write-Host (" 资源: {0} -> 状态: {1}" -f $_.name, '有效')
}
} else {
Write-Host "配置文档验证失败:" -ForegroundColor Red
Write-Host $validation
}

# 导出配置文档的完整 JSON Schema(用于 IDE 智能提示)
Write-Host "`n--- 配置文档概览 ---"
$schemaInfo = @{
配置路径 = $configPath
资源数量 = $configDocument.resources.Count
资源清单 = $configDocument.resources | ForEach-Object { "$($_.type)[$($_.name)]" }
版本 = $configDocument.contentVersion
}
$schemaInfo | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
配置文档已保存至: /tmp/dsc-v3-server-config.json

--- 已注册的 DSC 资源 ---
Microsoft.Windows/Registry 0.1.0
Microsoft.Windows/Feature 0.2.0
Microsoft/Process 0.1.0
Microsoft/OSInfo 0.1.0

--- 验证配置文档 ---
配置文档验证通过
资源: EnableLongPaths -> 状态: 有效
资源: InstallSSH -> 状态: 有效
资源: EnsureSSHService -> 状态: 有效

--- 配置文档概览 ---

配置路径 资源数量 资源清单 版本
-------- -------- -------- ----
/tmp/dsc-v3-server-config.json 3 {Microsoft.Windows/Registry[EnableLongPaths]...} 1.0.0

自定义 DSC 资源开发

DSC v3 的资源提供者采用适配器模式,每个资源需要实现 getsettestdelete 四个操作。对于 PowerShell 用户来说,最自然的方式是使用 Class-based 资源。下面的代码演示如何创建一个管理本地用户配置文件的自定义 DSC 资源,包括资源定义、导出和注册。

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
# 定义自定义 DSC 资源的清单文件(manifest)
$resourceManifest = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/resource/manifest.json'
type = 'Contoso.UserProfile'
version = '1.0.0'
description = '管理用户环境配置文件'
get = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Get-TargetResource"
)
}
set = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Set-TargetResource"
)
preTest = $true
}
test = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Test-TargetResource"
)
}
schema = @{
embedded = @{
type = 'object'
properties = @{
UserName = @{ type = 'string'; description = '用户名' }
HomeDirectory = @{ type = 'string'; description = '主目录路径' }
Shell = @{ type = 'string'; description = '默认 Shell' }
Ensure = @{
type = 'string'
enum = @('Present', 'Absent')
description = '确保状态'
}
}
required = @('UserName')
}
}
}

# 保存资源清单
$manifestPath = Join-Path $env:TEMP 'Contoso.UserProfile.dsc.resource.json'
$resourceManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding UTF8

# 编写 PowerShell 脚本资源实现
$scriptResource = @'
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$UserName,

[string]$HomeDirectory,
[string]$Shell = '/bin/zsh',
[ValidateSet('Present', 'Absent')]
[string]$Ensure = 'Present'
)

begin {
function Write-DscOutput {
param([hashtable]$Data)
$Data | ConvertTo-Json -Depth 5 -Compress
}
}

process {
# 获取当前状态的实际值
$actualUser = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue
$actualHome = $actualUser ? (Get-ADUserResultantHomePath $UserName) : $null

# 构建输出对象(符合 DSC v3 JSON Schema)
$result = @{
actualState = @{
UserName = $UserName
HomeDirectory = if ($actualHome) { $actualHome } else { '未设置' }
Shell = $Shell
Ensure = if ($actualUser) { 'Present' } else { 'Absent' }
InDesiredState = ($null -ne $actualUser -and $Ensure -eq 'Present')
}
}

Write-DscOutput -Data $result
}
'@

$resourceScript = Join-Path $env:TEMP 'Contoso.DscResources.psm1'
$scriptResource | Set-Content -Path $resourceScript -Encoding UTF8

# 注册自定义资源到 DSC v3
Write-Host "自定义资源清单已保存至: $manifestPath"
Write-Host "资源实现脚本已保存至: $resourceScript"

# 在配置文档中引用自定义资源
$customConfig = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json'
contentVersion = '1.0.0'
resources = @(
@{
type = 'Contoso.UserProfile'
name = 'DevOperator'
properties = @{
UserName = 'devops'
HomeDirectory = '/home/devops'
Shell = '/bin/zsh'
Ensure = 'Present'
}
}
)
}

$customConfigPath = Join-Path $env:TEMP 'dsc-v3-custom-config.json'
$customConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $customConfigPath -Encoding UTF8

Write-Host "`n自定义资源配置文档已保存至: $customConfigPath"
Write-Host "资源类型: Contoso.UserProfile v1.0.0"
Write-Host "包含属性: UserName, HomeDirectory, Shell, Ensure"

执行结果示例:

1
2
3
4
5
6
自定义资源清单已保存至: /tmp/Contoso.UserProfile.dsc.resource.json
资源实现脚本已保存至: /tmp/Contoso.DscResources.psm1

自定义资源配置文档已保存至: /tmp/dsc-v3-custom-config.json
资源类型: Contoso.UserProfile v1.0.0
包含属性: UserName, HomeDirectory, Shell, Ensure

配置测试与偏差修正

DSC v3 的核心工作模式是 Get-Test-Set 循环。get 操作获取当前实际状态,test 操作比较实际状态与期望状态,set 操作将系统收敛到期望状态。下面的代码演示如何构建一个完整的配置漂移检测和修正流程,并生成可读的漂移报告。

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
# 定义漂移检测与修正函数
function Invoke-DscDriftDetection {
param(
[Parameter(Mandatory)]
[string]$ConfigPath,

[switch]$AutoRemediate,

[switch]$DetailedReport
)

$report = [System.Collections.Generic.List[PSObject]]::new()
$timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'

# 步骤 1: 获取当前状态(dsc config get)
Write-Host "[$timestamp] 正在获取当前配置状态..." -ForegroundColor Cyan
$getResult = dsc config get -p $ConfigPath 2>&1
$currentState = $getResult | ConvertFrom-Json

# 步骤 2: 测试配置漂移(dsc config test)
Write-Host "[$timestamp] 正在检测配置漂移..." -ForegroundColor Cyan
$testResult = dsc config test -p $ConfigPath 2>&1
$testState = $testResult | ConvertFrom-Json

$driftCount = 0
$inSpecCount = 0

foreach ($resource in $testState.results) {
$entry = [ordered]@{
Timestamp = $timestamp
ResourceType = $resource.type
ResourceName = $resource.name
InDesiredState = $resource.inDesiredState
DriftDetails = $null
}

if (-not $resource.inDesiredState) {
$driftCount++
# 提取漂移的具体属性差异
$diffs = @()
if ($resource.differences) {
foreach ($diff in $diff) {
$diffs += "{0}: 期望='{1}', 实际='{2}'" -f @(
$diff.property
$diff.expected
$diff.actual
)
}
} else {
$diffs = @('状态不匹配')
}
$entry.DriftDetails = $diffs -join '; '
Write-Host (" [漂移] {0}[{1}]: {2}" -f @(
$resource.type, $resource.name, ($diffs -join ', ')
)) -ForegroundColor Yellow
} else {
$inSpecCount++
if ($DetailedReport) {
Write-Host (" [合规] {0}[{1}]" -f @(
$resource.type, $resource.name
)) -ForegroundColor Green
}
}

$report.Add([PSCustomObject]$entry)
}

# 步骤 3: 输出汇总报告
Write-Host "`n========== 配置漂移报告 ==========" -ForegroundColor White
Write-Host ("检测时间: {0}" -f $timestamp)
Write-Host ("配置文件: {0}" -f $ConfigPath)
Write-Host ("资源总数: {0}" -f ($driftCount + $inSpecCount))
Write-Host ("合规数量: {0}" -f $inSpecCount) -ForegroundColor Green
Write-Host ("漂移数量: {0}" -f $driftCount) -ForegroundColor $(if ($driftCount -gt 0) { 'Red' } else { 'Green' })

# 步骤 4: 自动修正(如果启用)
if ($driftCount -gt 0 -and $AutoRemediate) {
Write-Host "`n[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')] 正在执行自动修正..." -ForegroundColor Cyan
$setResult = dsc config set -p $ConfigPath 2>&1
$setState = $setResult | ConvertFrom-Json

$remediated = 0
foreach ($resource in $setState.results) {
if ($resource.rebootRequired) {
Write-Host (" [注意] {0}[{1}] 需要重启" -f @(
$resource.type, $resource.name
)) -ForegroundColor Magenta
}
$remediated++
}

Write-Host ("修正完成: {0} 个资源已收敛" -f $remediated) -ForegroundColor Green
} elseif ($driftCount -gt 0) {
Write-Host "`n提示: 使用 -AutoRemediate 参数可自动修正漂移" -ForegroundColor DarkGray
}

Write-Host "=================================="

# 返回结构化报告
return $report
}

# 执行漂移检测(仅检测,不修正)
$serverConfig = Join-Path $env:TEMP 'dsc-v3-server-config.json'
$driftReport = Invoke-DscDriftDetection -ConfigPath $serverConfig -DetailedReport

# 将漂移报告导出为 JSON(供 CI/CD 流水线消费)
$reportPath = Join-Path $env:TEMP "drift-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$driftReport | ConvertTo-Json -Depth 5 | Set-Content -Path $reportPath -Encoding UTF8
Write-Host "`n漂移报告已导出至: $reportPath"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2026-01-06T10:30:15+08:00] 正在获取当前配置状态...
[2026-01-06T10:30:17+08:00] 正在检测配置漂移...
[漂移] Microsoft.Windows/Registry[EnableLongPaths]: 状态不匹配
[合规] Microsoft.Windows/Feature[InstallSSH]
[合规] Microsoft/Process[EnsureSSHService]

========== 配置漂移报告 ==========
检测时间: 2026-01-06T10:30:17+08:00
配置文件: /tmp/dsc-v3-server-config.json
资源总数: 3
合规数量: 2
漂移数量: 1
提示: 使用 -AutoRemediate 参数可自动修正漂移
==================================

漂移报告已导出至: /tmp/drift-report-20260106-103017.json

注意事项

  1. DSC v3 需要独立安装:DSC v3 是独立的原生可执行文件(dsc),不随 PowerShell 自带。你需要从 PowerShell DSC 的 GitHub Releases 页面单独下载安装,或通过 winget install Microsoft.DSC 获取。

  2. JSON Schema 验证很重要:编写配置文档时务必引用官方 JSON Schema。大多数现代编辑器(VS Code、JetBrains)能根据 $schema 字段提供智能提示和实时校验,大幅减少语法错误。在应用到生产环境前,始终先运行 dsc config validate

  3. 自定义资源的幂等性:自定义资源必须确保 set 操作的幂等性——即多次执行结果一致。在 test 操作中精确比较期望状态与实际状态,避免产生不必要的修正操作。对于复杂属性,建议逐字段对比而非整体序列化比较。

  4. 配置漂移报告纳入 CI/CD:将漂移检测集成到 CI/CD 流水线中,每次配置变更都自动触发漂移检测。-AutoRemediate 参数在生产环境使用时应格外谨慎,建议先以只读模式运行检测,人工确认漂移报告后再执行修正。

  5. 与 Azure Machine Configuration 配合:DSC v3 是 Azure Machine Configuration( formerly Azure Policy Guest Configuration)的原生引擎。如果你的环境在 Azure 中,可以通过 Azure Policy 将 DSC v3 配置分配给虚拟机,实现大规模的配置合规性审计和自动修正。

  6. 版本管理配置文档:配置文档应纳入 Git 版本控制,通过 Pull Request 审核配置变更。建议在仓库中设置 dsc config validate 作为 pre-commit hook,确保每次提交的配置文档都是合法的。同时保留历史配置版本,便于回滚和变更追溯。

PowerShell 技能连载 - 类定义与面向对象编程

适用于 PowerShell 5.1 及以上版本

在 PowerShell 的早期版本中,我们通常使用 PSCustomObject 或哈希表来构建自定义数据结构。虽然它们足够灵活,但缺乏类型约束、无法定义方法、也不支持继承,在构建大型自动化项目时显得力不从心。PowerShell 5.0 引入了 class 关键字,让我们可以直接在脚本中定义真正的 .NET 类型。

class 不仅仅是语法糖,它带来了完整的面向对象编程能力:类型安全的属性、可重载的构造函数、继承与多态、以及与 .NET 生态的无缝集成。你可以用 class 来建模业务实体、封装复杂逻辑、甚至实现设计模式,让脚本从”一次性工具”进化为可维护的工程化代码。

本文将从基础类定义开始,逐步深入继承与多态,最后通过一个服务器管理框架的实战案例,展示 class 在真实自动化场景中的威力。

基础类定义

下面通过一个 ServerInfo 类来演示属性、构造函数和方法的定义与使用。这个类用于封装服务器的基本信息,并提供格式化输出和状态检查方法。

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
class ServerInfo {
# 类型化的属性
[string]$Name
[string]$IPAddress
[int]$Port
[string]$Environment
[datetime]$LastChecked

# 默认构造函数
ServerInfo() {
$this.Port = 443
$this.Environment = 'Development'
$this.LastChecked = Get-Date
}

# 带参数的构造函数
ServerInfo([string]$Name, [string]$IPAddress, [int]$Port) {
$this.Name = $Name
$this.IPAddress = $IPAddress
$this.Port = $Port
$this.Environment = 'Production'
$this.LastChecked = Get-Date
}

# 方法:获取连接地址
[string] GetEndpoint() {
return "$($this.IPAddress):$($this.Port)"
}

# 方法:检查端口是否可达
[bool] TestConnection() {
try {
$tcp = [System.Net.Sockets.TcpClient]::new()
$connect = $tcp.BeginConnect($this.IPAddress, $this.Port, $null, $null)
$wait = $connect.AsyncWaitHandle.WaitOne(3000, $false)
if ($wait) {
$tcp.EndConnect($connect)
$this.LastChecked = Get-Date
return $true
}
return $false
}
finally {
$tcp.Dispose()
}
}

# 重写 ToString 方法
[string] ToString() {
return "[$($this.Environment)] $($this.Name) ($($this.GetEndpoint()))"
}
}

# 使用默认构造函数
$devServer = [ServerInfo]::new()
$devServer.Name = 'DEV-WEB-01'
$devServer.IPAddress = '192.168.1.100'
Write-Host $devServer.ToString()

# 使用带参数的构造函数
$prodServer = [ServerInfo]::new('PROD-WEB-01', '10.0.0.50', 8443)
Write-Host "服务器: $($prodServer.Name)"
Write-Host "端点: $($prodServer.GetEndpoint())"
Write-Host "环境: $($prodServer.Environment)"
Write-Host "上次检查: $($prodServer.LastChecked.ToString('yyyy-MM-dd HH:mm:ss'))"

执行结果示例:

1
2
3
4
5
[Development] DEV-WEB-01 (192.168.1.100:443)
服务器: PROD-WEB-01
端点: 10.0.0.50:8443
环境: Production
上次检查: 2026-01-05 08:30:15

继承与多态

当类之间具有层次关系时,继承可以让子类复用父类的属性和方法,同时添加或重写自己的行为。下面的例子定义了一个通用的部署任务基类,然后派生出两种具体的任务类型。

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
# 基类:部署任务
class DeploymentTask {
[string]$TaskName
[string]$TargetServer
[datetime]$StartTime
[datetime]$EndTime
[string]$Status = 'Pending'

DeploymentTask([string]$TaskName, [string]$TargetServer) {
$this.TaskName = $TaskName
$this.TargetServer = $TargetServer
}

# 虚方法:执行部署(子类应重写)
[void] Execute() {
$this.StartTime = Get-Date
Write-Host "正在执行任务: $($this.TaskName)"
}

# 标记完成
[void] Complete() {
$this.EndTime = Get-Date
$this.Status = 'Completed'
$duration = ($this.EndTime - $this.StartTime).TotalSeconds
Write-Host "任务完成: $($this.TaskName) (耗时 $([math]::Round($duration, 2)) 秒)"
}

# 标记失败
[void] Fail([string]$Reason) {
$this.EndTime = Get-Date
$this.Status = "Failed: $Reason"
Write-Host "任务失败: $($this.TaskName) - $Reason"
}

# 获取摘要信息
[hashtable] GetSummary() {
return @{
TaskName = $this.TaskName
TargetServer = $this.TargetServer
Status = $this.Status
Duration = if ($this.StartTime -and $this.EndTime) {
($this.EndTime - $this.StartTime).TotalSeconds
} else { 0 }
}
}
}

# 子类:Web 应用部署
class WebAppDeployment : DeploymentTask {
[string]$AppPoolName
[string]$SiteName
[string]$PackagePath

WebAppDeployment(
[string]$TargetServer,
[string]$SiteName,
[string]$PackagePath
) : base("Deploy-WebApp-$SiteName", $TargetServer) {
$this.SiteName = $SiteName
$this.AppPoolName = "$SiteName-Pool"
$this.PackagePath = $PackagePath
}

# 重写 Execute 方法
[void] Execute() {
([DeploymentTask]$this).Execute()
Write-Host " 停止应用池: $($this.AppPoolName)"
Write-Host " 备份当前版本..."
Write-Host " 解压部署包: $($this.PackagePath)"
Write-Host " 配置站点: $($this.SiteName)"
Write-Host " 启动应用池: $($this.AppPoolName)"
$this.Complete()
}
}

# 子类:数据库迁移部署
class DatabaseMigration : DeploymentTask {
[string]$DatabaseName
[string]$MigrationScript
[bool]$BackupBeforeMigration = $true

DatabaseMigration(
[string]$TargetServer,
[string]$DatabaseName,
[string]$MigrationScript
) : base("DB-Migration-$DatabaseName", $TargetServer) {
$this.DatabaseName = $DatabaseName
$this.MigrationScript = $MigrationScript
}

# 重写 Execute 方法
[void] Execute() {
([DeploymentTask]$this).Execute()
Write-Host " 目标数据库: $($this.DatabaseName)"
if ($this.BackupBeforeMigration) {
Write-Host " 正在备份数据库..."
}
Write-Host " 执行迁移脚本: $($this.MigrationScript)"
Write-Host " 验证迁移结果..."
$this.Complete()
}
}

# 多态调用:统一执行不同类型的部署任务
$tasks = @(
[WebAppDeployment]::new('WEB-SVR-01', 'CustomerPortal', 'D:\Packages\v2.5.0.zip')
[DatabaseMigration]::new('DB-SVR-01', 'CustomerDB', 'v2.5.0_schema_update.sql')
)

foreach ($task in $tasks) {
Write-Host "`n--- 部署任务 ---"
$task.Execute()
$summary = $task.GetSummary()
Write-Host "摘要: $($summary.Status)"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- 部署任务 ---
正在执行任务: Deploy-WebApp-CustomerPortal
停止应用池: CustomerPortal-Pool
备份当前版本...
解压部署包: D:\Packages\v2.5.0.zip
配置站点: CustomerPortal
启动应用池: CustomerPortal-Pool
任务完成: Deploy-WebApp-CustomerPortal (耗时 12.35 秒)
摘要: Completed

--- 部署任务 ---
正在执行任务: DB-Migration-CustomerDB
目标数据库: CustomerDB
正在备份数据库...
执行迁移脚本: v2.5.0_schema_update.sql
验证迁移结果...
任务完成: DB-Migration-CustomerDB (耗时 8.72 秒)
摘要: Completed

实战应用:服务器管理框架

在实际运维场景中,我们可以用 class 构建一个完整的服务器管理框架,包含数据验证、JSON 序列化和结构化错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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
# 验证属性类
class ValidateRangeAttribute : System.Management.Automation.ValidateArgumentsAttribute {
[int]$MinValue
[int]$MaxValue

ValidateRangeAttribute([int]$Min, [int]$Max) {
$this.MinValue = $Min
$this.MaxValue = $Max
}

[void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) {
$val = [int]$arguments
if ($val -lt $this.MinValue -or $val -gt $this.MaxValue) {
throw "值 $val 不在允许范围 ($($this.MinValue) - $($this.MaxValue)) 内"
}
}
}

# 服务器管理基类
class ManagedServer {
[ValidateNotNullOrEmpty()][string]$HostName
[string]$IPAddress
[string]$Role
[string]$DataCenter
[bool]$IsMonitored = $false

# 静态属性:已注册的服务器列表
static [System.Collections.Generic.List[ManagedServer]]$Registry =
[System.Collections.Generic.List[ManagedServer]]::new()

ManagedServer([string]$HostName, [string]$IPAddress) {
$this.HostName = $HostName
$this.IPAddress = $IPAddress
[ManagedServer]::Registry.Add($this)
}

# 转换为 JSON
[string] ToJson() {
$ordered = [ordered]@{
hostName = $this.HostName
ipAddress = $this.IPAddress
role = $this.Role
dataCenter = $this.DataCenter
isMonitored = $this.IsMonitored
}
return $ordered | ConvertTo-Json -Depth 3
}

# 从 JSON 创建实例
static [ManagedServer] FromJson([string]$Json) {
$data = $Json | ConvertFrom-Json
$server = [ManagedServer]::new($data.hostName, $data.ipAddress)
$server.Role = $data.role
$server.DataCenter = $data.dataCenter
$server.IsMonitored = $data.isMonitored
return $server
}

# 获取所有已注册服务器
static [array] GetRegisteredServers() {
return [ManagedServer]::Registry.ToArray()
}
}

# 托管 Web 服务器
class ManagedWebServer : ManagedServer {
[string[]]$BoundUrls = @()
[int]$WorkerProcesses = 4
[string]$RuntimeVersion = '8.0'
[hashtable]$HealthMetrics = @{}

ManagedWebServer([string]$HostName, [string]$IPAddress) : base($HostName, $IPAddress) {
$this.Role = 'WebServer'
}

# 健康检查
[hashtable] CheckHealth() {
$result = @{
Server = $this.HostName
Timestamp = Get-Date -Format 'o'
Checks = @()
}

$checks = @(
@{ Name = 'CPU'; Value = (Get-Random -Min 10 -Max 95); Unit = '%' }
@{ Name = 'Memory'; Value = (Get-Random -Min 30 -Max 90); Unit = '%' }
@{ Name = 'Disk'; Value = (Get-Random -Min 20 -Max 85); Unit = '%' }
)

foreach ($check in $checks) {
$status = if ($check.Value -gt 80) { 'Warning' } else { 'OK' }
$result.Checks += @{
Name = $check.Name
Value = $check.Value
Unit = $check.Unit
Status = $status
}
}

$this.HealthMetrics = $result
$this.IsMonitored = $true
return $result
}

# 重写 ToJson 以包含扩展属性
[string] ToJson() {
$ordered = [ordered]@{
hostName = $this.HostName
ipAddress = $this.IPAddress
role = $this.Role
dataCenter = $this.DataCenter
isMonitored = $this.IsMonitored
boundUrls = $this.BoundUrls
workerProcesses = $this.WorkerProcesses
runtimeVersion = $this.RuntimeVersion
}
return $ordered | ConvertTo-Json -Depth 3
}
}

# --- 使用示例 ---

# 创建并注册服务器
$web1 = [ManagedWebServer]::new('WEB-PROD-01', '10.1.0.10')
$web1.DataCenter = 'EastAsia'
$web1.BoundUrls = @('https://portal.contoso.com', 'https://api.contoso.com')

$web2 = [ManagedWebServer]::new('WEB-PROD-02', '10.1.0.11')
$web2.DataCenter = 'EastAsia'
$web2.BoundUrls = @('https://portal.contoso.com')
$web2.WorkerProcesses = 8

Write-Host "已注册服务器数量: $([ManagedServer]::Registry.Count)"
Write-Host "`n--- $web1 健康检查 ---"
$health = $web1.CheckHealth()
foreach ($check in $health.Checks) {
Write-Host (" {0,-10} {1}{2,-5} [{3}]" -f $check.Name, $check.Value, $check.Unit, $check.Status)
}

Write-Host "`n--- JSON 序列化 ---"
Write-Host $web1.ToJson()

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
已注册服务器数量: 2

--- WEB-PROD-01 健康检查 ---
CPU 42% [OK]
Memory 67% [OK]
Disk 31% [OK]

--- JSON 序列化 ---
{
"hostName": "WEB-PROD-01",
"ipAddress": "10.1.0.10",
"role": "WebServer",
"dataCenter": "EastAsia",
"isMonitored": true,
"boundUrls": [
"https://portal.contoso.com",
"https://api.contoso.com"
],
"workerProcesses": 4,
"runtimeVersion": "8.0"
}

注意事项

  1. PowerShell 版本要求class 关键字需要 PowerShell 5.0 及以上版本。在 Windows PowerShell 5.1 中功能完整可用,PowerShell 7 进一步增强了与 .NET Core 的兼容性。如果你需要在旧版本中运行脚本,请改用 PSCustomObject 或 C# 编译的 cmdlet。

  2. 属性初始化时机:类的属性默认值在类定义时求值,不是在实例化时求值。如果需要动态默认值(如当前时间),应放在构造函数中赋值,而不是在属性声明处直接使用 Get-Date

  3. 继承的限制:PowerShell class 只支持单继承(一个父类),但可以实现多个接口。方法重写时需要使用 ([BaseClass]$this).Method() 语法调用父类方法,这与 C# 的 base.Method() 略有不同。

  4. 序列化注意事项:class 实例默认不能被 Export-Clixml 正确序列化和反序列化,反序列化后会变成 Deserialized.ClassName 对象,丢失方法。如果需要持久化,建议使用 ToJson() / FromJson() 模式。

  5. 调试与类型检查:class 中的方法不支持 Write-Output 返回值(会被忽略),必须使用 return 语句。同时,类方法内的 Write-Verbose 等流输出在某些宿主环境中可能不会显示,建议在方法外部进行日志记录。

  6. 性能考量:虽然 class 提供了强类型和结构化的优势,但对于简单的数据传递场景,PSCustomObject 仍然更轻量。仅在需要封装逻辑、继承关系或类型约束时使用 class,避免过度设计。

PowerShell 技能连载 - 新年自动化脚本

适用于 PowerShell 5.1 及以上版本

新年伊始,系统管理员面临着一系列”开年”初始化任务:更新所有自动化脚本的版权年份、重置年度计数器和统计数据、创建新的日志目录结构、部署第一季度的安全基线检查。这些琐碎但重要的工作如果逐一手动完成,不仅耗时,还容易遗漏关键环节,给后续全年运行埋下隐患。

更麻烦的是,这些任务往往分散在不同的服务器和项目目录中。脚本可能分布在几十个仓库里,日志配置文件散落在多台服务器上,计划任务的调度时间需要按新年度重新校准。手动逐一排查和更新,不仅效率低下,而且很难保证一致性——某台服务器忘了更新目录,某份脚本的年份还是去年的,这些疏忽往往要到年中出问题时才被发现。

通过 PowerShell 脚本将这些”开年”任务编排为一套可一键执行的自动化流程,可以确保所有初始化工作在统一的控制下完成,每一步都有日志记录、有结果校验。本文将从年度环境初始化、安全基线审计、全年计划任务部署三个维度,展示如何用 PowerShell 高效完成新年开局工作。

年度环境初始化

新年第一天要做的第一件事就是为全年运维准备好基础环境。下面的脚本会批量创建年度日志目录结构、重置 JSON 配置文件中的计数器、并自动更新所有脚本文件头部的版权年份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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
function Initialize-NewYearEnvironment {
<#
.SYNOPSIS
执行新年环境初始化:创建目录、重置计数器、更新版权年份
.PARAMETER Year
目标年份,默认为当前年份
.PARAMETER LogBasePath
日志根目录路径
.PARAMETER ConfigPath
年度配置 JSON 文件路径
#>
param(
[int]$Year = (Get-Date).Year,
[string]$LogBasePath = "C:\Logs",
[string]$ConfigPath = "C:\Scripts\config\annual-config.json"
)

$results = @()

# ---- 1. 创建年度日志目录结构(按月和类别) ----
$categories = @("System", "Security", "Application", "Backup", "Audit")
$createdDirs = @()

foreach ($category in $categories) {
1..12 | ForEach-Object {
$month = "{0:D2}" -f $_
$dirPath = Join-Path $LogBasePath "$Year\$category\$month"
if (-not (Test-Path $dirPath)) {
New-Item -Path $dirPath -ItemType Directory -Force | Out-Null
$createdDirs += $dirPath
}
}
}
$results += "[目录] 已创建 $($createdDirs.Count) 个日志目录 ($LogBasePath\$Year\)"

# ---- 2. 重置年度配置计数器 ----
if (Test-Path $ConfigPath) {
$config = Get-Content -Path $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json

# 重置核心计数器
$config.Year = $Year
$config.Counters.IncidentNumber = 0
$config.Counters.ChangeRequestNumber = 0
$config.Counters.BackupJobCount = 0
$config.Counters.LastResetDate = (Get-Date -Format "yyyy-MM-dd")
$config.Counters.PreviousYearTotal = $config.Counters.YearlyTotal
$config.Counters.YearlyTotal = 0

# 更新维护窗口配置
$config.MaintenanceWindows.NextScheduled = "$Year-01-05T02:00:00"

$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
$results += "[配置] 已重置年度计数器: $ConfigPath"
} else {
# 首次运行,创建默认配置
$defaultConfig = [PSCustomObject]@{
Year = $Year
Counters = @{
IncidentNumber = 0
ChangeRequestNumber = 0
BackupJobCount = 0
YearlyTotal = 0
PreviousYearTotal = 0
LastResetDate = (Get-Date -Format "yyyy-MM-dd")
}
MaintenanceWindows = @{
NextScheduled = "$Year-01-05T02:00:00"
DefaultDurationHours = 4
}
}
$dirName = Split-Path $ConfigPath -Parent
if (-not (Test-Path $dirName)) {
New-Item -Path $dirName -ItemType Directory -Force | Out-Null
}
$defaultConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
$results += "[配置] 已创建默认年度配置: $ConfigPath"
}

# ---- 3. 批量更新脚本文件的版权年份 ----
$scriptPaths = @(
"C:\Scripts\Automation\*.ps1"
"C:\Scripts\Maintenance\*.ps1"
"C:\Scripts\Monitoring\*.ps1"
)

$updatedFiles = 0
$oldYear = $Year - 1
foreach ($path in $scriptPaths) {
if (Test-Path (Split-Path $path)) {
Get-ChildItem -Path $path -File | ForEach-Object {
$content = Get-Content $_.FullName -Raw -Encoding UTF8
if ($content -match "Copyright.*$oldYear") {
$content = $content -replace "(Copyright\s*(?:\(c\)\s*)?)$oldYear", "`${1}$Year"
Set-Content -Path $_.FullName -Value $content -Encoding UTF8 -NoNewline
$updatedFiles++
}
}
}
}
$results += "[版权] 已更新 $updatedFiles 个脚本的版权年份 ($oldYear -> $Year)"

# ---- 4. 输出初始化报告 ----
$summary = [PSCustomObject]@{
初始化年份 = $Year
执行时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
创建目录数 = $createdDirs.Count
更新文件数 = $updatedFiles
配置文件 = $ConfigPath
}

Write-Host "`n=== 新年环境初始化完成 ===" -ForegroundColor Green
$results | ForEach-Object { Write-Host " $_" -ForegroundColor Cyan }
Write-Host ""
return $summary
}

# 执行 2026 年环境初始化
$initResult = Initialize-NewYearEnvironment -Year 2026
$initResult | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
=== 新年环境初始化完成 ===
[目录] 已创建 60 个日志目录 (C:\Logs\2026\)
[配置] 已重置年度计数器: C:\Scripts\config\annual-config.json
[版权] 已更新 23 个脚本的版权年份 (2025 -> 2026)

初始化年份 : 2026
执行时间 : 2026-01-02 09:15:32
创建目录数 : 60
更新文件数 : 23
配置文件 : C:\Scripts\config\annual-config.json

脚本中的目录结构采用”年份/类别/月份”三层组织方式,方便后续按时间和类型快速定位日志。版权年份更新通过正则表达式精确匹配,避免误改文件内容中的其他数字。配置计数器重置时会保留去年的累计值到 PreviousYearTotal 字段,用于年度间的对比分析。

安全基线审计

新年伊始是进行全面安全基线审计的最佳时机。下面的脚本会检查密码策略合规性、扫描服务账户的密码过期状态、并排查所有证书的到期时间,为全年安全工作打好基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
function Invoke-NewYearSecurityAudit {
<#
.SYNOPSIS
执行新年安全基线审计:密码策略、服务账户、证书到期
.PARAMETER DomainName
Active Directory 域名,为空则检查本地安全策略
.PARAMETER CertWarningDays
证书到期提前告警天数,默认 90 天
#>
param(
[string]$DomainName,
[int]$CertWarningDays = 90
)

$auditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$findings = @()
$criticalCount = 0
$warningCount = 0

# ---- 1. 密码策略审计 ----
Write-Host "`n[1/3] 正在检查密码策略..." -ForegroundColor Yellow

try {
if ($DomainName) {
$secPolicy = Get-ADDefaultDomainPasswordPolicy -Identity $DomainName -ErrorAction Stop
$policy = [PSCustomObject]@{
最小密码长度 = $secPolicy.MinPasswordLength
密码最长期限天 = $secPolicy.MaxPasswordAge.Days
密码最短期限天 = $secPolicy.MinPasswordAge.Days
密码历史记录数 = $secPolicy.PasswordHistoryCount
账户锁定阈值 = $secPolicy.LockoutThreshold
锁定持续时间分 = $secPolicy.LockoutDuration.TotalMinutes
}
} else {
# 本地安全策略通过 secedit 导出分析
$tempDb = Join-Path $env:TEMP "secedit-$(Get-Random).sdb"
$tempCfg = Join-Path $env:TEMP "secedit-$(Get-Random).cfg"
$tempInf = Join-Path $env:TEMP "secedit-$(Get-Random).inf"

secedit /export /cfg $tempCfg /quiet 2>$null
$policyContent = Get-Content $tempCfg -Encoding Unicode -ErrorAction SilentlyContinue

$minLen = ($policyContent | Select-String "MinimumPasswordLength").ToString().Split("=")[-1].Trim()
$maxAge = ($policyContent | Select-String "MaximumPasswordAge").ToString().Split("=")[-1].Trim()

$policy = [PSCustomObject]@{
最小密码长度 = [int]$minLen
密码最长期限天 = [int]$maxAge
密码最短期限天 = "N/A (本地策略)"
密码历史记录数 = "N/A (本地策略)"
账户锁定阈值 = "N/A (本地策略)"
锁定持续时间分 = "N/A (本地策略)"
}

Remove-Item $tempCfg -Force -ErrorAction SilentlyContinue
}

# 检查合规性
if ($policy.最小密码长度 -lt 12) {
$findings += "[严重] 密码最小长度为 $($policy.最小密码Length) 位,建议至少 12 位"
$criticalCount++
}
if ($policy.密码最长期限天 -gt 90 -or $policy.密码最长期限天 -eq 0) {
$findings += "[警告] 密码最长期限为 $($policy.密码最长期限天) 天,建议不超过 90 天"
$warningCount++
}
} catch {
$findings += "[信息] 无法读取密码策略: $($_.Exception.Message)"
}

# ---- 2. 服务账户密码过期检查 ----
Write-Host "[2/3] 正在检查服务账户状态..." -ForegroundColor Yellow

try {
if ($DomainName) {
$serviceAccounts = Get-ADUser -Filter {
Enabled -eq $true -and PasswordNeverExpires -eq $true
} -Properties Name, SamAccountName, PasswordLastSet, LastLogonDate -ErrorAction Stop

foreach ($acct in $serviceAccounts) {
$daysSincePwdSet = if ($acct.PasswordLastSet) {
((Get-Date) - $acct.PasswordLastSet).Days
} else { -1 }

if ($daysSincePwdSet -gt 365 -or $daysSincePwdSet -eq -1) {
$status = if ($daysSincePwdSet -eq -1) { "从未设置" } else { "$daysSincePwdSet 天" }
$findings += "[警告] 服务账户 $($acct.SamAccountName) 密码已 $status 未更新 (密码永不过期)"
$warningCount++
}
}
$findings += "[信息] 发现 $($serviceAccounts.Count) 个设置了'密码永不过期'的账户"
} else {
# 本地检查:获取非内置的本地用户
$localUsers = Get-LocalUser | Where-Object {
$_.Enabled -and $_.PasswordNeverExpires -and $_.Name -notmatch "^(Administrator|Guest|DefaultAccount|WDAGUtilityAccount)$"
}

foreach ($user in $localUsers) {
$findings += "[警告] 本地用户 $($user.Name) 设置了密码永不过期"
$warningCount++
}
}
} catch {
$findings += "[信息] 服务账户检查受限: $($_.Exception.Message)"
}

# ---- 3. 证书到期扫描 ----
Write-Host "[3/3] 正在扫描证书到期时间..." -ForegroundColor Yellow

$certStores = @("Cert:\LocalMachine\My", "Cert:\LocalMachine\Root")
$expiringCerts = @()

foreach ($store in $certStores) {
if (Test-Path $store) {
Get-ChildItem $store | ForEach-Object {
$daysUntilExpiry = ($_.NotAfter - (Get-Date)).Days
if ($daysUntilExpiry -le $CertWarningDays) {
$severity = if ($daysUntilExpiry -le 0) { "已过期" } elseif ($daysUntilExpiry -le 30) { "即将过期" } else { "即将到期" }
$expiringCerts += [PSCustomObject]@{
严重程度 = $severity
剩余天数 = $daysUntilExpiry
主题 = $_.Subject
到期时间 = $_.NotAfter.ToString("yyyy-MM-dd")
指纹 = $_.Thumbprint.Substring(0, 8) + "..."
}

if ($daysUntilExpiry -le 0) {
$criticalCount++
} else {
$warningCount++
}
}
}
}
}

if ($expiringCerts.Count -gt 0) {
$findings += "[证书] 发现 $($expiringCerts.Count) 个需要关注的证书:"
$expiringCerts | Sort-Object 剩余天数 | ForEach-Object {
$findings += " - [$($_.严重程度)] $($_.主题) (剩余 $($_.剩余天数) 天, 到期 $($_.到期时间))"
}
} else {
$findings += "[证书] 未发现即将到期的证书 ($CertWarningDays 天阈值)"
}

# ---- 输出审计报告 ----
$report = [PSCustomObject]@{
审计时间 = $auditTime
审计范围 = if ($DomainName) { $DomainName } else { "本地计算机" }
严重问题数 = $criticalCount
警告问题数 = $warningCount
检查项目数 = 3
密码策略 = $policy
到期证书 = $expiringCerts
详细发现 = $findings
}

Write-Host "`n=== 安全基线审计结果 ===" -ForegroundColor Green
Write-Host " 严重问题: $criticalCount 个" -ForegroundColor Red
Write-Host " 警告问题: $warningCount 个" -ForegroundColor Yellow
Write-Host " 详细发现:" -ForegroundColor Cyan
$findings | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }

return $report
}

# 执行新年安全基线审计
$auditReport = Invoke-NewYearSecurityAudit -CertWarningDays 90

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[1/3] 正在检查密码策略...
[2/3] 正在检查服务账户状态...
[3/3] 正在扫描证书到期时间...

=== 安全基线审计结果 ===
严重问题: 1
警告问题: 4
详细发现:
[严重] 密码最小长度为 8 位,建议至少 12
[信息] 发现 6 个设置了'密码永不过期'的账户
[警告] 服务账户 svc-backup 密码已 412 天未更新 (密码永不过期)
[警告] 服务账户 svc-monitor 密码已 289 天未更新 (密码永不过期)
[证书] 发现 3 个需要关注的证书:
- [即将过期] CN=api.company.com (剩余 18 天, 到期 2026-01-20)
- [即将到期] CN=vpn.company.com (剩余 67 天, 到期 2026-03-10)
- [即将到期] CN=mail.company.com (剩余 82 天, 到期 2026-03-25)

审计脚本对三种安全维度进行了分级的合规性检查:密码策略关注长度和轮换周期,服务账户检查是否存在长期未更新密码的”密码永不过期”账户,证书扫描则按剩余天数标记严重程度。每项检查都有 try-catch 保护,即使某项检查因权限不足而失败,也不会影响其他检查继续执行。

全年计划任务部署

安全基线审计完成后,下一步是为全年部署按月编排的计划任务。下面的脚本会创建 12 个月的定时维护任务、部署监控脚本、并验证所有任务的健康状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
function Deploy-YearlyScheduledTasks {
<#
.SYNOPSIS
部署全年按月编排的计划任务,并验证任务健康状态
.PARAMETER Year
目标年份
.PARAMETER TaskPrefix
计划任务名称前缀
.PARAMETER ScriptBasePath
维护脚本所在目录
#>
param(
[int]$Year = (Get-Date).Year,
[string]$TaskPrefix = "IT-Maintenance",
[string]$ScriptBasePath = "C:\Scripts\Maintenance"
)

$deployedTasks = @()
$failedTasks = @()

# ---- 1. 定义月度维护任务清单 ----
$monthlyTasks = @(
@{
Name = "SecurityAudit"
Script = "Invoke-SecurityAudit.ps1"
Description = "月度安全基线审计"
DayOfMonth = 1
Hour = 2
}
@{
Name = "DiskCleanup"
Script = "Invoke-DiskCleanup.ps1"
Description = "磁盘空间清理与报告"
DayOfMonth = 5
Hour = 3
}
@{
Name = "BackupVerification"
Script = "Test-BackupIntegrity.ps1"
Description = "备份完整性验证"
DayOfMonth = 10
Hour = 1
}
@{
Name = "CertificateCheck"
Script = "Get-CertificateExpiry.ps1"
Description = "证书到期巡检"
DayOfMonth = 15
Hour = 2
}
@{
Name = "PatchCompliance"
Script = "Get-PatchStatus.ps1"
Description = "补丁合规性检查"
DayOfMonth = 20
Hour = 3
}
@{
Name = "PerformanceReport"
Script = "New-PerformanceReport.ps1"
Description = "月度性能报告生成"
DayOfMonth = 25
Hour = 4
}
)

# ---- 2. 为每月创建计划任务 ----
Write-Host "`n正在部署 $Year 年度计划任务..." -ForegroundColor Cyan

foreach ($month in 1..12) {
foreach ($taskDef in $monthlyTasks) {
$taskName = "$TaskPrefix-$Year-$($month.ToString('D2'))-$($taskDef.Name)"

# 计算触发日期(处理月末日期越界)
$daysInMonth = [DateTime]::DaysInMonth($Year, $month)
$triggerDay = [math]::Min($taskDef.DayOfMonth, $daysInMonth)
$triggerDate = Get-Date -Year $Year -Month $month -Day $triggerDay `
-Hour $taskDef.Hour -Minute 0 -Second 0

# 如果触发日期已过,跳过创建
if ($triggerDate -lt (Get-Date)) {
continue
}

$scriptPath = Join-Path $ScriptBasePath $taskDef.Script

try {
# 检查是否已存在同名任务
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existingTask) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}

# 创建计划任务
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Month $month -Year $Year"

$trigger = New-ScheduledTaskTrigger -Once -At $triggerDate

$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-DontStopIfGoingOnBatteries `
-AllowStartIfOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 2)

$principal = New-ScheduledTaskPrincipal `
-UserId "SYSTEM" `
-LogonType ServiceAccount `
-RunLevel Highest

$task = Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "$($taskDef.Description) - $Year$month 月" `
-Force

$deployedTasks += [PSCustomObject]@{
任务名称 = $taskName
触发时间 = $triggerDate.ToString("yyyy-MM-dd HH:mm")
脚本 = $taskDef.Script
状态 = "已注册"
}
} catch {
$failedTasks += [PSCustomObject]@{
任务名称 = $taskName
错误信息 = $_.Exception.Message
}
}
}
}

# ---- 3. 部署持续监控任务(每天检查任务健康状态) ----
$monitorScript = Join-Path $ScriptBasePath "Watch-TaskHealth.ps1"
$monitorTaskName = "$TaskPrefix-HealthMonitor"

try {
$existingMonitor = Get-ScheduledTask -TaskName $monitorTaskName -ErrorAction SilentlyContinue
if ($existingMonitor) {
Unregister-ScheduledTask -TaskName $monitorTaskName -Confirm:$false
}

$monitorAction = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$monitorScript`""

$monitorTrigger = New-ScheduledTaskTrigger -Daily -At "06:00"

$monitorSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30)

Register-ScheduledTask `
-TaskName $monitorTaskName `
-Action $monitorAction `
-Trigger $monitorTrigger `
-Settings $monitorSettings `
-Principal (New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest) `
-Description "每日 06:00 检查所有维护任务的健康状态" `
-Force | Out-Null

Write-Host " 健康监控任务已部署: $monitorTaskName (每日 06:00)" -ForegroundColor Green
} catch {
$failedTasks += [PSCustomObject]@{
任务名称 = $monitorTaskName
错误信息 = "健康监控部署失败: $($_.Exception.Message)"
}
}

# ---- 4. 输出部署统计 ----
Write-Host "`n=== 年度计划任务部署完成 ===" -ForegroundColor Green
Write-Host " 成功部署: $($deployedTasks.Count) 个" -ForegroundColor Green
Write-Host " 部署失败: $($failedTasks.Count) 个" -ForegroundColor $(if ($failedTasks.Count -gt 0) { "Red" } else { "Green" })
Write-Host "`n 前 5 个已部署任务:" -ForegroundColor Cyan
$deployedTasks | Select-Object -First 5 | Format-Table -AutoSize

if ($failedTasks.Count -gt 0) {
Write-Host " 失败任务:" -ForegroundColor Red
$failedTasks | Format-Table -AutoSize
}

return [PSCustomObject]@{
年份 = $Year
部署时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
成功数 = $deployedTasks.Count
失败数 = $failedTasks.Count
已部署任务 = $deployedTasks
失败任务 = $failedTasks
}
}

# 部署 2026 年全年计划任务
$deployResult = Deploy-YearlyScheduledTasks -Year 2026

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
正在部署 2026 年度计划任务...
健康监控任务已部署: IT-Maintenance-HealthMonitor (每日 06:00)

=== 年度计划任务部署完成 ===
成功部署: 66 个
部署失败: 0 个

前 5 个已部署任务:

任务名称 触发时间 脚本 状态
-------- -------- ---- ----
IT-Maintenance-2026-01-SecurityAudit 2026-01-01 02:00 Invoke-SecurityAudit.ps1 已注册
IT-Maintenance-2026-01-DiskCleanup 2026-01-05 03:00 Invoke-DiskCleanup.ps1 已注册
IT-Maintenance-2026-01-BackupVerification 2026-01-10 01:00 Test-BackupIntegrity.ps1 已注册
IT-Maintenance-2026-01-CertificateCheck 2026-01-15 02:00 Get-CertificateExpiry.ps1 已注册
IT-Maintenance-2026-01-PatchCompliance 2026-01-20 03:00 Get-PatchStatus.ps1 已注册

脚本为每个月创建 6 类维护任务,共计 72 个计划任务(实际部署时已过去的月份会被自动跳过)。每个任务使用 SYSTEM 账户以最高权限运行,避免了凭据过期的风险。额外的健康监控任务每天早上 6 点自动检查所有维护任务的状态,发现异常时及时告警。

注意事项

  1. 权限要求:年度初始化脚本涉及文件系统操作、计划任务注册和安全策略读取,需要以管理员权限运行。建议在提升权限的 PowerShell 控制台中执行,或者通过”以管理员身份运行”的计划任务自动调用。

  2. 备份优先:在执行目录创建、配置重置和版权更新之前,务必先备份原有的配置文件和脚本。特别是 JSON 配置文件的重置操作不可逆,重置前的累计数据一旦清零就无法恢复。可以在脚本开头加入自动备份逻辑,将原始文件复制到带时间戳的备份目录中。

  3. 计划任务的脚本路径:部署计划任务时,脚本路径必须使用绝对路径。如果维护脚本存放在网络共享上,需要使用 UNC 路径(如 \\server\share\scripts\),并确保 SYSTEM 账户对该共享有读取和执行权限。

  4. 证书告警阈值:证书到期扫描的默认告警阈值是 90 天,这个值应根据组织的证书更新流程周期来调整。如果证书更新需要走审批流程,建议将阈值设为 120 天甚至更长,留出足够的时间缓冲。

  5. 跨平台兼容性:安全基线审计中的 Active Directory 相关命令(Get-ADDefaultDomainPasswordPolicyGet-ADUser)需要安装 ActiveDirectory PowerShell 模块。如果在不带域控的环境中运行,脚本会回退到本地安全策略检查模式,通过 secedit 导出配置进行分析。

  6. 任务冲突检测:如果服务器上已有其他计划任务在同一时间段运行,新部署的维护任务可能会产生资源竞争。建议在部署前先用 Get-ScheduledTask 检查目标时段是否已有任务,必要时错开执行时间,避免 CPU、磁盘 I/O 或网络带宽争用。

PowerShell 技能连载 - 2025 年度回顾与 2026 展望

适用于 PowerShell 5.1 及以上版本

2025 年对 PowerShell 社区而言是里程碑式的一年。PowerShell 7.5 正式发布,全面拥抱 .NET 8 带来的性能提升与新 API,同时官方团队持续推动跨平台体验的统一。云原生、容器化和 GitOps 已经不再只是 buzzword,而是日常运维的基本范式,PowerShell 在其中扮演了越来越重要的胶水语言角色。

与此同时,AI 大模型的爆发深刻改变了自动化脚本的工作方式。从调用 OpenAI API 做日志分析,到用本地模型生成配置模板,再到 IDE 中的智能补全,AI 工具链已经深度融入 PowerShell 开发者的日常工作流。这一年我们见证了脚本编写方式从”查文档写代码”到”描述需求生成代码”的范式转变。

站在岁末年初的节点,本文将回顾 2025 年 PowerShell 生态的关键技术变化,梳理当前最实用的 AI 集成与云原生实战技巧,并展望 2026 年值得关注的趋势和学习方向。

2025 技术回顾

PowerShell 7.5 带来了一系列值得关注的新特性:改进了管道性能,增强了实验性特性中的 PSAdapter 模式,并且对 ConvertFrom-Json 等常用命令的输出做了优化。社区模块生态也蓬勃发展,Pester v6 正式发布,PSFramework 持续迭代。下面这段脚本盘点了一些关键模块的版本信息。

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
# 2025 年 PowerShell 生态关键信息盘点
$psInfo = @{
Version = $PSVersionTable.PSVersion.ToString()
Edition = $PSVersionTable.PSEdition
OS = $PSVersionTable.OS
Platform = $PSVersionTable.Platform
}

Write-Host "=== PowerShell 运行环境 ===" -ForegroundColor Cyan
$psInfo.GetEnumerator() | Sort-Object Name | ForEach-Object {
Write-Host (" {0,-12} : {1}" -f $_.Key, $_.Value)
}

# 盘点已安装的关键模块版本
$keyModules = @(
'Pester', 'PSFramework', 'PSScriptAnalyzer',
'platyPS', 'InvokeBuild', 'PSChecker',
'Microsoft.PowerShell.SecretManagement'
)

Write-Host "`n=== 关键模块版本 ===" -ForegroundColor Cyan
foreach ($mod in $keyModules) {
$installed = Get-Module -ListAvailable -Name $mod -ErrorAction SilentlyContinue |
Sort-Object Version -Descending | Select-Object -First 1
if ($installed) {
Write-Host (" {0,-50} v{1}" -f $mod, $installed.Version)
}
else {
Write-Host (" {0,-50} [未安装]" -f $mod) -ForegroundColor DarkGray
}
}

# 统计脚本文件数量(假设在项目目录中)
$projectDir = $PWD.Path
$scriptFiles = @(
Get-ChildItem -Path $projectDir -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue
Get-ChildItem -Path $projectDir -Filter '*.psm1' -Recurse -ErrorAction SilentlyContinue
)
Write-Host ("`n=== 项目统计 ===" -ForegroundColor Cyan)
Write-Host (" 脚本文件总数 : {0}" -f $scriptFiles.Count)
Write-Host (" 代码行数合计 : {0}" -f (
$scriptFiles | ForEach-Object { (Get-Content $_.FullName -ErrorAction SilentlyContinue).Count } |
Measure-Object -Sum | Select-Object -ExpandProperty Sum
))

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=== PowerShell 运行环境 ===
Edition : Core
OS : Unix 25.4.0
Platform : Unix
Version : 7.5.0

=== 关键模块版本 ===
Pester v6.0.4
PSFramework v1.12.0
PSScriptAnalyzer v1.23.0
platyPS v0.14.2
InvokeBuild v5.12.1
PSChecker [未安装]
Microsoft.PowerShell.SecretManagement v1.1.0

=== 项目统计 ===
脚本文件总数 : 47
代码行数合计 : 3216

AI 集成与云原生实战

2025 年最显著的变化之一,是 AI API 的调用门槛大幅降低。PowerShell 原生的 Invoke-RestMethod 已经能轻松完成与大模型的交互,社区也涌现了 PSOpenAIPode 等优秀的封装模块。在云原生领域,kubectldocker 与 PowerShell 的组合使用已经成为日常操作,跨平台执行也不再是障碍。

下面展示一段综合了 AI 调用和容器管理的实用脚本,帮助你快速理解当前主流的集成模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 示例 1:调用 OpenAI 兼容 API 进行日志异常分析
function Invoke-AILogAnalysis {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogContent,

[Parameter()]
[string]$Endpoint = 'http://localhost:11434/v1/chat/completions',

[Parameter()]
[string]$Model = 'qwen2.5:7b',

[Parameter()]
[int]$MaxTokens = 1024
)

$body = @{
model = $Model
messages = @(
@{
role = 'system'
content = '你是一名资深运维工程师,擅长分析系统日志中的异常模式。请用中文回复,列出关键异常和建议处理方案。'
}
@{
role = 'user'
content = "请分析以下日志内容中的异常:`n`n$LogContent"
}
)
max_tokens = $MaxTokens
temperature = 0.3
} | ConvertTo-Json -Depth 5

$result = Invoke-RestMethod -Uri $Endpoint -Method Post -Body $body `
-ContentType 'application/json; charset=utf-8' `
-ErrorAction Stop

return $result.choices[0].message.content
}

# 示例 2:跨平台容器状态巡检
function Get-ContainerHealthReport {
[CmdletBinding()]
param(
[Parameter()]
[string]$DockerHost = 'localhost'
)

$containers = docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>$null

if (-not $containers) {
Write-Warning "未检测到运行中的容器,请确认 Docker 服务是否已启动。"
return
}

$report = $containers | ForEach-Object {
$parts = $_ -split '\|'
[PSCustomObject]@{
Name = $parts[0]
Status = $parts[1]
Image = $parts[2]
Ports = $parts[3]
IsHealthy = $parts[1] -match '^Up'
}
}

$report | Format-Table -AutoSize
Write-Host ("健康容器: {0}/{1}" -f ($report | Where-Object IsHealthy).Count, $report.Count) `
-ForegroundColor Green
}

# 执行巡检
Get-ContainerHealthReport

执行结果示例:

1
2
3
4
5
6
7
8
9
Name            Status          Image              Ports       IsHealthy
---- ------ ----- ----- ---------
ollama Up 3 hours ollama/ollama 11434/tcp True
open-webui Up 3 hours ghcr.io/open-webui 8080/tcp True
postgres-db Up 5 days postgres:16 5432/tcp True
redis-cache Exited (0) 2h redis:7 6379/tcp False
nginx-proxy Up 3 hours nginx:alpine 80, 443 True

健康容器: 4/5

2026 技术展望与学习路线

展望 2026 年,几个清晰的趋势已经浮现:.NET 10 的发布将进一步提升 PowerShell 的运行时性能;AI Agent 模式(AI 自主调用工具、编排工作流)将深刻改变自动化脚本的编写方式;更多企业开始将 PowerShell DSC v3 与 Kubernetes Operator 结合,实现声明式基础设施管理。

以下脚本构建了一个个人技术雷达,帮助你梳理 2026 年的学习优先级。

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
# 2026 年 PowerShell 技术雷达
$techRadar = @(
[PSCustomObject]@{
Category = '核心语言'
Technology = 'PowerShell 7.6 / .NET 10'
Priority = '必须掌握'
Reason = '运行时性能提升、新 API 支持、源生成器优化'
}
[PSCustomObject]@{
Category = 'AI 集成'
Technology = 'AI Agent + Tool Calling'
Priority = '重点投入'
Reason = '大模型从辅助编码走向自主执行,需掌握 function calling 模式'
}
[PSCustomObject]@{
Category = '基础设施'
Technology = 'DSC v3 + K8s Operator'
Priority = '重点投入'
Reason = '声明式配置管理成为标准,DSC v3 与容器编排深度整合'
}
[PSCustomObject]@{
Category = '安全合规'
Technology = 'Crescendo + AppLocker'
Priority = '持续关注'
Reason = '零信任架构下脚本签名与执行策略管理更加重要'
}
[PSCustomObject]@{
Category = '测试工程'
Technology = 'Pester 6 + Test-Kitchen'
Priority = '必须掌握'
Reason = '基础设施即代码的测试覆盖度直接决定交付信心'
}
[PSCustomObject]@{
Category = '开发体验'
Technology = 'VS Code + Copilot + PSScriptAnalyzer'
Priority = '日常使用'
Reason = 'AI 辅助编码已成标配,结合静态分析确保质量'
}
)

Write-Host "=== 2026 PowerShell 技术雷达 ===" -ForegroundColor Cyan
$techRadar | Format-Table -AutoSize -Wrap

# 学习路线建议
$learningPath = @{
'Q1 (1-3月)' = '深入学习 PowerShell 7.6 新特性,掌握 .NET 10 互操作'
'Q2 (4-6月)' = '实践 AI Agent + Tool Calling,构建自动化运维助手'
'Q3 (7-9月)' = '学习 DSC v3,结合 Kubernetes 实现声明式配置管理'
'Q4 (10-12月)' = '完善 CI/CD 流水线,提升测试覆盖率和文档质量'
}

Write-Host "`n=== 推荐学习路线 ===" -ForegroundColor Cyan
$learningPath.GetEnumerator() | Sort-Object Name | ForEach-Object {
Write-Host (" {0} : {1}" -f $_.Key, $_.Value) -ForegroundColor Yellow
}

# 推荐学习资源
Write-Host "`n=== 推荐资源 ===" -ForegroundColor Cyan
$resources = @(
'官方文档: learn.microsoft.com/powershell'
'社区博客: devblogs.microsoft.com/powershell'
'开源项目: github.com/PowerShell'
'实战课程: PowerShell Conference EU 视频'
'AI 实践: Ollama + Open WebUI 本地大模型环境'
)
$resources | ForEach-Object { Write-Host " - $_" }

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
=== 2026 PowerShell 技术雷达 ===

Category Technology Priority Reason
-------- ----------- -------- ------
核心语言 PowerShell 7.6 / .NET 10 必须掌握 运行时性能提升、新 API 支持、源生成器优化
AI 集成 AI Agent + Tool Calling 重点投入 大模型从辅助编码走向自主执行...
基础设施 DSC v3 + K8s Operator 重点投入 声明式配置管理成为标准...
安全合规 Crescendo + AppLocker 持续关注 零信任架构下脚本签名与执行策略管理...
测试工程 Pester 6 + Test-Kitchen 必须掌握 基础设施即代码的测试覆盖度...
开发体验 VS Code + Copilot 日常使用 AI 辅助编码已成标配...

=== 推荐学习路线 ===
Q1 (1-3月) : 深入学习 PowerShell 7.6 新特性,掌握 .NET 10 互操作
Q2 (4-6月) : 实践 AI Agent + Tool Calling,构建自动化运维助手
Q3 (7-9月) : 学习 DSC v3,结合 Kubernetes 实现声明式配置管理
Q4 (10-12月): 完善 CI/CD 流水线,提升测试覆盖率和文档质量

=== 推荐资源 ===
- 官方文档: learn.microsoft.com/powershell
- 社区博客: devblogs.microsoft.com/powershell
- 开源项目: github.com/PowerShell
- 实战课程: PowerShell Conference EU 视频
- AI 实践: Ollama + Open WebUI 本地大模型环境

注意事项

  1. 保持版本同步:生产环境建议统一使用 PowerShell 7.x LTS 版本,避免 5.1 和 7.x 的行为差异导致脚本在不同环境执行结果不一致。重点关注 ConvertFrom-JsonInvoke-WebRequest 等命令在两个版本间的差异。

  2. AI 辅助需要验证:大模型生成的代码虽然越来越准确,但仍然会产生幻觉。关键逻辑必须人工审查,敏感操作(删除、修改配置)务必增加确认步骤。把 AI 当作高效的初稿生成器,而不是最终的代码审查者。

  3. 安全基线不可忽视:2025 年发生的多起供应链安全事件提醒我们,脚本的执行策略、模块来源验证和凭据管理必须作为常规检查项。定期使用 Get-AuthenticodeSignature 验证脚本签名,用 Get-InstalledModule 审计模块来源。

  4. 测试先行:无论是 AI 生成的代码还是手写脚本,上线前都应该通过 Pester 测试。2026 年建议将测试覆盖率目标设定为至少 80%,关键业务脚本达到 100%。Pester 6BeforeDiscovery 和参数化测试功能值得深入学习。

  5. 跨平台兼容意识:随着 Linux 和 macOS 上 PowerShell 使用率持续增长,编写脚本时应养成使用 Join-Path 替代硬编码路径分隔符、避免依赖 Windows 专属命令的习惯。$IsWindows$IsLinux$IsMacOS 变量可以帮助你编写跨平台兼容的代码。

  6. 持续学习与社区参与:PowerShell 生态演进速度在加快,2026 年建议每周至少花 2 小时阅读 release notes 和社区博客。参与 GitHub Discussions、提交 Issue 或 PR 是跟上技术节奏的最佳方式。关注 PowerShell Team 的官方博客和年度路线图更新,可以让你的学习方向更加明确。

PowerShell 技能连载 - 脚本最佳实践

适用于 PowerShell 5.1 及以上版本

回顾这一年的技能连载,我们从基础命令一路走到了高级自动化场景。在实际生产环境中,能跑通一段脚本只是起点,让脚本在面对异常输入、网络波动、权限变更时依然稳定运行,才是工程师的真正功力。今天我们就来系统梳理 PowerShell 脚本编写的最佳实践。

这些实践并非教条,而是从大量生产故障中提炼出来的经验总结。遵循它们可以让你的脚本更健壮、更易维护,也让接手代码的同事少踩几个坑。无论你是刚入门的新手还是资深运维工程师,这些原则都值得时刻对照。

代码结构与命名规范

良好的命名和结构是可维护脚本的基石。PowerShell 社区有一套广泛接受的动词-名词命名约定,遵循它能让你的函数与内置 cmdlet 保持一致的调用体验。同时,参数验证、类型约束和结构化注释也是专业脚本的标配。

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
<#
.SYNOPSIS
获取指定路径下的文件大小统计信息
.DESCRIPTION
递归扫描目标目录,返回按扩展名分类的文件大小汇总
.PARAMETER Path
要扫描的目录路径,必须存在且可访问
.PARAMETER TopN
返回最大的 N 种文件类型,默认为 10
.EXAMPLE
Get-FileStatistics -Path "C:\Logs" -TopN 5
#>
function Get-FileStatistics {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[ValidateScript({
if (-not (Test-Path -Path $_ -PathType Container)) {
throw "指定的路径不存在或不是目录: $_"
}
$true
})]
[string]$Path,

[Parameter()]
[ValidateRange(1, 100)]
[int]$TopN = 10
)

begin {
# 记录开始时间,用于性能统计
$startTime = Get-Date
Write-Verbose "开始扫描目录: $Path"
}

process {
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction SilentlyContinue

$stats = $files |
Group-Object Extension |
Sort-Object Count -Descending |
Select-Object -First $TopN |
ForEach-Object {
[PSCustomObject]@{
Extension = $_.Name
Count = $_.Count
TotalSizeMB = [math]::Round(
($_.Group | Measure-Object Length -Sum).Sum / 1MB,
2
)
}
}

$stats
}

end {
$elapsed = (Get-Date) - $startTime
Write-Verbose "扫描完成,耗时: $($elapsed.TotalSeconds.ToString('F2')) 秒"
}
}

# 调用示例
Get-FileStatistics -Path "/var/log" -TopN 5 -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
VERBOSE: 开始扫描目录: /var/log
VERBOSE: 扫描完成,耗时: 0.34 秒

Extension Count TotalSizeMB
--------- ----- -----------
.log 128 256.45
.gz 42 1024.80
.json 18 12.30
.txt 7 3.15
.err 3 0.85

上面的代码展示了几个关键实践:函数名使用-approved动词加名词的形式,参数加了 [CmdletBinding] 和完整的验证属性,并用基于注释的帮助文档让 Get-Help 能直接识别。begin/process/end 块的划分让管道处理逻辑更清晰。

错误处理与防御性编程

脚本在开发环境能跑通很容易,但生产环境充满了意外:磁盘满、网络断、权限不足、文件被占用。防御性编程的核心理念是”永远假设会出错”,然后用结构化的方式处理每一个可能的失败点。

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

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

[Parameter()]
[int]$RetryCount = 3,

[Parameter()]
[int]$RetryDelaySeconds = 5
)

# 前置检查:源路径是否存在
if (-not (Test-Path $SourcePath)) {
Write-Error "源路径不存在: $SourcePath"
return
}

# 确保目标目录存在
if (-not (Test-Path $DestinationPath)) {
if ($PSCmdlet.ShouldProcess($DestinationPath, "创建目标目录")) {
try {
New-Item -Path $DestinationPath -ItemType Directory -Force |
Out-Null
Write-Verbose "已创建目标目录: $DestinationPath"
}
catch {
Write-Error "无法创建目标目录: $_"
return
}
}
}

# 获取待复制的文件列表
$files = @(Get-ChildItem -Path $SourcePath -File -Filter "*.log")

if ($files.Count -eq 0) {
Write-Warning "源目录中没有 .log 文件,操作跳过"
return
}

$copiedCount = 0
$failedFiles = @()

foreach ($file in $files) {
$destFile = Join-Path $DestinationPath $file.Name
$attempt = 0
$success = $false

while ($attempt -lt $RetryCount -and -not $success) {
$attempt++
try {
if ($PSCmdlet.ShouldProcess($file.FullName, "复制到 $DestinationPath")) {
Copy-Item -Path $file.FullName -Destination $destFile -Force
$copiedCount++
$success = $true
Write-Verbose "[$attempt/$RetryCount] 复制成功: $($file.Name)"
}
}
catch {
Write-Warning "[$attempt/$RetryCount] 复制失败: $($file.Name) - $($_.Exception.Message)"
if ($attempt -lt $RetryCount) {
Write-Verbose "等待 $RetryDelaySeconds 秒后重试..."
Start-Sleep -Seconds $RetryDelaySeconds
}
else {
$failedFiles += $file.Name
}
}
}
}

# 汇总报告
$report = [PSCustomObject]@{
TotalFiles = $files.Count
CopiedFiles = $copiedCount
FailedFiles = $failedFiles.Count
FailedList = $failedFiles -join ", "
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}

if ($failedFiles.Count -gt 0) {
Write-Warning "部分文件复制失败: $($failedFiles -join ', ')"
}

$report
}

# 调用示例
Copy-LogArchive -SourcePath "C:\Logs\App" -DestinationPath "\\Server\Backup\Logs" -RetryCount 3 -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
VERBOSE: 已创建目标目录: \\Server\Backup\Logs
VERBOSE: [1/3] 复制成功: app-2025-12-28.log
VERBOSE: [1/3] 复制成功: app-2025-12-29.log
WARNING: [1/3] 复制失败: app-2025-12-30.log - 被其他进程占用。
VERBOSE: 等待 5 秒后重试...
VERBOSE: [2/3] 复制成功: app-2025-12-30.log

TotalFiles : 3
CopiedFiles : 3
FailedFiles : 0
FailedList :
Timestamp : 2025-12-30 10:15:30

这个函数体现了多层防御:前置检查确保输入合法,重试机制应对瞬态故障,SupportsShouldProcess 提供 -WhatIf-Confirm 支持,汇总报告让运维人员一眼看清执行结果。特别注意 @(...) 包裹管道赋值,这确保即使没有文件也返回空数组而非 $null,避免后续 Count 属性报错。

性能优化与安全编码

当脚本处理的文件从几十个变成几万个,或者从单机扩展到数百台远程服务器时,性能和安全就成了不可忽视的问题。掌握这些技巧可以避免脚本在生产中”慢到不可用”或”泄露敏感信息”。

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

[Parameter()]
[pscredential]$Credential,

[Parameter()]
[ValidateRange(1, 50)]
[int]$ThrottleLimit = 10
)

# 构建安全的 CimSession 参数
$cimParams = @{
ErrorAction = 'Stop'
}

if ($Credential) {
$cimParams.Credential = $Credential
}

# 并行处理远程主机,控制并发数
$results = $ComputerName | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
$computer = $_
$params = $using:cimParams

try {
# 使用 CIM 替代 WMI,更安全且支持 WinRM
$os = Get-CimInstance -ClassName Win32_OperatingSystem @params
$patches = Get-CimInstance -ClassName Win32_QuickFixEngineering @params |
Sort-Object InstalledOn -Descending -ErrorAction SilentlyContinue |
Select-Object -First 5

# 安全地提取凭据信息:只记录用户名,不记录密码
[PSCustomObject]@{
ComputerName = $computer
Status = "Online"
OSVersion = $os.Caption
LastBootTime = $os.LastBootUpTime
FreeSpaceGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
RecentPatches = $patches.HotFixID -join "; "
AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
catch {
[PSCustomObject]@{
ComputerName = $computer
Status = "Error: $($_.Exception.Message)"
OSVersion = "N/A"
LastBootTime = $null
FreeSpaceGB = "N/A"
RecentPatches = "N/A"
AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
}

# 安全地输出结果,过滤敏感字段
$results | Format-Table -AutoSize

# 审计日志脱敏处理
$logEntry = @{
Action = "SecurityAudit"
Targets = $ComputerName -join ","
ResultCount = ($results | Where-Object Status -eq "Online").Count
ExecutedBy = $env:USERNAME
Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
}

# 注意:不要将 $Credential 或密码写入日志
$logEntry | ConvertTo-Json -Compress
}

# 安全地传递凭据(交互式输入)
$cred = Get-Credential -Message "输入远程服务器管理凭据"
Get-SecureAuditReport -ComputerName "SRV01", "SRV02", "SRV03" -Credential $cred -ThrottleLimit 5

执行结果示例:

1
2
3
4
5
6
7
ComputerName Status  OSVersion                     LastBootTime         FreeSpaceGB RecentPatches         AuditTime
------------ ------ --------- ------------ ----------- ------------- ---------
SRV01 Online Microsoft Windows Server 2022 2025-12-28 03:00:00 12.45 KB5044280;KB503... 2025-12-30 14:20:10
SRV02 Online Microsoft Windows Server 2019 2025-12-29 06:30:00 8.72 KB5044277;KB503... 2025-12-30 14:20:12
SRV03 Error: 无法连接到远程服务器。 N/A N/A N/A 2025-12-30 14:20:15

{"Action":"SecurityAudit","Targets":"SRV01,SRV02,SRV03","ResultCount":2,"ExecutedBy":"admin","Timestamp":"2025-12-30T14:20:15Z"}

这个函数使用了 ForEach-Object -Parallel 实现受控并发,用 CIM 替代已弃用的 WMI 协议,通过 PSCredential 对象安全传递凭据而非明文密码,审计日志中严格排除敏感字段。这些都是生产环境中必须考虑的安全和性能要点。

注意事项

  1. 始终使用 approved verbs:用 Get-Verb 查看合法动词列表,避免自定义动词导致命名不一致。如果不确定用什么动词,参考类似功能的内置 cmdlet。

  2. 参数验证优于函数内 if 判断[ValidateNotNullOrEmpty()][ValidateRange()][ValidateScript()] 等属性在参数绑定时就生效,比函数体内的 if 检查更早报错,错误信息也更标准。

  3. 永远不要在脚本中硬编码密码:使用 Get-Credential 交互获取凭据,或从 Azure Key Vault、Windows Credential Manager 等安全存储读取。代码中的 $Credential.PasswordSecureString,不能直接转为明文。

  4. -ErrorAction Stop 配合 try/catch:全局设置 $ErrorActionPreference = 'Stop' 可能导致意想不到的中断,建议在特定命令上用 -ErrorAction 精确控制,然后在 try 块中捕获。

  5. 大集合用 @() 包裹管道赋值$items = @(Get-ChildItem ...) 确保 $items 始终是数组,即使结果为空或只有一个元素。这样后续的 .Countforeach 行为一致,不会踩类型陷阱。

  6. 为函数写基于注释的帮助.SYNOPSIS.DESCRIPTION.PARAMETER.EXAMPLE 四件套是最低要求。三个月后你自己也会感谢今天写了注释的那个函数,更不用说接手代码的同事了。

PowerShell 技能连载 - 年终报告自动生成

适用于 PowerShell 5.1 及以上版本

每到年底,IT 部门都需要向管理层提交各类运维报告:服务器可用性统计、安全事件汇总、资源使用趋势分析、成本核算等。这些数据往往分散在事件日志、CSV 文件、数据库、REST API 等多个来源中,手动汇总整理费时费力,而且容易出现遗漏和统计口径不一致的问题。

借助 PowerShell 强大的数据采集与处理能力,我们可以编写脚本自动从多个数据源采集原始数据,按照预定义的维度进行聚合统计,再结合 HTML 模板引擎生成包含表格、图表和趋势线的高质量报告。整个过程无需手动干预,一条命令即可产出一份数据完整、格式专业的年终总结。

本文将围绕多源数据采集与聚合、HTML 报告渲染(含内联图表)、以及报告分发与归档三个核心环节,展示如何用 PowerShell 构建一套可复用的年终报告自动生成流水线。

多源数据采集与聚合

年终报告的第一步是从多个数据源采集原始数据并进行统一聚合。下面的脚本演示如何从 Windows 事件日志、CSV 日志文件和自定义数据结构中提取关键指标,生成结构化的年度汇总数据集。

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
function Get-YearEndReportData {
<#
.SYNOPSIS
从多个数据源采集并聚合年终报告所需的数据
.PARAMETER Year
统计年份,默认为当前年份
#>
param(
[int]$Year = (Get-Date).Year
)

$startDate = Get-Date -Year $Year -Month 1 -Day 1
$endDate = Get-Date -Year $Year -Month 12 -Day 31

# ---- 1. 从 Windows 事件日志采集安全事件统计 ----
$securityEvents = @()
try {
$logonEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4624, 4625
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$logonSuccess = ($logonEvents | Where-Object Id -eq 4624 | Measure-Object).Count
$logonFailed = ($logonEvents | Where-Object Id -eq 4625 | Measure-Object).Count

$securityEvents = [PSCustomObject]@{
成功登录 = $logonSuccess
失败登录 = $logonFailed
登录失败率 = if ($logonSuccess + $logonFailed -gt 0) {
[math]::Round($logonFailed / ($logonSuccess + $logonFailed) * 100, 2)
} else { 0 }
}
} catch {
$securityEvents = [PSCustomObject]@{
成功登录 = 0; 失败登录 = 0; 登录失败率 = 0
}
}

# ---- 2. 从系统事件日志采集可用性数据 ----
$rebootEvents = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Id = 1074, 6008
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$plannedReboots = ($rebootEvents | Where-Object Id -eq 1074 | Measure-Object).Count
$unexpectedShutdowns = ($rebootEvents | Where-Object Id -eq 6008 | Measure-Object).Count

# ---- 3. 从 CSV 日志文件采集资源使用数据 ----
$csvPath = ".\server-metrics-$Year.csv"
$monthlyStats = @()

if (Test-Path $csvPath) {
$metrics = Import-Csv -Path $csvPath -Encoding UTF8
$monthlyStats = $metrics | Group-Object { $_.Date.Substring(0, 7) } | ForEach-Object {
$avgCpu = [math]::Round(($_.Group | Measure-Object -Property CPU -Average).Average, 1)
$avgMem = [math]::Round(($_.Group | Measure-Object -Property Memory -Average).Average, 1)
$maxDisk = [math]::Round(($_.Group | Measure-Object -Property DiskUsage -Maximum).Maximum, 1)

[PSCustomObject]@{
月份 = $_.Name
平均CPU使用率 = "$avgCpu%"
平均内存使用率 = "$avgMem%"
最大磁盘使用率 = "$maxDisk%"
}
}
} else {
# 模拟数据用于演示
1..12 | ForEach-Object {
$month = "{0:D2}" -f $_
$monthlyStats += [PSCustomObject]@{
月份 = "$Year-$month"
平均CPU使用率 = "$([math]::Round((Get-Random -Min 25 -Max 75) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
平均内存使用率 = "$([math]::Round((Get-Random -Min 40 -Max 85) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
最大磁盘使用率 = "$([math]::Round((Get-Random -Min 50 -Max 90) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
}
}
}

# ---- 4. 汇总输出 ----
[PSCustomObject]@{
报告年份 = $Year
数据周期 = "$($startDate.ToString('yyyy-MM-dd')) 至 $($endDate.ToString('yyyy-MM-dd'))"
安全事件统计 = $securityEvents
计划重启次数 = $plannedReboots
非计划关机次数 = $unexpectedShutdowns
月度资源统计 = $monthlyStats
生成时间 = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# 采集 2025 年度报告数据
$reportData = Get-YearEndReportData -Year 2025

Write-Host "报告年份: $($reportData.报告年份)" -ForegroundColor Cyan
Write-Host "数据周期: $($reportData.数据周期)" -ForegroundColor Cyan
Write-Host "安全事件: 成功登录 $($reportData.安全事件统计.成功登录) 次, 失败登录 $($reportData.安全事件统计.失败登录) 次" -ForegroundColor Yellow
Write-Host "月度数据条数: $($reportData.月度资源统计.Count)" -ForegroundColor Green

执行结果示例:

1
2
3
4
报告年份: 2025
数据周期: 2025-01-01 至 2025-12-31
安全事件: 成功登录 15238 次, 失败登录 347 次
月度数据条数: 12

生成 HTML 报告(含 CSS 样式与内联图表)

数据采集完成后,下一步是将结构化数据渲染为美观的 HTML 报告。下面的脚本使用 PowerShell 的 here-string 构建 HTML 模板,内嵌 CSS 样式,并通过纯 CSS 的柱状图直观展示月度趋势数据。

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 New-YearEndHtmlReport {
<#
.SYNOPSIS
将年度报告数据渲染为带样式和内联图表的 HTML 文件
.PARAMETER ReportData
由 Get-YearEndReportData 返回的数据对象
.PARAMETER OutputPath
HTML 报告输出路径
#>
param(
[Parameter(Mandatory)]
$ReportData,
[string]$OutputPath = ".\YearEnd-Report-$($ReportData.报告年份).html"
)

# 构建月度资源表格行
$tableRows = $ReportData.月度资源统计 | ForEach-Object {
" <tr><td>$($_.月份)</td><td>$($_.平均CPU使用率)</td><td>$($_.平均内存使用率)</td><td>$($_.最大磁盘使用率)</td></tr>"
}
$tableRowsHtml = $tableRows -join "`n"

# 构建简易柱状图(纯 CSS 实现,无需 JavaScript 库)
$chartBars = $ReportData.月度资源统计 | ForEach-Object {
$cpuVal = [int]($_.平均CPU使用率 -replace '%', '')
$barHeight = $cpuVal * 2
$label = $_.月份.Substring(5)
" <div class='bar-col'><div class='bar' style='height:${barHeight}px'></div><div class='bar-label'>$label</div></div>"
}
$chartHtml = $chartBars -join "`n"

$sec = $ReportData.安全事件统计

# HTML 报告内容
$htmlContent = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>IT 运维年度报告 - $($ReportData.报告年份)</title>
<style>
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; margin: 40px; background: #f5f7fa; color: #333; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2c3e50; margin-top: 30px; }
.summary-box { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 200px; }
.stat-card h3 { margin: 0 0 10px 0; color: #7f8c8d; font-size: 14px; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #2c3e50; }
table { border-collapse: collapse; width: 100%; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; }
th { background: #3498db; color: white; padding: 12px; text-align: left; }
td { padding: 10px 12px; border-bottom: 1px solid #ecf0f1; }
tr:hover { background: #ebf5fb; }
.chart-container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 20px 0; }
.chart-area { display: flex; align-items: flex-end; gap: 8px; height: 200px; padding: 10px 0; }
.bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; }
.bar { background: linear-gradient(to top, #3498db, #2ecc71); border-radius: 4px 4px 0 0; width: 100%; max-width: 40px; }
.bar-label { font-size: 11px; margin-top: 5px; color: #7f8c8d; }
.footer { margin-top: 30px; color: #95a5a6; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<h1>IT 运维年度报告 - $($ReportData.报告年份)</h1>
<p>报告周期:$($ReportData.数据周期) &nbsp;|&nbsp; 生成时间:$($ReportData.生成时间)</p>

<h2>核心指标概览</h2>
<div class="summary-box">
<div class="stat-card"><h3>成功登录</h3><div class="value">$($sec.成功登录)</div></div>
<div class="stat-card"><h3>失败登录</h3><div class="value" style="color:#e74c3c">$($sec.失败登录)</div></div>
<div class="stat-card"><h3>登录失败率</h3><div class="value">$($sec.登录失败率)%</div></div>
<div class="stat-card"><h3>计划重启</h3><div class="value">$($ReportData.计划重启次数)</div></div>
<div class="stat-card"><h3>非计划关机</h3><div class="value" style="color:#e67e22">$($ReportData.非计划关机次数)</div></div>
</div>

<h2>月度 CPU 使用趋势</h2>
<div class="chart-container">
<div class="chart-area">
$chartHtml
</div>
</div>

<h2>月度资源使用明细</h2>
<table>
<thead><tr><th>月份</th><th>平均 CPU 使用率</th><th>平均内存使用率</th><th>最大磁盘使用率</th></tr></thead>
<tbody>
$tableRowsHtml
</tbody>
</table>

<div class="footer">本报告由 PowerShell 自动生成 | $($ReportData.生成时间)</div>
</body>
</html>
"@

$htmlContent | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "HTML 报告已生成: $OutputPath" -ForegroundColor Green
return (Resolve-Path $OutputPath).Path
}

# 生成 HTML 报告
$reportPath = New-YearEndHtmlReport -ReportData $reportData

执行结果示例:

1
HTML 报告已生成: /Users/user/reports/YearEnd-Report-2025.html

生成的 HTML 文件包含卡片式核心指标概览、纯 CSS 柱状图展示月度 CPU 趋势、以及带悬停效果的资源使用明细表格。整个报告无需任何 JavaScript 依赖,打开即可查看完整内容,也可通过浏览器”另存为 PDF”直接导出。

报告分发与归档

报告生成后,还需要将其自动发送给相关干系人并归档保存。下面的脚本实现了邮件分发、PDF 转换和按月份归档的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
function Send-YearEndReport {
<#
.SYNOPSIS
通过邮件发送年终报告并归档到指定目录
.PARAMETER ReportPath
HTML 报告文件路径
.PARAMETER Recipients
收件人邮箱地址数组
.PARAMETER ArchiveBasePath
归档根目录路径
#>
param(
[Parameter(Mandatory)]
[string]$ReportPath,
[string[]]$Recipients = @("manager@company.com", "it-ops@company.com"),
[string]$ArchiveBasePath = ".\Reports\Archive"
)

# ---- 1. 创建归档目录结构 ----
$year = (Get-Date).Year
$archiveDir = Join-Path $ArchiveBasePath "$year"
if (-not (Test-Path $archiveDir)) {
New-Item -Path $archiveDir -ItemType Directory -Force | Out-Null
Write-Host "已创建归档目录: $archiveDir" -ForegroundColor Cyan
}

# 复制报告到归档目录(带时间戳)
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archiveName = "YearEnd-Report-$year-$timestamp.html"
$archivePath = Join-Path $archiveDir $archiveName
Copy-Item -Path $ReportPath -Destination $archivePath
Write-Host "报告已归档: $archivePath" -ForegroundColor Green

# ---- 2. 生成 PDF 副本(利用 Chrome 无头模式) ----
$pdfPath = $archivePath -replace '\.html$', '.pdf'
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe"
)
$chrome = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1

if ($chrome) {
$pdfArgs = @(
"--headless",
"--disable-gpu",
"--no-sandbox",
"--print-to-pdf=$pdfPath",
"--print-to-pdf-no-header",
$archivePath
)
Start-Process -FilePath $chrome -ArgumentList $pdfArgs -Wait -NoNewWindow
Write-Host "PDF 已生成: $pdfPath" -ForegroundColor Green
} else {
Write-Host "未找到 Chrome,跳过 PDF 生成。可手动在浏览器中另存为 PDF。" -ForegroundColor Yellow
$pdfPath = $null
}

# ---- 3. 发送邮件 ----
$mailParams = @{
From = "it-automation@company.com"
To = $Recipients
Subject = "[IT 运维] $year 年度报告"
Body = "各位好,`n`n附件为 $year 年度 IT 运维报告。`n`n报告由系统自动生成,如有疑问请联系 IT 运维团队。`n`n祝好"
SmtpServer = "smtp.company.com"
Port = 587
Encoding = [System.Text.Encoding]::UTF8
}

# 添加 HTML 报告作为附件
$mailParams["Attachments"] = @($archivePath)
if ($pdfPath -and (Test-Path $pdfPath)) {
$mailParams["Attachments"] += $pdfPath
}

try {
Send-MailMessage @mailParams -ErrorAction Stop
Write-Host "邮件已发送至: $($Recipients -join ', ')" -ForegroundColor Green
} catch {
Write-Host "邮件发送失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "请检查 SMTP 配置或网络连接" -ForegroundColor Yellow
}

# ---- 4. 生成归档索引 ----
$indexEntry = [PSCustomObject]@{
报告名称 = $archiveName
生成时间 = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
文件大小KB = [math]::Round((Get-Item $archivePath).Length / 1KB, 1)
归档路径 = $archivePath
PDF路径 = $pdfPath
}

$indexPath = Join-Path $archiveDir "index.csv"
if (Test-Path $indexPath) {
$indexEntry | Export-Csv -Path $indexPath -Append -NoTypeInformation -Encoding UTF8
} else {
$indexEntry | Export-Csv -Path $indexPath -NoTypeInformation -Encoding UTF8
}
Write-Host "归档索引已更新: $indexPath" -ForegroundColor Green

return $indexEntry
}

# 执行完整的分发与归档流程
$result = Send-YearEndReport -ReportPath $reportPath `
-Recipients @("cto@company.com", "it-manager@company.com") `
-ArchiveBasePath ".\Reports\Archive"

$result | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建归档目录: .\Reports\Archive\2025
报告已归档: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF 已生成: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf
邮件已发送至: cto@company.com, it-manager@company.com
归档索引已更新: .\Reports\Archive\2025\index.csv

报告名称 : YearEnd-Report-2025-20251229-143022.html
生成时间 : 2025-12-29 14:30:22
文件大小KB : 4.2
归档路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf

注意事项

  1. 事件日志权限:读取 Windows 安全日志(Security Log)需要管理员权限。在生产环境中运行采集脚本时,应以提升权限启动 PowerShell,或者通过计划任务配置为以 SYSTEM 账户运行,确保能正常读取所有事件源。

  2. 大数据量性能:如果事件日志条目超过数十万条,直接使用 Get-WinEvent 可能占用大量内存。建议按月份分批查询,或使用 -MaxEvents 参数配合 -FilterHashtable 的时间范围分段采集,避免一次性加载过多数据导致内存溢出。

  3. CSV 数据格式约定:资源指标 CSV 文件应包含 DateCPUMemoryDiskUsage 等标准列名。如果监控系统导出的字段名不同,需在采集函数中添加字段映射逻辑,或使用 Select-Object 的计算属性进行重命名。

  4. SMTP 认证配置:示例中的 Send-MailMessage 使用了简化的参数。在生产环境中,SMTP 服务器通常需要身份认证和 TLS 加密。建议将凭据存储在 Windows 凭据管理器中,通过 Get-StoredCredential 或环境变量获取,不要在脚本中硬编码密码。

  5. Chrome 无头模式依赖:PDF 转换功能依赖 Chrome 或 Chromium 浏览器。在无 GUI 的 Windows Server 上,需要安装 Chrome 并确保无头模式可以正常运行。如果无法安装 Chrome,也可以考虑使用 wkhtmltopdf 等轻量级替代方案,或者直接使用 ConvertTo-PDF 模块。

  6. 报告模板维护:HTML 报告模板中的样式和结构应与企业管理规范保持一致。建议将模板抽取为独立的 HTML 文件,通过 PowerShell 的 -replace 操作符替换占位符,而非在脚本中硬编码整个模板。这样设计人员可以独立调整样式,开发人员只需关注数据填充逻辑。

PowerShell 技能连载 - Azure Functions 无服务器自动化

适用于 PowerShell 7.0 及以上版本

在传统运维模式中,自动化脚本通常运行在虚拟机或容器上,无论脚本是否执行,底层计算资源都在持续消耗成本。对于定时清理、事件响应、API 集成等轻量级任务来说,这种模式既浪费资源又增加了运维负担——你不仅要维护脚本本身,还要操心服务器的补丁更新、高可用和扩缩容。

Azure Functions 是微软 Azure 提供的无服务器(Serverless)计算平台,支持使用 PowerShell 作为运行时语言。函数只在被事件触发时才执行,按实际运行时间和调用次数计费,空闲时不产生任何费用。PowerShell 运行时内置了 Az 模块和大量常用 cmdlet,让你可以直接在云端编写和运行自动化逻辑,无需自行管理基础设施。

本文将从三个实战场景出发:搭建 Azure Functions PowerShell 项目并编写 HTTP 触发器函数、使用定时触发器和队列触发器处理周期性与事件驱动任务,以及部署、监控与错误处理的最佳实践。

项目结构与 HTTP 触发器函数

Azure Functions PowerShell 项目有固定的目录结构。function.json 定义触发器绑定,run.ps1 是入口脚本。以下示例展示如何使用命令行工具初始化项目,并编写一个接收 HTTP 请求、查询 Azure 资源状态的 HTTP 触发器函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 安装 Azure Functions Core Tools(如尚未安装)
# macOS / Linux:
# curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
# sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
# npm install -g azure-functions-core-tools@4 --unsafe-perm true

# Windows:
# winget install Microsoft.AzureFunctionsCoreTools

# 创建函数项目
func init PsFuncDemo --worker-runtime powershell --managed-dependencies

# 进入项目目录,创建 HTTP 触发器函数
Set-Location PsFuncDemo
func new --name Get-ResourceStatus --template "HTTP trigger" --authlevel "function"

# 查看生成的项目结构
Get-ChildItem -Recurse |
Where-Object { -not $_.PSIsContainer } |
Select-Object FullName |
Format-Table -AutoSize

生成的项目中,Get-ResourceStatus/run.ps1 是函数入口,Get-ResourceStatus/function.json 定义绑定。下面我们来改造 run.ps1,实现一个查询 Azure 资源组状态的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Get-ResourceStatus/run.ps1 的内容
# 注意:以下内容应替换到 run.ps1 文件中

using namespace System.Net

param($Request, $TriggerMetadata)

# 从查询参数中获取资源组名称
$ResourceGroupName = $Request.Query.ResourceGroup
if (-not $ResourceGroupName) {
$ResourceGroupName = $Request.Body.ResourceGroup
}

# 如果未指定资源组,返回用法说明
if (-not $ResourceGroupName) {
Push-OutputBinding -Name Response -Value (
[HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = @{
error = "请提供 ResourceGroup 参数"
usage = "GET /api/Get-ResourceStatus?ResourceGroup=<名称>"
}
}
)
return
}

try {
# 使用托管标识连接 Azure(Azure Functions 默认支持)
Connect-AzAccount -Identity -ErrorAction Stop | Out-Null

# 查询资源组信息
$rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop

# 获取该资源组下的资源列表
$resources = Get-AzResource -ResourceGroupName $ResourceGroupName |
Select-Object Name, ResourceType, Location, Tags

# 构造响应
$result = @{
status = 'success'
resourceGroup = $rg.ResourceGroupName
location = $rg.Location
provisioning = $rg.ProvisioningState
resourceCount = $resources.Count
resources = $resources
queriedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssZ')
}

Push-OutputBinding -Name Response -Value (
[HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $result
}
)
}
catch {
Push-OutputBinding -Name Response -Value (
[HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = @{
status = 'error'
error = $_.Exception.Message
}
}
)
}

以下是 function.json 的绑定配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"bindings": [
{
"authLevel": "function",
"type": "httpTrigger",
"direction": "in",
"name": "Request",
"methods": ["get", "post"]
},
{
"type": "http",
"direction": "out",
"name": "Response"
}
]
}

执行结果示例:

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
# 项目目录结构
FullName
--------
/Users/demo/PsFuncDemo/.gitignore
/Users/demo/PsFuncDemo/host.json
/Users/demo/PsFuncDemo/local.settings.json
/Users/demo/PsFuncDemo/requirements.psd1
/Users/demo/PsFuncDemo/profile.ps1
/Users/demo/PsFuncDemo/extensions.csproj
/Users/demo/PsFuncDemo/Get-ResourceStatus/run.ps1
/Users/demo/PsFuncDemo/Get-ResourceStatus/function.json

# 本地测试调用 func start 后,curl 触发函数
# curl "http://localhost:7071/api/Get-ResourceStatus?ResourceGroup=rg-demo"

HTTP/1.1 200 OK
Content-Type: application/json

{
"status": "success",
"resourceGroup": "rg-demo",
"location": "eastasia",
"provisioning": "Succeeded",
"resourceCount": 5,
"resources": [
{
"Name": "vm-web-01",
"ResourceType": "Microsoft.Compute/virtualMachines",
"Location": "eastasia",
"Tags": { "env": "production" }
},
...
],
"queriedAt": "2025-12-26T08:30:15Z"
}

定时触发器与队列触发器

除了 HTTP 触发器,Azure Functions 还支持定时触发器(Timer Trigger)和队列触发器(Queue Trigger),分别用于周期性任务和消息驱动的异步处理。以下示例分别创建一个每天凌晨执行的资源清理函数和一个监听存储队列的自动处理函数。

1
2
3
4
5
# 创建定时触发器函数 —— 每天 UTC 2:00 执行资源清理
func new --name Daily-Cleanup --template "Timer trigger"

# Daily-Cleanup/function.json 的 cron 表达式
# "0 0 2 * * *" 表示每天 UTC 凌晨 2 点

Daily-Cleanup/function.json 配置:

1
2
3
4
5
6
7
8
9
10
{
"bindings": [
{
"name": "Timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 0 2 * * *"
}
]
}

Daily-Cleanup/run.ps1 内容:

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
# Daily-Cleanup/run.ps1
param($Timer, $TriggerMetadata)

# 记录任务开始
$startTime = Get-Date
Write-Host "[$startTime] 每日清理任务启动"

# 连接 Azure(使用托管标识)
Connect-AzAccount -Identity | Out-Null

# 1. 清理超过 30 天未使用的快照
$cutoffDate = (Get-Date).AddDays(-30)
$snapshots = Get-AzSnapshot |
Where-Object { $_.TimeCreated -lt $cutoffDate }

$cleanedCount = 0
foreach ($snapshot in $snapshots) {
Write-Host "删除过期快照: $($snapshot.Name) (创建于 $($snapshot.TimeCreated))"
Remove-AzSnapshot -ResourceGroupName $snapshot.ResourceGroupName `
-SnapshotName $snapshot.Name -Force
$cleanedCount++
}

# 2. 清理空的资源组
$emptyGroups = Get-AzResourceGroup | Where-Object {
$resources = Get-AzResource -ResourceGroupName $_.ResourceGroupName
$resources.Count -eq 0 -and
$_.ResourceGroupName -notmatch '^rg-shared-|^rg-network-'
}

$removedGroups = 0
foreach ($group in $emptyGroups) {
Write-Host "删除空资源组: $($group.ResourceGroupName)"
Remove-AzResourceGroup -Name $group.ResourceGroupName -Force
$removedGroups++
}

# 3. 汇总输出
$duration = ((Get-Date) - $startTime).ToString('hh\:mm\:ss')
Write-Host "清理完成。删除快照: $cleanedCount 个,删除空资源组: $removedGroups 个,耗时: $duration"

接下来创建队列触发器函数:

1
2
# 创建队列触发器函数 —— 监听存储队列处理消息
func new --name Process-QueueMessage --template "Azure Queue Storage trigger"

Process-QueueMessage/function.json 配置:

1
2
3
4
5
6
7
8
9
10
11
{
"bindings": [
{
"name": "QueueItem",
"type": "queueTrigger",
"direction": "in",
"queueName": "automation-tasks",
"connection": "AzureWebJobsStorage"
}
]
}

Process-QueueMessage/run.ps1 内容:

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
# Process-QueueMessage/run.ps1
param($QueueItem, $TriggerMetadata)

# 队列消息为 JSON 格式,解析操作指令
$message = $QueueItem | ConvertFrom-Json
Write-Host "收到队列消息,操作类型: $($message.action)"

switch ($message.action) {
'restart-vm' {
Write-Host "重启虚拟机: $($message.vmName) in $($message.resourceGroup)"
Connect-AzAccount -Identity | Out-Null
Restart-AzVM -ResourceGroupName $message.resourceGroup `
-Name $message.vmName -NoWait
Write-Host "已发送重启指令"
}

'scale-webapp' {
Write-Host "调整 Web 应用规模: $($message.appName) -> $($message.tier)"
Connect-AzAccount -Identity | Out-Null
$app = Get-AzWebApp -ResourceGroupName $message.resourceGroup `
-Name $message.appName
$app.ServerFarmId = $message.newPlanId
Set-AzWebApp -WebApp $app | Out-Null
Write-Host "Web 应用规模已调整"
}

'send-notification' {
Write-Host "发送通知: $($message.subject)"
$body = @{
title = $message.subject
message = $message.body
channel = $message.channel
} | ConvertTo-Json -Compress

Invoke-RestMethod -Uri $message.webhookUrl `
-Method Post `
-Body $body `
-ContentType 'application/json'
Write-Host "通知已发送"
}

default {
Write-Warning "未知的操作类型: $($message.action)"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 定时清理任务日志
[12/26/2025 02:00:00] 每日清理任务启动
[12/26/2025 02:00:01] 删除过期快照: snap-backup-20251120 (创建于 11/20/2025)
[12/26/2025 02:00:03] 删除过期快照: snap-backup-20251115 (创建于 11/15/2025)
[12/26/2025 02:00:04] 删除过期快照: snap-backup-20251110 (创建于 11/10/2025)
[12/26/2025 02:00:05] 删除空资源组: rg-test-deploy-20251201
[12/26/2025 02:00:07] 清理完成。删除快照: 3 个,删除空资源组: 1 个,耗时: 00:00:07

# 队列触发器处理日志
收到队列消息,操作类型: restart-vm
重启虚拟机: vm-api-02 in rg-production
已发送重启指令

收到队列消息,操作类型: send-notification
发送通知: 扩容完成通知
通知已发送

收到队列消息,操作类型: unknown-action
警告: 未知的操作类型: unknown-action

部署、监控与错误处理最佳实践

函数开发完成后,需要部署到 Azure 并建立完善的监控和错误处理机制。以下脚本展示了使用 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
# --- 部署 Azure Functions ---

$ResourceGroup = 'rg-func-demo'
$Location = 'eastasia'
$FuncAppName = 'func-ps-automation-2025'
$StorageAccount = "stpsfunc$(Get-Random -Maximum 9999)"

# 1. 创建基础设施
New-AzResourceGroup -Name $ResourceGroup -Location $Location -Force

# 创建存储账户(Functions 运行必需)
New-AzStorageAccount `
-ResourceGroupName $ResourceGroup `
-Name $StorageAccount `
-Location $Location `
-SkuName Standard_LRS `
-Kind StorageV2

# 创建函数应用(PowerShell 运行时)
$plan = New-AzAppServicePlan `
-ResourceGroupName $ResourceGroup `
-Name "$FuncAppName-plan" `
-Location $Location `
-Tier Consumption `
-NumberofWorkers 1

New-AzFunctionApp `
-ResourceGroupName $ResourceGroup `
-Name $FuncAppName `
-StorageAccountName $StorageAccount `
-Runtime PowerShell `
-RuntimeVersion '7.4' `
-PlanName $plan.ServerFarmName `
-FunctionsVersion 4 `
-ManagedIdentity SystemAssigned

# 为函数应用授予所需 RBAC 权限(如 Reader 角色)
$identity = Get-AzWebApp -ResourceGroupName $ResourceGroup -Name $FuncAppName
$principalId = $identity.Identity.PrincipalId
New-AzRoleAssignment `
-ObjectId $principalId `
-RoleDefinitionName 'Reader' `
-Scope "/subscriptions/$((Get-AzContext).Subscription.Id)"

# 2. 部署函数代码
Publish-AzWebApp `
-ResourceGroupName $ResourceGroup `
-Name $FuncAppName `
-ArchivePath (Resolve-Path './PsFuncDemo.zip')

Write-Host "函数应用已部署: $FuncAppName"

# --- 监控与日志查询 ---

# 查询函数应用最近 1 小时的执行日志
$query = @"
AppTraces
| where AppRoleName == '$FuncAppName'
| where TimeGenerated > ago(1h)
| project TimeGenerated, MessageSeverity, Message
| order by TimeGenerated desc
"@

$results = Invoke-AzOperationalInsightsQuery `
-WorkspaceId $env:LOG_ANALYTICS_WORKSPACE_ID `
-Query $query

$results.Results | Format-Table -AutoSize

# 获取函数应用的执行统计
$stats = Get-AzFunctionApp `
-ResourceGroupName $ResourceGroup `
-Name $FuncAppName

Write-Host "函数应用状态: $($stats.State)"
Write-Host "运行时版本: $($stats.SiteConfig.PowerShellVersion)"

接下来在函数代码中加入结构化错误处理和重试逻辑。以下是 profile.ps1 中的全局错误处理配置,以及一个带重试机制的辅助函数:

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
# profile.ps1 —— 函数应用启动时自动加载
# 全局错误处理:将错误写入 Application Insights
$ErrorActionPreference = 'Stop'

# 注册全局异常处理
$ExecutionContext.SessionState.Module.OnRemove = {
Write-Host "函数执行结束,清理资源"
}

# 带重试机制的辅助函数
function Invoke-WithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,

[int]$MaxRetries = 3,
[int]$DelaySeconds = 5,
[string]$OperationName = '操作'
)

$attempt = 0
while ($attempt -lt $MaxRetries) {
$attempt++
try {
$result = & $ScriptBlock
Write-Host "[$OperationName] 第 $attempt 次尝试成功"
return $result
}
catch {
$errorMessage = $_.Exception.Message
Write-Warning "[$OperationName] 第 $attempt 次尝试失败: $errorMessage"

if ($attempt -ge $MaxRetries) {
Write-Error "[$OperationName] 已达最大重试次数 ($MaxRetries),操作失败"
throw
}

# 指数退避
$waitTime = $DelaySeconds * [Math]::Pow(2, $attempt - 1)
Write-Host "等待 $waitTime 秒后重试..."
Start-Sleep -Seconds $waitTime
}
}
}

# 使用示例:在 run.ps1 中调用
# Invoke-WithRetry -OperationName '查询资源组' -ScriptBlock {
# Get-AzResourceGroup -Name 'rg-production'
# }

执行结果示例:

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
# 部署输出
ResourceGroupName : rg-func-demo
Location : eastasia
ProvisioningState : Succeeded

StorageAccountName : stpsfunc4821
Kind : StorageV2
Sku : Standard_LRS

Name : func-ps-automation-2025
State : Running
Runtime : PowerShell
RuntimeVersion : 7.4
FunctionsVersion : 4
ManagedIdentity : SystemAssigned

函数应用已部署: func-ps-automation-2025

# 日志查询结果
TimeGenerated MessageSeverity Message
-------------- --------------- -------
2025-12-26T02:00:07Z Information 清理完成。删除快照: 3 个,删除空资源组: 1 个,耗时: 00:00:07
2025-12-26T02:00:05Z Information 删除空资源组: rg-test-deploy-20251201
2025-12-26T02:00:03Z Information 删除过期快照: snap-backup-20251120 (创建于 11/20/2025)
2025-12-26T01:30:12Z Information 收到队列消息,操作类型: restart-vm

# 函数应用状态
函数应用状态: Running
运行时版本: 7.4

# 重试辅助函数执行日志
[查询资源组] 第 1 次尝试失败: Resource group 'rg-production' not found.
等待 5 秒后重试...
[查询资源组] 第 2 次尝试失败: Resource group 'rg-production' not found.
等待 10 秒后重试...
[查询资源组] 第 3 次尝试成功

注意事项

  1. 使用 Consumption 计划控制成本:开发和测试阶段使用 Consumption(消耗量)计划,按实际执行时间和调用次数计费,空闲时不收费。只有当函数需要长时间运行(超过 10 分钟)或需要稳定的低延迟响应时,才考虑升级到 Premium 计划。

  2. 启用托管标识替代密钥认证:为函数应用开启系统分配的托管标识,并通过 RBAC 授予最小权限。这比在应用设置中存储服务主体密钥更安全,也避免了密钥轮换的运维负担。

  3. 注意冷启动影响:Consumption 计划的函数在长时间空闲后会经历冷启动(Cold Start),首次调用响应时间可能较长。对于对延迟敏感的场景,可以在 host.json 中配置 minimumConcurrency 或使用 Premium 计划保持常驻实例。

  4. 利用 requirements.psd1 管理模块依赖:PowerShell Functions 的模块依赖在 requirements.psd1 中声明,部署时会自动安装。不要在 run.ps1 中使用 Install-Module,这不仅会增加冷启动时间,还可能因网络问题导致函数启动失败。

  5. 为队列触发器实现幂等处理:队列消息可能被重复投递(至少一次语义),因此队列触发器函数必须具备幂等性。处理逻辑应先检查目标状态是否已经符合预期,避免重复执行产生副作用(如重复发送通知、重复创建资源)。

  6. 配置 Application Insights 收集运行日志:创建函数应用时务必关联 Application Insights 资源,它自动采集函数执行的请求追踪、异常信息和依赖项调用。配合 Log Analytics 查询,可以快速定位线上问题并设置告警规则,实现从开发到运维的全链路可观测性。

PowerShell 技能连载 - 包管理与依赖控制

适用于 PowerShell 5.1 及以上版本

PowerShell 模块生态在过去几年里蓬勃发展,PowerShell Gallery 上已经托管了数以万计的模块。从日常运维的 Active Directory 管理,到云端自动化的 Az 模块,再到新兴的 AI 交互工具,几乎每种场景都有对应的模块可用。然而,模块数量的增长也带来了管理上的挑战:不同项目依赖同一个模块的不同版本、私有环境的离线分发需求、以及供应链安全对模块来源的审计要求,都是实际工作中必须面对的问题。

PowerShell 7 引入了 PSResourceGet(Microsoft.PowerShell.PSResourceGet)作为新一代包管理引擎,替代了沿用多年的 PowerShellGet v2。PSResourceGet 基于 NuGet 协议重新实现了仓库交互,在性能、安全性和功能覆盖面上都有显著提升。同时,它保留了与 PowerShellGet 类似的命令风格,降低了迁移成本。对于仍然运行在 Windows PowerShell 5.1 环境中的系统,PowerShellGet 依然可用,但强烈建议尽早迁移到 PSResourceGet。

本文将从基础操作入手,逐步介绍模块搜索与安装、版本锁定与依赖分析,以及私有仓库的搭建方法,帮助你在不同规模的自动化环境中实现可靠的包管理与依赖控制。

PSResourceGet 基础操作

PSResourceGet 的核心操作围绕”仓库(Repository)”展开。仓库是模块的存储和分发端点,默认连接到 PowerShell Gallery。下面的代码展示了从注册仓库到搜索、安装、更新模块的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 安装 PSResourceGet 模块(如果尚未安装)
Install-Module Microsoft.PowerShell.PSResourceGet -Force -Scope CurrentUser

# 查看已注册的仓库
Get-PSResourceRepository

# 注册额外的仓库,例如自定义的 NuGet 源
Register-PSResourceRepository -Name 'MyCompanyFeed' -Uri 'https://nuget.mycompany.com/v3/index.json'

# 搜索模块:在所有仓库中查找包含 "Az" 关键字的模块
Find-PSResource -Name '*Az*' -Type Module -Repository 'PSGallery' | Select-Object Name, Version, Description | Format-Table -AutoSize

# 安装指定模块的最新稳定版本
Install-PSResource -Name 'Pester' -Scope CurrentUser -TrustRepository

# 安装指定版本的模块
Install-PSResource -Name 'Pester' -Version '5.5.0' -Scope CurrentUser

# 更新已安装的模块到最新版本
Update-PSResource -Name 'Pester' -Scope CurrentUser

# 查看本地已安装的模块信息
Get-InstalledPSResource -Name 'Pester'

上述代码中,Register-PSResourceRepository 用于注册自定义仓库,Find-PSResource 支持通配符搜索并可以限定仓库范围,Install-PSResourceUpdate-PSResource 分别完成安装与升级操作。-TrustRepository 参数表示信任该仓库,避免每次安装时都弹出确认提示。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name            Uri                                     Trusted
---- --- -------
PSGallery https://www.powershellgallery.com/api/… False
MyCompanyFeed https://nuget.mycompany.com/v3/indexTrue

Name Version Description
---- ------- -----------
Az 12.0.0 Microsoft Azure PowerShell
Az.Accounts 3.0.0 Microsoft Azure PowerShell - Accounts
Az.Compute 7.0.0 Microsoft Azure PowerShell - Compute

Name Version Prerelease Repository Description
---- ------- ---------- ---------- -----------
Pester 5.7.1 PSGallery Pester is the ubiquitous test…

版本锁定与依赖管理

在自动化管道和多人协作的项目中,模块版本的一致性至关重要。不同开发者或不同环境如果安装了不兼容的模块版本,可能导致脚本行为不一致甚至运行失败。PSResourceGet 提供了多种版本控制机制来应对这一问题。

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
# 安装精确版本(版本锁定)
Install-PSResource -Name 'Az.Accounts' -Version '3.0.0' -Scope CurrentUser

# 安装版本范围内的最新版本(支持 NuGet 版本范围语法)
# [3.0.0, 4.0.0) 表示大于等于 3.0.0 且小于 4.0.0
Install-PSResource -Name 'Az.Accounts' -Version '[3.0.0, 4.0.0)' -Scope CurrentUser

# 安装预发布版本
Install-PSResource -Name 'Pester' -Version '6.0.0' -Prerelease -Scope CurrentUser

# 查看模块的依赖关系
$resource = Find-PSResource -Name 'Az.Compute' -Repository 'PSGallery'
$resource.Dependencies | Format-Table Name, VersionRange -AutoSize

# 导出当前环境的模块清单(用于版本锁定文件)
$installed = Get-InstalledPSResource
$lockData = $installed | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
Version = $_.Version
}
}
$lockData | ConvertTo-Json -Depth 3 | Set-Content -Path './modules.lock.json' -Encoding UTF8

# 根据锁定文件批量恢复模块
$lock = Get-Content -Path './modules.lock.json' -Raw | ConvertFrom-Json
foreach ($entry in $lock) {
Install-PSResource -Name $entry.Name -Version $entry.Version -Scope CurrentUser -ErrorAction SilentlyContinue
}

这段代码展示了几个关键的版本管理技巧:使用 RequiredVersion 精确锁定版本,使用 NuGet 版本范围语法控制兼容范围,通过 Dependencies 属性分析模块的依赖树,以及将环境状态导出为 JSON 锁定文件以便在另一台机器上复现。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Name       VersionRange
---- ------------
Az.Accounts [3.0.0, 4.0.0)

modules.lock.json 内容示例:
[
{
"Name": "Pester",
"Version": "5.7.1"
},
{
"Name": "Az.Accounts",
"Version": "3.0.0"
},
{
"Name": "Az.Compute",
"Version": "7.0.0"
}
]

私有仓库搭建与内网分发

在企业环境中,出于安全审计或网络隔离的需要,通常不希望服务器直接访问公网的 PowerShell Gallery。搭建私有仓库可以解决这个问题,同时也能用于分发团队内部开发的模块。NuGet.Server 是一种轻量级的私有仓库方案,配合 PSResourceGet 可以实现完整的内网分发流程。

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
# ========== 服务端:搭建 NuGet.Server ==========

# 安装 NuGet.Server 包(在一台有 IIS 的 Windows 服务器上执行)
# 首先确保 NuGet 包源已注册
Register-PackageSource -Name 'NuGetGallery' -Location 'https://www.nuget.org/api/v2' -ProviderName NuGet -Trusted

# 创建 NuGet.Server 站点的目录
$sitePath = 'C:\PSGallery\Private'
New-Item -ItemType Directory -Path $sitePath -Force

# 使用 dotnet 方式创建 NuGet.Server(需要 .NET SDK)
# 或者直接下载 NuGet.Server 包并解压
Save-Package -Name NuGet.Server -Source 'NuGetGallery' -Path $sitePath

# 将自定义模块打包为 nupkg 并发布到私有仓库
$modulePath = 'C:\Modules\MyUtils'
$nupkgOutput = 'C:\PSGallery\Packages'

# 使用 PSResourceGet 发布模块
Publish-PSResource -Path $modulePath -Repository 'MyCompanyFeed' -ApiKey 'your-api-key-here'

# ========== 客户端:从私有仓库安装 ==========

# 在客户端机器上注册私有仓库
Register-PSResourceRepository -Name 'CompanyGallery' -Uri 'https://psgallery.mycompany.com/v3/index.json' -Trusted

# 设置默认仓库优先级(私有仓库优先于公网)
Set-PSResourceRepository -Name 'CompanyGallery' -Priority 1

# 从私有仓库搜索和安装模块
Find-PSResource -Name 'MyUtils' -Repository 'CompanyGallery'
Install-PSResource -Name 'MyUtils' -Repository 'CompanyGallery' -Scope CurrentUser

# 验证模块来源
Get-InstalledPSResource -Name 'MyUtils' | Select-Object Name, Version, Repository

上述代码分为服务端和客户端两部分。服务端负责搭建 NuGet.Server 并发布内部模块,客户端则注册私有仓库、设置优先级,并从中安装模块。通过设置 Priority 参数,可以确保私有仓库中的模块优先于公网同名模块被使用,这在覆盖公网模块的场景中非常有用。

执行结果示例:

1
2
3
4
5
6
7
Name    Version Repository       Description
---- ------- ---------- -----------
MyUtils 1.2.0 CompanyGallery Company internal utility module…

Name Version Repository
---- ------- ----------
MyUtils 1.2.0 CompanyGallery

注意事项

  1. PSResourceGet 与 PowerShellGet 的兼容性:PSResourceGet 可以管理由 PowerShellGet v2/v3 安装的模块,但反向操作可能存在兼容性问题。建议在团队内统一使用 PSResourceGet,避免混用导致的版本记录混乱。

  2. 版本范围语法:PSResourceGet 使用 NuGet 版本范围语法,方括号表示包含边界、圆括号表示排除边界。例如 [1.0.0, 2.0.0) 表示大于等于 1.0.0 且小于 2.0.0。务必仔细检查范围表达式,避免因语法错误导致意外安装了不兼容的版本。

  3. 模块锁定文件应纳入版本控制modules.lock.json 类似于 npm 的 package-lock.json,应与项目代码一起提交到 Git 仓库。这样可以确保 CI/CD 管道中的模块版本与开发环境完全一致。

  4. 私有仓库的安全加固:生产环境的私有仓库应启用 HTTPS、配置 API Key 认证,并定期审计已发布的模块内容。对于高安全要求的环境,可以考虑使用 Azure Artifacts 或 JFrog Artifactory 等企业级制品仓库。

  5. 离线环境的模块分发:对于完全隔离的网络环境,可以使用 Save-PSResource 将模块及其依赖下载到本地目录,然后通过 U 盘或内部文件共享拷贝到目标机器,再用 Install-PSResource 从本地路径安装。这种方式不需要搭建完整的 NuGet 服务。

  6. 模块依赖冲突排查:当遇到模块加载冲突时,可以使用 Get-Module -ListAvailable 查看所有可用版本,结合 $env:PSModulePath 分析模块搜索路径的优先级。对于冲突严重的环境,考虑使用 PowerShell 的 Assembly Load Context(ALC)隔离机制,或在不同项目中使用独立的模块安装路径。

PowerShell 技能连载 - 节日自动化与年度总结

适用于 PowerShell 5.1 及以上版本

每到年底,系统管理员面临着大量收尾工作:汇总全年的运维数据、归档陈旧日志、清理过期文件、检查系统健康状态,还要为来年的自动化计划做准备。这些任务如果逐一手动完成,往往要耗费数天时间。而通过 PowerShell 脚本,可以将这些重复性工作编排成可一键执行的自动化流程,大幅缩短收尾周期。

更重要的是,年度总结不仅是对过去一年工作的回顾,更是为来年制定计划的数据基础。通过脚本化汇总,可以确保数据的准确性和一致性——每年生成相同格式的报告,方便横向对比,发现趋势。将枯燥的数据整理交给脚本,管理员才能把精力集中在分析和决策上。

本文将从年度运维数据汇总、数据归档与清理、来年自动化准备三个维度,展示如何用 PowerShell 高效完成年末收尾工作。

年度运维数据汇总

年终总结的第一步是收集全年的关键运维指标。下面的脚本从 Windows 事件日志、系统运行时间等数据源中提取统计信息,生成一份结构化的年度运维报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 年度运维数据汇总脚本
function Get-YearEndOpsSummary {
param(
[int]$Year = (Get-Date).Year
)

$startDate = Get-Date -Year $Year -Month 1 -Day 1
$endDate = Get-Date -Year $Year -Month 12 -Day 31 -Hour 23 -Minute 59 -Second 59

Write-Host "正在汇总 $Year 年度运维数据..." -ForegroundColor Cyan
Write-Host ("=" * 50)

# 1. 事件日志统计
Write-Host "`n[事件日志统计]" -ForegroundColor Yellow

$logStats = @{}
$logNames = @('System', 'Application', 'Security')

foreach ($logName in $logNames) {
try {
$events = Get-WinEvent -LogName $logName |
Where-Object { $_.TimeCreated -ge $startDate -and $_.TimeCreated -le $endDate }

$logStats[$logName] = [PSCustomObject]@{
Total = $events.Count
Critical = ($events | Where-Object Level -eq 1).Count
Error = ($events | Where-Object Level -eq 2).Count
Warning = ($events | Where-Object Level -eq 3).Count
Information = ($events | Where-Object Level -eq 4).Count
}
Write-Host " $logName : $($logStats[$logName].Total) 条"
} catch {
Write-Host " $logName : 无法读取或无记录" -ForegroundColor DarkGray
}
}

# 2. 服务器可用性统计
Write-Host "`n[服务器可用性统计]" -ForegroundColor Yellow

$reboots = Get-WinEvent -LogName System -ErrorAction SilentlyContinue |
Where-Object {
$_.TimeCreated -ge $startDate -and $_.TimeCreated -le $endDate -and
$_.Id -in 1074, 6006, 6008, 41
}

$uptimeSpan = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$totalDaysInYear = ($endDate - $startDate).TotalDays

# 3. 磁盘使用趋势
Write-Host "`n[磁盘使用情况]" -ForegroundColor Yellow

$diskInfo = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' |
ForEach-Object {
$usedGB = [math]::Round(($_.Size - $_.FreeSpace) / 1GB, 2)
$freeGB = [math]::Round($_.FreeSpace / 1GB, 2)
$totalGB = [math]::Round($_.Size / 1GB, 2)
$usedPct = [math]::Round(($usedGB / $totalGB) * 100, 1)

[PSCustomObject]@{
Drive = $_.DeviceID
UsedGB = $usedGB
FreeGB = $freeGB
TotalGB = $totalGB
UsedPct = "$usedPct%"
}
}

$diskInfo | Format-Table -AutoSize

# 4. 汇总报告对象
$report = [PSCustomObject]@{
Year = $Year
GeneratedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
EventLogs = $logStats
RebootCount = $reboots.Count
CurrentUptime = "$([math]::Floor($uptimeSpan.TotalDays)) 天 $($uptimeSpan.Hours) 小时"
DiskStatus = $diskInfo
}

# 导出报告
$reportPath = Join-Path $env:USERPROFILE "Desktop\OpsSummary-$Year.json"
$report | ConvertTo-Json -Depth 5 | Out-File $reportPath -Encoding UTF8
Write-Host "`n报告已保存至:$reportPath" -ForegroundColor Green

return $report
}

# 执行年度汇总
Get-YearEndOpsSummary -Year 2025

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
正在汇总 2025 年度运维数据...
==================================================

[事件日志统计]
System : 48256 条
Application : 31420 条
Security : 128750 条

[服务器可用性统计]

[磁盘使用情况]
Drive UsedGB FreeGB TotalGB UsedPct
----- ------ ------ ------- -------
C: 186.42 63.58 250.00 74.6%
D: 412.30 587.70 1000.00 41.2%

报告已保存至:C:\Users\admin\Desktop\OpsSummary-2025.json

数据归档与清理

年度数据归档是运维管理的重要环节。下面的脚本实现了日志压缩打包、过期文件自动清理以及备份完整性验证,帮助管理员在假期前完成数据整理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# 数据归档与清理脚本
function Invoke-YearEndArchive {
param(
[string]$ArchiveRoot = "D:\Archives\$(Get-Date -Format 'yyyy')",
[int]$RetentionDays = 90,
[string[]]$LogPaths = @(
"C:\Logs",
"D:\ApplicationLogs",
"$env:TEMP"
),
[switch]$WhatIf
)

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$archiveDir = Join-Path $ArchiveRoot "Archive-$timestamp"

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

Write-Host "=== 年度数据归档与清理 ===" -ForegroundColor Cyan
Write-Host "归档目录:$archiveDir"
Write-Host "保留天数:$RetentionDays 天"
Write-Host ""

# 1. 日志压缩归档
Write-Host "[1/3] 压缩归档日志文件..." -ForegroundColor Yellow

$archiveResults = @()
foreach ($logPath in $LogPaths) {
if (-not (Test-Path $logPath)) {
Write-Host " 跳过(路径不存在):$logPath" -ForegroundColor DarkGray
continue
}

$logFiles = Get-ChildItem $logPath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object {
$_.Extension -in '.log', '.txt', '.csv', '.evtx' -and
$_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays)
}

if ($logFiles) {
$totalSize = [math]::Round(($logFiles | Measure-Object Length -Sum).Sum / 1MB, 2)
$zipName = "$($logPath -replace '[\\:]', '_')_$timestamp.zip"
$zipPath = Join-Path $archiveDir $zipName

if (-not $WhatIf) {
$logFiles | Compress-Archive -DestinationPath $zipPath -CompressionLevel Optimal
$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
} else {
$zipSize = "N/A (WhatIf)"
}

$archiveResults += [PSCustomObject]@{
Source = $logPath
FileCount = $logFiles.Count
OrigMB = $totalSize
ArchiveMB = $zipSize
}

Write-Host " $logPath : $($logFiles.Count) 个文件,${totalSize} MB -> ${zipSize} MB"
} else {
Write-Host " $logPath : 无过期日志" -ForegroundColor DarkGray
}
}

# 2. 过期文件清理
Write-Host "`n[2/3] 清理过期临时文件..." -ForegroundColor Yellow

$cleanPatterns = @('*.tmp', '*.temp', '*.bak', '~$*')
$cleanPaths = @($env:TEMP, "C:\Windows\Temp")
$cleanedCount = 0
$cleanedSize = 0

foreach ($path in $cleanPaths) {
foreach ($pattern in $cleanPatterns) {
$files = Get-ChildItem $path -Filter $pattern -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }

foreach ($file in $files) {
$cleanedSize += $file.Length
if (-not $WhatIf) {
Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue
}
$cleanedCount++
}
}
}

$cleanedSizeMB = [math]::Round($cleanedSize / 1MB, 2)
Write-Host " 已清理 $cleanedCount 个临时文件,释放 ${cleanedSizeMB} MB"

# 3. 备份完整性验证
Write-Host "`n[3/3] 验证归档完整性..." -ForegroundColor Yellow

if (-not $WhatIf -and (Test-Path $archiveDir)) {
$zips = Get-ChildItem $archiveDir -Filter '*.zip'
foreach ($zip in $zips) {
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName)
$entryCount = $archive.Entries.Count
$archive.Dispose()
Write-Host " $($zip.Name) : $entryCount 个文件 - 完整" -ForegroundColor Green
} catch {
Write-Host " $($zip.Name) : 验证失败 - $($_.Exception.Message)" -ForegroundColor Red
}
}
}

Write-Host "`n归档完成!" -ForegroundColor Green
}

# 预览模式(不实际执行)
Invoke-YearEndArchive -WhatIf

# 确认无误后正式执行
# Invoke-YearEndArchive

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=== 年度数据归档与清理 ===
归档目录:D:\Archives\2025\Archive-20251224_080000
保留天数:90 天

[1/3] 压缩归档日志文件...
C:\Logs : 156 个文件,2340.50 MB -> 412.30 MB
D:\ApplicationLogs : 89 个文件,1580.75 MB -> 298.60 MB
C:\Users\admin\AppData\Local\Temp : 42 个文件,86.20 MB -> 18.40 MB

[2/3] 清理过期临时文件...
已清理 327 个临时文件,释放 456.80 MB

[3/3] 验证归档完整性...
_C_Logs_20251224_080000.zip : 156 个文件 - 完整
_D_ApplicationLogs_20251224_080000.zip : 89 个文件 - 完整
_C_Users_admin_AppData_Local_Temp_20251224_080000.zip : 42 个文件 - 完整

归档完成!

来年自动化准备

假期是审视和优化自动化体系的最佳时机。下面的脚本检查当前计划任务的健康状态、扫描即将到期的证书,并生成一份系统健康检查报告,为来年的运维规划提供数据支撑。

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
# 来年自动化准备脚本
function Invoke-NewYearPreparation {
param(
[int]$CertificateWarningDays = 60,
[string]$ReportPath = "$env:USERPROFILE\Desktop\NewYear-HealthCheck-$(Get-Date -Format 'yyyyMMdd').html"
)

Write-Host "=== 来年自动化准备检查 ===" -ForegroundColor Cyan
Write-Host ""

# 1. 计划任务健康检查
Write-Host "[1/3] 检查计划任务..." -ForegroundColor Yellow

$scheduledTasks = Get-ScheduledTask |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
ForEach-Object {
$info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
[PSCustomObject]@{
Name = $_.TaskName
Path = $_.TaskPath
State = $_.State
LastRunTime = if ($info.LastRunTime -gt '1899-12-30') { $info.LastRunTime } else { '从未运行' }
LastResult = $info.LastTaskResult
NextRunTime = if ($info.NextRunTime -gt '1899-12-30') { $info.NextRunTime } else { '未计划' }
}
}

$taskStats = @{
Total = $scheduledTasks.Count
Running = ($scheduledTasks | Where-Object State -eq 'Running').Count
Ready = ($scheduledTasks | Where-Object State -eq 'Ready').Count
Disabled = ($scheduledTasks | Where-Object State -eq 'Disabled').Count
Failed = ($scheduledTasks | Where-Object LastResult -ne '0' -and $_.State -eq 'Ready').Count
}

Write-Host " 总计:$($taskStats.Total) 个自定义任务"
Write-Host " 就绪:$($taskStats.Ready) | 运行中:$($taskStats.Running) | 已禁用:$($taskStats.Disabled)"

# 找出上次执行失败的任务
$failedTasks = $scheduledTasks |
Where-Object { $_.LastResult -notin '0', '' -and $_.State -eq 'Ready' }

if ($failedTasks) {
Write-Host "`n 上次执行失败的任务:" -ForegroundColor Red
foreach ($task in $failedTasks) {
Write-Host " - $($task.Name) (错误码: $($task.LastResult))" -ForegroundColor Red
}
}

# 2. 证书到期检查
Write-Host "`n[2/3] 检查证书到期情况..." -ForegroundColor Yellow

$expiryThreshold = (Get-Date).AddDays($CertificateWarningDays)

$certExpirations = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -le $expiryThreshold } |
Sort-Object NotAfter |
ForEach-Object {
$daysLeft = ($_.NotAfter - (Get-Date)).Days
$status = if ($daysLeft -le 0) { '已过期' }
elseif ($daysLeft -le 30) { '紧急' }
elseif ($daysLeft -le 60) { '警告' }
else { '注意' }

[PSCustomObject]@{
Subject = $_.Subject
Thumbprint = $_.Thumbprint.Substring(0, 16) + '...'
Expires = $_.NotAfter.ToString('yyyy-MM-dd')
DaysLeft = $daysLeft
Status = $status
}
}

if ($certExpirations) {
$certExpirations | Format-Table -AutoSize
} else {
Write-Host " 未发现即将到期的证书" -ForegroundColor Green
}

# 3. 系统健康检查
Write-Host "`n[3/3] 系统健康检查..." -ForegroundColor Yellow

$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor
$memTotalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$memFreeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$memUsedPct = [math]::Round(($memTotalGB - $memFreeGB) / $memTotalGB * 100, 1)
$cpuLoad = $cpu.LoadPercentage

$healthReport = [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
OSVersion = $os.Caption
LastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss')
CPULoad = "$cpuLoad%"
MemoryTotalGB = $memTotalGB
MemoryFreeGB = $memFreeGB
MemoryUsedPct = "$memUsedPct%"
ServicesFailed = (Get-Service | Where-Object { $_.Status -eq 'Stopped' -and $_.StartType -eq 'Automatic' }).Count
PendingReboot = (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending')
}

Write-Host " 主机名:$($healthReport.ComputerName)"
Write-Host " 操作系统:$($healthReport.OSVersion)"
Write-Host " CPU 负载:$($healthReport.CPULoad)"
Write-Host " 内存使用:$($healthReport.MemoryUsedPct) ($($healthReport.MemoryFreeGB) GB 可用)"
Write-Host " 已停止的自动启动服务:$($healthReport.ServicesFailed)"

if ($healthReport.PendingReboot) {
Write-Host " 待重启:是" -ForegroundColor Red
} else {
Write-Host " 待重启:否" -ForegroundColor Green
}

# 生成 HTML 报告
$htmlBody = @"
<h1>新年系统健康检查报告</h1>
<p>生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>
<h2>系统概况</h2>
<table border="1" cellpadding="5" style="border-collapse:collapse">
<tr><td>主机名</td><td>$($healthReport.ComputerName)</td></tr>
<tr><td>操作系统</td><td>$($healthReport.OSVersion)</td></tr>
<tr><td>CPU 负载</td><td>$($healthReport.CPULoad)</td></tr>
<tr><td>内存使用率</td><td>$($healthReport.MemoryUsedPct)</td></tr>
<tr><td>待重启</td><td>$($healthReport.PendingReboot)</td></tr>
</table>
"@

$htmlBody | Out-File $ReportPath -Encoding UTF8
Write-Host "`nHTML 报告已保存至:$ReportPath" -ForegroundColor Green
Write-Host "`n来年自动化准备检查完毕!" -ForegroundColor Green
}

# 执行来年准备检查
Invoke-NewYearPreparation -CertificateWarningDays 60

执行结果示例:

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
=== 来年自动化准备检查 ===

[1/3] 检查计划任务...
总计:24 个自定义任务
就绪:18 | 运行中:2 | 已禁用:4

上次执行失败的任务:
- DailyBackup (错误码: 0x1)
- LogRotation (错误码: 0x2)

[2/3] 检查证书到期情况...
Subject Thumbprint Expires DaysLeft Status
------- ---------- ------- -------- ------
CN=web.vichamp.com 3A2F8B1C9D0E4... 2026-01-15 22 紧急
CN=api.internal.local 7E6D5C4B3A2F1... 2026-02-20 58 警告

[3/3] 系统健康检查...
主机名:SRV-PROD-01
操作系统:Microsoft Windows Server 2022 Standard
CPU 负载:23%
内存使用:67.5% (7.26 GB 可用)
已停止的自动启动服务:2
待重启:是

HTML 报告已保存至:C:\Users\admin\Desktop\NewYear-HealthCheck-20251224.html

来年自动化准备检查完毕!

注意事项

  1. 权限要求:事件日志查询和计划任务管理需要管理员权限,建议以提升模式运行 PowerShell,或在脚本开头添加 #Requires -RunAsAdministrator 确保权限充足。
  2. 大日志处理Get-WinEvent 在处理整年事件日志时可能消耗大量内存。对于事件量超过十万条的日志源,建议按月份分批查询后合并统计,避免内存溢出。
  3. 归档前验证:执行日志清理前务必先用 -WhatIf 参数预览操作范围,确认无误后再正式执行。已压缩的归档文件应存放到独立存储或异地备份,避免与原始数据在同一磁盘上。
  4. 证书到期监控:建议将证书到期检查集成到日常监控流程中(如每周执行一次),而非仅在年底检查。可以在脚本中加入邮件通知逻辑,在证书到期前 30 天和 7 天分别发送提醒。
  5. 跨平台兼容:本文部分示例使用了 Windows 特有的模块(如 ScheduledTaskCert: 驱动器)。如果在 Linux 或 macOS 上运行 PowerShell 7,需要使用对应的平台命令替代,如 crontab -l 替代计划任务检查。
  6. 报告持续化:年度报告建议统一存放并纳入版本管理。可以配合 Git 仓库或 SharePoint 文档库,每年追加新报告,形成连续的运维历史档案,便于长期趋势分析和审计回溯。