.NET 10 船端单机部署与离线升级实战:从闪退事故到无感回滚


目录


事故开篇 {#事故开篇}

事故一:运行时没装,启动直接闪退

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 条再决定要不要往下读。

  1. 永远 self-contained:船端不要相信"对方机器有运行时"。
  2. 永远单文件 + ReadyToRun:减少文件被误删 / 被杀毒软件锁定的概率;ReadyToRun 让冷启动从 4s 降到 1.2s。
  3. 永远不做 Native AOT(当前阶段):EF Core 的 Native AOT 支持仍被官方标注为"高度实验性",动态查询、LINQ 查询语法、捕获状态的值转换器都不支持------PMS 这种业务复杂度上了 AOT 等于自找麻烦。
  4. 永远四目录分离:程序目录 / 数据目录 / 日志目录 / 备份目录绝不混放。升级覆盖程序目录,不影响数据和日志。
  5. 永远 Windows Service:无人登录也能跑,故障自动重启,断电复电自启动。
  6. 永远命名 Mutex 守单实例Local\PMS_SingleInstance_v1,第二个实例被直接劝退。
  7. 启动自检五步编排:探活 → 必要时 VACUUM INTO 备份 → MigrateAsync → schema 闸门 → 失败还原 + 只读降级。
  8. 升级包结构标准化:manifest.json + SHA256 + 最低可升级版本 + 数字签名(可选)。
  9. 原子切换靠 junction + launcher:新版本拷贝到新目录 → 校验 → 停服务 → 切 junction → 启动 → 健康检查失败自动回滚。
  10. 数据库只能向前,程序可以回滚:向前迁移的库无法回滚时,旧版程序以只读模式启动 + 备份库保留,等岸基介入。

发布形态决策表 {#发布形态决策表}

.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%\.netDOTNET_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),有以下硬限制:

  1. 动态查询不支持 :运行期组合 Where()、条件拼接 IQueryable 都编译不过。PMS 里大量"按条件筛选物料 / 采购单"的场景都涉及动态查询。
  2. LINQ 查询表达式语法(from ... where ... select)不支持
  3. 使用捕获状态的值转换器不支持
  4. 生成的代码体积大、编译时间长
  5. 发布时会产生大量 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):

  1. 把 host lifetime 切成 WindowsServiceLifetime(阻塞主线程直到 SCM 通知停止)。
  2. 把 content root 切到 AppContext.BaseDirectory
  3. 把日志接到 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 IMMEDIATESQLITE_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 是两个不同的东西:

  • 单实例 MutexLocal\PMS_SingleInstance_v1,保证同一时刻只有一个 PMS 进程。
  • 迁移 MutexGlobal\PMS_Migration_v1(或文件锁),保证同一时刻只有一个进程在 MigrateAsync

船端单实例前提下,迁移 Mutex 其实可以退化为进程内锁------因为反正只有一个进程。但保留这个设计有两个好处:

  1. 开发机调试:多个实例同时跑时仍然安全。
  2. 未来扩展:如果哪天船端变成多进程(比如前端 / 后端分离),迁移逻辑不需要改。

