Why
大约两年前,Tinder决定将其平台移至Kubernetes。 Kubernetes为我们提供了一个通过不变的部署推动Tinder Engineering朝着容器化和少运维的方向发展的机遇。应用程序的构建,部署和基础结构将定义为代码。
我们还希望解决规模和稳定性方面的挑战。当扩展变得至关重要时,我们常常要等待几分钟才能等待新的EC2实例上线。容器在数秒而不是数分钟内调度和服务流量的想法吸引了我们。
这并不容易。在2019年初的迁移过程中,我们在Kubernetes集群中达到了临界规模,并且由于流量,集群规模和DNS而开始遇到各种挑战。我们解决了迁移200个服务并运行Kubernetes集群的有趣挑战,该集群的规模总计为1,000个节点,15,000个Pod和48,000个正在运行的容器。
How
从2018年1月开始,我们逐步完成了迁移工作的各个阶段。我们首先将所有服务容器化并将它们部署到一系列Kubernetes托管的各种环境中。从10月开始,我们开始有条不紊地将所有旧版服务迁移到Kubernetes。次年3月,我们完成了迁移,Tinder平台现在仅在Kubernetes上运行。
为Kubernetes构建镜像
Kubernetes集群中运行的微服务有30多个源代码存储库。这些存储库中的代码以不同的语言(例如,Node.js,Java,Scala,Go)编写,并且具有针对同一语言的多个运行时环境。
该构建系统旨在针对每个微服务在完全可定制的“构建上下文”上运行,该微服务通常由Dockerfile和一系列Shell命令组成。尽管它们的内容是完全可定制的,但是这些构建上下文都是按照标准格式编写的。构建上下文的标准化允许单个构建系统处理所有微服务。
为了在运行时环境之间实现最大的一致性,在开发和测试阶段使用了相同的构建过程。当我们需要设计一种方法来保证整个平台上一致的构建环境时,这就提出了一个独特的挑战。结果,所有构建过程都在一个特殊的“ Builder”容器中执行。
Builder容器的实现需要许多高级Docker技术。此Builder容器集成了访问Tinder私有存储库所需的本地用户ID和秘钥(例如SSH密钥,AWS凭据等)。它挂载包含源代码的本地目录,以自然方式存储构建工件。这种方法提高了性能,因为它消除了在Builder容器和主机之间复制构建的工件的麻烦。下次无需进一步配置即可再次使用存储的构建工件。
对于某些服务,我们需要在Builder中创建另一个容器,以使编译时环境与运行时环境匹配(例如,安装Node.js bcrypt库会生成特定于平台的二进制工件)。各个服务之间的编译时要求可能有所不同,最终的Dockerfile是动态生成的。
Kubernetes集群架构和迁移
Cluster 大小
我们决定使用kube-aws在Amazon EC2实例上进行自动集群配置。早期,我们在一个通用节点池中运行所有内容。我们迅速确定了将工作负载分成不同大小和类型的实例的需求,以便更好地利用资源。这样做的理由是,与让它们与大量单线程Pod共存相比,将更少的高线程Pod一起运行可以为我们带来更多可预测的性能结果。
我们决定:
- m5.4xlarge for monitoring (Prometheus)
- c5.4xlarge for Node.js workload (single-threaded workload)
- c5.2xlarge for Java and Go (multi-threaded workload)
- c5.4xlarge for the control plane (3 nodes)
迁移
从我们的旧基础架构迁移到Kubernetes的准备步骤之一是更改现有的服务到服务通信,以指向在特定的虚拟私有云(VPC)子网中创建的新的Elastic Load Balancer(ELB)。该子网已与Kubernetes VPC对等。这使我们可以不依赖于服务依赖关系的特定顺序而细粒度地迁移模块。
这些端点是使用加权DNS记录集创建的,该记录集的CNAME指向每个新的ELB。为了进行切换,我们添加了一条新记录,指向权重为0的新Kubernetes服务ELB。然后,将记录上的生存时间(TTL)设置为0。然后将新旧权重缓慢调整为最终在新服务器上获得100%的流量。转换完成后,将TTL设置为更合理的值。
我们的Java模块使用低DNS TTL,但我们的Node应用程序则不这样做。我们的一位工程师重写了部分连接池代码,以将其包装在管理器中,该管理器每60秒刷新一次连接池。这对我们来说非常有效,而性能没有明显下降。
坑
网络结构限制
在2019年1月8日凌晨,Tinder的平台持续停机。为响应当天凌晨平台延迟的增加,在群集上扩展了pod和节点数。这导致我们所有节点上的ARP缓存耗尽。
有三个与ARP缓存相关的Linux值:
gc_thresh3是一个硬上限。如果您收到“neighbor table overflow”日志记录,则表明即使在ARP缓存的同步垃圾回收(GC)之后,也没有足够的空间来存储 neighbor 记录 。在这种情况下,内核只会完全丢弃数据包。
我们使用Flannel作为Kubernetes中的网络结构。数据包通过VXLAN转发。 VXLAN是第3层网络上的第2层覆盖方案。它使用MAC地址用户数据报协议(MAC-UDP)封装来提供扩展第2层网络段的方法。物理数据中心网络上的传输协议为IP加UDP。
每个Kubernetes工作节点从一个较大的/9块中分配自己的/24虚拟地址空间。对于每个节点,这将导致1个路由表条目,1个ARP表条目(在flannel.1接口上)和1个转发数据库(FDB)条目。这些是在工作程序节点首次启动时或在发现每个新节点时添加的。
此外,节点到Pod(或Pod到Pod)的通信最终会流经eth0接口(如上面的Flannel图所示)。这将在ARP表中为每个相应的节点源和节点目的地添加一个附加条目。
在我们的环境中,这种通信非常普遍。对于我们的Kubernetes服务对象,将创建一个ELB,Kubernetes将向ELB注册每个节点。 ELB不支持Pod,并且所选节点可能不是数据包的最终目的地。这是因为当节点从ELB接收到数据包时,它将评估其iptables规则以获取服务,并随机选择另一个节点上的Pod。
中断时,群集中共有605个节点。由于上述原因,这将超出默认的gc_thresh3值。一旦发生这种情况,不仅会丢弃数据包,还会从ARP表中丢失整个Flannel/24虚拟地址空间。节点到Pod通讯和DNS查找失败。 (DNS托管在群集中,这将在本文稍后详细说明。)
要解决此问题,将提高gc_thresh1,gc_thresh2和gc_thresh3的值,并且必须重新启动Flannel以重新注册丢失的网络。
预料之外的coredns的scale
为了适应我们的迁移,我们大量利用DNS来促进流量整形和从旧版到Kubernetes的增量切换,以提供服务。我们在关联的Route53记录集上设置相对较低的TTL值。当我们在EC2实例上运行传统基础架构时,我们的解析器配置指向Amazon的DNS。我们认为这是理所当然的,我们的服务和Amazon服务(例如DynamoDB)的TTL相对较低的成本在很大程度上没有引起注意。
随着我们为Kubernetes加载越来越多的服务,我们发现自己正在运行DNS服务,该服务每秒可响应250,000个请求。我们在应用程序中遇到了间歇性且影响深远的DNS查找超时。尽管进行了详尽的调优工作,并且DNS提供商切换到了CoreDNS部署,该部署一次达到了1,000个Pod,消耗了120个内核,但还是发生了这种情况。
在研究其他可能的原因和解决方案时,我们发现了一篇描述影响Linux数据包过滤框架netfilter的竞争条件的文章。我们看到的DNS超时以及Flannel接口上增加的insert_failed计数器与本文的发现保持一致。
在源和目标网络地址转换(SNAT和DNAT)以及随后插入conntrack表期间,会发生此问题。内部讨论并由社区提出的一种解决方法是将DNS移到工作程序节点本身。在这种情况下:
- 不需要SNAT,因为流量在本地驻留在节点上。不需要通过eth0接口进行传输。
- 不需要DNAT,因为目标IP在节点本地,而不是根据iptables规则随机选择的Pod。
我们决定继续采用这种方法。 CoreDNS在Kubernetes中作为DaemonSet部署,我们通过配置kubelet-cluster-dns命令标志将节点的本地DNS服务器注入到每个Pod的resolv.conf中。解决方法对于DNS超时有效。
但是,我们仍然看到丢包,并且Flannel接口的insert_failed计数器增加。即使在上述解决方法之后,这种情况仍将持续,因为我们仅避免了DNS流量使用SNAT和/或DNAT。对于其他类型的流量,竞争条件仍然会发生。幸运的是,我们的大多数数据包都是TCP,并且在出现这种情况时,数据包将被成功地重新传输。我们仍在讨论针对所有类型流量的长期解决方案。
使用Envoy实现更好的负载平衡
当我们将后端服务迁移到Kubernetes时,我们开始遭受pod负载不平衡的困扰。我们发现由于HTTP Keepalive,ELB连接卡在每个滚动部署的第一个就绪Pod中,因此大多数流量流经一小部分可用Pod。我们尝试的首批缓解措施之一是针对最严重的违规者在新部署中使用100%MaxSurge。在某些较大型的部署中,这是微不足道的有效措施,并且不能长期持续。
我们使用的另一个缓解措施是人为地增加关键服务上的资源请求,以使共置的Pod与其他笨重的Pod相比具有更大的资源。从长远来看,由于资源浪费,这也不会成立,我们的Node应用程序是单线程的,因此实际上限制在1个内核上。唯一明确的解决方案是利用更好的负载平衡。
我们一直在内部评估Envoy。这为我们提供了以非常有限的方式部署它并获得直接收益的机会。 Envoy是为大型面向服务的体系结构而设计的开源,高性能第7层代理。它能够实现高级的负载平衡技术,包括自动重试,断路和全局速率限制。
我们想到的配置是,在每个Pod中有一个Envoy sidecar,该Pod具有一条路由和集群以将流量导向本地容器端口。为了最大程度地减少潜在的级联并保持较小的爆炸半径,我们使用了一组前置代理Envoy Pod,在每个可用区(AZ)中为每种服务部署了一个。这是一种新的服务发现机制,该机制仅返回了每个AZ中给定服务的Pod列表。
然后,服务前置Envoy将这种服务发现机制与一个上游群集和路由一起使用。我们配置了合理的超时时间,提高了所有断路器设置,然后进行了最小限度的重试配置,以帮助解决瞬态故障和平稳部署。我们在每一个前端Envoy服务中都使用TCP ELB。即使来自我们的主要前端代理层的keepalive固定在某些Envoy容器上,它们也能够更好地处理负载,并配置为通过minimum_request平衡到后端。
对于部署,我们在应用程序和sidecar pod上都使用了preStop挂钩。该钩子调用sidecar健康监测失败管理端点,并带有少量sleep,以留出一些时间来允许机上连接完成并耗尽。
我们之所以能够如此迅速地迁移的原因之一是由于我们能够轻松地与常规Prometheus设置集成的丰富指标。这使我们可以准确地了解在迭代配置设置并减少流量时发生的情况。
结果是立即而明显的。我们从最不平衡的服务开始,到现在为止,它在集群中最重要的十二个服务之前运行。今年,我们计划迁移到具有更多高级服务发现,断路,异常检测,速率限制和跟踪功能的全服务网格。
下图是切换Envoy前后服务CPU占用变化:
最终结果
通过这些学习和其他研究,我们已经建立了强大的内部基础架构团队,对如何设计,部署和操作大型Kubernetes集群非常熟悉。 Tinder的整个工程组织现在拥有如何在Kubernetes上容器化和部署其应用程序的知识和经验。
在我们的传统基础架构上,当需要进一步扩展时,我们常常要等待几分钟才能等待新的EC2实例上线。现在,容器可以在数秒而不是数分钟内调度和服务流量。在单个EC2实例上调度多个容器还可以提高水平密度。因此,我们预计2019年EC2与上一年相比将节省大量成本。
它花了将近两年的时间,但我们于2019年3月完成了迁移。Tinder平台仅在Kubernetes集群上运行,该集群包含200个服务,1,000个节点,15,000个Pod和48,000个运行中的容器。基础架构不再是我们运营团队的任务。取而代之的是,整个组织中的工程师共同承担这项责任,并控制如何使用所有内容作为代码来构建和部署其应用程序。
有疑问加站长微信联系(非本文作者)