探索使用 Kubernetes 扩展专用游戏服务器:第1部分-容器化和部署

2021-05-27 18:52:26 浏览数 (1)

原创科普整理

  • 油管视频:GCAP 2017: Scaling Multiplayer Games with Open Source
    • https://www.youtube.com/watch?v=a08WrvIPKMw
  • 原文:caling Dedicated Game Servers with Kubernetes: Part 1 - Containerising and Deploying
    • https://www.compoundtheory.com/scaling-dedicated-game-servers-with-kubernetes-part-1-containerising-and-deploying/
  • 示例项目:paddle-soccer
    • https://github.com/markmandel/paddle-soccer

你为什么要这样做?

尽管容器(containers)和 Kubernetes 是很酷的技术,但为什么我们要在此平台上运行游戏服务器?

  • 游戏服务器的扩展很困难,并且通常是专有软件的工作 - 软件容器和 Kubernetes 应该使它更容易,并且编码更少。
  • 容器为我们提供了一个可部署的工件,可用于运行游戏服务器。这消除了在部署过程中安装依赖项或配置机器的需要,并且极大地提高了人们对软件在开发和测试中能够像在生产环境中一样运行的信心。
  • 通过将软件容器和 Kubernetes 结合使用,我们可以建立一个坚实的基础,从而基本上可以大规模运行任何类型的软件 - 从部署(deployment),运行状况检查(health checking),日志聚合(log aggregation),扩展(scaling)等等,并使用 API 在几乎所有级别上控制这些事情。
  • 从本质上讲,Kubernetes 实际上只是一个集群管理解决方案,几乎可用于任何类型的软件。大规模运行专用游戏需要我们跨机器集群管理游戏服务器进程 – 因此,我们可以利用在该领域已经完成的工作,并根据自己的特定需求对其进行定制。
  • 这两个项目都是开源的,并且是积极开发的,因此我们也可以利用未来开发的任何新功能。

Paddle Soccer

为了验证我的理论,我创建了一个非常简单的基于 Unity 的游戏,称为 Paddle Soccer,该游戏实质上与描述的完全一样。这是一款两人在线游戏,其中每个玩家都是 paddle,他们踢足球,试图互相得分。它具有一个 Unity 客户端以及一个 Unity 专用服务器。它利用 Unity High Level Networking API 来在服务器和客户端之间提供游戏状态同步和 UDP 传输协议。值得注意的是,这是一款 session-based 的游戏; 即:你玩了一段时间,然后游戏结束,你回到大厅再玩,所以我们将专注于这种扩展,并在决定何时添加或删除服务器实例时使用这种设计。也就是说,理论上这些技巧也适用于 MMO 类型的游戏,只是需要进行一些调整。

Paddle Soccer 架构

Paddle Soccer 使用传统的整体体系结构来进行基于会话的多人游戏:

  1. 玩家连接到 matchmaker 服务,该服务使用 Redis 将它们配对在一起,以帮助实现此目的。
  2. 一旦两个玩家加入到一个游戏会话中,matchmaker 会与 game server manager 对话,让它在我们的机器集群中提供一个游戏服务器。
  3. game server manager 创建一个新的游戏服务器实例,该实例在集群中的一台计算机上运行。
  4. game server manager 还获取游戏服务器运行所在的IP地址和端口,并将其传递 matchmaker 服务。
  5. matchmaker 服务将 IP 和端口传递给玩家的客户端。
  6. …最后,玩家直接连接到游戏服务器,现在可以开始对战了。

由于我们不想自己构建这种类型的集群管理和游戏服务器编排,因此我们可以依靠容器和 Kubernetes 的强大功能来处理尽可能多的工作。

容器化游戏服务器

此过程的第一步是将游戏服务器放入软件容器中,以便 Kubernetes 可以部署它。将游戏服务器放置在 Docker 容器中基本上与容器化其他任何软件相同。

这是用于将 Unity 专用游戏服务器放置在容器中的 Dockerfile

代码语言:javascript复制
FROM ubuntu:16.04

RUN useradd -ms /bin/bash unity

WORKDIR /home/unity

COPY Server.tar.gz .

RUN chown unity:unity Server.tar.gz

USER unity

RUN tar --no-same-owner -xf Server.tar.gz && rm Server.tar.gz

ENTRYPOINT ["./Server.x86_64", "-logFile", "/dev/stdout"]

由于 Docker 默认情况下以 root 用户身份运行,因此我想创建一个新用户并在该帐户下的容器内运行所有进程。因此,我为游戏服务器创建了一个 “unity” 用户,并将游戏服务器复制到其主目录中。在构建过程中,我创建了专用游戏服务器的压缩包,并且将其构建为可以在 Linux 操作系统上运行。

