RPC远程过程调用,二进制封装,提供高吞吐低延迟的高性能RPC框架
大石头 authored at 2022-08-10 13:26:19
14.32 KiB
NewLife.Remoting
# 自动更新机制 ## 概述 `NewLife.Remoting` 提供了一套完整的应用自动更新机制,涵盖**检测 → 下载 → 校验 → 解压 → 文件覆盖 → 进程重启**全链路。核心类为 `Upgrade`(文件操作)和 `ClientBase.Upgrade`/`Restart`(流程编排),已在星尘代理(StarAgent)等生产环境中稳定运行超过 20 年。 ### 解决的问题 - **文件覆盖**:运行中的 exe/dll 在 Windows 上被锁定,Linux 上覆盖可能导致段错误。通过「先改名再拷贝」策略安全替换。 - **进程重启**:跨平台(Windows/Linux/OSX)拉起新版本进程,支持服务模式(systemd)和普通进程模式。 - **版本管理**:避免频繁更新同一个版本(`_lastVersion` 去重)。 - **回滚保护**:更新失败时自动恢复被移动的旧文件。 --- ## 更新流程 ```mermaid sequenceDiagram participant S as 服务端 participant C as ClientBase participant U as Upgrade participant F as 文件系统 participant P as 新进程 C->>S: 查询更新 (UpgradeAsync) S-->>C: 返回更新信息 (版本/下载地址/哈希/通道) C->>U: Download(url) U->>F: HTTP 下载 .zip 到 Update/ 目录 U-->>C: 下载完成 (MD5) C->>U: CheckFileHash(hash) alt 哈希不符 C-->>C: 上报 "哈希校验失败",终止 else 哈希一致 C->>U: Extract() U->>F: 解压到临时目录 C->>U: Update() U->>F: 旧 .dll/.exe → .del 改名 U->>F: CopyAndReplace(临时 → 目标) U->>F: Linux: sync 强制落盘 C->>C: Restart(upgrade) C->>U: Run(name, args) U->>P: dotnet name.dll args P-->>U: 进程持续运行 → 成功 end ``` ### 各阶段详解 | 阶段 | 方法 | 说明 | |------|------|------| | 检测 | `UpgradeAsync` | 调用服务端接口查询是否有新版本,返回 `IUpgradeInfo`(版本号、下载地址、哈希、通道、强制更新标志) | | 下载 | `Upgrade.Download` | HTTP GET 下载 .zip 更新包到 `Update/` 目录,超时 `DownloadTimeoutSeconds`(默认 30s)。通过 Content-Disposition 头获取真实文件名。瞬时网络故障自动重试两次(共三次尝试) | | 校验 | `Upgrade.CheckFileHash` | 对下载文件计算 MD5,与服务端下发的哈希比对,防止文件损坏或被篡改 | | 解压 | `Upgrade.Extract` | 解压 .zip 到系统临时目录,仅支持 .zip 格式 | | 预安装 | `Info.Preinstall` | 可选。在覆盖文件之前执行预安装脚本(如安装运行时依赖) | | 覆盖 | `Upgrade.Update` | 按 `Mode`(Partial/Standard/Full)清理目标目录;Standard 模式将当前 .exe/.dll 改名为 .del(Linux 上无法覆盖运行中文件);`CopyAndReplace` 拷贝新文件;Linux 上 `sync` 强制落盘 + `StorageFlushDelay` 等待;失败时恢复旧文件 | | 执行器 | `Info.Executor` | 可选。覆盖完成后执行更新后脚本 | | 重启 | `Restart` | 调用 `Upgrade.Run` 拉起新版本进程。通过 `BuildRestartArguments()` 虚方法构建重启参数,`MyStarClient` 可重载以支持服务模式(`-restart`)和进程模式(`-run`) | --- ## 进程拉起策略 ### Muxer 优先 `Upgrade.Run` 采用 **muxer 优先** 策略启动 .NET Core 应用: ```mermaid flowchart TD A["Run(name, args)"] --> B{"存在 name.runtimeconfig.json ?"} B -->|"是 .NET Core"| C["dotnet name.dll args"] B -->|"否"| D{"存在 name.exe ?"} D -->|"是 .NET Framework"| E["name.exe args"] D -->|"否"| F["抛异常: 无法确定启动方式"] C --> G["解析 dotnet 绝对路径"] G --> H["注入 DOTNET_ROOT 环境变量"] H --> J["Process.Start (RedirectStandardError=true)"] J --> K{"WaitForExit(msWait)"} K -->|"仍在运行"| L["✅ 启动成功"] K -->|"已退出, ExitCode=0"| L K -->|"已退出, ExitCode≠0"| M["❌ 捕获 stderr → LastErrorMessage"] ``` ### 为什么用 muxer 而不是 apphost - `dotnet <dll>` 是跨平台标准写法,与 StarAgent 的系统约定一致。 - .NET muxer(`dotnet`)能根据 `.runtimeconfig.json` 自动路由到正确的运行时版本,支持多版本共存。 - 原生 apphost(无扩展名的二进制)依赖运行时可发现性(`DOTNET_ROOT` / `/etc/dotnet/install_location`),在 systemd 精简环境下可能失败。 - muxer 本身已在内存中(热路径),即使目标 dll 尚未完全落盘,报错信息也比 apphost 的 SIGBUS 崩溃更具可读性。 ### dotnet 路径解析 `ResolveDotNetPath()` 按优先级查找: 1. `DOTNET_ROOT` 环境变量 → `$DOTNET_ROOT/dotnet` 2. `/usr/bin/dotnet`(Linux 标准符号链接位置) 3. `/usr/local/bin/dotnet` 4. 回退到 `"dotnet"`(依赖系统 PATH) ### 环境变量注入 `InjectDotNetRoot()` 从 dotnet 路径反推运行时根目录,向子进程注入: - `DOTNET_ROOT` — 通用运行时根目录 - `DOTNET_ROOT_ARM64` / `DOTNET_ROOT_X64` — 架构特定的高优先级变量 ### 启动诊断 当进程异常退出(非零退出码)时,`Run` 会: 1. 读取子进程 stderr(最多 4096 字节) 2. 将 `ExitCode` + stderr 写入 `LastErrorMessage` 3. 调用方(`ClientBase.Restart` / `MyStarClient.Restart`)通过 `WriteInfoEvent` 上报到星尘节点历史 --- ## 更新模式(UpdateModes) `Upgrade.Mode` 控制更新时目标目录的清理策略: | 模式 | 值 | 说明 | |------|-----|------| | `Partial` | 1 | **部分包**:不清理目标目录,直接覆盖包内文件,保留其它文件 | | `Standard` | 2 | **标准包**(默认):覆盖前将 `*.exe;*.dll` 移名为 `.del`,然后 `CopyAndReplace` 覆盖 | | `Full` | 3 | **完整包**:清空目标目录所有文件(移名为 `.del`)和子目录。保护清单:`appsettings.json`、`appsettings.*.json`、`Update/`、`Log/`、`Config/` 及其下的 `.del` 文件 | `Mode` 默认值为 `Standard`,如需由服务端下发可扩展 `IUpgradeInfo` 增加可选字段。 ### 进程终止 `Upgrade.KillSelf()` 用于更新完成后优雅终止当前进程: - 非控制台应用先调用 `CloseMainWindow()` - 最后执行 `Environment.Exit(0)` > 注意:`Environment.Exit` 之后的代码不会执行,这是预期行为。 --- ## 冒烟测试与自动回滚 自更新最怕新版本有 bug 把自己"玩死"。本机制采用 **冒烟测试 + 自动回滚** 策略确保不死: ```mermaid flowchart TD A["Update() 完成<br/>新版文件覆盖到位<br/>旧版 .exe/.dll/.json 备份为 .del"] --> B["Run(name, args, 3000ms)"] B --> C{"新进程在 3s 内退出?"} C -->|"仍在运行(冒烟通过)"| D["KillSelf<br/>旧进程退出,新进程接管"] C -->|"异常退出"| E["等待 5s(落盘延迟 / JIT 预热)"] E --> F["Run(name, args, 3000ms) 重试"] F --> G{"重试结果?"} G -->|"通过"| D G -->|"仍失败"| H["Rollback()<br/>恢复所有 .del → 旧版文件"] H --> I["记录失败原因 + stderr<br/>旧进程继续提供服务"] I --> J{"设备重启?"} J -->|"是"| K["systemd 拉起旧版(已验证)✅"] J -->|"否"| L["旧进程持续运行 ✅"] ``` ### 核心逻辑 1. **冒烟测试**:旧进程不退出,先尝试拉起新进程。`Run` 在 3 秒窗口内观察新进程是否存活。 2. **首次失败不立即判定**:等待 5 秒后重试一次,给慢速存储(eMMC GC)、JIT 冷启动等留下缓冲。 > 同一启动周期内 `exec()` 从内核页缓存读取文件——数据是完整的,不存在"读到半写入"问题。重试的价值在于排除 eMMC 后台垃圾回收、CPU 降频冷启动等瞬时延迟,而非页缓存一致性问题。 3. **重试仍失败** → `Rollback` 将所有 `.del` 备份恢复(含 `.runtimeconfig.json`、`.deps.json`),旧进程继续运行。 4. **测试通过** → `KillSelf` 退出旧进程,新进程接管。即使此时 systemd 重启服务,拉起的也是已验证的新版。 ### 备份范围 `Standard` 模式在 `CopyAndReplace` 前备份: - `*.exe` / `*.dll` — 可执行文件(覆盖运行中文件会导致段错误,必须先改名) - `*.runtimeconfig.json` / `*.deps.json` — 随 .NET 版本变化的构建输出文件 - 配置文件 `appsettings.json` 不会被覆盖(`CopyAndReplace` 内部保护),因此不备份 ### 与 StarAgent 的配合 StarAgent 使用 `dotnet StarAgent.dll` 启动新版作为冒烟测试: - 若新版正常 → 测试通过 → 旧版退出 → systemd 管理新版 - 若新版有致命缺陷(启动即崩)→ 测试失败 → `Rollback` 恢复旧版 → 旧版继续运行 - 若设备断电重启 → systemd 拉起的是恢复后的旧版,不会"自毁" `Upgrade.Rollback()` 自动处理两种 `.del` 命名: - 普通:`StarAgent.dll.del` → 恢复为 `StarAgent.dll` - 时间戳变体:`StarAgent.dll_260608153000.del` → 恢复为 `StarAgent.dll` --- ## A4 ARM64 故障案例复盘 ### 现象 A4 设备(ARM64 + Ubuntu 22.04 + eMMC 存储)在 net9 → net10 自动更新后,`Upgrade.Run` 拉起新进程失败。节点历史显示: ``` 13:35:30 强制更新完成,准备重启后台服务!PID=1316 13:35:31 拉起新进程失败,延迟3000ms后重试 13:35:34 强制更新完成,但拉起新进程失败! ``` 同一版本在其他 x86/ARM 机器上正常。 ### 时间线 | 时间 | 事件 | |------|------| | 13:34:35 | 旧 agent(3.1.2024.1117)→ net9 agent 升级成功 | | 13:34:54 | 开始下载 net10 运行时 | | 13:35:28 | **net10 运行时安装完成**(解压到 `/usr/share/dotnet`,创建软链 `/usr/bin/dotnet`) | | 13:35:29 | net9 agent 检测到 net10 更新,开始下载 | | 13:35:30 | 覆盖完成,调用 `Restart` | | 13:35:31 | **拉起失败**(进程 ~1 秒内非零退出) | | 13:35:34 | 重试仍失败,上报 "拉起新进程失败" | | 13:36:00 | 旧进程退出,systemd 自动重启服务 → 新 agent 正常上线 | ### 根因 **不是代码逻辑 bug,是 A4 ARM64 慢速存储(eMMC)上的 IO 竞态条件。** 此故障在 2024 年 11 月已被首次记录(commit `0eb5f38`:"在A4上执行StarAgent自动更新时,有一定几率无法自动重启")。 ```mermaid flowchart LR A["CopyAndReplace<br/>拷贝新二进制到磁盘"] --> B["数据写入页缓存<br/>(未提交到 NAND)"] B --> C["exec() 新进程"] C --> D{"页缓存已回写?"} D -->|"是(概率性)"| E["✅ 读到完整二进制"] D -->|"否(概率性)"| F["❌ 读到不完整二进制<br/>SIGBUS / 异常退出"] ``` **加重因素**:net10 运行时安装与升级仅间隔 2 秒,系统 IO 高负载时页缓存回写延迟更大,触发几率显著增加。 ### 修复措施(三层防御) | 层 | 措施 | 文件 | 作用 | |---|------|------|------| | ① | `CopyAndReplace` 后 `sync` 强制落盘 | `Upgrade.Update` | 确保二进制数据提交到存储介质后再重启 | | ② | ARM64 Linux 上 `StorageFlushDelay`(默认 800ms)等待页缓存回写 | `Upgrade.Update` | 给慢速存储(eMMC/SD)额外回写时间窗口,可配置 | | ③ | stderr 捕获 → `LastErrorMessage` → `WriteInfoEvent` 上报 | `Upgrade.Run` / `ClientBase.Restart` | 即使失败也能在节点历史看到 ExitCode 和错误输出 | ### 为什么 2024-11 的补丁(加重试)不够 重试时 IO 可能仍然拥堵(系统正在处理大量回写),导致第二次 `exec()` 依然读到不完整二进制。**重试只降低失败概率,不解决根因(数据未落盘)。** --- ## 兼容性 ### 目标框架 `Upgrade.cs` 编译面向 `net45;net461;netstandard2.0;netstandard2.1;net5.0-net10.0`。 - **net45/net461**:`InjectDotNetRoot` 跳过架构特定环境变量(`RuntimeInformation` 不可用)。 - **.NET Core 5.0+**:完整支持 `DOTNET_ROOT` + 架构特定变量。 ### 平台差异 | 平台 | 启动方式 | 文件改名 | |------|---------|---------| | Windows | 直接执行 .exe 或 `dotnet &lt;name&gt;.dll` | .exe/.dll → .del(覆盖前改名) | | Linux | `dotnet &lt;name&gt;.dll`(muxer 优先),apphost 仅用作非 .NET Core 兜底 | .exe/.dll → .del + `sync` 强制落盘 + `chmod +x` | | OSX | `dotnet &lt;name&gt;.dll` | 同 Linux 逻辑 | --- ## Stardust MyStarClient 适配说明 `MyStarClient` 在 `D:\X\Stardust\StarAgent\MyStarClient.cs` 中重写了 `Restart` 以支持服务/进程双模式,并已同步基类的冒烟测试 + 自动回滚策略。 ### 双模式差异 | 模式 | 启动参数 | 成功时 | 失败时 | |------|---------|--------|--------| | **服务模式**(systemd) | `-restart -upgrade` | 不自杀,外部命令重启服务会结束当前进程 | `Rollback` 恢复旧版,记录 `LastErrorMessage` | | **进程模式**(普通) | `-run -upgrade` | `Service.StopWork` + `KillSelf` | `Rollback` 恢复旧版,记录 `LastErrorMessage` | ### 与基类的对齐 `MyStarClient.Restart` 与 `ClientBase.Restart` 使用完全相同的三级策略: 1. **Run(name, args, 3000ms)** — 冒烟测试 2. **失败 → 等待 5s → 重试一次**(应对慢速存储 / JIT 预热) 3. **仍失败 → `upgrade.Rollback()`** — 恢复所有 `.del` 恢复旧版文件 4. 失败日志均携带 `upgrade.LastErrorMessage`(含 ExitCode + stderr) ### 为什么服务模式不调用 KillSelf 服务模式下,`-restart -upgrade` 参数告诉新版 StarAgent 通过 `systemctl restart` 接管服务。systemd 会先杀旧进程再启新进程,所以旧进程无需(也不应)主动退出——否则可能与 systemd 的进程管理产生竞态。 ### 后续优化方向 当前双模式仍需整体覆写 `Restart`,因为服务模式的"不自杀"语义无法通过纯 `BuildRestartArguments()` 钩子表达。如果基类增加 `protected virtual Boolean ShouldKillSelf => true` 属性,`MyStarClient` 可仅覆写 `BuildRestartArguments` + `ShouldKillSelf`,从而消除整段 `Restart` 覆写。 --- ## 相关文件 | 文件 | 说明 | |------|------| | `NewLife.Remoting/Clients/Upgrade.cs` | 文件下载、解压、覆盖、进程拉起 | | `NewLife.Remoting/Clients/ClientBase.cs` | 更新流程编排(`Upgrade`、`Restart`) | | `D:\X\Stardust\StarAgent\MyStarClient.cs` | StarAgent 重写的重启逻辑(服务/进程两路) | | `D:\X\NewLife.Agent\NewLife.Agent\Models\SystemdSetting.cs` | systemd 服务单元生成 | | `D:\X\Stardust\Stardust\Managers\NetRuntime.cs` | .NET 运行时安装 | ## 相关文档 - [ClientBase 使用说明](/NewLife/NewLife.Remoting/Blob/master/Doc/ClientBase.md) - [重试策略](/NewLife/NewLife.Remoting/Blob/master/Doc/重试策略.md)