PowerShell 技能连载 - DSC v3 配置管理

适用于 PowerShell 7.0 及以上版本(跨平台)

Desired State Configuration(DSC)一直是 Windows 生态中基础设施即代码(IaC)的重要支柱。从 DSC v1 基于 MOF 的推送模式,到 DSC v2 引入的拉取服务,再到如今全新的 DSC v3,微软对配置管理的理念发生了根本性的转变。DSC v3 完全抛弃了对 WMF(Windows Management Framework)的依赖,转而成为一个独立的跨平台工具,支持 Windows、Linux 甚至 macOS。

DSC v3 的核心变化在于它不再绑定 PowerShell 作为运行时。新的 DSC 引擎(dsc)是原生可执行文件,配置文档采用 YAML 或 JSON 格式编写,资源可以通过任何语言实现(不再局限于 PowerShell 模块)。这使得 DSC v3 能够更好地融入现代 DevOps 流水线,与 Ansible、Chef、Terraform 等工具协同工作。同时,DSC v3 还引入了基于 JSON Schema 的配置验证机制,在应用配置之前就能捕获语法和结构错误。

对于 PowerShell 用户而言,DSC v3 带来的好消息是你仍然可以用 PowerShell 编写自定义 DSC 资源,同时享受新版引擎带来的性能提升和更好的错误报告。下面我们通过几个实际示例来体验 DSC v3 的核心用法。

安装 DSC v3

DSC v3 是独立发布的工具,不随 PowerShell 自带。你可以通过多种方式安装。

1
2
3
4
5
6
7
8
9
10
11
12
# 使用 winget 安装 DSC v3(Windows)
winget install Microsoft.DSC v3

# 或者手动下载安装
$release = Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/DSC/releases/latest'
$asset = $release.assets | Where-Object { $_.name -like '*-x64*' -and $_.name -like '*.msi' }
$downloadPath = Join-Path $env:TEMP $asset.name
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $downloadPath
Write-Host "已下载到: $downloadPath"

# 验证安装
dsc --version

安装完成后,可以通过以下输出确认版本信息:

1
3.0.0

编写 YAML 配置文档

DSC v3 采用声明式的配置文档,用 YAML 或 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
# 创建 DSC v3 配置文档
$configContent = @"
schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json
resources:
- name: Ensure temp directory
type: Microsoft.DSC/File
properties:
destinationPath: /tmp/dsc-demo
ensure: present
attributes:
- directory

- name: Set editor environment variable
type: Microsoft.DSC/Environment
properties:
name: EDITOR
value: code
ensure: present

- name: Install Git
type: Microsoft.DSC/Package
properties:
name: git
ensure: present
"@

$configPath = Join-Path $env:TEMP 'dsc-config.dsc.yaml'
$configContent | Set-Content -Path $configPath -Encoding UTF8
Write-Host "配置文档已写入: $configPath"

执行结果示例:

1
配置文档已写入: /tmp/dsc-config.dsc.yaml

验证和测试配置

在真正应用配置之前,先用 dsc config test 检查当前系统是否已经符合期望状态。这是一个安全且不会产生副作用的操作。

1
2
3
4
5
6
7
8
9
10
11
# 验证配置文档的语法正确性
dsc config validate --path $configPath

# 测试当前系统状态与期望配置的差异
$testResult = dsc config test --path $configPath 2>&1
$resultObj = $testResult | ConvertFrom-Json

foreach ($resource in $resultObj.results) {
$status = if ($resource.inDesiredState) { '符合' } else { '不符合' }
Write-Host ("资源 [{0}] 状态: {1}" -f $resource.name, $status)
}

执行结果示例:

1
2
3
资源 [Ensure temp directory] 状态: 不符合
资源 [Set editor environment variable] 状态: 不符合
资源 [Install Git] 状态: 符合

应用配置并获取状态

确认测试结果后,使用 dsc config set 将系统推进到期望状态,再用 dsc config get 确认最终结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 应用配置(将系统设置为期望状态)
$setResult = dsc config set --path $configPath 2>&1
$setObj = $setResult | ConvertFrom-Json

foreach ($op in $setObj.operations) {
Write-Host ("操作: {0} -> {1}" -f $op.resourceName, $op.operation)
}

Write-Host "`n--- 获取当前配置状态 ---"

# 获取当前系统的实际状态
$getResult = dsc config get --path $configPath 2>&1
$getObj = $getResult | ConvertFrom-Json

foreach ($resource in $getObj.results) {
$state = if ($resource.inDesiredState) { 'OK' } else { 'DRIFT' }
Write-Host ("{0}: {1}" -f $resource.name, $state)
}

执行结果示例:

1
2
3
4
5
6
7
操作: Ensure temp directory -> set
操作: Set editor environment variable -> set

--- 获取当前配置状态 ---
Ensure temp directory: OK
Set editor environment variable: OK
Install Git: OK

编写基于 PowerShell 的自定义 DSC 资源

DSC v3 的一大改进是自定义资源不再需要实现复杂的 Cmdlet 类。你只需要提供一个导出 JSON 清单的 PowerShell 脚本,然后实现 getsettest 三个操作即可。

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
# 创建自定义 DSC 资源的目录结构
$resourceDir = Join-Path $env:TEMP 'DSCResources/MyCustomService'
New-Item -ItemType Directory -Path $resourceDir -Force | Out-Null

# 资源清单文件 (resource.manifest.json)
$manifest = @{
schemaVersion = '2024/04'
type = 'MyCustom/Service'
version = '1.0.0'
description = '管理 Windows 服务的自定义 DSC 资源'
capabilities = @('get', 'set', 'test')
filePath = './service-resource.ps1'
} | ConvertTo-Json -Depth 5

$manifest | Set-Content (Join-Path $resourceDir 'resource.manifest.json')

# 资源实现脚本 (service-resource.ps1)
$scriptContent = @'
param(
[string]$Operation,
[string]$InputData
)

$config = $InputData | ConvertFrom-Json

switch ($Operation) {
'get' {
$service = Get-Service -Name $config.name -ErrorAction SilentlyContinue
if ($service) {
@{ name = $config.name; status = $service.Status; startType = $service.StartType } |
ConvertTo-Json -Compress
} else {
@{ name = $config.name; status = 'NotFound'; startType = 'Unknown' } |
ConvertTo-Json -Compress
}
}
'test' {
$service = Get-Service -Name $config.name -ErrorAction SilentlyContinue
$desiredState = $config.ensure -eq 'present'
$actualState = ($null -ne $service -and $service.Status -eq 'Running')
@{ inDesiredState = ($desiredState -eq $actualState) } |
ConvertTo-Json -Compress
}
'set' {
if ($config.ensure -eq 'present') {
Set-Service -Name $config.name -StartupType Automatic
Start-Service -Name $config.name
} else {
Stop-Service -Name $config.name -Force
Set-Service -Name $config.name -StartupType Disabled
}
@{ changed = $true } | ConvertTo-Json -Compress
}
}
'@

$scriptContent | Set-Content (Join-Path $resourceDir 'service-resource.ps1')
Write-Host "自定义资源已创建于: $resourceDir"
Write-Host "清单文件: resource.manifest.json"
Write-Host "实现脚本: service-resource.ps1"

执行结果示例:

1
2
3
自定义资源已创建于: /tmp/DSCResources/MyCustomService
清单文件: resource.manifest.json
实现脚本: service-resource.ps1

注意事项

  1. DSC v3 与旧版不兼容:DSC v3 使用全新的配置格式(YAML/JSON),无法直接复用 DSC v1/v2 的 MOF 配置。迁移时需要重写配置文档,但资源逻辑通常可以保留。

  2. 资源清单是必须的:每个自定义资源都必须提供 resource.manifest.json,其中声明资源类型、版本、支持的操作(getsettestdelete)以及入口脚本的路径。缺少清单会导致 DSC 引擎无法发现该资源。

  3. 跨平台差异:虽然 DSC v3 支持多平台,但并非所有内置资源在每个操作系统上都可用。例如 Microsoft.DSC/Package 在 Linux 上使用系统包管理器(apt/yum),在 Windows 上则使用 winget 或 MSI。编写配置时要注意目标平台。

  4. 幂等性是关键set 操作必须是幂等的——多次执行 set 不应产生副作用。确保你的 test 操作能准确检测当前状态,set 操作只在需要变更时才执行修改。

  5. JSON 输出格式:自定义资源的 PowerShell 脚本必须输出有效的 JSON(使用 ConvertTo-Json),且不能有多余的控制台输出(Write-Host 等),否则会导致 DSC 引擎解析失败。调试时可以重定向到文件。

  6. Schema 验证要先于部署:始终先运行 dsc config validate 检查配置文档的语法和结构,再执行 testset。这可以在早期捕获拼写错误和结构问题,避免运行时意外。

PowerShell 技能连载 - 事件日志深度分析

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

Windows 事件日志是系统运维和安全审计的核心数据源。无论是排查服务崩溃、追踪用户登录行为,还是进行安全取证分析,事件日志都提供了不可替代的线索。然而,面对动辄数十万条日志记录,手动翻阅事件查看器显然效率低下。

PowerShell 内置的 Get-WinEvent cmdlet 拥有强大的过滤和查询能力,配合结构化对象输出,可以大幅提升日志分析效率。与旧版的 Get-EventLog(已在 PowerShell 7 中移除)不同,Get-WinEvent 支持所有日志通道(包括 ETL 诊断日志),并能通过 XPath 和哈希表实现高效的服务端过滤。

本文将从安全审计和故障排查两个场景出发,演示如何用 PowerShell 构建一套实用的事件日志深度分析脚本。重点在于掌握过滤技巧和结果聚合方法,让海量日志真正为你所用。

按时间窗口和级别提取安全日志

在安全审计场景中,最常见的操作是从安全日志中提取特定时间段内的事件。以下脚本演示如何查询最近 24 小时内的登录成功事件(Event ID 4624),并提取关键信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$startTime = (Get-Date).AddHours(-24)
$logonEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4624
StartTime = $startTime
} -ErrorAction SilentlyContinue

$results = @()
foreach ($event in $logonEvents) {
$xml = [xml]$event.ToXml()
$data = $xml.Event.EventData.Data
$targetUserName = ($data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
$logonType = ($data | Where-Object { $_.Name -eq 'LogonType' }).'#text'
$ipAddress = ($data | Where-Object { $_.Name -eq 'IpAddress' }).'#text'

$results += [PSCustomObject]@{
Time = $event.TimeCreated
User = $targetUserName
LogonType = $logonType
SourceIP = $ipAddress
}
}

$results | Sort-Object Time -Descending | Format-Table -AutoSize

上述代码使用 FilterHashtable 进行服务端过滤,只将符合条件的记录传输到客户端,避免大量无用数据占用内存。通过解析事件的 XML 结构,我们可以提取出事件数据中任意命名的字段。注意这里使用 foreach 循环而非管道,在处理大量记录时性能更稳定。

执行结果示例:

1
2
3
4
5
6
7
Time                  User           LogonType SourceIP
---- ---- --------- --------
2025/10/27 07:52:14 administrator 10 192.168.1.105
2025/10/27 07:48:33 jzhang 2 192.168.1.42
2025/10/27 07:30:19 SYSTEM 5 -
2025/10/27 06:15:42 lwei 10 10.0.0.88
2025/10/27 05:02:07 administrator 7 127.0.0.1

其中 LogonType 的含义尤为关键:类型 2 表示交互式登录(控制台),类型 10 表示远程桌面登录,类型 5 表示服务启动。如果发现异常的远程登录记录,应立即排查来源 IP 是否合法。

聚合高频错误事件并生成统计报告

在故障排查场景中,快速定位哪些错误出现频率最高,往往比逐条分析更有效率。以下脚本从 System 和 Application 两个日志中提取最近 7 天的 Error 和 Critical 级别事件,按事件 ID 分组统计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
$startTime = (Get-Date).AddDays(-7)
$logNames = @('System', 'Application')

$allErrors = @()
foreach ($logName in $logNames) {
$events = Get-WinEvent -FilterHashtable @{
LogName = $logName
Level = 1, 2
StartTime = $startTime
} -ErrorAction SilentlyContinue

foreach ($event in $events) {
$allErrors += [PSCustomObject]@{
LogName = $event.LogName
EventId = $event.Id
Level = $event.LevelDisplayName
Message = $event.Message.Substring(0, [Math]::Min(120, $event.Message.Length))
Time = $event.TimeCreated
Provider = $event.ProviderName
}
}
}

$summary = $allErrors | Group-Object LogName, EventId | Sort-Object Count -Descending

foreach ($group in $summary) {
$parts = $group.Name -split ', '
$log = $parts[0]
$eid = $parts[1]
$sampleMessage = $group.Group[0].Message

[PSCustomObject]@{
Count = $group.Count
LogName = $log
EventId = $eid
Provider = $group.Group[0].Provider
Sample = $sampleMessage
}
} | Format-Table -AutoSize -Wrap

这段脚本的核心思路是先用 Level = 1, 2(Critical 和 Error)做服务端过滤,减少数据传输量;然后在客户端用 Group-Object 按日志名和事件 ID 聚合,统计出现次数。最后输出每个高频错误的首次出现样例,方便运维人员快速判断问题根因。

执行结果示例:

1
2
3
4
5
6
7
Count LogName    EventId Provider              Sample
----- ------- ------- -------- ------
147 System 7036 Service Control Manager 服务已在后台启动或停止...
82 Application 1000 Application Error 故障模块名称: ntdll.dll...
45 System 7023 Service Control Manager 服务以特定错误退出...
31 Application 1026 .NET Runtime 进程已因未经处理的异常而终止...
12 System 41 Kernel-Power 系统未先正常关闭...

检测可疑的登录失败和账户锁定

安全取证中,登录失败事件(Event ID 4625)和账户锁定事件(Event ID 4740)是发现暴力破解攻击的关键指标。以下脚本汇总最近 7 天的登录失败记录,按目标账户和来源 IP 聚合,识别潜在的暴力破解目标。

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
$startTime = (Get-Date).AddDays(-7)

$failedLogonEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4625
StartTime = $startTime
} -ErrorAction SilentlyContinue

