PowerShell 技能连载 - Azure Automation Runbook

适用于 PowerShell 7.0 及以上版本

在日常运维中,许多任务需要定时执行:清理过期资源、轮转证书、检查合规状态、生成日报。如果依赖人工操作,不仅效率低下,还容易遗漏。Azure Automation 提供了一个托管的 PowerShell 执行环境,让脚本可以在云端按计划自动运行,无需维护本地服务器。

Azure Automation 的核心概念是 Runbook——一段托管在云端的 PowerShell 脚本。Runbook 支持多种触发方式:定时计划(Schedule)、Webhook 回调、事件驱动(Event Grid),甚至可以手动启动。它内置了凭据管理、变量存储和模块缓存,使脚本能安全地访问 Azure 资源而不暴露密钥。

本文将围绕三个核心场景展开:创建和管理 Runbook、配置定时计划与 Webhook 触发、以及监控作业状态与日志输出。通过这些实践,你可以构建一个完整的无人值守运维体系。

创建 Automation Account 与发布 Runbook

首先需要创建 Automation Account,然后在其中编写和发布 Runbook。以下脚本演示了完整的创建流程,包括模块导入、Runbook 编写、参数配置和发布。

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
# 连接到 Azure
Connect-AzAccount -Subscription 'production-sub'

# 定义变量
$ResourceGroup = 'rg-automation'
$AutomationAccount = 'aa-ops'
$Location = 'eastasia'

# 创建资源组和 Automation Account
New-AzResourceGroup -Name $ResourceGroup -Location $Location -Force

New-AzAutomationAccount `
-ResourceGroupName $ResourceGroup `
-Name $AutomationAccount `
-Location $Location `
-Plan Basic

# 导入常用模块(确保 Az 模块可用)
$ModuleVersions = @{
'Az.Accounts' = '3.0.0'
'Az.Resources' = '7.0.0'
'Az.Storage' = '6.0.0'
}

foreach ($Module in $ModuleVersions.GetEnumerator()) {
$ModuleUrl = "https://www.powershellgallery.com/api/v2/package/$($Module.Key)/$($Module.Value)"
New-AzAutomationModule `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $Module.Key `
-ContentLinkUri $ModuleUrl
}

Write-Host "模块导入已提交, provisioning 需要几分钟..."

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
ResourceGroupName : rg-automation
Location : eastasia
AutomationAccountName : aa-ops
Plan : Basic
Status : Ok

Name ContentLinkUri
---- ---------------
Az.Accounts https://www.powershellgallery.com/api/v2/package/Az.Accounts/3.0.0
Az.Resources https://www.powershellgallery.com/api/v2/package/Az.Resources/7.0.0
Az.Storage https://www.powershellgallery.com/api/v2/package/Az.Storage/6.0.0
模块导入已提交,provisioning 需要几分钟...

接下来编写 Runbook 内容并发布。Runbook 的脚本内容可以通过字符串定义并上传。

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
# Runbook 脚本内容
$RunbookContent = @'
param(
[string]$ResourceGroupName = 'rg-production',
[int]$OlderThanDays = 30,
[bool]$WhatIf = $true
)

$Conn = Get-AutomationConnection -Name 'AzureRunAsConnection'
Connect-AzAccount -ServicePrincipal `
-Tenant $Conn.TenantId `
-ApplicationId $Conn.ApplicationId `
-CertificateThumbprint $Conn.CertificateThumbprint

$CutoffDate = (Get-Date).AddDays(-$OlderThanDays)

$Snapshots = Get-AzSnapshot -ResourceGroupName $ResourceGroupName |
Where-Object { $_.TimeCreated -lt $CutoffDate }

Write-Output "找到 $($Snapshots.Count) 个超过 $OlderThanDays 天的快照"

foreach ($Snap in $Snapshots) {
if ($WhatIf) {
Write-Output "[WhatIf] 将删除快照: $($Snap.Name) (创建于 $($Snap.TimeCreated))"
} else {
Remove-AzSnapshot -ResourceGroupName $ResourceGroupName `
-SnapshotName $Snap.Name -Force
Write-Output "已删除快照: $($Snap.Name)"
}
}
'@

