6814 字
34 分钟
从 Mizuki 迁移到 Shirone 与 Strapi:Kubernetes 博客重构实践

前言#

原来的博客使用 Mizuki,文章以 Markdown 文件保存在仓库里。这个方案简单,但文章发布需要经过代码构建和部署;我希望保留 Markdown 写作习惯,同时让内容管理与网站代码发布分开。

这次迁移没有把 Strapi 当作需要改造的上游项目,而是在项目中维护它的配置和必要的内容模型,再用 Astro 将 Strapi 与 Shirone 连接起来。搜索交给 Meilisearch,服务运行在已有 Kubernetes 集群中。

本文是一次真实环境的迁移复盘,不是可以直接照搬的安装脚本。集群地址、凭据和环境专用参数均已省略;各阶段的数字反映当时的检查结果,不代表现在的文章总数。

目标与最终结构#

迁移前先确定了几件事:旧文章和 URL 尽量保留;草稿、加密文章和附件不能因为迁移变成公开内容;后台修改文章后,网站不需要重新构建;切换失败时保留旧站的恢复路径。

最终的内容链路可以概括为:

写作或编辑
    ↓
Strapi 5:文章、草稿、发布状态、媒体记录
    ├── PostgreSQL:内容与元数据
    ├── 持久卷:上传文件
    └── Meilisearch:可重建的搜索索引
                ↓
       Shirone / Astro 服务端渲染
                ↓
          访客访问的网站

桥接器使用 shirones@0.1.5 和 Astro,CMS 固定为 Strapi 5.60.0。网站从服务端读取已发布内容;后台保存或发布内容后,前台通过实时读取和渲染展示,不依赖文章变更触发镜像构建。

数据库和上传文件是需要保护的主要数据;搜索索引可以从已发布内容重建,不能代替数据库备份。代码仓库负责网站、CMS 定制配置和部署逻辑,文章内容则存放在 Strapi。

迁移时间线#

把过程按顺序展开,大致分成以下几段。日期表示对应工作发生的时间,而不是每个子系统从此以后都没有再修改。

时间主要工作当时的结果
10 月 30 日建立 Shirone 实时读取原型,准备 Strapi 内容模型和导入工具先在隔离环境验证,不切生产流量
10 月 31 日配置数据库角色、上传卷、搜索和集群暂存环境;导入归档并切换网站入口新站上线,旧站资源暂时保留
11 月 1 日调整中文后台及 IPv4 访问路径独立网关与隧道通过验证后切换
11 月 2 日起梳理镜像构建、制品签名和发布候选流程先运行隔离测试,旧站工作流保持禁用
11 月 4 日在 GitHub 托管 runner 做签名与加密制品的冒烟测试使用合成文件验证签名与篡改拒绝
11 月 11 日验证签名构建和真实镜像发布;另做隔离恢复检查CMS 与 SSR 完成上线检查,恢复边界另行记录
11 月 12~14 日接入受限自托管 runner,排查真实发布失败完成修复,但当时没有宣称生产自动回退已验证
11 月 15 日恢复手动发布验证并启用自动触发;仓库改名一次实际推送触发构建与部署,站点和搜索检查通过

这条时间线里,“隔离测试通过”“生产部署成功”和“生产故障后的自动回退已演练”是三个不同结论。前两项有对应记录,最后一项在本文记录时仍没有完整完成。

内容模型:让 Markdown 和发布状态各司其职#

旧站文章包含正文和 frontmatter;为了尽量减少迁移时的信息损失,Strapi 的文章模型保存三部分主要数据:

slug      原文章路径对应的稳定标识
body      Markdown 正文
metadata  标题、日期、标签、分类等 frontmatter

Strapi 的草稿/发布状态控制哪个版本可被网站读取;metadata 中原有的 draft、加密和密码相关字段也要参与业务检查。不能只把 draft: true 当成界面标签,也不能因为能查到数据库记录就向匿名访客返回正文。

网站使用服务端内容读取器按页面请求获取已发布内容,再交给 Shirone 的 Markdown 渲染链。受限读取接口负责过滤不可公开的字段,CMS 的管理凭据不进入浏览器。文章保存时还可以生成渲染快照;渲染失败或内容受保护时,不应把旧快照或明文当成新的可公开内容。

原站的别名、自定义路径、时间线、AI 工具等不只是文章 Markdown。本次迁移因此分别检查了内容模型、动态页面、旧路径和特殊页面,避免只导入正文后发现导航、站点地图或旧链接断掉。

先整理旧站,再迁移内容#

