Skip to content

怎么用这份文档

  • 第一次接触这类软件:先读第 1 章(术语与概念详解)。它是全篇的地基,后面每章都会用到那里的名词。术语章当字典用,不必背诵。
  • 想理解原理:第 2~7 章,从「这类系统长什么样」一路推到「四种发布状态的客观分析」。
  • 想动手:第 8 章挑一个开源项目读代码,第 9 章按阶段把演练环境搭起来。每一条命令下面都写了「这条命令在干什么、为什么要它」
  • 这是通用文档:文中所有环境均为示例规划192.168.0.0/24 网段),不绑定任何特定服务器。你可以按自己的资源情况调整 IP、主机名与配置档位。

本文档的定位

网上讲「发布流程」的文章,99% 讲的是网页项目:无状态容器、滚动更新、出问题回滚。那一套在客户端/服务端类软件上只能覆盖一半

这份文档专门讲另一半:为什么「回滚」这个词在这里含义完全不同,为什么「服务端改个字段」会打死所有老客户端,以及蓝绿/金丝雀/灰度在这些系统里如何"变形"。

00 演练环境规划

0.1 服务器清单

本文档第 9 章的动手部分使用下面这套环境。所有 IP 都在 192.168.0.0/24 网段,配置档位从 2C4G / 4C8G / 8C16G / 16C32G 中选取。

#IP主机名配置系统盘角色部署的组件 / 中间件
1192.168.0.10k8s-master4C8G100GK8s 控制平面kube-apiserver / etcd / controller-manager / scheduler、containerd、kubectl、Argo Rollouts 控制器
2192.168.0.11k8s-node18C16G200GK8s 工作节点kubelet、containerd、服务端 Pod(stable 版本)
3192.168.0.12k8s-node28C16G200GK8s 工作节点kubelet、containerd、服务端 Pod(canary 版本,与 stable 并存)
4192.168.0.20build8C16G200G构建机Jenkins、JDK 17、Node.js 20+、xdelta3(生成差量补丁)、Docker
5192.168.0.30nexus4C8G500G客户端制品仓库Nexus Repository 3(raw hosted 仓库,存客户端安装包与补丁)
6192.168.0.31dist2C4G100G客户端分发通道Nginx(版本清单托管 + 更新包下载,模拟 CDN 源站)
7192.168.0.40mysql4C8G200G数据库MySQL 8.0 —只给准备要求,不展开安装
8192.168.0.41redis2C4G50G缓存Redis 7 —只给准备要求,不展开安装
9192.168.0.50harbor4C8G500G服务端镜像仓库Harbor 2.15.2(存服务端容器镜像)
10192.168.0.60git2C4G100G代码仓库Gitea 1.27.x —只给准备要求,不展开安装

客户端测试机不占服务器资源:客户端程序(第 9 章的 demo、或 RustDesk / Colyseus 客户端)直接跑在你自己的笔记本上即可。

0.2 每台机器的职责与关键配置

IP这台机器为什么需要它需要放通的端口配置档位的选择理由
.10K8s 的大脑。etcd 对磁盘 IO 敏感,所以给独立机器64432379-23801025010257102594C8G:控制平面本身不重,但 etcd 需要稳定的 CPU 与磁盘
.11跑 stable 版服务端。双版本并存时这里是"老版本"的载体30000-32767(NodePort)、102508C16G:长连接服务端的内存占用与连接数成正比
.12跑 canary 版服务端。灰度期间新老版本同时在线同上8C16G:与 .11 对称,保证灰度流量不会因为资源不足而失真
.20构建机。编译、打包、生成差量补丁都在这里8080(Jenkins)8C16G:构建是 CPU 密集型的,且要同时跑打包与补丁生成
.30客户端产物的唯一权威来源。存全量包与差量补丁80814C8G / 500G 磁盘:制品仓库吃磁盘不吃 CPU
.31客户端下载的门面。它挂了客户端就更新不了804432C4G:纯静态文件分发,Nginx 极省资源
.40业务数据。游戏/IM 类系统还要存存档、配置表33064C8G:按数据量调整
.41会话、排行榜、在线状态63792C4G:Redis 吃内存,按并发量调整
.50服务端镜像100864C8G / 500G:镜像仓库吃磁盘
.60代码、配置表、协议定义30002C4G

0.3 为什么中间件要独立部署

这不是形式主义,每一条都对应一类具体的故障:

拆分混在一起的后果
制品仓库与构建机分开构建机在打包时 CPU 打满,同时仓库正在给几万个客户端提供下载 → 下载超时、用户更新失败
分发节点与制品仓库分开补丁仓库的磁盘写满(构建产物堆积)→ 连分发服务也一起挂掉
两个 K8s 工作节点分开承载 stable / canary灰度期间新老版本抢同一份 CPU → canary 版本因为资源不足而变慢,灰度指标失真,你以为新版本有问题
数据库与缓存分开Redis 内存打满触发 OOM Killer → 可能把同机的 MySQL 一起杀掉,数据损坏

0.4 与其它两份文档的关系