我唯一要做的另一件有趣的事是,当我设置 ENTRYPOINT(容器启动时运行)时,我告诉 Unity 将日志输出到 /dev/stdout(标准输出,即显示在前台),因为 DockerKubernetes 将从中收集日志。

从这里,我可以构建该镜像并将其推送到 Docker registry,以便我可以共享该镜像并将其部署到我的 Kubernetes 集群。我为此使用 Google Cloud Platform 的私有 Container Registry,因此我有一个私有且安全的 Docker 镜像存储库。

运行游戏服务器

对于更传统的系统,Kubernetes 提供了几个真正有用的构造,包括能够在一组机器集群上运行一个应用程序的多个实例的能力,以及在它们之间进行负载均衡的强大工具 但是,对于游戏服务器,这与我们想要的是直接相反的。游戏服务器通常在内存中维护有关玩家和游戏的状态数据,并且需要非常低的延迟连接以维持该状态与游戏客户端的同步性,以使玩家不会注意到延迟。因此,我们需要直接连接到游戏服务器,而无需任何中介,这会增加延迟,因为每一毫秒都很重要。

第一步是运行游戏服务器。每个实例都是有状态的,因此彼此不相同,因此我们不能像大多数无状态系统(例如 Web 服务器)那样使用 Deployment。相反,我们将依靠在 Kubernetes 上安装软件的最基本的构建模块 – Pod

Pod 只是一个或多个与某些共享资源(例如 IP 地址和端口空间)一起运行的容器。在这种特定情况下,每个 Pod 仅具有一个容器,因此,如果使事情更容易理解,只需在本文中将 Pod 视为软件容器的同义词即可。

直接连接到容器

通常,容器在自己的网络名称空间中运行,如果不做一些工作将运行容器中的开放端口转发给主机,则容器不能通过主机直接连接。在 Kubernetes 上运行容器也没有什么不同 —— 通常使用 Kubernetes 服务作为负载平衡器来公开一个或多个支持容器。然而,对于游戏服务器来说,这是行不通的,因为对网络流量的低延迟要求。

幸运的是,通过在配置 Pod 时将 hostNetwork 设置为 trueKubernetes 允许 Pod 直接使用主机网络名称空间。由于容器与主机在同一内核上运行,因此可以直接进行网络连接,而无需额外的延迟,这意味着我们可以直接连接到 Pod 所运行的机器的 IP,也可以直接连接到正在运行的容器。

虽然我的示例代码对 Kubernetes 进行了直接的 API 调用来创建 Pod,但通常的做法是将Pod 定义保存在 YAML 文件中,这些文件通过命令行工具 kubectl 发送到 Kubernetes 集群。下面是一个 YAML 文件的例子,它告诉 Kubernetes 为专用游戏服务器创建一个 Pod,这样我们就可以讨论更详细的细节了:

代码语言:javascript复制
apiVersion: v1
kind: Pod
metadata:
  generateName: "game-"
spec:
  hostNetwork: true
  restartPolicy: Never
  containers:
    - name: soccer-server
      image: gcr.io/soccer/soccer-server:0.1
      env:
        - name: SESSION_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name

让我们来分析一下:

  1. kind:告诉 Kubernetes 我们想要一个 Pod
  2. metadata > generateName:告诉 Kubernetes 在集群中为此 Pod 生成一个唯一的名称,其前缀为 “game-”
  3. spec > hostNetwork:由于将其设置为 true,因此 Pod 将在与主机相同的网络名称空间中运行。
  4. spec > restartPolicy:默认情况下,Kubernetes 将在容器崩溃时重新启动它。在这种情况下,我们不希望这种情况发生,因为我们在内存中有游戏状态,如果服务器崩溃了,我们就很难重新开始游戏。
  5. spec > containers > image:告诉 Kubernetes 将哪个容器镜像部署到 Pod。在这里,我们使用先前为专用游戏服务器创建的容器镜像。
  6. spec > containers > env > SESSION_NAME:我们将把 Pod 的集群唯一名称作为环境变量 SESSION_NAME 传递到容器中,稍后我们将使用它。这由 Kubernetes Downward API 提供支持。

如果我们使用 kubectl 命令行工具将该 YAML 文件部署到 Kubernetes,并且知道它将打开哪个端口,则可以使用命令行工具和/或 Kubernetes APIKubernetes 集群中查找它正在运行节点的 IP,并将其发送到游戏客户端,以便它可以直接连接!

由于我们也可以通过 Kubernetes API 创建 Pod,因此 Paddle Soccer 具有一个称为会话的游戏服务器管理系统,该系统具有/ create 处理程序,可以在 Kubernetes 上创建游戏服务器的新实例。调用时,它将使用上面的详细信息将游戏服务器创建为 Pod。然后,只要需要启动新的游戏服务器以允许两个玩家玩游戏,就可以通过配对服务调用该服务!

