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

凌晨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