国外手机交友APP Tinder向Kubernetes迁移的过程


【编者的话】这篇文章介绍了Tinder公司向Kubernetes转型的时候遇到的主要问题和解决方法。

为什么

大概两年前,Tinder决定转向Kubernetes。Kubernetes通过不可变部署推动Tinder Engine向容器化以及低接触式运维发展。应用程序的构建、部署以及基础架构都可以由代码定义。

我们还致力于解决扩展以及稳定性的挑战。当扩展变得至关重要时,我们通常在等待新的EC2实例上线的这几分钟里备受煎熬。容器能够在几秒中内,而不是几分钟,完成调度并且上线,这对于我们很有吸引力。

一切并不容易。2019年初的迁移里,我们的Kubernetes集群一团乱麻,并且遇到各种问题,流量,集群大小以及DNS等。我们解决了这些有意思的问题,迁移了200多个服务,并且运行了一个大规模的Kubernetes集群,一共有1000个节点,15000个pod以及48000个运行着的容器。

怎么做的

从2018年1月份起,我们就开始实验迁移的各个阶段了。首先将所有服务容器化,并且部署到一系列Kubernetes预生产环境上。从10月份起,我们开始系统性地将所有遗留服务迁移到Kubernetes上。第二年3月份,迁移工作结束,Tinder平台全都跑在了Kubernetes上。

为Kubernetes构建image

在Kubernetes集群里运行着超过30个微服务的源码仓库。这些仓库里的代码是不同语言编写的(比如,Node.js,Java,Scala,Go),同一种语言还有多个运行时环境。

build系统设计成可以为每个微服务做完整自定义的“build上下文”,通常包括Dockerfile以及一系列shell脚本。虽然内容都是全自定义的,这些build的上下文是遵循标准化的格式编写的。这样标准化的build上下文使得单个build系统可以处理所有的微服务。
1.png

图1-1 Builder容器的标准化构建流程

为了达到运行时环境的最大一致性,我们在开发和测试阶段使用相同的构建流程。当想设计一种方案来保证跨平台的build环境的一致性时,我们遇到了独特的问题。最终,所有build流程都在一个特别的“Builder”容器内执行。

Builder容器的实现要求一系列高级Docker技术。Builder容器继承本地user ID和secret(比如:SSH key, AWS认证等),因为需要访问Tinder的私有存储库。它mount了包含源码的本地目录来存储build artifact。该方案改进了性能,因为它不需要在Builder容器和宿主机之间拷贝build出来的artifact。无需任何配置,下次就可以重用存储好的build artifact。

对于某些服务来说,我们需要在Builder内创建另一个容器来实现编译环境和运行时环境的匹配(比如,安装Node.js bcrypt库生成平台特定的二进制artifact)。不同服务的编译需求可能并不相同,最终的Dockerfile是即时组装出来的。

Kubernetes集群架构和迁移

集群大小

我们决定使用kube-aws实现Amazon EC2实例上的自动化集群预配。之前,我们在一个通用节点池里运行所有东西,很快就发现需要将工作负载放到不同大小不同类型的实例上,才能最优化地使用资源。因为同时运行一些多线程的pod,和运行大量单线程Pod相比,能够得到更平滑的性能结果。

我们最后使用:
  • m5.4xlarge用于监控(Prometheus)
  • c5.4xlarge用于Node.js工作负载(单线程工作负载)
  • c5.2xlarge用于Java和Go(多线程工作负载)
  • c5.4xlarge用于控制平面(3个节点)


迁移

从遗留基础架构迁移到Kubernetes的准备步骤之一,是将已有的服务-服务的通信改为通过全新的Elastc Load Banlancer(ELB)的通信,ELB是在特定的虚拟私有云(VPC,Virtual Private Cloud)子网内创建的。该子网和Kubernetes VPC对等。这让我们可以逐步迁移模块,而无需考虑服务依赖的特定顺序。

这些端点使用weighted DNS记录集来创建,包含指向全新Kubernetes服务ELB的CNAME,weight为0。然后我们设置记录集的Time To Live(TTL)为0。随后慢慢调整weight值直至最终新服务器weight达到100%。直到这些都完成了,就可以将TTL调整为更合适的值。