# 创建并发布 Runbook
$RunbookName = 'Clean-OldSnapshots'

New-AzAutomationRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName `
-Type PowerShell `
-Description '清理超过指定天数的磁盘快照'

Set-AzAutomationRunbookContent `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName `
-Content $RunbookContent

Publish-AzAutomationRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $RunbookName

Write-Host "Runbook '$RunbookName' 已发布"

执行结果示例:

1
2
3
4
5
6
7
8
RunbookType     : PowerShell
Name : Clean-OldSnapshots
Description : 清理超过指定天数的磁盘快照
State : Published
CreationTime : 2026-03-18 02:15:00
LastModifiedTime: 2026-03-18 02:15:32

Runbook 'Clean-OldSnapshots' 已发布

配置定时计划与 Webhook 触发

Runbook 发布后,需要配置触发方式。最常见的两种是定时计划和 Webhook。定时计划适合周期性任务,Webhook 则适合外部系统回调的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# --- 定时计划 ---
# 创建每天凌晨 2 点执行的日程
$ScheduleName = 'daily-snapshot-cleanup'

New-AzAutomationSchedule `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name $ScheduleName `
-StartTime '2026-03-19T02:00:00+08:00' `
-DayInterval 1 `
-Description '每天凌晨清理过期快照'

# 将日程绑定到 Runbook 并传入参数
Register-AzAutomationScheduledRunbook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName `
-ScheduleName $ScheduleName `
-Parameters @{
ResourceGroupName = 'rg-production'
OlderThanDays = 30
WhatIf = $false
}

Write-Host "定时计划 '$ScheduleName' 已绑定到 Runbook '$RunbookName'"

# --- Webhook 触发 ---
$WebhookName = 'cleanup-trigger'
$ExpiryTime = (Get-Date).AddYears(1)

$Webhook = New-AzAutomationWebhook `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName `
-Name $WebhookName `
-IsEnabled $true `
-ExpiryTime $ExpiryTime

Write-Host "Webhook URL(仅显示一次,请妥善保存):"
Write-Host $Webhook.WebhookURI

# --- 关联凭据(使用 Automation 凭据代替明文密码)#
$Cred = Get-Credential -Message '输入服务主体凭据'
New-AzAutomationCredential `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Name 'ServicePrincipalCred' `
-Value $Cred

Write-Host "凭据已添加到 Automation Account"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ScheduleName    : daily-snapshot-cleanup
StartTime : 2026-03-19 02:00:00 +08:00
ExpiryTime : 9999-12-31 23:59:59
Interval : 1
Frequency : Day
IsEnabled : True

定时计划 'daily-snapshot-cleanup' 已绑定到 Runbook 'Clean-OldSnapshots'

WebhookName : cleanup-trigger
IsEnabled : True
ExpiryTime : 2027-03-18 02:30:00
RunbookName : Clean-OldSnapshots

Webhook URL(仅显示一次,请妥善保存):
https://s15events.azure-automation.net/webhooks?token=Jvq%2BdGGYaC%2F...masked...
凭据已添加到 Automation Account

通过 Webhook 触发 Runbook 的方式很简单,只需发送一个 HTTP POST 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 外部系统通过 Webhook 触发 Runbook
$WebhookUri = 'https://s15events.azure-automation.net/webhooks?token=...'

$Body = @{
ResourceGroupName = 'rg-staging'
OlderThanDays = 14
WhatIf = $true
} | ConvertTo-Json

$Response = Invoke-RestMethod -Uri $WebhookUri -Method Post `
-Body $Body -ContentType 'application/json'

Write-Host "作业已触发,Job ID: $($Response.jobIds[0])"

执行结果示例:

1
作业已触发,Job ID: 7a3f9c2e-4b12-4d8a-9e1f-5c6d7b8a1234

监控作业状态与日志输出

Runbook 每次执行都会产生一个 Job。监控 Job 状态、查看输出日志和配置告警是确保自动化可靠运行的关键。

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
# --- 查询作业状态 ---
# 获取最近 10 个 Job
$Jobs = Get-AzAutomationJob `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-RunbookName $RunbookName |
Sort-Object CreationTime -Descending |
Select-Object -First 10

