主题
怎么用这份文档
- 第一次看:先读第 1 章(术语),不然第 2 章之后会处处卡壳。术语章当字典用,不必背。
- 想理解原理:第 2~7 章,从「为什么网页那套不能照搬」一路推到「你的推断对不对」。
- 想动手:第 8 章挑一个开源项目,第 9 章按阶段推进。每一条命令下面都写了「这条命令在干什么、为什么要它」。
- 本机环境:
106.75.30.92(2 核 / 7.5G 内存 / 42G 可用磁盘,已跑 Jenkins + k8s 控制平面)。
01 术语与概念详解
这一章把后面会用到的所有名词讲清楚。如果你完全没接触过客户端/服务端软件,这一章是地基——它解释的都是「为什么这类软件天生比网页难发布」。
1.1 网络通信层:一切差异的起点
短连接 vs 长连接
短连接(Short-lived Connection)
客户端发一个请求,服务端返回结果,连接就关掉。浏览器访问网页、你现在的若依微服务、REST API 都是这种。
关键性质:服务端完全不需要「记住」你。任意一个实例都能处理你的下一个请求,所以可以随便增删实例、随便重启——这就是「无状态」的物理基础。
长连接(Persistent / Long-lived Connection)
客户端连上后一直保持不断,服务端可以随时主动推消息给它。网络游戏、IM 聊天、协同编辑、远程桌面都是这种。
关键性质:服务端实例上寄存着会话状态(你是谁、你在哪个房间、你的角色在哪)。这个实例一死,上面所有玩家的连接就全断了,而且状态丢了。
这就是为什么游戏服务端不能像网页那样随便滚动更新——也是整个文档里最核心的一条差异。
| 对比项 | 短连接(网页/API) | 长连接(游戏/IM) |
|---|---|---|
| 连接生命周期 | 毫秒~秒级 | 分钟~小时,甚至整天 |
| 服务端内存态 | 无需保存 | 必须保存(会话、房间、实体) |
| 谁主动发消息 | 只能客户端问、服务端答 | 服务端可随时主动推送 |
| 重启实例的代价 | 下一次请求换个实例即可,用户无感 | 该实例上所有玩家掉线 |
| 能否水平扩容 | 直接加实例即可 | 需要「分线/分服」或状态外置,复杂得多 |
| 典型协议 | HTTP/1.1、HTTP/2、gRPC | WebSocket、TCP 裸协议、KCP、ENet、QUIC |
传输协议家族(选哪个决定了你的发布有多难)
| 协议 | 底层 | 可靠性 | 延迟 | 典型用途 | 注意点 |
|---|---|---|---|---|---|
| TCP | TCP | 可靠、有序 | 较高(丢包会阻塞后续包) | 登录、支付、存档、IM 文本 | 「队头阻塞」:丢一个包,后面全等着,弱网下体感很差 |
| WebSocket | TCP | 可靠、有序 | 同 TCP | 网页端实时通信、H5 游戏 | 本质是「HTTP 升级后的 TCP」,穿过代理/防火墙友好 |
| HTTP 长轮询 | TCP | 可靠 | 差 | 老式实时方案、兼容性兜底 | 服务端压力大、延迟高,属于过渡方案 |
| gRPC | HTTP/2 | 可靠 | 中 | 服务端之间的内部调用 | 不是给玩家客户端用的;适合微服务互调 |
| UDP | UDP | 不可靠、无序 | 极低 | 实时对战(FPS/MOBA)的位置同步 | 丢包不管,宁可丢也不等——但业务层要自己处理丢包 |
| KCP | UDP | 可靠(重传算法激进) | 低 | 国内弱网手游 | 用带宽换延迟。国内网络环境下的常用选择 |
| ENet | UDP | 可靠+不可靠混合通道 | 低 | MOBA(英雄联盟用的就是它) | 可以同时开「可靠通道」和「快速通道」,各走各的 |
| rUDP | UDP | 可靠 | 低 | Nakama 的实时通信 | 「reliable UDP」的统称写法 |
| QUIC | UDP | 可靠、多路复用 | 低 | 新一代 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 层面的直接区别)
| Deployment | StatefulSet | |
|---|---|---|
| Pod 名字 | 随机后缀(app-7d9f-abc12) | 固定序号(app-0、app-1) |
| 存储 | 共享或不用,Pod 之间无差别 | 每个 Pod 绑定自己的 PVC |
| 更新顺序 | 并行滚动,随便杀 | 默认从大到小逐个更新,等前一个 Ready 才动下一个 |
| 适用 | 无状态服务(你的若依 7 个微服务) | 有状态服务(数据库、消息队列、分片游戏服) |
记住这个结论:你的若依项目用 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 CD | prod-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)→ 优雅停机 → 排空
这三个词说的是一件事的三个动作
- 摘流:先把实例从「可接收新请求/新连接」的列表里拿掉。新流量不再进来。
- 排空:等待已经在处理中的请求完成 / 等待已有连接自然结束。给一个宽限时间。
- 优雅停机:排空完成后才真正退出进程。
为什么长连接服务必须做这件事:无状态服务杀 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/sh → node。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.7.2) |
| 服务网格(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: 10 → pause: 5m → setWeight: 50 |
trafficRouting | 告诉 Rollouts「通过什么方式切流量」——这里是 nginx,也可以是 Istio / ALB |
AnalysisTemplate | 把「人工判断要不要继续」变成「自动查指标判断」,比如查成功率低于 99% 就自动中止 |
| Stable / Canary ReplicaSet | 老版本和新版本各对应一个 ReplicaSet,Rollouts 同时管理两者 |
1.7 制品与分发(中间件与名词)
三类仓库,管三种东西
| 仓库 | 管什么 | 你环境里的实例 | 访问方式 |
|---|---|---|---|
| 镜像仓库 (Container Registry) | Docker 镜像 | Harbor 106.75.29.47:10086 | Docker Registry v2 协议 |
| 制品仓库 (Artifact Repository) | 任意文件:安装包、补丁、jar、npm 包、配置表 | Nexus(你机器上装了但没启动) | 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 / Nacos | Consul / Eureka / Nacos |
| 你的环境 | Nacos(106.75.29.47: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 Packets | TCP 字节流不保证消息边界 |
| 断线重连 | 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 Export | Excel 转成服务端与客户端两份配置产物 |
| 序列化 | Serialization | 对象与字节流之间的转换 |
| 分区 / 分服 | Zone / Shard | 互相独立的运行单元,天然的灰度单位 |
| 合服 | Server Merge | 把多个区的数据合并,风险最高 |
| 状态同步 | State Synchronization | 服务端算结果推给客户端 |
| 帧同步 | Lockstep | 只转发操作指令,各端各自算 |
| Actor 模型 | Actor Model | 不共享内存、只发消息的并发模型 |
| 协程 / Fiber | Coroutine / 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 现状勘察结论
先把「当前真实跑着什么」钉死。下面每一条都是现场读出来的,不是推测。
2.1 节点与集群底座
4 个节点全部通过 Tailscale 组网,kubectl 的 API Server 挂在 Jenkins 那台机器上,它同时兼任控制平面:
| 节点名 | 角色 | Tailscale IP | CPU / 内存(allocatable) | 当前内存占用 | 状态 |
|---|---|---|---|---|---|
jenkins | control-plane(etcd / apiserver / controller-manager / scheduler 全在这) | 100.68.117.72 | 2C / 7.4Gi | 71% | Ready(带 control-plane 污点) |
k8s-node1 | worker | 100.125.80.98 | 4C / 3.5Gi | 76% | Ready |
k8s-node2 | worker | 100.97.166.90 | 4C / 3.5Gi | 93% | Ready(内存最紧张) |
k8s-node3 | worker | 100.78.249.106 | 2C / 7.4Gi | 70% | Ready(内存余量最大) |
| 组件 | 现状 |
|---|---|
| Kubernetes | v1.28.14(kubeadm 部署) |
| 容器运行时 | containerd 2.3.3;/etc/containerd/certs.d 已按 registry 配置了镜像加速 |
| CNI / 代理 | Calico(VXLAN),kube-proxy 为 IPVS 模式 |
| Ingress | ingress-nginx(IngressClass 名 nginx),NodePort 30080(HTTP) / 30443(HTTPS) |
| 存储 | 无 StorageClass、无 PV、无 PVC——全集群无持久化卷 |
| 可观测 | metrics-server 已装;kube-system 有 filebeat DaemonSet 对接 k8s-elk 节点;Kuboard v4 以容器方式跑在宿主机 |
| 已有 CRD | 仅 Calico + Kuboard,没有 Argo CD / Argo Rollouts / Prometheus Operator |
2.2 若依微服务的实际形态
应用全部落在 ruoyi 命名空间:7 个 Deployment + 7 个 NodePort Service + 1 个 Ingress。
| Deployment | 容器端口 | Service NodePort | 当前镜像 tag |
|---|---|---|---|
ruoyi-gateway-deploy | 8080 | 31080 | 20260827145338 |
ruoyi-auth-deploy | 9200 | 31081 | 20260827145033 |
ruoyi-modules-system-deploy | 9201 | 31093 | 20260827145603 |
ruoyi-modules-gen-deploy | 9202 | 30091 | 20260827161313 |
ruoyi-modules-job-deploy | 9203 | 30090 | 20260827162653 |
ruoyi-modules-file-deploy | 9300 | 30092 | 20260827152043 |
ruoyi-visual-monitor-deploy | 9100 | 30095 | 20260827191643 |
- 镜像仓库:私有 Harbor
106.75.29.47:10086,项目ruoyi-cloud,tag 是「秒级时间戳」。 - 拉取凭据:
harbor-secret/harbor-cred两个 dockerconfigjson。 - 配置管理:该命名空间只有一个 ConfigMap
ruoyi-gateway-config(以subPath挂到/home/ruoyi/application.yml);其余服务配置打进镜像。Redis 指向106.75.29.47:6379。 - Ingress:
ruoyi-gateway-ingress,ingressClassName: nginx,无域名(host为*)。 - 前端不在集群里:
ruoyi-ui由宿主机 Nginx 托管/opt/ruoyi/dist。 - 中间件全在另一台机器:
106.75.29.47开放 3306(MySQL)、6379(Redis)、8848/9848(Nacos)、10086(Harbor)、80/8080。
2.3 Jenkins 现状
| 项 | 现状 |
|---|---|
| Jenkins Home | /var/lib/jenkins(rpm 安装、systemd 托管) |
| 执行器 | numExecutors=2 |
| Job 形态 | 全部是 Multibranch Pipeline,Jenkinsfile 在各仓库根目录 |
| 代码来源 | Gitee:gitee.com/pang_le/ruoyi-*.git,分支过滤 prod*(实际分支 prod-k8s),未配置凭据 → 公开仓库 |
| 构建工具 | Maven-3.8.9 + JDK-17;前端用 npm + registry.npmmirror.com |
| 凭据清单 | harbor-credentials、gitee-enterprise-token、deploy-ssh-key、nexus-admin |
现有 Jenkinsfile 的部署逻辑(以 gateway 为例)
groovy
stage('代码编译打包') → mvn clean package -Dmaven.test.skip=true
stage('构建 Docker 镜像') → docker build -t ${HARBOR_URL}:latest . && docker tag ... :$VERSION
stage('推送镜像到 Harbor') → docker push ...:latest && docker push ...:$VERSION
stage('部署到 K8s') → sed -i 's/:latest/:$VERSION/g' k8s/deployment.yaml
kubectl apply -f k8s/deployment.yaml -n ruoyi这套流程里有 4 个必须改造的点
- CI 承担了 CD:Jenkins 持有集群 admin kubeconfig 并直接
kubectl apply,构建与发布耦合,失败无法自动回滚。 sed -i污染工作区:构建时直接改写仓库里的k8s/deployment.yaml,导致工作区 git 树长期 dirty,清单文件在本地被改坏。- 镜像 tag 不可追溯:时间戳无法定位到代码提交;还同时推
latest这种可变标签。 - 集群实况与仓库清单已经漂移:线上
ruoyi-gateway-deploy实际挂载了ruoyi-gateway-config,但仓库里的k8s/deployment.yaml这段是被注释掉的。漏跑一次 CI 或重装一次集群,网关的配置挂载就会丢。
2.4 与本次学习相关的资源现状(2026-09-24 补充勘察)
先说一个会影响所有实验结论的硬事实:这台机器只有 2 个 CPU 核
| CPU 核数 | 2 |
|---|---|
| 系统负载(空闲时) | 0.79 / 0.87 / 0.82 —— 也就是空闲状态下就已经用掉约 40% 的 CPU |
| 可用内存 | 约 3.5 GB |
| Swap | 0(完全没有)——任何内存尖峰都会直接 OOM,没有缓冲 |
| 磁盘 | 99G 用 57G,剩 42G |
| CPU 占用第一名 | tailscaled 28.3%(它同时在为整个 k8s 集群做中继,累计流量已到 TB 级) |
| 内存占用前列 | Jenkins 1018MB、kube-apiserver 404MB、tailscaled 251MB、dockerd 138MB |
这个结论很重要:任何在这台机器上新增的常驻服务,都要先问「还剩多少 CPU 给我」。它也是后面「代码仓库选型」建议的核心依据。
| 能力 | 现状 | 本次学习的用途 |
|---|---|---|
| Nexus 3.92.3 | 已安装但未启动。位置 /opt/nexus,端口 7878(不是默认的 8081),自带 Temurin JDK 21,数据目录 /opt/sonatype-work/nexus3(12M,说明初始化过但基本没用),没有 systemd 单元文件 | 客户端产物仓库(raw hosted repo)——本次最关键的现成资源 |
| Harbor | 运行中,106.75.29.47:10086 | 服务端镜像仓库 |
| Nginx 1.20.1 | 运行中,已有 ruoyi.conf + jenkins.wanfeng.com.conf | 补丁分发通道(加一个 location /patches/) |
| Argo CD | 方案已出,尚未安装 | 服务端 CD |
| Argo Rollouts | 未安装 | 蓝绿/金丝雀/灰度编排。需装 v1.7.2 |
| xdelta | 未安装,EPEL 源里可装(xdelta.x86_64 3.1.0-17.el9) | 生成客户端差量补丁 |
| Node.js v25.8.0 + npm 11.11.0 | 在 /root/node/bin | 跑第 9 章的最小 demo |
| Jenkins 插件 | 有 publish-over-ssh、ssh-steps、nodejs、docker-workflow、gitee;没有 Nexus 上传插件、没有 Generic Webhook Trigger | CI 编排(推 Nexus 用 curl 即可) |
| ELK(k8s-elk 节点) | filebeat DaemonSet 已在跑 | 按客户端版本分组观测日志 |
集群版本天花板:k8s v1.28 正在限制你的工具选型
这已经是第二次遇到同一个问题:
- Argo CD:官方测试矩阵里 3.3/3.4/3.5 只覆盖 k8s v1.32~v1.36,覆盖 v1.28 的最新分支是 2.14 → 只能用 v2.14.21(该分支已停止维护)
- Argo Rollouts:我直接读了官方仓库的 e2e 测试配置——
stable分支测 1.32~1.35,master测 1.34~1.37,都不含 1.28;且 1.8 起明确不再支持 1.28 以下 → 只能选 v1.7.2
结论:把集群升级到 v1.33+ 值得单独排一个项目。在升级之前,你没法用上任何一个最新版云原生工具。
03 为什么网页那套不能照搬
你上一轮做的若依项目是典型网页项目:7 个微服务 + 一个前端静态目录。那套流程里有一些「默认成立」的前提,在客户端软件里一个都不成立。
| 维度 | 网页项目(你的若依) | 客户端 / 服务端项目 |
|---|---|---|
| 交付产物 | 无状态容器镜像 + 静态文件 | 服务端镜像 + 客户端安装包 / 资源包 / 配置表(三份,需分别发布) |
| 用户侧版本 | 刷新即最新,全网只有一个版本 | 用户设备上可能装着 3 年前的版本,同时存在 N 个版本 |
| 生效方式 | 服务端部署完,下次请求立刻生效(秒级) | 服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月不等) |
| 变更可控性 | 你说了算 | 客户端部分你说了不算:商店审核、用户不点更新 |
| 回滚语义 | 回滚 = 换回旧版本,真·恢复 | 服务端可回滚;客户端升级不可逆,只能「再发一个版本改回来」 |
| 状态 | 无状态,Pod 随便杀 | 长连接 / 房间 / 会话 / 存档在内存里,重启就是断线 |
| 兼容性责任方 | 浏览器天然向前兼容,服务端随意改 | 必须由服务端兜底:新服务端要能服务老客户端 |
| 流量单位 | 请求 | 请求 + 连接(连接是「粘」的) |
| 测试难度 | 一套接口测试就够 | 需要客户端版本 × 服务端版本的兼容矩阵测试 |
3.1 三条最重要的差异
差异一:网页项目的回滚是回滚,客户端项目的「回滚」是再发一版
服务端出问题,argocd app rollback 一秒回旧版本,用户毫无感知。客户端出问题,你没有任何办法把用户设备上那个包变回旧版——只能连夜出新版本,然后祈祷用户愿意更新。
推论:客户端发布的风险是「不可逆」的。因此客户端的质量闸门必须比网页严得多,而且必须配备「服务端开关」作为兜底。
差异二:网页项目服务端可以随便改,客户端项目服务端必须「只加不改」
你的若依改个接口返回结构,改完部署,前端下次请求跟着改就行。但客户端项目里,老客户端是你改不动的存量。一旦把字段改名或删掉,所有没升级的老客户端立刻报错、闪退、卡在登录页。
推论:客户端项目的服务端接口必须遵守向后兼容契约——字段只增不减、新增字段要有默认值、旧语义不能改、接口不能直接下线(要先标废弃、观察调用量为零、再等一个发布周期才删)。
差异三:客户端有「长尾」,网页没有
网页项目的版本分布是一条竖线(发布完成 = 100% 新版本)。客户端项目是一条长尾曲线,尾部可能拖几个月到几年。企业内网软件常年停在某个 LTS 版本;手游里总有用户半年不更新;桌面软件里总有人关掉自动更新。
推论:客户端项目的版本管理本质是**「多版本共存治理」**,不是「版本替换」。你要维护的是兼容矩阵,不是版本号。
图 1 服务端是「替换」,客户端是「共存」。这就是两类项目发布复杂度差异的根源
04 标准流程:三类产物、三条通道
网页项目只有一条交付链路(代码 → 镜像 → 集群)。客户端/服务端项目有三条相互独立、节奏不同的交付通道,这是理解一切差异的框架。
图 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 = 所有在线玩家掉线。
标准做法(摘流 + 迁移):
- 摘流:从负载均衡/网关把该实例的新连接权重置零(新玩家不再进来)
- 排空:给一个宽限期(如 60s),让现有对局自然结束
- 引导迁移:主动通知仍在线的客户端「服务即将重启,请重连」→ 客户端重连到其他实例
- 优雅退出:
preStop钩子里完成上述动作,terminationGracePeriodSeconds要大于宽限期 - 分区发布:一次只滚动一个区/服,其他区照常运营
这也是为什么游戏服务端很少用「一次性蓝绿全切」——切过去那一瞬间,所有在线玩家都会掉线。
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 selector | 2× Pod | 可行,但你的集群内存紧张,节点余量不足 2 倍 |
| 金丝雀(按比例) | Argo Rollouts + 现有 ingress-nginx,用 nginx.ingress.kubernetes.io/canary-weight 注解按权重分流 | Nginx Ingress | 1× + 少量 | 可行,推荐作为入门演练 |
| 灰度(按人群) | Argo Rollouts + ingress-nginx 的 canary-by-header / canary-by-cookie,按请求头或 Cookie 路由 | Nginx Ingress | 1× + 少量 | 可行,最适合「先给测试账号放量」 |
| 更细粒度 | 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.3 节)。
- 兼容窗口与多版本共存:服务端要同时服务 N、N-1、N-2 三个版本的客户端。
- 数据/存档这个第四维:配置表、存档格式、数据库结构的变更不随双端发布一起生效。
7.1 推断一:服务端更新,客户端无需更新
结论:✅ 完全正确,日常发布的绝对主力
占实际工作量的 60%~70%。典型内容:服务端性能优化、后台 bug 修复、运营活动逻辑、风控规则、日志与监控增强。
它成立的技术前提(缺一不可):
- 协议只加不改:新增字段老客户端忽略即可;不能改字段类型、不能改字段语义
- 接口只增不删:老接口必须继续返回,哪怕新客户端已经不用了
- 行为不改变老客户端的可观测结果:可以改服务端内部算法,但不能让老客户端显示的数值变得不合理
- 配置表改动必须落在客户端已有字段上,不能新增客户端不认识的列
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 三种真实事故模式
事故模式一:服务端先发,老客户端全军覆没
场景:服务端改了登录接口的返回字段名(userId → uid),部署上线。老客户端解析不到 userId,登录失败,所有未更新的用户立刻无法进入。
根因:把「服务端可以随便改接口」这个网页项目的习惯带到了客户端项目。
正确做法:新字段与老字段共存一段时间(双写双读),等老客户端流量趋零再删老字段。时间窗口:至少一个完整的客户端铺开周期。
事故模式二:强制更新遇上审核延迟
场景:计划 T 日上线大版本,服务端 T 日切换协议并提升版本闸门。结果 iOS 版本审核被拒,重新提交后再等 3 天。这 3 天里,iOS 用户全部卡在「请更新到最新版本」的提示页上,而新版本根本下载不到。
根因:把「客户端可用性」和「服务端发布」的时序耦合在了一起,却忽略了客户端分发有外部依赖。
正确做法:① 版本闸门的提升必须在确认新客户端已在所有渠道可用之后才执行;② 闸门要支持按平台分别设置(iOS 通过审核前,iOS 的闸门不动);③ 预留缓冲期。
事故模式三:配置表只发了一半
场景:导表同学把服务端表更新到了环境并生效,客户端的资源包还在打包中。这期间上线的玩家看到「技能描述写着造成 100 点伤害,实际打了 50 点」,论坛立刻炸锅。
根因:把配置表当成「一份文件」而不是「两份必须同步的产物」。
正确做法:给配置打 config_version;服务端启动和客户端登录时都校验;不一致时优先走强制热更。导表产出必须是一个原子发布单元。
08 开源项目:拿来就能练的真项目
第 9 章的小 demo 能让你 30 分钟理解概念,但它只有几十行代码,看不到「真实项目长什么样」。这一章给你 8 个真实开源项目,按「学习目标」和「你机器的承受能力」分成三档。
选型标准(为什么是这些项目)
- 必须有「客户端 + 服务端」两端,或者至少能体现「客户端独立于服务端演进」这个特征
- 服务端能在 Linux 上跑,不需要你去装 Unity/Visual Studio 才能看到效果
- 国内可获取:他的服务器实测可以
git ls-remote到全部这些仓库(含 GitHub),也可以用gh-proxy.com/ghfast.top加速 - 有代码可读:不是只给你一个安装包,而是能读它的发布逻辑
8.1 项目矩阵
| 项目 | 语言 | ★ | 两端形态 | 最值得学的东西 | 部署难度 | 资源需求 |
|---|---|---|---|---|---|---|
| Colyseus | TypeScript | 7.3k | 服务端框架 + JS/Unity/Defold 等客户端 SDK | 房间(Room)生命周期、状态同步、重连 | 极低(npm 一条命令) | ≈100MB |
| Nakama | Go | 13.4k | 单二进制服务端 + 官方客户端 SDK 共 8 种 (Unity/Unreal/Godot/JS/C#/Java/Swift/Defold) | 工业级游戏后端:匹配、排行榜、实时多人、 rUDP 协议、内嵌管理控制台 | 低(官方 docker-compose) | ≈500MB + 数据库 |
| RustDesk | Rust | 124k | 全平台客户端(自带自动更新)+ 自建中继服务端(hbbs/hbbr) | 客户端自动更新的完整实现 + 「客户端 + 分发服务端」的最小完整案例 | 低(单二进制) | ≈50MB |
| Luanti (原 Minetest) | C++ | 13.6k | 独立客户端 + luantiserver 专用服务端 | 协议版本号 + 版本闸门的最佳教材: 官方文档明确写「最低协议版本 24」,握手时协商 | 中(要编译或装包) | ≈200MB |
| Mindustry | Java | 29.1k | 桌面/移动客户端 + -server 无头服务端 | 「客户端版本与服务端不匹配」的实际处理方式 | 低(一个 jar) | ≈300MB(JVM) |
| OpenIM | Go | 16.7k | 服务端 + Android/iOS/Flutter/Web/PC 全端客户端 | 长连接 IM 的完整工业架构: 网关层、消息可靠投递、离线推送、多端消息同步 | 高(需 Mongo+Redis+Kafka+MinIO+Etcd) | ≈4GB(你这台机器跑不动) |
| ET | C# | 9.9k | Unity 客户端 + C# 服务端(双端共享同一份协议代码) | HybridCLR 客户端代码热更 + 服务端 DLL 热重载 + Location Server 路由 + 中文文档 | 高(需 Unity + VS2022) | 开发机需求高 |
| Skynet | C | 14.2k | 只有服务端框架(无客户端) | Actor 调度模型、Lua 层热更新(国内游戏服务端的祖师爷级项目) | 中 | ≈100MB |
按你的机器条件给出的建议路线
你的服务器只剩约 3.5GB 内存、2 个 CPU 核、且 tailscaled 常驻吃掉 28% CPU。所以:
- 能真正跑起来的:Colyseus、RustDesk、Skynet、(Mindustry 也勉强)
- 建议只读代码不部署的:OpenIM(依赖太重)、ET(要 Unity 环境)
- Nakama:官方 docker-compose 自带 PostgreSQL,大约要 500MB~1GB。可以跑,但要先把 Nexus 或者别的服务停掉腾内存。
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,能在你的机器上跑 - 发布形态:服务端是镜像/二进制发布,客户端是安装包分发 —— 正是本文讲的两条通道
要读的代码:客户端里搜 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 一整套。你这台 2 核 3.5G 可用的机器跑不动,所以我的建议是:只克隆代码读架构,不部署。
它值得读的原因是: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 跨节点路由等)。
- 看
harbor和gate两个:前者是「跨机器路由」,后者是「客户端接入」。这两个就是 1.2 节讲的「网关服」和「位置服」的最简实现。
8.5 建议的学习顺序
| 顺序 | 项目 | 花多久 | 你要能回答的问题 |
|---|---|---|---|
| 1 | 第 9 章的自建 demo | 半天 | 版本闸门、Feature Flag、摘流 分别解决了什么问题? |
| 2 | Colyseus | 1 天 | 一个「房间」的生命周期是怎样的?客户端断线重连后怎么恢复? |
| 3 | RustDesk | 1 天 | 客户端怎么知道有新版本?下载完怎么保证没被改坏? |
| 4 | Luanti | 1 天 | 协议版本号为什么必须独立于软件版本号?不匹配时应该怎么处理? |
| 5 | Nakama | 2 天 | 一个成熟的游戏后端把哪些能力做成了「开箱即用」? |
| 6 | OpenIM / ET(读代码) | 各 1~2 天 | 工业级项目的服务端是怎么分层的?热更新在双端分别怎么实现? |
09 在你服务器上动手(单条命令)
关于「为什么这份文档里没有一键脚本」
你可能注意到 outputs/demo/ 目录里没有构建脚本、没有发布脚本、没有测试脚手架。这是刻意的:
- 脚本会把「过程」藏起来。你运行一个
publish.sh,回车,看到「成功」,但中间发生了什么你并不知道。学习阶段最需要的是看见每一步。 - **所以本文所有操作都是单条命令,每条下面都写了「它做什么、为什么需要它」。**你可以一条一条敲,敲错了就知道是哪一步错了。
- 那 demo 目录里剩下的文件是什么?它们不是自动化脚本,而是「被发布的软件本身」——就像若依项目的
ruoyi-gateway.jar一样。没有它们就没有东西可发布。
| 文件 | 它是什么 | 类比若依项目 |
|---|---|---|
server/server.js | 服务端程序(要被部署到集群里的那个东西) | ruoyi-gateway.jar |
server/Dockerfile | 怎么把服务端打成镜像 | 同样有一个 Dockerfile |
client/launcher.js | 客户端程序(要被分发给用户的那个东西) | 网页项目里没有对应物 |
manifest/manifest.json | 版本清单模板(你要照着填自己的真实数值) | 网页项目里没有对应物 |
k8s/rollout-canary.yaml | 服务端的部署清单(Apply 给 K8s 的) | k8s/deployment.yaml |
9.0 阶段 0:先做环境自检
每次开始动手前,先花 10 秒确认机器状态。这三个数字决定了你今天能做什么、不能做什么。
检查 CPU 核数、负载、可用内存、Swap
bash
nproc; uptime; free -m; df -h /这条命令在做什么
四个命令分别看什么:
nproc→ CPU 核数。你的机器是 2。uptime→ 最后的三个数字是 1/5/15 分钟平均负载。判断标准是「负载 ÷ 核数」:2 核机器上负载 0.8 就意味着 40% 已被占用。你的机器空闲时就是 0.79/0.87/0.82,所以要记得给自己留余量。free -m→ 看available那一列(真正能用的),不是free。同时看Swap——你的机器 Swap 是 0,意味着内存一紧张就会直接被 OOM Killer 杀进程,没有缓冲。所以起新服务前一定先看 available。df -h /→ 根分区剩余空间。你的机器剩 42G。
看看谁在吃资源
bash
ps aux --sort=-%cpu | head -6; echo '---'; ps aux --sort=-%mem | head -6这条命令在做什么
逐条解释:
ps aux --sort=-%cpu:按 CPU 占用降序排列(-表示降序),head -6只看前 5 行 + 表头。- 你会看到
tailscaled占 28% CPU——它同时在为整个 k8s 集群做中继。这是你环境里最大的常驻开销,知道它在哪,排查「机器为什么慢」时就不会走错方向。 - 第二个命令按内存排序,你会看到 Jenkins 占约 1GB。这是正常的(Jenkins 是 JVM 应用),但它意味着这台机器不是「什么都没跑」。
9.1 阶段 1:启动 Nexus,建立「客户端产物仓库」
这一阶段的目标
Nexus 在你机器上已经装好但从未启动(/opt/nexus,端口 7878,不是默认的 8081;数据目录 /opt/sonatype-work/nexus3 只有 12M,说明初始化过但基本没用)。我们要让它可以上传/下载文件——这就是「客户端产物仓库」。
① 先确认端口没被占用
bash
ss -lntp | grep ':7878' || echo "7878 端口空闲,可以启动"这条命令在做什么
**为什么先做这一步:**如果端口被别的程序占了,Nexus 启动会失败但错误信息可能藏在日志里,你会白折腾。先检查再启动是运维的通用习惯。
ss -lntp:-l只看监听中的、-n显示数字端口(不解析成服务名)、-tTCP、-p显示占用进程。|| echo ...:||表示「前面失败才执行后面」。grep没找到会返回非零退出码,所以就会打印那句话。
② 以 nexus 用户身份启动(Nexus 不允许用 root 跑)
bash
sudo -u nexus /opt/nexus/bin/nexus start这条命令在做什么
逐条解释:
sudo -u nexus:以nexus这个系统用户的身份运行。Nexus 出于安全考虑拒绝以 root 启动,直接/opt/nexus/bin/nexus start会报错退出。/opt/nexus/bin/nexus:官方的启停脚本。你的机器上没有 systemd 服务单元(systemctl list-unit-files | grep nexus会是空的),所以只能用这个脚本。start:启动。stop/status/restart同理,脚本支持这些参数。- 副作用提醒:Nexus 是 Java 应用,启动约占 1GB 内存(它自带 Temurin JDK 21)。启动前先确认
free -m的 available 够。
③ 等它起来,然后确认监听
bash
sleep 30; ss -lntp | grep ':7878'; curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:7878/这条命令在做什么
逐条解释:
sleep 30:Nexus 是 JVM 应用,启动要 20~40 秒。不是它卡住了,是 JVM 要初始化。ss -lntp | grep ':7878':确认端口真的在监听。看到LISTEN那行就说明成功了。curl -s -o /dev/null -w 'HTTP %{http_code}':-s静默(不打印进度)、-o /dev/null丢弃响应体、-w只打印我们关心的格式。这是检查 HTTP 服务是否存活的通用写法,比 curl 出来一整页 HTML 干净得多。期望看到HTTP 200。
④ 拿到初始管理员密码
bash
cat /opt/sonatype-work/nexus3/admin.password这条命令在做什么
**这条命令做什么:**Nexus 3 首次启动会生成一个随机密码写在这个文件里。第一次登录后它会要求你改密码,改完这个文件会被自动删除(所以只第一次能看到)。
- 登录地址:
http://<服务器IP>:7878,用户名admin。 - 学习建议:改密码时记下来,后面 curl 上传制品要用到。
⑤ 创建「客户端产物」仓库(用 REST API,不用点界面)
bash
curl -u admin:'你刚设的新密码' -X POST http://127.0.0.1:7878/service/rest/v1/repositories \
-H 'Content-Type: application/json' -d '{
"name": "game-client",
"format": "raw",
"type": "hosted",
"online": true,
"storage": {
"blobStoreName": "default",
"strictContentTypeValidation": false,
"writePolicy": "allow_once"
}
}' -w '\nHTTP %{http_code}\n'这条命令在做什么
逐条解释:
-u admin:'密码':HTTP Basic 认证。注意单引号——密码里有@这类特殊字符时,不加引号会被 shell 解释掉。-X POST:这是「创建」操作,不是查询。"format": "raw":关键字段。raw 类型表示「不按 Maven/npm 等格式解析,就当一个普通文件仓库」——正适合放安装包和补丁。"type": "hosted":本地托管(我们上传到它这里),区别于proxy(代理别人)和group(聚合) 。"writePolicy": "allow_once":这是本仓库最重要的一条策略——同一个路径只能写一次,不允许覆盖。为什么:产物一旦发布就不许改。如果允许覆盖,「1.2.0 版安装包」在不同时间下载到的内容可能不一样,客户端校验哈希就会失败,而且你永远说不清线上跑的是哪一个包。strictContentTypeValidation: false:不校验 MIME 类型(补丁文件是自定义二进制格式,校验会误拦)。-w '\nHTTP %{http_code}\n':最后打印状态码。201 或 204 表示创建成功。
⑥ 验证仓库真的能上传能下载
bash
echo "hello v1.0.0" > /tmp/test.txt
curl -u admin:'密码' --upload-file /tmp/test.txt \
http://127.0.0.1:7878/repository/game-client/client/1.0.0/test.txt -w 'upload HTTP %{http_code}\n'
curl -s -u admin:'密码' http://127.0.0.1:7878/repository/game-client/client/1.0.0/test.txt这条命令在做什么
逐条解释:
--upload-file:curl 的上传参数,直接把本地文件 PUT 到目标 URL。URL 里的路径就是仓库里的目录结构(client/1.0.0/test.txt)。- 下载时不需要认证也能读(raw 仓库默认允许匿名读)。这一点很重要:客户端要能匿名下载安装包。
- 第二条命令期望输出
hello v1.0.0。能看到就说明**「客户端产物分发通道」已经通了**。 - 再验证一次 allow_once 的效果:把同一条上传命令再跑一遍,应该返回
400而不是成功——这就是「已发布的东西不许改」。
阶段 1 验收标准
- Nexus 在 7878 端口监听,
curl返回 200 - 仓库
game-client创建成功(HTTP 201/204) - 上传一个文件 → 能匿名下载回来 → 内容一致
- 重复上传同一路径 → 被拒绝(验证
allow_once生效)
9.2 阶段 2:认识你的「被发布软件」
先花 20 分钟读一遍 server/server.js 和 client/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 始终返回 200 | readiness 与 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=/root/node/bin:$PATH:把 Node 加进 PATH。你的 Node 装在/root/node/bin,不是系统默认路径。npm config set registry https://registry.npmmirror.com:把 npm 源换成国内镜像。你的 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 源里(你的机器已启用 epel 仓库,实测可安装xdelta.x86_64 3.1.0-17.el9)。- 它是什么:一个生成/应用二进制差量补丁的工具。给两个版本的安装包,它能算出「只有差异部分」的小补丁。
- 为什么用 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' ... EOF:heredoc 写法,把两个 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.7.2.yaml \
https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.7.2/manifests/install.yaml
wc -c install-v1.7.2.yaml # 期望 929730这条命令在做什么
-f:HTTP 出错时返回非零退出码(否则 curl 会「成功」地保存一个错误页面)。-L:跟随重定向。--retry 3:失败重试 3 次。gh-proxy.com/https://raw.githubusercontent.com/...:这是把 GitHub 的原始链接套一层国内代理。实测下载 929730 字节。备用通道:ghfast.top前缀、cdn.jsdelivr.net/gh/argoproj/argo-rollouts@v1.7.2/manifests/install.yaml。wc -c校验字节数,确认下载完整——这是防止「下载了一半的 YAML」的第二道保险。- 为什么是 v1.7.2:你的集群是 k8s v1.28,而 Argo Rollouts 官方
stable分支测试的是 1.32~1.35、master是 1.34~1.37,都不覆盖 1.28。1.8 起已明确不支持 1.28 以下。
② 把镜像搬进自己的 Harbor,并替换清单里的地址
bash
HARBOR=106.75.29.47:10086
docker login ${HARBOR} -u admin -p '@Pl000000'
docker pull quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2
docker tag quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2 ${HARBOR}/infra/argo-rollouts:v1.7.2
docker push ${HARBOR}/infra/argo-rollouts:v1.7.2
cd /root/argo-rollouts
sed -i "s#quay.io/argoproj/argo-rollouts:#${HARBOR}/infra/argo-rollouts:#g" install-v1.7.2.yaml
grep -n 'image:' install-v1.7.2.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.7.2.yaml
kubectl -n argo-rollouts patch deployment argo-rollouts --type=strategic -p '{
"spec":{"template":{"spec":{
"nodeSelector":{"kubernetes.io/hostname":"k8s-node3"},
"imagePullSecrets":[{"name":"harbor-secret"}]
}}}}'
kubectl -n argo-rollouts rollout status deploy/argo-rollouts
kubectl get crd | grep argoproj这条命令在做什么
- 为什么要 nodeSelector 钉在 k8s-node3:你的集群里 node1 内存 76%、node2 内存 93%,只有 node3(7.4G 分配)余量够。不钉的话调度器可能把它丢到 node2 上,直接触发 OOM。
imagePullSecrets:Harbor 是私有仓库,需要拉取凭据。这个 secret 要先从ruoyi命名空间复制过来,或者手工创建。kubectl get crd | grep argoproj:确认 CRD 装上了——能看到rollouts.argoproj.io和analysistemplates.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=106.75.29.47: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 改成 5 | proto 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 | 「客户端更新了,服务端就不用管」 | 服务端仍要动 manifest、开关、兼容逻辑。而且服务端的兼容压力会变大——要同时服务已升级和未升级的用户 |
| 2 | 「服务端可以像网页那样随便改接口」 | 老客户端是你改不动的存量。接口必须只加不改不删 |
| 3 | 「客户端出问题回滚一下就行」 | 客户端升级不可逆。只能发新版或服务端关开关 |
| 4 | 「客户端也能做蓝绿」 | 客户端做不了蓝绿(用户设备不能瞬间切换)。能做的是放量控制 + 功能开关 |
| 5 | 「灰度 = 按比例」 | 按比例是金丝雀;灰度的核心是按人群/维度。两者常组合使用 |
| 6 | 「把客户端代码热更当成万能药」 | 热更范围有硬边界(iOS 审核限制、编译层代码、底层协议),且热更失败可能让客户端卡在更新界面 |
| 7 | 「配置表是一份文件」 | 它是两份必须同步的产物(服务端用 + 客户端用)。必须做版本校验 |
| 8 | 「发布完就结束了」 | 客户端发布完成后才进入真正的观测期。要看按版本分组的成功率/崩溃率。观测是发布的一部分 |
| 9 | 「readiness 和 liveness 用一个接口就行」 | 排空时 readiness 失败会让 K8s 重启容器,直接打断你的优雅停机。必须分开 |
| 10 | 「hash 取模就是分桶」 | 没有雪崩混淆的哈希会让连续 ID 落到连续桶,放量分布严重倾斜。必须做位混合 |
10.2 本环境相关命令速查
bash
# ---------- Nexus(客户端产物仓库)----------
sudo -u nexus /opt/nexus/bin/nexus start|stop|status
ss -lntp | grep 7878
cat /opt/sonatype-work/nexus3/admin.password # 首次启动才有
curl -u admin:'密码' http://127.0.0.1:7878/service/rest/v1/repositories
curl -u admin:'密码' --upload-file 本地文件 http://127.0.0.1:7878/repository/game-client/路径/文件名
# ---------- 差量补丁 ----------
dnf install -y xdelta
xdelta3 -e -s 旧版本包 新版本包 输出.patch # 生成补丁
xdelta3 -d -s 旧版本包 补丁文件 还原出的新包 # 应用补丁
# ---------- 客户端产物:算哈希与体积 ----------
sha256sum 文件
stat -c '%n %s bytes' 文件
python3 -m json.tool manifest.json # 校验 JSON 语法
# ---------- 原子发布(通用技巧)----------
mv manifest.json.tmp manifest.json # 同文件系统内是原子操作
# ---------- Nginx(分发通道)----------
nginx -t && nginx -s reload
curl -s http://127.0.0.1/patches/manifest.json | python3 -m json.tool
# ---------- Argo Rollouts ----------
kubectl -n argo-rollouts get pods
kubectl get crd | grep argoproj
kubectl argo rollouts list rollouts -A
kubectl argo rollouts get rollout <name> -n ruoyi --watch
kubectl argo rollouts set image <name> server=镜像:tag -n ruoyi
kubectl argo rollouts promote <name> -n ruoyi # 推进到下一步
kubectl argo rollouts abort <name> -n ruoyi # 中止并回滚
kubectl argo rollouts undo <name> -n ruoyi # 回退到上一版本
# ---------- 长连接服务自检 ----------
curl -s http://127.0.0.1:3000/healthz # readiness:排空时返回 503
curl -s http://127.0.0.1:3000/livez # liveness:始终 200
curl -s 'http://127.0.0.1:3000/drain?grace=3000' # 手动触发摘流+排空
pkill -TERM -f "node server.js" # 走 SIGTERM 路径(Linux)
# ---------- 环境自检 ----------
nproc; uptime; free -m; df -h /10.3 本次实测的镜像与下载地址
以下地址全部在 106.75.30.92 上实测通过(2026-09-24):
| 用途 | 地址 | 结果 |
|---|---|---|
| Argo Rollouts 镜像 | quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2 | manifest 200(amd64/arm64) |
| Argo Rollouts 镜像(备) | quay.nju.edu.cn/argoproj/argo-rollouts:v1.7.2 | 200 |
| Argo Rollouts 镜像(备) | quay.io/argoproj/argo-rollouts:v1.7.2 | 200(本环境可直连) |
| install.yaml v1.7.2 | https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.7.2/manifests/install.yaml | 200 / 929730 B |
| install.yaml(备) | https://ghfast.top/https://raw.githubusercontent.com/… | 可用 |
| install.yaml(备) | https://cdn.jsdelivr.net/gh/argoproj/argo-rollouts@v1.7.2/manifests/install.yaml | 可用 |
| kubectl 插件 | https://gh-proxy.com/https://github.com/argoproj/argo-rollouts/releases/download/v1.7.2/kubectl-argo-rollouts-linux-amd64 | 200 / 126850410 B |
| xdelta | dnf install -y xdelta(EPEL:3.1.0-17.el9) | 源可用 |
| npm 源 | https://registry.npmmirror.com | 200 |
| git 加速(克隆) | https://gh-proxy.com/https://github.com/<user>/<repo>.git | 支持 git ls-remote |
| 第 8 章项目仓库 | 从你的服务器实测 git ls-remote |
|---|---|
github.com/colyseus/colyseus.git | 通 |
github.com/rustdesk/rustdesk.git | 通 |
github.com/luanti-org/luanti.git | 通 |
github.com/Anuken/Mindustry.git | 通 |
github.com/openimsdk/open-im-server.git | 通 |
github.com/egametang/ET.git | 通 |
github.com/cloudwu/skynet.git | 通 |
github.com/heroiclabs/nakama.git | 通 |
10.4 三句话总结
INFO
- 你的四条推断方向都对,尤其抓住了「双端版本独立变化」这个关键;缺的是发布顺序、更新力度、兼容窗口、数据维度这四层。
- 网页项目优化的是「替换」,客户端项目优化的是「共存」。前者追求快速回滚,后者追求让服务端能兜住所有历史版本,并用 Feature Flag 换取「可回滚」的能力。
- 你服务器上的练习条件已经齐了:Nexus(已装未启动)负责客户端产物分发,Argo Rollouts v1.7.2 负责服务端蓝绿/金丝雀/灰度,xdelta 负责差量补丁,Nginx 负责分发通道。第 9 章六个阶段跑完,你就有了别人只能在书上看到的完整经验。
文档说明:本文环境结论来自 2026-09-24 对 106.75.30.92 的只读勘察(未做任何变更)。文中所有镜像地址、版本可用性、命令存在性均在该服务器上逐条实测。Argo Rollouts 选型依据为:官方 stable 分支 e2e 测试配置覆盖 k8s 1.32~1.35、master 覆盖 1.34~1.37(均已直接读取确认),因此对 k8s 1.28 选择 v1.7.2。第 8 章开源项目数据(语言、星数、最近提交时间)来自 GitHub API 实时查询。
配套目录 outputs/demo/ 只包含「被发布的软件本体」及其构建/部署定义,不包含任何自动化脚本。建议与上一轮《ArgoCD 部署与 CICD 流程改造方案》合并归档,作为同一条 GitOps / 渐进式交付演进路线的两份连续记录。