上一篇 下一篇 分享链接 返回 返回顶部

香港服务器运行 Windows Server 时,如何用 PowerShell + WMI 把补丁管理“攥在自己手里”

发布人:Minchunlin 发布时间:2025-08-29 09:28 阅读量:739


凌晨02:10,香港机房业务部门只给了我 120 分钟的维护窗,凌晨 4 点必须恢复。我背包里塞着装着 Type-C 转网口的转接头、一个 U 盘、和前一晚写到 1:30 的补丁自动化脚本。电梯门“叮”的一声,今晚要给 16 台 Windows Server 做安全补丁,而且要做到可回滚、可追溯、能分批。出问题我得第一时间复盘给 CTO。

我知道,这事光靠 GUI 点点点是顶不住的。我选了 **PowerShell + WMI/COM(Windows Update Agent)**这条路,远程编排、分环渐进、严格日志、可回滚。这篇就是我那晚的完整实操与复盘。

一、现场环境与目标

1) 基线硬件与系统(部分样本)

机柜 型号 CPU 内存 系统盘 数据盘 网卡 OS 版本 角色
R12 Dell R650xs 1U Xeon Silver 4310 × 2 128GB ECC 2 × 960GB NVMe (RAID1) 4 × 1.92TB SATA SSD (RAID10) 2 × 10GbE + 2 × 1GbE Windows Server 2019 Datacenter (Core) Web 前端集群
R13 HPE DL360 Gen10 Xeon Gold 6230R 192GB 2 × 480GB SAS 6 × 1.8TB SAS 4 × 10GbE Windows Server 2022 Standard 应用/中间件
R14 VMware vSphere 7.0U3(承载多台 Win VM) —— —— vSAN vSAN 25GbE 来宾:2016/2019/2022 混部 SQL/Job/工具
  • 网络与位置:机房在香港,出口带宽 2×10Gbps,业务网与管理网分离(Mgmt VLAN)。
  • 维护窗:HKT 02:00–04:00(每月第二周)。
  • 补丁策略:安全补丁优先,分环(Ring0/1/2),可回滚,强制日志,必要时自动重启。

2) 约束与目标

  • 不依赖第三方代理:只用 PowerShell、WMI/COM(Windows Update Agent)、计划任务。
  • 既支持直连 Microsoft Update,也支持 WSUS(部分服务器走内网 WSUS)。
  • 远程编排,并行/限流、按环推进。
  • 全量记录:扫描结果、安装结果、重启原因、回滚指令。
  • 失败可复盘:错误码与修复建议绑定。

二、架构设计(简单但能打)

[Orchestrator 节点]  —— WinRM(HTTPS)/CIM ——>  [目标服务器群]
      |                                      (Server 2016/2019/2022)
  PowerShell 5.1
  - Ring 控制
  - 并发节流
  - 日志/审计
  - 计划任务/窗口控制
  • Orchestrator:我用一台管理跳板(Windows Server 2019),开启 WinRM over HTTPS,使用 AD 的补丁服务账号(最小权限)。
  • 通信:优先 Kerberos(域内),跨域或特殊段走 TrustedHosts + HTTPS。
  • 更新源:默认直连 Microsoft Update;WSUS 机房存在时,按服务器标签切换到 WSUS。

分环:

  • Ring0:10% 金丝雀(低风险节点/双活节点)
  • Ring1:30% 业务边缘/可降级节点
  • Ring2:60% 核心节点(最后推)

三、准备与基线脚本

1) 目标服务器前置校验(一次性)

# 在目标机或用 GPO 统一:
Enable-PSRemoting -Force

# WinRM 监听 HTTPS(若无 AD PKI,可用自签,也能跑)
$cert = New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\Localhost\Listener\Listener_1 -Force | Out-Null
Set-Item -Path WSMan:\Localhost\Service\CertificateThumbprint -Value $cert.Thumbprint

# 放开防火墙规则
Enable-NetFirewallRule -DisplayGroup "Windows Remote Management"
Enable-NetFirewallRule -DisplayGroup "Windows Management Instrumentation (WMI)"

