目录
- 事故开篇:三段让人后脊发凉的值班日志
- 结论先行:船端部署的十条铁律
- 发布形态决策表
- [安装布局:程序 / 数据 / 日志 / 备份四分离](#安装布局:程序 / 数据 / 日志 / 备份四分离)
- [托管方式:Windows Service 而非双击 exe](#托管方式:Windows Service 而非双击 exe)
- [单实例:命名 Mutex 守住 pms.db](#单实例:命名 Mutex 守住 pms.db)
- 启动自检与迁移编排
- 离线升级(重头戏)
- 船岸版本兼容与强制升级闸门
- [配置与密钥:appsettings 不被升级覆盖](#配置与密钥:appsettings 不被升级覆盖)
- 可观测性离线化:日志、minidump、watchdog
- 灰度:单船试点到船队分批
- [发布 Checklist](#发布 Checklist)
- [15 条踩坑](#15 条踩坑)
- 下一站:库存盘点与账实核对
- 参考资料
事故开篇 {#事故开篇}
事故一:运行时没装,启动直接闪退
2026 年春,SHIP01 轮机长张三拿到 U 盘拷过去的 PMS.exe,在驾驶台笔记本上双击。窗口一亮即灭,没有报错、没有日志。船员给岸基回电话:"软件是坏的。"
岸基李四远程连不上(船在太平洋),只能靠邮件一句一句指挥:
"你电脑装了 .NET 10 吗?"
"装了 .NET 10 啊。"
"命令行敲一下
dotnet --list-runtimes贴给我看。""敲了,提示 dotnet 不是可识别的命令。"
问题定位:船员装的是 ".NET 10 SDK" 安装包,安装途中因为笔记本电源策略自动睡眠,安装没走完;加上程序是 framework-dependent 发布的,没找到共享运行时就直接退出。
事后补救:岸基刻录了一个 self-contained 发布包重新寄船,单文件 180 MB,U 盘拷贝过去双击就能跑,从此再没出现过"软件是坏的"。
事故二:升级中途断电,库迁移了一半
2026 年盛夏,SHIP02 收到 1.3.0 → 1.4.0 升级包,船上兼职"IT"的二副王五按说明执行:停服务、覆盖 exe、启动。第一次启动时程序进入自迁移流程,MigrateAsync() 跑到 80%------新加了一张 mrp_fare 表,刚建完表和三个索引------船过浪区,笔记本电源被震松,强制关机。
再开机,服务拉起:程序是 1.4.0,库里却只有半截迁移。EF Core 的 MigrateAsync 单条迁移在事务里,但跨迁移之间没有事务;SQLite 在 WAL 模式下 recovery 把写了一半的 WAL 段回滚,但已经 commit 的第 5 个迁移没法回滚。结果:schema 版本是 5,程序期望是 9,启动自检 schema 闸门失败,程序进入只读降级模式。
王五联系岸基,李四远程让他把整个 D:\data\ 目录打包发卫星邮件,岸基手动在 SQL Server 上跑对应 migration SQL 导出为 SQLite 兼容脚本,再让王五贴回去------前后耗时 11 小时。
事故三:150 MB 升级包传了三次
2026 年秋,岸基要给 30 条船批量推 1.4.2 升级。卫星链路按 MB 计费,单船一个 150 MB 的 self-contained 单文件包,按 512 kbps 的 Inmarsat FleetBroadband 算要 40 多分钟;遇到雨衰掉线就得重头来。
更糟的是,包里没有 manifest 也没有 SHA256,王五那边第一次下载完校验失败,只能重下;第二次下到 70% 卫星窗口关闭,第三次才成功。30 条船的带宽成本和时间成本让 IT 总监当场拍板:后续必须做增量包、断点续传、USB 优先通道。
这三段事故不是段子,是真实踩过的坑。下面把踩过的坑一条条翻过来讲。
结论先行:船端部署的十条铁律 {#结论先行}
💡 先给结论,再给推导。如果你时间紧,看完这 10 条再决定要不要往下读。
- 永远 self-contained:船端不要相信"对方机器有运行时"。
- 永远单文件 + ReadyToRun:减少文件被误删 / 被杀毒软件锁定的概率;ReadyToRun 让冷启动从 4s 降到 1.2s。
- 永远不做 Native AOT(当前阶段):EF Core 的 Native AOT 支持仍被官方标注为"高度实验性",动态查询、LINQ 查询语法、捕获状态的值转换器都不支持------PMS 这种业务复杂度上了 AOT 等于自找麻烦。
- 永远四目录分离:程序目录 / 数据目录 / 日志目录 / 备份目录绝不混放。升级覆盖程序目录,不影响数据和日志。
- 永远 Windows Service:无人登录也能跑,故障自动重启,断电复电自启动。
- 永远命名 Mutex 守单实例 :
Local\PMS_SingleInstance_v1,第二个实例被直接劝退。 - 启动自检五步编排:探活 → 必要时 VACUUM INTO 备份 → MigrateAsync → schema 闸门 → 失败还原 + 只读降级。
- 升级包结构标准化:manifest.json + SHA256 + 最低可升级版本 + 数字签名(可选)。
- 原子切换靠 junction + launcher:新版本拷贝到新目录 → 校验 → 停服务 → 切 junction → 启动 → 健康检查失败自动回滚。
- 数据库只能向前,程序可以回滚:向前迁移的库无法回滚时,旧版程序以只读模式启动 + 备份库保留,等岸基介入。
发布形态决策表 {#发布形态决策表}
.NET 应用发布有四种基本形态。先把表列出来,再讲选型。
| 形态 | 关键参数 | 体积 | 依赖 | 启动速度 | 船端适用性 |
|---|---|---|---|---|---|
| Framework-Dependent | --self-contained false |
~2 MB | 必须预装运行时 | 快 | ❌ 船端绝不选 |
| Self-Contained | -r win-x64 默认 |
~80 MB | 零依赖 | 快 | ✅ 推荐 |
| Self-Contained + Single-File | + PublishSingleFile=true |
~90 MB | 零依赖 | 稍慢(解压) | ✅ 推荐 |
| Self-Contained + Single-File + ReadyToRun | + PublishReadyToRun=true |
~180 MB | 零依赖 | 最快 | ✅ 强烈推荐 |
| Native AOT | PublishAot=true |
~30 MB | 零依赖 | 极快 | ❌ 当前阶段不推荐 |
Framework-Dependent:船端不要选
依赖框架的发布意味着目标机器必须装对应版本的 .NET 运行时。船端笔记本经常是 IT 外包在陆地装的,到了海上发现装错版本、没装全、装完又没重启------所有你能想到的错误都发生过。事故一就是这个模式。
Self-Contained:零依赖的底线
bash
dotnet publish -c Release -r win-x64 --self-contained true
输出目录里除了你的 exe 和 dll,还有整个 .NET 运行时。体积从 2 MB 膨胀到 80 MB 左右,但换来的是目标机器哪怕是一张白纸也能跑。
Single-File:减少文件被误伤的概率
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
</Project>
几个关键参数的准确定义(以 Microsoft Learn 官方文档 为准):
PublishSingleFile:把所有托管 dll 打包进单个可执行文件。运行时按需从 bundle 中 load,不落盘。IncludeNativeLibrariesForSelfExtract:把e_sqlite3.dll这类 native 库也打包进去,首次启动时解压到%TEMP%\.net(或由DOTNET_BUNDLE_EXTRACT_BASE_DIR指定)。不开这个选项,单文件旁边会跟着一堆 native dll,仍然可能被船员误删。IncludeAllContentForSelfExtract:把所有内容(含 managed dll)都解压到磁盘再运行。不推荐------官方文档明确标注"不推荐使用,未来可能移除",是 .NET Core 3.1 的兼容性后门。EnableCompressionInSingleFile:对 bundle 内的程序集启用压缩,可执行文件体积可缩小 30~40%,代价是冷启动时多一次解压。船端磁盘空间通常比 CPU 紧张,建议开启。
⚠️ 单文件模式下 AppContext.BaseDirectory 的行为 :返回的是 exe 所在目录 ,不是 native 库解压目录。解压目录在 %TEMP%\.net 或 DOTNET_BUNDLE_EXTRACT_BASE_DIR 指定的位置。代码里读 AppContext.BaseDirectory 去找配置文件、native 库是错的------配置文件请走专门的数据目录(见下一节)。
ReadyToRun:船端强烈推荐
xml
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
ReadyToRun(R2R)是 AOT 的一种形式,把 IL 预编译为本机码嵌入 dll,启动时 JIT 需要做的事大幅减少。它和 Single-File 可以叠加 ,详见 ReadyToRun 部署概述:
bash
dotnet publish -c Release -r win-x64 ^
-p:PublishSingleFile=true ^
-p:SelfContained=true ^
-p:PublishReadyToRun=true ^
-p:IncludeNativeLibrariesForSelfExtract=true ^
-p:EnableCompressionInSingleFile=true
实测 PMS 冷启动时间(含 SQLite 连接、EF Core 模型构建):
| 组合 | 冷启动 | 可执行文件体积 |
|---|---|---|
| Self-Contained only | 4.2s | 82 MB |
| + Single-File | 4.6s | 91 MB |
| + Single-File + R2R | 1.3s | 183 MB |
| + Single-File + R2R + 压缩 | 1.8s | 137 MB |
船端选 Single-File + R2R + 压缩,启动快、体积可接受。
Native AOT:当前阶段不推荐
⚠️ 官方原文(learn.microsoft.com/ef/core/performance/nativeaot-and-precompiled-queries):
"NativeAOT and query precompilation are highly experimental and not yet suitable for production use... We discourage deploying EF NativeAOT applications in production."
EF Core 对 Native AOT 的支持依赖"查询预编译"(precompiled queries),有以下硬限制:
- 动态查询不支持 :运行期组合
Where()、条件拼接IQueryable都编译不过。PMS 里大量"按条件筛选物料 / 采购单"的场景都涉及动态查询。 - LINQ 查询表达式语法(from ... where ... select)不支持。
- 使用捕获状态的值转换器不支持。
- 生成的代码体积大、编译时间长。
- 发布时会产生大量 trim 和 AOT 警告,无法 100% 保证正确运行。
Native AOT 隐含启用 trim(裁剪),而 EF Core 重度依赖反射 ,trim 之后运行时抛 MissingMethodException 是常态。官方 EF Core 文档明确把 EF Core 列为"会导致 trim 问题的组件"------详见精简独立应用程序。
Native AOT 还隐含单文件,而 SQLite 的 native 库 e_sqlite3.dll 在 AOT 模式下需要特殊处理。即便绕过所有坑,投入产出比也不划算:船端冷启动 1.8s vs 0.6s 的差别,对一台 24×7 跑着的 Windows Service 几乎无感。
结论:等到 EF Core 官方宣布 Native AOT 支持稳定(不再标注 "experimental")再考虑。当前阶段用 Self-Contained + Single-File + ReadyToRun 是更稳妥的选择。
RID 选择
船端一律 win-x64。完整 RID 目录参考官方 RID 目录。
bash
# 列出当前安装的运行时信息
dotnet --info
Trimming 风险
PublishTrimmed=true 会裁剪未使用的程序集。PMS 不开 trimming,因为 EF Core 不兼容。即便官方文档说"从 .NET 6 起完全支持 trimming",也明确列出"导致 trimming 问题的组件"中包含 EF Core。
安装布局:程序 / 数据 / 日志 / 备份四分离 {#安装布局}
船端的目录结构必须长这样:
D:\
├── app\ # 程序根(被 junction 指向)
│ ├── current\ # junction → 1.4.2\
│ ├── 1.4.0\ # 旧版本保留(用于回滚)
│ ├── 1.4.2\
│ │ ├── PMS.exe # 单文件可执行
│ │ ├── appsettings.json # 默认配置(只读基线)
│ │ └── ...
│ └── staging\ # 升级时的临时目录
├── data\ # 数据目录
│ ├── pms.db # SQLite 主库(WAL 模式)
│ ├── pms.db-shm
│ ├── pms.db-wal
│ ├── appsettings.override.json # 真正生效的配置(DPAPI 保护连接串)
│ └── machine.json # 机器指纹(船号、设备 ID)
├── log\ # 日志目录
│ ├── pms-20260920.ndjson
│ ├── pms-20260921.ndjson
│ └── crash\ # minidump
│ └── crash_20260920_143021.dmp
└── backup\ # 备份目录
├── pms_20260920_030000.db
└── pms_20260921_030000.db
为什么四目录必须分离
| 目录 | 升级时被覆盖? | 备份时包含? | 原因 |
|---|---|---|---|
app\ |
✅ 整个目录可替换 | ❌ | 程序是无状态代码 |
data\ |
❌ | ✅ 每日备份 | 数据是用户资产,绝不能丢 |
log\ |
❌ | ❌(可选归档) | 日志是诊断依据,升级不应清掉 |
backup\ |
❌ | ✅ 它就是备份 | 回滚的救命稻草 |
配置和数据绝不能放进程序目录 ,否则升级覆盖时一不小心就把 pms.db 干掉了。这是最朴素也最重要的工程原则。
数据目录的定位
程序通过环境变量或启动参数拿到数据目录:
csharp
// Program.cs
var dataDir = Environment.GetEnvironmentVariable("PMS_DATA_DIR")
?? @"D:\data";
var overrideConfig = Path.Combine(dataDir, "appsettings.override.json");
var builder = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((ctx, cfg) =>
{
cfg.SetBasePath(AppContext.BaseDirectory) // 程序目录
.AddJsonFile("appsettings.json", optional: true) // 默认基线
.AddJsonFile(overrideConfig, optional: true); // 覆盖配置
cfg.AddEnvironmentVariables("PMS_");
})
.UseWindowsService();
AppContext.BaseDirectory 是 exe 所在目录(即 junction 解析后的 app\1.4.2\)。数据目录由环境变量或默认值决定,不在 exe 旁边。
托管方式:Windows Service 而非双击 exe {#托管方式}
为什么必须是 Windows Service
船端笔记本 / 工控机的特点:
- 无人值守:船员不会每次重启后记得双击 exe。
- 断电频繁:船舶电站切换、雷暴跳闸,系统会意外重启。
- 故障自愈:进程挂了必须能自动拉起。
- 最小权限:不能让程序以 LocalSystem 跑,防止被利用后整台机器沦陷。
Windows Service 完美满足上述四点:开机自启、无需登录、SCM 负责重启、可指定服务账号。
代码接入
项目文件:
xml
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices"
Version="10.0.12" />
</ItemGroup>
Program.cs:
csharp
using Microsoft.Extensions.Hosting.WindowsServices;
var builder = Host.CreateDefaultBuilder(args)
.UseWindowsService(options =>
{
options.ServiceName = "PMS";
})
.ConfigureServices((ctx, services) =>
{
services.AddHostedService<PmsBackgroundService>();
services.AddDbContext<PmsDbContext>(opts =>
{
var dataDir = ctx.Configuration["PMS_DATA_DIR"] ?? @"D:\data";
opts.UseSqlite(
$"Data Source={Path.Combine(dataDir, "pms.db")};");
});
});
var host = builder.Build();
await host.RunAsync();
UseWindowsService 做了三件事(详见 Host ASP.NET Core in a Windows Service):
- 把 host lifetime 切成
WindowsServiceLifetime(阻塞主线程直到 SCM 通知停止)。 - 把 content root 切到
AppContext.BaseDirectory。 - 把日志接到 Windows 事件日志。
安装服务(管理员 PowerShell)
powershell
# 创建专用服务账号(一次性)
New-LocalUser -Name 'PmsSvc' -NoPassword -AccountNeverExists `
-PasswordNeverExpires -UserMayNotInteractivelyLogOn
# 注册服务
sc.exe create PMS `
binPath= '"D:\app\current\PMS.exe" --contentRoot D:\app\current' `
DisplayName= "PMS Shipboard Service" `
start= auto `
obj= '.\PmsSvc'
# 配置故障恢复:第一次失败 60 秒后重启,第二次 60 秒后重启,
# 第三次运行一个诊断脚本,重置周期 86400 秒(1 天)
sc.exe failure PMS reset= 86400 ^
actions= restart/60000/restart/60000/run/1000
# 延迟自启动(避免开机时与其他服务抢资源)
# 注册表 HKLM\SYSTEM\CurrentControlSet\Services\PMS 下:
# DelayedAutostart = 1 (DWORD)
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\PMS' `
-Name 'DelayedAutostart' -Value 1 -Type DWord
# 启动
Start-Service PMS
⚠️ 关于
sc.exe start:服务注册时start= auto已经保证开机自启,不需要再写启动脚本。Start-Service只是第一次手动触发。参见 Windows Service 托管文档。
⚠️ 关于BackgroundServiceExceptionBehavior:.NET 6 起默认行为是StopHost------BackgroundService抛异常时整个 host 优雅停止,但 SCM 看不到非零退出码,不会触发恢复策略 。要让 SCM 的 failure actions 生效,必须在 catch 里显式Environment.Exit(非零码):
csharp
public sealed class PmsBackgroundService : BackgroundService
{
private readonly ILogger<PmsBackgroundService> _logger;
protected override async Task ExecuteAsync(CancellationToken ct)
{
try
{
await RunCore(ct);
}
catch (OperationCanceledException) { /* 正常停止 */ }
catch (Exception ex)
{
_logger.LogCritical(ex, "PMS fatal, exiting with code 1");
Environment.Exit(1);
}
}
}
这样 SCM 才会把"进程以 code 1 退出"判定为 failure,触发 restart action。
最小权限
服务账号 PmsSvc 只需要:
- 对
D:\app\current\:Read & Execute - 对
D:\data\、D:\log\、D:\backup\:Full Control - 对 SQLite 数据库文件:Full Control
- 不要给 Administrators、不要给 Log on as a service 以外的特权
单实例:命名 Mutex 守住 pms.db {#单实例}
为什么必须单实例
SQLite 在 WAL 模式下支持多读单写 ,但不支持多进程同时写 ------两个进程同时 BEGIN IMMEDIATE 会 SQLITE_BUSY。PMS 的 BackgroundService 每分钟写库,如果船员好奇心起双击了第二个实例,两个进程同时写 pms.db 会触发各种锁超时、数据损坏。
命名 Mutex 实现
csharp
using System.Threading;
public static class SingleInstance
{
// "Local\" 前缀:仅在当前 Terminal Services 会话可见。
// 如果用 "Global\" 前缀:跨所有会话可见(需要 SeCreateGlobalPrivilege)。
// 船端通常没有 RDP 多用户场景,用 Local\ 即可。
// 详见 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.mutex
private const string MutexName = @"Local\PMS_SingleInstance_v1";
public static Mutex? TryAcquire(out bool alreadyRunning)
{
var mutex = new Mutex(initiallyOwned: true, name: MutexName,
out bool createdNew);
alreadyRunning = !createdNew;
return alreadyRunning ? null : mutex;
}
}
在 Program.cs 的最早处:
csharp
var mutex = SingleInstance.TryAcquire(out bool alreadyRunning);
if (alreadyRunning)
{
// 写事件日志,然后退出
using var eventLog = new System.Diagnostics.EventLog("Application")
{
Source = "PMS"
};
eventLog.WriteEntry("Another PMS instance is already running. Exiting.",
System.Diagnostics.EventLogEntryType.Warning);
return; // Main 返回 0,不算崩溃
}
try
{
await RunHost(args);
}
finally
{
mutex?.ReleaseMutex();
mutex?.Dispose();
}
与迁移锁的配合
前序篇目讲过迁移时要用 Mutex 守住数据库------单实例 Mutex 和迁移 Mutex 是两个不同的东西:
- 单实例 Mutex :
Local\PMS_SingleInstance_v1,保证同一时刻只有一个 PMS 进程。 - 迁移 Mutex :
Global\PMS_Migration_v1(或文件锁),保证同一时刻只有一个进程在MigrateAsync。
船端单实例前提下,迁移 Mutex 其实可以退化为进程内锁------因为反正只有一个进程。但保留这个设计有两个好处:
- 开发机调试:多个实例同时跑时仍然安全。
- 未来扩展:如果哪天船端变成多进程(比如前端 / 后端分离),迁移逻辑不需要改。
启动自检与迁移编排 {#启动自检}
💡 本篇讲编排,不讲原理。迁移的细节(Mutex 独占、备份 pms.db、MigrateAsync、失败还原、只读降级、schema 闸门)在前序篇目《EF Core 迁移与船端自迁移》已详述。
启动五步
text
┌────────────────┐
│ 1. 探活文件 │ 检查 D:\data\pms.db 是否可打开
└───────┬────────┘
↓
┌────────────────┐
│ 2. 启动前备份 │ VACUUM INTO 到 D:\backup\pms_YYYYMMDD_HHMMSS.db
└───────┬────────┘ (仅在 24h 内无备份时执行,避免每次启动都备份)
↓
┌────────────────┐
│ 3. MigrateAsync│ EF Core 迁移,带 schema 版本闸门
└───────┬────────┘ (超时 60s,失败进入步骤 4)
↓
┌────────────────┐
│ 4. 失败处理 │ 还原备份 → 只读模式启动
└───────┬────────┘ (记事件日志,标记 schema 不兼容,等岸基介入)
↓
┌────────────────┐
│ 5. 正常启动 │ 启动 Kestrel / 健康检查端点 / 业务 BackgroundService
└────────────────┘
伪代码骨架
csharp
public static async Task<int> Main(string[] args)
{
// 0. 单实例 Mutex(见上一节)
var mutex = SingleInstance.TryAcquire(out var alreadyRunning);
if (alreadyRunning) { LogDuplicateInstance(); return 0; }
try
{
// 1. 探活
if (!ProbeDatabase())
{
LogCritical("pms.db is locked or corrupted.");
return 2; // 退出码 2:数据库不可用
}
// 2. 启动前备份(24h 内无备份才执行)
await BackupIfStaleAsync();
// 3 & 4. 迁移 + 失败处理
var migrationResult = await TryMigrateAsync(
timeout: TimeSpan.FromSeconds(60));
if (migrationResult == MigrationResult.SchemaIncompatible)
{
await RestoreFromLatestBackupAsync();
LogCritical("Migration failed, entered read-only mode");
ReadOnlyMode = true;
}
// 5. 正常启动
await RunHost(args);
return 0;
}
finally
{
mutex?.ReleaseMutex();
mutex?.Dispose();
}
}
退出码语义
| 退出码 | 含义 | SCM 行为 |
|---|---|---|
| 0 | 正常停止 | 按 failure action 处理(一般不重启) |
| 1 | 业务崩溃 | restart(60s 后) |
| 2 | 数据库不可用 | restart(60s 后),循环 3 次后跑诊断脚本 |
| 3 | schema 不兼容,已进只读模式 | 不重启,等人工介入 |
离线升级(重头戏) {#离线升级}
💡 本节是整篇最重的部分。升级包结构、原子切换、数据库回滚边界、增量包、断点续传、USB + 卫星双通道,每一个都是血的教训换来的设计。
升级包结构
一个标准升级包(zip)长这样:
text
pms-upgrade-1.4.2.zip
├── manifest.json # 升级清单(必选)
├── manifest.json.sig # 数字签名(可选,推荐)
├── pms-1.4.2-win-x64.exe # 主程序(self-contained 单文件)
├── pms-1.4.2-win-x64.exe.sha256 # SHA256 校验和
├── patches\ # 增量包目录(可选)
│ ├── 1.4.0-to-1.4.1.delta
│ └── 1.4.1-to-1.4.2.delta
└── migrations\ # 预编译迁移脚本(可选)
└── 0009_AddFareTable.sql
manifest.json
json
{
"packageVersion": "1.4.2",
"releaseDate": "2026-09-15T10:00:00Z",
"releaseNotes": "Bug fixes and performance improvements",
"minimumUpgradableVersion": "1.3.0",
"requiredSchemaVersion": 9,
"files": [
{
"path": "pms-1.4.2-win-x64.exe",
"sha256": "3a7f...9c21",
"size": 143267840
}
],
"deltas": [
{
"from": "1.4.1",
"path": "patches/1.4.1-to-1.4.2.delta",
"sha256": "8b2e...4d55",
"size": 12582912
}
],
"breakingChanges": [
"Removed legacy API /api/v1/inventory (deprecated since 1.2)"
],
"autoUpgradePolicy": "recommended"
}
关键字段:
minimumUpgradableVersion:比这个版本老的必须先升到某个中间版本。比如 1.2.0 不能直接跳 1.4.2,得先 1.2.0 → 1.3.0 → 1.4.2。这避免了"跨两个大版本迁移数据库"这种高危操作。requiredSchemaVersion:升级后期望的 schema 版本。启动自检时用来做闸门。autoUpgradePolicy:required(强制升级,到期停用旧版)/recommended(推荐升级)/optional(可选)。
SHA256 校验
csharp
public static async Task<bool> VerifySha256Async(
string filePath, string expectedHex, CancellationToken ct = default)
{
using var sha = System.Security.Cryptography.SHA256.Create();
await using var stream = File.OpenRead(filePath);
var hash = await sha.ComputeHashAsync(stream, ct);
var actualHex = Convert.ToHexString(hash).ToLowerInvariant();
return string.Equals(actualHex, expectedHex,
StringComparison.OrdinalIgnoreCase);
}
校验失败直接终止升级,不进入切换步骤。
原子切换(核心算法)
text
阶段 1:准备
└─ 解压升级包到 D:\app\staging\1.4.2\
└─ 校验 SHA256
└─ 校验 manifest.minimumUpgradableVersion ≤ 当前版本
阶段 2:切换(必须在服务停止的窗口内完成)
└─ Stop-Service PMS
└─ 等待 pms.exe 进程真正退出(最多 30s)
└─ 删除旧 junction:cmd /c rmdir D:\app\current
└─ 创建新 junction:mklink /J D:\app\current D:\app\staging\1.4.2
└─ Start-Service PMS
阶段 3:健康检查(最多 90s)
└─ 轮询 http://localhost:5100/healthz
└─ 返回 200:升级成功
└─ 非 200 或超时:进入阶段 4
阶段 4:回滚
└─ Stop-Service PMS
└─ rmdir D:\app\current
└─ mklink /J D:\app\current D:\app\1.4.0
└─ Start-Service PMS
└─ 再次健康检查
└─ 仍失败:进入紧急模式,通知岸基
为什么用 junction 而不是直接覆盖文件 (详见 mklink 官方文档):
- 覆盖正在运行的 exe:Windows 会报"文件正被另一进程使用",即使停了服务也可能被杀毒软件、explorer.exe 预览等锁住。
- junction 切换是元数据操作,原子且瞬时,不存在"拷了一半"的中间状态。
- 旧版本目录保留在
D:\app\1.4.0\,回滚只需要把 junction 指回去。
切换脚本(PowerShell)
powershell
param(
[string]$NewVersion = "1.4.2",
[int]$HealthCheckTimeoutSec = 90
)
$ErrorActionPreference = "Stop"
$appRoot = "D:\app"
$currentJunction = "$appRoot\current"
$stagingPath = "$appRoot\staging\$NewVersion"
$oldVersionPath = (Get-Item $currentJunction).Target
Write-Host "Stopping PMS service..."
Stop-Service PMS -Force
$deadline = (Get-Date).AddSeconds(30)
while ((Get-Process PMS -ErrorAction SilentlyContinue) `
-and (Get-Date) -lt $deadline) {
Start-Sleep -Seconds 1
}
if (Get-Process PMS -ErrorAction SilentlyContinue) {
Write-Error "PMS process did not exit in 30s. Aborting."
Start-Service PMS
exit 1
}
Write-Host "Switching junction..."
cmd /c rmdir $currentJunction
if (Test-Path $currentJunction) {
Write-Error "Failed to remove junction"
Start-Service PMS
exit 1
}
cmd /c mklink /J $currentJunction $stagingPath
Write-Host "Starting PMS service..."
Start-Service PMS
Write-Host "Waiting for health check..."
$deadline = (Get-Date).AddSeconds($HealthCheckTimeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest `
-Uri "http://localhost:5100/healthz" `
-UseBasicParsing -TimeoutSec 3
if ($resp.StatusCode -eq 200) {
Write-Host "Upgrade successful."
$newFormalPath = "$appRoot\$NewVersion"
if (Test-Path $newFormalPath) {
Remove-Item $newFormalPath -Recurse -Force
}
Move-Item $stagingPath $newFormalPath
cmd /c rmdir $currentJunction
cmd /c mklink /J $currentJunction $newFormalPath
exit 0
}
} catch {}
Start-Sleep -Seconds 3
}
Write-Host "Health check timed out. Rolling back..."
Stop-Service PMS -Force
cmd /c rmdir $currentJunction
cmd /c mklink /J $currentJunction $oldVersionPath
Start-Service PMS
Write-Error "Upgrade failed, rolled back to $oldVersionPath"
exit 2
数据库回滚边界
⚠️ 这是最容易踩坑的点。
EF Core 的迁移是单向的 。向前迁移(MigrateAsync)在 SQLite 上:
- 单条迁移在事务里(加列、建表都是事务性的)。
- 跨迁移没有事务。第 5 个迁移 commit 了,第 6 个失败,前 5 个不会回滚。
- SQLite 3.35.0 以前不支持 DROP COLUMN,也不支持收缩表。
所以:
- 程序可以回滚:把 junction 切回旧版本目录即可。
- 数据库不能回滚:已经 commit 的迁移没法撤销。
兼容策略(推荐):
- 迁移前备份 pms.db(前面启动自检已经做了)。
- 迁移失败:还原备份 → 旧版程序启动。
- 迁移成功但新程序启动失败 (健康检查超时):旧版程序启动,但数据库 schema 已经是新版本。此时需要:
- 方案 A(简单) :旧版程序检测到 schema 版本高于预期,自动进入只读模式,所有写操作返回 503。岸基介入后决定是回滚数据还是强制升级。
- 方案 B(复杂) :写一个"桥接版本"程序,能同时兼容新旧 schema,作为过渡。这要求每个版本升级都写一套兼容代码,不推荐。
实际工程里我们用方案 A。只读模式下:
- 船员可以查询库存、查看历史,但不能下采购单、不能出入库。
- 程序通过健康检查端点暴露
schema_compatible=false,岸基监控看板立刻报警。 - 岸基派单,远程/上船修复。
增量包与断点续传
150 MB 的全量包对卫星链路是负担。增量包是必须的。
delta 文件格式 :用 bsdiff 或其 .NET 实现。bsdiff 生成的 patch 通常只有全量的 5~15%。
断点续传:
- HTTP 用
Range: bytes=xxx-头。 - 卫星通道如果是自研协议,必须支持续传------否则一次雨衰就让 40 分钟的传输白费。
- USB 通道直接全量拷,不需要续传。
csharp
public async Task DownloadWithResumeAsync(
string url, string destPath,
IProgress<long> progress, CancellationToken ct)
{
long existingSize = File.Exists(destPath)
? new FileInfo(destPath).Length : 0;
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (existingSize > 0)
request.Headers.Range = new RangeHeaderValue(existingSize, null);
using var response = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, ct);
// 服务端不支持 Range,从头开始
if (response.StatusCode == System.Net.HttpStatusCode.OK
&& existingSize > 0)
{
existingSize = 0;
File.Delete(destPath);
}
else if (response.StatusCode
!= System.Net.HttpStatusCode.PartialContent)
{
response.EnsureSuccessStatusCode();
}
var mode = existingSize > 0 ? FileMode.Append : FileMode.Create;
await using var fs = new FileStream(destPath, mode, FileAccess.Write);
await using var stream = await response.Content.ReadAsStreamAsync(ct);
var buffer = new byte[81920];
int bytesRead;
long totalRead = existingSize;
while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0)
{
await fs.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
totalRead += bytesRead;
progress.Report(totalRead);
}
}
USB + 卫星双通道
升级器必须支持两种来源:
csharp
public enum UpgradeChannel
{
Usb, // D:\upgrade\ 或可移动磁盘
Satellite, // HTTP(S) 岸基端点,支持断点续传
Manual // 船员手动拖入升级包到指定目录
}
策略优先级:USB > Manual > Satellite。USB 零成本、零风险(不占用卫星带宽),船员每次回港岸基就把最新升级包塞 U 盘里。
升级演练脚本
每半年必须做一次升级演练。演练脚本:
powershell
# upgrade-drill.ps1
param([string]$FromVersion = "1.4.0",
[string]$ToVersion = "1.4.2")
# 准备:安装旧版本
& .\install-clean.ps1 -Version $FromVersion
# 生成测试数据
& .\seed-test-data.ps1 -Records 10000
# 执行升级
$sw = [System.Diagnostics.Stopwatch]::StartNew()
& .\upgrade.ps1 -NewVersion $ToVersion
$sw.Stop()
# 验证数据完整性
& .\verify-data.ps1 -ExpectedRecords 10000
# 测试回滚
& .\rollback.ps1 -ToVersion $FromVersion
& .\verify-data.ps1 -ExpectedRecords 10000
Write-Host "Drill completed in $($sw.Elapsed)"
演练记录要存档,包括:升级时间、回滚时间、数据完整性验证结果、任何异常。
船岸版本兼容与强制升级闸门 {#船岸版本兼容}
客户端版本遥测
每次船岸握手(前序篇目《船岸同步链路》描述的报文),都在 envelope 里带上客户端版本:
json
{
"envelopeVersion": 2,
"messageId": "a1b2c3d4...",
"shipId": "SHIP01",
"clientVersion": "1.4.2",
"schemaVersion": 9,
"timestamp": "2026-09-20T08:30:00Z",
"payload": { "..." : "..." }
}
岸基最低兼容版本矩阵
岸基维护一张版本兼容表:
| 客户端版本 | 最低支持 | 推荐版本 | 强制升级截止日 |
|---|---|---|---|
| 1.4.2 | ✅ | ✅ | - |
| 1.4.1 | ✅ | ⚠️ 推荐升级 | 2026-12-31 |
| 1.4.0 | ⚠️ 仅接收 | ❌ | 2026-10-31 |
| 1.3.0 | ❌ 拒绝 | ❌ | 已过 |
| ≤1.2.x | ❌ 拒绝 | ❌ | 已过 |
强制升级闸门
当客户端版本低于 minimumUpgradableVersion 或超过强制升级截止日:
- 岸基拒绝同步 :返回 HTTP 426 Upgrade Required,body 里带
minimumVersion和upgradePackageUrl。 - 船端显示升级提示:UI 上弹"请联系岸基获取升级包"。
- 船端降级为本地模式:停止尝试同步,所有操作本地进行,等升级后重新对账。
csharp
public class VersionGateMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext ctx)
{
var env = ctx.RequestServices.GetRequiredService<IEnvelopeParser>();
var version = env.GetClientVersion(ctx);
if (VersionPolicy.IsTooOld(version, out var minimum))
{
ctx.Response.StatusCode = 426;
await ctx.Response.WriteAsJsonAsync(new
{
error = "client_version_too_old",
minimumVersion = minimum,
upgradePackageUrl = $"/api/upgrade/{minimum}"
});
return;
}
await _next(ctx);
}
}
配置与密钥:appsettings 不被升级覆盖 {#配置与密钥}
配置加载顺序
csharp
.ConfigureAppConfiguration((ctx, cfg) =>
{
cfg.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json",
optional: true, reloadOnChange: true)
.AddJsonFile("appsettings.Development.json", optional: true)
// 数据目录的覆盖配置(DPAPI 保护)
.AddJsonFile(
Path.Combine(DataDir, "appsettings.override.json"),
optional: true, reloadOnChange: true)
.AddEnvironmentVariables("PMS_")
.AddCommandLine(args);
})
升级只替换 D:\app\current\ ,数据目录的 appsettings.override.json 永远不被碰。
DPAPI 保护连接串
前序篇目《SQLite 船端运维》已详述 DPAPI。核心代码(包引用见 System.Security.Cryptography.ProtectedData):
csharp
using System.Security.Cryptography;
public static class ConfigProtector
{
public static string Protect(string plaintext)
{
var bytes = Encoding.UTF8.GetBytes(plaintext);
var protectedBytes = ProtectedData.Protect(
bytes,
additionalEntropy: null,
DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public static string Unprotect(string ciphertext)
{
var protectedBytes = Convert.FromBase64String(ciphertext);
var bytes = ProtectedData.Unprotect(
protectedBytes,
additionalEntropy: null,
DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(bytes);
}
}
⚠️ DPAPI 与用户账号绑定 :服务账号 PmsSvc 加密的密文,必须在同一个账号下才能解密。不要用 LocalSystem 加密、PmsSvc 解密------会失败。
⚠️ DPAPI 与机器绑定:换机器后密文无法解密。首次部署时需要在目标机器上重新加密连接串(一次性脚本)。
可观测性离线化:日志、minidump、watchdog {#可观测性离线化}
本地滚动日志
csharp
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore",
LogEventLevel.Warning)
.WriteTo.File(
path: Path.Combine(LogDir, "pms-.ndjson"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 31,
fileSizeLimitBytes: 100_000_000,
rollOnFileSizeLimit: true,
formatter: new Serilog.Formatting.Compact
.CompactJsonFormatter())
.WriteTo.EventLog(
source: "PMS",
manageEventSource: false,
restrictedToMinimumLevel: LogEventLevel.Warning)
.CreateLogger();
要点:
- NDJSON 格式(每行一个 JSON):方便 grep、方便后续岸基集中解析。
- 滚动策略:按天 + 按大小,保留 31 天。
- 双写:文件 + Windows 事件日志。事件日志是给本地运维看的,文件是给岸基回传看的。
崩溃 minidump
csharp
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
var dumpPath = Path.Combine(CrashDir,
$"crash_{DateTime.Now:yyyyMMdd_HHmmss}.dmp");
try
{
using var fs = File.Create(dumpPath);
var process = Process.GetCurrentProcess();
MiniDumpWriteDump(process.Handle, process.Id,
fs.SafeFileHandle,
MiniDumpType.WithFullMemory,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
}
catch (Exception ex)
{
Log.Fatal(ex, "Failed to write minidump");
}
};
minidump 文件保留 90 天,联网后岸基可以下载分析。相关工具参考 dotnet-dump 文档。
健康检查 + 看门狗
健康检查端点 (前序篇目已有,详见 ASP.NET Core 健康检查):
csharp
app.MapHealthChecks("/healthz", new HealthCheckOptions
{
ResponseWriter = async (ctx, report) =>
{
ctx.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
schemaVersion = SchemaVersion.Current,
clientVersion = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion,
uptime = DateTime.UtcNow
- Process.GetCurrentProcess().StartTime.ToUniversalTime(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description
})
};
await ctx.Response.WriteAsJsonAsync(result);
}
});
Watchdog:一个独立的、极简的小程序,职责就是:
- 每 30 秒探活
/healthz。 - 连续 3 次失败,尝试重启服务。
- 重启失败 3 次,发告警(本地蜂鸣 + 事件日志 + 联网后上报岸基)。
csharp
class Watchdog : BackgroundService
{
private readonly HttpClient _http = new();
protected override async Task ExecuteAsync(CancellationToken ct)
{
int failCount = 0;
while (!ct.IsCancellationRequested)
{
try
{
var resp = await _http.GetAsync(
"http://localhost:5100/healthz", ct);
if (resp.IsSuccessStatusCode)
{
failCount = 0;
}
else
{
failCount++;
if (failCount >= 3)
{
RestartPmsService();
failCount = 0;
}
}
}
catch { failCount++; }
await Task.Delay(TimeSpan.FromSeconds(30), ct);
}
}
}
Watchdog 自己也是 Windows Service,故障恢复策略同 PMS。
联网后日志回传
船靠港或卫星窗口打开时,PMS 把本地日志打包回传:
csharp
public class LogShipper : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (!await IsNetworkAvailableAsync(ct))
{
await Task.Delay(TimeSpan.FromMinutes(5), ct);
continue;
}
var unsentLogs = Directory.GetFiles(LogDir, "pms-*.ndjson")
.Where(f => !File.Exists(f + ".shipped"))
.OrderBy(f => f)
.ToList();
foreach (var log in unsentLogs)
{
using var content = new MultipartFormDataContent();
content.Add(new StreamContent(File.OpenRead(log)),
"file", Path.GetFileName(log));
var resp = await _http.PostAsync(
$"{_shoreBaseUrl}/api/logs/{_shipId}", content, ct);
if (resp.IsSuccessStatusCode)
{
File.WriteAllText(log + ".shipped",
DateTime.UtcNow.ToString("O"));
}
else
{
break; // 网络可能不稳,下次再试
}
}
await Task.Delay(TimeSpan.FromHours(1), ct);
}
}
}
岸基有版本看板,能看到每条船的客户端版本、schema 版本、最后在线时间、最近错误日志。
灰度:单船试点到船队分批 {#灰度}
灰度策略
30 条船的船队,升级节奏:
- 第 1 周:挑 1 条"试验船"(通常是最小、航线最稳定、船长最配合的),升级并观察 7 天。
- 第 2-3 周:如果试验船无问题,扩展到 5 条船(不同航线、不同机型、不同船员配置)。
- 第 4-6 周:扩展到 15 条船。
- 第 7-8 周:全船队推广。
灰度指标
| 指标 | 阈值 | 触发动作 |
|---|---|---|
| 升级成功率 | ≥ 95% | 低于则暂停灰度,排查原因 |
| 升级后 24h 内崩溃率 | ≤ 1% | 高于则强制回滚 |
| 升级后 7 天内回滚率 | ≤ 5% | 高于则暂停,岸基复盘 |
| 启动时间 p95 | ≤ 5s | 高于则检查机器性能 / 库大小 |
| 健康检查失败率 | ≤ 0.1% | 高于则触发 watchdog 告警 |
回滚决策
回滚决策矩阵:
- 数据损坏:立即回滚 + 通知岸基 + 备份当前数据库用于事后分析。
- 启动崩溃:SCM 自动回滚(如果升级器实现了);否则手动回滚 junction。
- 业务逻辑错误:视严重程度。如果只是非关键功能,等下一个补丁;如果阻塞核心业务(比如无法下采购单),立即回滚。
发布 Checklist {#发布-checklist}
发布前
- 所有单元测试通过
- 集成测试通过(含 EF Core 迁移测试)
- 在测试船(或模拟器)上跑过升级演练脚本
- manifest.json 字段齐全:packageVersion / minimumUpgradableVersion / requiredSchemaVersion / SHA256
- 数字签名(可选)已应用
- 增量包已生成并校验(bsdiff patch apply 后与全量包 SHA256 一致)
- 发布说明(release notes)已写
- 船岸版本兼容矩阵已更新
发布时
- 试验船升级成功,24h 观察无问题
- 灰度批次按策略推进
- 每条船升级后岸基看板确认客户端版本
- 健康检查通过
- 数据库 schema 版本与客户端版本匹配
发布后
- 收集 7 天内所有船的指标:崩溃率、回滚率、启动时间
- 复盘会议
- 归档升级演练记录
- 更新运维手册
15 条踩坑 {#15-条踩坑}
💬 以下每一条都是真实踩过的坑,不是理论推导。
- 杀毒软件锁文件 :Windows Defender 在扫描 exe 时会加共享锁,导致 junction 切换失败。解决 :把
D:\app\加入 Defender 排除列表(Exclusions)。 - 8.3 短路径 :老船员习惯用
D:\PROGRA~1\这种短路径。解决 :在程序里统一用Path.GetFullPath规范化。 - 中文用户名路径 :
C:\Users\张三\AppData\...在某些 .NET API 里会出问题。解决 :所有路径走Environment.GetFolderPath,不要自己拼。 - 电源策略睡眠 :笔记本默认 15 分钟无操作睡眠,PMS 进程被挂起。解决 :部署脚本里配置"从不睡眠"(
powercfg -change standby-timeout-ac 0)。 - 磁盘空间不足 :船端笔记本通常只有 120 GB SSD,日志 + 备份吃空间。解决 :日志保留 31 天、备份保留 14 天、定期
VACUUM INTO释放空间。 - 时区混乱 :船跨时区,
DateTime.Now每天漂移。解决 :全部用DateTime.UtcNow,UI 层再按航区时区显示(前序篇目《跨时区时间》已详述)。 - 服务账号权限不足 :PmsSvc 没给
D:\data\写权限,启动直接崩。解决 :部署脚本里显式icacls D:\data /grant PmsSvc:(OI)(CI)F。 - 事件日志源创建失败 :非管理员账号不能创建事件日志源。解决 :安装时以管理员跑一次注册事件日志源;或者服务启动时 catch
SecurityException并降级。 - junction 解析错误 :
mklink /J的 target 必须用绝对路径,相对路径会解析到C:\Windows\System32\下去。解决:脚本里一律用绝对路径。 - 升级包下载被防火墙拦 :某些船的卫星链路有透明代理,HTTPS 证书校验失败。解决:升级器支持配置自定义 CA 证书。
- SQLite WAL 文件未清理 :升级时只拷了
pms.db,没拷.db-wal、.db-shm,新程序启动后丢了未 checkpoint 的数据。解决 :备份和恢复都拷三个文件;迁移前先PRAGMA wal_checkpoint(TRUNCATE)。 - EF Core 迁移超时 :船端机器慢,
MigrateAsync跑 2 分钟。解决:启动自检的迁移步骤设 60s 超时,超时进只读模式;大迁移拆成多个小迁移。 - 旧版本卸载不干净 :船员手动删了
D:\app\1.4.0\目录,但没删 junction,导致 junction 失效。解决:升级器清理旧版本前先检查有没有其他 junction 指向它。 - 服务账号密码过期 :LocalUser 默认密码永不过期,但组策略可能覆盖。解决 :创建用户时显式
-PasswordNeverExpires。 - 单文件解压目录权限 :
%TEMP%\.net目录被其他用户写入,单文件解压后的 native 库被替换(安全风险)。解决 :设置DOTNET_BUNDLE_EXTRACT_BASE_DIR=D:\app\extract,并给服务账号独占权限。
下一站 {#下一站}
下一篇我们将进入库存盘点与账实核对:盘点单(循环盘 / 年度盘)、盘盈盘亏的过账逻辑、与 append-only 流水账的衔接、负库存与"账面数 vs 实点数"差异的处理。
参考资料 {#参考资料}
- .NET 支持策略 - .NET | Microsoft Learn
https://dotnet.microsoft.com/zh-cn/platform/support/policy - Announcing .NET 10 - .NET Blog
https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/ - 单文件部署 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/deploying/single-file/overview - .NET 应用程序发布概述 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/deploying/ - .NET 运行时标识符 (RID) 目录 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/rid-catalog - ReadyToRun 部署概述 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/deploying/ready-to-run - 精简独立应用程序 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/deploying/trimming/trim-self-contained - Native AOT deployment - .NET | Microsoft Learn
https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/ - NativeAOT 支持和预编译查询 (实验性) - EF Core | Microsoft Learn
https://learn.microsoft.com/zh-cn/ef/core/performance/nativeaot-and-precompiled-queries - 使用 BackgroundService 创建 Windows 服务 - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/extensions/windows-service - Host ASP.NET Core in a Windows Service
https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/windows-service - Mutex 类 (System.Threading) | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.mutex - mklink | Microsoft Learn
https://learn.microsoft.com/zh-cn/windows-server/administration/windows-commands/mklink - ASP.NET Core 中的健康检查 | Microsoft Learn
https://learn.microsoft.com/zh-cn/aspnet/core/host-and-deploy/health-checks - dotnet-dump 诊断工具 - .NET CLI - .NET | Microsoft Learn
https://learn.microsoft.com/zh-cn/dotnet/core/diagnostics/dotnet-dump - Microsoft.Extensions.Hosting.WindowsServices - NuGet
https://www.nuget.org/packages/Microsoft.Extensions.Hosting.WindowsServices - Serilog.Sinks.File - NuGet
https://www.nuget.org/packages/Serilog.Sinks.File - System.Security.Cryptography.ProtectedData - NuGet
https://www.nuget.org/packages/System.Security.Cryptography.ProtectedData
💬 互动时间:
船端部署这个场景比岸基苛刻得多------离线、断电、无人运维、卫星链路贵到肉疼。本文给出的"self-contained + single-file + ReadyToRun + junction 原子切换 + watchdog + 强制升级闸门"是一套完整方案,但肯定不是唯一解。
你在你的项目里是怎么处理离线部署 / 离线升级的?有没有更优雅的"数据库向前迁移后旧版程序兼容"的方案?欢迎在评论区交流。
记得订阅。