$failedLogins = @()
foreach ($event in $failedLogonEvents) {
$xml = [xml]$event.ToXml()
$data = $xml.Event.EventData.Data
$targetUser = ($data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
$sourceIP = ($data | Where-Object { $_.Name -eq 'IpAddress' }).'#text'
$failureReason = ($data | Where-Object { $_.Name -eq 'SubStatus' }).'#text'

$failedLogins += [PSCustomObject]@{
Time = $event.TimeCreated
User = $targetUser
SourceIP = $sourceIP
FailureCode = $failureReason
}
}

$suspicious = $failedLogins | Group-Object User, SourceIP | Where-Object { $_.Count -gt 5 } |
Sort-Object Count -Descending

foreach ($entry in $suspicious) {
$parts = $entry.Name -split ', '
[PSCustomObject]@{
FailedCount = $entry.Count
User = $parts[0]
SourceIP = $parts[1]
FirstSeen = $entry.Group[0].Time
LastSeen = $entry.Group[-1].Time
}
} | Format-Table -AutoSize

脚本中通过 Where-Object { $_.Count -gt 5 } 筛选出失败次数超过 5 次的组合,这些通常是暴力破解的特征。FailureCode 字段(SubStatus)可以进一步区分失败原因,例如 0xC000006A 表示密码错误,0xC0000234 表示账户已被锁定。

执行结果示例:

1
2
3
4
5
6
FailedCount User           SourceIP       FirstSeen             LastSeen
----------- ---- -------- --------- --------
89 administrator 203.0.113.42 2025/10/21 02:14:07 2025/10/27 06:58:33
34 backup_svc 198.51.100.7 2025/10/23 11:20:15 2025/10/26 23:47:01
12 testuser 192.168.1.105 2025/10/25 08:30:00 2025/10/27 07:12:44
8 sqladmin 10.0.0.99 2025/10/26 14:05:22 2025/10/27 03:40:18

发现此类模式后,建议立即检查对应账户的锁定策略和网络防火墙规则,并考虑将可疑 IP 加入黑名单。

注意事项

  1. 优先使用 FilterHashtable 而非管道过滤Get-WinEvent 支持 -FilterHashtable 参数做服务端过滤,比先获取全部事件再用 Where-Object 过滤效率高数十倍。在查询安全日志等大型日志时,差异尤为明显。

  2. 处理空结果集:当日志中没有符合条件的记录时,Get-WinEvent 会抛出异常而非返回空集合。务必使用 -ErrorAction SilentlyContinuetry/catch 块来优雅处理这种情况。

  3. Level 参数使用数值FilterHashtable 中的 Level 字段只接受数值:1 = Critical,2 = Error,3 = Warning,4 = Information,5 = Verbose。不要使用字符串,否则过滤不会生效。

  4. XPath 过滤更精细:当 FilterHashtable 无法满足复杂条件时,可以使用 -FilterXPath 参数编写 XPath 表达式。例如按消息内容中的特定字段值过滤,这是最灵活的服务端过滤方式。

  5. 安全日志需要管理员权限:查询 Security 日志需要以管理员身份运行 PowerShell(”以管理员身份运行”),否则会收到权限不足的错误。普通用户只能访问 Application 和 System 等日志。

  6. 大时间范围查询注意性能:如果查询跨度数月的安全日志,即使使用服务端过滤也可能返回海量数据。建议先按天或按小时分段查询,再汇总结果。也可以结合 -MaxEvents 参数限制返回条数做初步探查。

PowerShell 技能连载 - LINQ 数据操作

适用于 PowerShell 5.1 及以上版本

在处理大规模数据集时,PowerShell 原生的管道操作(如 Where-ObjectForEach-ObjectSort-Object)虽然语法直观,但在性能上往往不尽人意。管道每次传递对象都需要包装和拆包,当数据量达到数万甚至百万级别时,这个开销会变得非常可观。

LINQ(Language Integrated Query)是 .NET 框架内置的一套强大的数据查询和操作库。虽然 PowerShell 没有像 C# 那样提供原生的 LINQ 语法糖,但我们可以直接通过 [System.Linq.Enumerable] 静态类调用 LINQ 方法。在现代 PowerShell(5.1+)中,LINQ 的集成度已经大幅提升,尤其在批量数据处理、聚合计算和集合变换等场景下,相比管道操作可以获得数倍甚至数十倍的性能提升。

本文将系统介绍如何在 PowerShell 中使用 LINQ 进行高效的数据过滤、排序、聚合和分组操作,并通过基准测试对比原生管道与 LINQ 的性能差异。

LINQ 过滤与条件筛选

最常见的数据操作是条件筛选。PowerShell 中习惯使用 Where-Object,但 LINQ 的 Where 方法在大数据集上有明显的性能优势。下面的示例创建一个包含 10 万条记录的测试数据集,然后对比两种方式的筛选速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 构造测试数据:10 万个自定义对象
$testData = [System.Collections.Generic.List[PSObject]]::new()
$rng = [System.Random]::new(42)

foreach ($i in 1..100000) {
$testData.Add([PSCustomObject]@{
Id = $i
Name = "Item_$i"
Value = $rng.Next(1, 10001)
Active = ($rng.Next(2) -eq 0)
})
}

Write-Host "数据集大小: $($testData.Count) 条记录"

# 方式一:PowerShell 原生 Where-Object 管道筛选
$sw1 = [System.Diagnostics.Stopwatch]::StartNew()
$filtered1 = $testData | Where-Object { $_.Active -and $_.Value -gt 8000 }
$sw1.Stop()
Write-Host "Where-Object 筛选结果: $($filtered1.Count) 条,耗时: $($sw1.ElapsedMilliseconds) ms"

# 方式二:LINQ Where 方法筛选
$sw2 = [System.Diagnostics.Stopwatch]::StartNew()
$filtered2 = [System.Linq.Enumerable]::Where(
$testData,
[Func[object, bool]]{ param($x) $x.Active -and $x.Value -gt 8000 }
)
$filteredList2 = [System.Linq.Enumerable]::ToList($filtered2)
$sw2.Stop()
Write-Host "LINQ Where 筛选结果: $($filteredList2.Count) 条,耗时: $($sw2.ElapsedMilliseconds) ms"

LINQ 的 Where 方法接受一个委托函数作为筛选条件,返回的是 IEnumerable 延迟执行序列。使用 ToList() 可以立即执行并将结果物化为列表。在大数据集场景下,LINQ 避免了管道的对象传递开销,直接在内存中完成迭代筛选。

1
2
3
数据集大小: 100000 条记录
Where-Object 筛选结果: 968 条,耗时: 487 ms
LINQ Where 筛选结果: 968 条,耗时: 62 ms

LINQ 排序与聚合

除了筛选,排序和聚合也是日常数据处理的高频操作。LINQ 提供了 OrderByOrderByDescending 进行排序,SumAverageMinMax 进行聚合计算。下面我们演示如何在一个脚本中组合使用这些方法。

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
# 使用 LINQ 进行排序(按 Value 降序取前 10 名)
$sorted = [System.Linq.Enumerable]::OrderByDescending(
$testData,
[Func[object, int]]{ param($x) $x.Value }
)
$top10 = [System.Linq.Enumerable]::Take($sorted, 10)

Write-Host "=== Value 最高的 10 条记录 ==="
foreach ($item in $top10) {
Write-Host " Id=$($item.Id), Name=$($item.Name), Value=$($item.Value), Active=$($item.Active)"
}

# 使用 LINQ 聚合计算
$allValues = [System.Linq.Enumerable]::Select(
$testData,
[Func[object, int]]{ param($x) $x.Value }
)

$sum = [System.Linq.Enumerable]::Sum($allValues)
$avg = [System.Linq.Enumerable]::Average(
[System.Linq.Enumerable]::Select($testData, [Func[object, int]]{ param($x) $x.Value })
)
$min = [System.Linq.Enumerable]::Min($allValues)
$max = [System.Linq.Enumerable]::Max($allValues)

Write-Host ""
Write-Host "=== 聚合统计 ==="
Write-Host " 总和: $sum"
Write-Host " 平均值: $([math]::Round($avg, 2))"
Write-Host " 最小值: $min"
Write-Host " 最大值: $max"
Write-Host " 记录数: $($testData.Count)"

这段代码展示了 LINQ 的链式调用风格。OrderByDescending 返回一个有序序列,Take 从中截取前 N 条。聚合方法 SumAverageMinMax 可以直接对数值集合进行计算,无需创建中间的 Measure-Object 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=== Value 最高的 10 条记录 ===
Id=96827, Name=Item_96827, Value=10000, Active=True
Id=73614, Name=Item_73614, Value=10000, Active=False
Id=40291, Name=Item_40291, Value=10000, Active=True
Id=21983, Name=Item_21983, Value=10000, Active=True
Id=56802, Name=Item_56802, Value=9999, Active=False
Id=88451, Name=Item_88451, Value=9999, Active=True
Id=14567, Name=Item_14567, Value=9999, Active=True
Id=63290, Name=Item_63290, Value=9999, Active=True
Id=37148, Name=Item_37148, Value=9998, Active=False
Id=79205, Name=Item_79205, Value=9998, Active=True

=== 聚合统计 ===
总和: 500312847
平均值: 5003.13
最小值: 1
最大值: 10000
记录数: 100000

LINQ 分组与字典转换

在数据分析中,按字段分组统计是核心操作。PowerShell 的 Group-Object 可以完成分组,但 LINQ 的 GroupBy 配合 ToDictionary 在性能和灵活性上更胜一筹。下面的示例演示了按 Active 状态分组统计,以及将列表转换为字典以实现 O(1) 的查找性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 按Active状态分组统计
$grouped = [System.Linq.Enumerable]::GroupBy(
$testData,
[Func[object, bool]]{ param($x) $x.Active }
)

Write-Host "=== 按 Active 状态分组统计 ==="
foreach ($group in $grouped) {
$groupValues = [System.Linq.Enumerable]::Select(
$group,
[Func[object, int]]{ param($x) $x.Value }
)
$count = [System.Linq.Enumerable]::Count($group)
$avgVal = [math]::Round([System.Linq.Enumerable]::Average($groupValues), 2)
Write-Host " Active=$($group.Key): 共 $count 条,平均值=$avgVal"
}

# 将数据转为字典,以 Id 为键实现快速查找
$dict = [System.Linq.Enumerable]::ToDictionary(
$testData,
[Func[object, int]]{ param($x) $x.Id },
[Func[object, object]]{ param($x) $x }
)

Write-Host ""
Write-Host "=== 字典查找演示 ==="
$lookupIds = @(42, 99999, 50000, 7)
foreach ($lid in $lookupIds) {
if ($dict.ContainsKey($lid)) {
$found = $dict[$lid]
Write-Host " 查找 Id=$lid -> Name=$($found.Name), Value=$($found.Value)"
} else {
Write-Host " 查找 Id=$lid -> 未找到"
}
}

GroupBy 返回的是 IGrouping 对象的集合,每个分组有一个 Key 属性和一组属于该分组的元素。ToDictionary 将集合转换为 Dictionary<TKey, TValue>,后续通过键查找的时间复杂度为 O(1),比 Where-Object 的线性扫描快得多。

1
2
3
4
5
6
7
8
9
=== 按 Active 状态分组统计 ===
Active=True: 共 49963 条,平均值=5008.74
Active=False: 共 50037 条,平均值=4997.52

=== 字典查找演示 ===
查找 Id=42 -> Name=Item_42, Value=6789
查找 Id=99999 -> Name=Item_99999, Value=2345
查找 Id=50000 -> Name=Item_50000, Value=8901
查找 Id=7 -> Name=Item_7, Value=1234

综合实战:日志数据分析

将前面的 LINQ 操作组合起来,可以构建一个高效的日志分析脚本。以下示例模拟了一批服务器日志数据,使用 LINQ 完成多维度分析。

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
# 模拟服务器日志数据
$logEntries = [System.Collections.Generic.List[PSObject]]::new()
$levels = @("INFO", "WARN", "ERROR", "DEBUG")
$servers = @("WEB-01", "WEB-02", "API-01", "DB-01", "CACHE-01")
$logRng = [System.Random]::new(123)

$baseTime = [DateTime]::new(2025, 10, 23, 0, 0, 0)
foreach ($i in 1..50000) {
$logEntries.Add([PSCustomObject]@{
Timestamp = $baseTime.AddSeconds($logRng.Next(0, 86400))
Level = $levels[$logRng.Next($levels.Length)]
Server = $servers[$logRng.Next($servers.Length)]
Message = "Request processed in $($logRng.Next(1, 5000))ms"
RequestId = "REQ-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
})
}

Write-Host "日志条数: $($logEntries.Count)"
Write-Host ""

# 1. 按日志级别分组统计
$byLevel = [System.Linq.Enumerable]::GroupBy(
$logEntries,
[Func[object, string]]{ param($x) $x.Level }
)

Write-Host "=== 按日志级别统计 ==="
foreach ($g in $byLevel) {
$cnt = [System.Linq.Enumerable]::Count($g)
$pct = [math]::Round($cnt / $logEntries.Count * 100, 1)
Write-Host " $($g.Key): $cnt 条 ($pct%)"
}

# 2. 按服务器分组,找出每个服务器 ERROR 数量
Write-Host ""
Write-Host "=== 各服务器 ERROR 统计 ==="
$errorEntries = [System.Linq.Enumerable]::Where(
$logEntries,
[Func[object, bool]]{ param($x) $x.Level -eq "ERROR" }
)
$byServer = [System.Linq.Enumerable]::GroupBy(
$errorEntries,
[Func[object, string]]{ param($x) $x.Server }
)
foreach ($g in $byServer) {
$errorCount = [System.Linq.Enumerable]::Count($g)
Write-Host " $($g.Key): $errorCount 个 ERROR"
}

# 3. 使用 LINQ Distinct 获取所有唯一 RequestId 的数量
$allReqIds = [System.Linq.Enumerable]::Select(
$logEntries,
[Func[object, string]]{ param($x) $x.RequestId }
)
$uniqueCount = [System.Linq.Enumerable]::Count(
[System.Linq.Enumerable]::Distinct($allReqIds)
)
Write-Host ""
Write-Host "唯一 RequestId 数量: $uniqueCount(总条目: $($logEntries.Count))"

这个综合示例演示了 LINQ 的实际工程应用:从日志过滤(Where)、分组聚合(GroupBy)、去重统计(Distinct)到快速查找(ToDictionary),覆盖了日志分析的核心需求。5 万条日志的分析在毫秒级内即可完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
日志条数: 50000

=== 按日志级别统计 ===
INFO: 12543 条 (25.1%)
WARN: 12487 条 (25.0%)
ERROR: 12462 条 (24.9%)
DEBUG: 12508 条 (25.0%)

=== 各服务器 ERROR 统计 ===
WEB-01: 2512 个 ERROR
WEB-02: 2487 个 ERROR
API-01: 2498 个 ERROR
DB-01: 2478 个 ERROR
CACHE-01: 2487 个 ERROR

唯一 RequestId 数量: 50000(总条目: 50000)

注意事项

  1. 委托类型必须匹配:LINQ 方法要求传入强类型的委托(如 [Func[object, bool]]),PowerShell 的脚本块不会自动转换。务必显式指定委托类型,否则会抛出方法重载解析失败的异常。这也是 LINQ 在 PowerShell 中语法相对冗长的根本原因。

  2. 延迟执行与物化:LINQ 的 WhereSelectOrderBy 等方法返回的是 IEnumerable 延迟序列,数据在遍历时才真正处理。如果需要多次使用结果,应调用 ToList()ToArray() 进行物化,避免重复计算。

  3. 小数据集不必用 LINQ:当数据量在几百条以内时,PowerShell 原生的 Where-ObjectGroup-Object 性能已经足够好,代码可读性反而更好。LINQ 的优势在万级以上数据集才显著体现。不要为了用 LINQ 而用 LINQ。

  4. 类型转换是性能关键:LINQ 的 SumAverage 等数值方法需要特定类型的集合(如 int[]double[])。对于 PSObject 集合,需要先用 Select 提取数值字段再聚合。如果数据源本身就是强类型数组,性能会更好。

  5. PowerShell 7 的改进:在 PowerShell 7 中,可以通过 using namespace System.Linq 简化调用,也可以用 ::new() 直接创建泛型委托。此外,PowerShell 7 的核心是基于 .NET 6/8,LINQ 方法的行为与 C# 完全一致,不必担心兼容性问题。

  6. 避免在管道中使用 LINQ:LINQ 和 PowerShell 管道是两种不同的编程范式。在同一个脚本中应选择一种方式为主:要么全用管道,要么全用 LINQ + foreach 循环。混合使用会导致代码风格不一致,增加维护难度,且无法获得最佳性能。

PowerShell 技能连载 - Azure Key Vault 密钥管理

适用于 PowerShell 5.1 及以上版本

背景引入

在现代云原生应用的运维与开发中,密钥管理是安全体系中不可或缺的一环。数据库连接字符串、API 密钥、证书私钥等敏感信息如果以明文形式存储在代码仓库或配置文件中,一旦泄露就会造成严重的安全事故。Azure Key Vault 正是微软 Azure 平台提供的集中式密钥管理服务,它可以安全地存储和管理密钥(Key)、机密(Secret)及证书(Certificate)。

对于 PowerShell 用户而言,通过 Az.KeyVault 模块可以方便地完成 Key Vault 的创建、密钥的读写、访问策略的配置等操作。无论是日常运维脚本中拉取数据库密码,还是在 CI/CD 流水线中注入签名证书,PowerShell 都提供了简洁高效的 cmdlet 来实现这些需求。

本文将围绕实际场景,演示如何使用 PowerShell 完成 Azure Key Vault 的创建、Secret 的增删改查、批量操作以及访问权限控制。

前置准备

在开始操作 Key Vault 之前,需要确保已安装 Azure PowerShell 模块并完成登录认证。

1
2
3
4
5
6
7
8
# 安装 Az 模块(如果尚未安装)
Install-Module -Name Az -Repository PSGallery -Scope CurrentUser -Force

# 登录 Azure 账户(会弹出浏览器窗口进行交互式认证)
Connect-AzAccount

# 查看当前订阅信息,确认上下文正确
Get-AzContext | Select-Object Account, Subscription, Tenant

登录成功后,终端会显示当前账户的订阅和租户信息,确认是你期望操作的 Azure 订阅即可继续。

1
2
3
Account        SubscriptionName   TenantId
------- ---------------- --------
user@demo.com MySubscription xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

创建 Key Vault

Azure Key Vault 需要一个全局唯一的名称,并且必须指定资源组和所在区域。下面通过 PowerShell 一键完成创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 定义变量
$resourceGroupName = "rg-keyvault-demo"
$location = "EastAsia"
$vaultName = "kv-demo-$(Get-Random -Minimum 1000 -Maximum 9999)"

# 创建资源组(如果不存在)
New-AzResourceGroup -Name $resourceGroupName -Location $location -Force

# 创建 Key Vault
# 启用软删除(Soft Delete)和清除保护(Purge Protection)以增强安全性
$newVaultSplat = @{
VaultName = $vaultName
ResourceGroupName = $resourceGroupName
Location = $location
EnableSoftDelete = $true
EnablePurgeProtection = $true
Sku = "Standard"
}
$vault = New-AzKeyVault @newVaultSplat

Write-Host "Key Vault 创建成功: $($vault.VaultName)"
Write-Host "Vault URI: $($vault.VaultUri)"

这段脚本首先创建了一个资源组作为容器,然后在其中创建 Key Vault。EnableSoftDelete 参数确保删除的密钥可以在保留期内恢复,EnablePurgeProtection 则防止密钥被立即永久清除。

1
2
Key Vault 创建成功: kv-demo-7421
Vault URI: https://kv-demo-7421.vault.azure.net/

管理机密(Secret)

Key Vault 最常见的用途就是存储和管理机密信息。下面演示如何创建、读取、更新和删除 Secret。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 创建一个数据库连接字符串作为 Secret
$secretValue = ConvertTo-SecureString -String "Server=myserver.database.windows.net;Database=mydb;User=admin;Password=P@ssw0rd!" -AsPlainText -Force

$setSecretSplat = @{
VaultName = $vaultName
Name = "DatabaseConnectionString"
SecretValue = $secretValue
ContentType = "text/plain"
Tag = @{ Environment = "Production"; Owner = "DBA-Team" }
}
$secret = Set-AzKeyVaultSecret @setSecretSplat

Write-Host "Secret 创建成功"
Write-Host "名称: $($secret.Name)"
Write-Host "版本: $($secret.Version)"
Write-Host "过期时间: $($secret.Expires)"

# 读取 Secret 的值
$retrievedSecret = Get-AzKeyVaultSecret -VaultName $vaultName -Name "DatabaseConnectionString"
$plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($retrievedSecret.SecretValue)
)
Write-Host "读取到的连接字符串: $plainText"

