PowerShell 技能连载 - Docker 容器管理

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

为什么用 PowerShell 管理 Docker 容器

Docker 作为当前最主流的容器化平台,已经成为现代应用交付和运维的基石。无论是微服务架构下的服务编排、CI/CD 流水线中的构建打包,还是本地开发环境的快速搭建,Docker 容器都扮演着不可替代的角色。然而,直接在命令行手动敲 docker 命令来管理大量容器,不仅效率低下,而且难以保证操作的一致性和可重复性。

PowerShell 凭借其强大的对象管道和脚本编排能力,非常适合将 Docker CLI 的输出转化为结构化数据,再配合条件判断、循环和错误处理,构建出可靠且可复用的容器管理自动化方案。跨平台的 PowerShell 7 让 Linux 和 Windows 环境下的运维人员可以使用同一套脚本完成容器管理工作。

本文将从容器生命周期管理、资源监控与统计、以及多容器批量部署三个场景出发,演示如何用 PowerShell 7 编写实用的 Docker 容器管理脚本。

容器生命周期管理

容器的创建、启停和删除是日常运维中最频繁的操作。将这些操作封装为函数,并加入状态检查和错误处理,可以避免手动操作时的疏漏。下面的脚本定义了一个通用的容器管理函数,支持创建、启动、停止和删除四种操作,每次操作后自动验证容器状态。

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
function Invoke-ContainerLifecycle {
<#
.SYNOPSIS
管理 Docker 容器的生命周期操作
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateSet('Create', 'Start', 'Stop', 'Remove')]
[string]$Action,

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

[string]$ImageName = 'nginx:latest',

[int]$HostPort = 0,

[int]$ContainerPort = 0
)

Write-Host "`n--- 执行操作: $Action | 容器: $ContainerName ---" -ForegroundColor Cyan

try {
switch ($Action) {
'Create' {
# 检查同名容器是否已存在
$existing = docker ps -a --filter "name=^/$ContainerName$" --format '{{.Names}}' 2>$null
if ($existing -eq $ContainerName) {
Write-Host "[跳过] 容器 '$ContainerName' 已存在" -ForegroundColor Yellow
return
}

# 构建端口映射参数
$portArg = ''
if ($HostPort -gt 0 -and $ContainerPort -gt 0) {
$portArg = "-p ${HostPort}:${ContainerPort}"
}

# 拉取镜像并创建容器
Write-Host "正在拉取镜像: $ImageName ..."
docker pull $ImageName | Out-Null

$runArgs = @('run', '-d', '--name', $ContainerName)
if ($portArg) {
$runArgs += $portArg.Split(' ')
}
$runArgs += $ImageName

$containerId = docker @runArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "创建容器失败: $containerId"
}
Write-Host "[OK] 容器已创建, ID: $($containerId.Substring(0, 12))" -ForegroundColor Green
}

'Start' {
docker start $ContainerName 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "启动容器失败,请检查容器是否存在"
}
Write-Host "[OK] 容器已启动" -ForegroundColor Green
}

'Stop' {
Write-Host "正在停止容器..."
docker stop $ContainerName 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "停止容器失败"
}
Write-Host "[OK] 容器已停止" -ForegroundColor Green
}

'Remove' {
# 先尝试优雅停止
docker stop $ContainerName 2>$null | Out-Null
docker rm $ContainerName 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "删除容器失败"
}
Write-Host "[OK] 容器已删除" -ForegroundColor Green
}
}

# 操作完成后查询状态
$status = docker inspect -f '{{.State.Status}}' $ContainerName 2>$null
if ($status) {
Write-Host "当前状态: $status" -ForegroundColor White
}
}
catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
}

# 演示完整的生命周期
Invoke-ContainerLifecycle -Action Create -ContainerName 'demo-web' -ImageName 'nginx:alpine' -HostPort 8080 -ContainerPort 80
Invoke-ContainerLifecycle -Action Stop -ContainerName 'demo-web'
Invoke-ContainerLifecycle -Action Start -ContainerName 'demo-web'
Invoke-ContainerLifecycle -Action Remove -ContainerName 'demo-web'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- 执行操作: Create | 容器: demo-web ---
正在拉取镜像: nginx:alpine ...
[OK] 容器已创建, ID: a1b2c3d4e5f6
当前状态: running

--- 执行操作: Stop | 容器: demo-web ---
正在停止容器...
[OK] 容器已停止
当前状态: exited

--- 执行操作: Start | 容器: demo-web ---
[OK] 容器已启动
当前状态: running

--- 执行操作: Remove | 容器: demo-web ---
[OK] 容器已删除

容器资源监控与统计

在生产环境中,实时掌握容器的资源消耗情况至关重要。Docker 提供了 docker stats 命令输出 CPU、内存和网络等指标,但其默认输出是实时流式的文本,不便于程序化处理。下面的脚本将 docker stats 的输出解析为 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
function Get-ContainerStats {
<#
.SYNOPSIS
获取所有运行中容器的资源使用统计
#>
[CmdletBinding()]
param(
[int]$Top = 10
)

# 获取所有运行中的容器
$containers = docker ps --format '{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}'
if (-not $containers) {
Write-Host "当前没有运行中的容器" -ForegroundColor Yellow
return
}

# 获取一次性统计快照(不持续监控)
Write-Host "正在采集容器资源数据..." -ForegroundColor Cyan
$rawStats = docker stats --no-stream --format '{{.Container}}|{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}'

$stats = @()

foreach ($line in $rawStats) {
$parts = $line -split '\|'
if ($parts.Count -lt 7) { continue }

# 解析 CPU 和内存百分比
$cpuPct = $parts[2] -replace '%', ''
$memPct = $parts[4] -replace '%', ''

$stats += [PSCustomObject]@{
Container = $parts[1]
Image = $parts[0]
CPU_Percent = [double]$cpuPct
Mem_Percent = [double]$memPct
Mem_Usage = $parts[3]
Net_IO = $parts[5]
Block_IO = $parts[6]
}
}

# 按 CPU 使用率排序
$sorted = $stats | Sort-Object CPU_Percent -Descending | Select-Object -First $Top

Write-Host "`n========== 容器资源使用 Top $Top ==========" -ForegroundColor Cyan
$sorted | Format-Table -Property Container, CPU_Percent, Mem_Percent, Mem_Usage, Net_IO -AutoSize

# 检查资源异常的容器
$warnings = @()
foreach ($s in $stats) {
if ($s.CPU_Percent -gt 80) {
$warnings += "[CPU 告警] $($s.Container) CPU 使用率: $($s.CPU_Percent)%"
}
if ($s.Mem_Percent -gt 80) {
$warnings += "[内存告警] $($s.Container) 内存使用率: $($s.Mem_Percent)%"
}
}

if ($warnings.Count -gt 0) {
Write-Host "`n--- 资源告警 ---" -ForegroundColor Red
foreach ($w in $warnings) {
Write-Host " $w" -ForegroundColor Red
}
}
else {
Write-Host "`n所有容器资源使用正常" -ForegroundColor Green
}

return $sorted
}

# 执行监控
$report = Get-ContainerStats -Top 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
正在采集容器资源数据...

========== 容器资源使用 Top 5 ==========

Container CPU_Percent Mem_Percent Mem_Usage Net_IO
--------- ----------- ----------- --------- ------
api-server 45.2 62.1 256MiB / 512MiB 1.2GB / 340MB
web-frontend 8.7 35.4 128MiB / 512MiB 560MB / 12MB
redis-cache 2.1 48.3 96MiB / 200MiB 340MB / 85MB
postgres-db 1.8 72.0 576MiB / 800MiB 120MB / 2.1GB
nginx-proxy 0.5 12.1 24MiB / 200MiB 890MB / 45MB

--- 资源告警 ---
[CPU 告警] api-server CPU 使用率: 45.2%
[内存告警] postgres-db 内存使用率: 72.0%

多容器批量部署

在微服务架构中,通常需要一次性部署多个相互关联的容器。手动逐个执行 docker run 既耗时又容易遗漏环境变量或端口映射。下面的脚本通过声明式的配置方式定义一组容器,然后批量创建,并在部署完成后执行健康检查。

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
# 定义容器部署配置
$deployConfig = @(
@{
Name = 'web-app'
Image = 'nginx:alpine'
HostPort = 8080
ContPort = 80
Environment = @{
NODE_ENV = 'production'
}
HealthCheck = 'http://localhost:8080'
}
@{
Name = 'redis-cache'
Image = 'redis:7-alpine'
HostPort = 6379
ContPort = 6379
Environment = @{}
HealthCheck = 'redis-cli ping'
}
@{
Name = 'postgres-db'
Image = 'postgres:16-alpine'
HostPort = 5432
ContPort = 5432
Environment = @{
POSTGRES_DB = 'myapp'
POSTGRES_PASSWORD = 'changeme'
POSTGRES_USER = 'appuser'
}
HealthCheck = 'pg_isready'
}
)

function Deploy-Containers {
<#
.SYNOPSIS
根据配置批量部署容器
#>
param(
[array]$Config
)

$results = @()

foreach ($svc in $Config) {
Write-Host "`n>>> 部署服务: $($svc.Name)" -ForegroundColor Cyan

# 清理已存在的同名容器
$existing = docker ps -a --filter "name=^/$($svc.Name)$" --format '{{.Names}}' 2>$null
if ($existing -eq $svc.Name) {
Write-Host " 清理旧容器: $($svc.Name)"
docker rm -f $svc.Name 2>$null | Out-Null
}

# 构建环境变量参数
$envArgs = @()
foreach ($key in $svc.Environment.Keys) {
$envArgs += '-e'
$envArgs += "${key}=$($svc.Environment[$key])"
}

# 构建完整参数列表
$allArgs = @(
'run', '-d',
'--name', $svc.Name,
'-p', "$($svc.HostPort):$($svc.ContPort)"
)
$allArgs += $envArgs
$allArgs += $svc.Image

# 执行创建
$output = docker @allArgs 2>&1
$success = $LASTEXITCODE -eq 0

$results += [PSCustomObject]@{
Service = $svc.Name
Image = $svc.Image
Port = "$($svc.HostPort):$($svc.ContPort)"
Status = if ($success) { 'Running' } else { "Failed: $output" }
}

if ($success) {
Write-Host " [OK] 已启动, 端口映射: $($svc.HostPort):$($svc.ContPort)" -ForegroundColor Green
}
else {
Write-Host " [FAIL] 部署失败: $output" -ForegroundColor Red
}

# 等待容器初始化
Start-Sleep -Seconds 2

# 执行健康检查
$health = docker inspect -f '{{.State.Health.Status}}' $svc.Name 2>$null
if (-not $health) {
# 没有内置健康检查的镜像,用进程状态判断
$health = docker inspect -f '{{.State.Running}}' $svc.Name 2>$null
$health = if ($health -eq 'true') { 'Running' } else { 'Stopped' }
}
Write-Host " 健康状态: $health" -ForegroundColor White
}

Write-Host "`n========== 部署结果汇总 ==========" -ForegroundColor Cyan
$results | Format-Table -AutoSize

return $results
}

# 执行批量部署
$deployResult = Deploy-Containers -Config $deployConfig

# 验证所有容器运行状态
Write-Host "`n--- 容器运行状态一览 ---" -ForegroundColor Cyan
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" | ForEach-Object { Write-Host " $_" }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
>>> 部署服务: web-app
清理旧容器: web-app
[OK] 已启动, 端口映射: 8080:80
健康状态: Running

>>> 部署服务: redis-cache
[OK] 已启动, 端口映射: 6379:6379
健康状态: Running

>>> 部署服务: postgres-db
[OK] 已启动, 端口映射: 5432:5432
健康状态: Running

========== 部署结果汇总 ==========

Service Image Port Status
------- ----- ---- ------
web-app nginx:alpine 8080:80 Running
redis-cache redis:7-alpine 6379:6379 Running
postgres-db postgres:16-alpine 5432:5432 Running

--- 容器运行状态一览 ---
NAMES IMAGE STATUS PORTS
postgres-db postgres:16-alpine Up 4 seconds 0.0.0.0:5432->5432/tcp
redis-cache redis:7-alpine Up 4 seconds 0.0.0.0:6379->6379/tcp
web-app nginx:alpine Up 4 seconds 0.0.0.0:8080->80/tcp

注意事项

  1. Docker 服务可用性检查:脚本执行前应先验证 Docker 守护进程是否正在运行。可以通过 docker info 的退出码判断,Windows 上使用 Get-Service docker 检查服务状态,Linux 上使用 systemctl is-active docker。如果 Docker 未启动,后续所有命令都会失败且错误信息不够明确。

  2. 容器名称唯一性:同一台主机上不允许存在同名容器。创建容器前务必用 docker ps -a --filter 检查是否已有同名容器。批量部署脚本中应包含自动清理旧容器的逻辑,避免因名称冲突导致创建失败。

  3. 端口冲突预防:映射主机端口前应检查端口是否已被占用。Windows 上用 Get-NetTCPConnection -LocalPort,Linux 上用 ss -tlnpnetstat。端口冲突是容器创建失败最常见的原因之一,且 Docker 的错误提示往往不够直观。

  4. 资源限制与 OOM 防护:生产环境中应通过 --memory--cpus 参数为每个容器设置资源上限,防止单个异常容器耗尽宿主机资源导致整个服务不可用。可以使用 docker update 命令动态调整运行中容器的资源限制。

  5. 敏感信息管理:容器的环境变量中不应硬编码密码、Token 等敏感信息。推荐使用 Docker Secrets(Swarm 模式)或挂载外部配置文件(如 .env 文件配合 --env-file 参数),并结合密钥管理服务进行动态注入。docker inspect 默认会以明文显示所有环境变量,存在信息泄露风险。

  6. 日志与调试策略:容器默认使用 json-file 日志驱动,长时间运行会产生大量磁盘占用。建议在 docker run 时通过 --log-driver--log-opt 配置日志轮转(如 --log-opt max-size=10m --log-opt max-file=3),或使用集中式日志收集方案。排查问题时用 docker logs --tail 100 --follow 快速定位最近日志。

PowerShell 技能连载 - Terraform 集成

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

为什么要用 PowerShell 桥接 Terraform

Terraform 是目前最主流的基础设施即代码(IaC)工具,它通过声明式的 HCL 语言定义云资源,并用 terraform planterraform apply 实现可重复、可审计的部署流程。但 Terraform 本身是一个命令行工具,它不擅长处理动态逻辑、条件判断和外部系统集成。当部署流程涉及读取配置中心、调用审批 API、解析 CSV 数据源或发送通知等操作时,纯 HCL 的实现会变得笨重且难以维护。

PowerShell 作为跨平台的任务自动化框架,恰好弥补了这一空白。通过将 PowerShell 与 Terraform CLI 组合使用,可以用 PowerShell 编排整个部署生命周期:动态生成 tfvars 文件、在 apply 前插入审批检查、解析 plan 输出并生成可读报告、甚至实现漂移检测与自动修复。本文将围绕三个典型场景,演示如何用 PowerShell 脚本高效集成 Terraform。

动态生成 Terraform 配置并执行部署

在实际项目中,环境参数(实例规格、区域、节点数量等)通常存储在外部系统中,例如 JSON 配置文件、Consul KV 或者是 REST API 返回的结果。下面的脚本演示如何从 JSON 配置文件读取环境参数,动态生成 Terraform 变量文件,然后自动执行完整的 initplanapply 流程。

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
# 环境配置文件路径
$configPath = Join-Path $PSScriptRoot "environments.json"
$tfWorkDir = Join-Path $PSScriptRoot "terraform"

# 读取环境配置
$environments = Get-Content -Path $configPath -Raw | ConvertFrom-Json

# 选择目标环境
$targetEnv = "staging"
$envConfig = $environments.$targetEnv

if (-not $envConfig) {
throw "未找到环境配置: $targetEnv"
}

Write-Host "目标环境: $targetEnv" -ForegroundColor Cyan
Write-Host "区域: $($envConfig.location) | 实例规格: $($envConfig.vm_size) | 节点数: $($envConfig.node_count)"

# 动态生成 terraform.tfvars.json
$tfVars = @{
environment = $targetEnv
location = $envConfig.location
vm_size = $envConfig.vm_size
node_count = $envConfig.node_count
tags = @{
ManagedBy = "PowerShell"
CreatedAt = (Get-Date -Format "yyyy-MM-dd")
CostCenter = $envConfig.cost_center
}
}

$tfVarsPath = Join-Path $tfWorkDir "terraform.tfvars.json"
$tfVars | ConvertTo-Json -Depth 5 | Set-Content -Path $tfVarsPath -Encoding UTF8
Write-Host "[OK] 已生成变量文件: $tfVarsPath" -ForegroundColor Green