旧站代码和文章先保留到归档分支,新项目单独维护。导入时没有直接遍历文件后全部发布,而是先解析 Markdown 的 frontmatter,保留原始标题、日期、分类、标签与草稿标记,并根据原路径生成文章 slug。

导入工具默认只做检查。对于每篇文章,它会检查 slug 是否重复、必要字段是否存在,以及本地媒体引用能否解析;正式导入前还会核对 Strapi 中是否已经有同名文章。已存在且内容不一致时停止,避免重复导入覆盖已有内容。

这一步暴露了两个容易忽略的差异。

第一,旧站的 MDX 不能直接假设为普通 Markdown。迁移时,尚未转换的 MDX 草稿保留原始内容和待处理标记,不自动发布;已发布的 MDX 如果无法安全转换,就先阻止迁移,而不是把组件源码直接显示给读者。

第二,图片不只是 Markdown 里的一个字符串。相对路径、站点根路径、封面以及 HTML 媒体标签都需要分别处理;草稿或受保护文章的本地媒体不能随手放进公开上传目录。导入过程只重写已经确认存在且允许迁移的媒体引用,无法解析的相对路径会进入检查结果。

在一次暂存环境检查中,归档快照导入了 63 篇文章,其中 8 篇为草稿,并处理了 6 个原始媒体文件。再次执行时,同一批内容被识别并跳过;未转换的 MDX 草稿和一个缺失的示例图片仍需单独处理。这些数字是当时那份快照的结果,不等于后续站点的总文章数。

文章导入的具体顺序#

第一步:固定输入快照。 导入前先确认旧站分支、未提交改动和发布状态。文件是从哪个快照来的、当时哪些文章是草稿,都需要有依据;不能只以“目录里现在有多少个文件”推断线上状态。

第二步:只读预检。 导入器先生成 slug,检查重复、必填字段和附件位置。示例命令中的路径需要替换为自己的归档检出目录:

# 在桥接器项目内执行;不带 --apply,只生成预检结果
node cms/scripts/import-mizuki.mjs --source /path/to/mizuki-archive

预检结果需要同时检查错误与警告。缺少相对附件会阻止导入;已存在的站点根路径资源可能保留原地址并给出警告,仍需在新站实际访问验证。不要把“命令退出”直接当成“图片可见”。

第三步:在隔离 CMS 里执行导入。 测试使用独立数据库和上传目录,不连接生产数据。工具在上传前核对所有 slug,已存在记录只有在来源内容和附件哈希匹配时才跳过;同 slug 不同内容会整体停止。这样重试不会悄悄覆盖,但网络中断仍可能留下未引用的上传文件,需要检查。

第四步:核对发布状态和引用。 迁移后分别检查草稿是否不可匿名读取、已发布文章能否打开、封面和正文媒体能否加载,以及撤回后页面与搜索是否同步消失。代码块里的图片示例不应被识别成真正的附件引用,这也是导入器需要解析 Markdown 而不是全篇简单替换字符串的原因。

在集群中隔离内容与权限#

Strapi 没有复用其他应用的数据库账号,而是在现有 PostgreSQL 集群内准备独立数据库和低权限角色。上传文件使用独立持久卷,搜索服务使用独立 Deployment;网络策略只放行必要的访问路径。

SSR 侧使用受限的读取凭据,只读取允许公开的内容;管理端和写入接口走单独的访问路径。搜索也区分查询、写入和任务检查权限,避免把管理权限直接交给网站前台。

这里踩到的一个坑是:CMS Pod 看起来已经就绪,并不代表它能访问数据库。 暂存环境中,数据库最初不可达,排查后调整了对应网络策略,再验证 CMS 与数据库之间的连接。检查部署时不能只看 Pod 状态,还要沿实际依赖链验证数据库、上传目录、后台和搜索。

附件访问也需要结合文章状态判断。草稿和加密文章中的媒体不能因为知道文件地址就直接公开,因此媒体请求由网站侧做发布状态和权限检查;受保护内容还需要单独验证会话与资源访问。

数据库、上传目录和凭据如何落地#

数据库已经由集群内的 CloudNativePG 管理,因此没有为了迁移重建整套 PostgreSQL。实际操作先确认现有 Cluster 身份和当前配置,预览仅新增目标角色的变更;随后创建专用凭据、角色和数据库,并检查角色不是超级用户,也没有创建数据库、创建角色或复制权限。

CMS 所需的数据库凭据要在数据库侧和应用侧保持一致。创建过程采用只创建、不覆盖的方式,并在敏感操作前记录状态:如果请求超时,服务端可能已经执行成功,不能直接重新生成一套密码或覆盖 Secret。恢复时需要先确认对象身份和请求结果,再决定下一步。

上传目录使用单独 PVC,并通过一次性测试任务以应用实际 UID/GID 创建、读取和删除合成文件,确认权限可用。这里的持久卷底层使用异步 NFS;PVC 显示的容量和 Retain 回收策略不能被理解成空间配额、同步复制或可恢复备份。

生产运行所需的凭据存储在受保护位置和 Kubernetes Secret 中;文章导入用的临时令牌在迁移后撤销。示例和仓库不应包含真实 token、数据库密码、私钥或 kubeconfig。

搜索、动态页面和旧链接验证#

Meilisearch 只索引符合公开条件的文章。草稿、撤回文章和受保护内容不能只靠前端隐藏,应该在进入搜索索引前就过滤。索引重建时,网站还要区分“搜索服务进程活着”和“索引已经可用”;索引尚未准备好时返回不可用,比把空结果误当成正常结果更容易排查。

在隔离容器中,使用独立的 PostgreSQL、Strapi、Astro 和 Meilisearch,验证了草稿、发布、编辑、撤回、重新发布和删除。文章内容变化后,检查页面和搜索结果能否同步变化;随后清空测试搜索服务,验证受限密钥恢复和索引重建。

旧链接和静态资源也做了单独检查。暂存阶段对旧站点地图中的 75 个 URL 做了检查,其中包含 55 个文章地址;补齐旧静态资源后,记录中的 69 个旧资源路径也完成了访问检查。三份相册示例文件与旧包不完全相同,保留了新包版本,因此不能笼统地说所有静态文件都与旧站逐字节一致。

这些测试证明了当时被检查的路径和功能可用,但不能替代以后每次版本升级后的回归测试。

搜索重建:成功返回不等于索引可用#

搜索服务使用可重建索引,CMS 在文章变化后更新索引,并定期尝试修复遗漏。为了避免“进程返回 200,但索引还是空的”,重建完成后才写入就绪标记;网站查询前检查该标记。刚创建的空索引或恢复中的索引不能直接伪装成完整搜索结果。

另一个边界来自索引交换:构建新索引后,交换请求可能已经被服务端接受,但调用方在收到确认前断线。此时客户端不知道交换究竟有没有发生,直接删除重建索引或重复交换都可能操作错误对象。当前方案在结果不确定时停止自动清理,保留现场并按恢复流程核对;隔离测试覆盖了这一类不确定结果,但不代表生产中已经演练过所有故障。

IPv4 入口:HTTP 200 也可能返回了截断页面#

迁移不只是替换应用,原有 IPv4 入口和集群网关之间也需要重新接通。过程中先准备独立网关、受限隧道和入口配置,在不改变现有公共路由的情况下验证站点与后台,再进行实际切换。

一个非直观问题是:网关测试时首页响应状态是 HTTP 200,但响应体不完整。检查 WAF 与响应缓冲后,为独立网关配置了响应缓冲,重新验证完整页面,而不是只看状态码。测试还覆盖了 TLS SNI 与 HTTP Host 不一致的请求、缺少预期代理协议头的连接,以及未授权来源能否访问网关。

最终记录中,站点与管理入口的 IPv4 检查返回 200,而不应公开的草稿 API 返回 404;原有 IPv6 入口也保留。WAF 当时运行在 DetectionOnly:测试请求可以通过并产生审计记录,这表示检测,不表示已经阻断攻击。

网关的网络限制信任的是指定节点来源,并不等于能区分该节点上的每个进程。涉及入口、隧道或网络策略调整时,需要在自己的拓扑下重新验证,不能把一次测试结果推广成进程级隔离保证。

切换流量与恢复边界#

新服务在集群暂存环境验证后,才将已有网站入口切换到新的 SSR 服务。切换后检查了路由状态、主页、搜索、公开媒体和文章页面。旧 Mizuki Deployment 与内容卷在切换阶段被保留,作为站点入口恢复的参考。

这里需要区分两件事:把流量切回旧站,不等于把 Strapi 里后来新增或修改的文章带回旧站。 如果新站已经产生内容写入,回切入口之前还要决定如何处理两边的内容差异。

数据恢复也不能只看一次成功的数据库导出。后来分别对数据库和上传文件做过隔离恢复检查,并用恢复出的数据启动 CMS、验证一个媒体文件及搜索重建;但采集期间生产 CMS 仍在线,数据库和文件没有证明处于同一时点。这说明部分恢复路径可用,不等于已经验证了可直接用于灾难恢复的同一时点备份。