$Jobs | Format-Table JobId, Status, CreationTime, StartTime, EndTime -AutoSize

# --- 获取特定 Job 的输出 ---
$LatestJob = $Jobs | Select-Object -First 1

$Output = Get-AzAutomationJobOutput `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-Id $LatestJob.JobId

foreach ($Record in $Output) {
$FullRecord = Get-AzAutomationJobOutputRecord `
-ResourceGroupName $ResourceGroup `
-AutomationAccountName $AutomationAccount `
-JobId $LatestJob.JobId `
-Id $Record.StreamRecordId
Write-Host "[$($Record.Type)] $($FullRecord.Value)"
}

# --- 错误处理与告警 ---
$FailedJobs = $Jobs | Where-Object { $_.Status -eq 'Failed' }

if ($FailedJobs) {
$AlertBody = @{
text = "Runbook '$RunbookName' 有 $($FailedJobs.Count) 个失败作业"
details = ($FailedJobs | ForEach-Object {
"JobId: $($_.JobId), Time: $($_.CreationTime)"
}) -join '; '
} | ConvertTo-Json -Depth 3

# 发送到 Microsoft Teams 或 Slack(示例用 Teams Webhook)
$TeamsWebhook = 'https://outlook.office.com/webhook/...'

Invoke-RestMethod -Uri $TeamsWebhook -Method Post `
-Body $AlertBody -ContentType 'application/json'

Write-Host "告警已发送,共 $($FailedJobs.Count) 个失败作业"
} else {
Write-Host "所有作业状态正常"
}

