PowerShell 技能连载 - GitOps 工作流自动化

适用于 PowerShell 7.0 及以上版本

GitOps 是一种以 Git 仓库作为”唯一事实来源”的运维方法论,所有基础设施和应用配置都以代码形式存放在 Git 中,通过声明式描述来管理和交付系统。虽然 Kubernetes 生态中的 Argo CD 和 Flux 已经成为 GitOps 的代名词,但在传统的 Windows 环境和混合云场景中,PowerShell 同样可以成为实现 GitOps 工作流的利器。

本文将演示如何用 PowerShell 构建一套轻量级 GitOps 自动化流水线。从 Git 仓库状态监控、配置漂移检测,到多环境发布管道,这些脚本可以直接集成到 Windows 计划任务或 Azure DevOps Pipeline 中,让团队在不引入复杂工具链的前提下享受 GitOps 的好处。

核心思路非常简单:将所有配置文件纳入 Git 管理,用 PowerShell 定期检查仓库状态,对比期望状态与实际状态,发现漂移时自动修复或发出告警,最终实现基础设施即代码(IaC)的闭环管理。

Git 仓库状态检测与自动化提交

第一个场景是最基础也是最关键的环节——监控 Git 仓库的状态变化并自动提交推送。在 GitOps 模式下,任何配置文件的修改都应该尽快同步到远程仓库,确保 Git 始终反映最新的期望状态。

下面的脚本封装了 Git 操作的核心逻辑,支持自定义文件模式匹配、自动暂存、提交(附带变更摘要)和推送:

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

[string[]]$FilePatterns = @('*.json', '*.yaml', '*.yml', '*.ps1'),

[string]$RemoteName = 'origin',
[string]$BranchName = 'main',
[switch]$DryRun
)

Push-Location $RepoPath
try {
# 确认是有效的 Git 仓库
$null = git rev-parse --git-dir 2>$null
if ($LASTEXITCODE -ne 0) {
throw "路径 '$RepoPath' 不是有效的 Git 仓库"
}

# 拉取远程最新变更,避免冲突
git fetch $RemoteName 2>&1 | ForEach-Object { Write-Verbose $_ }
$localHash = (git rev-parse HEAD).Trim()
$remoteHash = (git rev-parse "$RemoteName/$BranchName").Trim()

if ($localHash -ne $remoteHash) {
Write-Host "[拉取] 检测到远程有新提交,正在合并..." -ForegroundColor Cyan
git pull $RemoteName $BranchName 2>&1 | ForEach-Object { Write-Host $_ }
}

# 检查工作区中匹配模式的文件变更
$changedFiles = @()
foreach ($pattern in $FilePatterns) {
$changedFiles += git diff --name-only --relative -- $pattern 2>$null
$changedFiles += git ls-files --others --exclude-standard -- $pattern 2>$null
}
$changedFiles = $changedFiles | Select-Object -Unique | Where-Object { $_ }

if ($changedFiles.Count -eq 0) {
Write-Host "[状态] 工作区无变更,与远程仓库同步。" -ForegroundColor Green
return
}

Write-Host "[变更] 检测到 $($changedFiles.Count) 个文件变更:" -ForegroundColor Yellow
$changedFiles | ForEach-Object { Write-Host " - $_" }

if ($DryRun) {
Write-Host "[试运行] 未执行实际提交。使用 -DryRun:`$false 提交变更。" -ForegroundColor Magenta
return
}

# 暂存、提交、推送
foreach ($pattern in $FilePatterns) {
git add $pattern 2>&1 | ForEach-Object { Write-Verbose $_ }
}

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$summary = "auto-sync: $($changedFiles.Count) file(s) updated at $timestamp"
git commit -m $summary 2>&1 | ForEach-Object { Write-Host $_ }
git push $RemoteName $BranchName 2>&1 | ForEach-Object { Write-Host $_ }

Write-Host "[完成] 变更已提交并推送到 $RemoteName/$BranchName" -ForegroundColor Green
}
finally {
Pop-Location
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
PS> Invoke-GitOpsSync -RepoPath D:\gitops-config -Verbose

[拉取] 检测到远程有新提交,正在合并...
Already up to date.
[变更] 检测到 3 个文件变更:
- config/appsettings.json
- deploy/infra.yaml
- scripts/deploy.ps1
[main c7a2f1d] auto-sync: 3 file(s) updated at 2026-04-02 09:15:33
3 files changed, 27 insertions(+), 5 deletions(-)
[完成] 变更已提交并推送到 origin/main

配置漂移检测与自动修复

GitOps 的核心理念是”期望状态”与”实际状态”的一致性。在生产环境中,人为修改、脚本错误或系统更新都可能导致配置漂移。下面的脚本会对比 Git 仓库中的声明式配置与实际运行环境的差异,发现漂移后自动拉取最新配置并同步。

这个方案特别适合管理 IIS 站点配置、Windows 服务参数、环境变量等场景,通过将期望配置存储在 JSON 文件中,PowerShell 负责定期对比并纠正偏差:

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

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

[switch]$AutoRemediate,
[switch]$ReportOnly
)

# 加载期望状态配置
$desiredConfig = Get-Content -Path $DesiredStatePath -Raw | ConvertFrom-Json
$driftReport = [System.Collections.Generic.List[PSObject]]::new()
$remediationLog = [System.Collections.Generic.List[string]]::new()

foreach ($service in $desiredConfig.services) {
$actual = Get-Service -Name $service.name -ErrorAction SilentlyContinue

if (-not $actual) {
$drift = [PSCustomObject]@{
Resource = $service.name
Type = 'Service'
Expected = $service.status
Actual = 'NotFound'
Drift = $true
Severity = 'Critical'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}
$driftReport.Add($drift)
$remediationLog.Add("[严重] 服务 '$($service.name)' 不存在于 $EnvironmentName 环境")
continue
}

$expectedStatus = $service.status
$actualStatus = $actual.Status.ToString()

if ($expectedStatus -ne $actualStatus) {
$drift = [PSCustomObject]@{
Resource = $service.name
Type = 'Service'
Expected = $expectedStatus
Actual = $actualStatus
Drift = $true
Severity = 'Warning'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}
$driftReport.Add($drift)
$remediationLog.Add("[漂移] 服务 '$($service.name)' 期望 $expectedStatus,实际 $actualStatus")

if ($AutoRemediate -and -not $ReportOnly) {
try {
Set-Service -Name $service.name -Status $expectedStatus -ErrorAction Stop
$remediationLog.Add("[修复] 已将服务 '$($service.name)' 状态恢复为 $expectedStatus")
}
catch {
$remediationLog.Add("[失败] 修复服务 '$($service.name)' 失败: $($_.Exception.Message)")
}
}
}
}

# 检查文件路径的漂移
foreach ($file in $desiredConfig.files) {
$fileExists = Test-Path -Path $file.path
if ($file.shouldExist -and -not $fileExists) {
$driftReport.Add([PSCustomObject]@{
Resource = $file.path
Type = 'File'
Expected = 'Exists'
Actual = 'Missing'
Drift = $true
Severity = 'Warning'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
})
$remediationLog.Add("[漂移] 文件 '$($file.path)' 期望存在但未找到")

if ($AutoRemediate -and $file.sourceUrl -and -not $ReportOnly) {
try {
Invoke-WebRequest -Uri $file.sourceUrl -OutFile $file.path -ErrorAction Stop
$remediationLog.Add("[修复] 已从 $($file.sourceUrl) 下载到 $($file.path)")
}
catch {
$remediationLog.Add("[失败] 下载文件失败: $($_.Exception.Message)")
}
}
}
}

# 输出报告
if ($driftReport.Count -eq 0) {
Write-Host "[通过] $EnvironmentName 环境配置与期望状态一致,未检测到漂移。" -ForegroundColor Green
}
else {
Write-Host "[漂移] $EnvironmentName 环境检测到 $($driftReport.Count) 项配置漂移:" -ForegroundColor Yellow
$driftReport | Format-Table -Property Resource, Type, Expected, Actual, Severity -AutoSize
}

if ($remediationLog.Count -gt 0) {
Write-Host "`n--- 修复日志 ---" -ForegroundColor Cyan
$remediationLog | ForEach-Object { Write-Host " $_" }
}

return $driftReport
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PS> Test-ConfigDrift -DesiredStatePath D:\gitops-config\desired-state.json -EnvironmentName staging -AutoRemediate

[漂移] staging 环境检测到 2 项配置漂移:

Resource Type Expected Actual Severity
-------- ---- -------- ------ --------
W3SVC Service Running Stopped Warning
D:\app\connection.json File Exists Missing Warning

--- 修复日志 ---
[漂移] 服务 'W3SVC' 期望 Running,实际 Stopped
[修复] 已将服务 'W3SVC' 状态恢复为 Running
[漂移] 文件 'D:\app\connection.json' 期望存在但未找到
[修复] 已从 https://config-server.local/staging/connection.json 下载到 D:\app\connection.json

多环境发布管道

在完整的 GitOps 工作流中,配置需要经过 dev、staging、prod 三个环境的逐步验证和发布。每个环境有自己的变量替换规则和审批策略。下面的脚本实现了一个轻量级的多环境发布管道,通过模板渲染将通用配置转化为环境特定的部署清单:

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

[ValidateSet('dev', 'staging', 'prod')]
[string]$TargetEnvironment = 'dev',

[string]$TemplatesDir = 'templates',
[string]$OutputDir = 'rendered',
[switch]$SkipValidation,
[switch]$WhatIf
)

$envConfigPath = Join-Path $ConfigRepoPath "environments/$TargetEnvironment.json"
if (-not (Test-Path $envConfigPath)) {
throw "找不到环境配置文件: $envConfigPath"
}

# 加载环境变量映射
$envVars = Get-Content -Path $envConfigPath -Raw | ConvertFrom-Json
Write-Host "`n========== GitOps 发布管道 ==========" -ForegroundColor Cyan
Write-Host " 环境: $TargetEnvironment"
Write-Host " 仓库: $ConfigRepoPath"
Write-Host " 时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "======================================`n"

$templatesPath = Join-Path $ConfigRepoPath $TemplatesDir
$renderedPath = Join-Path $ConfigRepoPath "$OutputDir/$TargetEnvironment"
$null = New-Item -ItemType Directory -Path $renderedPath -Force

# 渲染所有模板文件
$templateFiles = Get-ChildItem -Path $templatesPath -Filter '*.tpl' -Recurse
$renderResults = @()

foreach ($tpl in $templateFiles) {
$content = Get-Content -Path $tpl.FullName -Raw
$relativePath = $tpl.FullName.Substring($templatesPath.Length).TrimStart('\', '/')

# 使用环境变量替换模板占位符 {{VAR_NAME}}
$envVars.PSObject.Properties | ForEach-Object {
$placeholder = "{{$($_.Name)}}"
$content = $content -replace [regex]::Escape($placeholder), $_.Value
}

$outputFile = Join-Path $renderedPath ($tpl.BaseName -replace '\.tpl$', '.yaml')
$relativeOutput = "$OutputDir/$TargetEnvironment/" + ($tpl.BaseName -replace '\.tpl$', '.yaml')

if ($WhatIf) {
Write-Host "[预览] $relativeOutput (未写入)" -ForegroundColor Magenta
$renderResults += [PSCustomObject]@{ File = $relativeOutput; Status = 'Preview' }
}
else {
$content | Set-Content -Path $outputFile -NoNewline
Write-Host "[渲染] $relativeOutput" -ForegroundColor Green
$renderResults += [PSCustomObject]@{ File = $relativeOutput; Status = 'Rendered' }
}
}

# 验证渲染结果
if (-not $SkipValidation -and -not $WhatIf) {
Write-Host "`n--- 验证渲染结果 ---" -ForegroundColor Cyan
$yamlFiles = Get-ChildItem -Path $renderedPath -Filter '*.yaml'

foreach ($yaml in $yamlFiles) {
$yamlContent = Get-Content -Path $yaml.FullName -Raw
$unresolved = [regex]::Matches($yamlContent, '\{\{[A-Z_]+\}\}')

if ($unresolved.Count -gt 0) {
$varNames = $unresolved | ForEach-Object { $_.Value } | Select-Object -Unique
Write-Host "[警告] $($yaml.Name) 包含未替换的变量: $($varNames -join ', ')" -ForegroundColor Yellow
}
else {
Write-Host "[通过] $($yaml.Name) 所有变量已正确替换" -ForegroundColor Green
}
}
}

# prod 环境的额外确认
if ($TargetEnvironment -eq 'prod' -and -not $WhatIf) {
Write-Host "`n[生产] 即将发布到生产环境!" -ForegroundColor Red
$confirm = Read-Host "请输入 'CONFIRM' 确认发布到生产环境"
if ($confirm -ne 'CONFIRM') {
Write-Host "[取消] 发布已中止。" -ForegroundColor Yellow
return
}
}

Write-Host "`n========== 发布完成 ==========" -ForegroundColor Cyan
$renderResults | Format-Table -AutoSize

return $renderResults
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PS> Invoke-GitOpsPipeline -ConfigRepoPath D:\gitops-config -TargetEnvironment staging

========== GitOps 发布管道 ==========
环境: staging
仓库: D:\gitops-config
时间: 2026-04-02 10:30:00
======================================

[渲染] rendered/staging/app-deploy.yaml
[渲染] rendered/staging/infra-config.yaml
[渲染] rendered/staging/monitoring.yaml

--- 验证渲染结果 ---
[通过] app-deploy.yaml 所有变量已正确替换
[通过] infra-config.yaml 所有变量已正确替换
[通过] monitoring.yaml 所有变量已正确替换

========== 发布完成 ==========

File Status
---- ------
rendered/staging/app-deploy.yaml Rendered
rendered/staging/infra-config.yaml Rendered
rendered/staging/monitoring.yaml Rendered

注意事项

  1. Git 凭据管理:自动化推送依赖 Git 认证,推荐使用 SSH 密钥或 Windows Credential Manager 存储凭据,避免在脚本中硬编码密码。在 CI/CD 管道中可使用 PAT(Personal Access Token)配合环境变量注入。

  2. 并发冲突处理:多人同时修改同一仓库时可能出现合并冲突。建议配置文件按服务或模块拆分到不同子目录,减少冲突概率。脚本中已包含 git pull 逻辑,遇到冲突时应中断流程并通知人工介入。

  3. 漂移检测的定时执行:将 Test-ConfigDrift 注册为 Windows 计划任务或放入 Azure Functions 定时触发器,建议生产环境每 5 分钟检测一次,staging 环境每 15 分钟检测一次。可通过 Register-ScheduledTask 命令快速配置。

  4. 模板占位符规范:多环境管道中使用 {{VAR_NAME}} 格式的占位符,变量名统一使用大写字母和下划线。确保每个环境的 JSON 配置文件包含所有模板中引用的变量,否则渲染结果会残留未替换的占位符。

  5. 生产环境保护:发布到 prod 环境时,脚本内置了交互式确认步骤。在无人值守的 CI/CD 管道中,应通过外部审批门禁(如 Azure DevOps 的 Environment approvals)替代交互式确认,同时启用 Slack 或 Teams 通知。

  6. 日志与审计:所有 GitOps 操作都应输出结构化日志,建议通过 ConvertTo-Json 将漂移报告和发布记录序列化后写入中央日志系统。这样可以在 Grafana 或 Kibana 中建立配置漂移的仪表板,追踪漂移频率和修复耗时。

PowerShell 技能连载 - AI Agent 自动化框架

适用于 PowerShell 7.0 及以上版本

AI Agent(智能代理)是当前大语言模型应用的热门方向。与传统的”单次问答”不同,Agent 能够自主规划任务步骤、调用外部工具、根据执行结果进行推理,最终完成复杂目标。对于系统运维工程师来说,这意味着可以将 LLM 的理解能力与 PowerShell 强大的系统管理能力结合起来,构建出真正”懂意图”的自动化框架。

PowerShell 作为 Windows/Linux/macOS 通用的脚本语言,天生具备丰富的系统管理 cmdlet(如文件操作、进程管理、网络请求、注册表读写等),这些都可以作为 Agent 的”工具”暴露给 LLM。通过精心设计的工具调用协议,Agent 可以根据用户的自然语言描述,自动选择合适的命令并执行。

本文将分三个部分逐步构建一个轻量级 AI Agent 框架:首先实现与 LLM API 的对话集成,然后定义工具调用机制,最后实现 ReAct(Reasoning + Acting)循环,使 Agent 具备多步推理和自主执行的能力。

LLM API 集成

Agent 的核心是语言模型。我们首先封装一个通用的 LLM 调用函数,支持 OpenAI 兼容的 API(包括 OpenAI 官方、Azure OpenAI、以及 Ollama 等本地部署的模型)。该函数负责构建对话上下文、发送请求并解析响应。

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
function Invoke-LLMChat {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[array]$Messages,

[Parameter()]
[string]$Model = 'gpt-4o-mini',

[Parameter()]
[string]$BaseUrl = 'https://api.openai.com/v1',

[Parameter()]
[string]$ApiKey = $env:OPENAI_API_KEY,

[Parameter()]
[double]$Temperature = 0.3,

[Parameter()]
[array]$Tools
)

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

$body = @{
model = $Model
messages = $Messages
temperature = $Temperature
}

if ($Tools) {
$body['tools'] = $Tools
$body['tool_choice'] = 'auto'
}

$uri = "$BaseUrl/chat/completions"
$response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body ($body | ConvertTo-Json -Depth 10)

return $response.choices[0].message
}

# 构建系统提示词,定义 Agent 的角色和行为规范
$systemPrompt = @"
你是一个 PowerShell 运维 Agent。你可以使用提供的工具来执行系统管理任务。
请根据用户的请求,选择合适的工具进行操作。每次只调用一个工具。
如果任务需要多个步骤,请逐步完成。操作完成后请给出简洁的总结。
"@

# 初始化对话历史
$script:conversationHistory = @(
@{ role = 'system'; content = $systemPrompt }
)

上面的代码定义了 Invoke-LLMChat 函数,它接受对话消息数组、模型名称和可选的工具定义。通过 $env:OPENAI_API_KEY 环境变量读取 API 密钥,方便切换不同的 API 提供商。

执行结果示例:

1
2
3
4
PS> Invoke-LLMChat -Messages @(@{role='user';content='你好'}) -Model 'gpt-4o-mini'

role : assistant
content : 你好!我是 PowerShell 运维 Agent,可以帮助你管理系统。请问有什么需要?

工具调用框架

接下来定义 Agent 可用的工具集。每个工具包含名称、描述和参数定义(遵循 JSON Schema 格式),以及对应的 PowerShell 执行函数。当 LLM 决定调用某个工具时,我们会解析其返回的函数调用请求,执行对应的 PowerShell 命令,并将结果反馈给模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# 定义可用的工具列表(OpenAI function calling 格式)
$script:agentTools = @(
@{
type = 'function'
function = @{
name = 'get_system_info'
description = '获取当前系统的基本信息,包括操作系统版本、CPU、内存、磁盘使用情况'
parameters = @{
type = 'object'
properties = @{
details = @{
type = 'boolean'
description = '是否返回详细信息'
}
}
}
}
}
@{
type = 'function'
function = @{
name = 'list_processes'
description = '列出当前运行的进程,可按名称筛选并排序'
parameters = @{
type = 'object'
properties = @{
name = @{
type = 'string'
description = '按进程名称筛选(支持通配符)'
}
top = @{
type = 'integer'
description = '返回前 N 个结果,默认 10'
}
sortBy = @{
type = 'string'
enum = @('CPU', 'Memory', 'Name')
description = '排序依据'
}
}
}
}
}
@{
type = 'function'
function = @{
name = 'read_file_content'
description = '读取指定路径的文件内容'
parameters = @{
type = 'object'
properties = @{
path = @{
type = 'string'
description = '文件路径'
}
lastN = @{
type = 'integer'
description = '只读取最后 N 行'
}
}
required = @('path')
}
}
}
)

