这是 GitLab 14.9.3 升级到 19.2.1 系列的最后一篇。第一篇介绍同版本恢复与升级节奏,第二篇介绍停靠点、后台迁移和 PostgreSQL。本文只讨论一个问题:新 GitLab 已能启动,怎样才能安全替换旧 GitLab。

初始恢复成功,不等于可以切流

最初的应用备份只是某个时刻的快照。升级期间旧 GitLab 仍然对开发者开放,就可能继续产生 push、权限修改、CI 变量、Merge Request 和构建记录。

这次最终审计中,数据库对象计数没有新增,但发现了数百条 push 事件,涉及十余个仓库。这个结果很关键:只比项目数量、只恢复数据库,甚至只对比最新更新时间,都会漏掉真实的 Git 数据增量。

因此切流前的目标不是“新实例已经可访问”,而是明确回答三个问题:旧实例最后接收了什么写入?哪些数据需要补同步?补完后怎么证明新旧一致?

冻结旧实例,先把写入窗口关住

切流窗口开始后,我们先停止旧 GitLab 的 Web 和后台写入入口,保留受控的 root 运维通道:

gitlab-ctl stop nginx
gitlab-ctl stop gitlab-workhorse
gitlab-ctl stop puma
gitlab-ctl stop sidekiq
gitlab-ctl stop gitlab-kas

冻结后再比较两端,而不是边写边同步。审计范围至少包括:用户、组、项目、MR、CI Job 等核心对象计数;最新 Project/Event 时间;默认分支和活跃分支 tip;LFS、Artifact、Upload、Package 的变化;以及审计和事件记录。

对 Git 仓库而言,事件记录帮助定位“哪些仓库可能变了”,最终正确性仍由 refs 和对象校验决定。

不要对整个仓库目录盲目 rsync —delete

我们先从事件和项目更新时间锁定发生写入的仓库,再同步对应的 hashed repository 目录。对未知差异直接全量 rsync --delete,可能把目标侧已经生成的必要文件误删,也会把校验范围扩大到难以回滚。

同步后必须处理所有权。不同 GitLab 镜像里的 git UID/GID 可能不同;rsync --numeric-ids 会把旧数字原样带到目标,导致 Gitaly 没有读写权限。应以目标容器的实际 UID/GID 修复:

chown -R <target-git-uid>:<target-git-gid> <affected-repository>

然后做两层仓库验证。第一层比对所有 refs:

git -C <source-repository> for-each-ref \
  --format='%(refname) %(objectname)' | sort > source.refs
git -C <target-repository> for-each-ref \
  --format='%(refname) %(objectname)' | sort > target.refs
diff -u source.refs target.refs

第二层检查对象完整性:

git -C <target-repository> fsck --no-dangling

旧 commit-graph 缓存可能引用已不存在的对象。遇到这种情况,不要删除 Git 对象“修复报错”;保留旧缓存目录,再从当前可达 refs 重建 commit-graph。最后清理 Rails Cache 并更新项目活动时间,避免首页仍显示初始恢复时刻。

保留原 IP,先处理节点身份

为了不批量修改开发机、Jenkins 和历史 clone 地址,最终继续使用原 GitLab 服务 IP。这个决定减少了客户端改动,但把 IP、主机名、Kubernetes Node 名和 PV nodeAffinity 绑定在同一个切换窗口中。

实际顺序是:

  1. GitLab StatefulSet 缩容到 0。
  2. 确认旧服务器已停止写入、关机且原 IP 不再响应。
  3. 将目标节点改为最终主机名和静态 IP。
  4. kubeadm reset 后以新节点名重新加入集群。
  5. 恢复 GitLab 专用标签和污点。
  6. 以新节点身份重建 PV/PVC 元数据并绑定原数据目录。
  7. StatefulSet 扩容到 1。
  8. 执行 HTTP、SSH、Gitaly、数据库和仓库验收。