# --- Runbook 内部的结构化日志 ---
# 在 Runbook 脚本中推荐使用 Write-Output 和 Write-Warning
# 配合 $PSItem.ErrorRecord 做异常捕获
$RunbookWithLogging = @'
try {
$Conn = Get-AutomationConnection -Name 'AzureRunAsConnection'
Connect-AzAccount -ServicePrincipal `
-Tenant $Conn.TenantId `
-ApplicationId $Conn.ApplicationId `
-CertificateThumbprint $Conn.CertificateThumbprint |
Out-Null

Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 连接 Azure 成功"
Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 开始执行清理任务"

# ... 主要逻辑 ...

Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 任务完成"
}
catch {
$ErrorMsg = "[$(Get-Date -Format 'HH:mm:ss')] 错误: $($_.Exception.Message)"
Write-Error $ErrorMsg
throw $ErrorMsg
}
'@

Write-Host "带日志的 Runbook 模板已准备就绪"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
JobId                                  Status   CreationTime         StartTime            EndTime
----- ------ ------------ ---------- -------
7a3f9c2e-4b12-4d8a-9e1f-5c6d7b8a1234 Completed 2026-03-18 02:00:12 2026-03-18 02:00:15 2026-03-18 02:01:03
b1e8d3a5-7c09-4f2e-b3d4-8e9f0a1b2c3d Completed 2026-03-17 02:00:08 2026-03-17 02:00:11 2026-03-17 02:00:58
c4f0e2b6-9d21-4a3c-b5e7-1f2a3b4c5d6e Failed 2026-03-16 02:00:05 2026-03-16 02:00:09 2026-03-16 02:00:12

[Output] [02:00:16] 连接 Azure 成功
[Output] [02:00:17] 找到 12 个超过 30 天的快照
[Output] [02:00:18] [WhatIf] 将删除快照: disk-snap-20260115 (创建于 2026-01-15)
[Output] [02:01:03] 任务完成

告警已发送,共 1 个失败作业
带日志的 Runbook 模板已准备就绪

注意事项

  1. 模块版本管理:Automation Account 中 Az 模块的版本可能与本地不同。发布 Runbook 前务必确认云端模块版本支持你使用的 cmdlet,否则运行时会报”找不到命令”错误。

  2. 凭据安全:不要在 Runbook 中硬编码密码或密钥。使用 Get-AutomationConnectionGet-AutomationCredentialGet-AutomationVariable 从 Automation Account 的安全存储中获取敏感信息。

  3. 执行时间限制:Azure Automation 的 Free/Basic 计划中,单个 Job 的最长运行时间为 3 小时。超时后 Job 会被强制挂起(Suspended)。长时间运行的任务应拆分为多个 Runbook 或使用检查点(Checkpoint)。

  4. Webhook URL 安全:Webhook URL 包含认证令牌,创建后只显示一次。务必妥善保存,泄露后任何人都可触发你的 Runbook。如果怀疑泄露,应立即删除并重建 Webhook。

  5. 日志级别选择:Runbook 中 Write-Output 写入的信息会出现在 Job 的 Output 流中,Write-Warning 进入 Warning 流,Write-Error 进入 Error 流。根据需要选择合适的级别,避免关键信息被大量调试输出淹没。

  6. 计划时区注意New-AzAutomationSchedule-StartTime 参数使用 UTC 时间(除非显式指定时区偏移)。如果你的运维窗口是北京时间凌晨 2 点,需要写成 18:00:00Z02:00:00+08:00

PowerShell 技能连载 - 日期时间高级操作

适用于 PowerShell 5.1 及以上版本

日期时间处理在运维脚本中无处不在——日志时间戳解析、定时任务调度、报表周期计算、服务运行时间监控、跨时区协作。PowerShell 基于 .NET 的 DateTimeTimeSpan 类型,提供了丰富的时间操作能力。但时区转换、UTC 处理、文化格式化等场景容易出错,需要掌握一些关键技巧。

本文将讲解 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
# 标准格式化
$now = Get-Date
Write-Host "默认:$now"
Write-Host "短日期:$($now.ToString('d'))"
Write-Host "长日期:$($now.ToString('D'))"
Write-Host "ISO 8601:$($now.ToString('yyyy-MM-ddTHH:mm:ss.fff'))"
Write-Host "文件名安全:$($now.ToString('yyyyMMdd_HHmmss'))"
Write-Host "日志格式:$($now.ToString('yyyy-MM-dd HH:mm:ss.fff'))"

# 自定义格式化
$formats = @{
"年月日" = "yyyy年M月d日"
"时分秒" = "HH时mm分ss秒"
"季度" = "yyyy年 第q季度"
"周几" = "yyyy-MM-dd dddd"
"相对简写" = "M/d HH:mm"
}

foreach ($item in $formats.GetEnumerator()) {
$formatted = $now.ToString($item.Value)
Write-Host "$($item.Key):$formatted"
}

# 解析各种格式的时间字符串
$dateStrings = @(
"2025-07-07",
"2025/07/07 08:30:00",
"07-Jul-2025",
"2025年7月7日"
)

foreach ($ds in $dateStrings) {
try {
$parsed = [datetime]::Parse($ds)
Write-Host "解析 '$ds' => $($parsed.ToString('yyyy-MM-dd HH:mm:ss'))" -ForegroundColor Green
} catch {
Write-Host "解析失败 '$ds'" -ForegroundColor Red
}
}

# 精确解析指定格式
$logTime = "07/07/2025:08:30:15 +0800"
$parsed = [datetime]::ParseExact($logTime.Split(' ')[0], "MM/dd/yyyy:HH:mm:ss", $null)
Write-Host "日志时间解析:$($parsed.ToString('yyyy-MM-dd HH:mm:ss'))"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
默认:2025/7/7 8:00:00
短日期:2025/7/7
长日期:2025年7月7日
ISO 8601:2025-07-07T08:00:00.000
文件名安全:20250707_080000
日志格式:2025-07-07 08:00:00.000
年月日:2025年7月7日
时分秒:08时00分00秒
季度:2025年 第3季度
周几:2025-07-07 星期一
相对简写:7/7 08:00
解析 '2025-07-07' => 2025-07-07 00:00:00
解析 '2025/07/07 08:30:00' => 2025-07-07 08:30:00
解析 '07-Jul-2025' => 2025-07-07 00:00:00
解析 '2025年7月7日' => 2025-07-07 00:00:00
日志时间解析:2025-07-07 08:30:15

时间差计算

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
# TimeSpan 基础
$bootTime = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptime = (Get-Date) - $bootTime

Write-Host "系统运行时间:$($uptime.Days) 天 $($uptime.Hours) 小时 $($uptime.Minutes) 分钟"

# 计算工作日
function Get-WorkingDays {
param(
[Parameter(Mandatory)][datetime]$Start,
[Parameter(Mandatory)][datetime]$End
)

$days = 0
$current = $Start.Date

while ($current -le $End.Date) {
if ($current.DayOfWeek -notin @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)) {
$days++
}
$current = $current.AddDays(1)
}

return $days
}

$workingDays = Get-WorkingDays -Start (Get-Date "2025-07-01") -End (Get-Date "2025-07-31")
Write-Host "7 月工作日:$workingDays 天"

# 计算下一个工作日
function Get-NextWorkday {
param([datetime]$Date = (Get-Date))

$next = $Date.AddDays(1)
while ($next.DayOfWeek -in @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)) {
$next = $next.AddDays(1)
}
return $next
}

Write-Host "下一个工作日:$(Get-NextWorkday | Get-Date -Format 'yyyy-MM-dd dddd')"

# 计算月末日期
function Get-LastDayOfMonth {
param([datetime]$Date = (Get-Date))
return (New-Object System.DateTime $Date.Year, $Date.Month, 1).AddMonths(1).AddDays(-1)
}

Write-Host "本月最后一天:$(Get-LastDayOfMonth | Get-Date -Format 'yyyy-MM-dd dddd')"

执行结果示例:

1
2
3
4
系统运行时间:45 天 12 小时 30 分钟
7 月工作日:23 天
下一个工作日:2025-07-08 星期二
本月最后一天:2025-07-31 星期四

时区处理

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
# 获取时区信息
$tz = [System.TimeZoneInfo]::Local
Write-Host "本地时区:$($tz.DisplayName)"
Write-Host "UTC 偏移:$($tz.BaseUtcOffset)"

# UTC 与本地时间互转
$utcNow = [System.DateTime]::UtcNow
Write-Host "UTC 时间:$($utcNow.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Host "本地时间:$([System.TimeZoneInfo]::ConvertTimeFromUtc($utcNow, $tz).ToString('yyyy-MM-dd HH:mm:ss'))"

# 转换到指定时区
function ConvertTo-TimeZone {
param(
[Parameter(Mandatory)]
[datetime]$DateTime,

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

$targetTz = [System.TimeZoneInfo]::FindSystemTimeZoneById($TimeZoneId)
return [System.TimeZoneInfo]::ConvertTime($DateTime, $targetTz)
}

# 常用时区 ID
$timeZones = @{
"上海" = "China Standard Time"
"东京" = "Tokyo Standard Time"
"纽约" = "Eastern Standard Time"
"伦敦" = "GMT Standard Time"
"悉尼" = "AUS Eastern Standard Time"
}

$now = Get-Date
Write-Host "`n全球时间对照($($now.ToString('yyyy-MM-dd HH:mm:ss')) 本地时间):" -ForegroundColor Cyan

foreach ($city in $timeZones.GetEnumerator()) {
try {
$converted = ConvertTo-TimeZone -DateTime $now -TimeZoneId $city.Value
Write-Host " $($city.Key):$($converted.ToString('yyyy-MM-dd HH:mm:ss dddd'))" -ForegroundColor Green
} catch {
Write-Host " $($city.Key):转换失败" -ForegroundColor Red
}
}

# 列出所有可用时区
[System.TimeZoneInfo]::GetSystemTimeZones() |
Where-Object { $_.DisplayName -match 'China|Tokyo|Eastern|GMT Standard' } |
Select-Object Id, DisplayName |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
本地时区:(UTC+08:00) 北京,重庆,香港特别行政区,乌鲁木齐
UTC 偏移:08:00:00
UTC 时间:2025-07-07 00:00:00
本地时间:2025-07-07 08:00:00

全球时间对照(2025-07-07 08:00:00 本地时间):
上海:2025-07-07 08:00:00 星期一
东京:2025-07-07 09:00:00 星期一
纽约:2025-07-06 20:00:00 星期日
伦敦:2025-07-07 01:00:00 星期一
悉尼:2025-07-07 10:00:00 星期一

定时任务辅助

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
# 判断是否在维护窗口内
function Test-MaintenanceWindow {
param(
[int]$StartHour = 22,
[int]$EndHour = 6,
[datetime]$CheckTime = (Get-Date)
)

$hour = $CheckTime.Hour

if ($StartHour -gt $EndHour) {
# 跨午夜(如 22:00-06:00)
return ($hour -ge $StartHour -or $hour -lt $EndHour)
} else {
return ($hour -ge $StartHour -and $hour -lt $EndHour)
}
}

if (Test-MaintenanceWindow -StartHour 22 -EndHour 6) {
Write-Host "当前在维护窗口内(22:00-06:00)" -ForegroundColor Yellow
} else {
Write-Host "当前不在维护窗口内" -ForegroundColor Green
}

# 等待到指定时间
function Wait-Until {
param(
[Parameter(Mandatory)]
[datetime]$TargetTime
)

$now = Get-Date
if ($TargetTime -le $now) {
Write-Host "目标时间已过" -ForegroundColor Yellow
return
}

$waitSpan = $TargetTime - $now
Write-Host "等待 $($waitSpan.TotalMinutes -as [int]) 分钟到 $($TargetTime.ToString('HH:mm:ss'))..." -ForegroundColor Cyan

Start-Sleep -Seconds $waitSpan.TotalSeconds
Write-Host "到达目标时间" -ForegroundColor Green
}

# 生成时间区间
function New-TimeRange {
param(
[datetime]$Start = (Get-Date "2025-07-07"),
[datetime]$End = (Get-Date "2025-07-07 23:59:59"),
[int]$IntervalMinutes = 30
)

$ranges = @()
$current = $Start

while ($current -lt $End) {
$next = $current.AddMinutes($IntervalMinutes)
if ($next -gt $End) { $next = $End }

$ranges += [PSCustomObject]@{
Start = $current.ToString('HH:mm')
End = $next.ToString('HH:mm')
}

$current = $next
}

return $ranges
}

New-TimeRange -IntervalMinutes 60 | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
当前不在维护窗口内
Start End
----- ---
00:00 01:00
01:00 02:00
02:00 03:00
03:00 04:00
04:00 05:00
05:00 06:00
06:00 07:00
07:00 08:00
08:00 09:00
09:00 10:00
10:00 11:00
11:00 12:00
12:00 13:00
13:00 14:00
14:00 15:00
15:00 16:00
16:00 17:00
17:00 18:00
18:00 19:00
19:00 20:00
20:00 21:00
21:00 22:00
22:00 23:00
23:00 23:59

注意事项

  1. DateTime vs DateTimeOffsetDateTime 不包含时区信息,跨时区场景使用 DateTimeOffset
  2. Kind 属性DateTime.Kind 可以是 LocalUtcUnspecified,注意区分
  3. 闰年和月末:使用 .AddMonths(1).AddDays(-1) 计算月末,自动处理不同月份天数
  4. 夏令时:时区转换时注意夏令时影响,TimeZoneInfo 会自动处理
  5. 字符串解析[datetime]::Parse() 使用当前文化设置,服务器脚本中推荐 ParseExact() 明确格式
  6. 精度Get-Date 精度为毫秒级,Stopwatch 可以达到纳秒级精度