# 执行 Terraform 初始化
Write-Host "`n>>> terraform init" -ForegroundColor Yellow
$initResult = terraform -chdir=$tfWorkDir init -input=false -no-color 2>&1
$initExit = $LASTEXITCODE
Write-Host $initResult

if ($initExit -ne 0) {
throw "Terraform init 失败,退出码: $initExit"
}

# 执行 Plan
Write-Host "`n>>> terraform plan" -ForegroundColor Yellow
$planFile = Join-Path $tfWorkDir "current.tfplan"
$planResult = terraform -chdir=$tfWorkDir plan -out=$planFile -var-file="terraform.tfvars.json" -no-color 2>&1
$planExit = $LASTEXITCODE
Write-Host $planResult

if ($planExit -ne 0) {
throw "Terraform plan 失败,退出码: $planExit"
}

# 解析 Plan 摘要
$addCount = if ($planResult -match '(\d+) to add') { $Matches[1] } else { "0" }
$changeCount = if ($planResult -match '(\d+) to change') { $Matches[1] } else { "0" }
$destroyCount = if ($planResult -match '(\d+) to destroy') { $Matches[1] } else { "0" }

Write-Host "`nPlan 摘要: +$addCount ~$changeCount -$destroyCount" -ForegroundColor Cyan

# 安全检查:如果存在销毁操作,要求人工确认
if ($destroyCount -gt 0) {
$confirm = Read-Host "检测到 $destroyCount 个资源将被销毁,是否继续?(yes/no)"
if ($confirm -ne "yes") {
Write-Host "已取消部署" -ForegroundColor Yellow
return
}
}

# 执行 Apply
Write-Host "`n>>> terraform apply" -ForegroundColor Yellow
$applyResult = terraform -chdir=$tfWorkDir apply -auto-approve $planFile -no-color 2>&1
$applyExit = $LASTEXITCODE
Write-Host $applyResult

if ($applyExit -ne 0) {
throw "Terraform apply 失败,退出码: $applyExit"
}

Write-Host "`n[OK] 部署完成!" -ForegroundColor Green
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
目标环境: staging
区域: eastasia | 实例规格: Standard_D2s_v3 | 节点数: 3
[OK] 已生成变量文件: /deploy/terraform/terraform.tfvars.json

>>> terraform init
Initializing the backend...
Initializing provider plugins...
Terraform has been successfully initialized!

>>> terraform plan
Terraform will perform the following actions:
# module.vm.azurerm_linux_virtual_machine.main[0] will be created
# module.vm.azurerm_linux_virtual_machine.main[1] will be created
# module.vm.azurerm_linux_virtual_machine.main[2] will be created
Plan: 3 to add, 0 to change, 0 to destroy.

Plan 摘要: +3 ~0 -0

>>> terraform apply
module.vm.azurerm_linux_virtual_machine.main[0]: Creating...
module.vm.azurerm_linux_virtual_machine.main[0]: Creation complete after 2m35s
module.vm.azurerm_linux_virtual_machine.main[1]: Creating...
module.vm.azurerm_linux_virtual_machine.main[1]: Creation complete after 2m40s
module.vm.azurerm_linux_virtual_machine.main[2]: Creating...
module.vm.azurerm_linux_virtual_machine.main[2]: Creation complete after 2m38s

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

[OK] 部署完成!

解析 Terraform State 生成资源清单

Terraform 的状态文件(terraform.tfstate)记录了所有已部署资源的详细属性。在多环境、多项目的运维场景中,定期导出资源清单有助于成本审计、安全合规检查和资产盘点。下面的脚本演示如何通过 terraform show -json 将状态输出为结构化数据,并用 PowerShell 提取关键字段生成 CSV 报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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
function Get-TerraformResourceInventory {
<#
.SYNOPSIS
从 Terraform 状态中提取资源清单并导出报告
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$TerraformDir,

[string]$OutputPath = "./resource-inventory-$(Get-Date -Format 'yyyyMMdd').csv"
)

# 验证 Terraform 工作目录
if (-not (Test-Path (Join-Path $TerraformDir ".terraform"))) {
throw "目录未初始化: $TerraformDir - 请先运行 terraform init"
}

# 获取 JSON 格式的状态
Write-Host "正在读取 Terraform 状态..." -ForegroundColor Cyan
$stateJson = terraform -chdir=$TerraformDir show -json 2>$null

if ($LASTEXITCODE -ne 0) {
throw "无法读取 Terraform 状态。请确认已成功执行过 terraform apply"
}

$state = $stateJson | ConvertFrom-Json

# 提取资源信息
$inventory = @()

foreach ($resource in $state.values.root_module.resources) {
# 根据资源类型提取不同的关键属性
$keyAttrs = switch -Regex ($resource.type) {
'azurerm_linux_virtual_machine|azurerm_windows_virtual_machine' {
@{
'VM 名称' = $resource.values.name
'VM 规格' = $resource.values.size
'区域' = $resource.values.location
'资源组' = $resource.values.resource_group_name
}
}
'azurerm_storage_account' {
@{
'存储账户' = $resource.values.name
'SKU' = $resource.values.account_tier
'复制策略' = $resource.values.account_replication_type
'区域' = $resource.values.location
}
}
'azurerm_virtual_network' {
@{
'VNet 名称' = $resource.values.name
'地址空间' = ($resource.values.address_space -join ', ')
'区域' = $resource.values.location
}
}
default {
@{
'名称' = $resource.values.name
'属性' = "N/A"
}
}
}

$row = [ordered]@{
资源类型 = $resource.type
资源名称 = $resource.name
模块路径 = if ($resource.module) { $resource.module } else { "root" }
提供者 = ($resource.provider_name -split '::')[-1]
依赖数量 = if ($resource.depends_on) { $resource.depends_on.Count } else { 0 }
模式 = $resource.mode
}

# 合并类型特定的属性
foreach ($key in $keyAttrs.Keys) {
$row[$key] = $keyAttrs[$key]
}

$inventory += [PSCustomObject]$row
}

# 处理子模块中的资源
if ($state.values.root_module.child_modules) {
foreach ($child in $state.values.root_module.child_modules) {
foreach ($resource in $child.resources) {
$row = [ordered]@{
资源类型 = $resource.type
资源名称 = $resource.name
模块路径 = $child.address
提供者 = ($resource.provider_name -split '::')[-1]
依赖数量 = if ($resource.depends_on) { $resource.depends_on.Count } else { 0 }
模式 = $resource.mode
}
$inventory += [PSCustomObject]$row
}
}
}

# 输出统计信息
$typeSummary = $inventory | Group-Object 资源类型 | Sort-Object Count -Descending

Write-Host "`n资源统计:" -ForegroundColor Cyan
foreach ($group in $typeSummary) {
Write-Host (" {0,-45} {1,3} 个" -f $group.Name, $group.Count)
}
Write-Host (" {0,-45} {1,3} 个" -f "总计", $inventory.Count)

# 导出到 CSV
$inventory | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "`n[OK] 资源清单已导出: $OutputPath" -ForegroundColor Green

return $inventory
}

# 执行资源盘点
$inventory = Get-TerraformResourceInventory -TerraformDir "./terraform" -Verbose

# 筛选需要关注的资源(如公网 IP、未加密存储等)
$publicResources = $inventory | Where-Object {
$_.资源类型 -match 'public_ip|lb$'
}

if ($publicResources.Count -gt 0) {
Write-Host "`n[WARN] 发现 $($publicResources.Count) 个公网暴露资源,请检查安全策略:" -ForegroundColor Yellow
foreach ($pr in $publicResources) {
Write-Host " - $($pr.资源类型): $($pr.资源名称)" -ForegroundColor Yellow
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
正在读取 Terraform 状态...

资源统计:
azurerm_linux_virtual_machine 3 个
azurerm_network_interface 3 个
azurerm_virtual_network 1 个
azurerm_public_ip 3 个
azurerm_network_security_group 1 个
azurerm_storage_account 1 个
总计 12 个

[OK] 资源清单已导出: ./resource-inventory-20251013.csv

[WARN] 发现 3 个公网暴露资源,请检查安全策略:
- azurerm_public_ip: main_public_ip[0]
- azurerm_public_ip: main_public_ip[1]
- azurerm_public_ip: main_public_ip[2]

配置漂移检测与自动通知

基础设施配置漂移是指实际运行资源的状态与 Terraform 状态文件中记录的期望状态不一致的情况。漂移可能由手动操作、自动化脚本的副作用或外部系统变更引起。下面的脚本将漂移检测封装为一个可定期执行的巡检任务,在发现漂移时自动通过 Webhook 发送告警通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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
function Test-TerraformDrift {
<#
.SYNOPSIS
检测 Terraform 管理的基础设施是否存在配置漂移
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$WorkDirs,

[string]$WebhookUrl,

[int]$WarningThreshold = 5
)

$allResults = @()

foreach ($dir in $WorkDirs) {
$dirName = Split-Path $dir -Leaf
Write-Host "`n检查工作目录: $dirName" -ForegroundColor Cyan

# 确保 Terraform 已初始化
$initCheck = terraform -chdir=$dir init -backend=false -input=false -no-color 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [SKIP] 初始化失败,跳过此目录" -ForegroundColor Red
continue
}

# 执行 Plan(不保存计划文件,仅检测差异)
$planOutput = terraform -chdir=$dir plan -detailed-exitcode -input=false -no-color 2>&1
$exitCode = $LASTEXITCODE

# 详细退出码:0=无变化 1=错误 2=有变化
$driftStatus = switch ($exitCode) {
0 { "无漂移" }
1 { "检测失败" }
2 { "存在漂移" }
default { "未知 ($exitCode)" }
}

# 解析变化数量
$changes = @{
Add = if ($planOutput -match '(\d+) to add') { [int]$Matches[1] } else { 0 }
Change = if ($planOutput -match '(\d+) to change') { [int]$Matches[1] } else { 0 }
Destroy = if ($planOutput -match '(\d+) to destroy'){ [int]$Matches[1] } else { 0 }
}
$totalChanges = $changes.Add + $changes.Change + $changes.Destroy

# 提取具体变化的资源列表
$changedResources = @()
$planLines = $planOutput -split "`n"
foreach ($line in $planLines) {
if ($line -match '#\s+(\S+)\s+will be\s+(created|updated|deleted|replaced)') {
$changedResources += [PSCustomObject]@{
Resource = $Matches[1]
Action = $Matches[2]
}
}
}

$result = [PSCustomObject]@{
工作目录 = $dirName
漂移状态 = $driftStatus
新增 = $changes.Add
变更 = $changes.Change
销毁 = $changes.Destroy
总变化数 = $totalChanges
检测时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
变化资源 = $changedResources
}

$allResults += $result

# 控制台输出
$color = if ($totalChanges -eq 0) { "Green" } elseif ($totalChanges -lt $WarningThreshold) { "Yellow" } else { "Red" }
Write-Host " 状态: $driftStatus | 变化: +$($changes.Add) ~$($changes.Change) -$($changes.Destroy)" -ForegroundColor $color
}

# 汇总报告
$driftCount = ($allResults | Where-Object { $_.漂移状态 -eq "存在漂移" }).Count
$summary = [PSCustomObject]@{
检测目录数 = $WorkDirs.Count
正常目录数 = $WorkDirs.Count - $driftCount
漂移目录数 = $driftCount
总变化资源 = ($allResults | Measure-Object -Property 总变化数 -Sum).Sum
检测完成时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}

Write-Host "`n========== 漂移检测汇总 ==========" -ForegroundColor Cyan
Write-Host " 检测目录: $($summary.检测目录数) 个"
Write-Host " 正常: $($summary.正常目录数) | 漂移: $($summary.漂移目录数)"
Write-Host " 总变化资源数: $($summary.总变化资源)"

# 发送 Webhook 通知
if ($WebhookUrl -and $driftCount -gt 0) {
$severity = if ($summary.总变化资源 -ge $WarningThreshold) { "HIGH" } else { "MEDIUM" }

$payload = @{
text = "[Terraform 漂移告告警] 级别: $severity | 漂移目录: $driftCount/$($WorkDirs.Count) | 变化资源: $($summary.总变化资源) 个"
sections = @()
}

foreach ($res in ($allResults | Where-Object { $_.漂移状态 -eq "存在漂移" })) {
$payload.sections += @{
activityTitle = "目录: $($res.工作目录)"
facts = @(
@{ name = "新增"; value = $res.新增 },
@{ name = "变更"; value = $res.变更 },
@{ name = "销毁"; value = $res.销毁 }
)
}
}

$body = $payload | ConvertTo-Json -Depth 5 -Compress
try {
Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop
Write-Host "[OK] Webhook 通知已发送" -ForegroundColor Green
}
catch {
Write-Host "[WARN] Webhook 发送失败: $($_.Exception.Message)" -ForegroundColor Yellow
}
}

return $allResults
}

# 定期巡检:检查多个 Terraform 工作目录
$workDirs = @(
"/infra/terraform/networking"
"/infra/terraform/compute"
"/infra/terraform/storage"
)