# 执行策略与 TLS 强化
Set-ExecutionPolicy RemoteSigned -Force
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319" -Name "SchUseStrongCrypto" -Value 1 -PropertyType DWord -Force
New-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319" -Name "SchUseStrongCrypto" -Value 1 -PropertyType DWord -Force

2) Orchestrator 侧的服务器清单(CSV)

Hostname,IP,Role,Ring,UpdateSource,WSUSUrl,WindowStart,WindowEnd
web-01,10.10.1.11,web,Ring0,MSU,,02:00,04:00
web-02,10.10.1.12,web,Ring0,MSU,,02:00,04:00
app-01,10.10.2.21,app,Ring1,WSUS,http://wsus.local:8530,02:30,04:00
db-01,10.10.3.31,db,Ring2,WSUS,http://wsus.local:8530,03:00,04:00

四、核心函数库(PowerShell + WMI/COM)

说明:查询已装补丁主要靠 Win32_QuickFixEngineering(WMI)或 Get-HotFix;搜索/下载/安装用 WUA COM:Microsoft.Update.Session、IUpdateSearcher、IUpdateInstaller。这种方式不依赖外部模块,适用于受限环境。

1) 基线状态与“是否待重启”检测

function Test-PendingReboot {
    $paths = @(
      "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired",
      "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations",
      "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"
    )
    foreach ($p in $paths) { if (Test-Path $p) { return $true } }
    return $false
}

function Get-HostBaseline {
    $os = Get-CimInstance Win32_OperatingSystem
    $hotfix = Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 5
    [pscustomobject]@{
        ComputerName = $env:COMPUTERNAME
        Caption      = $os.Caption
        Version      = $os.Version
        Build        = $os.BuildNumber
        InstallDate  = $os.InstallDate
        PendingReboot= (Test-PendingReboot)
        Last5Hotfix  = ($hotfix | ForEach-Object {$_.HotFixID}) -join ','
    }
}

2) 切换更新源(Microsoft Update ↔ WSUS)

function Set-UpdateSource {
    param(
      [ValidateSet("MSU","WSUS")]$Mode,
      [string]$WSUSUrl
    )
    $AU = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
    $WU = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
    if ($Mode -eq "WSUS") {
        New-Item $WU -Force | Out-Null
        New-Item $AU -Force | Out-Null
        New-ItemProperty $WU -Name WUServer -Value $WSUSUrl -PropertyType String -Force | Out-Null
        New-ItemProperty $WU -Name WUStatusServer -Value $WSUSUrl -PropertyType String -Force | Out-Null
        New-ItemProperty $AU -Name UseWUServer -Value 1 -PropertyType DWord -Force | Out-Null
    } else {
        Remove-Item -Path $WU -Recurse -Force -ErrorAction SilentlyContinue
    }
    # 触发重新检测
    try { UsoClient.exe StartScan } catch { wuauclt /detectnow | Out-Null }
}

3) 搜索可用补丁(按分类过滤)

function Search-AvailableUpdates {
    param(
      [switch]$SecurityOnly,
      [switch]$ExcludeDrivers
    )
    $session = New-Object -ComObject Microsoft.Update.Session
    $searcher = $session.CreateUpdateSearcher()
    $criteria = "IsInstalled=0 and IsHidden=0"
    if ($ExcludeDrivers) { $criteria += " and Type='Software'" }
    $result = $searcher.Search($criteria)

    $updates = @()
    for ($i=0; $i -lt $result.Updates.Count; $i++) {
        $u = $result.Updates.Item($i)
        # 分类过滤(示例:只要安全/累积更新)
        $isSecurity = ($u.Categories | Where-Object {$_.Name -match "Security|安全"})
        if ($SecurityOnly -and -not $isSecurity) { continue }
        $updates += [pscustomobject]@{
            Title = $u.Title
            KB    = ($u KBArticleIDs) -join ','
            IsDownloaded = $u.IsDownloaded
            EulaAccepted = $u.EulaAccepted
            RebootRequired = $u.RebootRequired
            MaxDownloadSizeMB = [math]::Round($u.MaxDownloadSize/1MB,2)
            UpdateId = $u.Identity.UpdateID
            Revision = $u.Identity.RevisionNumber
        }
    }
    return @{ Raw = $result.Updates; View = $updates }
}

4) 下载与安装(带进度与错误码)

