从零搭建华为云 EVS 每日自动快照:hcloud + PowerShell + 邮件报告实战

一、背景:为什么需要自动快照

云硬盘的日常备份如果完全依赖人工操作,容易出现遗漏,也不便于统一查看执行结果。对于已经有华为云 ECS Windows 实例的环境,可以利用实例绑定的委托和 KooCLI 的 ecsAgency 模式,在无需硬编码 AK/SK 的情况下完成 EVS 快照的自动创建。

本文方案的目标很明确:

  1. 每日自动遍历当前区域的 EVS 云硬盘;
  2. 对每块磁盘检查已有快照数量;
  3. 当快照数达到保留上限时,删除最旧的快照;
  4. 为每块磁盘创建新快照,并支持 in-use 在线磁盘;
  5. 将成功、失败、删除数量、新快照 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
相关推荐
Helix2506 小时前
Intune 可观测性与 AI 排障实战:Copilot、高级分析与自动化运维指南
copilot·自动化运维·terminal·intune 可观测性·设备时间线·intelligent·ai排障
Linux界最忧郁之人16 小时前
OpenStack Victoria双节点实战|Packstack一键部署(CentOS‑Stream‑8)+华为云ECS实操
linux·运维·服务器·centos·华为云·openstack
MoRay1 天前
大专大一,手搓 AIOps 底座:当大模型幻觉时,我的系统在 98.9ms 抓住了宕机的 Redis
自动化运维
Lvan的前端笔记2 天前
华为云 obsutil 使用指南
华为云
JoePotter3 天前
Restic 备份工具使用指南(Windows Server 2019 适配版)
powershell·数据备份·windows server·运维自动化·restic
梯度下降者4 天前
二、自动化运维架构师的三大核心能力
ci/cd·架构师·自动化运维·iac·平台工程
迪康软件zz4 天前
从 PHP 高危漏洞看纵深防御:资产盘点、补丁管理与端点防护实践
运维·安全·自动化运维
智能运维指南4 天前
企业 IT 管理软件如何选型?先理清监控、服务管理、配置与自动化能力
运维·自动化·自动化运维·嘉为蓝鲸
垚垚学技术_聚焦云原生5 天前
Ansible 变量体系详解:主机变量、组变量、全局变量优先级实战
ansible·自动化运维·devops·变量优先级