$driftReport = Test-TerraformDrift `
-WorkDirs $workDirs `
-WebhookUrl "https://hooks.example.com/infra-alerts" `
-WarningThreshold 5

# 导出历史记录
$historyPath = "./drift-history-$(Get-Date -Format 'yyyyMMdd').json"
$driftReport | ConvertTo-Json -Depth 3 | Set-Content -Path $historyPath -Encoding UTF8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
检查工作目录: networking
状态: 无漂移 | 变化: +0 ~0 -0

检查工作目录: compute
状态: 存在漂移 | 变化: +0 ~2 -1

检查工作目录: storage
状态: 无漂移 | 变化: +0 ~0 -0

========== 漂移检测汇总 ==========
检测目录: 3 个
正常: 2 | 漂移: 1
总变化资源数: 3
[OK] Webhook 通知已发送

注意事项

  1. Terraform CLI 依赖:本文所有脚本都依赖本地安装的 Terraform CLI。在 CI/CD 管道中建议使用 tfenvmise 管理版本,确保团队使用相同的 Terraform 版本。版本不一致可能导致状态文件不兼容或 Provider 下载失败。

  2. 状态文件安全terraform show -json 输出的状态数据可能包含敏感信息(数据库密码、连接字符串等)。在生成报告时应避免将敏感字段写入明文 CSV,可通过 -sensitive=false 参数或在 PowerShell 中过滤 sensitive = true 的属性来降低泄露风险。生产环境务必使用远程状态后端(如 Azure Storage、S3)并启用加密。

  3. plan 退出码语义terraform plan -detailed-exitcode 返回三种退出码:0 表示无变化、1 表示执行出错、2 表示存在变更。漂移检测脚本必须区分这三种状态,不能简单地将非零退出码都视为错误,否则会误报正常的 plan 结果。

  4. 跨平台路径处理:PowerShell 7 支持在 Linux 和 macOS 上运行,但 Terraform 的 -chdir 参数在不同操作系统上对路径分隔符有严格要求。统一使用 Join-Path 拼接路径(而非硬编码 /\),可以保证脚本在 Windows 和 Linux 上行为一致。

  5. 并发执行与状态锁:如果同时对多个工作目录执行 terraform apply,且这些目录共享同一个远程状态后端,可能会触发状态锁定冲突。建议使用 -lock-timeout 参数设置合理的等待时间(如 terraform apply -lock-timeout=60s),或通过 PowerShell 的串行控制逻辑确保同一时间只有一个操作访问共享状态。

  6. 错误处理与回滚策略terraform apply 失败时 Terraform 会自动标记 tainted 资源并在下次 apply 时重建。但 PowerShell 脚本层面的错误处理同样重要:应在每次 Terraform CLI 调用后立即检查 $LASTEXITCODE,并在脚本顶层使用 try/catch/finally 确保临时文件(如 .tfplan)被清理,避免残留的计划文件影响下次执行。

PowerShell 技能连载 - Microsoft Graph 用户管理

适用于 PowerShell 5.1 及以上版本

为什么需要脚本化用户管理

在企业的 Microsoft 365 环境中,用户管理是一项高频且繁琐的日常工作。新员工入职需要创建账号、分配许可证、加入对应部门的安全组;员工离职则需要禁用账号、回收许可证、转移邮箱权限。如果通过 Microsoft 365 管理中心手动操作,不仅效率低下,还容易遗漏步骤导致安全风险。

Microsoft Graph PowerShell 模块(Microsoft.Graph)是微软官方推荐的 Entra ID(原 Azure AD)管理工具。它通过统一的 Graph API 端点提供用户全生命周期管理能力,包括创建、查询、更新、删除以及许可证分配。相比旧版 AzureAD 模块,Graph 模块支持更细粒度的权限控制和更好的分页性能。

本文将围绕用户管理的核心场景,演示如何使用 Microsoft.Graph.Users 模块完成用户创建与初始化、批量查询与筛选、以及用户生命周期自动化操作。

连接 Microsoft Graph 并准备环境

在开始管理用户之前,需要先安装模块并建立经过身份验证的连接。连接时通过 -Scopes 参数声明本次操作所需的最低权限,Graph 服务会根据登录账户的角色和已授予的同意来决定是否放行。

1
2
3
4
5
6
7
8
9
10
11
# 安装 Microsoft Graph Users 模块(仅首次)
Install-Module -Name Microsoft.Graph.Users -Scope CurrentUser -Force

# 连接到 Graph API,声明用户读写权限
Connect-MgGraph -Scopes "User.ReadWrite.All", "Directory.Read.All"

# 确认连接状态
$context = Get-MgContext
Write-Host "已连接账户: $($context.Account)"
Write-Host "租户标识: $($context.TenantId)"
Write-Host "已授权范围: $($context.Scopes -join ', ')"
1
2
3
已连接账户: admin@contoso.com
租户标识: contoso.onmicrosoft.com
已授权范围: User.ReadWrite.All, Directory.Read.All

创建用户并完成初始配置

新员工入职时,通常需要一次性完成多项配置:创建账户、设置初始密码、填写部门信息。下面的脚本将多个步骤组织在一起,并为每个步骤添加错误处理,确保任何一个环节失败都能及时发现。

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
# 定义新用户的完整信息
$newUsers = @(
@{
DisplayName = "李明"
MailNickname = "ming.li"
UserPrincipalName = "ming.li@contoso.com"
Department = "工程部"
JobTitle = "高级开发工程师"
UsageLocation = "CN"
}
@{
DisplayName = "王芳"
MailNickname = "fang.wang"
UserPrincipalName = "fang.wang@contoso.com"
Department = "市场部"
JobTitle = "市场经理"
UsageLocation = "CN"
}
)

# 生成随机初始密码
function New-RandomPassword {
$length = 16
$chars = @()
# 确保包含各类字符
$chars += [char](Get-Random -Minimum 65 -Maximum 91) # 大写字母
$chars += [char](Get-Random -Minimum 97 -Maximum 123) # 小写字母
$chars += [char](Get-Random -Minimum 48 -Maximum 58) # 数字
$chars += [char](Get-Random -Minimum 35 -Maximum 39) # 特殊字符
# 填充剩余长度
foreach ($i in 1..($length - 4)) {
$chars += [char](Get-Random -Minimum 33 -Maximum 127)
}
# 打乱顺序并返回
$password = ($chars | Sort-Object { Get-Random }) -join ''
return $password
}

# 批量创建用户
$results = @()

foreach ($userInfo in $newUsers) {
$tempPassword = New-RandomPassword
$body = @{
AccountEnabled = $true
DisplayName = $userInfo.DisplayName
MailNickname = $userInfo.MailNickname
UserPrincipalName = $userInfo.UserPrincipalName
Department = $userInfo.Department
JobTitle = $userInfo.JobTitle
UsageLocation = $userInfo.UsageLocation
PasswordProfile = @{
ForceChangePasswordNextSignIn = $true
Password = $tempPassword
}
}

try {
$user = New-MgUser -BodyParameter $body
$results += [PSCustomObject]@{
Status = "成功"
UPN = $userInfo.UserPrincipalName
UserId = $user.Id
TempPwdLen = $tempPassword.Length
}
Write-Host "[OK] 已创建用户: $($userInfo.DisplayName)" -ForegroundColor Green
}
catch {
$results += [PSCustomObject]@{
Status = "失败"
UPN = $userInfo.UserPrincipalName
UserId = $_.Exception.Message.Substring(0, [Math]::Min(60, $_.Exception.Message.Length))
TempPwdLen = 0
}
Write-Host "[FAIL] 创建失败: $($userInfo.DisplayName) - $($_.Exception.Message)" -ForegroundColor Red
}
}

# 输出创建结果汇总
$results | Format-Table -AutoSize
1
2
3
4
5
6
7
[OK] 已创建用户: 李明
[OK] 已创建用户: 王芳

Status UPN UserId TempPwdLen
------ --- ------ ----------
成功 ming.li@contoso.com a1b2c3d4-e5f6-7890-abcd-ef1234567890 16
成功 fang.wang@contoso.com b2c3d4e5-f6a7-8901-bcde-f12345678901 16

查询与筛选用户信息

用户数据是企业目录的核心资产,掌握高效的查询技巧对日常运维至关重要。Graph API 支持 OData 筛选语法,可以对用户属性进行精确匹配和排序。对于复杂筛选条件,还可以使用 -ConsistencyLevel eventual 开启高级查询功能。

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
# 基本查询:列出所有启用的用户
$activeUsers = Get-MgUser -All -Filter "accountEnabled eq true" |
Sort-Object DisplayName |
Select-Object DisplayName, UserPrincipalName, Department, JobTitle

Write-Host "当前活跃用户数: $($activeUsers.Count)"
$activeUsers | Format-Table -AutoSize

# 按部门筛选
$engineers = Get-MgUser -Filter "department eq '工程部'" `
-ConsistencyLevel eventual -CountVariable engCount -All

Write-Host "`n工程部人数: $engCount"
foreach ($eng in $engineers) {
Write-Host " - $($eng.DisplayName) ($($eng.UserPrincipalName))"
}

# 模糊搜索:查找显示名包含"李"的用户
$searchResult = Get-MgUser -Filter "startsWith(displayName,'李')" `
-ConsistencyLevel eventual -All

Write-Host "`n搜索'李'姓用户结果:"
foreach ($item in $searchResult) {
Write-Host " $($item.DisplayName) | $($item.Department) | $($item.Mail)"
}

# 查看用户的详细信息(包括扩展属性)
$targetUser = Get-MgUser -UserId "ming.li@contoso.com" -Property DisplayName, UserPrincipalName, Department, JobTitle, CreatedDateTime, LastPasswordChangeDateTime, SignInActivity

[PSCustomObject]@{
显示名 = $targetUser.DisplayName
邮箱 = $targetUser.UserPrincipalName
部门 = $targetUser.Department
职位 = $targetUser.JobTitle
创建时间 = $targetUser.AdditionalProperties.createdDateTime
上次改密 = $targetUser.AdditionalProperties.lastPasswordChangeDateTime
上次登录 = $targetUser.AdditionalProperties.signInActivity.lastSignInDateTime
} | Format-List
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
当前活跃用户数: 128

DisplayName UserPrincipalName Department JobTitle
----------- ----------------- ---------- --------
Alice Chen alice.chen@contoso.com 工程部 前端开发
李明 ming.li@contoso.com 工程部 高级开发工程师
王芳 fang.wang@contoso.com 市场部 市场经理

工程部人数: 45
- Alice Chen (alice.chen@contoso.com)
- 李明 (ming.li@contoso.com)

搜索'李'姓用户结果:
李明 | 工程部 | ming.li@contoso.com
李娜 | 财务部 | na.li@contoso.com

显示名 : 李明
邮箱 : ming.li@contoso.com
部门 : 工程部
职位 : 高级开发工程师
创建时间 : 2025-10-10T02:30:00Z
上次改密 : 2025-10-10T02:30:00Z
上次登录 : 2025-10-10T08:15:00Z

用户生命周期自动化

离职流程是用户管理中最容易出问题的环节。一个完整的离职处理需要按顺序执行:禁用账户、移除组成员身份、回收许可证、更新描述信息。将这个过程封装为函数,可以确保每次操作都不遗漏步骤。

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
function Disable-UserLifecycle {
<#
.SYNOPSIS
执行用户离职处理流程
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$UserPrincipalName,

[string]$Reason = "员工离职"
)

Write-Host "`n========== 离职处理: $UserPrincipalName ==========" -ForegroundColor Cyan

# 第一步:获取用户信息
try {
$user = Get-MgUser -UserId $UserPrincipalName -ErrorAction Stop
Write-Host "[1/5] 用户信息已获取: $($user.DisplayName)" -ForegroundColor Green
}
catch {
Write-Host "[1/5] 用户不存在或无法访问: $UserPrincipalName" -ForegroundColor Red
return
}

# 第二步:禁用账户
if ($PSCmdlet.ShouldProcess($UserPrincipalName, "禁用账户")) {
Update-MgUser -UserId $UserPrincipalName -AccountEnabled:$false
Write-Host "[2/5] 账户已禁用" -ForegroundColor Green
}

# 第三步:移除所有组成员身份
$memberships = Get-MgUserMemberOf -UserId $UserPrincipalName -All
$groupCount = 0

foreach ($member in $memberships) {
# 仅处理组类型(过滤掉目录角色等)
if ($member.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') {
try {
Remove-MgGroupMember -GroupId $member.Id -DirectoryObjectId $user.Id -ErrorAction SilentlyContinue
$groupCount++
}
catch {
# 动态组无法手动移除成员,记录即可
Write-Host " 跳过动态组: $($member.AdditionalProperties.displayName)" -ForegroundColor Yellow
}
}
}
Write-Host "[3/5] 已从 $groupCount 个组中移除" -ForegroundColor Green

# 第四步:回收许可证
$licenses = Get-MgUserLicenseDetail -UserId $UserPrincipalName -ErrorAction SilentlyContinue
$licenseSkuIds = @()

foreach ($lic in $licenses) {
$licenseSkuIds += $lic.SkuId
}

if ($licenseSkuIds.Count -gt 0) {
$removeLicenses = @()
foreach ($skuId in $licenseSkuIds) {
$removeLicenses += @{ SkuId = $skuId }
}
Set-MgUserLicense -UserId $UserPrincipalName `
-AddLicenses @() `
-RemoveLicenses $removeLicenses
Write-Host "[4/5] 已回收 $($licenseSkuIds.Count) 个许可证" -ForegroundColor Green
}
else {
Write-Host "[4/5] 无需回收许可证" -ForegroundColor Yellow
}

# 第五步:更新描述记录离职原因和日期
$leaveDate = Get-Date -Format "yyyy-MM-dd"
Update-MgUser -UserId $UserPrincipalName `
-AboutMe "$Reason - 处理日期: $leaveDate"
Write-Host "[5/5] 离职记录已更新 ($leaveDate)" -ForegroundColor Green

Write-Host "`n========== 处理完成 ==========" -ForegroundColor Cyan

# 返回处理结果
return [PSCustomObject]@{
User = $user.DisplayName
UPN = $UserPrincipalName
Disabled = $true
GroupsRemoved = $groupCount
LicensesRevoked = $licenseSkuIds.Count
ProcessedDate = $leaveDate
}
}

# 执行离职处理
$leavingUsers = @("ming.li@contoso.com", "fang.wang@contoso.com")

$reports = @()
foreach ($upn in $leavingUsers) {
$report = Disable-UserLifecycle -UserPrincipalName $upn -Reason "合同到期离职"
$reports += $report
}

# 输出处理报告
Write-Host "`n离职处理汇总报告:"
$reports | Format-Table -AutoSize
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
========== 离职处理: ming.li@contoso.com ==========
[1/5] 用户信息已获取: 李明
[2/5] 账户已禁用
[3/5] 已从 5 个组中移除
[4/5] 已回收 1 个许可证
[5/5] 离职记录已更新 (2025-10-10)

========== 处理完成 ==========

========== 离职处理: fang.wang@contoso.com ==========
[1/5] 用户信息已获取: 王芳
[2/5] 账户已禁用
[3/5] 已从 3 个组中移除
[4/5] 已回收 2 个许可证
[5/5] 离职记录已更新 (2025-10-10)

========== 处理完成 ==========

离职处理汇总报告:

User UPN Disabled GroupsRemoved LicensesRevoked ProcessedDate
---- --- -------- ------------- --------------- -------------
李明 ming.li@contoso.com True 5 1 2025-10-10
王芳 fang.wang@contoso.com True 3 2 2025-10-10

注意事项

  1. 权限最小化原则:连接 Graph API 时应按操作类型声明最小权限集。只读操作用 User.Read.All,写操作才需要 User.ReadWrite.All。避免在脚本中统一请求全部权限,防止权限滥用带来的安全审计风险。

  2. ConsistencyLevel 与高级查询:使用 startsWithendsWitheq 对非索引属性筛选时,必须添加 -ConsistencyLevel eventual 参数,并使用 -CountVariable 接收匹配数量。否则 Graph API 会返回”不支持的查询”错误。

  3. 分页与 -All 参数:默认情况下 Get-MgUser 只返回前 100 条记录。用户数超过 100 时必须加 -All 参数自动处理分页,或者通过 -Top-Skip 参数手动分页控制内存占用。

  4. 密码策略合规:创建用户时的初始密码必须满足 Entra ID 的密码复杂度要求(至少 8 位,包含大小写字母、数字和特殊字符中的三类)。建议配合 ForceChangePasswordNextSignIn = $true,确保用户首次登录时强制修改密码。

  5. 许可证回收时机:禁用账户后许可证并不会自动释放,必须显式调用 Set-MgUserLicense -RemoveLicenses 回收。如果许可证余额紧张,建议在离职流程中优先执行许可证回收步骤,避免因中间步骤失败导致许可证被占用。

  6. 错误处理与幂等性:批量操作用户时,务必对每个步骤添加 try/catch 错误处理。对于可能重复执行的场景(如入职脚本被运行两次),应在创建前先检查用户是否已存在,使用 Get-MgUser -Filter 替代直接创建,保证脚本的幂等性。

PowerShell 技能连载 - Win32 API 调用

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

PowerShell 虽然已经提供了丰富的 cmdlet 和 .NET 类库,但在某些场景下仍然需要直接调用 Win32 API 才能完成任务。比如获取系统硬件信息、操作窗口句柄、管理进程内存、控制屏幕分辨率等底层操作,往往没有对应的 .NET 封装。这时候,Platform Invoke(P/Invoke)机制就成为了连接 PowerShell 与 Win32 原生 API 的桥梁。

P/Invoke 是 .NET 提供的一种互操作机制,允许托管代码调用非托管的 DLL 导出函数。在 PowerShell 中,我们可以通过 Add-Type 动态编译 C# 代码来声明 Win32 API 的签名,然后在脚本中像调用普通方法一样调用这些原生函数。这种方式的灵活性极高,几乎可以访问 Windows 系统的全部底层能力。

本文将通过三个实用案例,演示如何在 PowerShell 中声明和调用 Win32 API:获取系统内存状态、操作剪贴板,以及控制窗口的显示状态。每个案例都包含完整的签名声明、参数说明和错误处理。

获取系统全局内存状态

GlobalMemoryStatusEx 是 Win32 API 中用于获取系统内存使用情况的核心函数,它返回的 MEMORYSTATUSEX 结构体包含了物理内存、虚拟内存的总量和可用量等关键信息。虽然 Get-CimInstance 也能获取部分内存数据,但 Win32 API 返回的信息更加实时和精确,且调用开销更小。

下面的代码通过 Add-Type 定义了必要的结构体和函数签名,然后封装为一个 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
# 定义 MEMORYSTATUSEX 结构体和 GlobalMemoryStatusEx 函数签名
$memApiDefinition = @'
using System;
using System.Runtime.InteropServices;

public class MemoryApi
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public class MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;

public MEMORYSTATUSEX()
{
this.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GlobalMemoryStatusEx(
[In, Out] MEMORYSTATUSEX lpBuffer);
}
'@

Add-Type -TypeDefinition $memApiDefinition -Language CSharp

# 封装为易用的 PowerShell 函数
function Get-SystemMemoryStatus {
$status = New-Object MemoryApi+MEMORYSTATUSEX
$result = [MemoryApi]::GlobalMemoryStatusEx($status)

if (-not $result) {
$errorCode = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
Write-Error "GlobalMemoryStatusEx 调用失败,错误码:$errorCode"
return
}

$gb = [math]::Round(1GB, 2)

[PSCustomObject]@{
MemoryLoadPercent = $status.dwMemoryLoad
TotalPhysicalGB = [math]::Round($status.ullTotalPhys / $gb, 2)
AvailablePhysicalGB = [math]::Round($status.ullAvailPhys / $gb, 2)
UsedPhysicalGB = [math]::Round(($status.ullTotalPhys - $status.ullAvailPhys) / $gb, 2)
TotalPageFileGB = [math]::Round($status.ullTotalPageFile / $gb, 2)
AvailablePageFileGB = [math]::Round($status.ullAvailPageFile / $gb, 2)
TotalVirtualGB = [math]::Round($status.ullTotalVirtual / $gb, 2)
AvailableVirtualGB = [math]::Round($status.ullAvailVirtual / $gb, 2)
}
}