function Install-Updates {
    param(
      $UpdatesRaw,       # 来自 Search-AvailableUpdates().Raw
      [switch]$DownloadOnly,
      [string]$LogDir = "C:\ProgramData\PatchOrchestrator\Logs"
    )
    New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
    $session = New-Object -ComObject Microsoft.Update.Session
    $downloader = $session.CreateUpdateDownloader()
    $installer  = $session.CreateUpdateInstaller()

    # 组装更新集合
    $collection = New-Object -ComObject Microsoft.Update.UpdateColl
    for ($i=0; $i -lt $UpdatesRaw.Count; $i++) {
        $u = $UpdatesRaw.Item($i)
        if (-not $u.EulaAccepted) { $u.AcceptEula() | Out-Null }
        $collection.Add($u) | Out-Null
    }

    # 下载
    $downloader.Updates = $collection
    $dres = $downloader.Download()
    $downloadSucceeded = ($dres.ResultCode -eq 2) # 2 = Succeeded

    if ($DownloadOnly) { 
        return [pscustomobject]@{Stage="Download"; Result=$dres.ResultCode; HResult=$dres.HResult}
    }

    # 安装
    $installer.Updates = $collection
    $ires = $installer.Install()

    $log = [pscustomobject]@{
        ComputerName = $env:COMPUTERNAME
        Time         = (Get-Date).ToString("s")
        DownloadResult = $dres.ResultCode
        InstallResult  = $ires.ResultCode
        HResult        = ('0x{0:X8}' -f $ires.HResult)
        RebootRequired = $ires.RebootRequired
        Succeeded      = ($ires.ResultCode -eq 2)
    }
    $log | Export-Csv -Path (Join-Path $LogDir "install-$(Get-Date -f yyyyMMdd-HHmmss).csv") -NoTypeInformation -Append
    return $log
}

5) 回滚(卸载指定 KB)

function Uninstall-KB {
    param([Parameter(Mandatory)][string]$KB)
    # 尝试 WUSA
    $p = Start-Process -FilePath "wusa.exe" -ArgumentList "/uninstall /kb:$KB /quiet /norestart" -PassThru -Wait
    if ($p.ExitCode -eq 0) { return $true }
    # 对于累积包/SSU,必要时走 DISM(注意:名称要与包名匹配)
    # DISM /Online /Get-Packages | findstr KB
    return $false
}

五、编排主脚本(分环、并发、时间窗、日志)

理念:我不追求“最酷”的框架,只要稳定、可读、能救火。下面这段 orchestrator 脚本,能把上面函数拼成一个可用的“半管平台”。

# Orchestrator.ps1
param(
  [string]$InventoryCsv = ".\servers.csv",
  [switch]$SecurityOnly,
  [int]$MaxParallel = 4
)

$inventory = Import-Csv $InventoryCsv
$groups = $inventory | Group-Object Ring | Sort-Object Name

function Invoke-UpdateOnNode {
  param($node, [switch]$SecurityOnly)
  $sessionOption = New-CimSessionOption -Protocol Dcom
  $cim = New-CimSession -ComputerName $node.Hostname -SessionOption $sessionOption -ErrorAction Stop

  try {
    Invoke-Command -ComputerName $node.Hostname -ScriptBlock {
      param($UpdateSource, $WSUSUrl, $SecurityOnly)

      # 切换更新源
      Set-UpdateSource -Mode $UpdateSource -WSUSUrl $WSUSUrl

      # 基线与预检
      $base = Get-HostBaseline
      if ($base.PendingReboot) { Write-Host "Pending reboot, rebooting first..."; shutdown /r /t 5 /c "PrePatchReboot"; exit 100 }

      # 搜索可用更新
      $s = Search-AvailableUpdates -ExcludeDrivers -SecurityOnly:$SecurityOnly
      $view = $s.View
      if ($view.Count -eq 0) {
        [pscustomobject]@{Computer=$env:COMPUTERNAME; Stage="Scan"; Info="No updates"} | Out-Host
        return
      }

      # 下载并安装
      $install = Install-Updates -UpdatesRaw $s.Raw
      if ($install.RebootRequired -or -not $install.Succeeded) {
          shutdown /r /t 30 /c "PatchInstalled:Reboot"
      }
      $install
    } -ArgumentList $node.UpdateSource, $node.WSUSUrl, $SecurityOnly
  }
  catch {
    [pscustomobject]@{Computer=$node.Hostname; Error=$_.Exception.Message}
  }
  finally {
    if ($cim) { $cim | Remove-CimSession }
  }
}

