在上篇文章《【Agones系列】Agones初体验》中,我们部署了一个game server,gs自动获取了一个ADDRESS和PORT,这个地址和端口是从哪来的,是如何分配的呢?在这篇文章中我们来揭晓Agones game server的网络模式。
首先,Agones使用的是主机IP+端口转发的网络模式。之所以不使用负载均衡器是为了减少转发、降低网络延迟。同时,Agones为了pod具备独立的网络空间进而有能力运行sidecar容器,也没有使用hostnetwork,而是主机端口转发。Agones会分配一个主机端口给game server,game server的容器端口也会通过containerPort字段暴露,主机端口到容器端口的路由通常由主机的iptables或者ipvs进行,这具体取决于容器网络模型。
知道了Agones的网络原理,接下来我们来看地址与端口的分配逻辑。
Address
如上文提到,game server使用主机IP,它会从game server所在节点的k8s node对象中拿到对应的IP信息,具体逻辑如下:
// agones/pkg/gameservers/gameservers.go
func address(node *corev1.Node) (string, error) {
externalDNS := runtime.FeatureEnabled(runtime.NodeExternalDNS)
if externalDNS {
for _, a := range node.Status.Addresses {
if a.Type == corev1.NodeExternalDNS {
return a.Address, nil
}
}
}
for _, a := range node.Status.Addresses {
if a.Type == corev1.NodeExternalIP && net.ParseIP(a.Address) != nil {
return a.Address, nil
}
}
// There might not be a public DNS/IP, so fall back to the private DNS/IP
if externalDNS {
for _, a := range node.Status.Addresses {
if a.Type == corev1.NodeInternalDNS {
return a.Address, nil
}
}
}
for _, a := range node.Status.Addresses {
if a.Type == corev1.NodeInternalIP && net.ParseIP(a.Address) != nil {
return a.Address, nil
}
}
return "", errors.Errorf("Could not find an address for Node: %s", node.ObjectMeta.Name)
}
agones依次找node.Status.Address中type为ExternalDNS、ExternalIP、InternalDNS、InternalIP 的IP。在我们上篇文章的例子中使用的是节点的ExternalIP,即120.27.21.131
Port
我们重点来看一下端口是如何分配的。Agones使用pod所在主机的端口暴露给用户连接,所以我们看到gs的status字段中端口叫做HostPort。HostPort的分配有三种方式:1)Static,由用户定义暴露端口; 2)Dynamic,Agones会为gs选择一个开放的端口;3)Passthrough,将containerPort设置为HostPort。Agones使用一个PortAllocator进行端口分配。下面着重介绍一下PortAllocator
PortAllocator 是围绕缓存数据构建的分配逻辑,该缓存叫做portAllocations ,数据结构如下
portAllocations []map[int32]bool
它记录了集群node以及每个node对应端口是否被占用的情况。用下面一张表来解释会比较容易理解,Node 0 为该slice的第一个元素,包含了其开放的各个端口是否被gs占用。值得注意的是,这里的Node实际上是一个“逻辑节点”,并不能真正对应上集群中某一个节点,只是方便分配/回收端口而设置而已。因此,该缓存是一个slice,而并非记录了nodeName的map
在缓存这块,除了portAllocations之外,还有一个gameServerRegistry,它是map类型,key为gs的id,value为bool类型,表示该gs是否已经被登记过占用。
首先,在PortAllocator启动后会新进行初始化(func (pa *PortAllocator) syncAll() error)其主要目的是通过遍历node与gameserver,实现对portAllocations与gameServerRegistry 两种数据结构的初始构建。构建过程如下:
-
通过lister得到存量所有的node与gameserver
-
根据存量的node初始化一个 nodePortAllocation ,即map版的 portAllocations ,key为nodename;对应还有一个 nodePortCount ,记录每个node已经被占用多少个端口
-
遍历所有gameserver及其Ports字段,忽略type为Static的类型,将 gsRegistry 对应gs记为true,若gs存在对应nodename,则更新 nodePortAllocation ,将node对应端口记为true。同时该node的 nodePortCount 加一;若gs hostport已经存在但没有对应nodename,则用 nonReadyNodesPorts 记录这些端口号
-
根据 nodePortCount 对 nodePortAllocation 进行排序,端口占用多的节点在最前,得到一个slice,这也是 portAllocations 的雏形,也就是说 portAllocations 中index越小,其map value为true的越多(如上面表的例子所示,Node 0 相对 Node N,true的数量更多)
-
将 nonReadyNodesPorts 记录的这些端口号都添加到 portAllocations 中,按照从前往后的顺序,只要第一个node中对应的端口没有被占用,就把它置为true,代表这个端口被占用了。最终得到了 portAllocations 与 gameServerRegistry 。这样一来,既不会漏掉还未分配节点的hostport,也不会干扰按照节点端口占用多少顺序的逻辑。
构建了这两个缓存portAllocations与gameServerRegistry,再来看分配逻辑非常简单:按照即将要被分配的gs的ports数量,从portAllocations中按顺序找到对应数目的还未分配的端口赋值给gs对应的字段。与此同时将gameServerRegistry对应的gs置为true。
最后再看一下回收的逻辑:遍历gs中的hostPort,与分配一样,按照portAllocations顺序找到对应的口端号,将其改为false即可,最后将gameServerRegistry 对应gs一项删除。
整体看下来,我们可以从代码中窥看到PortAllocator的分配思想,1)它其实并不关注gs要分配的端口在哪个节点,只要知道它占用与否。所以没有必要用map增加检索的复杂度,从分配端口最多的“逻辑节点”上拿/放就可以了。 只要确保集群中某个具体数字的端口开放数量和表中对应端口为True数量一致即可。2)尽管与调度无关,端口分配依然会打散分配的端口号,尽量在集群中不出现过多数字一样开放端口。