PowerShell 技能连载 - Excel 自动化报表

适用于 PowerShell 5.1 及以上版本

在企业环境中,Excel 是最通用的数据交换格式之一。无论是系统运维的周报、安全审计的统计报告,还是业务分析的汇总数据,Excel 报表几乎无处不在。然而,手动从多个数据源收集信息、格式化表格、生成图表并分发报表,这个过程既耗时又容易出错。

ImportExcel 模块的出现彻底改变了这一局面。它是一个纯 PowerShell 实现的 .xlsx 文件操作库,无需安装 Microsoft Excel 即可完成读取、写入、图表生成、条件格式等操作。这意味着你可以在 Windows Server Core 甚至 Linux 服务器上运行报表生成脚本,完全不依赖 Office 组件。

本文将通过三个递进的场景,带你掌握从基础数据导入导出到自动化报表分发的完整工作流。无论你是需要将系统日志转换为可读报表,还是要定期向管理层发送格式化的运维报告,这些技巧都能帮你节省大量时间。

数据导入导出

ImportExcel 最基础的能力是读取和写入 Excel 文件。下面的示例展示了如何读取一个包含服务器资产清单的 Excel 文件,按状态筛选数据,并将结果导出为新的报表文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 安装 ImportExcel 模块(仅需执行一次)
Install-Module -Name ImportExcel -Scope CurrentUser -Force

# 读取服务器资产清单
$assetFile = "C:\Reports\ServerAssets.xlsx"
$allServers = Import-Excel -Path $assetFile -WorksheetName "资产清单"

Write-Host "共读取 $($allServers.Count) 条服务器记录"

# 筛选在线服务器并按内存使用率排序
$onlineServers = $allServers |
Where-Object { $_.状态 -eq '在线' } |
Sort-Object -Property { [double]$_.内存使用率 } -Descending

# 计算汇总统计
$summary = [PSCustomObject]@{
总数 = $allServers.Count
在线数 = ($allServers | Where-Object { $_.状态 -eq '在线' }).Count
离线数 = ($allServers | Where-Object { $_.状态 -eq '离线' }).Count
平均内存 = '{0:N1}%' -f (($onlineServers | ForEach-Object { [double]$_.内存使用率 } | Measure-Object -Average).Average)
}

# 导出筛选结果和汇总到新 Excel 文件
$outputPath = "C:\Reports\OnlineServers_$(Get-Date -Format 'yyyyMMdd').xlsx"

# 使用自动表格样式导出
$onlineServers | Select-Object 主机名, IP地址, 操作系统, CPU核数, 内存使用率, 磁盘剩余, 最后响应时间 |
Export-Excel -Path $outputPath -WorksheetName "在线服务器" -TableName "ServerTable" -TableStyle Light1 -AutoSize

$summary | Export-Excel -Path $outputPath -WorksheetName "汇总" -StartRow 1

Write-Host "报表已导出到: $outputPath"
1
2
共读取 156 条服务器记录
报表已导出到: C:\Reports\OnlineServers_20260121.xlsx

导出的 Excel 文件包含两个工作表:「在线服务器」以表格形式展示筛选后的服务器详情,「汇总」提供一目了然的统计数字。-AutoSize 参数自动调整列宽,-TableStyle 参数应用内置表格样式,让报表专业且易读。

图表与格式化

纯数字的报表缺少直观性。ImportExcel 支持通过 Add-ExcelChart 命令在 Excel 中创建柱状图、饼图、折线图等多种图表,还能使用 Set-ExcelRange 添加条件格式,让关键数据一目了然。

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
# 准备示例数据:各区域服务器状态统计
$regionData = @(
[PSCustomObject]@{ 区域 = '华东'; 在线 = 45; 离线 = 3; 维护中 = 2 }
[PSCustomObject]@{ 区域 = '华北'; 在线 = 38; 离线 = 5; 维护中 = 1 }
[PSCustomObject]@{ 区域 = '华南'; 在线 = 32; 离线 = 2; 维护中 = 3 }
[PSCustomObject]@{ 区域 = '西南'; 在线 = 15; 离线 = 1; 维护中 = 1 }
[PSCustomObject]@{ 区域 = '东北'; 在线 = 8; 离线 = 0; 维护中 = 0 }
)

$chartFile = "C:\Reports\RegionStatus_$(Get-Date -Format 'yyyyMMdd').xlsx"

# 导出数据并附加柱状图
$excel = $regionData | Export-Excel -Path $chartFile -WorksheetName "区域统计" -TableName "RegionTable" -PassThru

$sheet = $excel.Workbook.Worksheets["区域统计"]

# 添加簇状柱状图
Add-ExcelChart -Worksheet $sheet -ChartType ColumnClustered `
-XRange "A2:A6" `
-YRange "B2:D6" `
-Title "各区域服务器状态分布" `
-SeriesHeader "在线", "离线", "维护中" `
-Width 800 -Height 400 `
-Row 8 -Column 1

# 为在线列添加数据条条件格式
Set-ExcelRange -Worksheet $sheet -Range "B2:B6" -ConditionalFormat {
param($cond)
$cond.SetDataBar(
[OfficeOpenXml.ConditionalFormatting.ExcelBarColor]::Green
)
}

# 为离线列添加高亮条件格式(值大于 3 时显示红色)
Set-ExcelRange -Worksheet $sheet -Range "C2:C6" -ConditionalFormat {
param($cond)
$rule = $cond.AddGreaterThanOrEqual()
$rule.Formula = "3"
$rule.Style.Fill.BackgroundColor.Color = [System.Drawing.Color]::LightPink
}

# 设置标题行样式
Set-ExcelRange -Worksheet $sheet -Range "A1:G1" -Bold -FontSize 12 -BackgroundColor ([System.Drawing.Color]::SteelBlue) -FontColor ([System.Drawing.Color]::White)

Close-ExcelPackage -ExcelPackage $excel -Save

Write-Host "图表报表已生成: $chartFile"
1
图表报表已生成: C:\Reports\RegionStatus_20260121.xlsx

这段代码生成了一个包含簇状柱状图的 Excel 报表。在线服务器数量列带有绿色数据条,直观反映各区域的容量规模;离线数量超过阈值的单元格自动标红,方便运维人员快速定位问题区域。-PassThru 参数返回 Excel 包对象,允许我们在保存前进行更多自定义操作。

自动化报表生成

将前面的技术整合起来,就可以构建一个完整的自动化报表工作流:从多个数据源汇总信息,生成多 Sheet 报表,并通过邮件自动分发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
function New-WeeklyOpsReport {
<#
.SYNOPSIS
生成运维周报并自动发送邮件
#>
param(
[string]$OutputPath = "C:\Reports\WeeklyOps_$(Get-Date -Format 'yyyyMMdd').xlsx",
[string[]]$Recipients = @("ops-team@company.com", "manager@company.com")
)

$reportDate = Get-Date -Format "yyyy年MM月dd日"

# --- 数据采集 ---
# 模拟从各系统采集的数据(实际环境中可替换为 API 调用、数据库查询等)
$eventStats = @(
[PSCustomObject]@{ 类型 = '错误'; 本周 = 23; 上周 = 31; 趋势 = '下降' }
[PSCustomObject]@{ 类型 = '警告'; 本周 = 156; 上周 = 142; 趋势 = '上升' }
[PSCustomObject]@{ 类型 = '信息'; 本周 = 8432; 上周 = 7891; 趋势 = '上升' }
)

$patchCompliance = @(
[PSCustomObject]@{ 分类 = '安全补丁'; 合格率 = '94.2%'; 待处理 = 12 }
[PSCustomObject]@{ 分类 = '功能更新'; 合格率 = '87.5%'; 待处理 = 28 }
[PSCustomObject]@{ 分类 = '驱动程序'; 合格率 = '96.1%'; 待处理 = 5 }
)

# --- 生成 Excel 报表 ---
$excel = Export-Excel -Path $OutputPath -PassThru

# Sheet 1:概览摘要
$overview = [PSCustomObject]@{
报告日期 = $reportDate
报告周期 = "$(Get-Date (Get-Date).AddDays(-7) -Format 'MM/dd') - $(Get-Date -Format 'MM/dd')"
服务器总数 = 156
在线率 = '97.4%'
平均响应时间 = '23ms'
}
$overview | Export-Excel -ExcelPackage $excel -WorksheetName "概览" -AutoSize -BoldTopRow

# Sheet 2:事件统计(含图表)
$eventSheet = $eventStats | Export-Excel -ExcelPackage $excel -WorksheetName "事件统计" -TableName "EventTable" -PassThru
Add-ExcelChart -Worksheet $eventSheet.Workbook.Worksheets["事件统计"] `
-ChartType BarClustered `
-XRange "A2:A4" -YRange "B2:C4" `
-Title "事件趋势对比(本周 vs 上周)" `
-SeriesHeader "本周", "上周" `
-Row 6 -Column 1 -Width 700 -Height 350

# Sheet 3:补丁合规率
$patchCompliance | Export-Excel -ExcelPackage $excel -WorksheetName "补丁合规" -TableName "PatchTable" -AutoSize -NumberFormat '0.0%'

# 保存并关闭
Close-ExcelPackage -ExcelPackage $excel -Save

Write-Host "报表已生成: $OutputPath"

# --- 发送邮件 ---
$smtpServer = "smtp.company.com"
$from = "ops-report@company.com"
$subject = "[自动化] 运维周报 - $reportDate"

$body = @"
各位好,

本周运维报表已自动生成,请查阅附件。

主要指标:
- 服务器在线率:97.4%
- 安全补丁合规率:94.2%
- 错误事件:23 起(较上周下降 25.8%)

此邮件由自动化系统发送,请勿直接回复。
"@

Send-MailMessage -From $from -To $Recipients -Subject $subject -Body $body -SmtpServer $smtpServer -Attachments $OutputPath -Encoding UTF8

Write-Host "报表已发送至: $($Recipients -join ', ')"
}

# 执行周报生成
New-WeeklyOpsReport
1
2
报表已生成: C:\Reports\WeeklyOps_20260121.xlsx
报表已发送至: ops-team@company.com, manager@company.com

这个函数实现了一个完整的自动化报表管道。它将数据采集、报表生成和邮件分发整合到一个可复用的函数中。三个 Sheet 分别承载概览摘要、事件趋势图表和补丁合规明细,满足不同受众的需求。结合 Windows 任务计划程序或 Linux 的 cron,就可以实现真正的无人值守周报。

注意事项

  • ImportExcel 模块依赖 .NET 的 EPPlus 库,无需安装 Microsoft Excel,可以在 Server Core 和 Linux 环境中运行,但需要确保 PowerShell 版本满足 5.1 及以上。
  • 使用 Export-Excel-PassThru 参数时,务必在操作完成后调用 Close-ExcelPackage -Save 保存文件,否则修改不会写入磁盘,且可能造成文件句柄泄漏。
  • Add-ExcelChart-XRange-YRange 参数引用的是 Excel 单元格范围,如果数据行数动态变化,需要先计算范围字符串再传入,避免图表数据截断。
  • 条件格式的设置使用 EPPlus 的原生 API,语法与 VBA 中的条件格式对象模型有差异,编写时建议参考 EPPlus 官方文档。
  • 邮件发送部分使用 Send-MailMessage,该 cmdlet 在 PowerShell 7.x 中标记为已过时,生产环境建议使用 MailKit 库或调用 REST API(如 Microsoft Graph)来发送邮件。
  • 处理大型 Excel 文件(超过 10 万行)时,建议使用 -NoNumberFormat-AsText 参数关闭自动类型推断,可以显著提升导入导出的性能。

PowerShell 技能连载 - Azure 存储服务管理

适用于 PowerShell 7.0 及以上版本

Azure 存储服务是云基础设施中不可或缺的一环。无论是应用程序数据持久化、静态网站托管、大数据分析流水线,还是灾备归档,都离不开它。一个中型企业往往拥有数十个存储账户、数百个容器和海量的 Blob 对象,靠手工在 Azure 门户中逐一配置既容易出错,也难以保证一致性。

PowerShell 通过 Az.Storage 模块提供了对 Azure 存储的完整管理能力。从存储账户的创建与访问层选择,到 Blob 容器的生命周期策略,再到细粒度的访问控制和成本监控,都可以编写脚本实现自动化。对于需要跨区域部署或遵循合规要求的场景,脚本能确保每次操作都遵循相同的规范。

本文将通过三个实战场景,展示如何使用 PowerShell 高效管理 Azure 存储:首先是存储账户与 Blob 容器的批量管理,其次是文件共享与生命周期策略配置,最后是存储分析与成本监控。

存储账户与 Blob 容器管理

在 Azure 中,存储账户是所有存储服务的根容器。创建时需要选择合适的复制策略和访问层,以平衡性能和成本。下面的脚本展示了如何批量创建存储账户、配置访问层,以及管理 Blob 容器的完整流程。

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

# 定义存储账户配置
$resourceGroup = "blog-storage-rg"
$location = "eastasia"
$accounts = @(
@{ Name = "stbloghotprod"; AccessTier = "Hot"; Sku = "Standard_LRS" }
@{ Name = "stblogcoolarch"; AccessTier = "Cool"; Sku = "Standard_GRS" }
@{ Name = "stblogpremv2"; AccessTier = "Hot"; Sku = "Premium_LRS" }
)

# 确保资源组存在
if (-not (Get-AzResourceGroup -Name $resourceGroup -ErrorAction SilentlyContinue)) {
New-AzResourceGroup -Name $resourceGroup -Location $location
Write-Host "已创建资源组: $resourceGroup"
}

# 批量创建存储账户
foreach ($acct in $accounts) {
$existing = Get-AzStorageAccount -ResourceGroupName $resourceGroup `
-Name $acct.Name -ErrorAction SilentlyContinue

if ($existing) {
Write-Host "存储账户 $($acct.Name) 已存在,跳过创建"
} else {
New-AzStorageAccount -ResourceGroupName $resourceGroup `
-Name $acct.Name `
-Location $location `
-SkuName $acct.Sku `
-Kind StorageV2 `
-AccessTier $acct.AccessTier `
-EnableHierarchicalNamespace $false
Write-Host "已创建存储账户: $($acct.Name) ($($acct.AccessTier) / $($acct.Sku))"
}
}

# 获取第一个存储账户的上下文并创建容器
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourceGroup `
-Name $accounts[0].Name
$ctx = $storageAccount.Context

# 定义容器列表及公开访问级别
$containers = @(
@{ Name = "images"; Access = "Blob" }
@{ Name = "documents"; Access = "Off" }
@{ Name = "logs"; Access = "Off" }
@{ Name = "backups"; Access = "Off" }
)

foreach ($c in $containers) {
$existing = Get-AzStorageContainer -Name $c.Name -Context $ctx `
-ErrorAction SilentlyContinue