# 工具执行分发器:根据工具名调用对应的 PowerShell 实现
function Invoke-AgentTool {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ToolName,

[Parameter(Mandatory)]
[hashtable]$Arguments
)

switch ($ToolName) {
'get_system_info' {
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$disks = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3'

$result = @{
OS = $os.Caption
Version = $os.Version
CPU = $cpu.Name
TotalMemGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
FreeMemGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
Disks = $disks | ForEach-Object {
@{
Drive = $_.DeviceID
FreeGB = [math]::Round($_.FreeSpace / 1GB, 2)
TotalGB = [math]::Round($_.Size / 1GB, 2)
UsedPct = [math]::Round(($_.Size - $_.FreeSpace) / $_.Size * 100, 1)
}
}
}
return ($result | ConvertTo-Json -Depth 5)
}

'list_processes' {
$procs = Get-Process
if ($Arguments.name) {
$procs = $procs | Where-Object { $_.Name -like $Arguments.name }
}
$sortField = if ($Arguments.sortBy -eq 'Memory') { 'WorkingSet64' }
elseif ($Arguments.sortBy -eq 'CPU') { 'CPU' }
else { 'Name' }
$top = if ($Arguments.top) { $Arguments.top } else { 10 }

$result = $procs |
Sort-Object -Property $sortField -Descending |
Select-Object -First $top |
ForEach-Object {
@{
Name = $_.Name
PID = $_.Id
CPU_s = [math]::Round($_.CPU, 2)
MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 2)
}
}
return ($result | ConvertTo-Json -Depth 3)
}

'read_file_content' {
$path = $Arguments.path
if (-not (Test-Path $path)) {
return "错误:文件 '$path' 不存在"
}
$content = Get-Content $path -Encoding UTF8
if ($Arguments.lastN -gt 0) {
$content = $content | Select-Object -Last $Arguments.lastN
}
return ($content -join "`n")
}

default {
return "错误:未知工具 '$ToolName'"
}
}
}

这段代码定义了三个实用工具:get_system_info 获取系统状态、list_processes 管理进程、read_file_content 读取文件。Invoke-AgentTool 函数作为分发器,根据工具名路由到对应的实现逻辑。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
PS> Invoke-AgentTool -ToolName 'get_system_info' -Arguments @{}

{
"OS": "Microsoft Windows 11 Pro",
"Version": "10.0.26100",
"CPU": "AMD Ryzen 9 7950X",
"TotalMemGB": 31.73,
"FreeMemGB": 14.25,
"Disks": [
{ "Drive": "C:", "FreeGB": 234.5, "TotalGB": 512.0, "UsedPct": 54.2 },
{ "Drive": "D:", "FreeGB": 876.1, "TotalGB": 1024.0, "UsedPct": 14.4 }
]
}

ReAct 循环实现

现在将 LLM 和工具调用结合起来,实现 ReAct(Reasoning + Acting)循环。Agent 在每一步都会思考当前状态、选择一个工具执行、观察执行结果,然后决定下一步行动,直到任务完成或达到最大步数限制。

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 Start-AgentReAct {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$UserQuery,

[Parameter()]
[int]$MaxSteps = 10,

[Parameter()]
[string]$Model = 'gpt-4o-mini',

[Parameter()]
[string]$BaseUrl = 'https://api.openai.com/v1',

[Parameter()]
[switch]$Verbose
)

# 初始化对话上下文
$history = [System.Collections.ArrayList]::new()
[void]$history.Add(@{ role = 'system'; content = $systemPrompt })
[void]$history.Add(@{ role = 'user'; content = $UserQuery })

Write-Host "`n=== Agent 启动 ===" -ForegroundColor Cyan
Write-Host "任务: $UserQuery`n" -ForegroundColor Yellow

for ($step = 1; $step -le $MaxSteps; $step++) {
Write-Host "--- 步骤 $step/$MaxSteps ---" -ForegroundColor DarkGray

# 调用 LLM 进行推理
$response = Invoke-LLMChat -Messages $history -Model $Model `
-BaseUrl $BaseUrl -Tools $script:agentTools

# 如果模型直接返回文本回复(没有工具调用),说明任务已完成
if (-not $response.tool_calls) {
Write-Host "`n=== Agent 完成 ===" -ForegroundColor Green
Write-Host "最终回复: $($response.content)" -ForegroundColor White
return $response.content
}

# 将助手消息(含工具调用请求)加入历史
[void]$history.Add($response)

# 处理每个工具调用
foreach ($toolCall in $response.tool_calls) {
$toolName = $toolCall.function.name
$toolArgs = $toolCall.function.arguments | ConvertFrom-Json -AsHashtable

if ($Verbose) {
Write-Host "调用工具: $toolName" -ForegroundColor Magenta
Write-Host "参数: $($toolArgs | ConvertTo-Json -Compress)" -ForegroundColor DarkGray
}

# 执行工具
$toolOutput = Invoke-AgentTool -ToolName $toolName -Arguments $toolArgs

if ($Verbose) {
$preview = if ($toolOutput.Length -gt 200) {
$toolOutput.Substring(0, 200) + '...'
} else {
$toolOutput
}
Write-Host "结果: $preview" -ForegroundColor DarkGray
}

# 将工具执行结果反馈给模型
[void]$history.Add(@{
role = 'tool'
tool_call_id = $toolCall.id
content = $toolOutput
})
}
}

Write-Host "`n=== 达到最大步数限制 ===" -ForegroundColor Red
Write-Host "Agent 未能在 $MaxSteps 步内完成任务。" -ForegroundColor Red
}

这段代码实现了完整的 ReAct 循环。每次迭代中,Agent 先向 LLM 发送当前对话历史和可用工具列表,LLM 决定是直接回复还是调用工具。如果调用了工具,执行后将结果追加到对话历史中,继续下一轮推理。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PS> Start-AgentReAct -UserQuery '检查系统磁盘空间是否充足,如果 C 盘使用率超过 80%,列出占用内存最多的 5 个进程' -Verbose

=== Agent 启动 ===
任务: 检查系统磁盘空间是否充足,如果 C 盘使用率超过 80%,列出占用内存最多的 5 个进程