# 调用并展示结果
$memStatus = Get-SystemMemoryStatus
Write-Host "=== 系统内存状态 ===" -ForegroundColor Cyan
Write-Host ("内存使用率:{0}%" -f $memStatus.MemoryLoadPercent)
Write-Host ("物理内存总量:{0} GB" -f $memStatus.TotalPhysicalGB)
Write-Host ("物理内存可用:{0} GB" -f $memStatus.AvailablePhysicalGB)
Write-Host ("物理内存已用:{0} GB" -f $memStatus.UsedPhysicalGB)
Write-Host ("页面文件总量:{0} GB" -f $memStatus.TotalPageFileGB)
Write-Host ("虚拟内存总量:{0} GB" -f $memStatus.TotalVirtualGB)
Write-Host ("虚拟内存可用:{0} GB" -f $memStatus.AvailableVirtualGB)
1
2
3
4
5
6
7
8
=== 系统内存状态 ===
内存使用率:68%
物理内存总量:31.88 GB
物理内存可用:10.21 GB
物理内存已用:21.67 GB
页面文件总量:35.88 GB
虚拟内存总量:131,072.00 GB
虚拟内存可用:130,882.16 GB

从结果可以看到,GlobalMemoryStatusEx 返回的内存使用率百分比为 68%,物理内存总量约 32 GB,与系统实际配置一致。虚拟内存总量显示为 128 TB,这是 64 位 Windows 的虚拟地址空间上限,并非实际的物理存储。

通过 Win32 API 操作剪贴板

PowerShell 5.1 中的 Get-ClipboardSet-Clipboard 虽然方便,但功能有限,无法处理非文本格式的剪贴板数据,也无法检测剪贴板是否包含特定格式的内容。Win32 剪贴板 API 提供了更精细的控制能力,包括打开/关闭剪贴板、清空剪贴板、检测数据格式等。下面的代码展示了如何用底层 API 实现剪贴板文本的读写,并提供比内置 cmdlet 更丰富的错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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
# 定义剪贴板相关的 Win32 API
$clipboardApiDefinition = @'
using System;
using System.Runtime.InteropServices;
using System.Text;

public class ClipboardApi
{
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenClipboard(IntPtr hWndNewOwner);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CloseClipboard();

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EmptyClipboard();

[DllImport("user32.dll", SetLastError = true)]
public static extern bool IsClipboardFormatAvailable(uint format);

[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr GetClipboardData(uint uFormat);

[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalLock(IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GlobalUnlock(IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalFree(IntPtr hMem);

// CF_UNICODETEXT = 13
public const uint CF_UNICODETEXT = 13;
// GMEM_MOVEABLE = 0x0002
public const uint GMEM_MOVEABLE = 0x0002;
}
'@

Add-Type -TypeDefinition $clipboardApiDefinition -Language CSharp

# 封装:检测剪贴板是否包含文本
function Test-ClipboardTextAvailable {
[ClipboardApi]::IsClipboardFormatAvailable([ClipboardApi]::CF_UNICODETEXT)
}

# 封装:通过 Win32 API 读取剪贴板文本
function Get-ClipboardTextNative {
if (-not (Test-ClipboardTextAvailable)) {
Write-Warning "剪贴板中不包含文本数据"
return
}

if (-not [ClipboardApi]::OpenClipboard([IntPtr]::Zero)) {
$err = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
Write-Error "无法打开剪贴板,错误码:$err"
return
}

try {
$handle = [ClipboardApi]::GetClipboardData([ClipboardApi]::CF_UNICODETEXT)
if ($handle -eq [IntPtr]::Zero) {
Write-Error "获取剪贴板数据失败"
return
}

$pointer = [ClipboardApi]::GlobalLock($handle)
if ($pointer -eq [IntPtr]::Zero) {
Write-Error "锁定内存失败"
return
}

try {
$text = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($pointer)
Write-Output $text
}
finally {
[ClipboardApi]::GlobalUnlock($handle) | Out-Null
}
}
finally {
[ClipboardApi]::CloseClipboard() | Out-Null
}
}

# 测试剪贴板操作
$hasText = Test-ClipboardTextAvailable
Write-Host "剪贴板包含文本数据:$hasText" -ForegroundColor Cyan

if ($hasText) {
$clipContent = Get-ClipboardTextNative
if ($clipContent) {
$preview = $clipContent
if ($preview.Length -gt 80) {
$preview = $preview.Substring(0, 80) + "..."
}
Write-Host ("剪贴板内容预览:{0}" -f $preview) -ForegroundColor Yellow
Write-Host ("内容长度:{0} 字符" -f $clipContent.Length)
}
}
1
2
3
剪贴板包含文本数据:True
剪贴板内容预览:Hello from PowerShell Win32 API!
内容长度:32 字符

上面的代码通过 OpenClipboardGetClipboardDataGlobalLock 等一系列 API 调用完成了剪贴板文本的读取。注意每个 API 调用都有对应的错误检查,且使用 try/finally 确保资源被正确释放。这种编程模式在调用 Win32 API 时非常重要,因为原生 API 不会像 .NET 那样自动管理资源。

控制窗口的显示状态

在自动化测试和运维场景中,我们经常需要控制窗口的行为,比如隐藏某个窗口、最小化所有窗口、或者判断窗口是否处于响应状态。ShowWindowIsWindow 等 Win32 API 为此提供了底层支持。下面的代码展示了如何查找窗口句柄并控制窗口的显示状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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
# 定义窗口管理相关的 Win32 API
$windowApiDefinition = @'
using System;
using System.Runtime.InteropServices;
using System.Text;

public class WindowApi
{
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindow(IntPtr hWnd);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);

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

[DllImport("user32.dll", SetLastError = true)]
public static extern bool SetForegroundWindow(IntPtr hWnd);

// ShowWindow 命令常量
public const int SW_HIDE = 0;
public const int SW_SHOWNORMAL = 1;
public const int SW_SHOWMINIMIZED = 2;
public const int SW_SHOWMAXIMIZED = 3;
public const int SW_RESTORE = 9;
}
'@

Add-Type -TypeDefinition $windowApiDefinition -Language CSharp

# 封装:按窗口标题查找窗口句柄
function Find-WindowByTitle {
param(
[Parameter(Mandatory)]
[string]$Title
)

$handle = [WindowApi]::FindWindow($null, $Title)
if ($handle -eq [IntPtr]::Zero) {
Write-Warning "未找到标题为「$Title」的窗口"
return $null
}
$handle
}

# 封装:获取窗口标题
function Get-WindowTitle {
param(
[Parameter(Mandatory)]
[IntPtr]$Handle
)

if (-not [WindowApi]::IsWindow($handle)) {
Write-Warning "无效的窗口句柄:$handle"
return
}

$sb = New-Object System.Text.StringBuilder(256)
[WindowApi]::GetWindowText($handle, $sb, $sb.Capacity) | Out-Null
$sb.ToString()
}

# 封装:控制窗口显示状态
function Set-WindowState {
param(
[Parameter(Mandatory)]
[IntPtr]$Handle,

[Parameter(Mandatory)]
[ValidateSet('Hide', 'ShowNormal', 'Minimize', 'Maximize', 'Restore')]
[string]$State
)

$stateMap = @{
Hide = [WindowApi]::SW_HIDE
ShowNormal = [WindowApi]::SW_SHOWNORMAL
Minimize = [WindowApi]::SW_SHOWMINIMIZED
Maximize = [WindowApi]::SW_SHOWMAXIMIZED
Restore = [WindowApi]::SW_RESTORE
}

$cmdValue = $stateMap[$State]
$result = [WindowApi]::ShowWindow($handle, $cmdValue)

if (-not $result) {
$err = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
Write-Error "ShowWindow 调用失败,错误码:$err"
return
}

$isVisible = [WindowApi]::IsWindowVisible($handle)
Write-Host ("窗口状态已设置为:{0},当前可见性:{1}" -f $State, $isVisible)
}

# 演示:查找记事本窗口并控制其状态
$notepadTitle = "Untitled - Notepad"
$handle = Find-WindowByTitle -Title $notepadTitle

if ($handle) {
$currentTitle = Get-WindowTitle -Handle $handle
Write-Host "找到窗口:$currentTitle" -ForegroundColor Cyan
Write-Host "窗口句柄:$handle"

# 最小化窗口
Write-Host "`n--- 最小化窗口 ---"
Set-WindowState -Handle $handle -State Minimize

Start-Sleep -Milliseconds 500

# 恢复窗口
Write-Host "`n--- 恢复窗口 ---"
Set-WindowState -Handle $handle -State Restore

Start-Sleep -Milliseconds 500

# 最大化窗口
Write-Host "`n--- 最大化窗口 ---"
Set-WindowState -Handle $handle -State Maximize

Start-Sleep -Milliseconds 500

# 恢复到正常状态
Write-Host "`n--- 恢复到正常大小 ---"
Set-WindowState -Handle $handle -State Restore
}
else {
Write-Host "请先打开记事本(Notepad),然后重新运行此脚本" -ForegroundColor Yellow
Write-Host "提示:也可以尝试查找其他窗口,如修改 -Title 参数" -ForegroundColor Yellow

# 列出一些常见窗口标题供参考
Write-Host "`n你可以尝试以下窗口标题:"
$commonWindows = @("Calculator", "Task Manager", "Windows PowerShell")
foreach ($win in $commonWindows) {
$testHandle = [WindowApi]::FindWindow($null, $win)
$status = if ($testHandle -ne [IntPtr]::Zero) { "存在" } else { "未找到" }
Write-Host (" {0} - {1}" -f $win, $status)
}
}
1
2
3
4
5
6
7
请先打开记事本(Notepad),然后重新运行此脚本
提示:也可以尝试查找其他窗口,如修改 -Title 参数

你可以尝试以下窗口标题:
Calculator - 未找到
Task Manager - 存在
Windows PowerShell - 存在

上述代码通过 FindWindow 按窗口标题查找句柄,然后使用 ShowWindow 配合不同的命令常量(SW_MINIMIZESW_MAXIMIZESW_RESTORE 等)来控制窗口的显示状态。在实际自动化场景中,这种能力可以用来在执行 UI 测试前确保窗口处于正确状态,或在无人值守任务中隐藏不必要的窗口。

注意事项

  1. Add-Type 无法重复定义:同一个类型名称在一个 PowerShell 会话中只能通过 Add-Type 定义一次。如果修改了 API 签名,需要重启 PowerShell 会话才能生效。建议在开发阶段使用不同的类名或重启控制台进行测试。

  2. 数据类型映射要准确:Win32 API 中的 BOOL 对应 C# 的 boolDWORD 对应 uintHANDLE 对应 IntPtrLPCWSTR 对应 string(带 CharSet.Auto)。类型映射错误会导致调用失败甚至进程崩溃,务必查阅 PInvoke.net 获取准确的签名。

  3. 始终检查返回值和错误码:大多数 Win32 API 通过返回值(如 BOOLHANDLE)指示成功或失败,并通过 SetLastError = true 配合 Marshal.GetLastWin32Error() 获取详细错误信息。不要忽略返回值检查。

  4. 注意 32 位和 64 位兼容性:在 64 位系统上运行 32 位 PowerShell 时,部分 API 的行为可能不同(如窗口句柄大小为 4 字节而非 8 字节)。建议始终使用 64 位 PowerShell 运行涉及 Win32 API 的脚本,以确保指针类型的一致性。

  5. 使用 try/finally 确保资源释放:Win32 API 中的资源(如剪贴板的打开状态、内存锁定的指针)需要手动释放。一定要用 try/finally 块包裹相关调用,确保即使发生异常也能正确清理资源,避免内存泄漏或系统状态异常。

  6. 参考官方文档和 PInvoke.net:Win32 API 数量庞大,参数和常量的含义需要查阅 Windows SDK 文档。PInvoke.net 网站提供了大量现成的 C# 签名定义,可以直接复制使用,省去手动映射的麻烦。在调用不熟悉的 API 之前,务必先在测试环境中验证其行为。

PowerShell 技能连载 - Pester 高级测试

适用于 PowerShell 5.1 及以上版本

在 PowerShell 生态中,Pester 已经成为事实上的测试框架标准。从简单的断言到复杂的端到端验证,Pester 能够覆盖各种测试场景。然而,许多开发者仅停留在 Should -Be 的基本用法上,对于 Mock、BeforeAll/AfterAll、参数化测试、Code Coverage 等高级特性缺乏了解。

随着 DevOps 实践的深入,持续集成流水线对自动化测试的要求越来越高。一份高质量的 Pester 测试套件不仅能捕捉回归缺陷,还能作为模块行为的”可执行文档”。掌握 Pester 的高级技巧,可以显著提升测试的可维护性、执行效率和覆盖广度。

本文将围绕四个高级主题展开:参数化测试(Data-Driven Tests)、Mock 与 Assert-VerifiableMock、自定义 Should 断言运算符,以及 Code Coverage 集成。每个主题都配有可直接运行的完整示例。

参数化测试:用 TestCases 消除重复

当你需要对同一个函数的多组输入进行验证时,逐一编写 It 块会导致大量重复代码。Pester 提供了 TestCases 参数,可以将测试数据与测试逻辑分离,一个 It 块即可覆盖所有场景。

下面的例子定义了一个字符串处理函数,然后使用 TestCases 同时验证多种输入组合:

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
# 被测函数:移除字符串首尾的空白字符并转为标题大小写
function Format-TitleCase {
param(
[Parameter(Mandatory)]
[string]$Text
)
$trimmed = $Text.Trim()
$words = $trimmed -split '\s+'
$result = foreach ($word in $words) {
$word.Substring(0, 1).ToUpper() + $word.Substring(1).ToLower()
}
$result -join ' '
}

# Pester 测试
Describe 'Format-TitleCase 参数化测试' {
It '应将 "<Input>" 转换为 "<Expected>"' -TestCases @(
@{ Input = 'hello world'; Expected = 'Hello World' }
@{ Input = ' powershell '; Expected = 'Powershell' }
@{ Input = 'a b c d'; Expected = 'A B C D' }
@{ Input = 'MIXED case INPUT'; Expected = 'Mixed Case Input' }
@{ Input = ' too many spaces '; Expected = 'Too Many Spaces' }
) {
param($Input, $Expected)
Format-TitleCase -Text $Input | Should -Be $Expected
}
}

执行结果示例:

1
2
3
4
5
6
7
8
Describing Format-TitleCase 参数化测试
[+] 应将 "hello world" 转换为 "Hello World" 32ms
[+] 应将 " powershell " 转换为 "Powershell" 18ms
[+] 应将 "a b c d" 转换为 "A B C D" 12ms
[+] 应将 "MIXED case INPUT" 转换为 "Mixed Case Input" 15ms
[+] 应将 " too many spaces " 转换为 "Too Many Spaces" 11ms
Tests completed in 188ms
Tests Passed: 5, Failed: 0, Skipped: 0 NotRun: 0

使用 TestCases 的好处在于:每条数据会生成独立的测试用例,某一条失败不影响其他数据的验证,测试报告中也能清晰看到具体是哪组输入出了问题。<Input><Expected> 这样的占位符会在输出中被实际值替换,方便定位问题。

Mock 与依赖隔离

在单元测试中,被测函数往往会调用外部依赖(文件系统、数据库、REST API 等)。如果不加以隔离,测试结果会受到外部环境的影响,导致测试时好时坏(Flaky Tests)。Pester 的 Mock 命令可以将任意命令替换为桩实现,让测试专注于验证逻辑本身。

下面的示例演示如何 Mock Invoke-RestMethod,使测试在不发送真实网络请求的情况下验证函数行为:

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
# 被测函数:调用天气 API 并格式化输出
function Get-WeatherReport {
param(
[Parameter(Mandatory)]
[string]$City
)
$uri = "https://api.weather.example.com/v1/current?city=$City"
$response = Invoke-RestMethod -Uri $uri -Method Get
if ($response.Temperature -gt 35) {
return "[$City] 高温预警:当前 $($response.Temperature)°C,天气 $($response.Condition)"
}
return "[$City] 当前 $($response.Temperature)°C,天气 $($response.Condition)"
}

# Pester 测试
Describe 'Get-WeatherReport' {
BeforeAll {
# Mock Invoke-RestMethod,返回预设的天气数据
Mock Invoke-RestMethod {
param($Uri)
if ($Uri -match 'Beijing') {
return @{
Temperature = 38
Condition = '晴'
}
}
return @{
Temperature = 22
Condition = '多云'
}
}
}

It '当温度超过 35 度时应包含高温预警' {
$result = Get-WeatherReport -City 'Beijing'
$result | Should -Match '高温预警'
$result | Should -Match '38°C'
}

It '正常温度应返回天气信息而无预警' {
$result = Get-WeatherReport -City 'Shanghai'
$result | Should -Not -Match '高温预警'
$result | Should -Match '22°C'
}

It '应调用 Invoke-RestMethod 一次' {
# 先调用一次被测函数
Get-WeatherReport -City 'Guangzhou' | Out-Null
# 验证 Mock 被调用了恰好 1 次
Should -Invoke Invoke-RestMethod -Exactly 1 -Scope It
}
}

执行结果示例:

1
2
3
4
5
6
Describing Get-WeatherReport
[+] 当温度超过 35 度时应包含高温预警 45ms
[+] 正常温度应返回天气信息而无预警 28ms
[+] 应调用 Invoke-RestMethod 一次 19ms
Tests completed in 92ms
Tests Passed: 3, Failed: 0, Skipped: 0 NotRun: 0

这里有几个关键点值得注意。BeforeAll 块中的 Mock 对当前 Describe 内的所有 It 块生效。Mock 命令通过拦截指定命令的调用,将其重定向到自定义脚本块。Should -Invoke(旧版为 Assert-MockCalled)用于验证 Mock 是否按预期被调用。这种方式特别适合测试错误处理分支——你可以让 Mock 抛出异常,验证被测函数的容错逻辑是否正确。

自定义 Should 断言运算符

Pester v5 允许通过 Add-ShouldOperator 注册自定义断言运算符。当你需要在多个测试文件中复用同一种复杂的验证逻辑时,自定义运算符比在每个测试中写重复的 Where-Object 管道要优雅得多。

下面展示如何创建一个 BeValidJson 运算符,用于断言字符串是否为合法的 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
# 首先定义自定义断言(通常放在测试的 BeforeAll 或单独的 .Tests.Setup.ps1 中)
BeforeAll {
Add-ShouldOperator -Name BeValidJson -Test {
param($ActualValue, [switch]$Negate)
# 尝试用 ConvertFrom-Json 解析,如果失败则不是合法 JSON
$isValid = $true
try {
ConvertFrom-Json -InputObject $ActualValue -ErrorAction Stop | Out-Null
}
catch {
$isValid = $false
}

if ($Negate) {
$isValid = -not $isValid
}

if (-not $isValid) {
if ($Negate) {
$failureMessage = "Expected the string to NOT be valid JSON, but it parsed successfully."
}
else {
$failureMessage = "Expected the string to be valid JSON, but parsing failed. Input: $($ActualValue.Substring(0, [Math]::Min(50, $ActualValue.Length)))..."
}
throw [Pester.Factory]::CreateShouldErrorResult($failureMessage, $ActualValue)
}
}
}

# 使用自定义运算符进行测试
Describe 'BeValidJson 自定义断言' {
It '合法的 JSON 字符串应通过验证' {
$json = '{"name": "PowerShell", "version": 7.4}'
$json | Should -BeValidJson
}

It '非法的 JSON 字符串应验证失败' {
$badJson = '{name: missing_quotes}'
$badJson | Should -Not -BeValidJson
}

It 'JSON 数组也应被正确识别' {
$array = '[1, 2, 3, "four"]'
$array | Should -BeValidJson
}

It '嵌套结构应通过验证' {
$nested = '{"users": [{"id": 1}, {"id": 2}], "total": 2}'
$nested | Should -BeValidJson
}
}

执行结果示例:

1
2
3
4
5
6
7
Describing BeValidJson 自定义断言
[+] 合法的 JSON 字符串应通过验证 36ms
[+] 非法的 JSON 字符串应验证失败 14ms
[+] JSON 数组也应被正确识别 11ms
[+] 嵌套结构应通过验证 13ms
Tests completed in 74ms
Tests Passed: 4, Failed: 0, Skipped: 0 NotRun: 0

自定义运算符的核心是 $Negate 参数,它处理 Should -Not 的反转逻辑。当断言失败时,通过抛出 [Pester.Factory]::CreateShouldErrorResult() 来提供清晰的错误消息。将自定义运算符的定义放在一个独立的 .ps1 文件中,然后在测试启动时通过 -ConfigurationScriptBlock 参数加载,就能在整个测试套件中复用。

Code Coverage 与 CI 集成

在团队协作中,仅知道”测试通过”还不够,你还需要量化测试覆盖了多少代码路径。Pester v5 内置了 Code Coverage 功能,可以告诉你哪些行、哪些函数从未被测试触达。结合 CI/CD 流水线,可以设置覆盖率阈值门禁,确保代码质量不会随时间退化。

下面的示例展示如何在 CI 脚本中运行 Pester 并生成覆盖率报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# run-tests.ps1 — CI 流水线中调用的测试入口脚本
param(
[double]$CoverageThreshold = 80
)

# 安装或导入 Pester
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge '5.0' })) {
Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck
}
Import-Module Pester

