跳到主要内容

案例分享:使用 Agones 在 TKE 上部署游戏专用服务器

术语​

  • DS:Dedicated Server,游戏专用服务器。在线对战的房间类游戏,同一局的玩家都会连上同一个 DS。本文中的方案采用单 Pod 单房间模式,可以认为一个 DS 就是一个游戏房间。

用户诉求​

有一款 PVP(房间类)的游戏基于虚幻引擎 UE5.4 开发,玩家在线匹配到一个房间后,连上同一个 DS 开始进行对战。

具体流程如下。

  1. 首先玩家准备游戏,游戏客户端请求大厅服进行游戏匹配:

  1. 大厅服对玩家进行匹配,匹配成功后,大厅服会根据玩家所选地图、游戏模式和最终匹配人数等条件筛选出符合条件的 DS,将选中的 DS 标记为已分配并下发玩家信息,让 DS 加载:

  1. 等 DS 加载完玩家信息后,大厅服通知游戏客户端开始游戏并提供 DS 公网地址:

  1. 客户端拿到 DS 地址后就可以连上 DS,玩家开始对战:

核心诉求:

  1. 玩家同时在线数量可能暴涨或暴跌,需支持 DS 的动态扩缩容,且扩容速度要快,避免玩家等待过久。
  2. 每个 DS 都需要独立的公网地址,且 DS 数量可能较大,需要支持大规模动态分配公网地址的能力。
  3. 被分配的 DS 不能被再次被分配,缩容时也不允许已分配的 DS 被销毁,避免玩家中断。

初步选型​

围绕 DS 有类似 GameLift 这样专门对 DS 进行部署与弹性伸缩云服务,但价格相对较高,灵活性较低,为降低成本和提升灵活性,业务团队决定基于云厂商的托管 Kubernetes 和云原生游戏开源项目来部署 DS,并配置根据空闲房间数量和比例的自动扩缩容。

托管 Kubernetes 选型​

TKE 是腾讯云上托管 Kubernetes 集群的服务,支持超级节点这种 Serverless 形态,每个 Pod 独占轻量虚拟机,既能避免 DS 间相互干扰(强隔离),也能实现快速扩容 Pod(Pod 独占轻量虚机,无需扩容节点)。

Pod 销毁立即自动停止计费,即能保证隔离性和扩容速度,又能按需使用,降低成本,所以托管 Kubernetes 服务选择了 TKE。

工作负载选型​

集群选择了 TKE,接下来就是要确定用什么工作负载类型来部署 DS 了。

Kubernetes 自带了 Deployment 和 StatefulSet 两种工作负载类型,但对游戏 DS 来说,都太简陋了。它们都无法做到标识 Pod 中的房间是否空闲,缩容的时候,非空闲的 Pod 可能会被删除,影响正在对战的玩家。

OpenKruiseGame 作为 OpenKruise 项目中的一部分,也是 CNCF 的孵化项目,提供了专门针对游戏场景的 GameServerSet 工作负载,支持通过 自定义服务质量 的方式自动设置 Pod 关联的 GameServer 的扩展状态,比如增加一个名为 idle 的状态,容器内提供探测脚本,根据探测结果自动设置 GameServer 的 idle 状态,并当 idle 为 true 时,自动将 opsState 设为 WaitToBeDeleted,以便在缩容时先缩空闲的 Pod,避免影响正在对战的玩家。

但经深入研究,发现存在一些问题:

  1. OpenKruiseGame 的弹性伸缩基于 KEDA,而 KEDA 最终也是依赖 Kubernetes 的 HPA 进行的弹性伸缩,HPA 在缩容时,会将 Pod 副本数降至 HPA 计算出来的期望副本数,这意味着无法绝对保证所有空闲 Pod 都不被缩容,只能做到优先缩容非空闲 Pod,还需结合合理的配置才能尽可能保证空闲 Pod 不被缩容。
  2. 这个自定义的 idle 状态始终是异步的,当 Pod/DS 已被分配,但 idle 状态还没来得及更新,恰好又正在缩容时,可能导致已被分配且即将开始对战的游戏房间被销毁。虽然可以通过调大 terminationGracePeriodSeconds + 实现优雅终止来尽量降低影响,但着实不太优雅。
  3. 对于游戏匹配,OpenKruiseGame 并未提供分配 GamesServer 的接口。虽然有提供 OpenMatch 的 director kruise-game-open-match-director,可以实现基于 OpenMatch 的游戏匹配(自动将被分配过的 GameServer 的 opsState 置为 Allocated,避免被再次分配和缩容被删除),但由于 OpenMatch 并不能满足业务需求,自研了一套游戏匹配逻辑,所以这个 direcotr 也用不上,需要自行实现 OpenKruiseGame 的 GameServer 分配和管理逻辑才能适配。
  4. 游戏基于虚幻引擎开发,而 OpenKruiseGame 并未提供游戏引擎的 SDK,要实现业务层面的健康检查、扩展状态和信息管理(如房间里的玩家列表以及玩家信息是否加载完成)等,还是使用 SDK 来的更方便。

