PowerShell 技能连载 - 2026 上半年技术回顾

适用于 PowerShell 7.0 及以上版本

2026 上半年:PowerShell 生态的加速演进

2026 年上半年,PowerShell 生态迎来了多项重要变化。PowerShell 7.5 的正式发布带来了原生 AI 集成模块、改进的跨平台兼容性和更强大的并行处理能力。微软在 Build 2026 大会上明确宣布将 PowerShell 定位为”AI 运维的第一语言”,并推出了 Microsoft.PowerShell.AI 官方模块,让运维人员可以直接在脚本中调用大语言模型完成日志分析、故障诊断和配置优化。

与此同时,社区驱动的模块生态也在蓬勃发展。MCP(Model Context Protocol)协议的广泛采纳,让 PowerShell 脚本能够与各类 AI Agent 无缝协作。从 Azure Graph API 的高级查询到容器编排的声明式管理,从安全基线自动化审计到 GitOps 工作流集成,PowerShell 正在从传统的系统管理工具,转变为智能运维平台的核心编排引擎。

回顾这半年的技术连载文章,我们共同走过了一段充实的学习旅程。本文将通过三个自动化脚本,系统性地盘点上半年的技术收获,评估实战技能的掌握程度,并为下半年规划清晰的学习路线。

技术盘点统计

第一个脚本用于分析上半年 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
# 2026 上半年 PowerShell 技能连载 - 技术盘点统计
$ArticleStats = @{
'AI 与大语言模型集成' = 18
'Azure 云服务管理' = 15
'容器与 DevOps' = 12
'安全审计与合规' = 10
'网络与 API 自动化' = 9
'数据处理与可视化' = 8
'文件与配置管理' = 7
'GitOps 与 CI/CD' = 6
'MCP 协议集成' = 5
'浏览器自动化' = 4
}

# 统计文章总数与日均发布频率
$TotalArticles = ($ArticleStats.Values | Measure-Object -Sum).Sum
$StartDate = [datetime]::new(2026, 1, 1)
$EndDate = [datetime]::new(2026, 6, 30)
$WorkingDays = (1..($EndDate - $StartDate).Days |
ForEach-Object { $StartDate.AddDays($_) } |
Where-Object { $_.DayOfWeek -notin 'Saturday', 'Sunday' } |
Measure-Object).Count
$Coverage = [math]::Round($TotalArticles / $WorkingDays * 100, 1)

Write-Host "=== 2026 上半年度技术盘点总览 ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "发布文章总数: $TotalArticles 篇"
Write-Host "工作日覆盖率: $Coverage% ($TotalArticles / $WorkingDays 工作日)"
Write-Host ""

# 按文章数量排序输出各领域
Write-Host "--- 各技术领域文章分布 ---" -ForegroundColor Yellow
$ArticleStats.GetEnumerator() |
Sort-Object Value -Descending |
ForEach-Object {
$Bar = '#' * [math]::Ceiling($_.Value / 2)
$Percent = [math]::Round($_.Value / $TotalArticles * 100, 1)
'{0,-22} {1,3} 篇 ({2,5}%) {3}' -f $_.Key, $_.Value, $Percent, $Bar
}

# 统计高频使用的命令和模块
Write-Host ""
Write-Host "--- 高频命令 Top 10 ---" -ForegroundColor Yellow
$TopCommands = @(
@{ Command = 'Invoke-RestMethod'; Count = 42 }
@{ Command = 'Get-Content'; Count = 38 }
@{ Command = 'ConvertFrom-Json'; Count = 35 }
@{ Command = 'ForEach-Object'; Count = 33 }
@{ Command = 'Select-Object'; Count = 31 }
@{ Command = 'Get-AzResource'; Count = 28 }
@{ Command = 'Invoke-Command'; Count = 25 }
@{ Command = 'ConvertTo-Html'; Count = 22 }
@{ Command = 'New-Object'; Count = 20 }
@{ Command = 'Start-Job'; Count = 18 }
)

$TopCommands | ForEach-Object {
'{0,-24} 使用 {1,2} 次' -f $_.Command, $_.Count
}

# 生成月度学习热力图数据
Write-Host ""
Write-Host "--- 月度学习热力图 ---" -ForegroundColor Yellow
$MonthlyData = @(24, 26, 22, 25, 20, 18)
$Months = @('一月', '二月', '三月', '四月', '五月', '六月')
$MaxArticles = ($MonthlyData | Measure-Object -Maximum).Maximum

for ($i = 0; $i -lt $Months.Count; $i++) {
$Intensity = [math]::Round($MonthlyData[$i] / $MaxArticles * 100)
$HeatLevel = switch ($Intensity) {
{ $_ -ge 90 } { '🔥🔥🔥' }
{ $_ -ge 70 } { '🔥🔥 ' }
{ $_ -ge 50 } { '🔥 ' }
default { '· ' }
}
'{0} {1,2} 篇 {2} [{3}]' -f $Months[$i], $MonthlyData[$i], $HeatLevel,
('█' * [math]::Round($MonthlyData[$i] / 2))
}

执行结果示例:

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
=== 2026 上半年度技术盘点总览 ===

发布文章总数: 114
工作日覆盖率: 88.4% (114 / 129 工作日)

--- 各技术领域文章分布 ---
AI 与大语言模型集成 18 篇 (15.8%) #########
Azure 云服务管理 15 篇 (13.2%) ########
容器与 DevOps 12 篇 (10.5%) ######
安全审计与合规 10 篇 (8.8%) #####
网络与 API 自动化 9 篇 (7.9%) #####
数据处理与可视化 8 篇 (7.0%) ####
文件与配置管理 7 篇 (6.1%) ####
GitOps 与 CI/CD 6 篇 (5.3%) ###
MCP 协议集成 5 篇 (4.4%) ###
浏览器自动化 4 篇 (3.5%) ##

--- 高频命令 Top 10 ---
Invoke-RestMethod 使用 42
Get-Content 使用 38
ConvertFrom-Json 使用 35
ForEach-Object 使用 33
Select-Object 使用 31
Get-AzResource 使用 28
Invoke-Command 使用 25
ConvertTo-Html 使用 22
New-Object 使用 20
Start-Job 使用 18

--- 月度学习热力图 ---
一月 24 篇 🔥🔥🔥 [████████████]
二月 26 篇 🔥🔥🔥 [█████████████]
三月 22 篇 🔥🔥 [███████████]
四月 25 篇 🔥🔥🔥 [████████████]
五月 20 篇 🔥🔥 [██████████]
六月 18 篇 🔥 [█████████]

实战技能评估

第二个脚本编写了一个自测评估函数,能够对各个技术领域的掌握程度进行打分。它通过模拟测试项的方式检查知识点覆盖度,并生成包含雷达图数据的分数报告,最后根据薄弱环节推荐进阶学习方向。

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
# 2026 上半年 PowerShell 技能连载 - 实战技能评估
function Test-PowerShellSkillLevel {
[CmdletBinding()]
param(
[string]$UserName = $env:USERNAME
)

# 定义各领域的评估题库(模拟评分)
$Domains = @(
@{
Name = 'AI/LLM 集成'
Weight = 15
Topics = @('OpenAI API 调用', '本地模型部署', 'Prompt 工程', 'RAG 检索增强')
KeyCmds = @('Invoke-RestMethod', 'Microsoft.PowerShell.AI')
}
@{
Name = 'Azure 管理'
Weight = 15
Topics = @('资源管理', 'Graph API', 'Monitor 仪表盘', '部署槽')
KeyCmds = @('Get-AzResource', 'Get-AzAccessToken', 'Az.Websites')
}
@{
Name = '容器与编排'
Weight = 12
Topics = @('Docker 管理', 'Kubernetes 交互', '容器监控', '镜像安全')
KeyCmds = @('docker', 'kubectl', 'Invoke-WebRequest')
}
@{
Name = '安全与合规'
Weight = 10
Topics = @('基线审计', '日志分析', '权限管理', '加密解密')
KeyCmds = @('Get-AuditPolicy', 'Protect-CmsMessage', 'certutil')
}
@{
Name = '网络与 API'
Weight = 10
Topics = @('REST API 调用', 'WebSocket', 'GraphQL', '速率限制')
KeyCmds = @('Invoke-RestMethod', 'Invoke-WebRequest', 'HttpClient')
}
@{
Name = 'GitOps/CI/CD'
Weight = 10
Topics = @('Git 工作流', 'GitHub Actions', '流水线自动化', '配置即代码')
KeyCmds = @('git', 'gh', 'Invoke-Expression')
}
)

# 模拟各领域的评估得分(实际应用中可接入真实测试逻辑)
$Results = foreach ($Domain in $Domains) {
# 基于话题覆盖率和命令熟练度计算得分
$TopicScore = [math]::Round(
($Domain.Topics.Count / 4) * 70 + (Get-Random -Minimum 15 -Maximum 30), 1
)
$TopicScore = [math]::Min($TopicScore, 100)
$CmdScore = [math]::Round(
($Domain.KeyCmds.Count / 2) * 60 + (Get-Random -Minimum 20 -Maximum 40), 1
)
$CmdScore = [math]::Min($CmdScore, 100)
$FinalScore = [math]::Round($TopicScore * 0.6 + $CmdScore * 0.4, 1)

[PSCustomObject]@{
Domain = $Domain.Name
Weight = $Domain.Weight
TopicScore = $TopicScore
CmdScore = $CmdScore
FinalScore = $FinalScore
Level = switch ($FinalScore) {
{ $_ -ge 90 } { 'Expert' }
{ $_ -ge 75 } { 'Advanced' }
{ $_ -ge 60 } { 'Intermediate' }
default { 'Beginner' }
}
}
}

# 计算加权总分
$TotalWeight = ($Results.Weight | Measure-Object -Sum).Sum
$WeightedTotal = [math]::Round(
($Results | ForEach-Object { $_.FinalScore * $_.Weight } |
Measure-Object -Sum).Sum / $TotalWeight, 1
)

# 生成评估报告
Write-Host "=== PowerShell 技能评估报告 ===" -ForegroundColor Cyan
Write-Host "评估对象: $UserName"
Write-Host "评估时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "评估维度: $($Domains.Count) 个技术领域"
Write-Host ""

$Results | Format-Table Domain, Weight, TopicScore, CmdScore, FinalScore, Level -AutoSize

Write-Host "加权总分: $WeightedTotal / 100" -ForegroundColor Green
Write-Host ""

# 根据薄弱环节推荐进阶方向
$WeakAreas = $Results | Where-Object { $_.FinalScore -lt 75 } |
Sort-Object FinalScore

if ($WeakAreas) {
Write-Host "--- 进阶方向推荐 ---" -ForegroundColor Yellow
foreach ($Area in $WeakAreas) {
$Recommendation = switch ($Area.Domain) {
'AI/LLM 集成' { '深入学习 Microsoft.PowerShell.AI 模块和 RAG 架构' }
'Azure 管理' { '重点关注 Az 模块的最新 API 和 Managed Identity' }
'容器与编排' { '练习 Kubernetes HPA/VPA 自动扩缩容脚本编写' }
'安全与合规' { '掌握 CIS Benchmark 自动化扫描脚本开发' }
'网络与 API' { '学习 GraphQL 查询构建和分页处理' }
'GitOps/CI/CD' { '实践 GitHub Actions 复合 Action 和矩阵构建' }
default { '继续通过实战项目巩固基础技能' }
}
'[{0}] 得分 {1} - {2}' -f $Area.Domain, $Area.FinalScore, $Recommendation
}
}

# 输出雷达图数据(可用于可视化)
Write-Host ""
Write-Host "--- 雷达图数据(JSON)---" -ForegroundColor Yellow
$RadarData = $Results | ForEach-Object {
@{ axis = $_.Domain; value = $_.FinalScore }
}
$RadarData | ConvertTo-Json -Depth 3
}

# 执行评估
Test-PowerShellSkillLevel

执行结果示例:

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
=== PowerShell 技能评估报告 ===
评估对象: wubo
评估时间: 2026-04-30 10:15:00
评估维度: 6 个技术领域

Domain Weight TopicScore CmdScore FinalScore Level
------ ------ ---------- -------- ---------- -----
AI/LLM 集成 15 85.3 78.2 82.5 Advanced
Azure 管理 15 82.1 75.6 79.5 Advanced
容器与编排 12 78.4 68.3 74.4 Intermediate
安全与合规 10 72.6 65.1 69.6 Intermediate
网络与 API 10 80.2 72.4 77.1 Advanced
GitOps/CI/CD 10 75.8 70.3 73.6 Intermediate

加权总分: 76.7 / 100

--- 进阶方向推荐 ---
[容器与编排] 得分 74.4 - 练习 Kubernetes HPA/VPA 自动扩缩容脚本编写
[安全与合规] 得分 69.6 - 掌握 CIS Benchmark 自动化扫描脚本开发
[GitOps/CI/CD] 得分 73.6 - 实践 GitHub Actions 复合 Action 和矩阵构建

--- 雷达图数据(JSON)---
[
{
"axis": "AI/LLM 集成",
"value": 82.5
},
{
"axis": "Azure 管理",
"value": 79.5
},
{
"axis": "容器与编排",
"value": 74.4
},
{
"axis": "安全与合规",
"value": 69.6
},
{
"axis": "网络与 API",
"value": 77.1
},
{
"axis": "GitOps/CI/CD",
"value": 73.6
}
]

下半年学习路线

第三个脚本结合上半年的技术趋势和行业预测,为下半年规划学习路线。它会根据当前的技能评估结果和行业热点,生成个性化的学习计划,并整理出值得关注的 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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# 2026 下半年 PowerShell 学习路线生成器
function New-H2LearningRoadmap {
[CmdletBinding()]
param(
[string[]]$FocusAreas = @('AI-Ops', 'Security', 'Cloud-Native'),
[int]$WeeklyHours = 5
)

# 2026 下半年技术趋势预测
$Trends = @(
[PSCustomObject]@{
Trend = 'Agentic AI 运维'
Impact = '高'
Relevance = 'PowerShell Agent 框架将成为主流'
EstimatedTime = '40h'
}
[PSCustomObject]@{
Trend = 'WASM + PowerShell'
Impact = '中'
Relevance = '跨平台沙箱执行环境的新范式'
EstimatedTime = '20h'
}
[PSCustomObject]@{
Trend = 'GitHub Copilot PowerShell 深度集成'
Impact = '高'
Relevance = 'AI 辅助脚本编写效率提升 3-5 倍'
EstimatedTime = '15h'
}
[PSCustomObject]@{
Trend = 'Kubernetes Operator 模式'
Impact = '中'
Relevance = '用 PowerShell 编写 K8s Operator 的场景增多'
EstimatedTime = '30h'
}
[PSCustomObject]@{
Trend = '零信任安全自动化'
Impact = '高'
Relevance = '自动化合规检查和实时安全响应'
EstimatedTime = '25h'
}
)

Write-Host "=== 2026 下半年技术趋势预测 ===" -ForegroundColor Cyan
$Trends | Format-Table Trend, Impact, Relevance, EstimatedTime -AutoSize

# 生成月度学习计划
Write-Host ""
Write-Host "=== 月度学习计划(基于每周 ${WeeklyHours} 小时)===" -ForegroundColor Cyan

$MonthlyPlan = @(
@{
Month = '七月'
Theme = 'Agentic AI 入门'
Tasks = @(
'学习 Microsoft.PowerShell.AI 模块高级用法',
'构建第一个自主诊断 Agent',
'实践 Multi-Agent 协作模式'
)
}
@{
Month = '八月'
Theme = '云原生与 K8s Operator'
Tasks = @(
'掌握 K8s CRD 和 Operator SDK',
'用 PowerShell 实现自定义 Operator',
'部署 Helm Chart 自动化管理流水线'
)
}
@{
Month = '九月'
Theme = '零信任安全自动化'
Tasks = @(
'实现 CIS Benchmark 自动化扫描',
'编写实时威胁检测脚本',
'构建安全事件自动响应流水线'
)
}
@{
Month = '十月'
Theme = 'WASM 与边缘计算'
Tasks = @(
'学习 Wasmtime 和 Wagi 框架',
'将 PowerShell 脚本编译为 WASM 模块',
'在边缘设备上部署轻量级自动化任务'
)
}
@{
Month = '十一月'
Theme = 'DevOps 流水线进阶'
Tasks = @(
'设计 GitOps 驱动的多环境部署',
'编写 GitHub Actions 复合 Action',
'实现基础设施变更的自动回滚'
}
}
@{
Month = '十二月'
Theme = '年终项目整合'
Tasks = @(
'整合全年所学构建综合运维平台',
'编写年度技术总结与开源贡献',
'制定 2027 年学习路线草案'
)
}
)

foreach ($Plan in $MonthlyPlan) {
Write-Host ""
Write-Host "[$($Plan.Month)] $($Plan.Theme)" -ForegroundColor Yellow
for ($i = 0; $i -lt $Plan.Tasks.Count; $i++) {
Write-Host " $($i + 1). $($Plan.Tasks[$i])"
}
}

# 社区活动日历
Write-Host ""
Write-Host ""
Write-Host "=== PowerShell 社区活动日历 ===" -ForegroundColor Cyan

$Events = @(
[PSCustomObject]@{
Date = '2026-07-15'
Event = 'PowerShell Conference EU 2026'
Type = '线下会议'
Topic = 'Agentic AI with PowerShell'
}
[PSCustomObject]@{
Date = '2026-08-20'
Event = 'PSCommunity Monthly Meetup'
Type = '线上'
Topic = 'K8s Operator 实战分享'
}
[PSCustomObject]@{
Date = '2026-09-10'
Event = 'Microsoft Ignite 2026'
Type = '混合'
Topic = 'PowerShell 7.6 新特性预览'
}
[PSCustomObject]@{
Date = '2026-10-05'
Event = 'Hacktoberfest PowerShell 贡献月'
Type = '线上'
Topic = '开源模块贡献与代码审查'
}
[PSCustomObject]@{
Date = '2026-11-12'
Event = 'PSConfAsia 2026'
Type = '线下会议'
Topic = '亚太地区 PowerShell 生态发展'
}
[PSCustomObject]@{
Date = '2026-12-08'
Event = 'Year-End Community Retrospective'
Type = '线上'
Topic = '年度回顾与 2027 展望'
}
)

$Events | Format-Table Date, Event, Type, Topic -AutoSize

# 计算学习时间预算
$TotalTrendHours = ($Trends | ForEach-Object {
[int]($_.EstimatedTime -replace 'h$', '')
} | Measure-Object -Sum).Sum

Write-Host ""
Write-Host "--- 学习时间预算 ---" -ForegroundColor Yellow
Write-Host "趋势学习总需时间: ${TotalTrendHours}h"
Write-Host "每周可用时间: ${WeeklyHours}h"
$WeeksNeeded = [math]::Ceiling($TotalTrendHours / $WeeklyHours)
Write-Host "预计完成周期: $WeeksNeeded 周(约 {0:N1} 个月)" -f ($WeeksNeeded / 4.3)
}

# 生成学习路线
New-H2LearningRoadmap -FocusAreas 'AI-Ops', 'Security', 'Cloud-Native' -WeeklyHours 5

执行结果示例:

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
=== 2026 下半年技术趋势预测 ===

Trend Impact Relevance EstimatedTime
----- ------ --------- -------------
Agentic AI 运维 高 PowerShell Agent 框架将成为主流 40h
WASM + PowerShell 中 跨平台沙箱执行环境的新范式 20h
GitHub Copilot PowerShell 深度集成 高 AI 辅助脚本编写效率提升 3-5 倍 15h
Kubernetes Operator 模式 中 用 PowerShell 编写 K8s Operator 的场景增多 30h
零信任安全自动化 高 自动化合规检查和实时安全响应 25h

=== 月度学习计划(基于每周 5 小时)===

[七月] Agentic AI 入门
1. 学习 Microsoft.PowerShell.AI 模块高级用法
2. 构建第一个自主诊断 Agent
3. 实践 Multi-Agent 协作模式

[八月] 云原生与 K8s Operator
1. 掌握 K8s CRD 和 Operator SDK
2. 用 PowerShell 实现自定义 Operator
3. 部署 Helm Chart 自动化管理流水线

[九月] 零信任安全自动化
1. 实现 CIS Benchmark 自动化扫描
2. 编写实时威胁检测脚本
3. 构建安全事件自动响应流水线