我们的Java模块需要较低的DNS TTL,但是Node应用程序不需要。一名工程师重写了连接池的部分代码,将其封装进一个管理器,会每隔60秒刷新一次连接池。这在我们的场景里工作得很好,对性能没有太大影响。

学习

网络Fabric限制

在2019年1月8号的早晨,Tinder平台发生了一次宕机事件。为了解决之前的平台延迟增长,我们扩展了集群里的Pod和节点数量。这导致所有节点上的ARP缓存耗尽。

这是和ARP缓存相关的三个Linux值:
2.png


Credit

gc_thresh3是hard cap。如果看到日志里出现“neighbor table overflow”,这意味着即使在ARP缓存同步垃圾回收(GC)之后,也没有足够的空间存储neighbor entry。这时,kernel会直接彻底丢弃数据包。

我们使用Flannel作为Kubernetes的网络fabric。通过VXLAN转发数据包。VXLAN是三层网络上的二层overlay scheme。它使用MAC Address-in-User Datagram Protocol(MAC-in-UDP)封装提供扩展二层网络segment的方式。物理数据中心网络上的传输协议是IP+UDP。
3.png

图2-1 Flannel图示

4.jpeg

图2-2 VXLAN数据包

每个Kubernetes工作节点分配自己的/24虚拟地址空间。对于每个节点来说,有1个路由表entry,1个ARP表entry(在flannel.1接口上),以及1个转发数据库(FDB)entry。这些entry在worker节点第一次启动或者第一次发现新节点时添加

另外,节点-pod(或者pod-pod)通信最终经过eth0接口。对应每个相应的节点源和节点目的地,都会在ARP表里对应一条额外添加的entry。

在我们的环境里,这样的通信非常常见。对于Kubernetes服务对象,会创建一个ELB,Kubernetes将每个节点注册到ELB上。ELB不知道pod,被选中的节点也不一定是数据包的最终目的地。这是因为当节点从ELB接收到数据包时,它会评估自己服务的iptables规则,并且随机选择另一个节点上的pod。

宕机发生的时候,集群里有605个节点。由于上述原因,超过了默认的 gc_thresh3值。一旦这种情况发生,不仅数据包会被丢弃,而且整个Flannel /24的虚拟地址空间都会从ARP表里丢失。节点-pod通信和DNS查询都会失败。(DNS在集群内部,本文后面会更为详细地解释这里的细节。)

要解决问题,需要提高gc_thresh1gc_thresh2gc_thresh3的值,并且重启Flannel重新注册丢失的网络。

大规模集群里运行DNS

为了实现迁移,我们重度依赖于DNS来辅助流量整型,并且渐进地将服务从遗留系统引流到Kubernetes上。我们在相关的Route53 RecordSet上设置相对较低的TTL值。当在EC2实例上运行遗留基础架构时,解析器配置指向Amazon的DNS。这是个自然的选择,没有关注我们和亚马逊服务TTL设置相对较低时的成本。

随着越来越多的服务进入Kubernetes,我们发现自己运行的DNS服务每秒需要响应250,000次请求。在应用程序里,开始遇到时不时发生但影响挺大的DNS查询超时问题。即使尝试了很多调优的方法,并且将DNS供应商切换到CoreDNS部署上,它在峰值时会消耗120个核1000个pod,但是这个问题仍然会发生。

在研究其他可能的原因和解决方案时,我们发现了一篇文章,介绍了一种竞争条件,会影响Linux数据包过滤框架netfilter。我们遇到的DNS超时问题,伴随着Flannel接口上insert_failed次数的增加,和这篇文章的发现很一致。

这个问题发生在源和目标网络地址翻译(SNAT和DNAT)以及后续contrack表插入的过程中。一个内部讨论并且被社区推荐的workaround是将DNS移到worker节点本身上。这时:
  • SNAT不需要了,因为流量还在节点本地。不需要通过eth0接口传输。
  • DNAT不需要了,因为目标IP对于节点来说就是本地,并不需要通过iptables规则去随机选择。


我们决定按照这种方案执行。CoreDNS作为DaemonSet部署到Kubernetes上,并且通过配置kubelet-cluster-dns命令的参数,将节点本地DNS注入到每个节点的resolv.conf文件里。这个workaround对DNS超时问题非常有效。