根据自身业务情况,基于以上原因,不考虑使用 OpenKruiseGame,转而采用了基于 Agones 来部署 DS。

原因如下:

  1. Agones 提供了各种语言的 SDK 和游戏引擎的插件,当然也包括虚幻引擎,生态很完善,对于开发者来说,让 DS 接入 Kubernetes 很方便。
  2. Agones 天然支持了 GameServer 是否已分配的状态,且提供 GameServerAllocation API 来分配 GameServer,被分配过的 GameServer 的状态自动标记为 Allocated,可以避免被再次分配,更重要的是,可以保证缩容时 Allocated 的 GameServer 一定不会被销毁。
  3. Agones 对自研的游戏匹配模块很友好,直接基于 GameServerAllocation API 来分配 GameServer,减少了很多 GameServer 状态管理的开发成本。

分配游戏房间(DS)的方法​

Agones 是单 Pod 单房间的模型,社区也有讨论对单 Pod 多房间的支持,参考 issue #1197,但这会让游戏服的管理很复杂也很难实现,最终只给了个 High Density GameServers 的妥协方案,流程复杂且需要游戏服自己做很大开发工作来适配。

所以还是选择了用单 Pod 单房间的模型进行管理,Agones 提供了 GameServerAllocation API 来分配 GameServer,一个 GameServer 代表一个房间,也代表一个 DS,还支持根据特征(如 label)分配合适的 GameServer,如不同地图、不同 Pod 规格的 GameServer 打上指定的 label,匹配时根据游戏人数和游戏属性(如选择的地图和游戏模式)来匹配到适合的 GameServer。

使用虚幻引擎插件接入 Agones​

在游戏项目导入 Agones 插件并启用后,在合适位置初始化 Agones SDK 和调用相关 hook 函数,即可接入 Agones。

Agones 官方提供了 UE5 的插件及其使用方法,参考 Unreal Engine Game Server Client Plugin。

使用流水线自动构建容器镜像​

使用虚幻引擎开发的游戏,需使用虚幻官方提供的工具进行构建,平时自测一般用虚幻编辑器构建在本地测试,发版时则使用 Coding 流水线来自动构建并编译容器镜像,推送至 TCR 镜像仓库。

流水线的大致思路是:

  1. UE 官方提供了 通过命令行构建项目 的方法,可根据自身需求在流水线中编写适合的构建命令。
  2. 假设上一步构建出压缩包名为 LinuxServer.zip,流水线中将其解压到 LinuxServer 文件夹,并使用如下的 Dockerfile 构建容器镜像(注意替换 ENTRYPOINT 中的脚本名称)。
    FROM ubuntu:22.04
    RUN mkdir /app
    COPY ./LinuxServer /app
    RUN useradd -m ue5
    RUN chown -R ue5:ue5 /app
    USER ue5

    EXPOSE 7777/tcp
    EXPOSE 7777/udp

    ENTRYPOINT [ "/app/LyraServer.sh" ]
  3. 最后流水线自动将容器镜像推送到镜像仓库。一般游戏的镜像都较大,就使用腾讯云 TCR 镜像仓库,确保镜像拉取的速度和稳定性。

使用 Agones Fleet 部署​

Agones 提供了 Fleet 来编排 DS,也就是一种 Kubernetes 中扩展的自定义工作负载类型,类似 Kubernetes 的 StatefulSet,只是专为游戏场景设计。

Fleet 指定 DS 的副本数,每个副本对应一个 GameServer 对象,该对象中可以记录游戏服务器的状态,如是否已被分配、对外的公网地址、玩家数量等。