迁移过程中曾调整并移除原有自动应用备份;这篇文章记录时没有重新启用该备份机制。存储卷、可重建搜索索引与完整备份是不同的东西,后续仍需要独立设计并演练一致性备份与恢复。

恢复验证到底验证到了哪里#

11 月 11 日的一次只读检查把实际数据库的逻辑转储流入隔离 PostgreSQL 容器,恢复后检查到 45 张公开模式表;上传文件则单独传入隔离环境并校验归档内容。后续使用恢复的数据库、文件和空的 Meilisearch 启动 CMS,验证了 CMS 健康、一个媒体文件以及一个已发布标题的索引重建。

这个结果有用,但范围有限。数据库和文件是分开采集的,生产写入没有在整个采集窗口被证明冻结;因此不能证明它们构成一致的数据对。测试也没有覆盖全部 SSR 页面、所有受保护媒体、真实灾难恢复、数据库集群故障切换或某份长期保存的备份。记录这些缺口,是为了避免把“恢复过一份临时数据”误认为“备份策略已经完成”。

GitHub Actions:从构建到自动部署#

网站代码和 CMS 定制配置采用 GitHub Actions 构建。托管 runner 执行测试并构建镜像;制品经过加密和签名,部署端再验证来源和内容,传入内部镜像仓库,并通过已有受限 runner 与 Ansible 更新对应 Deployment。

最初是手动触发构建和部署,完成真实发布验证后,再接入 main 推送触发。部署产生的镜像清单提交不再触发新构建,避免自动部署把自己反复触发。一次实际的代码推送已经触发构建,并自动衔接到生产部署;站点、管理入口和搜索随后通过检查。

制品如何从 GitHub 到集群#

这次采用两类 runner 分工。GitHub 托管 runner 负责依赖安装、源代码检查、构建和隔离集成测试,不直接拿生产集群凭据;已有的自托管 runner 负责从成功的构建中取回制品并执行受限部署。大致流程如下:

main 的代码变更
    ↓
托管 runner:测试源码 → 构建镜像 → 用实际镜像运行集成测试
    ↓
加密制品与元数据 → 单独的签名作业
    ↓
自托管 runner:核对来源与签名 → 解密并校验镜像内容
    ↓
内部镜像仓库 → Ansible 预检 → CMS / SSR 滚动更新 → 健康检查

签名和加密解决的是不同问题。加密限制谁能读取制品,但仅靠加密不能证明是谁构建的;签名证明则用于核对来源与完整性。签名也不能替代测试本身:构建环境、工作流代码、来源提交、测试内容和凭据边界依然需要可信。

为避免用“某个可变标签”误认测试结果,构建过程记录并校验镜像与 OCI 内容的摘要;部署端验证签名和制品身份后,再检查解密后的 OCI 描述信息。迁移初期先用合成文件测试真实 GitHub 签名,验证正确制品可以通过、篡改或错误身份不能通过;之后才逐步验证完整尺寸的加密制品和 runner 传输。

自托管 runner 使用单独的集群身份,只允许读取和更新指定的两个 Deployment,而不授予读取 Secret、操作其他应用或命名空间的常规权限。但 Kubernetes 的 Deployment patch 不能只限定到镜像字段:拥有这个权限的 runner 仍然是需要信任的执行环境,工作流和允许执行的代码也必须受控。

发布前记录上一次已部署镜像及资源身份,检查当前 Git 和集群状态;应用变更时使用带条件的更新,避免把并发发生的其他改动覆盖。发布中断或推送结果不确定时,锁和状态记录会阻止简单重放,后续需要对照 Git 与实际 Deployment 协调。

这里有两次有代表性的故障。

第一次:CMS 更新后健康检查失败,恢复时 Git 推送也失败。 日志中先出现公共健康检查返回 HTTP 500,随后恢复流程里的 Git 推送失败,导致自动恢复结果无法确认。处理时先核对线上 Deployment、Git 状态与原镜像,再受控恢复旧版本;之后为健康检查增加了有限重试,并在实际部署前增加 Git 推送预检。预检可以提前发现部分问题,但不能保证后续每次推送一定成功。

第二次:runner 能接到任务,却卡在代码检出。 对照发现该节点直连 GitHub 超时,而经过环境内已有代理可以访问。随后将代理配置限定在发布作业,并对内网与网站检查设置直连;重新构建和部署后,代码检出正常。下载依赖与基础镜像时仍优先使用已配置镜像;镜像不可用时,才使用相同版本和摘要的官方来源。