注意,Key Vault 返回的 Secret 值始终是 SecureString 类型。如果你需要在脚本中获取明文值(例如传递给数据库驱动),需要通过 Marshal 类进行转换。在实际生产环境中,应尽量避免将明文输出到控制台。

1
2
3
4
5
6
Secret 创建成功
名称: DatabaseConnectionString
版本: 1a2b3c4d5e6f7890abcdef1234567890
过期时间:

读取到的连接字符串: Server=myserver.database.windows.net;Database=mydb;User=admin;Password=P@ssw0rd!

批量管理 Secret

在真实项目中,我们往往需要一次性管理多个配置项。例如将应用的所有环境变量从本地配置文件批量导入到 Key Vault 中。下面演示一个批量写入和批量读取的完整流程。

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
# 定义多个配置项(模拟从配置文件读取)
$configItems = @(
@{ Name = "App-Api-Key"; Value = "sk-api-20251023-abcdefg123456" }
@{ Name = "App-Jwt-Secret"; Value = "my-super-secret-jwt-key-2025" }
@{ Name = "App-Redis-Connection"; Value = "redis://cache.redis.cache.windows.net:6380,password=xxx" }
@{ Name = "App-Storage-Key"; Value = "storageAccountAccessKeyXYZ789" }
@{ Name = "App-SendGrid-Key"; Value = "SG.sendgridapikey123456" }
)

# 批量写入 Key Vault
foreach ($item in $configItems) {
$secureValue = ConvertTo-SecureString -String $item.Value -AsPlainText -Force

$setSplat = @{
VaultName = $vaultName
Name = $item.Name
SecretValue = $secureValue
}
Set-AzKeyVaultSecret @setSplat | Out-Null
Write-Host "已写入 Secret: $($item.Name)"
}

Write-Host "`n批量写入完成,共 $($configItems.Count) 个 Secret"

# 批量读取并汇总
Write-Host "`n--- 当前 Key Vault 中的所有 Secret ---"
$allSecrets = Get-AzKeyVaultSecret -VaultName $vaultName

foreach ($s in $allSecrets) {
$detail = Get-AzKeyVaultSecret -VaultName $vaultName -Name $s.Name
$tagsStr = ""
if ($detail.Tags) {
$tagParts = @()
foreach ($key in $detail.Tags.Keys) {
$tagParts += "$key=$($detail.Tags[$key])"
}
$tagsStr = $tagParts -join ", "
}
Write-Host ("{0,-30} 创建于: {1}" -f $s.Name, $detail.Created.ToString("yyyy-MM-dd HH:mm"))
}

这段代码首先定义了一个配置数组,然后通过 foreach 循环逐个写入 Key Vault。读取时先获取所有 Secret 的列表,再逐个获取详细信息。这种方式适合在应用部署流水线中做配置初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
已写入 Secret: App-Api-Key
已写入 Secret: App-Jwt-Secret
已写入 Secret: App-Redis-Connection
已写入 Secret: App-Storage-Key
已写入 Secret: App-SendGrid-Key

批量写入完成,共 5 个 Secret

--- 当前 Key Vault 中的所有 Secret ---
DatabaseConnectionString 创建于: 2025-10-23 08:15
App-Api-Key 创建于: 2025-10-23 08:16
App-Jwt-Secret 创建于: 2025-10-23 08:16
App-Redis-Connection 创建于: 2025-10-23 08:16
App-Storage-Key 创建于: 2025-10-23 08:16
App-SendGrid-Key 创建于: 2025-10-23 08:16

配置访问策略

Key Vault 默认采用访问策略(Access Policy)模型来控制谁可以执行什么操作。创建者自动拥有全部权限,但对于团队协作场景,需要精确地为不同角色分配最小权限。

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
# 获取当前用户或服务主体的对象 ID
$currentUser = Get-AzADUser -SignedIn

# 为当前用户设置只读权限(Get 和 List)
$readOnlySplat = @{
VaultName = $vaultName
UserPrincipalName = $currentUser.UserPrincipalName
PermissionsToSecrets = "Get", "List"
}
Set-AzKeyVaultAccessPolicy @readOnlySplat

Write-Host "已为用户 $($currentUser.UserPrincipalName) 设置只读权限"

# 为另一个团队成员设置完整 Secret 权限
$devOpsUpn = "devops@demo.com"
$devOpsSplat = @{
VaultName = $vaultName
UserPrincipalName = $devOpsUpn
PermissionsToSecrets = "Get", "List", "Set", "Delete", "Recover", "Backup", "Restore", "Purge"
}
Set-AzKeyVaultAccessPolicy @devOpsSplat

Write-Host "已为用户 $devOpsUpn 设置完整 Secret 管理权限"

