PowerShell 技能连载 - SQL Server 管理

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

SQL Server 是企业级关系型数据库的常青树,广泛应用于金融、制造、零售等行业。作为 DBA 或运维工程师,日常需要频繁执行实例巡检、数据库备份、性能监控、空间分析等操作。传统做法是通过 SQL Server Management Studio(SSMS)手工完成,但面对多实例、多数据库的环境,图形界面操作效率低下且容易遗漏。PowerShell 凭借其强大的自动化能力和丰富的 SQL Server 模块生态,让这些任务变得可编程、可复用、可调度。

微软提供的 SqlServer 模块封装了 SQL Server Management Objects(SMO)库,几乎覆盖了 SSMS 能做的所有事情。从查询实例信息、管理数据库文件,到执行 T-SQL、配置安全策略,都可以通过 PowerShell 脚本完成。结合 Windows 任务计划或 SQL Agent Job,还能实现定时自动巡检和告警推送,大幅减轻 DBA 的重复劳动负担。

本文将从连接实例、查询元数据、执行 T-SQL、监控数据库空间四个典型场景入手,演示如何用 PowerShell 高效管理 SQL Server,帮助你建立一套实用的数据库自动化运维脚本库。

连接 SQL Server 实例并获取基本信息

管理 SQL Server 的第一步是建立连接。SqlServer 模块提供了 Connect-SqlInstance 命令,返回一个 SMO Server 对象,通过它可以访问实例级别和数据库级别的几乎所有属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 安装 SqlServer 模块(如尚未安装)
Install-Module SqlServer -Force -Scope CurrentUser

# 连接到本地默认实例(使用 Windows 集成身份验证)
$instance = Connect-SqlInstance -ServerInstance "localhost"

# 输出实例基本信息
[PSCustomObject]@{
InstanceName = $instance.Name
Version = $instance.VersionString
Edition = $instance.Edition
ProductLevel = $instance.ProductLevel
Platform = $instance.Platform
HostName = $instance.NetName
Collation = $instance.Collation
LoginMode = $instance.LoginMode
IsClustered = $instance.IsClustered
}

执行结果示例:

1
2
3
4
5
6
7
8
9
InstanceName   : localhost
Version : 16.0.1113.5
Edition : Developer Edition (64-bit)
ProductLevel : RTM
Platform : Windows NT x64 <x64>
HostName : DB-SERVER01
Collation : Chinese_PRC_CI_AS
LoginMode : Integrated
IsClustered : False

Connect-SqlInstance 默认使用当前 Windows 凭据进行身份验证,也支持 SQL 身份验证(通过 -Credential 参数)。返回的 Server 对象包含了实例的版本、版本级别、排序规则、登录模式等关键信息,可用于后续的条件判断和兼容性检查。如果环境中有多台服务器,可以将实例名存放在配置文件或 CSV 中,用循环批量连接巡检。

查询所有数据库的状态和空间使用

数据库状态和空间使用情况是 DBA 日常巡检的核心指标。SMO 的 Databases 集合为每个数据库提供了丰富的属性,无需手写 T-SQL 即可获取结构化的空间信息。

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
# 连接实例
$instance = Connect-SqlInstance -ServerInstance "localhost"

# 遍历所有用户数据库,收集状态和空间信息
$dbStats = foreach ($db in $instance.Databases) {
# 跳过系统数据库(可选)
if ($db.IsSystemObject) { continue }

[PSCustomObject]@{
DatabaseName = $db.Name
Status = $db.Status
RecoveryModel = $db.RecoveryModel
SizeMB = [math]::Round($db.Size, 2)
DataSpaceMB = [math]::Round(
($db.FileGroups | ForEach-Object { $_.Files } |
Measure-Object -Property Size -Sum).Sum / 1024, 2
)
LogSpaceMB = [math]::Round(
($db.LogFiles | Measure-Object -Property Size -Sum).Sum / 1024, 2
)
LastBackupDate = $db.LastBackupDate
CreateDate = $db.CreateDate
}
}

# 按大小降序排列并输出
$dbStats | Sort-Object SizeMB -Descending | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
DatabaseName Status   RecoveryModel SizeMB DataSpaceMB LogSpaceMB LastBackupDate       CreateDate
------------ ------ ------------- ------ ----------- ---------- -------------- ----------
SalesDB Normal Full 2048.5 1800 248.5 2025/11/10 02:00:00 2024/03/15 10:30:00
AppLog Normal Simple 1024.0 900 124.0 2025/11/11 00:30:00 2024/06/20 14:15:00
TestDB Normal Simple 512.0 450 62.0 2025/11/09 22:00:00 2025/01/10 09:00:00

$db.Size 以 MB 为单位返回数据库总大小(数据文件和日志文件之和)。通过遍历 FileGroups.FilesLogFiles 集合,可以分别计算数据空间和日志空间的占比,及时发现日志文件异常膨胀的问题。LastBackupDate 字段能快速定位长时间未备份的数据库,是安全巡检的必检项。

执行 T-SQL 查询并处理结果

除了通过 SMO 对象访问元数据,很多场景下需要直接执行 T-SQL 语句来查询动态管理视图或业务数据。Invoke-SqlcmdSqlServer 模块中最常用的命令,支持执行任意 T-SQL 并将结果以 DataRow 对象的形式返回。

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
# 查询当前实例中活跃会话和阻塞情况
$query = @"
SELECT
session_id,
DB_NAME(database_id) AS DatabaseName,
status,
wait_type,
wait_time_ms,
blocking_session_id,
cpu_time,
reads,
writes,
logical_reads,
start_time
FROM sys.dm_exec_requests
WHERE session_id > 50
ORDER BY start_time
"@

$results = Invoke-Sqlcmd -ServerInstance "localhost" -Query $query

# 筛选被阻塞的会话
$blocked = $results | Where-Object { $_.blocking_session_id -ne 0 }

if ($blocked) {
Write-Host "检测到阻塞会话:" -ForegroundColor Yellow
foreach ($row in $blocked) {
[PSCustomObject]@{
SessionId = $row.session_id
Database = $row.DatabaseName
Status = $row.status
WaitType = $row.wait_type
WaitTimeMs = $row.wait_time_ms
BlockedBy = $row.blocking_session_id
StartTime = $row.start_time
}
}
} else {
Write-Host "当前无阻塞会话" -ForegroundColor Green
}

执行结果示例:

1
2
3
4
5
6
检测到阻塞会话:

SessionId Database Status WaitType WaitTimeMs BlockedBy StartTime
--------- -------- ------ -------- ---------- --------- ---------
58 SalesDB running LCK_M_S 4520 52 2025/11/11 09:15:30
61 SalesDB suspended LCK_M_S 850 52 2025/11/11 09:16:10

Invoke-Sqlcmd-Query 参数接受任意 T-SQL 文本,也可以通过 -InputFile 参数执行 .sql 文件。查询 sys.dm_exec_requests 视图是排查阻塞问题的常用手段,blocking_session_id 不为 0 表示该会话正在被另一个会话阻塞。将这段脚本配置为定时任务,每 5 分钟执行一次并发送告警邮件,就能实现自动化的阻塞监控。

批量检查数据库备份状态

备份是数据库安全的最后一道防线。以下脚本批量检查所有用户数据库的备份状态,识别出超过阈值未备份或从未备份的数据库,并生成结构化报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 连接实例并定义备份阈值(小时)
$instance = Connect-SqlInstance -ServerInstance "localhost"
$thresholdHours = 24
$cutoffTime = (Get-Date).AddHours(-$thresholdHours)

# 检查备份状态
$backupReport = foreach ($db in $instance.Databases) {
if ($db.IsSystemObject) { continue }

$lastFull = $db.LastBackupDate
$lastDiff = $db.LastDifferentialBackupDate
$lastLog = $db.LastLogBackupDate
$recoveryModel = $db.RecoveryModel.ToString()

$riskLevel = "正常"
if ($lastFull -eq [datetime]::MinValue) {
$riskLevel = "高风险"
} elseif ($lastFull -lt $cutoffTime) {
$riskLevel = "警告"
}

[PSCustomObject]@{
Database = $db.Name
RiskLevel = $riskLevel
RecoveryModel = $recoveryModel
LastFullBackup = if ($lastFull -eq [datetime]::MinValue) { "从未备份" } else { $lastFull }
LastDiffBackup = if ($lastDiff -eq [datetime]::MinValue) { "无" } else { $lastDiff }
LastLogBackup = if ($lastLog -eq [datetime]::MinValue) { "无" } else { $lastLog }
}
}

# 按风险等级排序输出
$backupReport | Sort-Object RiskLevel | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
Database    RiskLevel RecoveryModel LastFullBackup        LastDiffBackup        LastLogBackup
-------- --------- ------------- -------------- -------------- -------------
SalesDB 正常 Full 2025/11/11 02:00:00 2025/11/11 06:00:00 2025/11/11 08:45:00
AppLog 正常 Simple 2025/11/11 00:30:00 无 无
StagingDB 警告 Simple 2025/11/09 22:00:00 无 无
ArchiveDB 高风险 Full 从未备份 无 无

通过 $db.LastBackupDate 判断最后一次完整备份的时间,与阈值比较后划分为”正常””警告””高风险”三个等级。对于使用完整恢复模式(Full Recovery Model)的数据库,还需要额外关注日志备份频率,否则事务日志会持续增长。将此脚本接入监控系统,每天自动执行并推送异常结果,可以有效防止备份遗漏导致的数据丢失风险。

注意事项

  1. 模块选择SqlServer 模块是 SQLPS 的继任者,建议始终使用 SqlServer。如果环境中同时安装了两个模块,可能产生命名冲突。可通过 Get-Module -ListAvailable SqlServer 确认版本,建议使用 22.x 以上版本以获得完整的 SMO 支持。

  2. 连接安全:生产环境建议使用 Windows 集成身份验证,避免在脚本中硬编码 SQL 账号密码。如果必须使用 SQL 身份验证,密码应存放在 PowerShell 凭据对象中(Get-Credential)或通过 Azure Key Vault / Windows Credential Manager 安全读取,绝不能以明文形式写入脚本文件。

  3. 防火墙与端口:连接远程 SQL Server 实例时,确保目标服务器的 1433 端口(或自定义端口)已在防火墙中放行,且 SQL Server 配置管理器中已启用 TCP/IP 协议。命名实例还需要 SQL Server Browser 服务运行在 1434 端口上才能正确解析动态端口。

  4. SMO 对象生命周期Connect-SqlInstance 返回的 Server 对象持有数据库连接,在长时间运行的脚本中(如循环处理数百个数据库),建议在操作完成后调用 $instance.ConnectionContext.Disconnect() 主动释放连接,避免占用过多数据库连接数。

  5. 错误处理:执行 T-SQL 或访问 SMO 属性时,应使用 try/catch 包裹关键操作。例如数据库处于恢复状态时访问 $db.Size 会抛出异常。对于批量巡检脚本,单库异常不应中断整个流程,建议收集错误信息后统一输出报告。

  6. 性能考量Invoke-Sqlcmd 默认将整个结果集加载到内存,对于返回大量行的查询(如审计日志全表扫描),可能导致内存占用过高。可以通过 -MaxCharLength-MaxBinaryLength 参数控制字段截断长度,或在 T-SQL 中使用 TOP 子句限制行数。对于海量数据导出场景,建议使用 bcp 工具或 SqlBulkCopy 类。

PowerShell 技能连载 - Azure Policy 合规管理

适用于 PowerShell 5.1 及以上版本

随着企业云基础设施规模不断膨胀,资源合规性管理成为运维团队的核心挑战。Azure Policy 是微软 Azure 平台提供的治理服务,能够在资源创建和更新时自动执行组织规则,例如限制资源类型、强制标签策略、审核配置基线等。通过 PowerShell 的 Az 模块,我们可以将策略的定义、分配和合规审查全部纳入自动化流水线,实现”基础设施即代码”的治理闭环。

传统的合规审计往往依赖人工巡检或第三方工具,耗时且容易遗漏。Azure Policy 将合规检查前移到资源生命周期中,一旦资源偏离预期状态就会触发警报甚至自动修正。结合 PowerShell 的批量处理能力,管理员可以在几分钟内对数百个订阅进行策略评估,快速定位不合规资源并生成报告,大幅降低安全风险和审计成本。

本文将介绍如何使用 PowerShell 完成 Azure Policy 的常见操作,包括查询策略定义、分配策略到作用域、检索合规状态,以及批量导出合规报告,帮助你构建可重复、可审计的云治理工作流。

查询 Azure Policy 内置定义

Azure 提供了大量内置策略定义,覆盖存储、网络、计算、安全等多个领域。我们可以用 PowerShell 快速搜索并筛选需要的策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 连接 Azure 账户(如已登录可跳过)
Connect-AzAccount

# 搜索与"标签"相关的内置策略定义
$tagPolicies = Get-AzPolicyDefinition | Where-Object {
$_.Properties.DisplayName -match "tag" -and
$_.Properties.PolicyType -eq "BuiltIn"
}

# 输出策略名称和显示名
foreach ($policy in $tagPolicies) {
[PSCustomObject]@{
Name = $policy.Name
DisplayName = $policy.Properties.DisplayName
Category = $policy.Properties.Metadata.category
}
}

执行结果示例:

1
2
3
4
5
Name                                  DisplayName                                Category
---- ----------- --------
1e30110a-5ceb-460c-a204-fsdfsdf Require a tag on resources Tags
8e346d3c-483d-4fef-8d21-dsfsdf Inherit a tag from the resource group Tags
...

通过 Get-AzPolicyDefinition 获取所有策略定义后,用 Where-ObjectDisplayNameCategory 进行筛选,方便找到目标策略。PolicyTypeBuiltIn 表示微软内置策略,Custom 则是用户自定义策略。

分配策略到指定作用域

找到合适的策略后,下一步是将其分配到订阅或资源组级别。以下示例将”要求资源具有环境标签”的内置策略分配到指定资源组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 获取目标资源组信息
$rg = Get-AzResourceGroup -Name "prod-app-rg"

# 获取内置策略定义(要求资源必须带有指定标签)
$policyDef = Get-AzPolicyDefinition | Where-Object {
$_.Properties.DisplayName -eq "Require a tag on resources"
} | Select-Object -First 1

# 构造策略参数:指定必须存在的标签名为 "Environment"
$policyParam = @{
tagName = "Environment"
}