启动自检与迁移编排 {#启动自检}

💡 本篇讲编排,不讲原理。迁移的细节(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 版本。启动自检时用来做闸门。
  • autoUpgradePolicyrequired(强制升级,到期停用旧版)/ 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 的迁移没法撤销。

兼容策略(推荐)

  1. 迁移前备份 pms.db(前面启动自检已经做了)。
  2. 迁移失败:还原备份 → 旧版程序启动。
  3. 迁移成功但新程序启动失败 (健康检查超时):旧版程序启动,但数据库 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 或超过强制升级截止日:

  1. 岸基拒绝同步 :返回 HTTP 426 Upgrade Required,body 里带 minimumVersionupgradePackageUrl
  2. 船端显示升级提示:UI 上弹"请联系岸基获取升级包"。
  3. 船端降级为本地模式:停止尝试同步,所有操作本地进行,等升级后重新对账。
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 {#可观测性离线化}

本地滚动日志

使用 Serilog.Sinks.File

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 周:挑 1 条"试验船"(通常是最小、航线最稳定、船长最配合的),升级并观察 7 天。
  2. 第 2-3 周:如果试验船无问题,扩展到 5 条船(不同航线、不同机型、不同船员配置)。
  3. 第 4-6 周:扩展到 15 条船。
  4. 第 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-条踩坑}

💬 以下每一条都是真实踩过的坑,不是理论推导

  1. 杀毒软件锁文件 :Windows Defender 在扫描 exe 时会加共享锁,导致 junction 切换失败。解决 :把 D:\app\ 加入 Defender 排除列表(Exclusions)。
  2. 8.3 短路径 :老船员习惯用 D:\PROGRA~1\ 这种短路径。解决 :在程序里统一用 Path.GetFullPath 规范化。
  3. 中文用户名路径C:\Users\张三\AppData\... 在某些 .NET API 里会出问题。解决 :所有路径走 Environment.GetFolderPath,不要自己拼。
  4. 电源策略睡眠 :笔记本默认 15 分钟无操作睡眠,PMS 进程被挂起。解决 :部署脚本里配置"从不睡眠"(powercfg -change standby-timeout-ac 0)。
  5. 磁盘空间不足 :船端笔记本通常只有 120 GB SSD,日志 + 备份吃空间。解决 :日志保留 31 天、备份保留 14 天、定期 VACUUM INTO 释放空间。
  6. 时区混乱 :船跨时区,DateTime.Now 每天漂移。解决 :全部用 DateTime.UtcNow,UI 层再按航区时区显示(前序篇目《跨时区时间》已详述)。
  7. 服务账号权限不足 :PmsSvc 没给 D:\data\ 写权限,启动直接崩。解决 :部署脚本里显式 icacls D:\data /grant PmsSvc:(OI)(CI)F
  8. 事件日志源创建失败 :非管理员账号不能创建事件日志源。解决 :安装时以管理员跑一次注册事件日志源;或者服务启动时 catch SecurityException 并降级。
  9. junction 解析错误mklink /J 的 target 必须用绝对路径,相对路径会解析到 C:\Windows\System32\ 下去。解决:脚本里一律用绝对路径。
  10. 升级包下载被防火墙拦 :某些船的卫星链路有透明代理,HTTPS 证书校验失败。解决:升级器支持配置自定义 CA 证书。
  11. SQLite WAL 文件未清理 :升级时只拷了 pms.db,没拷 .db-wal.db-shm,新程序启动后丢了未 checkpoint 的数据。解决 :备份和恢复都拷三个文件;迁移前先 PRAGMA wal_checkpoint(TRUNCATE)
  12. EF Core 迁移超时 :船端机器慢,MigrateAsync 跑 2 分钟。解决:启动自检的迁移步骤设 60s 超时,超时进只读模式;大迁移拆成多个小迁移。
  13. 旧版本卸载不干净 :船员手动删了 D:\app\1.4.0\ 目录,但没删 junction,导致 junction 失效。解决:升级器清理旧版本前先检查有没有其他 junction 指向它。
  14. 服务账号密码过期 :LocalUser 默认密码永不过期,但组策略可能覆盖。解决 :创建用户时显式 -PasswordNeverExpires
  15. 单文件解压目录权限%TEMP%\.net 目录被其他用户写入,单文件解压后的 native 库被替换(安全风险)。解决 :设置 DOTNET_BUNDLE_EXTRACT_BASE_DIR=D:\app\extract,并给服务账号独占权限。

下一站 {#下一站}

下一篇我们将进入库存盘点与账实核对:盘点单(循环盘 / 年度盘)、盘盈盘亏的过账逻辑、与 append-only 流水账的衔接、负库存与"账面数 vs 实点数"差异的处理。


参考资料 {#参考资料}

  1. .NET 支持策略 - .NET | Microsoft Learn
    https://dotnet.microsoft.com/zh-cn/platform/support/policy
  2. Announcing .NET 10 - .NET Blog
    https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/
  3. 单文件部署 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/deploying/single-file/overview
  4. .NET 应用程序发布概述 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/deploying/
  5. .NET 运行时标识符 (RID) 目录 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/rid-catalog
  6. ReadyToRun 部署概述 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/deploying/ready-to-run
  7. 精简独立应用程序 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/deploying/trimming/trim-self-contained
  8. Native AOT deployment - .NET | Microsoft Learn
    https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/
  9. NativeAOT 支持和预编译查询 (实验性) - EF Core | Microsoft Learn
    https://learn.microsoft.com/zh-cn/ef/core/performance/nativeaot-and-precompiled-queries
  10. 使用 BackgroundService 创建 Windows 服务 - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/extensions/windows-service
  11. Host ASP.NET Core in a Windows Service
    https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/windows-service
  12. Mutex 类 (System.Threading) | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.mutex
  13. mklink | Microsoft Learn
    https://learn.microsoft.com/zh-cn/windows-server/administration/windows-commands/mklink
  14. ASP.NET Core 中的健康检查 | Microsoft Learn
    https://learn.microsoft.com/zh-cn/aspnet/core/host-and-deploy/health-checks
  15. dotnet-dump 诊断工具 - .NET CLI - .NET | Microsoft Learn
    https://learn.microsoft.com/zh-cn/dotnet/core/diagnostics/dotnet-dump
  16. Microsoft.Extensions.Hosting.WindowsServices - NuGet
    https://www.nuget.org/packages/Microsoft.Extensions.Hosting.WindowsServices
  17. Serilog.Sinks.File - NuGet
    https://www.nuget.org/packages/Serilog.Sinks.File
  18. System.Security.Cryptography.ProtectedData - NuGet
    https://www.nuget.org/packages/System.Security.Cryptography.ProtectedData

💬 互动时间

船端部署这个场景比岸基苛刻得多------离线、断电、无人运维、卫星链路贵到肉疼。本文给出的"self-contained + single-file + ReadyToRun + junction 原子切换 + watchdog + 强制升级闸门"是一套完整方案,但肯定不是唯一解。

你在你的项目里是怎么处理离线部署 / 离线升级的?有没有更优雅的"数据库向前迁移后旧版程序兼容"的方案?欢迎在评论区交流。

记得订阅。

相关推荐
淡海水2 小时前
11-04-Unity-Span-foreach-LINQ的分配与热路径边界
unity·c#·linq·foreach·span
慧都小妮子2 小时前
Word/Excel/PPT 如何稳定导出 Markdown?文档 SDK 三线能力拆解
.net·markdown·知识库·aspose·rag·文档转换·文档互操作
唐青枫15 小时前
对象到底住在哪里?C#.NET 托管堆从分配、GC 到内存排查
c#·.net
fogota17 小时前
Emoji与Segoe MDL2 Assets的比较
c#·wpf
灸木18 小时前
C# 多线程与异步
c#
鱼听禅1 天前
C# 学习笔记-使用 类型化客户端搭建HTTP客户端服务
学习·http·c#·工厂方法模式
geovindu1 天前
CSharp:Condition Variable Pattern
后端·设计模式·c#·.net·.netcore·条件变量模式·同步型模式
淡海水1 天前
11-03-Unity-List-T-和Dictionary-TKey-TValue-的性能调优实战
算法·unity·c#·list·dictionary
de之梦-御风1 天前
【未来】.NET 开发在「劳动价值重估」下的定位与走向
职场和发展·.net