# 查看当前所有访问策略
$vault = Get-AzKeyVault -VaultName $vaultName
Write-Host "`n--- 当前访问策略 ---"
foreach ($policy in $vault.AccessPolicies) {
$secrets = $policy.PermissionsToSecrets -join ", "
Write-Host ("对象ID: {0} | Secret权限: {1}" -f $policy.ObjectId, $secrets)
}

在实际项目中,建议遵循最小权限原则:运维人员只授予 GetList 权限,密钥管理员才授予 SetDelete 等写入权限。对于应用程序,可以使用托管标识(Managed Identity)代替服务主体,避免在代码中硬编码凭据。

1
2
3
4
5
6
7
已为用户 user@demo.com 设置只读权限
已为用户 devops@demo.com 设置完整 Secret 管理权限

--- 当前访问策略 ---
对象ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | Secret权限: Get, List, Set, Delete, Recover, Backup, Restore, Purge
对象ID: yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy | Secret权限: Get, List
对象ID: zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz | Secret权限: Get, List, Set, Delete, Recover, Backup, Restore, Purge

设置 Secret 过期与版本管理

Key Vault 支持 Secret 的自动过期和版本管理。当密钥需要定期轮换时(例如每 90 天更换一次 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
# 为已有的 Secret 设置过期时间
$expireDate = (Get-Date).AddDays(90)

$secretValue = ConvertTo-SecureString -String "sk-new-api-key-$(Get-Random -Minimum 100000 -Maximum 999999)" -AsPlainText -Force

$setExpiringSplat = @{
VaultName = $vaultName
Name = "App-Api-Key"
SecretValue = $secretValue
Expires = $expireDate
ContentType = "text/plain"
Tag = @{ RotatedBy = "PowerShell-Script"; RotationCycle = "90-days" }
}
$newVersion = Set-AzKeyVaultSecret @setExpiringSplat

Write-Host "Secret 已更新,新版本过期时间: $($newVersion.Expires.ToString('yyyy-MM-dd HH:mm'))"

# 查看某个 Secret 的所有历史版本
$allVersions = Get-AzKeyVaultSecret -VaultName $vaultName -Name "App-Api-Key" -IncludeVersions

Write-Host "`n--- App-Api-Key 版本历史 ---"
foreach ($ver in $allVersions) {
$status = if ($ver.Expires -and $ver.Expires -lt (Get-Date)) { "已过期" } else { "有效" }
Write-Host ("版本: {0} | 状态: {1} | 创建: {2}" -f $ver.Version.Substring(0, 8), $status, $ver.Created.ToString("yyyy-MM-dd HH:mm"))
}

每次对同一个 Secret 名称调用 Set-AzKeyVaultSecret,都会生成一个新版本。旧版本不会被自动删除,你可以随时回滚到之前的版本。这在密钥轮换出现问题时非常有用。

1
2
3
4
5
Secret 已更新,新版本过期时间: 2026-01-21 08:30

--- App-Api-Key 版本历史 ---
版本: 1a2b3c4d | 状态: 有效 | 创建: 2025-10-23 08:16
版本: 5e6f7g8h | 状态: 有效 | 创建: 2025-10-23 08:30

注意事项

  1. 启用软删除和清除保护:创建 Key Vault 时务必启用 SoftDeletePurgeProtection。软删除允许在保留期内(默认 90 天)恢复误删的 Secret,清除保护则防止恶意永久删除。这两项是生产环境的最低安全要求。

  2. 避免在日志中泄露明文:Key Vault 返回的 Secret 值是 SecureString 类型,在脚本中读取后切勿通过 Write-Host 或日志输出明文。如果确实需要调试,可以在开发环境使用 Marshal 转换,但在生产脚本中应删除此类代码。

  3. 使用托管标识代替硬编码凭据:在 Azure VM、App Service 或 AKS 上运行的应用,应使用系统分配或用户分配的托管标识(Managed Identity)访问 Key Vault,避免在代码或环境变量中存储服务主体密码。

  4. 遵循最小权限原则:为每个用户和服务主体仅授予其所需的最小权限集合。只读应用只需 GetList,只有密钥管理员才需要 SetDelete 权限。定期审计访问策略,移除不再需要的权限。

  5. 定期轮换密钥:为所有 Secret 设置合理的过期时间(如 30 天到 90 天),并建立自动轮换流程。Key Vault 支持通过 Event Grid 事件触发 Azure Function 来实现密钥的自动轮换,减少人工介入。

  6. 网络隔离与防火墙:对于高安全要求的场景,可以配置 Key Vault 的网络防火墙规则,仅允许特定虚拟网络的流量访问。结合 Private Endpoint 可以确保密钥数据不会经过公共互联网。

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

适用于 PowerShell 7.0 及以上版本

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

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

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

连接 Grafana 并获取仪表板列表

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Grafana 连接配置
$grafanaConfig = @{
BaseUrl = "http://localhost:3000"
User = "admin"
Password = "admin"
}

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

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

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

$dashboards | Format-Table -AutoSize

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

执行结果示例:

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

创建新的 Grafana 仪表板

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

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

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

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

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

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

执行结果示例:

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

批量导出并备份仪表板

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

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

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

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

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

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

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

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

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

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

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

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

执行结果示例:

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

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

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

跨实例同步仪表板配置

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 定义源和目标 Grafana 实例
$sourceConfig = @{
BaseUrl = "http://grafana-dev.internal:3000"
User = "admin"
Password = "dev-admin-pass"
}

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

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

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

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

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

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

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

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

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

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

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

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

执行结果示例:

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

同步结果:

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

注意事项

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

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

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

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

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

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

PowerShell 技能连载 - 条件访问策略管理

适用于 PowerShell 5.1 及以上版本

在混合办公和零信任架构日益普及的今天,条件访问(Conditional Access)已成为 Microsoft Entra ID(原 Azure AD)中最核心的安全控制手段之一。通过条件访问策略,管理员可以根据用户位置、设备状态、风险等级等信号,动态决定是否允许访问特定资源。然而,随着策略数量增长,手动管理门户中的数十条策略变得极其低效且容易出错。

PowerShell 与 Microsoft Graph API 的结合为条件访问策略的管理提供了自动化能力。无论是批量审计现有策略、快速创建标准化的安全基线策略,还是在紧急安全事件中快速调整策略状态,脚本化操作都比手动点击门户界面更可靠、更快速。特别是在多租户环境下,统一的脚本可以帮助安全团队确保所有租户的策略配置保持一致。

本文将介绍如何使用 PowerShell 通过 Microsoft Graph API 查询、创建、更新和报告条件访问策略,帮助你在日常运维和安全运营中提升效率。

连接 Microsoft Graph 并获取现有策略

操作条件访问策略需要 Policy.Read.AllPolicy.ReadWrite.ConditionalAccess 等权限。以下代码展示了如何连接 Graph 并列出所有现有策略的关键信息。

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
# 连接 Microsoft Graph,请求条件访问策略所需权限
Connect-MgGraph -Scopes `
"Policy.Read.All", `
"Policy.ReadWrite.ConditionalAccess", `
"Agreement.Read.All"

# 获取所有条件访问策略
$allPolicies = Get-MgIdentityConditionalAccessPolicy

# 输出策略摘要
$summary = foreach ($policy in $allPolicies) {
[PSCustomObject]@{
名称 = $policy.DisplayName
状态 = $policy.State
创建时间 = $policy.CreatedDateTime.ToString("yyyy-MM-dd")
修改时间 = $policy.ModifiedDateTime.ToString("yyyy-MM-dd")
包含用户 = if ($policy.Conditions.Users.IncludeUsers -contains "All") {
"所有用户"
} else {
"$($policy.Conditions.Users.IncludeUsers.Count) 个用户/组"
}
}
}

$summary | Format-Table -AutoSize

上述脚本首先连接到 Microsoft Graph 并声明必要的权限范围。然后使用 Get-MgIdentityConditionalAccessPolicy cmdlet 拉取所有条件访问策略,并通过 foreach 循环提取关键信息生成摘要对象。注意判断 IncludeUsers 是否包含 “All” 来显示友好的用户范围描述。

执行结果示例:

1
2
3
4
5
6
7
名称                                  状态     创建时间   修改时间   包含用户
---- ---- -------- -------- --------
要求所有用户使用 MFA enabled 2024-03-15 2025-09-20 所有用户
阻止来自高风险国家的登录 enabled 2024-06-01 2025-08-12 所有用户
要求管理员使用合规设备 enabled 2024-08-20 2025-10-01 3 个用户/组
仅限批准的客户端应用 enabled 2025-01-10 2025-01-10 所有用户
标记为报告专用的新设备策略 enabled forReportingButNotEnforced 2025-10-15 2025-10-15 所有用户

创建条件访问策略

创建策略时需要构造条件访问策略的完整参数对象,包括条件(Conditions)和访问控制(GrantControls)。以下示例创建一条要求特定组用户必须使用 MFA 的策略。

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
# 定义目标组和排除组
$targetGroupId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
$excludeGroupId = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"

# 构建策略参数
$newPolicyParams = @{
DisplayName = "要求运维组使用 MFA - 自动创建"
State = "enabledForReportingButNotEnforced"
Conditions = @{
Applications = @{
IncludeApplications = @("All")
}
Users = @{
IncludeUsers = @("None")
IncludeGroups = @($targetGroupId)
ExcludeGroups = @($excludeGroupId)
}
ClientAppTypes = @("browser", "mobileAppsAndDesktopClients")
Locations = @{
IncludeLocations = @("All")
}
}
GrantControls = @{
Operator = "OR"
BuiltInControls = @("mfa")
}
}

# 创建策略,初始设为仅报告模式以避免影响生产环境
$newPolicy = New-MgIdentityConditionalAccessPolicy -BodyParameter $newPolicyParams

Write-Host "策略已创建,ID: $($newPolicy.Id)"
Write-Host "当前状态: $($newPolicy.State)(仅报告模式)"
Write-Host "请在验证后手动切换为 'enabled'"

这个示例有几个值得注意的设计决策。首先,策略初始状态设为 enabledForReportingButNotEnforced(仅报告模式),这样在确认策略不会产生意外阻断之前,不会影响用户正常访问。其次,IncludeApplications 设为 @("All") 表示该策略应用于所有云应用。最后,GrantControls 使用 OR 操作符和 mfa 内置控制,表示只要满足 MFA 要求即可放行。

执行结果示例:

1
2
3
策略已创建,ID: abc12345-6789-def0-1234-567890abcdef
当前状态: enabledForReportingButNotEnforced(仅报告模式)
请在验证后手动切换为 'enabled'

批量切换策略状态

在安全事件响应场景中,可能需要快速启用或禁用一批条件访问策略。以下脚本演示如何按名称模式批量切换策略状态,并记录操作日志。

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
# 定义要切换的策略名称关键词和目标状态
$namePattern = "高风险"
$targetState = "disabled"

# 获取匹配的策略
$matchedPolicies = Get-MgIdentityConditionalAccessPolicy | `
Where-Object { $_.DisplayName -like "*$namePattern*" }

if ($matchedPolicies.Count -eq 0) {
Write-Host "未找到匹配 '$namePattern' 的策略"
return
}

Write-Host "找到 $($matchedPolicies.Count) 条匹配策略:"

# 记录操作日志
$logEntries = foreach ($policy in $matchedPolicies) {
$previousState = $policy.State

Write-Host (" 切换: {0} ({1} -> {2})" -f `
$policy.DisplayName, $previousState, $targetState)

# 更新策略状态
Update-MgIdentityConditionalAccessPolicy `
-ConditionalAccessPolicyId $policy.Id `
-BodyParameter @{ State = $targetState }

# 构建日志记录
[PSCustomObject]@{
操作时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
策略ID = $policy.Id
策略名称 = $policy.DisplayName
原状态 = $previousState
新状态 = $targetState
操作人 = "自动化脚本"
}
}

# 导出操作日志到 CSV
$logPath = "ConditionalAccess_ChangeLog_{0:yyyyMMdd_HHmmss}.csv" -f (Get-Date)
$logEntries | Export-Csv -Path $logPath -NoTypeInformation -Encoding UTF8
Write-Host "`n操作日志已保存至: $logPath"

这段代码的核心逻辑是先按名称关键词筛选策略,然后逐一切换状态。每次操作都记录到对象数组中,最终导出为带时间戳的 CSV 文件。这种日志记录方式在安全审计中非常重要,可以追溯每次策略变更的详细上下文。使用 foreach 而非管道中的 ForEach-Object,使代码逻辑更清晰且方便调试。

执行结果示例:

1
2
3
4
5
找到 2 条匹配策略:
切换: 阻止来自高风险国家的登录 (enabled -> disabled)
切换: 高风险会话要求重新认证 (enabled -> disabled)

操作日志已保存至: ConditionalAccess_ChangeLog_20251021_143022.csv

生成条件访问策略合规报告

定期审计条件访问策略的配置是否符合安全基线要求是安全运营的重要环节。以下脚本生成一份包含策略详情和合规性检查的 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
# 定义合规检查规则
$complianceRules = @{
MustHaveMFAPolicy = "必须有一条针对所有用户的 MFA 策略"
MustBlockLegacyAuth = "必须阻止旧版身份验证协议"
MustHaveDeviceCompliance = "建议包含设备合规性检查策略"
}

# 获取所有策略并分析
$allPolicies = Get-MgIdentityConditionalAccessPolicy

$reportData = foreach ($policy in $allPolicies) {
$conditions = $policy.Conditions
$grantControls = $policy.GrantControls

# 提取关键属性用于分析
$appliesToAllUsers = $conditions.Users.IncludeUsers -contains "All"
$requiresMFA = $grantControls.BuiltInControls -contains "mfa"
$blocksAccess = $grantControls.BuiltInControls -contains "block"
$clientAppTypes = $conditions.ClientAppTypes -join ", "

[PSCustomObject]@{
策略名称 = $policy.DisplayName
状态 = $policy.State
应用于所有用户 = if ($appliesToAllUsers) { "是" } else { "否" }
要求MFA = if ($requiresMFA) { "是" } else { "否" }
阻止访问 = if ($blocksAccess) { "是" } else { "否" }
客户端类型 = $clientAppTypes
}
}

# 合规性检查
$hasEnabledMFA = $allPolicies | Where-Object {
$_.State -eq "enabled" -and
$_.Conditions.Users.IncludeUsers -contains "All" -and
$_.GrantControls.BuiltInControls -contains "mfa"
}

$hasBlockLegacy = $allPolicies | Where-Object {
$_.State -eq "enabled" -and
$_.Conditions.ClientAppTypes -contains "exchangeActiveSyncClients" -and
$_.GrantControls.BuiltInControls -contains "block"
}

Write-Host "=== 条件访问策略合规报告 ==="
Write-Host ("报告生成时间: {0}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"))
Write-Host ("策略总数: {0}" -f $allPolicies.Count)
Write-Host ""
Write-Host ("[合规检查] 全局 MFA 策略: {0}" -f $(if ($hasEnabledMFA) { "PASS" } else { "FAIL" }))
Write-Host ("[合规检查] 阻止旧版认证: {0}" -f $(if ($hasBlockLegacy) { "PASS" } else { "FAIL" }))
Write-Host ""
Write-Host "策略明细:"
$reportData | Format-Table -AutoSize

这个脚本实现了两层逻辑。第一层通过 foreach 循环为每条策略生成详细属性记录,用于展示策略的配置细节。第二层通过 Where-Object 进行合规性检查,判断是否存在启用的全局 MFA 策略和旧版认证阻止策略。这种结构化的报告方式可以帮助安全团队快速识别配置缺口。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=== 条件访问策略合规报告 ===
报告生成时间: 2025-10-21 14:30:00
策略总数: 5

[合规检查] 全局 MFA 策略: PASS
[合规检查] 阻止旧版认证: FAIL

策略明细:

策略名称 状态 应用于所有用户 要求MFA 阻止访问 客户端类型
-------- ---- -------------- ------- -------- ----------
要求所有用户使用 MFA enabled 是 是 否 browser, mobileAppsAndDesktopClients
阻止来自高风险国家的登录 enabled 是 否 是 browser, mobileAppsAndDesktopClients
要求管理员使用合规设备 enabled 否 否 否 browser, mobileAppsAndDesktopClients
仅限批准的客户端应用 enabled 是 否 否 browser, mobileAppsAndDesktopClients
标记为报告专用的新设备策略 enabled 否 否 否 browser, mobileAppsAndDesktopClients

注意事项

  1. 权限要求:管理条件访问策略需要 Entra ID 中的条件访问管理员或安全管理员角色。使用 Connect-MgGraph 时,确保请求了 Policy.ReadWrite.ConditionalAccess 权限,且管理员已在门户中同意该权限。

  2. 先报告后启用:新建策略时务必先将 State 设为 enabledForReportingButNotEnforced(仅报告模式),在日志中确认策略不会误阻断正常用户后,再切换为 enabled。误启用的策略可能导致大面积锁定。

  3. 策略冲突检测:多条策略之间可能产生冲突或叠加效果。例如一条策略要求 MFA,另一条策略要求合规设备,两者同时满足时用户需要完成两种验证。建议在脚本中记录策略间的覆盖关系。

  4. 命名规范:为策略建立统一的命名规范(如 [类别] - [描述]),便于脚本按名称模式筛选和批量操作。避免使用无意义的默认名称如”New Policy”。

  5. 排除紧急访问账户:每条策略都应排除紧急访问(Break Glass)账户,确保在策略配置错误或服务中断时,紧急账户仍能登录进行修复。建议在脚本中加入自动化检查。

  6. 操作日志留存:所有通过脚本执行的策略变更都应记录到外部日志(CSV、数据库或 SIEM),包含操作时间、策略 ID、变更前后状态和操作人信息,以满足安全审计和合规要求。

PowerShell 技能连载 - Pode Web API 开发

适用于 PowerShell 7.0 及以上版本

在日常运维和内部工具开发中,我们经常需要一个轻量级的 HTTP 服务来暴露数据或接收指令。传统做法是搭建 IIS、写 C# Web API 项目,但这对于一个简单的查询接口来说未免过于笨重。Pode 是一个纯 PowerShell 实现的跨平台 Web 框架,它内置了路由、中间件、认证、日志等功能,让你用熟悉的 PowerShell 语法就能快速构建 REST API。

Pode 的典型使用场景包括:为运维脚本提供 HTTP 调用入口、搭建内部微服务接口、接收 Webhook 回调、构建简单的管理仪表盘后端等。本文将从零开始,逐步演示如何用 Pode 搭建一个具备完整 CRUD 功能的任务管理 API。

基础 API 服务搭建

首先安装 Pode 模块并创建一个最基础的服务器,包含健康检查接口和路由组织结构。Pode 使用 Start-PodeServer 作为入口,通过 Add-PodeRoute 注册路由处理逻辑,整体风格类似于 Express.js 或 Flask。

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
# 安装 Pode 模块
Install-Module -Name Pode -Force -Scope CurrentUser

# 创建基础 API 服务器
Start-PodeServer {
# 监听本地 8090 端口
Add-PodeEndpoint -Address localhost -Port 8090 -Protocol Http

# 全局日志中间件 - 记录每个请求的方法、路径和耗时
Add-PodeMiddleware -Name 'RequestLogger' -ScriptBlock {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$WebEvent.Data['__stopwatch'] = $sw
# 继续处理下一个中间件
}

Add-PodeMiddleware -Name 'ResponseTimer' -ScriptBlock {
$sw = $WebEvent.Data['__stopwatch']
if ($sw) {
$sw.Stop()
$elapsed = $sw.ElapsedMilliseconds
Write-PodeHost "[API] $($WebEvent.Method) $($WebEvent.Path) - ${elapsed}ms"
}
}

# GET /api/health - 健康检查端点
Add-PodeRoute -Method Get -Path '/api/health' -ScriptBlock {
Write-PodeJsonResponse -Value @{
status = 'healthy'
timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
version = '1.0.0'
machine = $env:COMPUTERNAME
pwsh = $PSVersionTable.PSVersion.ToString()
}
}

# GET /api/info - 服务器信息
Add-PodeRoute -Method Get -Path '/api/info' -ScriptBlock {
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
Write-PodeJsonResponse -Value @{
computer = $env:COMPUTERNAME
os = if ($os) { $os.Caption } else { 'Unknown' }
psVersion = $PSVersionTable.PSVersion.ToString()
podeVersion = (Get-Module Pode).Version.ToString()
uptime = if ($os) {
[math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1)
} else { 0 }
}
}
}

启动服务器后,用浏览器或 Invoke-RestMethod 访问即可获取响应。

1
2
3
4
5
6
7
PS> Invoke-RestMethod http://localhost:8090/api/health

status : healthy
timestamp : 2025-10-20T08:00:00.123Z
version : 1.0.0
machine : WORKSTATION-01
pwsh : 7.4.6
1
2
3
4
5
6
7
PS> Invoke-RestMethod http://localhost:8090/api/info

computer : WORKSTATION-01
os : Microsoft Windows 11 Pro
psVersion : 7.4.6
podeVersion : 2.12.0
uptime : 15.3

内存数据存储与 CRUD 操作

接下来我们实现一个完整的任务管理 API,使用内存哈希表作为数据存储。Pode 支持 Add-PodeState 来维护服务端状态,这种状态在服务器运行期间持久存在于内存中。我们将实现标准的 RESTful 风格 CRUD 操作。

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

# 初始化内存数据存储
Set-PodeState -Name 'Tasks' -Value @(
@{
Id = 1
Title = '部署生产环境补丁'
Description = '安装 2025 年 10 月安全更新'
Priority = 'High'
Status = 'Pending'
CreatedAt = '2025-10-18T09:00:00'
}
@{
Id = 2
Title = '备份数据库'
Description = '执行每周全量备份'
Priority = 'Medium'
Status = 'Completed'
CreatedAt = '2025-10-17T14:30:00'
}
)

# 设置下一个可用 ID
Set-PodeState -Name 'NextTaskId' -Value 3

# GET /api/tasks - 获取所有任务,支持筛选和分页
Add-PodeRoute -Method Get -Path '/api/tasks' -ScriptBlock {
$tasks = Get-PodeState -Name 'Tasks'
$status = $WebEvent.Query['status']
$priority = $WebEvent.Query['priority']
$limit = if ($WebEvent.Query['limit']) { [int]$WebEvent.Query['limit'] } else { 50 }

# 按条件筛选
$filtered = $tasks
if ($status) {
$filtered = $filtered | Where-Object { $_.Status -eq $status }
}
if ($priority) {
$filtered = $filtered | Where-Object { $_.Priority -eq $priority }
}

# 限制返回数量
$result = $filtered | Select-Object -First $limit

Write-PodeJsonResponse -Value @{
total = $filtered.Count
count = $result.Count
limit = $limit
data = @($result)
}
}

# GET /api/tasks/:id - 获取单个任务
Add-PodeRoute -Method Get -Path '/api/tasks/:id' -ScriptBlock {
$taskId = [int]$WebEvent.Parameters['id']
$tasks = Get-PodeState -Name 'Tasks'
$task = $tasks | Where-Object { $_.Id -eq $taskId } | Select-Object -First 1

if ($task) {
Write-PodeJsonResponse -Value $task
} else {
Set-PodeResponseStatus -Code 404
Write-PodeJsonResponse -Value @{
error = "Task with Id=$taskId not found"
}
}
}

# POST /api/tasks - 创建新任务
Add-PodeRoute -Method Post -Path '/api/tasks' -ScriptBlock {
$body = $WebEvent.Data
$nextId = Get-PodeState -Name 'NextTaskId'
$tasks = Get-PodeState -Name 'Tasks'

$newTask = @{
Id = $nextId
Title = $body.Title
Description = if ($body.Description) { $body.Description } else { '' }
Priority = if ($body.Priority) { $body.Priority } else { 'Medium' }
Status = 'Pending'
CreatedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss')
}

# 添加到列表并更新状态
$tasks = @($tasks) + $newTask
Set-PodeState -Name 'Tasks' -Value $tasks
Set-PodeState -Name 'NextTaskId' -Value ($nextId + 1)

Set-PodeResponseStatus -Code 201
Write-PodeJsonResponse -Value $newTask
}

# PUT /api/tasks/:id - 更新任务
Add-PodeRoute -Method Put -Path '/api/tasks/:id' -ScriptBlock {
$taskId = [int]$WebEvent.Parameters['id']
$body = $WebEvent.Data
$tasks = Get-PodeState -Name 'Tasks'
$found = $false

$updatedTasks = foreach ($t in $tasks) {
if ($t.Id -eq $taskId) {
$found = $true
@{
Id = $t.Id
Title = if ($body.Title) { $body.Title } else { $t.Title }
Description = if ($body.Description) { $body.Description } else { $t.Description }
Priority = if ($body.Priority) { $body.Priority } else { $t.Priority }
Status = if ($body.Status) { $body.Status } else { $t.Status }
CreatedAt = $t.CreatedAt
UpdatedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss')
}
} else {
$t
}
}

if ($found) {
Set-PodeState -Name 'Tasks' -Value @($updatedTasks)
$updated = $updatedTasks | Where-Object { $_.Id -eq $taskId } | Select-Object -First 1
Write-PodeJsonResponse -Value $updated
} else {
Set-PodeResponseStatus -Code 404
Write-PodeJsonResponse -Value @{
error = "Task with Id=$taskId not found"
}
}
}

# DELETE /api/tasks/:id - 删除任务
Add-PodeRoute -Method Delete -Path '/api/tasks/:id' -ScriptBlock {
$taskId = [int]$WebEvent.Parameters['id']
$tasks = Get-PodeState -Name 'Tasks'
$original = $tasks

$remaining = @($tasks | Where-Object { $_.Id -ne $taskId })

if ($remaining.Count -lt $original.Count) {
Set-PodeState -Name 'Tasks' -Value $remaining
Write-PodeJsonResponse -Value @{
message = "Task $taskId deleted"
remaining = $remaining.Count
}
} else {
Set-PodeResponseStatus -Code 404
Write-PodeJsonResponse -Value @{
error = "Task with Id=$taskId not found"
}
}
}
}

以下是调用这些 API 的示例。先创建一条新任务,再查询所有任务列表。

1
2
3
4
5
6
7
8
9
PS> $body = @{ Title = '检查 SSL 证书过期'; Priority = 'High' } | ConvertTo-Json
PS> Invoke-RestMethod -Method Post -Uri http://localhost:8090/api/tasks -Body $body -ContentType 'application/json'

Id : 3
Title : 检查 SSL 证书过期
Description :
Priority : High
Status : Pending
CreatedAt : 2025-10-20T08:15:00
1
2
3
4
5
6
PS> Invoke-RestMethod http://localhost:8090/api/tasks?status=Pending

total : 2
count : 2
limit : 50
data : {@{Id=1; Title=部署生产环境补丁; ...}, @{Id=3; Title=检查 SSL 证书过期; ...}}

JWT 认证与请求验证

在生产环境中,API 需要认证机制来保护敏感操作。Pode 内置了对 JWT (JSON Web Token) 认证的支持,只需要几行配置就能为路由添加安全防护。同时我们还可以利用中间件实现请求体验证,确保提交的数据格式正确。

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

# 配置 JWT 认证 - 使用自定义密钥签名
New-PodeAuthScheme -ApiKey -Location Header -Name 'Authorization' |
Add-PodeAuth -Name 'JwtAuth' -Sessionless -ScriptBlock {
param($token)

# 移除 "Bearer " 前缀
if ($token.StartsWith('Bearer ')) {
$token = $token.Substring(7)
}

try {
# 验证 JWT(此处用对称密钥示例,生产环境应使用证书)
$parts = $token.Split('.')
if ($parts.Count -ne 3) {
return @{ Message = 'Invalid token format' }
}

# 解码 Payload(Base64Url)
$payloadBytes = [System.Convert]::FromBase64String(
$parts[1].Replace('-', '+').Replace('_', '/')
)
$payloadJson = [System.Text.Encoding]::UTF8.GetString($payloadBytes)
$payload = $payloadJson | ConvertFrom-Json

# 检查过期时间
if ($payload.exp -and $payload.exp -lt [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) {
return @{ Message = 'Token expired' }
}

# 认证成功,返回用户信息
return @{
User = @{
Name = $payload.sub
Role = $payload.role
Issue = $payload.iss
}
}
} catch {
return @{ Message = "Token validation failed: $_" }
}
}

# POST /api/auth/login - 登录获取 Token
Add-PodeRoute -Method Post -Path '/api/auth/login' -ScriptBlock {
$body = $WebEvent.Data
$username = $body.Username
$password = $body.Password

# 简化示例 - 生产环境应查询数据库验证
$validUsers = @{
'admin' = @{ Password = 'P@ssw0rd'; Role = 'Admin' }
'reader' = @{ Password = 'Read0nly'; Role = 'Reader' }
}

if (-not $validUsers.ContainsKey($username)) {
Set-PodeResponseStatus -Code 401
Write-PodeJsonResponse -Value @{ error = 'Unknown user' }
return
}

if ($validUsers[$username].Password -ne $password) {
Set-PodeResponseStatus -Code 401
Write-PodeJsonResponse -Value @{ error = 'Invalid password' }
return
}

# 构建 JWT Payload
$header = @{ alg = 'HS256'; typ = 'JWT' } | ConvertTo-Json -Compress
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$payload = @{
sub = $username
role = $validUsers[$username].Role
iss = 'pode-api-server'
iat = $now
exp = $now + 3600
} | ConvertTo-Json -Compress

# Base64Url 编码
$headerB64 = [System.Convert]::ToBase64String(
[System.Text.Encoding]::UTF8.GetBytes($header)
).TrimEnd('=').Replace('+', '-').Replace('/', '_')

$payloadB64 = [System.Convert]::ToBase64String(
[System.Text.Encoding]::UTF8.GetBytes($payload)
).TrimEnd('=').Replace('+', '-').Replace('/', '_')

# 生成签名(简化示例 - 生产环境应使用 HMAC-SHA256)
$secret = 'my-secret-key-2025'
$signInput = "$headerB64.$payloadB64"
$hmac = [System.Security.Cryptography.HMACSHA256]::new(
[System.Text.Encoding]::UTF8.GetBytes($secret)
)
$signatureBytes = $hmac.ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($signInput)
)
$signatureB64 = [System.Convert]::ToBase64String($signatureBytes).
TrimEnd('=').Replace('+', '-').Replace('/', '_')

$jwt = "$headerB64.$payloadB64.$signatureB64"

Write-PodeJsonResponse -Value @{
token = $jwt
tokenType = 'Bearer'
expiresIn = 3600
role = $validUsers[$username].Role
}
}

# 请求体验证中间件
Add-PodeMiddleware -Name 'TaskValidation' -Route '/api/tasks' -ScriptBlock {
if ($WebEvent.Method -in @('Post', 'Put')) {
$body = $WebEvent.Data
$errors = @()

if (-not $body.Title -or $body.Title.Trim().Length -eq 0) {
$errors += 'Title is required'
}
if ($body.Title -and $body.Title.Length -gt 200) {
$errors += 'Title must not exceed 200 characters'
}
if ($body.Priority -and $body.Priority -notin @('Low', 'Medium', 'High', 'Critical')) {
$errors += "Priority must be one of: Low, Medium, High, Critical"
}
if ($body.Status -and $body.Status -notin @('Pending', 'InProgress', 'Completed', 'Cancelled')) {
$errors += "Status must be one of: Pending, InProgress, Completed, Cancelled"
}

if ($errors.Count -gt 0) {
Set-PodeResponseStatus -Code 400
Write-PodeJsonResponse -Value @{
error = 'Validation failed'
detail = $errors
}
# 阻止请求继续传递到路由处理
return $false
}
}
}

# 需要认证的受保护路由 - GET /api/tasks
Add-PodeRoute -Method Get -Path '/api/tasks' -Authentication 'JwtAuth' -ScriptBlock {
$tasks = Get-PodeState -Name 'Tasks'
Write-PodeJsonResponse -Value @{
user = $WebEvent.Auth.User.Name
count = $tasks.Count
data = @($tasks)
}
}
}