# 分配策略到资源组
$assignment = New-AzPolicyAssignment `
-Name "require-env-tag" `
-DisplayName "要求所有资源带有 Environment 标签" `
-PolicyDefinition $policyDef `
-Scope $rg.ResourceId `
-PolicyParameterObject $policyParam

Write-Host "策略已分配,AssignmentId: $($assignment.PolicyAssignmentId)"

执行结果示例:

1
策略已分配,AssignmentId: /subscriptions/xxxx-xxxx/resourceGroups/prod-app-rg/providers/Microsoft.Authorization/policyAssignments/require-env-tag

New-AzPolicyAssignmentScope 参数支持订阅级别(/subscriptions/{id})、资源组级别(含 /resourceGroups/{name})甚至单个资源级别。PolicyParameterObject 以哈希表形式传递策略所需的参数,不同策略需要的参数各异,可查看策略定义的 Properties.Parameters 了解详情。

检索合规状态

策略分配完成后,需要定期检查资源的合规情况。以下脚本查询指定订阅下所有策略分配的合规状态,并筛选出不合规的资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 获取当前订阅 ID
$subscriptionId = (Get-AzContext).Subscription.Id

# 查询最近一次策略评估的合规状态
$complianceStates = Get-AzPolicyState | Where-Object {
$_.ComplianceState -eq "NonCompliant"
}

# 按策略分配分组统计不合规资源数量
$summary = @{}
foreach ($state in $complianceStates) {
$key = $state.PolicyAssignmentName
if (-not $summary.ContainsKey($key)) {
$summary[$key] = 0
}
$summary[$key]++
}

foreach ($item in $summary.GetEnumerator() | Sort-Object Value -Descending) {
[PSCustomObject]@{
PolicyAssignment = $item.Key
NonCompliantCount = $item.Value
}
}

执行结果示例:

1
2
3
4
5
PolicyAssignment             NonCompliantCount
---------------- -----------------
require-env-tag 12
audit-storage-https 5
deny-public-endpoints 3

Get-AzPolicyState 返回的是策略评估的详细记录,每条记录对应一个资源与一条策略的关系。ComplianceState 字段取值 Compliant(合规)或 NonCompliant(不合规)。按策略分配名称分组统计,可以快速了解哪些策略的不合规资源最多,便于优先处理。

批量导出合规报告

对于审计和归档需求,我们通常需要将合规数据导出为 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
# 定义输出路径
$reportDate = Get-Date -Format "yyyyMMdd"
$csvPath = "$HOME/AzurePolicyReport_$reportDate.csv"

# 获取所有不合规状态记录
$nonCompliant = Get-AzPolicyState | Where-Object {
$_.ComplianceState -eq "NonCompliant"
}

# 构造报告对象
$reportRows = foreach ($record in $nonCompliant) {
[PSCustomObject]@{
Timestamp = $record.Timestamp
ResourceId = $record.ResourceId
ResourceType = $record.ResourceType
ResourceGroup = $record.ResourceGroup
PolicyAssignment = $record.PolicyAssignmentName
PolicyDefinition = $record.PolicyDefinitionName
ComplianceState = $record.ComplianceState
}
}

# 导出为 CSV
$reportRows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

Write-Host "合规报告已导出: $csvPath"
Write-Host "共 $($reportRows.Count) 条不合规记录"

执行结果示例:

1
2
合规报告已导出: /home/user/AzurePolicyReport_20251110.csv
20 条不合规记录

导出的 CSV 文件包含资源 ID、类型、所属资源组、关联策略等字段,可以直接用 Excel 打开进行筛选和透视分析,也可以导入到 SIEM 或仪表盘系统中做持续监控。

注意事项

  1. 模块版本:确保安装了最新版 Az.PolicyAz.Resources 模块(Install-Module Az.Resources -Force),旧版本可能缺少 Get-AzPolicyState 等关键命令。PowerShell 5.1 用户注意 .NET Framework 版本兼容性。

  2. 权限要求:执行策略相关操作需要订阅级别的 Microsoft.Authorization/policyAssignments/* 权限。建议使用专用的服务主体(Service Principal)并在 CI/CD 流水线中运行,避免使用个人账户。

  3. 合规数据延迟Get-AzPolicyState 返回的是最近一次策略评估引擎扫描的结果,通常有 15-30 分钟的延迟。资源变更后不会立即反映在合规状态中,如需即时验证可手动触发评估:Start-AzPolicyComplianceScan

  4. 大数据量处理:在拥有数千资源的订阅中,Get-AzPolicyState 可能返回海量记录。建议通过 -Filter 参数缩小范围,例如 Get-AzPolicyState -Filter "PolicyAssignmentName eq 'require-env-tag'" 只查询特定策略的合规数据,避免内存溢出。

  5. 策略豁免管理:某些资源可能需要临时豁免策略(如测试环境),可使用 New-AzPolicyExemption 创建豁免记录并设置过期时间,而不是直接删除策略分配,以保持治理完整性。

  6. 自定义策略定义:当内置策略无法满足需求时,可通过 New-AzPolicyDefinition 创建自定义策略。策略规则使用 JSON 格式声明条件与效果(如 Audit、Deny、DeployIfNotExists),建议将策略 JSON 纳入 Git 版本控制,配合 CI/CD 流水线自动部署,确保策略变更可追溯、可回滚。

掌握以上操作后,你可以将 Azure Policy 的日常管理全部纳入 PowerShell 自动化流程,从策略定义到合规监控再到报告导出,形成完整的治理闭环。

PowerShell 技能连载 - PowerShell Gallery 发布

适用于 PowerShell 5.1 及以上版本

PowerShell Gallery(www.powershellgallery.com)是微软官方维护的 PowerShell 模块和脚本共享平台,类似于 Node.js 的 npm 或 Python 的 PyPI。通过它,你可以将自己编写的模块分发给全球的 PowerShell 用户,也可以方便地安装其他人发布的工具。对于团队协作而言,将内部通用组件发布到私有 Gallery 或公开 Gallery,能够显著提升代码复用率和团队协作效率。

本文将从模块准备、API Key 管理、发布流程以及版本更新四个环节,完整介绍如何将一个自定义 PowerShell 模块发布到 PowerShell Gallery。无论你是开源项目维护者还是企业内部工具开发者,掌握这一流程都能让你的 PowerShell 代码分发更加规范和专业。

准备模块清单文件

PowerShell Gallery 要求发布的模块必须包含模块清单文件(.psd1)。清单文件中定义了模块的元数据,包括版本号、作者、描述、依赖项等信息。其中一些字段是 Gallery 发布的必填项,缺少这些字段会导致发布失败。

使用 New-ModuleManifest 可以快速创建一个标准的清单文件:

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
# 创建模块目录结构
$modulePath = Join-Path -Path $env:USERPROFILE -ChildPath "Documents\PowerShell\Modules\MyUtils"
if (-not (Test-Path -Path $modulePath)) {
New-Item -Path $modulePath -ItemType Directory -Force | Out-Null
Write-Host "已创建模块目录: $modulePath"
}

# 创建模块清单
$manifestParams = @{
Path = Join-Path -Path $modulePath -ChildPath "MyUtils.psd1"
RootModule = "MyUtils.psm1"
ModuleVersion = "1.0.0"
Author = "Your Name"
CompanyName = "Your Company"
Description = "A collection of utility functions for daily PowerShell tasks"
PowerShellVersion = "5.1"
FunctionsToExport = @("Get-SystemReport", "Invoke-HealthCheck")
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
Tags = @("Utility", "Automation", "Monitoring")
ProjectUri = "https://github.com/yourname/MyUtils"
LicenseUri = "https://github.com/yourname/MyUtils/blob/main/LICENSE"
ReleaseNotes = "Initial release with system report and health check functions."
}

New-ModuleManifest @manifestParams
Write-Host "模块清单已创建"

执行结果示例:

1
2
已创建模块目录: C:\Users\admin\Documents\PowerShell\Modules\MyUtils
模块清单已创建

清单中 TagsProjectUriLicenseUriReleaseNotes 这几个字段虽然不是 New-ModuleManifest 的必需参数,但 PowerShell Gallery 发布时会检查它们。缺失这些字段虽然不会阻止发布,但会严重影响模块在 Gallery 中的可发现性和用户体验。建议在发布前使用 Test-ModuleManifest 验证清单文件的完整性。

获取并配置 API Key

发布模块到 PowerShell Gallery 需要一个 API Key。你需要先在 powershellgallery.com 注册账户(支持 Microsoft 账户或 GitHub 账户登录),然后在账户设置中生成 API Key。API Key 本质上是一个 NuGet API Key,PowerShell Gallery 底层基于 NuGet 协议运行。

为了安全起见,不要将 API Key 硬编码在脚本中。推荐的做法是将 Key 保存到环境变量或凭据管理器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 检查是否已配置 API Key 环境变量
if ($env:PSGALLERY_API_KEY) {
Write-Host "API Key 已配置,长度: $($env:PSGALLERY_API_KEY.Length) 个字符"
}
else {
Write-Host "尚未配置 PSGALLERY_API_KEY 环境变量"
Write-Host "请按以下步骤操作:"
Write-Host " 1. 访问 https://www.powershellgallery.com/users/account/LogOn"
Write-Host " 2. 登录后进入 API Keys 页面"
Write-Host " 3. 点击 Create 生成新的 API Key"
Write-Host " 4. 将 Key 保存为环境变量:"
Write-Host ' [Environment]::SetEnvironmentVariable("PSGALLERY_API_KEY", "your-key-here", "User")'
}

# 永久保存 API Key 到用户级环境变量(只需执行一次)
# [Environment]::SetEnvironmentVariable("PSGALLERY_API_KEY", "your-api-key", "User")

执行结果示例:

1
2
3
4
5
6
7
尚未配置 PSGALLERY_API_KEY 环境变量
请按以下步骤操作:
1. 访问 https://www.powershellgallery.com/users/account/LogOn
2. 登录后进入 API Keys 页面
3. 点击 Create 生成新的 API Key
4. 将 Key 保存为环境变量:
[Environment]::SetEnvironmentVariable("PSGALLERY_API_KEY", "your-key-here", "User")

API Key 生成时可以选择过期时间和作用域。建议使用最小权限原则:如果只需要推送特定模块,就创建限定到该模块名称的 Key,而不是创建全权限 Key。这样即使 Key 泄露,影响范围也可以控制。

一切准备就绪后,使用 Publish-Module 命令将模块发布到 PowerShell Gallery。发布前务必在本地完整测试模块功能,因为一旦发布,版本号就不可重复使用——如果你发布了 1.0.0 版本后发现 bug,只能通过发布 1.0.1 等新版本修复,无法覆盖 1.0.0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 发布前的预检查
$modulePath = Join-Path -Path $env:USERPROFILE -ChildPath "Documents\PowerShell\Modules\MyUtils"

# 验证清单文件
$manifest = Test-ModuleManifest -Path (Join-Path -Path $modulePath -ChildPath "MyUtils.psd1")
if ($manifest) {
Write-Host "模块名称: $($manifest.Name)"
Write-Host "版本号: $($manifest.Version)"
Write-Host "导出函数: $($manifest.ExportedFunctions.Keys -join ', ')"
}

# 发布模块
$publishParams = @{
Path = $modulePath
NuGetApiKey = $env:PSGALLERY_API_KEY
Repository = "PSGallery"
Verbose = $true
}

Publish-Module @publishParams
Write-Host "模块已成功发布到 PowerShell Gallery!"

执行结果示例:

1
2
3
4
5
6
7
模块名称: MyUtils
版本号: 1.0.0
导出函数: Get-SystemReport, Invoke-HealthCheck
VERBOSE: Successfully created an API key for PSGallery.
VERBOSE: Attempting to publish module 'MyUtils' version '1.0.0' to 'PSGallery'.
VERBOSE: Successfully published module 'MyUtils' version '1.0.0' to 'PSGallery'.
模块已成功发布到 PowerShell Gallery!

发布成功后,模块通常在几分钟内就能在 PowerShell Gallery 上搜索到。其他用户可以通过 Install-Module -Name MyUtils 直接安装使用。

验证发布结果

发布完成后,应当验证模块在 Gallery 上的展示效果。可以通过命令行查询,也可以在浏览器中直接访问模块页面,检查描述、标签、项目链接等信息是否正确显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 通过命令行验证模块是否已上线
$published = Find-Module -Name "MyUtils" -Repository PSGallery

if ($published) {
Write-Host "模块已上线!"
Write-Host " 名称: $($published.Name)"
Write-Host " 版本: $($published.Version)"
Write-Host " 描述: $($published.Description)"
Write-Host " 作者: $($published.Author)"
Write-Host " 下载量: $($published.AdditionalMetadata['versionDownloadCount'])"
Write-Host " 页面: https://www.powershellgallery.com/packages/$($published.Name)/$($published.Version)"
}
else {
Write-Host "警告: 未在 Gallery 中找到模块,可能需要等待几分钟"
}

执行结果示例:

1
2
3
4
5
6
7
模块已上线!
名称: MyUtils
版本: 1.0.0
描述: A collection of utility functions for daily PowerShell tasks
作者: Your Name
下载量: 0
页面: https://www.powershellgallery.com/packages/MyUtils/1.0.0

如果信息有误,可以在修正后发布新版本。已发布的版本无法修改或删除(除非联系 PowerShell Gallery 运维团队)。

更新模块版本

当模块修复了 bug 或新增功能后,需要递增版本号并重新发布。PowerShell Gallery 遵循语义化版本(SemVer)规则:主版本号.次版本号.修订号。例如,修复 bug 递增修订号(1.0.0 → 1.0.1),新增功能递增次版本号(1.0.1 → 1.1.0),不兼容的 API 变更递增主版本号(1.1.0 → 2.0.0)。

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
# 更新模块版本号和发布说明
$modulePath = Join-Path -Path $env:USERPROFILE -ChildPath "Documents\PowerShell\Modules\MyUtils"
$manifestPath = Join-Path -Path $modulePath -ChildPath "MyUtils.psd1"

# 读取当前版本并递增修订号
$currentManifest = Test-ModuleManifest -Path $manifestPath
$currentVersion = $currentManifest.Version
$newVersion = [version]::new(
$currentVersion.Major,
$currentVersion.Minor,
$currentVersion.Build + 1
)

Write-Host "当前版本: $currentVersion → 新版本: $newVersion"

# 更新清单中的版本号和发布说明
$updateData = @{
ModuleVersion = $newVersion.ToString()
ReleaseNotes = "Fixed edge case in Get-SystemReport when WMI service is stopped."
}
Update-ModuleManifest -Path $manifestPath @updateData

# 重新发布
Publish-Module -Path $modulePath -NuGetApiKey $env:PSGALLERY_API_KEY -Repository PSGallery
Write-Host "新版本 $newVersion 已发布"

执行结果示例:

1
2
3
4
当前版本: 1.0.0 → 新版本: 1.0.1
VERBOSE: Attempting to publish module 'MyUtils' version '1.0.1' to 'PSGallery'.
VERBOSE: Successfully published module 'MyUtils' version '1.0.1' to 'PSGallery'.
新版本 1.0.1 已发布

版本更新后,已安装旧版本的用户通过 Update-Module -Name MyUtils 即可获取最新版本。PowerShellGet 会自动处理版本比较和下载安装。

自动化发布流程

在 CI/CD 场景中,可以将发布流程集成到 GitHub Actions 或 Azure Pipelines 中,实现提交代码后自动发布新版本。以下是一个使用 PowerShell 脚本在 CI 环境中发布的示例:

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
# 适用于 CI/CD 管道的发布脚本
# 从 git tag 中提取版本号,确保版本号与代码提交严格对应
$gitTag = git describe --tags --abbrev=0 2>$null
if (-not $gitTag) {
Write-Error "未找到 git tag,请先创建版本标签"
return
}

# 从 tag 名称中解析版本号(例如 v1.2.3 → 1.2.3)
$versionStr = $gitTag -replace '^v', ""
$newVersion = [version]$versionStr

Write-Host "准备发布版本: $newVersion (from tag: $gitTag)"

# 更新清单
$manifestPath = "./src/MyUtils/MyUtils.psd1"
$releaseNotes = git log --pretty=format:"- %s" ("$($gitTag)~1"..HEAD) 2>$null
if (-not $releaseNotes) {
$releaseNotes = "Release $newVersion"
}

Update-ModuleManifest -Path $manifestPath -ModuleVersion $newVersion.ToString() -ReleaseNotes $releaseNotes

# 验证清单完整性
$requiredFields = @("Description", "Author", "Tags", "ProjectUri", "LicenseUri")
$manifest = Test-ModuleManifest -Path $manifestPath
$warnings = @()
foreach ($field in $requiredFields) {
$value = $manifest.$field
if (-not $value) {
$warnings += "缺少必填字段: $field"
}
}

if ($warnings.Count -gt 0) {
Write-Host "发布前检查警告:"
foreach ($w in $warnings) {
Write-Host " [WARN] $w"
}
}

# 发布
Publish-Module -Path "./src/MyUtils" -NuGetApiKey $env:PSGALLERY_API_KEY -Repository PSGallery
Write-Host "CI/CD 发布完成: v$newVersion"

执行结果示例:

1
2
准备发布版本: 1.1.0 (from tag: v1.1.0)
CI/CD 发布完成: v1.1.0

将 API Key 存储在 CI/CD 平台的安全变量中(如 GitHub Secrets),确保密钥不会出现在日志和代码仓库中。这种方式适合团队协作开发,每次合并代码后自动触发构建和发布。

注意事项

  • 版本号不可复用:PowerShell Gallery 不允许删除已发布的版本,也无法覆盖已有版本号。即使你发布了一个有严重 bug 的版本,该版本号也永久被占用。发布前务必在本地充分测试,确认无误后再推送。
  • 清单必填字段DescriptionAuthor 是 Gallery 的强制要求字段。虽然 TagsProjectUriLicenseUri 不阻止发布,但缺失这些字段会让模块难以被搜索和信任。建议在 New-ModuleManifest 阶段就完整填写所有字段。
  • API Key 安全管理:不要将 API Key 提交到代码仓库,也不要在聊天记录或日志中明文输出。使用环境变量或 CI/CD 平台的安全变量存储,并为不同用途创建不同作用域的 Key。
  • 模块大小限制:PowerShell Gallery 对单个模块包的大小有限制(目前约 250 MB)。如果你的模块包含大量二进制文件或数据文件,考虑使用外部存储并仅在模块中提供下载脚本。
  • 私有 Gallery 部署:企业内部可以使用 PowerShell Gallery 的私有实例(如基于 NuGet Server 或 Artifactory 搭建),发布流程完全相同,只需通过 Register-PSRepository 注册内部仓库地址,然后在 Publish-Module 中指定 -Repository 参数即可。
  • 依赖项声明:如果模块依赖其他模块,务必在清单中通过 RequiredModules 字段声明。这样用户安装你的模块时,PowerShellGet 会自动解析并安装所有依赖,避免因缺少依赖导致模块加载失败。

PowerShell 技能连载 - Azure Monitor 告警

适用于 PowerShell 5.1 及以上版本

Azure Monitor 是微软 Azure 平台的核心可观测性服务,提供指标采集、日志分析、告警通知等一站式监控能力。在云原生架构日益复杂的今天,运维团队往往需要管理数十甚至上百个 Azure 资源的监控策略。手动在 Azure 门户中逐一配置告警规则既耗时又容易遗漏,而通过 PowerShell 自动化管理告警,可以实现告警策略的标准化、版本化和批量部署。

本文将介绍如何使用 PowerShell 和 Az 模块操作 Azure Monitor 告警,包括查看现有告警规则、创建指标告警、配置操作组(Action Group)实现通知推送,以及批量管理告警规则。这些方法适用于日常运维自动化,也能与基础设施即代码(IaC)流程相结合,确保监控策略随应用部署同步更新。

在开始之前,请确保已安装 Az PowerShell 模块并完成 Azure 身份认证。所有示例基于 Azure 资源管理器(ARM)REST API,需要拥有目标资源组或订阅级别的 Monitoring Contributor 权限。

连接 Azure 并获取监控资源

第一步是连接 Azure 账户并获取目标资源的信息。告警规则需要绑定到具体的 Azure 资源(如虚拟机、App Service、SQL 数据库等),因此我们先确认订阅上下文并查询需要监控的资源列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 安装 Az 监控相关模块
Install-Module -Name Az.Accounts, Az.Monitor, Az.Resources -Force -Scope CurrentUser

# 连接 Azure 账户
Connect-AzAccount

# 获取当前订阅信息
$context = Get-AzContext
$subscriptionId = $context.Subscription.Id
Write-Host ("当前订阅: {0} ({1})" -f $context.Subscription.Name, $subscriptionId)

# 查询目标资源组中的虚拟机
$resourceGroupName = "rg-production"
$vms = Get-AzVM -ResourceGroupName $resourceGroupName

Write-Host "`n===== 资源组中的虚拟机 =====" -ForegroundColor Cyan
foreach ($vm in $vms) {
$status = (Get-AzVM -ResourceGroupName $resourceGroupName -Name $vm.Name -Status).Statuses[1].DisplayStatus
Write-Host (" {0,-30} {1}" -f $vm.Name, $status)
}