Fleet 配置方法参考官方文档 Quickstart: Create a Game Server Fleet。

由于游戏中每场对局的人数、地图、游戏模式等业务属性可能不同,所以需要将 Fleet 拆分成多个,不同 Fleet 使用不同 Pod 规格、使用不同启动参数加载不同地图等业务数据,并打上能标识规格、地图等属性的 label,用于后续分配房间(DS)时根据 label 过滤合适的 GameServer。

使用 TKE 网络插件为游戏房间映射公网地址​

每个游戏房间(DS)都需要独立的公网地址,而 Agones 只提供了 HostPort 这一种方式,如果用 TKE 超级节点,HostPort 无法使用(因为超级节点是虚拟的节点,HostPort 没有意义)。

TKE 提供了 tke-extend-network-controller 网络插件,可以通过 CLB 端口来为 DS 暴露公网地址,可参考 使用 CLB 为 Pod 分配公网地址映射 进行安装和配置。

如何关联 Agones 的 GameServer 与映射的 CLB IP:Port?可以将 IP:Port 信息写到 GameServer 的 label 中。

下面介绍实现步骤:

  1. 首先定义 DedicatedCLBService 时指定 addressPodAnnotation,即将 CLB 的 IP:Port 信息注入到 Pod 指定注解中:
apiVersion: networking.cloud.tencent.com/v1alpha1
kind: DedicatedCLBService
metadata:
namespace: demo
name: gameserver
spec:
maxPod: 50
selector:
app: gameserver
ports:
- protocol: UDP
targetPort: 9000
addressPodAnnotation: networking.cloud.tencent.com/external-address # 将外部地址自动注入到指定的 pod annotation 中
existedLbIds:
- lb-xxx
  1. 然后定义游戏服工作负载的 pod template 时(Fleet 是 .spec.template.spec.template 字段),利用 downward API 将注解信息挂载到容器中:
spec:
containers:
- ...
volumeMounts:
- name: podinfo
mountPath: /etc/podinfo
volumes:
- name: podinfo
downwardAPI:
items:
- path: "address"
fieldRef:
fieldPath: metadata.annotations['networking.cloud.tencent.com/external-address']
  1. 游戏服启动时轮询此文件,发现内容不为空时即表示 CLB 已绑定好 Pod,内容即为当前房间的公网地址。可通过调用 Agones SDK 的 SetLabel 函数将信息写入到 GameServer 对象中以实现 GameServer 与 CLB 公网地址映射的关联。

架构设计​

在游戏业务场景中,游戏房间(DS)不仅有是否分配的状态,还有一些其他业务扩展的状态,比如玩家信息是否加载完成的状态(在玩家匹配成功后,分配一个游戏房间,即 Agones 的 GameServer,但还需等待房间加载完将要连上来的玩家信息后,才通知玩家连接进入房间进行对战)。

考虑到后续还有很多其它游戏要用,就打算不直接在大厅服里写这些房间管理的逻辑,所以引入 room-manager 作为房间管理的中间件,该中间件使用 Go 语言开发,利用 k8s 的 client-go 对集群中的 GameServer 进行 list-watch (其他语言的 k8s SDK 不支持自定义资源的 list-watch),为大厅服暴露两个接口:

  1. 查询 GameServer 信息(从 client-go list-watch 的缓存拿,用于大厅服查询分配的房间是否加载完玩家信息,等加载完就通知玩家连上该房间进行战斗)。
  2. 分配 GameServer (本质上会调用 Agones 提供的 GameServerAllocation API,只是会根据业务需求加一些过滤条件,比如根据匹配的人数、选择的地图和游戏模式等条件匹配满足条件的 GameServer,通过 label 标识和过滤)。

整体流程如下:

弹性伸缩​

Agones 支持通过 FleetAutoScaler 声明游戏服的弹性伸缩策略,可以指定 Fleet 预留的 buffer 大小(冗余的空闲房间),可以是数量,也可以是百分比(空闲房间比例):

考虑到 Fleet 众多和游戏规模等因素,希望尽可能控制成本,尽可能精准控制 GameServer 数量,决定设定预留 buffer 大小为指定数量的 GameServer,使用脚本定时修改 buffer 大小,以实现在每日高峰期到来之前提前预留的 GameServer 数量,减少玩家的等待时间;高峰期结束后减小 buffer 数量,降低成本。

参考资料​