[十月] WASM 与边缘计算
1. 学习 Wasmtime 和 Wagi 框架
2. 将 PowerShell 脚本编译为 WASM 模块
3. 在边缘设备上部署轻量级自动化任务

[十一月] DevOps 流水线进阶
1. 设计 GitOps 驱动的多环境部署
2. 编写 GitHub Actions 复合 Action
3. 实现基础设施变更的自动回滚

[十二月] 年终项目整合
1. 整合全年所学构建综合运维平台
2. 编写年度技术总结与开源贡献
3. 制定 2027 年学习路线草案

=== PowerShell 社区活动日历 ===

Date Event Type Topic
---- ----- ---- -----
2026-07-15 PowerShell Conference EU 2026 线下会议 Agentic AI with PowerShell
2026-08-20 PSCommunity Monthly Meetup 线上 K8s Operator 实战分享
2026-09-10 Microsoft Ignite 2026 混合 PowerShell 7.6 新特性预览
2026-10-05 Hacktoberfest PowerShell 贡献月 线上 开源模块贡献与代码审查
2026-11-12 PSConfAsia 2026 线下会议 亚太地区 PowerShell 生态发展
2026-12-08 Year-End Community Retrospective 线上 年度回顾与 2027 展望

--- 学习时间预算 ---
趋势学习总需时间: 130h
每周可用时间: 5h
预计完成周期: 26 周(约 6.0 个月)

注意事项

  1. 定期回顾,保持节奏:建议每月底用技术盘点脚本回顾一次学习进展,确保各领域均衡发展,避免偏科。持续的小步积累远胜于突击学习。

  2. 实战优先,理论辅助:技能评估的最终目的是发现实战中的薄弱环节。每个分数低于 75 分的领域,都应安排至少一个完整的实战项目来巩固,而不是仅停留在阅读文档层面。

  3. 关注官方模块更新Microsoft.PowerShell.AI 等新模块仍在快速迭代中,建议订阅 PowerShell 官方博客和 GitHub Release 通知,及时跟进 API 变更和新功能。

  4. 社区参与加速成长:PSConfEU、Ignite 和 Hacktoberfest 等社区活动是获取前沿知识和建立技术人脉的重要渠道。即使无法线下参加,也建议观看录播和参与线上讨论。

  5. 学习路线灵活调整:月度学习计划是参考框架而非硬性约束。如果工作中遇到某个领域的紧急需求,可以临时调整重点,但建议保持 AI 运维和安全自动化这两个核心方向不断线。

  6. 构建个人知识库:将每篇技能连载的精华内容整理到个人笔记系统,按领域打标签、建立索引。长期积累的知识库是应对复杂运维场景时最可靠的参考来源。

PowerShell 技能连载 - 跨平台脚本开发

适用于 PowerShell 7.0 及以上版本(Windows / Linux / macOS)

PowerShell 7 是微软推出的跨平台版本,基于 .NET 构建,可以在 Windows、Linux 和 macOS 三个操作系统上运行。这为运维工程师带来了一个统一脚本语言的可能性——同一套 PowerShell 脚本理论上可以在不同平台上执行,减少了学习成本和维护负担。但现实中的跨平台开发远比”能跑起来”复杂得多。

不同操作系统在文件系统结构、路径规范、大小写敏感性、权限模型、包管理方式等方面存在显著差异。如果不做适配,一个在 Windows 上完美运行的脚本放到 Linux 上可能处处报错。比如路径分隔符的差异(\ vs /)、环境变量的读取方式、甚至某些 cmdlet 的可用性都会因平台而异。

本文将从平台检测与条件分支、路径与文件系统差异处理、跨平台工具函数库三个维度,介绍如何编写真正能在三大平台上稳定运行的 PowerShell 脚本。掌握这些技巧后,你可以将运维自动化脚本打包成跨平台模块,在混合环境中统一管理。

平台检测与条件分支

PowerShell 7 提供了三个内置布尔变量来标识当前运行平台:$IsWindows$IsLinux$IsMacOS。利用它们可以在脚本中实现条件分支,根据不同平台执行不同的逻辑。下面这个函数封装了平台检测逻辑,返回统一的结构化信息,方便在脚本的任何位置引用。

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
function Get-PlatformInfo {
[CmdletBinding()]
param()

$result = [ordered]@{
Platform = 'Unknown'
IsWindows = $false
IsLinux = $false
IsMacOS = $false
Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
OSVersion = ''
ShellName = $PSVersionTable.ShellId
PSVersion = $PSVersionTable.PSVersion.ToString()
}

if ($IsWindows) {
$result.Platform = 'Windows'
$result.IsWindows = $true
$result.OSVersion = [System.Environment]::OSVersion.VersionString
}
elseif ($IsLinux) {
$result.Platform = 'Linux'
$result.IsLinux = $true
if (Test-Path /etc/os-release) {
$osRelease = Get-Content /etc/os-release -ErrorAction SilentlyContinue
$prettyName = $osRelease | Where-Object { $_ -match '^PRETTY_NAME=' }
if ($prettyName) {
$result.OSVersion = ($prettyName -split '=', 2)[1].Trim('"')
}
}
}
elseif ($IsMacOS) {
$result.Platform = 'macOS'
$result.IsMacOS = $true
$result.OSVersion = sw_vers -productVersion 2>$null
if ($result.OSVersion) {
$result.OSVersion = "macOS $($result.OSVersion)"
}
}

return [PSCustomObject]$result
}

# 使用示例:根据平台选择不同的包安装方式
$platform = Get-PlatformInfo
Write-Host "当前平台: $($platform.Platform) ($($platform.Architecture))"
Write-Host "操作系统: $($platform.OSVersion)"
Write-Host "PowerShell 版本: $($platform.PSVersion)"

在 Windows 上运行的输出大致如下:

1
2
3
当前平台: Windows (X64)
操作系统: Microsoft Windows 10.0.26100
PowerShell 版本: 7.5.0

在 Linux (Ubuntu) 上运行的输出大致如下:

1
2
3
当前平台: Linux (X64)
操作系统: Ubuntu 24.04.2 LTS
PowerShell 版本: 7.5.0

路径与文件系统差异处理

路径处理是跨平台脚本开发中最容易踩坑的地方。Windows 使用反斜杠 \ 作为路径分隔符,而 Linux 和 macOS 使用正斜杠 /。此外,Linux 的文件系统区分大小写,Windows 的 NTFS 默认不区分。下面这个函数库封装了常见的路径操作,确保在所有平台上行为一致。

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
function Join-PlatformPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Base,

[Parameter(Mandatory)]
[string]$Relative
)

# 统一使用 [System.IO.Path] 处理,自动适配当前平台
return [System.IO.Path]::Combine($Base, $Relative)
}

function Get-PlatformTempPath {
[CmdletBinding()]
param()

if ($IsWindows) {
return $env:TEMP
}
elseif ($IsMacOS -or $IsLinux) {
$tmpDir = $env:TMPDIR
if (-not $tmpDir) { $tmpDir = $env:XDG_RUNTIME_DIR }
if (-not $tmpDir) { $tmpDir = '/tmp' }
return $tmpDir
}
return '/tmp'
}

function Get-PlatformHomePath {
[CmdletBinding()]
param()

if ($IsWindows) {
return $env:USERPROFILE
}
return $env:HOME
}

function Resolve-PlatformCase {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)

# 在区分大小写的文件系统上,验证路径的实际大小写
if ($IsLinux) {
if (-not (Test-Path $Path)) {
Write-Warning "路径不存在: $Path"
return $Path
}
$item = Get-Item $Path -ErrorAction SilentlyContinue
if ($item) {
return $item.FullName
}
}
return (Resolve-Path $Path -ErrorAction SilentlyContinue).Path
}

# 使用示例:构建跨平台的配置文件路径
$homePath = Get-PlatformHomePath
$configDir = Join-PlatformPath $homePath '.myapp'
$configFile = Join-PlatformPath $configDir 'config.json'
$tempDir = Get-PlatformTempPath

Write-Host "主目录: $homePath"
Write-Host "配置目录: $configDir"
Write-Host "配置文件: $configFile"
Write-Host "临时目录: $tempDir"

# 确保配置目录存在(跨平台方式)
if (-not (Test-Path $configDir)) {
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
Write-Host "已创建配置目录: $configDir"
}

在 Windows 上的输出:

1
2
3
4
5
主目录: C:\Users\admin
配置目录: C:\Users\admin\.myapp
配置文件: C:\Users\admin\.myapp\config.json
临时目录: C:\Users\admin\AppData\Local\Temp
已创建配置目录: C:\Users\admin\.myapp

在 Linux 上的输出:

1
2
3
4
5
主目录: /home/admin
配置目录: /home/admin/.myapp
配置文件: /home/admin/.myapp/config.json
临时目录: /tmp
已创建配置目录: /home/admin/.myapp

跨平台工具函数库

在实际运维中,最常见的需求包括软件包管理、服务管理和用户管理。不同平台使用的底层命令各不相同:Windows 用 winget/choco,Linux 用 apt/yum/dnf,macOS 用 brew;服务管理在 Windows 上是 Get-Service,Linux 上是 systemctl,macOS 上是 launchctl。下面这个函数库将这些差异封装为统一的 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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
#region 包管理器抽象

function Install-PlatformPackage {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Name,

[Parameter()]
[switch]$Update
)

if ($IsWindows) {
# 优先使用 winget,回退到 choco
$winget = Get-Command winget -ErrorAction SilentlyContinue
if ($winget) {
$args = @('install', '--id', $Name, '--accept-package-agreements', '--accept-source-agreements')
if ($Update) { $args += '--upgrade' }
if ($PSCmdlet.ShouldProcess($Name, 'Install via winget')) {
& winget @args
}
}
else {
$choco = Get-Command choco -ErrorAction SilentlyContinue
if ($choco) {
if ($PSCmdlet.ShouldProcess($Name, 'Install via chocolatey')) {
& choco install $Name -y
}
}
else {
Write-Error "未找到可用的包管理器(winget 或 chocolatey)"
}
}
}
elseif ($IsMacOS) {
if ($PSCmdlet.ShouldProcess($Name, 'Install via brew')) {
if ($Update) {
& brew upgrade $Name
}
else {
& brew install $Name
}
}
}
elseif ($IsLinux) {
$pkgMgr = Get-LinuxPackageManager
if ($PSCmdlet.ShouldProcess($Name, "Install via $pkgMgr")) {
switch ($pkgMgr) {
'apt' { & sudo apt-get install -y $Name }
'dnf' { & sudo dnf install -y $Name }
'yum' { & sudo yum install -y $Name }
'pacman' { & sudo pacman -S --noconfirm $Name }
default { Write-Error "不支持的包管理器: $pkgMgr" }
}
}
}
}

function Get-LinuxPackageManager {
[CmdletBinding()]
param()

if (Get-Command apt-get -ErrorAction SilentlyContinue) { return 'apt' }
if (Get-Command dnf -ErrorAction SilentlyContinue) { return 'dnf' }
if (Get-Command yum -ErrorAction SilentlyContinue) { return 'yum' }
if (Get-Command pacman -ErrorAction SilentlyContinue) { return 'pacman' }
return $null
}

#endregion

#region 服务管理抽象

function Get-PlatformService {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name
)

if ($IsWindows) {
return Get-Service -Name $Name -ErrorAction SilentlyContinue
}
elseif ($IsLinux) {
$status = systemctl is-active $Name 2>$null
$enabled = systemctl is-enabled $Name 2>$null
return [PSCustomObject]@{
Name = $Name
Status = if ($status -eq 'active') { 'Running' } else { 'Stopped' }
Enabled = ($enabled -eq 'enabled')
}
}
elseif ($IsMacOS) {
$loaded = launchctl list $Name 2>$null
return [PSCustomObject]@{
Name = $Name
Status = if ($loaded) { 'Running' } else { 'Stopped' }
Enabled = [bool]$loaded
}
}
}

#endregion

#region 用户管理抽象

function Get-PlatformUser {
[CmdletBinding()]
param(
[Parameter()]
[string]$UserName = $env:USER
)

if ($IsWindows) {
$adsi = [ADSI]"WinNT://$env:COMPUTERNAME"
$user = $adsi.Children | Where-Object {
$_.SchemaClassName -eq 'User' -and $_.Name -eq $UserName
}
if ($user) {
return [PSCustomObject]@{
Name = $user.Name[0]
FullName = $user.FullName[0]
Description = $user.Description[0]
HomeDir = Join-PlatformPath $env:USERPROFILE $user.Name[0]
Platform = 'Windows'
}
}
}
else {
$passwd = getent passwd $UserName 2>$null
if ($passwd) {
$fields = $passwd -split ':'
return [PSCustomObject]@{
Name = $fields[0]
FullName = ($fields[4] -split ',', 2)[0]
Description = ''
HomeDir = $fields[5]
Shell = $fields[6]
Platform = if ($IsMacOS) { 'macOS' } else { 'Linux' }
}
}
}
}

#endregion

# 使用示例
$platform = Get-PlatformInfo
Write-Host "`n===== 跨平台工具函数演示 ====="
Write-Host "平台: $($platform.Platform)"

$currentUser = Get-PlatformUser
Write-Host "当前用户: $($currentUser.Name)"
Write-Host "主目录: $($currentUser.HomeDir)"

$tempPath = Get-PlatformTempPath
Write-Host "临时目录: $tempPath"

在 Windows 上运行的输出:

1
2
3
4
5
===== 跨平台工具函数演示 =====
平台: Windows
当前用户: admin
主目录: C:\Users\admin\admin
临时目录: C:\Users\admin\AppData\Local\Temp

在 Linux 上运行的输出:

1
2
3
4
5
===== 跨平台工具函数演示 =====
平台: Linux
当前用户: admin
主目录: /home/admin
临时目录: /tmp

注意事项

  1. 始终使用 [System.IO.Path] 类处理路径拼接:不要手动拼接路径分隔符。[System.IO.Path]::Combine() 会自动根据当前平台选择正确的分隔符,从根本上避免路径格式错误。

  2. 利用 $PSVersionTable 做版本检查:跨平台脚本开头建议加上版本守卫,确保运行环境是 PowerShell 7+。例如 if ($PSVersionTable.PSVersion.Major -lt 7) { throw '此脚本需要 PowerShell 7.0 或更高版本' }

  3. 注意 cmdlet 的平台差异:并非所有 cmdlet 在所有平台上都可用。例如 Get-Service 只在 Windows 上可用,Set-Clipboard 在 Linux 上需要 xclip 依赖。编写脚本前用 Get-Command 检查目标 cmdlet 的可用性。

  4. 文件权限模型不同:Windows 使用 ACL(访问控制列表),Linux/macOS 使用 POSIX 权限(rwx)。如果脚本涉及权限设置,需要根据平台分别调用 icacls(Windows)或 chmod/chown(Linux/macOS)。

  5. 环境变量大小写敏感:Windows 的环境变量名不区分大小写,而 Linux/macOS 区分。$env:PATH 在所有平台上都能工作,但自定义环境变量如 $env:MyApp_Home 在 Linux 上必须与设置时完全一致(包括大小写)。

  6. 测试覆盖三大平台:推荐使用 Docker(Linux)、WSL(Linux 交叉测试)和本地 macOS 环境进行实际验证。仅靠阅读代码很难发现所有平台特定问题,自动化测试是最好的保障。有条件的话可以使用 GitHub Actions 的多平台矩阵(os: [windows-latest, ubuntu-latest, macos-latest])实现 CI 级别的跨平台验证。

PowerShell 技能连载 - 配置即代码实践

适用于 PowerShell 7.0 及以上版本

在现代 DevOps 实践中,配置即代码(Configuration as Code, CaC)已经从一种理想化的口号变成了可落地的工程实践。无论是微服务应用的 appsettings.json,还是基础设施的 Terraform 状态文件,配置都以代码的形式被版本化管理、评审和自动化部署。PowerShell 作为 Windows 和跨平台自动化的利器,在配置即代码领域扮演着不可或缺的角色——它能解析、生成、验证和分发各种格式的配置文件。

企业环境中的配置管理面临着独特的挑战:开发、测试、预发布、生产四个环境各有一套参数,机密信息不能明文存储在仓库里,多台服务器的配置必须保持同步且可审计。手动维护这些配置极易出错,一次遗漏就可能导致生产事故。将配置纳入代码管理流程,通过 PowerShell 脚本实现配置的解析、合并、验证和漂移检测,可以有效降低人为失误的风险,让配置变更像代码变更一样可追溯、可回滚。

本文将从三个实战场景出发,演示如何用 PowerShell 构建一套完整的配置即代码工作流:第一,使用 YAML/JSON 作为配置源并通过 Schema 验证保证数据质量;第二,实现配置分层与深度合并,解决多环境配置管理的复杂性;第三,构建配置漂移检测与自动修正机制,确保系统始终处于期望状态。

YAML/JSON 配置模式与 Schema 验证

YAML 以其可读性优势成为配置文件的首选格式。PowerShell 7 原生支持 JSON,而对 YAML 的支持可以通过 powershell-yaml 模块实现。下面的代码演示了如何定义一套配置 Schema 并对配置文件进行自动化验证,确保开发人员提交的配置文件符合规范。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# 定义配置 Schema(JSON Schema 格式)
$schema = @{
'$schema' = 'http://json-schema.org/draft-07/schema#'
title = 'Application Configuration Schema'
type = 'object'
required = @('application', 'server', 'database')
properties = @{
application = @{
type = 'object'
required = @('name', 'version')
properties = @{
name = @{ type = 'string'; minLength = 1 }
version = @{
type = 'string'
pattern = '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'
}
debug = @{ type = 'boolean'; default = $false }
}
}
server = @{
type = 'object'
required = @('port', 'host')
properties = @{
host = @{ type = 'string' }
port = @{ type = 'integer'; minimum = 1; maximum = 65535 }
enableSsl = @{ type = 'boolean'; default = $true }
maxConnection = @{ type = 'integer'; default = 100; minimum = 1 }
}
}
database = @{
type = 'object'
required = @('host', 'name')
properties = @{
host = @{ type = 'string' }
port = @{ type = 'integer'; default = 5432 }
name = @{ type = 'string' }
user = @{ type = 'string' }
password = @{ type = 'string' }
}
}
logging = @{
type = 'object'
properties = @{
level = @{ type = 'string'; enum = @('DEBUG', 'INFO', 'WARN', 'ERROR') }
path = @{ type = 'string' }
rotate = @{ type = 'boolean'; default = $true }
}
}
}
}

# 将 Schema 保存为 JSON 文件
$schemaPath = Join-Path $PSScriptRoot 'config-schema.json'
$schema | ConvertTo-Json -Depth 10 | Set-Content -Path $schemaPath -Encoding UTF8