--- 步骤 1/10 ---
调用工具: get_system_info
参数: {"details":true}
结果: {"OS":"Microsoft Windows 11 Pro","Version":"10.0.26100","CPU":"AMD Ryzen 9 7950X",...

--- 步骤 2/10 ---
调用工具: list_processes
参数: {"sortBy":"Memory","top":5}
结果: [{"Name":"chrome","PID":12804,"CPU_s":342.56,"MemoryMB":812.34},...

--- 步骤 3/10 ---

=== Agent 完成 ===
最终回复: 系统磁盘状态良好。C 盘使用率 54.2%,未超过 80% 阈值。
不过我仍然为你列出了占用内存最多的 5 个进程:
1. chrome (PID 12804) - 812.3 MB
2. Code (PID 9216) - 654.1 MB
3. msedge (PID 4452) - 423.7 MB
4. PowerShell (PID 7780) - 287.4 MB
5. docker (PID 3308) - 198.2 MB

注意事项

  1. API 密钥安全:切勿将 API 密钥硬编码在脚本中,应通过环境变量(如 $env:OPENAI_API_KEY)或 Azure Key Vault 等密钥管理服务获取,避免密钥泄露。

  2. 工具执行权限:Agent 调用的工具具有与运行脚本相同的权限。在生产环境中,务必对工具实现添加权限校验和白名单机制,防止 Agent 执行危险操作(如删除关键文件、修改系统配置)。

  3. 循环步数限制:ReAct 循环必须设置 MaxSteps 上限,防止 LLM 陷入无限循环。建议根据任务复杂度设置为 5-15 步,并在达到上限时给出明确的告警信息。

  4. 本地模型支持:如果使用 Ollama 等本地模型,只需将 BaseUrl 改为 http://localhost:11434/v1ApiKey 设为 ollama 即可。但本地模型的工具调用能力可能不如 GPT-4 系列稳定,建议充分测试。

  5. 错误处理与重试:网络请求可能因超时或限流失败。建议在 Invoke-LLMChat 中添加指数退避重试逻辑,并对工具执行结果进行异常捕获,将错误信息反馈给 Agent 以便自我修正。

  6. 对话历史管理:长对话会消耗大量 Token。实际使用时应实现滑动窗口或摘要机制,在保留关键上下文的同时控制历史消息长度,降低 API 调用成本。

PowerShell 技能连载 - Q1 技术回顾与展望

适用于 PowerShell 5.1 及以上版本

2026 年第一季度即将画上句号。回顾这三个月,PowerShell 生态在多个方向上都有值得关注的进展:PowerShell 7.x 持续迭代,跨平台能力进一步增强;AI 辅助编程工具链加速成熟,越来越多团队开始将大语言模型嵌入到自动化工作流中;社区在安全基线、基础设施即代码(IaC)等领域的最佳实践也在不断沉淀。

每个季度末做一次技术盘点,不仅是对过往工作的梳理,更是为下个阶段的学习和工作明确方向。本文将分三个部分展开:Q1 技术盘点、实战经验总结以及 Q2 学习路线规划,并附上可直接运行的 PowerShell 脚本来帮助你完成自己的回顾与展望。

Q1 技术盘点

第一个代码块帮助我们汇总本季度关注的核心技术动态,包括 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
# Q1 技术盘点脚本
$quarter = "2026-Q1"

# 当前 PowerShell 环境信息
$psInfo = [PSCustomObject]@{
Version = $PSVersionTable.PSVersion.ToString()
Edition = $PSVersionTable.PSEdition
OS = $PSVersionTable.OS
Machine = $PSVersionTable.Platform
}
Write-Host "=== 当前 PowerShell 环境 ===" -ForegroundColor Cyan
$psInfo | Format-List

# Q1 重点更新盘点
$updates = @(
[PSCustomObject]@{
Category = "PowerShell 核心"
Item = "PowerShell 7.5 LTS 持续维护,7.6 Preview 发布"
Impact = "性能优化,新增 ConvertFrom-Json -AsHashtable 改进"
}
[PSCustomObject]@{
Category = "AI 集成"
Item = "PowerShell AI 模块(PSAI)进入稳定版"
Impact = "可直接在脚本中调用本地/云端 LLM 完成文本处理"
}
[PSCustomObject]@{
Category = "安全模块"
Item = "Microsoft.PowerShell.SecretManagement 2.0"
Impact = "支持更多密钥保管库后端,跨平台凭据管理更便捷"
}
[PSCustomObject]@{
Category = "DSC v3"
Item = "Desired State Configuration v3 预览版更新"
Impact = "基于声明式配置,与容器化部署深度整合"
}
[PSCustomObject]@{
Category = "工具链"
Item = "PSScriptAnalyzer 1.23 规则扩展"
Impact = "新增 AI 相关脚本的安全审查规则"
}
[PSCustomObject]@{
Category = "社区"
Item = "PowerShell Gallery 月下载量突破 5 亿"
Impact = "生态持续增长,模块质量评估机制上线"
}
)

Write-Host "`n=== $quarter 技术盘点 ===" -ForegroundColor Cyan
$updates | Format-Table -AutoSize

# 统计已安装模块的更新情况
$installedModules = Get-InstalledModule |
Sort-Object Name -Unique |
Select-Object -First 10 Name, Version, RepositoryDescription

Write-Host "`n=== 已安装模块(前 10 个)===" -ForegroundColor Cyan
$installedModules | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
=== 当前 PowerShell 环境 ===

Version : 7.5.0
Edition : Core
OS : Darwin 25.4.0 Darwin Kernel Version 25.4.0
Machine : Unix

=== 2026-Q1 技术盘点 ===

Category Item Impact
-------- ---- ------
PowerShell 核心 PowerShell 7.5 LTS 持续维护,7.6 Preview 发布 性能优化,新增 ConvertFrom-Json -AsHashtable 改进
AI 集成 PowerShell AI 模块(PSAI)进入稳定版 可直接在脚本中调用本地/云端 LLM 完成文本处理
安全模块 Microsoft.PowerShell.SecretManagement 2.0 支持更多密钥保管库后端,跨平台凭据管理更便捷
DSC v3 Desired State Configuration v3 预览版更新 基于声明式配置,与容器化部署深度整合
工具链 PSScriptAnalyzer 1.23 规则扩展 新增 AI 相关脚本的安全审查规则
社区 PowerShell Gallery 月下载量突破 5 亿 生态持续增长,模块质量评估机制上线

=== 已安装模块(前 10 个)===

Name Version RepositoryDescription
---- ------- ---------------------
Microsoft.PowerShell.SecretManagement 2.0.0 ...
PSScriptAnalyzer 1.23.0 ...
PSReadLine 2.4.2 ...

实战经验总结

Q1 中涌现了大量优秀的自动化模式和问题解决方案。以下脚本梳理了社区中最受关注的实践模式,并提供了可以直接用于项目模板的代码片段。

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
# Q1 实战经验总结脚本
Write-Host "=== Q1 实战经验总结 ===" -ForegroundColor Cyan

# 模式 1:结构化日志与错误处理
$patterns = @(
[PSCustomObject]@{
Pattern = "结构化日志(Structured Logging)"
Description = "使用 ConvertTo-Json 输出结构化日志,便于 ELK/Splunk 采集"
Popularity = "High"
}
[PSCustomObject]@{
Pattern = "并行任务(ForEach-Object -Parallel)"
Description = "利用 runspace 池并发执行,显著缩短批量操作耗时"
Popularity = "High"
}
[PSCustomObject]@{
Pattern = "AI 辅助脚本生成"
Description = "通过 PSAI 模块调用 LLM 自动生成样板代码并人工审核"
Popularity = "Medium-High"
}
[PSCustomObject]@{
Pattern = "凭据安全传递链"
Description = "SecretManagement + Key Vault 统一管理敏感信息"
Popularity = "Medium"
}
[PSCustomObject]@{
Pattern = "跨平台环境检测"
Description = "用 $IsWindows/$IsLinux/$IsMacOS 编写通用脚本"
Popularity = "Medium"
}
)

$patterns | Format-Table -AutoSize

# 实用函数:检测脚本是否通过 lint 检查
function Test-ScriptQuality {
param(
[Parameter(Mandatory)]
[string]$ScriptPath
)

$issues = @()

# 检查文件是否存在
if (-not (Test-Path $ScriptPath)) {
$issues += "文件不存在: $ScriptPath"
return $issues
}

$content = Get-Content $ScriptPath -Raw

# 基础质量检查
if ($content.Length -lt 50) {
$issues += "脚本内容过短,可能缺少有效逻辑"
}

if ($content -notmatch 'try\s*\{') {
$issues += "未发现 try/catch 错误处理"
}

if ($content -notmatch '\[CmdletBinding\(\)') {
$issues += "建议添加 CmdletBinding() 以支持高级函数特性"
}

if ($content -notmatch '#\s*(Synopsis|Description)') {
$issues += "建议添加基于注释的帮助文档"
}

if ($content -match '\-Password\s+\$') {
$issues += "检测到明文密码参数,建议使用 SecureString 或 SecretManagement"
}

if ($issues.Count -eq 0) {
Write-Host " [PASS] $ScriptPath 质量检查通过" -ForegroundColor Green
} else {
Write-Host " [WARN] $ScriptPath 发现 $($issues.Count) 个建议" -ForegroundColor Yellow
$issues | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkYellow }
}

return $issues
}

# 批量检查脚本质量示例
Write-Host "`n--- 脚本质量检查示例 ---" -ForegroundColor Green
$sampleScripts = Get-ChildItem -Path "." -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 5

foreach ($script in $sampleScripts) {
Test-ScriptQuality -ScriptPath $script.FullName | Out-Null
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=== Q1 实战经验总结 ===

Pattern Description Popularity
------- ----------- ----------
结构化日志(Structured Logging) 使用 ConvertTo-Json 输出结构化日志,便于 ELK/Splunk 采集 High
并行任务(ForEach-Object -Parallel)利用 runspace 池并发执行,显著缩短批量操作耗时 High
AI 辅助脚本生成 通过 PSAI 模块调用 LLM 自动生成样板代码并人工审核 Medium-High
凭据安全传递链 SecretManagement + Key Vault 统一管理敏感信息 Medium
跨平台环境检测 用 $IsWindows/$IsLinux/$IsMacOS 编写通用脚本 Medium

--- 脚本质量检查示例 ---
[PASS] ./deploy.ps1 质量检查通过
[WARN] ./backup.ps1 发现 2 个建议
- 未发现 try/catch 错误处理
- 建议添加 CmdletBinding() 以支持高级函数特性
[PASS] ./monitor.ps1 质量检查通过

Q2 学习路线

展望第二季度,以下脚本帮你规划学习路线、追踪技术趋势以及关注社区活动。

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
# Q2 学习路线规划脚本
Write-Host "=== 2026 Q2 学习路线 ===" -ForegroundColor Cyan

# Q2 重点学习方向
$learningPath = @(
[PSCustomObject]@{
Priority = "P0 - 必学"
Topic = "PowerShell 7.6 新特性与迁移指南"
Resources = "官方 CHANGELOG、PowerShell Blog、GitHub Releases"
Timeline = "4 月"
}
[PSCustomObject]@{
Priority = "P0 - 必学"
Topic = "AI Agent 自动化工作流深度实践"
Resources = "PSAI 模块文档、LangChain-PowerShell 集成案例"
Timeline = "4-5 月"
}
[PSCustomObject]@{
Priority = "P1 - 推荐"
Topic = "容器化 PowerShell 脚本(Docker/Podman)"
Resources = "Microsoft 官方 Docker 镜像、DSC v3 文档"
Timeline = "5 月"
}
[PSCustomObject]@{
Priority = "P1 - 推荐"
Topic = "结构化配置管理(YAML/JSON Schema 验证)"
Resources = "powershell-yaml 模块、Pester 6 测试框架"
Timeline = "5-6 月"
}
[PSCustomObject]@{
Priority = "P2 - 关注"
Topic = "WebAssembly 上的 PowerShell 实验性支持"
Resources = "PowerShell Community Calls、实验性特性 RFC"
Timeline = "6 月"
}
[PSCustomObject]@{
Priority = "P2 - 关注"
Topic = "安全供应链:模块签名与 SBOM 生成"
Resources = "PowerShell Gallery 安全指南、NuGet 签名工具"
Timeline = "6 月"
}
)

$learningPath | Format-Table -AutoSize

# Q2 社区活动日历
$events = @(
[PSCustomObject]@{
Date = "2026-04-15"
Event = "PowerShell Community Call(每月第三个周三)"
Type = "线上"
}
[PSCustomObject]@{
Date = "2026-05-06"
Event = "PowerShell + AI Summit 虚拟大会"
Type = "线上"
}
[PSCustomObject]@{
Date = "2026-05-20"
Event = "PowerShell Community Call"
Type = "线上"
}
[PSCustomObject]@{
Date = "2026-06-17"
Event = "PowerShell Community Call"
Type = "线上"
}
[PSCustomObject]@{
Date = "2026-06-23"
Event = "PSConfEU 2026(欧洲 PowerShell 大会)"
Type = "线下/线上"
}
)

Write-Host "`n=== Q2 社区活动 ===" -ForegroundColor Cyan
$events | Format-Table -AutoSize

# 生成个人学习进度追踪文件
$progressFile = "Q2-Learning-Progress-$(Get-Date -Format 'yyyyMMdd').json"
$progress = [PSCustomObject]@{
GeneratedDate = (Get-Date -Format "yyyy-MM-dd")
Quarter = "2026-Q2"
Goals = $learningPath | Select-Object Topic, Timeline
Events = $events
Status = "NotStarted"
}

$progress | ConvertTo-Json -Depth 3 | Set-Content $progressFile
Write-Host "`n学习进度追踪文件已生成: $progressFile" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
=== 2026 Q2 学习路线 ===

Priority Topic Resources Timeline
-------- ----- --------- --------
P0 - 必学 PowerShell 7.6 新特性与迁移指南 官方 CHANGELOG、PowerShell Blog、GitHub Releases 4 月
P0 - 必学 AI Agent 自动化工作流深度实践 PSAI 模块文档、LangChain-PowerShell 集成案例 4-5 月
P1 - 推荐 容器化 PowerShell 脚本(Docker/Podman) Microsoft 官方 Docker 镜像、DSC v3 文档 5 月
P1 - 推荐 结构化配置管理(YAML/JSON Schema 验证) powershell-yaml 模块、Pester 6 测试框架 5-6 月
P2 - 关注 WebAssembly 上的 PowerShell 实验性支持 PowerShell Community Calls、实验性特性 RFC 6 月
P2 - 关注 安全供应链:模块签名与 SBOM 生成 PowerShell Gallery 安全指南、NuGet 签名工具 6 月

=== Q2 社区活动 ===

Date Event Type
---- ----- ----
2026-04-15 PowerShell Community Call(每月第三个周三) 线上
2026-05-06 PowerShell + AI Summit 虚拟大会 线上
2026-05-20 PowerShell Community Call 线上
2026-06-17 PowerShell Community Call 线上
2026-06-23 PSConfEU 2026(欧洲 PowerShell 大会) 线下/线上

学习进度追踪文件已生成: Q2-Learning-Progress-20260331.json

注意事项

  1. 季度复盘是持续学习的基础:建议每个季度末用 30 分钟运行一次盘点脚本,回顾自己的技术成长轨迹,比漫无目的地浏览技术文章更高效。

  2. AI 工具是加速器而非替代品:Q1 中 AI 辅助编程工具确实提升了效率,但生成的代码必须经过人工审核。建议将 PSScriptAnalyzer 作为 AI 生成代码的必经检查环节。

  3. 关注安全供应链:随着 PowerShell Gallery 生态壮大,模块安全性愈发重要。安装第三方模块前务必检查作者签名、下载量和社区评价,优先选择 Microsoft 官方或知名社区成员维护的模块。

  4. 并行执行的适用边界ForEach-Object -Parallel 虽然强大,但不适合涉及共享状态修改的场景。使用前要评估线程安全性,必要时用 ConcurrentDictionary 或文件锁来协调并发访问。

  5. 跨平台意识要从现在培养:即使当前只用 Windows,在编写新脚本时养成使用 $IsWindows/$IsLinux/$IsMacOS 做环境检测的习惯,这会让未来的迁移成本趋近于零。

  6. 参与社区是最好的学习方式:每月的 PowerShell Community Call 是了解官方路线图的最佳窗口,PSConfEU 和各类虚拟大会则能帮你拓宽视野。建议至少每季度参加一次社区活动,保持与生态前沿的同频。

PowerShell 技能连载 - PowerShellGet v3 与模块生态

适用于 PowerShell 7.0 及以上版本

PowerShell 的强大很大程度上来自于其丰富的模块生态——从 AWS 和 Azure 的云管理工具,到 Pester 测试框架、Plaster 项目脚手架,社区贡献了成千上万的实用模块。而这一切的基石就是包管理器。PowerShellGet v3(即 PSResourceGet)作为新一代包管理模块,基于 NuGet v3 协议从头重写,带来了显著的性能提升和更现代化的 API 设计。

与 v2 相比,PSResourceGet 的安装和搜索速度快了数倍,支持并行下载,资源类型从单一的 Script 和 Module 扩展到了 DSCResource、Command、RoleCapability 等更细粒度的分类。同时,它对私有仓库(如 Azure Artifacts、JFrog Artifactory、Sonatype Nexus)的原生支持,让企业内部模块的分发和管理变得前所未有的便捷。

本文将从三个层面展开:首先介绍 PSResourceGet 的安装配置与基础模块管理操作,然后深入讲解依赖解析与版本锁定策略,最后演示如何将自研模块发布到公共仓库或私有仓库,并融入 CI/CD 流水线。

PSResourceGet 基础:安装、仓库注册与模块搜索

PSResourceGet 是 PowerShell 7 的官方推荐包管理模块。安装后,你需要注册模块仓库(默认自带 PSGallery),然后就可以搜索、安装和更新模块。理解仓库的优先级机制和信任级别设置,是安全使用包管理器的前提。

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
# 安装 PSResourceGet 模块
Install-Module -Name PSResourceGet -Force -Scope CurrentUser
Write-Host "PSResourceGet 安装完成"

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

# 注册私有 NuGet 仓库(例如 Azure Artifacts)
$repoParams = @{
Name = 'MyCompanyGallery'
Uri = 'https://pkgs.dev.azure.com/mycompany/_packaging/myFeed/nuget/v3/index.json'
Trusted = $true
Priority = 30
}
Register-PSResourceRepository @repoParams
Write-Host "私有仓库 MyCompanyGallery 注册完成"

# 设置 PSGallery 为受信任(避免每次安装都提示确认)
Set-PSResourceRepository -Name 'PSGallery' -Trusted

# 搜索模块:按关键词查找
Find-PSResource -Name 'Pester' -Repository PSGallery
Write-Host "`n--- 按标签搜索 ---"
Find-PSResource -Tags 'azure' -Repository PSGallery |
Select-Object -First 5 |
Format-Table Name, Version, Description -AutoSize

# 搜索特定类型的资源
Find-PSResource -Type Module -Name 'Az.*' |
Select-Object -First 5 |
Format-Table Name, Version -AutoSize

# 安装模块
Install-PSResource -Name 'Pester' -Version '[5.0.0,6.0.0)' -Repository PSGallery
Write-Host "`nPester 已安装"

# 查看已安装的模块信息
Get-InstalledPSResource -Name 'Pester' |
Format-Table Name, Version, InstalledLocation -AutoSize

# 更新模块到最新版
Update-PSResource -Name 'Pester' -Prerelease
Write-Host "Pester 已更新到最新版本(含预发布版)"

# 卸载模块
# Uninstall-PSResource -Name 'SomeOldModule'

执行结果示例:

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
PSResourceGet 安装完成

Name Uri Trusted Priority
---- --- ------- --------
PSGallery https://www.powershellgallery.com/api/v2 True 50
MyCompanyGallery https://pkgs.dev.azure.com/... True 30
私有仓库 MyCompanyGallery 注册完成

Name Version Description
---- ------- -----------
Pester 5.7.1 Pester provides a framework for running BDD tests

--- 按标签搜索 ---
Name Version Description
---- ------- -----------
Az.Accounts 3.0.2 Microsoft Azure PowerShell...
Az.Compute 7.0.1 Microsoft Azure PowerShell Compute...

Pester 已安装

Name Version InstalledLocation
---- ------- -----------------
Pester 5.7.1 /Users/user/.local/share/powershell/Modules

Pester 已更新到最新版本(含预发布版)

模块依赖与版本管理

随着项目规模增长,模块之间的依赖关系会变得复杂。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
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
# 版本范围语法速查
$versionExamples = @(
@{ Expression = '1.2.3'; Meaning = '精确匹配 1.2.3' }
@{ Expression = '[1.2.3,2.0.0)'; Meaning = '大于等于 1.2.3,小于 2.0.0' }
@{ Expression = '[1.0.0,)'; Meaning = '大于等于 1.0.0(无上限)' }
@{ Expression = '(1.0.0,2.0.0)'; Meaning = '大于 1.0.0,小于 2.0.0(不含边界)' }
@{ Expression = '[1.0.0,2.0.0]'; Meaning = '大于等于 1.0.0,小于等于 2.0.0' }
)
Write-Host "版本范围语法参考:"
$versionExamples | Format-Table -AutoSize

# 使用锁文件管理项目依赖(导出当前环境的模块版本)
$projectModules = @('Pester', 'PSScriptAnalyzer', 'platyPS', 'ModuleBuilder')
$lockData = foreach ($mod in $projectModules) {
$installed = Get-InstalledPSResource -Name $mod -ErrorAction SilentlyContinue
if ($installed) {
[PSCustomObject]@{
ModuleName = $installed.Name
Version = $installed.Version.ToString()
Repository = $installed.Repository
Installed = $installed.InstalledDate.ToString('yyyy-MM-dd')
}
}
else {
[PSCustomObject]@{
ModuleName = $mod
Version = 'NOT INSTALLED'
Repository = '-'
Installed = '-'
}
}
}

$lockFile = Join-Path $PWD 'modules.lock.json'
$lockData | ConvertTo-Json | Set-Content -Path $lockFile -Encoding UTF8
Write-Host "`n依赖锁文件已生成: $lockFile"
$lockData | Format-Table -AutoSize

# 根据锁文件批量安装精确版本
function Install-FromLockFile {
param([string]$Path = 'modules.lock.json')
$lock = Get-Content -Path $Path -Raw | ConvertFrom-Json
foreach ($entry in $lock) {
if ($entry.Version -eq 'NOT INSTALLED') {
Write-Warning "跳过未指定的模块: $($entry.ModuleName)"
continue
}
Write-Host "安装 $($entry.ModuleName) @$($entry.Version)..."
Install-PSResource -Name $entry.ModuleName -Version $entry.Version -Quiet -Reinstall
}
Write-Host "`n所有依赖安装完成"
}

# 检查模块兼容性:确认模块是否支持当前平台
function Test-ModuleCompatibility {
param([string]$ModuleName)
$mod = Find-PSResource -Name $ModuleName -Repository PSGallery -ErrorAction SilentlyContinue
if (-not $mod) {
Write-Warning "未找到模块: $ModuleName"
return
}
$info = [PSCustomObject]@{
ModuleName = $mod.Name
LatestVersion = $mod.Version.ToString()
PowerShell = if ($mod.RequiredResource -match 'Core') { 'PS7+' } else { 'PS5.1+' }
HasPrerelease = $mod.IsPrerelease
}
# 检查是否已安装
$installed = Get-InstalledPSResource -Name $ModuleName -ErrorAction SilentlyContinue
if ($installed) {
$info | Add-Member -NotePropertyName 'InstalledVersion' -NotePropertyValue $installed.Version.ToString()
}
return $info
}

# 批量检查项目依赖的兼容性
foreach ($mod in $projectModules) {
$result = Test-ModuleCompatibility -ModuleName $mod
if ($result) { $result }
}

执行结果示例:

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
版本范围语法参考:
Expression Meaning
---------- -------
1.2.3 精确匹配 1.2.3
[1.2.3,2.0.0) 大于等于 1.2.3,小于 2.0.0
[1.0.0,) 大于等于 1.0.0(无上限)
(1.0.0,2.0.0) 大于 1.0.0,小于 2.0.0(不含边界)
[1.0.0,2.0.0] 大于等于 1.0.0,小于等于 2.0.0

依赖锁文件已生成: /Users/user/project/modules.lock.json

ModuleName Version Repository Installed
---------- ------- ---------- ---------
Pester 5.7.1 PSGallery 2026-03-15
PSScriptAnalyzer 1.23.0 PSGallery 2026-03-15
platyPS 0.14.2 PSGallery 2026-03-10
ModuleBuilder 3.0.0 PSGallery 2026-03-01

安装 Pester @5.7.1...
安装 PSScriptAnalyzer @1.23.0...
安装 platyPS @0.14.2...
安装 ModuleBuilder @3.0.0...
所有依赖安装完成

ModuleName LatestVersion PowerShell HasPrerelease InstalledVersion
---------- ------------- ---------- ------------- ----------------
Pester 5.7.1 PS5.1+ False 5.7.1
PSScriptAnalyzer 1.23.0 PS5.1+ False 1.23.0
platyPS 0.14.2 PS5.1+ False 0.14.2
ModuleBuilder 3.0.0 PS7+ False 3.0.0

模块发布与 CI/CD 集成

当你开发了自己的 PowerShell 模块并希望在团队或社区中共享时,就需要将它发布到模块仓库。PSResourceGet 提供了 Publish-PSResource 命令来简化发布流程。结合 CI/CD 流水线,可以实现代码提交后自动测试、打包和发布的完整工作流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# 准备模块清单(module manifest)
$manifestParams = @{
Path = './MyToolModule/MyToolModule.psd1'
RootModule = 'MyToolModule.psm1'
ModuleVersion = '1.0.0'
Author = 'Vic Hamp'
Description = '企业内部运维工具集模块'
PowerShellVersion = '7.0'
FunctionsToExport = @('Get-SystemReport', 'Start-HealthCheck', 'Reset-ServiceState')
Tags = @('Ops', 'Monitoring', 'HealthCheck', 'Enterprise')
ProjectUri = 'https://github.com/mycompany/MyToolModule'
LicenseUri = 'https://github.com/mycompany/MyToolModule/blob/main/LICENSE'
ReleaseNotes = '初始发布:系统报告、健康检查、服务状态重置功能'
RequiredModules = @(
@{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
)
}
New-ModuleManifest @manifestParams
Write-Host "模块清单已创建"

# 验证模块清单有效性
$manifest = Test-ModuleManifest -Path './MyToolModule/MyToolModule.psd1' -ErrorAction Stop
Write-Host "模块名: $($manifest.Name)"
Write-Host "版本: $($manifest.Version)"
Write-Host "导出函数: $($manifest.ExportedFunctions.Keys -join ', ')"

# 发布到 PSGallery(需要 API Key)
function Publish-ModuleToGallery {
param(
[Parameter(Mandatory)]
[string]$ModulePath,
[Parameter(Mandatory)]
[string]$ApiKey
)
$publishParams = @{
Path = $ModulePath
Repository = 'PSGallery'
ApiKey = $ApiKey
Verbose = $true
}
Publish-PSResource @publishParams
Write-Host "模块已发布到 PSGallery"
}

# 发布到私有仓库
function Publish-ModuleToPrivateRepo {
param(
[Parameter(Mandatory)]
[string]$ModulePath,
[Parameter(Mandatory)]
[string]$RepositoryName
)
# 私有仓库通常使用 NuGet API Key 或 PAT 认证
Publish-PSResource -Path $ModulePath -Repository $RepositoryName
Write-Host "模块已发布到私有仓库: $RepositoryName"
}

# CI/CD 集成示例:生成发布用的 GitHub Actions 工作流配置
$githubActions = @{
name = 'Publish PowerShell Module'
on = @{
push = @{ tags = @('v*') }
}
jobs = @{
publish = @{
'runs-on' = 'ubuntu-latest'
steps = @(
@{ uses = 'actions/checkout@v4' }
@{ name = 'Install PowerShell'; uses = 'PowerShell/setup-pwsh@v1' }
@{
name = 'Install PSResourceGet'
run = 'Install-Module -Name PSResourceGet -Force -Scope CurrentUser'
shell = 'pwsh'
}
@{
name = 'Publish Module'
run = @'
$tag = $env:GITHUB_REF -replace ''refs/tags/v'', ''''
Publish-PSResource -Path ./MyToolModule `
-Repository PSGallery `
-ApiKey $env:PSGALLERY_API_KEY
'@
shell = 'pwsh'
env = @{ PSGALLERY_API_KEY = '${{ secrets.PSGALLERY_API_KEY }}' }
}
)
}
}
}

$workflowPath = Join-Path $PWD '.github' 'workflows' 'publish-module.yml'
New-Item -ItemType Directory -Path (Split-Path $workflowPath) -Force | Out-Null
# 使用 ConvertTo-Yaml 需要安装相关模块,这里用 JSON 作为示意
$githubActions | ConvertTo-Json -Depth 10 |
Set-Content -Path $workflowPath -Encoding UTF8
Write-Host "`nGitHub Actions 工作流已生成: $workflowPath"

# 版本号自动递增工具
function Update-ModuleVersion {
param(
[Parameter(Mandatory)]
[string]$ManifestPath,
[ValidateSet('Major', 'Minor', 'Patch')]
[string]$Bump = 'Patch'
)
$content = Get-Content -Path $ManifestPath -Raw
if ($content -match 'ModuleVersion\s*=\s*[''"](\d+)\.(\d+)\.(\d+)[''"]') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
$patch = [int]$Matches[3]
switch ($Bump) {
'Major' { $major++; $minor = 0; $patch = 0 }
'Minor' { $minor++; $patch = 0 }
'Patch' { $patch++ }
}
$newVersion = "$major.$minor.$patch"
$content = $content -replace
"(ModuleVersion\s*=\s*[''"])\d+\.\d+\.\d+(['""])",
"`$1$newVersion`$2"
Set-Content -Path $ManifestPath -Value $content -NoNewline
Write-Host "版本号已更新为: $newVersion ($Bump)"
}
else {
Write-Warning "未在清单文件中找到 ModuleVersion"
}
}

Update-ModuleVersion -ManifestPath './MyToolModule/MyToolModule.psd1' -Bump Patch

执行结果示例:

1
2
3
4
5
6
7
模块清单已创建
模块名: MyToolModule
版本: 1.0.0
导出函数: Get-SystemReport, Start-HealthCheck, Reset-ServiceState

GitHub Actions 工作流已生成: /Users/user/project/.github/workflows/publish-module.yml
版本号已更新为: 1.0.1 (Patch)

注意事项

  1. PSResourceGet 与 PowerShellGet v2 的共存问题:PSResourceGet(模块名 Microsoft.PowerShell.PSResourceGet)与旧版 PowerShellGet 可以在同一系统上共存,但不应混用两者的命令来管理同一模块,否则可能导致安装状态不一致。建议在团队中统一选择其中一个,并在项目文档中明确说明。

  2. API Key 安全管理:发布模块到 PSGallery 需要使用 API Key,该密钥应通过环境变量或 CI/CD 平台的 Secrets 机制注入,绝不能硬编码在脚本或配置文件中。PSGallery 的 API Key 可以在账户设置中按权限范围生成,建议遵循最小权限原则。

  3. 私有仓库的认证配置:企业私有仓库(Azure Artifacts、JFrog 等)通常需要 PAT(Personal Access Token)或特定的 NuGet API Key 进行认证。使用 Register-PSResourceRepository 注册仓库时,可以通过 -Credential 参数预设凭据,也可在发布时通过 -ApiKey 参数动态传入。

  4. 版本范围语法要严格:PSResourceGet 使用 NuGet 版本范围语法(方括号和圆括号的组合),与 npm 或 pip 的语义不同。特别是 [1.0.0,2.0.0) 这种左闭右开的区间表达,在锁文件和 CI 脚本中务必仔细核对,否则可能出现依赖版本偏离预期的情况。

  5. 模块结构规范:发布到 PSGallery 的模块必须包含有效的模块清单(.psd1),并且清单中的 FunctionsToExportCmdletsToExport 等字段应显式列出要公开的成员,而非使用通配符 *。这不仅能提升模块加载性能,也便于用户通过 Get-Command -Module 发现可用命令。

  6. 跨平台兼容性声明:如果你的模块仅在 Windows 上可用(例如调用了 Win32 API 或 Windows 专属的 .NET 类),务必在模块清单的 DescriptionPrivateData 中明确说明,并在代码中加入平台检测逻辑,避免 Linux/macOS 用户安装后无法使用而困惑。

PowerShell 技能连载 - 合规审计自动化

适用于 PowerShell 5.1 及以上版本

在企业 IT 管理中,合规审计是一项持续性工作。无论是为了满足 CIS Benchmarks、ISO 27001 还是行业监管要求,安全团队都需要定期检查系统配置是否符合既定的安全基线。手动逐项检查不仅耗时巨大,而且容易出现遗漏和标准不一致的问题,尤其是在服务器数量较多的环境中。

PowerShell 凭借其对 Windows 系统的深度访问能力,可以从密码策略、账户策略、审核策略到注册表配置、防火墙规则、文件权限等维度全面采集安全配置信息。将审计规则标准化为可执行脚本后,每次审计都能以完全一致的检查项和判定逻辑运行,确保结果的可重复性和可比性。

本文将构建一套完整的合规审计自动化方案,涵盖安全基线检查、系统配置审计和合规报告生成三个核心模块,帮助企业将审计周期从数天缩短到数分钟,并输出结构化的 HTML 报告供管理层审阅。

安全基线检查

安全基线检查是合规审计的基础。我们将密码策略、账户策略、审核策略和用户权限分配四项关键检查整合到一个函数中,每项检查都映射到 CIS Benchmark 的具体控制项,并给出合规/不合规的判定结果。

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

$results = @()

# 密码策略检查
$passwordPolicy = net accounts |
Select-String -Pattern 'Minimum password length|Maximum password age|Minimum password age|Password history length'

$passwordLengthLine = $passwordPolicy | Where-Object { $_ -match 'Minimum password length' }
$minLength = if ($passwordLengthLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '密码策略'
CheckItem = '最小密码长度'
Expected = '>= 14 字符 (CIS 1.1.1)'
Actual = "$minLength 字符"
Status = if ($minLength -ge 14) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
}

$maxAgeLine = $passwordPolicy | Where-Object { $_ -match 'Maximum password age' }
$maxAge = if ($maxAgeLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '密码策略'
CheckItem = '密码最大使用期限'
Expected = '<= 60 天 (CIS 1.1.3)'
Actual = "$maxAge 天"
Status = if ($maxAge -gt 0 -and $maxAge -le 60) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Medium'
}

# 账户策略检查
$lockoutPolicy = net accounts |
Select-String -Pattern 'Lockout threshold|Lockout duration|Lockout observation'

$lockoutLine = $lockoutPolicy | Where-Object { $_ -match 'Lockout threshold' }
$lockoutThreshold = if ($lockoutLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '账户策略'
CheckItem = '账户锁定阈值'
Expected = '<= 5 次失败尝试 (CIS 1.2.1)'
Actual = if ($lockoutThreshold -eq 0) { 'Never' } else { "$lockoutThreshold 次" }
Status = if ($lockoutThreshold -gt 0 -and $lockoutThreshold -le 5) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
}

# 审核策略检查
$auditPolicies = auditpol /get /category:* /r 2>$null |
ConvertFrom-Csv |
Where-Object { $_.'Inclusion Setting' -eq 'No Auditing' -and $_.'Subcategory' -ne '' }

$criticalAuditCategories = @(
'Logon',
'Logoff',
'Special Logon',
'User Account Management',
'Security State Change'
)

foreach ($critical in $criticalAuditCategories) {
$found = $auditPolicies | Where-Object { $_.'Subcategory' -eq $critical }
$results += [PSCustomObject]@{
Category = '审核策略'
CheckItem = "审核: $critical"
Expected = 'Success and Failure (CIS 17.x)'
Actual = if ($found) { 'No Auditing' } else { '已配置' }
Status = if ($found) { 'Non-Compliant' } else { 'Compliant' }
Severity = 'High'
}
}

# 用户权限检查 - 检查敏感权限的分配情况
$sensitivePrivileges = @{
'SeDebugPrivilege' = '调试程序'
'SeTakeOwnershipPrivilege' = '取得文件所有权'
'SeLoadDriverPrivilege' = '加载和卸载设备驱动程序'
}

foreach ($privilege in $sensitivePrivileges.GetEnumerator()) {
$privOutput = secedit /export /cfg "$env:TEMP\secedit.cfg" 2>$null
$privLine = Get-Content "$env:TEMP\secedit.cfg" |
Select-String -Pattern $privilege.Key
$assignedTo = if ($privLine) {
($privLine.Line -split '=')[1].Trim()
} else {
'未找到'
}

$isAdminOnly = $assignedTo -match 'Administrators' -and
$assignedTo -notmatch 'Everyone|Users|Authenticated'

$results += [PSCustomObject]@{
Category = '用户权限'
CheckItem = $privilege.Value
Expected = '仅 Administrators (CIS 2.x)'
Actual = $assignedTo
Status = if ($isAdminOnly) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Critical'
}
}

Remove-Item "$env:TEMP\secedit.cfg" -ErrorAction SilentlyContinue

$results | Sort-Object Severity, Category, CheckItem
}

运行基线检查后,可以看到各项安全策略的合规状态:

1
2
3
4
5
6
7
8
9
Category  CheckItem              Expected                    Actual         Status          Severity
------- ---------- -------- ------ ------ --------
密码策略 最小密码长度 >= 14 字符 (CIS 1.1.1) 8 字符 Non-Compliant High
密码策略 密码最大使用期限 <= 60 天 (CIS 1.1.3) 90 天 Non-Compliant Medium
账户策略 账户锁定阈值 <= 5 次失败尝试 (CIS 1.2.1) 5 次 Compliant High
审核策略 审核: Logon Success and Failure (CIS) 已配置 Compliant High
审核策略 审核: User Account Management Success and Failure (CIS) No Auditing Non-Compliant High
用户权限 调试程序 仅 Administrators (CIS 2.x) Administrators Compliant Critical
用户权限 取得文件所有权 仅 Administrators (CIS 2.x) Administrators Compliant Critical

系统配置审计

安全基线只覆盖了策略层面的检查,实际运行环境中的服务、防火墙、注册表和文件系统同样需要审计。下面这个函数对系统配置进行全面扫描,检查每项配置是否满足 CIS 和 ISO 27001 的要求。

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
function Invoke-SystemConfigAudit {
[CmdletBinding()]
param(
[string[]]$CriticalRegistryKeys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System',
'HKLM:\SYSTEM\CurrentControlSet\Services\LDAP',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer'
)
)

$auditResults = @()

# 服务配置检查
$insecureServices = @{
'SysMain' = 'SuperFetch/SysMain 可能导致信息泄露'
'RemoteRegistry' = '远程注册表访问应被禁用'
'XblAuthManager' = 'Xbox 服务不应存在于服务器'
'WSearch' = 'Windows Search 在服务器上应禁用'
}

foreach ($svc in $insecureServices.GetEnumerator()) {
$service = Get-Service -Name $svc.Key -ErrorAction SilentlyContinue
$startType = try {
(Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\$($svc.Key)" -ErrorAction Stop).Start
} catch { 4 }

$isDisabled = $service.Status -eq 'Stopped' -and $startType -eq 4

$auditResults += [PSCustomObject]@{
Category = '服务配置'
CheckItem = "服务: $($svc.Key)"
Expected = '已禁用 (CIS 9.x)'
Actual = "状态: $($service.Status), 启动类型: $(if($startType -eq 4){'Disabled'}elseif($startType -eq 3){'Manual'}else{'Auto'})"
Status = if ($isDisabled) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Medium'
Remediation = "Set-Service -Name $($svc.Key) -StartupType Disabled; Stop-Service -Name $($svc.Key) -Force"
}
}

# 防火墙规则审计
$firewallProfiles = Get-NetFirewallProfile |
Select-Object Name, Enabled

foreach ($profile in $firewallProfiles) {
$auditResults += [PSCustomObject]@{
Category = '防火墙'
CheckItem = "防火墙配置文件: $($profile.Name)"
Expected = '已启用 (CIS 9.1.x)'
Actual = "Enabled: $($profile.Enabled)"
Status = if ($profile.Enabled) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Critical'
Remediation = "Set-NetFirewallProfile -Name $($profile.Name) -Enabled True"
}
}

# 危险入站规则检查
$dangerousRules = Get-NetFirewallRule |
Where-Object {
$_.Direction -eq 'Inbound' -and
$_.Action -eq 'Allow' -and
$_.Enabled -eq 'True'
} | Get-NetFirewallPortFilter |
Where-Object { $_.LocalPort -in @('3389', '22', '445', '139') }

$rdpExposed = $dangerousRules |
Where-Object { $_.LocalPort -eq '3389' }

if ($rdpExposed) {
$auditResults += [PSCustomObject]@{
Category = '防火墙'
CheckItem = 'RDP (3389) 入站规则'
Expected = '不应公开暴露 (CIS 9.2.x)'
Actual = '存在允许的入站规则'
Status = 'Non-Compliant'
Severity = 'Critical'
Remediation = '限制 RDP 访问源 IP 范围,或通过 VPN 隧道访问'
}
}

# 注册表安全审计
$regChecks = @(
@{
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Name = 'EnableLUA'
Expected = 1
Desc = 'UAC 必须启用 (CIS 2.3.17.3)'
Severity = 'Critical'
},
@{
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Name = 'ConsentPromptBehaviorAdmin'
Expected = 2
Desc = 'UAC 提示级别应为"始终通知" (CIS 2.3.17.5)'
Severity = 'Medium'
},
@{
Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurePipeServers\Winreg'
Name = 'RemoteAllowed'
Expected = 1
Desc = '远程注册表访问应受限制 (CIS 2.3.10.3)'
Severity = 'High'
}
)

foreach ($check in $regChecks) {
$actualValue = try {
(Get-ItemProperty -Path $check.Path -Name $check.Name -ErrorAction Stop).($check.Name)
} catch { $null }

$auditResults += [PSCustomObject]@{
Category = '注册表安全'
CheckItem = $check.Desc
Expected = "值 = $($check.Expected)"
Actual = if ($null -ne $actualValue) { "值 = $actualValue" } else { '键值不存在' }
Status = if ($actualValue -eq $check.Expected) { 'Compliant' } else { 'Non-Compliant' }
Severity = $check.Severity
Remediation = "Set-ItemProperty -Path '$($check.Path)' -Name $($check.Name) -Value $($check.Expected)"
}
}

# 关键目录权限审计
$sensitivePaths = @(
@{ Path = 'C:\Windows\System32\config'; ExpectedOwner = 'BUILTIN\Administrators'; Desc = 'SAM/SYSTEM 文件目录' },
@{ Path = 'C:\ProgramData'; ExpectedOwner = 'BUILTIN\Administrators'; Desc = '公共程序数据目录' }
)

foreach ($sp in $sensitivePaths) {
if (Test-Path $sp.Path) {
$acl = Get-Acl -Path $sp.Path
$owner = $acl.Owner

$auditResults += [PSCustomObject]@{
Category = '文件权限'
CheckItem = "目录所有者: $($sp.Desc)"
Expected = "所有者 = $($sp.ExpectedOwner) (CIS 13.x)"
Actual = "所有者 = $owner"
Status = if ($owner -match 'Administrators') { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
Remediation = "手动检查并修复 $sp.Path 的 NTFS 权限"
}
}
}

$auditResults | Sort-Object Severity, Category, CheckItem
}

系统配置审计的输出结果示例如下:

1
2
3
4
5
6
7
8
9
Category  CheckItem                              Expected                              Actual           Status          Severity
-------- ---------- -------- ------ ------ --------
注册表安全 UAC 必须启用 (CIS 2.3.17.3) 值 = 1 值 = 1 Compliant Critical
防火墙 防火墙配置文件: Domain 已启用 (CIS 9.1.x) Enabled: True Compliant Critical
防火墙 RDP (3389) 入站规则 不应公开暴露 (CIS 9.2.x) 存在允许的入站规则 Non-Compliant Critical
注册表安全 远程注册表访问应受限制 (CIS 2.3.10.3) 值 = 1 值 = 1 Compliant High
文件权限 目录所有者: SAM/SYSTEM 文件目录 所有者 = BUILTIN\Administrators (CIS) 所有者 = ... Compliant High
服务配置 服务: RemoteRegistry 已禁用 (CIS 9.x) 状态: Running Non-Compliant Medium
注册表安全 UAC 提示级别应为"始终通知" (CIS 2.3.17.5) 值 = 2 值 = 5 Non-Compliant Medium

合规报告生成

审计数据的最终价值体现在报告上。下面的函数将前面两个模块的检查结果汇总,生成一份结构化的 HTML 报告,包含合规率统计、风险等级分布、各检查项的详细状态,以及每个不合规项的具体修复建议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
function New-ComplianceReport {
[CmdletBinding()]
param(
[string]$OutputPath = "$env:USERPROFILE\Desktop\ComplianceReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[string]$Organization = 'IT 安全团队'
)

Write-Host "正在执行安全基线检查..." -ForegroundColor Cyan
$baselineResults = Invoke-SecurityBaselineCheck

Write-Host "正在执行系统配置审计..." -ForegroundColor Cyan
$configResults = Invoke-SystemConfigAudit

$allResults = @($baselineResults) + @($configResults)
$totalCount = $allResults.Count
$compliantCount = ($allResults | Where-Object { $_.Status -eq 'Compliant' }).Count
$complianceRate = [math]::Round(($compliantCount / $totalCount) * 100, 1)

# 按严重等级统计
$severityStats = $allResults |
Group-Object Severity |
Select-Object Name, Count

# 按类别统计合规率
$categoryStats = $allResults |
Group-Object Category |
ForEach-Object {
$compliant = ($_.Group | Where-Object { $_.Status -eq 'Compliant' }).Count
[PSCustomObject]@{
Category = $_.Name
Total = $_.Count
Compliant = $compliant
NonCompliant = $_.Count - $compliant
ComplianceRate = [math]::Round(($compliant / $_.Count) * 100, 1)
}
}

# 修复建议汇总
$remediations = $allResults |
Where-Object { $_.Status -eq 'Non-Compliant' -and $_.Remediation } |
Select-Object CheckItem, Severity, Remediation |
Sort-Object Severity

Write-Host "正在生成 HTML 报告..." -ForegroundColor Cyan

$severityColorMap = @{
'Critical' = '#dc3545'
'High' = '#fd7e14'
'Medium' = '#ffc107'
'Low' = '#28a745'
}

# 构建 HTML 报告
$htmlSections = @()
$htmlSections += '<!DOCTYPE html><html lang="zh-CN"><head>'
$htmlSections += '<meta charset="UTF-8">'
$htmlSections += '<title>合规审计报告</title>'
$htmlSections += '<style>'
$htmlSections += 'body { font-family: "Microsoft YaHei", sans-serif; margin: 20px; background: #f5f5f5; }'
$htmlSections += 'h1 { color: #333; border-bottom: 3px solid #007bff; padding-bottom: 10px; }'
$htmlSections += '.summary { display: flex; gap: 20px; margin: 20px 0; }'
$htmlSections += '.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; text-align: center; }'
$htmlSections += '.rate { font-size: 48px; font-weight: bold; }'
$htmlSections += 'table { width: 100%; border-collapse: collapse; background: white; margin: 10px 0; }'
$htmlSections += 'th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }'
$htmlSections += 'th { background: #007bff; color: white; }'
$htmlSections += '.compliant { color: #28a745; font-weight: bold; }'
$htmlSections += '.non-compliant { color: #dc3545; font-weight: bold; }'
$htmlSections += '</style></head><body>'
$htmlSections += "<h1>合规审计报告</h1>"
$htmlSections += "<p>组织: $Organization | 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | 计算机: $env:COMPUTERNAME</p>"

# 概览卡片
$rateColor = if ($complianceRate -ge 80) { '#28a745' } elseif ($complianceRate -ge 60) { '#ffc107' } else { '#dc3545' }
$htmlSections += '<div class="summary">'
$htmlSections += "<div class='card'><div class='rate' style='color:$rateColor'>$complianceRate%</div><div>总体合规率</div></div>"
$htmlSections += "<div class='card'><div class='rate' style='color:#333'>$compliantCount / $totalCount</div><div>合规项 / 总检查项</div></div>"

$criticalCount = ($severityStats | Where-Object { $_.Name -eq 'Critical' }).Count
$htmlSections += "<div class='card'><div class='rate' style='color:#dc3545'>$criticalCount</div><div>Critical 不合规项</div></div>"
$htmlSections += '</div>'

# 类别合规率表格
$htmlSections += '<h2>分类合规率</h2><table><tr><th>类别</th><th>总数</th><th>合规</th><th>不合规</th><th>合规率</th></tr>'
foreach ($cat in $categoryStats) {
$catRateColor = if ($cat.ComplianceRate -ge 80) { 'compliant' } else { 'non-compliant' }
$htmlSections += "<tr><td>$($cat.Category)</td><td>$($cat.Total)</td><td>$($cat.Compliant)</td><td>$($cat.NonCompliant)</td><td class='$catRateColor'>$($cat.ComplianceRate)%</td></tr>"
}
$htmlSections += '</table>'

# 详细结果表格
$htmlSections += '<h2>详细检查结果</h2><table><tr><th>类别</th><th>检查项</th><th>期望值</th><th>实际值</th><th>状态</th><th>严重等级</th></tr>'
foreach ($item in ($allResults | Sort-Object Severity, Category)) {
$statusClass = if ($item.Status -eq 'Compliant') { 'compliant' } else { 'non-compliant' }
$sevColor = $severityColorMap[$item.Severity]
$htmlSections += "<tr><td>$($item.Category)</td><td>$($item.CheckItem)</td><td>$($item.Expected)</td><td>$($item.Actual)</td><td class='$statusClass'>$($item.Status)</td><td style='color:$sevColor'>$($item.Severity)</td></tr>"
}
$htmlSections += '</table>'

# 修复建议
if ($remediations) {
$htmlSections += '<h2>修复建议</h2><table><tr><th>检查项</th><th>严重等级</th><th>修复命令</th></tr>'
foreach ($rem in $remediations) {
$remSevColor = $severityColorMap[$rem.Severity]
$htmlSections += "<tr><td>$($rem.CheckItem)</td><td style='color:$remSevColor'>$($rem.Severity)</td><td><code>$($rem.Remediation)</code></td></tr>"
}
$htmlSections += '</table>'
}

$htmlSections += '</body></html>'

$htmlSections -join "`n" | Out-File -FilePath $OutputPath -Encoding UTF8

# 控制台输出汇总
[PSCustomObject]@{
ReportPath = $OutputPath
TotalChecks = $totalCount
CompliantItems = $compliantCount
NonCompliantItems = $totalCount - $compliantCount
ComplianceRate = "$complianceRate%"
CriticalIssues = $criticalCount
GeneratedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

运行报告生成函数后,控制台输出汇总信息,同时在桌面生成 HTML 报告文件:

1
2
3
4
5
6
7
8
9
10
11
正在执行安全基线检查...
正在执行系统配置审计...
正在生成 HTML 报告...

ReportPath : C:\Users\Admin\Desktop\ComplianceReport_20260327_100530.html
TotalChecks : 24
CompliantItems : 17
NonCompliantItems: 7
ComplianceRate : 70.8%
CriticalIssues : 2
GeneratedAt : 2026-03-27 10:05:30

注意事项

  1. 管理员权限要求:安全基线检查和系统配置审计需要以管理员身份运行 PowerShell。账户策略、审核策略和注册表部分操作均涉及系统级配置,普通用户权限无法获取完整信息。

  2. CIS Benchmark 版本对应:本文中的检查项基于 CIS Microsoft Windows Server Benchmark v3.0 编写。不同版本的 Benchmark 可能存在差异,建议根据实际部署的基线版本调整 Expected 字段中的判定标准。

  3. 生产环境慎用修复命令:报告中给出的修复命令(Remediation 字段)仅供参考。直接在生产环境中执行这些命令可能导致服务中断(如禁用 RemoteRegistry 可能影响远程管理工具),务必先在测试环境验证。

  4. 远程审计扩展:上述函数目前仅审计本地计算机。如需批量审计多台服务器,可以结合 Invoke-Command -ComputerName $servers -ScriptBlock { ... } 实现,但需注意 WinRM 服务的连通性和防火墙配置。

  5. HTML 报告编码问题Out-File -Encoding UTF8 在 Windows PowerShell 5.1 中会生成带 BOM 的 UTF-8 文件,在浏览器中通常能正确显示。如果遇到中文乱码,可尝试使用 [System.IO.File]::WriteAllText($OutputPath, $htmlContent, [System.Text.Encoding]::UTF8) 替代。

  6. 定期审计与趋势跟踪:建议将合规审计脚本纳入计划任务(schtasks),每周或每月自动执行一次,并将报告归档保存。通过对比不同时期的合规率变化,可以追踪安全态势的改善趋势,为管理层的合规决策提供数据支撑。

PowerShell 技能连载 - Azure Private Endpoint 网络隔离

适用于 PowerShell 7.0 及以上版本

在混合云架构中,数据安全的核心诉求之一是确保敏感流量不经过公共互联网。Azure PaaS 服务(如 Storage Account、SQL Database、Key Vault)默认通过公网端点提供服务,虽然传输层使用 TLS 加密,但出于合规要求(如金融行业的 PCI-DSS、医疗行业的 HIPAA),很多企业需要将所有数据路径锁定在私有网络内。Azure Private Endpoint 正是为满足这一需求而设计的网络隔离方案。

Private Endpoint 在虚拟网络中分配一个私有 IP 地址,将 PaaS 服务的入口映射到你的 VNet 内部。通过配合 Private DNS Zone,客户端可以使用原有的服务 FQDN(如 mystorage.blob.core.windows.net)直接解析到私有 IP,无需修改应用代码。整个数据平面流量都在 Microsoft 骨干网内传输,完全不暴露在公网上。

本文将从三个层面展开实战:首先是 Private Endpoint 的创建与 DNS 配置自动化;然后是 Private Link Service 的构建,将内部服务安全地暴露给合作伙伴网络;最后是网络隔离合规验证,通过脚本自动检测配置漂移并生成合规报告。

Private Endpoint 创建与 DNS 配置

创建 Private Endpoint 需要同时处理网络接口和 DNS 解析两个层面。下面的脚本封装了完整的端点创建流程,包括自动关联 Private DNS Zone、配置 A 记录以及验证 DNS 解析结果。

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

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

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

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

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

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

[Parameter(Mandatory = $false)]
[string]$DnsZoneResourceGroup
)

# 获取子网信息,确认子网存在且可用
$vnet = Get-AzVirtualNetwork -Name $VirtualNetworkName -ResourceGroupName $ResourceGroupName
$subnet = Get-AzVirtualNetworkSubnetConfig -Name $SubnetName -VirtualNetwork $vnet

if (-not $subnet) {
Write-Error "子网 $SubnetName 不存在于虚拟网络 $VirtualNetworkName 中"
return
}

Write-Host "目标子网: $($subnet.Name) ($($subnet.AddressPrefix))" -ForegroundColor Cyan

# 构建 Private Link Service 连接配置
$privateLinkConnection = New-AzPrivateLinkServiceConnection `
-Name "$PrivateEndpointName-connection" `
-PrivateLinkServiceId $TargetResourceId `
-GroupId $TargetSubresource

# 创建 Private Endpoint
Write-Host "正在创建 Private Endpoint: $PrivateEndpointName ..." -ForegroundColor Yellow
$pe = New-AzPrivateEndpoint `
-Name $PrivateEndpointName `
-ResourceGroupName $ResourceGroupName `
-Location $vnet.Location `
-Subnet $subnet `
-PrivateLinkServiceConnection $privateLinkConnection `
-ManualRequest:$false

Write-Host "Private Endpoint 创建完成" -ForegroundColor Green

# 自动确定 DNS Zone 名称
$targetResource = Get-AzResource -ResourceId $TargetResourceId
$dnsZoneMap = @{
"Microsoft.Storage/storageAccounts" = "privatelink.blob.core.windows.net"
"Microsoft.Storage/storageAccounts/file" = "privatelink.file.core.windows.net"
"Microsoft.Sql/servers" = "privatelink.database.windows.net"
"Microsoft.KeyVault/vaults" = "privatelink.vaultcore.azure.net"
"Microsoft.ContainerRegistry/registries" = "privatelink.azurecr.io"
"Microsoft.Web/sites" = "privatelink.azurewebsites.net"
}

$dnsZoneName = $dnsZoneMap[$targetResource.ResourceType]
if (-not $dnsZoneName) {
Write-Warning "未找到资源类型 $($targetResource.ResourceType) 的默认 DNS Zone,请手动指定"
return $pe
}

# 查找或创建 Private DNS Zone
$dnsRg = if ($DnsZoneResourceGroup) { $DnsZoneResourceGroup } else { $ResourceGroupName }
$dnsZone = Get-AzPrivateDnsZone -ResourceGroupName $dnsRg -Name $dnsZoneName -ErrorAction SilentlyContinue

if (-not $dnsZone) {
Write-Host "创建 Private DNS Zone: $dnsZoneName" -ForegroundColor Yellow
$dnsZone = New-AzPrivateDnsZone -ResourceGroupName $dnsRg -Name $dnsZoneName
}

# 创建 Virtual Network Link,将 DNS Zone 关联到 VNet
$vnetLinkName = "link-$($VirtualNetworkName)-$($dnsZoneName -replace '\.','')"
$existingLink = Get-AzPrivateDnsVirtualNetworkLink `
-ResourceGroupName $dnsRg -ZoneName $dnsZoneName -Name $vnetLinkName -ErrorAction SilentlyContinue

if (-not $existingLink) {
Write-Host "关联 DNS Zone 到虚拟网络: $VirtualNetworkName" -ForegroundColor Yellow
$null = New-AzPrivateDnsVirtualNetworkLink `
-ResourceGroupName $dnsRg `
-ZoneName $dnsZoneName `
-Name $vnetLinkName `
-VirtualNetworkId $vnet.Id
}

# 获取 Private Endpoint 的网络接口 IP 地址
$networkInterface = Get-AzNetworkInterface -ResourceId $pe.NetworkInterfaces[0].Id
$privateIpAddress = $networkInterface.IpConfigurations[0].PrivateIpAddress

Write-Host "Private Endpoint IP: $privateIpAddress" -ForegroundColor Green

# 创建 DNS A 记录
$dnsRecordName = $targetResource.Name
$existingRecord = Get-AzPrivateDnsRecordSet `
-ResourceGroupName $dnsRg -ZoneName $dnsZoneName -Name $dnsRecordName -RecordType A -ErrorAction SilentlyContinue

if (-not $existingRecord) {
$null = New-AzPrivateDnsRecordSet `
-ResourceGroupName $dnsRg `
-ZoneName $dnsZoneName `
-Name $dnsRecordName `
-RecordType A `
-Ttl 300 `
-PrivateDnsRecord (New-AzPrivateDnsRecordConfig -IPv4Address $privateIpAddress)
Write-Host "DNS A 记录已创建: $dnsRecordName -> $privateIpAddress" -ForegroundColor Green
}

return @{
PrivateEndpoint = $pe
PrivateIpAddress = $privateIpAddress
DnsZoneName = $dnsZoneName
DnsRecordName = $dnsRecordName
}
}

# 示例:为 Storage Account 创建 Private Endpoint
$result = New-AzPrivateEndpointWithDns `
-ResourceGroupName "rg-production" `
-VirtualNetworkName "vnet-prod-eastus" `
-SubnetName "snet-endpoints" `
-PrivateEndpointName "pe-prod-storage" `
-TargetResourceId "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production/providers/Microsoft.Storage/storageAccounts/stproddata001" `
-TargetSubresource "blob"

# 验证 DNS 解析
$storageFqdn = "stproddata001.blob.core.windows.net"
$resolved = Resolve-DnsName -Name $storageFqdn -DnsOnly -ErrorAction SilentlyContinue
Write-Host "`nDNS 解析验证: $storageFqdn -> $($resolved.IPAddress -join ', ')" -ForegroundColor Cyan

执行结果示例:

1
2
3
4
5
6
7
8
9
目标子网: snet-endpoints (10.0.2.0/24)
正在创建 Private Endpoint: pe-prod-storage ...
Private Endpoint 创建完成
创建 Private DNS Zone: privatelink.blob.core.windows.net
关联 DNS Zone 到虚拟网络: vnet-prod-eastus
Private Endpoint IP: 10.0.2.4
DNS A 记录已创建: stproddata001 -> 10.0.2.4

DNS 解析验证: stproddata001.blob.core.windows.net -> 10.0.2.4

从输出可以看到,Storage Account 的 FQDN 已经解析到 VNet 内的私有 IP 10.0.2.4,而不是公网地址。这意味着同一 VNet 内的所有虚拟机访问该 Storage Account 时,流量不会离开 Microsoft 骨干网。DNS Zone 的自动关联确保了无需修改应用配置,原有的连接字符串即可直接工作。

Private Link Service 是 Private Endpoint 的镜像能力——它允许你将 VNet 内部运行的服务(如 API 服务、中间件)通过私有连接暴露给其他 Azure 租户或订阅。下面的脚本展示了如何创建 Private Link Service,配置 NAT 规则和访问控制策略。

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
function New-AzPrivateLinkServiceAutomation {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ResourceGroupName,

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

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

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

[Parameter(Mandatory = $false)]
[string[]]$AllowedSubscriptions = @(),

[Parameter(Mandatory = $false)]
[bool]$EnableProxyProtocol = $false,

[Parameter(Mandatory = $false)]
[string]$Visibility = "AutoApproval"
)

# 获取内部负载均衡器和前端 IP 配置
$lb = Get-AzLoadBalancer -Name $LoadBalancerName -ResourceGroupName $ResourceGroupName
$frontendIp = $lb.FrontendIpConfigurations | Where-Object { $_.Name -eq $FrontendIpConfigName }

if (-not $frontendIp) {
Write-Error "未找到前端 IP 配置: $FrontendIpConfigName"
return
}

Write-Host "负载均衡器: $($lb.Name)" -ForegroundColor Cyan
Write-Host "前端 IP: $($frontendIp.PrivateIpAddress)" -ForegroundColor Cyan

# 创建 Private Link Service 的 IP 配置
$ipConfig = New-AzPrivateLinkServiceIpConfig `
-Name "$ServiceName-ipconfig" `
-PrivateIpAddress "10.0.3.10" `
-Subnet (Get-AzVirtualNetwork -ResourceGroupName $ResourceGroupName |
Get-AzVirtualNetworkSubnetConfig -Name "snet-privatelink")

# 构建访问控制参数
$autoApproval = $null
$visibilityList = @()

if ($Visibility -eq "AutoApproval" -and $AllowedSubscriptions.Count -gt 0) {
# 指定的订阅自动批准连接请求
$autoApproval = $AllowedSubscriptions
$visibilityList = $AllowedSubscriptions
Write-Host "访问策略: 自动批准 (订阅白名单: $($AllowedSubscriptions.Count) 个)" -ForegroundColor Yellow
}
elseif ($Visibility -eq "Private") {
# 仅对特定订阅可见
$visibilityList = $AllowedSubscriptions
Write-Host "访问策略: 私有可见 (订阅白名单: $($AllowedSubscriptions.Count) 个)" -ForegroundColor Yellow
}
else {
# 对所有订阅可见
Write-Host "访问策略: 公开可见 (需手动批准连接)" -ForegroundColor Yellow
}

# 创建 Private Link Service
Write-Host "正在创建 Private Link Service: $ServiceName ..." -ForegroundColor Yellow

$plsParams = @{
Name = $ServiceName
ResourceGroupName = $ResourceGroupName
Location = $lb.Location
LoadBalancerFrontendIpConfiguration = $frontendIp
IpConfiguration = $ipConfig
EnableProxyProtocol = $EnableProxyProtocol
}

if ($visibilityList.Count -gt 0) {
$plsParams["Visibility"] = $visibilityList
}

if ($autoApproval) {
$plsParams["AutoApproval"] = $autoApproval
}

$pls = New-AzPrivateLinkService @plsParams

Write-Host "Private Link Service 创建完成" -ForegroundColor Green
Write-Host "服务别名 (Alias): $($pls.Alias)" -ForegroundColor Green
Write-Host "服务资源 ID: $($pls.Id)" -ForegroundColor DarkGray

# 查看待处理的连接请求
$connections = Get-AzPrivateEndpointConnection -PrivateLinkServiceId $pls.Id -ErrorAction SilentlyContinue
if ($connections) {
Write-Host "`n当前连接请求:" -ForegroundColor Cyan
$connections | Format-Table Name, PrivateEndpointId, ConnectionState, Description -AutoSize
}

return $pls
}

# 示例:创建 Private Link Service,暴露内部 API 给合作伙伴
$partnerSubscriptions = @(
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
"c3d4e5f6-a7b8-9012-cdef-123456789012"
)

$pls = New-AzPrivateLinkServiceAutomation `
-ResourceGroupName "rg-production" `
-ServiceName "pls-internal-api" `
-LoadBalancerName "lb-internal-api" `
-FrontendIpConfigName "feip-api-v1" `
-AllowedSubscriptions $partnerSubscriptions `
-EnableProxyProtocol $false `
-Visibility "AutoApproval"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
负载均衡器: lb-internal-api
前端 IP: 10.0.3.5
访问策略: 自动批准 (订阅白名单: 2 个)
正在创建 Private Link Service: pls-internal-api ...
Private Link Service 创建完成
服务别名 (Alias): pls-internal-api.7b8c9d0e-eastus.azure.privatelinkservice
服务资源 ID: /subscriptions/a1b2c3d4-.../Microsoft.Network/privateLinkServices/pls-internal-api

当前连接请求:
Name PrivateEndpointId ConnectionState Description
---- ----------------- --------------- -----------
pe-partner-a-eastus /subscriptions/b2c3d4e5-.../pe-partner Approved
pe-partner-b-eastus /subscriptions/c3d4e5f6-.../pe-partner Pending

Private Link Service 的核心价值在于安全可控的跨租户连接。通过 AutoApproval 机制,指定的合作伙伴订阅(如 b2c3d4e5-...)创建的 Private Endpoint 连接会被自动批准,无需人工干预;而 Pending 状态的连接则需要运维团队手动审核。Alias 是消费方创建 Private Endpoint 时使用的唯一标识,不需要暴露你的资源 ID。EnableProxyProtocol 参数在需要获取客户端真实 IP 的场景中开启,但会增加少量网络开销。

网络隔离验证与合规检查

Private Endpoint 部署完成后,持续验证网络隔离状态是合规审计的基本要求。下面的脚本实现了自动化合规检查,覆盖 DNS 解析、公网端点状态、网络路由和访问策略等多个维度,并生成结构化的合规报告。

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
function Test-AzPrivateEndpointCompliance {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ResourceGroupName,

[Parameter(Mandatory = $false)]
[string]$OutputPath = "$HOME/PrivateEndpointCompliance_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
)

$results = @()
$now = Get-Date
$compliantCount = 0
$nonCompliantCount = 0

Write-Host ("=" * 70) -ForegroundColor Cyan
Write-Host "Private Endpoint 网络隔离合规检查" -ForegroundColor Cyan
Write-Host "检查时间: $($now.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Host "资源组: $ResourceGroupName"
Write-Host ("=" * 70) -ForegroundColor Cyan

# 获取资源组内所有 Private Endpoint
$privateEndpoints = Get-AzPrivateEndpoint -ResourceGroupName $ResourceGroupName
Write-Host "`n发现 $($privateEndpoints.Count) 个 Private Endpoint" -ForegroundColor Yellow

foreach ($pe in $privateEndpoints) {
$checkResult = [ordered]@{
Name = $pe.Name
ResourceId = $pe.Id
Location = $pe.Location
CheckTime = $now.ToString("yyyy-MM-dd HH:mm:ss")
Status = "Compliant"
Issues = @()
}

Write-Host "`n--- 检查: $($pe.Name) ---" -ForegroundColor White

# 检查 1: 连接状态是否为 Approved
foreach ($conn in $pe.PrivateLinkServiceConnections) {
$connStatus = $conn.PrivateLinkServiceConnectionState.Status
if ($connStatus -ne "Approved") {
$checkResult.Status = "NonCompliant"
$checkResult.Issues += "连接状态非 Approved: $connStatus (服务: $($conn.PrivateLinkServiceId))"
Write-Host " [FAIL] 连接状态: $connStatus" -ForegroundColor Red
}
else {
Write-Host " [PASS] 连接状态: Approved" -ForegroundColor Green
}
}

# 检查 2: 关联的网络接口是否具有私有 IP
$nic = Get-AzNetworkInterface -ResourceId $pe.NetworkInterfaces[0].Id
$privateIp = $nic.IpConfigurations[0].PrivateIpAddress
if ($privateIp -match "^10\.|^172\.(1[6-9]|2[0-9]|3[01])\.|^192\.168\.") {
Write-Host " [PASS] 私有 IP 地址: $privateIp (RFC1918)" -ForegroundColor Green
}
else {
$checkResult.Status = "NonCompliant"
$checkResult.Issues += "IP 地址不在 RFC1918 私有范围: $privateIp"
Write-Host " [FAIL] IP 地址不在 RFC1918 范围: $privateIp" -ForegroundColor Red
}

# 检查 3: 目标资源的公网端点是否已禁用
$targetResourceId = $pe.PrivateLinkServiceConnections[0].PrivateLinkServiceId
$targetResource = Get-AzResource -ResourceId $targetResourceId
$resourceType = $targetResource.ResourceType

$publicAccessEnabled = $false
switch ($resourceType) {
"Microsoft.Storage/storageAccounts" {
$storage = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $targetResource.Name
$publicAccessEnabled = $storage.NetworkRuleSet.DefaultAction -eq "Allow"
}
"Microsoft.KeyVault/vaults" {
$kv = Get-AzKeyVault -ResourceGroupName $ResourceGroupName -VaultName $targetResource.Name
$publicAccessEnabled = $kv.NetworkAcls.DefaultAction -eq "Allow"
}
"Microsoft.Sql/servers" {
$sql = Get-AzSqlServer -ResourceGroupName $ResourceGroupName -ServerName $targetResource.Name
$publicAccessEnabled = $sql.PublicNetworkAccess -eq "Enabled"
}
}

if ($publicAccessEnabled) {
$checkResult.Status = "NonCompliant"
$checkResult.Issues += "目标资源的公网访问未禁用 (类型: $resourceType)"
Write-Host " [FAIL] 公网端点未禁用 ($resourceType)" -ForegroundColor Red
}
else {
Write-Host " [PASS] 公网端点已禁用 ($resourceType)" -ForegroundColor Green
}

# 检查 4: DNS 解析是否指向私有 IP
$dnsZoneMap = @{
"Microsoft.Storage/storageAccounts" = "{0}.blob.core.windows.net"
"Microsoft.KeyVault/vaults" = "{0}.vault.azure.net"
"Microsoft.Sql/servers" = "{0}.database.windows.net"
}

$fqdnTemplate = $dnsZoneMap[$resourceType]
if ($fqdnTemplate) {
$fqdn = $fqdnTemplate -f $targetResource.Name
$resolved = Resolve-DnsName -Name $fqdn -DnsOnly -ErrorAction SilentlyContinue
$resolvedIp = if ($resolved) { $resolved[0].IPAddress } else { "未解析" }

if ($resolvedIp -eq $privateIp) {
Write-Host " [PASS] DNS 解析: $fqdn -> $resolvedIp" -ForegroundColor Green
}
else {
$checkResult.Status = "NonCompliant"
$checkResult.Issues += "DNS 解析不匹配: $fqdn 解析为 $resolvedIp,应为 $privateIp"
Write-Host " [FAIL] DNS 解析不匹配: $resolvedIp (应为 $privateIp)" -ForegroundColor Red
}
}

# 汇总
if ($checkResult.Status -eq "Compliant") {
$compliantCount++
}
else {
$nonCompliantCount++
}

$results += [PSCustomObject]$checkResult
}

# 生成合规报告
$report = [ordered]@{
ReportTime = $now.ToString("yyyy-MM-dd HH:mm:ss")
ResourceGroup = $ResourceGroupName
TotalEndpoints = $privateEndpoints.Count
Compliant = $compliantCount
NonCompliant = $nonCompliantCount
ComplianceRate = if ($privateEndpoints.Count -gt 0) {
[math]::Round(($compliantCount / $privateEndpoints.Count) * 100, 1)
} else { 100 }
Details = $results
}

$reportJson = $report | ConvertTo-Json -Depth 5
$reportJson | Out-File -FilePath $OutputPath -Encoding utf8

Write-Host "`n" ("=" * 70) -ForegroundColor Cyan
Write-Host "合规检查完成" -ForegroundColor Cyan
Write-Host " 通过: $compliantCount / $($privateEndpoints.Count)" -ForegroundColor Green
Write-Host " 不通过: $nonCompliantCount / $($privateEndpoints.Count)" -ForegroundColor Red
Write-Host " 合规率: $($report.ComplianceRate)%" -ForegroundColor Cyan
Write-Host " 报告已保存: $OutputPath" -ForegroundColor White
Write-Host ("=" * 70) -ForegroundColor Cyan

return $report
}

# 执行合规检查
$compliance = Test-AzPrivateEndpointCompliance `
-ResourceGroupName "rg-production" `
-OutputPath "$HOME/PrivateEndpointCompliance_20260326.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
======================================================================
Private Endpoint 网络隔离合规检查
检查时间: 2026-03-26 08:30:00
资源组: rg-production
======================================================================

发现 4 个 Private Endpoint

--- 检查: pe-prod-storage ---
[PASS] 连接状态: Approved
[PASS] 私有 IP 地址: 10.0.2.4 (RFC1918)
[PASS] 公网端点已禁用 (Microsoft.Storage/storageAccounts)
[PASS] DNS 解析: stproddata001.blob.core.windows.net -> 10.0.2.4

--- 检查: pe-prod-keyvault ---
[PASS] 连接状态: Approved
[PASS] 私有 IP 地址: 10.0.2.5 (RFC1918)
[PASS] 公网端点已禁用 (Microsoft.KeyVault/vaults)
[PASS] DNS 解析: kv-prod-eastus.vault.azure.net -> 10.0.2.5

--- 检查: pe-prod-sql ---
[PASS] 连接状态: Approved
[PASS] 私有 IP 地址: 10.0.2.6 (RFC1918)
[FAIL] 公网端点未禁用 (Microsoft.Sql/servers)
[PASS] DNS 解析: sql-prod-eastus.database.windows.net -> 10.0.2.6

--- 检查: pe-staging-storage ---
[PASS] 连接状态: Approved
[PASS] 私有 IP 地址: 10.0.2.10 (RFC1918)
[PASS] 公网端点已禁用 (Microsoft.Storage/storageAccounts)
[FAIL] DNS 解析不匹配: 20.150.12.34 (应为 10.0.2.10)

======================================================================
合规检查完成
通过: 2 / 4
不通过: 2 / 4
合规率: 50.0%
报告已保存: /home/admin/PrivateEndpointCompliance_20260326.json
======================================================================

合规报告清晰地暴露了两个风险点:pe-prod-sql 的 SQL Server 公网端点未禁用,意味着虽然 Private Endpoint 已配置,但数据仍可能通过公网路径被访问,网络隔离形同虚设;pe-staging-storage 的 DNS 解析指向公网 IP 20.150.12.34,说明 Private DNS Zone 配置不完整或 VNet Link 缺失。合规率 50% 表明环境存在明显的配置漂移,建议将此检查集成到 Azure DevOps Pipeline 中,在每次基础设施变更后自动运行。

注意事项

  1. 子网委派要求:Private Endpoint 所在的子网必须禁用 privateEndpointNetworkPolicies 属性。创建子网时需要显式执行 Set-AzVirtualNetworkSubnetConfig -PrivateEndpointNetworkPoliciesFlag Disabled,否则 Private Endpoint 创建会失败。建议在 Terraform 或 Bicep 模板中将此设置标准化。

  2. DNS 解析范围:Private DNS Zone 的 A 记录仅在关联的 VNet 内生效。如果本地数据中心通过 ExpressRoute 或 VPN 连接到 Azure VNet,需要额外配置 DNS 条件转发器或自定义 DNS 服务器,将 Azure 服务域名转发到 Azure 提供的 DNS 代理(168.63.129.16)进行解析。

  3. 连接审批流程:当 Private Endpoint 的连接请求需要手动审批时,服务提供方会收到 Azure Activity Log 事件。可以使用 Approve-AzPrivateEndpointConnectionDeny-AzPrivateEndpointConnection cmdlet 在脚本中自动处理审批流程,结合 Azure Automation Runbook 实现审批自动化。

  4. 配额与限制:每个 Private Endpoint 占用子网中的一个 IP 地址,单个子网最多支持一定数量的 Private Endpoint(具体取决于子网大小)。此外,每个订阅的 Private Endpoint 数量有默认配额限制,大规模部署前请通过 Azure Portal 提交配额提升请求。

  5. 公网端点必须显式禁用:仅创建 Private Endpoint 并不能阻止公网访问。对于 Storage Account,需要将网络规则集的 DefaultAction 设为 Deny;对于 SQL Server,需要将 PublicNetworkAccess 设为 Disabled。合规检查脚本中已包含此验证,建议在 CI/CD 流程中强制执行。

  6. 成本考量:Private Endpoint 按小时计费(约 $0.01/小时),加上数据处理的出入站费用。在大规模部署场景下(数十个 PaaS 服务 + 多个区域),Private Endpoint 成本可能显著增加。建议按环境区分策略——生产环境全面使用 Private Endpoint,开发和测试环境可选择性使用或使用 Service Endpoint 作为替代。

PowerShell 技能连载 - CIM/WMI 高级查询

适用于 PowerShell 5.1 及以上版本

在 Windows 系统管理中,CIM(Common Information Model)和 WMI(Windows Management Instrumentation)是获取系统底层信息的核心接口。日常运维中我们经常使用 Get-CimInstance 来查询服务状态、磁盘信息等,但这仅仅是冰山一角。CIM 提供的高级查询能力——WQL 查询语言、关联遍历、事件订阅、远程会话管理——可以让你对整个 Windows 基础设施进行深度洞察。

对于管理数十台甚至上百台服务器的运维团队来说,掌握 CIM 高级查询技术意味着可以用更少的代码获取更精确的系统信息。通过 WQL 的 WHERE 子句在服务端完成数据过滤,比在客户端用 Where-Object 筛选效率高出数倍;通过 CIM 事件订阅可以实现文件变更监控、进程启停追踪等实时告警;通过 CIM 会话(CimSession)可以高效地批量管理远程主机。

本文将从 WQL 查询优化、硬件资产清单采集、CIM 事件订阅与远程管理三个维度,带你深入挖掘 CIM/WMI 的高级用法。

WQL 查询与性能优化

WQL(WMI Query Language)是专用于 WMI/CIM 的查询语言,语法类似 SQL,支持 SELECT、FROM、WHERE 等子句。很多用户习惯先获取全部实例再用 PowerShell 过滤,这在大规模查询时会产生大量不必要的数据传输。正确的做法是把过滤条件交给 WQL,让 WMI 服务端只返回符合条件的记录。

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
# ============================================================
# WQL 查询性能优化示例
# ============================================================

# 1. 基本查询:获取所有磁盘信息(低效写法)
$allDisks = Get-CimInstance -ClassName Win32_LogicalDisk
$filtered = $allDisks | Where-Object { $_.DriveType -eq 3 -and $_.FreeSpace -lt 1GB }
$filtered | Select-Object DeviceID, `
@{N='TotalGB';E={[math]::Round($_.Size/1GB,2)}}, `
@{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,2)}}, `
@{N='UsedPct';E={[math]::Round(($_.Size - $_.FreeSpace)/$_.Size*100,1)}}

# 2. WQL 优化写法:服务端过滤,仅返回本地磁盘且空间不足的记录
$query = "SELECT DeviceID, Size, FreeSpace, VolumeName " +
"FROM Win32_LogicalDisk " +
"WHERE DriveType=3 AND FreeSpace < 1073741824"
$wqlResult = Get-CimInstance -Query $query
$wqlResult | Select-Object DeviceID, VolumeName, `
@{N='TotalGB';E={[math]::Round($_.Size/1GB,2)}}, `
@{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,2)}}

# 3. 仅选择需要的属性(减少返回数据量)
Get-CimInstance -ClassName Win32_Process -Property Name, ProcessId, WorkingSetSize |
Sort-Object WorkingSetSize -Descending |
Select-Object -First 10 Name, ProcessId,
@{N='MemMB';E={[math]::Round($_.WorkingSetSize/1MB,1)}}

# 4. LIKE 模糊查询:查找所有包含 "SQL" 的服务
Get-CimInstance -Query `
"SELECT Name, State, StartMode FROM Win32_Service WHERE Name LIKE '%SQL%'" |
Select-Object Name, State, StartMode

# 5. 关联查询:获取磁盘分区与逻辑磁盘的对应关系
Get-CimInstance -Query `
"ASSOCIATORS OF {Win32_DiskDrive.DeviceID='\\\\.\\PHYSICALDRIVE0'} " +
"WHERE ResultClass=Win32_DiskPartition" |
Select-Object DeviceID, Type, Size, Bootable

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DeviceID VolumeName TotalGB FreeGB UsedPct
-------- ---------- ------- ------ -------
C: System 256.00 0.85 99.7
E: Data 500.00 0.32 99.9

Name ProcessId MemMB
---- --------- -----
chrome 12840 812.3
devenv 9456 624.7
Code 10234 418.2

Name State StartMode
---- ----- ---------
MSSQLSERVER Running Auto
SQLSERVERAGENT Stopped Manual
SQLBrowser Running Auto
SQLTELEMETRY Running Auto

DeviceID Type Size Bootable
-------- ---- ---- --------
Disk #0, Part. 0 Installable File Sys. 524288000 True
Disk #0, Part. 1 GPT: Basic Data 274877906944 False

上面的代码展示了五种不同层次的查询优化技巧。第一种是”反面教材”——先全量拉取再客户端过滤;第二种用 WQL 的 WHERE 子句在服务端完成过滤,显著减少网络传输和内存占用;第三种通过 -Property 参数限定返回字段;第四种演示 WQL 的 LIKE 模糊匹配;第五种用 ASSOCIATORS OF 语句实现跨类关联查询,可以沿着 CIM 模型的关系树遍历关联对象。

硬件资产清单采集

硬件资产清点是 IT 运维中的高频需求。通过 CIM 可以采集 CPU、内存、磁盘、网络适配器、BIOS、操作系统等完整的硬件和软件信息。下面的脚本将多种 CIM 查询组合在一起,生成一份结构化的资产报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# ============================================================
# 完整硬件资产清单采集脚本
# ============================================================

function Get-SystemInventory {
[CmdletBinding()]
param(
[string]$ComputerName = $env:COMPUTERNAME
)

$report = [ordered]@{}
$report['ComputerName'] = $ComputerName
$report['ScanTime'] = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

# 1. 操作系统信息
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName
$report['OSName'] = $os.Caption
$report['OSVersion'] = $os.Version
$report['OSArch'] = $os.OSArchitecture
$report['InstallDate']= $os.InstallDate.ToString('yyyy-MM-dd')
$report['LastBoot'] = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss')
$report['UptimeDays'] = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1)
$report['TotalMemGB'] = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$report['FreeMemGB'] = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$report['MemUsagePct']= [math]::Round(
($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) /
$os.TotalVisibleMemorySize * 100, 1)

# 2. CPU 信息
$cpus = Get-CimInstance -ClassName Win32_Processor -ComputerName $ComputerName
$report['CPUCount'] = $cpus.Count
$report['CPUDetails'] = $cpus | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
Cores = "$($_.NumberOfCores)C/$($_.NumberOfLogicalProcessors)T"
MaxClock = "$([math]::Round($_.MaxClockSpeed/1000,1)) GHz"
L2Cache = "$($_.L2CacheSize) KB"
L3Cache = "$($_.L3CacheSize) KB"
}
}

# 3. 磁盘信息
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" `
-ComputerName $ComputerName
$report['Disks'] = $disks | ForEach-Object {
[PSCustomObject]@{
Drive = $_.DeviceID
Label = $_.VolumeName
TotalGB = [math]::Round($_.Size/1GB, 2)
FreeGB = [math]::Round($_.FreeSpace/1GB, 2)
UsedPct = [math]::Round(($_.Size - $_.FreeSpace)/$_.Size*100, 1)
FileSystem= $_.FileSystem
}
}

# 4. 网络适配器(仅物理适配器)
$query = "SELECT Name, MACAddress, NetConnectionID, Speed, NetEnabled " +
"FROM Win32_NetworkAdapter WHERE PhysicalAdapter=True AND NetEnabled=True"
$nics = Get-CimInstance -Query $query -ComputerName $ComputerName
$report['NetworkAdapters'] = $nics | ForEach-Object {
[PSCustomObject]@{
Interface = $_.NetConnectionID
MAC = $_.MACAddress
SpeedMbps = [math]::Round($_.Speed / 1000000, 0)
}
}

# 5. 已安装软件(含版本和安装日期)
$software = Get-CimInstance -ClassName Win32_Product -ComputerName $ComputerName |
Sort-Object Name |
Select-Object -First 30 Name, Version, @{N='InstallDate';E={
if ($_.InstallDate) { $_.InstallDate.ToString('yyyy-MM-dd') } else { 'N/A' }
}}
$report['InstalledSoftware'] = $software

# 6. BIOS 和序列号(用于资产登记)
$bios = Get-CimInstance -ClassName Win32_BIOS -ComputerName $ComputerName
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ComputerName $ComputerName
$report['BIOSVersion'] = $bios.SMBIOSBIOSVersion
$report['SerialNumber'] = $bios.SerialNumber
$report['Manufacturer'] = $cs.Manufacturer
$report['Model'] = $cs.Model

return [PSCustomObject]$report
}

# 执行采集
$inventory = Get-SystemInventory

# 输出摘要
$inventory | Select-Object ComputerName, OSName, OSVersion, OSArch, `
SerialNumber, Manufacturer, Model, TotalMemGB, MemUsagePct, UptimeDays

# 输出磁盘详情
$inventory.Disks | Format-Table -AutoSize

# 输出网络适配器
$inventory.NetworkAdapters | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ComputerName : DESKTOP-PROD01
OSName : Microsoft Windows 11 Enterprise
OSVersion : 10.0.26100
OSArch : 64-bit
SerialNumber : PF4K7X2R
Manufacturer : Dell Inc.
Model : Precision 5680
TotalMemGB : 32.00
MemUsagePct : 67.3
UptimeDays : 12.5

Drive Label TotalGB FreeGB UsedPct FileSystem
----- ----- ------- ------ ------- ----------
C: System 256.00 98.45 61.5 NTFS
D: Data 512.00 187.32 63.4 NTFS
E: Backup 1024.00 891.50 13.0 ReFS

Interface MAC SpeedMbps
--------- --- ---------
Ethernet 4C:7A:DB:12:34:56 1000
Wi-Fi 4C:7A:DB:12:34:57 867

这个 Get-SystemInventory 函数封装了六大类信息的采集逻辑:操作系统概览(含运行时间和内存使用率)、CPU 详细参数(核心数、频率、缓存)、磁盘分区与使用率、活跃的网络适配器及带宽、已安装软件清单、BIOS 序列号和硬件型号。在实际运维中,你可以将此函数配合远程 CIM 会话批量采集多台主机的资产数据,汇总到 CSV 或数据库中。

注意 Win32_Product 类的查询会比较慢(它会触发 MSI 重新配置检查),在生产环境中如果只需要软件清单,建议改用注册表路径 HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ 来读取,效率会高很多。

CIM 事件订阅与远程管理

CIM 事件订阅是 WMI 的一项强大功能,可以监控系统中特定事件的发生,例如进程创建、文件变更、服务状态变化等。配合 CimSession,我们可以实现对远程主机的高效批量管理。下面的代码演示了三种典型的 CIM 高级应用场景。

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
# ============================================================
# CIM 事件订阅与远程管理
# ============================================================

# ---- 场景 1:进程创建事件监控 ----

# 注册 WMI 事件查询,每秒轮询一次,监控 notepad.exe 启动
$query = "SELECT * FROM __InstanceCreationEvent WITHIN 1 " +
"WHERE TargetInstance ISA 'Win32_Process' " +
"AND TargetInstance.Name = 'notepad.exe'"

$job = Register-CimIndicationEvent -Query $query -SourceIdentifier 'MonitorNotepad'

Write-Host "已注册 notepad.exe 进程监控事件..." -ForegroundColor Green
Write-Host "请打开记事本触发事件,等待 60 秒..." -ForegroundColor Yellow

# 等待事件(60 秒超时)
$event = Wait-Event -SourceIdentifier 'MonitorNotepad' -Timeout 60

if ($event) {
$proc = $event.SourceEventArgs.NewEvent.TargetInstance
Write-Host "`n[检测到新进程]" -ForegroundColor Cyan
Write-Host " 进程名: $($proc.Name)"
Write-Host " PID: $($proc.ProcessId)"
Write-Host " 命令行: $($proc.CommandLine)"
Write-Host " 创建时间: $(Get-Date -Format 'HH:mm:ss')"
$event | Remove-Event
} else {
Write-Host "`n60 秒内未检测到 notepad.exe 启动" -ForegroundColor DarkGray
}

# 清理事件订阅
Get-EventSubscriber -SourceIdentifier 'MonitorNotepad' -ErrorAction SilentlyContinue |
Unregister-Event
Remove-Job -Name 'MonitorNotepad' -Force -ErrorAction SilentlyContinue

# ---- 场景 2:远程 CIM 会话管理 ----

# 创建到多台主机的 CIM 会话(使用当前凭据)
$computers = @('SRV-DB01', 'SRV-WEB01', 'SRV-FILE01')

$sessions = $computers | ForEach-Object {
$session = $null
try {
$session = New-CimSession -ComputerName $_ -ErrorAction Stop
Write-Host "[OK] 已连接: $_" -ForegroundColor Green
} catch {
Write-Host "[FAIL] 连接失败: $_ - $($_.Exception.Message)" -ForegroundColor Red
}
$session
} | Where-Object { $_ -ne $null }

# 批量查询磁盘空间
$sessions | ForEach-Object {
$computer = $_.ComputerName
Get-CimInstance -CimSession $_ -Query `
"SELECT DeviceID, Size, FreeSpace FROM Win32_LogicalDisk WHERE DriveType=3" |
ForEach-Object {
[PSCustomObject]@{
Server = $computer
Drive = $_.DeviceID
TotalGB = [math]::Round($_.Size/1GB, 2)
FreeGB = [math]::Round($_.FreeSpace/1GB, 2)
UsedPct = [math]::Round(($_.Size - $_.FreeSpace)/$_.Size*100, 1)
}
}
} | Format-Table -AutoSize

# 批量查询服务状态
$serviceName = 'WinRM'
$sessions | ForEach-Object {
Get-CimInstance -CimSession $_ -Query `
"SELECT State, StartMode FROM Win32_Service WHERE Name='$serviceName'" |
ForEach-Object {
[PSCustomObject]@{
Server = $_PSItem.PSComputerName
Service = $serviceName
State = $_.State
Start = $_.StartMode
}
}
} | Format-Table -AutoSize

# 清理会话
$sessions | Remove-CimSession
Write-Host "`n已清理所有 CIM 会话" -ForegroundColor Green

# ---- 场景 3:文件变更实时监控 ----

# 监控指定目录下的文件创建事件(利用 CIM 的 __InstanceCreationEvent)
$watchPath = 'C:\Temp\Watch'
if (-not (Test-Path $watchPath)) { New-Item -Path $watchPath -ItemType Directory | Out-Null }

# 使用 .NET FileSystemWatcher 作为轻量替代(CIM 事件开销较大)
$watcher = [System.IO.FileSystemWatcher]::new($watchPath)
$watcher.Filter = '*.*'
$watcher.EnableRaisingEvents = $true

# 注册文件创建事件
$action = {
$name = $Event.SourceEventArgs.Name
$changeType = $Event.SourceEventArgs.ChangeType
$time = $Event.TimeGenerated.ToString('HH:mm:ss.fff')
Write-Host "[$time] $changeType : $name" -ForegroundColor Yellow
}

Register-ObjectEvent -InputObject $watcher -EventName Created -Action $action |
Out-Null

Write-Host "正在监控目录: $watchPath" -ForegroundColor Green
Write-Host "创建文件触发事件,30 秒后自动停止..." -ForegroundColor Yellow

Start-Sleep -Seconds 30

# 清理
$watcher.EnableRaisingEvents = $false
Get-EventSubscriber | Unregister-Event
Get-Job | Remove-Job -Force

执行结果示例:

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
已注册 notepad.exe 进程监控事件...
请打开记事本触发事件,等待 60 秒...

[检测到新进程]
进程名: notepad.exe
PID: 28456
命令行: "C:\Windows\System32\notepad.exe" C:\Temp\test.txt
创建时间: 14:23:17

[OK] 已连接: SRV-DB01
[OK] 已连接: SRV-WEB01
[FAIL] 连接失败: SRV-FILE01 - The RPC server is unavailable.

Server Drive TotalGB FreeGB UsedPct
------ ----- ------- ------ -------
SRV-DB01 C: 256.00 45.30 82.3
SRV-DB01 D: 1024.00 312.50 69.5
SRV-WEB01 C: 128.00 89.20 30.3

Server Service State Start
------ ------- ----- -----
SRV-DB01 WinRM Running Auto
SRV-WEB01 WinRM Running Auto

已清理所有 CIM 会话

正在监控目录: C:\Temp\Watch
创建文件触发事件,30 秒后自动停止...
[14:25:03.127] Created : report.csv
[14:25:08.342] Created : config.json
[14:25:15.891] Created : backup.zip

第一个场景展示了 CIM 事件订阅的核心模式:通过 Register-CimIndicationEvent 注册一个后台事件查询任务,WMI 服务每秒(WITHIN 1)检查一次是否有新的 notepad.exe 进程出现,事件触发后通过 Wait-Event 接收详细信息。这种模式可以扩展到监控服务停止、磁盘空间低于阈值等运维告警场景。

第二个场景演示了 CimSession 的批量远程管理能力。与每次调用 Get-CimInstance -ComputerName 不同,CimSession 会建立持久的连接通道,后续的多个查询复用同一会话,避免了反复认证和连接建立的开销。在管理大量服务器时,这种方式的效率提升非常明显。

第三个场景中,鉴于 CIM 文件监控事件在系统开销上较重,代码也演示了使用 .NET 的 FileSystemWatcher 作为轻量级替代方案,这在实际生产中更为实用。

注意事项

  1. 优先使用 Get-CimInstance 而非 Get-WmiObjectGet-WmiObject 在 PowerShell 3.0 后已被标记为过时,Get-CimInstance 使用标准 WS-Man 协议,跨平台兼容性更好,且返回的对象更符合 PowerShell 管道规范。

  2. WQL 过滤优于 Where-Object 过滤:通过 WQL 的 WHERE 子句或 -Filter 参数在服务端完成数据筛选,避免将全量数据传输到客户端再过滤,在查询远程主机时性能差距尤为明显。

  3. Win32_Product 类的查询陷阱Win32_Product 在枚举时会触发每个 MSI 包的完整性检查,非常耗时且可能引发 Windows Installer 重新配置。生产环境中采集软件清单应改用注册表路径读取。

  4. CIM 事件订阅的资源开销WITHIN 子句指定的轮询间隔不宜过短,频繁轮询会增加 CPU 和 WMI 服务的负担。一般建议设置为 5 秒以上。对于高频事件监控,优先考虑 .NET 的 FileSystemWatcherSystem.Diagnostics.TraceEvent 等原生 API。

  5. 远程 CIM 连接的防火墙配置:CIM 远程管理依赖 WinRM 服务(端口 5985/5986)。目标主机必须启用 WinRM(Enable-PSRemoting -Force),防火墙放行对应端口,且两端的信任主机配置需匹配(Set-Item WSMan:\localhost\Client\TrustedHosts)。

  6. CIM 类名在不同系统上的差异:部分 CIM 类(如 Win32_PhysicalMemory)在不同 Windows 版本上返回的属性可能不同。建议在正式脚本中先用 Get-CimClass 检查目标类是否存在及其属性列表,避免因属性缺失导致脚本异常退出。

PowerShell 技能连载 - Azure Log Analytics 查询

适用于 PowerShell 7.0 及以上版本

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

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

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

Log Analytics 查询基础

执行 KQL 查询的第一步是获取工作区信息并调用 Azure Monitor Query API。下面的脚本封装了一个通用查询函数,支持连接指定工作区、执行 KQL 查询,并将返回的 JSON 结果转换为结构化的 PowerShell 对象,方便后续处理。

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

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

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

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

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

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

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

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

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

$result = $response.Content | ConvertFrom-Json

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

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

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

return $output
}

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

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

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

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

执行结果示例:

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

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

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

运维分析查询

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

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

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

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

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

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

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

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

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

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

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

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
======================================================================
Log Analytics 运维分析报告
时间范围: 2026-03-23 08:00 ~ 2026-03-24 08:00
======================================================================

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

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

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

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

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

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

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

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

自动化报告与告警

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

return $alerts
}

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

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

执行结果示例:

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

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

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

注意事项

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

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

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

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

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

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

PowerShell 技能连载 - 剪贴板与系统交互

适用于 PowerShell 7.0 及以上版本

很多运维人员习惯在终端里做所有事情,却忽略了 PowerShell 与桌面环境交互的能力。日常工作中,我们经常需要在浏览器里复制一段 JSON、在 Excel 里复制一列数据、在日志系统中截取错误信息,然后粘贴到脚本中处理。如果能直接在脚本中读写剪贴板,就能省去中间的文件保存步骤,让数据流转更顺畅。

除了剪贴板操作,PowerShell 还可以通过 .NET 的互操作能力枚举系统窗口、控制窗口焦点,甚至模拟键盘输入来自动化 GUI 应用。这些技巧在处理那些没有提供命令行接口的传统软件时尤为实用——比如操作老旧的 ERP 系统、向不支持 API 的工具批量输入数据等。

本文将从三个层面展开:首先介绍剪贴板的读写与数据转换,然后演示如何枚举和管理系统窗口,最后通过 SendKeys 实现简单的 GUI 自动化操作。

剪贴板读写与数据转换

PowerShell 内置了 Get-ClipboardSet-Clipboard 两个 cmdlet,可以方便地读取和设置剪贴板内容。结合正则表达式和对象转换,可以快速对剪贴板中的数据进行提取、清洗和格式化处理。这在处理从网页或 Excel 中复制的表格数据时特别高效。

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
# 基础剪贴板操作
# 读取剪贴板内容
$clipContent = Get-Clipboard
Write-Host "当前剪贴板内容(前 100 字符):"
Write-Host ($clipContent.Substring(0, [Math]::Min(100, $clipContent.Length)))

# 将处理结果写回剪贴板
$processed = $clipContent | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
$processed | Set-Clipboard
Write-Host "`n已清洗剪贴板内容,去除空行和首尾空格"

# 实用场景:从剪贴板提取 IP 地址
function Get-ClipboardIpAddresses {
$text = Get-Clipboard -Raw
if (-not $text) {
Write-Warning "剪贴板为空"
return
}
$ipPattern = '\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b'
$matches = [regex]::Matches($text, $ipPattern)
$ips = $matches | ForEach-Object { $_.Value } | Sort-Object -Unique
return $ips
}

$ips = Get-ClipboardIpAddresses
if ($ips) {
Write-Host "`n从剪贴板提取到 $($ips.Count) 个 IP 地址:"
$ips | ForEach-Object { Write-Host " $_" }
# 将结果写回剪贴板,方便粘贴到其他工具
$ips -join "`r`n" | Set-Clipboard
Write-Host "已将 IP 列表写回剪贴板"
}

# 实用场景:剪贴板 JSON 格式化
function Format-ClipboardJson {
$text = (Get-Clipboard -Raw).Trim()
if ($text -match '^\{|\[') {
try {
$obj = $text | ConvertFrom-Json
$formatted = $obj | ConvertTo-Json -Depth 10
$formatted | Set-Clipboard
Write-Host "JSON 已格式化并写回剪贴板"
}
catch {
Write-Warning "剪贴板内容不是有效的 JSON: $($_.Exception.Message)"
}
}
else {
Write-Warning "剪贴板内容不以 { 或 [ 开头,可能不是 JSON"
}
}

# 实用场景:剪贴板文本大小写转换工具
function Convert-ClipboardCase {
param(
[ValidateSet('Upper', 'Lower', 'Title', 'Camel')]
[string]$Style = 'Upper'
)
$text = Get-Clipboard -Raw
if (-not $text) { return }
$result = switch ($Style) {
'Upper' { $text.ToUpper() }
'Lower' { $text.ToLower() }
'Title' {
(Get-Culture).TextInfo.ToTitleCase($text.ToLower())
}
'Camel' {
$words = $text -split '\s+'
($words[0].ToLower()) + (($words[1..($words.Length - 1)] | ForEach-Object {
if ($_.Length -gt 0) {
$_.Substring(0, 1).ToUpper() + $_.Substring(1).ToLower()
}
}) -join '')
}
}
$result | Set-Clipboard
Write-Host "已将剪贴板文本转换为 $Style 格式"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
当前剪贴板内容(前 100 字符):
Server 192.168.1.10 is responding
Server 192.168.1.20 is down
Server 10.0.0.5 is responding

已清洗剪贴板内容,去除空行和首尾空格

从剪贴板提取到 3 个 IP 地址:
10.0.0.5
192.168.1.10
192.168.1.20
已将 IP 列表写回剪贴板

窗口枚举与焦点管理

在自动化 GUI 操作之前,通常需要先找到目标窗口。Windows 系统通过句柄(Handle)标识每个窗口,PowerShell 可以借助 .NET 的 System.Diagnostics.Process 类和 P/Invoke 调用 Win32 API 来枚举、定位和控制系统窗口。这在需要对特定应用程序进行自动化操作时是关键的准备步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# 使用 Add-Type 加载 Win32 API
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Win32Api {
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);

[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

[DllImport("user32.dll")]
public static extern bool IsWindowVisible(IntPtr hWnd);

[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern int GetWindowTextLength(IntPtr hWnd);

[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

public const int SW_RESTORE = 9;
public const int SW_MINIMIZE = 6;
public const int SW_MAXIMIZE = 3;
}
"@

# 枚举所有可见窗口及其标题
function Get-VisibleWindow {
$processes = Get-Process | Where-Object {
$_.MainWindowHandle -ne 0 -and
$_.MainWindowTitle -ne ''
}
foreach ($proc in $processes) {
$sb = New-Object System.Text.StringBuilder 256
[Win32Api]::GetWindowText($proc.MainWindowHandle, $sb, 256) | Out-Null
[PSCustomObject]@{
ProcessName = $proc.ProcessName
Title = $sb.ToString()
Handle = $proc.MainWindowHandle
Id = $proc.Id
Memory = [Math]::Round($proc.WorkingSet64 / 1MB, 2)
}
}
}

# 列出当前所有可见窗口
$windows = Get-VisibleWindow
Write-Host "当前可见窗口(共 $($windows.Count) 个):"
Write-Host ("{0,-20} {1,-40} {2,10} {3,8}" -f "进程", "标题", "PID", "内存(MB)")
Write-Host ("-" * 82)
foreach ($w in $windows) {
$title = if ($w.Title.Length -gt 38) { $w.Title.Substring(0, 35) + "..." } else { $w.Title }
Write-Host ("{0,-20} {1,-40} {2,10} {3,8}" -f $w.ProcessName, $title, $w.Id, $w.Memory)
}

# 将指定窗口切换到前台
function Set-WindowFocus {
param(
[Parameter(Mandatory)]
[string]$ProcessName
)
$proc = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne 0 } |
Select-Object -First 1
if (-not $proc) {
Write-Warning "未找到进程: $ProcessName"
return
}
[Win32Api]::ShowWindow($proc.MainWindowHandle, [Win32Api]::SW_RESTORE) | Out-Null
[Win32Api]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
Write-Host "已将 $ProcessName 窗口切换到前台 (PID: $($proc.Id))"
}

# 窗口状态控制
function Set-WindowState {
param(
[Parameter(Mandatory)]
[string]$ProcessName,
[ValidateSet('Minimize', 'Maximize', 'Restore')]
[string]$State
)
$proc = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne 0 } |
Select-Object -First 1
if (-not $proc) {
Write-Warning "未找到进程: $ProcessName"
return
}
$cmd = switch ($State) {
'Minimize' { [Win32Api]::SW_MINIMIZE }
'Maximize' { [Win32Api]::SW_MAXIMIZE }
'Restore' { [Win32Api]::SW_RESTORE }
}
[Win32Api]::ShowWindow($proc.MainWindowHandle, $cmd) | Out-Null
Write-Host "已将 $ProcessName 窗口设置为 $State 状态"
}

# 按窗口标题搜索
$notepadWindows = $windows | Where-Object { $_.Title -match '记事本|Notepad' }
if ($notepadWindows) {
Write-Host "`n找到记事本窗口:"
$notepadWindows | Format-Table -AutoSize
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
当前可见窗口(共 8 个):
进程 标题 PID 内存(MB)
----------------------------------------------------------------------------------
code test-script.ps1 - Visual Studio Code 12345 312.45
explorer PowerShell 67890 85.21
msedge blog.vichamp.com - 个人技术博客 - Microsoft Edge 11223 425.67
notepad 未命名 - 记事本 33445 12.30
WindowsTerminal Windows PowerShell 55677 98.15

找到记事本窗口:
ProcessName Title Handle Id Memory
----------- ----- ------ -- ------
notepad 未命名 - 记事本 1234567 33445 12.3

SendKeys 与 GUI 自动化

当目标应用没有提供命令行接口或 API 时,SendKeys 模拟键盘输入就成了最后的自动化手段。PowerShell 可以通过 [System.Windows.Forms.SendKeys] 类向当前活动窗口发送按键指令,实现自动填写表单、执行菜单操作等功能。结合前面的窗口管理能力,可以先激活目标窗口再发送按键,形成完整的自动化链路。

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
# 加载必要的程序集
Add-Type -AssemblyName System.Windows.Forms

# 基础 SendKeys 操作
function Invoke-SendKeys {
param(
[Parameter(Mandatory)]
[string]$Keys,
[int]$DelayMs = 500
)
Start-Sleep -Milliseconds $DelayMs
[System.Windows.Forms.SendKeys]::SendWait($Keys)
Write-Host "已发送按键: $Keys"
}

# SendKeys 特殊键对照(常用)
function Show-SendKeysReference {
$ref = @(
[PSCustomObject]@{ Key = 'Enter'; SendKeys = '{ENTER}' }
[PSCustomObject]@{ Key = 'Tab'; SendKeys = '{TAB}' }
[PSCustomObject]@{ Key = 'Escape'; SendKeys = '{ESC}' }
[PSCustomObject]@{ Key = 'Backspace'; SendKeys = '{BACKSPACE}' }
[PSCustomObject]@{ Key = 'Delete'; SendKeys = '{DELETE}' }
[PSCustomObject]@{ Key = 'Home'; SendKeys = '{HOME}' }
[PSCustomObject]@{ Key = 'End'; SendKeys = '{END}' }
[PSCustomObject]@{ Key = 'Page Up'; SendKeys = '{PGUP}' }
[PSCustomObject]@{ Key = 'Page Down'; SendKeys = '{PGDN}' }
[PSCustomObject]@{ Key = 'Ctrl+A'; SendKeys = '^a' }
[PSCustomObject]@{ Key = 'Ctrl+C'; SendKeys = '^c' }
[PSCustomObject]@{ Key = 'Ctrl+V'; SendKeys = '^v' }
[PSCustomObject]@{ Key = 'Ctrl+S'; SendKeys = '^s' }
[PSCustomObject]@{ Key = 'Alt+F4'; SendKeys = '%{F4}' }
[PSCustomObject]@{ Key = 'F5 (刷新)'; SendKeys = '{F5}' }
)
$ref | Format-Table -AutoSize
}

Show-SendKeysReference

# 完整的 GUI 自动化脚本示例
function Invoke-NotepadAutomation {
# 启动记事本
Write-Host "启动记事本..."
$notepad = Start-Process notepad -PassThru
Start-Sleep -Seconds 1

# 等待窗口出现
$timeout = 10
while ($timeout -gt 0) {
$proc = Get-Process -Id $notepad.Id -ErrorAction SilentlyContinue
if ($proc -and $proc.MainWindowHandle -ne 0) { break }
Start-Sleep -Milliseconds 500
$timeout--
}

# 输入内容
Write-Host "输入文本内容..."
Invoke-SendKeys -Keys "PowerShell GUI Automation Demo" -DelayMs 300
Invoke-SendKeys -Keys "{ENTER}" -DelayMs 200
Invoke-SendKeys -Keys "Generated at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -DelayMs 200
Invoke-SendKeys -Keys "{ENTER}" -DelayMs 200

# 全选并复制
Write-Host "全选并复制到剪贴板..."
Invoke-SendKeys -Keys "^a" -DelayMs 300
Invoke-SendKeys -Keys "^c" -DelayMs 300

# 验证剪贴板
Start-Sleep -Milliseconds 500
$clipContent = Get-Clipboard -Raw
if ($clipContent -match 'PowerShell GUI') {
Write-Host "`n自动化成功!剪贴板内容已捕获:"
Write-Host $clipContent
}
else {
Write-Warning "剪贴板验证失败"
}

# 关闭记事本(不保存)
Write-Host "`n关闭记事本..."
Invoke-SendKeys -Keys "%{F4}" -DelayMs 500
Invoke-SendKeys -Keys "%n" -DelayMs 500
}

# 剪贴板内容自动填入 Web 表单的辅助函数
function Send-ClipboardToField {
param(
[int]$PreDelayMs = 2000,
[int]$FieldCount = 1
)
Write-Host "请在 $([Math]::Round($PreDelayMs/1000, 1)) 秒内切换到目标窗口并定位到第一个输入框..."
Start-Sleep -Milliseconds $PreDelayMs

$lines = (Get-Clipboard -Raw) -split "`r?`n" | Where-Object { $_ -ne '' }
$count = [Math]::Min($FieldCount, $lines.Count)

for ($i = 0; $i -lt $count; $i++) {
Set-Clipboard -Value $lines[$i]
Invoke-SendKeys -Keys "^v" -DelayMs 300
if ($i -lt $count - 1) {
Invoke-SendKeys -Keys "{TAB}" -DelayMs 300
}
Write-Host "已填入第 $($i + 1) 个字段: $($lines[$i].Substring(0, [Math]::Min(30, $lines[$i].Length)))"
}
Write-Host "`n共填入 $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
Key             SendKeys
--- --------
Enter {ENTER}
Tab {TAB}
Escape {ESC}
Backspace {BACKSPACE}
Delete {DELETE}
Home {HOME}
End {END}
Page Up {PGUP}
Page Down {PGDN}
Ctrl+A ^a
Ctrl+C ^c
Ctrl+V ^v
Ctrl+S ^s
Alt+F4 %{F4}
F5 (刷新) {F5}

启动记事本...
输入文本内容...
已发送按键: PowerShell GUI Automation Demo
已发送按键: {ENTER}
已发送按键: Generated at 2026-03-23 10:30:00
已发送按键: {ENTER}
全选并复制到剪贴板...
已发送按键: ^a
已发送按键: ^c

自动化成功!剪贴板内容已捕获:
PowerShell GUI Automation Demo
Generated at 2026-03-23 10:30:00

关闭记事本...
已发送按键: %{F4}
已发送按键: %n

注意事项

  1. 剪贴板是共享资源:Windows 剪贴板是全局共享的,如果在脚本读写剪贴板的过程中用户手动进行了复制操作,会导致数据被覆盖。对于关键的自动化流程,建议先读取并保存到变量中再处理,避免中间过程依赖剪贴板状态。

  2. SendKeys 的时序问题SendKeys 发送的是按键事件而非直接输入,目标窗口必须在前台且已就绪。网络延迟、应用启动速度等因素都可能导致按键发送到错误的窗口。建议在每步操作之间加入适当的 Start-Sleep 延迟,并在发送前验证目标窗口确实获得了焦点。

  3. Win32 API 调用需要管理员权限:部分窗口操作(如操控其他用户会话的窗口、系统进程的窗口)需要以管理员身份运行 PowerShell。普通用户权限只能操控同一会话下的窗口。

  4. 跨平台限制Get-ClipboardSet-Clipboard 在 PowerShell 7 中支持 Windows、macOS 和 Linux,但窗口管理和 SendKeys 功能仅限 Windows 平台。在 macOS 上可以考虑使用 osascript 配合 AppleScript 实现类似的 GUI 自动化。

  5. SendKeys 特殊字符需要转义SendKeys+^%{} 等字符有特殊含义(分别代表 Shift、Ctrl、Alt 和特殊键定界符)。如果要发送这些字符本身,需要用 {} 包裹,例如发送加号应使用 {+} 而非 +

  6. GUI 自动化的脆弱性:基于窗口标题匹配和按键模拟的自动化方案非常脆弱——界面布局变化、分辨率调整、系统语言切换都可能导致脚本失效。对于有命令行接口或 API 的应用,应优先使用这些更稳定的方案;SendKeys 仅作为没有其他选择时的补充手段。

PowerShell 技能连载 - Bicep 基础设施即代码

适用于 PowerShell 7.0 及以上版本

在现代云原生开发中,基础设施即代码(Infrastructure as Code,IaC)已经成为团队协作和持续交付的基石。传统 ARM Template 虽然功能强大,但 JSON 语法的冗长和嵌套层级让维护成本居高不下。微软推出的 Bicep 语言正是为了解决这个痛点而诞生的——它是一种透明抽象(transparent abstraction),编译后生成标准 ARM Template,同时提供了更简洁的声明式语法、类型安全和模块化支持。

Bicep 的核心优势在于:语法简洁(去掉了 JSON 的大量样板代码)、编译期类型检查(在部署前就能发现错误)、模块化设计(支持将复杂部署拆分为可复用的模块),以及与 Azure 生态的深度集成。配合 PowerShell 的 Az 模块,我们可以构建从开发到生产的完整部署流水线,实现环境一致性管理和自动化运维。

本文将从 Bicep 模板编写、PowerShell 部署脚本以及模块化与重用三个层面,介绍如何通过 PowerShell + Bicep 构建 Azure 基础设施的自动化部署方案。

Bicep 模板编写

Bicep 模板以 .bicep 为扩展名,采用声明式语法定义 Azure 资源。以下是一个完整的 Bicep 模板,包含参数定义、变量计算、资源声明和输出值。

1
2
3
4
5
# 安装 Bicep CLI(如果尚未安装)
az bicep install

# 检查 Bicep 版本
az bicep version

下面是 Bicep 模板文件 main.bicep 的内容:

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
@description('资源部署的目标区域')
param location string = resourceGroup().location

@description('环境名称,用于资源命名前缀')
@allowed([
'dev'
'staging'
'prod'
])
param environment string = 'dev'

@description('应用名称,用于资源命名')
param appName string

@description('SKU 定价层')
@allowed([
'F1'
'B1'
'S1'
'P1v3'
])
param skuName string = 'B1'

// 变量:统一命名前缀
var namingPrefix = '${appName}-${environment}'
var tags = {
Environment: environment
Application: appName
ManagedBy: 'bicep'
CreatedDate: utcNow('yyyy-MM-dd')
}

// 应用服务计划
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${namingPrefix}-asp'
location: location
sku: {
name: skuName
tier: (skuName startsWith('F') ? 'Free' : (skuName startsWith('B') ? 'Basic' : 'Standard'))
capacity: 1
}
properties: {
reserved: false
}
tags: tags
}

// Web 应用
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: '${namingPrefix}-web'
location: location
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'DOTNETCORE|8.0'
minTlsVersion: '1.2'
ftpsState: 'Disabled'
http20Enabled: true
}
}
tags: tags
}

// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: '${namingPrefix}-ai'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
SamplingPercentage: 100
DisableIpMasking: false
}
tags: tags
}

// 输出部署结果
output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.defaultHostName}'
output appInsightsKey string = appInsights.properties.InstrumentationKey
output resourceGroupName string = resourceGroup().name

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PS> az bicep version
Bicep CLI version 0.32.4 (SHA: abc123def)

PS> az bicep build --file main.bicep
# 编译成功,生成 main.json(ARM Template)

PS> Test-AzResourceGroupDeployment `
-ResourceGroupName "myapp-dev-rg" `
-TemplateFile ./main.bicep `
-TemplateParameterObject @{
appName = "myapp"
environment = "dev"
}

# 输出验证结果(无错误)

PowerShell 部署脚本

有了 Bicep 模板后,我们通过 PowerShell 构建自动化部署脚本,支持多环境参数化配置和部署状态跟踪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<#
.SYNOPSIS
使用 Bicep 模板部署 Azure 基础设施

.DESCRIPTION
支持多环境部署(dev/staging/prod),自动读取参数文件,
支持差异预览(WhatIf)和实际部署两种模式。
#>

[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateSet('dev', 'staging', 'prod')]
[string]$Environment,

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

[string]$ResourceGroupName,

[string]$Location = 'eastasia',

[string]$BicepTemplatePath = './main.bicep',

[string]$ParameterFilePath = "./parameters/${Environment}.bicepparam"
)

# 导入 Az 模块
$RequiredModules = @('Az.Resources', 'Az.Accounts')
foreach ($Module in $RequiredModules) {
if (-not (Get-Module -ListAvailable -Name $Module)) {
Write-Host "安装模块: $Module" -ForegroundColor Yellow
Install-Module -Name $Module -Force -Scope CurrentUser
}
Import-Module $Module -ErrorAction Stop
}

# 确保已登录 Azure
$Context = Get-AzContext
if (-not $Context) {
Write-Host '请先登录 Azure...' -ForegroundColor Cyan
Connect-AzAccount
$Context = Get-AzContext
}

Write-Host "当前订阅: $($Context.Subscription.Name)" -ForegroundColor Green

# 构建资源组名称
if (-not $ResourceGroupName) {
$ResourceGroupName = "${AppName}-${Environment}-rg"
}

# 确保资源组存在
$Rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if (-not $Rg) {
if ($PSCmdlet.ShouldProcess($ResourceGroupName, '创建资源组')) {
$Rg = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
Write-Host "已创建资源组: $ResourceGroupName" -ForegroundColor Green
}
}

# 构建部署参数
$DeployParams = @{
ResourceGroupName = $ResourceGroupName
TemplateFile = $BicepTemplatePath
Verbose = $true
}

# 优先使用参数文件,其次使用命令行参数
if (Test-Path $ParameterFilePath) {
$DeployParams['TemplateParameterFile'] = $ParameterFilePath
Write-Host "使用参数文件: $ParameterFilePath" -ForegroundColor Cyan
} else {
$DeployParams['TemplateParameterObject'] = @{
appName = $AppName
environment = $Environment
location = $Location
}
Write-Host '使用命令行参数' -ForegroundColor Cyan
}

# WhatIf 模式:预览变更
if ($PSCmdlet.ShouldProcess('Azure 资源', '差异预览')) {
Write-Host "`n========== 差异预览 ==========" -ForegroundColor Yellow
$WhatIfResult = Get-AzResourceGroupDeploymentWhatIfResult @DeployParams
$WhatIfResult.Changes | ForEach-Object {
Write-Host " [$($_.ChangeType)] $($_.ResourceId)" -ForegroundColor White
}
}

# 实际部署
if ($PSCmdlet.ShouldProcess('Azure 资源', '执行部署')) {
$DeploymentName = "${AppName}-${Environment}-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$DeployParams['Name'] = $DeploymentName

Write-Host "`n========== 开始部署 ==========" -ForegroundColor Yellow
$Result = New-AzResourceGroupDeployment @DeployParams

# 输出部署结果
Write-Host "`n========== 部署结果 ==========" -ForegroundColor Green
Write-Host " 部署名称: $($Result.DeploymentName)"
Write-Host " 状态: $($Result.ProvisioningState)"

if ($Result.Outputs) {
Write-Host "`n 输出变量:" -ForegroundColor Cyan
foreach ($Key in $Result.Outputs.Keys) {
Write-Host " $Key = $($Result.Outputs[$Key].Value)"
}
}
}

执行结果示例:

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
PS> .\Deploy-Infrastructure.ps1 -Environment dev -AppName myapp -WhatIf

当前订阅: Visual Studio Enterprise
使用命令行参数

========== 差异预览 ==========
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Web/serverfarms/myapp-dev-asp
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Web/sites/myapp-dev-web
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Insights/components/myapp-dev-ai

PS> .\Deploy-Infrastructure.ps1 -Environment dev -AppName myapp

当前订阅: Visual Studio Enterprise
使用命令行参数

========== 开始部署 ==========

========== 部署结果 ==========
部署名称: myapp-dev-20260320-080000
状态: Succeeded

输出变量:
webAppName = myapp-dev-web
webAppUrl = https://myapp-dev-web.azurewebsites.net
appInsightsKey = 12345678-abcd-efgh-ijkl-1234567890ab
resourceGroupName = myapp-dev-rg

模块化与重用

当基础设施规模增长后,将 Bicep 模板拆分为模块是提高可维护性的关键。以下展示如何创建 Bicep 模块、构建共享模板库,并与 CI/CD 流水线集成。

首先是共享模块 modules/storage.bicep

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
# modules/storage.bicep - 存储账户共享模块

@description('存储账户位置')
param location string

@description('存储账户名称(全局唯一)')
param storageAccountName string

@description('存储账户 SKU')
param skuName string = 'Standard_LRS'

@description('资源标签')
param tags object

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: {
name: skuName
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
networkAcls: {
defaultAction: 'Deny'
}
}
tags: tags
}

// 启用 Blob 软删除
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
name: '${storageAccount.name}/default'
properties: {
deleteRetentionPolicy: {
enabled: true
days: 30
}
containerDeleteRetentionPolicy: {
enabled: true
days: 30
}
}
}

output storageAccountId string = storageAccount.id
output primaryEndpoint string = storageAccount.properties.primaryEndpoints.blob

然后在主模板中引用模块,并构建共享模板仓库:

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
<#
.SYNOPSIS
管理共享 Bicep 模块仓库
#>

param(
[ValidateSet('publish', 'list', 'restore')]
[string]$Action = 'restore',

[string]$RegistryName = 'myacr.azurecr.io',
[string]$ModulePath = 'bicep/modules'
)

# 模块清单:定义所有需要发布到 ACR 的模块
$ModuleManifest = @{
'storage' = @{
Source = './modules/storage.bicep'
Tag = 'v1.2.0'
}
'keyvault' = @{
Source = './modules/keyvault.bicep'
Tag = 'v1.0.0'
}
'appservice' = @{
Source = './modules/appservice.bicep'
Tag = 'v2.1.0'
}
'sql' = @{
Source = './modules/sql.bicep'
Tag = 'v1.3.0'
}
}

switch ($Action) {
'publish' {
Write-Host '发布模块到 Azure Container Registry...' -ForegroundColor Cyan
foreach ($ModuleName in $ModuleManifest.Keys) {
$Module = $ModuleManifest[$ModuleName]
$Target = "br:${RegistryName}/${ModulePath}/${ModuleName}:${$Module.Tag}"
Write-Host " 发布: ${ModuleName}@$($Module.Tag)" -ForegroundColor White

az bicep publish `
--file $Module.Source `
--target $Target

if ($LASTEXITCODE -eq 0) {
Write-Host " 成功" -ForegroundColor Green
} else {
Write-Host " 失败" -ForegroundColor Red
}
}
}

'restore' {
Write-Host '从 ACR 还原模块依赖...' -ForegroundColor Cyan
az bicep restore --file ./main.bicep --force
Write-Host '还原完成' -ForegroundColor Green
}

'list' {
Write-Host '已注册的 Bicep 模块:' -ForegroundColor Cyan
foreach ($ModuleName in $ModuleManifest.Keys | Sort-Object) {
$Module = $ModuleManifest[$ModuleName]
Write-Host " ${ModuleName}:$($Module.Tag) -> $($Module.Source)"
}
}
}

在主模板中使用远程模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# main.bicep - 引用 ACR 中的共享模块

param location string = resourceGroup().location
param environment string
param appName string

var namingPrefix = '${appName}-${environment}'
var tags = {
Environment: environment
ManagedBy: 'bicep'
}

// 引用 ACR 中的存储模块
module storage 'br:myacr.azurecr.io/bicep/modules/storage:v1.2.0' = {
name: '${namingPrefix}-storage-deploy'
params: {
location: location
storageAccountName: '${namingPrefix}st01'
skuName: (environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS')
tags: tags
}
}

output storageEndpoint string = storage.outputs.primaryEndpoint

CI/CD 集成脚本(GitHub Actions 本地验证):

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
<#
.SYNOPSIS
CI/CD 流水线中的 Bicep 验证与部署步骤
#>

param(
[string]$BicepPath = './main.bicep',
[string]$Environment = 'dev'
)

$ErrorActionPreference = 'Stop'

# 步骤 1: 语法验证
Write-Host '== 步骤 1: Bicep 语法验证 ==' -ForegroundColor Yellow
$LintResult = az bicep lint --file $BicepPath 2>&1
if ($LintResult -match 'Error') {
Write-Host "语法检查失败:`n$LintResult" -ForegroundColor Red
exit 1
}
Write-Host '语法检查通过' -ForegroundColor Green

# 步骤 2: 编译为 ARM Template
Write-Host "`n== 步骤 2: 编译 Bicep 模板 ==" -ForegroundColor Yellow
az bicep build --file $BicepPath --outfile ./artifacts/main.json
Write-Host '编译成功' -ForegroundColor Green

# 步骤 3: 安全扫描(检查硬编码密钥等)
Write-Host "`n== 步骤 3: 模板安全扫描 ==" -ForegroundColor Yellow
$ArmContent = Get-Content ./artifacts/main.json -Raw
$SensitivePatterns = @(
@{ Name = '密码明文'; Pattern = '"password"\s*:\s*"[^{}"]+"' }
@{ Name = '密钥明文'; Pattern = '"secretKey"\s*:\s*"[^{}"]+"' }
@{ Name = '连接字符串'; Pattern = '"connectionString"\s*:\s*"[^{}"]+"' }
)
$SecurityPassed = $true
foreach ($Pattern in $SensitivePatterns) {
if ($ArmContent -match $Pattern.Pattern) {
Write-Host " 警告: 检测到 $($Pattern.Name)" -ForegroundColor Red
$SecurityPassed = $false
}
}
if ($SecurityPassed) {
Write-Host '安全扫描通过' -ForegroundColor Green
} else {
Write-Host '安全扫描未通过,请修复后重试' -ForegroundColor Red
exit 1
}

# 步骤 4: WhatIf 预览
Write-Host "`n== 步骤 4: 部署差异预览 ==" -ForegroundColor Yellow
$WhatIf = Get-AzResourceGroupDeploymentWhatIfResult `
-ResourceGroupName "myapp-${Environment}-rg" `
-TemplateFile $BicepPath `
-TemplateParameterFile "./parameters/${Environment}.bicepparam"

$CreateCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Create' }).Count
$ModifyCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Modify' }).Count
$DeleteCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Delete' }).Count

Write-Host " 新增: $CreateCount | 修改: $ModifyCount | 删除: $DeleteCount" -ForegroundColor White
Write-Host "`n验证流程全部通过,可以执行部署" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PS> .\Manage-BicepModules.ps1 -Action list

已注册的 Bicep 模块:
appservice:v2.1.0 -> ./modules/appservice.bicep
keyvault:v1.0.0 -> ./modules/keyvault.bicep
sql:v1.3.0 -> ./modules/sql.bicep
storage:v1.2.0 -> ./modules/storage.bicep

PS> .\CiCd-Validate.ps1 -BicepPath ./main.bicep -Environment staging

== 步骤 1: Bicep 语法验证 ==
语法检查通过

== 步骤 2: 编译 Bicep 模板 ==
编译成功

== 步骤 3: 模板安全扫描 ==
安全扫描通过

== 步骤 4: 部署差异预览 ==
新增: 3 | 修改: 1 | 删除: 0

验证流程全部通过,可以执行部署

注意事项

  1. Bicep CLI 安装方式:Bicep CLI 可通过 Azure CLI(az bicep install)或独立 MSI 安装。推荐使用 Azure CLI 方式管理,避免版本不一致的问题。确保 Bicep 版本与目标 Azure API 版本兼容。

  2. 参数文件优先级:使用 .bicepparam 文件替代 .json 参数文件,前者支持 Bicep 语法并可直接引用变量和函数,比纯 JSON 参数文件更灵活。命令行传入的参数会覆盖参数文件中的同名参数。

  3. 模块版本管理:将 Bicep 模块发布到 Azure Container Registry(ACR)后,主模板通过 br: 前缀引用。建议使用语义化版本号(SemVer),生产环境应锁定具体版本号而非使用 latest 标签。

  4. 敏感信息处理:绝不要在 Bicep 模板中硬编码密码、密钥等敏感信息。应使用 secureStringsecureObject 类型参数,配合 Azure Key Vault 引用(referenceKeyId)在部署时动态获取。

  5. WhatIf 的局限性Get-AzResourceGroupDeploymentWhatIfResult 的差异预览是一种预测,某些资源类型的变更可能无法准确检测(如应用设置、连接字符串等嵌套属性的变更)。在关键环境部署前,建议先在非生产环境进行完整测试。

  6. 订阅级与租户级部署New-AzResourceGroupDeployment 用于资源组级部署,New-AzDeployment 用于订阅级部署(如创建资源组、策略分配),New-AzTenantDeployment 用于租户级部署。选择正确的部署范围可以避免权限错误。