但是,我们仍然观察到被丢弃的数据包,并且Flannel接口的insert_failed数还在增加。这在使用了上述workaround后仍然发生,因为这个workaround仅仅避免了DNS流量的SNAT和/或DNAT。对于其他类型的流量,竞争条件仍然存在。幸运的是,我们的数据包绝大部分是TCP,当竞争发生后,数据包可以被成功地重新传输。能够解决所有类型流量的长期解决方案仍在讨论之中。

使用Envoy实现更好的负载均衡

随着后台服务向Kubernetes的迁移,我们开始遇到Pod间负载不均衡的问题。我们发现因为Http的Keepalive,每次滚动部署时,ELB连接会卡在第一个ready的Pod上,因此绝大部分流量会流过一小部分可用的Pod。我们第一次迁移尝试在新部署里设置最差情况下使用100%MaxSurge。这非常高效,但是对于一些大型部署并不可持续。

我们使用的另一个缓解措施是人为地夸大关键服务的资源请求,以便其中的Pod与其他高负载Pod一起拥有更多的空间。这也不是很好,因为会浪费资源,我们的Node应用程序是单线程的,它在1个核的时候更高效。唯一有效的方案是使用更好的负载均衡技术。

我们调研了Envoy。我们在非常有限的范围内部署了Envoy并且得到了很好的效果。Envoy是开源的,高性能的7层代理,非常适合大型的面向服务的架构。它能够实现高级负载均衡技术,包括自动重试,断流以及全局限速。

最后我们在每个Pod里以sidecar模式部署Envoy,然后连接到本地容器端口。为了最大限度地减少潜在的级联并保持较小的爆破半径,我们使用了front-proxy Envoy Pod,每个服务每个可用Zone里部署一个。这就是一个小型的服务发现机制,对于给定服务返回每个AZ的Pod列表。

然后front-Envoys服务将这个服务发现机制和上游的集群和路由连接起来。我们配置了合理的timeout时间,增加了所有断流设置,然后加入了最小的重试设置来解决偶发的故障,让部署更为流畅。在这些front Envoy服务之前放置了TCP ELB。即使主要的front proxy层需要pin Envoy pod保持keepalive,它们仍然能够更好地处理负载,通过least_request配置后台的负载。

对于部署,我们在应用程序和sidecar pod上都使用了preStop
hook。这个hook调用sidecar健康检查失败的admin端点,sleep一小段之后,给些时间让正在进行的连接结束并耗尽。

能够进展如此迅速的一个原因是能够轻松地将丰富的metric和我们常规的Prometheus系统集成起来。这让我们在迭代配置中能够观测到到底发生了什么,并且截断流量。

结果很明显。一开始服务非常不均衡,现在集群里最重要的12个服务之前运行了这一系统。今年我们计划改进程全服务网格,提供更先进的服务发现,断流,异常值检测,限流和跟踪日志。
5.png

图 3-1 切换到Envoy时某服务的CPU收敛过程

使用envoy前的调用链
6.png


envoy服务调用链
7.png

结论

通过这些学习和研究,我们成长为一个强大的自研基础架构团队,非常熟悉如何设计,部署以及运维大型Kubernetes集群。Tinder的整个工程师团队现在都拥有了如何在Kubernetes上做容器化以及部署应用程序的知识和经验。

在我们的遗留基础架构上,当需要额外扩容时,通常需要等待几分钟新的EC2实例才能启动上线。现在容器调度并能承载流量只需要几秒钟。在单个EC2实例上调度多个容器还提供了更好的水平密度。最终,2019年里,我们比上一年节省了EC2上的花费。

整个过程花了大概2年,最终2019年3月份我们完成了迁移。Tinder Platform完全运行在Kubernetes集群里,这个集群包含200个服务,1,000个节点,15,000个Pod以及48,000个运行着的容器。

基础架构不再是运维团队的保留任务。相反,整个公司的工程师都在承担这个职责,并且任何事情都是代码,工程师们控制自己的应用程序如何构建及部署。

原文链接:Tinder’s move to Kubernetes(翻译:崔婧雯)
===========================
译者介绍
崔婧雯,现就职于IBM,高级软件工程师,负责IBM WebSphere业务流程管理软件的系统测试工作。曾就职于VMware从事桌面虚拟化产品的质量保证工作。对虚拟化,中间件技术,业务流程管理有浓厚的兴趣。

0 个评论

要回复文章请先登录注册