if ($existing) {
Write-Host "容器 $($c.Name) 已存在"
} else {
New-AzStorageContainer -Name $c.Name -Context $ctx `
-Permission $c.Access
Write-Host "已创建容器: $($c.Name) (公开访问: $($c.Access))"
}
}

# 批量上传文件到 images 容器
$uploadPath = "/data/blog-assets"
if (Test-Path $uploadPath) {
$files = Get-ChildItem -Path $uploadPath -File
foreach ($file in $files) {
Set-AzStorageBlobContent -File $file.FullName `
-Container "images" `
-Blob $file.Name `
-Context $ctx `
-StandardBlobTier Hot `
-Force
Write-Host "已上传: $($file.Name) ($('{0:N2}' -f ($file.Length / 1KB)) KB)"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建资源组: blog-storage-rg
已创建存储账户: stbloghotprod (Hot / Standard_LRS)
已创建存储账户: stblogcoolarch (Cool / Standard_GRS)
已创建存储账户: stblogpremv2 (Hot / Premium_LRS)
已创建容器: images (公开访问: Blob)
已创建容器: documents (公开访问: Off)
已创建容器: logs (公开访问: Off)
已创建容器: backups (公开访问: Off)
已上传: hero-banner.png (245.67 KB)
已上传: logo-dark.svg (3.21 KB)
已上传: favicon.ico (4.12 KB)

文件共享与生命周期管理

当存储规模增长后,手动管理数据的分层和过期变得不现实。Azure 存储的生命周期管理(Lifecycle Management)可以根据规则自动将数据从热层迁移到冷层或归档层,甚至自动删除过期数据。结合 SAS 令牌,还可以为第三方应用授予临时访问权限,而无需暴露存储账户密钥。

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
# 获取存储账户上下文
$storageAccount = Get-AzStorageAccount -ResourceGroupName "blog-storage-rg" `
-Name "stbloghotprod"
$ctx = $storageAccount.Context

# 创建文件共享(Azure Files)
$shareName = "blog-shared-assets"
$share = Get-AzStorageShare -Name $shareName -Context $ctx `
-ErrorAction SilentlyContinue

if (-not $share) {
New-AzStorageShare -Name $shareName -Context $ctx
Write-Host "已创建文件共享: $shareName"
}

# 上传目录到文件共享
$localDir = "/data/shared-docs"
if (Test-Path $localDir) {
Get-ChildItem -Path $localDir -File | ForEach-Object {
Set-AzStorageFileContent -ShareName $shareName `
-Source $_.FullName `
-Path $_.Name `
-Context $ctx `
-Force
Write-Host "已上传到文件共享: $($_.Name)"
}
}

# 生成 SAS 令牌(只读,7 天有效)
$sasToken = New-AzStorageAccountSASToken `
-Service Blob,File `
-ResourceType Service,Container,Object `
-Permission "rl" `
-ExpiryTime (Get-Date).AddDays(7) `
-Context $ctx

Write-Host "SAS 令牌已生成(7 天只读):"
Write-Host $sasToken.Substring(0, 30) "..."

# 配置生命周期管理策略
$lifecycleRules = @(
@{
Name = "move-logs-to-cool"
Enabled = $true
Definition = @{
Filters = @{
PrefixMatch = @("logs/"]
BlobTypes = @("blockBlob")
}
Actions = @{
BaseBlob = @{
TierToCool = @{ DaysAfterModificationGreaterThan = 30 }
TierToArchive = @{ DaysAfterModificationGreaterThan = 90 }
Delete = @{ DaysAfterModificationGreaterThan = 365 }
}
}
}
}
@{
Name = "archive-old-backups"
Enabled = $true
Definition = @{
Filters = @{
PrefixMatch = @("backups/"]
BlobTypes = @("blockBlob")
}
Actions = @{
BaseBlob = @{
TierToArchive = @{ DaysAfterModificationGreaterThan = 60 }
Delete = @{ DaysAfterModificationGreaterThan = 180 }
}
}
}
}
)

# 应用生命周期策略
$policy = Set-AzStorageAccountManagementPolicy `
-ResourceGroupName "blog-storage-rg" `
-StorageAccountName "stbloghotprod" `
-Policy (@{ Rules = $lifecycleRules } | ConvertTo-Json -Depth 10 | ConvertFrom-Json)

Write-Host "已配置 $($lifecycleRules.Count) 条生命周期管理规则"

# 查看各容器中的 Blob 层级分布
$containers = @("images", "documents", "logs", "backups")
foreach ($containerName in $containers) {
$blobs = Get-AzStorageBlob -Container $containerName -Context $ctx `
-ErrorAction SilentlyContinue
$total = ($blobs | Measure-Object).Count
$totalSize = ($blobs | ForEach-Object { $_.Length } | Measure-Object -Sum).Sum
$sizeMB = if ($totalSize) { '{0:N2}' -f ($totalSize / 1MB) } else { '0.00' }
Write-Host "容器 $containerName : $total 个 Blob, 总计 $sizeMB MB"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建文件共享: blog-shared-assets
已上传到文件共享: architecture-diagram.pdf
已上传到文件共享: deployment-guide.docx
已上传到文件共享: runbook.xlsx
SAS 令牌已生成(7 天只读):
?sv=2024-08-03&ss=bf&srt=sco&sp=r ...
已配置 2 条生命周期管理规则
容器 images : 3 个 Blob, 总计 0.25 MB
容器 documents : 12 个 Blob, 总计 8.45 MB
容器 logs : 156 个 Blob, 总计 234.67 MB
容器 backups : 28 个 Blob, 总计 1024.33 MB

存储分析与监控

持续监控存储使用情况和访问模式是控制云成本的关键。通过 Az.StorageAz.Monitor 模块,可以自动化查询存储指标、分析访问日志,并生成成本报告。下面的脚本演示了如何收集这些信息并输出结构化的分析结果。

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
# 获取存储账户信息
$resourceGroup = "blog-storage-rg"
$storageAccountName = "stbloghotprod"
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourceGroup `
-Name $storageAccountName
$ctx = $storageAccount.Context

# 查询存储账户的容量和使用情况
$usage = Get-AzStorageUsage -Location $storageAccount.Location
Write-Host "`n=== 存储账户配额使用情况 ==="
foreach ($u in $usage) {
$pct = if ($u.Limit -gt 0) { '{0:P1}' -f ($u.CurrentValue / $u.Limit) } else { 'N/A' }
Write-Host ("{0,-40} {1,6}/{2,6} ({3})" -f $u.Name.Value, `
$u.CurrentValue, $u.Limit, $pct)
}

# 启用 Blob 服务的访问日志(如尚未启用)
$logging = $ctx.Logging
if (-not $logging.LoggingOperations -contains "Read") {
Set-AzStorageServiceLoggingProperty -ServiceType Blob `
-LoggingOperations Read,Write,Delete `
-RetentionDays 30 `
-Context $ctx
Write-Host "`n已启用 Blob 访问日志(保留 30 天)"
}

# 查询最近的存储指标
$endTime = Get-Date
$startTime = $endTime.AddDays(-7)
$metricNames = @("UsedCapacity", "TransactionCount", "Ingress", "Egress")

Write-Host "`n=== 最近 7 天存储指标 ==="
foreach ($metric in $metricNames) {
$metrics = Get-AzMetric -ResourceId $storageAccount.Id `
-MetricName $metric `
-StartTime $startTime `
-EndTime $endTime `
-AggregationType Total `
-ErrorAction SilentlyContinue

if ($metrics) {
$data = $metrics.Data | Where-Object { $_.Total -gt 0 } | Select-Object -Last 1
if ($data) {
$value = switch ($metric) {
"UsedCapacity" { '{0:N2} GB' -f ($data.Total / 1GB) }
"TransactionCount" { '{0:N0}' -f $data.Total }
"Ingress" { '{0:N2} MB' -f ($data.Total / 1MB) }
"Egress" { '{0:N2} MB' -f ($data.Total / 1MB) }
}
Write-Host ("{0,-25} {1}" -f $metric, $value)
}
}
}

# 分析各容器的 Blob 层级和大小分布
Write-Host "`n=== Blob 层级分布 ==="
$containers = Get-AzStorageContainer -Context $ctx
$summary = foreach ($container in $containers) {
$blobs = Get-AzStorageBlob -Container $container.Name -Context $ctx `
-ErrorAction SilentlyContinue
if (-not $blobs) { continue }

$blobs | Group-Object AccessTier | ForEach-Object {
$sizeBytes = ($_.Group | ForEach-Object { $_.Length } | Measure-Object -Sum).Sum
[PSCustomObject]@{
Container = $container.Name
Tier = $_.Name
Count = $_.Count
SizeMB = [math]::Round($sizeBytes / 1MB, 2)
}
}
}

$summary | Format-Table -AutoSize

# 生成成本估算摘要
Write-Host "`n=== 月度成本估算 ==="
$hotBlobs = $summary | Where-Object { $_.Tier -eq "Hot" }
$coolBlobs = $summary | Where-Object { $_.Tier -eq "Cool" }
$archiveBlobs = $summary | Where-Object { $_.Tier -eq "Archive" }

$hotCost = ($hotBlobs | Measure-Object SizeMB -Sum).Sum * 0.018 / 1000
$coolCost = ($coolBlobs | Measure-Object SizeMB -Sum).Sum * 0.01 / 1000
$archiveCost = ($archiveBlobs | Measure-Object SizeMB -Sum).Sum * 0.0004 / 1000

Write-Host ("Hot 层存储: {0,10:N4} USD/月" -f $hotCost)
Write-Host ("Cool 层存储: {0,10:N4} USD/月" -f $coolCost)
Write-Host ("Archive 层存储:{0,10:N4} USD/月" -f $archiveCost)
Write-Host ("--------------------------")
Write-Host ("合计估算: {0,10:N4} USD/月" -f ($hotCost + $coolCost + $archiveCost))

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
=== 存储账户配额使用情况 ===
StorageAccounts 3/ 250 (1.2%)
已启用 Blob 访问日志(保留 30 天)

=== 最近 7 天存储指标 ===
UsedCapacity 1.24 GB
TransactionCount 12,456
Ingress 456.78 MB
Egress 2345.67 MB

=== Blob 层级分布 ===
Container Tier Count SizeMB
--------- ---- ----- ------
backups Hot 28 1024.33
documents Hot 12 8.45
images Hot 3 0.25
logs Hot 156 234.67

=== 月度成本估算 ===
Hot 层存储: 0.0229 USD/月
Cool 层存储: 0.0000 USD/月
Archive 层存储: 0.0000 USD/月
--------------------------
合计估算: 0.0229 USD/月

注意事项

  1. SkuName 选择Standard_LRS 适合开发测试,生产环境建议使用 Standard_ZRSStandard_GRS 以获得更高的数据持久性。Premium_LRS 仅支持页 Blob 和文件共享,不支持块 Blob 的访问层分层。

  2. 生命周期策略延迟:生命周期管理规则的应用并非实时生效,Azure 通常每天执行一次策略评估。规则变更后可能需要 24-48 小时才能看到效果,不要误以为配置未生效。

  3. SAS 令牌安全:生成的 SAS 令牌等同于访问凭证,务必通过安全渠道分发。建议优先使用存储在 Azure Key Vault 中的 SAS 定义,而非在脚本中硬编码令牌。设置最短的有效期以满足业务需求即可。

  4. 容器公开访问级别Blob 级别允许匿名读取单个 Blob,Container 级别则允许列出容器内容。默认值 Off 最安全,仅在需要公开访问的场景(如静态网站资源)下才开启。

  5. 上传大文件分块Set-AzStorageBlobContent 对大于 256 MB 的文件会自动分块上传,但网络不稳定时建议手动设置 -ConcurrentTaskCount 参数来控制并发数,避免上传超时。

  6. 成本监控盲区Get-AzMetric 返回的指标有数分钟到数小时的延迟,不适合做实时成本告警。如需精确的实时成本控制,应配置 Azure Cost Management 的预算告警规则,并通过 Az.Billing 模块查询。

PowerShell 技能连载 - Docker 容器管理

适用于 PowerShell 7.0 及以上版本

容器化已成为现代应用部署的主流方式。无论是微服务架构、CI/CD 流水线还是本地开发环境,Docker 都扮演着核心角色。在 Windows 和跨平台场景中,PowerShell 凭借强大的对象管道和丰富的模块生态,能够将 Docker 操作无缝融入自动化运维流程。

传统的容器管理往往依赖手动执行 docker 命令,效率低下且容易出错。而通过 PowerShell 脚本化封装,我们可以实现镜像构建的标准化、容器编排的可重复部署、以及资源清理的定时任务调度。本文将从基础操作、Docker Compose 编排和运维工具集三个维度,展示如何用 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
function Get-DockerImageList {
[CmdletBinding()]
param(
[string]$Filter,
[switch]$DanglingOnly
)

$args = @('image', 'ls', '--format', '{{.Repository}}:{{.Tag}}|{{.ID}}|{{.Size}}|{{.CreatedSince}}')
if ($DanglingOnly) {
$args += @('--filter', 'dangling=true')
}

$result = docker $args 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "无法获取镜像列表: $result"
return
}

$images = $result | ForEach-Object {
$parts = $_ -split '\|'
[PSCustomObject]@{
Repository = $parts[0]
ImageId = $parts[1]
Size = $parts[2]
Created = $parts[3]
}
}

if ($Filter) {
$images = $images | Where-Object { $_.Repository -like "*$Filter*" }
}

$images
}

function Start-DockerApp {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Image,

[string]$Name = "app-$(Get-Random -Maximum 9999)",
[int]$Port = 8080,
[string]$Volume,
[hashtable]$EnvVars,
[string]$Network = 'bridge'
)

$dockerArgs = @('run', '-d', '--name', $Name, '--restart', 'unless-stopped')

# 端口映射
$dockerArgs += @('-p', "${Port}:${Port}")

# 挂载卷
if ($Volume) {
if (-not (Test-Path $Volume)) {
New-Item -ItemType Directory -Path $Volume -Force | Out-Null
Write-Host "已创建挂载目录: $Volume" -ForegroundColor Yellow
}
$dockerArgs += @('-v', "${Volume}:/app/data")
}

# 环境变量
if ($EnvVars) {
foreach ($key in $EnvVars.Keys) {
$dockerArgs += @('-e', "${key}=$($EnvVars[$key])")
}
}

# 网络
$dockerArgs += @('--network', $Network)
$dockerArgs += $Image

Write-Host "正在启动容器 [$Name]..." -ForegroundColor Cyan
$containerId = docker $dockerArgs 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "容器已启动: $containerId" -ForegroundColor Green
return $containerId
} else {
Write-Error "启动失败: $containerId"
}
}

function Get-DockerContainerLog {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$Name,

[int]$Tail = 50,
[switch]$Follow,
[datetime]$Since
)

$args = @('logs')
if ($Tail -gt 0) { $args += @('--tail', $Tail) }
if ($Follow) { $args += '--follow' }
if ($Since) { $args += @('--since', $Since.ToString('yyyy-MM-ddTHH:mm:ss')) }
$args += $Name

docker $args 2>&1
}

上面的代码定义了三个核心函数:Get-DockerImageList 以结构化对象形式输出镜像信息,支持按名称过滤和仅显示悬空镜像;Start-DockerApp 封装了容器启动流程,自动处理端口映射、卷挂载、环境变量注入等常见配置;Get-DockerContainerLog 提供灵活的日志查看方式,支持尾部行数、实时跟踪和时间过滤。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS> Get-DockerImageList -Filter 'nginx'

Repository ImageId Size Created
---------- ------- ---- -------
nginx:latest a8758716bb 187MB 3 days ago
nginx:alpine c31a4dc1b5 42MB 2 weeks ago

PS> Start-DockerApp -Image 'nginx:alpine' -Name 'web-test' -Port 8080 -Volume 'C:\data\web' -EnvVars @{ 'NGINX_HOST' = 'localhost' }

正在启动容器 [web-test]...
已创建挂载目录: C:\data\web
容器已启动: d4f5a2c8e1b3...

PS> Get-DockerContainerLog -Name 'web-test' -Tail 10

2026/01/19 08:15:32 [notice] 1#1: start worker process 32
2026/01/19 08:15:32 [notice] 1#1: start worker process 33
192.168.1.100 - - [19/Jan/2026:08:16:01 +0000] "GET / HTTP/1.1" 200 615

Docker Compose 编排与 PowerShell 自动化

在多服务场景下,Docker Compose 是首选的编排工具。我们可以用 PowerShell 动态生成 Compose 文件、管理服务生命周期,并将环境配置与部署流程解耦,实现可重复的一键部署。

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
function New-DockerComposeConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProjectName,

[string]$OutputPath = '.',
[string]$AppImage = 'myapp:latest',
[string]$DbImage = 'postgres:16-alpine',
[string]$RedisImage = 'redis:7-alpine',
[int]$AppPort = 3000
)

$compose = @{
services = @{
app = @{
image = $AppImage
ports = @("${AppPort}:3000")
environment = @{
NODE_ENV = 'production'
DATABASE_URL = "postgresql://appuser:secret@db:5432/${ProjectName}"
REDIS_URL = 'redis://redis:6379'
}
depends_on = @(
@{ service = 'db'; condition = 'service_healthy' }
@{ service = 'redis'; condition = 'service_started' }
)
restart = 'unless-stopped'
deploy = @{
resources = @{
limits = @{
memory = '512M'
cpus = '1.0'
}
}
}
}
db = @{
image = $DbImage
volumes = @("${ProjectName}-db-data:/var/lib/postgresql/data")
environment = @{
POSTGRES_DB = $ProjectName
POSTGRES_USER = 'appuser'
POSTGRES_PASSWORD = 'secret'
}
healthcheck = @{
test = @('CMD-SHELL', 'pg_isready -U appuser -d ' + $ProjectName)
interval = '10s'
timeout = '5s'
retries = 5
}
restart = 'unless-stopped'
}
redis = @{
image = $RedisImage
volumes = @("${ProjectName}-redis-data:/data")
command = 'redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru'
restart = 'unless-stopped'
}
}
volumes = @{
"${ProjectName}-db-data" = @{}
"${ProjectName}-redis-data" = @{}
}
}

$outputFile = Join-Path $OutputPath "docker-compose-${ProjectName}.yml"
$compose | ConvertTo-Yaml | Set-Content -Path $outputFile -Encoding UTF8
Write-Host "已生成 Compose 文件: $outputFile" -ForegroundColor Green

return $outputFile
}

function Invoke-DockerCompose {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComposeFile,

[ValidateSet('Up', 'Down', 'Restart', 'Pull', 'Ps')]
[string]$Action = 'Up',

[switch]$Build,
[switch]$Detach
)

$baseArgs = @('-f', $ComposeFile)

switch ($Action) {
'Up' {
$args = $baseArgs + @('up')
if ($Detach) { $args += '-d' }
if ($Build) { $args += '--build' }
Write-Host "正在启动服务..." -ForegroundColor Cyan
}
'Down' {
$args = $baseArgs + @('down', '--volumes', '--remove-orphans')
Write-Host "正在停止并清理服务..." -ForegroundColor Yellow
}
'Restart' {
$args = $baseArgs + @('restart')
Write-Host "正在重启服务..." -ForegroundColor Cyan
}
'Pull' {
$args = $baseArgs + @('pull')
Write-Host "正在拉取最新镜像..." -ForegroundColor Cyan
}
'Ps' {
$args = $baseArgs + @('ps', '--format', 'table {{.Name}}\t{{.Status}}\t{{.Ports}}')
}
}

docker $args 2>&1 | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
Write-Error "Compose 操作 [$Action] 执行失败"
}
}

这段代码的核心思路是将 Compose 配置参数化。New-DockerComposeConfig 根据项目名称和镜像版本动态生成 YAML 文件,包含健康检查、资源限制和持久化卷定义。Invoke-DockerComposedocker compose 子命令做了一层 PowerShell 封装,支持启动、停止、重启、拉取镜像和查看状态等操作,每个动作都有清晰的状态提示。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PS> New-DockerComposeConfig -ProjectName 'myblog' -AppImage 'myblog:v2.1' -AppPort 8080

已生成 Compose 文件: .\docker-compose-myblog.yml

PS> Invoke-DockerCompose -ComposeFile '.\docker-compose-myblog.yml' -Action Up -Detach -Build

正在启动服务...
[+] Building 15.2s
=> [app internal] load build definition from Dockerfile
=> [app] => exporting to image
[+] Running 4/4
✔ Network myblog_default Created
✔ Container myblog-redis-1 Started
✔ Container myblog-db-1 Healthy
✔ Container myblog-app-1 Started

PS> Invoke-DockerCompose -ComposeFile '.\docker-compose-myblog.yml' -Action Ps

NAME STATUS PORTS
myblog-app-1 Up 2 minutes 0.0.0.0:8080->3000/tcp
myblog-db-1 Up 2 minutes (healthy) 5432/tcp
myblog-redis-1 Up 2 minutes 6379/tcp

容器运维工具集

随着容器数量增长,定期清理无用资源、监控容器健康状态和检查镜像安全变得至关重要。下面的工具集提供了批量清理、资源监控和安全扫描的自动化能力。

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

$report = [System.Text.StringBuilder]::new()
[void]$report.AppendLine("=== Docker 资源清理报告 ===")
[void]$report.AppendLine("执行时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`n")

# 清理已停止的容器
$stopped = docker ps -aq --filter 'status=exited' 2>$null
if ($stopped) {
$count = ($stopped | Measure-Object).Count
[void]$report.AppendLine("[容器] 发现 $count 个已停止的容器")
if (-not $DryRun) {
docker rm $stopped 2>$null | Out-Null
[void]$report.AppendLine(" -> 已清理")
}
}

