PAYATHON 2026

Windows service 调用 msiexec 无法完成自更新

排障赵师傅

结论

Windows service 以 LocalSystem 身份启动 msiexec.exe,权限上没有问题。问题在于,服务不能等待 msiexec 结束,同时又允许 MSI 停止并替换自己。

典型的失败过程如下:

  1. 服务启动 msiexec.exe,然后调用 WaitForExit()。
  2. MSI 准备更新服务文件,通过 Service Control Manager 请求停止服务。
  3. 服务还在等待安装进程退出,无法及时停止。
  4. 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 管理服务,不要依赖自定义脚本直接复制可执行文件。升级过程至少需要做到:

  1. 替换服务文件前先停止服务。
  2. 等待服务进入 SERVICE_STOPPED 状态。
  3. 更新文件及服务注册信息。
  4. 安装成功后启动服务,或交给外部更新程序启动。
  5. 安装回滚时恢复原有状态。

如果服务的 OnStop 执行时间较长,应尽量减少清理工作,并向 Service Control Manager 正确报告状态。停止回调本身不能等待 MSI 或更新程序完成。

还要确认是否有其他进程加载了服务目录中的 DLL。服务即使已经停止,其他进程占用文件仍可能造成替换失败,或使安装要求重新启动系统。

推荐的更新流程

更新工作可以分给服务和独立更新程序。

1. 服务负责下载和验证

服务负责:

  • 检查新版本。
  • 将 MSI 下载到受保护的暂存目录。
  • 验证 MSI 的数字签名、发布者和预期版本。
  • 启动独立更新程序。
  • 启动后直接返回,不等待更新程序结束。

暂存目录可以使用类似下面的位置:

C:\ProgramData\Vendor\Product\Updates

该目录的 ACL 应禁止普通用户写入。否则,攻击者可能替换其中的 MSI,再借助 LocalSystem 权限执行任意安装代码。

2. 独立更新程序负责停止服务

更新程序应放在安装目录之外,也可以先复制到不会被 MSI 替换的临时目录。它负责:

  1. 等待原服务完成当前操作。
  2. 请求停止服务。
  3. 等待服务完全停止。
  4. 启动 msiexec.exe。
  5. 等待安装结束并读取退出码。
  6. 根据退出码启动服务、记录失败状态或安排重启。

服务启动更新程序后,不要调用 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 实例。

排查顺序

可以按以下顺序排查:

  1. 使用 /L*v 生成完整日志,找到 Return value 3 前的第一个实际错误。
  2. 确认服务不会等待 msiexec,更新工作已交给外部进程。
  3. 确认 MSI 能在替换文件前停止服务,并为停止操作设置合理的超时时间。
  4. 检查 UpgradeCode、ProductCode、PackageCode 和 ProductVersion。
  5. 检查 MSI 的 Major Upgrade 配置及 RemoveExistingProducts 排序。
  6. 确认待替换文件没有被其他进程占用。
  7. 检查自定义操作的返回值、执行身份和日志。
  8. 分别处理 0、3010、1603、1618 等退出码。
  9. 验证 MSI 签名,并保护下载目录和更新程序。
  10. 在虚拟机中测试升级成功、升级失败、断电、服务停止超时及需要重启等场景。

LocalSystem 对安装目录拥有 Full Control,只能帮助排除部分权限问题。MSI 的升级配置、服务自等待、文件占用、自定义操作失败和安装事务冲突,仍需分别检查。

备注:内容仅供参考。