# 选取第一台虚拟机的资源 ID 作为后续示例
$targetResourceId = $vms[0].Id
Write-Host ("`n目标资源 ID: {0}" -f $targetResourceId)

执行结果示例:

1
2
3
4
5
6
7
8
9
当前订阅: Production-Subscription (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)

===== 资源组中的虚拟机 =====
vm-web-01 VM running
vm-web-02 VM running
vm-db-01 VM running
vm-api-01 VM deallocated

目标资源 ID: /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-production/providers/Microsoft.Compute/virtualMachines/vm-web-01

连接成功后,我们得到了目标资源的完整 ID。后续创建告警规则时,需要将这个资源 ID 作为监控的目标范围(Scope)。如果需要监控整个资源组或订阅级别的指标,可以使用资源组 ID 或订阅 ID 作为范围。

创建指标告警规则

Azure Monitor 的指标告警(Metric Alert)是最常用的告警类型,它基于资源发出的性能指标数据进行评估。以下代码演示如何为虚拟机创建 CPU 使用率告警,当 CPU 平均使用率连续 5 分钟超过 85% 时触发告警。

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
# 定义告警规则参数
$alertRuleName = "cpu-high-vm-web-01"
$alertDescription = "虚拟机 CPU 使用率连续 5 分钟超过 85%,可能影响应用性能"
$windowSize = "PT5M"
$evaluationFrequency = "PT1M"
$threshold = 85
$operator = "GreaterThan"
$aggregation = "Average"
$severity = 2

# 获取目标虚拟机的资源 ID
$targetVm = Get-AzVM -ResourceGroupName $resourceGroupName -Name "vm-web-01"

# 创建告警条件
$condition = New-AzMetricAlertRuleV2Criteria `
-MetricName "Percentage CPU" `
-TimeAggregation $aggregation `
-Operator $operator `
-Threshold $threshold

# 创建告警规则
$alertRule = New-AzMetricAlertRuleV2 `
-Name $alertRuleName `
-ResourceGroupName $resourceGroupName `
-WindowSize $windowSize `
-Frequency $evaluationFrequency `
-TargetResourceId $targetVm.Id `
-Condition $condition `
-Severity $severity `
-Description $alertDescription `
-Enabled

if ($alertRule) {
Write-Host "指标告警规则创建成功!" -ForegroundColor Green
Write-Host (" 规则名称: {0}" -f $alertRule.Name)
Write-Host (" 目标资源: {0}" -f $targetVm.Name)
Write-Host (" 监控指标: Percentage CPU")
Write-Host (" 聚合方式: {0}" -f $aggregation)
Write-Host (" 阈值: {0} {1}" -f $operator, $threshold)
Write-Host (" 检测窗口: {0}" -f $windowSize)
Write-Host (" 评估频率: {0}" -f $evaluationFrequency)
Write-Host (" 严重级别: Sev{0}" -f $severity)
}

执行结果示例:

1
2
3
4
5
6
7
8
9
指标告警规则创建成功!
规则名称: cpu-high-vm-web-01
目标资源: vm-web-01
监控指标: Percentage CPU
聚合方式: Average
阈值: GreaterThan 85
检测窗口: PT5M
评估频率: PT1M
严重级别: Sev2

指标告警规则的几个关键参数需要根据实际场景调优。WindowSize(检测窗口)决定了评估指标的时间跨度,Frequency(评估频率)决定了多久检查一次条件。窗口越大越不容易产生误报,但响应时间也会变长。对于关键业务系统,建议采用较短的窗口(如 PT1M 到 PT5M),配合适度的阈值,在灵敏度和稳定性之间取得平衡。

配置操作组实现告警通知

告警规则触发后,需要有渠道将通知送达运维人员。Azure Monitor 的操作组(Action Group)定义了告警触发时的响应动作,包括发送邮件、短信、Webhook、调用 Azure Function 等。以下代码展示如何创建一个操作组,包含邮件通知和 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
# 创建邮件接收人
$emailReceiver = New-AzActionGroupReceiver `
-Name "ops-team-email" `
-EmailAddress "ops-team@example.com"

# 创建 Webhook 接收(可对接企业 IM、事件管理平台)
$webhookReceiver = New-AzActionGroupReceiver `
-Name "incident-webhook" `
-WebhookUri "https://hooks.example.com/azure-alerts/incident"

# 创建操作组
$actionGroupName = "ag-production-critical"
$actionGroupShortName = "prod-crit"

$actionGroup = Set-AzActionGroup `
-Name $actionGroupName `
-ResourceGroupName $resourceGroupName `
-ShortName $actionGroupShortName `
-Receiver $emailReceiver, $webhookReceiver

if ($actionGroup) {
Write-Host "操作组创建成功!" -ForegroundColor Green
Write-Host (" 操作组名称: {0}" -f $actionGroup.Name)
Write-Host (" 短名称: {0}" -f $actionGroup.ShortName)
Write-Host (" 接收人数量: {0}" -f $actionGroup.Receivers.Count)
Write-Host "`n 接收人详情:" -ForegroundColor Cyan
foreach ($receiver in $actionGroup.Receivers) {
Write-Host (" - {0}: {1}" -f $receiver.Name, $receiver.EmailAddress)
}
}

# 将操作组关联到告警规则
$actionGroupId = (Get-AzActionGroup -ResourceGroupName $resourceGroupName -Name $actionGroupName).Id

# 创建动作引用
$action = New-AzAlertRuleAction -ActionGroupId $actionGroupId

Write-Host "`n操作组已就绪,可关联到告警规则。" -ForegroundColor Green
Write-Host (" Action Group ID: {0}" -f $actionGroupId)

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
操作组创建成功!
操作组名称: ag-production-critical
短名称: prod-crit
接收人数量: 2

接收人详情:
- ops-team-email: ops-team@example.com
- incident-webhook:

操作组已就绪,可关联到告警规则。
Action Group ID: /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-production/providers/microsoft.insights/actionGroups/ag-production-critical

操作组设计为独立于告警规则的资源,这意味着一个操作组可以被多条告警规则复用。建议按照团队或响应级别来组织操作组,例如”生产环境-紧急”、”生产环境-警告”、”测试环境-通知”等。当团队成员变动时,只需修改操作组即可,无需逐条更新告警规则。

批量查询和管理告警规则

在大型 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
62
# 查询资源组中所有指标告警规则
$alertRules = Get-AzMetricAlertRuleV2 -ResourceGroupName $resourceGroupName

Write-Host "===== 告警规则清单 =====" -ForegroundColor Cyan
Write-Host ("资源组: {0}" -f $resourceGroupName)
Write-Host ("规则总数: {0}" -f $alertRules.Count)
Write-Host ""

# 构建告警规则报告
$reportData = foreach ($rule in $alertRules) {
$targetResources = @()
foreach ($scope in $rule.Scopes) {
$parts = $scope -split "/"
$resourceName = $parts[-1]
$targetResources += $resourceName
}

$criteria = $rule.Criteria
$metricName = if ($criteria.AllOf) { $criteria.AllOf[0].MetricName } else { "N/A" }
$thresholdValue = if ($criteria.AllOf) { $criteria.AllOf[0].Threshold } else { "N/A" }
$operatorValue = if ($criteria.AllOf) { $criteria.AllOf[0].OperatorProperty } else { "N/A" }

[PSCustomObject]@{
规则名称 = $rule.Name
严重级别 = "Sev{0}" -f $rule.Severity
启用状态 = if ($rule.Enabled) { "已启用" } else { "已禁用" }
监控指标 = $metricName
阈值条件 = "{0} {1}" -f $operatorValue, $thresholdValue
检测窗口 = $rule.WindowSize
目标资源 = ($targetResources -join ", ")
操作组 = if ($rule.Actions.Count -gt 0) { "已配置 ({0} 个)" -f $rule.Actions.Count } else { "未配置" }
}
}

# 按严重级别排序并输出
$sortedReport = $reportData | Sort-Object 严重级别, 规则名称

foreach ($item in $sortedReport) {
Write-Host "---" -ForegroundColor Gray
Write-Host (" 规则名称: {0}" -f $item.规则名称)
Write-Host (" 严重级别: {0}" -f $item.严重级别)
Write-Host (" 启用状态: {0}" -f $item.启用状态)
Write-Host (" 监控指标: {0}" -f $item.监控指标)
Write-Host (" 阈值条件: {0}" -f $item.阈值条件)
Write-Host (" 检测窗口: {0}" -f $item.检测窗口)
Write-Host (" 目标资源: {0}" -f $item.目标资源)
Write-Host (" 操作组: {0}" -f $item.操作组)
}

# 导出 CSV 报告
$reportPath = Join-Path $env:TEMP "AlertRules-Report-{0:yyyyMMdd}.csv" -f (Get-Date)
$sortedReport | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
Write-Host ("`n报告已导出: {0}" -f $reportPath) -ForegroundColor Green

# 检查未配置操作组的规则
$noActionRules = $reportData | Where-Object { $_.操作组 -eq "未配置" }
if ($noActionRules) {
Write-Host "`n[警告] 以下告警规则未配置操作组,触发后不会发送通知:" -ForegroundColor Yellow
foreach ($rule in $noActionRules) {
Write-Host (" - {0}" -f $rule.规则名称) -ForegroundColor Yellow
}
}

执行结果示例:

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
===== 告警规则清单 =====
资源组: rg-production
规则总数: 8

---
规则名称: cpu-high-vm-api-01
严重级别: Sev2
启用状态: 已启用
监控指标: Percentage CPU
阈值条件: GreaterThan 90
检测窗口: 00:05:00
目标资源: vm-api-01
操作组: 已配置 (1 个)
---
规则名称: cpu-high-vm-web-01
严重级别: Sev2
启用状态: 已启用
监控指标: Percentage CPU
阈值条件: GreaterThan 85
检测窗口: 00:05:00
目标资源: vm-web-01
操作组: 已配置 (2 个)
---
规则名称: disk-space-vm-db-01
严重级别: Sev3
启用状态: 已启用
监控指标: OsDisk.Used
阈值条件: GreaterThan 80
检测窗口: 00:10:00
目标资源: vm-db-01
操作组: 未配置

报告已导出: /tmp/AlertRules-Report-20251106.csv

[警告] 以下告警规则未配置操作组,触发后不会发送通知:
- disk-space-vm-db-01

批量审查告警规则是运维巡检的重要环节。脚本中特别加入了”未配置操作组”的检测逻辑——一条没有操作组的告警规则即使被触发,也不会通知任何人,形同虚设。建议将此脚本纳入定期巡检流程,确保所有告警规则都处于有效工作状态。

使用日志查询告警

除了指标告警,Azure Monitor 还支持基于 Log Analytics 日志查询的告警。日志告警使用 KQL(Kusto Query Language)编写查询条件,能够对结构化日志数据进行复杂的关联分析。以下示例展示如何通过 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
# 定义日志告警参数
$logAlertName = "anomalous-signin-detection"
$logAlertDescription = "检测 1 小时内同一账户从不同地区登录的行为,可能表示凭据泄露"
$workspaceResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.OperationalInsights/workspaces/law-production"

# 构建 KQL 查询
$kqlQuery = @"
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType == 0
| summarize LoginCount = count(), DistinctLocations = dcount(Location),
Locations = make_set(Location, 10) by UserPrincipalName
| where DistinctLocations > 2
| project TimeGenerated = now(), UserPrincipalName, LoginCount, DistinctLocations, Locations
| order by DistinctLocations desc
"@

# 创建日志查询告警的条件
$schedule = New-AzScheduledQueryRuleScheduleObject `
-FrequencyInMinutes 15 `
-TimeWindowInMinutes 60

$conditionObject = New-AzScheduledQueryRuleConditionObject `
-Query $kqlQuery `
-TimeWindow (New-TimeSpan -Minutes 60)

# 创建日志告警规则
$logAlert = New-AzScheduledQueryRule `
-Name $logAlertName `
-ResourceGroupName $resourceGroupName `
-Location "eastus" `
-DisplayName "异常登录行为检测" `
-Description $logAlertDescription `
-Severity 2 `
-Enabled `
-Schedule $schedule `
-Condition $conditionObject `
-Scope $workspaceResourceId

if ($logAlert) {
Write-Host "日志查询告警规则创建成功!" -ForegroundColor Green
Write-Host (" 规则名称: {0}" -f $logAlert.Name)
Write-Host (" 显示名称: {0}" -f $logAlert.DisplayName)
Write-Host (" 严重级别: Sev{0}" -f $logAlert.Severity)
Write-Host (" 查询频率: 每 {0} 分钟" -f $schedule.FrequencyInMinutes)
Write-Host (" 查询窗口: {0} 分钟" -f $schedule.TimeWindowInMinutes)
Write-Host (" 目标工作区: {0}" -f ($workspaceResourceId -split "/")[-1])
}

执行结果示例:

1
2
3
4
5
6
7
日志查询告警规则创建成功!
规则名称: anomalous-signin-detection
显示名称: 异常登录行为检测
严重级别: Sev2
查询频率: 每 15 分钟
查询窗口: 60 分钟
目标工作区: law-production

日志告警的灵活性远高于指标告警,但代价是更高的 Log Analytics 查询成本。编写 KQL 查询时,务必使用 where 子句尽早过滤数据,减少扫描的数据量。TimeWindow 参数决定查询回溯的时间范围,Frequency 参数决定查询的执行间隔,两者的设置需要平衡检测时效性和运行成本。