通过从生成的 Pod 名称中查找新 Pod,我们还可以使用内置的 Kubernetes API 来确定新 Pod 在集群中的哪个节点上。反过来,我们可以查找该节点的外部 IP,现在我们知道了要发送给游戏客户端的 IP 地址。

这已经为我们解决了一些问题:

  • 我们有一个预先构建的解决方案,用于通过容器镜像和 Kubernetes 将服务器部署到我们的机器集群中。
  • Kubernetes 管理整个群集中的游戏服务器的调度,而无需我们编写自己的 bin-packing 算法来优化资源使用。
  • 可以通过标准的 Docker / Kubernetes 机制部署新版本的游戏服务器;我们不需要自己编写。
  • 我们可以免费获得各种好处——从日志聚合到性能监视等等。
  • 我们不必编写太多代码来协调跨计算机集群的游戏服务器。

Port 管理

由于我们可能会在 Kubernetes 集群中的每个节点上运行多个专用游戏服务器,因此它们每个都需要自己的端口才能运行。不幸的是,Kubernetes 不能为我们提供帮助,但是解决这个问题并不是特别困难。

第一步是确定要让流量通过的端口范围。这使您的群集的网络规则变得更轻松(如果您不想即时添加/删除网络规则),但如果你的玩家需要在自己的网络上设置端口转发或类似的东西,这也会让事情变得更容易。

为了解决这个问题,我尽量让事情简单化:在创建我的 pod 时,我传递可以用作两个环境变量的端口范围,并让 Unity 专用服务器在该范围中随机选择一个值,直到它成功打开一个套接字。

您可以看到 Paddle Soccer Unity 游戏服务器正是这样做的:

代码语言:javascript复制
public static void Start(IUnityServer server)
{
    instance = new GameServer(server);
    for (var i = 0; i < maxStartRetries; i  )
    {
        // select a random port in a range, and set it
        instance.SelectPort();
        if (instance.server.StartServer())
        {
            instance.Register();
            return;
        }
    }
    throw new Exception(string.Format("Could not find port"));
}

每次对 SelectPort 的调用都会选择一个范围内的随机端口,该端口将在 StartServer 调用时打开。如果无法打开端口并启动服务器,则 StartServer 将返回 false

您可能还注意到对 instance.Register 的调用。这是因为 Kubernetes 并没有提供任何方法来检查该容器从哪个端口开始,所以我们需要编写自己的端口。为此,Paddle Soccer 游戏服务器管理器具有一个简单的/ register REST 端点,该端点由 Redis 支持用于存储,该端点具有Kubernetes 提供的 Pod 名称(我们通过环境变量进行传递),并存储服务器启动时使用的端口。它还提供了/ get端点,用于查找游戏服务器在哪个端口上启动。它已与创建游戏服务器的 REST 端点打包在一起,因此我们在 Kubernetes 中提供了一项用于管理游戏服务器的单一服务。

这是专用的游戏服务器注册代码:

代码语言:javascript复制
private void Register()
{
    var session = new Session
    {
        id = Environment.GetEnvironmentVariable("SESSION_NAME"),
        port = this.port
    };

    var host = "http://sessions/register";
    server.PostHTTP(host, JsonUtility.ToJson(session));
}

您可以看到游戏服务器在何处将环境变量 SESSION_NAME 与集群唯一的 Pod 名称一起使用,并将其与端口组合。然后,此组合作为 JSON 数据包发送到游戏服务器管理器的/ register 处理程序,即会话的/ register处理程序。

放在一起

如果我们将其与 Paddle Soccer 游戏客户端以及一个非常简单的 matchmaker 相结合,我们将得到以下结果:

  1. 一名玩家的客户端连接到 matchmaker 服务,但它什么也不做,因为它需要两名玩家来玩。
  2. 第二个玩家的客户端连接到 matchmaker 服务,matchmaker 服务决定它需要一个游戏服务器来连接这两个玩家,所以它向游戏服务器管理器发送一个请求。
  3. 游戏服务器管理器调用 Kubernetes API,以告知它在其中包含专用游戏服务器的集群中启动Pod
  4. 专用游戏服务器启动。
  5. 专用游戏服务器向游戏服务器管理器进行注册,并告知其开始在哪个端口上。
  6. 游戏服务器管理器从 Kubernetes 获取上述端口信息和 PodIP 信息,并将其传递回Matchmaker
  7. matchmaker 将端口和 IP 信息传回给两个玩家客户端。
  8. 客户端现在直接连接到专用游戏服务器,并玩游戏。

EtVoilà!(瞧)我们的集群中正在运行一个多人专用游戏!

在本例中,通过利用软件容器和 Kubernetes 的强大功能,相对少量的定制代码能够跨大型机器集群部署、创建和管理游戏服务器。老实说,容器和 Kubernetes 提供给您的功能非常强大!

代码语言:javascript复制

0 人点赞