文档覆盖内容
《若依微服务上 K8s》服务端标准形态(无状态微服务)的完整搭建
《Jenkins 做 CI、Argo CD 做 CD》声明式部署与 GitOps(本文档的服务端部分沿用它
本文档客户端 / 服务端类软件特有的部分:多版本共存、热更新、差量补丁、长连接排空、蓝绿金丝雀在客户端侧的变形

真实项目里这三块是合起来的

一个完整的游戏/IM 项目,CI/CD 体系大致是这样:

text
服务端:代码仓库 → Jenkins(CI) → 镜像仓库 → Argo CD(CD) → K8s(★ 文档①②覆盖)
客户端:代码仓库 → Jenkins(CI) → 全量包/差量包 → Nexus → 分发节点 → 用户设备(★ 本文档覆盖)
配置表:配置源文件 → 导表流水线 → 服务端表 + 客户端表 → 原子发布(★ 本文档覆盖)

三条通道独立演进,但发布时必须协调——这就是第 7 章要讲的"状态矩阵"。

01 术语与概念详解

这一章把后面会用到的所有名词讲清楚。如果你完全没接触过客户端/服务端软件,这一章是地基——它解释的都是「为什么这类软件天生比网页难发布」。

1.1 网络通信层:一切差异的起点

短连接 vs 长连接

短连接(Short-lived Connection)

客户端发一个请求,服务端返回结果,连接就关掉。浏览器访问网页、常见的若依微服务、REST API 都是这种。

关键性质:服务端完全不需要「记住」你。任意一个实例都能处理你的下一个请求,所以可以随便增删实例、随便重启——这就是「无状态」的物理基础。

长连接(Persistent / Long-lived Connection)

客户端连上后一直保持不断,服务端可以随时主动推消息给它。网络游戏、IM 聊天、协同编辑、远程桌面都是这种。

关键性质:服务端实例上寄存着会话状态(你是谁、你在哪个房间、你的角色在哪)。这个实例一死,上面所有玩家的连接就全断了,而且状态丢了。

这就是为什么游戏服务端不能像网页那样随便滚动更新——也是整个文档里最核心的一条差异。

对比项短连接(网页/API)长连接(游戏/IM)
连接生命周期毫秒~秒级分钟~小时,甚至整天
服务端内存态无需保存必须保存(会话、房间、实体)
谁主动发消息只能客户端问、服务端答服务端可随时主动推送
重启实例的代价下一次请求换个实例即可,用户无感该实例上所有玩家掉线
能否水平扩容直接加实例即可需要「分线/分服」或状态外置,复杂得多
典型协议HTTP/1.1、HTTP/2、gRPCWebSocket、TCP 裸协议、KCP、ENet、QUIC

传输协议家族(选哪个决定了你的发布有多难)

协议底层可靠性延迟典型用途注意点
TCPTCP可靠、有序较高(丢包会阻塞后续包)登录、支付、存档、IM 文本「队头阻塞」:丢一个包,后面全等着,弱网下体感很差
WebSocketTCP可靠、有序同 TCP网页端实时通信、H5 游戏本质是「HTTP 升级后的 TCP」,穿过代理/防火墙友好
HTTP 长轮询TCP可靠老式实时方案、兼容性兜底服务端压力大、延迟高,属于过渡方案
gRPCHTTP/2可靠服务端之间的内部调用不是给玩家客户端用的;适合微服务互调
UDPUDP不可靠、无序极低实时对战(FPS/MOBA)的位置同步丢包不管,宁可丢也不等——但业务层要自己处理丢包
KCPUDP可靠(重传算法激进)国内弱网手游用带宽换延迟。国内网络环境下的常用选择
ENetUDP可靠+不可靠混合通道MOBA(英雄联盟用的就是它)可以同时开「可靠通道」和「快速通道」,各走各的
rUDPUDP可靠Nakama 的实时通信「reliable UDP」的统称写法
QUICUDP可靠、多路复用新一代 HTTP/3、部分游戏解决了 TCP 的队头阻塞,是趋势

为什么这件事和「发布」有关?

协议一旦上线,老客户端就锁死在这个协议上了。你换了协议(比如 TCP 换 KCP),所有没更新的客户端立刻连不上。所以协议体系通常设计成「可切换」的——ET 框架就明确支持 TCP / KCP / ENet / WebSocket 运行时可切换且不断线,这样服务端换协议时老客户端仍能工作。

三个长连接的必备机制

① 心跳(Heartbeat)

双方定期互发一个小包(比如每 30 秒),用来判断「对面还活着吗」。

为什么必须要有:TCP 连接在中间链路断掉(比如手机切 WiFi、NAT 超时)时,两端可能都不知道,会一直以为连着。心跳就是用来发现这种「假连接」的。
工程要点:心跳要有超时阈值 + 重试次数,超了就判定掉线并触发重连。没有心跳的长连接服务一定会积累一堆僵尸连接。

② 粘包 / 拆包(Sticky & Fragmented Packets)

TCP 是「字节流」,它不保证你发一次、对面就收一次。你连发两个包,对面可能一次收到两个黏在一起;一个大包可能被拆成几次收到。

解决方案:定义帧结构,常见有三种——
· 固定长度头 + 长度字段(最常用)
· 特殊分隔符(如 \n
· 定长包
这是写游戏服务端第一个要解决的问题,也是面试高频题。

③ 断线重连(Reconnect)

连接断了之后,客户端要能自动重连,并且恢复到断线前的状态(比如重新进入原来的房间、恢复角色位置)。

为什么和发布有关:如果服务端只支持「重连后从零开始」,那么每次服务端滚动更新都会让玩家体验很差。成熟的做法是:服务端发布前主动通知客户端「我要重启了,请重连到别的实例」,客户端带着会话令牌重连——这就是会话迁移摘流

状态同步 vs 帧同步(游戏特有的两种架构)

这个概念决定了服务端要存多少状态,从而决定了发布难度:

状态同步(State Sync)帧同步(Lockstep)
做法服务端算出结果,把「谁在哪、血量多少」推给客户端服务端只转发玩家的操作指令,每个客户端各自算
服务端状态量大(要保存整个世界状态)小(只保存指令序列)
典型游戏MMO、SLG、卡牌RTS、MOBA、格斗
发布影响服务端重启会丢失世界状态,需要额外做快照/持久化一局内的指令序列丢了就得重开这局,所以通常要求整局结束后才允许更新该实例
反外挂强(逻辑在服务端)弱(逻辑在客户端,需要额外校验)

1.2 服务端架构层

服务端的分工角色(游戏/IM 类项目通用)

网页项目通常只有「网关 + 业务服务」。游戏类项目会分得更细,因为每类工作的特征完全不同:

角色职责有无状态重启代价发布策略
网关服 / 接入服
(Gateway / Gate)
与客户端保持长连接,做协议加解密、限流、消息转发半状态(只存连接)玩家掉线摘流 + 引导重连
登录服 / 认证服
(Login / Auth)
验证账号、发令牌、分配区服无状态几乎无感蓝绿 / 金丝雀,随便发
逻辑服 / 游戏服
(Game / Logic)
跑游戏核心逻辑,保存房间、战斗、玩家实体强状态该服所有玩家掉线分服灰度,一服一服地升
DB 代理服
(DB Proxy)
统一收口数据库读写,做缓存与批量写无状态无感滚动更新
位置服 / 路由服
(Location / Router)
记录「哪个实体在哪个进程」,实现跨服消息投递轻状态(可重建)短暂不可用先升,或在低峰期升
匹配服
(Matchmaking)
把玩家组成一局轻状态无感滚动更新
聊天服世界/公会/私聊轻状态无感滚动更新

这个分工的工程意义

看出来了吗?「无状态的那部分可以随便发,有状态的那部分才需要小心发」。所以成熟架构会刻意把逻辑从有状态服务里抽出来。

ET 框架的做法更彻底:它用 Location Server 统一记录实体位置,任何服务器只要知道实体 ID 就能给它发消息,不用关心它在哪个进程、哪台机器上。这样逻辑服就可以相对自由地迁移和重启。

Actor 模型 / Fiber / 协程

Actor 模型

把每个「有状态的对象」(一个玩家、一个房间、一个公会)当成一个 Actor。Actor 之间不共享内存,只能通过发消息通信。

好处:天然无锁、天然可分布。因为不共享内存,所以这个 Actor 在哪个进程里都行,理论上可以像搬家一样迁移到别的机器上——这让「重启某个实例」这件事的影响面变得可控

Fiber / 协程(Coroutine)

一种比线程更轻的并发单位。一个线程可以跑成千上万个 Fiber,切换代价极小。

在游戏服务端的用途:让每个玩家的逻辑看起来像「顺序执行」的代码(写起来简单),实际上是并发跑的(性能好)。ET 的 Fiber 还支持父子关系,父 Fiber 销毁时子 Fiber 一起清理——这解决了「玩家掉线后残留任务」的问题。

会话(Session)与会话迁移(Session Migration)

会话

「这个连接属于哪个玩家、已经登录了没有、当前在哪个房间」这组信息的集合。它存在于网关服和逻辑服的内存里。

会话迁移要解决的问题:网关服要升级重启,但上面挂着 5000 个玩家的连接。
做法:新网关实例起来 → 老实例通知客户端「请重连到新地址」→ 客户端带上会话令牌重连 → 新实例从数据库/缓存里恢复会话。
效果:玩家感受到的是「一瞬间的卡顿」,而不是「掉线回登录页」。

分区 / 分服 / 合服

概念含义发布意义
区 / 服(Zone / Server)一套独立运行的逻辑服 + 一份独立的存档数据。玩家登录时被分配到某个区这是游戏服灰度的天然单位——可以「今天升 1~3 区,明天升 4~6 区」
开新服上一个全新的区本质是蓝绿的一种变体:新版本在新服跑,老服不动
合服把几个冷清的老区合并成一个这是最危险的一类发布,涉及大量数据迁移、ID 冲突、排行榜重算。必须停机 + 完整演练
跨服不同区的玩家能一起玩(跨服战场)需要服务端之间协议兼容,等于把兼容窗口从「客户端 ↔ 服务端」扩展到「服务端 ↔ 服务端」

StatefulSet 与 Deployment(K8s 层面的直接区别)

DeploymentStatefulSet
Pod 名字随机后缀(app-7d9f-abc12固定序号(app-0app-1
存储共享或不用,Pod 之间无差别每个 Pod 绑定自己的 PVC
更新顺序并行滚动,随便杀默认从大到小逐个更新,等前一个 Ready 才动下一个
适用无状态服务(若依这类无状态微服务)有状态服务(数据库、消息队列、分片游戏服)

记住这个结论:无状态微服务用 Deployment 就够了,因为它是无状态的。但一旦开始做游戏服务端,你可能需要 StatefulSet,而「更新顺序受控」正是你想要的——只是你必须自己实现排空逻辑。

PDB(PodDisruptionBudget)

PodDisruptionBudget

一句话:**「最多允许多少个 Pod 同时挂掉」**的约束。

为什么需要:K8s 节点维护(kubectl drain)或集群自动缩容时会「驱逐」Pod。如果没有 PDB,它可能把你某个服务的 3 个副本一次性全驱逐,服务直接归零。

例子:minAvailable: 2 + 3 个副本 → K8s 会保证任何时候至少有 2 个活着,驱逐会排队进行。

1.3 客户端层

launcher / updater / bootstrapper(启动器)

启动器(Launcher / Updater)

你双击游戏图标后,第一个跑起来的那个小程序。它不负责游戏逻辑,只做四件事:
① 检查自己是不是最新(启动器本身极少更新)
② 拉取版本清单,判断要不要更新游戏主体
③ 下载并应用补丁
④ 启动真正的游戏程序

为什么要把启动器单独拆出来:因为它是「唯一能修自己的东西」。如果更新逻辑写在游戏主体里,一旦游戏主程序的更新逻辑崩了,你就再也没有机会修它了——用户只能卸载重装。启动器做得越小越简单,它坏的概率就越低。

现实例子:几乎所有 PC 网游、RustDesk、Discord、以及 Electron 类桌面应用(Joplin)都有这一层。

热更新(Hot Update)的三个层次 —— 理解「客户端少量更新」的关键

层次更新的是什么要不要重启客户端各平台限制典型用途
资源热更图片、音频、模型、动画、配置表通常下次进入场景生效所有平台都允许换皮肤、改文案、上新地图素材
脚本热更用脚本语言写的逻辑(Lua / JS / TypeScript)要重新加载脚本环境所有平台都允许改数值、修活动逻辑、加小玩法。这是手游最主要的更新方式
代码热更编译型语言的逻辑代码(C# / Java / C++)要重启或重载程序集有限制:iOS 审核条款理论上禁止改变 App 主要功能的动态代码下发,实践中通过 IL 解释执行等方案绕行;Android 相对宽松Unity + HybridCLR(ET 框架用的就是这个)、ILRuntime
整包更新整个安装包重新下载安装要走应用商店审核(iOS 1~7 天,不可控)引擎升级、大版本资料片、包结构重构

客户端工程的核心目标:把变更尽量推到上面几层

因为越往上越可控、越快、风险越低。所以成熟团队会:
① 把尽可能多的业务逻辑放在脚本层而不是编译层;
② 把功能开关做成服务端下发(Feature Flag),代码已经发出去了也能关掉;
③ 把资源按分包设计,玩家只下载自己用得到的部分。

业界常说的「包体优化」「热更率」就是在这个框架下讨论的。手游能每周更新好几次而不用提交审核,靠的就是这个分层。

AssetBundle / 资源包 / 分包

  • AssetBundle(AB 包):Unity 的资源打包格式。一个 AB 包里装若干资源(贴图、模型等),可以单独下载、单独更新。其他引擎有类似机制(Godot 的 PCK、自研引擎的自定义格式)。
  • 为什么要分包:整包 2GB 一次下载完体验极差。所以做成「核心包 300MB + 按需下载」——玩家进新手村只下载新手村资源,进新地图再下新的。
  • 对发布的影响:资源包是成千上万个小文件,所以它们的更新不能靠「重新发一个安装包」,必须靠清单文件 + 增量下载。这就是为什么客户端必须有 manifest。

整包 vs 增量包(差量补丁)

整包(Full Package)增量包 / 差量补丁(Delta / Patch)
内容完整的安装包只有新旧版本之间的差异部分
体积大(几十 MB ~ 几十 GB)小(常常是整包的 1%~5%)
生成工具打包工具直接产出bsdiff / xdelta3 / hdiffpatch / 自研
使用方法直接装必须在本地已有正确旧版本的前提下应用
风险较高:补丁应用失败会产生「看起来能用但实际损坏」的包,所以必须校验哈希
何时用用户版本太旧 / 没有对应补丁 / 补丁比整包还大绝大多数日常更新

差量补丁的一个陷阱

假设玩家本地文件被改过(比如自己替换了个贴图),补丁应用后可能得到一个半新半旧的状态,程序能启动但行为诡异。这就是为什么流程里必须有一步:下载完先算 SHA256,跟清单里的值比对,不一致就丢弃并退回整包。这一步省不得。

客户端更新的四种「力度」

这是后面所有讨论的基础。同样叫「客户端更新」,这四种的成本差一个数量级:

力度机制用户感知可控性典型场景
静默热更后台悄悄替换资源/脚本,下次启动生效完全无感完全可控改数值、改文案、修 bug
可选更新启动器提示「有新版本,是否更新?」弹窗,可以点「以后再说」可控新功能、体验优化
强制更新版本闸门校验失败,拒绝进入,必须更新进不去,被迫更新可控但风险高协议不兼容、安全漏洞
整包重装下载完整安装包重新安装重新下载大包、走安装流程最不可控(商店审核)大版本、引擎升级

应用商店审核 / 分阶段发布 / TestFlight

  • 应用商店审核(App Review):iOS 提交新版本后需要 Apple 审核,通常 1~7 天,且可能被拒。这意味着你无法「想发就发」——这是 iOS 与 Android 在发布流程上最大的分叉点。
  • 分阶段发布(Staged Rollout):Google Play 支持「先给 1% 用户,再 5%、20%、100%」;App Store 也有 7 天自动放量的分阶段发布。这是客户端侧的金丝雀
  • TestFlight / 内测:上架前的灰度渠道,可以给指定用户测试。相当于「灰度白名单」。

Feature Flag(功能开关)/ 远程配置

Feature Flag

把新功能的代码随版本一起发出去,但用一个开关控制它是否显示/生效,开关的值由服务端下发。

为什么它是客户端项目的命门:客户端代码一旦发出去就收不回来(用户已经装了)。Feature Flag 是唯一能让「已经发出去的代码」失效的手段——它等效于「零成本回滚」

所以客户端项目有一个铁律:每一个新功能都必须配一个开关。没有开关的新功能 = 出问题只能发新版本 = 用户等好几天。

开关的常见用法还包括:按用户分桶放量(先给 5% 用户开)、AB 测试(一半用户看到 A 版,一半看到 B 版)、紧急止血(线上炸了秒关)。

崩溃上报 / 埋点 / 版本维度观测

  • 崩溃上报(Crash Reporting):客户端崩溃时把堆栈上传(Sentry、Bugly、Firebase Crashlytics)。这是客户端项目唯一能知道「用户那边到底怎么样」的渠道
  • 埋点(Telemetry):记录用户行为(登录成功率、关卡通过率、卡在哪个界面)。
  • 关键要求:必须按客户端版本分组。只看「总崩溃率 0.1%」没意义,要看「1.2.0 版本崩溃率 0.02%,1.1.0 版本崩溃率 0.5%」——这样才能定位是新版本引入的问题还是老版本的历史问题。
  • 而且要注意长尾:1.1.0 的用户虽然只占 2%,但如果他们的崩溃率是 30%,那也是一批正在流失的用户。

1.4 版本与兼容

一个系统里有三套版本号(很多人只想到一套)

版本号含义谁在用例子
客户端版本用户设备上装的那个包的版本启动器、商店、埋点1.2.0(build 20260924)
服务端版本运行在集群里的那套服务的版本K8s 镜像 tag、Argo CDprod-k8s-a3e752d
协议版本客户端与服务端「说话的语法」版本。这才是判断兼容性的依据握手阶段、版本闸门proto 5

为什么必须有独立的「协议版本」

因为客户端版本 1.2.0 和服务端版本之间不是一一对应的。服务端可能连发 20 个版本,协议一直没变;也可能一次改动就断了兼容。

把兼容性判断从「版本号」解耦到「协议版本号」,带来的好处是:服务端可以随便发小版本,只要协议版本不变,老客户端就一直能用。这是让「服务端高频发布」和「客户端低频发布」共存的关键设计。

真实案例(Luanti / Minetest):它把协议版本写死在代码里,官方文档明确写着「当前最低协议版本是 24」。客户端握手时先交换协议版本,不在范围内就拒绝。它甚至出过一个真实的 bug——版本不匹配的客户端把服务端搞崩了,导致所有在线玩家被踢下线。这正好说明:版本闸门做不好是会出大事故的。

版本闸门(Version Gate)

版本闸门

服务端在客户端连接时(握手阶段)做的一道检查:你的版本我支持吗?不支持就拒绝并告知该怎么做

典型响应有三种:
· 放行:协议在兼容窗口内
· 警告但放行:能玩,但推荐更新
· 拒绝:告知最低要求版本,客户端提示强制更新

它放在协议层而不是业务层,是因为这是唯一能保证「所有客户端都过这一关」的地方——你不可能在每个接口里都写一遍版本检查。

兼容窗口(Compatibility Window)/ 多版本共存 / N-2 兼容

客户端有「长尾」:永远有一批用户没升级。所以服务端要同时服务多个客户端版本。业界常见约定:

bash
服务端必须兼容「最近 3 个客户端版本」(N、N-1、N-2)
超出窗口的客户端 通过版本闸门强制更新

这个窗口宽度是团队必须明确写下来的规则,不是自然形成的。
没有这条规则,服务端代码会被历史兼容逻辑淹没;
窗口开得太窄,又会把一批用户挡在门外。

兼容窗口的代价

窗口里每多一个版本,服务端代码里就多一点这种东西:

bash
if (clientProto >= 5) {
 // 新客户端:返回带 newField 的结构
} else {
 // 老客户端:返回旧结构,把新字段丢掉
}

所以窗口宽度是一个工程成本与用户留存之间的权衡,需要定期 review。

懒迁移(Lazy Migration)

懒迁移

问题:玩家的存档格式要改,但玩家可能几个月不上线,你不可能在停机窗口里升完所有人的存档。

解法:玩家登录的那一刻,才把他的存档升级到新格式。这意味着服务端在一段时间内必须能读新旧两种格式

配套要求:需要一个「迁移进度」的可观测指标(还有多少活跃存档是旧格式),等这个数字归零,才能放心删掉旧格式的读取代码。

数据库 expand-contract(扩展-收缩)

数据库结构的变更必须拆成四步、跨多个发布周期完成,才能保证「任何时刻都能安全回滚代码」:

bash
 expand(扩展) 加新列/新表,代码双写(新老结构都写) 此时老代码依然正常
 migrate(迁移) 后台任务把历史数据回填到新结构
 switch(切换) 代码改成只读新结构
 contract(收缩)观察若干个发布周期后,删掉旧列/旧代码

关键:每一步都必须「向后兼容」。
绝对不要在同一个发布里既改表结构又改读法——那样一旦要回滚代码,数据库已经回不去了。

1.5 发布策略

蓝绿 / 金丝雀 / 灰度 / Ring 发布:它们是两个维度

策略本质切换方式一句话记忆
蓝绿 Blue-Green同时维护两套完整环境一次性把流量从 A 切到 B回答「怎么切
金丝雀 Canary同一环境里跑新旧两版比例逐步放量(5%→20%→50%→100%)回答「切给谁
灰度 Gray人群或维度圈定范围按规则(白名单→内部用户→某地区→全量)
Ring 发布把用户分成同心圆环,一圈圈往外放环 0 = 内部,环 1 = 尝鲜用户,环 2 = 大众灰度的组织化形式

它们都归属于 渐进式交付(Progressive Delivery) 这个概念。实践中常组合使用:「灰度人群 + 金丝雀比例」——先给内部白名单 10% 流量,再扩大到全部用户的 50%,最后全量;最终切换可以用蓝绿。

分桶(Bucketing)与哈希分布

分桶

要「给 20% 的用户开放新功能」,怎么决定谁是那 20%?答案是把用户 ID 通过哈希函数映射到 0~99 的桶里,然后判断桶号是否小于 20。

关键要求:同一个用户在每次判断时必须落在同一个桶里(否则用户会一会儿看到新功能一会儿看不到)。这就是为什么用哈希而不是随机数。

一个真实且常见的缺陷:如果哈希函数太简单(比如 h = h*31 + 字符),连续的用户 ID(1001、1002、1003…)会落到连续的桶里。于是「放量 20%」实际只覆盖了 ID 尾号靠前的一小撮用户,流量分布严重倾斜,灰度数据完全失真

正确做法:哈希之后必须做一步雪崩混淆(avalanche / 位混合),让相邻输入的输出差异极大。常用的是 murmur3 的 fmix32。判断标准很简单:取 1000 个连续用户 ID,20% 放量下命中率应该接近 20.0%,且各个十分位分布均匀。

放量百分比(Rollout Percentage)

服务端按分桶结果决定「这个用户是否允许更新到新版本 / 是否看到新功能」。这是 PC 客户端和自有启动器最常用的金丝雀手段,因为这一层完全由你控制,不像应用商店那样受第三方制约。

1.6 部署与流量

摘流(Drain)→ 优雅停机 → 排空

这三个词说的是一件事的三个动作

  1. 摘流:先把实例从「可接收新请求/新连接」的列表里拿掉。新流量不再进来。
  2. 排空:等待已经在处理中的请求完成 / 等待已有连接自然结束。给一个宽限时间。
  3. 优雅停机:排空完成后才真正退出进程。

为什么长连接服务必须做这件事:无状态服务杀 Pod 像关水龙头;长连接服务杀 Pod 等于把正在通话的电话线剪断。摘流做得好,玩家感受到的是「一瞬间的卡顿」;做得不好,就是「掉线回登录页」。

readiness vs liveness 探针(必须分开)

探针回答的问题失败时 K8s 做什么排空期间应该
readiness(就绪)「现在能把流量给它吗?」把 Pod 从 Service Endpoints 摘掉,但容器继续跑返回失败(这本身就是摘流机制
liveness(存活)「它是不是卡死了?」重启容器必须保持成功,否则重启会打断排空
startup(启动)「它启动完了吗?」启动完成前不执行 liveness

把两者写成同一个接口是一个经典错误

如果 readiness 和 liveness 都指向 /healthz,那么排空时 /healthz 一失败,K8s 会认为容器卡死并把重启它——你的排空流程刚走到一半就被打断,玩家全部异常掉线。所以排空时期 readiness 失败、liveness 必须成功。

preStop 钩子与 terminationGracePeriodSeconds

bash
K8s 要删除一个 Pod 时的完整顺序:

 调用 preStop 钩子(阻塞,必须执行完)
 同时开始「摘流」:把 Pod Endpoints 移除
 preStop 执行完 发送 SIGTERM 给容器主进程
 容器自己处理 SIGTERM(执行排空逻辑)
 超过 terminationGracePeriodSeconds 还没退出 SIGKILL 强杀

两个必须注意的点:
· preStop 里通常写「等几秒」或「调一下本机的 /drain 接口」,
 目的是给「Endpoints 摘流生效」留出时间——摘流是异步的,不是瞬间的
· terminationGracePeriodSeconds 必须 > preStop 耗时 + 排空耗时,
 否则第 步的强杀会打断你的优雅停机

Node 必须作为容器里的 PID 1 运行

如果 Dockerfile 写成 CMD node server.js(shell 形式),进程树实际是 /bin/shnode。K8s 发的 SIGTERM 给到 /bin/sh,会被它吞掉,你的排空逻辑永远不会执行,Pod 到宽限期上限被 SIGKILL 硬杀,在线玩家全部异常掉线。

必须写成 exec 形式:CMD ["node", "server.js"]。这个细节决定了你的优雅停机是真的还是假的。

流量切分手段(按实现复杂度排序)

手段能按什么切复杂度你环境的可行性
Service selector 切换全切(蓝绿用)极低可以
Ingress 权重注解 canary-weight按比例(金丝雀)可以,推荐入门用这个
Ingress Header/Cookie 注解 canary-by-header按请求头/Cookie(灰度)可以,适合「指定账号先看新版」
Argo Rollouts + ingress-nginx以上全部 + 自动推进/回滚可以(需装 v1.10.0)
服务网格(Istio/Linkerd)按用户 ID 哈希、自定义规则不建议(资源不够)

会话粘性(Sticky Session)

会话粘性

让同一个用户的请求尽量落到同一个实例上。WebSocket 场景下这是必需的——因为连接是「粘」的,如果玩家的两个请求被分到不同实例,第二个实例不认识他。

实现:Ingress 用 Cookie 做亲和(nginx.ingress.kubernetes.io/affinity: cookie)。

与发布的矛盾:有粘性就意味着某个实例上的连接不会主动跑掉,所以你必须靠摘流 + 通知重连来「推」它们走。这也是为什么长连接服务的发布比无状态服务麻烦。

Argo Rollouts 相关名词

名词含义
Rollout(CRD)替代 K8s 原生 Deployment 的资源类型,多了 strategy.canary / strategy.blueGreen
steps发布的步骤序列,如 setWeight: 10pause: 5msetWeight: 50
trafficRouting告诉 Rollouts「通过什么方式切流量」——这里是 nginx,也可以是 Istio / ALB
AnalysisTemplate把「人工判断要不要继续」变成「自动查指标判断」,比如查成功率低于 99% 就自动中止
Stable / Canary ReplicaSet老版本和新版本各对应一个 ReplicaSet,Rollouts 同时管理两者

1.7 制品与分发(中间件与名词)

三类仓库,管三种东西

仓库管什么你环境里的实例访问方式
镜像仓库
(Container Registry)
Docker 镜像Harbor 192.168.0.50:10086Docker Registry v2 协议
制品仓库
(Artifact Repository)
任意文件:安装包、补丁、jar、npm 包、配置表Nexus / Artifactory(自建)HTTP(可按目录上传/下载)
对象存储 / CDN海量静态文件的高并发下载(暂无,可用 Nginx 顶替)HTTP(自带大带宽)

为什么要分这么细?

  • 镜像仓库是给 K8s 用的,它只认镜像格式,不能放安装包。
  • 制品仓库是「有版本管理、有权限、有保留策略的网盘」。它不擅长扛大带宽——你总不能把 2GB 的游戏包放在 Nexus 上让几万玩家同时下载。
  • CDN / 对象存储专门解决「大带宽」问题。学习阶段用 Nginx 顶替就够了,生产环境才需要 CDN。

版本清单(Manifest)—— 双端发布的中枢

manifest.json 是网页项目里完全不存在的东西

它是一个 JSON 文件,同时被三方读取:客户端启动器(决定要不要更新)、服务端(决定版本闸门放不放行)、运维(人工查看当前该发什么版本)。

核心字段

  • latest:最新客户端版本
  • minSupported:最低支持版本,低于它 → 强制更新
  • recommended:推荐版本,低于它 → 可选更新
  • protocol:协议兼容窗口(min / current)
  • configVersion:配置表版本,双端必须一致
  • packages[]:整包列表(地址、体积、SHA256)
  • patches[]:差量补丁列表(从哪个版本到哪个版本)
  • rollout.percent:放量比例
  • forceUpdate:按平台分别控制强制更新(应对 iOS 审核延迟)

原子发布(Atomic Publish)

为什么 manifest 必须原子替换

如果客户端正好在你写入 manifest.json 写到一半时来拉取,它会读到半个 JSON,解析失败,然后就卡在更新界面——这是一个真实发生过的线上事故类型。

正确做法:先写临时文件 manifest.json.tmp,写完并 fsync 之后,用 mv 覆盖正式文件。mv 在同一文件系统内是原子操作,客户端要么看到旧文件、要么看到新文件,永远不会看到半个。

这个技巧在整个运维领域都通用(配置下发、证书替换、静态站点发布)。

1.8 配置与数据

配置中心 vs 注册中心(两个容易混的概念)

配置中心注册中心
解决什么「配置怎么下发和热更新」「服务 A 怎么找到服务 B 的地址」
存的东西键值对配置(限流阈值、开关、DB 地址)服务实例列表(IP:端口)
典型产品Apollo / NacosConsul / Eureka / Nacos
典型部署Nacos(如 192.168.0.24:8848同时提供这两种能力,所以若依这类网关才能用 lb://ruoyi-auth 这种写法

对客户端项目的意义:注册中心在游戏服务端里还有额外用途——它就是「位置服」的原型。ET 框架的 Location Server 本质就是一个为 Actor 服务的注册中心。

配置表 / 导表流水线(游戏项目的命门)

配置表是「一份源、两份产物」,必须原子生效

策划改 Excel → 导表工具 → 同时产出两份:

bash
策划改 Excel(唯一的事实来源)
 导表工具(同一次运行)
 ├── 服务端用:config_server.json 逻辑判定用(伤害 = 100)
 └── 客户端用:config_client.bytes 显示用(显示伤害 = 100)

如果服务端用了新表(伤害 = 100)而客户端还是旧表(显示 = 50),玩家就会看到「打出的伤害和显示的对不上」,直接爆发客诉。

工程解法:给配置打一个 config_version,客户端启动时上报自己的版本,服务端发现不一致就触发强制热更。

序列化(Serialization)

格式可读性体积速度用途
JSON人可读配置、调试、Web 接口
Protobuf需 schema跨语言通信、协议定义
MessagePack半可读较小客户端与服务端通信
MemoryPack / FlatBuffers不可读极快(零 GC)高性能游戏通信(ET 用的 MemoryPack)

与发布的关系:序列化格式决定了协议兼容性。Protobuf 这种带字段编号的格式天然向后兼容(加字段不影响老客户端);JSON 靠字段名,改名就会破坏兼容;而自定义二进制格式最容易出事——必须严格版本化。

存档(Save Data)

玩家的进度数据。它的发布要求和代码完全不同:

  • 不能丢:代码发错了可以回滚,存档丢了这个玩家就永远流失了。
  • 要能向前兼容:老存档要能被新服务端读。
  • 格式变更要走懒迁移(见 1.4)。
  • 要能回滚:大版本上线前必须备份,出问题时能回滚到升级前的存档快照。

1.9 术语速查表(中英对照)

中文English一句话
长连接 / 短连接Persistent / Short-lived Connection连接会不会一直保持
心跳Heartbeat定期小包,检测对面是否还活着
粘包 / 拆包Sticky / Fragmented PacketsTCP 字节流不保证消息边界
断线重连Reconnect断了自动连回来并恢复状态
会话Session「这个连接是谁」的信息集合
会话迁移Session Migration把在线会话从老实例搬到新实例
摘流Drain先把实例从流量入口摘掉
排空Graceful Shutdown等已有请求/连接自然结束
版本闸门Version Gate握手时校验版本,不兼容就拒绝
协议版本Protocol Version判断兼容性的依据,独立于软件版本号
兼容窗口Compatibility Window服务端要兼容最近几个客户端版本
热更新Hot Update / Live Update不重新安装就更新资源或逻辑
差量补丁Delta / Binary Patch只含新旧版本差异的补丁文件
启动器 / 自更新器Launcher / Updater负责检查和下载更新的小程序
功能开关Feature Flag / Feature Toggle服务端控制已发布功能是否生效
分桶Bucketing把用户稳定地映射到某个百分比桶
放量Rollout Percentage开放给多少比例的用户
灰度Gray Release / Ring Rollout按人群范围放量
金丝雀Canary Release按比例渐进放量
蓝绿Blue-Green Deployment两套环境,一次性切流量
渐进式交付Progressive Delivery蓝绿/金丝雀/灰度的总称
版本清单Manifest客户端该更新到哪个版本的权威描述
原子发布Atomic Publish要么全生效要么不生效,不会读到中间态
懒迁移Lazy Migration用户实际访问时才升级其数据格式
扩展-收缩Expand-Contract数据库结构变更的四步安全流程
导表Config ExportExcel 转成服务端与客户端两份配置产物
序列化Serialization对象与字节流之间的转换
分区 / 分服Zone / Shard互相独立的运行单元,天然的灰度单位
合服Server Merge把多个区的数据合并,风险最高
状态同步State Synchronization服务端算结果推给客户端
帧同步Lockstep只转发操作指令,各端各自算
Actor 模型Actor Model不共享内存、只发消息的并发模型
协程 / FiberCoroutine / Fiber比线程更轻的并发单位
崩溃上报Crash Reporting客户端崩溃堆栈上报(必须按版本分组)
埋点Telemetry / Analytics记录用户行为用于分析
分阶段发布Staged Rollout应用商店提供的按比例放量能力
对象存储Object Storage存海量静态文件(S3/OSS/MinIO)
内容分发网络CDN把静态文件缓存到离用户近的节点
制品仓库Artifact Repository带版本管理的文件仓库(Nexus/Artifactory)
配置中心Config Center集中下发配置(Nacos/Apollo)
注册中心Service Registry服务发现(Nacos/Consul/Eureka)

02 这类系统的典型形态

第 1 章把名词讲清了。这一章回答一个更实际的问题:一个真实的客户端/服务端系统,到底由哪些部分组成? 后面所有关于"发布"的讨论,都建立在这张组件地图上。

2.1 两端形态总览

维度网页项目客户端 / 服务端项目
交付形态一份代码 → 一个容器镜像(+ 静态文件)至少三份产物:服务端镜像、客户端安装包、客户端资源/配置包
版本数量全网同一时刻只有 1 个版本在跑客户端侧同时存在 N 个版本(可能跨好几个大版本)
生效延迟部署完成即生效(秒级)服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月)
分发控制权完全在你自己手里部分不在:应用商店审核、用户不点更新、渠道包版本不一致
回滚语义换回旧镜像 = 真·恢复服务端可回滚;客户端已升级的设备无法回滚,只能"再发一版改回来"
运行状态无状态,Pod 可随时杀常有长连接 / 房间 / 会话 / 存档在内存里,重启即断线
兼容责任浏览器天然向前兼容必须由服务端兜底:新服务端要能服务老客户端
流量单位请求(Request)请求 + 连接(Connection)——连接是"粘"的、有生命周期的
测试复杂度接口测试即可需要客户端版本 × 服务端版本的兼容矩阵测试

一句话总纲

网页项目优化的是「版本替换」;客户端项目优化的是「多版本共存」。

所有的架构设计、发布流程、回滚方案,都是为了让这两件事不发生冲突:

  1. 服务端能同时正确服务多个版本的客户端
  2. 新客户端能在老服务端上"降级运行"(至少不崩)

2.2 服务端侧的典型组件

服务端从来不是一个"程序",而是一组职责分明的角色。不同类型对发布的要求完全不同。

角色职责有状态?典型实现发布方式
网关 / 接入层承载客户端连接、协议解析、限流、路由否(可无状态)Spring Cloud Gateway、自研 TCP 网关滚动更新(需配合连接排空)
登录 / 认证服务账号验证、发 token、跨服跳转Spring Boot + Redis滚动更新
逻辑服 / 游戏服处理业务逻辑,持有房间、会话状态自研 Actor 框架、Skynet必须先排空,再逐台重启
匹配服组队、匹配队列半状态(队列在内存)自研排空后重启
中心服 / 跨服服跨区活动、全局排行榜自研停机窗口或双写迁移
聊天 / IM 服长连接维护、消息投递Netty、自研排空(推送重连指令)
数据服 / DB 代理统一数据访问、分库分表路由自研滚动更新
定时任务 / 离线计算结算、跑批xxl-job、Quartz可随时重启(注意幂等)
后台管理服务GM 工具、运营配置常规 Web 服务完全等同于网页项目

关键洞察:同一套系统里,不同角色的发布策略不一样

很多人一开始会想"给所有服务配一套统一的发布流程"。这是错的。

  • 后台管理服务:随便重启,滚动更新即可
  • 逻辑服:必须先让玩家下线(或迁移到别的实例),再重启
  • 网关:可以让老连接自然结束,但不能同时重启所有实例(否则新连接没地方进)

所以真实的发布系统里,每个服务都会标注自己的"发布类型",流水线按这个类型选择策略。

服务端组件到 K8s 对象的映射

服务端角色推荐 K8s 对象原因
无状态服务(网关/登录/后台)Deployment副本可随意增删、滚动更新天然支持
有状态服务(逻辑服/房间服)Deployment StatefulSetDeployment 也可以,但必须自己实现排空逻辑;StatefulSet 提供稳定的网络标识与受控的更新顺序
需要固定身份与顺序StatefulSet如按序号分片的游戏服(game-0game-1…)
每节点一份(日志采集/边缘代理)DaemonSet每个节点跑一个
一次性任务(数据迁移)Job跑完即止,适合"expand 阶段"的 schema 变更
定时任务CronJob替代 crontab

StatefulSet 不是"有状态服务的必需条件"

一个常见误解是"有状态就必须用 StatefulSet"。实际上:

  • StatefulSet 提供的是「稳定标识 + 有序启停 + 独立存储」,它本身不管理你的业务状态(房间里的玩家、存档数据还是要你自己保存到 Redis/DB)
  • 很多游戏服务端用 Deployment + 自己实现排空,反而更灵活

判断标准:如果服务需要"我是 3 号分片,必须连 3 号数据库"这种固定身份,用 StatefulSet;否则 Deployment 足够。

2.3 客户端侧的典型组件

客户端也不是"一个可执行文件",而是一组协作的进程/模块

组件职责为什么单独存在
启动器 / 更新器(Launcher / Updater)检查版本、下载补丁、校验完整性、启动主程序必须在主程序之外——因为主程序正在运行时无法替换自己
主程序(Game / Client)业务逻辑、渲染、交互体积大,更新成本最高
资源包(Asset Bundle / 资源)贴图、模型、音频、UI 素材体积最大但可独立更新(不需要动代码)
脚本 / 热更代码Lua、C#(HybridCLR)、JS 等不需要重新提交应用商店就能更新逻辑
配置表(数据表)数值、文案、活动配置策划频繁改动,需要独立流水线
内嵌 WebView活动页、公告、充值可随时更新(等同网页项目)

为什么必须有"启动器"

这是个很多初学者会踩的坑:一个正在运行的程序无法替换自己

  • Linux/Windows 上文件被占用时无法覆盖
  • 手机上应用包是只读的(沙箱权限)

所以更新逻辑必须放在一个独立的、足够小的程序里。它的职责很单纯:

text
1. 读取本地版本号
2. 从服务器获取最新版本清单(manifest)
3. 对比:需要更新吗?是强制还是可选?
4. 下载差量补丁或全量包
5. 校验哈希(完整性)
6. 解压/应用补丁
7. 写入新的本地版本号
8. 启动主程序

第 9 章的 demo 会把这 8 步完整实现一遍,你会看到每一步的真实代码。

2.4 一个典型系统的完整组件清单

把两端合起来看,一个运营中的客户端/服务端系统至少有这些部分:

text
【客户端侧】
  ├─ 启动器/更新器(Launcher)
  ├─ 主程序(二进制)
  ├─ 资源包(可热更)
  ├─ 脚本/热更代码
  └─ 配置表(本地副本)

【服务端侧】
  ├─ 网关/接入层
  ├─ 登录认证
  ├─ 逻辑服 / 游戏服(有状态)
  ├─ 匹配服、聊天服、中心服
  ├─ 定时任务
  └─ 后台管理服务

【数据侧】
  ├─ 关系型数据库(账号、订单、存档索引)
  ├─ 缓存(会话、在线状态、排行榜)
  ├─ 配置中心 / 注册中心
  └─ 日志 / 埋点 / 崩溃上报

【发布基础设施】
  ├─ 代码仓库(服务端 & 客户端 & 配置表)
  ├─ CI 构建机(编译、打包、生成补丁)
  ├─ 镜像仓库(服务端产物)
  ├─ 制品仓库(客户端产物:全量包、差量包)
  ├─ 分发通道(CDN / 静态服务器)
  ├─ 版本清单服务(客户端更新的决策依据)
  └─ 观测系统(按版本维度看成功率、崩溃率)

2.5 做这类发布需要的基础设施能力

对照上表,逐项检查你的环境是否具备这些能力。缺哪一项,对应的发布场景就跑不通。

能力用途缺了会怎样本文档用到的实现
制品仓库存客户端全量包、差量补丁,带版本管理只能靠共享目录或手工拷文件,无法追溯Nexus 3(raw hosted 仓库)
签名/哈希校验保证下载的包没被篡改、没下载完整客户端装上一个损坏的包 → 崩溃且难以定位SHA256 + 客户端校验
差量补丁生成大版本只下载差量部分每次更新都要下几百 MB → 用户流失xdelta3
版本清单服务告诉客户端"最新版本是什么、要不要更新"无法控制放量节奏静态 manifest.json(本文档)/后端接口(进阶)
分发通道承载大规模下载下载慢或失败Nginx(本文档)/CDN(生产)
灰度能力(服务端)新版本先给部分流量只能全量发布,出问题影响所有用户Argo Rollouts 金丝雀
功能开关客户端侧的新功能可远程关闭客户端发出去的新功能无法收回Feature Flag(第 1 章 1.3 节)
按版本维度的观测看到"各客户端版本的成功率/崩溃率"灰度期间无法判断新版本是否真的更好日志/埋点按版本分组
长连接排空能力服务端重启时不让玩家异常掉线每次更新都造成大量玩家掉线投诉preStop + drain 接口

这张表里最容易被忽略的两项

  1. 签名/哈希校验:几乎所有自研的更新器都"一开始不做",然后在某次用户反馈"装完打不开"之后才补上。
  2. 按版本维度的观测:没有它,灰度发布就是盲发——你放量 20%,但不知道那 20% 里新版本的成功率是多少。

这两项都不是"以后再说"的优化,而是灰度发布能否成立的前提。

03 为什么网页那套不能照搬

若依这类微服务项目是典型的网页项目:一组无状态服务 + 一个前端静态目录。那套流程里有一些「默认成立」的前提,在客户端软件里一个都不成立

维度网页项目(如若依微服务)客户端 / 服务端项目
交付产物无状态容器镜像 + 静态文件服务端镜像 客户端安装包 / 资源包 / 配置表(三份,需分别发布)
用户侧版本刷新即最新,全网只有一个版本用户设备上可能装着 3 年前的版本,同时存在 N 个版本
生效方式服务端部署完,下次请求立刻生效(秒级)服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月不等)
变更可控性你说了算客户端部分你说了不算:商店审核、用户不点更新
回滚语义回滚 = 换回旧版本,真·恢复服务端可回滚;客户端升级不可逆,只能「再发一个版本改回来」
状态无状态,Pod 随便杀长连接 / 房间 / 会话 / 存档在内存里,重启就是断线
兼容性责任方浏览器天然向前兼容,服务端随意改必须由服务端兜底:新服务端要能服务老客户端
流量单位请求请求 + 连接(连接是「粘」的)
测试难度一套接口测试就够需要客户端版本 × 服务端版本的兼容矩阵测试

3.1 三条最重要的差异

差异一:网页项目的回滚是回滚,客户端项目的「回滚」是再发一版

服务端出问题,argocd app rollback 一秒回旧版本,用户毫无感知。客户端出问题,你没有任何办法把用户设备上那个包变回旧版——只能连夜出新版本,然后祈祷用户愿意更新。

推论:客户端发布的风险是「不可逆」的。因此客户端的质量闸门必须比网页严得多,而且必须配备「服务端开关」作为兜底

差异二:网页项目服务端可以随便改,客户端项目服务端必须「只加不改」

网页项目改个接口返回结构,改完部署,前端下次请求跟着改就行。但客户端项目里,老客户端是你改不动的存量。一旦把字段改名或删掉,所有没升级的老客户端立刻报错、闪退、卡在登录页。

推论:客户端项目的服务端接口必须遵守向后兼容契约——字段只增不减、新增字段要有默认值、旧语义不能改、接口不能直接下线(要先标废弃、观察调用量为零、再等一个发布周期才删)。

差异三:客户端有「长尾」,网页没有

网页项目的版本分布是一条竖线(发布完成 = 100% 新版本)。客户端项目是一条长尾曲线,尾部可能拖几个月到几年。企业内网软件常年停在某个 LTS 版本;手游里总有用户半年不更新;桌面软件里总有人关掉自动更新。

推论:客户端项目的版本管理本质是**「多版本共存治理」**,不是「版本替换」。你要维护的是兼容矩阵,不是版本号。

版本分布对比:同一时刻,服务端只有一个版本,客户端有一堆版本服务端(发布即替换)100%0%v1 = 100%切换v2 = 100%一条竖线:切换完,旧版本就没了客户端(长尾共存)100%0%新版本占比老版本残存(长尾)一条长尾:永远有一批人没升级
图 1 服务端是「替换」,客户端是「共存」。这就是两类项目发布复杂度差异的根源

04 标准流程:三类产物、三条通道

网页项目只有一条交付链路(代码 → 镜像 → 集群)。客户端/服务端项目有三条相互独立、节奏不同的交付通道,这是理解一切差异的框架。

一份源码服务端 + 客户端① 服务端镜像Docker Image② 客户端安装包整包 / 差量补丁③ 资源配置表双端共用,最易出事Harbor + K8sArgo CD / RolloutsNexus + Nginx版本清单 + 增量补丁Nexus(同一份)必须原子性双端生效长连接 / API秒级生效用户设备分钟~月级生效双端同时读取不一致就出事故④ 版本清单(manifest)—— 双端发布的「调度中枢」记录:最新客户端版本 / 最低支持版本 / 协议版本 / 补丁地址 / 校验哈希 / 强制更新级别服务端启动时读它做「版本闸门」;客户端启动时读它决定「要不要更新、更新到什么程度」← 这是网页项目里完全不存在的东西,也是客户端项目 CD 的核心
图 2 三类产物、三条通道,以及把三条通道串起来的「版本清单」。网页项目只有最上面那条

4.1 通用标准流程(7 步)

#环节网页项目的做法客户端/服务端项目的额外要求
1版本号策略Git SHA 或时间戳,能追溯即可需要三套版本号:客户端版本、服务端版本、协议版本。靠协议版本判断兼容性
2构建与打包只出镜像同时产出:服务端镜像、客户端整包、客户端差量补丁、导表产物
3自动化测试接口测试 + 单测额外需要:协议契约测试(新服务端跑老请求样本)、兼容矩阵测试、导表一致性校验
4制品入库只推 Harbor镜像推 Harbor;客户端包与配置表推 Nexus;大包另需 CDN/对象存储
5发布版本清单不存在这一步关键环节:更新 manifest.json原子发布。这一步决定双端的可见性
6发布编排k8s 滚动 / 蓝绿 / 金丝雀服务端同上;客户端是分渠道 / 分地区 / 分用户比例放量,无法「滚动」
7观测与回滚服务端指标 + 一键回滚必须按客户端版本分组观测;客户端回滚靠「服务端开关 + 发新版」

4.2 服务端标准流程(K8s 视角)

bash
代码提交 CI 构建镜像 Harbor 更新 GitOps 清单 Argo CD 同步

 RollingUpdate(默认) / BlueGreen / Canary

服务端特有的两点增强:
 长连接服务必须先「摘流 + 排空」再终止,否则用户断线
 启动时做「版本闸门」:读 manifest 里的 proto 版本,拒绝不兼容的客户端连接

4.3 客户端标准流程(网页项目完全没有的部分)

bash
 构建:出整包 + 生成版本号(写进包内版本文件)
 差量:与上一个线上版本对比,生成增量补丁(xdelta3 / bsdiff)
 入库:整包 + 补丁包推 Nexus,记录 SHA256 与体积
 清单:更新 manifest.json(新版本号、最低支持版本、补丁地址、强制级别)
 放量:分渠道/分桶逐步放开,同时开启服务端 Feature Flag

 观测:各客户端版本的成功率、崩溃率、登录转化率

4.4 配置表 / 静态数据:第三类产物

网页项目里配置改完部署就生效。客户端项目里,配置表是 Excel → 导表工具 → 双端产物,两份产物必须来自同一次导表(详见 1.8 节)。

05 特别流程:8 类软件的差异地图

「客户端/服务端软件」不是一个东西,不同类型差异大到需要不同流程。下面按「标准流程里哪一步被替换掉」来组织。

5.1 手机游戏(热更新为主)

环节与标准流程的差异
审核iOS 必须过 App Store 审核(1~7 天,不可控)。所以 iOS 端的「可热更范围」被严格限制在 Apple 允许的范围内(脚本、资源、配置),这是 iOS 和 Android 发布流程最大的分叉点
渠道Android 有几十个渠道包(应用宝、华为、小米、TapTap…),一个版本要出 N 个包,且各渠道审核节奏不同 → 发布是「多点异步」的
放量商店支持分阶段发布(如 Google Play Staged Rollout:1% → 5% → 20% → 100%),这是客户端侧的金丝雀
回滚无法回滚,只能「发新版」或服务端下开关

5.2 PC 客户端网游

  • 自更新器是核心组件:先启动一个极小的 launcher,launcher 检查 manifest → 下载补丁 → 应用 → 启动主程序。launcher 本身极少更新(它一旦坏了就没法修)。
  • 下载策略:整包 10~50GB,必须用差量补丁;大厂还会用 P2P + 多 CDN 源分摊带宽。
  • 发布窗口:通常选凌晨低峰,配合停机维护公告。玩家对「维护」有预期,这是 PC 网游比手游宽松的地方。

5.3 长连接游戏服务器(最重要的「特别流程」)

核心难点:连接是「粘」的,重启就是断线

网页项目里 Pod 随时可以被杀,因为每个请求都是独立的。但长连接服务里,玩家的整个游戏过程都挂在一条连接上,杀掉 Pod = 所有在线玩家掉线。

标准做法(摘流 + 迁移)

  1. 摘流:从负载均衡/网关把该实例的新连接权重置零(新玩家不再进来)
  2. 排空:给一个宽限期(如 60s),让现有对局自然结束
  3. 引导迁移:主动通知仍在线的客户端「服务即将重启,请重连」→ 客户端重连到其他实例
  4. 优雅退出preStop 钩子里完成上述动作,terminationGracePeriodSeconds 要大于宽限期
  5. 分区发布:一次只滚动一个区/服,其他区照常运营

这也是为什么游戏服务端很少用「一次性蓝绿全切」——切过去那一瞬间,所有在线玩家都会掉线。

5.4 桌面 / 企业内网软件

  • 发布渠道不是应用商店,而是内网分发 + 域策略 / MDM 推送
  • 可以做到真正的「静默升级」,但需要管理员权限,且常受变更窗口约束(只能在维护窗口推送)。
  • 蓝绿在这里有真实对应物:先给 IT 部门灰度,再给财务部,最后全公司——本质是按组织维度的灰度。
  • 常有「LTS 版本」概念:大部分用户停留在 LTS,只有关键补丁才强制推送。

5.5 无状态 API / 微服务(最接近网页项目)

若依这类微服务就属于这一类。标准 k8s 流程可以直接套用,不需要本文的大部分复杂度。差别只在:如果它同时服务客户端,则要额外遵守「向后兼容契约」和「版本闸门」。

5.6 强一致 / 金融类服务

  • 不能出现「新旧版本同时处理同一笔业务」的情况 → 蓝绿切换时需要先排空队列
  • 发布要做对账:切换后跑一次数据一致性校验,不一致就回滚。
  • 数据库变更严格遵守 expand-contract,跨多个发布周期完成。

5.7 IoT / 边缘设备 OTA

  • 设备可能永远在线不稳定、可能断电 → 升级包必须支持断电续传与失败回滚。
  • 硬件上常用 A/B 双分区:新固件写入 B 分区,验证成功后切换引导分区。这是「蓝绿」在硬件层的原始形态——而且它把回滚变成了硬件级的一行操作。
  • 分批放量必须按「设备批次 / 地区」而不是按比例,因为坏固件可能刷砖,无法远程修复。

5.8 数据层 / 数据库

无论是哪类软件,数据库变更都是独立的第四类发布,走 expand-contract 四步(见 1.4 节)。

06 蓝绿 / 金丝雀 / 灰度在哪里体现

6.1 服务端侧(K8s):三者的落地方式

策略用现有能力怎么实现流量入口资源成本可行性
蓝绿两套 Deployment(-blue/-green)+ 一个 Service,改 Service 的 selector 一次性切换。或用 Argo Rollouts 的 blueGreen 策略Service selector2× Pod可行,但你的集群内存紧张,节点余量不足 2 倍
金丝雀(按比例)Argo Rollouts + 现有 ingress-nginx,用 nginx.ingress.kubernetes.io/canary-weight 注解按权重分流Nginx Ingress1× + 少量可行,推荐作为入门演练
灰度(按人群)Argo Rollouts + ingress-nginx 的 canary-by-header / canary-by-cookie,按请求头或 Cookie 路由Nginx Ingress1× + 少量可行,最适合「先给测试账号放量」
更细粒度Istio / Linkerd 服务网格按用户 ID 哈希分流Sidecar较高不建议(集群资源不够,且引入复杂度)

6.2 客户端侧:三者变得完全不一样

客户端上不存在「蓝绿」

蓝绿的前提是「流量可以瞬间从 A 切到 B」。但客户端已经装在用户设备上了,你没法让用户的手机瞬间变成另一个版本。所以客户端侧只有两类手段:

  • 放量控制(对应金丝雀/灰度):控制「谁能下载到新版本」
  • 功能开关(对应灰度):控制「装了新版本的人能不能用新功能」
策略客户端上的对应物具体做法
金丝雀商店分阶段发布Google Play Staged Rollout:1% → 5% → 20% → 50% → 100%。iOS 用 App Store 的分阶段发布(7 天自动放量)
金丝雀渠道分批先放 TapTap / 官网包(可控),再放应用宝,最后放华为/小米。观察各渠道数据再决定是否继续
金丝雀自有更新器的比例放量服务端按「用户 ID 分桶 < N」决定是否下发「可更新」标记 → 这一层你完全可控,是 PC 端最常用的手段
灰度白名单 / 内测组内部员工账号、测试机、招募的体验服玩家先行
灰度按地区/运营商先开一个小地区,验证 CDN 与网络兼容性后再全国
Feature Flag这才是客户端项目最有力的灰度手段新功能代码已随版本发出,但默认关闭;服务端按用户分桶下发「开启」标记。可以随时关掉,等效于「零成本回滚」
蓝绿双版本并行(变体)老客户端连老逻辑、新客户端连新逻辑,服务端同时维护两条路径。这不是真正的蓝绿,而是「双轨共存」,是过渡期的权宜之计

6.3 一次完整的大版本发布:双端编排表

阶段服务端动作客户端动作用户感知
T-7 天部署新接口(双协议并行,老协议继续服务)新客户端包提审(iOS)
T-3 天保持双协议Android 各渠道包准备完毕
T-1 天上线配置表新版本(双端同一份)
T 日 停机发布维护公告 → 停服 → 升级数据库(expand)→ 部署新服务端停机公告
T 日 开服版本闸门设为「最低支持 proto=5」开放整包下载;老客户端登录时被要求更新强制更新
T+1 天观察各客户端版本的成功率/崩溃率放量到 50%部分用户仍未更新
T+3 天确认老版本流量接近 0放量到 100%
T+14 天下线旧协议代码(contract)

07 四种发布状态:客观分析

四种状态判定真实情况
服务端更新,客户端无需更新✔ 正确,占比最高日常发布的绝对主力(约 6~7 成)。唯一前提是服务端保持向后兼容——只加不改、不删字段、不改语义。
服务端无更新,客户端更新◐ 方向对,但「无更新」是理想态纯客户端更新(改 UI、换素材、性能优化)确实存在;但现实中服务端通常仍要动一点:更新版本清单、调开关默认值、放开新资源路径。真正的「服务端零改动」只发生在纯资源替换。
服务端更新,客户端少量更新✔ 正确,是「新功能发布」的标准形态服务端加接口 + 客户端资源热更 + Feature Flag 按人群放量。老客户端依然能登录,只是看不到新功能。
服务端大更新,客户端大更新甚至升级✔ 正确,对应「资料片 / 大版本」协议不兼容的破坏性变更。需要停机窗口或分服灰度、强制更新、协议大版本号递增、老客户端被版本闸门拒绝登录。

容易被忽略的 4 个维度

  1. 发布顺序:谁先发?客户端先发还是服务端先发?这一步选错,就是全服事故。
  2. 客户端更新的「力度」:静默热更 / 可选更新 / 强制更新 / 整包重装。这四者对服务端的要求完全不同(见 1.3 节)。
  3. 兼容窗口与多版本共存:服务端要同时服务 N、N-1、N-2 三个版本的客户端。
  4. 数据/存档这个第四维:配置表、存档格式、数据库结构的变更不随双端发布一起生效

7.1 状态一:服务端更新,客户端无需更新

成立,且是日常发布的绝对主力

占实际工作量的 60%~70%。典型内容:服务端性能优化、后台 bug 修复、运营活动逻辑、风控规则、日志与监控增强。

它成立的技术前提(缺一不可)

  1. 协议只加不改:新增字段老客户端忽略即可;不能改字段类型、不能改字段语义
  2. 接口只增不删:老接口必须继续返回,哪怕新客户端已经不用了
  3. 行为不改变老客户端的可观测结果:可以改服务端内部算法,但不能让老客户端显示的数值变得不合理
  4. 配置表改动必须落在客户端已有字段上,不能新增客户端不认识的列

7.2 状态二:服务端无更新,客户端更新

方向正确,但「服务端无更新」在现实中很少是字面意义的零改动

真正的「服务端一行不动」只发生在纯资源替换(换张图片、改个文案、字体优化)。只要涉及任何「用户能不能拿到新版本」的判断,服务端就必须动:

  • 至少要更新 manifest.json(新版本下载地址、哈希、体积)
  • 如果要控制放量节奏,服务端要下发「是否允许该用户更新」
  • 如果新客户端有默认开启的新功能,服务端要能关掉它
  • 如果新客户端改了上报格式,服务端要能解析新格式同时兼容旧格式

更关键的一点:这种情况下服务端的兼容压力其实更大,因为它要同时服务「已经升级的新客户端」和「没升级的老客户端」,两边期望不一样。这就是「多版本共存」的真实代价。

7.3 状态三:服务端更新,客户端少量更新

成立,且是最健康的形态

bash
服务端:新增接口 + 新增逻辑(老接口不变)
客户端:资源包/脚本热更(走「静默热更」或「可选更新」)
开关 :Feature Flag 控制新入口是否显示,按用户分桶放量

效果:老客户端照常玩,只是看不到新入口;
 新客户端能看到新入口,但后台可以随时关掉

为什么这种形态最健康:它把风险完全控制在「可回滚」范围内——出问题关掉开关就行,不需要发新版本,也不需要回滚服务端。

7.4 状态四:服务端大更新,客户端大更新甚至升级

成立,对应「资料片 / 大版本 / 引擎升级」

特征是协议不兼容:新服务端无法正确服务老客户端。特征清单:

  • 协议版本大版本号递增(proto 4 → 5
  • 版本闸门提升「最低支持版本」,老客户端被拒绝登录并提示强制更新
  • 需要停机窗口(或分服分批灰度,一个区一个区地升)
  • 数据库执行 expand 阶段的结构变更
  • 客户端走「整包重装」或大体积补丁
  • iOS 需要提前提审,时间不可控 → 排期时必须预留审核缓冲

风险最高的也是这一类:一旦客户端审核延迟而服务端已经切到新协议,就会出现「玩家进不去游戏,而新客户端还没上架」的灾难。所以实践中通常选择**「服务端双协议并行一段时间」**而不是一刀切。

7.5 维度 1:发布顺序(最容易造成全服事故的变量)

DANGER

这是最容易被忽略、但后果最严重的变量。四种组合:

顺序前提后果
服务端先发新服务端必须能服务老客户端安全。前提是服务端严格遵守向后兼容
客户端先发服务端必须能服务新客户端通常需要服务端提前上线新接口(双协议并行)
客户端先发,服务端没准备不成立新客户端调用不存在的接口 → 新用户直接不可用
服务端先发(破坏性变更),客户端还没发不成立最典型的事故:全部老客户端立刻挂掉

工程上的通用法则
① 破坏性变更 → 服务端先上双协议,等新客户端铺开后再下线老协议;
② 更稳妥的做法是「三步走」:服务端支持双协议 → 客户端发布并放量 → 服务端下线老协议。
这个过程的绝对时长由「客户端铺开速度」决定,通常是 2 周到 2 个月

7.6 维度 2:客户端更新的「力度」

「客户端更新」其实是四件不同的事,有四种力度(见 1.3)。同样是「服务端更新,客户端少量更新」,走静默热更和走强制更新,对服务端的要求和风险完全不同:

客户端更新力度服务端需要做什么风险
静默热更只要更新资源清单,几乎不需改动
可选更新需要下发更新提示;需兼容未更新用户
强制更新必须提升版本闸门,且要准备好回退方案中高(用户被挡在门外)
整包重装要协调应用商店/分发渠道,需预留审核期高(时间不可控)

7.7 维度 3:兼容窗口与多版本共存

一个常见的隐含假设是「服务端和客户端一一对应」。真实情况是一对多:任意时刻,服务端要同时正确服务 N、N-1、N-2 三个版本的客户端(详见 1.4 节)。

7.8 维度 4:数据 / 存档这第四维

只考虑「服务端」和「客户端」两个轴是不够的,真实系统里还有第三个独立的版本轴:数据格式(存档结构、数据库 schema、配置表版本)。它的特点是不随双端发布一起生效,而且一旦转换往往不可逆。解法是懒迁移(见 1.4 节)。

7.9 完整状态矩阵:从 4 种扩展到 8 种

把「发布顺序」「客户端更新力度」「协议兼容性」「数据变更」四个维度补进来之后,完整状态空间是这样的:

#服务端改动客户端改动协议发布顺序停机回滚能力现实频次
1内部逻辑兼容服务端单发服务端可回滚最高(约 40%)
2纯资源兼容客户端单发靠再发一版
3逻辑/代码兼容(新客户端兼容老服务端)客户端单发靠再发一版/开关
4新增接口热更资源+开关兼容(老客端可用)服务端先关开关即可
5新增接口代码+资源兼容服务端先(双协议)服务端可回滚 + 关开关
6破坏性变更大版本不兼容停机 or 分服极难(双端已切)低(每年 1~4 次)
7数据格式变更视情况兼容(惰性迁移)服务端先(读新旧)谨慎(数据已迁移)
8配置表变更必须同步热更需版本校验原子双端回滚表即可高频(每周)

看第 1 行和第 8 行

日常 90% 的工作量集中在第 1、2、4、8 这四种状态。第 6 行(大版本)虽然只占发布次数的极小比例,却占事故后果的绝大部分。所以工程资源应当这样分配:把第 1/2/4/8 做成完全自动化的流水线,把人力集中在第 6 行的演练与预案上。

7.10 三种真实事故模式

事故模式一:服务端先发,老客户端全军覆没

场景:服务端改了登录接口的返回字段名(userIduid),部署上线。老客户端解析不到 userId,登录失败,所有未更新的用户立刻无法进入。

根因:把「服务端可以随便改接口」这个网页项目的习惯带到了客户端项目。

正确做法:新字段与老字段共存一段时间(双写双读),等老客户端流量趋零再删老字段。时间窗口:至少一个完整的客户端铺开周期

事故模式二:强制更新遇上审核延迟

场景:计划 T 日上线大版本,服务端 T 日切换协议并提升版本闸门。结果 iOS 版本审核被拒,重新提交后再等 3 天。这 3 天里,iOS 用户全部卡在「请更新到最新版本」的提示页上,而新版本根本下载不到

根因:把「客户端可用性」和「服务端发布」的时序耦合在了一起,却忽略了客户端分发有外部依赖。

正确做法:① 版本闸门的提升必须在确认新客户端已在所有渠道可用之后才执行;② 闸门要支持按平台分别设置(iOS 通过审核前,iOS 的闸门不动);③ 预留缓冲期。

事故模式三:配置表只发了一半

场景:导表同学把服务端表更新到了环境并生效,客户端的资源包还在打包中。这期间上线的玩家看到「技能描述写着造成 100 点伤害,实际打了 50 点」,论坛立刻炸锅。

根因:把配置表当成「一份文件」而不是「两份必须同步的产物」。

正确做法:给配置打 config_version;服务端启动和客户端登录时都校验;不一致时优先走强制热更。导表产出必须是一个原子发布单元。

08 开源项目:拿来就能练的真项目

第 9 章的小 demo 能让你 30 分钟理解概念,但它只有几十行代码,看不到「真实项目长什么样」。这一章给你 8 个真实开源项目,按「学习目标」和「机器的承受能力」分成三档。

选型标准(为什么是这些项目)

  • 必须有「客户端 + 服务端」两端,或者至少能体现「客户端独立于服务端演进」这个特征
  • 服务端能在 Linux 上跑,不需要装 Unity / Visual Studio 就能看到效果
  • 国内可获取:这些仓库都可以克隆(必要时用 gh-proxy.com / ghfast.top 前缀加速)
  • 有代码可读:不是只提供一个安装包,而是能读它的发布逻辑

8.1 项目矩阵

项目语言两端形态最值得学的东西部署难度资源需求
ColyseusTypeScript7.3k服务端框架 + JS/Unity/Defold 等客户端 SDK房间(Room)生命周期、状态同步、重连极低(npm 一条命令)≈100MB
NakamaGo13.4k单二进制服务端 + 官方客户端 SDK 共 8 种
(Unity/Unreal/Godot/JS/C#/Java/Swift/Defold)
工业级游戏后端:匹配、排行榜、实时多人、
rUDP 协议、内嵌管理控制台
低(官方 docker-compose)≈500MB + 数据库
RustDeskRust124k全平台客户端(自带自动更新)+ 自建中继服务端(hbbs/hbbr)客户端自动更新的完整实现 +
「客户端 + 分发服务端」的最小完整案例
低(单二进制)≈50MB
Luanti
(原 Minetest)
C++13.6k独立客户端 + luantiserver 专用服务端协议版本号 + 版本闸门的最佳教材:
官方文档明确写「最低协议版本 24」,握手时协商
中(要编译或装包)≈200MB
MindustryJava29.1k桌面/移动客户端 + -server 无头服务端「客户端版本与服务端不匹配」的实际处理方式低(一个 jar)≈300MB(JVM)
OpenIMGo16.7k服务端 + Android/iOS/Flutter/Web/PC 全端客户端长连接 IM 的完整工业架构
网关层、消息可靠投递、离线推送、多端消息同步
高(需 Mongo+Redis+Kafka+MinIO+Etcd)≈4GB(需 8C16G 以上)
ETC#9.9kUnity 客户端 + C# 服务端(双端共享同一份协议代码HybridCLR 客户端代码热更 + 服务端 DLL 热重载 +
Location Server 路由 + 中文文档
高(需 Unity + VS2022)开发机需求高
SkynetC14.2k只有服务端框架(无客户端)Actor 调度模型、Lua 层热更新(国内游戏服务端的祖师爷级项目)≈100MB

按目标机器配置给出的建议路线

按「单机能跑起来」的要求,可以这样选:

  • 4C8G 即可跑起来的:Colyseus、RustDesk、Skynet、Mindustry
  • 建议只读代码不部署的:OpenIM(依赖太重)、ET(需要 Unity 环境)
  • Nakama:官方 docker-compose 自带 PostgreSQL,大约需要 1~2GB 内存,4C8G 机器可以跑

判断方法:先看目标机器上还跑着什么(数据库、构建任务),确认剩余内存再决定部署哪个。

8.2 第一档:能立刻跑起来,并且能看见「两端」

① Colyseus —— 最小可跑的多人游戏框架(推荐第一个练)

为什么第一个选它

它是 Node.js 生态的,和你已经会的 npm 流程完全一致;服务端和客户端 SDK 都是 TypeScript,一套语言看两端;代码量小,能把「房间生命周期」这个概念看透。启动只要一条命令。

对应的知识点:长连接、房间(Room)、状态同步(State Sync)、断线重连、客户端与服务端的消息协议。

把项目拉下来(只读操作,不改任何东西)
bash
cd /root && git ls-remote --heads https://github.com/colyseus/colyseus.git | head -5

这条命令在做什么

这条命令做什么:git ls-remote 只列出远端分支,不下载任何东西

  • 为什么先做这一步:它验证「服务器能不能连上这个仓库」——这正是后面 Argo CD 的 repo-server 要做的事。连通性有问题就要先解决,而不是等到部署时才发现。
  • 输出里的 HEAD 和分支名是哈希值,不是文件名。能看到一堆哈希就说明通了。
正式克隆并看一眼它的结构
bash
cd /root && git clone --depth 1 https://github.com/colyseus/colyseus.git colyseus-study
cd colyseus-study && ls -1 | head -20 && cat package.json | head -30

这条命令在做什么

逐条解释:

  • --depth 1浅克隆,只拿最新一次提交,不拉全部历史。学习用途足够,能省大量时间和磁盘。
  • ls -1:每行一个地列目录。先看它怎么分层——这一步的目的是找出「服务端代码」和「客户端 SDK」分别在哪个目录,这是你理解两端关系的第一步。
  • cat package.json:看它的 workspaces 字段。Monorepo 项目会把多个包放在一个仓库里,看清这个结构你就知道发布时哪些包需要各自打版本。

② RustDesk —— 「客户端自动更新」的最小完整案例

为什么它对你特别有价值

RustDesk 的结构和「游戏客户端 + 服务端」几乎一模一样,但轻得多:

  • 客户端:装在你电脑上的那个程序,自带「检查更新」功能,需要更新时会提示或自动升级
  • 服务端hbbs(信令/ID 服务器)+ hbbr(中继服务器),两个都是单个二进制文件,几十 MB,4C8G 机器就能跑
  • 发布形态:服务端是镜像/二进制发布,客户端是安装包分发 —— 正是本文讲的两条通道

要读的代码:客户端里搜 update 相关模块,看它怎么「检查新版本 → 下载 → 校验 → 替换自己」。这就是 launcher/updater 的真实实现。

克隆并定位「自动更新」相关代码
bash
cd /root && git clone --depth 1 https://github.com/rustdesk/rustdesk.git rustdesk-study
cd rustdesk-study && grep -ril 'check_update\|check_update_version\|update_from_url' --include=*.rs . | head -20

这条命令在做什么

逐条解释:

  • grep -ril-r 递归、-i 忽略大小写、-l 只输出文件名(不输出匹配内容,避免刷屏)。
  • --include=*.rs:只在 Rust 源码里搜,跳过文档和资源文件。
  • 为什么用这种方式读代码:面对一个十几万行的大项目,不要从头读。先锁定关键词再定位文件,是读陌生代码库最有效的方法。你要找的就是「版本检查」和「下载替换」这两段。
跑一个 RustDesk 中继服务端(可选,需要 Docker)
bash
ss -lntp | grep -E ':21115|:21116|:21117' || echo "端口空闲,可以起服务"
docker run -d --name hbbs --restart=unless-stopped --net=host \
 rustdesk/rustdesk-server:latest hbbs
docker logs --tail 30 hbbs

这条命令在做什么

逐条解释:

  • ss -lntp | grep ...:先检查端口有没有被占用。21115/21116/21117 是 RustDesk 服务端的默认端口。先检查再启动是个好习惯,能避免「启动了但连不上」的困惑。
  • docker run -d:后台运行。--restart=unless-stopped:除非手动停,否则开机自启。
  • --net=host:直接使用宿主机网络(RustDesk 需要大量 UDP 端口,桥接模式很麻烦)。
  • docker logs --tail 30:看最近 30 行日志确认启动成功。服务端启动后第一件事永远是看日志

注意:这条命令会真的启动一个容器。不想留就 docker rm -f hbbs 清掉。

8.3 第二档:理解「协议版本」和「版本闸门」

③ Luanti(原 Minetest)—— 协议版本号的最佳教材

为什么它值得专门跑一遍

官方文档里写明:Luanti 的网络协议页明确写着**「当前最低协议版本是 24」**,客户端和服务端在握手的第一时间就交换协议版本,不在范围内直接拒绝。一个合格的「版本闸门」应该长什么样,看它就够了。

更能说明问题的是它的一个真实事故:有用户用 5.5.1 版本的安卓客户端连 5.6.1 的服务端,结果服务端直接崩溃,把所有在线玩家踢下线(该问题后来被修复)。这说明——版本不匹配如果只做了「拒绝」而没有做好错误处理,是会从一个客户端的问题升级成全服事故的。

克隆并阅读协议定义
bash
cd /root && git clone --depth 1 https://github.com/luanti-org/luanti.git luanti-study
cd luanti-study && grep -rn 'LATEST_PROTOCOL_VERSION\|MIN_PROTOCOL_VERSION\|PROTOCOL_VERSION' \
 src/network/networkprotocol.h | head -20

这条命令在做什么

逐条解释:

  • 官方文档说协议定义在 networkprotocol.h 这个头文件里,所以直接去那里找。
  • PROTOCOL_VERSION 类常量是编译期常量——也就是「协议版本」是写死在代码里的,改一次就要重新编译发版。这正是「协议变更 = 不可热更」的物理原因。
  • 你要观察的:这个数字是怎么被用在握手里的?搜 TOSERVER_INIT(客户端发给服务端的第一个包)看它怎么校验版本、校验失败后怎么回应。
看它的版本号规则(理解「大版本 = 破坏性变更」)
bash
cd /root/luanti-study && grep -n -A12 -i 'version scheme\|versioning' README.md | head -40

这条命令在做什么

**这条命令做什么:**在 README 里找「版本号方案」那一段。

  • Luanti 的规则是:Major 号只有出现破坏性变更时才递增,minor 是新功能,patch 是修 bug。
  • 这就是语义化版本(SemVer)的核心思想,也是为什么「协议版本」和「软件版本」要分开——软件的 patch 号涨了不代表协议变了。

④ Mindustry —— 看它怎么处理客户端与服务端版本不匹配

它适合看什么

Mindustry 是一个 Java 写的塔防 RTS,客户端和专用服务端是同一个 jar 用不同参数启动java -jar server-release.jar)。它会在玩家连接时校验版本,版本不一致会直接提示。

学习点:这是「单体发布物,两种运行角色」的形态——和「一份源码出两个产物」的思路一致,但更简单。适合对比理解。

克隆并找版本校验逻辑
bash
cd /root && git clone --depth 1 https://github.com/Anuken/Mindustry.git mindustry-study
cd mindustry-study && grep -rn 'version' core/src/mindustry/core/NetServer.java | head -20

这条命令在做什么

逐条解释:

  • NetServer.java 是它的服务端网络核心。先找到「核心文件」,再在里面搜关键词,比全仓库搜快得多。
  • 你要找的是:它怎么拿到连进来的客户端的版本、怎么比较、不一致时是断开还是警告。这就是版本闸门的最小实现

8.4 第三档:只读代码,理解工业级架构(不建议部署)

⑤ OpenIM —— 长连接 IM 的完整工业架构

先看依赖,再决定要不要动手

OpenIM 的服务端依赖 MongoDB + Redis + Kafka + MinIO + Etcd 一整套。这套依赖至少要 8C16G 才能跑起来,所以建议:只克隆代码读架构,不部署

它值得读的原因是:IM 的架构和游戏服务端几乎同构——都是长连接 + 网关层 + 消息路由 + 离线存储 + 多端同步。而且它客户端覆盖 Android / iOS / Flutter / Web / PC 五端,是观察「多端版本共存」的真实样本。

克隆并摸清服务端模块划分
bash
cd /root && git clone --depth 1 https://github.com/openimsdk/open-im-server.git openim-study
cd openim-study && ls -1 cmd/ && echo "--- 网关/长连接相关 ---" && ls -1 internal/ 2>/dev/null | head -20

这条命令在做什么

逐条解释:

  • ls -1 cmd/:Go 项目的惯例是每个可执行程序在 cmd/ 下有一个目录。看这里就知道它拆了几个服务进程——这就是 1.2 节讲的「服务端分工」的真实样子。
  • ls -1 internal/:内部实现包。看目录名能看出它的分层(网关、消息、推送、存储…)。
  • 读法建议:不要读实现细节,先画出「哪个进程负责什么、它们之间怎么通信」的图。有了图,再去读你关心的那一块。

⑥ ET 框架 —— 客户端热更新 + 双端共享协议的中文教材

它解决的是本文最核心的一个问题

ET 是国产的 Unity 双端框架,文档全中文。它有三个设计直接对应本文的概念:

  • 客户端代码热更:用 HybridCLR 把 C# 逻辑编译成可以运行时加载的程序集,不用重新提交应用商店就能更新游戏逻辑,连协议、配置、UI 都能热更。这正是 1.3 节「代码热更」的真实实现。
  • 服务端 DLL 热重载:它的组件只有数据、没有方法,所有方法做成扩展方法放在可重载的 DLL 里。重载 DLL 时实体数据保留——这就是「热更服务端逻辑而不丢状态」的办法。
  • 双端共享同一份协议文件:客户端和服务端引用同一份消息定义,加一个消息只需要改一遍。这是避免「双端协议不一致」的工程手段。

它需要 Unity + VS2022 才能跑起来,所以建议读文档和代码结构,不部署。仓库里的 Book/ 目录是中文教程。

克隆并看它的文档目录结构
bash
cd /root && git clone --depth 1 https://github.com/egametang/ET.git ET-study
cd ET-study && ls -1 Book/ | head -30

这条命令在做什么

**这条命令做什么:**列出中文文档目录。

  • Book/ 里是分章节的中文教程(运行指南、热更、网络、服务器架构等),比读代码快得多
  • 建议阅读顺序:先看「运行指南」理解两端怎么启动,再看「热更」章节——那一章就是本文 1.3 节的实践版。

⑦ Skynet —— 国内游戏服务端的祖师爷

云风的 Lua 游戏服务端框架。它没有客户端,所以不适合学「双端发布」,但它是理解 Actor 调度模型Lua 层热更新最好的材料——国内大量商业游戏服务端的架构思路都源自它。

克隆并看它的服务划分
bash
cd /root && git clone --depth 1 https://github.com/cloudwu/skynet.git skynet-study
cd skynet-study && ls -1 service/ | head -30

这条命令在做什么

**这条命令做什么:**列出 service/ 目录。Skynet 里每个「服务」就是一个 Actor 进程单元(gate 网关、logger 日志、harbor 跨节点路由等)。

  • harborgate 两个:前者是「跨机器路由」,后者是「客户端接入」。这两个就是 1.2 节讲的「网关服」和「位置服」的最简实现。

8.5 建议的学习顺序

顺序项目花多久你要能回答的问题
1第 9 章的自建 demo半天版本闸门、Feature Flag、摘流 分别解决了什么问题?
2Colyseus1 天一个「房间」的生命周期是怎样的?客户端断线重连后怎么恢复?
3RustDesk1 天客户端怎么知道有新版本?下载完怎么保证没被改坏?
4Luanti1 天协议版本号为什么必须独立于软件版本号?不匹配时应该怎么处理?
5Nakama2 天一个成熟的游戏后端把哪些能力做成了「开箱即用」?
6OpenIM / ET(读代码)各 1~2 天工业级项目的服务端是怎么分层的?热更新在双端分别怎么实现?

09 动手演练:从白板环境到完整发布链路

前八章讲的是"是什么、为什么"。这一章回答"怎么做"。

本章的组织方式

阶段推进,每个阶段结束时都有一个明确的验收点。只有前一个阶段验收通过,才进入下一个阶段——这类系统涉及的组件多,跳步会产生大量难以定位的问题。

阶段目标产出
9.0环境准备一个可用的 K8s 集群 + 检查清单
9.1部署客户端制品仓库Nexus 上一个可上传/下载的 raw 仓库
9.2认识"被发布的软件"理解 demo 的结构(它就是被发布的那个软件)
9.3客户端发布链路版本清单、全量包、差量补丁、原子发布、哈希校验
9.4服务端编排Argo Rollouts 金丝雀发布,可观察到流量按比例分发
9.5双端联合演练把第 7 章的 8 种状态矩阵逐个跑一遍

关于"自动化脚本"的一个说明

本章不提供任何自动化脚本(除了修复 bug 的),所有能力都用单条命令 + 解释的方式给出。

原因:你的目标是"学会",而不是"跑通一次"。每一条命令你亲手敲过、知道它在做什么、出了问题知道从哪查——这比一个 ./deploy.sh 有价值得多。

唯一的例外:demo 目录里的 server.jslauncher.jsDockerfilerollout-canary.yaml 这些文件必须保留。理由见 9.2:

它们不是自动化脚本,而是「被发布的软件本身」——就像若依项目里的 ruoyi-gateway.jar。删了就没东西可发布了。

9.0 阶段 0:环境准备与前置检查

9.0.1 需要准备的机器

完整清单见 第 0 章的两张表格。这里只列本阶段要用到的部分:

IP主机名配置本阶段要做的事
192.168.0.10k8s-master4C8G装 K8s 控制平面、装 Argo Rollouts
192.168.0.11k8s-node18C16G加入集群,跑服务端 Pod(stable)
192.168.0.12k8s-node28C16G加入集群,跑服务端 Pod(canary)
192.168.0.20build8C16G构建镜像、生成差量补丁、跑客户端
192.168.0.30nexus4C8G客户端制品仓库
192.168.0.31dist2C4G客户端更新包分发(Nginx)
192.168.0.50harbor4C8G服务端镜像仓库

9.0.2 搭建 K8s 集群(简版)

K8s 集群的完整搭建过程在另一份文档里。这里给出可以直接复制的最小命令序列,保证本章能独立跑起来。

如果已经有可用的 K8s 集群

跳到 9.0.3 做前置检查即可。只要集群版本 ≥ v1.30、且至少有一个工作节点有 2 CPU / 4Gi 以上余量,本章的命令都能跑。

bash
# ========== 【三台节点】系统初始化(.10 / .11 / .12 各自执行) ==========
dnf install -y vim wget curl net-tools telnet lsof bash-completion git tar chrony
systemctl enable --now chronyd

# 关闭 swap(kubelet 的硬性要求)
swapoff -a && sed -i '/swap/s/^/#/' /etc/fstab

# 加载内核模块
cat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
ip_vs
ip_vs_rr
EOF
modprobe overlay && modprobe br_netfilter && modprobe ip_vs && modprobe ip_vs_rr

# 内核参数
cat > /etc/sysctl.d/k8s.conf <<'EOF'
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
fs.inotify.max_user_instances       = 8192
fs.inotify.max_user_watches         = 524288
net.netfilter.nf_conntrack_max      = 1048576
EOF
sysctl --system

# 关闭防火墙与 SELinux(内网自建环境;云环境请用安全组代替)
systemctl disable --now firewalld
setenforce 0 && sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config

这几条命令在做什么

  • swapoff -a + 注释 fstab:K8s 调度器按内存请求做决策,swap 会让这个决策失真。kubelet 检测到 swap 开启会拒绝启动
  • br_netfilter:让 iptables 能"看见"网桥上的流量。缺了它,同节点上不同 Pod 之间的网络会不通,且报错非常隐晦。
  • nf_conntrack_max:连接跟踪表上限。长连接场景下这张表消耗极快,默认值容易被打满,表现为"网络随机超时"。
bash
# ========== 【三台节点】安装容器运行时(containerd) ==========
dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
dnf makecache && dnf install -y containerd.io

containerd config default > /etc/containerd/config.toml
# 关键一:cgroup 驱动与 kubelet 一致
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
# 关键二:pause 镜像换国内源(否则任何 Pod 都起不来)
sed -i 's#sandbox_image = "registry.k8s.io/pause:.*"#sandbox_image = "m.daocloud.io/registry.k8s.io/pause:3.10"#' /etc/containerd/config.toml

systemctl enable --now containerd
systemctl status containerd --no-pager

这条命令在做什么

  • SystemdCgroup = true必须与 kubelet 的 cgroup-driver=systemd 保持一致。不一致会导致容器启动后立刻被 OOM Kill,而且 describe pod 里看不到原因。
  • sandbox_image:Pod 里那个"只为占住网络命名空间"的 pause 容器。默认地址在国内拉不到,不换它,所有 Pod 会卡在 ContainerCreating
bash
# ========== 【三台节点】安装 kubeadm / kubelet / kubectl ==========
cat > /etc/yum.repos.d/kubernetes.repo <<'EOF'
[kubernetes]
name=Kubernetes
baseurl=https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/
enabled=1
gpgcheck=1
gpgkey=https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/repodata/repomd.xml.key
EOF
dnf makecache && dnf install -y kubelet kubeadm kubectl
systemctl enable kubelet      # 只 enable,不要 start(此时还没有配置文件)

# 预拉取控制平面镜像
kubeadm config images pull --image-repository registry.cn-hangzhou.aliyuncs.com/google_containers

这条命令在做什么

  • systemctl enable kubelet不 start):kubelet 此时没有配置文件,直接启动会反复重启报错。kubeadm init 会生成配置并启动它。这是完全正常的现象。
  • kubeadm config images pull:提前把约 8 个控制平面镜像拉好。默认从 registry.k8s.io 拉取,国内基本超时。
bash
# ========== 【master .10】初始化集群 ==========
cat > /root/kubeadm-config.yaml <<'EOF'
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 192.168.0.10
  bindPort: 6443
nodeRegistration:
  criSocket: unix:///var/run/containerd/containerd.sock
  kubeletExtraArgs:
    cgroup-driver: systemd
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.5
imageRepository: registry.cn-hangzhou.aliyuncs.com/google_containers
controlPlaneEndpoint: "192.168.0.10:6443"
networking:
  serviceSubnet: 10.96.0.0/12
  podSubnet: 10.244.0.0/16
EOF

kubeadm init --config /root/kubeadm-config.yaml --upload-certs 2>&1 | tee /root/kubeadm-init.log

# 配置 kubectl
mkdir -p $HOME/.kube && cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

这条命令在做什么

  • podSubnetserviceSubnet 绝不能和物理网段(192.168.0.0/24)重叠,否则会出现"部分 IP 无法访问"这种极难排查的问题。
  • | tee /root/kubeadm-init.log保存输出。日志末尾的 kubeadm join 命令是节点加入的唯一凭据(丢了可以用 kubeadm token create --print-join-command 重新生成)。
bash
# ========== 【master .10】安装 Calico 网络插件 ==========
cd /root
curl -fL -o calico.yaml \
  https://gh-proxy.com/https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/calico.yaml

# 关键:Calico 默认 Pod 网段是 192.168.0.0/16,与物理网段重叠!必须改成 10.244.0.0/16
sed -i 's|^\( *\)# *- name: CALICO_IPV4POOL_CIDR|\1- name: CALICO_IPV4POOL_CIDR|' calico.yaml
sed -i 's|^\( *\)# *value: "192.168.0.0/16"|\1value: "10.244.0.0/16"|' calico.yaml

# 确认改动生效
grep -A 1 "CALICO_IPV4POOL_CIDR" calico.yaml | head -4

kubectl apply -f calico.yaml
kubectl get pods -n kube-system -l k8s-app=calico-node -w

这条命令在做什么

  • 这是本章最容易埋雷的一处:Calico 清单里默认的 Pod 网段是 192.168.0.0/16,而示例环境的物理网段是 192.168.0.0/24,正好落在里面。如果不改,Pod 会拿到与物理机冲突的 IP,内核会直接发 ARP 而不走隧道,表现为网络时通时断
bash
# ========== 【worker .11 / .12】加入集群 ==========
# 在 master 上生成 join 命令
kubeadm token create --print-join-command

# 在每台 worker 上执行上面输出的命令
kubeadm join 192.168.0.10:6443 --token <token> --discovery-token-ca-cert-hash sha256:<hash>

# 回到 master 验证
kubectl get nodes
# 期望:三个节点都是 Ready

9.0.3 前置检查清单

bash
# ① 节点状态
kubectl get nodes
# 期望:k8s-master / k8s-node1 / k8s-node2 全部 Ready

# ② 集群版本(决定后面用哪个版本的 Argo Rollouts)
kubectl version
# 记下 Server Version,例如 v1.36.5

# ③ 节点资源余量(决定 Argo Rollouts 装在哪、副本数设多少)
kubectl top nodes 2>/dev/null || echo "(metrics-server 未装,可跳过)"

# ④ 节点标签(本演练用 hostname 区分两个工作节点)
kubectl get nodes --show-labels | grep -E "hostname|NAME"

# ⑤ 节点可分配资源
kubectl describe node k8s-node1 | grep -A 6 "Allocatable"

# ⑥ 构建机上的工具是否齐备
#    在 build(192.168.0.20)上执行:
for c in node npm docker git python3 curl; do
  printf "  %-8s " "$c"
  command -v $c >/dev/null && $c --version 2>&1 | head -1 || echo "❌ 未安装"
done

这个清单在确认什么

检查不通过的后果
集群本身有问题,先修集群
版本决定了 Argo Rollouts 能装哪个版本——选错会在装的时候撞墙
③ ⑤资源不够时 Argo Rollouts 的 Pod 会一直 Pending
后面用 nodeSelector 把 canary 版本钉到 node2,需要确认标签存在
xdelta3 就无法生成差量补丁(9.3 会讲)
bash
# 在 build 机上补装缺失的工具(以 Rocky / RHEL 系为例)
dnf install -y nodejs npm git python3
# Node.js 版本建议 20+;若默认源版本过旧,用 NodeSource 或 nvm 安装

# ★ 差量补丁工具(EPEL 源)
dnf install -y epel-release
dnf install -y xdelta

# 验证
xdelta3 -V
# 期望输出:Xdelta version 3.x.x

这条命令在做什么

  • xdelta3 是生成与应用差量补丁的工具(第 1 章 1.3 节讲的"增量包")。它比较两个版本的二进制,只输出差异部分,能把几百 MB 的更新压缩到几十 MB。
  • 在 RHEL/Rocky 系里包名是 xdelta(提供的命令是 xdelta3)。
  • 如果 dnf install xdelta 找不到:确认 epel-release 已安装并 dnf makecache,或从源码编译。

9.1 阶段 1:部署客户端制品仓库(Nexus)

9.1.1 为什么需要制品仓库

第一次做这类发布的人,往往会把这个环节省掉:客户端更新包直接放在某台服务器的某个目录里,用 scp 拷过去。

问题会在第三个版本开始暴露

问题具体表现
没有版本管理一个目录里堆着 client-1.2.tar.gzclient-1.2-new.tar.gzclient-1.2-final.tar.gz,没人知道哪个是在线的
无法追溯用户反馈"1.2.0 装不上",你找不到当时发布的那个包到底是哪一份
无法回滚想回退到 1.1.0,那个包已经被覆盖或删了
没有校验下载完整性靠自己算哈希,没有权威记录
权限混乱谁都能往目录里扔文件

制品仓库解决的就是这五件事:带版本、可追溯、可回滚、有校验和、有权限。

三类仓库分别管什么

仓库类型存什么在本演练里的角色
镜像仓库(Container Registry)Docker/OCI 镜像Harbor(服务端产物)
制品仓库(Artifact Repository)任意文件:安装包、补丁、jar、npm 包、配置表Nexus(客户端产物)
代码仓库(SCM)源代码、配置源文件Gitea

Nexus 是一个通用的制品仓库,它同时支持 Maven、npm、Docker、raw 等多种格式。本演练只用到 raw(原始文件)格式——因为我们只需要"存下载链接可寻址的文件、带版本、能算哈希"。

9.1.2 在 192.168.0.30 上安装 Nexus

bash
# 1) 安装 JDK 17(Nexus 3.70+ 不再自带 JDK,必须系统安装)
dnf install -y java-17-openjdk java-17-openjdk-headless

# 2) 验证
java -version
# 期望:openjdk version "17.0.x"

这条命令在做什么

  • Nexus 3.70 起移除了内置的 JRE,改为使用系统 JDK,且要求 JDK 17
  • -headless 版本即可(Nexus 是服务端程序,不需要图形库)。
bash
# 3) 下载 Nexus(华为云镜像,实测 HTTP 200 可下载)
cd /opt
curl -fLO https://mirrors.huaweicloud.com/nexus/nexus-3.96.3-01-unix.tar.gz

# 4) 确认下载完整(约 250MB)
ls -lh nexus-3.96.3-01-unix.tar.gz

# 5) 解压(会得到两个目录:nexus-3.96.3-01 和 sonatype-work)
tar -xzf nexus-3.96.3-01-unix.tar.gz -C /opt

# 6) 确认目录结构
ls -l /opt
#   nexus-3.96.3-01/     <- 程序本体
#   sonatype-work/       <- 数据目录(仓库内容存在这里)

这条命令在做什么

  • Nexus 的 tar 包解开后是两个平级的目录:程序目录 nexus-3.96.3-01 和数据目录 sonatype-work升级时只替换程序目录,数据目录保留 —— 这是它设计的巧妙之处。
  • curl -fLO-f 让 HTTP 错误返回非零退出码(避免把错误页存成文件),-O 使用 URL 里的文件名。

为什么用华为云的镜像

Sonatype 官方的下载站(download.sonatype.com)在国内访问不稳定,实测部分路径返回 404。华为云维护了 Nexus 的镜像,地址规则很简单:

text
https://mirrors.huaweicloud.com/nexus/nexus-<版本>-unix.tar.gz

实测该地址返回 HTTP 200。如果你要装别的版本,把版本号换掉即可。

bash
# 7) 创建一个专用的运行用户(不要用 root 跑 Nexus)
useradd -r -m -d /opt/nexus -s /sbin/nologin nexus 2>/dev/null || echo "用户已存在"

# 8) 把数据目录与程序目录的属主交给它
chown -R nexus:nexus /opt/nexus-3.96.3-01 /opt/sonatype-work

# 9) 调整 JVM 内存(默认值对 4C8G 的机器偏大)
vim /opt/nexus-3.96.3-01/bin/nexus.vmoptions

nexus.vmoptions 里的关键两行(按机器配置调整):

properties
# 4C8G 机器的推荐值
-Xms1024m
-Xmx1024m
-XX:MaxDirectMemorySize=1024m

这几条命令在做什么

  • useradd -r:创建一个系统用户(-r 表示不创建家目录的登录用户之外的用途)。-s /sbin/nologin 禁止它登录 shell —— 这是安全基线,服务账号不应该能登录
  • -Xms / -Xmx 设成一样大:避免 JVM 在运行期反复扩容堆,减少 GC 抖动(这是个通用建议)。
  • 为什么要把内存调小:Nexus 默认的 -Xmx2703m 是为大机器准备的,在 4G 内存的机器上会导致系统内存吃紧,甚至触发 OOM Killer 把 Nexus 自己杀掉。
bash
# 10) 让 Nexus 以 nexus 用户运行
#     修改 bin/nexus 脚本里的 run_as_user(不同版本位置可能不同,有的用环境变量)
vim /opt/nexus-3.96.3-01/bin/nexus
# 找到 run_as_user="" 这一行,改成:
# run_as_user="nexus"

# 11) 创建 systemd 服务单元(推荐,方便管理)
cat > /etc/systemd/system/nexus.service <<'EOF'
[Unit]
Description=Nexus Repository Manager
After=network.target

[Service]
Type=forking
LimitNOFILE=65536
ExecStart=/opt/nexus-3.96.3-01/bin/nexus start
ExecStop=/opt/nexus-3.96.3-01/bin/nexus stop
User=nexus
Restart=on-abort
TimeoutSec=600

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now nexus

# 12) 观察启动(首次启动要初始化数据库,约 1~2 分钟)
systemctl status nexus --no-pager
tail -f /opt/sonatype-work/nexus3/log/nexus.log
# 看到 "Started Sonatype Nexus OSS" 后按 Ctrl+C

这个 unit 文件在做什么

指令作用
Type=forkingNexus 的启动脚本会 fork 到后台,属于传统 fork 型服务
LimitNOFILE=65536提高文件描述符上限。Nexus 要处理大量下载请求,默认 1024 会不够
User=nexus以专用用户运行
Restart=on-abort异常中止时自动重启
bash
# 13) 放通端口(若用 firewalld;已关闭则跳过)
firewall-cmd --permanent --add-port=8081/tcp && firewall-cmd --reload

# 14) 验证 Web 端可访问
curl -s -o /dev/null -w "nexus: HTTP %{http_code}\n" http://192.168.0.30:8081
# 期望:HTTP 200

# 15) 获取初始管理员密码
cat /opt/sonatype-work/nexus3/admin.password
# 输出一串随机字符,例如 8f2c...(这是 admin 用户的初始密码)

这条命令在做什么

  • /opt/sonatype-work/nexus3/admin.password 是 Nexus 首次启动时生成的一次性密码。登录后会要求你修改密码,改完之后这个文件会被删除
  • 如果这个文件不存在且你也没改过密码,说明数据目录是旧的(之前初始化过)。

9.1.3 创建客户端产物仓库

登录 Web(http://192.168.0.30:8081,用户名 admin + 上面的初始密码),按下面的步骤操作:

步骤操作路径参数为什么这样配
1修改 admin 密码提示时设置一个强密码初始密码是一次性的
2创建仓库设置(齿轮图标)→ Repository → Repositories → Create repository
3选类型选择 raw (hosted)raw = 原始文件;hosted = 本地存储(不是代理远端)
4填参数Name: client-releases
Version policy: Release
Layout policy: Strict
Blob store: 默认
名字后面会出现在 URL 里
5保存Create repository

为什么选 raw (hosted),不选 Maven/npm

格式适用对象为什么这里不合适
maven2 (hosted)Java 的 jar 包它的 URL 结构强制按 group/artifact/version 分层,且会生成 maven-metadata.xml。客户端安装包不是这种结构
npm (hosted)Node 的包同上,且需要 package.json
raw (hosted)任意文件,URL 路径完全由你决定✅ 正是我们要的:http://host/repository/client-releases/1.2.0/full.tar.gz

raw 仓库的本质就是一个带权限与版本管理能力的文件服务器,这正是客户端产物分发的需求。

bash
# 16) 验证仓库可用:用 curl 上传一个测试文件
#     先在 build 机上造一个测试文件
echo "hello nexus" > /tmp/test.txt

# 17) 上传(Nexus 的 raw 仓库上传用 PUT 方法)
curl -u admin:'你的新密码' --upload-file /tmp/test.txt \
  http://192.168.0.30:8081/repository/client-releases/test/test.txt

# 18) 下载验证
curl -s http://192.168.0.30:8081/repository/client-releases/test/test.txt
# 期望输出:hello nexus

这几条命令在做什么

  • --upload-file + PUT:Nexus 的 raw 仓库支持标准的 HTTP PUT 上传。这是它作为"文件服务器"最直接的体现。
  • URL 结构http://<nexus地址>:8081/repository/<仓库名>/<你自定义的路径>仓库名之后的路径完全由你决定,这就是 raw 仓库的灵活性。
  • 下载不需要认证(client-releases 默认是公开可读的)。如果要私有,在仓库配置里改权限。

第 17 步的认证方式在生产环境要换掉

curl -u admin:密码 会把密码明文写在命令里(会进入 shell 历史)。正确做法

bash
# 用环境变量 + stdin
read -s -p "Nexus 密码: " NEXUS_PASS; echo
curl -u "admin:${NEXUS_PASS}" --upload-file /tmp/test.txt \
  http://192.168.0.30:8081/repository/client-releases/test/test.txt

# 或者在 CI 里用专用的上传账号(权限只到这一个仓库)

在 CI 场景下,一定要创建专用的上传账号,不要在流水线里用 admin。

9.2 阶段 2:认识「被发布的软件」

先花 20 分钟读一遍 server/server.jsclient/launcher.js。它们不是「跑一下就完事」的脚本,而是要被发布的两个产物。读的时候对照第 1 章的概念:

server.js 里找对应 1.x 节的哪个概念
MIN_PROTOCOL / CURRENT_PROTOCOL协议兼容窗口(1.4)
握手时检查 msg.protocol 并返回 reject版本闸门(1.4)
握手时检查 msg.configVersion配置表版本校验(1.8)
FEATURE_FLAGS 环境变量Feature Flag(1.3)
bucketOf() 函数分桶与哈希雪崩(1.5)
ROLLOUT_PERCENT放量百分比(1.5)
ready 变量 + /healthz 返回 503摘流(1.6)
/livez 始终返回 200readiness 与 liveness 分离(1.6)
drain() 函数优雅停机(1.6)
跑起来看效果(先不涉及 K8s)
bash
export PATH=/root/node/bin:$PATH
cd /root/ArgoCD-study/demo/server 2>/dev/null || cd ~/demo/server
npm config set registry https://registry.npmmirror.com
npm install
FEATURES=new_map,new_shop SERVER_VERSION=1.2.0 node server.js

这条命令在做什么

逐条解释:

  • export PATH=/usr/bin:$PATH:把 Node 加进 PATH。如果 Node 是通过 nvm 或自定义路径安装的,需要显式把它的 bin 目录加进来。
  • npm config set registry https://registry.npmmirror.com:把 npm 源换成国内镜像。demo 的 Dockerfile 里也做了这一步,所以构建镜像时不会因为拉不到包而失败。
  • npm install:安装依赖(这个项目只依赖一个 ws 包,用来做 WebSocket 服务端)。
  • 最后一行:用环境变量启动,这是本 demo 的核心教学点——FEATURES 就是 Feature Flag。改这个值重启,就相当于「服务端下发不同的开关配置」。
  • 观察启动日志:它会打印协议窗口 [4, 5]configVersion、当前开关。这三行就是「服务端能力声明」

Ctrl+C 停止,或者另开一个终端继续下一步。

另开一个终端,用 4 种客户端参数验证 4 个机制
bash
export PATH=/root/node/bin:$PATH && cd ~/demo/client

node launcher.js --user 1001 --protocol 5 # ① 新客户端:应通过,并拿到 2 个功能
node launcher.js --user 1001 --protocol 4 # ② 老客户端:应通过,但功能列表为空
node launcher.js --user 1001 --protocol 3 # ③ 太老的客户端:应被版本闸门拒绝
node launcher.js --user 1001 --config cfg-x # ④ 配置表版本不一致:应要求先热更

这条命令在做什么

这四条命令各自演示了什么:

  • 正常路径。观察输出里的「握手通过」和「对我开放的功能」。
  • ② 这是最重要的一条。协议 4 在兼容窗口 [4,5] 内,所以放行;但因为它是老协议,服务端不下发新功能。这一条完整演示了「老客户端还能玩,只是看不到新功能」——也就是你推断三的实现。
  • 协议 3 低于 MIN_PROTOCOL=4,服务端返回 reject 并告知最低要求。这就是「强制更新」的技术本质——不是客户端自己想更新,是服务端不让它进。
  • 模拟「配置表只发了一半」的场景(1.8 节的事故模式三)。
验证分桶分布是否均匀(这是容易被忽略的坑)
bash
export PATH=/root/node/bin:$PATH && cd ~/demo/client
for u in $(seq 1000 1099); do node launcher.js --user $u --no-update 2>/dev/null; done \
 | grep -c '是否命中放量桶:true'

这条命令在做什么

**这条命令做什么:**跑 100 个连续用户,数一数有多少个「命中放量桶」。grep -c 只输出计数,不输出内容。

  • 为什么要做这个测试:如果分桶哈希写得不好,连续 ID 会落到连续的桶里,你看到的可能是「命中 0 个」或「命中 1 个」这种荒谬结果,而不是接近放量比例的数字。
  • 服务端默认 ROLLOUT_PERCENT=100(全部命中),所以想看到真实分布,要把服务端用 ROLLOUT_PERCENT=20 重启后再测,此时 100 个用户里应该命中约 20 个。
  • 这个坑真实存在:本文 demo 的第一版哈希就没做雪崩混淆,连续 ID 得到的是连续桶号,放量 20% 实际只覆盖了 ID 尾号靠前的一小撮用户。修复后 1000 个连续 ID 的命中率才是 20.1%。
验证摘流 + 优雅排空(长连接服务最关键的一段)
bash
# 终端 A:起服务端(把排空宽限期设短一点,方便观察)
export PATH=/root/node/bin:$PATH && cd ~/demo/server
DRAIN_GRACE_MS=3000 node server.js

# 终端 B:跑一个客户端连上
export PATH=/root/node/bin:$PATH && cd ~/demo/client
node launcher.js --no-update

# 终端 C:确认排空前 readiness 正常,然后触发摘流
curl -s http://127.0.0.1:3000/healthz; echo
curl -s 'http://127.0.0.1:3000/drain?grace=3000'; echo
curl -s http://127.0.0.1:3000/healthz; echo # ← 现在应该是 503(= 摘流生效)
curl -s http://127.0.0.1:3000/livez; echo # ← 仍然是 200(= 没被判定为卡死)

这条命令在做什么

这一组命令在演示什么:

  • DRAIN_GRACE_MS=3000:把排空宽限期从默认 20 秒改成 3 秒,这样你不用等太久。
  • 第三个 curl /healthz 返回 503故意的——readiness 失败 → K8s 会把 Pod 从 Endpoints 摘掉 → 新连接不再进来。这就是「摘流」的实现方式:不是去调负载均衡的 API,而是让自己的就绪探针失败
  • 第四个 curl /livez 仍然是 200——这是为了告诉 K8s「我活着,别重启我」。如果这两个探针用同一个接口,排空会被 K8s 的重启打断(1.6 节的经典错误)。
  • 同时去终端 B 看:客户端应该收到 {"type":"reconnect","message":"服务即将重启..."}而不是异常断开。这就是「用户感受到的是卡顿,而不是掉线」。
  • Linux 上还可以用 SIGTERM 触发同一条路径pkill -TERM -f "node server.js"。它和 /drain 调用的是同一个函数。

9.3 阶段 3:客户端发布链路(全程单条命令)

这一阶段的目标

把「客户端从 1.0.0 更新到 1.2.0」这条链路完整跑通,而且每一步都是你自己敲的:打包 → 生成差量补丁 → 算哈希 → 写清单 → 原子发布 → 客户端自动更新。

① 装差量补丁工具
bash
dnf install -y xdelta

这条命令在做什么

  • xdelta 在 EPEL 源里(dnf install -y epel-release 之后即可安装,包名 xdelta,提供 xdelta3 命令)。
  • 它是什么:一个生成/应用二进制差量补丁的工具。给两个版本的安装包,它能算出「只有差异部分」的小补丁。
  • 为什么用 xdelta 而不是 bsdiff:bsdiff 在 Rocky 的官方源和 EPEL 里都没有,xdelta3 有。功能用途相同。
  • 验证:xdelta3 -V 能看到版本号就成功了。
② 准备两个版本的「客户端安装包」
bash
mkdir -p /opt/patches/client/1.0.0 /opt/patches/client/1.2.0
cd /opt/patches/client

# 用随机数据模拟两个体积不同的包(真实项目里这里是你的构建产物)
dd if=/dev/urandom of=1.0.0/full.tar.gz bs=1k count=2000
dd if=/dev/urandom of=1.2.0/full.tar.gz bs=1k count=2080

ls -lh 1.0.0/full.tar.gz 1.2.0/full.tar.gz

这条命令在做什么

  • mkdir -p-p 表示父目录不存在就一起建,已存在也不报错。
  • dd if=/dev/urandom of=... bs=1k count=2000:生成 2000KB(约 2MB)的随机文件,用来模拟安装包。bs=1k count=2000 就是 1000 字节 × 2000 块。真实项目里这一步是「执行构建脚本」,这里只是造个有体积的假包。
  • ls -lh-h 让体积以人类可读形式显示(KB/MB),而不是字节数。
③ 生成差量补丁 —— 观察体积差,这是客户端更新的意义所在
bash
cd /opt/patches/client
xdelta3 -e -s 1.0.0/full.tar.gz 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
ls -lh 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch

这条命令在做什么

逐条解释:

  • -e:encode,表示「生成补丁」(对应的 -d 是 decode,即应用补丁)。
  • -s 1.0.0/full.tar.gz源文件(玩家本地已有的那个版本)。-s 是 source 的意思。
  • 然后是新文件,最后是输出补丁。顺序不能乱。
  • 真实场景下这里会看到补丁只有整包的百分之几。你这次用随机数据,补丁会接近整包大小——这是正常的,因为随机数据没有重复模式可利用。想看到真实效果,可以把 1.2.0 改成「1.0.0 的副本 + 改几个字节」:cp 1.0.0/full.tar.gz 1.2.0/full.tar.gz && echo change >> 1.2.0/full.tar.gz,这样生成的补丁就只有几 KB。
④ 算 SHA256 和体积 —— 这两个数字要填进清单
bash
cd /opt/patches/client
sha256sum 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
stat -c '%n %s bytes' 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch

这条命令在做什么

逐条解释:

  • sha256sum:输出「64 位十六进制哈希 + 文件名」。它的用途是完整性校验——客户端下载完文件后重算一次,和清单里的值比对,不一致就说明文件损坏或被篡改。
  • stat -c '%n %s bytes'-c 表示自定义输出格式。%n 是文件名,%s 是文件字节数。不要用 ls -l 去抄体积,它显示的是 KB/MB 会四舍五入,清单里需要精确字节数。
  • 为什么必须手工算而不是估算:这两个值是客户端用来判断「下载是否成功」的唯一依据。填错了客户端会认为所有下载都是坏的。
⑤ 写版本清单(先写临时文件)
bash
cd /opt/patches/client
cat > manifest.json.tmp <<'EOF'
{
 "latest": { "client": "1.2.0" },
 "minSupported": { "client": "1.0.0" },
 "recommended": { "client": "1.2.0" },
 "protocol": { "min": 4, "current": 5 },
 "configVersion": "cfg-20260924-01",
 "rollout": { "enabled": true, "percent": 20 },
 "forceUpdate": { "ios": false, "android": false },
 "packages": [
 { "platform": "linux-x64", "version": "1.2.0", "type": "full",
 "url": "http://127.0.0.1/patches/1.2.0/full.tar.gz",
 "size": 把上一步的字节数填这里,
 "sha256": "把上一步的哈希填这里" }
 ],
 "patches": [
 { "platform": "linux-x64", "from": "1.0.0", "to": "1.2.0", "type": "delta",
 "url": "http://127.0.0.1/patches/1.0.0-to-1.2.0.patch",
 "size": 把上一步的字节数填这里,
 "sha256": "把上一步的哈希填这里" }
 ]
}
EOF
python3 -m json.tool manifest.json.tmp

这条命令在做什么

逐条解释:

  • cat > 文件 <<'EOF' ... EOFheredoc 写法,把两个 EOF 之间的内容写进文件。注意 'EOF' 要加单引号——不加的话 shell 会把内容里的 $ 和反引号当变量展开,JSON 就会被破坏。
  • 为什么叫 .tmp:下一步会解释。这一步是「先写临时文件」。
  • python3 -m json.tool:把 JSON 格式化并校验语法。如果 JSON 写错了(少个逗号、多个括号),这条命令会直接报错并指出行号。你要用这个当「语法检查器」——不要等客户端来发现你的 JSON 是坏的。
  • 每个字段的作用(对照 1.7 节):
  • latest / minSupported / recommended 三个版本号决定客户端是「不更新 / 可选更新 / 强制更新」。
  • protocol 是给服务端做版本闸门用的兼容窗口。
  • configVersion 用来防「配置表只发了一半」。
  • rollout.percent: 20 表示只给 20% 的用户开放更新(分桶决定谁命中)。
  • packages[] 是整包兜底,patches[] 是优先使用的差量补丁。
⑥ 原子发布 —— 这一行是整个客户端发布链路里最重要的一个技术细节
bash
cd /opt/patches/client
mv manifest.json.tmp manifest.json
ls -l manifest.json*

这条命令在做什么

为什么不能直接写 manifest.json

  • 如果客户端正好在你写到一半的时候来拉取,它读到的是半个 JSON → 解析失败 → 卡在更新界面。这是一个真实的线上事故类型。
  • mv 在同一个文件系统内是原子操作:客户端要么看到完整的旧文件、要么看到完整的新文件,不存在中间态
  • ls -l manifest.json* 会显示只剩 manifest.json,临时文件已消失。
  • 这个技巧通用:配置下发、证书替换、静态站点发布都用同一招。
⑦ 把 Nginx 变成「客户端分发通道」
bash
# 先备份,再改(改配置前永远先备份)
cp /etc/nginx/conf.d/ruoyi.conf /etc/nginx/conf.d/ruoyi.conf.bak

# 在 ruoyi.conf 的 server 块内、location / 之前插入一段:
# location /patches/ {
# alias /opt/patches/client/;
# autoindex on;
# expires 1h;
# }

nginx -t # ← 先测试语法,不要直接 reload
nginx -s reload # ← 语法没问题才生效

这条命令在做什么

逐条解释:

  • cp ... .bak:改任何配置文件前先备份。改坏了 cp ruoyi.conf.bak ruoyi.conf && nginx -s reload 就能回滚。
  • alias /opt/patches/client/把 URL 路径 /patches/ 映射到磁盘目录。注意 alias 会把 location 之后的部分直接拼到目录后面。
  • autoindex on:允许列出目录内容。学习阶段方便你确认文件在不在,生产环境建议关掉(会暴露文件结构)。
  • nginx -t只检查配置语法,不生效。这一步至关重要——如果配置写错直接 reload,Nginx 会拒绝加载新配置(老配置继续跑,服务不断),但你会困惑「为什么我的修改没效果」。
  • nginx -s reload:平滑重载(不中断现有连接)。
  • 验证curl -s http://127.0.0.1/patches/manifest.json | python3 -m json.tool 应该能打印出格式化的 JSON。
⑧ 让客户端走完整更新流程
bash
export PATH=/root/node/bin:$PATH && cd ~/demo/client
echo -n "1.0.0" > local/version.txt # 把本地版本重置为 1.0.0
node launcher.js --user 1001 --auto --url http://127.0.0.1/patches/manifest.json

这条命令在做什么

逐条解释:

  • echo -n "1.0.0" > local/version.txt:模拟「用户设备上装的是 1.0.0 版本」。-n 表示不追加换行符。
  • --auto:自动接受可选更新(不传的话,弹窗类的更新会被跳过)。
  • --url:指向我们刚刚用 Nginx 发布的清单地址。
  • 预期输出顺序:拉取 manifest → 打印三个版本号 → 决策(可选更新/未命中放量/已最新)→ 下载差量补丁 → SHA256 校验 → 应用 → 版本变更 → 连接服务端握手。这一整条就是客户端的 CD。
  • 如果分桶没命中percent: 20),换个 --user 值再试,直到命中。这本身就是在体验「分阶段放量」。
⑨ 验证「完整性校验能拦住坏包」
python
cd /opt/patches/client
# 故意把清单里的补丁哈希改错
python3 - <<'PY'
import json
p = "/opt/patches/client/manifest.json"
m = json.load(open(p))
m["patches"][0]["sha256"] = "deadbeef" * 8 # 换成一个错误的哈希
json.dump(m, open(p + ".tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json # 原子替换(还是用这一招)
echo -n "1.0.0" > ~/demo/client/local/version.txt
cd ~/demo/client && node launcher.js --user 1001 --auto --url http://127.0.0.1/patches/manifest.json

这条命令在做什么

**这条命令演示什么:**客户端下载完补丁后重算 SHA256,和清单里的值比对,不一致就拒绝应用

  • 预期输出:[校验] SHA256 不匹配!期望 deadbeef… 实际 29c0d44c… 然后 拒绝应用该包,本地版本仍然是 1.0.0
  • python3 - <<'PY' ... PY:把一段 Python 脚本通过标准输入交给 Python 执行,不落盘成文件。这就是「不需要写脚本文件」的做法——一次性操作直接用 heredoc 喂给解释器。
  • 为什么必须有这一步:差量补丁应用失败会产生「看起来能用但实际损坏」的包。没有哈希校验,这种坏包会直接进到玩家设备上。

9.4 阶段 4:服务端编排(Argo Rollouts)

① 下载安装清单(走国内加速)
bash
mkdir -p /root/argo-rollouts && cd /root/argo-rollouts
curl -fL --retry 3 -o install-v1.10.0.yaml \
 https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.10.0/manifests/install.yaml
wc -c install-v1.10.0.yaml # 期望 3048423

这条命令在做什么

  • -f:HTTP 出错时返回非零退出码(否则 curl 会「成功」地保存一个错误页面)。-L:跟随重定向。--retry 3:失败重试 3 次。
  • gh-proxy.com/https://raw.githubusercontent.com/...这是把 GitHub 的原始链接套一层国内代理。实测下载 3048423 字节。备用通道:ghfast.top 前缀、cdn.jsdelivr.net/gh/argoproj/argo-rollouts@v1.10.0/manifests/install.yaml
  • wc -c 校验字节数,确认下载完整——这是防止「下载了一半的 YAML」的第二道保险。
  • 怎么选版本:Argo Rollouts 官方会在 CI 配置里声明它测试过的 K8s 版本(stable 分支与 master 分支覆盖的范围不同)。先看示例集群版本落在哪个区间,再决定装哪个 Argo Rollouts。本文档的示例集群是 k8s v1.36,对应 v1.10.0
  • 注意官方清单里的镜像 tag 是 latest,必须显式改成版本号(命令里已经用 sed 处理),否则集群重启后会悄悄换版本。
② 把镜像搬进自己的 Harbor,并替换清单里的地址
bash
HARBOR=192.168.0.50:10086
docker login ${HARBOR} -u admin -p '<你的 Harbor 密码>'

docker pull quay.m.daocloud.io/argoproj/argo-rollouts:v1.10.0
docker tag quay.m.daocloud.io/argoproj/argo-rollouts:v1.10.0 ${HARBOR}/infra/argo-rollouts:v1.10.0
docker push ${HARBOR}/infra/argo-rollouts:v1.10.0

cd /root/argo-rollouts
sed -i "s#quay.io/argoproj/argo-rollouts:#${HARBOR}/infra/argo-rollouts:#g" install-v1.10.0.yaml
grep -n 'image:' install-v1.10.0.yaml

这条命令在做什么

  • quay.m.daocloud.io:DaoCloud 提供的 quay.io 国内代理,实测 manifest 可拉取(含 amd64)。备用:quay.nju.edu.cn/argoproj/argo-rollouts
  • 为什么要搬进 Harbor 而不是直接用代理:集群对公网没有依赖、镜像有留存、后续升级路径统一。这是上一轮 Argo CD 方案里同样的做法。
  • sed -i "s#旧#新#g":用 # 当分隔符是因为路径里含 /,用 / 当分隔符要转义很麻烦。
  • grep -n 'image:'确认替换干净——输出里不应该再出现 quay.io
③ 安装并钉到内存余量最大的节点
bash
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f install-v1.10.0.yaml

kubectl -n argo-rollouts patch deployment argo-rollouts --type=strategic -p '{
 "spec":{"template":{"spec":{
 "nodeSelector":{"kubernetes.io/hostname":"k8s-node1"},
 "imagePullSecrets":[{"name":"harbor-secret"}]
 }}}}'

kubectl -n argo-rollouts rollout status deploy/argo-rollouts
kubectl get crd | grep argoproj

这条命令在做什么

  • 为什么要 nodeSelector 钉在 k8s-node1:示例集群里 node1 内存 76%、node2 内存 93%,只有 node3(7.4G 分配)余量够。不钉的话调度器可能把它丢到 node2 上,直接触发 OOM。
  • imagePullSecrets:Harbor 是私有仓库,需要在目标命名空间创建拉取凭据(kubectl create secret docker-registry)。
  • kubectl get crd | grep argoproj确认 CRD 装上了——能看到 rollouts.argoproj.ioanalysistemplates.argoproj.io 就成功了。
④ 发一次金丝雀,全程观察权重推进
bash
# 终端 A:实时看发布进度
kubectl argo rollouts get rollout demo-game-server -n ruoyi --watch

# 终端 B:换镜像触发新一次发布
kubectl argo rollouts set image demo-game-server \
 server=192.168.0.50:10086/ruoyi-cloud/demo-game-server:1.1.0 -n ruoyi

# 想提前推进:kubectl argo rollouts promote demo-game-server -n ruoyi
# 想中止并回滚:kubectl argo rollouts abort demo-game-server -n ruoyi

这条命令在做什么

  • --watch:持续刷新状态。你会看到 setWeight 从 10% → 30% → 60% → 100%,中间有 pause 等待窗口。
  • set image:改镜像 tag,这就是触发一次新发布的动作。在 GitOps 流程里这一步由 Argo CD 改清单完成,这里手工触发是为了让你看到过程。
  • abort这个命令请务必练熟。它会把流量切回 Stable 版本,是新版本出问题时的第一反应。
  • 注意流量切分靠的是 ingress-nginx 的 canary 注解,由 Rollouts 自动维护,你不需要手工改 Ingress。

9.5 阶段 5:双端联合演练(把 7.9 节的矩阵跑一遍)

这是最有价值的一步——把上面几个阶段的机制组合起来,逐个复现 8 种状态,并记录每种状态下你需要哪些操作、耗时多久、能不能回滚。演练完你会得到一张属于自己环境的表,比任何文档都值钱。

演练操作观察重点要回答的问题
① 纯服务端发布只更新服务端镜像,客户端不动老客户端连接是否正常我的服务端真的向后兼容吗?
② 纯客户端资源热更只更新资源包 + manifest客户端能否静默更新成功热更失败时能否退回旧资源?
③ 新功能(服务端+热更+开关)服务端加接口,客户端热更,开关先关后开关开关时新入口是否消失开关生效要多久?
④ 可选更新recommended 提升到新版本客户端弹提示、可取消取消后老版本还能正常玩吗?
强制更新minSupported 提升到新版本老客户端被拒绝进入怎么紧急撤回这次强制更新?
⑥ 协议破坏性变更服务端把 MIN_PROTOCOL 改成 5proto 4 客户端全部失败能不能快速切回双协议?
⑦ 配置表不一致只改服务端 CONFIG_VERSION客户端是否检测到不一致不一致时的兜底行为是什么?
⑧ 金丝雀中途 abort发金丝雀,20% 时执行 abort流量回切速度、在线连接是否受影响abort 到完全恢复要多久?
演练⑤:强制更新,以及最重要的「紧急撤回」
python
cd /opt/patches/client

# 第一步:提升最低支持版本 → 触发强制更新
python3 - <<'PY'
import json
m = json.load(open("/opt/patches/client/manifest.json"))
m["minSupported"]["client"] = "1.2.0" # 低于 1.2.0 的客户端一律拒绝
json.dump(m, open("/opt/patches/client/manifest.json.tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json

# 第二步:从 1.0.0 的客户端发起 —— 应该被强制更新(无需 --auto)
echo -n "1.0.0" > ~/demo/client/local/version.txt
cd ~/demo/client && node launcher.js --user 1001 --url http://127.0.0.1/patches/manifest.json

# 第三步(关键):紧急撤回强制更新 —— 把最低支持版本改回去
python3 - <<'PY'
import json
m = json.load(open("/opt/patches/client/manifest.json"))
m["minSupported"]["client"] = "1.0.0" # 撤回
json.dump(m, open("/opt/patches/client/manifest.json.tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json
echo "已撤回,重新跑一次客户端应能正常进入"

这条命令在做什么

为什么这个演练最重要:

  • 「强制更新」是唯一一个一旦发错就可能导致全量用户进不去的操作。它和别的发布不一样——别的发错了用户还能用旧版本,这个发错了用户连旧版本都用不了
  • 所以你必须提前练会「紧急撤回」:把 minSupported 改回旧值 → 原子替换 manifest → 确认老客户端能重新登录。这个动作要练到 3 分钟内能完成。
  • 注意每一步都用了 .tmp + mv 的原子替换。这种场景下尤其不能省——你在撤回的时候如果写坏了 manifest,就是雪上加霜。

10 常见误区与速查

10.1 学习路径上的 10 个误区

每一条都是这类项目里真实踩过的坑,按"被误解的频率"排序。

#常见说法为什么不对正确的理解
1「客户端更新就是让用户下载新版本」把四件不同的事当成一件事客户端更新有四种力度(静默热更 / 可选更新 / 强制更新 / 整包重装),成本和风险差一个数量级。见 1.3 节
2「服务端改个字段名,前端跟着改就行」这是网页项目的习惯客户端是你改不动的存量。改字段名会打死所有没升级的老客户端。必须双写双读、过渡期后再删老字段
3「出问题回滚一下」客户端回滚是伪命题用户设备上的包你已经改不动了。客户端的"回滚"只能是"再发一版改回来"或"服务端关开关"
4「服务端和客户端必须一起发版」强行同步会放大风险正确做法是错开发布:服务端先上双协议 → 客户端放量 → 服务端下线老协议。周期 2 周到 2 个月
5「蓝绿、金丝雀、灰度是三种并列的方案」混淆了两个维度蓝绿回答"怎么切",金丝雀/灰度回答"切给谁"。而且客户端侧根本没有蓝绿(用户设备不能瞬间切换)
6「灰度就是按用户 ID 取模放量」哈希写得不对会严重倾斜连续 ID 用简单哈希(h = h*31 + c)会落到连续的桶里,"放量 20%"实际只覆盖一小撮连续用户。必须用带雪崩的哈希(如 murmur3 的 fmix32)
7「readiness 和 liveness 用一个 /healthz 就行」排空时会互相干扰排空期间 readiness 必须失败(让流量摘走),但 liveness 必须保持成功(否则 K8s 会重启正在排空的容器,直接打断优雅停机)。必须分成两个端点
8「配置表发一份就够了」配置表是两份产物服务端表和客户端表必须原子发布。只发一半会造成"客户端显示 100 伤害、实际打 50",这是最典型的线上事故之一
9「等所有用户升级到最新版就好了」这个时刻永远不会到来客户端项目要长期同时服务 N、N-1、N-2 三个版本。架构必须按"多版本共存"设计,而不是指望版本收敛
10「更新包不用校验,下载完直接用」网络传输会出错,也有中间人风险每个包都必须有 SHA256,客户端下载后必须校验。校验失败要重新下载,绝不能直接应用

第 7 条值得单独强调

写成伪代码是这样:

text
readiness 端点:/healthz/ready
  - 启动时:未就绪
  - 初始化完成:就绪
  - ★ 收到排空信号后:立即变为未就绪 ★   ← 让 K8s 把流量摘走
  - 还活着的连接处理完毕:容器退出

liveness 端点:/healthz/live
  - 进程活着就返回 200
  - ★ 排空期间也必须保持 200 ★          ← 否则 K8s 会重启你

如果两个端点共用一个 URL:排空开始时它变成 503,K8s 的 liveness 探针连续失败 3 次后重启容器 —— 你的排空流程走到一半被强行打断,所有在线玩家异常掉线。

这个错误在文档里出现两次(1.6 节和 7.10 节),因为它造成的后果(全部用户掉线)远大于它的复杂度。

10.2 命令速查

Nexus(客户端制品仓库)

bash
# 服务管理
systemctl start nexus / stop / status / restart
systemctl enable nexus

# 查看日志
tail -f /opt/sonatype-work/nexus3/log/nexus.log

# 获取初始密码(首次启动时)
cat /opt/sonatype-work/nexus3/admin.password

# 上传文件(PUT)
curl -u "admin:${NEXUS_PASS}" --upload-file <本地文> \
  http://192.168.0.30:8081/repository/client-releases/<>

# 下载文件
curl -O http://192.168.0.30:8081/repository/client-releases/<>

# 列出仓库内容(raw 仓库支持目录浏览)
curl -s http://192.168.0.30:8081/service/rest/v1/components?repository=client-releases

# 清理旧版本(保留最近 5 个),用 API 删除
curl -u "admin:${NEXUS_PASS}" -X DELETE \
  http://192.168.0.30:8081/repository/client-releases/<>

差量补丁(xdelta3)

bash
# 生成补丁:把 旧版 -> 新版 的差异存成 patch 文件
# 参数顺序:-s 旧文件 新文件 输出补丁
xdelta3 -s ./1.0.0/full.tar.gz ./1.2.0/full.tar.gz ./1.0.0-to-1.2.0.patch

# 查看补丁体积(通常只有全量包的 5%~20%)
ls -lh ./1.0.0-to-1.2.0.patch

# 应用补丁:用旧文件 + 补丁,还原出新文件
xdelta3 -d -s ./1.0.0/full.tar.gz ./1.0.0-to-1.2.0.patch ./restored.tar.gz

# ★ 关键校验:还原出来的文件必须与新版**逐字节一致**
sha256sum ./1.2.0/full.tar.gz ./restored.tar.gz
cmp ./1.2.0/full.tar.gz ./restored.tar.gz && echo "✅ 补丁可用"

这几条命令在做什么

  • -s <源文件>:指定"基准文件"。xdelta3 会基于它计算差异。
  • -d:decode(应用补丁)。
  • 最后的两条校验不能省:生成补丁之后必须验证它能还原出一模一样的文件。损坏的补丁发出去,用户会装上一个坏掉的客户端。这一步应该进 CI 流水线(见 9.3)。

客户端产物的哈希与体积

bash
# 计算 SHA256(生成清单时要用)
sha256sum ./1.2.0/full.tar.gz
# 输出:a1b2c3d4...  ./1.2.0/full.tar.gz

# 只要哈希值,不要文件名
sha256sum ./1.2.0/full.tar.gz | awk '{print $1}'

# 获取字节数(manifest 里的 size 字段)
stat -c '%s' ./1.2.0/full.tar.gz

# 一次性输出所有产物的哈希与体积(便于填清单)
for f in $(find . -name '*.tar.gz' -o -name '*.patch'); do
  printf "%s  %s  %s\n" "$(stat -c '%s' "$f")" "$(sha256sum "$f" | awk '{print $1}')" "$f"
done

这几条命令在做什么

  • sha256sum:生成 SHA256 摘要。这是客户端校验完整性的依据
  • stat -c '%s':输出文件的精确字节数(不是 ls -lh 那种带单位的近似值)。manifest 里的 size 字段用它可以做下载进度与断点续传。
  • 最后那个 for 循环:一次拿到所有产物的哈希与体积,直接抄进 manifest。这个技巧在手工维护清单时非常省事。

原子发布(通用技巧)

bash
# 为什么需要原子发布:客户端可能在任何时刻来拉清单
# 如果清单先于包上传,或者上传到一半就被读到,客户端会下载失败

# 正确顺序(三步,不能颠倒):
# ① 先把所有产物上传到"临时位置"
for f in 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch; do
  curl -u "admin:${NEXUS_PASS}" --upload-file "./$f" \
    "http://192.168.0.30:8081/repository/client-releases/$f.tmp"
done

# ② 校验上传后的哈希与本地一致(防止传坏)
curl -s "http://192.168.0.30:8081/repository/client-releases/1.2.0/full.tar.gz.tmp" \
  | sha256sum | awk '{print $1}'
sha256sum ./1.2.0/full.tar.gz | awk '{print $1}'
# 两个值必须一致

# ③ 全部校验通过后,一次性把清单发布出去(换名/移到正式路径)
curl -u "admin:${NEXUS_PASS}" --upload-file ./manifest.json \
  "http://192.168.0.30:8081/repository/client-releases/manifest.json"

# 最后清理 .tmp 文件

这套流程在做什么

  • 核心思想让"不完整的状态"永远不会被读到
  • 客户端只读 manifest.json,而 manifest.json 只在所有产物都上传并校验完成后才发布。于是客户端要么看到旧的完整清单,要么看到新的完整清单,不会看到"清单指向了一个还没传完的包"
  • 生产环境更好的做法:清单带一个自增版本号,客户端只认比本地更新的版本(避免半新半旧)。

Nginx(分发通道)

bash
# 安装
dnf install -y nginx
systemctl enable --now nginx

# 配置:把客户端产物目录发布出去
cat > /etc/nginx/conf.d/client-dist.conf <<'EOF'
server {
    listen 80;
    server_name _;
    root /opt/client-dist;

    # 客户端更新包:允许目录浏览,方便人工核对
    location /packages/ {
        alias /opt/client-dist/packages/;
        autoindex on;
        autoindex_exact_size off;
        # 更新包是内容寻址的(文件名带版本号),可以长缓存
        expires 1h;
        add_header Cache-Control "public, max-age=3600";
    }

    # ★ 版本清单:绝对不能缓存 ★
    location = /manifest.json {
        alias /opt/client-dist/manifest.json;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
        expires -1;
    }
}
EOF

nginx -t && systemctl reload nginx

这段配置在做什么

配置为什么
location /packages/ + autoindex on允许目录浏览。排障时可以直接在浏览器里看有哪些包
expires 1h(对包)更新包的文件名里带版本号(内容寻址),变了的文件一定是新文件名,所以可以放心长缓存
location = /manifest.json + no-store最关键的一条。清单必须每次实时获取,否则客户端会拿到缓存里的旧清单,永远看不到新版本。这个 bug 极难排查(因为"看起来一切正常,就是没更新")

清单被缓存是这类系统里最隐蔽的故障

现象:你明明发布了 1.3.0,客户端却还是显示"已是最新"。

原因:CDN 或浏览器缓存了 manifest.json

排查方法

bash
# 直接看响应头里的缓存策略
curl -sI http://192.168.0.31/manifest.json | grep -i -E "cache|expires|etag"

# 对比 CDN 返回的内容与源站是否一致
curl -s http://192.168.0.31/manifest.json | head -5

正确做法(三条都要做):

  1. 服务端返回 Cache-Control: no-store
  2. 如果用了 CDN,在 CDN 侧对该路径配置"不缓存"
  3. 清单 URL 带上版本参数(如 manifest.json?v=1737686000),让 URL 本身变化

Argo Rollouts(服务端灰度)

bash
# 安装(注意:官方清单里的镜像 tag 是 latest,必须改成具体版本)
kubectl create namespace argo-rollouts
curl -fL -o /tmp/argo-rollouts-install.yaml \
  https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.10.0/manifests/install.yaml
sed -i 's#quay.io/argoproj/argo-rollouts:latest#quay.io/argoproj/argo-rollouts:v1.10.0#g' \
  /tmp/argo-rollouts-install.yaml
kubectl apply -n argo-rollouts -f /tmp/argo-rollouts-install.yaml

# 安装 CLI 插件
curl -fL -o /usr/local/bin/kubectl-argo-rollouts \
  https://files.m.daocloud.io/github.com/argoproj/argo-rollouts/releases/download/v1.10.0/kubectl-argo-rollouts-linux-amd64
chmod +x /usr/local/bin/kubectl-argo-rollouts

# 常用命令
kubectl argo rollouts list rollouts -n ruoyi
kubectl argo rollouts get rollout <name> -n ruoyi --watch     # 实时看发布进度
kubectl argo rollouts promote <name> -n ruoyi                 # 推进到下一步
kubectl argo rollouts abort <name> -n ruoyi                   # 中止并回滚
kubectl argo rollouts undo <name> -n ruoyi                    # 回退到上一个版本
kubectl argo rollouts set image <name> <container>=<image> -n ruoyi   # 换镜像触发新发布

这几条命令在做什么

  • sed 那一步不能省:Argo Rollouts 官方 install.yaml 里的镜像 tag 写的是 latest。装在集群里会永远跟随 latest,某天 Argo Rollouts 发了新版,你的 Pod 重启后就换了个版本,行为可能变化。生产环境必须锁定版本
  • kubectl argo rollouts get --watch观察金丝雀发布最直观的方式,它能实时显示 stable 与 canary 各自的副本数、流量权重、以及当前停在哪一步。
  • promote / abort:手动模式下用来推进或中止。abort 会自动把流量全部切回 stable

长连接服务自检

bash
# ① 检查服务端的优雅停机是否真的生效
#    触发方式(K8s 环境下):
kubectl -n ruoyi delete pod <pod-name> --wait=false
kubectl -n ruoyi get pod -w
# 观察:Pod 的 STATUS 是否停留在 Terminating 一段时间(说明 preStop 生效),
#       还是立刻消失(说明信号没被正确处理)

# ② 查看服务端是否打印了排空日志
kubectl -n ruoyi logs <pod-name> --tail=50 | grep -i -E "drain|shutdown|graceful"

# ③ 从客户端侧验证:服务端重启时,客户端收到的是「重连指令」还是「连接断开」
#    在客户端日志里搜索 reconnect / ECONNRESET

# ④ 检查容器的启动命令是否是 exec 形式(决定 SIGTERM 能否传递)
kubectl -n ruoyi get pod <pod-name> -o jsonpath='{.spec.containers[0].command}{"\n"}'
# 如果是空,看镜像的 ENTRYPOINT/CMD
# ★ 必须是 ["node","server.js"] 这种 JSON 数组形式
#   shell 形式(CMD node server.js)会让 /bin/sh 成为 1 号进程并吞掉 SIGTERM

这几条命令在做什么

  • 第 ① 条是判断"排空到底有没有生效"最快的方法:Pod 在 Terminating 状态停留的时长 ≈ preStop 的时长 + 应用自己的排空时长。如果几乎瞬间就消失,说明排空逻辑根本没跑到。
  • 第 ④ 条是最容易忽略但最致命的:见 1.6 节和 7.10 节的说明。Dockerfile 写成 shell 形式,你的整个优雅停机设计就是摆设。

环境自检

bash
# CPU 核数(决定能跑多少个服务、多少并发)
nproc

# 负载(判断标准是「负载 ÷ 核数」,2 核机器上负载 0.8 就是 40% 被占用)
uptime

# 内存(看 available 那一列,不是 free;同时看 Swap 是否为 0)
free -h

# 磁盘
df -h /

# 端口占用
ss -lntp

# 网络连通性(不装 telnet/nc 也能测)
timeout 3 bash -c 'cat < /dev/null > /dev/tcp/192.168.0.30/8081' && echo "Nexus 可达"

这几条命令在做什么

  • uptime 最后三个数字是 1/5/15 分钟平均负载。它是个绝对数字,要和核数对比才有意义——这是最常见的误读。
  • free -h 要看 available 而不是 freefree 包含了可回收的缓存,available 才是"真正还能给新进程用多少"。
  • /dev/tcp/ 是 bash 内置的伪设备,不需要安装任何工具就能测端口连通性timeout 3 是为了避免在端口不通时一直阻塞)。

10.3 下载地址汇总(写作时实测)

探测说明

下列地址在写作时逐条实测,200 / 206 表示可正常访问(206 是 Range 请求的部分内容响应)。镜像地址的"可用"判定标准是能成功换取匿名 token 并取到 manifest,且架构列表里包含 amd64

系统与运行时

用途地址实测
containerd / Docker(阿里云)https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo200
Docker CE 包目录https://mirrors.aliyun.com/docker-ce/linux/centos/9/x86_64/stable/200

Kubernetes

用途地址实测
K8s yum 源(v1.36)https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/200
GPG Keyhttps://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/repodata/repomd.xml.key200
控制平面镜像(阿里云)registry.cn-hangzhou.aliyuncs.com/google_containers含 v1.36.5
控制平面镜像(daocloud)m.daocloud.io/registry.k8s.io含 v1.36.5
pause 镜像m.daocloud.io/registry.k8s.io/pause:3.10206
Calico 清单 v3.32.2https://gh-proxy.com/https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/calico.yaml206
Calico 镜像m.daocloud.io/docker.io/calico/node:v3.32.2206

本文档特有的组件

用途地址实测
Nexus 3.96.3https://mirrors.huaweicloud.com/nexus/nexus-3.96.3-01-unix.tar.gz200
Argo Rollouts 清单 v1.10.0https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.10.0/manifests/install.yaml206
Argo Rollouts 镜像quay.m.daocloud.io/argoproj/argo-rollouts:v1.10.0206
Argo Rollouts CLIhttps://files.m.daocloud.io/github.com/argoproj/argo-rollouts/releases/download/v1.10.0/kubectl-argo-rollouts-linux-amd64206
Harbor 离线包 v2.15.2https://gh-proxy.com/https://github.com/goharbor/harbor/releases/download/v2.15.2/harbor-offline-installer-v2.15.2.tgz200
Argo CD 清单 v3.5.3https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml206
Argo CD 镜像quay.m.daocloud.io/argoproj/argocd:v3.5.3206
Argo CD 的 dexghcr.m.daocloud.io/dexidp/dex:v2.45.1206
Argo CD 的 redism.daocloud.io/public.ecr.aws/docker/library/redis:8.2.3-alpine206

第 8 章开源项目仓库

项目地址实测
Colyseushttps://github.com/colyseus/colyseus.git可 clone
RustDeskhttps://github.com/rustdesk/rustdesk.git可 clone
Luantihttps://github.com/luanti-org/luanti.git可 clone
Mindustryhttps://github.com/Anuken/Mindustry.git可 clone
Nakamahttps://github.com/heroiclabs/nakama.git可 clone
OpenIMhttps://github.com/openimsdk/open-im-server.git可 clone
ET 框架https://github.com/egametang/ET.git可 clone
Skynethttps://github.com/cloudwu/skynet.git可 clone

这些仓库在国内的加速方式

方式一:URL 前缀(对 git clone 也有效)

bash
git clone https://gh-proxy.com/https://github.com/egametang/ET.git
git clone https://ghfast.top/https://github.com/cloudwu/skynet.git

方式二:用 Gitee / AtomGit 的同步仓库(搜同名项目,很多都有镜像)

方式三:只取最新一层(不需要历史时)

bash
git clone --depth 1 <url>

本次实测确认失效的地址(不要用)

地址结果正确替代
https://download.sonatype.com/nexus/3/latest-unix.tar.gz404用华为云镜像或指定具体版本号
https://gh-proxy.com/https://github.com/sonatype/nexus-public/releases/download/.../nexus-*-unix.tar.gz404Sonatype 的 release 附件不挂在 GitHub 上
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/quay.io/argoproj/...404quay.m.daocloud.io
docker.m.daocloud.io/argoproj/argocd403该域名只代理 Docker Hub,不代理 quay
m.daocloud.io/docker.io/library/redis:8.2.3-alpine无此 tagArgo CD 的 redis 来自 public.ecr.aws,用 m.daocloud.io/public.ecr.aws/...

10.4 三句话总结

如果只记住三件事

1. 这类系统的发布要按"三条通道"来组织,而不是"一次发布"。

服务端(镜像 → K8s)、客户端(包 → 制品仓库 → 分发)、配置表(双份 → 原子发布)各自独立演进,靠版本清单和版本闸门在运行时协调

2. 「多版本共存」是这类系统的本质约束,不是过渡状态。

服务端要长期同时正确服务 N、N-1、N-2 三个版本的客户端。兼容责任永远在服务端(只加不改、双写双读、过渡期后清理)。回滚语义也由此不同:客户端没有真正的回滚,只有"再发一版"或"关开关"

3. 灰度发布能成立的前提是「能观测到差异」。

按客户端版本维度统计成功率与崩溃率、服务端按流量比例分流并观察指标 —— 没有观测的灰度只是"少发一点",并不能帮你发现新版本的问题。这是最容易被跳过、后果最严重的一环。


文档说明

本文档为通用知识文档,所有环境规划(192.168.0.0/24 网段、各服务器的配置档位)均为示例,用于说明"一个完整环境需要哪些角色",不绑定任何特定服务器。

文中所有镜像地址、下载地址、版本可用性均在写作时逐条实测(探测结果已标注)。涉及具体版本的兼容性结论,均以官方仓库的测试配置或文档为依据。

版本信息的时效性提醒:K8s 生态迭代很快。本文档中的版本号(K8s v1.36.x、Argo Rollouts v1.10.0、Argo CD v3.5.3、Nexus 3.96.3、Harbor v2.15.2)是写作时的最新稳定版,半年后建议重新确认。选型方法论见《Jenkins 做 CI、Argo CD 做 CD》的 §3。

基于 VitePress 构建 · 内容为个人学习与工程实践笔记