# 清理悬空镜像
$dangling = docker images -q --filter 'dangling=true' 2>$null
if ($dangling) {
$count = ($dangling | Measure-Object).Count
[void]$report.AppendLine("[镜像] 发现 $count 个悬空镜像")
if (-not $DryRun) {
docker rmi $dangling 2>$null | Out-Null
[void]$report.AppendLine(" -> 已清理")
}
}

# 清理未使用的网络
$networks = docker network ls --filter 'type=custom' -q 2>$null
if ($networks) {
$count = ($networks | Measure-Object).Count
[void]$report.AppendLine("[网络] 发现 $count 个自定义网络")
if (-not $DryRun) {
docker network prune -f 2>$null | Out-Null
[void]$report.AppendLine(" -> 已清理")
}
}

# 清理未使用的卷
if ($IncludeVolumes) {
$volumes = docker volume ls -q --filter 'dangling=true' 2>$null
if ($volumes) {
$count = ($volumes | Measure-Object).Count
[void]$report.AppendLine("[卷] 发现 $count 个悬空卷")
if (-not $DryRun) {
docker volume prune -f 2>$null | Out-Null
[void]$report.AppendLine(" -> 已清理")
}
}
}

# 回收磁盘空间
if (-not $DryRun) {
[void]$report.AppendLine("`n正在回收磁盘空间...")
$reclaim = docker system df --format '{{.Reclaimable}}' 2>$null
docker system prune -f 2>$null | Out-Null
[void]$report.AppendLine("可回收空间: $($reclaim -join ', ')")
} else {
[void]$report.AppendLine("`n[模拟模式] 未执行实际清理操作")
}

$report.ToString()
}

function Get-DockerResourceMonitor {
[CmdletBinding()]
param(
[int]$IntervalSeconds = 5,
[int]$Count = 12
)

for ($i = 0; $i -lt $Count; $i++) {
$timestamp = Get-Date -Format 'HH:mm:ss'

$stats = docker stats --no-stream --format `
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.PIDs}}' 2>$null

if ($i -eq 0) {
Write-Host ("{0,-8} {1,-20} {2,-10} {3,-15} {4,-8} {5,-20} {6,-6}" -f `
'时间', '容器', 'CPU', '内存使用', '内存%', '网络IO', 'PID') `
-ForegroundColor Cyan
Write-Host ('-' * 90)
}

$stats | ForEach-Object {
$parts = $_ -split '\|'
if ($parts.Count -eq 6) {
Write-Host ("{0,-8} {1,-20} {2,-10} {3,-15} {4,-8} {5,-20} {6,-6}" -f `
$timestamp, $parts[0], $parts[1], $parts[2], $parts[3], $parts[4], $parts[5])
}
}

if ($i -lt $Count - 1) {
Start-Sleep -Seconds $IntervalSeconds
}
}
}

function Test-DockerImageSecurity {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Image,

[string]$Severity = 'HIGH,CRITICAL',
[switch]$SkipDbUpdate
)

Write-Host "正在扫描镜像: $Image" -ForegroundColor Cyan
Write-Host "严重级别过滤: $Severity`n"

# 检查 trivy 是否安装
$trivy = Get-Command trivy -ErrorAction SilentlyContinue
if (-not $trivy) {
Write-Warning "未找到 trivy 扫描器,使用 docker scout 替代"
$scanResult = docker scout cves $Image 2>&1
} else {
$trivyArgs = @('image', '--severity', $Severity, '--format', 'table', $Image)
if ($SkipDbUpdate) { $trivyArgs += '--skip-db-update' }
$scanResult = trivy $trivyArgs 2>&1
}

$scanResult | ForEach-Object { Write-Host $_ }

# 提取漏洞摘要
$criticalCount = ($scanResult | Select-String -Pattern 'CRITICAL' -SimpleMatch).Count
$highCount = ($scanResult | Select-String -Pattern 'HIGH' -SimpleMatch).Count

[PSCustomObject]@{
Image = $Image
Critical = $criticalCount
High = $highCount
ScanTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Status = if ($criticalCount -gt 0) { 'NEEDS ATTENTION' } else { 'ACCEPTABLE' }
}
}

这套工具集包含三个核心功能:Remove-DockerOrphanResources 生成详细的清理报告,支持 DryRun 模式预览将要删除的资源,避免误删;Get-DockerResourceMonitor 以固定间隔采集所有运行中容器的 CPU、内存和网络指标,适合排查性能瓶颈;Test-DockerImageSecurity 调用 Trivy 或 Docker Scout 对镜像进行漏洞扫描,按严重级别过滤结果。

执行结果示例:

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
PS> Remove-DockerOrphanResources -DryRun

=== Docker 资源清理报告 ===
执行时间: 2026-01-19 08:30:00

[容器] 发现 3 个已停止的容器
[镜像] 发现 5 个悬空镜像
[网络] 发现 2 个自定义网络

[模拟模式] 未执行实际清理操作

PS> Get-DockerResourceMonitor -Count 3

时间 容器 CPU 内存使用 内存% 网络IO PID
------------------------------------------------------------------------------------------
08:30:05 myblog-app-1 2.35% 128MiB/512MiB 25.00% 1.2kB/856B 18
08:30:05 myblog-db-1 0.82% 89MiB/256MiB 34.77% 456B/312B 12
08:30:05 myblog-redis-1 0.15% 12MiB/128MiB 9.38% 234B/156B 6
08:30:10 myblog-app-1 3.12% 132MiB/512MiB 25.78% 2.1kB/1.1kB 18
08:30:10 myblog-db-1 1.05% 91MiB/256MiB 35.55% 512B/378B 12
08:30:10 myblog-redis-1 0.18% 12MiB/128MiB 9.38% 256B/178B 6

PS> Test-DockerImageSecurity -Image 'myblog:v2.1' -Severity 'HIGH,CRITICAL'

正在扫描镜像: myblog:v2.1
严重级别过滤: HIGH,CRITICAL

Total: 3 (HIGH: 2, CRITICAL: 1)

Image Critical High ScanTime Status
----- -------- ---- -------- ------
myblog:v2.1 1 2 2026-01-19 08:35:12 NEEDS ATTENTION

注意事项

  1. Docker 服务依赖:运行脚本前确保 Docker Desktop 或 Docker Engine 已启动并正常运行,可通过 docker info 命令快速验证,脚本中建议加入服务状态检查逻辑。

  2. ConvertTo-Yaml 模块New-DockerComposeConfig 函数依赖 powershell-yaml 模块来序列化 YAML,使用前需执行 Install-Module -Name powershell-yaml -Scope CurrentUser 安装。

  3. 权限与安全:容器操作通常需要管理员或 docker 用户组权限,在 CI/CD 环境中注意凭据管理,避免在 Compose 文件中硬编码数据库密码,应使用 Docker Secrets 或环境变量文件。

  4. 资源清理的破坏性Remove-DockerOrphanResources-IncludeVolumes 参数会删除未挂载的卷数据,生产环境中务必先使用 -DryRun 预览,确认无误后再执行实际清理。

  5. 监控性能开销Get-DockerResourceMonitor 通过 docker stats 采集指标会引入轻微的 CPU 开销,在容器数量超过 50 个时建议降低采集频率或仅监控关键服务。

  6. 镜像扫描工具Test-DockerImageSecurity 优先使用 Trivy(开源免费),备选 Docker Scout(需 Docker Desktop 许可)。对于企业级场景,可集成 Aqua、Snyk 等商业扫描平台获取更全面的漏洞情报。

PowerShell 技能连载 - Windows Update 自动化

适用于 PowerShell 5.1 及以上版本

补丁管理是企业安全运维的基石。每个月的 Patch Tuesday,微软会发布数十个安全更新,涵盖操作系统、浏览器、.NET 运行时等关键组件。如果依赖手动逐台安装更新,不仅效率低下,还容易遗漏关键补丁,给攻击者留下可乘之机。

PowerShell 为 Windows Update 自动化提供了多种手段。从内置的 Microsoft.Update.Session COM 对象完成底层扫描,到社区广泛使用的 PSWindowsUpdate 模块实现批量部署,再到结合 WSUS API 编写审批流程,可以覆盖从单机到数千台服务器的全场景补丁管理需求。

本文将围绕三个核心场景展开:更新扫描与审批、批量安装与重启编排、合规报告与异常处理,帮助你构建一套完整的 Windows Update 自动化体系。

更新扫描与审批

在部署更新之前,首先要扫描可用的更新列表,根据分类(安全更新、关键更新、驱动程序等)和 KB 编号进行筛选,然后决定哪些更新需要审批安装。以下脚本演示了完整的扫描和审批流程。

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
# 使用 PSWindowsUpdate 模块扫描可用更新
# 首次使用需安装模块(需要管理员权限)
# Install-Module -Name PSWindowsUpdate -Force -Scope CurrentUser
Import-Module PSWindowsUpdate

# 扫描当前机器的可用更新
Write-Host "正在扫描可用更新..." -ForegroundColor Cyan
$availableUpdates = Get-WindowsUpdate -Verbose:$false

if ($availableUpdates.Count -eq 0) {
Write-Host "当前系统已是最新,无需更新。" -ForegroundColor Green
return
}

Write-Host "发现 $($availableUpdates.Count) 个可用更新:`n"

# 分类汇总
$updatesByCategory = $availableUpdates | Group-Object -Property {
if ($_.Title -match 'Security') { '安全更新' }
elseif ($_.Title -match 'Cumulative|累计') { '累积更新' }
elseif ($_.Title -match 'Driver|驱动') { '驱动程序' }
else { '其他更新' }
} | Sort-Object Count -Descending

foreach ($group in $updatesByCategory) {
Write-Host (" {0,-12} {1} 个" -f $group.Name, $group.Count) -ForegroundColor Yellow
}

# 过滤安全更新和累积更新
$criticalUpdates = $availableUpdates | Where-Object {
$_.Title -match 'Security|Security Update|安全' -or
$_.Title -match 'Cumulative|累计'
}

Write-Host "`n关键更新列表:"
$criticalUpdates | ForEach-Object {
$kb = if ($_.KB -match 'KB\d+') { $Matches[0] } else { 'N/A' }
Write-Host (" [{0}] {1}" -f $kb, $_.Title)
}

# 审批逻辑:按 KB 编号白名单安装
$approvedKBs = @(
'KB5034441' # 示例:Windows 安全更新
'KB5034440' # 示例:累积更新
)

$toInstall = $criticalUpdates | Where-Object {
$kb = if ($_.KB -match 'KB\d+') { $Matches[0] } else { '' }
$kb -in $approvedKBs
}