foreach ($g in $groups) {
  Write-Host "=== Start Ring: $($g.Name) ===" -ForegroundColor Cyan
  $jobs = @()
  foreach ($n in $g.Group) {
    while (@(Get-Job | Where-Object { $_.State -eq 'Running' }).Count -ge $MaxParallel) {
      Start-Sleep -Seconds 5
    }
    $jobs += Start-Job -ScriptBlock ${function:Invoke-UpdateOnNode} -ArgumentList $n, $SecurityOnly
  }
  Receive-Job -Job $jobs -Wait -AutoRemoveJob | Tee-Object -FilePath ".\orchestrator-$(Get-Date -f yyyyMMdd-HHmm).log"
  Write-Host "=== Finish Ring: $($g.Name) ===" -ForegroundColor Green
}

你会注意到,我用 Dcom CIMSession 是因为某些隔离段对 WinRM/HTTP(S) 有策略,WMI/DCOM 在内网能打通,这就是现场权衡。

六、运行前的“Checklist”(血泪经验)

检查项 方法
快照/备份 虚机快照;物理机镜像 vSphere Snapshot / Windows Server Backup
空间 C:\ 预留 8–10GB Get-PSDrive C
时间 NTP 正确、TLS 不报错 w32tm /query /status
重启窗口 能否影响旁路/双活 流量切换 / NLB 暂停
脚本签名 Orchestrator 脚本签名 代码签名证书 + Set-AuthenticodeSignature
账户 最小权限服务账号 本地管理员 + 远程权限即可
WSUS 分类、批准策略 WSUS 控台或 GPO

七、一次真实“Ring0→Ring2”的流程(节选日志)

Ring0(web-01 / web-02):

  • 02:12 扫描出 5 个安全补丁,累计下载 ~ 380MB,安装返回 ResultCode=2、RebootRequired=True。
  • 02:18 自动重启(30 秒倒计时),02:22 服务恢复,NLB 自动回入。

Ring1(app-01...):

  • 02:30 WSUS 模式扫描,有 1 台报错 0x8024401C(WSUS 超时)→ 改回 MSU 模式复试成功。
  • 02:46 重启后应用自检 OK。

Ring2(db-01):

  • 03:05 扫描 2 个累积更新,下载 650MB,安装成功但未重启(DB 作业队列未清空)。
  • 03:20 我手工 shutdown /r /t 60 /c "DB maintenance",03:28 起库,通过 AlwaysOn 备库承压。
  • 03:35 接口延迟恢复至基线。
  • 总耗时:约 83 分钟,满足窗口。

八、表格:补丁结果汇总(示例)

主机 更新源 安装数 失败数 需重启 重启时刻 错误码
web-01 Ring0 MSU 5 0 02:18
web-02 Ring0 MSU 5 0 02:19
app-01 Ring1 WSUS→MSU 4 0 02:47
db-01 Ring2 WSUS 2 0 (手工)03:20

九、常见“坑”与我当场怎么救

WSUS 相关错误

0x8024401C / 0x8024401F:网络/超时。解法:临时切 MSU 模式;或 WSUS 开启 BITS 限制后适度放宽;必要时把该环延后。

证书/代理导致 0x80072F8F:TLS/时间问题。解法:统一 UTC/NTP,启用 .NET SchUseStrongCrypto(上文已有)。

安装报 0x800f0922(分区不足/保留分区问题)

解法:提前检查 C:\ 空间;遇到保留分区过小(< 500MB)先扩容再推补丁(现场曾用 PE + 磁盘工具)。

WMI Repository 损坏(连 Get-HotFix 都抽风)

net stop winmgmt
winmgmt /salvagerepository
winmgmt /resetrepository

重建后重启 WMI 服务,重新扫描。

WinRM “双跳问题”(拉日志到第三处)

解法:用 Kerberos 限定委派或临时启用 CredSSP;我更偏向把日志落到目标机,再异步拉回,避免双跳。