下面演示完整的认证流程:先登录获取 Token,再用 Token 访问受保护的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PS> $cred = @{ Username = 'admin'; Password = 'P@ssw0rd' } | ConvertTo-Json
PS> $resp = Invoke-RestMethod -Method Post -Uri http://localhost:8090/api/auth/login -Body $cred -ContentType 'application/json'

token : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...
tokenType : Bearer
expiresIn : 3600
role : Admin

PS> $headers = @{ Authorization = "Bearer $($resp.token)" }
PS> Invoke-RestMethod -Uri http://localhost:8090/api/tasks -Headers $headers

user : admin
count : 3
data : {@{Id=1; Title=部署生产环境补丁; ...}, ...}
1
2
3
PS> # 不带 Token 访问受保护路由会被拒绝
PS> Invoke-RestMethod http://localhost:8090/api/tasks
Invoke-RestMethod: 401 Unauthorized
1
2
3
4
PS> # 提交无效数据会触发验证中间件
PS> $bad = @{ Title = ''; Priority = 'Invalid' } | ConvertTo-Json
PS> Invoke-RestMethod -Method Post -Uri http://localhost:8090/api/tasks -Body $bad -ContentType 'application/json'
Invoke-RestMethod: 400 Bad Request

注意事项

  1. 生产环境应使用 HTTPS:Pode 支持通过 Add-PodeEndpoint -Protocol Https -Certificate 配置 SSL/TLS 证书。在公网部署时务必启用 HTTPS,避免认证凭据和 API 数据以明文传输。

  2. 状态存储不跨重启Set-PodeState 维护的数据存储在内存中,服务器重启后数据丢失。如需持久化,应结合文件、SQLite 或外部数据库。Pode 社区有 Pode.Web 和各种数据库连接器可以配合使用。

  3. 并发与线程模型:Pode 默认使用多线程处理请求(通过 runspace pool),但 Get-PodeState / Set-PodeState 对共享状态的访问已内置线程安全机制。如果直接操作全局变量或文件,需要自行处理并发问题。

  4. JWT 示例仅为演示:本文中的 JWT 实现是简化版本,仅用于说明 Pode 认证流程的工作原理。在实际项目中,应使用成熟的 JWT 库(如 System.IdentityModel.Tokens.Jwt)来生成和验证令牌,并妥善保管签名密钥。

  5. 错误处理应覆盖全面:API 中每个可能失败的操作都应有 try/catch 保护,避免未处理异常导致整个请求线程崩溃。建议在全局中间件中统一捕获异常并返回标准化的错误响应格式。

  6. 跨平台运行:Pode 完全基于 PowerShell 编写,在 Windows、Linux 和 macOS 上均可运行。使用 dotnet 命令或 Docker 容器即可将 Pode API 部署到 Linux 服务器,适合构建轻量级的跨平台运维微服务。

