一、背景:为什么需要自动快照
云硬盘的日常备份如果完全依赖人工操作,容易出现遗漏,也不便于统一查看执行结果。对于已经有华为云 ECS Windows 实例的环境,可以利用实例绑定的委托和 KooCLI 的 ecsAgency 模式,在无需硬编码 AK/SK 的情况下完成 EVS 快照的自动创建。
本文方案的目标很明确:
- 每日自动遍历当前区域的 EVS 云硬盘;
- 对每块磁盘检查已有快照数量;
- 当快照数达到保留上限时,删除最旧的快照;
- 为每块磁盘创建新快照,并支持
in-use在线磁盘; - 将成功、失败、删除数量、新快照 ID 等信息汇总成 HTML 邮件发送。
二、方案组成
整套方案由三部分组成:
- 华为云 ECS 实例:脚本必须运行在已绑定委托的 ECS Windows 实例上;
- KooCLI(hcloud):负责调用 EVS API,查询磁盘、查询快照、删除快照和创建快照;
- Mailozaurr 模块:负责通过 SMTP 发送 HTML 执行报告。
脚本运行后会先执行:
powershell
hcloud configure set --cli-mode=ecsAgency --cli-region=$Region --cli-agree-privacy-statement=true
随后通过 hcloud EVS ListVolumes 获取磁盘列表,再逐块处理快照。
三、部署前准备
1. 安装 KooCLI
下载 Windows 版 KooCLI,解压得到 hcloud.exe,建议放到固定目录并加入系统 Path。验证命令:
powershell
hcloud version
2. 安装 Mailozaurr
在运行脚本的账户下执行:
powershell
Install-Module Mailozaurr -Scope CurrentUser
如果遇到 NuGet 或 PSGallery 问题,可先安装 NuGet 提供程序并将 PSGallery 设为受信任。
3. 为 ECS 绑定委托
进入 IAM 创建委托,委托类型选择"云服务",云服务选择 ECS,并授予 EVS 快照相关权限。最直接的方式是授予 EVS FullAccess;如果追求最小权限,至少应包含快照创建、查询、删除权限。创建完成后,将委托绑定到目标 ECS 实例。
这一步非常关键:如果 ECS 没有绑定委托,ecsAgency 模式配置后实际 API 调用仍会失败。
4. 准备 SMTP 授权码
脚本示例使用 QQ 邮箱 SMTP。注意 ConvertTo-SecureString 中应填写邮箱的 SMTP 授权码,不是 QQ 登录密码。首次部署可以先跑通流程,但不要长期把授权码明文保存在脚本中,后续建议改为从 Windows 凭据管理器或受限环境变量读取。
四、脚本核心逻辑
脚本配置区主要包含:
powershell
$Region = "cn-north-4"
$ProjectId = "你的项目ID"
$FreeQuota = 7
$NamePrefix = "daily-auto"
其中 $FreeQuota 表示每块磁盘保留的快照数量上限。脚本会在创建新快照前检查数量:如果当前快照数达到或超过该值,就先删除最旧的若干快照,再创建新快照。
创建快照时脚本使用:
powershell
--snapshot.force=true
以支持 in-use 状态的在线磁盘,避免因磁盘状态导致创建失败。
每块磁盘处理完成后,脚本会记录原快照数、删除数量、新快照 ID、状态和错误信息,并生成 HTML 邮件报告。
五、首次运行与验证
在脚本目录打开 PowerShell:
powershell
.\EVS-DailySnapshot-WithMail.ps1
如果提示"禁止运行脚本",使用进程级绕过:
powershell
powershell.exe -ExecutionPolicy Bypass -File ".\EVS-DailySnapshot-WithMail.ps1"
正常运行时控制台会显示 hcloud 配置成功、找到的磁盘数量、每块磁盘的快照处理结果以及邮件发送结果。检查收件箱时,如果控制台显示发送成功但未收到邮件,先查看垃圾邮件文件夹,并确认 SMTP 服务状态和授权码是否有效。
六、配置每日定时任务
使用 Windows 任务计划程序创建任务:
- 名称:
EVS Daily Snapshot - 选择"不管用户是否登录都要运行"
- 勾选"使用最高权限运行"
- 触发器:每天固定时间,例如凌晨 02:00
- 操作程序:
powershell.exe - 参数:
text
-NoProfile -ExecutionPolicy Bypass -File "D:\jg_cpz\soft\EVS-DailySnapshot\EVS-DailySnapshot-WithMail.ps1"
"起始于"要填写脚本所在目录:
text
D:\jg_cpz\soft\EVS-DailySnapshot\
保存后右键任务手动运行一次,确认"上次运行结果"为 0x0,并检查邮件是否到达。
如果使用 SYSTEM 账户运行,要注意 Mailozaurr 模块的安装位置可能不同;必要时改用 -Scope AllUsers 安装模块,或指定有模块权限的服务账户。
七、计费注意事项
脚本本身不区分快照计费类型。需要特别关注的是:
- 存量快照处于公测阶段,免费;
- 标准快照为商用快照,根据快照链容量和存储时长收费。
如果账户默认创建的是标准快照,每块磁盘保留 7 个快照会产生持续费用。建议首次运行后到 EVS 控制台确认快照类型,并根据实际成本调整 $FreeQuota。
八、常见故障排查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
找不到 hcloud 命令 |
未加入 Path | 加入 Path 或脚本中使用全路径 |
ecsAgency 配置成功但 API 失败 |
ECS 未绑定委托或权限不足 | 检查实例委托和 EVS 权限 |
| 创建快照报状态错误 | 缺少 --snapshot.force=true |
确认脚本已包含该参数 |
| 邮件发送成功但未收到 | 垃圾邮件拦截或授权码失效 | 检查垃圾箱、白名单和授权码 |
| 定时任务未执行 | 执行策略或"起始于"未填 | 保留 -ExecutionPolicy Bypass 并填写脚本目录 |
频繁生成 debug-json-*.json |
hcloud 返回非 JSON 错误 | 打开调试文件查看原始报错 |
九、总结
这套方案的核心优势是"轻量":不需要额外部署服务器,不需要在脚本中硬编码 AK/SK,依靠 ECS 委托和 hcloud 即可完成 EVS 快照自动化;再通过 Mailozaurr 把每日执行结果发送到邮箱,便于运维人员快速确认成功与失败情况。
上线时建议重点关注三件事:确认 ECS 已绑定具备 EVS 权限的委托、确认 SMTP 授权码有效、确认快照计费类型与保留额度 。先把手动运行跑通,再配置任务计划程序,最后根据成本和保留需求调整 $FreeQuota,就可以稳定实现每日自动快照。
十、 完整代码,保存为文件EVS-DailySnapshot-WithMail.ps1运行(或者下载链接: https://pan.baidu.com/s/1hJ4g_h8JY6RXI8LGatEM1g?pwd=uue6 提取码: uue6 )
PowerShell
# 强制 PowerShell 以 UTF-8 编码捕获和控制台输出
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
<#
.SYNOPSIS
华为云 EVS 云硬盘每日自动快照脚本 (hcloud KooCLI + Mailozaurr 邮件报告)
.DESCRIPTION
1. 从 ECS 元数据服务获取委托临时凭证,并配置到 hcloud 的临时会话中
2. 通过 hcloud 命令遍历云硬盘、清理超额快照、创建新快照
3. 采集每块磁盘的详细操作记录
4. 通过 Mailozaurr 发送 HTML 格式的执行报告邮件
.NOTES
运行环境: 绑定了委托的华为云 ECS Windows 实例
依赖工具: hcloud.exe (华为云 KooCLI)
依赖模块: Mailozaurr (Install-Module Mailozaurr -Scope CurrentUser)
#>
# ================= 配置区 =================
$Region = "cn-north-4"
$ProjectId = "0a49d3a25480f5..."
$FreeQuota = 7
$NamePrefix = "daily-auto"
$MetadataUrl = "http://169.254.169.254/openstack/latest/securitykey"
# --- 邮件配置 (SMTP) ---
$SmtpServer = "smtp.qq.com"
$SmtpPort = 587
$MailFrom = "610152753@qq.com"
$MailTo = "252177507@qq.com"
$MailCredential= New-Object System.Management.Automation.PSCredential(
"610152753@qq.com",
(ConvertTo-SecureString "你的SMTP授权码" -AsPlainText -Force)
)
$UseSsl = $true
# ================= 工具函数 =================
function ConvertFrom-HcloudJson {
<# 安全地将 hcloud 的输出解析为 PowerShell 对象,自动处理乱码、换行和非 JSON 错误 #>
param($RawOutput)
if ($null -eq $RawOutput) { return $null }
if ($RawOutput -is [array]) {
$RawOutput = $RawOutput -join "`n"
}
if ([string]::IsNullOrWhiteSpace($RawOutput)) { return $null }
# 1. 移除 BOM 头和不可见的控制字符
$cleaned = $RawOutput -replace '^\ufeff', ''
$cleaned = $cleaned -replace '[\x00-\x08\x0B\x0C\x0E-\x1F]', ''
$cleaned = $cleaned -replace '\uFFFD', ''
# 2. 将所有实际换行符替换为空格,让 JSON 变成单行
$cleaned = $cleaned -replace "`r`n", " " -replace "`r", " " -replace "`n", " "
$trimmed = $cleaned.Trim()
# 3. 检查内容是否以 JSON 的标志性字符开头
if (-not ($trimmed.StartsWith('{') -or $trimmed.StartsWith('['))) {
throw "hcloud 返回了非预期的输出: $trimmed"
}
try {
return $trimmed | ConvertFrom-Json -ErrorAction Stop
} catch {
# 将清理后的内容保存到文件,便于排查
$debugFile = Join-Path $PSScriptRoot "debug-json-$(Get-Date -Format 'yyyyMMddHHmmss').json"
$cleaned | Out-File -FilePath $debugFile -Encoding utf8
Write-Warning "JSON 解析失败。已保存调试文件: $debugFile"
throw
}
}
function Initialize-HcloudCredential {
<# 配置 hcloud 使用 ecsAgency 模式,并非交互式同意隐私声明 #>
Write-Host " 正在配置 hcloud 使用 ECS 委托模式 (ecsAgency)..." -ForegroundColor Gray
$result = hcloud configure set --cli-mode=ecsAgency --cli-region=$Region --cli-agree-privacy-statement=true 2>&1
if ($LASTEXITCODE -ne 0) {
throw "配置 hcloud ecsAgency 模式失败: $result"
}
Write-Host " hcloud 已配置为 ECS 委托模式" -ForegroundColor Green
}
function Get-AllVolumes {
<# 通过 hcloud 查询所有云硬盘 #>
$output = hcloud EVS ListVolumes --cli-region=$Region --cli-output=json 2>$null
if ($LASTEXITCODE -ne 0) { throw "hcloud ListVolumes 执行失败,请检查权限或网络。" }
$resp = ConvertFrom-HcloudJson -RawOutput $output
if ($null -eq $resp -or $null -eq $resp.volumes) { return @() }
return @($resp.volumes)
}
function Get-SnapshotsForVolume {
<# 通过 hcloud 查询指定云硬盘的快照列表 #>
param([string]$VolumeId)
$output = hcloud EVS ListSnapshots --volume_id=$VolumeId --cli-region=$Region --cli-output=json 2>$null
if ($LASTEXITCODE -ne 0) { throw "hcloud ListSnapshots 执行失败。" }
$resp = ConvertFrom-HcloudJson -RawOutput $output
if ($null -eq $resp -or $null -eq $resp.snapshots) { return @() }
return @($resp.snapshots)
}
function New-VolumeSnapshot {
<# 通过 hcloud 创建快照,参数按 API 要求嵌套在 --snapshot.* 下,并强制支持 in-use 状态 #>
param([string]$VolumeId)
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$snapName = "$NamePrefix-$($VolumeId.Substring(0,8))-$timestamp"
# 核心修正:增加 --snapshot.force=true 以支持 in-use 状态的磁盘
$output = hcloud EVS CreateSnapshot `
--snapshot.volume_id=$VolumeId `
--snapshot.name=$snapName `
--snapshot.description="Auto snapshot at $timestamp" `
--snapshot.force=true `
--cli-region=$Region `
--cli-output=json 2>$null
if ($LASTEXITCODE -ne 0) { throw "hcloud CreateSnapshot 命令执行返回错误码。" }
$resp = ConvertFrom-HcloudJson -RawOutput $output
if ($null -eq $resp -or $null -eq $resp.snapshot) { throw "创建快照返回为空或格式异常。" }
Write-Host " [创建成功] 快照ID: $($resp.snapshot.id), 名称: $($resp.snapshot.name)" -ForegroundColor Green
return $resp.snapshot
}
function Remove-VolumeSnapshot {
<# 通过 hcloud 删除快照 #>
param([string]$SnapshotId)
hcloud EVS DeleteSnapshot --snapshot_id=$SnapshotId --cli-region=$Region --cli-output=json 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "hcloud DeleteSnapshot 执行失败。" }
Write-Host " [删除成功] 快照ID: $SnapshotId" -ForegroundColor Yellow
}
function Sort-SnapshotsByTime {
param([array]$Snapshots)
return $Snapshots | Sort-Object { [datetime]$_.created_at }
}
function Send-SnapshotReportEmail {
param(
[string]$HtmlBody, [string]$Subject,
[string]$From, [string]$To,
[string]$SmtpServer, [int]$SmtpPort,
[PSCredential]$Credential = $null,
[bool]$UseSsl = $true
)
try {
$params = @{
From = $From; To = $To; Subject = $Subject; HtmlBody = $HtmlBody
SmtpServer = $SmtpServer; Port = $SmtpPort; UseSsl = $UseSsl
ErrorAction = "Stop"
}
if ($Credential) { $params.Credential = $Credential }
Send-EmailMessage @params
Write-Host " 邮件发送成功: $To" -ForegroundColor Green
} catch {
Write-Warning "邮件发送失败: $_"
}
}
# ================= 主流程 =================
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 华为云 EVS 自动快照脚本启动 (hcloud 版)" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# 1. 配置 hcloud 认证模式
Write-Host "`n[1/3] 正在配置 hcloud 认证模式..." -ForegroundColor Cyan
try {
Initialize-HcloudCredential
} catch {
Write-Host " 配置失败,脚本终止。" -ForegroundColor Red
exit 1
}
# 2. 查询所有云硬盘
Write-Host "`n[2/3] 正在通过 hcloud 查询云硬盘列表..." -ForegroundColor Cyan
try {
$volumes = Get-AllVolumes
Write-Host " 共找到 $($volumes.Count) 块云硬盘。" -ForegroundColor Green
} catch {
Write-Host " 查询云硬盘失败: $_" -ForegroundColor Red
exit 1
}
# 3. 逐盘处理
Write-Host "`n[3/3] 开始处理每块云硬盘的快照..." -ForegroundColor Cyan
$logEntries = [System.Collections.Generic.List[hashtable]]::new()
$successCount = 0
$failCount = 0
$startTime = Get-Date
foreach ($vol in $volumes) {
$volId = $vol.id
$volName = if ($vol.name) { $vol.name } else { "(未命名)" }
Write-Host "`n--- 处理磁盘: $volId ($volName) ---" -ForegroundColor White
$entry = @{
VolumeId = $volId; VolumeName = $volName; Status = "成功"
SnapshotCount = 0; DeletedCount = 0; NewSnapshotId = ""; ErrorMessage = ""
}
try {
$snapshots = Get-SnapshotsForVolume -VolumeId $volId
$entry.SnapshotCount = $snapshots.Count
Write-Host " 当前快照数量: $($snapshots.Count)" -ForegroundColor Gray
if ($snapshots.Count -ge $FreeQuota) {
$sorted = Sort-SnapshotsByTime -Snapshots $snapshots
$deleteNum = $sorted.Count - $FreeQuota + 1
Write-Host " 快照数达到/超过 $FreeQuota,将删除 $deleteNum 个最旧快照。" -ForegroundColor Yellow
for ($i = 0; $i -lt $deleteNum; $i++) {
Remove-VolumeSnapshot -SnapshotId $sorted[$i].id
$entry.DeletedCount++
}
} else {
Write-Host " 快照数未达 $FreeQuota,无需删除。" -ForegroundColor Gray
}
$newSnap = New-VolumeSnapshot -VolumeId $volId
$entry.NewSnapshotId = $newSnap.id
$successCount++
} catch {
$entry.Status = "失败"
$entry.ErrorMessage = "$_"
$failCount++
Write-Host " 处理磁盘 $volId 时出错: $_" -ForegroundColor Red
}
$logEntries.Add($entry)
}
$endTime = Get-Date
$duration = ($endTime - $startTime).ToString("hh\:mm\:ss")
# ================= 构建邮件 HTML 报告 =================
Write-Host "`n正在构建邮件报告..." -ForegroundColor Cyan
$now = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$html = @"
<html>
<head>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; }
h2 { color: #1a5276; }
.summary { background: #eaf2f8; padding: 15px; border-radius: 6px; margin-bottom: 20px; }
.summary span { margin-right: 30px; font-size: 15px; }
.success { color: #1e8449; font-weight: bold; }
.fail { color: #c0392b; font-weight: bold; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; }
th { background: #1a5276; color: #fff; padding: 10px; text-align: left; }
td { border: 1px solid #ddd; padding: 8px; font-size: 13px; word-break: break-all; }
tr:nth-child(even) { background: #f9f9f9; }
.err { color: #c0392b; }
.footer { margin-top: 25px; color: #888; font-size: 12px; }
</style>
</head>
<body>
<h2>📸 华为云 EVS 自动快照执行报告 (hcloud 版)</h2>
<p>执行时间:<strong>$now</strong> | 总耗时:<strong>$duration</strong></p>
<div class="summary">
<span>云硬盘总数:<strong>$($volumes.Count)</strong></span>
<span>成功:<strong class="success">$successCount</strong></span>
<span>失败:<strong class="fail">$failCount</strong></span>
<span>免费额度:<strong>$FreeQuota</strong> / 盘</span>
</div>
<table>
<tr>
<th>#</th><th>云硬盘 ID</th><th>名称</th><th>原快照数</th>
<th>删除旧快照</th><th>新快照 ID</th><th>状态</th><th>错误信息</th>
</tr>
"@
$index = 0
foreach ($e in $logEntries) {
$index++
$statusHtml = if ($e.Status -eq "成功") { '<span class="success">成功</span>' } else { '<span class="err">失败</span>' }
$errHtml = if ($e.ErrorMessage) { "<span class='err'>$($e.ErrorMessage)</span>" } else { "-" }
$html += @"
<tr>
<td>$index</td><td>$($e.VolumeId)</td><td>$($e.VolumeName)</td>
<td>$($e.SnapshotCount)</td><td>$($e.DeletedCount)</td>
<td>$($e.NewSnapshotId)</td><td>$statusHtml</td><td>$errHtml</td>
</tr>
"@
}
$html += @"
</table>
<p class="footer">
本邮件由自动化脚本生成,请勿直接回复。<br>
脚本运行于 ECS 实例(hcloud 委托认证),区域:$Region
</p>
</body>
</html>
"@
# ================= 发送邮件 =================
Write-Host "`n正在发送邮件报告..." -ForegroundColor Cyan
Import-Module Mailozaurr -ErrorAction Stop
$subject = "【快照报告】$now - 成功 $successCount / 失败 $failCount"
Send-SnapshotReportEmail `
-HtmlBody $html -Subject $subject `
-From $MailFrom -To $MailTo `
-SmtpServer $SmtpServer -SmtpPort $SmtpPort `
-Credential $MailCredential -UseSsl $UseSsl
# ================= 执行摘要 =================
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " 执行完成" -ForegroundColor Cyan
Write-Host " 成功: $successCount 块 | 失败: $failCount 块" -ForegroundColor Green
Write-Host " 总耗时: $duration" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan