Web搜索引擎十分复杂,我们的产品是一个分布式系统,在性能和延迟方面有非常苛刻的要求。除此之外,这个系统的运营也非常昂贵,需要大量人力,当然也需要大量金钱。
这篇文章将探讨我们使用的一些技术栈,以及我们做出的一些选择和决策。
以下为译文:
在本文中,我们将系统地介绍我们的私有搜索产品,经过多年的迭代,来满足外部和内部的用户。我们结合使用了很多有名的开源技术,以及云原生技术,这些技术都经受了严格的测试。对于哪些未能从开源或商业系统中找到解决方案的领域,我们只能深入研究,并自行从头编写系统。这种方式十分适合我们现在的规模。免责声明:本文描述的只是系统现在的情况。当然最初的系统并非如此。多年来我们采用过多种架构,并不断思考诸如成本、流量和数据大小等约束。但本文并不是构建搜索引擎的指南,而只是我们目前正在使用的系统,高德纳曾说:我们完全同意这句话。我们真心建议所有人不要一次性把所有食材都扔进锅里。但也不必逐个放,而是每次一小步,逐步增加复杂性。
Cliqz的搜索引擎有两类客户,他们有不同的需求。
浏览器的地址栏中可以搜索,搜索结果显示在下拉菜单中。这类搜索要求的结果很少(通常是3条),但对于延迟的要求十分苛刻(一般在150毫秒以内),否则就会影响用户体验。
Cliqz搜索引擎的结果页面 beta.cliqz.com在网页上进行搜索,显示人所共知的搜索结果页面。这里,搜索的深度是无限的,但与下拉菜单相比,它对于延迟的要求较低(只需在1000毫秒以内就可以)。考虑一个查询,如“拜仁慕尼黑”。这个查询似乎非常普通,但该查询会使用我们系统中的数个服务。如果考虑这个查询的意图,就会发现用户可能想要:研究拜仁慕尼黑俱乐部(这种情况下显示维基百科的小窗可能会有用)你也许会注意到,这些意图远非“相关网页”能概括。这些信息不仅从语义上相关,而且还与时间有关。搜索的时间敏感度对于用户体验非常重要。
为提供合理的用户体验,这些信息必须由不同的信息源提供,并以近乎实时的方式转换成可以被搜索的索引。我们要保证所有模型、索引和相关文件都是最新的(例如,加载的图像必须反映当前的事件,标题和内容也必须随时根据正在发展的事件而更新)。在大规模的条件下,尽管这一切看似很难,但我们坚持认为我们应该永远给用户推送最新的信息。这个理念贯穿了我们整个系统架构的基础。Cliqz的数据处理和服务平台采用了多层的Lambda架构。该架构根据内容索引的即时性分成三层,分别是:完全自动,由Kafka(生产者、消费者和流处理器)、Cassandra、Granne和RocksDB负责提供
Cassandra将索引信息存储在多个表中。不同表中的记录有不同的生存时间(TTL),这样可以在数据稍后被重新索引时清理存储空间
该组件还负责根据趋势或流行程度进行排名,这样可以协助在不同大小的移动窗口中找出趋势。这项功能使用了KafkaStreams提供的流处理功能
这些技术造就了产品特性,包括搜索结果中的最新内容、最流行新闻等
每周或基于滑动窗口的批次索引
基于过去60天的内容
每周重建索引(使用Jenkins上的端到端自动流水线中的批处理作业)
根据最新的数据执行机器学习和数据流水线,提高搜索结果的质量
有一个很好的框架,利用一小部分数据测试新的机器学习模型和算法的改变并建立原型,避免在全部数据上进行端到端试验造成的高昂成本
利用Luigi实现基于Map-Reduce和Spark的批处理工作流管理,并利用Jenkins Pipeline进行回顾管理
利用Keyvi、Cassandra、qpick和Granne提供服务
全批次索引
基于全部数据
每两个月重建一次索引
用Luigi管理的基于MapReduce和Spark的批处理工作流
用于在大数据集上训练大规模的机器学习模型。例如,查询和词嵌入、近似最近邻居模型、语言模型等
利用Keyvi、Cassandra、qpick和Granne提供服务
值得指出的是,近乎实时的索引和每周索引负责了SERP上搜索相关内容的一大部分。其他搜索引擎也采用了类似的做法,即更看重某个话题最新的内容,而不是历史内容。批次索引负责处理与时间无关的查询、长尾查询,以及针对罕见内容、历史内容或语境苛刻的查询。这三者的组合能为我们提供足够多的结果,因此Cliqz搜索才做到了今天的样子。所有系统都能够应答所有查询,但是最终结果是所有索引上的结果的混合。
“只有当你明白何时不该使用某个工具,才算真正掌握了它。”——Kelsey Hightower从一开始我们就专注于使用云服务商提供搜索服务,而不是自己搭建基础设施。在过去的十年内,云服务已经成了行业的标准,与自己搭建数据中心相比,无论从复杂性还是从资源需求的角度,云服务都有巨大的优势,而且使用很方便,创业公司还可以按量付费。对于我们而言,AWS十分方便,我们不需要管理自己的机器和基础设施。要是没有AWS,我们就得花很多精力才会有现在的成就。(但是,AWS虽然很方便,但也很昂贵。这篇文章里会介绍一些可以降低成本的手段,但我们建议你在大规模情况下使用云服务时务必要谨慎。)我们通常会避免那些可能会有用的服务,因为在我们的规模下,成本可能会高到无法接受。为了便于理解,我举一个2014年的例子,当时我们遇到的一个增长的问题就是如何在AWS上可靠地分配资源并部署应用。刚开始的时候,我们尝试在AWS上构建自己的基础设施和配置管理系统。我们的做法是用python实现了一套解决方案,这样开发者更容易上手。这套解决方案基于Fabric项目,并与Boto集成,只需要几行代码就可以建立新的服务器并配置好应用程序。当时docker还刚刚起步,我们采用的是传统的方法,直接发布python包,或者纯文本的python文件,这种方式在依赖管理上有很大困难。尽管项目收到了许多关注,在Cliqz也被用于管理很多产品中的服务,但以库为基础的基础设施和配置管理方式总是有一些不足。全局状态管理、基础设施变更的中心锁、无法集中地查看某个项目或开发者使用的云资源、依赖外部工具来清理孤立资源、功能有限的配置管理、很难查看开发者的资源使用量、使用者的环境泄露等,这些问题带来了不便,逐渐让操作变得越来越复杂。因此我们决定寻找一种新的外部管理解决方案,因为我们没有足够的资源自行开发。我们最终决定的方案是采用来自Hashicorp的解决方案组合,包括Consun、Terraform和Packer,还有配置管理工具如Ansible和Salt。Terraform使用优秀的声明式方式定义基础设施管理,云原生领域的许多最新技术都采用了这个概念。因此我们在谨慎地评估之后决定,放弃了自己基于fabric的部署库,转而采用Terraform。除了技术上的优劣之外,我们还必须考虑人的因素。一些团队接受改变比较缓慢,有可能是因为缺乏资源,有可能是因为转变的代价在各个团队之间并不一致。我们花了整整一年的时间才完成迁移。Terraform的一些开箱即用的特性是我们以前没有的,如:基础设施的中心状态管理
详尽的计划、补丁和应用支持
很容易关闭资源,最小化孤立资源
支持多种云
同时,我们在使用Terraform的过程中也面临着一些挑战:
Terraform当然在Cliqz有用武之地,时至今日,我们依然用它来部署大多数Kubernetes基础设施。
这些年来,我们由数十台服务器组成的分布式架构迁移到了整体式架构,最后又迁移到了微服务架构。我们相信,每个服务在当时的资源条件下都是最方便的。例如,采用整体式架构是因为绝大多数延迟都是由于集群中的服务器之间的网络IO导致的。当时AWS发布了X1实例,它拥有2TB的内存。改变架构可以有效地降低延迟,当然成本也会攀升。而下一个架构方面的迭代重点放在了成本上。我们在不影响其他因素的前提下一点点改变每个变量。尽管这个方法看上去并不那么漂亮,但非常适合我们。“微服务架构风格将应用程序分解成一组小服务,每个服务在自己的进程上运行,通过轻量化的机制(通常是HTTP资源API)与其他进程进行通信。” ——Martin Fowler理论上,Martin Fowler给出的微服务的定义是正确的,但过于抽象。对于我们来说,这个定义并没有说明应当怎样构建和分割微服务,而这才是重点。采用微服务给我们带来了如下好处:团队之间更好的模块化和自动化,以及关注点分离。
水平伸缩和工作负载划分。
错误隔离,更好地支持多语言。
多租户,更好的安全功能。
更好的运维自动化。
从架构整体以及微服务的结构上来看,每当查询请求发送到后端时,请求路径上会触发多个服务。每个服务都可以看做是微服务,因为它们都有关注点分离,采用轻量级协议(REST/GRPC),并且可水平伸缩。每个服务都由一系列独立的微服务组成,可以拥有一个持久层。请求路径通常包括:
Web应用层防火墙(WAF):应用层防火墙,用于抵御常见的Web漏洞。
负载均衡器:接收请求、负载均衡。
Ingress代理:路由、边缘可观测性、发现、策略执行。
Eagle:SERP的服务器端渲染。
Fuse:API网管,结果融合,边缘缓存,认证和授权。
建议:查询建议。
排名:用近乎实时的索引和预编译的批次索引提供搜索结果(Lambda架构)。
富结果:添加更丰富的信息,如天气、实时比分的小窗体,以及来自第三方信息源的信息。
知识图谱和瞬时解答:查找与查询有关的信息。
地点:基于地理位置的内容推荐。
新闻:来自知名新闻源的实时内容。
跟踪器:由WhoTracks.me提供的特定于某个领域的跟踪信息。
图像:与用户查询有关的图像结果。
所有服务都编排至公用的API网关,该API网关负责处理搜索结果的大小,还提供了其他功能,如针对访问量激增的保护、根据请求量/CPU/内存/自定义基准自动进行伸缩、边缘缓存、流量模仿和分割、A/B测试、蓝绿部署、金丝雀发布等。
到目前为止,我们介绍了产品的部分需求和一些细节。我们介绍了怎样进行部署,以及各种方案的缺点。有了这些经验教训,我们最终选择了Docker作为所有服务的基本组成部分。我们开始使用Docker容器来分发代码,而不再使用虚拟机+代码+依赖的形式。有了Docker,代码和依赖就可以作为Docker镜像发送到容器仓库(ECR)。但随着服务继续增长,我们需要管理这些容器,特别是在需要在生产环境中进行伸缩的情况。难点包括(1)浪费很多计算资源 (2)基础设施的复杂性 (3) 配置管理。人员和计算力一直是稀缺资源,这是许多资源有限的创业公司都会面临的困境。当然,为了提高效率,我们必须重点解决那些存在但现有工具不能解决的问题。但是,我们并不希望重新发明轮子(除非这样做能有效地改变状况)。我们十分愿意使用开源软件,开源解决了许多关键的业务问题。Kubernetes 1.0版公布之后我们立即着手尝试,到1.4版的时候,Kubernetes已经比较稳定,其工具也比较成熟,我们就开始在Kubernetes上运行生产环境的负载。同时,我们还在大型项目(如fetcher)上评测了其他编排系统,如Apache Mesos和Docker Swarm。最后我们决定用Kubernetes来编排一切,因为有足够的证据表明,Kubernetes采用了非常诱人的措施来解决编排和配置管理的问题,而其他方案并没有做到这一点。再说Kubernetes还有强力的社区支持。
Cliqz依赖于许多开源软件项目,特别是依赖于云原生基金会(Cloud Native Computing Foundation)旗下的诸多项目,来提供整体的云原生体验。我们通过提供代码、博客文章以及Slack等其他渠道尽可能回馈开源社区 。下面来介绍一下我们的技术栈中使用的关键开源项目:在容器编排方面,我们利用KOPS和一些自己开发的工具来自行管理横跨多个区域的Kubernetes集群,管理集群生命周期和插件等。感谢Justin Santa Barbara和kops的维护者们做出的优异工作,使得k8s的控制平面和工作节点可以非常好地结合在一起。目前我们没有依赖任何提供商管理的服务,因为KOPS非常灵活,而AWS提供的k8s控制平面服务EKS还非常不成熟。使用KOPS以及自行管理集群意味着我们可以按照自己的节奏行事,可以深入研究问题,可以启用那些应用程序真正需要、却仅在某个Kubernetes版本中才存在的功能。如果我们依赖于云服务,那么达到现状需要花费更长的时间。值得一提的是,Kubernetes可以对系统的各个部分进行抽象。不仅包括计算和存储,还包括网络。我们的集群可能会增长到几百个节点,因此我们采用了覆盖网络(overlay network)构成了骨干网络,为横跨多个节点甚至多个区的Pod提供基本的网络功能并实行网络策略。我们采用了Weave Net作为覆盖网络,因为它很容易管理。随着规模增长,我们可能会切换到AWS VPC CNI和Calico,因为它们更成熟,能提供更少的网络跳数,以及更一致的路由和流量。到现在为止,Weave Net在我们的延迟和吞吐量环境下表现良好,所以还没有理由切换。我们最初依赖于helm(v2)进行Kubernetes manifest的包管理和发布。尽管它有许多痛点,但我们认为它依然是个优秀的发布管理和模板工具。我们采用了单一代码仓库来存储所有服务的heml图,并使用chartmuseum项目进行打包和分发。依赖于环境的值会保存到另一个代码仓库中,以实现关注点分离。这些都通过Helmfile提供的的gitOps模式来实现,它提供了声明式的方式,以实现多个helm图的发布管理,并关联重要的插件,如diff、tillerless,并使用SOPS进行秘密管理。对该代码仓库做出的改变,会通过Jenkins的CI/CD流水线进行验证并部署。Tilk / K9s——无压力的本地Kubernetes开发我们面临的问题之一在于:怎样才能在开发者的开发周期中引入Kubernetes。一些需求非常明显,那就是怎样才能构建代码并同步到容器中,怎样才能做得又快又好。最初我们使用了简单的自制解决方案,利用文件系统事件来监视源代码变更,然后rsync到容器中。我们还尝试了许多项目,如Google的Skaffold和微软的Draft,试图解决同样的问题。最适合我们的是Windmill Engineering的Tilt(感谢Daniel Bentley),该产品非常优秀,其工作流由Tiltfile驱动,该文件由starlark语言编写。它可以监视文件编辑,可以自动应用修改,实时自动构建容器镜像,利用集群构建、跳过容器仓库等手段来加速构建,还有漂亮的UI,可以在一个面板中查看所有服务的信息。如果你希望深入研究,我们把这些k8s的知识开源成一个名为K9s的命令行工具(https://github.com/derailed/k9s),它能以交互的方式执行k9s命令,并简化开发者的工作流程。今天,所有运行于k8s上的工作负载都在集群中进行开发,并提供统一、快速的体验,每个新加入的人只需要几个命令就可以开始工作,这一切都要归功于helm / tilt / k9s。Prometheus,AlertManager,Jaeger,Grafana和Loki——可观测性我们依赖Prometheus的监视方案,使用时间序列数据库(tsdb)来收集、统计和转发从各个服务收集到的指标数据。Prometheus提供了非常好的查询语言PromQl和报警服务Alert Manager。Jaeger构成了跟踪统计方案的骨干部分。最近我们将日志后台从Graylog迁移到了Loki,以提供与Prometheus相似的体验。这一切都是为了提供单一的平面,满足所有可观测性的需求,我们打算通过图表解决方案Grafana来发布这些数据。为了编排这些服务,我们利用Prometheus Operator项目,管理多租户Prometheus部署的生命周期。在任意时刻,我们都会接收几十万条时间序列数据,从中了解基础设施的运行情况,出现问题时判断从哪个服务开始解决问题。以后我们打算集成Thanos或Cortex项目来解决Prometheus的可伸缩性问题,并提供全局的查询视图、更高的可用性,以及历史分析的数据备份功能。我们使用Luigi和Jenkins来编排并自动化数据流水线。批处理作业提交到EMR,Luigi负责构建非常复杂的批处理工作流。然后使用Jenkins来触发一系列ETL操作,这样我们就能控制每个任务的自动化和资源的使用状况。我们将批处理作业的代码打包并添加版本号后,放到带有版本号的docker容器中,以保证开发和生产环境中的体验一致。我们还使用了许多社区开发的其他项目,这些作为插件发布的项目是集群生命周期的一部分,它们为生产环境和开发环境中部署的服务提供额外的价值。下面简单介绍一下:Argo工作流和持续部署:我们评测了该项目,作为Jenkins的后备,用于批量处理任务和持续部署。
AWS IAM认证器:k8s中的用户认证管理。
ChartMuseum:提供远程helm图。
Cluster Autoscaler:管理集群中的自动伸缩。
Vertical Pod Autoscaler:按需要或根据自定义指标来管理Pod的垂直伸缩。
Consul:许多项目的状态存储。
External DNS:将DNS记录映射到Route53来实现外部和内部的访问。
Kube Downscaler:当不再需要时对部署和状态集进行向下伸缩。
Kube2IAM:透明代理,限制AWS metadata的访问,为Pod提供角色管理。
Loki / Promtail:日志发送和统计。
Metrics Server:指标统计,与其他消费者的接口。
Nginx Ingress:内部和外部服务的ingress控制器。为了扩展API网关的功能,我们在不断评测其他ingress控制器,包括Gloo、Istio ingress gateway和Kong。
Prometheus Operator:Prometheus操作器栈,能够准备Grafana、Prometheus、AlertManager和Jaeger部署。
RBAC Manager:可以很容易地为k8s资源提供基于角色的访问控制。
Spot Termination Handler:通过提前警戒并清空节点的方式来优雅地处理单点中断。
Istio:我们一直在评测Istio的网格、可观察性、流量路由等功能。许多功能我们都已自己编写了解决方案,但长时间以来这些方案开始暴露出了限制,我们希望该项目能够满足我们的要求。
k8s的经验加上丰富的社区支持,我们不仅能够发布核心的无状态服务来提供搜索功能,还能在多个区域和集群中运行大型有状态的负载,如Cassandra、 Kafka、Memcached和RocksDB等,以提供高可用性和副本。我们还开发了其他工具,在Kubernetes中管理并安全地执行这些负载。
上述介绍了许多我们使用的工具。这里我想结合一个具体的例子来介绍怎样使用这些工具,更重要的是介绍这些工具怎样影响开发者的日常工作。我们以一名负责开发搜索结果排名的工程师为例,之前的工作流为:可见,开发者需要重复进行一系列的操作,团队中的每个新工程师都要重复这一切,这完全是对开发者生产力的浪费。如果实例丢失,就要重复一遍。而且,生产环境和本地开发环境的工作流还有少许不同,有时会导致不一致。有人认为在开发排名应用程序时设置其他服务(如前端)是不必要的,但这里的例子是为了通用起见,再说设置完整的产品总没有坏处。此外,随着团队不断增长,需要创建的云资源越来越多,资源的利用率也越来越低。工程师会让实例一直运行,因为他们不想每天重复这一系列操作。如果某个工程师离职,他的实例也没有加上足够的标签,那么很难判断是否可以安全地关闭该实例并删除云资源。
理想情况是为工程师提供用于设置本地开发环境的基础模板,该模板可以设置好完整的SERP,以及其他排名应用程序需要的服务。这个模板是通用的,它会给用户创建的其他资源加上唯一的标签,帮助他们控制应用程序的生命周期。因为k8s已经将创建实例和管理实例的需求抽象化(我们通过KOPS来集中管理),所以我们利用模板来设置默认值(在非工作时间自动向下伸缩),从而极大地降低了成本。现在,用户只需关心他自己编写的diamante,我们的工具(由Docker、Helm和Tilt组成)会在幕后神奇地完成这一系列工作流。下面是Tiltfile的例子,描述了设置最小版本的SERP所需的服务和其他依赖的服务。要在开发模式下启动这些服务,用户只需要执行tilt up:# -*- mode: Python -*-
"""
This Tiltfile manages 1 primary service which depends on a number of other micro services.
Also, it makes it easier to launch some extra ancilliary services which may be
useful during development.
Here's a quick rundown of these services and their properties:
* ranking: Handles ranking
* api-gateway: API Gateway for frontend
* frontend: Server Side Rendering for SERP
"""
####################
# Project defaults #
####################
project = "some-project"
namespace = "some-namespace"
chart_name = "some-project-chart"
deploy_path = "../../deploy"
charts_path = "{}/charts".format(deploy_path)
chart_path = "{}/{}".format(charts_path, chart_name)
values_path = "{}/some-project/services/development.yaml".format(deploy_path)
secrets_path = "{}/some-project/services/secrets.yaml".format(deploy_path)
secrets_dec_path = "{}/some-project/services/secrets.yaml.dec".format(deploy_path)
chart_version = "X.X.X"
# Load tiltfile library
load("../../libs/tilt/Tiltfile", "validate_environment")
env = validate_environment(project, namespace)
# Docker repository path for components
serving_image = env["docker_registry"] + "/some-repo/services/some-project/serving"
####################################
# Build services and deploy to k8s #
####################################
# Watch development values file for helm chart to re-execute Tiltfile in case of changes
watch_file(values_path)
# Build docker images
# Uncomment the live_update part if you wish to use the live_update function
# i.e., no container restarts while developing. Ex: Using Python debugging
docker_build(serving_image, "serving", dockerfile="./serving/Dockerfile", build_args={"PIP_INDEX_URL": env["pip_index_url"], "AWS_REGION": env["region"]} #, live_update=[sync('serving/src/', '/some-project/'),]
)
# Update local helm repos list
local("helm repo update")
# Remove old download chart in case of changes
local("rm -rf {}".format(chart_path))
# Decrypt secrets
local("export HELM_TILLER_SILENT=true && helm tiller run {} -- helm secrets dec {}".format(namespace, secrets_path))
# Convert helm chart to standard k8s manifests
template_script = "helm fetch {}/{} --version {} --untar --untardir {} && helm template {} --namespace {} --name {} -f {} -f {}".format(env["chart_repo"], chart_name, chart_version, charts_path, chart_path, namespace, env["release_name"], values_path, secrets_dec_path)
yaml_blob = local(template_script)
# Clean secrets file
local("rm {}".format(secrets_dec_path))
# Deploy k8s manifests
k8s_yaml(yaml_blob)
dev_config = read_yaml(values_path)
# Port-forward specific resources
k8s_resource('{}-{}'.format(env["release_name"], 'ranking'), port_forwards=['XXXX:XXXX'], new_name="short-name-1")
k8s_resource('{}-{}'.format(env["release_name"], 'some-project-2'), new_name="short-name-2")
if dev_config.get('api-gateway', {}).get('enabled', False):
k8s_resource('{}-{}'.format(env["release_name"], 'some-project-3'), port_forwards=['XXXX:XXXX'], new_name="short-name-3")
if dev_config.get('frontend', {}).get('enabled', False):
k8s_resource('{}-{}'.format(env["release_name"], 'some-project-4-1'), port_forwards=['XXXX:XXXX'], new_name="short-name-4-1")
k8s_resource('{}-{}'.format(env["release_name"], 'some-project-4-2'), new_name="short-name-4-2")
Helm图主要用于应用程序打包,以及管理每个发布的生命周期。我们使用helm的模板,并使用自定义yaml为模板提供值。这样我们就可以对每个发布进行深入的配置。我们可以配置为容器分配的资源,很容易地配置每个容器需要连接的服务,可以使用的端口等。
使用Tilt加上helm图来设置本地的k8s开发环境,并将本地代码映射到helm图中定义的服务上。利用它提供的功能,我们可以持续地构建docker容器并将应用程序部署到k8s上,或者进行本地更新(将所有本地修改rsync到正在运行的容器上)。开发者也可以利用端口转发将应用程序映射到本地实例上,以便在开发时访问服务的端点。我们使用k8s manifest,从helm图中提取出渲染后的模板,利用它进行部署。这是因为我们的图的需求过于复杂,无法完全依靠Tilt提供的helm的功能。
如果应用程序端点需要与其他团队成员共享,那么helm图就可以提供统一的机制来创建内部ingress端点。
我们的图通过公有的helm图仓库来公开,因此无论是生产环境还是开发环境,我们使用的都是同一套代码(带有版本号的docker镜像),同一个图模板,但模板中的值不一样,以适应不同的需求(如部署名称、端点名称、资源、副本等)。
整套实践在每个端点和每个项目中都保持一致,这样新加入团队的人就非常容易上手,云资源的管理也非常容易。
“只要技术足够先进,就和魔法没什么区别。”——阿瑟·克拉克
但这个魔法有一个问题。它通过更有效的资源共享,提高生产力、增加可靠度并降低成本 。但是,当某个东西出问题时,人们很难发现问题在哪里,找出问题的根源变得十分困难,而且这种错误特别容易在在人们不方便解决的时候出现。所以,尽管为这些努力感到骄傲,但我们依然保持谦逊的姿态。
廉价的基础设施和互联网规模的搜索引擎不可能兼得。话虽如此,想要省钱总会有办法。我来介绍一下我们是怎样利用基于k8s的架构来优化成本的。我们极度依赖于AWS spot instances,使用该服务,我们必须在构建系统时考虑可能的失败。但这样做是值得的,因为这些实例要比按需的实例要便宜得多。但要注意不要像我们一样搬起石头砸自己的脚。我们早就习惯了spot instances,因此有时候会高估自己的实力,导致本不应该发生的失败。而且,不要榨干高性能服务器的所有性能,否则你就会陷入与其他公司的竞价之争。最后,永远不要在大型的NLP/ML会议之前使用spot GPU instances。使用Spot的混合实例池:我们不仅使用spot instances来完成一次性的作业,也利用它来运行服务的工作负载。我们想出了一个绝佳的策略。我们利用多种实例类型(但配置都类似),为Kubernetes资源创建了一个节点池,该节点池分布在多个可用性区域中。与Spot Termination handler配合使用,我们就可以将无状态的工作负载移动到新建的或空闲的spot节点上,避免可能出现的长时间宕机。由于我们完全依靠Kubernetes,因此在讨论工作负载时都是在讨论Kubernetes需要多少CPU、多少内存,以及每个服务需要多少个副本。因此,如果Request和Limits相等,性能就能得到保证。但是,如果Request低但Limit高(这种情况在零星的工作负载上有用),我们可以多准备一些资源,并将某个实例的资源使用最大化(减少实例上的闲置资源量)。3. 集群的自动扩展器,Pod的垂直和水平Autoscaler我们用集群自动扩展器来自动化Pod的创建和缩小,只有在需求上升时才创建实例。这样在没有工作负载时仅启动最少的实例,也不需要人工干预。对于开发设置中的所有服务,我们使用部署的down-scaler在特定时间将pod的副本数收缩为0.在Kubernetes的manifest中添加一条注释,就可以指定启动计划:annotations:
downscaler/uptime: Mon-Fri 08:00-19:30 Europe/Berlin
也就是说,在非工作时间,部署的大小会收缩为0,副本数也会由集群的自动扩展器进行收缩,因为实例上没有活跃的工作负载。在生产环境中,一旦我们确定了资源使用量,就可以选择那些负载会很高的实例。这些实例不再采用按需模式,而是采用预留实例(reserved instance)的定价模型,这种模型需要预先支付一年的费用。但是,其成本要比按需启动的实例要低得多。在Kubernetes中,有一些解决方案如kubecost,可以监视长期的使用成本,然后据此来推荐额外的节约陈本的方法。它还提供了指定工作负载的价格估算功能,这样就可以算出部署一个系统的总体成本。通过同一个界面,使用者还可以知道哪些资源可能不再被使用,如ebs卷等。所有这些措施都可以为我们每年节省大约几十到几千欧元。对于拥有高额基础设施账单的大公司来说,如果这些措施得当,就能轻易地每年节省几百万。
机器学习系统中的隐藏技术债务——Sculley等人很有意思的是,我们的Kubernetes之旅以一种谁也没想到的方式开始。我们想要搭建一个基础设施,从而可以用Tensorflow运行分布式深度学习。当时这个想法还很新颖。尽管Tensorflow的分布式训练已经推出了一段时间,但除了为数不多的几个财大气粗的公司之外,很少有人知道怎样大规模地从头到尾运行分布式训练。当时也没有任何云解决方案能解决这个问题。我们一开始采用了Terraform来架设了一个分布式架构,但很快就意识到这个方案在伸缩性方面有局限性。同时,我们找到一些社区贡献的代码,利用jinja模板引擎来生成Kubernetes manifests,再创建深度学习训练应用程序的分布式部署(包括参数服务器和工作模式)。这是我们与Kubernetes的第一次接触。此外,我们还构建了自己的近乎实时的搜索引擎,同时试验按照新颖程度的排名。就在那时Kubernetes给我们带来了曙光,所以我们决定采用Kubernetes。作为机器学习系统之旅的一部分(就像上述所有基础设施一样),我们的目标就是向整个公司开放该系统,让开发者可以很容易地在Kubernetes上部署应用程序。我们希望开发者能把精力花费在解决问题上,而不是解决服务带来的基础设施问题上。但是,尽管每个人都利用机器学习解决了问题,但我们迅速意识到,维护机器学习系统的确是个非常痛苦的事情。它远远不止编写机器学习代码或者训练模型这么简单。即使是我们这种规模的公司,也需要解决一些问题。在“Hidden Technical Debt in Machine Learning System”这篇论文中有详细的描述。任何希望在生产环境中依靠并运行具有一定规模的机器学习系统的人都应该仔细阅读这篇论文。我们讨论了几种不同的解决方案,例如:MLT
AWS SageMaker
Kubeflow
MLFlow
在所有这些服务中,我们发现Kubeflow功能最全、性价比最高,且可以定制。
前一段时间,我们还在Kubeflow的官方博客上写了一些原因。kubeflow除了能为我们提供自定义资源,如TfJob和PytorchJob来运行训练代码,它的一大优势就是自带notebook支持。Kubeflow的许多特性都在我们的近实时排名中得到了应用。工程师可以在集群中打开一个notebook,然后直接进入数据基础设施(批次和实时流)。分享notebook,让多人分别处理代码的不同部分非常容易。工程师们可以很容易地进行各种实验,因为他们不需要设置任何notebook服务器,也不需要任何访问数据基础设施的权限,更不需要深入到部署的细节,只需要使用一个简单的Web界面就可以选择notebook所需的资源(CPU、内存甚至GPU),分配一个EBS卷,然后启动一个notebook服务器。有意思的是,一些实验是在0.5个CPU和1GB内存上进行的。通常这样规模的资源在我们的集群中随时存在,生成这种notebook非常容易,甚至都不需要新建实例。如果不这样做,那么来自不同团队的两名工程师想要一起工作时,他们很可能会启动各自的实例,这就会导致成本增加,资源的利用率也不高。此外还可以提交作业,这些作业可以用来在notebook中训练、验证模型并用模型提供服务。这方面的一个有意思的项目叫做Fairing。Kubeflow本身是个非常完善的项目,我们仅仅接触到了冰山一角。最近我们还开始了解其他项目,如Katib(机器学习模型的超参数调节)、KFServing(在Kubernetes上实现机器学习模型的无服务器推断)和TFX(创建并管理生产环境下的ML流水线)。我们已经利用这些项目创建了一些原型,希望能尽快将其应用到生产环境中。由于有这许多好处,我们衷心地感谢Kubeflow背后的团队打造的这个优秀的项目。随着我们的增长,随着我们越来越依赖于机器学习,我们希望围绕机器学习的处理可以流水线化,可以拥有更高的可重复性。因此,像模型跟踪、模型管理、数据版本管理变得极其重要。为了能在这种规模下稳定地运行模型,定期进行更新和评估,我们需要一个数据管理的解决方案,才能在生产环境中运行模型,从而实现模型和索引的自动热替换。为了解决这个问题,我们自己搭建了一个解决方案“Hydra”,它能为下游的服务提供数据集的订阅服务。它海能在Kubernetes集群中为服务提供卷管理。
“在取得成功后,下一个目标就是帮助别人成功。”——Kelsey HightowerCliqz的架构很困难,同时也很有趣。我们相信我们还有很长的路要走。随着开发的进行,我们有多种方案可以选择。尽管Cliqz已有120多名员工,但代码实际上是由数千名开源开发者编写并发布的,他们尽可能写出高质量的代码,并尽一切努力保证了安全性。没有他们,我们不可能有今天的成就。我们衷心感谢开源社区提供的代码,以及在我们遇到问题时帮我们解决问题。通过这篇文章,我们希望分享我们曾经的迷茫、获得的经验和解决方案,期待能对遇到类似问题的人有所帮助。怀着开放的心态,我们也想分享我们的资源(https://github.com/cliqz-oss/)来回馈开源社区。原文:https://www.0x65.dev/blog/2019-12-14/the-architecture-of-a-large-scale-web-search-engine-circa-2019.html【END】