Write-Host "`n已审批 $($toInstall.Count) 个更新,准备安装。"
$toInstall | ForEach-Object {
$kb = if ($_.KB -match 'KB\d+') { $Matches[0] } else { 'N/A' }
Write-Host (" [审批通过] {0} - {1}" -f $kb, $_.Title) -ForegroundColor Green
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
正在扫描可用更新...
发现 12 个可用更新:

累积更新 4
安全更新 5
其他更新 3

关键更新列表:
[KB5034441] 2026-01 Security Update for Windows Server 2022
[KB5034440] 2026-01 Cumulative Update for Windows 11
[KB5034439] 2026-01 Security Update for .NET Framework 4.8.1
[KB5034438] 2026-01 Security Update for Windows 10
[KB5034437] 2026-01 Cumulative Update for Windows Server 2019
[KB5034436] 2026-01 Cumulative Update for .NET 8.0
[KB5034435] 2026-01 Cumulative Update for Windows 11 (23H2)
[KB5034434] 2026-01 Cumulative Update for Windows Server 2022

已审批 2 个更新,准备安装。
[审批通过] KB5034441 - 2026-01 Security Update for Windows Server 2022
[审批通过] KB5034440 - 2026-01 Cumulative Update for Windows 11

扫描结果按类别分组显示,让运维人员一目了然地看到安全更新和累积更新的数量。审批环节通过 KB 白名单机制控制,只有经过安全团队审核的补丁才会被安装,避免未经测试的更新引发兼容性问题。

批量安装与重启编排

单台机器的更新安装只是起点。在企业环境中,通常需要同时对多台服务器执行滚动更新:按批次安装更新、重启机器、验证服务恢复后再处理下一批。以下脚本实现了一个完整的滚动更新流程。

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
# 批量安装与滚动重启编排
Import-Module PSWindowsUpdate

# 目标服务器列表(可从 AD、CMDB 或文本文件读取)
$servers = @(
'WEB-SVR01',
'WEB-SVR02',
'WEB-SVR03',
'DB-SVR01',
'DB-SVR02'
)

# 分批策略:Web 服务器先更新,数据库后更新
$batches = @(
@{ Name = 'Web层'; Servers = $servers | Where-Object { $_ -match '^WEB' } }
@{ Name = '数据库层'; Servers = $servers | Where-Object { $_ -match '^DB' } }
)

foreach ($batch in $batches) {
Write-Host "`n========== 开始处理:$($batch.Name) ==========" -ForegroundColor Cyan
$batchServers = $batch.Servers
$restartQueue = @()

foreach ($server in $batchServers) {
Write-Host "`n[$server] 扫描并安装更新..." -ForegroundColor Yellow

try {
# 远程安装已审批的安全更新(自动接受并安装)
$result = Invoke-Command -ComputerName $server -ScriptBlock {
Import-Module PSWindowsUpdate
Get-WindowsUpdate -AcceptAll -Install -AutoReboot -Verbose:$false |
Select-Object KB, Title, Result
} -ErrorAction Stop

$installed = $result | Where-Object { $_.Result -eq 'Installed' }
Write-Host " 已安装 $($installed.Count) 个更新"

# 将需要重启的服务器加入队列
if ($result | Where-Object { $_.Result -eq 'InstalledRebootRequired' }) {
$restartQueue += $server
Write-Host " 需要重启,已加入重启队列" -ForegroundColor Magenta
}
}
catch {
Write-Host " 安装失败:$($_.Exception.Message)" -ForegroundColor Red
}
}

# 编排重启:逐台重启并等待恢复
foreach ($server in $restartQueue) {
Write-Host "`n[$server] 正在重启..." -ForegroundColor Yellow
Restart-Computer -ComputerName $server -Force -Wait -For PowerShell `
-Timeout 600 -Delay 15

# 健康检查:验证关键服务已恢复
$health = Invoke-Command -ComputerName $server -ScriptBlock {
$services = @('W3SVC', 'WinRM', 'EventLog')
$results = @{}
foreach ($svc in $services) {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
$results[$svc] = if ($s) { $s.Status } else { 'NotFound' }
}
$results
}

$allRunning = $health.Values | Where-Object { $_ -ne 'Running' }
if ($allRunning.Count -eq 0) {
Write-Host " 健康检查通过,所有关键服务已恢复" -ForegroundColor Green
}
else {
Write-Host " 警告:部分服务未恢复 $allRunning" -ForegroundColor Red
}
}

Write-Host "`n$($batch.Name) 批次处理完成" -ForegroundColor Green
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
========== 开始处理:Web层 ==========

[WEB-SVR01] 扫描并安装更新...
已安装 3 个更新
需要重启,已加入重启队列

[WEB-SVR02] 扫描并安装更新...
已安装 2 个更新

[WEB-SVR03] 扫描并安装更新...
已安装 3 个更新
需要重启,已加入重启队列

[WEB-SVR01] 正在重启...
健康检查通过,所有关键服务已恢复

[WEB-SVR03] 正在重启...
健康检查通过,所有关键服务已恢复

Web层 批次处理完成

========== 开始处理:数据库层 ==========

[DB-SVR01] 扫描并安装更新...
已安装 4 个更新
需要重启,已加入重启队列

[DB-SVR02] 扫描并安装更新...
已安装 3 个更新

[DB-SVR01] 正在重启...
健康检查通过,所有关键服务已恢复

数据库层 批次处理完成

滚动更新策略按应用层分批执行,确保在更新过程中始终有足够的实例保持服务可用。每台服务器重启后会自动执行健康检查,验证 WinRM、IIS、事件日志等关键服务已正常恢复,发现问题立即告警。

合规报告与异常处理

补丁管理的最后一步是生成合规报告,追踪哪些机器已完成更新、哪些存在缺失补丁、哪些安装失败需要重试。以下脚本构建了一个简易的合规审计仪表板。

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
# 合规报告与异常处理
Import-Module PSWindowsUpdate

$servers = @(
'WEB-SVR01', 'WEB-SVR02', 'WEB-SVR03',
'DB-SVR01', 'DB-SVR02'
)

$report = @()
$failedServers = @()

foreach ($server in $servers) {
try {
$updateStatus = Invoke-Command -ComputerName $server -ScriptBlock {
Import-Module PSWindowsUpdate

# 获取缺失的更新
$missing = Get-WindowsUpdate -Verbose:$false

# 获取最近 7 天安装的更新
$recentInstalled = Get-WUHistory | Where-Object {
$_.Date -gt (Get-Date).AddDays(-7)
}

# 获取上次重启时间
$lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime

return @{
MissingCount = $missing.Count
MissingCritical = ($missing | Where-Object {
$_.Title -match 'Security|安全'
}).Count
RecentInstalled = $recentInstalled.Count
LastBootTime = $lastBoot
}
} -ErrorAction Stop

# 计算合规状态
$daysSinceBoot = ((Get-Date) - $updateStatus.LastBootTime).Days
$compliance = if ($updateStatus.MissingCritical -eq 0 -and $daysSinceBoot -lt 30) {
'合规'
}
elseif ($updateStatus.MissingCritical -le 2 -and $daysSinceBoot -lt 60) {
'部分合规'
}
else {
'不合规'
}

$report += [PSCustomObject]@{
Server = $server
Compliance = $compliance
MissingCritical = $updateStatus.MissingCritical
MissingTotal = $updateStatus.MissingCount
RecentInstalled = $updateStatus.RecentInstalled
LastBootDays = $daysSinceBoot
}

# 记录失败的服务器,用于后续重试
if ($compliance -eq '不合规') {
$failedServers += $server
}
}
catch {
$report += [PSCustomObject]@{
Server = $server
Compliance = '无法连接'
MissingCritical = '-'
MissingTotal = '-'
RecentInstalled = '-'
LastBootDays = '-'
}
$failedServers += $server
}
}

# 输出合规仪表板
Write-Host "`n========== Windows Update 合规报告 ==========" -ForegroundColor Cyan
Write-Host "报告时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`n"

$report | Format-Table -AutoSize

# 合规统计
$compliant = ($report | Where-Object { $_.Compliance -eq '合规' }).Count
$partial = ($report | Where-Object { $_.Compliance -eq '部分合规' }).Count
$nonCompliant = ($report | Where-Object {
$_.Compliance -in '不合规', '无法连接'
}).Count

Write-Host "合规统计:"
Write-Host (" 合规:{0} 台 ({1:P0})" -f $compliant,
($compliant / $servers.Count)) -ForegroundColor Green
Write-Host (" 部分合规:{0} 台 ({1:P0})" -f $partial,
($partial / $servers.Count)) -ForegroundColor Yellow
Write-Host (" 不合规/离线:{0} 台 ({1:P0})" -f $nonCompliant,
($nonCompliant / $servers.Count)) -ForegroundColor Red

# 失败重试:对不合规服务器重新安装
if ($failedServers.Count -gt 0) {
Write-Host "`n不合规服务器,准备重试安装:" -ForegroundColor Magenta
$failedServers | ForEach-Object { Write-Host " - $_" }
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
========== Windows Update 合规报告 ==========
报告时间:2026-01-16 14:30:00

Server Compliance MissingCritical MissingTotal RecentInstalled LastBootDays
------ ---------- --------------- ------------ --------------- ------------
WEB-SVR01 合规 0 0 3 2
WEB-SVR02 部分合规 1 2 2 15
WEB-SVR03 合规 0 0 4 2
DB-SVR01 不合规 4 6 0 45
DB-SVR02 无法连接 - - - -

合规统计:
合规:2 台 (40.00%)
部分合规:1 台 (20.00%)
不合规/离线:2 台 (40.00%)

不合规服务器,准备重试安装:
- DB-SVR01
- DB-SVR02

合规报告从三个维度评估补丁状态:缺失关键更新的数量、最近一周已安装更新的数量、以及距上次重启的天数。将三者结合可以全面反映机器的补丁健康度。不合规的服务器会被自动收集,用于触发重试流程。

注意事项

  1. 权限要求:Windows Update 操作需要本地管理员权限。在远程执行时,确保 WinRM 已启用且执行账户具有目标服务器的管理员权限,建议使用 Group Managed Service Account(gMSA)统一管理服务账户。

  2. PSWindowsUpdate 模块来源:该模块托管在 PowerShell Gallery 上,安装前请确认执行策略允许运行(Set-ExecutionPolicy RemoteSigned)。在生产环境中,建议将模块下载到内部 NuGet 仓库,避免直接从公网拉取。

  3. 测试先行:每个 Patch Tuesday 发布的更新应先在测试环境中验证,确认与业务应用无兼容性问题后再推广到生产。建议维护一个延迟 1-2 周的部署窗口。

  4. 重启窗口:某些更新(特别是内核和驱动相关补丁)必须重启才能生效。重启操作应安排在维护窗口内,并提前通知相关团队。对于高可用集群,确保滚动重启不会同时影响所有节点。

  5. WSUS 集成:大规模环境建议通过 WSUS(Windows Server Update Services)或 Configuration Manager 统一管理更新审批和分发。PowerShell 可以通过 Microsoft.UpdateServices 命名空间的 API 编写 WSUS 审批规则。

  6. 日志与回滚:保留每次更新的安装日志(路径 C:\Windows\Logs\CBS\CBS.log),出现问题时可以通过 wusa /uninstall 回滚特定补丁。建议将合规报告定期归档到中央日志系统,方便审计追溯。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本

现代 DevOps 实践中,基础设施即代码(IaC)和自动化测试已经成为标准流程,而 CI/CD 流水线正是将这些实践落地的核心工具。无论是代码提交触发的自动测试,还是合并后自动部署到生产环境,流水线都在其中扮演着承上启下的角色。PowerShell 凭借其强大的系统管理能力和丰富的模块生态,成为了各大 CI/CD 平台中编写构建、测试和部署逻辑的理想选择。

主流 CI/CD 平台(如 GitHub Actions、Azure DevOps、Jenkins)都原生支持运行 PowerShell 脚本。这意味着团队可以用同一门语言编写本地运维脚本和流水线逻辑,减少技术栈切换带来的认知负担。同时,Pester 测试框架可以与流水线深度集成,实现代码质量门控——只有当所有测试用例通过时才允许部署继续推进。

本文将从 GitHub Actions 集成、Azure DevOps 流水线配置和通用流水线工具三个角度,展示如何用 PowerShell 构建健壮的 CI/CD 流水线。

GitHub Actions 中的 PowerShell

GitHub Actions 是目前最流行的 CI/CD 平台之一,它在 Windows、Linux 和 macOS runner 上都原生支持 PowerShell(pwsh)。以下代码展示了如何编写一个完整的 GitHub Actions workflow,包含 Pester 测试、代码质量检查和构建产物发布。

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
# 文件路径: .github/workflows/ci.yml
# 以下为 workflow 配置的 PowerShell 等价描述
# 实际 workflow 文件为 YAML 格式

# === 本地模拟 CI 流水线的 PowerShell 脚本 ===

# 1. 安装测试依赖
Write-Host '=== 步骤 1: 安装依赖 ===' -ForegroundColor Cyan
Install-Module -Name Pester -MinimumVersion 5.5 -Force -Scope CurrentUser
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser

# 2. 运行 PSScriptAnalyzer 代码质量检查
Write-Host '`n=== 步骤 2: 代码质量分析 ===' -ForegroundColor Cyan
$analysisResults = Invoke-ScriptAnalyzer -Path '.\Scripts' -Severity Warning,Error -Recurse
if ($analysisResults) {
$analysisResults | Format-Table -Property RuleName, Severity, ScriptName, Line, Message -Wrap
$errorCount = ($analysisResults | Where-Object Severity -eq 'Error').Count
if ($errorCount -gt 0) {
Write-Host "发现 $errorCount 个错误,流水线终止" -ForegroundColor Red
exit 1
}
} else {
Write-Host '代码质量检查通过,无警告或错误' -ForegroundColor Green
}

# 3. 运行 Pester 测试
Write-Host '`n=== 步骤 3: 运行 Pester 测试 ===' -ForegroundColor Cyan
$pesterConfig = @{
Run = @{
Path = '.\Tests'
PassThru = $true
}
TestResult = @{
Enabled = $true
OutputPath = 'TestResults.xml'
OutputFormat = 'JUnitXml'
}
CodeCoverage = @{
Enabled = $true
Path = '.\Scripts\*.ps1'
OutputPath = 'Coverage.xml'
}
}
$result = Invoke-Pester -Configuration $pesterConfig

# 4. 输出测试摘要
Write-Host "`n测试结果摘要:" -ForegroundColor Yellow
Write-Host " 总计: $($result.TotalCount) 个测试"
Write-Host " 通过: $($result.PassedCount)" -ForegroundColor Green
Write-Host " 失败: $($result.FailedCount)" -ForegroundColor Red
Write-Host " 跳过: $($result.SkippedCount)" -ForegroundColor Gray
Write-Host " 耗时: $($result.Duration.TotalSeconds) 秒"

if ($result.FailedCount -gt 0) {
Write-Host '`n测试未全部通过,流水线终止' -ForegroundColor Red
exit 1
}

# 5. 打包构建产物
Write-Host '`n=== 步骤 4: 打包构建产物 ===' -ForegroundColor Cyan
$artifactName = "ops-scripts-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$artifactPath = "dist\$artifactName"
New-Item -Path $artifactPath -ItemType Directory -Force | Out-Null
Copy-Item -Path '.\Scripts\*' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Modules' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Config\env.template.ps1' -Destination $artifactPath
Compress-Archive -Path $artifactPath -DestinationPath "dist\$artifactName.zip"
Write-Host "构建产物已打包: dist\$artifactName.zip" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
=== 步骤 1: 安装依赖 ===
正在安装 Pester 5.5.x...
正在安装 PSScriptAnalyzer...

=== 步骤 2: 代码质量分析 ===
代码质量检查通过,无警告或错误

=== 步骤 3: 运行 Pester 测试 ===
[+] D:\OpsScripts\Tests\Get-DiskHealth.Tests.ps1 120ms (3 tests)
[+] D:\OpsScripts\Tests\Deploy-App.Tests.ps1 85ms (5 tests)
[+] D:\OpsScripts\Tests\Export-Report.Tests.ps1 42ms (2 tests)

测试结果摘要:
总计: 10 个测试
通过: 10
失败: 0
跳过: 0
耗时: 0.35 秒

=== 步骤 4: 打包构建产物 ===
构建产物已打包: dist\ops-scripts-20260115-083000.zip

Azure DevOps 流水线

Azure DevOps 提供了经典的构建/发布管道和 YAML 管道两种模式,两者都深度集成 PowerShell 执行环境。以下代码展示了如何在 Azure DevOps 中实现多阶段流水线,包括构建验证、 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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# Azure DevOps Pipeline Agent 中运行的部署脚本
# 文件路径: scripts/Deploy-Application.ps1

param(
[Parameter(Mandatory)]
[ValidateSet('Build', 'Staging', 'Production')]
[string]$Environment,

[string]$ArtifactPath,

[string]$TargetServer
)

# 1. 根据环境加载对应配置
$envConfig = @{
Build = @{
Servers = @('localhost')
Validate = $false
Backup = $false
Notify = $false
}
Staging = @{
Servers = @('staging-web-01', 'staging-web-02')
Validate = $true
Backup = $true
Notify = $false
}
Production = @{
Servers = @('prod-web-01', 'prod-web-02', 'prod-web-03')
Validate = $true
Backup = $true
Notify = $true
}
}
$config = $envConfig[$Environment]

Write-Host "部署环境: $Environment"
Write-Host "目标服务器: $($config.Servers -join ', ')"

# 2. 部署前健康检查
if ($config.Validate) {
Write-Host '`n执行部署前健康检查...' -ForegroundColor Cyan
foreach ($server in $config.Servers) {
$session = New-PSSession -ComputerName $server -ErrorAction Stop
$result = Invoke-Command -Session $session -ScriptBlock {
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
[PSCustomObject]@{
Server = $env:COMPUTERNAME
FreeDiskGB = [math]::Round($disk.FreeSpace / 1GB, 1)
CpuUsage = if ($cpu) { $cpu } else { 0 }
ServicesUp = (Get-Service -Name 'W3SVC','WinRM' |
Where-Object Status -eq 'Running').Count
}
}
$result | Format-Table -AutoSize
if ($result.FreeDiskGB -lt 5) {
throw "服务器 $($result.Server) 磁盘空间不足 ($($result.FreeDiskGB) GB)"
}
Remove-PSSession $session
}
Write-Host '健康检查通过' -ForegroundColor Green
}

# 3. 备份当前版本
if ($config.Backup) {
Write-Host "`n备份当前版本..." -ForegroundColor Cyan
$backupTag = "pre-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($tag)
$appPath = 'D:\WebApp'
$backupPath = "D:\Backups\$tag"
Copy-Item -Path $appPath -Destination $backupPath -Recurse -Force
Write-Host " $env:COMPUTERNAME: 已备份至 $backupPath"
} -ArgumentList $backupTag
}
Write-Host '备份完成' -ForegroundColor Green
}

# 4. 执行部署
Write-Host "`n开始部署..." -ForegroundColor Cyan
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($src)
$appPath = 'D:\WebApp'
# 停止应用
Stop-WebSite -Name 'Default Web Site' -ErrorAction SilentlyContinue
# 复制文件
Copy-Item -Path "$src\*" -Destination $appPath -Recurse -Force
# 启动应用
Start-WebSite -Name 'Default Web Site'
Write-Host " $env:COMPUTERNAME: 部署完成,站点已启动"
} -ArgumentList $ArtifactPath
}

# 5. 部署后验证与通知
if ($config.Notify) {
Write-Host "`n发送部署通知..." -ForegroundColor Cyan
$notifyBody = @{
text = "[部署完成] $Environment 环境已于 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') 完成部署"
} | ConvertTo-Json -Compress
Invoke-RestMethod -Uri $env:WEBHOOK_URL -Method Post `
-Body $notifyBody -ContentType 'application/json'
Write-Host '通知已发送' -ForegroundColor Green
}

Write-Host "`n部署流程全部完成" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
部署环境: Staging
目标服务器: staging-web-01, staging-web-02

执行部署前健康检查...
Server FreeDiskGB CpuUsage ServicesUp
------ ---------- -------- -----------
STAGING-WEB-01 45.2 12 2
STAGING-WEB-02 38.7 8 2
健康检查通过

备份当前版本...
STAGING-WEB-01: 已备份至 D:\Backups\pre-deploy-20260115-090000
STAGING-WEB-02: 已备份至 D:\Backups\pre-deploy-20260115-090000
备份完成

开始部署...
STAGING-WEB-01: 部署完成,站点已启动
STAGING-WEB-02: 部署完成,站点已启动

部署流程全部完成

通用流水线工具

无论使用哪个 CI/CD 平台,一些通用的流水线工具都是不可或缺的。以下代码封装了代码质量检查、语义版本计算和变更日志自动生成三个常用功能,可以作为独立脚本被任何流水线调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# 文件路径: scripts/Pipeline-Utils.ps1
# 通用流水线工具函数集

# --- 函数 1: 代码质量门控检查 ---
function Invoke-CodeQualityGate {
[CmdletBinding()]
param(
[string]$Path = '.\Scripts',
[int]$MaxWarnings = 10,
[int]$MaxErrors = 0
)
$results = Invoke-ScriptAnalyzer -Path $Path -Recurse -Severity Information,Warning,Error
$summary = $results | Group-Object Severity | ForEach-Object {
@{ Severity = $_.Name; Count = $_.Count }
}
$errorCount = ($results | Where-Object Severity -eq 'Error').Count
$warningCount = ($results | Where-Object Severity -eq 'Warning').Count

Write-Host "代码质量报告:" -ForegroundColor Yellow
Write-Host " 错误: $errorCount (阈值: $MaxErrors)"
Write-Host " 警告: $warningCount (阈值: $MaxWarnings)"

$passed = ($errorCount -le $MaxErrors) -and ($warningCount -le $MaxWarnings)
if ($passed) {
Write-Host ' 结果: 通过' -ForegroundColor Green
} else {
Write-Host ' 结果: 未通过' -ForegroundColor Red
}
return $passed
}

# --- 函数 2: 语义版本计算 ---
function Get-NextSemanticVersion {
[CmdletBinding()]
param(
[string]$TagPrefix = 'v',
[string]$DefaultVersion = '0.1.0'
)
$latestTag = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "未找到已有标签,使用默认版本: $DefaultVersion"
return [version]$DefaultVersion
}
$versionStr = $latestTag -replace "^$([regex]::Escape($TagPrefix))"
$current = [version]$versionStr
$messages = git log "$latestTag..HEAD" --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新的提交,版本不变'
return $current
}
$bump = 'Patch'
if ($messages -match '^feat(\(|:)') { $bump = 'Minor' }
if ($messages -match 'BREAKING CHANGE') { $bump = 'Major' }
$next = switch ($bump) {
'Major' { [version]::new($current.Major + 1, 0, 0) }
'Minor' { [version]::new($current.Major, $current.Minor + 1, 0) }
'Patch' { [version]::new($current.Major, $current.Minor, $current.Build + 1) }
}
Write-Host "版本: $current -> $next (递增: $bump)"
return $next
}

# --- 函数 3: 变更日志自动生成 ---
function New-ChangeLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[version]$Version,
[string]$TagPrefix = 'v'
)
$latestTag = git describe --tags --abbrev=0 2>$null
$range = if ($LASTEXITCODE -eq 0) { "$latestTag..HEAD" } else { 'HEAD' }
$messages = git log $range --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新变更,跳过 CHANGELOG 生成'
return
}

$lines = @(
"## [$Version] - $(Get-Date -Format 'yyyy-MM-dd')"
)

$features = $messages | Where-Object { $_ -match '^feat' }
$fixes = $messages | Where-Object { $_ -match '^fix' }
$others = $messages | Where-Object {
$_ -notmatch '^feat' -and $_ -notmatch '^fix' -and $_ -notmatch '^chore'
}

if ($features) {
$lines += '', '### 新功能'
$features | ForEach-Object { $lines += "- $_" }
}
if ($fixes) {
$lines += '', '### 修复'
$fixes | ForEach-Object { $lines += "- $_" }
}
if ($others) {
$lines += '', '### 其他变更'
$others | ForEach-Object { $lines += "- $_" }
}

$changeLogPath = 'CHANGELOG.md'
$existing = if (Test-Path $changeLogPath) { Get-Content $changeLogPath -Raw } else { '' }
$newContent = ($lines -join "`n") + "`n`n" + $existing
Set-Content -Path $changeLogPath -Value $newContent.TrimEnd() -Encoding UTF8
Write-Host "CHANGELOG 已更新: $changeLogPath" -ForegroundColor Green
}

# --- 主流程: 调用示例 ---
Write-Host '========== 流水线工具执行 ==========' -ForegroundColor Cyan

# 运行代码质量检查
$qualityOk = Invoke-CodeQualityGate -Path '.\Scripts' -MaxWarnings 10 -MaxErrors 0

# 计算下一个版本号
$nextVersion = Get-NextSemanticVersion -TagPrefix 'v'
Write-Host "下一个发布版本: $nextVersion"

# 生成变更日志
New-ChangeLog -Version $nextVersion -TagPrefix 'v'

