Windows service 调用 msiexec 无法完成自更新
结论
Windows service 以 LocalSystem 身份启动 msiexec.exe,权限上没有问题。问题在于,服务不能等待 msiexec 结束,同时又允许 MSI 停止并替换自己。
典型的失败过程如下:
- 服务启动
msiexec.exe,然后调用WaitForExit()。 - MSI 准备更新服务文件,通过 Service Control Manager 请求停止服务。
- 服务还在等待安装进程退出,无法及时停止。
- MSI 也在等待服务退出,双方相互等待,最终安装失败并返回
1603。
更稳妥的方案是交给独立更新程序处理。更新程序应放在本次 MSI 不会覆盖的位置,由它停止服务、运行 MSI、检查退出码,并按需重新启动服务。
MainEngineThread is returning 1603 只表示安装过程中发生了致命错误,无法说明具体原因。要定位问题,需要查看详细日志中 Return value 3 附近的内容。
先生成完整安装日志
每次更新都应保存详细日志,不能只使用 /qn:
msiexec.exe /i "C:\ProgramData\Vendor\Product\Updates\Product.msi" /qn /norestart /L*v "C:\ProgramData\Vendor\Product\Logs\update.log"
检查日志时,可以从 Return value 3 开始向上查找首次出现的实际错误。常见原因有:
- MSI 等待服务停止,而服务正在等待 MSI。
- 服务进程或其他进程仍占用待替换的文件。
- MSI 中负责停止服务的
ServiceControl配置有误。 - 自定义操作执行失败。
- 新旧 MSI 的升级关系配置错误。
- 安装源文件无法访问,或临时目录的 ACL 配置不正确。
- 系统中已有另一项 MSI 安装正在运行。
- 安装需要重新启动,但静默安装流程没有处理对应的退出码。
日志中可能有多个 “error”,其中很多并不是根因。应先看失败的操作,再结合它前面的日志内容判断。
“新版本”并不代表可以直接升级
Windows Installer 不会根据文件内容自行判断版本关系,MSI 必须明确、正确地声明如何升级。
采用 Major Upgrade 时,通常需要满足这些条件:
- 新旧安装包使用相同的
UpgradeCode。 - 新版本使用新的
ProductCode。 - 每次重新构建正式发布包时使用新的
PackageCode。 ProductVersion高于当前已安装版本。- MSI 包含正确的升级检测和
RemoveExistingProducts排序。 - 组件 GUID、安装目录和组件内容符合 Windows Installer 的组件规则。
对于服务程序,正确配置 Major Upgrade,一般比自行编写文件覆盖逻辑更容易维护。具体配置取决于所用的 MSI 制作工具,如 WiX、InstallShield 或 Advanced Installer。
如果制作的是 Minor Upgrade,命令行和 MSI 数据库还必须符合相应规则。把新 MSI 继续交给 /i,并不能保证 Windows Installer 会完成所需的重新安装。没有明确的 Minor Upgrade 需求时,建议使用配置正确的 Major Upgrade。
正确配置服务安装项
MSI 应使用 ServiceInstall 和 ServiceControl 管理服务,不要依赖自定义脚本直接复制可执行文件。升级过程至少需要做到:
- 替换服务文件前先停止服务。
- 等待服务进入
SERVICE_STOPPED状态。 - 更新文件及服务注册信息。
- 安装成功后启动服务,或交给外部更新程序启动。
- 安装回滚时恢复原有状态。
如果服务的 OnStop 执行时间较长,应尽量减少清理工作,并向 Service Control Manager 正确报告状态。停止回调本身不能等待 MSI 或更新程序完成。
还要确认是否有其他进程加载了服务目录中的 DLL。服务即使已经停止,其他进程占用文件仍可能造成替换失败,或使安装要求重新启动系统。
推荐的更新流程
更新工作可以分给服务和独立更新程序。
1. 服务负责下载和验证
服务负责:
- 检查新版本。
- 将 MSI 下载到受保护的暂存目录。
- 验证 MSI 的数字签名、发布者和预期版本。
- 启动独立更新程序。
- 启动后直接返回,不等待更新程序结束。
暂存目录可以使用类似下面的位置:
C:\ProgramData\Vendor\Product\Updates
该目录的 ACL 应禁止普通用户写入。否则,攻击者可能替换其中的 MSI,再借助 LocalSystem 权限执行任意安装代码。
2. 独立更新程序负责停止服务
更新程序应放在安装目录之外,也可以先复制到不会被 MSI 替换的临时目录。它负责:
- 等待原服务完成当前操作。
- 请求停止服务。
- 等待服务完全停止。
- 启动
msiexec.exe。 - 等待安装结束并读取退出码。
- 根据退出码启动服务、记录失败状态或安排重启。
服务启动更新程序后,不要调用 WaitForExit():
using System.Diagnostics;
var updaterPath =
@"C:\ProgramData\Vendor\Product\Updater\ProductUpdater.exe";
var msiPath =
@"C:\ProgramData\Vendor\Product\Updates\Product.msi";
var startInfo = new ProcessStartInfo
{
FileName = updaterPath,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("--service");
startInfo.ArgumentList.Add("ProductService");
startInfo.ArgumentList.Add("--package");
startInfo.ArgumentList.Add(msiPath);
Process.Start(startInfo);
// 不要在服务中调用 WaitForExit()。
// 后续的停止服务、安装和重启由 ProductUpdater.exe 完成。
ProcessStartInfo.ArgumentList 能避免手工处理带空格的参数。如果目标 .NET 版本不支持该属性,则应通过 Arguments 传参,并严格处理转义。
更新程序示例
下面的 PowerShell 示例包含外部更新程序的基本流程。生产环境中可以改为签名的独立 EXE,并加入日志、超时和回滚处理。
param(
[Parameter(Mandatory = $true)]
[string]$ServiceName,
[Parameter(Mandatory = $true)]
[string]$PackagePath
)
$ErrorActionPreference = "Stop"
$logDirectory = "C:\ProgramData\Vendor\Product\Logs"
$logPath = Join-Path $logDirectory "update.log"
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) {
throw "MSI package not found: $PackagePath"
}
$service = Get-Service -Name $ServiceName
if ($service.Status -ne "Stopped") {
Stop-Service -Name $ServiceName
$service.WaitForStatus(
[System.ServiceProcess.ServiceControllerStatus]::Stopped,
[TimeSpan]::FromSeconds(60)
)
}
$arguments = @(
"/i"
"`"$PackagePath`""
"/qn"
"/norestart"
"/L*v"
"`"$logPath`""
)
$process = Start-Process `
-FilePath "$env:SystemRoot\System32\msiexec.exe" `
-ArgumentList $arguments `
-Wait `
-PassThru
switch ($process.ExitCode) {
0 {
# 如果 MSI 已通过 ServiceControl 启动服务,这里无需重复启动。
if ((Get-Service -Name $ServiceName).Status -ne "Running") {
Start-Service -Name $ServiceName
}
}
3010 {
# 安装成功,但需要重启系统。
# 记录状态,由既定的重启策略处理,不要把它当成安装失败。
}
default {
throw "msiexec failed with exit code $($process.ExitCode). See $logPath"
}
}
如果 MSI 已经负责停止和启动服务,更新程序只需启动 MSI,无需再操作服务。不要让两套逻辑同时无条件执行,以免重复启动服务或引发状态竞争。
需要处理的退出码
更新流程至少要分别处理以下退出码:
0:安装成功。3010:安装成功,但需要重新启动系统。1603:安装过程中发生致命错误,需要结合详细日志定位。1618:已有另一项 Windows Installer 安装正在进行。
不能把所有非零退出码都视为同一种失败。特别是 3010,它通常表示安装已成功,只是系统还需要重启。
服务启动的第一个 msiexec.exe 进程也不一定会承担整个安装过程。Windows Installer 可能把工作转交给 Windows Installer service。更新程序应以最终的进程退出码和 MSI 日志为准,不能仅通过某个进程是否存在来判断更新是否结束。
不建议使用的方式
下面的写法容易造成死锁或留下未完成的更新:
var process = Process.Start("msiexec.exe", arguments);
process.WaitForExit();
只要 MSI 需要停止当前服务,这段代码就可能让服务和安装程序相互等待。
服务也不应直接执行以下操作:
- 在自身仍运行时覆盖自己的 EXE 或 DLL。
- 从安装目录启动即将被 MSI 替换的更新程序。
- 仅因为下载地址可信就跳过数字签名验证。
- 允许普通用户修改 MSI 暂存目录。
- 安装失败后仍无条件启动新版本服务。
- 同时启动多个更新任务或多个
msiexec实例。
排查顺序
可以按以下顺序排查:
- 使用
/L*v生成完整日志,找到Return value 3前的第一个实际错误。 - 确认服务不会等待
msiexec,更新工作已交给外部进程。 - 确认 MSI 能在替换文件前停止服务,并为停止操作设置合理的超时时间。
- 检查
UpgradeCode、ProductCode、PackageCode和ProductVersion。 - 检查 MSI 的 Major Upgrade 配置及
RemoveExistingProducts排序。 - 确认待替换文件没有被其他进程占用。
- 检查自定义操作的返回值、执行身份和日志。
- 分别处理
0、3010、1603、1618等退出码。 - 验证 MSI 签名,并保护下载目录和更新程序。
- 在虚拟机中测试升级成功、升级失败、断电、服务停止超时及需要重启等场景。
LocalSystem 对安装目录拥有 Full Control,只能帮助排除部分权限问题。MSI 的升级配置、服务自等待、文件占用、自定义操作失败和安装事务冲突,仍需分别检查。
备注:内容仅供参考。
