--- title: "上游被删了,然后带着一个重写版回来" description: "我线上跑的阅读服务,依赖的镜像官方仓库已经 404。几个月后上游回来了——233 个提交、换了语言、零 release。怎么分辨「项目死了」和「项目在重写」。" pubDate: 2026-10-06 category: devops tags: ["docker", "self-hosting", "upstream", "maintenance", "registry"] ogImage: "/og/zh-the-upstream-was-deleted-then-came-back-rewritten.jpg" banner: "/banners/the-upstream-was-deleted-then-came-back-rewritten.jpg" draft: false --- `docker pull hectorqin/reader` 返回 **404**。而这正是我线上阅读服务所依赖的项目:一个只发邀请码的小书库,给我家人和几位付费读者用,每天在跑,上面还搭了一条我自己做的语音朗读管线,让「朗读」按钮念出来的是正经的神经网络音色。 404 还不是最有意思的部分。**2026-09-16**,同一个仓库被从零重新初始化——提交信息是 `chore: 初始化仓库`——十六天后,它已经有了 **233 个提交**、另一门语言、另一个端口、另一种数据库、另一个镜像仓库。我依赖的项目没有死,它是被**一个重写版替换了**,名字没变、地址没变、那 11,036 颗星也没变。 所以问题是:**怎么判断一个上游项目是死了,还是正在被重写?而当它以另一个应用的样子回来时,你该怎么做?** ## 先说我这边的情况,因为细节才是这件事的关键 这个应用是一个自托管阅读服务器。浏览器端是 hash 路由的 Vue SPA,服务端是 Kotlin + Spring Boot,配置按用户存成磁盘上的 JSON。而我专门为它的一条能力搭了一整套管线:**HTTP 语音合成**。阅读器会把朗读请求代理到你配置的 URL,也就是说音色不是应用自带的——我的指向 n8n 的两个 webhook,由它们去换取短期 Azure / Google 凭据并合成音频。语音选择器里那 9 个引擎是这么来的,不是应用自带的。 此外,应用的镜像外面还有我自己的一层 nginx 网关容器:对外落地页、社交分享卡片、中英切换、以及一层英文界面补丁,全在那里,不在应用镜像里。当初做这个决定是为了品牌,而正是它让整件事从头到尾都很平静——后面我会回到这点。 ## 我去找升级路径的时候,发现了什么 三个选项,没有一个叫「升级」: | 选项 | 它实际是什么 | 我为什么没选 | |---|---|---| | 官方镜像 | Docker Hub 上的 `hectorqin/reader` | 没了。仓库返回 404,`docker pull` 直接失败 | | 还在维护的 fork | `changshengyu/reader`,版本 `2.5.4` | 基于最后一个完全开源的快照。它的朗读只用浏览器自带的 `speechSynthesis`,没有 HTTP 语音。它的整个历史里没有任何 `httpTTS` 字样,`/reader3/httpTts` 也是 404。我那 9 个引擎会全部消失 | | 别人重新打包的镜像 | `liangnianzhi/reader.hectorqin:latest-20250525` | **这就是我现在跑的。** 唯一还能拉取、并且保留了 HTTP 语音的 3.x 构建。最后更新 2025-09-05,一共三个标签,发布者不是原作者 | 我把那个标签钉死,把数据目录打包备份,把源码镜像到自己的 Git 服务器上——至少我依赖的代码得存在于我能控制的地方。然后把那句决定未来任何镜像是否有资格的话写了下来:**它是否还讲我这条管线所实现的 TTS 协议?** 这就是当时的处境:一个跑在「官方仓库已被删除」的镜像上的服务,靠着某个陌生人重新打包的构建活着。 ## 然后上游回来了 以下数据于 2026-10-06 直接取自 GitHub API 与镜像仓库: | 指标 | 数值 | |---|---| | 仓库 | `hectorqin/reader` —— 活着,AGPL-3.0,默认分支 `main` | | Star / Fork | 11,036 / 5,471 | | 最后推送 | 2026-10-02 | | 提交数 | **233** —— 而最早的一条是 `chore: 初始化仓库`,日期 **2026-09-16** | | release / tag | **0 / 0** | | Docker Hub | 仍然 **404** | 2021 年那段历史还在,但已经断开了。`main` 上是 233 个提交,最早一条的日期是 2026-09-16;旧分支仍然存在,但只剩**一个提交,日期 2026-06-22**,提交信息就是 `commit`,而它的 README 全文只有一个词——`deleted`。两个分支**没有共同祖先**——这就是「先清空、再重来」在 git 里的样子。 而这 233 个提交里的代码,不是我原来那个代码库的延续。它是另一个应用,只是恰好沿用了同一个名字: | | 我现在跑的(3.x) | 上游现在的样子 | |---|---|---| | 运行时 | Kotlin,Spring Boot + Vert.x | TypeScript,Node | | 端口 | 8080 | 5888 | | 数据 | `/storage/data`,按用户存 JSON | `/data`,SQLite(`reader.db`) | | 配置 | 环境变量 + 每用户 JSON 文件 | 管理后台界面,存进数据库 | | 镜像 | Docker Hub,命名空间已删除 | `cnb.cool/hectorqin/reader:main` | | 范围 | 只有书 | 书 **和** 电影、剧集、音乐、有声书、OPDS、OpenList、PWA、Android 客户端 | 表里有三个细节值得单独拎出来,因为它们各自都有一个后果: **镜像仓库搬去了一个大多数人从没拉过东西的平台。** 官方 compose 里写的是 `image: ${READER_IMAGE:-cnb.cool/hectorqin/reader:main}`,旁边一行注释解释了原因:CI 只发布**分支和提交标签,刻意不发布 `:latest`**。也就是说默认配置跟的是一个会动的分支,唯一稳定的引用是一个 commit SHA。 **配置离开了环境变量。** 业务设置——HTTP 语音接口、扫描行为、登录有效期、对外 URL、允许的浏览器来源、WebDAV——现在都在管理后台里,持久化进数据库。文档说明:首次升级时「旧版业务环境变量会被导入一次」,并且已存在的数据库值永远优先。这是一次性的导入加一条优先级规则,正是你希望在副本上验证、而不是在线上实例上发现的东西。 **HTTP 语音回来了,只是换了个地方。** 新版本重新提供了 HTTP 语音上游的配置:合成 URL、token、voices URL、超时、缓存上限,还有连接测试和试听,全部在管理后台里配。而我的 n8n webhook 实现的协议,是我照着旧版构建自己设计的。这两个形状是否匹配,现在是一个需要用实验回答的问题,不是查文档就能回答的问题。 ## 没人提醒你的部分:重写版是一个新应用 本能反应是把「上游复活了,还多了影音支持和 Android 客户端」读成「我等的升级到了」。并不是。一次重写会同时改变数据格式、部署面和配置模型——这意味着要做的是**一场带 schema 边界的迁移**,而不是 `docker pull` 加一次重启。 让我决定慢下来的,是项目自己的进度记录。不要拿厂商的营销页当状态报告,去看它自己写的东西,你会看到作者在列**没做完**的事情: - 旧数据**自动迁移和首次接入向导都还没实现**; - **镜像拉取与真实容器升级演练从未执行过**,因为构建机上没有跑 Docker 引擎。 这是一份诚实得罕见的文档,也是整个仓库里最有用的一个文件。重写的作者在告诉我:升级路径写好了,但没人走过。如果连他们都没走过,我当然不会在工作日下午、在我读者正在使用的实例上、在没有数据副本的情况下走去试。 ## 我现在会把一个依赖交给线上服务之前做的事 1. **仓库健康不等于产物健康。** `pushed_at`、star、fork 说明**源码**还活着;对你要跑的那个确切标签执行 `docker pull`,才能说明**你部署的东西**还在。两个都要查,并读一遍 compose 看它到底指向哪个镜像仓库——我这个现在指向一个我本周之前从没打开过的域名。 2. **「没有 release、没有 tag」是一条部署事实。** 零 tag 意味着每次升级要么写死一个提交哈希,要么跟一个会动的分支。把那个 SHA 记进自己的笔记,上游不会为你记。 3. **你输不起的东西,就镜像一份——源码和镜像都要。** 一个钉在别人命名空间里的标签,是一种「那个陌生人账号别出事」的依赖。我活下来的 3.x 源码现在在我自己的 Git 服务器上,重写版也是。 4. **先比形状,再比功能。** 端口、挂载路径、变量名、数据库引擎、配置存放位置。如果这些大多变了,就把它当成一个新产品,按迁移来规划。 5. **你重建不了的部件,放在你控制不了的镜像之外。** 我的落地页、分享卡片、语言切换和翻译层,是同一条 stack 里的另一个容器。正因为如此,上游这次重写没有打扰到任何一个读者——应用镜像怎么换,都碰不到前门。 6. **读维护者的「还没做」清单,而不是功能清单。** 功能清单是销售页,未完成清单才是风险登记表。 7. **如果你把它当成对外服务来跑,读一下许可证。** 这个是 AGPL-3.0,如果你拿它给付费用户提供服务、而不是自己用,值得认真读一遍。 8. **「仓库被归档或被清空」不等于「项目被放弃」。** 有好几个月,「这个项目死了吗」的诚实答案是「是」——README 就写着 `deleted`,而且它说的是实话。然后有人重新开始了,换了门语言,而诚实的答案在名字不变的情况下变了。 ## 结果 书库在跑,跑在我钉住的那个构建上,读者完全无感——这本来就是目标,而且它一直成立。变的是:「我们被困在一个没人管的镜像上」从一句含糊的担忧,变成了一个有方案的具体问题: - 数据目录已复制,新镜像自带的 `backup` / `verify` / `restore` 命令先**对着副本**跑,确认没问题才碰线上卷; - TTS 协议要拿我现有的 webhook 实测,因为一旦对不上,我丢掉的就是朗读这个功能; - 旧镜像标签留着做回滚,而且无论走哪条路,源码现在都在我自己的服务器上; - 前门不在讨论范围内,因为它在另一个容器里,换镜像够不着它。 真正有用的教训不是「要钉版本」——那个我早就做了。而是:**「这个项目还活着吗」有三个答案,而它们可以互相矛盾**。仓库可以活着,而镜像已被删除;README 可以写着 `deleted`,而重写版已经开工四周;版本号可以纹丝不动,而运行时、端口、数据库和配置模型在底下全换了一遍。这三件事要分别去查,否则你迟早会为一个「已经不再以你测试过的形态存在」的应用规划升级。 --- **你在替别人跑自托管服务,但不确定底下的东西还有没有人管?** 这正是我在做的审计:依赖与镜像健康度、升级可行性,以及随之而来的迁移方案。 [WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20dependency%20audit) · [hoelee.com](https://hoelee.com)