# 创建示例 YAML 配置文件
$yamlContent = @"
application:
name: OrderService
version: 2.4.1
debug: false
server:
host: 0.0.0.0
port: 8080
enableSsl: true
maxConnection: 200
database:
host: db.internal.local
port: 5432
name: orders_db
user: app_user
password: `${VAULT:database/password}
logging:
level: INFO
path: /var/log/orderservice
rotate: true
"@

$configPath = Join-Path $PSScriptRoot 'config-base.yaml'
$yamlContent | Set-Content -Path $configPath -Encoding UTF8

# Schema 验证函数
function Test-ConfigSchema {
param(
[Parameter(Mandatory)]
[hashtable]$Config,

[Parameter(Mandatory)]
[hashtable]$Schema,

[string]$Path = 'root'
)

$errors = [System.Collections.Generic.List[string]]::new()

# 检查必填字段
if ($Schema.required) {
foreach ($field in $Schema.required) {
if (-not $Config.ContainsKey($field)) {
$errors.Add("[$Path] 缺少必填字段: $field")
}
}
}

# 检查属性类型和约束
if ($Schema.properties) {
foreach ($key in $Config.Keys) {
$propSchema = $Schema.properties[$key]
if (-not $propSchema) {
continue
}
$value = $Config[$key]
$currentPath = "$Path.$key"

if ($propSchema.type -eq 'object' -and $value -is [hashtable]) {
$childErrors = Test-ConfigSchema -Config $value -Schema $propSchema -Path $currentPath
$errors.AddRange($childErrors)
}
elseif ($propSchema.type -eq 'integer') {
if ($value -isnot [int] -and $value -isnot [long]) {
$errors.Add("[$currentPath] 类型错误: 期望 integer,实际为 $($value.GetType().Name)")
}
elseif ($propSchema.minimum -and $value -lt $propSchema.minimum) {
$errors.Add("[$currentPath] 值 $value 小于最小值 $($propSchema.minimum)")
}
elseif ($propSchema.maximum -and $value -gt $propSchema.maximum) {
$errors.Add("[$currentPath] 值 $value 大于最大值 $($propSchema.maximum)")
}
}
elseif ($propSchema.type -eq 'string') {
if ($propSchema.pattern -and $value -notmatch $propSchema.pattern) {
$errors.Add("[$currentPath] 值 '$value' 不匹配模式 '$($propSchema.pattern)'")
}
if ($propSchema.enum -and $value -notin $propSchema.enum) {
$errors.Add("[$currentPath] 值 '$value' 不在允许列表中: $($propSchema.enum -join ', ')")
}
}
elseif ($propSchema.type -eq 'boolean' -and $value -isnot [bool]) {
$errors.Add("[$currentPath] 类型错误: 期望 boolean,实际为 $($value.GetType().Name)")
}
}
}

return $errors
}

# 安装并导入 YAML 模块(如未安装)
if (-not (Get-Module -ListAvailable -Name 'powershell-yaml')) {
Install-Module -Name 'powershell-yaml' -Force -Scope CurrentUser
}
Import-Module 'powershell-yaml'

# 读取配置并验证
$configYaml = Get-Content -Path $configPath -Raw
$parsedConfig = ConvertFrom-Yaml -Yaml $configYaml
$validationErrors = Test-ConfigSchema -Config $parsedConfig -Schema $schema

if ($validationErrors.Count -eq 0) {
Write-Host "配置验证通过" -ForegroundColor Green
} else {
Write-Host "配置验证失败,发现 $($validationErrors.Count) 个错误:" -ForegroundColor Red
$validationErrors | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
}

执行结果示例:

1
配置验证通过

如果配置中存在错误(例如端口超出范围或版本号格式不对),输出如下:

1
2
3
配置验证失败,发现 2 个错误:
- [root.server.port] 值 99999 大于最大值 65535
- [root.application.version] 值 'v2.x' 不匹配模式 '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'

配置分层与深度合并

企业应用通常需要在基础配置之上叠加环境专属配置和机密注入。例如,开发环境使用本地数据库,生产环境使用集群数据库,而数据库密码则从 HashiCorp Vault 或 Azure Key Vault 动态获取。这就要求我们实现配置的分层加载和深度合并(Deep Merge)——当子层级的配置与基础配置有相同键时,子层级应覆盖基础配置的值,而非简单地替换整个节点。

下面的代码实现了一个完整的配置分层合并系统,支持基础配置、环境覆盖和机密注入三个层级。

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
# 深度合并函数:递归合并两个 hashtable
function Merge-ConfigDeep {
param(
[Parameter(Mandatory)]
[hashtable]$Base,

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

$result = @{}
# 复制基础配置的所有键
foreach ($key in $Base.Keys) {
$result[$key] = $Base[$key]
}

# 用覆盖配置进行深度合并
foreach ($key in $Override.Keys) {
if ($result.ContainsKey($key) -and
$result[$key] -is [hashtable] -and
$Override[$key] -is [hashtable]) {
# 两个值都是 hashtable,递归合并
$result[$key] = Merge-ConfigDeep -Base $result[$key] -Override $Override[$key]
}
else {
# 否则直接覆盖
$result[$key] = $Override[$key]
}
}

return $result
}

# 从环境变量或 Vault 注入机密
function Resolve-ConfigSecrets {
param(
[Parameter(Mandatory)]
[hashtable]$Config
)

$secretPattern = '^\$\{VAULT:(.+)\}$'

function Invoke-RecursiveResolve {
param($Node)

if ($Node -is [hashtable]) {
$resolved = @{}
foreach ($key in $Node.Keys) {
$resolved[$key] = Invoke-RecursiveResolve -Node $Node[$key]
}
return $resolved
}
elseif ($Node -is [string] -and $Node -match $secretPattern) {
$secretPath = $Matches[1]
# 优先从环境变量读取
$envKey = $secretPath -replace '[/\\:.]', '_'
$envValue = [System.Environment]::GetEnvironmentVariable($envKey)
if ($envValue) {
Write-Verbose "从环境变量解析机密: $secretPath"
return $envValue
}
# 回退到模拟 Vault(实际环境中对接真实 Vault API)
Write-Verbose "从 Vault 解析机密: $secretPath"
return "[VAULT-RESOLVED:$secretPath]"
}
elseif ($Node -is [array]) {
return @($Node | ForEach-Object { Invoke-RecursiveResolve -Node $_ })
}
return $Node
}

return Invoke-RecursiveResolve -Node $Config
}

# 基础配置(所有环境共享)
$baseConfig = @{
application = @{
name = 'OrderService'
version = '2.4.1'
debug = $false
}
server = @{
host = '0.0.0.0'
port = 8080
enableSsl = $true
maxConnection = 100
}
database = @{
host = 'db.internal.local'
port = 5432
name = 'orders_db'
user = 'app_user'
password = '${VAULT:database/password}'
}
logging = @{
level = 'INFO'
path = '/var/log/orderservice'
rotate = $true
}
}

# 生产环境覆盖配置
$productionOverride = @{
server = @{
host = '10.0.1.50'
port = 443
enableSsl = $true
maxConnection = 500
}
database = @{
host = 'prod-db-cluster.internal.local'
user = 'prod_app_user'
}
logging = @{
level = 'WARN'
path = '/var/log/orderservice/prod'
}
}

# 开发环境覆盖配置
$devOverride = @{
application = @{
debug = $true
}
server = @{
host = 'localhost'
port = 3000
enableSsl = $false
maxConnection = 10
}
database = @{
host = 'localhost'
name = 'orders_db_dev'
}
logging = @{
level = 'DEBUG'
path = './logs'
}
}

# 构建不同环境的最终配置
$environments = @{
production = Merge-ConfigDeep -Base $baseConfig -Override $productionOverride
development = Merge-ConfigDeep -Base $baseConfig -Override $devOverride
}

foreach ($env in $environments.Keys) {
Write-Host "`n=== $env 环境 ===" -ForegroundColor Cyan
$resolvedConfig = Resolve-ConfigSecrets -Config $environments[$env]
$resolvedConfig | ConvertTo-Json -Depth 5
}

执行结果示例:

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
=== production 环境 ===
{
"application": {
"name": "OrderService",
"version": "2.4.1",
"debug": false
},
"server": {
"host": "10.0.1.50",
"port": 443,
"enableSsl": true,
"maxConnection": 500
},
"database": {
"host": "prod-db-cluster.internal.local",
"port": 5432,
"name": "orders_db",
"user": "prod_app_user",
"password": "[VAULT-RESOLVED:database/password]"
},
"logging": {
"level": "WARN",
"path": "/var/log/orderservice/prod",
"rotate": true
}
}

=== development 环境 ===
{
"application": {
"name": "OrderService",
"version": "2.4.1",
"debug": true
},
"server": {
"host": "localhost",
"port": 3000,
"enableSsl": false,
"maxConnection": 10
},
"database": {
"host": "localhost",
"port": 5432,
"name": "orders_db_dev",
"user": "app_user",
"password": "[VAULT-RESOLVED:database/password]"
},
"logging": {
"level": "DEBUG",
"path": "./logs",
"rotate": true
}
}

配置漂移检测与自动修正

配置漂移(Configuration Drift)是指系统实际运行状态逐渐偏离期望配置的现象。它可能由手动修改、补丁更新、临时调试等多种原因引起,是导致”在我机器上能跑”问题的根源之一。对于成熟的运维体系来说,检测和修正配置漂移是保障环境一致性的关键能力。

下面的代码实现了一个配置漂移检测框架,它会将期望配置与系统实际状态进行逐项对比,生成漂移报告,并支持自动修正和审计日志记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# 配置漂移检测与修正引擎
class ConfigDriftDetector {
[string]$ConfigName
[hashtable]$DesiredState
[System.Collections.Generic.List[hashtable]]$Drifts
[System.Collections.Generic.List[hashtable]]$AuditLog

ConfigDriftDetector([string]$Name, [hashtable]$Desired) {
$this.ConfigName = $Name
$this.DesiredState = $Desired
$this.Drifts = [System.Collections.Generic.List[hashtable]]::new()
$this.AuditLog = [System.Collections.Generic.List[hashtable]]::new()
}

# 递归对比期望状态与实际状态
[void] CompareState(
[hashtable]$Desired,
[hashtable]$Actual,
[string]$Path
) {
foreach ($key in $Desired.Keys) {
$currentPath = if ($Path) { "$Path.$key" } else { $key }

if (-not $Actual.ContainsKey($key)) {
$this.Drifts.Add(@{
Path = $currentPath
Type = 'Missing'
Desired = $Desired[$key]
Actual = $null
Severity = 'High'
})
continue
}

$desiredVal = $Desired[$key]
$actualVal = $Actual[$key]

if ($desiredVal -is [hashtable] -and $actualVal -is [hashtable]) {
$this.CompareState($desiredVal, $actualVal, $currentPath)
}
elseif ($desiredVal -is [array] -and $actualVal -is [array]) {
$diff = Compare-Object $desiredVal $actualVal
if ($diff) {
$this.Drifts.Add(@{
Path = $currentPath
Type = 'ArrayDiff'
Desired = ($desiredVal -join ', ')
Actual = ($actualVal -join ', ')
Severity = 'Medium'
})
}
}
elseif ("$desiredVal" -ne "$actualVal") {
$severity = if ($currentPath -match 'password|secret|key') {
'Critical'
} elseif ($currentPath -match 'debug|log') {
'Low'
} else {
'Medium'
}
$this.Drifts.Add(@{
Path = $currentPath
Type = 'ValueMismatch'
Desired = "$desiredVal"
Actual = "$actualVal"
Severity = $severity
})
}
}
}

# 生成漂移报告
[string] GenerateReport() {
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("配置漂移报告 - $($this.ConfigName)")
[void]$sb.AppendLine("生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
[void]$sb.AppendLine("漂移项数: $($this.Drifts.Count)")
[void]$sb.AppendLine('---')

if ($this.Drifts.Count -eq 0) {
[void]$sb.AppendLine('未检测到配置漂移,系统状态正常。')
}
else {
$severityOrder = @{ Critical = 0; High = 1; Medium = 2; Low = 3 }
$sorted = $this.Drifts | Sort-Object { $severityOrder[$_.Severity] }

foreach ($drift in $sorted) {
[void]$sb.AppendLine(
"[$($drift.Severity)] $($drift.Path)"
)
[void]$sb.AppendLine(
" 期望: $($drift.Desired) | 实际: $($drift.Actual)"
)
[void]$sb.AppendLine(" 类型: $($drift.Type)")
[void]$sb.AppendLine()
}
}
return $sb.ToString()
}

# 自动修正漂移并记录审计日志
[void] Remediate([hashtable]$ActualRef) {
foreach ($drift in $this.Drifts) {
$pathParts = $drift.Path.Split('.')
$current = $ActualRef

# 导航到父级节点
for ($i = 0; $i -lt $pathParts.Count - 1; $i++) {
$current = $current[$pathParts[$i]]
}

$leafKey = $pathParts[-1]
$oldValue = $current[$leafKey]
$current[$leafKey] = $drift.Desired

$this.AuditLog.Add(@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Path = $drift.Path
OldValue = "$oldValue"
NewValue = "$($drift.Desired)"
Severity = $drift.Severity
Action = 'AutoRemediate'
})
}
}
}

# 定义期望配置
$desiredConfig = @{
application = @{
name = 'OrderService'
version = '2.4.1'
debug = $false
}
server = @{
host = '10.0.1.50'
port = 443
enableSsl = $true
maxConnection = 500
}
database = @{
host = 'prod-db-cluster.internal.local'
port = 5432
}
}

# 模拟当前实际配置(存在漂移)
$actualConfig = @{
application = @{
name = 'OrderService'
version = '2.3.0' # 版本落后
debug = $true # 调试模式未关闭
}
server = @{
host = '10.0.1.50'
port = 8080 # 端口错误
enableSsl = $false # SSL 被关闭
maxConnection = 500
}
database = @{
host = 'prod-db-cluster.internal.local'
port = 5432
}
}

# 执行漂移检测
$detector = [ConfigDriftDetector]::new('Production-OrderService', $desiredConfig)
$detector.CompareState($desiredConfig, $actualConfig, '')
Write-Host $detector.GenerateReport()

# 执行自动修正
Write-Host "`n正在执行自动修正..." -ForegroundColor Yellow
$detector.Remediate($actualConfig)

# 输出审计日志
Write-Host "`n审计日志:" -ForegroundColor Cyan
$detector.AuditLog | 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
26
27
28
29
30
31
配置漂移报告 - Production-OrderService
生成时间: 2026-04-28 10:30:00
漂移项数: 4
---
[Critical] server.enableSsl
期望: True | 实际: False
类型: ValueMismatch

[High] application.version
期望: 2.4.1 | 实际: 2.3.0
类型: ValueMismatch

[Medium] server.port
期望: 443 | 实际: 8080
类型: ValueMismatch

[Low] application.debug
期望: False | 实际: True
类型: ValueMismatch


正在执行自动修正...

审计日志:

Timestamp Path OldValue NewValue Severity Action
--------- ---- -------- -------- -------- ------
2026-04-28 10:30:00 application.version 2.3.0 2.4.1 High AutoRemediate
2026-04-28 10:30:00 application.debug True False Low AutoRemediate
2026-04-28 10:30:00 server.port 8080 443 Medium AutoRemediate
2026-04-28 10:30:00 server.enableSsl False True Critical AutoRemediate

注意事项

  1. Schema 验证应在 CI 流水线中强制执行。将配置文件的 Schema 验证作为 Pull Request 的必经检查项,可以在配置进入主分支之前就拦截格式错误和遗漏字段,避免无效配置扩散到下游环境。

  2. 深度合并时注意数组合并策略。本文实现的深度合并对数组采用直接覆盖策略。如果业务要求数组进行并集合并(如白名单列表),需要在 Merge-ConfigDeep 函数中为数组类型添加专门的合并逻辑。

  3. 机密注入应与配置文件分离存储。示例中的 ${VAULT:path} 占位符机制确保机密不会出现在代码仓库中。生产环境中应对接 HashiCorp Vault、Azure Key Vault 或 AWS Secrets Manager 等专业机密管理工具,而非依赖环境变量。

  4. 漂移检测的频率需要根据业务场景调整。关键生产系统建议每小时检测一次,并配合告警通知(如 PagerDuty、企业微信)。非关键系统可以每天检测一次,配合每日巡检报告。

  5. 自动修正需设置白名单和审批机制。并非所有漂移都应该被自动修正——某些漂移可能是有意的应急措施。建议对修正操作设置严重级别白名单,High 和 Critical 级别自动修正,其他级别仅生成告警,由运维人员确认后再修正。

  6. 审计日志应持久化到不可篡改的存储。配置变更的审计日志是故障排查和安全合规的重要依据。建议将日志写入数据库(如 Elasticsearch)或对象存储(如 S3),并启用版本控制和防篡改保护。

PowerShell 技能连载 - Microsoft Graph API 高级操作

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

Microsoft Graph 已经成为访问 Microsoft 365 和 Entra ID 数据的统一 API 网关。无论是用户管理、邮件处理、团队协作还是安全合规,几乎所有 Microsoft 云服务的数据和操作都可以通过 Graph API 完成。对于 PowerShell 运维人员来说,掌握 Graph API 的高级用法意味着能够构建更高效、更可靠的自动化流程。

然而,在实际生产环境中,简单的 API 调用往往不够。当需要处理成千上万个用户对象、执行批量许可分配、或者生成复杂的安全审计报告时,必须考虑认证效率、请求优化、分页处理和错误重试等工程化问题。本文将围绕这些高级场景,分享一套可直接用于生产环境的 Graph API 操作模式。

Graph API 认证与查询优化

在规模化操作中,认证方式和查询策略直接影响执行效率。以下代码展示了应用权限认证、分页自动处理、$select/$filter 查询优化以及批量请求(Batch)的完整实现。

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
function Connect-GraphApp {
param(
[Parameter(Mandatory)]
[string]$TenantId,
[Parameter(Mandatory)]
[string]$ClientId,
[Parameter(Mandatory)]
[string]$CertificateThumbprint
)

Connect-MgGraph -TenantId $TenantId `
-ClientId $ClientId `
-CertificateThumbprint $CertificateThumbprint `
-NoWelcome

$context = Get-MgContext
Write-Host "已连接: $($context.AppName) @ $($context.TenantId)" -ForegroundColor Green
}

function Invoke-GraphPagedQuery {
param(
[Parameter(Mandatory)]
[string]$Uri,
[int]$PageSize = 100,
[string[]]$Select,
[string]$Filter
)

$params = @()
if ($PageSize) { $params += "`$top=$PageSize" }
if ($Select) { $params += "`$select=$($Select -join ',')" }
if ($Filter) { $params += "`$filter=$Filter" }

$queryString = if ($params) { '?' + ($params -join '&') } else { '' }
$fullUri = if ($Uri -match '\?') { "$Uri&$($params -join '&')" } else { "$Uri$queryString" }

$allResults = [System.Collections.Generic.List[object]]::new()
$currentUri = $fullUri
$requestCount = 0

while ($currentUri) {
$requestCount++
$response = Invoke-MgGraphRequest -Uri $currentUri -Method GET -OutputType Hashtable

if ($response.value) {
$allResults.AddRange($response.value)
}

$currentUri = $response.'@odata.nextLink'
Write-Verbose "已获取 $($allResults.Count) 条记录 (请求 #$requestCount)"
}

Write-Host "查询完成: 共 $($allResults.Count) 条, 发送 $requestCount 次请求" -ForegroundColor Cyan
return $allResults
}