B 机重启卡 Pending

解法:Test-PendingReboot 先清掉,或者维护窗开始就先做一次预重启,把历史 pending 一次性消化。

十、计划任务与“第二个星期二”自动化

Patch Tuesday 通常是每月第二个星期二。我更习惯把实际落地放在香港时间的周三凌晨(第二个星期三 02:10),留出一整天观察社区回报。

function Get-SecondWednesday {
    param([datetime]$Base=(Get-Date))
    $first = Get-Date -Year $Base.Year -Month $Base.Month -Day 1
    $wednesdays = 0..30 | ForEach-Object { $d = $first.AddDays($_); if ($d.DayOfWeek -eq 'Wednesday') { $d } }
    return ($wednesdays[1]).Date.AddHours(2).AddMinutes(10)  # 02:10
}

$trigger = New-ScheduledTaskTrigger -Once -At (Get-SecondWednesday) -RepetitionInterval (New-TimeSpan -Days 28)
$action  = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-File C:\ops\Orchestrator.ps1 -InventoryCsv C:\ops\servers.csv -SecurityOnly -MaxParallel 4"
Register-ScheduledTask -TaskName "MonthlyPatch-HK" -Trigger $trigger -Action $action -RunLevel Highest -Description "HK Windows Patch Orchestrator"

十一、安全与合规细节(不能少)

  • 脚本签名:给 Orchestrator.ps1 与函数库签名,AllSigned 或 RemoteSigned。
  • 最小权限:服务账号仅本地管理员 + 远程登录,避免域管理员滥权。
  • 审计:每次安装生成 CSV 日志(含 HResult、ResultCode、UpdateId);中央汇总后丢到 SIEM。
  • 节流:$MaxParallel 控制并发,避免同时重启导致集群抖动。
  • 生产切换:对无状态前端,引流出池再打补丁;对数据库,AlwaysOn/镜像或备库承压。

十二、完整“上/下线”Runbook(摘要)

T-1 天:拉最新脚本、测签名、检查 WSUS 批准列表。

T-1 小时:快照/备份完成;确认回退窗口。

T-10 分钟:Get-HostBaseline 全量跑一遍,发现 Pending 先重启。

T+0:推 Ring0;5–10 分钟观察。

T+20:推 Ring1;监控业务指标。

T+60:推 Ring2;必要时手工协调重启。

T+80:汇总日志;抽查 Get-HotFix 与 DISM /Online /Get-Packages。

T+翌日:回顾错误码与失败主机,是否需要 Uninstall-KB 回滚。

十三、FAQ:为什么我没用 PSWindowsUpdate 模块?

在“干净/受限”的金融网络里,模块外网拉取是政策风险。WUA COM + WMI 是系统自带能力,覆盖扫描/下载/安装/重启,足够稳定。如果你环境允许外部模块,PSWindowsUpdate 会更省代码,但我这次更看重“零外依赖”。

十四、收尾:凌晨四点的机房门口

03:43,我把最后一行 CSV 推到汇总共享盘,网关图上的延迟回到了绿区。
走出机房时,清洁阿姨推着小车从我身边经过,她问:“又加班?”
我笑了笑——“嗯,补丁夜。”

说实话,我们做补丁这件事,很少有掌声。可每一次在窗口内把一堆看不见的风险稳稳地压下去,就是对全公司、对用户最好的交代。

以上脚本和方法,是我在香港机房一次次“热手感”里打磨出来的。你可以按需改动:把并发调大,换成你的 WSUS,或者挂到 Jenkins 夜班里。只要记住三件事:分环、可回滚、全记录。

下次风起的时候,我们照样顶得住。

附:实用命令/片段速查

只扫描不安装:Install-Updates -DownloadOnly

列最新 5 条补丁:Get-HotFix | Sort InstalledOn -Desc | Select -First 5

检查 CBS 包:DISM /Online /Get-Packages /Format:Table

强制触发扫描:UsoClient StartScan(或 wuauclt /detectnow)

卸载 KB:wusa /uninstall /kb:XXXXXX /quiet /norestart

WMI 直查:Get-CimInstance Win32_QuickFixEngineering

目录结构
全文