原创科普整理
- 油管视频:GCAP 2017: Scaling Multiplayer Games with Open Source
- https://www.youtube.com/watch?v=a08WrvIPKMw
- Scaling Dedicated Game Servers with Kubernetes: Part 4 – Scaling Down
- https://www.compoundtheory.com/scaling-dedicated-game-servers-with-kubernetes-part-4-scaling-down/
- 示例项目:paddle-soccer
- https://github.com/markmandel/paddle-soccer
在前三篇文章中,我们将游戏服务器托管在 Kubernetes
上,测量并限制它们的资源使用,并根据使用情况扩大集群中的节点。现在我们需要解决更困难的问题:当资源不再被使用时,缩小集群中的节点,同时确保正在进行的游戏在节点被删除时不会中断。
从表面上看,按比例缩小集群中的节点似乎特别复杂。每个游戏服务器具有当前游戏的内存状态,并且多个游戏客户端连接到玩游戏的单个游戏服务器。删除任意节点可能会断开活动玩家的连接,这会使他们生气!因此,只有在节点没有专用游戏服务器的情况下,我们才能从集群中删除节点。
这意味着,如果您运行在谷歌 Kubernetes Engine (GKE)
或类似的平台上,就不能使用托管的自动缩放系统。引用 GKE autoscaler
的文档“ Cluster autoscaler
假设所有复制的 pod
都可以在其他节点上重新启动……” — 这在我们的例子中绝对不起作用,因为它可以很容易地删除那些有活跃玩家的节点。
也就是说,当我们更仔细地研究这种情况时,我们会发现我们可以将其分解为三个独立的策略,当这些策略结合在一起时,我们就可以将问题缩小成一个可管理的问题,我们可以自己执行:
- 将游戏服务器组合在一起,以避免整个集群的碎片化
- 当
CPU
容量超过配置的缓冲区时,封锁节点 - 一旦节点上的所有游戏退出,就从集群中删除被封锁的节点
让我们看一下每个细节。
系列
- 探索使用 Kubernetes 扩展专用游戏服务器:第1部分-容器化和部署
- 探索使用Kubernetes扩展专用游戏服务器:第2部分-管理CPU和内存
- 探索使用Kubernetes扩展专用游戏服务器:第3部分 - 扩展节点
在集群中将游戏服务器分组在一起
我们想要避免集群中游戏服务器的碎片化,这样我们就不会在多个节点上运行一个任性的小游戏服务器集,这将防止这些节点被关闭和回收它们的资源。
这意味着我们不希望有一个调度模式在整个集群的随机节点上创建游戏服务器 Pod
,如下所示:
而是我们想让我们的游戏服务器pod安排得尽可能紧凑,像这样:
要将我们的游戏服务器分组在一起,我们可以利用带有 PreferredDuringSchedulingIgnoredDuringExecution
选项的 Kubernetes Pod PodAffinity
配置。
这使我们能够告诉 Pods
我们更喜欢按它们当前所在的节点的主机名对它们进行分组,这实质上意味着 Kubernetes
将更喜欢将专用的游戏服务器 Pod
放置在已经具有专用游戏服务器的节点上(上面已经有 Pod
了)。
在理想情况下,我们希望在拥有最专用游戏服务器 Pod
的节点上调度专用游戏服务器 Pod
,只要该节点还有足够的空闲 CPU
资源。如果我们想为 Kubernetes
编写自己的自定义调度程序,我们当然可以这样做,但为了保持演示简单,我们将坚持使用 PodAffinity
解决方案。也就是说,当我们考虑到我们的游戏长度很短,并且我们将很快添加(and explaining
)封锁节点时,这种技术组合已经足够满足我们的需求,并且消除了我们编写额外复杂代码的需要。
当我们将 PodAffinity
配置添加到前一篇文章的配置时,我们得到以下内容,它告诉 Kubernetes
在可能的情况下将带有标签 sessions: game
的 pod
放置在彼此相同的节点上。
apiVersion: v1
kind: Pod
metadata:
generateName: "game-"
spec:
hostNetwork: true
restartPolicy: Never
nodeSelector:
role: game-server
containers:
- name: soccer-server
image: gcr.io/soccer/soccer-server:0.1
env:
- name: SESSION_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
limits:
cpu: "0.1"
affinity:
podAffinity: # group game server Pods
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
sessions: game
topologyKey: kubernetes.io/hostname
复制
封锁节点
现在我们已经把我们的游戏服务器很好地打包在一起了,我们可以讨论“封锁节点”了。“封锁节点”到底是什么意思?很简单,Kubernetes
让我们能够告诉调度器:“嘿,调度器,不要在这个节点上调度任何新东西”。这将确保该节点上不会调度新的 pod
。事实上,在 Kubernetes
文档的某些地方,这被简单地称为标记节点不可调度。
在下面的代码中,如果您专注于 s.bufferCount < available
,您将看到,如果当前拥有的 CPU
缓冲区的数量大于我们所需要的数量,我们将向警戒节点发出请求。
// scale scales nodes up and down, depending on CPU constraints
// this includes adding nodes, cordoning them as well as deleting them
func (s Server) scaleNodes() error {
nl, err := s.newNodeList()
if err != nil {
return err
}
available := nl.cpuRequestsAvailable()
if available < s.bufferCount {
finished, err := s.uncordonNodes(nl, s.bufferCount-available)
// short circuit if uncordoning means we have enough buffer now
if err != nil || finished {
return err
}
nl, err := s.newNodeList()
if err != nil {
return err
}
// recalculate
available = nl.cpuRequestsAvailable()
err = s.increaseNodes(nl, s.bufferCount-available)
if err != nil {
return err
}
} else if s.bufferCount < available {
err := s.cordonNodes(nl, available-s.bufferCount)
if err != nil {
return err
}
}
return s.deleteCordonedNodes()
}
复制
从上面的代码中还可以看到,如果我们降到配置的 CPU
缓冲区以下,则可以取消集群中任何可用的封闭节点的约束。这比添加一个全新的节点要快,因此在从头开始添加全新的节点之前,请先检查受约束的节点,这一点很重要。由于这个原因,我们还配置了删除隔离节点的时间延迟,以限制不必要地在集群中创建和删除节点时的抖动。
这是一个很好的开始。但是,当我们要封锁节点时,我们只希望封锁其上具有最少数量的游戏服务器 Pod
的节点,因为在这种情况下,随着游戏会话的结束,它们最有可能先清空。
得益于 Kubernetes API
,计算每个节点上的游戏服务器 Pod
的数量并按升序对其进行排序相对容易。从那里,我们可以算术确定如果我们封锁每个可用节点,是否仍保持在所需的 CPU
缓冲区上方。如果是这样,我们可以安全地封锁这些节点。
// cordonNodes decrease the number of available nodes by the given number of cpu blocks (but not over),
// but cordoning those nodes that have the least number of games currently on them
func (s Server) cordonNodes(nl *nodeList, gameNumber int64) error {
// … removed some input validation ...
// how many nodes (n) do we have to delete such that we are cordoning no more
// than the gameNumber
capacity := nl.nodes.Items[0].Status.Capacity[v1.ResourceCPU] //assuming all nodes are the same
cpuRequest := gameNumber * s.cpuRequest
diff := int64(math.Floor(float64(cpuRequest) / float64(capacity.MilliValue())))
if diff <= 0 {
log.Print("[Info][CordonNodes] No nodes to be cordoned.")
return nil
}
log.Printf("[Info][CordonNodes] Cordoning %v nodes", diff)
// sort the nodes, such that the one with the least number of games are first
nodes := nl.nodes.Items
sort.Slice(nodes, func(i, j int) bool {
return len(nl.nodePods(nodes[i]).Items) < len(nl.nodePods(nodes[j]).Items)
})
// grab the first n number of them
cNodes := nodes[0:diff]
// cordon them all
for _, n := range cNodes {
log.Printf("[Info][CordonNodes] Cordoning node: %v", n.Name)
err := s.cordon(&n, true)
if err != nil {
return err
}
}
return nil
}
复制
从集群中删除节点
现在我们的集群中的节点已经被封锁,这只是一个等待,直到被封锁的节点上没有游戏服务器 Pod
为止,然后再删除它。下面的代码还确保节点数永远不会低于配置的最小值,这是集群容量的良好基线。
您可以在下面的代码中看到这一点:
// deleteCordonedNodes will delete a cordoned node if it
// the time since it was cordoned has expired
func (s Server) deleteCordonedNodes() error {
nl, err := s.newNodeList()
if err != nil {
return err
}
l := int64(len(nl.nodes.Items))
if l <= s.minNodeNumber {
log.Print("[Info][deleteCordonedNodes] Already at minimum node count. exiting")
return nil
}
var dn []v1.Node
for _, n := range nl.cordonedNodes() {
ct, err := cordonTimestamp(n)
if err != nil {
return err
}
pl := nl.nodePods(n)
// if no game session pods && if they have passed expiry, then delete them
if len(filterGameSessionPods(pl.Items)) == 0 && ct.Add(s.shutdown).Before(s.clock.Now()) {
err := s.cs.CoreV1().Nodes().Delete(n.Name, nil)
if err != nil {
return errors.Wrapf(err, "Error deleting cordoned node: %v", n.Name)
}
dn = append(dn, n)
// don't delete more nodes than the minimum number set
if l--; l <= s.minNodeNumber {
break
}
}
}
return s.nodePool.DeleteNodes(dn)
}
复制