PowerShell 技能连载 - Prometheus 指标采集

适用于 PowerShell 7.0 及以上版本

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

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

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

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

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

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

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

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

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

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

return $lines -join "`n"
}

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

执行结果示例:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[Parameter()]
[hashtable]$Labels
)

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

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

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

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

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

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

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

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

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

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

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

执行结果示例:

1
构建指标已推送到 Pushgateway

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

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

执行结果示例:

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

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

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

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

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

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

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

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

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

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

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

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

return $results
}

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

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

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

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

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

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

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

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

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

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

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

return $results
}

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

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

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

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

执行结果示例:

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

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

注意事项

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

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

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

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

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

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

PowerShell 技能连载 - Ansible 集成

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

在现代混合 IT 环境中,Windows 和 Linux 服务器往往共存于同一基础设施。Ansible 作为无代理(agentless)的配置管理工具,原生支持通过 WinRM 协议管理 Windows 主机,而 PowerShell 正是 Ansible 在 Windows 端执行任务的核心引擎——每个 Ansible 模块在 Windows 上最终都会转化为 PowerShell 脚本执行。

理解 PowerShell 与 Ansible 的集成方式,不仅可以帮助运维团队构建跨平台的自动化流水线,还能在现有 PowerShell 脚本资产的基础上无缝接入 Ansible 生态。本文将从连接配置、自定义模块编写和脚本集成三个层面,展示如何在 PowerShell 环境中高效使用 Ansible。

配置 WinRM 连接

Ansible 通过 WinRM 与 Windows 通信。在开始之前,需要确保目标 Windows 主机的 WinRM 服务已正确配置。微软提供了专门的配置脚本,但生产环境中往往需要更精细的控制。