# 配置 Pester 运行
$configuration = @{
Run = @{
Path = '.\tests'
}
Output = @{
Verbosity = 'Detailed'
}
CodeCoverage = @{
Enabled = $true
Path = '.\src\*.ps1', '.\src\*.psm1'
CoverageThreshold = $CoverageThreshold
OutputFormat = 'JaCoCo'
OutputPath = '.\coverage.xml'
}
TestResult = @{
Enabled = $true
OutputFormat = 'NUnitXml'
OutputPath = '.\testResults.xml'
}
}

$result = Invoke-Pester -Configuration $configuration

# 输出覆盖率摘要
if ($result.CodeCoverage) {
$covered = $result.CodeCoverage.NumberOfCommandsExecuted
$total = $result.CodeCoverage.NumberOfCommandsAnalyzed
$percent = if ($total -gt 0) { [math]::Round(($covered / $total) * 100, 1) } else { 0 }
Write-Host "代码覆盖率:$covered / $total 条命令已覆盖 ($percent%)"
}

# 根据结果设置退出码
if ($result.FailedCount -gt 0) {
Write-Host "测试失败:$($result.FailedCount) 个用例未通过" -ForegroundColor Red
exit 1
}
if ($result.CodeCoverage -and $percent -lt $CoverageThreshold) {
Write-Host "覆盖率 $percent% 低于阈值 $CoverageThreshold%" -ForegroundColor Yellow
exit 2
}
Write-Host "所有测试通过,覆盖率达标" -ForegroundColor Green
exit 0

执行结果示例:

1
2
3
4
5
6
7
8
9
10
Starting discovery in 1 files.
Discovery finished in 245ms.
Running tests.
[+] .\tests\Format-TitleCase.Tests.ps1 188ms (5 passed)
[+] .\tests\Get-WeatherReport.Tests.ps1 92ms (3 passed)
[+] .\tests\BeValidJson.Tests.ps1 74ms (4 passed)
Tests completed in 554ms
Tests Passed: 12, Failed: 0, Skipped: 0 NotRun: 0
代码覆盖率:87 / 102 条命令已覆盖 (85.3%)
所有测试通过,覆盖率达标

这段脚本做了三件关键的事情。第一,通过 CodeCoverage 配置项指定需要追踪的源码路径和覆盖率阈值,低于阈值时以非零退出码退出,CI 流水线可以据此拦截不合格的提交。第二,OutputFormat 设为 JaCoCo 后生成标准格式的覆盖率报告,可被 Azure DevOps、GitLab CI、SonarQube 等工具直接消费。第三,TestResult 生成 NUnit 格式的测试结果,便于 CI 平台展示测试趋势和失败详情。

注意事项

  1. Mock 作用域要精确控制Mock 默认作用于当前及子作用域。如果你在 Describe 级别 Mock 了 Get-Content,该 Describe 下所有 ContextIt 块都会受影响。如果只想在特定 It 中生效,就把 Mock 写在那个 It 内部。滥用全局 Mock 会让测试难以理解和维护。

  2. 避免在 Mock 中执行真实操作:Mock 的脚本块中不应调用外部服务或修改文件系统。一旦 Mock 内部产生了副作用,就违背了隔离测试的初衷。如果 Mock 逻辑很复杂,考虑先写一个简单的桩函数,再 Mock 这个桩函数。

  3. TestCases 中的特殊字符需要转义:当测试数据包含双引号、美元符号、反引号等 PowerShell 特殊字符时,哈希表中的字符串要用单引号包裹,或者使用反引号转义,否则解析器会提前展开变量。

  4. Code Coverage 不是银弹:80% 的行覆盖率不代表 80% 的逻辑覆盖率。条件分支的短路求值、异常路径、边界值等场景需要专门的测试用例来覆盖。不要为了凑覆盖率数字而写无意义的断言。

  5. Pester v5 与 v4 的差异:v5 对配置模型做了大幅重构,Invoke-Pester 的参数风格完全不同。如果你在迁移旧测试套件,注意 Assert-MockCalled 已被 Should -Invoke 替代,TestDriveTestRegistry 的行为也有细微变化。建议统一使用 v5 的新语法。

  6. 在 CI 中固定 Pester 版本:不同版本的 Pester 行为差异较大,CI 流水线中务必通过 -RequiredVersion 锁定版本号,或者在 pwsh 启动时显式 Import-Module Pester -RequiredVersion 5.x.x。否则某天 Pester 发布了大版本更新,你的流水线可能突然大面积失败。

PowerShell 技能连载 - Azure 存储管理

适用于 PowerShell 5.1 及以上版本

Azure 存储服务概述

在现代云原生架构中,对象存储已经成为应用数据持久化的首选方案。Azure Blob Storage 作为微软 Azure 平台的核心存储服务,支持海量非结构化数据的存放,包括文档、镜像、日志文件、备份归档等。无论是 DevOps 流水线中的制品管理,还是数据分析管道中的原始数据暂存,Blob Storage 都扮演着不可替代的角色。

对于运维工程师和自动化开发者来说,通过 PowerShell 管理 Azure 存储资源可以带来显著的效率提升。Azure 提供了两个主要的 PowerShell 模块:Az.Storage(基于 Az 资源管理器)和 Azure.Storage(经典管理模式)。本文将围绕推荐的 Az.Storage 模块,介绍存储账户创建、Blob 容器操作、文件上传下载以及存储访问策略配置等常见操作。

掌握这些操作后,你可以将存储管理无缝集成到现有的自动化脚本中,实现从基础设施配置到数据流转的全链路 PowerShell 自动化。

连接 Azure 并创建存储账户

在操作 Blob Storage 之前,需要先安装 Az 模块并通过 Connect-AzAccount 完成身份认证。认证通过后,即可创建资源组和存储账户。存储账户是所有 Blob、文件、队列和表服务的顶级命名空间,其名称在 Azure 全局范围内必须唯一。

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
# 安装 Az 模块(仅首次需要)
Install-Module -Name Az -Repository PSGallery -Force -Scope CurrentUser

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

# 选择目标订阅
$subscriptionId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Set-AzContext -SubscriptionId $subscriptionId

# 创建资源组
$resourceGroupName = "rg-storage-demo"
$location = "eastasia"
$null = New-AzResourceGroup -Name $resourceGroupName -Location $location -Force
Write-Host "资源组 [$resourceGroupName] 已创建于 [$location]"

# 创建存储账户(Standard LRS 冗余级别,适合开发测试)
$storageAccountName = "st$(Get-Random -Minimum 100000 -Maximum 999999)"
$storageSku = "Standard_LRS"
$storageKind = "StorageV2"