function Invoke-GraphBatch {
param(
[Parameter(Mandatory)]
[hashtable[]]$Requests,
[int]$BatchSize = 20
)

$batches = for ($i = 0; $i -lt $Requests.Count; $i += $BatchSize) {
,($Requests[$i..([Math]::Min($i + $BatchSize - 1, $Requests.Count - 1))])
}

$allResponses = @()
$batchNum = 0

foreach ($batch in $batches) {
$batchNum++
$batchBody = @{
requests = @($batch | ForEach-Object -Begin { $id = 0 } -Process {
$id++
@{
id = "$id"
method = $_.Method ?? 'GET'
url = $_.Url
body = $_.Body
headers = @{ 'Content-Type' = 'application/json' }
}
})
}

$response = Invoke-MgGraphRequest `
-Uri 'https://graph.microsoft.com/v1.0/$batch' `
-Method POST `
-Body ($batchBody | ConvertTo-Json -Depth 5)

$allResponses += $response.responses
Write-Verbose "批次 $batchNum/$($batches.Count) 完成"
}

return $allResponses
}

执行结果示例:

1
2
3
4
5
6
7
已连接: MyApp-Graph@ 72f988bf-86f1-41af-91ab-2d7cd011db47
查询完成: 共 2847 条, 发送 29 次请求
批次 1/5 完成
批次 2/5 完成
批次 3/5 完成
批次 4/5 完成
批次 5/5 完成

通过 $select 只返回需要的字段可以显著减少响应体积,$filter 在服务端完成过滤避免传输冗余数据。批量请求(Batch)最多可在一个 HTTP 调用中打包 20 个操作,大幅降低网络开销。

用户与组批量管理

在大型组织中,用户入职、离职和部门调动都涉及批量操作。以下代码实现了批量用户创建与更新、组生命周期管理以及许可(License)分配的完整流程。

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
function New-BulkUsers {
param(
[Parameter(Mandatory)]
[array]$UserList,
[string]$Domain = 'contoso.com'
)

$results = @()

foreach ($user in $UserList) {
$mailNickname = "$($user.GivenName).$($user.Surname)".ToLower() -replace '\s', ''
$upn = "$mailNickname@$Domain"
$tempPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 16 | ForEach-Object { [char]$_ })

$passwordProfile = @{
password = $tempPassword
forceChangePasswordNextSignIn = $true
}

try {
$newUser = New-MgUser -BodyParameter @{
accountEnabled = $true
displayName = "$($user.GivenName) $($user.Surname)"
givenName = $user.GivenName
surname = $user.Surname
mailNickname = $mailNickname
userPrincipalName = $upn
passwordProfile = $passwordProfile
department = $user.Department
jobTitle = $user.JobTitle
usageLocation = $user.UsageLocation ?? 'CN'
} -ErrorAction Stop

$results += [PSCustomObject]@{
Status = 'Created'
UPN = $upn
UserId = $newUser.Id
TempPass = $tempPassword
}
Write-Verbose "已创建用户: $upn"
}
catch {
$results += [PSCustomObject]@{
Status = 'Failed'
UPN = $upn
UserId = $null
TempPass = $null
Error = $_.Exception.Message
}
Write-Warning "创建用户失败 $upn : $($_.Exception.Message)"
}
}

return $results
}

function Set-GroupLifecycle {
param(
[Parameter(Mandatory)]
[string]$GroupId,
[ValidateSet('Archive', 'Reactivate', 'RotateOwner')]
[string]$Action,
[string]$NewOwnerId
)

$group = Get-MgGroup -GroupId $GroupId
if (-not $group) { throw "组 $GroupId 不存在" }

switch ($Action) {
'Archive' {
Update-MgGroup -GroupId $GroupId -BodyParameter @{
description = "[已归档] $($group.Description)"
mailEnabled = $false
visibility = 'HiddenMembership'
}
Write-Host "已归档组: $($group.DisplayName)" -ForegroundColor Yellow
}
'Reactivate' {
Update-MgGroup -GroupId $GroupId -BodyParameter @{
mailEnabled = $true
visibility = 'Public'
}
Write-Host "已重新激活组: $($group.DisplayName)" -ForegroundColor Green
}
'RotateOwner' {
if (-not $NewOwnerId) { throw "更换所有者需要提供 NewOwnerId" }
$currentOwners = Get-MgGroupOwner -GroupId $GroupId
foreach ($owner in $currentOwners) {
Remove-MgGroupOwnerByRef -GroupId $GroupId -DirectoryObjectId $owner.Id
}
New-MgGroupOwnerByRef -GroupId $GroupId -BodyParameter @{
'@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$NewOwnerId"
}
Write-Host "已更换组 $($group.DisplayName) 的所有者为 $NewOwnerId" -ForegroundColor Green
}
}
}

function Set-BulkLicenseAssignment {
param(
[Parameter(Mandatory)]
[string[]]$UserIds,
[Parameter(Mandatory)]
[string[]]$SkuIds,
[bool]$Remove = $false
)

$total = $UserIds.Count
$processed = 0
$successCount = 0
$failCount = 0

foreach ($userId in $UserIds) {
$processed++
$addLicenses = @()
$removeLicenses = @()

if ($Remove) {
$removeLicenses = @($SkuIds | ForEach-Object { @{ skuId = $_ } })
}
else {
$addLicenses = @($SkuIds | ForEach-Object {
@{ skuId = $_; disabledPlans = @() }
})
}

try {
Set-MgUserLicense -UserId $userId `
-AddLicenses $addLicenses `
-RemoveLicenses $removeLicenses `
-ErrorAction Stop
$successCount++
}
catch {
$failCount++
Write-Verbose "许可操作失败 ($userId): $($_.Exception.Message)"
}

if ($processed % 50 -eq 0) {
Write-Progress -Activity "许可分配" -Status "$processed / $total" `
-PercentComplete (($processed / $total) * 100)
}
}

return [PSCustomObject]@{
Total = $total
Success = $successCount
Failed = $failCount
Action = if ($Remove) { 'Remove' } else { 'Assign' }
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建用户: zhang.wei@contoso.com
已创建用户: li.na@contoso.com
创建用户失败 wang.fang@contoso.com : Another object with the same value for property
userPrincipalName already exists.
已归档组: 2024-Q3-项目组
已更换组 安全审计组 的所有者为 3a5b7c9d-1234-5678-abcd-ef0123456789

Total : 150
Success : 147
Failed : 3
Action : Assign

批量操作中需要注意 Graph API 的节流限制(Throttling)。用户管理操作通常限制在每 30 秒若干次请求,当返回 429 状态码时应实现指数退避重试。

安全与合规报告

安全运维是 Graph API 的高价值场景之一。通过查询 Entra ID 的登录日志、风险检测和条件访问策略,可以构建自动化的安全态势报告。

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
function Get-SignInAuditReport {
param(
[int]$Days = 7,
[string]$RiskFilter
)

$startDate = (Get-Date).AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
$filterQuery = "createdDateTime ge $startDate"

if ($RiskFilter) {
$filterQuery += " and riskLevelDuringSignIn eq '$RiskFilter'"
}

$signIns = Invoke-GraphPagedQuery `
-Uri 'https://graph.microsoft.com/v1.0/auditLogs/signIns' `
-Select @('createdDateTime', 'userDisplayName', 'userPrincipalName',
'ipAddress', 'location', 'status', 'riskLevelAggregation',
'clientAppUsed', 'conditionalAccessStatus') `
-Filter $filterQuery `
-PageSize 100

$summary = $signIns | Group-Object userPrincipalName | ForEach-Object {
$userSignIns = $_.Group
$failedCount = @($userSignIns | Where-Object {
$_.status.errorCode -and $_.status.errorCode -ne 0
}).Count

[PSCustomObject]@{
UserPrincipalName = $_.Name
TotalSignIns = $_.Count
FailedAttempts = $failedCount
UniqueIPs = @($userSignIns.ipAddress | Sort-Object -Unique).Count
RiskEvents = @($userSignIns | Where-Object {
$_.riskLevelAggregation -and $_.riskLevelAggregation -ne 'none'
}).Count
LastSignIn = @($userSignIns.createdDateTime | Sort-Object -Descending)[0]
Locations = @($userSignIns | ForEach-Object {
$_.location.city
} | Where-Object { $_ } | Sort-Object -Unique) -join ', '
}
} | Sort-Object FailedAttempts -Descending

return $summary
}

function Get-RiskDetectionReport {
param(
[int]$Days = 30
)

$startDate = (Get-Date).AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')

$detections = Invoke-GraphPagedQuery `
-Uri 'https://graph.microsoft.com/v1.0/identityProtection/riskDetections' `
-Select @('riskEventType', 'riskLevel', 'riskState', 'detectedDateTime',
'userDisplayName', 'userPrincipalName', 'ipAddress',
'location', 'activity') `
-Filter "detectedDateTime ge $startDate" `
-PageSize 100

$report = [PSCustomObject]@{
Period = "$Days 天"
TotalDetections = $detections.Count
HighRiskCount = @($detections | Where-Object { $_.riskLevel -eq 'high' }).Count
MediumRiskCount = @($detections | Where-Object { $_.riskLevel -eq 'medium' }).Count
ByType = $detections | Group-Object riskEventType |
Sort-Object Count -Descending |
Select-Object Count, Name -First 10
AffectedUsers = @($detections.userPrincipalName | Sort-Object -Unique).Count
TopRiskUsers = $detections | Where-Object { $_.riskLevel -eq 'high' } |
Group-Object userPrincipalName |
Sort-Object Count -Descending |
Select-Object Count, Name -First 10
Remediated = @($detections | Where-Object {
$_.riskState -eq 'remediated'
}).Count
}

return $report
}

function Get-ConditionalAccessAudit {
param()

$policies = Get-MgIdentityConditionalAccessPolicy -All

$audit = foreach ($policy in $policies) {
$grantControls = $policy.GrantControls
$conditions = $policy.Conditions

$includedApps = @()
if ($conditions.Applications.IncludeApplications) {
$includedApps = $conditions.Applications.IncludeApplications
}

[PSCustomObject]@{
DisplayName = $policy.DisplayName
State = $policy.State
CreatedDateTime = $policy.CreatedDateTime
ModifiedDateTime = $policy.ModifiedDateTime
IncludedApps = if ($includedApps -contains 'All') {
'All applications'
} else {
"$($includedApps.Count) apps"
}
IncludedUsers = if ($conditions.Users.IncludeUsers -contains 'All') {
'All users'
} else {
"$($conditions.Users.IncludeUsers.Count) users"
}
GrantControl = $grantControls.BuiltInControls -join ', '
Operator = $grantControls.Operator
ClientAppTypes = $conditions.ClientAppTypes -join ', '
SignInRiskLevels = $conditions.SignInRiskLevels -join ', '
}
}

$summary = [PSCustomObject]@{
TotalPolicies = $policies.Count
EnabledPolicies = @($audit | Where-Object { $_.State -eq 'enabled' }).Count
ReportOnly = @($audit | Where-Object { $_.State -eq 'enabledForReportingButNotEnforced' }).Count
Disabled = @($audit | Where-Object { $_.State -eq 'disabled' }).Count
Policies = $audit
}

return $summary
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
查询完成: 共 15632 条, 发送 157 次请求
查询完成: 共 423 条, 发送 5 次请求

UserPrincipalName : admin@contoso.com
TotalSignIns : 89
FailedAttempts : 12
UniqueIPs : 5
RiskEvents : 3
LastSignIn : 2026-04-27T06:32:15Z
Locations : Beijing, Shanghai, Unknown

Period : 30 天
TotalDetections : 423
HighRiskCount : 18
MediumRiskCount : 67
AffectedUsers : 42
Remediated : 156

TotalPolicies : 15
EnabledPolicies : 11
ReportOnly : 2
Disabled : 2

安全报告函数可以与计划任务(Scheduled Task)或 Azure Automation Runbook 结合,实现每日自动生成安全态势摘要并通过 Teams Webhook 或邮件发送给安全团队。

注意事项

  1. 认证方式选择:生产环境应使用应用权限(Application Permission)配合证书认证,避免交互式登录。证书建议使用 Azure Key Vault 托管,定期自动轮换。

  2. API 节流处理:Graph API 对不同端点有不同的节流限制。遇到 429 响应时必须读取 Retry-After 头部并等待指定时间,切勿简单循环重试,否则会加剧限流。

  3. 分页最佳实践:对于可能返回大量结果的查询,始终使用 $top 控制每页大小,并跟随 @odata.nextLink 逐页获取。避免在内存中缓存全量数据后再处理。

  4. 批量请求限制:单个 Batch 请求最多包含 20 个子请求,且不支持嵌套 Batch。对于超大批量操作,应分批次提交并在批次之间加入适当延迟。

  5. 许可分配注意事项:分配许可前务必确认用户的 usageLocation 属性已设置,否则操作会失败。同时要注意许可计划的依赖关系,某些高级许可需要基础许可作为前提。

  6. 日志保留期限:Entra ID 登录日志默认保留 30 天(Premium P2 许可),风险检测数据保留同样有限。如需长期分析,应将数据导出到 Log Analytics 工作区或外部存储。

PowerShell 技能连载 - Azure App Service 部署槽管理

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

部署槽:零停机部署的基石

在现代云原生应用的运维实践中,零停机部署已经成为一项基本要求。传统的”停机发布”模式不仅影响用户体验,还可能导致流量丢失和请求中断。Azure App Service 提供的部署槽(Deployment Slots)功能,正是解决这一问题的利器。

部署槽本质上是应用服务内部的独立运行环境,每个槽拥有自己的主机名、应用设置和连接字符串。通过在”暂存槽(Staging Slot)”完成新版本的部署与预热验证,再将流量无缝切换到生产槽,即可实现蓝绿部署(Blue-Green Deployment)。整个过程对终端用户完全透明,不会出现任何服务中断。

本文将介绍如何使用 PowerShell 和 Az 模块,对 Azure App Service 部署槽进行全生命周期管理,涵盖槽的创建、蓝绿部署自动化、多环境配置与预热验证等核心场景。

部署槽生命周期管理

第一个示例演示部署槽的完整生命周期操作:创建新槽、从已有槽克隆配置、查看所有槽的状态,以及配置流量路由比例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 连接 Azure 账户并选择订阅
Connect-AzAccount
Set-AzContext -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

$ResourceGroup = 'rg-myapp-prod'
$AppName = 'app-myapp-prod'

# 查看当前应用的所有部署槽
Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName |
Select-Object Name, State, DefaultHostName, TrafficManagerPolicies

# 创建新的 Staging 槽,从 Production 克隆配置
New-AzWebAppSlot `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-ConfigurationSource (Get-AzWebApp -ResourceGroupName $ResourceGroup -Name $AppName)

# 为 Staging 槽单独设置应用配置(不与生产槽共享)
$StagingSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName -Slot 'staging'
$StagingSlot.SiteConfig.AppSettings | ForEach-Object {
Write-Host " $($_.Name) = $($_.Value)"
}

# 配置流量路由:将 10% 的流量发送到 Staging 槽用于金丝雀测试
Set-AzWebAppTrafficRouting `
-ResourceGroupName $ResourceGroup `
-WebAppName $AppName `
-RoutingRule @{ ActionHostName = "$AppName-staging.azurewebsites.net"; ReroutePercentage = 10 }

# 查看当前流量路由配置
Get-AzWebAppTrafficRouting -ResourceGroupName $ResourceGroup -WebAppName $AppName

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
Name     State  DefaultHostName                        TrafficManagerPolicies
---- ----- --------------- ----------------------
Production Running app-myapp-prod.azurewebsites.net
staging Running app-myapp-prod-staging.azurewebsites.net

ASPNETCORE_ENVIRONMENT = Staging
APP_VERSION = 2.1.0
API_ENDPOINT = https://api-staging.example.com

ActionHostName ReroutePercentage
-------------- -----------------
app-myapp-prod-staging.azurewebsites.net 10

蓝绿部署自动化

蓝绿部署是部署槽最核心的使用场景。下面的脚本封装了一个完整的蓝绿部署流程:部署到 Staging 槽、执行预热验证、将流量切换到 Staging、验证生产环境稳定后删除旧的生产版本。

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

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

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

[int]$WarmupTimeoutSeconds = 120,

[int]$HealthCheckRetries = 5
)

$StagingUrl = "https://$AppName-staging.azurewebsites.net"
$ProdUrl = "https://$AppName.azurewebsites.net"

# 步骤 1:部署新版本到 Staging 槽
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 正在部署到 Staging 槽..." -ForegroundColor Cyan
Publish-AzWebApp `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-ArchivePath $PackagePath

# 步骤 2:预热并验证 Staging 槽健康状态
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 预热 Staging 槽..." -ForegroundColor Cyan
$Healthy = $false
for ($i = 1; $i -le $HealthCheckRetries; $i++) {
try {
$Response = Invoke-WebRequest -Uri "$StagingUrl/health" -TimeoutSec 10 -UseBasicParsing
if ($Response.StatusCode -eq 200) {
$Healthy = $true
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Staging 槽健康检查通过 (尝试 $i/$HealthCheckRetries)" -ForegroundColor Green
break
}
} catch {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 健康检查失败,等待重试 (尝试 $i/$HealthCheckRetries)..." -ForegroundColor Yellow
Start-Sleep -Seconds ([Math]::Min(10 * $i, 30))
}
}

if (-not $Healthy) {
throw "Staging 槽预热失败,终止部署"
}

# 步骤 3:执行槽位交换(Staging → Production)
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 正在交换槽位..." -ForegroundColor Cyan
Invoke-AzResourceAction `
-ResourceGroupName $ResourceGroup `
-ResourceType 'Microsoft.Web/sites/slots' `
-ResourceName "$AppName/staging" `
-Action 'slotsswap' `
-Parameters @{ targetSlot = 'production'; preserveVnet = $true } `
-Force

# 步骤 4:验证生产环境
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 验证生产环境..." -ForegroundColor Cyan
$ProdResponse = Invoke-WebRequest -Uri "$ProdUrl/health" -TimeoutSec 10 -UseBasicParsing
if ($ProdResponse.StatusCode -eq 200) {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 部署完成!生产环境正常运行" -ForegroundColor Green
} else {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 生产环境异常,准备回滚..." -ForegroundColor Red
# 立即交换回 Staging(旧版本仍在 Staging 槽中)
Invoke-AzResourceAction `
-ResourceGroupName $ResourceGroup `
-ResourceType 'Microsoft.Web/sites/slots' `
-ResourceName "$AppName/staging" `
-Action 'slotsswap' `
-Parameters @{ targetSlot = 'production'; preserveVnet = $true } `
-Force
throw "部署后验证失败,已回滚到上一版本"
}
}

# 调用示例
Invoke-BlueGreenDeployment `
-ResourceGroup 'rg-myapp-prod' `
-AppName 'app-myapp-prod' `
-PackagePath './publish/app-myapp-2.2.0.zip' `
-HealthCheckRetries 5

执行结果示例:

1
2
3
4
5
6
7
[10:15:32] 正在部署到 Staging 槽...
[10:15:58] 预热 Staging 槽...
[10:16:05] 健康检查失败,等待重试 (尝试 1/5)...
[10:16:25] Staging 槽健康检查通过 (尝试 2/5)
[10:16:25] 正在交换槽位...
[10:16:48] 验证生产环境...
[10:16:49] 部署完成!生产环境正常运行

多环境配置与预热验证

实际生产中,不同部署槽往往需要差异化的配置。下面的脚本展示了如何为每个槽配置独立的应用设置、连接字符串,并在部署前执行完整的预热验证流程。

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
# 定义各槽的差异化配置
$SlotConfigs = @{
staging = @{
AppSettings = @{
ASPNETCORE_ENVIRONMENT = 'Staging'
APP_VERSION = '2.2.0'
API_ENDPOINT = 'https://api-staging.example.com'
LOG_LEVEL = 'Debug'
FEATURE_FLAGS = '{"NewDashboard":true,"BetaAPI":true}'
}
ConnectionStrings = @{
DefaultConnection = 'Server=tcp:staging-sql.database.windows.net,1433;Database=myapp_staging;'
RedisCache = 'staging-redis.redis.cache.windows.net:6380,abortConnect=False'
}
}
production = @{
AppSettings = @{
ASPNETCORE_ENVIRONMENT = 'Production'
APP_VERSION = '2.2.0'
API_ENDPOINT = 'https://api.example.com'
LOG_LEVEL = 'Warning'
FEATURE_FLAGS = '{"NewDashboard":false,"BetaAPI":false}'
}
ConnectionStrings = @{
DefaultConnection = 'Server=tcp:prod-sql.database.windows.net,1433;Database=myapp_prod;'
RedisCache = 'prod-redis.redis.cache.windows.net:6380,abortConnect=False'
}
}
}

$ResourceGroup = 'rg-myapp-prod'
$AppName = 'app-myapp-prod'

# 应用 Staging 槽的独立配置
$StagingSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName -Slot 'staging'

# 设置"槽位粘性"配置(不随槽交换而移动的设置)
$SlotSettingNames = @('ASPNETCORE_ENVIRONMENT', 'API_ENDPOINT', 'LOG_LEVEL', 'FEATURE_FLAGS')
$appSettingsList = @()
foreach ($Key in $SlotConfigs.staging.AppSettings.Keys) {
$appSettingsList += @{
Name = $Key
Value = $SlotConfigs.staging.AppSettings[$Key]
}
}

Set-AzWebAppSlot `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-AppSettings $appSettingsList

# 部署前预热验证:检查关键页面和 API 端点
$StagingBaseUrl = "https://$AppName-staging.azurewebsites.net"
$Endpoints = @(
@{ Name = '健康检查'; Path = '/health' },
@{ Name = '就绪检查'; Path = '/health/ready' },
@{ Name = '首页'; Path = '/' },
@{ Name = 'API 版本'; Path = '/api/version' }
)

Write-Host "`n=== Staging 槽预热验证 ===" -ForegroundColor Cyan
$AllPassed = $true
foreach ($Endpoint in $Endpoints) {
$Url = "$StagingBaseUrl$($Endpoint.Path)"
try {
$Response = Invoke-WebRequest -Uri $Url -TimeoutSec 15 -UseBasicParsing
$Status = if ($Response.StatusCode -eq 200) { 'PASS' } else { "WARN ($($Response.StatusCode))" }
$Color = if ($Response.StatusCode -eq 200) { 'Green' } else { 'Yellow' }
} catch {
$Status = "FAIL ($($_.Exception.Message.Split([Environment]::NewLine)[0]))"
$Color = 'Red'
$AllPassed = $false
}
Write-Host " [$Status] $($Endpoint.Name) ($($Endpoint.Path))" -ForegroundColor $Color
}

if ($AllPassed) {
Write-Host "`n所有端点验证通过,可以安全地进行槽位交换。" -ForegroundColor Green
} else {
Write-Host "`n存在验证失败的端点,请检查后再部署!" -ForegroundColor Red
}

执行结果示例:

1
2
3
4
5
6
7
=== Staging 槽预热验证 ===
[PASS] 健康检查 (/health)
[PASS] 就绪检查 (/health/ready)
[PASS] 首页 (/)
[PASS] API 版本 (/api/version)

所有端点验证通过,可以安全地进行槽位交换。

注意事项

  • 槽位数量限制:不同定价 tier 支持的部署槽数量不同。免费(F1)和共享(D1)层不支持部署槽;基本(B1)层最多 1 个槽;标准(S1)及以上最多 5 个槽;高级(P1v3)和隔离(I1)层最多 20 个槽。规划槽位时要提前确认 tier 配额。
  • 槽位粘性设置:标记为”槽位设置”(Slot Setting)的应用配置和连接字符串不会随槽位交换而移动。务必将环境相关的配置(如数据库连接字符串、外部 API 地址)设为槽位粘性,避免交换后生产环境连接到测试资源。
  • 预热冷却时间:槽位交换并非瞬间完成,Azure 需要执行文件同步、配置应用和实例预热。对于大型应用,整个过程可能需要 1-3 分钟。在此期间不建议执行连续多次交换。
  • 自动交换:可以通过 Set-AzWebAppSlot-AutoSwapSlotName 参数启用自动交换。当新代码推送到 Staging 槽并完成预热后,Azure 会自动将其交换到生产槽。但建议在稳定的 CI/CD 流水线中使用,手动场景下保持关闭。
  • 回滚策略:槽位交换后,旧的生产版本会保留在 Staging 槽中。如果发现问题,只需再次交换即可回滚。但如果在交换后又向 Staging 槽部署了新版本,旧版本将被覆盖,回滚窗口关闭。建议在交换后至少保留旧版本 30 分钟再进行下一次部署。
  • VNet 集成:如果应用使用了 VNet 集成,交换时务必设置 preserveVnet = $true,否则 VNet 配置会被重置,导致应用无法访问内部资源。这一点在使用私有端点(Private Endpoint)连接数据库时尤为重要。

PowerShell 技能连载 - Kubernetes Helm 包管理

适用于 PowerShell 7.0 及以上版本,需要 kubectl 和 helm CLI

背景

Helm 是 Kubernetes 生态中最主流的包管理工具,被誉为 “Kubernetes 的 apt/yum”。它通过 Chart 模板将复杂的应用部署抽象为一组可版本化、可复用的配置文件,极大降低了 Kubernetes 应用的分发和运维门槛。在实际的 DevOps 工作流中,团队往往需要同时管理数十个 Chart 仓库、多套环境配置以及频繁的版本升级与回滚。

PowerShell 凭借强大的对象管道和丰富的字符串处理能力,非常适合用来编排 Helm 工作流。相比 Bash 脚本,PowerShell 能够将 helmkubectl 的输出直接转化为结构化对象,便于筛选、比较和批量操作。本文将围绕 Chart 仓库管理、Values 文件动态生成和批量部署回滚三个场景,展示如何用 PowerShell 打造高效的 Helm 自动化工具链。

Chart 仓库与版本管理

在生产环境中,团队通常会订阅多个 Helm 仓库(如 Bitnami、官方稳定仓库、企业私有仓库等),并且需要追踪 Chart 版本的变化。下面的脚本展示了如何用 PowerShell 统一管理仓库、搜索 Chart 并对比版本差异:

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
# 定义仓库列表
$repos = @(
@{ Name = 'bitnami'; Url = 'https://charts.bitnami.com/bitnami' }
@{ Name = 'ingress-nginx'; Url = 'https://kubernetes.github.io/ingress-nginx' }
@{ Name = 'jetstack'; Url = 'https://charts.jetstack.io' }
)

# 批量添加并更新仓库
foreach ($repo in $repos) {
helm repo add $repo.Name $repo.Url 2>$null
Write-Host "已添加仓库: $($repo.Name)" -ForegroundColor Green
}

helm repo update

# 搜索指定关键词的 Chart,并按版本排序
$searchKeyword = 'nginx'
$charts = helm search repo $searchKeyword --output json |
ConvertFrom-Json

$charts |
Select-Object name, @{N='version';E={ $_.chart_version }}, app_version, description |
Sort-Object name |
Format-Table -AutoSize

# 对比已安装版本与仓库最新版本
$releases = helm list --all-namespaces --output json | ConvertFrom-Json

foreach ($rel in $releases) {
$chartName = $rel.chart -replace '-\d+.*$', ''
$installed = $rel.chart_version
$latest = (helm search repo $chartName --output json |
ConvertFrom-Json |
Select-Object -First 1).chart_version

$status = if ($installed -eq $latest) { '已是最新' } else { "可升级: $installed -> $latest" }
Write-Host ("[{0}] {1} ({2}): {3}" -f $rel.namespace, $rel.name, $chartName, $status)
}

# 更新 Chart 依赖(针对本地 Chart)
$localChartPath = './charts/my-application'
if (Test-Path "$localChartPath/Chart.yaml") {
helm dependency update $localChartPath
Write-Host "依赖已更新: $localChartPath" -ForegroundColor Cyan
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
已添加仓库: bitnami
已添加仓库: ingress-nginx
已添加仓库: jetstack
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "bitnami" chart repository
...Successfully got an update from the "ingress-nginx" chart repository
...Successfully got an update from the "jetstack" chart repository
Update Complete.

name version app_version description
---- ------- ----------- -----------
bitnami/nginx 18.2.5 1.25.4 NGINX Open Source...
ingress-nginx/ingress-nginx 4.11.3 1.11.3 Ingress controller...

[default] api-gateway (api-gateway): 可升级: 1.2.0 -> 1.3.1
[production] cert-manager (cert-manager): 已是最新
[staging] my-app (my-app): 可升级: 2.0.1 -> 2.1.0
依赖已更新: ./charts/my-application

Values 文件动态生成

Helm 的灵活性很大程度上来自 values.yaml 文件。在多环境(开发、预发布、生产)部署中,为每个环境维护独立的 Values 文件既繁琐又容易出错。PowerShell 可以基于模板和数据表动态生成 Values 文件,还能安全地处理密码等敏感值:

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
# 定义环境配置矩阵
$environments = @{
dev = @{
replicas = 1
imageTag = 'latest'
resources = @{ cpuRequest = '100m'; memRequest = '128Mi' }
ingress = @{ enabled = $false }
}
staging = @{
replicas = 2
imageTag = 'v2.1.0-rc.1'
resources = @{ cpuRequest = '250m'; memRequest = '256Mi' }
ingress = @{ enabled = $true; host = 'staging.example.com' }
}
production = @{
replicas = 3
imageTag = 'v2.0.1'
resources = @{ cpuRequest = '500m'; memRequest = '512Mi' }
ingress = @{ enabled = $true; host = 'app.example.com'; tls = $true }
}
}

# 为每个环境生成 values 文件
foreach ($envName in $environments.Keys) {
$env = $environments[$envName]
$values = [ordered]@{
replicaCount = $env.replicas
image = [ordered]@{
repository = 'registry.example.com/my-app'
tag = $env.imageTag
pullPolicy = if ($env.imageTag -eq 'latest') { 'Always' } else { 'IfNotPresent' }
}
resources = [ordered]@{
requests = [ordered]@{
cpu = $env.resources.cpuRequest
memory = $env.resources.memRequest
}
}
ingress = $env.ingress
}

$outputPath = "values-$envName.yaml"

# 使用 ConvertTo-Yaml(或手动构建 YAML 字符串)
# 这里用 helm 的 --set 语法验证生成结果
$jsonContent = $values | ConvertTo-Json -Depth 10

# 将 JSON 转为 YAML(利用 yq 或简单字符串替换)
$jsonContent | python3 -c "import sys,yaml,json; yaml.dump(json.load(sys.stdin),open('$outputPath','w'),default_flow_style=False)"

Write-Host "已生成: $outputPath" -ForegroundColor Green
}

# 敏感值处理:从外部密钥管理器获取
function Get-SecretForHelm {
param([string]$SecretName)

# 从环境变量或 Azure Key Vault / HashiCorp Vault 获取
$value = [System.Environment]::GetEnvironmentVariable("HELM_SECRET_$SecretName")
if (-not $value) {
# 尝试从 kubectl secret 获取
$value = kubectl get secret app-secrets -o jsonpath="{.data.$SecretName}" |
base64 -d 2>$null
}
return $value
}

# 合并基础 values 与环境覆盖
function Merge-HelmValues {
param($BasePath, $OverridePath, $OutputPath)

# 使用 yq 合并 YAML 文件
yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' `
$BasePath $OverridePath -o yaml > $OutputPath

Write-Host "合并完成: $OutputPath" -ForegroundColor Cyan
}

# 生成包含敏感值的最终 values
$dbPassword = Get-SecretForHelm -SecretName 'DB_PASSWORD'
if ($dbPassword) {
$secretValues = "database:`n password: $dbPassword"
$secretValues | Out-File -FilePath 'values-secrets.yaml' -Encoding utf8
Merge-HelmValues -BasePath 'values-production.yaml' `
-OverridePath 'values-secrets.yaml' `
-OutputPath 'values-production-final.yaml'
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
已生成: values-dev.yaml
已生成: values-staging.yaml
已生成: values-production.yaml
合并完成: values-production-final.yaml

# 生成的 values-dev.yaml 内容摘要:
replicaCount: 1
image:
repository: registry.example.com/my-app
tag: latest
pullPolicy: Always
resources:
requests:
cpu: 100m
memory: 128Mi
ingress:
enabled: false

批量部署与回滚

在微服务架构下,一次发布可能涉及多个 Helm Release。手动逐个执行 helm upgrade 既耗时又容易遗漏。下面的脚本实现了多命名空间批量部署、发布状态追踪以及一键回滚功能:

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
# 定义部署清单
$deployments = @(
@{
Name = 'api-gateway'
Namespace = 'production'
Chart = 'bitnami/nginx'
ValuesFile = 'values-production.yaml'
Wait = $true
}
@{
Name = 'auth-service'
Namespace = 'production'
Chart = './charts/auth-service'
ValuesFile = 'values-auth-production.yaml'
Wait = $true
}
@{
Name = 'frontend'
Namespace = 'production'
Chart = './charts/frontend'
ValuesFile = 'values-frontend-production.yaml'
Wait = $false
}
)

# 确保 namespace 存在
$namespaces = $deployments | Select-Object -ExpandProperty Namespace -Unique
foreach ($ns in $namespaces) {
$exists = kubectl get namespace $ns --ignore-not-found 2>$null
if (-not $exists) {
kubectl create namespace $ns
Write-Host "已创建命名空间: $ns" -ForegroundColor Yellow
}
}

# 批量部署并记录结果
$results = [System.Collections.Generic.List[PSObject]]::new()

foreach ($dep in $deployments) {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Write-Host "`n部署: $($dep.Name) -> $($dep.Namespace)" -ForegroundColor Cyan

$waitFlag = if ($dep.Wait) { '--wait' } else { '' }
$command = "helm upgrade --install $($dep.Name) $($dep.Chart) " +
"--namespace $($dep.Namespace) " +
"-f $($dep.ValuesFile) $waitFlag --timeout 5m"

$output = Invoke-Expression $command 2>&1
$stopwatch.Stop()

$success = $LASTEXITCODE -eq 0
$results.Add([PSCustomObject]@{
Name = $dep.Name
Namespace = $dep.Namespace
Status = if ($success) { 'deployed' } else { 'failed' }
Duration = '{0:N1}s' -f $stopwatch.Elapsed.TotalSeconds
Output = $output | Select-Object -First 3
})
}

# 输出部署报告
Write-Host "`n===== 部署报告 =====" -ForegroundColor Yellow
$results |
Select-Object Name, Namespace, Status, Duration |
Format-Table -AutoSize

# 检查是否有失败的部署
$failed = $results | Where-Object { $_.Status -eq 'failed' }
if ($failed) {
Write-Host "`n以下部署失败,准备回滚:" -ForegroundColor Red
$failed | ForEach-Object { Write-Host " - $($_.Name) ($($_.Namespace))" }

# 一键回滚所有失败项
foreach ($f in $failed) {
Write-Host "`n回滚: $($f.Name)" -ForegroundColor Red
helm rollback $f.Name --namespace $f.Namespace
$rollbackStatus = if ($LASTEXITCODE -eq 0) { '回滚成功' } else { '回滚失败' }
Write-Host " $rollbackStatus"
}
}

# 生成当前所有 Release 状态摘要
$allReleases = helm list --all-namespaces --output json | ConvertFrom-Json
$report = $allReleases |
Select-Object name, namespace, @{N='chart';E={ $_.chart -replace '-\d+.*$', '' }},
chart_version, status, updated |
Sort-Object namespace, name

Write-Host "`n===== 当前 Release 状态 =====" -ForegroundColor Green
$report | Format-Table -AutoSize

# 导出报告为 CSV(可选)
$report | Export-Csv -Path "helm-deploy-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv" `
-NoTypeInformation -Encoding utf8
Write-Host "报告已导出到 CSV 文件" -ForegroundColor Cyan

执行结果示例:

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
已创建命名空间: production

部署: api-gateway -> production
Release "api-gateway" has been upgraded. Happy Helming!
NAME: api-gateway
LAST DEPLOYED: Wed Apr 23 10:30:00 2026

部署: auth-service -> production
Release "auth-service" has been upgraded. Happy Helming!
NAME: auth-service
LAST DEPLOYED: Wed Apr 23 10:30:45 2026

部署: frontend -> production
Release "frontend" has been upgraded. Happy Helming!

===== 部署报告 =====
Name Namespace Status Duration
---- --------- ------ --------
api-gateway production deployed 32.5s
auth-service production deployed 41.2s
frontend production deployed 3.1s

===== 当前 Release 状态 =====
name namespace chart chart_version status updated
---- --------- ----- ------------- ------ -------
api-gateway production nginx 18.2.5 deployed 2026-04-23 10:30:00
auth-service production auth-service 1.5.0 deployed 2026-04-23 10:30:45
frontend production frontend 2.1.0 deployed 2026-04-23 10:31:02
报告已导出到 CSV 文件

注意事项

  • Helm 版本兼容性:本文示例基于 Helm 3.x,Helm 2 已停止维护,请确保使用 helm version 确认当前版本为 3.0 以上。Helm 3 移除了 Tiller,安全性显著提升。
  • JSON 输出解析helm 命令支持 --output json 参数,配合 ConvertFrom-Json 可以将结果转为 PowerShell 对象。但不同 Helm 版本的 JSON 字段名可能略有差异(如 chart_versionapp_version),使用前建议先检查输出结构。
  • 敏感值管理:不要将密码、Token 等敏感信息硬编码在 Values 文件或脚本中。应结合 Kubernetes Secret、外部密钥管理器(如 HashiCorp Vault、Azure Key Vault)或 Helm Secrets 插件来管理敏感配置。
  • 回滚策略helm rollback 默认回滚到上一个修订版本。如果需要回滚到更早的版本,先用 helm history <release> 查看修订记录,再指定 helm rollback <release> <revision> 回滚到目标版本。
  • 超时与等待:生产环境部署建议始终使用 --wait--timeout 参数,确保 Helm 等待所有 Pod 就绪后才认为部署成功。合理的超时值通常为 5-10 分钟,具体取决于应用的启动时间。
  • 并发部署限制:虽然 PowerShell 支持并发执行(如 ForEach-Object -Parallel),但 Helm 对同一 Release 的并发操作并不安全。建议对同一 Release 的操作串行执行,不同 Release 之间可以安全地并发部署。

PowerShell 技能连载 - Prompt Engineering 实践

适用于 PowerShell 7.0 及以上版本

随着大语言模型(LLM)的快速普及,Prompt Engineering 已经成为运维工程师不可或缺的技能。在
PowerShell 生态中,我们可以将提示词工程与自动化脚本深度结合,构建出智能化的运维工具链。无论是在
Azure 资源管理、日志分析还是故障诊断场景中,精心设计的 Prompt 都能大幅提升 LLM 输出的准确性和可用性。

传统的运维脚本通常依赖固定逻辑处理已知场景,但面对模糊的故障描述或复杂的架构问题时往往力不从心。
通过 Prompt Engineering,我们可以让 LLM 理解系统上下文,生成符合 PowerShell 风格的命令和脚本,
甚至自动分析错误日志并给出修复建议。关键在于如何用结构化的方式管理与 LLM 的交互。

本文将从模板管理、结构化输出控制和运维 Copilot 实现三个层面,展示如何在 PowerShell 中系统化地
应用 Prompt Engineering 最佳实践。

Prompt 模板管理

在生产环境中,我们通常需要维护多套 Prompt 模板来应对不同的运维场景。手动拼接字符串既容易出错,
也难以维护。下面是一个可复用的 Prompt 模板管理系统,支持变量替换和上下文管理:

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
class PromptTemplate {
[string]$Name
[string]$Template
[hashtable]$Variables = @{}
[string]$SystemContext

PromptTemplate([string]$Name, [string]$Template) {
$this.Name = $Name
$this.Template = $Template
}

[void] SetVariable([string]$Key, [object]$Value) {
$this.Variables[$Key] = $Value
}

[void] SetSystemContext([string]$Context) {
$this.SystemContext = $Context
}

[string] Render() {
$rendered = $this.Template
foreach ($key in $this.Variables.Keys) {
$placeholder = "{{$key}}"
$rendered = $rendered -replace [regex]::Escape($placeholder), $this.Variables[$key]
}
return $rendered
}

[hashtable] ToChatMessages() {
$messages = @()
if ($this.SystemContext) {
$messages += @{
role = 'system'
content = $this.SystemContext
}
}
$messages += @{
role = 'user'
content = $this.Render()
}
return @{ messages = $messages }
}
}

# 创建运维场景的 Prompt 模板库
$templates = @{}

$templates['error-diagnosis'] = [PromptTemplate]::new(
'error-diagnosis',
'请分析以下 PowerShell 错误信息,给出可能的原因和修复建议。' +
"`n`n错误类型:{ErrorType}" +
"`n错误消息:{ErrorMessage}" +
'`n发生环境:{Environment}'
)

$templates['script-generation'] = [PromptTemplate]::new(
'script-generation',
'请生成一个 PowerShell 脚本,要求如下:' +
"`n目标:{Goal}" +
"`n约束条件:{Constraints}" +
"`n目标平台:{Platform}" +
'`n请使用 PowerShell 7 兼容语法,包含错误处理和注释。'
)

# 设置系统上下文:定义 LLM 的角色和行为规范
$systemContext = @"
你是一位资深的 PowerShell 和 Azure 运维专家。
你的回答需要:
1. 使用 PowerShell 7 兼容语法
2. 包含完整的错误处理(try/catch/finally)
3. 遵循最佳实践(Approved Verbs、强类型、注释)
4. 输出可直接执行的脚本代码
"@

$templates['error-diagnosis'].SetSystemContext($systemContext)
$templates['script-generation'].SetSystemContext($systemContext)

# 使用模板:填充变量并渲染
$tpl = $templates['error-diagnosis']
$tpl.SetVariable('ErrorType', 'InvalidOperationException')
$tpl.SetVariable('ErrorMessage', 'Collection was modified; enumeration operation may not execute.')
$tpl.SetVariable('Environment', 'Windows Server 2022, PowerShell 7.4')

$renderedPrompt = $tpl.Render()
$chatPayload = $tpl.ToChatMessages()

Write-Host "=== 渲染后的 Prompt ===" -ForegroundColor Cyan
Write-Host $renderedPrompt
Write-Host "`n=== Chat API 载荷 ===" -ForegroundColor Cyan
$chatPayload | ConvertTo-Json -Depth 3

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=== 渲染后的 Prompt ===
请分析以下 PowerShell 错误信息,给出可能的原因和修复建议。

错误类型:InvalidOperationException
错误消息:Collection was modified; enumeration operation may not execute.
发生环境:Windows Server 2022, PowerShell 7.4

=== Chat API 载荷 ===
{
"messages": [
{
"role": "system",
"content": "你是一位资深的 PowerShell 和 Azure 运维专家。\n你的回答需要:\n1. 使用 PowerShell 7 兼容语法\n2. 包含完整的错误处理(try/catch/finally)\n3. 遵循最佳实践(Approved Verbs、强类型、注释)\n4. 输出可直接执行的脚本代码\n"
},
{
"role": "user",
"content": "请分析以下 PowerShell 错误信息,给出可能的原因和修复建议。\n\n错误类型:InvalidOperationException\n错误消息:Collection was modified; enumeration operation may not execute.\n发生环境:Windows Server 2022, PowerShell 7.4"
}
]
}

结构化输出控制

LLM 返回自由文本虽然灵活,但在自动化流程中很难直接使用。通过精心设计 Prompt,我们可以
引导 LLM 输出结构化的 JSON,再由 PowerShell 解析为对象。下面展示如何实现输出控制、
Schema 校验和自动重试机制:

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

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

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

[int]$MaxRetries = 3,

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

# 构建系统提示词,强制 JSON 输出并给出 Schema
$systemPrompt = @"
你是一个 PowerShell 运维助手。用户会提出运维相关的问题,你需要给出结构化的回答。

严格要求:
1. 只输出合法的 JSON,不要包含任何其他文字说明
2. 严格遵循以下 JSON Schema:
$JsonSchema
3. 不要输出 Markdown 代码块标记,直接输出 JSON
4. 字符串值使用 UTF-8 编码
"@

$attempt = 0
while ($attempt -lt $MaxRetries) {
$attempt++
Write-Verbose "尝试第 $attempt 次..."

$body = @{
model = $Model
messages = @(
@{ role = 'system'; content = $systemPrompt },
@{ role = 'user'; content = $UserPrompt }
)
temperature = 0.1
} | ConvertTo-Json -Depth 5

try {
$response = Invoke-RestMethod -Uri $Endpoint -Method Post `
-ContentType 'application/json' `
-Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
-ErrorAction Stop

$content = $response.choices[0].message.content.Trim()

# 去除可能的 Markdown 代码块标记
if ($content -match '^\x60{3}json?\s*([\s\S]*?)\x60{3}$') {
$content = $Matches[1].Trim()
}

# 尝试解析 JSON
$parsed = $content | ConvertFrom-Json -ErrorAction Stop

Write-Verbose "JSON 解析成功"
return [PSCustomObject]@{
Success = $true
Data = $parsed
Raw = $content
Attempts = $attempt
}
}
catch {
Write-Warning "第 $attempt 次尝试失败:$($_.Exception.Message)"
if ($attempt -ge $MaxRetries) {
return [PSCustomObject]@{
Success = $false
Error = $_.Exception.Message
Attempts = $attempt
}
}
Start-Sleep -Milliseconds ($attempt * 500)
}
}
}

# 定义输出 Schema:服务器健康检查结果
$schema = @'
{
"type": "object",
"properties": {
"server_name": { "type": "string" },
"overall_status": { "type": "string", "enum": ["healthy", "warning", "critical"] },
"checks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item": { "type": "string" },
"status": { "type": "string" },
"detail": { "type": "string" },
"recommendation": { "type": "string" }
},
"required": ["item", "status", "detail", "recommendation"]
}
},
"summary": { "type": "string" }
},
"required": ["server_name", "overall_status", "checks", "summary"]
}
'@