以下 PowerShell 函数用于配置 WinRM,支持 HTTP 和 HTTPS 两种传输方式,并输出 Ansible inventory 所需的连接参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
function Set-AnsibleWinRM {
<#
.SYNOPSIS
配置 Windows 主机以支持 Ansible 远程管理
.PARAMETER UseHTTPS
是否启用 HTTPS 传输(推荐生产环境使用)
.PARAMETER CertificateThumbprint
指定 HTTPS 证书的指纹,不指定则生成自签名证书
#>
[CmdletBinding()]
param(
[bool]$UseHTTPS = $true,
[string]$CertificateThumbprint
)

# 确保 WinRM 服务运行
$service = Get-Service -Name WinRM -ErrorAction SilentlyContinue
if ($service.Status -ne 'Running') {
Start-Service -Name WinRM
Write-Host "WinRM 服务已启动"
}

# 启用基本认证和 CredSSP(Ansible 常用认证方式)
$winrmPath = 'WSMan:\localhost\Service\Auth'
Set-Item -Path "$winrmPath\Basic" -Value $true -Force
Set-Item -Path "$winrmPath\CredSSP" -Value $true -Force
Write-Host "基本认证和 CredSSP 已启用"

if ($UseHTTPS) {
if (-not $CertificateThumbprint) {
# 创建自签名证书(仅用于测试环境)
$cert = New-SelfSignedCertificate `
-DnsName $env:COMPUTERNAME `
-CertStoreLocation 'Cert:\LocalMachine\My' `
-FriendlyName 'Ansible WinRM HTTPS'
$CertificateThumbprint = $cert.Thumbprint
Write-Host "自签名证书已创建,指纹: $CertificateThumbprint"
}

# 创建 HTTPS 监听器
$existingListener = Get-ChildItem -Path 'WSMan:\localhost\Listener' |
Where-Object { $_.Keys -match 'Transport=HTTPS' }

if ($existingListener) {
Remove-Item -Path "WSMan:\localhost\Listener\$($existingListener.Name)" -Recurse -Force
}

New-Item -Path 'WSMan:\localhost\Listener' -Transport HTTPS -Address * `
-CertificateThumbprint $CertificateThumbprint -Force | Out-Null
Write-Host "HTTPS 监听器已创建"
}

# 配置防火墙规则
$ports = @{ HTTP = 5985; HTTPS = 5986 }
foreach ($proto in @('HTTP', 'HTTPS')) {
$port = $ports[$proto]
$ruleName = "Ansible WinRM $proto"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existingRule) {
New-NetFirewallRule -DisplayName $ruleName `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort $port | Out-Null
Write-Host "防火墙规则已添加: $ruleName (端口 $port)"
}
}

# 输出 Ansible inventory 配置参考
$ansibleUser = "$env:COMPUTERNAME\Administrator"
$ansiblePort = if ($UseHTTPS) { 5986 } else { 5985 }
$ansibleScheme = if ($UseHTTPS) { 'https' } else { 'http' }

Write-Host "`n--- Ansible Inventory 配置参考 ---"
Write-Host "[windows]"
Write-Host "$($env:COMPUTERNAME.ToLower()) ansible_host=<目标IP>"
Write-Host "[windows:vars]"
Write-Host "ansible_user=$ansibleUser"
Write-Host "ansible_password=<密码>"
Write-Host "ansible_connection=winrm"
Write-Host "ansible_winrm_transport=basic"
Write-Host "ansible_winrm_server_cert_validation=ignore"
Write-Host "ansible_port=$ansiblePort"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WinRM 服务已启动
基本认证和 CredSSP 已启用
自签名证书已创建,指纹: A1B2C3D4E5F6789012345678901234567890ABCD
HTTPS 监听器已创建
防火墙规则已添加: Ansible WinRM HTTP (端口 5985)
防火墙规则已添加: Ansible WinRM HTTPS (端口 5986)

--- Ansible Inventory 配置参考 ---
[windows]
winserver01 ansible_host=<目标IP>
[windows:vars]
ansible_user=WINSERVER01\Administrator
ansible_password=<密码>
ansible_connection=winrm
ansible_winrm_transport=basic
ansible_winrm_server_cert_validation=ignore
ansible_port=5986

编写 Ansible 自定义模块

Ansible 的 Windows 模块本质上就是遵循特定输入输出约定的 PowerShell 脚本。当内置模块无法满足需求时,可以编写自定义模块,直接复用已有的 PowerShell 函数和模块。

自定义模块需要遵循 Ansible 的 JSON 通信协议:从标准输入读取 JSON 参数,通过 Exit-Json 返回成功结果,或通过 Fail-Json 返回失败信息。以下示例实现了一个检查 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
#!powershell
# Ansible Windows 自定义模块: win_service_health
# 文件位置: library/win_service_health.ps1

# ANSIBLE_METADATA 块(Ansible 2.10+ 使用 DOCUMENTATION 替代)
$ErrorActionPreference = 'Stop'

# 加载 Ansible 模块工具函数
# 在实际部署中这些函数由 Ansible 自动注入
function Exit-Json {
param([hashtable]$Result)
$Result.ansible_facts = $Result.ansible_facts
$jsonOutput = $Result | ConvertTo-Json -Depth 10 -Compress
Write-Output $jsonOutput
exit 0
}

function Fail-Json {
param([hashtable]$Result, [string]$Message)
$Result.failed = $true
$Result.msg = $Message
$jsonOutput = $Result | ConvertTo-Json -Depth 10 -Compress
Write-Output $jsonOutput
exit 1
}

# 解析模块参数(生产环境使用 Ansible.ModuleUtils)
$params = @{
name = ''
state = 'started'
start_mode = 'auto'
check_only = $false
}

$result = @{
changed = $false
services = @()
}

# 获取目标服务列表
$serviceNames = $params.name -split ',' | ForEach-Object { $_.Trim() }
$allServices = @()

foreach ($svcName in $serviceNames) {
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if (-not $svc) {
Fail-Json -Result $result -Message "服务 '$svcName' 不存在"
}

$wmiSvc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$svcName'"
$serviceInfo = @{
name = $svcName
displayname = $svc.DisplayName
status = $svc.Status.ToString()
starttype = $wmiSvc.StartMode
}

if (-not $params.check_only) {
# 检查启动类型是否符合预期
if ($wmiSvc.StartMode -ne $params.start_mode) {
Set-Service -Name $svcName -StartupType $params.start_mode
$serviceInfo.starttype = $params.start_mode
$result.changed = $true
}

# 检查运行状态是否符合预期
$desiredState = $params.state
if ($desiredState -eq 'started' -and $svc.Status -ne 'Running') {
Start-Service -Name $svcName
$serviceInfo.status = 'Running'
$result.changed = $true
}
elseif ($desiredState -eq 'stopped' -and $svc.Status -ne 'Stopped') {
Stop-Service -Name $svcName -Force
$serviceInfo.status = 'Stopped'
$result.changed = $true
}
}

$allServices += $serviceInfo
}

$result.services = $allServices
$result.msg = "已检查 $($allServices.Count) 个服务"
Exit-Json -Result $result

执行结果示例(Ansible playbook 调用后的 JSON 输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
TASK [检查并修复服务状态] ********************************************************
ok: [winserver01] => {
"changed": true,
"services": [
{
"name": "wuauserv",
"displayname": "Windows Update",
"status": "Running",
"starttype": "auto"
},
{
"name": "WinRM",
"displayname": "Windows Remote Management (WS-Management)",
"status": "Running",
"starttype": "auto"
}
],
"msg": "已检查 2 个服务"
}

将现有 PowerShell 脚本封装为 Ansible Playbook

许多团队已经积累了大量的 PowerShell 运维脚本。通过 Ansible 的 script 模块或 win_shell 模块,可以直接在 playbook 中调用这些脚本,但更推荐的做法是使用 win_task 或结构化的方式封装,以便获得更好的幂等性和错误处理。

以下函数演示如何从一个目录中的 PowerShell 脚本自动生成 Ansible playbook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
function New-AnsiblePlaybookFromScripts {
<#
.SYNOPSIS
从 PowerShell 脚本目录生成 Ansible playbook
.PARAMETER ScriptPath
PowerShell 脚本所在目录
.PARAMETER OutputFile
生成的 playbook 输出路径
.PARAMETER TargetHosts
playbook 的目标主机组名称
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ScriptPath,

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

[string]$TargetHosts = 'windows'
)

$scripts = Get-ChildItem -Path $ScriptPath -Filter '*.ps1' |
Sort-Object -Property Name

if ($scripts.Count -eq 0) {
Write-Warning "目录 $ScriptPath 中没有找到 PowerShell 脚本"
return
}

# 构建 playbook 任务列表
$tasks = @()
foreach ($script in $scripts) {
$scriptName = $script.BaseName
# 从脚本文件头部提取注释作为任务描述
$description = $scriptName
$firstLine = Get-Content -Path $script.FullName -TotalCount 1
if ($firstLine -match '^\s*#\s*(.+)') {
$description = $Matches[1]
}

$task = @{
name = $description
script = $script.FullName
register = "result_$($scriptName -replace '[^a-zA-Z0-9]', '_')"
}
$tasks += $task
Write-Host " 已添加任务: $description ($($script.Name))"
}

# 构建完整的 playbook 结构
$playbook = @(
@{
name = "执行 PowerShell 运维脚本"
hosts = $TargetHosts
gather_facts = $false
tasks = $tasks
}
)

# 生成 YAML 文件(手动构建以控制格式)
$yamlLines = @(
'---'
''
"- name: 执行 PowerShell 运维脚本"
" hosts: $TargetHosts"
' gather_facts: false'
' tasks:'
)

foreach ($script in $scripts) {
$scriptName = $script.BaseName
$firstLine = Get-Content -Path $script.FullName -TotalCount 1
$description = $scriptName
if ($firstLine -match '^\s*#\s*(.+)') {
$description = $Matches[1]
}

$regName = "result_$($scriptName -replace '[^a-zA-Z0-9]', '_')"
$yamlLines += @(
" - name: $description"
" script: scripts/$($script.Name)"
" register: $regName"
''
)
}

Set-Content -Path $OutputFile -Value $yamlLines -Encoding UTF8
Write-Host "`nPlaybook 已生成: $OutputFile"
Write-Host "共包含 $($scripts.Count) 个任务"
}

# 使用示例:从 D:\ops-scripts 目录生成 playbook
# New-AnsiblePlaybookFromScripts `
# -ScriptPath 'D:\ops-scripts' `
# -OutputFile 'site.yml' `
# -TargetHosts 'production_windows'

执行结果示例:

1
2
3
4
5
6
7
8
  已添加任务: 清理临时文件 (01-CleanTempFiles.ps1)
已添加任务: 检查磁盘空间 (02-CheckDiskSpace.ps1)
已添加任务: 更新 Windows (03-InstallUpdates.ps1)
已添加任务: 重启挂起检测 (04-CheckRebootPending.ps1)
已添加任务: 导出系统信息 (05-ExportSystemInfo.ps1)

Playbook 已生成: site.yml
共包含 5 个任务

在 PowerShell 中调用 Ansible Playbook

除了从 Ansible 端管理 Windows,有时也需要从 Windows 主机本身触发 Ansible 操作。例如在 CI/CD 流水线中,由 PowerShell 编排整个部署流程。以下函数封装了通过 WSL 或远程 Linux 跳板机执行 Ansible playbook 的逻辑:

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
function Invoke-AnsiblePlaybook {
<#
.SYNOPSIS
从 Windows 端触发 Ansible playbook 执行
.PARAMETER PlaybookPath
playbook 文件的相对路径或绝对路径
.PARAMETER Inventory
inventory 文件路径
.PARAMETER ExtraVars
额外的变量,以键值对形式传入
.PARAMETER UseWSL
是否通过 WSL 调用 Ansible(默认通过 SSH 到跳板机)
.PARAMETER RemoteHost
Ansible 控制节点的 SSH 地址(不使用 WSL 时必填)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PlaybookPath,

[string]$Inventory = 'inventory/hosts',

[hashtable]$ExtraVars,

[bool]$UseWSL = $true,

[string]$RemoteHost
)

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$logFile = "ansible_run_${timestamp}.log"

# 构建额外变量参数
$extraVarsStr = ''
if ($ExtraVars) {
$varParts = @()
foreach ($key in $ExtraVars.Keys) {
$varParts += "$key=$($ExtraVars[$key])"
}
$extraVarsStr = " --extra-vars `"$($varParts -join ' ')`""
}

$ansibleCmd = "ansible-playbook -i $Inventory$extraVarsStr $PlaybookPath"

if ($UseWSL) {
# 通过 WSL 执行 Ansible
Write-Host "通过 WSL 执行: $ansibleCmd"
$result = wsl bash -c "$ansibleCmd 2>&1 | tee $logFile"
}
else {
if (-not $RemoteHost) {
throw "不使用 WSL 时必须指定 RemoteHost 参数"
}
# 通过 SSH 在远程 Ansible 控制节点执行
Write-Host "通过 SSH 在 $RemoteHost 执行: $ansibleCmd"
$result = ssh $RemoteHost "$ansibleCmd 2>&1 | tee $logFile"
}

# 解析执行结果
$successPattern = 'ok=.*changed=.*unreachable=0.*failed=0'
$lastLine = ($result | Where-Object { $_ -match 'PLAY RECAP' -or $_ -match 'ok=' }) |
Select-Object -Last 1

if ($lastLine -match 'failed=0' -and $lastLine -match 'unreachable=0') {
Write-Host "`nPlaybook 执行成功" -ForegroundColor Green
}
else {
Write-Host "`nPlaybook 执行存在问题,请检查日志: $logFile" -ForegroundColor Red
}

return $result
}

# 使用示例
# Invoke-AnsiblePlaybook `
# -PlaybookPath 'site.yml' `
# -Inventory 'inventory/production' `
# -ExtraVars @{ target = 'web_servers'; action = 'deploy' } `
# -UseWSL $true

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
通过 WSL 执行: ansible-playbook -i inventory/production --extra-vars "target=web_servers action=deploy" site.yml

PLAY [部署 Web 应用] ***********************************************************

TASK [Gathering Facts] *********************************************************
ok: [web01.vichamp.com]
ok: [web02.vichamp.com]

TASK [拉取最新代码] *************************************************************
changed: [web01.vichamp.com]
changed: [web02.vichamp.com]

TASK [重启应用服务] *************************************************************
changed: [web01.vichamp.com]
changed: [web02.vichamp.com]

PLAY RECAP *********************************************************************
web01.vichamp.com : ok=3 changed=2 unreachable=0 failed=0
web02.vichamp.com : ok=3 changed=2 unreachable=0 failed=0

Playbook 执行成功

注意事项

  1. WinRM 认证安全:生产环境务必使用 HTTPS 传输,并配合域账户或证书认证。基本认证(Basic Auth)以明文传输密码,仅适合测试环境。如果使用域环境,推荐配置 Kerberos 认证,安全性更高且支持委派。

  2. 执行策略与脚本签名:Ansible 在 Windows 端执行脚本时,WinRM 会话的执行策略可能与交互式会话不同。确保 Ansible 连接用户的执行策略允许脚本运行(RemoteSignedUnrestricted),同时注意自定义模块中的 Set-ExecutionPolicy 不会影响系统全局策略。

  3. 双跃点(Double Hop)问题:当 Ansible 通过 WinRM 连接到 Windows 主机后,该主机再尝试访问网络资源(如文件共享、远程数据库)时,会遇到凭据委派失败的问题。解决方案包括启用 CredSSP、使用 Kerberos 约束委派,或在目标主机上使用 Invoke-Command 配合 -Authentication CredSSP 参数。

  4. 模块幂等性:编写自定义 Ansible 模块时,务必保证幂等性——同一模块多次执行应产生相同结果。在修改任何状态之前先检查当前状态,仅在确实需要变更时才标记 changed = true。这样可以让 playbook 安全地重复执行。

  5. 输出编码问题:PowerShell 的输出编码默认可能不是 UTF-8,导致中文路径或错误信息在 Ansible 日志中显示为乱码。建议在模块开头设置 $OutputEncoding = [System.Text.Encoding]::UTF8,并在 playbook 中配置 ansible_winrm_codepage = 65001

  6. WSL 与 Ansible 控制节点:在 Windows 上通过 WSL 运行 Ansible 是开发环境的常见方案,但需注意 WSL 的文件系统性能问题。Playbook 和 inventory 文件建议放在 Linux 文件系统(如 ~/ansible/)下而非挂载的 Windows 盘(/mnt/c/)上,可以显著提升执行速度。

PowerShell 技能连载 - Kubernetes 客户端操作

适用于 PowerShell 7.0 及以上版本(跨平台)

在 Kubernetes 生态中,kubectl 是最常用的命令行工具,但它的输出是纯文本或 JSON 字符串,难以直接用于复杂的自动化流程。当我们需要在 CI/CD 管道中动态创建资源、在运维脚本中批量查询 Pod 状态,或者构建自定义的 Kubernetes 监控面板时,直接调用 Kubernetes API 会比反复解析 kubectl 输出更高效、更可靠。

PowerShell 7 的跨平台特性使其成为与 Kubernetes API 交互的理想选择。通过 Kubernetes 官方提供的 .NET 客户端库(KubernetesClient),我们可以用 PowerShell 脚本直接操作 Kubernetes API,获得完整的类型安全、自动补全和管道支持。这种方式不仅能处理认证、证书验证等底层细节,还能与 PowerShell 的对象模型无缝融合。

本文将介绍如何安装和配置 Kubernetes .NET 客户端,并通过三个实用场景——集群状态查询、资源批量操作和事件监控——展示 PowerShell 作为 Kubernetes 客户端的强大能力。

安装 Kubernetes 客户端模块

首先,我们需要安装 KubernetesClient NuGet 包并创建与集群的连接。该客户端会自动读取 ~/.kube/config 中的上下文信息,无需手动配置认证参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 安装 Kubernetes .NET 客户端
Install-Module -Name KubernetesClient -Scope CurrentUser -Force

# 导入模块并创建客户端实例
using module KubernetesClient

# 方式一:使用默认 kubeconfig 自动连接
$k8sClient = [KubernetesClient.KubernetesClientConfiguration]::BuildDefaultConfig()
$client = [KubernetesClient.Kubernetes]::new($k8sClient)

# 方式二:指定特定的 kubeconfig 上下文
$config = [KubernetesClient.KubernetesClientConfiguration]::BuildConfigFromConfigFile(
$null, "$HOME/.kube/config", 'production-cluster'
)
$client = [KubernetesClient.Kubernetes]::new($config)

# 验证连接:列出所有命名空间
$namespaces = $client.ListNamespaceAsync().Result
$namespaces.Items | Select-Object -Property Name, Status | Format-Table

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                Status
---- ------
default Active
kube-system Active
kube-public Active
kube-node-lease Active
monitoring Active
production Active
staging Active

场景一:集群资源状态巡检

在生产环境中,快速掌握集群中各类资源的运行状态是日常运维的基础。下面的脚本封装了一个巡检函数,它遍历所有命名空间中的 Pod、Deployment 和 Service,汇总资源使用情况,并以 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
function Get-K8sClusterReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[KubernetesClient.Kubernetes]$Client,

[Parameter()]
[string[]]$Namespaces
)

# 如果未指定命名空间,则获取所有命名空间
if (-not $Namespaces) {
$nsList = $Client.ListNamespaceAsync().Result
$Namespaces = $nsList.Items | ForEach-Object { $_.Metadata.Name }
}

$report = foreach ($ns in $Namespaces) {
# 获取该命名空间下的所有 Pod
$pods = $Client.ListNamespacedPodAsync($ns).Result.Items

$totalPods = $pods.Count
$runningPods = ($pods | Where-Object {
$_.Status.Phase -eq 'Running'
}).Count
$failedPods = ($pods | Where-Object {
$_.Status.Phase -eq 'Failed'
}).Count
$pendingPods = ($pods | Where-Object {
$_.Status.Phase -eq 'Pending'
}).Count

# 获取 Deployment 信息
$deployments = $Client.ListNamespacedDeploymentAsync($ns).Result.Items
$totalDeployments = $deployments.Count

# 统计未就绪的 Deployment
$unreadyDeployments = ($deployments | Where-Object {
$_.Status.ReadyReplicas -ne $_.Status.Replicas
}).Count

# 获取 Service 信息
$services = $Client.ListNamespacedServiceAsync($ns).Result.Items

[PSCustomObject]@{
Namespace = $ns
TotalPods = $totalPods
RunningPods = $runningPods
PendingPods = $pendingPods
FailedPods = $failedPods
Deployments = $totalDeployments
UnreadyDeploys = $unreadyDeployments
Services = $services.Count
HealthScore = if ($totalPods -gt 0) {
[math]::Round(($runningPods / $totalPods) * 100, 1)
} else { 100 }
}
}

return $report
}