$storageAccount = New-AzStorageAccount `
-ResourceGroupName $resourceGroupName `
-Name $storageAccountName `
-SkuName $storageSku `
-Kind $storageKind `
-Location $location `
-EnableHttpsTrafficOnly $true `
-MinimumTlsVersion "TLS1_2"

Write-Host "存储账户 [$storageAccountName] 已创建"
Write-Host " SKU: $($storageAccount.Sku.Name)"
Write-Host " 类型: $($storageAccount.Kind)"
Write-Host " 位置: $($storageAccount.Location)"
Write-Host " 访问端点: $($storageAccount.PrimaryEndpoints.Blob)"
1
2
3
4
5
6
资源组 [rg-storage-demo] 已创建于 [eastasia]
存储账户 [st729384] 已创建
SKU: Standard_LRS
类型: StorageV2
位置: eastasia
访问端点: https://st729384.blob.core.windows.net/

Blob 容器操作与文件上传

存储账户创建完成后,下一步是创建 Blob 容器(Container),它类似于文件夹的概念,用于组织和隔离不同业务场景的 Blob 对象。Azure 提供三种访问级别:Private(默认,需认证)、Blob(允许匿名读取 Blob)和 Container(允许匿名列出和读取)。对于生产环境,建议始终保持 Private,通过 SAS 令牌或 Azure AD 认证控制访问。

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
# 获取存储账户上下文(后续操作都需要)
$ctx = $storageAccount.Context

# 创建 Blob 容器
$containerName = "app-logs"
$container = New-AzStorageContainer -Name $containerName -Context $ctx -Permission Off
Write-Host "容器 [$containerName] 已创建,访问级别: $($container.PublicAccess)"

# 准备测试文件
$tempDir = Join-Path -Path $env:TEMP -ChildPath "storage-demo"
$null = New-Item -Path $tempDir -ItemType Directory -Force

$logFiles = @(
@{ Name = "app-2025-10-01.log"; Content = "2025-10-01 INFO Application started" }
@{ Name = "app-2025-10-02.log"; Content = "2025-10-02 INFO Database connected" }
@{ Name = "app-2025-10-03.log"; Content = "2025-10-03 WARN Memory usage high" }
)

foreach ($log in $logFiles) {
$filePath = Join-Path -Path $tempDir -ChildPath $log.Name
Set-Content -Path $filePath -Value $log.Content -Encoding UTF8
Write-Host "已生成本地文件: $($log.Name)"
}

# 批量上传文件到 Blob 容器
Write-Host "`n开始上传文件到 Blob 容器..."
$localFiles = Get-ChildItem -Path $tempDir -Filter "*.log"
foreach ($file in $localFiles) {
$blob = Set-AzStorageBlobContent `
-File $file.FullName `
-Container $containerName `
-Blob $file.Name `
-Context $ctx `
-StandardBlobTier Hot

Write-Host " 已上传: $($blob.Name) | 大小: $($blob.Length) 字节 | 层级: $($blob.StandardBlobTier)"
}

# 列出容器中的所有 Blob
Write-Host "`n容器 [$containerName] 中的文件列表:"
$blobs = Get-AzStorageBlob -Container $containerName -Context $ctx
foreach ($blob in $blobs) {
Write-Host " - $($blob.Name) ($($blob.Length) 字节) 最后修改: $($blob.LastModified.ToString('yyyy-MM-dd HH:mm:ss'))"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
容器 [app-logs] 已创建,访问级别: Off
已生成本地文件: app-2025-10-01.log
已生成本地文件: app-2025-10-02.log
已生成本地文件: app-2025-10-03.log

开始上传文件到 Blob 容器...
已上传: app-2025-10-01.log | 大小: 38 字节 | 层级: Hot
已上传: app-2025-10-02.log | 大小: 40 字节 | 层级: Hot
已上传: app-2025-10-03.log | 大小: 38 字节 | 层级: Hot

容器 [app-logs] 中的文件列表:
- app-2025-10-01.log (38 字节) 最后修改: 2025-10-07 10:30:15
- app-2025-10-02.log (40 字节) 最后修改: 2025-10-07 10:30:16
- app-2025-10-03.log (38 字节) 最后修改: 2025-10-07 10:30:17

生成 SAS 令牌与访问策略管理

在实际应用中,经常需要将 Blob 的访问权限临时授予外部系统或客户端应用,而不希望暴露存储账户的密钥。共享访问签名(Shared Access Signature,SAS)正是为此设计的。SAS 令牌可以精确控制访问权限(读、写、删除)、有效时间范围和允许的 IP 地址,是 Azure 存储安全模型的核心组成部分。

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
# 为整个容器生成 SAS 令牌(只读,有效期 1 小时)
$sasToken = New-AzStorageContainerSASToken `
-Name $containerName `
-Context $ctx `
-Permission "r" `
-StartTime (Get-Date) `
-ExpiryTime (Get-Date).AddHours(1)

Write-Host "容器 SAS 令牌(只读,1小时有效):"
Write-Host $sasToken

# 为单个 Blob 生成 SAS 令牌(读写,有效期 30 分钟)
$targetBlob = "app-2025-10-01.log"
$blobSasToken = New-AzStorageBlobSASToken `
-Container $containerName `
-Blob $targetBlob `
-Context $ctx `
-Permission "rw" `
-StartTime (Get-Date) `
-ExpiryTime (Get-Date).AddMinutes(30)

Write-Host "`nBlob [$targetBlob] SAS 令牌(读写,30分钟有效):"
Write-Host $blobSasToken

# 使用 SAS 令牌通过 HTTP 下载 Blob
$fullUrl = "$($ctx.BlobEndPoint)$containerName/$targetBlob$blobSasToken"
$downloadPath = Join-Path -Path $tempDir -ChildPath "downloaded-$targetBlob"
Invoke-WebRequest -Uri $fullUrl -OutFile $downloadPath
Write-Host "`n通过 SAS URL 下载成功: $downloadPath"
Write-Host "文件内容: $((Get-Content -Path $downloadPath -Raw).Trim())"

# 配置存储访问策略(用于长期管理的最佳实践)
$accessPolicy = @{
StartTime = Get-Date
ExpiryTime = (Get-Date).AddDays(30)
Permission = "rwl"
}

$policyName = "app-log-write-policy"
$null = Set-AzStorageContainerAcl `
-Name $containerName `
-Context $ctx `
-Policy $accessPolicy `
-PolicyName $policyName

Write-Host "`n已创建存储访问策略: [$policyName]"
Write-Host " 权限: $($accessPolicy.Permission)(读写+列表)"
Write-Host " 有效期: $($accessPolicy.StartTime.ToString('yyyy-MM-dd')) - $($accessPolicy.ExpiryTime.ToString('yyyy-MM-dd'))"

# 查看容器上的所有访问策略
$acl = Get-AzStorageContainerAcl -Name $containerName -Context $ctx
foreach ($policy in $acl) {
Write-Host "`n策略: $($policy.Id)"
Write-Host " 权限: $($policy.AccessPolicy.Permission)"
Write-Host " 起始: $($policy.AccessPolicy.StartTime.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Host " 过期: $($policy.AccessPolicy.ExpiryTime.ToString('yyyy-MM-dd HH:mm:ss'))"
}

# 清理临时文件
Remove-Item -Path $tempDir -Recurse -Force
Write-Host "`n临时文件已清理"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
容器 SAS 令牌(只读,1小时有效):
?sv=2023-01-03&ss=b&srt=co&sp=r&se=2025-10-07T11:30:00Z&st=2025-10-07T10:30:00Z&spr=https&sig=abc123XYZ...

Blob [app-2025-10-01.log] SAS 令牌(读写,30分钟有效):
?sv=2023-01-03&ss=b&srt=sco&sp=rw&se=2025-10-07T11:00:00Z&st=2025-10-07T10:30:00Z&spr=https&sig=def456UVW...

通过 SAS URL 下载成功: /tmp/storage-demo/downloaded-app-2025-10-01.log
文件内容: 2025-10-01 INFO Application started

已创建存储访问策略: [app-log-write-policy]
权限: rwl(读写+列表)
有效期: 2025-10-07 - 2025-11-06

策略: app-log-write-policy
权限: rwl
起始: 2025-10-07 10:30:00
过期: 2025-11-06 10:30:00

临时文件已清理

注意事项

  1. 存储账户命名规则:存储账户名称只能包含小写字母和数字,长度为 3-24 个字符,且在 Azure 全局范围内必须唯一。建议在脚本中使用 Get-Random 或业务前缀加哈希的方式生成名称,避免因名称冲突导致创建失败。

  2. SAS 令牌安全保管:SAS 令牌本质上是一个携带权限信息的 URL 参数,任何获得该令牌的人都可以在有效期内访问对应资源。在日志记录、API 响应和脚本输出中应避免打印完整的 SAS URL。建议使用存储访问策略(Stored Access Policy)来管理 SAS,这样可以在令牌泄露时通过撤销策略立即失效。

  3. Blob 层级选择:Azure Blob Storage 提供热(Hot)、冷(Cool)和归档(Archive)三种访问层级。热层级访问性能最优但存储成本最高,归档层级存储成本最低但读取需要数小时的解冻时间。在脚本中可以通过 StandardBlobTier 参数在上传时指定层级,也可以对已有 Blob 调用 Set-AzStorageBlobTier 进行层级转换。

  4. Az 模块版本兼容性Az.Storage 模块更新频繁,不同版本之间的 cmdlet 参数可能有差异。建议在脚本开头通过 #Requires -Modules @{ ModuleName="Az.Storage"; ModuleVersion="5.0.0" } 声明最低版本要求,确保脚本在目标环境中能正常运行。

  5. 大文件上传策略:对于超过 256 MB 的文件,应使用分块上传(Block Blob)方式,将文件拆分为多个 Block 分别上传后提交组合。Set-AzStorageBlobContent 会自动处理分块逻辑,但在网络不稳定的环境下,建议手动控制分块大小并实现重试机制,避免因单次网络中断导致整个上传任务失败。

  6. 资源清理与成本控制:测试和演示完毕后,务必通过 Remove-AzStorageAccountRemove-AzResourceGroup 清理不再使用的资源。存储账户即使不活跃也会产生最低存储费用和计费周期费用。建议在非生产环境中为资源组添加自动过期标签,并通过 Azure Policy 或定时脚本定期清理过期资源。

PowerShell 技能连载 - WMI 与 CIM 查询

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

Windows 管理规范(WMI)是 Windows 平台上用于管理和查询系统信息的核心基础设施。从硬盘序列号到操作系统版本,从网络适配器状态到 BIOS 信息,WMI 几乎能访问系统的每一个角落。长期以来,PowerShell 通过 Get-WmiObject cmdlet 为管理员提供了便捷的 WMI 查询能力。

然而,Get-WmiObject 基于 DCOM/RPC 协议进行远程通信,存在防火墙配置复杂、性能开销大等问题。从 PowerShell 3.0 开始,微软引入了基于 CIM(Common Information Model)标准的新一代 cmdlet——Get-CimInstance。CIM cmdlet 默认使用 WS-Man(WinRM)协议,不仅更安全、更高效,而且与跨平台标准 DMTF 对齐,代表了 Windows 系统管理的未来方向。

本文将对比 WMI 和 CIM 两套 cmdlet 的使用方式,并通过实际场景演示如何用 CIM 查询硬件信息、远程管理服务器,以及构建高效的系统清单脚本。

WMI 与 CIM 的基本对比

我们先来看两套 cmdlet 在查询本地系统信息时的差异。以下代码分别使用 Get-WmiObjectGet-CimInstance 获取操作系统基本信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 传统 WMI 方式(PowerShell 2.0 引入,已标记为弃用)
$os = Get-WmiObject -Class Win32_OperatingSystem
Write-Host "操作系统: $($os.Caption)"
Write-Host "版本: $($os.Version)"
Write-Host "安装日期: $($os.InstallDate)"
Write-Host "上次启动: $($os.LastBootUpTime)"

# 现代 CIM 方式(PowerShell 3.0 引入,推荐使用)
$osCim = Get-CimInstance -ClassName Win32_OperatingSystem
Write-Host "操作系统: $($osCim.Caption)"
Write-Host "版本: $($osCim.Version)"
Write-Host "安装日期: $($osCim.InstallDate)"
Write-Host "上次启动: $($osCim.LastBootUpTime)"

注意输出中日期格式的差异:Get-WmiObject 返回原始的 WMI 时间字符串(如 20250101120000.000000+480),而 Get-CimInstance 自动将其转换为可读的 DateTime 对象,这一点在日常使用中非常方便。

1
2
3
4
操作系统: Microsoft Windows 11 Pro
版本: 10.0.26100
安装日期: 2025-01-15 10:30:00
上次启动: 2025-10-05 22:14:33

查询硬件信息

CIM 在硬件清单收集方面非常强大。下面的脚本演示了如何一次性收集 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
# 查询 CPU 信息
$cpu = Get-CimInstance -ClassName Win32_Processor
foreach ($proc in $cpu) {
Write-Host "=== CPU 信息 ==="
Write-Host "名称: $($proc.Name)"
Write-Host "核心数: $($proc.NumberOfCores)"
Write-Host "逻辑处理器: $($proc.NumberOfLogicalProcessors)"
Write-Host "最大频率: $($proc.MaxClockSpeed) MHz"
Write-Host ""
}

# 查询物理内存
$mem = Get-CimInstance -ClassName Win32_PhysicalMemory
$totalMemGB = [math]::Round(($mem | Measure-Object -Property Capacity -Sum).Sum / 1GB, 2)
Write-Host "=== 内存信息 ==="
Write-Host "总内存: ${totalMemGB} GB"
foreach ($slot in $mem) {
$sizeGB = [math]::Round($slot.Capacity / 1GB, 2)
Write-Host " 插槽 $($slot.DeviceLocator): ${sizeGB} GB - $($slot.Speed) MHz"
}
Write-Host ""

# 查询磁盘信息
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
Write-Host "=== 磁盘信息 ==="
foreach ($disk in $disks) {
$freeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
$totalGB = [math]::Round($disk.Size / 1GB, 2)
$percent = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 1)
Write-Host " 盘符: $($disk.DeviceID)"
Write-Host " 总空间: ${totalGB} GB | 剩余: ${freeGB} GB | 可用率: ${percent}%"
Write-Host ""
}

这段脚本展示了 CIM 查询的典型模式:用 -ClassName 指定 WMI 类名,用 -Filter 进行服务端筛选(比客户端 Where-Object 更高效),然后用 foreach 遍历结果进行格式化输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== CPU 信息 ===
名称: Intel(R) Core(TM) i7-14700K
核心数: 20
逻辑处理器: 28
最大频率: 5600 MHz

=== 内存信息 ===
总内存: 32 GB
插槽 DIMM_A0: 16 GB - 5600 MHz
插槽 DIMM_B0: 16 GB - 5600 MHz

=== 磁盘信息 ===
盘符: C:
总空间: 512 GB | 剩余: 234.56 GB | 可用率: 45.8%

盘符: D:
总空间: 2048 GB | 剩余: 1567.89 GB | 可用率: 76.6%

使用 CIM 会话进行远程查询

CIM 相比 WMI 的一大优势是远程管理的简便性。通过 CimSession,你可以同时管理多台服务器,并且可以选择 DCOM 或 WinRM 协议。这在管理老旧系统(不支持 WinRM)时特别有用。

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
# 定义目标服务器列表
$servers = @("SRV-DC01", "SRV-WEB01", "SRV-DB01")

# 创建 CIM 会话(默认使用 WinRM)
$sessions = New-CimSession -ComputerName $servers -ErrorAction SilentlyContinue

# 批量查询所有服务器的操作系统和内存使用情况
$results = foreach ($session in $sessions) {
$os = Get-CimInstance -CimSession $session -ClassName Win32_OperatingSystem
foreach ($item in $os) {
$totalMemGB = [math]::Round($item.TotalVisibleMemorySize / 1MB, 2)
$freeMemGB = [math]::Round($item.FreePhysicalMemory / 1MB, 2)
$memUsage = [math]::Round(($item.TotalVisibleMemorySize - $item.FreePhysicalMemory) / $item.TotalVisibleMemorySize * 100, 1)

[PSCustomObject]@{
Server = $session.ComputerName
OS = $item.Caption
TotalMemGB = $totalMemGB
FreeMemGB = $freeMemGB
MemUsage = "$memUsage%"
Uptime = (Get-Date) - $item.LastBootUpTime
}
}
}

# 输出结果表格
$results | Format-Table -AutoSize

# 使用完毕后关闭会话
Remove-CimSession -CimSession $sessions

上面的脚本展示了 CIM 会话的完整生命周期:创建会话、执行查询、格式化输出、最后清理会话。注意 New-CimSession 一次性建立了到所有服务器的连接,后续查询复用这些连接,避免了重复认证的开销。

1
2
3
4
5
Server   OS                                    TotalMemGB FreeMemGB MemUsage Uptime
------ -- ---------- --------- -------- ------
SRV-DC01 Microsoft Windows Server 2022 Standard 32.00 12.45 61.1% 45.12:34:56
SRV-WEB01 Microsoft Windows Server 2022 Datacenter 64.00 28.90 54.8% 12.08:15:30
SRV-DB01 Microsoft Windows Server 2025 Standard 128.00 45.67 64.3% 02.16:42:18

使用 WQL 筛选与高级查询

WQL(WMI Query Language)是一种类似 SQL 的查询语言,可以在服务端完成数据过滤,减少网络传输量。Get-CimInstance-Filter 参数接受简化的 WQL 条件,而 -Query 参数则支持完整的 WQL 语句。

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
# 使用 -Filter 参数进行简单筛选(推荐方式)
# 查找所有已启用但未连接的网络适配器
$disconnectedAdapters = Get-CimInstance -ClassName Win32_NetworkAdapter `
-Filter "NetEnabled = true AND NetConnectionStatus != 2"

foreach ($adapter in $disconnectedAdapters) {
Write-Host "适配器: $($adapter.Name)"
Write-Host " MAC 地址: $($adapter.MACAddress)"
Write-Host " 速度: $([math]::Round($adapter.Speed / 1Mbps, 2)) Mbps"
Write-Host " 状态: 未连接"
Write-Host ""
}

# 使用 -Query 参数执行完整的 WQL 查询
# 查找启动类型为自动但当前未运行的服务
$stoppedAutoServices = Get-CimInstance -Query @"
SELECT Name, DisplayName, State, StartMode
FROM Win32_Service
WHERE StartMode = 'Auto' AND State != 'Running'
"@

Write-Host "=== 未运行的自动启动服务 ==="
foreach ($svc in $stoppedAutoServices) {
Write-Host " [$($svc.Name)] $($svc.DisplayName) - 状态: $($svc.State)"
}

# 关联查询:通过 __RELPATH 获取关联的磁盘分区
$diskDrive = Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -First 1
$partitions = Get-CimAssociatedInstance -InputObject $diskDrive -ResultClassName Win32_DiskPartition

Write-Host ""
Write-Host "=== 磁盘分区关联查询 ==="
Write-Host "磁盘: $($diskDrive.Model)"
foreach ($part in $partitions) {
Write-Host " 分区 $($part.Name): $($part.Size / 1GB -as [int]) GB - 类型: $($part.Type)"
}

Get-CimAssociatedInstance 是 CIM cmdlet 的独有功能,用于遍历 WMI 对象之间的关联关系。传统 WMI cmdlet 没有直接等价的方法,需要手动构造关联查询,这也是推荐迁移到 CIM 的重要理由之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
适配器: Intel(R) Ethernet Controller I225-V
MAC 地址: AA:BB:CC:DD:EE:FF
速度: 1000 Mbps
状态: 未连接

=== 未运行的自动启动服务 ===
[Spooler] Print Spooler - 状态: Stopped
[WSearch] Windows Search - 状态: Stopped

=== 磁盘分区关联查询 ===
磁盘: Samsung SSD 990 PRO 2TB
分区 磁盘 #0,分区 #0: 512 GB - 类型: GPT: System
分区 磁盘 #0,分区 #1: 1536 GB - 类型: GPT: Basic Data

构建系统清单报告

将前面的知识综合起来,我们可以构建一个实用的系统清单报告脚本。这个脚本收集关键系统信息并生成结构化的报告对象。

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
function Get-SystemInventory {
<#
.SYNOPSIS
收集本地或远程系统的硬件和软件清单信息。
#>
param(
[string[]]$ComputerName = $env:COMPUTERNAME
)

$session = New-CimSession -ComputerName $ComputerName -ErrorAction Stop

foreach ($s in $session) {
$os = Get-CimInstance -CimSession $s -ClassName Win32_OperatingSystem
$cs = Get-CimInstance -CimSession $s -ClassName Win32_ComputerSystem
$bios = Get-CimInstance -CimSession $s -ClassName Win32_BIOS
$cpus = Get-CimInstance -CimSession $s -ClassName Win32_Processor
$disks = Get-CimInstance -CimSession $s -ClassName Win32_LogicalDisk -Filter "DriveType=3"

$cpuNames = @()
foreach ($cpu in $cpus) {
$cpuNames += "$($cpu.Name) ($($cpu.NumberOfCores)C/$($cpu.NumberOfLogicalProcessors)T)"
}

$diskInfo = @()
foreach ($disk in $disks) {
$diskInfo += [PSCustomObject]@{
Drive = $disk.DeviceID
Label = $disk.VolumeName
TotalGB = [math]::Round($disk.Size / 1GB, 1)
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 1)
Percent = "$([math]::Round($disk.FreeSpace / $disk.Size * 100, 1))%"
}
}

$totalMemGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 2)
$uptime = (Get-Date) - $os.LastBootUpTime

[PSCustomObject]@{
ComputerName = $s.ComputerName
Manufacturer = $cs.Manufacturer
Model = $cs.Model
SerialNumber = $bios.SerialNumber
BIOSVersion = $bios.SMBIOSBIOSVersion
OS = $os.Caption
OSBuild = $os.BuildNumber
CPUs = $cpuNames -join "; "
TotalMemGB = $totalMemGB
Disks = $diskInfo
BootTime = $os.LastBootUpTime
Uptime = "{0:dd}天 {0:hh}时 {0:mm}分" -f $uptime
}
}

Remove-CimSession -CimSession $session
}

# 执行清单收集并查看结果
$inventory = Get-SystemInventory
$inventory | Format-List ComputerName, Manufacturer, Model, SerialNumber, BIOSVersion, OS, OSBuild, CPUs, TotalMemGB, BootTime, Uptime
$inventory.Disks | Format-Table -AutoSize

这个函数展示了 CIM 查询的工程化实践:参数化的计算机名、会话复用、错误处理、结构化输出对象。你可以将它放入自己的工具模块中,在日常运维中反复调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ComputerName : DESKTOP-WORKSTATION
Manufacturer : Gigabyte Technology Co., Ltd.
Model : Z790 AORUS MASTER
SerialNumber : GG2401001234
BIOSVersion : F12
OS : Microsoft Windows 11 Pro
OSBuild : 26100
CPUs : Intel(R) Core(TM) i7-14700K (20C/28T)
TotalMemGB : 32
BootTime : 2025-10-05 22:14:33
Uptime : 00天 09时 45分

Drive Label TotalGB FreeGB Percent
----- ----- ------- ------ -------
C: Windows 512.0 234.6 45.8%
D: Data 2048.0 1567.9 76.6%
E: Backup 4096.0 3890.2 95.0%

注意事项

  1. 优先使用 CIM cmdletGet-WmiObject 在 PowerShell 5.1 中已标记为弃用,在 PowerShell 7 的 Windows 版本中仍然可用但建议迁移。新项目应一律使用 Get-CimInstanceInvoke-CimMethod 等 CIM 系列 cmdlet。

  2. 服务端筛选优于客户端筛选:始终优先使用 -Filter 参数在服务端过滤数据(如 -Filter "DriveType=3"),而不是将全部结果拉回本地再用 Where-Object 过滤。前者只传输需要的数据,后者在 WMI 类数据量大时会产生明显的性能差距。

  3. CIM 会话需要清理:通过 New-CimSession 创建的会话在使用完毕后务必调用 Remove-CimSession 释放资源。未清理的会话会占用服务器端的 WinRM 连接配额,在大量并发场景下可能导致后续连接失败。

  4. 注意 DCOM 回退场景:如果目标机器不支持 WinRM(如老旧的 Windows Server 2003),可以在 New-CimSession 时通过 -SessionOption 指定 DCOM 协议:New-CimSessionOption -Protocol Dcom。但 DCOM 的端口配置更复杂,防火墙规则更多,建议统一部署 WinRM。

  5. WMI 类名大小写不敏感但保持一致:PowerShell 中 Win32_OperatingSystemwin32_operatingsystem 效果相同,但在脚本中应统一使用 PascalCase 命名(如 Win32_OperatingSystem),以提高代码可读性和团队协作一致性。

  6. 远程查询需要管理员权限:无论是 WMI 还是 CIM,远程查询都需要目标机器的本地管理员权限,并且 WinRM 服务必须处于运行状态。可以使用 Test-WSMan 命令快速验证远程机器的 WinRM 连通性。

PowerShell 技能连载 - 凭据管理

适用于 PowerShell 5.1 及以上版本

凭据管理的重要性

在日常运维和自动化脚本中,我们经常需要处理各种凭据:远程服务器的登录密码、API 密钥、数据库连接字符串等。如果直接将这些敏感信息以明文形式写在脚本中,不仅存在极大的安全隐患,也违反了绝大多数企业的安全合规要求。

PowerShell 提供了 PSCredential 对象来安全地封装用户名和密码。密码在 PSCredential 内部以 SecureString 形式存储,不会以明文暴露在内存中。结合 Windows 的 DPAPI(数据保护 API),我们可以将凭据安全地保存到文件,在后续脚本中重复使用,而无需每次手动输入。

本文将介绍从创建凭据、安全存储凭据到使用 Microsoft.PowerShell.SecretManagement 模块管理密钥的完整流程,帮助你建立一套规范的凭据管理方案。

创建 PSCredential 对象

最直接的方式是使用 Get-Credential cmdlet,它会弹出一个 Windows 对话框让用户输入用户名和密码。在无人值守的自动化场景中,我们可以通过代码手动构建 PSCredential 对象。以下示例展示了两种创建方式,并将密码转换为明文进行验证。

1
2
3
4
5
6
7
8
9
10
11
12
# 方式一:交互式弹窗获取凭据
$cred = Get-Credential -Message "请输入远程服务器凭据"

# 方式二:在脚本中手动构建凭据(适合自动化场景)
$username = "AdminUser"
$password = ConvertTo-SecureString -String "MyP@ssw0rd!" -AsPlainText -Force
$cred = [System.Management.Automation.PSCredential]::new($username, $password)

# 查看凭据信息
Write-Host "用户名: $($cred.UserName)"
Write-Host "密码长度: $($cred.GetNetworkCredential().Password.Length) 个字符"
Write-Host "密码是否已加密: $($cred.Password.IsReadOnly())"
1
2
3
用户名: AdminUser
密码长度: 11 个字符
密码是否已加密: False

安全存储和读取凭据

直接在脚本中硬编码密码显然不安全。更好的做法是将密码以加密形式导出到文件,后续脚本从文件中读取。ConvertFrom-SecureString 利用当前用户的 DPAPI 证书对密码加密,导出的密文只有同一台机器上的同一个用户才能解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 将密码加密保存到文件
$secretFile = "$env:USERPROFILE\.secrets\remote-admin.xml"

# 确保目录存在
$dir = Split-Path -Path $secretFile -Parent
if (-not (Test-Path -Path $dir)) {
$null = New-Item -Path $dir -ItemType Directory -Force
}

# 创建凭据并导出加密密码
$username = "AdminUser"
$password = Read-Host -Prompt "请输入密码" -AsSecureString
$cred = [System.Management.Automation.PSCredential]::new($username, $password)

# 将加密后的密码保存到文件
$password | ConvertFrom-SecureString | Out-File -FilePath $secretFile -Encoding UTF8
Write-Host "凭据已安全保存到: $secretFile"

# 在另一个脚本中读取凭据
$encryptedPassword = Get-Content -Path $secretFile -Raw
$securePassword = $encryptedPassword | ConvertTo-SecureString
$restoredCred = [System.Management.Automation.PSCredential]::new($username, $securePassword)

Write-Host "凭据已恢复 - 用户名: $($restoredCred.UserName)"
1
2
凭据已安全保存到: C:\Users\admin\.secrets\remote-admin.xml
凭据已恢复 - 用户名: AdminUser

批量管理多个服务的凭据

在真实环境中,我们往往需要同时管理多组凭据:数据库账号、API 密钥、SSH 登录等。可以为每组凭据创建独立的加密文件,并编写一个统一的管理函数来简化操作。

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
function Save-Secret {
<#
.SYNOPSIS
将凭据加密保存到本地文件
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name,

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

[Parameter(Mandatory)]
[securestring]$Password
)

$secretDir = Join-Path -Path $env:USERPROFILE -ChildPath ".secrets"
if (-not (Test-Path -Path $secretDir)) {
$null = New-Item -Path $secretDir -ItemType Directory -Force
}

$cred = [System.Management.Automation.PSCredential]::new($UserName, $Password)
$filePath = Join-Path -Path $secretDir -ChildPath "$Name.xml"

# 使用 Export-Clixml 保存整个凭据对象(包含用户名和加密密码)
$cred | Export-Clixml -Path $filePath -Encoding UTF8
Write-Host "[$Name] 凭据已保存到: $filePath"
}

function Get-Secret {
<#
.SYNOPSIS
从本地文件读取已加密的凭据
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name
)

$filePath = Join-Path -Path $env:USERPROFILE -ChildPath ".secrets\$Name.xml"
if (-not (Test-Path -Path $filePath)) {
Write-Error "找不到名为 [$Name] 的凭据文件"
return
}

# Import-Clixml 自动利用 DPAPI 解密还原 PSCredential 对象
$cred = Import-Clixml -Path $filePath
Write-Host "[$Name] 凭据已加载 - 用户名: $($cred.UserName)"
return $cred
}

# 批量保存多个服务凭据
$services = @(
@{ Name = "sqlserver"; User = "sa" }
@{ Name = "ssh-gateway"; User = "deploy" }
@{ Name = "api-service"; User = "api-reader" }
)

foreach ($svc in $services) {
$securePwd = Read-Host -Prompt "请输入 $($svc.Name) 的密码" -AsSecureString
Save-Secret -Name $svc.Name -UserName $svc.User -Password $securePwd
}

# 验证:列出所有已保存的凭据
Write-Host "`n已保存的凭据列表:"
$secretFiles = Get-ChildItem -Path "$env:USERPROFILE\.secrets\*.xml"
foreach ($file in $secretFiles) {
$cred = Import-Clixml -Path $file.FullName
Write-Host " - $($file.BaseName): $($cred.UserName)"
}
1
2
3
4
5
6
7
8
[sqlserver] 凭据已保存到: C:\Users\admin\.secrets\sqlserver.xml
[ssh-gateway] 凭据已保存到: C:\Users\admin\.secrets\ssh-gateway.xml
[api-service] 凭据已保存到: C:\Users\admin\.secrets\api-service.xml

已保存的凭据列表:
- sqlserver: sa
- ssh-gateway: deploy
- api-service: api-reader

使用 SecretManagement 模块统一管理密钥

PowerShell 7 引入了 Microsoft.PowerShell.SecretManagementMicrosoft.PowerShell.SecretStore 模块,提供了一套跨平台的密钥管理框架。它支持将密钥存储在本地 SecretStore 中,也可以扩展对接 Azure Key Vault、KeePass 等外部密钥管理服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 安装模块(仅首次需要)
Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser
Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser

# 注册本地 SecretStore 作为密钥保管库
Register-SecretVault -Name "MyLocalVault" -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault

# 设置 SecretStore 密码(用于解锁保管库)
$storePassword = Read-Host -Prompt "设置 SecretStore 密码" -AsSecureString
Set-SecretStorePassword -NewPassword $storePassword -Password $storePassword

# 存储各种类型的密钥
Set-Secret -Name "DatabaseConnection" -Secret "Server=db01;Database=prod;User Id=app;Password=s3cret;"
Set-Secret -Name "ApiKey" -Secret "ak-12345-abcde-67890"
Set-Secret -Name "ServiceAccount" -Secret (Get-Credential -UserName "svc_automation" -Message "输入服务账号密码")

# 查询已存储的密钥信息
Write-Host "密钥保管库中存储的密钥列表:"
$secrets = Get-SecretInfo
foreach ($secret in $secrets) {
Write-Host " 名称: $($secret.Name) | 类型: $($secret.Type)"
}

# 在脚本中按需获取密钥
$dbConn = Get-Secret -Name "DatabaseConnection" -AsPlainText
Write-Host "`n数据库连接字符串长度: $($dbConn.Length) 个字符"

$apiCred = Get-Secret -Name "ServiceAccount"
Write-Host "服务账号用户名: $($apiCred.UserName)"
1
2
3
4
5
6
7
密钥保管库中存储的密钥列表:
名称: DatabaseConnection | 类型: String
名称: ApiKey | 类型: String
名称: ServiceAccount | 类型: PSCredential

数据库连接字符串长度: 68 个字符
服务账号用户名: svc_automation

注意事项

  1. DPAPI 作用域限制:使用 ConvertFrom-SecureStringExport-Clixml 加密的凭据只能在同一台机器上的同一个 Windows 用户账户下解密。如果需要跨机器使用,请使用 -Key 参数指定 AES 加密密钥,或者使用 SecretManagement 模块配合外部密钥管理服务。

  2. 避免密码明文传入ConvertTo-SecureString -AsPlainText -Force 虽然方便,但会暴露明文密码。在生产环境中,应优先使用 Get-CredentialRead-Host -AsSecureString 交互获取密码,或从已加密的文件中读取。

  3. SecureString 并非绝对安全SecureString 能防止密码在内存中以明文形式长期驻留,但不能防御具有管理员权限的恶意程序通过内存转储获取密码。在 Linux 和 macOS 上,SecureString 的加密保护能力弱于 Windows,建议结合操作系统级别的密钥管理工具使用。

  4. 文件权限控制:保存凭据文件的目录应设置严格的 NTFS 权限,仅允许当前用户访问。可以通过以下命令快速设置:icacls "$env:USERPROFILE\.secrets" /inheritance:r /grant:r "$env:USERNAME:(R,W)"

  5. SecretStore 密码管理Microsoft.PowerShell.SecretStore 默认要求在每次会话中输入密码解锁。如果自动化脚本需要无人值守运行,可以通过 Set-SecretStoreConfiguration -Authentication None 关闭密码提示,但这会降低安全性,请根据实际场景权衡。

  6. 定期轮换凭据:无论使用哪种存储方式,都应建立凭据轮换机制。建议在密钥管理流程中记录每个凭据的创建时间和过期时间,并在脚本中添加检查逻辑,对即将过期的凭据发出告警提醒。

PowerShell 技能连载 - 错误处理模式

适用于 PowerShell 5.1 及以上版本

背景

在编写 PowerShell 脚本时,错误处理往往是被忽视的环节。很多开发者习惯性地依赖默认的错误输出,直到脚本在无人值守的生产环境中意外崩溃才开始重视。PowerShell 提供了丰富的错误处理机制,从简单的 -ErrorAction 参数到完整的 try/catch/finally 结构,再到 $Error 自动变量的深度利用,每一种机制都有其最佳适用场景。

掌握错误处理模式不仅能提升脚本的健壮性,还能让运维人员更快地定位问题根因。本文将介绍三种最常见的错误处理模式:基本的 try/catch 模式、批量操作的容错模式以及错误日志收集模式,帮助你在不同场景下选择最合适的策略。

模式一:基本的 try/catch/finally 模式

try/catch/finally 是最经典的错误处理结构。try 块中放置可能出错的操作,catch 块中处理异常,finally 块无论是否出错都会执行,适合做资源清理。

需要注意,PowerShell 默认的”非终止错误”不会被 catch 捕获。必须将 ErrorAction 设置为 Stop,将非终止错误提升为终止错误,catch 才能生效。

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
function Copy-LogArchive {
param(
[string]$SourcePath,
[string]$DestinationPath
)

try {
Write-Host "开始复制日志归档文件..."
$sourceItem = Get-Item -Path $SourcePath -ErrorAction Stop

if (-not $sourceItem.Exists) {
throw "源文件不存在: $SourcePath"
}

Copy-Item -Path $SourcePath -Destination $DestinationPath -Force -ErrorAction Stop
Write-Host "文件复制成功: $DestinationPath"
}
catch [System.IO.FileNotFoundException] {
Write-Error "文件未找到异常: $($_.Exception.Message)"
}
catch [System.UnauthorizedAccessException] {
Write-Error "权限不足: $($_.Exception.Message)"
}
catch {
Write-Error "未知错误: $($_.Exception.Message)"
Write-Host "异常类型: $($_.Exception.GetType().FullName)"
}
finally {
Write-Host "操作完成,清理临时资源..."
}
}

Copy-LogArchive -SourcePath "C:\Logs\app-2025.zip" -DestinationPath "D:\Backup\app-2025.zip"

执行结果示例:

1
2
3
开始复制日志归档文件...
文件未找到异常: 找不到路径"C:\Logs\app-2025.zip",因为该路径不存在。
操作完成,清理临时资源...

模式二:批量操作的容错模式

在批量处理大量对象时(比如遍历数百台服务器或上千个文件),不希望某一个对象的失败导致整个操作中断。此时应使用 foreach 循环配合 try/catch,让每个对象独立处理,并记录成功与失败的统计信息。

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
$computers = @(
"SRV-WEB01",
"SRV-DB01",
"SRV-APP01",
"SRV-FILE01",
"SRV-MAIL01"
)

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

foreach ($computer in $computers) {
try {
$session = New-PSSession -ComputerName $computer -ErrorAction Stop

$result = Invoke-Command -Session $session -ScriptBlock {
Get-Service -Name "WinRM" | Select-Object Name, Status, StartType
} -ErrorAction Stop

Write-Host "[$computer] WinRM 状态: $($result.Status)" -ForegroundColor Green
$successList.Add($computer)

Remove-PSSession -Session $session
}
catch {
$errorMsg = $_.Exception.Message
Write-Host "[$computer] 连接失败: $errorMsg" -ForegroundColor Red
$failList.Add("${computer}: ${errorMsg}")
}
}

Write-Host "`n===== 批量操作汇总 ====="
Write-Host "成功: $($successList.Count) 台"
Write-Host "失败: $($failList.Count) 台"

if ($failList.Count -gt 0) {
Write-Host "`n失败详情:" -ForegroundColor Yellow
foreach ($item in $failList) {
Write-Host " - $item"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
[SRV-WEB01] WinRM 状态: Running
[SRV-DB01] 连接失败: 连接到远程服务器 SRV-DB01 失败。
[SRV-APP01] WinRM 状态: Running
[SRV-FILE01] 连接失败: 拒绝访问。
[SRV-MAIL01] WinRM 状态: Stopped

===== 批量操作汇总 =====
成功: 3 台
失败: 2 台

失败详情:
- SRV-DB01: 连接到远程服务器 SRV-DB01 失败。
- SRV-FILE01: 拒绝访问。

模式三:结构化错误日志收集模式

在复杂脚本或自动化流水线中,仅靠控制台输出远远不够。我们需要将错误信息以结构化方式记录下来,便于后续分析和审计。下面的模式使用自定义错误记录对象,在脚本执行结束后统一输出 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
$logEntries = [System.Collections.Generic.List[PSObject]]::new()

function Write-OperationLog {
param(
[string]$Operation,
[string]$Target,
[string]$Status,
[string]$Message = ""
)

$entry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Operation = $Operation
Target = $Target
Status = $Status
Message = $Message
}

$logEntries.Add($entry)
}

$services = @("Spooler", "WinRM", "wuauserv", "NonExistSvc", "BITS")

foreach ($svc in $services) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
Restart-Service -InputObject $service -Force -ErrorAction Stop
Write-OperationLog -Operation "Restart-Service" -Target $svc -Status "Success"
Write-Host "[OK] $svc 已重启" -ForegroundColor Green
}
catch {
Write-OperationLog -Operation "Restart-Service" -Target $svc -Status "Failed" -Message $_.Exception.Message
Write-Host "[FAIL] $svc 失败: $($_.Exception.Message)" -ForegroundColor Red
}
}

