主题
怎么用这份文档
- 第一次接触这类软件:先读第 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 | 主机名 | 配置 | 系统盘 | 角色 | 部署的组件 / 中间件 |
|---|---|---|---|---|---|---|
| 1 | 192.168.0.10 | k8s-master | 4C8G | 100G | K8s 控制平面 | kube-apiserver / etcd / controller-manager / scheduler、containerd、kubectl、Argo Rollouts 控制器 |
| 2 | 192.168.0.11 | k8s-node1 | 8C16G | 200G | K8s 工作节点 | kubelet、containerd、服务端 Pod(stable 版本) |
| 3 | 192.168.0.12 | k8s-node2 | 8C16G | 200G | K8s 工作节点 | kubelet、containerd、服务端 Pod(canary 版本,与 stable 并存) |
| 4 | 192.168.0.20 | build | 8C16G | 200G | 构建机 | Jenkins、JDK 17、Node.js 20+、xdelta3(生成差量补丁)、Docker |
| 5 | 192.168.0.30 | nexus | 4C8G | 500G | 客户端制品仓库 | Nexus Repository 3(raw hosted 仓库,存客户端安装包与补丁) |
| 6 | 192.168.0.31 | dist | 2C4G | 100G | 客户端分发通道 | Nginx(版本清单托管 + 更新包下载,模拟 CDN 源站) |
| 7 | 192.168.0.40 | mysql | 4C8G | 200G | 数据库 | MySQL 8.0 —只给准备要求,不展开安装 |
| 8 | 192.168.0.41 | redis | 2C4G | 50G | 缓存 | Redis 7 —只给准备要求,不展开安装 |
| 9 | 192.168.0.50 | harbor | 4C8G | 500G | 服务端镜像仓库 | Harbor 2.15.2(存服务端容器镜像) |
| 10 | 192.168.0.60 | git | 2C4G | 100G | 代码仓库 | Gitea 1.27.x —只给准备要求,不展开安装 |
客户端测试机不占服务器资源:客户端程序(第 9 章的 demo、或 RustDesk / Colyseus 客户端)直接跑在你自己的笔记本上即可。
0.2 每台机器的职责与关键配置
| IP | 这台机器为什么需要它 | 需要放通的端口 | 配置档位的选择理由 |
|---|---|---|---|
.10 | K8s 的大脑。etcd 对磁盘 IO 敏感,所以给独立机器 | 6443、2379-2380、10250、10257、10259 | 4C8G:控制平面本身不重,但 etcd 需要稳定的 CPU 与磁盘 |
.11 | 跑 stable 版服务端。双版本并存时这里是"老版本"的载体 | 30000-32767(NodePort)、10250 | 8C16G:长连接服务端的内存占用与连接数成正比 |
.12 | 跑 canary 版服务端。灰度期间新老版本同时在线 | 同上 | 8C16G:与 .11 对称,保证灰度流量不会因为资源不足而失真 |
.20 | 构建机。编译、打包、生成差量补丁都在这里 | 8080(Jenkins) | 8C16G:构建是 CPU 密集型的,且要同时跑打包与补丁生成 |
.30 | 客户端产物的唯一权威来源。存全量包与差量补丁 | 8081 | 4C8G / 500G 磁盘:制品仓库吃磁盘不吃 CPU |
.31 | 客户端下载的门面。它挂了客户端就更新不了 | 80、443 | 2C4G:纯静态文件分发,Nginx 极省资源 |
.40 | 业务数据。游戏/IM 类系统还要存存档、配置表 | 3306 | 4C8G:按数据量调整 |
.41 | 会话、排行榜、在线状态 | 6379 | 2C4G:Redis 吃内存,按并发量调整 |
.50 | 服务端镜像 | 10086 | 4C8G / 500G:镜像仓库吃磁盘 |
.60 | 代码、配置表、协议定义 | 3000 | 2C4G |
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、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 才动下一个 |
| 适用 | 无状态服务(若依这类无状态微服务) | 有状态服务(数据库、消息队列、分片游戏服) |
记住这个结论:无状态微服务用 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.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: 10 → pause: 5m → setWeight: 50 |
trafficRouting | 告诉 Rollouts「通过什么方式切流量」——这里是 nginx,也可以是 Istio / ALB |
AnalysisTemplate | 把「人工判断要不要继续」变成「自动查指标判断」,比如查成功率低于 99% 就自动中止 |
| Stable / Canary ReplicaSet | 老版本和新版本各对应一个 ReplicaSet,Rollouts 同时管理两者 |
1.7 制品与分发(中间件与名词)
三类仓库,管三种东西
| 仓库 | 管什么 | 你环境里的实例 | 访问方式 |
|---|---|---|---|
| 镜像仓库 (Container Registry) | Docker 镜像 | Harbor 192.168.0.50:10086 | Docker 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 / Nacos | Consul / 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 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 这类系统的典型形态
第 1 章把名词讲清了。这一章回答一个更实际的问题:一个真实的客户端/服务端系统,到底由哪些部分组成? 后面所有关于"发布"的讨论,都建立在这张组件地图上。
2.1 两端形态总览
| 维度 | 网页项目 | 客户端 / 服务端项目 |
|---|---|---|
| 交付形态 | 一份代码 → 一个容器镜像(+ 静态文件) | 至少三份产物:服务端镜像、客户端安装包、客户端资源/配置包 |
| 版本数量 | 全网同一时刻只有 1 个版本在跑 | 客户端侧同时存在 N 个版本(可能跨好几个大版本) |
| 生效延迟 | 部署完成即生效(秒级) | 服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月) |
| 分发控制权 | 完全在你自己手里 | 部分不在:应用商店审核、用户不点更新、渠道包版本不一致 |
| 回滚语义 | 换回旧镜像 = 真·恢复 | 服务端可回滚;客户端已升级的设备无法回滚,只能"再发一版改回来" |
| 运行状态 | 无状态,Pod 可随时杀 | 常有长连接 / 房间 / 会话 / 存档在内存里,重启即断线 |
| 兼容责任 | 浏览器天然向前兼容 | 必须由服务端兜底:新服务端要能服务老客户端 |
| 流量单位 | 请求(Request) | 请求 + 连接(Connection)——连接是"粘"的、有生命周期的 |
| 测试复杂度 | 接口测试即可 | 需要客户端版本 × 服务端版本的兼容矩阵测试 |
一句话总纲
网页项目优化的是「版本替换」;客户端项目优化的是「多版本共存」。
所有的架构设计、发布流程、回滚方案,都是为了让这两件事不发生冲突:
- 服务端能同时正确服务多个版本的客户端
- 新客户端能在老服务端上"降级运行"(至少不崩)
2.2 服务端侧的典型组件
服务端从来不是一个"程序",而是一组职责分明的角色。不同类型对发布的要求完全不同。
| 角色 | 职责 | 有状态? | 典型实现 | 发布方式 |
|---|---|---|---|---|
| 网关 / 接入层 | 承载客户端连接、协议解析、限流、路由 | 否(可无状态) | Spring Cloud Gateway、自研 TCP 网关 | 滚动更新(需配合连接排空) |
| 登录 / 认证服务 | 账号验证、发 token、跨服跳转 | 否 | Spring Boot + Redis | 滚动更新 |
| 逻辑服 / 游戏服 | 处理业务逻辑,持有房间、会话状态 | 是 | 自研 Actor 框架、Skynet | 必须先排空,再逐台重启 |
| 匹配服 | 组队、匹配队列 | 半状态(队列在内存) | 自研 | 排空后重启 |
| 中心服 / 跨服服 | 跨区活动、全局排行榜 | 是 | 自研 | 停机窗口或双写迁移 |
| 聊天 / IM 服 | 长连接维护、消息投递 | 是 | Netty、自研 | 排空(推送重连指令) |
| 数据服 / DB 代理 | 统一数据访问、分库分表路由 | 否 | 自研 | 滚动更新 |
| 定时任务 / 离线计算 | 结算、跑批 | 否 | xxl-job、Quartz | 可随时重启(注意幂等) |
| 后台管理服务 | GM 工具、运营配置 | 否 | 常规 Web 服务 | 完全等同于网页项目 |
关键洞察:同一套系统里,不同角色的发布策略不一样
很多人一开始会想"给所有服务配一套统一的发布流程"。这是错的。
- 后台管理服务:随便重启,滚动更新即可
- 逻辑服:必须先让玩家下线(或迁移到别的实例),再重启
- 网关:可以让老连接自然结束,但不能同时重启所有实例(否则新连接没地方进)
所以真实的发布系统里,每个服务都会标注自己的"发布类型",流水线按这个类型选择策略。
服务端组件到 K8s 对象的映射
| 服务端角色 | 推荐 K8s 对象 | 原因 |
|---|---|---|
| 无状态服务(网关/登录/后台) | Deployment | 副本可随意增删、滚动更新天然支持 |
| 有状态服务(逻辑服/房间服) | Deployment 或 StatefulSet | Deployment 也可以,但必须自己实现排空逻辑;StatefulSet 提供稳定的网络标识与受控的更新顺序 |
| 需要固定身份与顺序 | StatefulSet | 如按序号分片的游戏服(game-0、game-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 接口 |
这张表里最容易被忽略的两项
- 签名/哈希校验:几乎所有自研的更新器都"一开始不做",然后在某次用户反馈"装完打不开"之后才补上。
- 按版本维度的观测:没有它,灰度发布就是盲发——你放量 20%,但不知道那 20% 里新版本的成功率是多少。
这两项都不是"以后再说"的优化,而是灰度发布能否成立的前提。
03 为什么网页那套不能照搬
若依这类微服务项目是典型的网页项目:一组无状态服务 + 一个前端静态目录。那套流程里有一些「默认成立」的前提,在客户端软件里一个都不成立。
| 维度 | 网页项目(如若依微服务) | 客户端 / 服务端项目 |
|---|---|---|
| 交付产物 | 无状态容器镜像 + 静态文件 | 服务端镜像 + 客户端安装包 / 资源包 / 配置表(三份,需分别发布) |
| 用户侧版本 | 刷新即最新,全网只有一个版本 | 用户设备上可能装着 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 就能看到效果
- 国内可获取:这些仓库都可以克隆(必要时用
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(需 8C16G 以上) |
| ET | C# | 9.9k | Unity 客户端 + C# 服务端(双端共享同一份协议代码) | HybridCLR 客户端代码热更 + 服务端 DLL 热重载 + Location Server 路由 + 中文文档 | 高(需 Unity + VS2022) | 开发机需求高 |
| Skynet | C | 14.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 跨节点路由等)。
- 看
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 动手演练:从白板环境到完整发布链路
前八章讲的是"是什么、为什么"。这一章回答"怎么做"。
本章的组织方式
按阶段推进,每个阶段结束时都有一个明确的验收点。只有前一个阶段验收通过,才进入下一个阶段——这类系统涉及的组件多,跳步会产生大量难以定位的问题。
| 阶段 | 目标 | 产出 |
|---|---|---|
| 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.js、launcher.js、Dockerfile、rollout-canary.yaml 这些文件必须保留。理由见 9.2:
它们不是自动化脚本,而是「被发布的软件本身」——就像若依项目里的
ruoyi-gateway.jar。删了就没东西可发布了。
9.0 阶段 0:环境准备与前置检查
9.0.1 需要准备的机器
完整清单见 第 0 章的两张表格。这里只列本阶段要用到的部分:
| IP | 主机名 | 配置 | 本阶段要做的事 |
|---|---|---|---|
192.168.0.10 | k8s-master | 4C8G | 装 K8s 控制平面、装 Argo Rollouts |
192.168.0.11 | k8s-node1 | 8C16G | 加入集群,跑服务端 Pod(stable) |
192.168.0.12 | k8s-node2 | 8C16G | 加入集群,跑服务端 Pod(canary) |
192.168.0.20 | build | 8C16G | 构建镜像、生成差量补丁、跑客户端 |
192.168.0.30 | nexus | 4C8G | 客户端制品仓库 |
192.168.0.31 | dist | 2C4G | 客户端更新包分发(Nginx) |
192.168.0.50 | harbor | 4C8G | 服务端镜像仓库 |
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这条命令在做什么
podSubnet与serviceSubnet绝不能和物理网段(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
# 期望:三个节点都是 Ready9.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.gz、client-1.2-new.tar.gz、client-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.vmoptionsnexus.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=forking | Nexus 的启动脚本会 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-releasesVersion policy: ReleaseLayout policy: StrictBlob 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.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=/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' ... 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.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.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=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 改成 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 | 「客户端更新就是让用户下载新版本」 | 把四件不同的事当成一件事 | 客户端更新有四种力度(静默热更 / 可选更新 / 强制更新 / 整包重装),成本和风险差一个数量级。见 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正确做法(三条都要做):
- 服务端返回
Cache-Control: no-store - 如果用了 CDN,在 CDN 侧对该路径配置"不缓存"
- 清单 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而不是free:free包含了可回收的缓存,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.repo | 200 |
| 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 Key | https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/repodata/repomd.xml.key | 200 |
| 控制平面镜像(阿里云) | 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.10 | 206 |
| Calico 清单 v3.32.2 | https://gh-proxy.com/https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/calico.yaml | 206 |
| Calico 镜像 | m.daocloud.io/docker.io/calico/node:v3.32.2 | 206 |
本文档特有的组件
| 用途 | 地址 | 实测 |
|---|---|---|
| Nexus 3.96.3 | https://mirrors.huaweicloud.com/nexus/nexus-3.96.3-01-unix.tar.gz | 200 ✅ |
| Argo Rollouts 清单 v1.10.0 | https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.10.0/manifests/install.yaml | 206 |
| Argo Rollouts 镜像 | quay.m.daocloud.io/argoproj/argo-rollouts:v1.10.0 | 206 |
| Argo Rollouts CLI | https://files.m.daocloud.io/github.com/argoproj/argo-rollouts/releases/download/v1.10.0/kubectl-argo-rollouts-linux-amd64 | 206 |
| Harbor 离线包 v2.15.2 | https://gh-proxy.com/https://github.com/goharbor/harbor/releases/download/v2.15.2/harbor-offline-installer-v2.15.2.tgz | 200 |
| Argo CD 清单 v3.5.3 | https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml | 206 |
| Argo CD 镜像 | quay.m.daocloud.io/argoproj/argocd:v3.5.3 | 206 |
| Argo CD 的 dex | ghcr.m.daocloud.io/dexidp/dex:v2.45.1 | 206 |
| Argo CD 的 redis | m.daocloud.io/public.ecr.aws/docker/library/redis:8.2.3-alpine | 206 |
第 8 章开源项目仓库
| 项目 | 地址 | 实测 |
|---|---|---|
| Colyseus | https://github.com/colyseus/colyseus.git | 可 clone |
| RustDesk | https://github.com/rustdesk/rustdesk.git | 可 clone |
| Luanti | https://github.com/luanti-org/luanti.git | 可 clone |
| Mindustry | https://github.com/Anuken/Mindustry.git | 可 clone |
| Nakama | https://github.com/heroiclabs/nakama.git | 可 clone |
| OpenIM | https://github.com/openimsdk/open-im-server.git | 可 clone |
| ET 框架 | https://github.com/egametang/ET.git | 可 clone |
| Skynet | https://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.gz | 404 | 用华为云镜像或指定具体版本号 |
https://gh-proxy.com/https://github.com/sonatype/nexus-public/releases/download/.../nexus-*-unix.tar.gz | 404 | Sonatype 的 release 附件不挂在 GitHub 上 |
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/quay.io/argoproj/... | 404 | 用 quay.m.daocloud.io |
docker.m.daocloud.io/argoproj/argocd | 403 | 该域名只代理 Docker Hub,不代理 quay |
m.daocloud.io/docker.io/library/redis:8.2.3-alpine | 无此 tag | Argo 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。