这次排查有一个实践上的区别:runner 在 GitHub 页面显示在线,只说明它能接收作业,不能证明它在作业里访问 GitHub、下载大制品、推送 Git 或访问内部镜像仓库的每条链路都正常。应该按失败步骤分别检查,而不是仅凭在线状态断定网络没有问题。

还有一个保守检查拦截过发布:基础镜像重建脚本增加了同摘要官方源回退,即使 CMS 代码未变,原规则也把它视为可能影响回退兼容性的构建输入变化。处理方法是核对固定摘要、比较旧新构建所得根文件系统层和运行配置,再对这组明确的文件版本记录限定性兼容判断;并没有把任意 CMS 改动都视为安全。

这个检查并不是为了判断 Git diff 看起来大不大,而是为了回答:新 CMS 是否可能修改数据库或引入无法由旧镜像读取的状态?实际复核时,CMS 源码和依赖未变,基础镜像的国内源与官方源使用同一固定摘要;旧、新 Dockerfile 用相同输入做离线对照时,文件系统层和运行配置一致。镜像 ID 仍可能因构建元数据不同而变化,因此这里没有声称两个 OCI 清单逐字节相同。

人工部署验证通过后,才接入 GitHub Actions 的自动触发:代码推送到 main 后执行构建,成功后触发部署;部署自动写回的镜像清单变更不再触发构建。一次实际的推送构建和自动部署都成功。当前工作流仍会构建并部署 CMS 与 SSR 两个组件,即使某次只修改了网站侧代码;按组件变更范围分别部署是后续可以做的简化,不是这次已经完成的功能。

需要保留边界:隔离环境中做过镜像切换和恢复测试,也完成过真实生产发布;但本文记录时,完整的生产自动回退演练仍未完成。实际失败可能发生在网络、推送或进程中断之后,遇到结果不确定时,应先核对线上状态,而不是盲目重试。

写作目录与发布方式#

迁移后,Obsidian Vault 改为独立目录,不再通过软链接依赖旧 Mizuki 工作区。旧站归档与现有笔记仍需分别保留;本地文件和 Strapi 中的文章在迁移后可能各自发生变化,不能假设两边永远一致。

当前写作流程先保持简单:检查当前笔记与 Strapi 草稿、已发布版本是否一致;需要推送时,将当前文章写入 Strapi 草稿。已有草稿不同会提示确认,线上版本在检查后变化则停止;是否公开仍在 Strapi 后台决定。本地图片、Obsidian 双链和 MDX 尚未自动转换,遇到这些内容需要先处理。

这条写作链路与 GitHub Actions 的代码部署是两回事:写文章不需要为了更新内容重新部署网站;改桥接器代码才走构建发布流程。

最初尝试自写 Obsidian 插件时,我没有先调研现有社区插件,这是一个不必要的顺序错误。之后查到了可以导出到 Strapi 的现有方案,但它们与当前内容模型、更新方式和草稿语义的匹配还需要实际验证。因此当前仅保留本机使用的“检查当前文章”和“推送当前文章”两个操作,不把它描述成成熟的双向同步或自动发布系统。

本地和线上分别修改过同一篇文章时,内容不同并不自动说明哪一边正确。检查后线上或本地再次变化时应停止推送;如果需要覆盖已有 Strapi 草稿,要先确认覆盖的是哪一份。比较与提交之间没有服务器端原子条件写入,使用时仍应避免多人同时编辑同一篇文章。

经验小结#

这次迁移最有价值的不是把某个静态站换成 CMS,而是把内容来源、公开边界、依赖关系和恢复方式逐项梳理清楚。旧链接、草稿状态和附件权限都属于迁移结果的一部分,不能只以“新首页能打开”作为验收。

比较稳妥的顺序是:先保留旧站和数据,检查待迁移内容,再在隔离环境验证导入、发布与搜索,最后切换流量并检查真实访问。自动化发布应该建立在可观察、可核对的手动流程之上,同时诚实记录尚未验证的回退与备份边界。

项目代码以 shirone_strapi 为名维护。具体集群的网络、存储、版本与权限不同,复用方案时仍应先在自己的隔离环境验证。

从 Mizuki 迁移到 Shirone 与 Strapi:Kubernetes 博客重构实践
https://hyperbola.cc/posts/tech/linux_ops/cloud_native/mizuki-to-shirone-strapi-migration/
作者
Hyperbola
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取