(这篇可能希望可以长期更新,记录一下这个问题的解决过程,最终可以产生一个方案)
遗留的两个问题
目前游戏服务器框架是使用的Skynet,之前的所有开发设计都是基于单机情况下的,在配合了Gitlab-CI之后,已经可以非常爽快的使用了。代码合并进master分支之后,会被pipeline自动构建并部署到阿里云的测试服务器上。在年底的时候,因为终于有了一位正式的服务器程序( 雷迪 )入职,在做完框架交接之后,基本就是他直接和产品那边配合开发逻辑了,我也把精力放在了其他方面。
在当时我这边暂时脱离服务器开发的时候,实际上还有两个点不放心,或者说在正式上线前必须解决。一个是数据库,一个是集群。
目前的数据库连接方案是我基于Redis和MySQL,写了一层薄封装,称为Unistore。这层封装本质是是给MySQL实现了一套Redis接口,这样一来,Redis就完全变成了一个缓存,数据实际上都是存在MySQL里面的。做这件事主要还是上一篇日志描述的事情给我带来很大的鸭梨,觉得数据这种东西还是小心点好。不过因为实现得比较粗暴,写入的时候是直接往Redis和MySQL里同时阻塞写入,这样一来,MySQL的写入是大概率撑不住的。这一块我跟雷迪聊了想法,在年前的阶段他尝试了一下改进,目前应该已经是在使用新的方案了,这个下周抽空去找他聊下,作为评审和确定。
至于集群,虽然我很赞同云风的建议,如果靠单机加核数和内存就能解决的问题,是没有必要上集群的。在年前我也对现有的单机部门做了一些压测,情况总体看来还是很不错的,CPU和内存基本压力没有那么大,也很明显只要往上加,性能是可以对应堆上去的。遇到稍微麻烦的问题在于并发连接数,我们的连接用的是TCP,所以用户数和并发连接数成正比,这就是著名的C10K问题了,虽然说,目前应该C10K问题依旧被研究得比较清楚了,但是对于我这样的运维萌新来说还是挺一头雾水的,在研究了半天的Linux kernel参数之后,应该勉强是在两台PC的轰击之下保持了10000+的连接(使用ss -s查看),但是离预期的目标来说还是差挺大的,并且总归是要为未来的拓展做好准备,这样集群方案就是绕不开的问题了。
Docker 与 Kubernetes
最近一段时间,林峰的平台那边因为基本的需求都完成得差不多了,所以开始研究了一下Docker。说来惭愧,我一直觉得Docker应该是很确定的方向,但是几次尝试,都看得云里雾里。Docker本身还是挺好理解的,但是一涉及到实际生产方案,资料就十分匮乏,最终唯一的用途就是在Mac mini上面起了一个Container取代原本原生部署的Unity cache server,这种无状态缓存用Docker还是很爽的。这次林峰的成果大大超出我的预期,在本地使用Minikube进行尝试后,在内网服务器部署了一个IBM cloud private集群,算是对kubernetes的使用有了一个大概的了解,我看差不多可以用了,就开始怂恿他开始往生产环境上面试,于是在阿里云上面尝试了一番容器服务。
阿里云的容器服务Kubernetes版总共有四种,Kubernetes、Kubernetes托管版,多可用区Kubernetes、Serverless Kubernetes。Kubernets和多可用区Kubernetes基本一样,最正常的方案,就是用至少3台ECS作为Master节点,然后加入Worker。托管版就是不需要自己买Master,只需要配置Worker。而Serverless Kubernetes就更厉害啦,连Worker都不用买,直接也被托管了。我们兴趣最大也最先尝试的就是Serverless Kubernetes,完全无视底层虚拟机,完全面向容器,多么令人开心的方案。结果,试了半天,helloworld都跑不通,直接报错无法拉取镜像。无奈只能工单咨询,结果答曰:Serverless Kubernetes的Worker没有公网访问权限,所以无法访问 Docker hub……这我可是头一回听说,我买台云服务器还有不能上网的吗?我又不需要公网访问……仔细研究了一番才明白,VPC还真不能上网,之前我买的几台可以上网是因为我分配了公网IP,如果只是内网机的话,不仅仅是外面访问不进来,里面也访问不出去。这下开心了,Serverless Kubernetes的Worker又不是我的,我也无权分配公网IP,要想上网只能买个NAT服务,价格嘛,12块钱一天……我说就为了拉个镜像至于么,于是开始找寻方法,找了半天还真给我找到了,原来只需要配置一下路由表,把0.0.0.0/0的下一跳指向一台可以联网的ECS,然后在这台ECS上开启ipv4 redirect转发即可,啊,第一次感受到了SNI的强大。配置好之后终于可以拉取镜像部署了。经过一番试用,最终还是放弃了,Serverless Kubernetes的运行本质上还是依赖了弹性容器实例 ECI,但是不知道为什么,阿里云设定每个pod至少分配2核4G内存,这对我们现在的测试实在是太不友好啦(穷),再考虑到方案强依赖云服务商的特定服务也是我一直很不喜欢的,于是出门右转开始尝试Kubernetes托管版。中途解决了各种奇奇怪怪的问题,最终效果非常令人满意,搭配上完整的弹性伸缩服务,现在整个集群都可以根据负载自动伸缩,负载高了,自动加入一台新的ECS作为节点,而ECS我也发现了抢占式实例这个性价比神器,只要不限制最高价格,也不用担心会被回收,平均只需要正常按量计费的1/6价格,目前是用了两台2核8G的ECS测试,每小时只需要2毛多。简直让我产生了在玩异星工厂的爽感。
在这个过程中,我也大概了解了Kubernetes集群和服务架构的一些逻辑,真的是非常方便,怪不得大家都在容器化,这才是真正的DevOps。既然容器这么好,那游戏服务器这边,怎么搞也得上一下吧?于是就产生了这篇日志要讨论的问题。
Web 项目与游戏服务器集群的差异
目前在Web项目中,使用微服务架构已经成为标准做法,简单来说,就是让所有的接口都成为无状态的,那么服务就可以近乎无限的横向拓展,而微服务所依赖的服务发现与负载均衡网关,则为快速拓展带来了非常大的方便。游戏服务器从本质上来说和Web服务器其实并没有非常大的区别,主要的差异大概列一下是以下几点:
- 游戏不可能做到完全无状态,主要是性能方面考虑。所有数据都只存在数据库或者某个中心节点是几乎不可能的。一个网页,点击一下链接,花费一秒钟打开你可能还觉得挺快的,而游戏你滑动一下摇杆,过一秒角色才动,可能你就想砸电脑了。
- 游戏的通讯协议一般是直接走TCP或者封装一下TCP on UDP,不太会(也不是没有)走HTTP协议,在频繁通讯的情况下每个HTTP请求都要重新建立TCP连接,每个HTTP包还要带上那么一大坨额外的数据,时间和带宽开销有相对较大,就算考虑到keep-alive可以复用连接,二者的性能还是有明显差距的。(最近看到从Google的QUIC项目升级而来的HTTP/3,是基于UDP的,非常有意思)
- 游戏服务器没有类似Spring Cloud这样的成熟方案……
这两(三)个问题合并在一起,就让我犯难了。理论上说,我当然希望所有的服务是无状态的,那么TCP也就没关系了,我随便连谁都行。如果有状态的话,我就必须保证同一个用户每次链接的服务都是同一个,这样势必导致需要给每个服务都开放公网连接端口,这些端口还必须有办法让用户知道。如果考虑Kubernetes集群就更完了,每个Container都需要对外暴露端口,这简直没法做了。
Frontd
想了两天(开始写文章的时候是第一天晚上),偶然看到了很久以前其实就看到过的东西……结果豁然开朗
https://github.com/xindong/frontd
感谢沈老师,感谢达达,没想到竟然是这么这么熟悉的大佬们帮我解惑了,解决方案也很容易,只是在出口架设一个网关,每次连接上去以后,先跟他说你要找谁,网关就帮你转发了……理论上和HTTP是一个逻辑。
有些时候,解决方案不是你想不到,是不敢想。
于是周一很开心的写了一天的Go……把这个三年前的项目重新整理了一下,替换了一些依赖,并基于Alpine重新build了镜像,让镜像的体积从257M减小到了7M(多阶段构建大法好!!!)
今天呢,则是build了一天的项目镜像……前面花了一半的时间企图继续使用Alpine打包,结果最后发现Jemalloc不兼容……GG
然后又转而使用Ubuntu,也遇到很多问题,一一解决。镜像传参问题也想到了一个比较好的办法,算是搞定了基本的镜像问题,standalone模式也可以顺利运行了。
明天开始,来解决服务发现问题,并且需要重新改写客户端那边基于frontd网关的连接。
服务发现
服务发现问题解决的比较顺利,除了中途差点操作失误丢失了全部的工作成果,最终竟然是从之前打好的docker image里面把脚本都找回来了……简直万幸,这个故事告诉我们,找回 git 中 staged 的变更只存在理论可能,实际操作中实在太麻烦了……
服务发现本质上要解决的问题是在所有节点共享一份地址簿,当有节点加入或退出的时候,需要根据实际情况实时更新。所有无状态节点都是走Kubernetes服务的,直接通过服务名称即可访问,也是保证高可用的,这部分节点的伸缩,对于Pod外都是完全透明的,不需要动态注册与反注册,只需要订阅地址簿的更新即可。而有状态的节点(比如游戏服务器),在集群中应该是在无头服务的后面,必须要指定到具体的Pod才可以访问,所以这类节点不仅需要订阅更新,也需要动态注册自己。
具体的实现方式是这样的,首先有一个单独的节点作为服务发现的管理节点,称为cluster_manager(暂时这就是一个节点,如果做成高可用就需要解决多节点的数据一致性问题,比较麻烦,这个节点的功能也比较单一,应该不会出现严重的问题,后续有机会可以加强这里的健壮性),其他每个节点都有一个对应的服务发现服务,称为cluster_client。所有节点在启动的时候,都必须先启动cluster_client服务,这个服务会自动向cluster_manager订阅更新。需要注册的节点在订阅之后可以调用接口进行注册,注册成功,则会获得一份地址簿用于更新。后续的更新都会通过之前的订阅自动完成。
成功注册之后那就有个节点下线的时候的反注册问题。正常的下线反注册很简单,就是一个接口调用的问题,但是异常状态下,比如崩溃或者网络连接中断的情况,就必须及时的把这个节点下线。要解决这个问题,就需要一个健康检查的功能,一般的HTTP服务需要维持心跳来刷“存在感”,Skynet使用的是TCP,云风大佬在一个Issue里面给出了一个很好的办法,套用过来就是说,cluster_manager服务在接收到注册后,就会给cluster_client发送一个请求,正常情况下cluster_client不需要回应,这样,当这个节点中断的时候,cluster_manager就会收到一个error,从而得知节点状态异常。顺带着,我把login service到game service的健康检查也顺便做了一下,这样当game service断开的时候,login service就可以及时了解,从而防止推荐不存在的节点给用户。
网关模式的实现
frontd网关模式的实现……就是一把辛酸泪了,理论上是很简单清晰的,泪点都在具体实现上。首先,我研究了很久都没想通为什么frontd连接成功之后不会回应一个状态码,这样的话错误码就无从接收了呀,你接收4个字节,一看不是错误码,哦,那说明连上了,那收到的4个字节怎么办!你还能还给socket不成……或者万一正确的消息恰好就是状态码的一种呢?最后实在无解了,就还是给网关改写了这块功能,也修改了一下相应的协议。
然后呢,frontd的加密使用的是AES算法,具体的应用标准则是基于openssl,这里并没有详细的文档说明到底是怎样得到的最后的base64编码的结果,我所能依赖的只有1、源码中引用的一个叫做go-oepnssl的库;2、文档中给出的一个在线加密网站;3、openssl的官方实现。第一个库基本就不用看了,整个一挂羊头卖狗肉,就单单实现了一个加密也好意思叫go-openssl?而在线加密网站完全只说用了Crypto-js,其他的一概不知,最终我能依靠的就只有openssl了。以下省略N个小时(包括一通宵),之后,终于搞出一个Lua绑定的C库用来生成加密结果了。后续的工作主要就是客户端的对接,顺便解决了一下最近发现的,shell被强制结束后,Skynet进程会成为孤儿进程的问题。一开始我假设了一堆原因,顺便吧UNIX的signal机制好好学习了一番,最后怎么看怎么不对,这为什么会结束不掉呢?苦思许久后灵光一闪,日志服务的logrotate信号好像就是SIGHUP,看了一下果然是的。为了不改Skynet库代码,我在自己实现的日志模块中把Skynet注册的SIGHUP信号恢复为默认了,并重新注册了SIGUSR1信号作为代替。
至此,集群方案的开发部分就已经差不多了,后续的工作主要是尝试在Kubernetes中部署,并合并代码,将现有的客户端通讯替换为集群方案。
想想觉得很有意思,不懂的时候遇到觉得无比巨难的问题,在解决了以后就好像是理所当然的了。人的进步不正式基于此吗:)
最后
提两个小点:
- 开始没发现网关方法的时候,为了对外暴露的接口有限,我尝试找寻了一些方法,其中一个叫做LO_REUSEPORT的套接字选项成功吸引了我的注意,网络上所有的文章都把我说的云里雾里,实际尝试确实可以让端口被重复监听,但是会导致收到的消息完全混乱。最终,我还是老老实实去翻了《UNIX 网络编程》,解释得清清楚楚,希望后面有对这个选项感兴趣的同学可以直接去看书……简单来说就是这个选项只有UDP通讯的时候需要启用,TCP绝对不要开就对了。
- 看书的时候顺便吧IPv4协议部分又复习了一遍,发现了一个有趣的点,原来127.0.0.1/24(即 127.0.0.0 到 127.255.255.255 )全都是本地环回接口,我实际试了一下,在Windows上,除了127.255.255.255(广播地址)以外,确实都是可以ping通的。这个小知识在当天就派上了用场,我在本地单机尝试的时候,希望和集群保持一致的端口,但是端口不能重复监听,怎么办呢?好办,通常我们都是会开启LO_REUSEADDR的,这样我们在本地只要监听不同的本地环回地址,就可以使用相同的端口的,我们还能在hosts中配置对应的hostname,这样就非常开心了,本地和集群配置完全一致。