$report = $logEntries | ConvertTo-Json -Depth 3
$reportPath = Join-Path $env:TEMP "service-operation-report.json"
$report | Out-File -FilePath $reportPath -Encoding UTF8

Write-Host "`n报告已保存至: $reportPath"
Write-Host "总记录数: $($logEntries.Count)"
Write-Host "失败记录: $(($logEntries | Where-Object { $_.Status -eq 'Failed' }).Count)"

执行结果示例:

1
2
3
4
5
6
7
8
9
[OK] Spooler 已重启
[OK] WinRM 已重启
[FAIL] wuauserv 失败: 无法打开"Windows Update"服务。
[FAIL] NonExistSvc 失败: 找不到任何服务名称为"NonExistSvc"的服务。
[OK] BITS 已重启

报告已保存至: /tmp/service-operation-report.json
总记录数: 5
失败记录: 2

模式四:错误类型筛选与重试模式

在生产环境中,某些操作可能因瞬时故障(如网络抖动、数据库连接超时)而失败,这种情况下自动重试比直接报错更合理。下面的模式根据异常类型决定是否重试,并设置最大重试次数和退避间隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function Invoke-WebRequestWithRetry {
param(
[string]$Url,
[int]$MaxRetries = 3,
[int]$RetryIntervalSeconds = 2
)

$attempt = 0
$lastError = $null

while ($attempt -lt $MaxRetries) {
$attempt++
try {
Write-Host "第 $attempt 次请求: $Url"
$response = Invoke-WebRequest -Uri $Url -TimeoutSec 10 -ErrorAction Stop
Write-Host "请求成功,状态码: $($response.StatusCode)" -ForegroundColor Green
return $response
}
catch [System.Net.WebException] {
$lastError = $_
$statusCode = $_.Exception.Response.StatusCode

if ($statusCode -ge 400 -and $statusCode -lt 500) {
Write-Host "客户端错误 ($statusCode),不重试。" -ForegroundColor Red
throw $_
}

Write-Host "服务端/网络错误,${RetryIntervalSeconds}s 后重试..." -ForegroundColor Yellow
Start-Sleep -Seconds $RetryIntervalSeconds
$RetryIntervalSeconds = $RetryIntervalSeconds * 2
}
catch {
$lastError = $_
Write-Host "非网络异常: $($_.Exception.Message)" -ForegroundColor Red
throw $_
}
}

Write-Host "已达最大重试次数 ($MaxRetries),操作失败。" -ForegroundColor Red
throw $lastError
}