sequenceDiagram
  participant Old as 旧 GitLab
  participant Node as 目标 Kubernetes 节点
  participant API as Kubernetes API
  participant New as GitLab 19

  Old->>Old: 冻结 HTTP / SSH Git 写入
  Old->>Node: 同步最终仓库增量
  Old->>Old: 关机并释放服务 IP
  Node->>Node: 修改 hostname 与静态 IP
  Node->>API: 重新加入集群并恢复标签/污点
  API->>New: 绑定 PV 并启动 StatefulSet
  New->>New: 六层验收

这里的 Kubernetes 陷阱是:PV 的 nodeAffinity 不可变。节点改名后不能直接 patch。正确方式是先让 GitLab Pod 停止,确认 PV 为 Retain,删除 PVC/PV 对象、保留宿主机数据目录,再按新节点名重建 PV/PVC,等 PVC 为 Bound 后才启动 GitLab。

删除的是 Kubernetes 元数据,不是仓库数据;但这个区别只有在 Pod 已停止、路径核对正确且回收策略为 Retain 时才成立。旧服务器也不能带着原 IP 再次开机,必须先断开虚拟网卡或改成其他地址,避免 IP 冲突。

六层验收:Pod Ready 只是第一层

最终验收按下面六层执行:

  1. Kubernetes:节点名、InternalIP、主机名一致;Pod 在预期节点且不重启;PVC 为 Bound;PV 是 Retain 并指向最终节点。
  2. GitLab 服务:gitlab-ctl status、gitlab-rake gitlab:check SANITIZE=true、GitLab Shell、Gitaly、Sidekiq、Redis、Uploads 和 hashed storage 正常。
  3. 数据库:核心计数与冻结源一致,目标 PostgreSQL 版本正确,schema migration 完成,未完成批量后台迁移为 0,gitlab:doctor:secrets 能解密关键数据。
  4. 仓库:所有发生增量的仓库 refs 完全一致,git fsck --no-dangling 通过。
  5. 入口:HTTP 登录页可访问,SSH 握手成功,原地址没有变化。
  6. 真实业务:Jenkins 能拉取并回传状态,Webhook 能投递,Runner 能领取任务,CI/CD 变量可解密,用户登录、2FA 和 Personal Access Token 正常。
curl -fsS http://<gitlab-host>/users/sign_in >/dev/null
ssh -T git@<gitlab-host>

六层全部通过,才把这次迁移标记为完成。尤其不要把“页面能打开”当作验收结束:仓库读写、CI、Webhook、Secret 解密才是开发者真正依赖的能力。

回滚点不是一个文件,而是一组边界

最终切流后,旧 GitLab 虚拟机、旧 PostgreSQL 数据目录、中间版本镜像和关键备份都不能立即清理。我们至少保留:旧虚拟机(关机、断网)、最后一个已验证完整 GitLab 备份、关键大版本 dump 与配置归档、PostgreSQL 旧数据目录、GitLab 18 完整恢复点、GitLab 19 数据库保护备份、实际镜像 digest、最终增量仓库清单和 refs 校验结果。

回滚也分层:

  • 入口异常但数据正常:切回旧入口。
  • GitLab Pod 异常:恢复前一镜像和对应数据库状态。
  • PostgreSQL 异常:切回保留的旧数据目录。
  • 数据完整性异常:关闭新实例写入,恢复最后完整备份,再重新计算增量。

任何回滚都必须避免新旧两边同时接受写入。观察期结束后再清理回滚点,升级才算真正收尾。

这次迁移留下的三个判断标准

  • GitLab 升级的最小单位不是镜像,而是“版本 + 数据库 + 后台迁移 + 可恢复备份”。
  • 单副本 GitLab 加本地 PV 不是高可用;Kubernetes 提供的是声明式部署和统一运维入口,不会自动复制有状态数据。
  • 初始恢复成功并不等于数据完成迁移。只要旧实例持续开放,最终增量就必须按数据库对象、Git 仓库、LFS/Artifact/Upload 和配置分别核对。

回头看,真正耗时的从来不是下载二十个镜像,而是每到一个关键点都愿意停下来验证。把迁移拆成有输入、有输出、有验收和有回滚的小步骤,复杂升级才不会依赖运气。