# 执行巡检
$config = [KubernetesClient.KubernetesClientConfiguration]::BuildDefaultConfig()
$k8s = [KubernetesClient.Kubernetes]::new($config)

$report = Get-K8sClusterReport -Client $k8s
$report | Sort-Object HealthScore | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
Namespace       TotalPods RunningPods PendingPods FailedPods Deployments UnreadyDeploys Services HealthScore
--------- --------- ----------- ----------- ---------- ----------- -------------- -------- -----------
production 120 118 2 0 15 1 28 98.3
staging 30 29 1 0 8 0 12 96.7
monitoring 18 18 0 0 5 0 8 100
kube-system 15 15 0 0 3 0 7 100
default 3 3 0 0 1 0 2 100

场景二:批量资源标签管理

在多环境、多团队的 Kubernetes 集群中,标签(Label)是资源分类、筛选和策略执行的基础。当需要批量更新标签(例如标记维护窗口、变更环境归属或添加成本中心标签)时,通过 PowerShell 调用 Kubernetes API 可以高效完成。下面的脚本展示了如何批量查询并修改指定命名空间中所有 Deployment 的标签。

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 Update-K8sDeploymentLabels {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[KubernetesClient.Kubernetes]$Client,

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

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

[Parameter()]
[string]$LabelSelector
)

# 获取指定命名空间的 Deployment 列表
$deployments = $Client.ListNamespacedDeploymentAsync(
$Namespace,
labelSelector: $LabelSelector
).Result.Items

$results = foreach ($deploy in $deployments) {
$name = $deploy.Metadata.Name

# 在现有标签基础上添加新标签
foreach ($key in $LabelsToAdd.Keys) {
$deploy.Metadata.Labels[$key] = $LabelsToAdd[$key]
}

# 构造更新用的 patch 对象
$patchBody = @{
metadata = @{
labels = $deploy.Metadata.Labels
}
} | ConvertTo-Json -Depth 10

try {
$updated = $Client.PatchNamespacedDeploymentAsync(
[KubernetesClient.V1Patch]::new(
$patchBody,
[KubernetesClient.V1Patch]::StrategicMergePatchType
),
$name,
$Namespace
).Result

[PSCustomObject]@{
Name = $name
Status = 'Updated'
NewLabels = ($LabelsToAdd.Keys | ForEach-Object {
"${_}=$($LabelsToAdd[$_])"
}) -join ', '
}
}
catch {
[PSCustomObject]@{
Name = $name
Status = "Failed: $($_.Exception.Message)"
NewLabels = 'N/A'
}
}
}

return $results
}

# 批量为 production 命名空间的 Deployment 添加成本标签
$config = [KubernetesClient.KubernetesClientConfiguration]::BuildDefaultConfig()
$k8s = [KubernetesClient.Kubernetes]::new($config)

$updateResults = Update-K8sDeploymentLabels -Client $k8s `
-Namespace 'production' `
-LabelsToAdd @{
'cost-center' = 'engineering'
'maintenance' = '2025-Q4'
'managed-by' = 'powershell'
} `
-LabelSelector 'app-type=web'

$updateResults | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
Name                 Status   NewLabels
---- ------ ---------
web-frontend Updated cost-center=engineering, maintenance=2025-Q4, managed-by=powershell
web-api-gateway Updated cost-center=engineering, maintenance=2025-Q4, managed-by=powershell
web-notification Updated cost-center=engineering, maintenance=2025-Q4, managed-by=powershell
web-user-service Failed: The Deployment "web-user-service" is being deleted: N/A
web-payment Updated cost-center=engineering, maintenance=2025-Q4, managed-by=powershell

场景三:实时事件流监控

Kubernetes 事件(Event)是排查集群问题的重要信息源。与 kubectl get events 的一次性查询不同,通过客户端的 Watch 机制可以实现事件流的实时订阅。下面的脚本演示了如何使用 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
function Watch-K8sEvents {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[KubernetesClient.Kubernetes]$Client,

[Parameter()]
[string]$Namespace = 'default',

[Parameter()]
[int]$DurationSeconds = 60
)

$startTime = Get-Date
$eventStats = @{
Normal = 0
Warning = 0
Total = 0
}
$warningEvents = [System.Collections.Generic.List[object]]::new()

Write-Host "开始监控命名空间 '$Namespace' 的事件流(持续 $DurationSeconds 秒)..."
Write-Host ('=' * 60)

# 获取事件列表
$events = $Client.ListNamespacedEventAsync($Namespace).Result.Items

foreach ($evt in $events) {
$eventStats['Total']++

$eventType = if ($evt.Type -eq 'Normal') { 'Normal' } else { 'Warning' }
$eventStats[$eventType]++

# 对 Warning 级别的事件进行重点记录
if ($eventType -eq 'Warning') {
$warningEvents.Add(
[PSCustomObject]@{
Time = $evt.LastTimestamp
Object = "$($evt.InvolvedObject.Kind)/$($evt.InvolvedObject.Name)"
Reason = $evt.Reason
Message = $evt.Message
}
)
}
}

# 输出统计摘要
Write-Host "`n事件统计摘要:"
Write-Host " 总事件数: $($eventStats['Total'])"
Write-Host " Normal: $($eventStats['Normal'])"
Write-Host " Warning: $($eventStats['Warning'])"
Write-Host ('-' * 60)

# 输出告警级别事件详情
if ($warningEvents.Count -gt 0) {
Write-Host "`n告警事件详情:"
$warningEvents | Sort-Object Time -Descending | Select-Object -First 10 |
Format-Table Time, Object, Reason -Wrap
}
else {
Write-Host "`n无告警级别事件,集群运行正常。"
}

return [PSCustomObject]@{
MonitoredAt = $startTime
Namespace = $Namespace
TotalEvents = $eventStats['Total']
NormalEvents = $eventStats['Normal']
WarningEvents = $eventStats['Warning']
Warnings = $warningEvents
}
}

# 监控 production 命名空间的事件
$config = [KubernetesClient.KubernetesClientConfiguration]::BuildDefaultConfig()
$k8s = [KubernetesClient.Kubernetes]::new($config)

$eventReport = Watch-K8sEvents -Client $k8s -Namespace 'production'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
开始监控命名空间 'production' 的事件流(持续 60 秒)...
============================================================

事件统计摘要:
总事件数: 47
Normal: 39
Warning: 8
------------------------------------------------------------

告警事件详情:

Time Object Reason Message
---- ------ ------ -------
2025-10-15T06:42:11Z Pod/web-api-7d8f6c4b5-xk2mn FailedScheduling 0/5 nodes are available...
2025-10-15T06:41:58Z Pod/web-api-7d8f6c4b5-xk2mn InsufficientCPU Node didn't have enough...
2025-10-15T06:40:33Z Pod/payment-worker-5c9b8d7f-n4r1 OOMKilled Container payment-worker...
2025-10-15T06:39:15Z Ingress/api-ingress BackendError Error refreshing SSL cert...

注意事项

  1. 认证配置优先级:Kubernetes .NET 客户端会按照 KUBECONFIG 环境变量、~/.kube/config 文件、Pod 内 ServiceAccount token 的顺序查找认证信息。在 CI/CD 环境中建议显式指定 kubeconfig 路径,避免因环境差异导致连接失败。

  2. 异步方法与 Result 属性:客户端库的 API 大多是异步方法(返回 Task)。在 PowerShell 中可以直接访问 .Result 属性获取同步结果,但如果脚本需要处理大量并发请求,建议使用 [System.Threading.Tasks.Task]::WhenAll() 进行并行调度,避免阻塞主线程。

  3. API 版本兼容性:不同版本的 Kubernetes 集群支持的 API 版本不同。使用 KubernetesClient 之前应确认客户端库版本与目标集群版本的兼容性,例如 apps/v1 是 Kubernetes 1.9+ 才稳定的 API 组,老版本集群可能只支持 apps/v1beta2

  4. 资源限流与服务器压力:批量操作(如遍历所有命名空间的所有 Pod)可能对 API Server 造成较大压力。建议在循环中添加适当的延迟(Start-Sleep -Milliseconds 200),并在查询时利用 labelSelectorfieldSelector 缩小结果范围,避免不必要的数据传输。

  5. JSON 序列化深度:Kubernetes 资源对象嵌套层级较深,使用 ConvertTo-Json 时务必指定足够的 -Depth 参数(建议 10 以上),否则深层字段(如容器规格中的环境变量、挂载点等)会被截断为字符串 System.Collections.Hashtable,导致 patch 操作失败。

  6. 错误处理与重试机制:Kubernetes API 在高负载时可能返回 429 Too Many Requests503 Service Unavailable。建议在关键操作的外层包装重试逻辑,配合指数退避策略(如初次等待 1 秒,后续每次翻倍),并区分可重试错误(网络超时、5xx)和不可重试错误(403 权限不足、404 资源不存在),避免无意义的重试循环。