Invoke-WebRequestWithRetry -Url "https://httpbin.org/status/500" -MaxRetries 3

执行结果示例:

1
2
3
4
5
6
7
1 次请求: https://httpbin.org/status/500
服务端/网络错误,2s 后重试...
2 次请求: https://httpbin.org/status/500
服务端/网络错误,4s 后重试...
3 次请求: https://httpbin.org/status/500
已达最大重试次数 (3),操作失败。
Invoke-WebRequestWithRetry: 远程服务器返回错误: (500) 内部服务器错误。

注意事项

  1. 区分终止错误与非终止错误:PowerShell 中大多数 cmdlet 产生的是非终止错误(Non-Terminating Error),默认不会触发 catch。务必在 cmdlet 后添加 -ErrorAction Stop 参数,或通过 $ErrorActionPreference = 'Stop' 全局设置,才能让 try/catch 正常工作。

  2. 不要滥用空 catch 块:空的 catch {} 会静默吞掉所有错误,让排错变得极其困难。即使在不需要特殊处理的场景下,也应至少记录一行日志,比如 Write-Warning "操作失败: $($_.Exception.Message)"

  3. 善用 catch 的类型过滤catch 可以指定具体的异常类型(如 [System.IO.FileNotFoundException]),先捕获具体异常,最后用通用的 catch {} 兜底。多个 catch 块按照从具体到通用的顺序排列。

  4. finally 块用于资源释放:无论是否发生异常,finally 块都会执行。适合关闭数据库连接、移除临时文件、释放 PSSession 等清理工作。即使 try 中有 return 语句,finally 也会在返回前执行。

  5. $Error 自动变量保留历史记录:PowerShell 的 $Error 数组自动收集所有会话中的错误,最新错误在索引 0。可以通过 $Error[0] | Format-List * 查看完整的错误详情,包括脚本堆栈追踪(ScriptStackTrace)。

  6. 批量操作避免管道中的 try/catch:在管道(|)中使用 ForEach-Object 嵌套 try/catch 会导致代码难以阅读和调试。推荐改用 foreach 语句,结构更清晰,也更容易添加计数器和日志收集逻辑。

PowerShell 技能连载 - PowerShellGet 包管理

适用于 PowerShell 5.1 及以上版本

背景

在现代运维和开发工作中,模块化管理代码已成为主流趋势。Python 有 pip,Node.js 有 npm,而 PowerShell 的官方包管理工具就是 PowerShellGet。它提供了一组标准 cmdlet,让用户可以从 PowerShell Gallery 或私有 NuGet 源搜索、安装、更新和发布模块及脚本。自 PowerShell 5.0 起,PowerShellGet 已内置于系统中,开箱即用。

在日常工作中,我们经常面临以下场景:团队共享了一批自定义运维模块,需要统一分发和版本控制;某个第三方模块发布了安全补丁,需要快速检查并升级所有服务器上的旧版本;或者我们需要将内部开发的工具模块发布到私有仓库,供多个环境使用。PowerShellGet 正是解决这些问题的利器。

本文将从仓库管理、模块搜索与安装、批量更新、以及私有源搭建等方面,全面介绍 PowerShellGet 的实际用法,帮助你构建高效的 PowerShell 模块管理工作流。

查看和管理仓库源

PowerShellGet 依赖 NuGet 协议与仓库交互。默认情况下,系统已注册 PowerShell Gallery 作为公共源。我们可以先查看当前注册的所有仓库,确认源是否可用。

1
2
# 查看已注册的仓库列表
Get-PSRepository
1
2
3
Name                      InstallationPolicy   SourceLocation
---- ----------------- --------------
PSGallery Untrusted https://www.powershellgallery.com/api/v2

如果默认的 PSGallery 仓库丢失(某些精简系统可能出现),可以使用以下命令恢复:

1
2
3
4
5
# 恢复默认的 PSGallery 仓库
Register-PSRepository -Default

# 验证仓库是否恢复成功
Get-PSRepository
1
2
3
Name                      InstallationPolicy   SourceLocation
---- ----------------- --------------
PSGallery Untrusted https://www.powershellgallery.com/api/v2

在生产环境中,建议将仓库的 InstallationPolicy 设为 Trusted,避免每次安装时都需要手动确认:

1
2
# 将 PSGallery 设为受信任源
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted

搜索和安装模块

PowerShellGet 提供了 Find-ModuleInstall-Module 两个核心 cmdlet,分别用于搜索和安装模块。下面演示如何按关键词搜索模块,并查看模块的详细信息。

1
2
3
4
5
6
# 按标签搜索安全相关的模块
$modules = Find-Module -Tag 'Security' | Select-Object -First 5

foreach ($m in $modules) {
Write-Output ("模块名: {0}, 版本: {1}, 描述: {2}" -f $m.Name, $m.Version, $m.Description)
}
1
2
3
4
5
模块名: Carbon, 版本: 2.5.0, 描述: Carbon is a PowerShell module for automating the configuration of Windows.
模块名: ACMESharp, 版本: 0.8.1, 描述: Client library for the ACME protocol for certificate management.
模块名: DSInternals, 版本: 2.22, 描述: The DSInternals PowerShell Module exposes several internal features of Active Directory.
模块名: DSCEA, 版本: 1.2.0.0, 描述: DSCEA is a scanning engine for processing Test-DscConfiguration results.
模块名: PoshACME, 版本: 3.10.0, 描述: PowerShell module for ACME certificate management.

找到需要的模块后,使用 Install-Module 进行安装。建议指定 -Scope CurrentUser 避免需要管理员权限:

1
2
3
4
5
# 安装指定模块到当前用户范围
Install-Module -Name 'PSScriptAnalyzer' -Scope CurrentUser -Repository PSGallery

# 确认安装成功
Get-Module -ListAvailable -Name 'PSScriptAnalyzer' | Select-Object Name, Version, ModuleBase
1
2
3
Name               Version    ModuleBase
---- ------- ----------
PSScriptAnalyzer 1.22.0 C:\Users\user\Documents\PowerShell\Modules\PSScriptAnalyzer\1.22.0

有时候我们需要安装特定版本的模块,比如为了兼容性而回退到旧版本:

1
2
# 查看模块的所有可用版本
Find-Module -Name 'PSScriptAnalyzer' -AllVersions | Select-Object Name, Version | Sort-Object Version -Descending | Select-Object -First 5
1
2
3
4
5
6
7
Name               Version
---- -------
PSScriptAnalyzer 1.22.0
PSScriptAnalyzer 1.21.0
PSScriptAnalyzer 1.20.0
PSScriptAnalyzer 1.19.1
PSScriptAnalyzer 1.19.0
1
2
# 安装指定版本
Install-Module -Name 'PSScriptAnalyzer' -RequiredVersion '1.21.0' -Scope CurrentUser -Force

批量检查和更新模块

在维护多台服务器时,定期检查已安装模块是否有更新是一项重要的运维任务。下面的脚本会扫描所有已安装模块,对比 PowerShell Gallery 上的最新版本,并生成更新报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 获取所有已安装模块的当前版本和最新版本
$installedModules = Get-InstalledModule
$updateReport = @()

foreach ($mod in $installedModules) {
$latest = Find-Module -Name $mod.Name -Repository PSGallery -ErrorAction SilentlyContinue

if ($latest -and $latest.Version -ne $mod.Version) {
$updateReport += [PSCustomObject]@{
Name = $mod.Name
CurrentVersion = $mod.Version
LatestVersion = $latest.Version
NeedsUpdate = $true
}
}
else {
$updateReport += [PSCustomObject]@{
Name = $mod.Name
CurrentVersion = $mod.Version
LatestVersion = $mod.Version
NeedsUpdate = $false
}
}
}

$updateReport | Format-Table -AutoSize
1
2
3
4
5
6
Name                CurrentVersion  LatestVersion  NeedsUpdate
---- -------------- ------------- -----------
PSScriptAnalyzer 1.21.0 1.22.0 True
PSSqlite 1.1.0 1.1.0 False
PowerShellGet 2.2.5 2.2.5 False
Pester 5.4.0 5.5.0 True

确认需要更新的模块后,可以使用 Update-Module 进行升级:

1
2
3
4
5
6
7
8
# 更新所有有新版本的模块
foreach ($mod in $updateReport) {
if ($mod.NeedsUpdate) {
Write-Output ("正在更新 {0} 从 {1} 到 {2}..." -f $mod.Name, $mod.CurrentVersion, $mod.LatestVersion)
Update-Module -Name $mod.Name -Scope CurrentUser
Write-Output ("{0} 更新完成。" -f $mod.Name)
}
}
1
2
3
4
正在更新 PSScriptAnalyzer 从 1.21.0 到 1.22.0...
PSScriptAnalyzer 更新完成。
正在更新 Pester 从 5.4.0 到 5.5.0...
Pester 更新完成。

注册私有 NuGet 仓库

在企业环境中,公共的 PowerShell Gallery 并不总是可用(比如受限网络环境),或者我们需要分发内部开发的专用模块。这时可以注册私有 NuGet 仓库,实现内部模块的统一管理。

1
2
3
4
5
6
7
8
9
10
11
12
# 注册企业内部的 NuGet 仓库
$repoParams = @{
Name = 'CompanyPSRepo'
SourceLocation = 'https://nuget.company.com/v3/index.json'
PublishLocation = 'https://nuget.company.com/v3/index.json'
InstallationPolicy = 'Trusted'
}

Register-PSRepository @repoParams

# 验证注册结果
Get-PSRepository -Name 'CompanyPSRepo'
1
2
3
Name                InstallationPolicy   SourceLocation
---- ----------------- --------------
CompanyPSRepo Trusted https://nuget.company.com/v3/index.json

注册完成后,就可以像使用 PSGallery 一样搜索和安装私有仓库中的模块:

1
2
3
4
5
# 搜索私有仓库中的模块
Find-Module -Repository 'CompanyPSRepo'

# 从私有仓库安装模块
Install-Module -Name 'Company.Utils' -Repository 'CompanyPSRepo' -Scope CurrentUser
1
2
3
4
5
Version    Name              Repository      Description
------- ---- ---------- -----------
1.5.0 Company.Utils CompanyPSRepo 公司内部通用运维工具模块
2.0.1 Company.Security CompanyPSRepo 公司安全基线检查模块
1.0.0 Company.Logging CompanyPSRepo 统一日志记录模块

发布模块到仓库

开发完一个 PowerShell 模块后,可以使用 Publish-Module 将其发布到 PowerShell Gallery 或私有仓库。发布前需要准备好模块清单(.psd1 文件),确保元数据完整。

1
2
3
# 查看模块清单信息
$modulePath = 'C:\Modules\MyToolKit'
Test-ModuleManifest -Path "$modulePath\MyToolKit.psd1" | Select-Object Name, Version, Author, Description
1
2
3
4
Name        : MyToolKit
Version : 1.0.0
Author : admin
Description : 日常运维工具集
1
2
3
4
5
6
7
8
9
# 发布模块到私有仓库(使用 API Key 认证)
$publishParams = @{
Path = $modulePath
Repository = 'CompanyPSRepo'
NuGetApiKey = 'your-api-key-here'
Verbose = $true
}

Publish-Module @publishParams
1
2
3
4
VERBOSE: 正在准备要发布的模块 'MyToolKit' (版本 1.0.0)...
VERBOSE: 正在创建 NuGet 包...
VERBOSE: 正在将包推送到 'https://nuget.company.com/v3/index.json'...
VERBOSE: 模块 'MyToolKit' (版本 1.0.0) 发布成功。

发布完成后,其他团队成员就可以通过 Find-ModuleInstall-Module 获取最新版本。

注意事项

  1. PowerShellGet 版本差异:PowerShellGet v2(随 Windows 自带)和 v3(需要单独安装)在 cmdlet 命名和参数上有较大变化。v3 的包名为 Microsoft.PowerShell.PSResourceGet,计划在未来替代 v2。迁移前请确认兼容性。

  2. NuGet 提供程序依赖:首次使用 Install-Module 时,系统可能会提示安装 NuGet 提供程序。在企业环境中建议提前部署,可以通过 Install-PackageProvider -Name NuGet -Force 预装。

  3. 执行策略限制:如果系统执行策略设置为 Restricted,模块安装和脚本执行都会被阻止。建议将执行策略设为 RemoteSignedSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

  4. 网络代理问题:在需要通过代理访问外网的环境中,PowerShellGet 默认不会自动使用系统代理。需要显式配置 PowerShellGet 的代理设置,或者使用 -Proxy 参数指定代理地址。

  5. 模块冲突处理:如果系统中存在多个版本的同一模块,PowerShell 默认加载路径中排在最前面的版本。使用 Import-Module 时通过 -RequiredVersion 参数明确指定版本,避免意外加载旧版本导致兼容性问题。

  6. 私有仓库安全:发布模块时使用的 API Key 应妥善保管,切勿硬编码在脚本中。建议使用环境变量或 Azure Key Vault 等安全存储来管理凭据,例如 $apiKey = $env:PSGALLERY_API_KEY