Project NOMAD 更新机制全指南:软件核心、Apps 与离线内容的三线更新实战
Project NOMAD 是一台离线优先(offline-first)的知识与教育服务器,它的核心设计哲学是:在有网时把软件与内容更新到最新,到无网环境后一切立即可用。本文以项目官方文档 admin/docs/updates.md 为主体,结合仓库中自动更新服务、定时任务、更新调度页面等源码,完整讲解三类可更新对象(软件核心 / Supply Depot 应用 / Wikipedia·ZIM·地图内容)的手动更新步骤与自动更新配置,并逐层拆解“时间窗口 + 冷却期 + 主版本不放行 + 安全预检 + 失败自禁用”这套自动更新决策管线的实现细节,帮助自托管者在掌握行为边界的前提下把它调教到“省心且不失控”。
一、先建立心智模型:三类更新,各自独立控制
在 Project NOMAD 中,“更新”不是单一动作,而是三个相互独立、可分别控制的通道。只有先分清它们,才能正确使用后面的所有配置:
| 类别 | 指向对象 | 触发入口(UI) | 典型体量 |
|---|---|---|---|
| 软件核心(Software / Core) | Command Center、新功能、Bug 修复与安全改进 | Settings → Check for Updates / Updates | 镜像级更新,通常 2–5 分钟 |
| 应用(Apps) | Supply Depot 中可安装的应用(Kiwix、AI Assistant 等) | Supply Depot 各应用卡片 Manage → Update | 容器镜像更新 |
| 内容(Content) | 已安装的 Wikipedia 等 Kiwix 库、下载的地图区域 | Settings 中的内容管理/内容浏览页 | ZIM 常达数 GB |
三者都可以“手动触发”,也可以各自“设为自动更新”。把“有网时保持最新”作为习惯,才能让离线体验始终处于“最后同步到的最新版本”。
前端设置入口统一收敛在 admin/inertia/pages/settings/update.tsx(对应 UI 导航 Settings → Update),供应链/应用商店入口见 admin/inertia/pages/supply-depot.tsx。
二、手动更新:按需检查与安装
1. 软件核心更新与失败回滚
手动升级核心的路径在 Settings → Check for Updates:若检测到新版本,点击安装即可,NOMAD 会下载更新并重启容器(通常 2–5 分钟)。该页面的前端实现把整个流程建模成一段可轮询的阶段状态机,状态标签见 admin/inertia/pages/settings/update.tsx:
idle → starting → pulling → pulled → recreating → complete
↘ error
pulling / pulled:拉取所有核心容器的新 Docker 镜像;recreating:安全地停止并重建核心容器;complete:前端检测完成后自动重新加载页面,并用checkLatestVersion清除 KVStore 中陈旧的“有可用更新”标记,避免出现 “current → current” 的假横幅。
页面实现中有两个值得注意的工程细节:
- 轮询容错:更新期间 admin 容器会重启导致连接中断,前端轮询捕获失败后并不报错,而是显示“连接暂时中断(符合预期)”的提示并继续轮询,直到容器恢复。
- 阶段竞态处理:sidecar 停留在
complete阶段只有约 5 秒(见 install/sidecar-updater/update-watcher.sh),SPA 在 admin 容器重启时可能错过该窗口;因此前端用seenAdvancedStageRef记录是否见过高级阶段,一旦从recreating等阶段回到idle,就视为“错过窗口的完成事件”而非失败。
页面同时提供 “View Update Logs”,拉取真实更新日志(后端接口在 admin/app/controllers/system_controller.ts,对应路由注册见 admin/start/routes.ts)。官方给出的三点执行预期也写在 UI 上:先拉镜像 → 再重建容器 → 完成后页面自动刷新,并提示用户“更新前建议备份关键数据”“更新期间服务会短暂不可用”。
关键的安全承诺是优雅降级:即使软件或应用更新失败,之前正常工作的版本会继续运行,服务器不会因此停机。
2. Apps 手动更新
应用在 Supply Depot 各自的应用卡片上通过 Manage → Update 更新。与“核心由 sidecar 代为重建”不同,兄弟应用由 admin 容器直接操作 Docker:从源码看,DockerService.updateContainer 走的是进程内 pull → 重命名替换 → 健康检查 → 必要时回滚的更新链路(参见 admin/app/services/docker_service.ts),应用级更新不需要 sidecar。
3. 内容手动更新(ZIM 与地图)
内容更新由 Settings 下的内容管理与内容浏览界面承载,可对已安装的 Kiwix 库与地图下载更新版本。在 Settings → Update 页面内,同时内置了一个 Manual Content Updates 面板(组件见 admin/inertia/components/updates/ContentUpdatesSection.tsx),其工作流为:
- 点击 Check for Content Updates,调用
GET /api/content-updates检查通道(由 admin/app/controllers/collection_updates_controller.ts 提供),对照上游 Kiwix / 地图目录逐一比对已安装资源版本; - 结果以表格呈现:标题、类型(ZIM/Map)、新版本体积、
installed_version → latest_version,用户可对单个资源点 Update,也可一键 Update All; - 更新被投递为可断点续传的下载任务(组件会主动使下载列表失效,让过小的更新也能立即出现在 Active Downloads 中,而不是等待空闲轮询)。
三、自动更新的共同安全边界
NOMAD 的自动更新默认全部关闭、必须显式开启(opt-in)——在你打开之前,任何东西都不会自行更新。三种自动更新共享如下设计原则,全部集中在 Settings → Updates 管理:
- 时间窗口自选:自动更新只在设定的时段内执行,避免打断使用(窗口取容器本地时间,通过 TZ 环境变量指定);
- 大版本绝不自动:只有 minor / patch 版本才会自动生效,主版本跳跃始终留给你手动执行,这是有意的设计;
- 安全预检先行:应用任何更新前,NOMAD 都会确认磁盘空间充足、且当前没有其他更新/下载/安装正在执行;
- 离线无害:若无法联网检查,就跳过本轮,稍后重试,绝不报错中断。
这三条边界不只是在文档层面承诺,而是在代码里被严格实现为一条**“决策管线”。核心服务 admin/app/services/auto_update_service.ts 的注释将其概括为:服务本身“不重建容器”,只负责决策**——是否启用、是否在窗口内、是否存在已过冷却期的 minor/patch 版本、预检是否通过——决策通过后带着校验过的镜像 tag 驱动既有更新通道,实际的容器重建由 sidecar 完成。通用判定产出是一个无副作用的 AutoUpdateDecision:
disabled | outside-window | eligibility-error | no-eligible | blocked | ready
evaluate() 是无副作用的决策核心,attempt() 与 dryRun() 都建立在它之上,因此干跑(dry-run)能忠实地反映真实运行会做什么。三个 dry-run 命令入口见 admin/commands/auto_update/dry_run.ts、admin/commands/app_auto_update/dry_run.ts、admin/commands/content_auto_update/dry_run.ts。由于决策器支持注入“当前版本、release 列表、固定时钟、强制窗口、假预检”等输入,自托管者可以在实施自动更新前精确模拟任意场景。
底层判定依赖的通用工具包括:
- 窗口判断 admin/app/utils/update_window.ts:支持
HH:MM24 小时制解析与跨午夜窗口(如 22:00–02:00,即start > end时取current >= start || current < end);起止相同视为零长度窗口返回 false。 - 版本比较与主版本解析 admin/app/utils/version.ts:主版本相同性判断是“大版本不自动”的直接依据。
- 镜像磁盘预检 admin/app/utils/image_disk_preflight.ts:把所有阻断因素分成 skip(瞬时,下一窗口自动重试、不计惩罚) 与 failure(真故障,计入退避,最终导致自动禁用) 两类。
四、自动软件(核心)更新
在 Settings → Updates 打开开关后,NOMAD 会在你设定的窗口内、经过可配置的**冷却期(cool-off)**之后,把核心升级到同主版本内的新版本。同一页面集中展示:开关、窗口、冷却期与实时状态。UI 实现见 admin/inertia/components/updates/CoreAutoUpdateSection.tsx,对应 KV 配置键如下:
| 配置键(KVStore) | 含义 | 默认值 |
|---|---|---|
autoUpdate.enabled |
总开关 | false |
autoUpdate.windowStart / windowEnd |
服务器本地时区下的执行窗口 | 02:00 / 05:00 |
autoUpdate.cooloffHours |
新版本发布后延迟生效的小时数 | 72 |
autoUpdate.lastResult / lastError / consecutiveFailures / autoDisabledReason |
运行状态记录与失败退避 | — |
冷却期在 UI 上提供四档:24h(1 天)、48h(2 天)、72h(3 天)、168h(7 天)。设置存储在 KVStore 中,读写实现见 admin/app/models/kv_store.ts。注意代码中的一个细节:未设置时 Number(null) === 0,所以实现会区分“显式填 0”与“未设置”,未设置回落到默认 72 小时,避免静默出现“零冷却”的激进更新。
资格判定(eligibility) 是核心算法的灵魂,逻辑位于 admin/app/services/auto_update_service.ts 的 selectEligibleTarget:
- 过滤掉 draft 与 prerelease——自动更新从不搭乘 Early Access;
- tag 必须是严格 semver(正则
^\d+\.\d+\.\d+$),作为纵深防御:该 tag 会被 sidecar 拼接到宿主侧sed中(见 install/sidecar-updater/update-watcher.sh),畸形 tag 必须无法触达; - 与当前运行版本同主版本、严格更新;
- 发布时间不早于
now - cooloffHours(冷却期从发布时刻起算); - 开发构建(
dev/0.0.0)直接返回无可更新目标。
版本来源是 GitHub Releases 接口,进程内做了两级缓存:成功结果缓存 15 分钟(避免每次打开状态页都命中未认证 API 的限流),失败结果负缓存 60 秒(离线时反复调用不会每次都阻塞在超时上)。
安全预检 五项(源码 runPreflight):
- sidecar 必须可用(否则 core 更新无人执行,属
failure); - 无系统更新正在执行(
skip); - 无内容/模型下载在进行(
skip); - 无应用安装/更新在进行(
skip); - 磁盘空间足够容纳新镜像(依据 Docker 守护进程架构映射到 OCI 命名 amd64/arm64/arm 后查询镜像大小)。
失败退避与自禁用:只有真实的更新请求失败(ready 分支后的失败)才计入连续失败计数;发布源查询失败这类瞬时错误被当作“离线正常态”直接跳过,不触发退避。连续失败达到 3 次(MAX_CONSECUTIVE_FAILURES = 3)后,服务会把 autoUpdate.enabled 置回 false、写入 autoDisabledReason 并在 UI 顶部以警告条展示原因,而不是无限重试。
调度方式:状态页之外,真正驱动它的是一个每小时执行一次的 BullMQ 定时任务 admin/app/jobs/auto_update_job.ts,cron 0 * * * *。设计上窗口可能跨多个小时,因此每小时都评估一次、由 attempt() 自行判断“是否落在窗口内”——窗口只做门禁,不负责叫醒。
五、自动应用更新:双开关与逐应用自禁用
App 自动更新是两级 opt-in:总开关在 Settings → Updates,同时还需在 Supply Depot 每个应用卡片上单独打开 per-app 开关,两者都开启该应用才会自更新。实现见 admin/app/services/app_auto_update_service.ts 与前端 admin/inertia/components/updates/AppAutoUpdateSection.tsx。
关键行为:
- 共享窗口与冷却期:
appAutoUpdate.enabled是独立开关,但窗口/冷却期复用 core 的autoUpdate.windowStart / windowEnd / cooloffHours,避免维护多套时间表; - 仍只升 minor/patch:上游 admin/app/services/container_registry_service.ts 的更新发现本就带“同主版本”过滤,服务内再叠加一道主版本校验作为纵深防御;此外 pinned 到
:latest的应用无法做版本比对,会被标记为不可自动更新; - 应用级预检:全局预检确保没有下载在跑;逐应用预检确保该应用当前无操作进行(
installation_status === 'idle')且磁盘充足; - 逐应用失败退避:单个应用连续失败 3 次后仅自身被禁用(写入该应用的
auto_update_disabled_reason,见 admin/app/models/service.ts 相关字段),其他应用不受影响继续运行——这正对应文档所说的“back off automatically for any individual app that keeps failing”。
执行时通过 DockerService.updateContainer(name, targetVersion) 完成进程内 pull → 替换 → 健康检查 → 回滚的容器更新,调度任务 admin/app/jobs/app_auto_update_job.ts 同样每小时评估一次。
六、自动内容更新:独立窗口、带宽上限与预算调度
已安装的 Wikipedia/ZIM 库与地图区域也可以自动刷新。因为内容下载体积大(常达数 GB),内容更新运行在它自己专属的“夜间”窗口,并配有带宽上限,与软件/应用的时间表完全分离;NOMAD 直接检查上游 Kiwix 与地图目录;当某 Wikipedia 库被替换成新版本时,AI 知识库(Knowledge Base)会自动保持同步。
实现位于 admin/app/services/content_auto_update_service.ts,前端面板见 admin/inertia/components/updates/ContentAutoUpdateSection.tsx。配置键与 UI 表单一一对应:
| 配置键(KVStore) | 含义 | 默认值 / 建议 |
|---|---|---|
contentAutoUpdate.enabled |
内容自动更新总开关 | false |
contentAutoUpdate.windowStart / windowEnd |
内容专用窗口(本地时间) | 02:00 / 05:00 |
contentAutoUpdate.cooloffHours |
新版本出现后的冷却期 | 72 |
contentAutoUpdate.maxBytesPerWindow |
每个窗口新增下载字节上限,0 = 不限 | 0(官方建议至少 0.5 GB/窗口) |
带宽上限(Data Cap) 是内容更新区别于其他两类更新的核心机制:UI 以 GB 为单位编辑(0 = 不限),存储时换算为字节。选包算法 selectUnderCap 是一个贪心预算调度:
- 排序策略:最久安装的优先(越陈旧的内容优先级越高),同时间再按体积小者优先,保证可预测;
- 体积未知(0)→ 推迟(无法安全预算);
- 体积超过整个窗口上限 → 归入
skippedOversize,永不自动启动、只能手动更新,并在 UI 中明确显示 “Skipped — exceeds data cap, update manually”; - 体积在剩余预算内 → 选中下载;
- 否则 → 推迟到下一窗口重试。
每次进入窗口,KVStore 中的窗口预算(windowBytesUsed)会基于 windowResetAt 边界重置一次(cron 每小时触发,但窗口可能横跨数小时,因此只重置一次)。一个细节值得留意:下载真正完成才清零该资源的失败计数(RunDownloadJob.onComplete),而投递失败在 ContentAutoUpdateService.attempt 中直接计一次,终态失败由队列 worker 的 failed 处理器记账——这样避免“每个窗口都重新投递成功而永远触发不了自禁用”。资源级退避共享逻辑在 admin/app/utils/content_auto_update_backoff.ts,同样以 3 次为阈值;此外还有功能级退避(如整个目录不可达),连续失败 3 次后关闭整个内容自动更新并写入 autoDisabledReason。
内容更新的冷却期以“首次检测到该更新”为计时起点(记录在 InstalledResource 的 available_update_first_seen_at),版本号采用 YYYY-MM 时间戳词法比较(admin/app/models/installed_resource.ts,字段由迁移 admin/database/migrations/1776200000001_add_content_auto_update_fields_to_installed_resources.ts 建立),天然按时间顺序排序。
内容更新绝不“同步安装”:它只投递可断点续传的下载任务,随后交给既有任务完成链路推进已安装版本并重建 Kiwix 库。同一每小时任务还会顺带执行 FDA 药品数据集的鲜度检查(attemptDrugDataset,走药品下载/摄取链路而非目录版本模型),但它与 ZIM/地图共享同一总开关与窗口,且被隔离封装——药品侧的失败不会拖垮目录运行。任务入口见 admin/app/jobs/content_auto_update_job.ts。
七、Early Access Channel:抢先体验 RC
在 Settings → Check for Updates 页面启用 Early Access Channel 即可接收候选发布(RC)构建,提前体验正式版之前的新功能。RC 版本可能包含粗糙边缘,页面会以显著文案提醒“RC 版本可能包含 Bug,不建议在稳定性与数据完整性要求高的环境使用”。你可以随时切回稳定版。
实现上这是一个名为 system.earlyAccess 的布尔设置(前端见 admin/inertia/pages/settings/update.tsx 的 Early Access 区与 admin/inertia/hooks/useSystemSetting.ts)。两个值得注意的行为:
- 切换后页面会立刻重新检查版本,而不是等用户手动点 “Check Again”——因为开关改变的是“哪些版本有资格”这一判定前提;
- 自动更新与 Early Access 是严格隔离的:决策器在选取目标时显式排除
prerelease,因此“开了 Early Access 的服务器会自己装 RC”是错误预期——RC 只会出现在手动检查结果里,自动通道永远只认 stable 的同主版本 minor/patch。
八、下线之前:把“有网更新”变成肌肉记忆
无论选择手动还是自动,最重要的习惯只有一条:在仍有网络时完成更新。手动可以,交给自动更新也可以——关键是确保软件与内容在下一次离线前处于最新状态。离线期间,你手上就是所有内容的最后一次同步版本。
相关速查:
- 更新与自动更新设置页:Settings → Update,仓库实现见 admin/inertia/pages/settings/update.tsx;
- 核心 / 应用 / 内容三个自动更新面板:见 admin/inertia/components/updates/ 下的
CoreAutoUpdateSection.tsx、AppAutoUpdateSection.tsx、ContentAutoUpdateSection.tsx; - 手动内容更新与下载活动面板:admin/inertia/components/updates/ContentUpdatesSection.tsx;
- 各版本更新说明(含每个发布版引入的功能与修复):admin/docs/release-notes.md;
- 更新系统的宿主侧基础设施:sidecar 更新监视脚本 install/sidecar-updater/update-watcher.sh、容器编排文件 install/management_compose.yaml。
总结:一条可控、可观测、可回滚的更新管线
把文档与源码对照后可以看到,Project NOMAD 的更新体系并非“轮询然后升级”的简单脚本,而是一条贯穿 UI 状态机、BullMQ 每小时任务、无副作用决策器、双级退避与 sidecar 执行器的完整管线。三类更新共享四条纪律——opt-in 默认关闭、窗口内执行、同主版本限定、预检先行;再分别叠加各自的特殊策略:核心更新靠严格 semver 与冷却期保护,App 更新靠双开关与逐应用自禁用,内容更新靠独立窗口、数据上限与“下载完成才算成功”的记账规则。理解了这些边界,你就能放心地在有网环境下把它调到自动挡,让每次离线都从一个“最新状态”开始。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00