注意事项

  1. 权限配置:操作 Azure Monitor 告警需要 Microsoft.Insights/metricAlerts/*Microsoft.Insights/actionGroups/* 等权限。建议创建 Azure 自定义角色,仅授予 Monitoring Contributor 级别的权限,避免使用 Owner 或 Contributor 等过宽的角色。在多团队协作环境中,通过角色划分明确告警管理的责任边界。

  2. 告警疲劳治理:阈值设置不当会导致大量无效告警(Alert Fatigue),使运维人员逐渐忽视告警通知。建议先以较宽松的阈值试运行一周,观察触发频率后再逐步收紧。对于非关键指标,可以设置较高的严重级别(Sev3 或 Sev4),减少高频告警的干扰。

  3. API 版本管理Az.Monitor 模块中的 cmdlet 对应不同版本的 ARM API,行为可能随模块更新而变化。生产脚本中应固定 Az 模块版本(如 RequiredVersion),并在升级前在测试环境中验证兼容性。同时关注 Azure 更新公告,了解 Breaking Change。

  4. 操作组测试:创建操作组后,务必使用 Azure 门户的”测试操作组”功能或手动触发一条测试告警,确认邮件和 Webhook 通知能够正常送达。Webhook 端点可能存在防火墙规则或认证配置问题,仅靠创建成功的返回值无法验证端到端的可达性。

  5. 成本控制:日志告警基于 Log Analytics 查询,每次执行都会消耗数据扫描量。高频的日志告警(如每分钟执行一次复杂查询)可能产生显著的额外费用。建议在 Azure Cost Management 中设置预算告警,监控 Monitor 服务的月度开支,并对查询频率和复杂度进行成本效益评估。

  6. 告警规则即代码:将告警规则的定义保存在 JSON 或 PowerShell 脚本文件中,通过 Git 进行版本管理。这样不仅能追踪每次阈值调整的历史,还能在灾难恢复场景中快速重建完整的监控体系。结合 Azure DevOps Pipeline 或 GitHub Actions,可以实现告警规则的自动部署和审批流程。

PowerShell 技能连载 - 安全字符串处理

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

在自动化脚本中处理密码、API 密钥、连接字符串等敏感信息时,直接将明文写入脚本或配置文件是非常危险的做法。一旦代码仓库泄露或日志被不当收集,这些凭据就会暴露无遗。PowerShell 提供了 SecureString 类型,它使用 Windows 数据保护 API(DPAPI)对内存中的字符串进行加密,使得敏感数据不会以明文形式驻留在进程内存中。

虽然 SecureString 并非银弹——在 .NET Core 跨平台场景下其保护能力有所减弱——但在 Windows 环境中,结合 DPAPI 的用户/机器级密钥,它仍然是脚本开发中最实用的凭据保护手段之一。本文将系统介绍 SecureString 的创建、转换、持久化以及凭据对象的完整使用流程,帮助你在自动化场景中更安全地管理敏感信息。

创建 SecureString

SecureString 有多种创建方式,应根据使用场景选择合适的方法。对于交互式脚本,推荐使用 Read-Host 弹出提示让用户输入;对于自动化场景,可以从已加密的文件中还原。

下面的示例展示了三种常见的创建方式:

1
2
3
4
5
6
7
8
9
10
# 方式一:交互式输入(推荐,最安全)
$securePwd = Read-Host -Prompt "请输入密码" -AsSecureString

# 方式二:从明文转换(仅用于测试或临时场景)
$plainText = "MySecretPass123!"
$securePwd = $plainText | ConvertTo-SecureString -AsPlainText -Force

# 方式三:从已加密的字符串还原(适合自动化)
$encryptedString = Get-Content -Path "C:\Vault\pwd.enc.txt" -Raw
$securePwd = $encryptedString | ConvertTo-SecureString

执行结果示例:

1
2
3
请输入密码: ************

(使用 -AsSecureString 时,输入内容以星号显示,不会回显明文)

注意方式二使用了 -AsPlainText -Force 参数,它会将明文字符串直接转换为 SecureString。由于明文在转换前会短暂出现在内存中,这种方式仅适合开发测试,不建议在生产环境中使用。

持久化加密字符串

在自动化场景中,我们需要将密码保存到文件以便脚本无人值守运行。ConvertFrom-SecureString 可以将 SecureString 导出为加密后的十六进制字符串。默认情况下使用当前用户的 DPAPI 密钥加密,这意味着只有同一个 Windows 用户账户才能解密。

1
2
3
4
5
# 将密码安全地保存到文件
$cred = Get-Credential -Message "输入需要持久化的凭据"
$cred.Password | ConvertFrom-SecureString | Set-Content -Path "C:\Vault\encrypted_pwd.txt"

Write-Host "密码已加密保存到 C:\Vault\encrypted_pwd.txt"

执行结果示例:

1
2
3
4
5
6
7
8
9
cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:
Message: 输入需要持久化的凭据

Windows PowerShell credential request.
输入需要持久化的凭据
User: admin
Password for user admin: ************
密码已加密保存到 C:\Vault\encrypted_pwd.txt

保存后的文件内容是一串十六进制字符,例如:

1
01000000d08c9ddf0115d1118c7a00c04fc297eb01000000...

这段密文绑定到当前 Windows 用户的 DPAPI 密钥,其他用户即使拿到文件也无法解密。

使用自定义密钥跨机器复用

默认的 DPAPI 加密方式仅限当前用户解密,如果需要在不同机器或不同用户之间共享加密凭据,可以使用自定义密钥(AES-256)进行加密。密钥本身需要通过安全的渠道分发(如密钥管理系统或环境变量),而不是硬编码在脚本中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 生成 32 字节(256 位)的随机密钥
$key = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($key)

# 使用自定义密钥加密并保存
$plainText = "SuperSecretAPIKey2025!"
$securePwd = $plainText | ConvertTo-SecureString -AsPlainText -Force
$encrypted = $securePwd | ConvertFrom-SecureString -Key $key
$encrypted | Set-Content -Path "C:\Vault\api_key.enc.txt"

# 将密钥安全保存(实际场景中应使用密钥管理服务)
$key | Set-Content -Path "C:\Vault\aes_key.bin" -Encoding Byte

Write-Host "已使用 AES-256 密钥加密并保存"

执行结果示例:

1
已使用 AES-256 密钥加密并保存

在目标机器上,只需要加载相同的密钥文件即可还原密码:

1
2
3
4
5
6
7
8
9
10
# 在另一台机器上还原
$key = Get-Content -Path "C:\Vault\aes_key.bin" -Encoding Byte -ReadCount 0
$encrypted = Get-Content -Path "C:\Vault\api_key.enc.txt" -Raw
$securePwd = $encrypted | ConvertTo-SecureString -Key $key

# 还原为明文(仅用于需要明文的 API 调用场景)
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePwd)
$plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

Write-Host "还原后的 API 密钥: $($plainText.Substring(0,8))..."

执行结果示例:

1
还原后的 API 密钥: SuperSec...

构建 PSCredential 对象

在实际工作中,我们很少单独使用 SecureString,更多时候需要将它封装为 PSCredential 对象。PSCredential 是 PowerShell 中标准的凭据类型,几乎所有需要身份验证的 cmdlet(如 Invoke-CommandEnter-PSSessionNew-SmbMapping)都接受此类型作为 -Credential 参数。

1
2
3
4
5
6
7
8
9
10
11
# 从持久化的加密文件构建凭据对象
$username = "DOMAIN\ServiceAccount"
$encryptedPwd = Get-Content -Path "C:\Vault\encrypted_pwd.txt" -Raw
$securePwd = $encryptedPwd | ConvertTo-SecureString

$credential = New-Object System.Management.Automation.PSCredential($username, $securePwd)

# 展示凭据信息
Write-Host "用户名: $($credential.UserName)"
Write-Host "密码长度: $($credential.GetNetworkCredential().Password.Length) 个字符"
Write-Host "是否已加密: $($credential.Password.IsReadOnly())"

执行结果示例:

1
2
3
用户名: DOMAIN\ServiceAccount
密码长度: 17 个字符
是否已加密: True

构建好 PSCredential 对象后,就可以直接传递给需要身份验证的命令:

1
2
3
4
5
6
7
8
9
10
11
# 使用凭据远程执行命令
$session = New-PSSession -ComputerName "SRV01" -Credential $credential
$result = Invoke-Command -Session $session -ScriptBlock {
Get-Process | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 5
}
Remove-PSSession -Session $session

# 输出远程进程信息
foreach ($proc in $result) {
Write-Host "$($proc.Name) - $([math]::Round($proc.WorkingSet64 / 1MB, 2)) MB"
}

执行结果示例:

1
2
3
4
5
sqlservr - 2456.32 MB
w3wp - 856.71 MB
Microsoft.Exchange - 412.48 MB
MsMpEng - 198.15 MB
System - 45.89 MB

批量管理多个凭据

在管理多台服务器或多个服务时,往往需要维护多组凭据。可以将凭据信息存储在结构化的配置文件中,通过循环批量加载使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 定义服务凭据清单
$services = @(
@{ Name = "DatabaseServer"; User = "sa"; EncryptedFile = "C:\Vault\db_pwd.txt" }
@{ Name = "FileShare"; User = "fileadmin"; EncryptedFile = "C:\Vault\fs_pwd.txt" }
@{ Name = "BackupService"; User = "backup"; EncryptedFile = "C:\Vault\bk_pwd.txt" }
)

# 批量构建凭据对象
$credentials = @{}
foreach ($svc in $services) {
$encryptedPwd = Get-Content -Path $svc.EncryptedFile -Raw
if ($encryptedPwd) {
$securePwd = $encryptedPwd | ConvertTo-SecureString
$cred = New-Object System.Management.Automation.PSCredential($svc.User, $securePwd)
$credentials[$svc.Name] = $cred
Write-Host "[OK] 已加载凭据: $($svc.Name) ($($svc.User))"
}
else {
Write-Host "[WARN] 凭据文件为空: $($svc.EncryptedFile)"
}
}

Write-Host "`n共加载 $($credentials.Count) 组凭据"

执行结果示例:

1
2
3
4
5
[OK] 已加载凭据: DatabaseServer (sa)
[OK] 已加载凭据: FileShare (fileadmin)
[OK] 已加载凭据: BackupService (backup)

共加载 3 组凭据

通过哈希表存储凭据对象,在后续脚本中可以按服务名称快速查找并使用对应的凭据,既清晰又安全。

注意事项

  • DPAPI 绑定用户和机器:默认的 ConvertFrom-SecureString 使用 DPAPI 加密,密文与当前 Windows 用户账户绑定。如果切换用户或在其他机器上运行,将无法解密。跨机器场景请使用自定义密钥模式。
  • 避免在代码中硬编码明文ConvertTo-SecureString -AsPlainText -Force 虽然方便,但明文会在脚本文件和进程内存中暴露。生产环境应使用 Get-Credential 或从加密文件加载。
  • 自定义密钥的安全分发:使用 -Key 参数时,密钥文件本身需要妥善保管。建议通过环境变量、Azure Key Vault、HashiCorp Vault 等专业密钥管理服务分发,而非将密钥文件提交到代码仓库。
  • 及时释放 BSTR 指针:使用 [Marshal]::SecureStringToBSTR() 还原明文后,应调用 [Marshal]::ZeroFreeBSTR() 释放内存,避免明文在内存中残留时间过长。
  • 跨平台限制:在 PowerShell 7 的 Linux/macOS 上,SecureString 的加密机制不同于 Windows DPAPI,保护效果有限。如果在非 Windows 平台运行,建议使用平台原生的密钥管理工具。
  • 日志脱敏:确保脚本中的 Write-HostWrite-Verbose 等输出语句不会打印 SecureString 的内容。PSCredential 对象的 .Password 属性在字符串上下文中会显示为 System.Security.SecureString,不会泄露明文,但仍需避免调用 .GetNetworkCredential().Password 后输出结果。

PowerShell 技能连载 - 性能分析器

适用于 PowerShell 5.1 及以上版本

背景引入

脚本跑得慢,是每个 PowerShell 用户迟早会遇到的问题。也许是一段处理 CSV 的管道耗时过长,也许是一个循环遍历数千台远程计算机的报表任务跑了一整天。面对性能瓶颈,靠直觉猜测往往事倍功半——真正拖慢脚本的”罪犯”可能藏在你想不到的地方。

PowerShell 本身提供了多种轻量级的计时和度量工具,从简单的 Measure-Command 到功能更丰富的 Measure-Object,再到手动插入时间戳实现分段计时。善用这些工具,可以在不引入第三方模块的前提下,快速定位脚本中最耗时的代码段。

本文将从易到难介绍三种性能分析方法:单段代码计时、多段代码对比基准测试,以及完整的脚本分段性能分析器。掌握这些技巧后,你可以像专业开发者一样系统性地优化 PowerShell 脚本的执行效率。

方法一:使用 Measure-Command 快速计时

Measure-Command 是 PowerShell 内置的计时命令行工具,适合对单段代码进行快速计时。它接受一个脚本块作为参数,执行该脚本块并返回执行耗时。与手动记录 Get-Date 相比,Measure-Command 的精度更高,使用也更简洁。

下面的例子展示了如何用 Measure-Command 分别测量数组创建和哈希表查找的耗时。我们将每次测试重复多轮并取平均值,以减少偶然波动带来的干扰。

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
# 定义测试参数
$iterations = 1000
$arrayTimes = @()
$hashtableTimes = @()

for ($i = 1; $i -le $iterations; $i++) {
# 测量数组追加操作
$arrayTime = Measure-Command {
$arr = @()
foreach ($j in 1..500) {
$arr += $j
}
}
$arrayTimes += $arrayTime.TotalMilliseconds

# 测量哈希表查找操作
$ht = @{}
foreach ($j in 1..500) {
$ht[$j] = "value_$j"
}
$htTime = Measure-Command {
$null = $ht[250]
}
$hashtableTimes += $htTime.TotalMilliseconds
}

# 计算平均耗时
$avgArray = ($arrayTimes | Measure-Object -Average).Average
$avgHash = ($hashtableTimes | Measure-Object -Average).Average

Write-Host ("数组追加 平均耗时: {0:N2} ms" -f $avgArray)
Write-Host ("哈希表查找 平均耗时: {0:N4} ms" -f $avgHash)

执行结果示例:

1
2
数组追加 平均耗时: 8.73 ms
哈希表查找 平均耗时: 0.0012 ms

从结果可以看出,数组使用 += 运算符追加元素时,每次操作都会重建整个数组,导致性能明显低于哈希表的键值查找。这个发现为我们选择数据结构提供了数据支撑。

方法二:构建可复用的基准测试框架

当需要对比同一任务的多种实现方式时,逐一编写 Measure-Command 会显得繁琐。我们可以封装一个轻量的基准测试函数,一次传入多个候选实现,自动完成多轮迭代、结果汇总和排名。

下面这个 Invoke-Benchmark 函数接受一个哈希表,键为实现的名称,值为对应的脚本块。函数会对每个实现执行指定次数的迭代,并按平均耗时从快到慢排序输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function Invoke-Benchmark {
param(
[hashtable]$Implementations,
[int]$Iterations = 100
)

$results = @()

foreach ($name in $Implementations.Keys) {
$scriptBlock = $Implementations[$name]
$timings = @()

for ($i = 1; $i -le $Iterations; $i++) {
$elapsed = Measure-Command -Expression $scriptBlock
$timings += $elapsed.TotalMilliseconds
}

$stats = $timings | Measure-Object -Average -Minimum -Maximum

$results += [PSCustomObject]@{
Name = $name
AvgMs = [math]::Round($stats.Average, 4)
MinMs = [math]::Round($stats.Minimum, 4)
MaxMs = [math]::Round($stats.Maximum, 4)
Runs = $Iterations
}
}

# 按平均耗时排序并输出
$results | Sort-Object AvgMs | Format-Table -AutoSize
}

# 定义三种字符串拼接方式的候选实现
$impls = @{
'加号拼接' = {
$s = ''
foreach ($i in 1..200) {
$s = $s + "item_$i"
}
}
'格式化拼接' = {
$s = ''
foreach ($i in 1..200) {
$s = '{0}{1}' -f $s, "item_$i"
}
}
'StringBuilder' = {
$sb = [System.Text.StringBuilder]::new()
foreach ($i in 1..200) {
$null = $sb.Append("item_$i")
}
$s = $sb.ToString()
}
}

Invoke-Benchmark -Implementations $impls -Iterations 50

执行结果示例:

1
2
3
4
5
Name          AvgMs    MinMs    MaxMs Runs
---- ----- ----- ----- ----
StringBuilder 0.0812 0.0450 0.2100 50
加号拼接 0.3256 0.2801 0.5130 50
格式化拼接 0.4103 0.3502 0.8901 50

结果清晰地表明,StringBuilder 在循环拼接场景下具有明显优势。加号拼接每次都创建新字符串,格式化运算符也有类似开销,而 StringBuilder 在内部维护可变缓冲区,避免了重复分配内存。

方法三:脚本分段性能分析器

在实际排障场景中,我们需要知道一个较长脚本中各段代码分别占用了多少时间。下面实现一个轻量的分析器,通过在脚本关键位置插入”标签”来记录各段的起止时间,最终输出每段的耗时统计和占比。

Start-ProfileBlock 函数在调用时记录一个标签和时间戳,Stop-ProfileSession 则汇总所有标签并计算各段耗时。这个方案无需修改脚本的核心逻辑,只在关键位置添加一行调用即可。

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
# 性能分析器的全局存储
$script:ProfileMarks = [System.Collections.Generic.List[PSObject]]::new()

function Start-ProfileBlock {
param([string]$Label)
$script:ProfileMarks.Add(
[PSCustomObject]@{
Label = $Label
Time = Get-Date
}
)
}

function Stop-ProfileSession {
$totalMarks = $script:ProfileMarks.Count
if ($totalMarks -lt 2) {
Write-Warning "至少需要两个标记点才能计算耗时。"
return
}

$segments = @()
for ($i = 1; $i -lt $totalMarks; $i++) {
$prev = $script:ProfileMarks[$i - 1]
$curr = $script:ProfileMarks[$i]
$duration = ($curr.Time - $prev.Time).TotalMilliseconds
$segments += [PSCustomObject]@{
Segment = "$($prev.Label) -> $($curr.Label)"
Label = $prev.Label
Duration = [math]::Round($duration, 2)
}
}

$totalDuration = ($segments | Measure-Object -Property Duration -Sum).Sum

foreach ($seg in $segments) {
$seg | Add-Member -NotePropertyName 'Percent' `
-NotePropertyValue ([math]::Round($seg.Duration / $totalDuration * 100, 1)) `
-PassThru
}

$segments | Sort-Object Duration -Descending | Format-Table -AutoSize
Write-Host ("总耗时: {0:N2} ms" -f $totalDuration)

# 清理标记以便复用
$script:ProfileMarks.Clear()
}

# ---- 模拟被分析的脚本 ----

Start-ProfileBlock -Label "开始"

# 第一段:读取 CSV 数据
$data = @(1..5000 | ForEach-Object {
[PSCustomObject]@{
Id = $_
Name = "User_$_"
Score = Get-Random -Minimum 0 -Maximum 100
Active = [bool](Get-Random -Minimum 0 -Maximum 2)
}
})
Start-ProfileBlock -Label "数据生成"

# 第二段:过滤和排序
$filtered = @()
foreach ($item in $data) {
if ($item.Active -and $item.Score -ge 50) {
$filtered += $item
}
}
$sorted = $filtered | Sort-Object Score -Descending
Start-ProfileBlock -Label "过滤排序"

# 第三段:输出报表
$report = $sorted | Select-Object -First 20 | Format-Table -AutoSize | Out-String
Write-Host $report
Start-ProfileBlock -Label "报表输出"

# 输出性能分析报告
Stop-ProfileSession

执行结果示例:

1
2
3
4
5
6
7
Segment                      Label      Duration Percent
------- ----- -------- -------
过滤排序 -> 报表输出 过滤排序 182.45 67.3
数据生成 -> 过滤排序 数据生成 72.10 26.6
开始 -> 数据生成 开始 16.30 6.0

总耗时: 270.85 ms

分析结果一目了然:过滤排序阶段占用了将近七成的时间。罪魁祸首是数组使用 += 在循环中不断重建。如果改用 [System.Collections.Generic.List[PSObject]]Add 方法,该阶段的耗时会大幅缩短。这就是分段分析器的价值——它帮你把有限的优化精力集中到真正需要改进的地方。

注意事项

  1. Measure-Command 会吞掉输出Measure-Command 执行脚本块时会丢弃所有输出流,包括 Write-Host。如果你需要在计时的同时观察输出,请使用手动记录 Get-Date 的方式,或者在脚本块内将结果写入文件。

  2. 首次运行存在冷启动偏差:PowerShell 的命令解析、程序集加载和 JIT 编译在首次执行时会产生额外开销。做基准测试时建议先跑一轮”预热”,再正式计时。这也是 Invoke-Benchmark 函数默认执行多次迭代的原因之一。

  3. 避免在循环中使用数组 += 操作:如上文示例所示,$array += $item 每次都会创建新数组并复制所有元素。对于大规模数据,应改用 List<T>Add 方法,或预分配好数组大小后按索引赋值。

  4. 注意管道开销:PowerShell 管道每传递一个对象都会经过一系列的处理步骤(如 BeginProcessProcessRecord),对于简单的循环操作,使用 foreach 语句(而非 ForEach-Object)可以避免管道开销,显著提升性能。

  5. 分析器的时钟精度Get-Date 在 Windows 上的理论精度约为 15.6 毫秒(系统时钟分辨率),对于耗时极短的操作,建议用 Measure-Command[System.Diagnostics.Stopwatch] 来获得更高精度。Stopwatch 使用高分辨率硬件计时器,精度可达纳秒级。

  6. 生产环境中的性能分析:在实际生产脚本中嵌入性能分析代码时,建议通过开关参数(如 $DebugProfile)控制是否启用,避免在正常运行时产生不必要的性能损耗和日志文件。同时,分析结果可以导出为 CSV 或 JSON,便于后续对比和趋势分析。

PowerShell 技能连载 - Azure Functions 无服务器

适用于 PowerShell 7.0 及以上版本

无服务器架构(Serverless)正在改变企业自动化的交付方式。Azure Functions 作为微软云平台的核心计算服务,支持 PowerShell 作为一等公民运行时——这意味着你可以直接用 PowerShell 脚本响应 HTTP 请求、处理队列消息、定时执行任务,而无需管理任何虚拟机或容器。Azure 负责底层基础设施的扩缩容、补丁更新和高可用,你只需关注业务逻辑本身。

对于 PowerShell 运维人员来说,Azure Functions 打开了全新的可能性。传统的计划任务、Windows 服务、甚至本地脚本,都可以迁移到云端,获得天然的高可用性和按需付费的优势。2025 年,Azure Functions 已全面支持 PowerShell 7.4 运行时,并且与 Azure Managed Identity、Key Vault 等安全组件深度集成,让云端自动化脚本既安全又易维护。本文将从实战角度出发,带你完成 Azure Functions 的项目创建、本地调试和云端部署全流程。

场景一:使用 PowerShell 创建 Azure Functions 项目

Azure Functions Core Tools 提供了完整的命令行开发体验。下面的脚本会自动初始化一个 PowerShell Azure Functions 项目,并创建两个常用触发器函数——HTTP 触发器和定时触发器,让你快速搭建起自动化骨架。

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

[string]$Location = 'eastasia',
[string]$Runtime = 'powershell',
[string]$RootPath = $PWD.Path
)

$projectDir = Join-Path $RootPath $ProjectName

if (Test-Path $projectDir) {
Write-Output "项目目录已存在: $projectDir"
return
}

Write-Output "=== 创建 Azure Functions 项目 ==="
Write-Output "项目名称: $ProjectName"
Write-Output "运行时: $Runtime"
Write-Output "区域: $Location"
Write-Output ""

# 初始化 Functions 项目
$null = New-Item -Path $projectDir -ItemType Directory -Force
Push-Location $projectDir

try {
# 初始化项目(相当于 func init)
$hostJson = @{
version = '2.0'
logging = @{
applicationInsights = @{
samplingSettings = @{
isEnabled = $false
}
}
}
extensionBundle = @{
id = 'Microsoft.Azure.Functions.ExtensionBundle'
version = '[4.*, 5.0.0)'
}
}
$hostJson | ConvertTo-Json -Depth 5 |
Out-File -FilePath 'host.json' -Encoding UTF8

# 创建 local.settings.json
$localSettings = @{
IsEncrypted = $false
Values = @{
AzureWebJobsStorage = 'UseDevelopmentStorage=true'
FUNCTIONS_WORKER_RUNTIME = 'powershell'
FUNCTIONS_WORKER_RUNTIME_VERSION = '~7'
}
}
$localSettings | ConvertTo-Json -Depth 5 |
Out-File -FilePath 'local.settings.json' -Encoding UTF8

# 创建 profile.ps1(模块加载和托管身份初始化)
$profileContent = @'
# Azure Functions Profile
# 此文件在每次函数调用前自动执行

if ($env:MSI_SECRET) {
Disable-AzContextAutosave -Scope Process | Out-Null
Connect-AzAccount -Identity
}
'@
$profileContent | Out-File -FilePath 'profile.ps1' -Encoding UTF8

# 创建 requirements.psd1(PowerShell 模块依赖)
$requirementsContent = @'
# 此文件声明函数所需的 PowerShell 模块
# Azure Functions 会在部署时自动安装这些模块
@{
'Az' = '13.*'
'Az.TableModule' = '0.*'
}
'@
$requirementsContent | Out-File -FilePath 'requirements.psd1' -Encoding UTF8

# 创建 HTTP 触发器函数
$httpDir = Join-Path $projectDir 'Get-SystemStatus'
$null = New-Item -Path $httpDir -ItemType Directory -Force

$functionJson = @{
bindings = @(
@{
authLevel = 'function'
type = 'httpTrigger'
direction = 'in'
name = 'Request'
methods = @('get')
}
@{
type = 'http'
direction = 'out'
name = 'Response'
}
)
}
$functionJson | ConvertTo-Json -Depth 5 |
Out-File -FilePath (Join-Path $httpDir 'function.json') -Encoding UTF8

$runPs1 = @'
using namespace System.Net

param($Request, $TriggerMetadata)

$status = [PSCustomObject]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Machine = $env:COMPUTERNAME
Runtime = $PSVersionTable.PSVersion.ToString()
Platform = $PSVersionTable.Platform
Status = 'Healthy'
}

Push-OutputBinding -Name Response -Value (
[HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = ($status | ConvertTo-Json)
ContentType = 'application/json'
}
)
'@
$runPs1 | Out-File -FilePath (Join-Path $httpDir 'run.ps1') -Encoding UTF8

# 创建定时触发器函数(每天早上 8 点执行)
$timerDir = Join-Path $projectDir 'DailyHealthCheck'
$null = New-Item -Path $timerDir -ItemType Directory -Force

$timerJson = @{
bindings = @(
@{
type = 'timerTrigger'
direction = 'in'
name = 'TimerInfo'
schedule = '0 0 8 * * *'
}
)
}
$timerJson | ConvertTo-Json -Depth 5 |
Out-File -FilePath (Join-Path $timerDir 'function.json') -Encoding UTF8

$timerRun = @'
param($TimerInfo)

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Output "[$timestamp] 每日健康检查开始执行"

# 模拟检查逻辑
$checks = @(
@{ Name = '存储账户连接'; Status = 'OK'; Detail = '响应时间 45ms' }
@{ Name = '数据库可用性'; Status = 'OK'; Detail = '延迟 12ms' }
@{ Name = 'API 网关状态'; Status = 'OK'; Detail = '成功率 99.97%' }
)

foreach ($check in $checks) {
Write-Output " [$($check.Status)] $($check.Name) - $($check.Detail)"
}

Write-Output "[$timestamp] 每日健康检查完成"
'@
$timerRun | Out-File -FilePath (Join-Path $timerDir 'run.ps1') -Encoding UTF8

# 列出项目结构
Write-Output ""
Write-Output "=== 项目结构 ==="
$files = Get-ChildItem -Path $projectDir -Recurse -File
foreach ($file in $files) {
$relativePath = $file.FullName.Replace("$projectDir\", '')
Write-Output " $relativePath"
}
Write-Output ""
Write-Output "项目创建完成: $projectDir"
}
finally {
Pop-Location
}
}

New-AzureFunctionsProject -ProjectName 'MyServerlessAutomation'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=== 创建 Azure Functions 项目 ===
项目名称: MyServerlessAutomation
运行时: powershell
区域: eastasia

=== 项目结构 ===
host.json
local.settings.json
profile.ps1
requirements.psd1
Get-SystemStatus\function.json
Get-SystemStatus\run.ps1
DailyHealthCheck\function.json
DailyHealthCheck\run.ps1

项目创建完成: /Users/dev/MyServerlessAutomation

脚本创建了一个完整的 Azure Functions 项目结构,包含两个函数:Get-SystemStatus 是一个 HTTP 触发器,返回当前运行环境的状态信息;DailyHealthCheck 是一个定时触发器,每天早上 8 点自动执行健康检查。requirements.psd1 声明了模块依赖,部署时 Azure 会自动安装。profile.ps1 处理了托管身份认证,让函数在云端运行时自动获取 Azure 资源的访问令牌。

场景二:本地调试与测试

在推送到 Azure 之前,建议先在本地完成调试。Azure Functions Core Tools 提供了本地运行时环境,可以模拟 HTTP 触发和定时触发。下面的脚本演示如何在本地启动函数运行时,并对 HTTP 函数进行集成测试。

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
function Test-AzureFunctionsLocally {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[int]$Port = 7071
)

Write-Output "=== 本地测试 Azure Functions ==="
Write-Output "项目路径: $ProjectPath"
Write-Output "监听端口: $Port"
Write-Output ""

# 检查 func CLI 是否安装
$funcCli = Get-Command 'func' -ErrorAction SilentlyContinue
if (-not $funcCli) {
Write-Output "[错误] 未找到 Azure Functions Core Tools"
Write-Output "安装方式: npm install -g azure-functions-core-tools@4"
return
}

Write-Output "Core Tools 版本: $($funcCli.Version)"
Write-Output ""

# 模拟发送 HTTP 请求到本地函数
$baseUrl = "http://localhost:$Port"
$testResults = [System.Text.StringBuilder]::new()

$null = $testResults.AppendLine("=== 集成测试结果 ===")
$null = $testResults.AppendLine("测试时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $testResults.AppendLine()

# 测试一:调用 Get-SystemStatus 函数
$null = $testResults.AppendLine("[测试 1] HTTP 触发器 - Get-SystemStatus")
try {
$response = Invoke-RestMethod -Uri "$baseUrl/api/Get-SystemStatus" -Method Get `
-ErrorAction Stop
$null = $testResults.AppendLine(" 状态码: 200 OK")
$null = $testResults.AppendLine(" 响应体:")
$formattedJson = $response | ConvertTo-Json -Depth 3
foreach ($line in $formattedJson.Split("`n")) {
$null = $testResults.AppendLine(" $($line.Trim())")
}
$null = $testResults.AppendLine(" 结果: PASS")
}
catch {
$null = $testResults.AppendLine(" 结果: FAIL - $($_.Exception.Message)")
}
$null = $testResults.AppendLine()

# 测试二:验证函数配置文件完整性
$null = $testResults.AppendLine("[测试 2] 项目配置验证")
$configChecks = @(
@{ File = 'host.json'; Desc = '主机配置' },
@{ File = 'local.settings.json'; Desc = '本地设置' },
@{ File = 'profile.ps1'; Desc = 'PowerShell 配置文件' },
@{ File = 'requirements.psd1'; Desc = '模块依赖' }
)

foreach ($check in $configChecks) {
$filePath = Join-Path $ProjectPath $check.File
if (Test-Path $filePath) {
$null = $testResults.AppendLine(" [OK] $($check.Desc) ($($check.File))")
}
else {
$null = $testResults.AppendLine(" [MISSING] $($check.Desc) ($($check.File))")
}
}
$null = $testResults.AppendLine()

# 测试三:验证函数目录结构
$null = $testResults.AppendLine("[测试 3] 函数目录验证")
$functionDirs = Get-ChildItem -Path $ProjectPath -Directory |
Where-Object {
Test-Path (Join-Path $_.FullName 'function.json')
}

foreach ($dir in $functionDirs) {
$hasRun = Test-Path (Join-Path $dir.FullName 'run.ps1')
$hasConfig = Test-Path (Join-Path $dir.FullName 'function.json')
$status = if ($hasRun -and $hasConfig) { 'OK' } else { 'INCOMPLETE' }
$null = $testResults.AppendLine(
" [$status] $($dir.Name) - run.ps1: $hasRun, function.json: $hasConfig"
)
}
$null = $testResults.AppendLine()

# 测试四:检查模块依赖是否可解析
$null = $testResults.AppendLine("[测试 4] 模块依赖检查")
$reqPath = Join-Path $ProjectPath 'requirements.psd1'
if (Test-Path $reqPath) {
$requirements = Import-PowerShellDataFile -Path $reqPath -ErrorAction Stop
foreach ($module in $requirements.GetEnumerator()) {
$installed = Get-Module -Name $module.Key -ListAvailable -ErrorAction SilentlyContinue
if ($installed) {
$null = $testResults.AppendLine(
" [OK] $($module.Key) - 本地版本: $($installed[0].Version)"
)
}
else {
$null = $testResults.AppendLine(
" [PENDING] $($module.Key) $($module.Value) - 部署时自动安装"
)
}
}
}
$null = $testResults.AppendLine()
$null = $testResults.AppendLine("=== 测试完成 ===")

Write-Output $testResults.ToString()
}

Test-AzureFunctionsLocally -ProjectPath '/Users/dev/MyServerlessAutomation'

执行结果示例:

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
=== 本地测试 Azure Functions ===
项目路径: /Users/dev/MyServerlessAutomation
监听端口: 7071

Core Tools 版本: 4.0.6610

=== 集成测试结果 ===
测试时间: 2025-11-03 10:15:30

[测试 1] HTTP 触发器 - Get-SystemStatus
状态码: 200 OK
响应体:
{
"Timestamp": "2025-11-03 10:15:31",
"Machine": "localhost",
"Runtime": "7.4.6",
"Platform": "Unix",
"Status": "Healthy"
}
结果: PASS

[测试 2] 项目配置验证
[OK] 主机配置 (host.json)
[OK] 本地设置 (local.settings.json)
[OK] PowerShell 配置文件 (profile.ps1)
[OK] 模块依赖 (requirements.psd1)

[测试 3] 函数目录验证
[OK] Get-SystemStatus - run.ps1: True, function.json: True
[OK] DailyHealthCheck - run.ps1: True, function.json: True

[测试 4] 模块依赖检查
[OK] Az - 本地版本: 13.2.0
[PENDING] Az.TableModule 0.* - 部署时自动安装

=== 测试完成 ===

本地测试通过后,可以确信函数逻辑和配置文件都正确无误。Az 模块已在本地安装,而 Az.TableModule 将在部署时由 Azure Functions 自动安装——这就是 requirements.psd1 的作用。测试脚本还验证了每个函数目录下都有完整的 run.ps1function.json 文件对,这是 Azure Functions 加载函数的必要条件。

场景三:自动化部署到 Azure 云端

项目经过本地测试后,下一步是部署到 Azure。下面的脚本将创建所需的云资源(资源组、存储账户、Function App),并完成代码部署。整个过程完全自动化,无需手动操作 Azure Portal。

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
function Publish-AzureFunctionsProject {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

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

[string]$ResourceGroupName = "rg-$AppName",
[string]$Location = 'eastasia',
[string]$Sku = 'Y1'
)

Write-Output "=== 部署 Azure Functions 到云端 ==="
Write-Output "应用名称: $AppName"
Write-Output "资源组: $ResourceGroupName"
Write-Output "区域: $Location"
Write-Output ""

$deployLog = [System.Text.StringBuilder]::new()
$null = $deployLog.AppendLine("部署开始: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")

# 步骤一:创建资源组
$null = $deployLog.AppendLine("[1/5] 创建资源组...")
$rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if (-not $rg) {
$rg = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
$null = $deployLog.AppendLine(" 已创建资源组: $ResourceGroupName ($Location)")
}
else {
$null = $deployLog.AppendLine(" 资源组已存在: $ResourceGroupName")
}

# 步骤二:创建存储账户(Functions 运行时必需)
$null = $deployLog.AppendLine("[2/5] 创建存储账户...")
$storageName = "st$($AppName -replace '-','').Substring(0, [Math]::Min(19, ($AppName -replace '-','').Length))"
$storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName `
-Name $storageName -ErrorAction SilentlyContinue

if (-not $storageAccount) {
$storageAccount = New-AzStorageAccount -ResourceGroupName $ResourceGroupName `
-Name $storageName -Location $Location `
-SkuName 'Standard_LRS' -Kind 'StorageV2'
$null = $deployLog.AppendLine(" 已创建存储账户: $storageName")
}
else {
$null = $deployLog.AppendLine(" 存储账户已存在: $storageName")
}

# 步骤三:创建 Function App(消费计划)
$null = $deployLog.AppendLine("[3/5] 创建 Function App...")
$funcApp = Get-AzFunctionApp -ResourceGroupName $ResourceGroupName `
-Name $AppName -ErrorAction SilentlyContinue

if (-not $funcApp) {
$planName = "plan-$AppName"
$null = New-AzFunctionApp -ResourceGroupName $ResourceGroupName `
-Name $AppName `
-StorageAccountName $storageName `
-Runtime 'PowerShell' `
-RuntimeVersion '7.4' `
-PlanName $planName `
-OSType 'Windows' `
-Location $Location `
-FunctionsVersion '4'

$null = $deployLog.AppendLine(" 已创建 Function App: $AppName")
$null = $deployLog.AppendLine(" 运行时: PowerShell 7.4")
$null = $deployLog.AppendLine(" 计划类型: 消费 (Serverless)")
}
else {
$null = $deployLog.AppendLine(" Function App 已存在: $AppName")
}

# 步骤四:配置应用设置
$null = $deployLog.AppendLine("[4/5] 配置应用设置...")
$appSettings = @{
FUNCTIONS_WORKER_RUNTIME_VERSION = '~7'
PowerShellModuleVersion = '7.4'
}

# 启用 Managed Identity
$identity = Get-AzWebApp -ResourceGroupName $ResourceGroupName `
-Name $AppName -ErrorAction SilentlyContinue
if ($identity -and -not $identity.Identity.Type) {
$null = Set-AzWebApp -ResourceGroupName $ResourceGroupName `
-Name $AppName -AssignIdentity $true
$null = $deployLog.AppendLine(" 已启用系统分配的托管身份")
}
else {
$null = $deployLog.AppendLine(" 托管身份已配置")
}

# 步骤五:部署函数代码
$null = $deployLog.AppendLine("[5/5] 部署函数代码...")
$publishResult = Publish-AzWebApp -ResourceGroupName $ResourceGroupName `
-Name $AppName -ArchivePath $ProjectPath -Force

if ($publishResult) {
$null = $deployLog.AppendLine(" 代码部署成功")
}

$null = $deployLog.AppendLine()
$null = $deployLog.AppendLine("=== 部署摘要 ===")
$null = $deployLog.AppendLine("Function App: $AppName")
$null = $deployLog.AppendLine("资源组: $ResourceGroupName")
$null = $deployLog.AppendLine("存储账户: $storageName")
$null = $deployLog.AppendLine("运行时: PowerShell 7.4")
$null = $deployLog.AppendLine("访问地址: https://$AppName.azurewebsites.net")
$null = $deployLog.AppendLine()
$null = $deployLog.AppendLine("部署完成: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")

Write-Output $deployLog.ToString()
}

Publish-AzureFunctionsProject `
-ProjectPath '/Users/dev/MyServerlessAutomation' `
-AppName 'my-serverless-auto'

执行结果示例:

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
=== 部署 Azure Functions 到云端 ===
应用名称: my-serverless-auto
资源组: rg-my-serverless-auto
区域: eastasia

部署开始: 2025-11-03 11:02:45

[1/5] 创建资源组...
已创建资源组: rg-my-serverless-auto (eastasia)
[2/5] 创建存储账户...
已创建存储账户: stmyserverlessauto
[3/5] 创建 Function App...
已创建 Function App: my-serverless-auto
运行时: PowerShell 7.4
计划类型: 消费 (Serverless)
[4/5] 配置应用设置...
已启用系统分配的托管身份
[5/5] 部署函数代码...
代码部署成功

=== 部署摘要 ===
Function App: my-serverless-auto
资源组: rg-my-serverless-auto
存储账户: stmyserverlessauto
运行时: PowerShell 7.4
访问地址: https://my-serverless-auto.azurewebsites.net

部署完成: 2025-11-03 11:05:18

整个部署过程耗时约 3 分钟,从零开始创建了资源组、存储账户和 Function App。使用消费计划(Consumption Plan)意味着只有在函数实际执行时才计费,空闲时不产生任何费用。系统分配的托管身份让函数可以安全访问其他 Azure 资源(如存储、数据库、Key Vault),无需在代码中硬编码密码或密钥。

注意事项

  1. PowerShell 运行时版本:Azure Functions 对 PowerShell 7.x 的支持有明确的版本窗口。建议在创建 Function App 时指定 -RuntimeVersion '7.4',并在 local.settings.jsonrequirements.psd1 中保持版本一致。避免使用过旧的 5.1 运行时(仅限 Windows),新项目应统一使用 PowerShell 7。

  2. 冷启动延迟:消费计划的 Function App 在首次请求或长时间空闲后会经历冷启动,PowerShell 函数的冷启动时间通常在 5-15 秒。如果对响应延迟敏感,可以考虑使用高级计划(Premium Plan)并设置最小实例数(minimum instance count)为 1,保持至少一个实例始终预热。

  3. 模块管理策略requirements.psd1 中声明的模块会在部署时自动安装,但大型模块(如 Az)会增加部署时间和冷启动开销。建议只声明实际使用的子模块(如 Az.StorageAz.KeyVault),而不是整个 Az 模块。单个函数的模块加载应控制在 30 秒以内。

  4. 触发器选择:不同触发器类型适用于不同场景——HTTP 触发器适合 API 和 Webhook,定时触发器适合周期性任务,队列触发器适合异步处理,Blob 触发器适合文件处理。避免用定时触发器轮询外部服务,应该使用事件驱动的触发器(如 Event Grid)来降低延迟和成本。

  5. 敏感信息处理:连接字符串、API 密钥等敏感信息绝不能写在代码或 local.settings.json 中。生产环境应使用 Azure Key Vault 结合 Managed Identity 来安全读取密钥。local.settings.json 应添加到 .gitignore,防止意外提交到代码仓库。

  6. 日志与监控:Azure Functions 默认集成 Application Insights,会自动收集执行日志、异常信息和性能指标。建议在 host.json 中合理配置日志级别(Information 用于常规日志,Warning 以上用于生产告警),并在关键业务函数中加入结构化日志输出,方便后续在 Application Insights 中查询和分析。

PowerShell 技能连载 - 安全事件响应自动化

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

安全事件响应(Incident Response)是企业安全运维中最关键的环节之一。当安全警报触发时,响应速度直接影响损失范围——根据 IBM 的《数据泄露成本报告》,平均响应时间每缩短一天,泄露成本可降低约 100 万美元。传统的手动排查方式耗时耗力,而且容易遗漏关键线索,特别是在攻击者已经横向移动的情况下。

PowerShell 作为 Windows 环境的原生脚本语言,天生具备深度系统访问能力,可以从事件日志、注册表、网络连接、进程树等多个维度快速采集取证数据。结合 2025 年企业安全场景中普遍面临的勒索软件、供应链攻击、凭证窃取等威胁,用 PowerShell 构建自动化事件响应流程不仅能提升效率,还能确保采集过程的标准化和可重复性。

今天是万圣节,让我们用 PowerShell 来对付那些比鬼魂更可怕的——系统里的安全威胁。本文将从实战角度出发,带你构建一套涵盖威胁检测、取证采集和自动处置的安全事件响应工具集。

场景一:实时威胁检测与告警

当安全事件发生时,第一步是快速确认威胁是否存在以及影响范围。下面的脚本会检查系统中常见的入侵指标(IOC),包括可疑进程、异常网络连接和近期创建的计划任务。

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
function Test-SecurityThreatIndicator {
$report = [System.Text.StringBuilder]::new()
$null = $report.AppendLine("=== 安全威胁检测报告 ===")
$null = $report.AppendLine("扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $report.AppendLine()

# 检查可疑进程(已知攻击工具特征)
$suspiciousNames = @(
'mimikatz', 'procdump', 'lazagne', 'nc.exe', 'ncat',
'psexec', 'bloodhound', 'sharphound', 'rubeus', 'kekeo'
)

$null = $report.AppendLine("[可疑进程检查]")
$foundSuspicious = $false
$processes = Get-Process -ErrorAction SilentlyContinue
foreach ($proc in $processes) {
foreach ($name in $suspiciousNames) {
if ($proc.ProcessName -like "*$name*") {
$null = $report.AppendLine(
" [!] 发现可疑进程: $($proc.ProcessName) " +
"(PID: $($proc.Id), 路径: $($proc.Path))"
)
$foundSuspicious = $true
}
}
}
if (-not $foundSuspicious) {
$null = $report.AppendLine(" [OK] 未发现已知可疑进程")
}

# 检查异常出站网络连接
$null = $report.AppendLine()
$null = $report.AppendLine("[异常网络连接检查]")
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
Where-Object { $_.RemoteAddress -notmatch '^(127\.|0\.|::1|::$)' }

$suspiciousPorts = @(4444, 5555, 6666, 7777, 8888, 9999, 1234, 31337)
foreach ($conn in $connections) {
if ($conn.RemotePort -in $suspiciousPorts) {
$procInfo = try {
(Get-Process -Id $conn.OwningProcess -ErrorAction Stop).ProcessName
} catch {
"未知"
}
$null = $report.AppendLine(
" [!] 可疑连接: $($conn.LocalAddress):$($conn.LocalPort) -> " +
"$($conn.RemoteAddress):$($conn.RemotePort) (进程: $procInfo)"
)
}
}

# 检查近期创建的计划任务(24小时内)
$null = $report.AppendLine()
$null = $report.AppendLine("[近期计划任务检查 - 24小时内创建]")
$cutoff = (Get-Date).AddHours(-24)
$tasks = Get-ScheduledTask -ErrorAction SilentlyContinue |
Where-Object { $_.Date -and $_.Date -gt $cutoff }
foreach ($task in $tasks) {
$null = $report.AppendLine(
" [!] 新任务: $($task.TaskName) " +
"(状态: $($task.State), 创建时间: $($task.Date))"
)
}
if (-not $tasks) {
$null = $report.AppendLine(" [OK] 24小时内无新增计划任务")
}

$null = $report.AppendLine()
$null = $report.AppendLine("=== 扫描完成 ===")
return $report.ToString()
}

Test-SecurityThreatIndicator

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=== 安全威胁检测报告 ===
扫描时间: 2025-10-31 14:23:05

[可疑进程检查]
[OK] 未发现已知可疑进程

[异常网络连接检查]
[!] 可疑连接: 192.168.1.105:49832 -> 10.0.0.88:4444 (进程: svchost)

[近期计划任务检查 - 24小时内创建]
[!] 新任务: WindowsUpdateSync (状态: Ready, 创建时间: 2025/10/31 08:15:00)
[!] 新任务: SystemHealthMonitor (状态: Ready, 创建时间: 2025/10/31 10:30:22)

=== 扫描完成 ===

在这个输出中可以看到一个连接到 4444 端口的可疑网络连接(4444 是 Metasploit 默认反向 shell 端口),以及两个近期新创建的计划任务——这些都是需要进一步调查的入侵指标。

场景二:自动取证数据采集

确认威胁后,下一步是采集取证数据用于分析。下面的脚本会自动收集 Windows 安全事件日志、用户登录记录和文件系统变更信息,并将结果打包保存。

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

$null = New-Item -Path $OutputPath -ItemType Directory -Force

$collectionLog = [System.Text.StringBuilder]::new()
$null = $collectionLog.AppendLine("取证采集开始: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $collectionLog.AppendLine("输出目录: $OutputPath")
$null = $collectionLog.AppendLine()

# 采集一:导出安全事件日志(最近 7 天)
$null = $collectionLog.AppendLine("[1/4] 采集安全事件日志...")
$securityLogPath = Join-Path $OutputPath "SecurityEvents.csv"
$startTime = (Get-Date).AddDays(-7)
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
StartTime = $startTime
} -MaxEvents 5000 -ErrorAction SilentlyContinue

$events | Select-Object TimeCreated, Id,
@{N='Level';E={$_.LevelDisplayName}},
@{N='User';E={$_.UserId}},
Message |
Export-Csv -Path $securityLogPath -NoTypeInformation -Encoding UTF8

$null = $collectionLog.AppendLine(
" 已导出 $($events.Count) 条安全事件到 SecurityEvents.csv"
)

# 采集二:收集成功登录记录(Event ID 4624)
$null = $collectionLog.AppendLine("[2/4] 采集登录记录...")
$logonPath = Join-Path $OutputPath "LogonRecords.csv"
$logonEvents = $events |
Where-Object { $_.Id -eq 4624 }

$logonRecords = foreach ($evt in $logonEvents) {
$xml = [xml]$evt.ToXml()
$data = @{}
foreach ($item in $xml.Event.EventData.Data) {
$data[$item.Name] = $item.'#text'
}
[PSCustomObject]@{
TimeCreated = $evt.TimeCreated
TargetUser = $data['TargetUserName']
LogonType = $data['LogonType']
SourceAddress = $data['IpAddress']
SourcePort = $data['IpPort']
AuthPackage = $data['AuthenticationPackageName']
}
}
$logonRecords | Export-Csv -Path $logonPath -NoTypeInformation -Encoding UTF8
$null = $collectionLog.AppendLine(
" 已导出 $($logonRecords.Count) 条登录记录到 LogonRecords.csv"
)

# 采集三:列出近期修改的高风险文件位置
$null = $collectionLog.AppendLine("[3/4] 采集文件系统变更...")
$fileChangesPath = Join-Path $OutputPath "FileChanges.csv"
$criticalPaths = @(
"$env:WINDIR\System32\drivers\etc",
"$env:WINDIR\System32\WindowsPowerShell",
"$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
"$env:WINDIR\Temp",
"$env:TEMP"
)

$fileChanges = foreach ($path in $criticalPaths) {
if (Test-Path $path) {
Get-ChildItem -Path $path -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt $startTime } |
Select-Object FullName, Length, LastWriteTime, CreationTime
}
}
$fileChanges | Export-Csv -Path $fileChangesPath -NoTypeInformation -Encoding UTF8
$null = $collectionLog.AppendLine(
" 已导出 $($fileChanges.Count) 个近期变更文件到 FileChanges.csv"
)

# 采集四:导出当前运行服务状态
$null = $collectionLog.AppendLine("[4/4] 采集服务状态...")
$servicePath = Join-Path $OutputPath "Services.csv"
$services = Get-CimInstance -ClassName Win32_Service |
Select-Object Name, DisplayName, State, StartMode,
@{N='Path';E={$_.PathName}},
@{N='Account';E={$_.StartName}}
$services | Export-Csv -Path $servicePath -NoTypeInformation -Encoding UTF8
$null = $collectionLog.AppendLine(
" 已导出 $($services.Count) 个服务信息到 Services.csv"
)

# 生成采集报告摘要
$null = $collectionLog.AppendLine()
$null = $collectionLog.AppendLine("取证采集完成: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$summary = $collectionLog.ToString()
$summary | Out-File -FilePath (Join-Path $OutputPath "CollectionLog.txt") -Encoding UTF8

Write-Output $summary
Write-Output "`n取证数据已保存到: $OutputPath"
}

Start-ForensicDataCollection

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
取证采集开始: 2025-10-31 14:35:12
输出目录: C:\Users\admin\AppData\Local\Temp\ForensicCollection_20251031_143512

[1/4] 采集安全事件日志...
已导出 3247 条安全事件到 SecurityEvents.csv
[2/4] 采集登录记录...
已导出 186 条登录记录到 LogonRecords.csv
[3/4] 采集文件系统变更...
已导出 47 个近期变更文件到 FileChanges.csv
[4/4] 采集服务状态...
已导出 215 个服务信息到 Services.csv

取证采集完成: 2025-10-31 14:35:58

取证数据已保存到: C:\Users\admin\AppData\Local\Temp\ForensicCollection_20251031_143512

脚本会在临时目录下创建一个带时间戳的文件夹,里面包含四个 CSV 文件和一个采集日志。这些数据可以直接交给安全分析团队,也可以导入 SIEM 系统做进一步关联分析。整个采集过程在管理员权限下运行,大约 1 分钟即可完成。

场景三:自动隔离与威胁阻断

当确认系统被入侵后,最紧急的操作是阻断攻击者的通信通道并防止横向移动。下面的脚本提供了网络隔离、账户锁定和持久化后门清除三大处置能力。

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

$actionLog = [System.Text.StringBuilder]::new()
$null = $actionLog.AppendLine("=== 威胁处置记录 ===")
$null = $actionLog.AppendLine("执行时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $actionLog.AppendLine()

# 处置一:封禁可疑 IP 地址
if ($BlockSuspiciousIPs -and $MaliciousIPs.Count -gt 0) {
$null = $actionLog.AppendLine("[网络封禁] 封禁恶意 IP 地址")
foreach ($ip in $MaliciousIPs) {
$ruleName = "IR-Block-$($ip -replace '\.','_')"
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($existing) {
$null = $actionLog.AppendLine(" [SKIP] 规则已存在: $ruleName")
continue
}
New-NetFirewallRule -DisplayName $ruleName `
-Direction Outbound -Action Block `
-RemoteAddress $ip -Protocol Any `
-Description "事件响应自动封禁 - $(Get-Date -Format 'yyyy-MM-dd')" |
Out-Null
$null = $actionLog.AppendLine(" [OK] 已封禁出站连接: $ip")
}
$null = $actionLog.AppendLine()
}

# 处置二:禁用被盗用的本地账户
if ($DisableCompromisedAccounts) {
$null = $actionLog.AppendLine("[账户处置] 检查异常登录账户")
$recentLogons = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4625
StartTime = (Get-Date).AddHours(-1)
} -ErrorAction SilentlyContinue

$failedAccounts = [System.Collections.Generic.HashSet[string]]::new()
foreach ($evt in $recentLogons) {
$xml = [xml]$evt.ToXml()
foreach ($item in $xml.Event.EventData.Data) {
if ($item.Name -eq 'TargetUserName' -and $item.'#text') {
$null = $failedAccounts.Add($item.'#text')
}
}
}

# 失败次数超过阈值的账户视为可疑
foreach ($account in $failedAccounts) {
$localUser = Get-LocalUser -Name $account -ErrorAction SilentlyContinue
if ($localUser -and $localUser.Enabled) {
Disable-LocalUser -Name $account
$null = $actionLog.AppendLine(
" [!] 已禁用可疑账户: $account (近期多次登录失败)"
)
}
}
$null = $actionLog.AppendLine()
}

# 处置三:清除持久化后门
if ($RemovePersistence) {
$null = $actionLog.AppendLine("[持久化清除] 检查常见持久化位置")

# 检查启动项注册表
$runKeys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
)
$knownGoodEntries = @('SecurityHealth', 'SysTrayApp', 'OneDrive')
foreach ($key in $runKeys) {
if (Test-Path $key) {
$entries = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue
$props = $entries.PSObject.Properties |
Where-Object {
$_.Name -notin @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider') -and
$_.Name -notin $knownGoodEntries
}
foreach ($prop in $props) {
$null = $actionLog.AppendLine(
" [!] 可疑启动项: $($prop.Name) = $($prop.Value)"
)
}
}
}
$null = $actionLog.AppendLine()
}

$null = $actionLog.AppendLine("=== 处置完成 ===")
$result = $actionLog.ToString()
Write-Output $result

# 将处置记录保存到安全日志目录
$logDir = "C:\IR_Logs"
if (-not (Test-Path $logDir)) {
$null = New-Item -Path $logDir -ItemType Directory -Force
}
$result | Out-File -FilePath "$logDir\ActionLog_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt" -Encoding UTF8
}

# 示例调用:封禁可疑 IP 并清除持久化后门
Invoke-ThreatContainment `
-BlockSuspiciousIPs `
-MaliciousIPs @('10.0.0.88', '192.168.100.200', '203.0.113.50') `
-RemovePersistence

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
=== 威胁处置记录 ===
执行时间: 2025-10-31 14:42:18

[网络封禁] 封禁恶意 IP 地址
[OK] 已封禁出站连接: 10.0.0.88
[OK] 已封禁出站连接: 192.168.100.200
[OK] 已封禁出站连接: 203.0.113.50

[持久化清除] 检查常见持久化位置
[!] 可疑启动项: WindowsUpdate = C:\Users\Public\update.exe
[!] 可疑启动项: SvchostHelper = C:\ProgramData\svchost.bat

=== 处置完成 ===

脚本通过 Windows 防火墙规则封禁了三个恶意 IP 的出站连接,同时在注册表启动项中发现了两个可疑条目——一个伪装成 Windows Update 的可执行文件和一个 svchost 辅助脚本。这些信息需要进一步分析确认,但第一时间阻断网络通道可以有效阻止数据外泄和远程控制。

注意事项

  1. 权限要求:安全事件日志的读取和防火墙规则操作需要管理员权限。建议以提升权限的 PowerShell 会话运行这些脚本,或者通过 WinRM 远程执行时确保会话凭据具有本地管理员权限。

  2. 事件日志大小:如果安全事件日志配置为按需覆盖或日志量极大,Get-WinEvent 可能返回大量数据。建议使用 -MaxEvents 参数限制返回数量,并在过滤条件中加入时间范围,避免内存溢出。

  3. 取证完整性:采集取证数据时务必注意操作顺序——先采集后处置。一旦封禁 IP 或禁用账户,攻击者可能触发自毁机制删除痕迹。优先采集内存镜像和日志,再执行阻断操作。

  4. 误封风险:自动封禁 IP 地址存在误伤业务系统的风险。建议将封禁逻辑与资产白名单结合,先排除内部合法服务器 IP,再执行封禁。封禁后持续监控业务连接是否受影响。

  5. 日志保护:事件响应操作本身也会产生日志记录,这是好事——确保操作可审计。但攻击者也可能尝试清除日志。建议在处置流程中增加一步:将关键日志导出到只读共享或集中式日志平台。

  6. 脚本安全存储:这些响应脚本包含敏感的安全检测逻辑和处置动作,应存储在受控的代码仓库中,避免被攻击者预先研究。在生产环境中使用时,建议通过 DSC 或 CI/CD 管道分发,而不是直接在目标系统上留存脚本文件。

万圣节的鬼怪不过是一晚的恶作剧,但安全威胁可能在你毫无察觉时潜伏数周。掌握 PowerShell 自动化事件响应,让安全团队拥有比驱魔人更可靠的武器。祝大家在这个万圣节既没有鬼敲门,也没有警报响。

PowerShell 技能连载 - Microsoft Sentinel 集成

适用于 PowerShell 5.1 及以上版本

Microsoft Sentinel 是微软云原生的 SIEM(安全信息与事件管理)解决方案,能够收集、检测、调查和响应来自整个企业环境的安全威胁。在大型企业中,安全运营团队每天需要处理成百上千条告警,手动在门户中逐一排查效率极低。通过 PowerShell 自动化与 Sentinel API 的集成,我们可以实现告警的批量查询、自动化响应规则的部署以及威胁情报的快速推送,大幅提升安全运营效率。

本文将介绍如何使用 PowerShell 连接 Microsoft Sentinel REST API,完成查询安全告警、创建自动化分析规则、批量导入威胁指标(TI Indicator)以及导出事件报告等常见操作。这些方法不仅适用于日常安全运维,也能嵌入 CI/CD 流水线,实现安全策略的版本化管理和自动部署。

在开始之前,需要确保已安装 Az PowerShell 模块并完成身份认证。以下示例基于 Azure 资源管理器 REST API,适用于已部署 Microsoft Sentinel 工作区的环境。

连接 Sentinel 并获取工作区信息

第一步是连接 Azure 账户并获取 Sentinel 工作区的基本信息。我们需要订阅 ID、资源组名称和工作区名称三个关键参数,它们构成了所有 Sentinel API 调用的基础路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 安装 Az 模块(如果尚未安装)
Install-Module -Name Az.Accounts, Az.OperationalInsights, Az.SecurityInsights -Force -Scope CurrentUser

# 连接 Azure 账户
Connect-AzAccount

# 获取当前订阅上下文
$subscriptionId = (Get-AzContext).Subscription.Id
$resourceGroupName = "rg-security-prod"
$workspaceName = "law-sentinel-prod"

# 构建 Sentinel 资源基础 URI
$sentinelBaseUri = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$workspaceName/providers/Microsoft.SecurityInsights"

Write-Host "Sentinel 基础路径: $sentinelBaseUri"
Write-Host "订阅 ID: $subscriptionId"

执行结果示例:

1
2
Sentinel 基础路径: /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-security-prod/providers/Microsoft.OperationalInsights/workspaces/law-sentinel-prod/providers/Microsoft.SecurityInsights
订阅 ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

连接成功后,我们得到了 Sentinel API 的基础 URI。后续所有 API 调用都会在这个路径上追加具体的资源端点。如果环境中有多个订阅,可以用 Set-AzContext 切换到包含 Sentinel 工作区的订阅。

查询安全告警

Sentinel 的告警信息可以通过 REST API 直接查询。以下代码展示如何获取最近 24 小时内的活跃告警,并按严重程度进行分类汇总。这在安全运营日报自动化场景中非常实用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 获取最近 24 小时的 Sentinel 告警
$timeRange = "Last24Hours"
$apiVersion = "2024-03-01"

$alertUri = "https://management.azure.com" + $sentinelBaseUri + "/alerts?api-version=$apiVersion"

$response = Invoke-AzRestMethod -Uri $alertUri -Method Get

if ($response.StatusCode -eq 200) {
$alerts = ($response.Content | ConvertFrom-Json).value

# 按严重程度分类统计
$severityGroups = $alerts | Group-Object -Property {
if ($_.properties.severity) { $_.properties.severity } else { "Unknown" }
}

Write-Host "`n===== Sentinel 告警汇总(最近 24 小时)=====" -ForegroundColor Cyan
Write-Host ("告警总数: {0}" -f $alerts.Count)
Write-Host ""

foreach ($group in $severityGroups) {
$severity = $group.Name
$count = $group.Count
$color = switch ($severity) {
"High" { "Red" }
"Medium" { "Yellow" }
"Low" { "Green" }
"Informational" { "Gray" }
default { "White" }
}
Write-Host (" [{0}] {1} 条" -f $severity.PadRight(14), $count) -ForegroundColor $color
}

# 输出高危告警详情
$highAlerts = $alerts | Where-Object { $_.properties.severity -eq "High" }
if ($highAlerts) {
Write-Host "`n===== 高危告警详情 =====" -ForegroundColor Red
foreach ($alert in $highAlerts) {
Write-Host (" 名称: {0}" -f $alert.properties.alertDisplayName)
Write-Host (" 时间: {0}" -f $alert.properties.startTimeUtc)
Write-Host (" 状态: {0}" -f $alert.properties.status)
Write-Host ""
}
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
===== Sentinel 告警汇总(最近 24 小时)=====
告警总数: 47

[High ] 5 条
[Medium ] 18 条
[Low ] 21 条
[Informational ] 3 条

===== 高危告警详情 =====
名称: Suspicious PowerShell execution detected
时间: 2025-10-29T14:23:17Z
状态: New

名称: Brute force attack on Azure AD
时间: 2025-10-29T08:45:02Z
状态: New

告警查询是安全运营的日常操作,通过 PowerShell 脚本化后可以定时执行,将结果推送到 Teams 频道或邮件,实现无人值守的安全监控。高危告警的即时通知机制对于缩短平均响应时间(MTTR)至关重要。

创建自动化分析规则

Sentinel 的分析规则(Analytics Rule)是威胁检测的核心引擎。下面演示如何通过 PowerShell 创建一条自定义的分析规则,用于检测异常的远程桌面登录行为。规则的查询逻辑基于 KQL(Kusto Query Language),当匹配到可疑行为时自动触发告警。

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
# 定义分析规则的参数
$ruleName = "Anomalous-RDP-Login-Detection"
$ruleDisplayName = "异常 RDP 登录检测"
$ruleDescription = "检测来自非常见地理位置的 RDP 登录尝试,可能是横向移动攻击的信号"

# KQL 查询语句
$kqlQuery = @"
SecurityEvent
| where EventID in (4624, 4625)
| where LogonType == 10
| where TimeGenerated > ago(1h)
| summarize LoginCount = count(), DistinctIPs = dcount(IpAddress),
IPs = make_set(IpAddress, 10) by TargetUserName, Computer
| where LoginCount > 5 and DistinctIPs > 3
| project TimeGenerated = now(), TargetUserName, Computer, LoginCount, DistinctIPs, IPs
| order by LoginCount desc
"@

# 构建请求体
$ruleBody = @{
properties = @{
displayName = $ruleDisplayName
description = $ruleDescription
severity = "High"
enabled = $true
query = $kqlQuery
queryFrequency = "PT1H"
queryPeriod = "PT1H"
triggerOperator = "GreaterThan"
triggerThreshold = 0
suppressionDuration = "PT5H"
suppressionEnabled = $false
tactics = @("LateralMovement")
techniques = @("T1021.001")
alertRuleTemplateName = $null
incidentConfiguration = @{
createIncident = $true
groupingConfiguration = @{
enabled = $true
reopenClosedIncident = $false
lookbackDuration = "PT5H"
matchingMethod = "Selected"
groupByEntities = @("Account")
}
}
}
} | ConvertTo-Json -Depth 10

# 发送创建请求
$ruleApiVersion = "2024-03-01"
$ruleUri = "https://management.azure.com" + $sentinelBaseUri + "/alertRules/$ruleName`?api-version=$ruleApiVersion"

$result = Invoke-AzRestMethod -Uri $ruleUri -Method Put -Payload $ruleBody

if ($result.StatusCode -eq 200 -or $result.StatusCode -eq 201) {
$createdRule = $result.Content | ConvertFrom-Json
Write-Host "分析规则创建成功!" -ForegroundColor Green
Write-Host (" 规则名称: {0}" -f $createdRule.properties.displayName)
Write-Host (" 严重级别: {0}" -f $createdRule.properties.severity)
Write-Host (" 查询频率: {0}" -f $createdRule.properties.queryFrequency)
Write-Host (" 战术分类: {0}" -f ($createdRule.properties.tactics -join ", "))
Write-Host (" 创建事件: {0}" -f $createdRule.properties.incidentConfiguration.createIncident)
}

执行结果示例:

1
2
3
4
5
6
分析规则创建成功!
规则名称: 异常 RDP 登录检测
严重级别: High
查询频率: PT1H
战术分类: LateralMovement
创建事件: True

通过脚本创建分析规则的最大优势是版本可控。将规则定义存储在 JSON 或 PowerShell 数据文件中,配合 Git 进行版本管理,团队可以追踪每次规则变更的历史,实现安全检测策略的 Infrastructure as Code(基础设施即代码)实践。

批量导入威胁指标

威胁情报(Threat Intelligence)是 Sentinel 主动防御的重要组成部分。安全团队经常需要将外部威胁情报源(如 STIX/TAXII feeds、商业情报平台)的 IoC(失陷指标)批量导入 Sentinel。以下代码展示如何通过 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
# 准备威胁指标数据
$threatIndicators = @(
@{
displayName = "Malicious C2 Server - apt29-c2.example.com"
description = "APT29 已知 C2 通信域名,关联攻击活动 UNC2452"
patternType = "domain-name"
pattern = "[domain-name:value = 'apt29-c2.example.com']"
severity = "High"
confidence = 85
source = "Internal-Threat-Intel-Platform"
validFrom = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
validUntil = (Get-Date).AddDays(90).ToString("yyyy-MM-ddTHH:mm:ssZ")
threatTypes = @("malicious-activity", "command-and-control")
},
@{
displayName = "Suspicious IP - 198.51.100.42"
description = "频繁扫描行为的来源 IP,多次触发 IDS 告警"
patternType = "ipv4-addr"
pattern = "[ipv4-addr:value = '198.51.100.42']"
severity = "Medium"
confidence = 70
source = "Honeypot-System"
validFrom = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
validUntil = (Get-Date).AddDays(30).ToString("yyyy-MM-ddTHH:mm:ssZ")
threatTypes = @("malicious-activity")
},
@{
displayName = "Phishing Domain - secure-login.example.net"
description = "钓鱼攻击使用的仿冒域名,目标为财务部门"
patternType = "domain-name"
pattern = "[domain-name:value = 'secure-login.example.net']"
severity = "High"
confidence = 92
source = "Phishing-Report-System"
validFrom = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
validUntil = (Get-Date).AddDays(60).ToString("yyyy-MM-ddTHH:mm:ssZ")
threatTypes = @("malicious-activity", "phishing")
}
)

# 批量导入威胁指标
$tiApiVersion = "2024-03-01"
$successCount = 0
$failCount = 0

Write-Host "开始导入威胁指标..." -ForegroundColor Cyan

foreach ($indicator in $threatIndicators) {
$indicatorName = $indicator.displayName -replace '\s', '-' -replace '[^a-zA-Z0-9\-]', ''
$indicatorName = $indicatorName.Substring(0, [System.Math]::Min($indicatorName.Length, 50))

$body = @{
properties = @{
displayName = $indicator.displayName
description = $indicator.description
patternType = $indicator.patternType
pattern = $indicator.pattern
severity = $indicator.severity
confidence = $indicator.confidence
source = $indicator.source
validFrom = $indicator.validFrom
validUntil = $indicator.validUntil
threatTypes = $indicator.threatTypes
}
kind = "indicator"
} | ConvertTo-Json -Depth 5

$uri = "https://management.azure.com" + $sentinelBaseUri + "/threatIntelligence/main/indicators/$indicatorName`?api-version=$tiApiVersion"

$result = Invoke-AzRestMethod -Uri $uri -Method Put -Payload $body

if ($result.StatusCode -eq 200 -or $result.StatusCode -eq 201) {
$successCount++
Write-Host (" [OK] {0}" -f $indicator.displayName) -ForegroundColor Green
}
else {
$failCount++
$errorDetail = ($result.Content | ConvertFrom-Json).error.message
Write-Host (" [FAIL] {0}: {1}" -f $indicator.displayName, $errorDetail) -ForegroundColor Red
}
}

Write-Host "`n导入完成: 成功 $successCount 条, 失败 $failCount 条" -ForegroundColor Cyan

执行结果示例:

1
2
3
4
5
6
开始导入威胁指标...
[OK] Malicious C2 Server - apt29-c2.example.com
[OK] Suspicious IP - 198.51.100.42
[OK] Phishing Domain - secure-login.example.net

导入完成: 成功 3 条, 失败 0 条

批量导入威胁指标时,建议控制单次请求的数量,避免触发 API 速率限制。对于大规模的威胁情报源(数千条 IoC),可以采用分批处理的方式,每批 50-100 条,并在批次之间加入适当的间隔。

导出安全事件报告

安全合规审计通常需要定期的安全事件报告。以下代码展示如何从 Sentinel 提取事件(Incident)数据并生成结构化的报告,便于向管理层汇报或存档备查。

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
# 查询 Sentinel 事件
$incidentsApiVersion = "2024-03-01"
$incidentsUri = "https://management.azure.com" + $sentinelBaseUri + "/incidents?api-version=$incidentsApiVersion&`$filter=properties/createdTimeUtc ge 2025-10-01T00:00:00Z and properties/createdTimeUtc le 2025-10-30T23:59:59Z"

$incidentsResponse = Invoke-AzRestMethod -Uri $incidentsUri -Method Get

if ($incidentsResponse.StatusCode -eq 200) {
$incidents = ($incidentsResponse.Content | ConvertFrom-Json).value

# 构建报告数据
$reportData = foreach ($incident in $incidents) {
$props = $incident.properties
[PSCustomObject]@{
事件编号 = $props.incidentNumber
标题 = $props.title
描述 = if ($props.description.Length -gt 80) { $props.description.Substring(0, 80) + "..." } else { $props.description }
严重级别 = $props.severity
状态 = $props.status
创建时间 = $props.createdTimeUtc
关闭时间 = if ($props.closedTimeUtc) { $props.closedTimeUtc } else { "未关闭" }
分类 = if ($props.classification) { $props.classification } else { "未分类" }
负责人 = if ($props.owner.assignedTo) { $props.owner.assignedTo } else { "未分配" }
}
}

# 输出统计摘要
Write-Host "===== 2025年10月 Sentinel 安全事件报告 =====" -ForegroundColor Cyan
Write-Host ("事件总数: {0}" -f $reportData.Count)
Write-Host ""

$bySeverity = $reportData | Group-Object -Property 严重级别
Write-Host "--- 按严重级别分布 ---"
foreach ($group in $bySeverity) {
Write-Host (" {0}: {1} 条 ({2:P1})" -f $group.Name.PadRight(10), $group.Count, ($group.Count / $reportData.Count))
}

$byStatus = $reportData | Group-Object -Property 状态
Write-Host "`n--- 按状态分布 ---"
foreach ($group in $byStatus) {
Write-Host (" {0}: {1} 条" -f $group.Name.PadRight(15), $group.Count)
}

# 导出 CSV 报告
$reportPath = Join-Path $env:TEMP "Sentinel-Incident-Report-2025-10.csv"
$reportData | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
Write-Host ("`n详细报告已导出: {0}" -f $reportPath) -ForegroundColor Green

# 显示前 5 条未关闭的事件
$openIncidents = $reportData | Where-Object { $_.状态 -ne "Closed" } | Select-Object -First 5
if ($openIncidents) {
Write-Host "`n--- 待处理事件(前 5 条)---" -ForegroundColor Yellow
foreach ($inc in $openIncidents) {
Write-Host (" #{0} [{1}] {2}" -f $inc.事件编号, $inc.严重级别, $inc.标题)
}
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
===== 2025年10月 Sentinel 安全事件报告 =====
事件总数: 128

--- 按严重级别分布 ---
High : 12 条 (9.4%)
Medium : 45 条 (35.2%)
Low : 56 条 (43.8%)
Informational: 15 条 (11.7%)

--- 按状态分布 ---
New : 8 条
Active : 23 条
Closed : 97 条

详细报告已导出: /tmp/Sentinel-Incident-Report-2025-10.csv

--- 待处理事件(前 5 条)---
#1042 [High] Suspicious PowerShell execution detected
#1040 [High] Brute force attack on Azure AD
#1039 [Medium] Anomalous sign-in location detected
#1037 [Medium] Unusual file download activity
#1035 [Low] Multiple failed VPN attempts

导出的 CSV 报告可以直接用于月度安全复盘会议,也可以导入 Excel 进行进一步的数据透视分析。通过 PowerShell 定时任务(Scheduled Task)或 Azure Automation Runbook,可以将此脚本配置为每月自动执行,自动将报告发送到指定邮箱或 SharePoint 文档库。

注意事项

  1. API 版本兼容性:Microsoft Sentinel REST API 处于持续演进中,不同版本的行为可能存在差异。建议在脚本中显式指定 API 版本(如 2024-03-01),并在升级前查阅官方 changelog 确认破坏性变更。生产环境中应锁定 API 版本,避免因自动升级导致脚本失效。

  2. 权限最小化原则:调用 Sentinel API 的服务主体或用户账户应遵循最小权限原则。建议使用 Azure 自定义角色(Custom Role),仅授予 Microsoft.SecurityInsights/* 相关操作的读取或写入权限,而非贡献者(Contributor)级别的宽泛权限。

  3. API 速率限制:Azure ARM API 对订阅级别的请求有速率限制(通常每订阅每小时 12000 次写入请求)。在批量操作(如导入数千条威胁指标)时,务必实现重试逻辑和退避策略,监控响应头中的 x-ms-ratelimit-remaining-subscription-writes 值。

  4. KQL 查询性能:分析规则中的 KQL 查询直接影响 Sentinel 的运行成本和响应速度。避免在查询中使用 join 操作大量数据集,善用 summarizewhere 子句尽早过滤数据。查询频率(queryFrequency)和查询周期(queryPeriod)的设置也需要根据实际数据量调优,过大的查询周期会显著增加 Log Analytics 的扫描费用。

  5. 敏感数据处理:告警和事件数据中可能包含用户主体名称(UPN)、IP 地址等敏感信息。在导出报告或推送到第三方系统时,注意遵守数据保护法规(如 GDPR、个人信息保护法),必要时对敏感字段进行脱敏处理。存储报告的路径也应设置适当的访问控制。

  6. 模块版本管理Az.SecurityInsights 模块目前仍处于预览阶段,cmdlet 和参数可能发生变更。建议在脚本中检查模块版本,并在 CI/CD 流水线中固定模块版本号(如 RequiredVersion),确保不同环境中的行为一致。可同时准备 REST API 直接调用的降级方案,以应对模块兼容性问题。

PowerShell 技能连载 - Windows Terminal 定制

适用于 PowerShell 7.0 及以上版本

Windows Terminal 已经成为 Windows 平台上最受欢迎的终端模拟器之一。它支持多标签页、GPU 加速渲染、Unicode 和 UTF-8 字符显示,以及对 WSL、CMD、PowerShell 等多种 Shell 的统一管理。但对于日常重度使用命令行的开发者来说,默认的 Terminal 外观和功能往往不够用——提示符单调、配色平庸、缺少上下文信息,这些都会影响工作效率。

好消息是,借助 PowerShell 7 的强大生态,我们可以通过 Oh My Posh 主题引擎、Profile 脚本自动化以及 Terminal 的 JSON 配置,打造一个既美观又实用的终端环境。从 Git 状态感知的提示符,到一键切换配色方案,再到自定义快捷键绑定,几乎所有的视觉和行为要素都可以按需调整。

本文将从实际场景出发,逐步展示如何用 PowerShell 脚本完成 Windows Terminal 的深度定制。每一段代码都可以直接复制到你的环境中运行,让你在几分钟内拥有一个令人印象深刻的终端工作区。

安装和初始化 Oh My Posh

Oh My Posh 是一个跨平台的提示符主题引擎,可以为 PowerShell 提供丰富的上下文信息,包括 Git 分支状态、Python 虚拟环境、执行耗时等。首先我们需要安装它并配置到 Profile 中。

1
2
3
4
5
6
7
8
# 安装 Oh My Posh(使用 winget)
winget install JanDeDobbeleer.OhMyPosh -s winget

# 如果 winget 不可用,也可以用 PowerShell 直接安装
Install-Module oh-my-posh -Scope CurrentUser -Force

# 查看 Oh My Posh 版本确认安装成功
oh-my-posh --version

安装完成后,输出类似如下:

1
24.5.0

接下来将 Oh My Posh 初始化命令写入 Profile,使其在每次启动 PowerShell 时自动加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 查找或创建 Profile 文件
$profilePath = $PROFILE.CurrentUserAllHosts
if (-not (Test-Path $profilePath)) {
$null = New-Item -Path $profilePath -ItemType File -Force
Write-Host "已创建 Profile 文件: $profilePath"
} else {
Write-Host "Profile 文件已存在: $profilePath"
}

# 向 Profile 中追加 Oh My Posh 初始化行
$initLine = 'oh-my-posh init pwsh | Invoke-Expression'
$content = Get-Content -Path $profilePath -Raw -ErrorAction SilentlyContinue
if ($content -notmatch 'oh-my-posh init') {
Add-Content -Path $profilePath -Value $initLine
Write-Host '已添加 Oh My Posh 初始化命令'
} else {
Write-Host 'Oh My Posh 初始化命令已存在,跳过'
}

执行结果示例:

1
2
已创建 Profile 文件: C:\Users\dev\Documents\PowerShell\profile.ps1
已添加 Oh My Posh 初始化命令

浏览和应用主题

Oh My Posh 内置了大量开箱即用的主题,你可以通过脚本快速预览并切换。以下代码列出所有可用主题,并让你预览效果。

1
2
3
4
5
6
7
8
9
10
11
12
# 获取 Oh My Posh 主题目录
$themesDir = "$(oh-my-posh config export themes)"

# 列出所有主题文件名
$themes = Get-ChildItem -Path $themesDir -Filter '*.omp.json' |
Select-Object -ExpandProperty BaseName

Write-Host "共找到 $($themes.Count) 个主题"
Write-Host '---'
foreach ($t in $themes) {
Write-Host " - $t"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
共找到 127 个主题
---
- 1_shell
- agnoster
- agnosterplus
- aliens
- amro
- atomic
- atomicBit
- avit
...

找到喜欢的主题后,将其写入 Profile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 选择主题名称
$selectedTheme = 'jandebuhr'

# 构建 Oh My Posh 初始化命令(指定主题)
$themeInit = "oh-my-posh init pwsh --config `"$themesDir\$selectedTheme.omp.json`" | Invoke-Expression"

# 读取当前 Profile 内容
$profileContent = Get-Content -Path $PROFILE.CurrentUserAllHosts -Raw

# 替换已有的 oh-my-posh init 行,或追加新行
if ($profileContent -match 'oh-my-posh init pwsh') {
$profileContent = $profileContent -replace 'oh-my-posh init pwsh.*Invoke-Expression', $themeInit
Set-Content -Path $PROFILE.CurrentUserAllHosts -Value $profileContent -NoNewline
Write-Host "已更新主题为: $selectedTheme"
} else {
Add-Content -Path $PROFILE.CurrentUserAllHosts -Value $themeInit
Write-Host "已添加主题: $selectedTheme"
}

Write-Host '请重新打开终端以查看效果'

执行结果示例:

1
2
已更新主题为: jandebuhr
请重新打开终端以查看效果

自动化管理 Terminal 配置文件

Windows Terminal 的设置存储在一个 JSON 文件中,路径通常是 settings.json。我们可以用 PowerShell 脚本直接读取和修改它,实现配色方案的批量管理和快捷键自定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 定位 Windows Terminal settings.json 路径
$settingsPath = Join-Path -Path $env:LOCALAPPDATA `
-ChildPath 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'

# 如果是 Preview 版本
if (-not (Test-Path $settingsPath)) {
$settingsPath = Join-Path -Path $env:LOCALAPPDATA `
-ChildPath 'Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json'
}

# 备份原始配置
$backupPath = $settingsPath + '.backup'
Copy-Item -Path $settingsPath -Destination $backupPath -Force
Write-Host "已备份配置到: $backupPath"

# 读取并解析 JSON
$settings = Get-Content -Path $settingsPath -Raw | ConvertFrom-Json
Write-Host "当前共有 $(($settings.schemes | Measure-Object).Count) 个配色方案"

执行结果示例:

1
2
已备份配置到: C:\Users\dev\AppData\Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json.backup
当前共有 24 个配色方案

下面演示如何通过脚本添加自定义配色方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 定义一个新的配色方案
$newScheme = @{
name = 'PowerShell Dark Modern'
background = '#0C0C0C'
foreground = '#CCCCCC'
cursorColor = '#00FF00'
black = '#0C0C0C'
blue = '#0037DA'
cyan = '#3A96DD'
green = '#13A10E'
purple = '#881798'
red = '#C50F1F'
white = '#CCCCCC'
yellow = '#C19C00'
brightBlack = '#767676'
brightBlue = '#3B78FF'
brightCyan = '#61D6D6'
brightGreen = '#16C60C'
brightPurple = '#B4009E'
brightRed = '#E74856'
brightWhite = '#F2F2F2'
brightYellow = '#F9F1A5'
}

# 将哈希表转换为 PSCustomObject 并添加到 schemes 数组
$schemeObj = [PSCustomObject]$newScheme

if (-not $settings.schemes) {
$settings | Add-Member -MemberType NoteProperty -Name 'schemes' -Value @()
}
$settings.schemes += $schemeObj

# 将指定 Profile 的配色方案设置为新建的方案
foreach ($profile in $settings.profiles.list) {
if ($profile.name -match 'PowerShell') {
$profile | Add-Member -MemberType NoteProperty -Name 'colorScheme' `
-Value 'PowerShell Dark Modern' -Force
Write-Host "已为 Profile '$($profile.name)' 应用新配色"
}
}

# 写回 settings.json
$settings | ConvertTo-Json -Depth 10 | Set-Content -Path $settingsPath -Encoding UTF8
Write-Host '配置已保存,重新打开 Terminal 即可看到效果'

执行结果示例:

1
2
3
已为 Profile 'Windows PowerShell' 应用新配色
已为 Profile 'PowerShell 7' 应用新配色
配置已保存,重新打开 Terminal 即可看到效果

在 Profile 中添加实用函数

一个精心定制的终端不仅仅是好看,更要好用。以下是一组可以直接加入 Profile 的实用函数,提升日常操作效率。

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
# 这段代码演示如何向 Profile 中批量添加实用函数
$functions = @(
@{
Name = 'Get-TerminalVersion'
Code = @'
function Get-TerminalVersion {
<# 获取当前 Windows Terminal 版本 #>
$pkg = Get-AppxPackage *WindowsTerminal*
if ($pkg) {
[PSCustomObject]@{
Name = $pkg.Name
Version = $pkg.Version
Status = '已安装'
}
} else {
Write-Warning '未检测到 Windows Terminal 安装'
}
}
'@
}
@{
Name = 'Export-TerminalSettings'
Code = @'
function Export-TerminalSettings {
<# 导出 Windows Terminal 配置到桌面 #>
$dest = Join-Path $env:USERPROFILE 'Desktop\wt-settings-backup.json'
$src = Join-Path $env:LOCALAPPDATA `
'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
if (Test-Path $src) {
Copy-Item -Path $src -Destination $dest -Force
Write-Host "配置已导出到: $dest"
} else {
Write-Warning '未找到 Windows Terminal 配置文件'
}
}
'@
}
@{
Name = 'Set-TerminalOpacity'
Code = @'
function Set-TerminalOpacity {
<# 设置 Terminal 窗口透明度(需要 Terminal 设置中启用亚克力效果)#>
param([int]$Opacity = 80)
$settingsPath = Join-Path $env:LOCALAPPDATA `
'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
foreach ($p in $settings.profiles.list) {
$p | Add-Member -MemberType NoteProperty -Name 'opacity' `
-Value $Opacity -Force
}
$settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath -Encoding UTF8
Write-Host "已将所有 Profile 透明度设置为 ${Opacity}%"
}
'@
}
)

# 写入 Profile
$profileFile = $PROFILE.CurrentUserAllHosts
foreach ($func in $functions) {
if (-not (Select-String -Path $profileFile -Pattern "function $($func.Name)" -Quiet)) {
Add-Content -Path $profileFile -Value "`n$($func.Code)"
Write-Host "已添加函数: $($func.Name)"
} else {
Write-Host "函数已存在,跳过: $($func.Name)"
}
}

执行结果示例:

1
2
3
已添加函数: Get-TerminalVersion
已添加函数: Export-TerminalSettings
已添加函数: Set-TerminalOpacity

添加完成后,重新加载 Profile 即可使用这些函数:

1
2
3
4
. $PROFILE.CurrentUserAllHosts

# 测试获取 Terminal 版本
Get-TerminalVersion
1
2
3
Name                            Version        Status
---- ------- ------
Microsoft.WindowsTerminal 1.22.11141.0 已安装

注意事项

  1. 备份配置再修改:Terminal 的 settings.json 是唯一配置来源,修改前务必备份。本文中的脚本会自动创建 .backup 副本,但建议你也定期将配置纳入版本控制。

  2. Oh My Posh 字体依赖:大部分 Oh My Posh 主题需要 Nerd Font 字体才能正确显示图标。推荐安装 CascadiaCodeFiraCode 的 Nerd Font 版本,并在 Terminal 设置中将字体名填入 Profile 的 font.face 字段。

  3. Profile 执行策略:如果系统执行策略禁止运行脚本,Oh My Posh 和自定义函数都不会生效。需要以管理员身份执行 Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 来放行本地脚本。

  4. JSON 序列化深度ConvertTo-Json 默认深度为 2,Terminal 的 settings.json 嵌套较深(特别是 actions 数组),务必使用 -Depth 10 或更高,否则部分配置会丢失。

  5. Preview 和 Stable 版本路径不同:Windows Terminal Preview 版的包名包含 Previewsettings.json 的路径也不同。脚本中应同时检测两个路径,避免修改错目标。

  6. 亚克力效果和透明度需要 GPU 支持useAcrylicopacity 设置依赖 GPU 加速渲染。在虚拟机或远程桌面会话中,这些视觉效果可能无法正常工作,但不影响核心功能使用。