if ($qualityOk) {
Write-Host "`n所有门控检查通过,可以继续发布流程" -ForegroundColor Green
} else {
Write-Host "`n门控检查未通过,请修复后重试" -ForegroundColor Red
exit 1
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
========== 流水线工具执行 ==========

代码质量报告:
错误: 0 (阈值: 0)
警告: 3 (阈值: 10)
结果: 通过

版本: 1.0.0 -> 1.1.0 (递增: Minor)
下一个发布版本: 1.1.0

CHANGELOG 已更新: CHANGELOG.md

所有门控检查通过,可以继续发布流程

注意事项

  1. 流水线中的执行策略:CI/CD runner 上的 PowerShell 执行策略可能限制脚本运行。在 workflow 中显式设置 pwsh -ExecutionPolicy Bypass -File script.ps1,或在脚本开头使用 Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned 来确保脚本能正常执行。

  2. 敏感信息管理:绝不要将 API 密钥、连接字符串等敏感信息硬编码在脚本中。GitHub Actions 使用 Secrets,Azure DevOps 使用 Variable Groups 配合 Key Vault,Jenkins 使用 Credentials Binding。在流水线中通过环境变量($env:API_KEY)引用这些安全值。

  3. Pester 测试的隔离性:每个测试用例应该独立运行,不依赖其他测试的执行顺序或状态。使用 BeforeAllAfterAll 进行测试环境的初始化和清理,避免测试之间产生副作用,尤其是在流水线中并发执行测试的场景。

  4. 幂等部署设计:部署脚本必须是幂等的——多次执行的结果与一次执行的结果相同。在部署前先检查目标状态,如果已经是期望状态则跳过操作。这样可以安全地重试失败的部署步骤,而不会产生重复创建或数据损坏等问题。

  5. 跨平台兼容性:如果流水线可能在 Linux 或 macOS runner 上运行,避免使用 Windows 专用的 cmdlet(如 Stop-WebSiteNew-PSSession)。改用 REST API 或 SSH 方式进行远程管理,并使用 Join-Path 代替硬编码路径分隔符,确保脚本在不同操作系统上行为一致。

  6. 流水线日志与遥测:在关键步骤添加结构化的日志输出(如 ::group::::set-output name=key::value),便于在流水线界面中快速定位问题。对于长时间运行的部署,建议集成 Slack 或 Teams Webhook 通知,让团队实时感知部署进度和结果。

PowerShell 技能连载 - REST API 开发

适用于 PowerShell 7.0 及以上版本

当我们谈论 PowerShell 与 REST API 时,通常是在讨论如何用 Invoke-RestMethod 调用外部服务。但 PowerShell 的能力不止于此——借助 .NET 的 HttpListener 类或社区开发的 Web 框架,你完全可以用 PowerShell 构建自己的 REST API 服务。

这种能力在内部工具场景中尤为实用。比如 CI/CD 流水线需要一个状态查询端点、监控系统需要一个数据聚合接口、第三方 Webhook 需要一个接收器——这些都不需要引入一整套 ASP.NET 或 Node.js 项目,几十行 PowerShell 脚本就能搞定。本文将从底层到高层,逐步演示三种方案。

使用 HttpListener 创建基础 REST API

System.Net.HttpListener 是 .NET 内置的 HTTP 服务器类,无需安装任何额外模块即可使用。下面这段代码创建了一个支持 GET 和 POST 路由的简易 API,返回 JSON 格式的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using namespace System.Net
using namespace System.Text

# 创建 API 数据存储
$script:dataStore = @{
tasks = [System.Collections.ArrayList]::new()
1..5 | ForEach-Object {
@{ id = $_; title = "任务 $_"; status = if ($_ -le 3) { 'done' } else { 'pending' } }
}
}

# 路由表
$routes = @{
'GET /api/tasks' = {
param($ctx)
$json = $script:dataStore.tasks | ConvertTo-Json -Depth 3
Send-JsonResponse $ctx $json 200
}
'GET /api/tasks/id' = {
param($ctx)
$id = [int]($ctx.Request.Url.Segments | Select-Object -Last 1)
$task = $script:dataStore.tasks | Where-Object { $_.id -eq $id }
if ($task) {
Send-JsonResponse $ctx ($task | ConvertTo-Json) 200
} else {
Send-JsonResponse $ctx '{"error":"任务不存在"}' 404
}
}
'POST /api/tasks' = {
param($ctx)
$reader = [StreamReader]::new($ctx.Request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$newTask = $body | ConvertFrom-Json
$script:dataStore.tasks.Add(@{
id = ($script:dataStore.tasks.Count + 1)
title = $newTask.title
status = 'pending'
}) | Out-Null
Send-JsonResponse $ctx '{"message":"任务已创建"}' 201
}
}

# 响应辅助函数
function Send-JsonResponse {
param([HttpListenerContext]$Context, [string]$Body, [int]$StatusCode)
$buffer = [Encoding]::UTF8.GetBytes($Body)
$Context.Response.StatusCode = $StatusCode
$Context.Response.ContentType = 'application/json; charset=utf-8'
$Context.Response.ContentLength64 = $buffer.Length
$Context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
$Context.Response.Close()
}

# 启动监听器
$listener = [HttpListener]::new()
$listener.Prefixes.Add('http://localhost:8080/')
$listener.Start()
Write-Host "API 服务已启动: http://localhost:8080/" -ForegroundColor Green

try {
while ($listener.IsListening) {
$ctx = $listener.GetContext()
$method = $ctx.Request.HttpMethod
$path = $ctx.Request.Url.AbsolutePath.TrimEnd('/')
Write-Host " [$method] $path" -ForegroundColor Cyan

# 路由匹配
$routeKey = "$method $path"
$handler = $routes[$routeKey]
if (-not $handler -and $path -match '/api/tasks/\d+$') {
$handler = $routes['GET /api/tasks/id']
}

if ($handler) {
& $handler $ctx
} else {
Send-JsonResponse $ctx '{"error":"路由不存在"}' 404
}
}
} finally {
$listener.Stop()
Write-Host "API 服务已停止" -ForegroundColor Yellow
}

在另一个终端中测试 API:

1
2
3
4
5
6
7
8
# 查询所有任务
Invoke-RestMethod http://localhost:8080/api/tasks

# 查询单个任务
Invoke-RestMethod http://localhost:8080/api/tasks/2

# 创建新任务
Invoke-RestMethod http://localhost:8080/api/tasks -Method Post -Body '{"title":"新任务"}' -ContentType 'application/json'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
id title  status
-- ----- ------
1 任务 1 done
2 任务 2 done
3 任务 3 done
4 任务 4 pending
5 任务 5 pending

id title status
-- ----- ------
2 任务 2 done

message
-------
任务已创建

使用 Pode 框架构建完整 API

HttpListener 虽然灵活,但需要手动处理路由、中间件等逻辑。Pode 是一个专为 PowerShell 设计的跨平台 Web 框架,提供了路由、中间件、认证、静态文件、日志等开箱即用的功能。

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
# 安装 Pode 模块(首次使用)
Install-Module Pode -Force -Scope CurrentUser

# 创建 api-server.ps1
Start-PodeServer {
# 监听端口
Add-PodeEndpoint -Address localhost -Port 3000 -Protocol Http

# 配置日志
New-PodeLoggingMethod -File -Name 'api-errors' | Enable-PodeErrorLogging
New-PodeLoggingMethod -File -Name 'api-requests' | New-PodeRequestLogging

# JWT 认证中间件
New-PodeAuthScheme -Bearer | Add-PodeAuth -Name 'jwt-auth' -Sessionless {
param($token)
try {
$payload = $token | Invoke-RestMethod -Uri 'https://your-idp/.well-known/jwks.json'
# 简化演示:直接验证 token 非空
if ([string]::IsNullOrEmpty($token)) {
return @{ Message = 'Token 无效' }
}
return @{ User = @{ Name = 'api-user'; Role = 'admin' } }
} catch {
return @{ Message = "认证失败: $_" }
}
}

# 全局中间件:请求计时
Add-PodeMiddleware -Name 'timer' -ScriptBlock {
$WebEvent.Metadata['StartTime'] = [datetime]::UtcNow
}

# 全局中间件:CORS 支持
Add-PodeMiddleware -Name 'cors' -ScriptBlock {
$WebEvent.Response.Headers.Add('Access-Control-Allow-Origin', '*')
$WebEvent.Response.Headers.Add('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
if ($WebEvent.Method -eq 'OPTIONS') {
Set-PodeResponseStatus -Code 204
}
}

# 健康检查端点(无需认证)
Add-PodeRoute -Method Get -Path '/health' -ScriptBlock {
Write-PodeJsonResponse -Value @{
status = 'healthy'
uptime = (Get-Date) - $PodeContext.Server.StartTime
version = '1.0.0'
hostname = $env:COMPUTERNAME
}
}

# GET /api/items - 获取列表
Add-PodeRoute -Method Get -Path '/api/items' -Authentication 'jwt-auth' -ScriptBlock {
$page = [int]($WebEvent.Query['page'] ?? '1')
$size = [int]($WebEvent.Query['size'] ?? '20')
Write-PodeJsonResponse -Value @{
data = @(
@{ id = 1; name = '项目 A'; status = 'active' }
@{ id = 2; name = '项目 B'; status = 'archived' }
@{ id = 3; name = '项目 C'; status = 'active' }
)
page = $page
size = $size
total = 3
}
}

# POST /api/items - 创建项目
Add-PodeRoute -Method Post -Path '/api/items' -Authentication 'jwt-auth' -ScriptBlock {
$body = $WebEvent.Data
if (-not $body.name) {
Set-PodeResponseStatus -Code 400
Write-PodeJsonResponse -Value @{ error = 'name 字段必填' }
return
}
Write-PodeJsonResponse -Value @{
id = Get-Random -Maximum 1000
name = $body.name
status = 'created'
message = '项目创建成功'
} -StatusCode 201
}

# 静态文件托管
Add-PodeStaticRoute -Path '/docs' -Source './public'

Write-Host "Pode API 服务运行在 http://localhost:3000" -ForegroundColor Green
}

启动服务后测试各端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 健康检查
Invoke-RestMethod http://localhost:3000/health | Format-List

# 获取项目列表(带认证头)
$headers = @{ Authorization = 'Bearer your-jwt-token-here' }
Invoke-RestMethod http://localhost:3000/api/items -Headers $headers

# 分页查询
Invoke-RestMethod 'http://localhost:3000/api/items?page=1&size=10' -Headers $headers

# 创建新项目
$body = @{ name = '项目 D' } | ConvertTo-Json
Invoke-RestMethod http://localhost:3000/api/items -Method Post -Headers $headers -Body $body -ContentType 'application/json'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
status  : healthy
uptime : 00:15:32.0180000
version : 1.0.0
hostname: SERVER01

data : {@{id=1; name=项目 A; status=active}, @{id=2; name=项目 B;
status=archived}, @{id=3; name=项目 C; status=active}}
page : 1
size : 20
total : 3

id : 847
name : 项目 D
status : created
message : 项目创建成功

API 部署与运维

将 API 从开发脚本变成生产服务,需要考虑日志记录、健康检查、自动重启等运维需求。以下脚本展示了如何将 Pode API 注册为 Windows 服务,并添加运维监控端点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 将 API 注册为 Windows 服务
function Register-ApiService {
param(
[string]$Name = 'PowershellApi',
[string]$ScriptPath = 'C:\api\server.ps1'
)

# 使用 NSSM(Non-Sucking Service Manager)注册服务
$nssmPath = 'C:\tools\nssm.exe'
if (-not (Test-Path $nssmPath)) {
Write-Warning '请先安装 NSSM: choco install nssm'
return
}

# 注册服务
& $nssmPath install $Name pwsh.exe "-NoProfile -ExecutionPolicy Bypass -File `"$ScriptPath`""
& $nssmPath set $Name DisplayName "PowerShell REST API Service"
& $nssmPath set $Name Description "内部工具 REST API 服务"
& $nssmPath set $Name Start SERVICE_AUTO_START
& $nssmPath set $Name AppDirectory (Split-Path $ScriptPath)
& $nssmPath set $Name AppStdout (Join-Path (Split-Path $ScriptPath) 'stdout.log')
& $nssmPath set $Name AppStderr (Join-Path (Split-Path $ScriptPath) 'stderr.log')
& $nssmPath set $Name AppRotateFiles 1
& $nssmPath set $Name AppRotateBytes 10485760

Write-Host "服务 [$Name] 已注册,使用以下命令管理:" -ForegroundColor Green
Write-Host " 启动: nssm start $Name"
Write-Host " 停止: nssm stop $Name"
Write-Host " 状态: nssm status $Name"
Write-Host " 删除: nssm remove $Name confirm"
}

# 运维监控脚本:定时检查 API 可用性
function Test-ApiHealth {
param(
[string]$BaseUrl = 'http://localhost:3000',
[int]$IntervalSeconds = 30
)

$logFile = "C:\api\health-$(Get-Date -Format 'yyyyMMdd').log"

while ($true) {
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
try {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$response = Invoke-RestMethod "$BaseUrl/health" -TimeoutSec 5
$sw.Stop()
$latency = $sw.ElapsedMilliseconds

$logEntry = "[$timestamp] OK | latency: ${latency}ms | uptime: $($response.uptime)"
Write-Host $logEntry -ForegroundColor Green
Add-Content -Path $logFile -Value $logEntry

} catch {
$logEntry = "[$timestamp] FAIL | error: $($_.Exception.Message)"
Write-Host $logEntry -ForegroundColor Red
Add-Content -Path $logFile -Value $logEntry

# 可选:自动重启服务
# Restart-Service -Name 'PowershellApi' -Force
}

Start-Sleep -Seconds $IntervalSeconds
}
}

# 一键部署函数
function Deploy-ApiService {
param([string]$ApiDir = 'C:\api')

# 创建目录
$dirs = @($ApiDir, "$ApiDir\logs", "$ApiDir\public")
$dirs | ForEach-Object {
if (-not (Test-Path $_)) {
New-Item -ItemType Directory -Path $_ -Force | Out-Null
}
}

# 部署最新代码
Copy-Item '.\api-server.ps1' "$ApiDir\server.ps1" -Force
Copy-Item '.\public\*' "$ApiDir\public\" -Recurse -Force

# 注册并启动服务
Register-ApiService -ScriptPath "$ApiDir\server.ps1"

Write-Host "部署完成!API 地址: http://localhost:3000" -ForegroundColor Green
Write-Host "健康检查: http://localhost:3000/health" -ForegroundColor Cyan
Write-Host "API 文档: http://localhost:3000/docs" -ForegroundColor Cyan
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
服务 [PowershellApi] 已注册,使用以下命令管理:
启动: nssm start PowershellApi
停止: nssm stop PowershellApi
状态: nssm status PowershellApi
删除: nssm remove PowershellApi confirm

[2026-01-14 10:00:00] OK | latency: 12ms | uptime: 00:45:12.0000000
[2026-01-14 10:00:30] OK | latency: 8ms | uptime: 00:45:42.0000000
[2026-01-14 10:01:00] OK | latency: 15ms | uptime: 00:46:12.0000000
[2026-01-14 10:01:30] FAIL | error: Unable to connect to the remote server
[2026-01-14 10:02:00] OK | latency: 10ms | uptime: 00:00:30.0000000

部署完成!API 地址: http://localhost:3000
健康检查: http://localhost:3000/health
API 文档: http://localhost:3000/docs

注意事项

  1. HttpListener 权限:在 Windows 上监听非 localhost 地址时,需要以管理员身份运行 netsh http add urlacl url=http://+:8080/ user=Everyone 来预留 URL 命名空间,否则会抛出”拒绝访问”异常。

  2. 并发处理HttpListenerGetContext() 是同步阻塞调用,只能一次处理一个请求。生产环境应使用 BeginGetContext / EndGetContext 异步模式,或者直接采用 Pode 框架——它内部已处理好并发问题。

  3. HTTPS 支持:生产环境务必启用 TLS。Pode 支持 Add-PodeEndpoint -ProtocolHttps -Certificate 参数直接绑定证书;HttpListener 则需要通过 netsh 绑定 SSL 证书到端口。

  4. 内存泄漏防范:长时间运行的 API 服务需要注意 IDisposable 对象(如 HttpListenerStreamReader)的释放。使用 try/finally 确保资源被正确清理,或使用 using 语句(PowerShell 7.0+ 支持)。

  5. 错误处理与日志:永远不要让未捕获的异常终止整个服务进程。在路由处理器中使用 try/catch,并将错误写入结构化日志文件,方便后续通过 ELK 或 Splunk 等工具分析。

  6. 跨平台部署:如果需要在 Linux 上运行,HttpListener 方案可以工作(依赖 .NET 的 Unix Socket 实现),但更推荐使用 Pode——它原生支持 Linux,可以搭配 systemd 或 Docker 容器化部署,实现与 Windows 服务对等的运维体验。

PowerShell 技能连载 - Azure Container Apps 管理

适用于 PowerShell 7.0 及以上版本

Azure Container Apps 是微软推出的全托管无服务器容器平台,它在底层基于 Kubernetes 却将集群管理完全抽象化,让开发者无需编写 YAML 清单就能享受容器编排的好处。内置的流量分流、自动扩缩容、服务发现和 Dapr 集成,使其特别适合微服务和事件驱动型工作负载。

对于运维工程师而言,手工在 Azure 门户中点击操作不仅效率低下,也无法纳入版本控制和审计流程。通过 PowerShell 的 Az 模块,我们可以将容器应用的创建、环境配置、版本管理和扩缩容策略全部脚本化,实现真正的 GitOps 工作流。今天就来详细介绍如何用 PowerShell 完成 Azure Container Apps 的全生命周期管理。

环境创建与应用部署

一切从 Container Apps 环境开始。每个容器应用都必须部署在一个环境中,环境定义了应用之间的网络边界和共享基础设施。下面的脚本演示了如何创建环境、部署一个 Web API 容器,并配置基本的入站流量规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 定义变量
$resourceGroup = "rg-microservice-demo"
$location = "eastasia"
$envName = "cae-microservice"
$appName = "ca-weather-api"
$acrLoginServer = "myacr.azurecr.io"
$imageTag = "$acrLoginServer/weather-api:v1.0.0"

# 登录并选择订阅
Connect-AzAccount
Set-AzContext -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

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

# 创建 Container Apps 环境(包含 Log Analytics 工作区)
$workspace = New-AzOperationalInsightsWorkspace `
-ResourceGroupName $resourceGroup `
-Name "log-microservice" `
-Location $location `
-Sku Standard

$envArgs = @{
Name = $envName
ResourceGroupName = $resourceGroup
Location = $location
AppLogConfiguration = @{
LogAnalyticsConfiguration = @{
CustomerId = $workspace.CustomerId
SharedKey = (Get-AzOperationalInsightsWorkspaceSharedKey `
-ResourceGroupName $resourceGroup `
-Name $workspace.Name).PrimarySharedKey
}
}
}
$containerEnv = New-AzContainerAppManagedEnv @envArgs

# 获取 ACR 凭据并创建托管标识拉取镜像
$acr = Get-AzContainerRegistry -ResourceGroupName $resourceGroup -Name "myacr"
$acrCred = Get-AzContainerRegistryCredential -Registry $acr

# 部署容器应用
$appArgs = @{
Name = $appName
ResourceGroupName = $resourceGroup
Location = $location
ManagedEnvironment = $containerEnv
Configuration = @{
ActiveRevisionsMode = "Single"
Ingress = @{
External = $true
TargetPort = 8080
Traffic = @(
@{
RevisionName = "$appName--v1"
Weight = 100
}
)
}
}
Template = @{
Containers = @(
@{
Name = "weather-api"
Image = $imageTag
Env = @(
@{
Name = "ASPNETCORE_ENVIRONMENT"
Value = "Production"
}
@{
Name = "LOG_LEVEL"
Value = "Information"
}
)
Resources = @{
Cpu = "0.5"
Memory = "1.0Gi"
}
}
)
}
}
New-AzContainerApp @appArgs

Write-Host "容器应用部署完成: $appName"

执行结果示例:

1
2
3
4
5
6
7
8
9
ResourceGroupName : rg-microservice-demo
Location : eastasia
Name : ca-weather-api
ProvisioningState : Succeeded
Fqdn : ca-weather-api.politebay-xxxxxxxx.eastasia.azurecontainerapps.io
LatestRevision : ca-weather-api--v1
TrafficWeight : 100%

容器应用部署完成: ca-weather-api

上面的脚本完成了从零到一的部署。首先创建了 Log Analytics 工作区用于日志收集,然后基于工作区创建 Container Apps 环境。容器配置中指定了镜像地址、环境变量和资源限额,入站配置将 8080 端口暴露为外部 HTTP 端点。ActiveRevisionsMode 设为 Single 表示同时只运行一个活跃版本。

版本管理与流量分流

微服务的迭代发布要求我们能够在不同版本之间平滑切换。Container Apps 的修订版本(Revision)机制天然支持蓝绿部署和金丝雀发布。下面的脚本展示了如何推送新版本、配置流量分流比例,以及在出现问题时快速回滚。

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
# 推送 v2.0.0 版本并配置金丝雀发布(10% 流量到新版本)
$v2Image = "$acrLoginServer/weather-api:v2.0.0"

$updateArgs = @{
Name = $appName
ResourceGroupName = $resourceGroup
Configuration = @{
ActiveRevisionsMode = "Multiple"
Ingress = @{
External = $true
TargetPort = 8080
Traffic = @(
@{
RevisionName = "$appName--v1"
Weight = 90
}
@{
RevisionName = "$appName--v2"
Weight = 10
}
)
}
}
Template = @{
Containers = @(
@{
Name = "weather-api"
Image = $v2Image
Env = @(
@{
Name = "ASPNETCORE_ENVIRONMENT"
Value = "Production"
}
@{
Name = "LOG_LEVEL"
Value = "Debug"
}
)
Resources = @{
Cpu = "0.5"
Memory = "1.0Gi"
}
}
)
}
}
New-AzContainerApp @updateArgs

Write-Host "金丝雀发布已配置: v1(90%) -> v2(10%)"

# 观察新版本运行状态
Start-Sleep -Seconds 30

# 查看所有修订版本及状态
$revisions = Get-AzContainerAppRevision `
-Name $appName `
-ResourceGroupName $resourceGroup

$revisions | Format-Table Name, Active, TrafficWeight, Replicas, CreatedTime -AutoSize

# 确认新版本稳定后,切换全量流量到 v2
$fullCutArgs = @{
Name = $appName
ResourceGroupName = $resourceGroup
Configuration = @{
ActiveRevisionsMode = "Multiple"
Ingress = @{
External = $true
TargetPort = 8080
Traffic = @(
@{
RevisionName = "$appName--v2"
Weight = 100
}
)
}
}
}
Update-AzContainerApp @fullCutArgs

Write-Host "全量切换完成: v2(100%)"

# 如果发现问题,一键回滚到 v1
# Update-AzContainerApp @fullCutArgs -Configuration @{
# Ingress = @{
# Traffic = @(@{ RevisionName = "$appName--v1"; Weight = 100 })
# }
# }

执行结果示例:

1
2
3
4
5
6
7
8
金丝雀发布已配置: v1(90%) -> v2(10%)

Name Active TrafficWeight Replicas CreatedTime
---- ------ ------------- -------- -----------
ca-weather-api--v1 True 90 2 2026-01-13 08:30:00
ca-weather-api--v2 True 10 1 2026-01-13 09:15:00

全量切换完成: v2(100%)

流量分流的原理在于将 ActiveRevisionsMode 切换为 Multiple,此时多个修订版本可以同时运行。通过 Traffic 数组中每个条目的 Weight 字段控制流量比例,总和必须为 100。金丝雀发布时先用小比例流量验证新版本,观察日志和监控指标后再逐步放量。回滚只需将流量权重重新指向旧版本即可,秒级生效。

自动扩缩容与监控

无服务器容器的核心优势之一是根据负载自动调整实例数量。Container Apps 使用 KEDA(Kubernetes Event-Driven Autoscaler)作为扩缩容引擎,支持基于 HTTP 并发、CPU/内存利用率、消息队列长度等多种触发器。下面的脚本展示了如何配置扩缩容规则并设置监控告警。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# 配置基于 HTTP 并发的自动扩缩容
$scaleArgs = @{
Name = $appName
ResourceGroupName = $resourceGroup
Template = @{
Containers = @(
@{
Name = "weather-api"
Image = "$acrLoginServer/weather-api:v2.0.0"
Resources = @{
Cpu = "1.0"
Memory = "2.0Gi"
}
}
)
Scale = @{
MinReplicas = 1
MaxReplicas = 20
Rules = @(
@{
Name = "http-concurrency"
Custom = @{
Type = "http"
Metadata = @{
concurrentRequests = "100"
}
}
}
@{
Name = "cpu-utilization"
Custom = @{
Type = "cpu"
Metadata = @{
type = "Utilization"
value = "70"
}
}
}
)
}
}
}
Update-AzContainerApp @scaleArgs

Write-Host "自动扩缩容已配置: 1-20 副本,HTTP 并发阈值 100"

# 查询容器应用日志
$logQuery = @"
ContainerAppConsoleLogs_CL
| where ContainerAppName_s == '$appName'
| where TimeGenerated > ago(1h)
| project TimeGenerated, Log_s, RevisionName_s
| order by TimeGenerated desc
| limit 50
"@

$queryResult = Invoke-AzOperationalInsightsQuery `
-WorkspaceId $workspace.CustomerId `
-Query $logQuery

$queryResult.Results | Format-Table TimeGenerated, RevisionName_s, Log_s -AutoSize

# 配置监控告警:当副本数超过 15 时触发通知
$actionGroup = New-AzActionGroup `
-ResourceGroupName $resourceGroup `
-Name "ag-container-alerts" `
-ShortName "caAlert"

$alertRule = New-AzMetricAlertRuleV2 `
-Name "alert-high-scale" `
-ResourceGroupName $resourceGroup `
-WindowSize (New-TimeSpan -Minutes 5) `
-Frequency (New-TimeSpan -Minutes 1) `
-TargetResourceId (Get-AzContainerApp `
-Name $appName `
-ResourceGroupName $resourceGroup).Id `
-Condition @{ `
MetricName = "ReplicaCount"; `
Operator = "GreaterThan"; `
Threshold = 15 `
} `
-ActionGroupId $actionGroup.Id `
-Severity 2

Write-Host "告警规则已创建: 副本数 > 15 时通知运维团队"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
自动扩缩容已配置: 1-20 副本,HTTP 并发阈值 100

TimeGenerated RevisionName_s Log_s
------------- -------------- -----
2026-01-13T09:20:15Z ca-weather-api--v2 [INFO] Request GET /api/weather/beijing - 200 OK (45ms)
2026-01-13T09:20:14Z ca-weather-api--v2 [INFO] Request GET /api/weather/shanghai - 200 OK (32ms)
2026-01-13T09:20:12Z ca-weather-api--v2 [INFO] Request GET /api/weather/guangzhou - 200 OK (38ms)
2026-01-13T09:20:10Z ca-weather-api--v1 [INFO] Health check passed

告警规则已创建: 副本数 > 15 时通知运维团队

扩缩容规则中,http-concurrency 规则表示当单个副本的并发请求数超过 100 时触发扩容,cpu-utilization 规则表示当 CPU 使用率超过 70% 时也会触发。两个规则是 OR 关系,任一条件满足即扩容。MinReplicas = 1 保证至少有一个实例在运行,即使流量为零也不会缩容到零(除非设为 0)。通过 Log Analytics 的 KQL 查询可以实时查看应用日志,配合告警规则在异常时及时通知运维团队。

注意事项

  1. 先创建环境再部署应用:Container Apps 环境是一次性的基础设施决策,创建后无法更改所在区域和 VNet 配置。建议在项目初期就规划好网络拓扑,将环境和共享资源放在同一个资源组中统一管理。

  2. 镜像拉取凭据要安全传递:不要在脚本中硬编码 ACR 的用户名和密码。推荐使用托管标识(Managed Identity)让 Container Apps 自动拉取镜像,或通过 Get-AzContainerRegistryCredential 在运行时动态获取并注入。

  3. 流量分流的权重总和必须为 100:配置多版本流量时,所有条目的 Weight 值加起来必须等于 100。如果总和不对,部署会失败。建议在脚本中增加前置校验逻辑。

  4. 扩缩容冷却时间会影响响应速度:KEDA 默认的冷却期为 300 秒(5 分钟),即缩容后至少等待 5 分钟才会再次缩容。如果业务流量有突发尖峰,可以适当调大 MinReplicas 以保证足够的缓冲容量。

  5. 日志查询会产生额外费用:Log Analytics 按数据摄入量和查询量收费。在生产环境中建议设置每日上限,并在查询时添加时间范围过滤(如 ago(1h)),避免全表扫描导致费用飙升。

  6. Az 模块版本要保持最新:Container Apps 的 PowerShell 支持仍在快速迭代,新功能(如 Dapr 组件、服务间 mTLS)可能需要最新版 Az 模块。部署脚本开头建议加一行 Update-Module Az -Force 或至少检查模块版本是否满足要求。

PowerShell 技能连载 - 脚本版本控制与 Git

适用于 PowerShell 7.0 及以上版本

运维脚本是基础设施管理的核心资产,其重要性不亚于应用程序代码。然而现实中,很多团队的脚本文件散落在共享目录或个人电脑中,没有版本历史、没有变更追踪、没有协作机制。当脚本出现问题时,无法快速回滚到上一个稳定版本;当多人同时修改时,很容易互相覆盖、引入冲突。

Git 作为业界标准的分布式版本控制系统,为脚本管理提供了完整的解决方案。结合 PowerShell 生态中的 posh-git 模块,可以在终端中获得丰富的状态提示、Tab 补全和分支可视化,大幅提升日常操作的效率。更重要的是,通过合理的仓库结构和分支策略,可以将脚本的版本管理与 CI/CD 发布流程无缝衔接。

本文将从 Git 基础集成、脚本仓库管理策略和自动化发布流水线三个层面,展示如何用 PowerShell 构建一套完整的脚本版本控制方案。

Git 基础与 posh-git 集成

posh-git 是专为 PowerShell 设计的 Git 扩展模块,它提供了分支状态提示、命令 Tab 补全和丰富的颜色输出。以下代码演示了 posh-git 的安装配置以及常用的 Git 仓库初始化与分支操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 1. 安装 posh-git 模块
Install-Module -Name posh-git -Scope CurrentUser -Force

# 2. 导入模块并添加到 Profile 实现自动加载
Import-Module posh-git
Add-Content -Path $PROFILE -Value 'Import-Module posh-git'

# 3. 初始化脚本仓库
$repoPath = 'D:\OpsScripts'
if (-not (Test-Path $repoPath)) {
New-Item -Path $repoPath -ItemType Directory | Out-Null
}
Set-Location $repoPath
git init
git config user.name 'OpsTeam'
git config user.email 'ops@example.com'

# 4. 创建初始文件并首次提交
@"
# 运维脚本仓库

本仓库包含生产环境运维脚本。
"@ | Set-Content -Path 'README.md'

git add README.md
git commit -m 'chore: 初始化脚本仓库'

# 5. 分支操作 — 创建功能分支开发新脚本
git checkout -b feature/add-disk-monitor
@"
#Requires -Version 7.0
function Get-DiskHealth {
[CmdletBinding()]
param(
[string[]]$ComputerName = $env:COMPUTERNAME
)
foreach ($computer in $ComputerName) {
$disk = Get-CimInstance -ClassName Win32_LogicalDisk `
-Filter 'DriveType=3' -ComputerName $computer
foreach ($d in $disk) {
$freePercent = [math]::Round($d.FreeSpace / $d.Size * 100, 1)
[PSCustomObject]@{
Computer = $computer
Drive = $d.DeviceID
FreeGB = [math]::Round($d.FreeSpace / 1GB, 2)
TotalGB = [math]::Round($d.Size / 1GB, 2)
FreePercent = $freePercent
Status = if ($freePercent -lt 10) { 'Critical' }
elseif ($freePercent -lt 20) { 'Warning' }
else { 'OK' }
}
}
}
}
"@ | Set-Content -Path 'Scripts\Get-DiskHealth.ps1'

git add Scripts\Get-DiskHealth.ps1
git commit -m 'feat: 新增磁盘健康检查函数'

# 6. 合并到主分支
git checkout main
git merge feature/add-disk-monitor --no-ff -m 'merge: 磁盘监控功能分支'

# 7. 查看提交历史(posh-git 增强)
git log --oneline --graph --decorate -10

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Installing posh-git...
[main (root-commit) a1b2c3f] chore: 初始化脚本仓库
1 file changed, 3 insertions(+)
create mode 100644 README.md
Switched to a new branch 'feature/add-disk-monitor'
[feature/add-disk-monitor d4e5f6a] feat: 新增磁盘健康检查函数
1 file changed, 28 insertions(+)
create mode 100644 Scripts/Get-DiskHealth.ps1
Switched to branch 'main'
Merge made by the 'ort' strategy.
Scripts/Get-DiskHealth.ps1 | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
create mode 100644 Scripts/Get-DiskHealth.ps1

* b7c8d9e (HEAD -> main) merge: 磁盘监控功能分支
|\
| * d4e5f6a (feature/add-disk-monitor) feat: 新增磁盘健康检查函数
|/
* a1b2c3f chore: 初始化脚本仓库

脚本仓库管理策略

一个结构良好的脚本仓库是版本控制的基础。合理的目录划分、完善的 .gitignore 配置以及统一的模块版本标注,能让团队协作更加高效。以下代码展示了一套适用于运维团队的仓库管理最佳实践。

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
# 1. 创建标准化的仓库目录结构
$folders = @(
'Scripts\Monitoring'
'Scripts\Deployment'
'Scripts\Maintenance'
'Modules\OpsToolkit'
'Modules\OpsToolkit\Public'
'Modules\OpsToolkit\Private'
'Modules\OpsToolkit\en-US'
'Config'
'Tests'
'Docs'
)
foreach ($folder in $folders) {
$fullPath = Join-Path -Path $repoPath -ChildPath $folder
if (-not (Test-Path $fullPath)) {
New-Item -Path $fullPath -ItemType Directory | Out-Null
}
}

# 2. 创建 .gitignore 排除敏感信息与临时文件
@"
# 输出文件
*.log
*.csv
*.tmp

# 敏感配置(使用模板文件代替)
Config\local.env
Config\credentials.json

# IDE 和编辑器
.vscode/
.idea/
*.swp

# PowerShell 模块缓存
Modules/*/bin/
Modules/*/obj/

# 测试覆盖率报告
coverage/
"@ | Set-Content -Path '.gitignore'

# 3. 创建配置模板(不含真实凭据)
@"
# 环境配置模板
# 复制为 local.env 后填入真实值
$env:API_ENDPOINT = 'https://api.example.com'
$env:LOG_PATH = 'C:\Logs\OpsToolkit'
"@ | Set-Content -Path 'Config\env.template.ps1'

# 4. 为模块添加语义版本标注
$moduleManifest = @{
Path = 'Modules\OpsToolkit\OpsToolkit.psd1'
RootModule = 'OpsToolkit.psm1'
ModuleVersion = '1.0.0'
Author = 'OpsTeam'
CompanyName = 'IT Operations'
Description = '运维工具集模块'
FunctionsToExport = @('Get-DiskHealth', 'Start-ServiceHealthCheck')
VariablesToExport = @()
CmdletsToExport = @()
AliasesToExport = @()
FileList = @(
'OpsToolkit.psm1',
'Public\Get-DiskHealth.ps1',
'Public\Start-ServiceHealthCheck.ps1'
)
PrivateData = @{
PSData = @{
Tags = @('Operations', 'Monitoring', 'DevOps')
ProjectUri = 'https://git.example.com/ops/OpsScripts'
ReleaseNotes = '初始版本:包含磁盘监控和服务健康检查功能'
}
}
}
New-ModuleManifest @moduleManifest

# 5. 统一提交
git add .
git commit -m 'chore: 建立仓库目录结构与模块骨架'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
  创建目录: D:\OpsScripts\Scripts\Monitoring
创建目录: D:\OpsScripts\Scripts\Deployment
创建目录: D:\OpsScripts\Scripts\Maintenance
创建目录: D:\OpsScripts\Modules\OpsToolkit\Public
创建目录: D:\OpsScripts\Tests
创建目录: D:\OpsScripts\Docs

[main e1f2a3b] chore: 建立仓库目录结构与模块骨架
9 files changed, 58 insertions(+)
create mode 100644 .gitignore
create mode 100644 Config/env.template.ps1
create mode 100644 Modules/OpsToolkit/OpsToolkit.psd1
create mode 100644 Modules/OpsToolkit/OpsToolkit.psm1

自动化发布流水线

当脚本库逐渐成熟,就需要一套规范的发布流程。语义版本号(SemVer)搭配 Git tag 可以精确定位每一次发布。结合 PowerShell 的字符串处理能力,可以自动从提交记录中提取变更并生成 CHANGELOG,实现从开发到发布的全链路追踪。

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
# 1. 定义语义版本号读取与递增函数
function Step-SemanticVersion {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[version]$CurrentVersion,
[ValidateSet('Major', 'Minor', 'Patch')]
[string]$BumpType = 'Patch'
)
switch ($BumpType) {
'Major' { [version]::new($CurrentVersion.Major + 1, 0, 0) }
'Minor' { [version]::new($CurrentVersion.Major, $CurrentVersion.Minor + 1, 0) }
'Patch' { [version]::new($CurrentVersion.Major, $CurrentVersion.Minor, $CurrentVersion.Build + 1) }
}
}

# 2. 从模块清单读取当前版本
$manifestPath = Join-Path $repoPath 'Modules\OpsToolkit\OpsToolkit.psd1'
$manifestData = Test-ModuleManifest -Path $manifestPath -ErrorAction SilentlyContinue
$currentVersion = $manifestData.Version
Write-Host "当前版本: $currentVersion"

# 3. 根据最近提交判断版本号递增类型
$lastTag = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0) {
$lastTag = 'v0.0.0'
$commitsSinceTag = git rev-list HEAD --count
} else {
$commitsSinceTag = git rev-list "$lastTag..HEAD" --count
}

$recentMessages = git log "$lastTag..HEAD" --pretty=format:'%s' 2>$null
$bumpType = 'Patch'
if ($recentMessages -match '^feat') { $bumpType = 'Minor' }
if ($recentMessages -match '^breaking' -or $recentMessages -match 'BREAKING') {
$bumpType = 'Major'
}
$newVersion = Step-SemanticVersion -CurrentVersion $currentVersion -BumpType $bumpType
Write-Host "新版本: $newVersion (递增类型: $bumpType)"

# 4. 更新模块清单中的版本号
$manifestContent = Get-Content $manifestPath -Raw
$manifestContent = $manifestContent -replace
"ModuleVersion\s*=\s*'[^']*'",
"ModuleVersion = '$newVersion'"
Set-Content -Path $manifestPath -Value $manifestContent -NoNewline

# 5. 自动生成 CHANGELOG
$changeLog = @("# 更新日志`n")
$changeLog += "## [$newVersion] - $(Get-Date -Format 'yyyy-MM-dd')`n"

$featCommits = $recentMessages | Where-Object { $_ -match '^feat' }
$fixCommits = $recentMessages | Where-Object { $_ -match '^fix' }
$otherCommits = $recentMessages | Where-Object {
$_ -notmatch '^feat' -and $_ -notmatch '^fix' -and $_ -notmatch '^chore'
}

if ($featCommits) {
$changeLog += "`n### 新功能`n"
foreach ($c in $featCommits) {
$changeLog += "- $c`n"
}
}
if ($fixCommits) {
$changeLog += "`n### 修复`n"
foreach ($c in $fixCommits) {
$changeLog += "- $c`n"
}
}
if ($otherCommits) {
$changeLog += "`n### 其他变更`n"
foreach ($c in $otherCommits) {
$changeLog += "- $c`n"
}
}

$changeLogPath = Join-Path $repoPath 'CHANGELOG.md'
$changeLog | Set-Content -Path $changeLogPath -Encoding UTF8

# 6. 创建 Git tag 并提交
git add .
git commit -m "release: v$newVersion"
git tag -a "v$newVersion" -m "发布版本 $newVersion"
Write-Host "已创建标签 v$newVersion"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
当前版本: 1.0.0
新版本: 1.1.0 (递增类型: Minor)

# 更新日志

## [1.1.0] - 2026-01-12

### 新功能
- feat: 新增磁盘健康检查函数
- feat: 添加服务健康自动巡检

### 修复
- fix: 修复日志路径包含空格时的解析错误

### 其他变更
- docs: 更新部署文档

[main f8g9h0i] release: v1.1.0
3 files changed, 42 insertions(+), 2 deletions(-)
已创建标签 v1.1.0

注意事项

  1. 凭据隔离:永远不要将密码、Token 或 API Key 硬编码在脚本中。使用环境变量或配置模板(如 env.template.ps1),将真实凭据文件加入 .gitignore。如果意外提交了敏感信息,需要使用 git filter-branch 或 BFG Repo-Cleaner 清除历史记录,而不仅是删除文件。

  2. 提交消息规范:采用 Conventional Commits 格式(feat:fix:chore:docs: 等),便于自动生成 CHANGELOG 和判断语义版本递增类型。团队应统一约定并在 Code Review 中严格执行。

  3. 分支策略选择:小型团队可以采用 GitHub Flow(main + feature 分支),大型团队建议使用 GitFlow(main + develop + feature + release + hotfix 分支)。无论哪种策略,禁止直接向 main 分支提交代码,所有变更必须通过 Pull Request 合并。

  4. posh-git 性能:在大型仓库中,posh-git 的状态提示可能会略微降低终端响应速度。可以通过 $GitPromptSettings.EnableFileStatus = $false 关闭文件状态检测,或设置 $GitPromptSettings.EnablePromptStatus = $false 完全禁用提示。

  5. 模块版本同步:当使用 New-ModuleManifestUpdate-ModuleManifest 更新版本号时,确保 Git tag 与 ModuleVersion 字段保持一致。可以在发布脚本中添加断言检查:如果 git describe --tags 的版本号与清单中的不一致,终止发布流程。

  6. 跨平台路径处理:PowerShell 7 支持跨平台运行,但 Git 在 Windows 和 Linux 上的路径分隔符不同。脚本中应始终使用 Join-PathSplit-Path 来构建路径,避免硬编码反斜杠或斜杠。

PowerShell 技能连载 - Windows Admin Center 自动化

适用于 PowerShell 5.1 及以上版本

Windows Admin Center(WAC)是微软推出的基于 Web 的服务器管理平台,通过浏览器即可完成对 Windows Server 的日常运维操作。与传统的远程桌面管理不同,WAC 采用网关架构,运维人员只需要在一个入口就能管理数据中心内的多台服务器和集群,涵盖事件日志、性能监视、证书管理、文件服务、Hyper-V 虚拟机等核心功能模块。

在企业实际部署中,WAC 网关的安装、证书配置、角色权限设置以及受管节点的批量导入,往往需要在多台服务器上重复执行。纯手工配置不仅耗时,还容易遗漏关键步骤。通过 PowerShell 脚本化这些部署和管理流程,可以确保环境的一致性,也方便在灾难恢复或扩容场景中快速重建管理平台。

本文将从三个维度介绍 WAC 的自动化实践:首先是网关的安装与初始配置,其次是受管连接的批量管理与分组,最后是 WAC 扩展开发的入门知识,帮助你掌握将 WAC 深度集成到运维体系中的方法。

WAC 安装与网关配置

在生产环境中部署 WAC 时,需要完成软件安装、SSL 证书绑定、端口配置以及访问权限设置等一系列步骤。以下脚本将这些操作封装为可重复执行的自动化流程。

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
# 定义 WAC 安装参数
$wacInstallerPath = "C:\Install\WindowsAdminCenterPreview.msi"
$gatewayPort = 443
$certThumbprint = "A1B2C3D4E5F6789012345678901234567890ABCD"
$smiEnabled = $true

# 检查 WAC 是否已安装
$wacProduct = Get-WmiObject -Class Win32_Product |
Where-Object { $_.Name -like "*Windows Admin Center*" }

if ($null -eq $wacProduct) {
Write-Output "正在安装 Windows Admin Center..."

# 构建 MSI 安装参数
$msiArgs = @(
"/i", $wacInstallerPath
"/qn"
"SME_PORT=$gatewayPort"
"SSL_CERTIFICATE_OPTION=installed"
"THUMBPRINT=$certThumbprint"
"SME_INCLUDE_MANAGEMENT_TOOLS=$smiEnabled"
)

# 执行静默安装
$process = Start-Process -FilePath "msiexec.exe" `
-ArgumentList $msiArgs `
-Wait -PassThru

if ($process.ExitCode -eq 0) {
Write-Output "WAC 安装成功"
} else {
Write-Error "WAC 安装失败,退出代码:$($process.ExitCode)"
}
} else {
Write-Output "WAC 已安装,版本:$($wacProduct.Version)"
}

# 验证网关服务状态
$service = Get-Service -Name "ServerManagementGateway" -ErrorAction SilentlyContinue

if ($service.Status -eq "Running") {
Write-Output "WAC 网关服务运行正常(端口:$gatewayPort)"
} else {
Write-Output "正在启动 WAC 网关服务..."
Start-Service -Name "ServerManagementGateway"
}

# 配置角色权限(将安全组映射到 WAC 管理角色)
$gatewayServer = $env:COMPUTERNAME
$adminGroup = "CONTOSO\WAC-Admins"
$userGroup = "CONTOSO\WAC-Users"

# 使用 WAC 的配置命令行工具设置访问权限
$configTool = "${env:ProgramFiles}\Windows Admin Center\ConfigureWAC.exe"

if (Test-Path $configTool) {
# 设置管理员组(完整权限)
$null = & $configTool setRole `
-Role Administrators `
-Group $adminGroup 2>&1

# 设置只读用户组
$null = & $configTool setRole `
-Role Users `
-Group $userGroup 2>&1

Write-Output "角色权限配置完成"
Write-Output " 管理员组:$adminGroup"
Write-Output " 只读用户组:$userGroup"
}

# 输出网关访问地址
$fqdn = [System.Net.Dns]::GetHostEntry($gatewayServer).HostName
Write-Output "网关访问地址:https://$fqdn"

执行结果示例:

1
2
3
4
5
6
7
正在安装 Windows Admin Center...
WAC 安装成功
WAC 网关服务运行正常(端口:443)
角色权限配置完成
管理员组:CONTOSO\WAC-Admins
只读用户组:CONTOSO\WAC-Users
网关访问地址:https://SRV-MGMT01.contoso.com

这段脚本涵盖了 WAC 部署的完整流程:首先检查是否已安装以避免重复操作,然后通过 msiexec 静默安装并传入端口、证书指纹等参数。安装完成后验证网关服务状态,并通过配置工具将 Active Directory 安全组映射到 WAC 的内置角色。将此脚本纳入服务器初始化流水线后,新建的管理服务器可以在几分钟内完成 WAC 的部署和配置。

管理 WAC 连接与批量导入

WAC 网关部署完成后,下一步是将需要管理的服务器注册到网关中。在大型环境中可能有数百台服务器需要纳入管理,手动逐台添加显然不现实。以下脚本演示如何从不同数据源批量导入服务器连接,并按业务分组进行组织。

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
# WAC 网关地址
$gateway = "https://wac.contoso.com"

# 方法一:从 Active Directory 获取服务器列表
Import-Module ActiveDirectory

$serverOUs = @{
"域控制器" = "OU=Domain Controllers,DC=contoso,DC=com"
"文件服务器" = "OU=File Servers,OU=Servers,DC=contoso,DC=com"
"Web 服务器" = "OU=Web Servers,OU=Servers,DC=contoso,DC=com"
"数据库服务器" = "OU=DB Servers,OU=Servers,DC=contoso,DC=com"
}

$allConnections = @()

foreach ($ouName in $serverOUs.Keys) {
$ouPath = $serverOUs[$ouName]

# 查询 OU 下启用的计算机账户
$computers = Get-ADComputer -Filter { Enabled -eq $true } `
-SearchBase $ouPath `
-Properties Name, DNSHostName, OperatingSystem

foreach ($computer in $computers) {
$allConnections += [PSCustomObject]@{
Name = $computer.Name
FQDN = $computer.DNSHostName
Type = "Server"
Group = $ouName
OS = $computer.OperatingSystem
}
}
}

Write-Output "从 AD 发现 $($allConnections.Count) 台服务器"

# 方法二:从 CSV 文件补充导入(适用于工作组服务器)
$csvPath = "C:\Config\WorkgroupServers.csv"

if (Test-Path $csvPath) {
$csvServers = Import-Csv -Path $csvPath -Encoding UTF8

foreach ($srv in $csvServers) {
$allConnections += [PSCustomObject]@{
Name = $srv.Name
FQDN = $srv.IPAddress
Type = "Server"
Group = $srv.Group
OS = "Workgroup Server"
}
}

Write-Output "从 CSV 补充导入 $($csvServers.Count) 台工作组服务器"
}

# 批量添加连接到 WAC 网关
$addedCount = 0
$failedCount = 0

foreach ($conn in $allConnections) {
try {
# 构造连接添加请求
$body = @{
name = $conn.Name
type = $conn.Type
group = $conn.Group
target = $conn.FQDN
} | ConvertTo-Json

$uri = "$gateway/api/connections"
$null = Invoke-RestMethod -Uri $uri -Method Post `
-Body $body -ContentType "application/json" `
-UseDefaultCredentials -SkipCertificateCheck

$addedCount++
} catch {
Write-Warning "添加失败:$($conn.Name) - $($_.Exception.Message)"
$failedCount++
}
}

Write-Output "`n导入结果汇总:"
Write-Output " 成功:$addedCount 台"
Write-Output " 失败:$failedCount 台"
Write-Output " 总计:$($allConnections.Count) 台"

# 按分组统计
$allConnections | Group-Object -Property Group |
ForEach-Object {
[PSCustomObject]@{
分组 = $_.Name
数量 = $_.Count
}
} | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
从 AD 发现 23 台服务器
从 CSV 补充导入 5 台工作组服务器

导入结果汇总:
成功:27 台
失败:1 台
总计:28 台

分组 数量
---- ----
域控制器 3
文件服务器 6
Web 服务器 8
数据库服务器 6
外围服务器 5

脚本展示了两种服务器数据源的整合方式:Active Directory OU 查询适合域内服务器,CSV 导入适合工作组或云端服务器。所有服务器信息统一汇总后,通过 REST API 批量注册到 WAC 网关,并按业务归属进行分组。这种分组方式在 WAC 界面中会以树形结构呈现,运维人员可以快速定位到特定业务线下的所有服务器。

WAC 扩展开发基础

WAC 支持通过扩展来增强其功能。扩展本质上是一个基于 Angular 框架的 Web 应用,通过 WAC 提供的 SDK 与网关后端通信。以下示例演示如何使用 PowerShell 辅助创建 WAC 扩展项目,并调用 WAC 的 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
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
# 第一步:准备扩展开发环境
$extensionName = "custom-disk-monitor"
$extensionDir = "C:\WACExtensions\$extensionName"

# 创建扩展项目目录结构
$directories = @(
"$extensionDir\src"
"$extensionDir\src\components"
"$extensionDir\src\assets"
"$extensionDir\manifest"
)

foreach ($dir in $directories) {
$null = New-Item -Path $dir -ItemType Directory -Force
}

Write-Output "扩展项目目录已创建:$extensionDir"

# 生成扩展清单文件
$manifest = @{
name = $extensionName
displayName = "自定义磁盘监控"
description = "可视化展示服务器磁盘使用趋势"
version = "1.0.0"
entryPoint = "src/main.js"
icon = "assets/icon.svg"
targetVersions = @("2.0", "2.1", "2.2")
} | ConvertTo-Json -Depth 3

$manifest | Set-Content -Path "$extensionDir\manifest\manifest.json" -Encoding UTF8
Write-Output "清单文件已生成"

# 第二步:封装 WAC REST API 调用函数
function Invoke-WacDiskQuery {
param(
[Parameter(Mandatory)]
[string]$GatewayEndpoint,

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

[int]$WarningThreshold = 80
)

$queryScript = @(
"Get-CimInstance -ClassName Win32_LogicalDisk |"
" Where-Object { `$_.DriveType -eq 3 } |"
" ForEach-Object {"
" $pct = [math]::Round(($_.FreeSpace / $_.Size) * 100, 1)"
" $used = [math]::Round(($_.Size - $_.FreeSpace) / 1GB, 1)"
" $total = [math]::Round($_.Size / 1GB, 1)"
" [PSCustomObject]@{"
" Drive = $_.DeviceID"
" UsedGB = $used"
" TotalGB = $total"
" FreePct = $pct"
" Status = if ((100 - $pct) -gt $WarningThreshold)"
" { 'Warning' } else { 'OK' }"
" }"
" }"
) -join "`n"

$body = @{ command = $queryScript } | ConvertTo-Json

$uri = "$GatewayEndpoint/api/connections/$ServerName/powershell"

try {
$response = Invoke-RestMethod -Uri $uri -Method Post `
-Body $body -ContentType "application/json" `
-UseDefaultCredentials -SkipCertificateCheck

return $response.value
} catch {
Write-Error "查询 $ServerName 磁盘信息失败:$($_.Exception.Message)"
return $null
}
}

# 第三步:批量查询并生成 JSON 数据供扩展使用
$servers = @("SRV-FILE01", "SRV-WEB01", "SRV-DB01")
$diskData = @()

foreach ($server in $servers) {
$disks = Invoke-WacDiskQuery -GatewayEndpoint $gateway `
-ServerName $server -WarningThreshold 80

if ($null -ne $disks) {
foreach ($disk in $disks) {
$diskData += [PSCustomObject]@{
Server = $server
Drive = $disk.Drive
UsedGB = $disk.UsedGB
TotalGB = $disk.TotalGB
FreePct = $disk.FreePct
Status = $disk.Status
}
}
}
}

# 输出查询结果
$diskData | Format-Table -AutoSize

# 将数据序列化为 JSON 供前端扩展消费
$dataPath = "$extensionDir\src\assets\disk-data.json"
$diskData | ConvertTo-Json -Depth 3 |
Set-Content -Path $dataPath -Encoding UTF8

Write-Output "磁盘数据已导出至:$dataPath"

# 将扩展注册到 WAC 网关
$registerBody = @{
name = $extensionName
path = $extensionDir
enabled = $true
} | ConvertTo-Json

$null = Invoke-RestMethod -Uri "$gateway/api/extensions" `
-Method Post -Body $registerBody `
-ContentType "application/json" `
-UseDefaultCredentials -SkipCertificateCheck

Write-Output "扩展已注册到 WAC 网关"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
扩展项目目录已创建:C:\WACExtensions\custom-disk-monitor
清单文件已生成

Server Drive UsedGB TotalGB FreePct Status
------ ----- ------ ------- ------- ------
SRV-FILE01 C: 85.3 100.0 14.7 Warning
SRV-FILE01 D: 450.2 500.0 10.0 Warning
SRV-WEB01 C: 62.1 100.0 37.9 OK
SRV-WEB01 E: 120.5 200.0 39.8 OK
SRV-DB01 C: 45.8 100.0 54.2 OK
SRV-DB01 F: 780.3 1000.0 22.0 OK
SRV-DB01 G: 920.1 1000.0 8.0 Warning

磁盘数据已导出至:C:\WACExtensions\custom-disk-monitor\src\assets\disk-data.json
扩展已注册到 WAC 网关

这段脚本展示了 WAC 扩展开发的辅助流程。首先创建扩展的项目结构和清单文件,然后封装了一个可复用的 Invoke-WacDiskQuery 函数,通过 WAC REST API 远程执行磁盘查询。查询结果以 JSON 格式输出,供前端 Angular 组件直接消费。通过这种方式,团队可以将自定义的监控面板、运维工具或审批流程以扩展的形式嵌入到 WAC 界面中,形成统一的管理入口。

注意事项

  1. 安装前置条件:WAC 网关要求运行在 Windows Server 2016 及以上版本,且需要安装 .NET Framework 4.6 或更高版本。部署前应通过 Get-WindowsFeature 确认必要组件已启用,避免安装过程中因缺少依赖而失败。

  2. 证书管理策略:WAC 强制要求 HTTPS,生产环境中应使用企业 CA 或公共 CA 签发的证书,并在证书即将过期前通过自动化脚本完成续签和绑定。脚本中使用的证书指纹必须在本地计算机证书存储中存在。

  3. 连接批量导入的幂等性:重复执行导入脚本时,应先检查连接是否已存在再决定是否添加。忽略幂等性检查会导致 WAC 数据库中出现重复条目,影响管理界面的展示效果。

  4. 扩展开发的技术栈要求:WAC 扩展基于 Angular 框架,前端代码需要使用 TypeScript 编写。PowerShell 主要用于后端数据准备和 API 调用封装,实际的 UI 交互逻辑仍需前端开发工具链支持。

  5. API 调用的身份传递:WAC REST API 支持 Windows 集成认证(Kerberos/NTLM)。在跨域场景中,需要配置 CredSSP 或 Kerberos 约束委派,确保用户凭据能从 WAC 网关正确传递到目标服务器。

  6. 扩展版本兼容性:WAC 每次大版本升级可能导致扩展 API 变更。在清单文件的 targetVersions 字段中明确声明支持的 WAC 版本范围,并在升级前在测试环境验证扩展的兼容性,避免升级后扩展功能异常。

PowerShell 技能连载 - Polyglot Notebooks 交互式脚本

适用于 PowerShell 7.0 及以上版本

Polyglot Notebooks(前身 .NET Interactive)是微软推出的一款交互式笔记本工具,它将 Jupyter 笔记本的理念带入了 .NET 生态。与传统的 Jupyter 仅支持 Python 等单一内核不同,Polyglot Notebooks 的核心优势在于多语言互操作:你可以在同一个笔记本中无缝切换 PowerShell、C#、F#、JavaScript、HTML 等语言,并通过变量共享机制让不同语言的代码块协同工作。

对于 PowerShell 用户而言,这意味着你不必再在”用 PowerShell 写脚本”和”用 Jupyter 做数据分析”之间做选择。你可以直接在笔记本中用 PowerShell 获取系统数据、调用 REST API、操作文件系统,然后用 C# 处理复杂的计算逻辑,最后用 JavaScript 生成可交互的可视化图表——所有这些都在同一个文档中完成。

本文将从三个方面介绍 Polyglot Notebooks 的实战用法:首先搭建环境并掌握基础的 Magic Commands,然后演示多语言协作与变量共享的技巧,最后通过一个数据分析实战案例展示完整的工作流。

Notebook 基础操作

要在 Polyglot Notebooks 中使用 PowerShell,需要先安装 .NET SDK 和 Polyglot Notebooks 扩展。整个过程非常简洁,下面这个脚本会自动检查环境并完成初始化配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 检查 .NET SDK 是否已安装
function Assert-DotNetSdk {
$sdk = dotnet --version 2>$null
if (-not $sdk) {
Write-Host "正在安装 .NET SDK ..." -ForegroundColor Yellow
winget install Microsoft.DotNet.SDK.8 --accept-source-agreements
} else {
Write-Host ".NET SDK 版本: $sdk" -ForegroundColor Green
}

# 安装 PowerShell 内核(Jupyter kernel)
dotnet tool install -g Microsoft.dotnet-interactive
dotnet interactive jupyter install

# 验证内核是否注册成功
$kernels = jupyter kernelspec list 2>$null
if ($kernels -match "powershell") {
Write-Host "PowerShell 内核已注册" -ForegroundColor Green
} else {
Write-Host "PowerShell 内核未找到,请手动执行 jupyter kernelspec list 排查" -ForegroundColor Red
}
}

Assert-DotNetSdk

执行结果示例:

1
2
.NET SDK 版本: 8.0.403
PowerShell 内核已注册

安装完成后,在 VS Code 中安装 Polyglot Notebooks 扩展(扩展 ID: ms-dotnettools.dotnet-interactive-vscode),然后创建一个 .dib.ipynb 文件即可开始使用。Polyglot Notebooks 提供了一组称为 “Magic Commands” 的特殊指令,以 #! 开头,用于控制笔记本行为。以下是最常用的几个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 查看当前可用的所有语言内核
#!about

# 切换当前单元格的语言为 PowerShell
#!pwsh

# 切换到 C#
#!csharp

# 切换到 JavaScript
#!javascript

# 设置变量共享 —— 在 PowerShell 中定义变量后让其他语言使用
#!set --value @pwsh:serverName --name serverName

# 从外部文件导入脚本并执行
#!import --file ./data-processing.ps1

# 获取笔记本运行环境信息
$PSVersionTable
[System.Environment]::OSVersion

执行结果示例:

1
2
3
PowerShell 7.4.6
OS: Microsoft Windows 10.0.22631
Platform: Win32NT

Magic Commands 的强大之处在于它们可以在单元格的第一行使用,无需额外的配置。这意味着你可以逐个单元格地指定语言,实现真正的多语言混合编程。

多语言协作与变量共享

Polyglot Notebooks 最令人兴奋的特性是跨语言变量共享。你可以在 PowerShell 中采集数据,传递给 C# 进行复杂计算,再用 JavaScript 渲染为可交互的 HTML 页面。下面这个示例演示了完整的多语言协作流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# === PowerShell 单元格:采集系统性能数据 ===
#!pwsh

# 收集当前系统的 CPU 和内存信息
$cpuUsage = [math]::Round(
(Get-CimInstance Win32_Processor).LoadPercentage, 2
)
$os = Get-CimInstance Win32_OperatingSystem
$totalMemGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeMemGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedMemGB = [math]::Round($totalMemGB - $freeMemGB, 2)
$memPercent = [math]::Round(($usedMemGB / $totalMemGB) * 100, 1)

# 获取磁盘信息
$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
Select-Object DeviceID,
@{N="TotalGB"; E={[math]::Round($_.Size/1GB,2)}},
@{N="FreeGB"; E={[math]::Round($_.FreeSpace/1GB,2)}},
@{N="UsedPercent"; E={[math]::Round(($_.Size - $_.FreeSpace)/$_.Size * 100, 1)}}

$systemInfo = @{
HostName = $env:COMPUTERNAME
CpuUsage = $cpuUsage
TotalMemGB = $totalMemGB
UsedMemGB = $usedMemGB
FreeMemGB = $freeMemGB
MemPercent = $memPercent
Disks = $disks
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}

Write-Host "系统数据采集完成: $($systemInfo.HostName)" -ForegroundColor Cyan
$systemInfo | ConvertTo-Json -Depth 3

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
系统数据采集完成: DESKTOP-WIN11
{
"HostName": "DESKTOP-WIN11",
"CpuUsage": 23,
"TotalMemGB": 31.87,
"UsedMemGB": 18.54,
"FreeMemGB": 13.33,
"MemPercent": 58.2,
"Disks": [
{
"DeviceID": "C:",
"TotalGB": 476.37,
"FreeGB": 198.24,
"UsedPercent": 58.4
},
{
"DeviceID": "D:",
"TotalGB": 931.51,
"FreeGB": 645.12,
"UsedPercent": 30.7
}
],
"Timestamp": "2026-01-08 09:15:32"
}

接下来在 C# 单元格中接收这些数据并进行处理分析:

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
# === C# 单元格:利用 LINQ 进行数据分析 ===
#!csharp
#!set --value @pwsh:systemInfo --name systemInfo

using System.Text.Json;

// 解析 PowerShell 传入的 JSON 数据
var json = systemInfo.ToString();
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;

// 提取关键指标
var hostName = root.GetProperty("HostName").GetString();
var cpuUsage = root.GetProperty("CpuUsage").GetInt32();
var memPercent = root.GetProperty("MemPercent").GetDouble();
var timestamp = root.GetProperty("Timestamp").GetString();

// 生成健康评估报告
var healthScore = 100.0;
var warnings = new List<string>();

if (cpuUsage > 80) {
healthScore -= 30;
warnings.Add($"CPU 使用率过高: {cpuUsage}%");
} else if (cpuUsage > 60) {
healthScore -= 10;
warnings.Add($"CPU 使用率偏高: {cpuUsage}%");
}

if (memPercent > 85) {
healthScore -= 25;
warnings.Add($"内存使用率过高: {memPercent}%");
} else if (memPercent > 70) {
healthScore -= 10;
warnings.Add($"内存使用率偏高: {memPercent}%");
}

var status = healthScore >= 80 ? "健康" :
healthScore >= 60 ? "注意" : "警告";

// 将结果保存为共享变量供后续使用
var report = new {
HostName = hostName,
Timestamp = timestamp,
HealthScore = Math.Round(healthScore, 1),
Status = status,
Warnings = warnings,
Summary = $"[{status}] {hostName} 健康评分: {healthScore:F1}/100"
};

Console.WriteLine(report.Summary);
if (warnings.Any()) {
Console.WriteLine("告警项:");
warnings.ForEach(w => Console.WriteLine($" - {w}"));
}

执行结果示例:

1
2
3
[注意] DESKTOP-WIN11 健康评分: 80.0/100
告警项:
- 内存使用率偏高: 58.2%

通过 #!set 命令,C# 单元格直接读取了 PowerShell 中定义的 $systemInfo 变量。这种跨语言变量传递是 Polyglot Notebooks 的核心能力,使得每种语言都可以专注于自己最擅长的领域。

数据分析与可视化实战

最后一个场景是数据分析的完整工作流:用 PowerShell 导入 CSV 数据并进行清洗,然后用 C# 进行聚合计算,最后生成 HTML 可视化报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# === PowerShell 单元格:数据导入与清洗 ===
#!pwsh

# 模拟生成服务器监控日志数据(实际使用时替换为 Import-Csv)
$sampleData = @(
@{ Server="WEB-01"; Region="East"; CPU=45; Memory=62; Requests=12500; Errors=23; Date="2026-01-01" }
@{ Server="WEB-02"; Region="East"; CPU=78; Memory=81; Requests=18900; Errors=156; Date="2026-01-01" }
@{ Server="API-01"; Region="West"; CPU=32; Memory=55; Requests=45000; Errors=12; Date="2026-01-01" }
@{ Server="API-02"; Region="West"; CPU=91; Memory=88; Requests=52000; Errors=423; Date="2026-01-01" }
@{ Server="DB-01"; Region="Central"; CPU=67; Memory=74; Requests=8500; Errors=5; Date="2026-01-01" }
@{ Server="DB-02"; Region="Central"; CPU=55; Memory=69; Requests=7200; Errors=2; Date="2026-01-02" }
@{ Server="WEB-01"; Region="East"; CPU=52; Memory=65; Requests=13100; Errors=31; Date="2026-01-02" }
@{ Server="API-01"; Region="West"; CPU=38; Memory=58; Requests=47200; Errors=18; Date="2026-01-02" }
@{ Server="CACHE-01"; Region="East"; CPU=22; Memory=91; Requests=98000; Errors=1; Date="2026-01-02" }
@{ Server="WORKER-01"; Region="West"; CPU=85; Memory=43; Requests=3200; Errors=8; Date="2026-01-02" }
)

$records = $sampleData | ForEach-Object {
[PSCustomObject]@{
Server = $_.Server
Region = $_.Region
CPU = [int]$_.CPU
Memory = [int]$_.Memory
Requests = [int]$_.Requests
Errors = [int]$_.Errors
Date = $_.Date
ErrorRate = [math]::Round($_.Errors / $_.Requests * 100, 3)
}
}

# 数据清洗:标记异常服务器
$cleanData = $records | ForEach-Object {
$flags = @()
if ($_.CPU -gt 80) { $flags += "CPU高负载" }
if ($_.Memory -gt 85) { $flags += "内存高占用" }
if ($_.ErrorRate -gt 0.5) { $flags += "错误率异常" }

$_ | Add-Member -NotePropertyName "Flags" -NotePropertyValue ($flags -join ", ") -PassThru
}

# 输出统计摘要
$totalRecords = $cleanData.Count
$flaggedCount = ($cleanData | Where-Object { $_.Flags }).Count
$avgCpu = [math]::Round(($cleanData | Measure-Object -Property CPU -Average).Average, 1)
$avgMem = [math]::Round(($cleanData | Measure-Object -Property Memory -Average).Average, 1)

Write-Host "数据概览:" -ForegroundColor Cyan
Write-Host " 总记录数: $totalRecords"
Write-Host " 异常标记数: $flaggedCount"
Write-Host " 平均 CPU: ${avgCpu}%"
Write-Host " 平均内存: ${avgMem}%"
Write-Host ""

# 按区域分组汇总
$regionSummary = $cleanData |
Group-Object Region |
ForEach-Object {
$group = $_.Group
[PSCustomObject]@{
Region = $_.Name
Count = $_.Count
AvgCPU = [math]::Round(($group | Measure-Object CPU -Average).Average, 1)
AvgMemory = [math]::Round(($group | Measure-Object Memory -Average).Average, 1)
TotalReqs = ($group | Measure-Object Requests -Sum).Sum
TotalErrors = ($group | Measure-Object Errors -Sum).Sum
}
}

$regionSummary | Format-Table -AutoSize

# 生成 HTML 可视化报告
$htmlReport = @"
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>服务器监控报告</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; }
th { background: #0078d4; color: white; padding: 10px; text-align: left; }
td { padding: 8px 10px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background: #f9f9f9; }
.flag { color: #d13438; font-weight: bold; }
.ok { color: #107c10; }
.warn { color: #ff8c00; }
</style>
</head>
<body>
<h1>服务器监控报告</h1>
<p>生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>
<h2>异常服务器列表</h2>
<table>
<tr><th>服务器</th><th>区域</th><th>CPU</th><th>内存</th>
<th>请求数</th><th>错误数</th><th>错误率</th><th>标记</th></tr>
"@

foreach ($row in ($cleanData | Where-Object { $_.Flags })) {
$cpuClass = if ($row.CPU -gt 80) { "flag" } elseif ($row.CPU -gt 60) { "warn" } else { "ok" }
$memClass = if ($row.Memory -gt 85) { "flag" } elseif ($row.Memory -gt 70) { "warn" } else { "ok" }
$htmlReport += @"
<tr>
<td>$($row.Server)</td>
<td>$($row.Region)</td>
<td class="$cpuClass">$($row.CPU)%</td>
<td class="$memClass">$($row.Memory)%</td>
<td>$($row.Requests)</td>
<td>$($row.Errors)</td>
<td>$($row.ErrorRate)%</td>
<td class="flag">$($row.Flags)</td>
</tr>
"@
}

$htmlReport += "</table></body></html>"

# 保存报告
$reportPath = Join-Path $env:TEMP "ServerMonitor-$(Get-Date -Format 'yyyyMMdd-HHmmss').html"
$htmlReport | Out-File -FilePath $reportPath -Encoding utf8
Write-Host ""
Write-Host "HTML 报告已生成: $reportPath" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
数据概览:
总记录数: 10
异常标记数: 4
平均 CPU: 56.5%
平均内存: 68.6%

Region Count AvgCPU AvgMemory TotalReqs TotalErrors
------ ----- ------ --------- --------- -----------
Central 2 61.0 71.5 15700 7
East 4 49.2 74.8 132500 211
West 4 61.5 61.8 104400 461

HTML 报告已生成: /tmp/ServerMonitor-20260108-091532.html

这个实战案例展示了 Polyglot Notebooks 在数据运维中的完整工作流:PowerShell 负责数据采集和清洗,通过对象管道完成分组聚合,最终生成带有条件高亮的 HTML 可视化报告。在实际工作中,你可以将 Import-Csv 替换为真实数据源,还可以添加 C# 单元格进行更复杂的统计分析。

注意事项

  • Polyglot Notebooks 需要 .NET 8.0 SDK 或更高版本。安装前请确认系统已满足此先决条件,否则 dotnet interactive 命令将无法正常注册 Jupyter 内核。

  • 跨语言变量共享通过 #!set 命令实现,传递时数据会被序列化为 JSON 格式。复杂对象(如 PSCustomObject)可以顺利传递,但包含方法或闭包的对象会被自动剥离,只保留属性数据。

  • 在 VS Code 中使用 .dib 格式是 Polyglot Notebooks 的原生体验,功能最为完整。如果使用标准 .ipynb 格式,请确保在笔记本元数据中正确指定了内核为 .NET (Polyglot)

  • Magic Commands 必须写在单元格的第一行,前面不能有任何其他代码或空行。否则笔记本引擎无法识别指令,会将其作为普通文本处理。

  • 当笔记本中的数据量较大时(例如数万行 CSV),建议在 PowerShell 单元格中使用 Measure-ObjectGroup-Object 等流式处理命令,避免将全量数据加载到内存中导致性能下降。

  • Polyglot Notebooks 目前不支持直接在 JupyterLab 中运行 PowerShell 内核的自动补全功能。如果需要 IntelliSense 支持,推荐在 VS Code 中配合 Polyglot 扩展使用,可以获得完整的代码补全和语法检查体验。