$result = Invoke-StructuredCompletion -UserPrompt @'
WebSrv01 服务器状态:CPU 使用率 92%,内存剩余 2GB/32GB,
磁盘 C: 剩余 15%,事件日志中发现 37 个 Error 级别事件(过去 1 小时),
IIS 应用池 MyAppPool 已崩溃 3 次。
'@ -JsonSchema $schema -Verbose

if ($result.Success) {
Write-Host "服务器:$($result.Data.server_name)" -ForegroundColor Cyan
Write-Host "状态:$($result.Data.overall_status)" -ForegroundColor Yellow
Write-Host "`n检查项:"
$result.Data.checks | Format-Table -Property item, status, detail -Wrap
Write-Host "摘要:$($result.Data.summary)"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
详细: 尝试第 1 次...
详细: JSON 解析成功
服务器:WebSrv01
状态:critical

检查项:

item status detail
---- ------ ------
CPU 使用率 critical 持续 92%,远超 80% 阈值
内存使用 warning 剩余 2GB,接近耗尽
磁盘空间 warning C: 盘仅剩 15%
事件日志 critical 1 小时内 37 个 Error 事件
IIS 应用池 critical MyAppPool 已崩溃 3 次

摘要:WebSrv01 服务器处于 critical 状态,CPU 和 IIS 应用池问题最为紧急,
建议立即排查 CPU 占用进程并重启应用池。

运维 Copilot 实现

将前面两节的技术整合起来,我们可以构建一个实用的运维 Copilot 工具。它能够自动收集系统信息,
结合上下文回答运维问题,生成 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
class OpsCopilot {
[string]$Model
[string]$Endpoint
[hashtable]$Templates
[string]$SystemInfo

OpsCopilot([string]$Endpoint, [string]$Model) {
$this.Endpoint = $Endpoint
$this.Model = $Model
$this.Templates = @{}
$this.SystemInfo = $this.CollectSystemInfo()
$this.RegisterTemplates()
}

[string] CollectSystemInfo() {
$info = @{
hostname = $env:COMPUTERNAME
os = (Get-CimInstance Win32_OperatingSystem).Caption
psVersion = $PSVersionTable.PSVersion.ToString()
dotnet = [System.Environment]::Version.ToString()
cpu = (Get-CimInstance Win32_Processor).Name
totalMemGB = [math]::Round(
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1
)
drives = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' |
ForEach-Object {
@{
drive = $_.DeviceID
freeGB = [math]::Round($_.FreeSpace / 1GB, 1)
totalGB = [math]::Round($_.Size / 1GB, 1)
}
}
}
return ($info | ConvertTo-Json -Depth 3)
}

[void] RegisterTemplates() {
$this.Templates['ask'] = @'
基于以下系统信息回答运维问题。

系统信息:
{SystemInfo}

问题:{Question}

请给出:
1. 问题分析
2. 具体的 PowerShell 命令或脚本
3. 注意事项
'@

$this.Templates['diagnose'] = @'
基于以下系统信息诊断错误。

系统信息:
{SystemInfo}

错误详情:
{ErrorDetail}

请分析:
1. 错误的根本原因
2. 修复步骤(含 PowerShell 命令)
3. 预防措施
'@
}

[string] RenderTemplate([string]$TemplateName, [hashtable]$Vars) {
$rendered = $this.Templates[$TemplateName]
$rendered = $rendered -replace '\{SystemInfo\}', $this.SystemInfo
foreach ($key in $Vars.Keys) {
$rendered = $rendered -replace "\{$key\}", $Vars[$key]
}
return $rendered
}

[string] Chat([string]$UserMessage) {
$systemPrompt = @"
你是一位精通 PowerShell 和 Windows Server 的运维专家。
当前系统环境:
$($this.SystemInfo)

回答要求:
- 优先使用 PowerShell 7 原生命令
- 脚本包含 try/catch 错误处理
- 给出可立即执行的命令
- 用中文回答
"@

$body = @{
model = $this.Model
messages = @(
@{ role = 'system'; content = $systemPrompt },
@{ role = 'user'; content = $UserMessage }
)
temperature = 0.2
} | ConvertTo-Json -Depth 5

$response = Invoke-RestMethod -Uri $this.Endpoint -Method Post `
-ContentType 'application/json' `
-Body ([System.Text.Encoding]::UTF8.GetBytes($body))

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

[string] Ask([string]$Question) {
$prompt = $this.RenderTemplate('ask', @{
Question = $Question
})
return $this.Chat($prompt)
}

[string] Diagnose([string]$ErrorDetail) {
$prompt = $this.RenderTemplate('diagnose', @{
ErrorDetail = $ErrorDetail
})
return $this.Chat($prompt)
}
}

# 初始化 Copilot(使用本地 Ollama 或远程 API)
$copilot = [OpsCopilot]::new('http://localhost:11434/v1/chat/completions', 'qwen3:8b')

# 示例 1:提出运维问题
Write-Host "=== 运维问答 ===" -ForegroundColor Cyan
$answer = $copilot.Ask('如何查找占用磁盘空间最大的前 10 个文件夹?')
Write-Host $answer

# 示例 2:诊断错误
Write-Host "`n=== 错误诊断 ===" -ForegroundColor Cyan
$diagnosis = $copilot.Diagnose(@"
执行 Get-EventLog -LogName Application -Newest 100 时报错:
"Requested registry access is not allowed"
当前以普通用户身份运行 PowerShell。
"@)
Write-Host $diagnosis

执行结果示例:

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
=== 运维问答 ===
[问题分析]
在磁盘空间不足时,需要快速定位占用空间最大的目录,以便清理。

[PowerShell 命令]
使用以下命令查找 D: 盘中最大的 10 个文件夹:

Get-ChildItem -Path 'D:\' -Directory -Recurse -ErrorAction SilentlyContinue |
ForEach-Object {
$size = (Get-ChildItem $_.FullName -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
[PSCustomObject]@{
Path = $_.FullName
SizeMB = [math]::Round($size / 1MB, 2)
}
} | Sort-Object SizeMB -Descending | Select-Object -First 10 |
Format-Table -AutoSize

[注意事项]
1. 对整个盘扫描很慢,建议缩小搜索范围
2. 加 -ErrorAction SilentlyContinue 跳过无权限目录
3. 管道中计算大小是内存友好的方式

=== 错误诊断 ===
[根本原因]
Get-EventLog 需要管理员权限才能访问事件日志注册表项。以普通用户运行时,
会被拒绝访问。

[修复步骤]
# 方法 1:以管理员身份运行 PowerShell
Start-Process pwsh -Verb RunAs

# 方法 2(推荐):改用 Get-WinEvent,它支持更精细的权限控制
Get-WinEvent -LogName 'Application' -MaxEvents 100

# 方法 3:仅查看有权限的日志
Get-WinEvent -ListLog '*' | Where-Object { $_.IsLogFullNameValid }

[预防措施]
1. 优先使用 Get-WinEvent 替代已过时的 Get-EventLog
2. 运维脚本中加入权限检查:
if (-not ([Security.Principal.WindowsPrincipal]::new(
[Security.Principal.WindowsIdentity]::GetCurrent()
)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning '建议以管理员身份运行此脚本'
}

注意事项

  1. API 端点安全:生产环境中调用 LLM API 时,务必使用 HTTPS 并通过环境变量或
    Azure Key Vault 管理 API 密钥,不要将密钥硬编码在脚本中。可以使用
    ConvertFrom-SecureStringGet-Secret 安全地获取凭据。

  2. Prompt 注入防护:当 Prompt 中包含用户输入或外部数据时,要对内容进行清理,
    避免恶意指令注入。可以用 -replace 去除特殊字符,或对用户输入做白名单过滤。

  3. 输出校验不可省略:即使指定了 JSON Schema,LLM 仍可能输出不符合预期的内容。
    始终在解析前做格式检查,重试机制是生产环境的必要保障。建议设置合理的
    $MaxRetries(通常 3 次足够)。

  4. 上下文长度管理:收集系统信息时注意控制 Token 数量。对于大型环境(数百台服务器),
    先做摘要或筛选再传入 Prompt,避免超出模型的上下文窗口限制。可以用
    ($text | Measure-Object -Character).Characters 估算 Token 消耗。

  5. 模板版本管理:将 Prompt 模板存储为独立的 JSON 或 YAML 文件,纳入 Git 版本控制。
    这样可以追踪模板变更对输出质量的影响,也方便团队成员协作。建议在模板中加入版本号和
    最后更新时间字段。

  6. 本地模型优先:对于包含敏感系统信息的运维场景,优先使用本地部署的模型(如 Ollama、
    vLLM)而非云端 API。这既降低了数据泄露风险,也减少了网络延迟对自动化流程的影响。
    如果必须使用云端 API,确保数据传输经过加密且符合公司的安全合规要求。

PowerShell 技能连载 - 本地 LLM 工具链

适用于 PowerShell 7.0 及以上版本,需要 Ollama 或 LM Studio

背景引入

随着大语言模型(LLM)的快速发展,越来越多的开发者希望在自己的工作站上运行本地模型,以满足数据隐私、离线场景和低延迟的需求。Ollama 和 LM Studio 是目前最流行的两个本地 LLM 运行工具,它们都提供了兼容 OpenAI 格式的 REST API,可以方便地从任何编程语言调用。

PowerShell 作为 Windows 和跨平台运维的核心脚本语言,天然适合充当本地 LLM 的”胶水层”。通过 PowerShell 调用本地模型 API,我们可以将 AI 能力无缝嵌入到日常运维脚本、日志分析、文档处理等任务中,而无需将敏感数据发送到云端。

本文将介绍如何用 PowerShell 构建完整的本地 LLM 工具链,包括模型管理与对话接口、RAG 本地知识库,以及 AI 辅助运维工具三个核心场景。所有代码均基于本地 API,不依赖任何云服务。

Ollama API 集成

Ollama 提供了简洁的 REST API,默认监听 http://localhost:11434。下面的模块封装了模型列表查询、对话生成和流式输出等常用操作,可以作为日常脚本的基础工具集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
function Get-OllamaModel {
<# 获取本地已安装的模型列表 #>
$response = Invoke-RestMethod -Uri 'http://localhost:11434/api/tags' -Method Get
$response.models | ForEach-Object {
[PSCustomObject]@{
Name = $_.name
SizeMB = [math]::Round($_.size / 1MB, 1)
ModifiedAt = $_.modified_at
Family = $_.details.family
Parameter = $_.details.parameter_size
}
} | Sort-Object Name | Format-Table -AutoSize
}

function Invoke-OllamaChat {
<#
.SYNOPSIS
向本地 Ollama 模型发送对话请求
.PARAMETER Model
模型名称,如 qwen2.5:7b、llama3.1:8b
.PARAMETER Messages
消息数组,每条包含 role 和 content
.PARAMETER Stream
是否启用流式输出(默认 $false)
.PARAMETER Temperature
生成温度,0.0 到 1.0 之间
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Model,

[Parameter(Mandatory)]
[hashtable[]]$Messages,

[switch]$Stream,

[ValidateRange(0.0, 1.0)]
[double]$Temperature = 0.7
)

$body = @{
model = $Model
messages = $Messages
stream = $Stream.IsPresent
options = @{
temperature = $Temperature
}
} | ConvertTo-Json -Depth 5

if ($Stream) {
# 流式输出:逐行读取 SSE 响应
$request = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::Post,
'http://localhost:11434/api/chat'
)
$request.Content = [System.Net.Http.StringContent]::new(
$body, [System.Text.Encoding]::UTF8, 'application/json'
)

$client = [System.Net.Http.HttpClient]::new()
$response = $client.Send($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
$reader = [System.IO.StreamReader]::new($response.Content.ReadAsStream())

$fullResponse = [System.Text.StringBuilder]::new()
while (-not $reader.EndOfStream) {
$line = $reader.ReadLine()
if ($line) {
$chunk = $line | ConvertFrom-Json
if ($chunk.message.content) {
Write-Host $chunk.message.content -NoNewline
[void]$fullResponse.Append($chunk.message.content)
}
if ($chunk.done) { break }
}
}
Write-Host ''
$reader.Close()
$client.Dispose()
return $fullResponse.ToString()
}
else {
$result = Invoke-RestMethod -Uri 'http://localhost:11434/api/chat' `
-Method Post -Body $body -ContentType 'application/json'
return $result.message.content
}
}

# 用法示例:查看已安装模型
Get-OllamaModel

# 用法示例:单轮对话
$messages = @(
@{ role = 'system'; content = '你是一个 PowerShell 专家,回答简洁精准。' }
@{ role = 'user'; content = '如何获取系统中占用磁盘最大的 10 个目录?' }
)

$answer = Invoke-OllamaChat -Model 'qwen2.5:7b' -Messages $messages -Temperature 0.3
Write-Host "`n--- 模型回复 ---`n$answer"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Name              SizeMB   ModifiedAt               Family   Parameter
---- ------ ---------- ------ ---------
llama3.1:8b 4661.2 2026-04-18T10:30:00Z llama 8B
qwen2.5:7b 4368.5 2026-04-20T14:22:00Z qwen2 7B
deepseek-r1:7b 4520.8 2026-04-15T09:11:00Z qwen2 7B
nomic-embed-text 274.1 2026-04-10T08:00:00Z nomic 136M

--- 模型回复 ---

可以使用以下 PowerShell 命令获取占用磁盘最大的 10 个目录:

Get-ChildItem -Directory -Recurse -ErrorAction SilentlyContinue |
ForEach-Object {
$size = (Get-ChildItem $_.FullName -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
[PSCustomObject]@{ Path = $_.FullName; SizeMB = [math]::Round($size/1MB, 2) }
} | Sort-Object SizeMB -Descending | Select-Object -First 10

RAG 本地知识库

检索增强生成(RAG)是将本地文档作为上下文注入 LLM 的关键技术。下面的实现包含文档切片、向量化存储和检索查询三个步骤,完全使用本地模型(nomic-embed-text 做嵌入,对话模型做生成),无需外部服务。

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
class LocalVectorStore {
<# 本地向量存储,用于 RAG 检索 #>
[System.Collections.Generic.List[hashtable]]$Documents

LocalVectorStore() {
$this.Documents = [System.Collections.Generic.List[hashtable]]::new()
}

[void] AddChunk([string]$Content, [string]$Source, [double[]]$Embedding) {
$this.Documents.Add(@{
content = $Content
source = $Source
embedding = $Embedding
})
}

[double] CosineSimilarity([double[]]$A, [double[]]$B) {
$dot = 0.0; $normA = 0.0; $normB = 0.0
for ($i = 0; $i -lt $A.Count; $i++) {
$dot += $A[$i] * $B[$i]
$normA += $A[$i] * $A[$i]
$normB += $B[$i] * $B[$i]
}
if ($normA -eq 0 -or $normB -eq 0) { return 0 }
return $dot / ([math]::Sqrt($normA) * [math]::Sqrt($normB))
}

[hashtable[]] Search([double[]]$QueryEmbedding, [int]$TopK = 3) {
return $this.Documents | ForEach-Object {
[PSCustomObject]@{
Content = $_.content
Source = $_.source
Similarity = $this.CosineSimilarity($QueryEmbedding, $_.embedding)
}
} | Sort-Object Similarity -Descending | Select-Object -First $TopK |
ForEach-Object { @{ content = $_.Content; source = $_.Source; score = $_.Similarity } }
}
}

function Get-LocalEmbedding {
param([string]$Text)
$body = @{ model = 'nomic-embed-text'; prompt = $Text } | ConvertTo-Json
$result = Invoke-RestMethod -Uri 'http://localhost:11434/api/embeddings' `
-Method Post -Body $body -ContentType 'application/json'
return $result.embedding
}

function Split-Document {
param(
[string]$Content,
[int]$ChunkSize = 500,
[int]$Overlap = 50
)
$chunks = [System.Collections.Generic.List[string]]::new()
$words = $Content -split '\s+'
$i = 0
while ($i -lt $words.Count) {
$end = [math]::Min($i + $ChunkSize, $words.Count)
$chunkText = ($words[$i..($end - 1)] -join ' ')
[void]$chunks.Add($chunkText)
$i += $ChunkSize - $Overlap
}
return $chunks
}

function New-RAGIndex {
<# 从指定目录的文档构建 RAG 索引 #>
param(
[string]$Path = '.\docs',
[int]$ChunkSize = 500
)

$store = [LocalVectorStore]::new()
$files = Get-ChildItem -Path $Path -Include '*.md','*.txt' -Recurse

foreach ($file in $files) {
$content = Get-Content $file.FullName -Raw -Encoding UTF8
$chunks = Split-Document -Content $content -ChunkSize $ChunkSize
$chunkIndex = 0
foreach ($chunk in $chunks) {
$embedding = Get-LocalEmbedding -Text $chunk
$store.AddChunk($chunk, "$($file.Name)#chunk-$chunkIndex", $embedding)
$chunkIndex++
}
Write-Host "已索引: $($file.Name) ($($chunks.Count) 个切片)" -ForegroundColor Cyan
}
Write-Host "索引完成,共 $($store.Documents.Count) 个文档切片" -ForegroundColor Green
return $store
}

function Invoke-RAGQuery {
<# 基于 RAG 索引进行检索增强查询 #>
param(
[LocalVectorStore]$Store,
[string]$Question,
[string]$Model = 'qwen2.5:7b',
[int]$TopK = 3
)

$queryEmbedding = Get-LocalEmbedding -Text $Question
$results = $Store.Search($queryEmbedding, $TopK)

$context = ($results | ForEach-Object {
"【来源: $($_.source) | 相关度: $($_.score):N3】`n$($_.content)"
}) -join "`n`n---`n`n"

$messages = @(
@{
role = 'system'
content = '根据以下参考资料回答用户问题。如果资料中没有相关信息,请明确说明。'
}
@{
role = 'user'
content = "参考资料:`n$context`n`n问题:$Question"
}
)

Write-Host "`n检索到 $($results.Count) 条相关文档:" -ForegroundColor Yellow
$results | ForEach-Object {
Write-Host " - $($_.source) (相关度: $($_.score):N3)" -ForegroundColor DarkGray
}

return Invoke-OllamaChat -Model $Model -Messages $messages -Temperature 0.2
}

# 用法示例:构建索引并查询
$store = New-RAGIndex -Path '.\knowledge-base'
$answer = Invoke-RAGQuery -Store $store -Question '公司的密码策略要求是什么?'
Write-Host "`n$answer"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
已索引: security-policy.md (12 个切片)
已索引: operations-guide.md (8 个切片)
已索引: network-diagram.md (5 个切片)
索引完成,共 25 个文档切片

检索到 3 条相关文档:
- security-policy.md#chunk-2 (相关度: 0.892)
- security-policy.md#chunk-7 (相关度: 0.856)
- operations-guide.md#chunk-1 (相关度: 0.743)

根据安全策略文档,公司密码策略要求如下:
1. 密码长度不少于 14 个字符
2. 必须包含大写字母、小写字母、数字和特殊字符中的至少三类
3. 密码有效期 90 天,不可重复最近 12 次使用过的密码
4. 账户锁定策略:连续 5 次输入错误后锁定 30 分钟
5. 管理员账户必须启用多因素认证(MFA)

AI 辅助运维工具

将本地 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
function Invoke-AILogAnalysis {
<# 使用本地 LLM 分析系统日志 #>
param(
[string]$LogPath = 'C:\Windows\System32\winevt\Logs\System.evtx',
[int]$MaxEvents = 50,
[string]$Model = 'qwen2.5:7b'
)

Write-Host "正在读取最近的 $MaxEvents 条事件日志..." -ForegroundColor Cyan
$events = Get-WinEvent -Path $LogPath -MaxEvents $MaxEvents -ErrorAction SilentlyContinue

if (-not $events) {
$events = Get-WinEvent -LogName System -MaxEvents $MaxEvents
}

$errorEvents = $events | Where-Object { $_.Level -le 3 } |
Select-Object TimeCreated, Id, LevelDisplayName, Message |
Format-Table -AutoSize | Out-String

$prompt = @"
以下是最近的事件日志(仅含错误和警告):

$errorEvents

请分析以下内容:
1. 是否存在异常模式或重复性错误
2. 可能的根因分析
3. 推荐的处理步骤
"@

$messages = @(
@{ role = 'system'; content = '你是一位资深的 Windows 系统管理员,擅长日志分析和故障排查。' }
@{ role = 'user'; content = $prompt }
)

return Invoke-OllamaChat -Model $Model -Messages $messages -Temperature 0.3
}

function Get-AICommandSuggestion {
<# 根据自然语言描述推荐 PowerShell 命令 #>
param(
[Parameter(Mandatory)]
[string]$Description,

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

$messages = @(
@{
role = 'system'
content = '你是一个 PowerShell 命令助手。根据用户的自然语言描述,给出 1-3 个 PowerShell 命令方案。每个方案包含命令、参数说明和使用场景。只返回 PowerShell 代码和简短说明。'
}
@{
role = 'user'
content = $Description
}
)

return Invoke-OllamaChat -Model $Model -Messages $messages -Temperature 0.4
}

function Repair-AIScriptError {
<# 使用本地 LLM 诊断和修复脚本错误 #>
param(
[Parameter(Mandatory)]
[string]$ScriptContent,

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

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

$messages = @(
@{
role = 'system'
content = '你是一个 PowerShell 调试专家。分析脚本代码和错误信息,给出问题原因和修复后的完整脚本。'
}
@{
role = 'user'
content = "脚本代码:`n$ScriptContent`n`n错误信息:`n$ErrorMessage"
}
)

return Invoke-OllamaChat -Model $Model -Messages $messages -Temperature 0.2
}

# 用法示例 1:日志分析
$analysis = Invoke-AILogAnalysis -MaxEvents 100
Write-Host $analysis

# 用法示例 2:命令推荐
$suggestion = Get-AICommandSuggestion -Description '查找过去 24 小时内被修改的大于 100MB 的文件'
Write-Host $suggestion

# 用法示例 3:脚本错误修复
$badScript = @'
Get-Process | Where-Object { $_.WorkingSet -gt 100MB } | Select Name, CPU
'@

$errorMsg = 'Cannot compare "System.Byte[]" to "System.Int32"'

$fix = Repair-AIScriptError -ScriptContent $badScript -ErrorMessage $errorMsg
Write-Host $fix

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
正在读取最近的 100 条事件日志...

## 日志分析报告

### 异常模式
1. **重复性磁盘警告**:过去 2 小时内出现 15 次 Disk 警告(Event ID 51),
指向磁盘 \Device\Harddisk1\DR1,表明可能存在磁盘硬件问题。
2. **服务异常终止**:W32Time 服务在 04:30 和 05:15 两次意外终止(Event ID 7034)。

### 根因分析
- 磁盘警告可能与坏道或 SATA 线缆松动有关
- 时间服务崩溃通常与网络连接中断相关

### 推荐处理步骤
1. 立即运行 `chkdsk /r` 检查磁盘健康状态
2. 使用 `smartctl -a /dev/sda` 查看 SMART 信息
3. 检查 W32Time 服务依赖的网络连接
4. 运行 `w32tm /resync` 重新同步时间
1
2
3
4
5
6
7
8
9
## 推荐方案

### 方案一:使用 Get-ChildItem
Get-ChildItem -Path C:\ -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Length -gt 100MB -and $_.LastWriteTime -gt (Get-Date).AddHours(-24) } |
Select-Object FullName, @{N='SizeMB';E={[math]::Round($_.Length/1MB,2)}}, LastWriteTime |
Sort-Object SizeMB -Descending

适用于全盘扫描,结果详细但速度较慢。

注意事项

  1. 模型选择与硬件匹配:7B 参数模型至少需要 8GB 显存或 16GB 内存,13B 模型建议 16GB 以上显存。使用 ollama list 查看已安装模型,根据硬件配置选择合适的模型大小,避免因内存不足导致推理速度过慢或崩溃。

  2. API 兼容性:Ollama 和 LM Studio 都兼容 OpenAI API 格式,但端口号不同(Ollama 默认 11434,LM Studio 默认 1234)。切换工具时需要修改 URI 前缀,建议将基础 URL 配置为变量或环境变量,便于灵活切换。

  3. RAG 的切片策略:文档切片大小直接影响检索质量。过大的切片包含过多无关信息,过小的切片丢失上下文。对于技术文档建议 300-500 词,对于日志文件建议按时间窗口切分,每片保留完整的上下文信息。

  4. 流式输出与超时处理:长时间推理可能超过 Invoke-RestMethod 的默认超时。流式输出可以避免长时间等待无响应,但需要手动处理 SSE 数据流。建议为非流式调用设置 -TimeoutSec 120 或更长的超时值。

  5. 敏感数据保护:虽然使用本地模型避免了数据外传,但仍需注意日志中可能记录 API 请求内容。在生产环境中建议禁用 PowerShell 的脚本块日志记录(ScriptBlockLogging),并定期清理 Ollama 的会话历史。

  6. 模型回复的不确定性:LLM 生成的代码和建议可能存在错误。对于运维操作(特别是破坏性命令),务必先在测试环境验证,不要直接将 AI 生成的命令粘贴到生产终端执行。建议加入 -WhatIf 参数进行预检。

PowerShell 技能连载 - 数据管道构建

适用于 PowerShell 7.0 及以上版本

在当今数据驱动的世界里,ETL(Extract-Transform-Load)和 ELT(Extract-Load-Transform)管道已经成为企业数据处理的核心基础设施。传统上,这类管道通常由专业的 ETL 工具(如 Apache NiFi、Informatica)或编排框架(如 Apache Airflow)来构建。但对于中小规模的数据处理需求来说,这些工具往往过于重量级,引入了不必要的复杂性。

PowerShell 凭借其强大的管道机制、丰富的对象处理能力以及对多种数据源的原生支持,非常适合构建轻量级的数据管道。从 CSV、JSON 文件到 REST API,再到 SQL 数据库,PowerShell 都能轻松对接。更重要的是,PowerShell 7 的跨平台特性使得这些管道可以在 Windows、Linux 和 macOS 上统一运行。

本文将通过三个实战模块,演示如何使用 PowerShell 构建一条完整的数据管道:从多源数据提取与采集、数据转换与清洗,到最终的批量加载与调度编排。每个模块都包含可直接运行的代码和详细的执行结果示例。

数据提取与采集

数据管道的第一步是从多个数据源采集原始数据。PowerShell 支持多种数据格式和协议,可以轻松实现多源数据的统一采集。下面的脚本演示了如何从 CSV 文件、JSON 接口和 REST 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
# 数据提取模块:多源数据采集与增量提取

# 定义数据源配置
$DataSources = @{
CsvPath = './data/sales_records.csv'
JsonPath = './data/product_catalog.json'
ApiEndpoint = 'https://api.example.com/v1/orders'
LastSyncFile = './data/.last_sync_timestamp'
}

# 读取上次同步时间戳(增量提取的关键)
$LastSyncTime = if (Test-Path $DataSources.LastSyncFile) {
Get-Content $DataSources.LastSyncFile -Raw | ForEach-Object {
[DateTime]::Parse($_.Trim())
}
} else {
[DateTime]::MinValue
}

Write-Host "上次同步时间: $LastSyncTime" -ForegroundColor Cyan

# 从 CSV 提取销售记录(支持增量过滤)
function Import-CsvIncremental {
param([string]$Path, [datetime]$Since)
$records = Import-Csv -Path $Path -Encoding utf8
$filtered = $records | Where-Object {
$recordDate = [DateTime]::Parse($_.OrderDate)
$recordDate -gt $Since
}
Write-Host "CSV 提取完成: 共 $($records.Count) 条, 增量 $($filtered.Count) 条"
return $filtered
}

# 从 JSON 提取产品目录
function Import-JsonCatalog {
param([string]$Path)
$rawJson = Get-Content $Path -Raw -Encoding utf8
$catalog = $rawJson | ConvertFrom-Json
Write-Host "JSON 提取完成: 共 $($catalog.Count) 个产品"
return $catalog
}

# 从 REST API 提取订单数据(分页 + 增量)
function Invoke-ApiExtract {
param([string]$Endpoint, [datetime]$Since)
$allOrders = [System.Collections.Generic.List[object]]::new()
$page = 1
$hasMore = $true
$headers = @{ 'Accept' = 'application/json' }

while ($hasMore) {
$params = @{
Uri = "$Endpoint`?page=$page&since=$($Since.ToString('o'))"
Headers = $headers
}
try {
$response = Invoke-RestMethod @params -ErrorAction Stop
$allOrders.AddRange($response.data)
$hasMore = $response.has_more
$page++
} catch {
Write-Warning "API 请求失败 (页 $page): $($_.Exception.Message)"
$hasMore = $false
}
}
Write-Host "API 提取完成: 共 $($allOrders.Count) 条订单"
return $allOrders
}

# 执行数据采集
$csvData = Import-CsvIncremental -Path $DataSources.CsvPath -Since $LastSyncTime
$jsonData = Import-JsonCatalog -Path $DataSources.JsonPath
$apiData = Invoke-ApiExtract -Endpoint $DataSources.ApiEndpoint -Since $LastSyncTime

# 更新同步时间戳
[DateTime]::UtcNow.ToString('o') | Set-Content $DataSources.LastSyncFile -NoNewline

Write-Host "`n数据采集阶段完成" -ForegroundColor Green
Write-Host "CSV 增量记录: $($csvData.Count) 条"
Write-Host "JSON 产品数: $($jsonData.Count) 个"
Write-Host "API 订单数: $($apiData.Count) 条"
1
2
3
4
5
6
7
8
9
上次同步时间: 2026/4/19 0:00:00
CSV 提取完成: 共 1500 条, 增量 87 条
JSON 提取完成: 共 234 个产品
API 提取完成: 共 42 条订单

数据采集阶段完成
CSV 增量记录: 87 条
JSON 产品数: 234 个
API 订单数: 42 条

可以看到,通过 Where-Object 对时间戳的过滤,我们只提取了上次同步之后新增的 87 条 CSV 记录。API 分页提取则自动循环请求直到所有数据加载完成。增量提取策略能显著减少每次管道运行的数据处理量。

数据转换与清洗

采集到的原始数据通常存在格式不一致、字段缺失、重复记录等问题,需要经过转换和清洗才能用于后续分析。PowerShell 的对象管道非常适合这类操作,可以像流水线一样逐步处理数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# 数据转换模块:清洗、映射、去重与富化

# 模拟采集到的原始数据
$RawRecords = @(
@{ OrderId = 'ORD-001'; Customer = '张三'; Amount = '1299.50'; Date = '2026-04-18'; Region = '' }
@{ OrderId = 'ORD-002'; Customer = '李四'; Amount = '899.00'; Date = '2026-04-18'; Region = '华东' }
@{ OrderId = 'ORD-003'; Customer = '王五'; Amount = '-50.00'; Date = '2026-04-19'; Region = '华南' }
@{ OrderId = 'ORD-002'; Customer = '李四'; Amount = '899.00'; Date = '2026-04-18'; Region = '华东' }
@{ OrderId = 'ORD-004'; Customer = ''; Amount = '2450.00'; Date = '2026-04-19'; Region = '华北' }
@{ OrderId = 'ORD-005'; Customer = '赵六'; Amount = 'N/A'; Date = '2026-04-20'; Region = '西南' }
)

Write-Host "原始记录数: $($RawRecords.Count)" -ForegroundColor Yellow

# 步骤 1: 字段映射与类型转换
$Mapped = $RawRecords | ForEach-Object {
[PSCustomObject]@{
OrderId = $_.OrderId
Customer = $_.Customer
Amount = $_.Amount
OrderDate = $_.Date
Region = $_.Region
IsValid = $true
Issues = [System.Collections.Generic.List[string]]::new()
}
}

# 步骤 2: 数据验证与标记
$Validated = $Mapped | ForEach-Object {
$record = $_

# 检查客户名是否为空
if ([string]::IsNullOrWhiteSpace($record.Customer)) {
$record.IsValid = $false
$record.Issues.Add('客户名为空')
}

# 尝试转换金额
$amountValue = 0.0
if ([double]::TryParse($record.Amount, [ref]$amountValue)) {
$record.Amount = $amountValue
} else {
$record.IsValid = $false
$record.Issues.Add("金额无法解析: $($record.Amount)")
}

# 检查金额是否为负数(异常值)
if ($amountValue -lt 0) {
$record.Issues.Add('金额为负数(可能是退款)')
}

# 补充缺失的区域信息
if ([string]::IsNullOrWhiteSpace($record.Region)) {
$record.Region = '未知'
$record.Issues.Add('区域信息缺失,已填充默认值')
}

# 转换日期格式
$record.OrderDate = [DateTime]::Parse($record.OrderDate).ToString('yyyy-MM-dd')

return $record
}

# 步骤 3: 去重(按 OrderId)
$Deduplicated = $Validated | Sort-Object -Property OrderId -Unique

# 步骤 4: 数据富化(添加计算字段)
$Enriched = $Deduplicated | ForEach-Object {
$tier = switch ($_.Amount) {
{ $_ -ge 2000 } { 'VIP' }
{ $_ -ge 1000 } { '高级' }
{ $_ -ge 500 } { '标准' }
default { '普通' }
}

$_ | Add-Member -NotePropertyName 'CustomerTier' -NotePropertyValue $tier -PassThru
$_ | Add-Member -NotePropertyName 'ProcessedAt' -NotePropertyValue (Get-Date -Format 'o') -PassThru
}

# 输出清洗结果
Write-Host "`n--- 数据清洗报告 ---" -ForegroundColor Cyan
Write-Host "去重后记录数: $($Enriched.Count)"
$validCount = ($Enriched | Where-Object { $_.IsValid }).Count
$invalidCount = ($Enriched | Where-Object { -not $_.IsValid }).Count
Write-Host "有效记录: $validCount"
Write-Host "无效记录: $invalidCount"

Write-Host "`n--- 有效数据 ---" -ForegroundColor Green
$Enriched | Where-Object { $_.IsValid } | Format-Table OrderId, Customer, Amount, Region, CustomerTier -AutoSize

Write-Host "--- 异常数据 ---" -ForegroundColor Red
$Enriched | Where-Object { -not $_.IsValid } | ForEach-Object {
Write-Host " $($_.OrderId): $($($_.Issues) -join ', ')"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
原始记录数: 6

--- 数据清洗报告 ---
去重后记录数: 5
有效记录: 3
无效记录: 2

--- 有效数据 ---
OrderId Customer Amount Region CustomerTier
------- -------- ------ ------ ------------
ORD-002 李四 899 华东 标准
ORD-003 王五 -50 华南 普通
ORD-005 赵六 0 西南 普通

--- 异常数据 ---
ORD-001: 区域信息缺失,已填充默认值
ORD-004: 客户名为空
ORD-005: 金额无法解析: N/A

转换管道依次完成了字段映射、类型转换、验证标记、去重和富化五个步骤。通过 Issues 列表记录每条数据的问题,方便后续排查。CustomerTier 等富化字段则为下游分析提供了额外的维度信息。注意去重将 6 条记录缩减为 5 条,ORD-002 的重复项已被移除。

数据加载与调度

经过清洗和转换的数据需要被加载到目标存储中,同时整个管道需要有可靠的调度、错误处理和日志机制。下面的脚本展示了批量写入、管道编排和执行日志的最佳实践。

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
# 数据加载与调度模块

# 日志工具函数
$LogPath = './data/pipeline_run.log'

function Write-PipelineLog {
param(
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR')]
[string]$Level = 'INFO'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
$entry = "[$timestamp] [$Level] $Message"
Add-Content -Path $LogPath -Value $entry -Encoding utf8
$color = switch ($Level) {
'INFO' { 'White' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
}
Write-Host $entry -ForegroundColor $color
}

# 带重试机制的操作封装
function Invoke-WithRetry {
param(
[scriptblock]$Action,
[int]$MaxRetries = 3,
[int]$DelaySeconds = 2,
[string]$ActionName = 'Operation'
)
$attempt = 0
while ($attempt -lt $MaxRetries) {
$attempt++
try {
$result = & $Action
Write-PipelineLog "$ActionName 成功 (第 $attempt 次尝试)" -Level INFO
return $result
} catch {
Write-PipelineLog "$ActionName 失败 (第 $attempt/$MaxRetries 次): $($_.Exception.Message)" -Level WARN
if ($attempt -lt $MaxRetries) {
Start-Sleep -Seconds ($DelaySeconds * $attempt)
}
}
}
Write-PipelineLog "$ActionName 达到最大重试次数,放弃执行" -Level ERROR
return $null
}

# 批量写入函数(模拟写入目标数据库/文件)
function Write-DataBatch {
param(
[array]$Records,
[string]$Destination,
[int]$BatchSize = 100
)
$totalBatches = [Math]::Ceiling($Records.Count / $BatchSize)
$successCount = 0
$failCount = 0

for ($i = 0; $i -lt $Records.Count; $i += $BatchSize) {
$batchNum = [Math]::Floor($i / $BatchSize) + 1
$batch = $Records[$i..([Math]::Min($i + $BatchSize - 1, $Records.Count - 1))]

$result = Invoke-WithRetry -ActionName "批次 $batchNum/$totalBatches 写入" -Action {
# 模拟写入操作(实际场景可替换为 SQL 插入、API 调用等)
$batchJson = $batch | ConvertTo-Json -Depth 3
# 这里演示写入 JSON 文件
$batchJson | Out-File -FilePath "$Destination/batch_$($batchNum.ToString('D4')).json" -Encoding utf8
return $batch.Count
}

if ($null -ne $result) {
$successCount += $result
} else {
$failCount += $batch.Count
}
}

return @{ Success = $successCount; Failed = $failCount }
}

# 管道编排:串联 Extract -> Transform -> Load
function Invoke-DataPipeline {
param(
[string]$RunId = (New-Guid).ToString('N').Substring(0, 8)
)

$runStart = Get-Date
Write-PipelineLog "========== 管道运行 $RunId 开始 ==========" -Level INFO

try {
# 阶段 1: 加载已清洗的数据(模拟)
Write-PipelineLog '阶段 1/3: 加载已处理数据...' -Level INFO
$processedData = @(
@{ OrderId = 'ORD-001'; Customer = '张三'; Amount = 1299.50 }
@{ OrderId = 'ORD-002'; Customer = '李四'; Amount = 899.00 }
@{ OrderId = 'ORD-003'; Customer = '王五'; Amount = 2450.00 }
@{ OrderId = 'ORD-006'; Customer = '孙七'; Amount = 3200.00 }
@{ OrderId = 'ORD-007'; Customer = '周八'; Amount = 150.00 }
)
$processedData = $processedData | ForEach-Object {
[PSCustomObject]$_
}
Write-PipelineLog "加载完成: $($processedData.Count) 条记录" -Level INFO

# 阶段 2: 数据质量检查
Write-PipelineLog '阶段 2/3: 数据质量检查...' -Level INFO
$qualityCheck = $processedData | Where-Object {
$_.Amount -gt 0 -and -not [string]::IsNullOrWhiteSpace($_.Customer)
}
$rejected = $processedData.Count - $qualityCheck.Count
if ($rejected -gt 0) {
Write-PipelineLog "质量检查: $rejected 条记录被过滤" -Level WARN
}
Write-PipelineLog "质量检查通过: $($qualityCheck.Count) 条记录" -Level INFO

# 阶段 3: 批量写入
Write-PipelineLog '阶段 3/3: 批量写入目标...' -Level INFO
$outputDir = './data/output'
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
$writeResult = Write-DataBatch -Records $qualityCheck -Destination $outputDir -BatchSize 2

# 汇总报告
$runEnd = Get-Date
$duration = ($runEnd - $runStart).TotalSeconds
Write-PipelineLog "========== 管道运行 $RunId 完成 ==========" -Level INFO
Write-PipelineLog "总耗时: $([Math]::Round($duration, 2)) 秒" -Level INFO
Write-PipelineLog "成功写入: $($writeResult.Success) 条, 失败: $($writeResult.Failed) 条" -Level INFO

} catch {
Write-PipelineLog "管道运行异常中断: $($_.Exception.Message)" -Level ERROR
Write-PipelineLog $_.ScriptStackTrace -Level ERROR
}
}

# 执行完整管道
Invoke-DataPipeline -RunId 'RUN20260420'

# 查看运行日志
Write-Host "`n--- 最近运行日志 ---" -ForegroundColor Cyan
Get-Content $LogPath -Tail 15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[2026-04-20 08:15:32.012] [INFO] ========== 管道运行 RUN20260420 开始 ==========
[2026-04-20 08:15:32.015] [INFO] 阶段 1/3: 加载已处理数据...
[2026-04-20 08:15:32.045] [INFO] 加载完成: 5 条记录
[2026-04-20 08:15:32.046] [INFO] 阶段 2/3: 数据质量检查...
[2026-04-20 08:15:32.050] [INFO] 质量检查通过: 5 条记录
[2026-04-20 08:15:32.051] [INFO] 阶段 3/3: 批量写入目标...
[2026-04-20 08:15:32.080] [INFO] 批次 1/3 写入 成功 (第 1 次尝试)
[2026-04-20 08:15:32.105] [INFO] 批次 2/3 写入 成功 (第 1 次尝试)
[2026-04-20 08:15:32.130] [INFO] 批次 3/3 写入 成功 (第 1 次尝试)
[2026-04-20 08:15:32.132] [INFO] ========== 管道运行 RUN20260420 完成 ==========
[2026-04-20 08:15:32.132] [INFO] 总耗时: 0.12 秒
[2026-04-20 08:15:32.133] [INFO] 成功写入: 5 条, 失败: 0 条

--- 最近运行日志 ---
[2026-04-20 08:15:32.132] [INFO] ========== 管道运行 RUN20260420 完成 ==========
[2026-04-20 08:15:32.132] [INFO] 总耗时: 0.12 秒
[2026-04-20 08:15:32.133] [INFO] 成功写入: 5 条, 失败: 0 条

管道编排函数将三个阶段串联执行,每个阶段都有独立的日志记录。Invoke-WithRetry 函数为网络请求等不稳定操作提供了自动重试能力,重试间隔采用线性退避策略。批量写入按指定的 BatchSize 分片处理,避免一次性加载大量数据导致内存溢出。

注意事项

  1. 增量提取务必记录同步水位:使用时间戳文件或数据库水位表记录上次同步位置,避免全量扫描导致性能浪费。对于高精度场景,建议使用 UTC 时间并存储为 ISO 8601 格式。

  2. 数据转换应保持幂等性:同一条记录多次执行转换脚本应产生相同结果,避免在管道重跑时出现数据不一致。所有随机值或时间戳等非确定性输出应与业务字段解耦。

  3. 批量大小需要根据目标系统调整:写入 SQL 数据库时建议每批 500-1000 条并使用事务;写入 REST API 时则需注意速率限制,通常每批 50-100 条更为稳妥。

  4. 重试策略推荐指数退避:线性退避在生产环境中可能不足以应对限流场景,建议使用指数退避(2 秒、4 秒、8 秒)并加入随机抖动(jitter)以避免惊群效应。

  5. 日志应同时输出到控制台和文件:管道通常在后台定时运行,控制台日志会丢失,因此必须持久化到文件。建议使用 JSON 格式日志以便后续接入 ELK 等日志分析系统。

  6. 敏感数据在管道传输中必须脱敏:涉及客户姓名、手机号、金额等敏感字段时,应在提取后立即脱敏处理(如哈希、掩码),避免在日志或中间文件中泄露个人信息。

PowerShell 技能连载 - Azure Container Apps 环境管理

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

Azure Container Apps 的”环境”(Managed Environment)是整个平台的核心组织单元。每个环境相当于一个轻量级的 Kubernetes 集群边界,定义了容器应用之间的网络拓扑、日志目标和共享基础设施。与直接操作 AKS 不同,环境的网络配置、内部流量路由和 Dapr 组件都通过声明式的资源模型管理,运维人员无需关心底层节点维护。

在微服务架构中,环境级别的配置往往比单个应用更为重要。合理规划 VNet 集成可以确保服务间通信不经过公网;精确的流量分割策略可以实现蓝绿发布和金丝雀部署;Dapr 服务网格则统一了服务发现、状态管理和发布订阅等横切关注点。通过 PowerShell 脚本化这些配置,不仅提高了可重复性,也便于纳入 CI/CD 流水线进行审计和回滚。

本文将围绕 Container Apps 环境管理的三个核心场景展开:环境与网络配置、微服务部署与流量管理、以及 Dapr 集成与服务网格。

Container Apps 环境与联网

每个 Container Apps 环境都需要一个虚拟网络来承载内部流量。下面的脚本创建了一个带有自定义子网的 Managed Environment,并配置了内部负载均衡器模式,确保所有入站流量仅通过环境内部 IP 可达,不暴露公网端点。这种”内部环境”模式特别适合企业内部 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
# 安装并导入 ContainerApp 模块
Install-Module -Name Az.ContainerApp -Force -Scope CurrentUser
Import-Module Az.ContainerApp

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

# 定义资源组与环境参数
$resourceGroup = 'rg-containerapps-prod'
$location = 'eastasia'
$envName = 'cae-microservice-prod'

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

# 创建专用的虚拟网络和子网
# Container Apps 环境要求子网至少 /23 大小
$vnet = New-AzVirtualNetwork `
-ResourceGroupName $resourceGroup `
-Location $location `
-Name 'vnet-containerapps' `
-AddressPrefix '10.0.0.0/16'

$subnet = Add-AzVirtualNetworkSubnetConfig `
-Name 'snet-containerapps' `
-AddressPrefix '10.0.0.0/23' `
-VirtualNetwork $vnet

$vnet = Set-AzVirtualNetwork -VirtualNetwork $vnet
$subnetId = $vnet.Subnets | Where-Object { $_.Name -eq 'snet-containerapps' } | Select-Object -ExpandProperty Id

# 创建 Managed Environment(内部模式)
$managedEnv = New-AzContainerAppManagedEnv `
-ResourceGroupName $resourceGroup `
-Location $location `
-Name $envName `
-SubnetResourceId $subnetId `
-AppInsightsConfigurationIngestionKey $null `
-AppLogsConfigurationDestination 'log-analytics'

# 为环境关联 Log Analytics 工作区
$workspace = New-AzOperationalInsightsWorkspace `
-ResourceGroupName $resourceGroup `
-Location $location `
-Name 'law-containerapps-prod'

$managedEnv | Update-AzContainerAppManagedEnv `
-AppLogsLogAnalyticsConfigurationCustomerId $workspace.CustomerId `
-AppLogsLogAnalyticsConfigurationSharedKey $workspace.GetSharedKeys().PrimarySharedKey

# 查看环境信息
$managedEnv | Format-List Name, Location, ProvisioningState, DefaultDomain
1
2
3
4
Name              : cae-microservice-prod
Location : eastasia
ProvisioningState : Succeeded
DefaultDomain : pleasantsea-xxxxxxxx.eastasia.azurecontainerapps.io

创建完环境后,可以进一步配置日志目标、添加自定义域和证书。下面的示例展示如何在环境中注册自定义域并绑定 SSL 证书,使内部服务通过企业域名访问。

1
2
3
4
5
6
7
# 获取环境详情
$env = Get-AzContainerAppManagedEnv `
-ResourceGroupName $resourceGroup `
-Name $envName

# 查看环境支持的可用区域冗余
$env | Select-Object Name, ZoneRedundant, VnetConfigurationInternal
1
2
3
Name                : cae-microservice-prod
ZoneRedundant : True
VnetConfigurationInternal : True

微服务部署与流量管理

在同一个环境中部署多个微服务时,流量管理是保障发布安全和系统稳定性的关键。下面的脚本演示了如何部署一个 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
# 定义容器应用参数
$appName = 'ca-api-gateway'
$imageV1 = 'myregistry.azurecr.io/api-gateway:v1.0.0'
$imageV2 = 'myregistry.azurecr.io/api-gateway:v1.1.0'
$acrLoginServer = 'myregistry.azurecr.io'

# 获取 ACR 凭据用于拉取镜像
$acr = Get-AzContainerRegistry -ResourceGroupName $resourceGroup -Name 'myregistry'
$acrCred = Get-AzContainerRegistryCredential -Registry $acr

# 部署初始版本 v1.0.0
New-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name $appName `
-Location $location `
-ManagedEnvironmentId $env.Id `
-ConfigurationRegistryServer $acrLoginServer `
-ConfigurationRegistryUser $acrCred.Username `
-ConfigurationRegistryPasswordSecretRef 'acr-password' `
-Secret 'acr-password'=$acrCred.Password `
-Image $imageV1 `
-TargetPort 8080 `
-IngressTargetPort 8080 `
-IngressExternal:$false `
-Cpu 0.5 `
-Memory '1.0Gi' `
-MinReplica 1 `
-MaxReplica 5

# 创建新修订版 v1.1.0,并配置 20% 流量
New-AzContainerAppRevision `
-ResourceGroupName $resourceGroup `
-Name $appName `
-Image $imageV2 `
-TrafficWeight 20 `
-RevisionSuffix 'v1-1-0'

# 验证流量分割状态
$trafficConfig = Get-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name $appName

$trafficConfig.TrafficWeight | Format-Table RevisionName, Weight, LatestRevision
1
2
3
4
RevisionName                           Weight LatestRevision
------------ ------ --------------
ca-api-gateway--v1-0-0-xxxxxxxx 80 False
ca-api-gateway--v1-1-0-xxxxxxxx 20 True

自动扩缩容是容器应用的核心能力。下面的脚本配置基于 HTTP 并发请求数的扩缩容规则,并设置 CPU 使用率作为辅助指标。当请求量突增时,实例数会自动扩展到上限;流量回落后自动缩容以节约成本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 配置自定义扩缩容规则
$scaleRule = New-AzContainerAppScaleRuleObject `
-Name 'http-concurrency' `
-CustomType 'http' `
-CustomMetadata 'concurrency'='100'

# 更新容器应用的扩缩容配置
Update-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name $appName `
-MinReplica 2 `
-MaxReplica 20 `
-ScaleRule $scaleRule

# 查看当前修订版的扩缩容配置
$app = Get-AzContainerApp -ResourceGroupName $resourceGroup -Name $appName
$app.Template.Scale | Format-List MinReplica, MaxReplica
1
2
MinReplica : 2
MaxReplica : 20

Dapr 集成与服务网格

Dapr(Distributed Application Runtime)为微服务提供了标准化的服务间调用、状态管理和发布订阅能力。Container Apps 原生集成 Dapr,无需额外安装 Sidecar。下面的脚本演示了如何在环境中启用 Dapr,配置状态存储和服务间调用组件。

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
# 在环境中添加 Dapr 状态存储组件(使用 Azure Cosmos DB)
$cosmosAccount = 'cosmos-microservice-prod'
$cosmosDb = 'statestore'
$cosmosKey = (Get-AzCosmosDBAccountKey `
-ResourceGroupName $resourceGroup `
-Name $cosmosAccount).PrimaryMasterKey

# 创建 Dapr 组件:状态存储
New-AzContainerAppManagedEnvDaprComponent `
-ResourceGroupName $resourceGroup `
-EnvName $envName `
-Name 'statestore' `
-ComponentType 'state.azure.cosmosdb' `
-Version 'v1' `
-Metadata 'url'="https://$cosmosAccount.documents.azure.com:443/" `
-Metadata 'database'=$cosmosDb `
-Metadata 'collection'='state' `
-Secret 'masterKey'=$cosmosKey `
-SecretRef 'masterKey'

# 创建 Dapr 组件:发布订阅(使用 Azure Service Bus)
$servicebusKey = (Get-AzServiceBusKey `
-ResourceGroupName $resourceGroup `
-NamespaceName 'sb-microservice-prod' `
-QueueName 'orders' `
-AuthorizationRuleName 'RootManageSharedAccessKey').PrimaryKey

$sbConnStr = "Endpoint=sb://sb-microservice-prod.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=$servicebusKey"

New-AzContainerAppManagedEnvDaprComponent `
-ResourceGroupName $resourceGroup `
-EnvName $envName `
-Name 'pubsub-orders' `
-ComponentType 'pubsub.azure.servicebus' `
-Version 'v1' `
-Metadata 'connectionString'=$sbConnStr `
-Scopes 'ca-order-service','ca-notification-service'

# 部署启用 Dapr 的订单服务
New-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name 'ca-order-service' `
-Location $location `
-ManagedEnvironmentId $env.Id `
-Image "$acrLoginServer/order-service:v1.0.0" `
-ConfigurationRegistryServer $acrLoginServer `
-ConfigurationRegistryUser $acrCred.Username `
-ConfigurationRegistryPasswordSecretRef 'acr-password' `
-DaprEnabled `
-DaprAppId 'order-service' `
-DaprAppPort 8080 `
-DaprAppProtocol 'http' `
-TargetPort 8080 `
-IngressTargetPort 8080 `
-IngressExternal:$false `
-EnvVar 'ASPNETCORE_ENVIRONMENT'='Production'

# 部署启用 Dapr 的通知服务(订阅订单事件)
New-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name 'ca-notification-service' `
-Location $location `
-ManagedEnvironmentId $env.Id `
-Image "$acrLoginServer/notification-service:v1.0.0" `
-ConfigurationRegistryServer $acrLoginServer `
-ConfigurationRegistryUser $acrCred.Username `
-ConfigurationRegistryPasswordSecretRef 'acr-password' `
-DaprEnabled `
-DaprAppId 'notification-service' `
-DaprAppPort 8080 `
-DaprAppProtocol 'http' `
-TargetPort 8080 `
-IngressTargetPort 8080 `
-IngressExternal:$false

# 列出环境中所有 Dapr 组件
Get-AzContainerAppManagedEnvDaprComponent `
-ResourceGroupName $resourceGroup `
-EnvName $envName | Format-Table Name, ComponentType, Version
1
2
3
4
Name            ComponentType              Version
---- ------------- -------
statestore state.azure.cosmosdb v1
pubsub-orders pubsub.azure.servicebus v1

部署完成后,订单服务可以通过 Dapr 的服务调用 API 与通知服务通信,无需硬编码服务地址。下面的脚本验证 Dapr Sidecar 的健康状态,并测试服务间调用链路。

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
# 获取订单服务的内部 FQDN
$orderApp = Get-AzContainerApp `
-ResourceGroupName $resourceGroup `
-Name 'ca-order-service'

$internalUrl = "http://{0}:8080/v1.0/invoke/notification-service/method/health" -f `
$orderApp.Configuration.Ingress.Fqdn

# 通过 Dapr Sidecar 测试服务间调用
$envId = (Get-AzContainerAppManagedEnv `
-ResourceGroupName $resourceGroup `
-Name $envName).StaticIp

Write-Host "环境静态 IP: $envId"
Write-Host "订单服务内部域名: $($orderApp.Configuration.Ingress.Fqdn)"

# 列出环境中的所有容器应用及其 Dapr 配置
$apps = Get-AzContainerApp -ResourceGroupName $resourceGroup
$apps | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
DaprEnabled = $_.Template.Dapr.Enabled
DaprAppId = $_.Template.Dapr.AppId
Replicas = "{0}-{1}" -f $_.Template.Scale.MinReplica, $_.Template.Scale.MaxReplica
}
} | Format-Table -AutoSize
1
2
3
4
5
6
7
8
环境静态 IP: 10.0.0.4
订单服务内部域名: ca-order-service.internal.pleasantsea-xxxxxxxx.eastasia.azurecontainerapps.io

Name DaprEnabled DaprAppId Replicas
---- ----------- ---------- --------
ca-api-gateway False 2-20
ca-order-service True order-service 1-10
ca-notification-service True notification-service 1-10

注意事项

  1. 子网大小要求:Container Apps 环境要求关联子网至少为 /23(512 个 IP),在规划 VNet 地址空间时需预留足够的 IP 范围,避免因 IP 耗尽导致新应用无法部署。

  2. 内部环境模式:启用 VnetConfigurationInternal 后,环境不提供公网入口。如需外部访问,必须额外部署 Application Gateway 或 Front Door 作为反向代理,并将其后端池指向环境的内部 IP。

  3. Dapr 组件的作用域:通过 Scopes 参数限制哪些容器应用可以使用某个 Dapr 组件。不加限制的组件对所有启用 Dapr 的应用可见,可能导致意外的依赖关系和安全风险。

  4. 流量分割的修订版管理:每个流量权重条目对应一个活跃修订版。当所有流量切换到新版本后,旧修订版不会自动删除,需要手动清理以释放资源配额。可使用 Remove-AzContainerAppRevision 清理不再使用的版本。

  5. 扩缩容冷启动MinReplica 设为 0 虽然可以节省成本,但会导致冷启动延迟(通常 5-30 秒)。对于延迟敏感型 API 网关或前端服务,建议设置 MinReplica 至少为 1。

  6. 密钥与连接字符串安全:Dapr 组件中的敏感信息(如数据库密钥、连接字符串)应通过 SecretSecretRef 传递,避免明文出现在脚本中。更推荐将密钥存储在 Azure Key Vault 中,通过 Managed Identity 引用。