原文:贝壳找房 | 基于 Milvus 的向量搜索实践(一)

原文:贝壳找房 | 基于 Milvus 的向量搜索实践(二)

原文:贝壳找房 | 基于 Milvus 的向量搜索实践(三)

视频:贝壳找房向量搜索平台化实践 - InfoQ

作者:孙要飞,贝壳找房人工智能技术中心工程师

本文版权归作者和AIQ共有

文章分为三部分,第一部分主要讲基本概念、背景、选型及服务的整体架构;第二部分主要讲针对低延时、高吞吐需求,对 Milvus 部署方式的一种定制;第三部分主要讲实现数据更新、保证数据一致性,以及保证服务稳定及提高资源利用率做的一些事情.

1. 名词解释

Milvus[1]: 一种基于 Faiss[5],NMSLIB[6] 和 Annoy[7]的相似特征向量搜索引擎.

向量: 即特征向量,是对客观世界物体特征的数值表示;比如我们用 RGB(红绿蓝)三元色来表示物体的颜色,那么对于一个像素点,我们可以用数组[255,255,255]表示白色,用数组[0,0,0]表示黑色,这里[255,255,255]、[0,0,0]可以认为是物体该像素点的特征向量.

向量搜索:也叫最邻近搜索,是指按照一定的相似/距离算法[9-12],从指定集合中搜索(计算)出与输入的某个向量最相似的 N 个向量(即 topN).

2. 背景

随着计算机技术及机器学习技术的发展,特征向量作为一种对多媒体数据(复杂文本、语音、图片)的描述方式,逐渐成熟起来,而向量搜索(向量相似计算)也逐渐成为一种通用的需求.

近些年,贝壳找房业务迅猛发展,在搜索、推荐、图谱、智能客服等业务场景下,对向量搜索提出了比较强的需求.

面对多业务的需求,结合对业界已有工具的调研,最终选择了 milvus 做为底层引擎,建设了一个通用的向量搜索平台,以解决 向量相似计算 这个共性的问题.

3. 技术选型

在技术选型阶段,我们调研了业界已经比较成熟的工具,如 Facebook 的 faiss[5]、微软的 SPTAG[8],以及国内发起的开源项目 Vearch[13],Milvus[1]. 具体对比见表 1,2.

Vearch 和 Milvus 属于同类型产品,对比 faiss 和 SPTAG 的优势在于,后两者为开发库,不能开箱即用,在生产环境中使用涉及更多的开发、维护成本. Milvus 和 Vearch 是两款基于现有的开发库,开箱即用的应用,在实现基本的相似计算功能的基础上,围绕服务整体易用性、部署、稳定性等方面做了更多工作. 另外,Milvus 对比 Vearch,在社区活跃度、支持度上具有更明显的优势. 基于以上的调研,综合考虑各方面的成本,我们选择 Milvus 作为底层引擎.

表 1. Milvus 对比 FAISS、SPTAG[3]

表 2. Milvus 对比 Vearch(2019.11 数据)

4. Milvus 引擎简介

如图 1 所示,Milvus 基于 Faiss、Annoy 等比较成熟的开源库,并针对性做了定制,支持结构化查询、多模查询等业界比较急需的功能;Milvus 支持 CPU、gpu、arm 等多种类型的处理器;同时使用 MySQL 存储元数据,并且在共享存储的支持下,Milvus 可以支持分布式部署.

图 1. Milvus 架构[1]

5. 服务整体架构

图 2. 服务整体架构图,整体架构分三层,网关层、应用层和引擎层.

网关层: 主要负责服务整体的访问控制和监控报警,并对外暴露 API,属于应用的通用能力,这里不详细讲.

应用层: 应用层的定位是面向使用方,提供通用的向量搜索能力,同时屏蔽掉底层引擎的细节;应用层主要分为读模块写模块以及管理模块. 写模块定位是数据更新,抽象出了一组通用的写入 API,以实现对数据的更新,并保证数据更新的一致性. 读模块定位是支撑向量搜索,适配用户的查询请求,转换成具体引擎的查询,最后把结果转换成通用格式;读模块借助读缓存,来提高有重复查询请求时的整体吞吐量. 我们使用 ID 映射模块来完成业务 id 和引擎 id 的内部转换,为通用读/写做支撑. 另外,我们使用集群管理模块来实现集群的创建、配置更新以及状态的监控.

引擎层: 引擎层基于 docker+kubernetes,实现了 Milvus 引擎的部署、服务发现、数据存储以及资源的管理.

6. 参考文献(一)

  1. https://github.com/milvus-io/milvus
  2. https://github.com/milvus-io/milvus/tree/0.11.1/shards
  3. https://github.com/milvus-io/docs/blob/master/v0.6.0/site/en/reference/comparison.md
  4. https://milvus.io/docs/v0.11.0/vector.md
  5. https://github.com/facebookresearch/faiss
  6. https://github.com/nmslib/nmslib
  7. https://github.com/spotify/annoy
  8. https://github.com/microsoft/SPTAG
  9. https://en.wikipedia.org/wiki/Euclidean_distance
  10. https://en.wikipedia.org/wiki/Taxicab_geometry
  11. https://en.wikipedia.org/wiki/Hamming_distance
  12. https://en.wikipedia.org/wiki/Minkowski_distance
  13. https://github.com/vearch/vearch

7. 遇到了哪些问题

在项目调研、实施以及最终上线使用过程中,我们遇到了不少的问题,包括:

  • 如何解决在满足响应时间的条件下,解决横向扩展的问题.
  • 在引擎本身不稳定的情况下,如何实现数据 T+1 更新时的一致性.
  • 在引擎本身不稳定且问题暂时无法明确定位/解决的情况下,如何实现服务的高可用.
  • 如何实现资源的动态调整,以提高资源的利用率.

8. 低时延、高吞吐的要求

互联网垂直搜索领域,特别是电商行业,对于特定业务的搜索,热数据的量级一般是可控的(百万级、千万级),一般情况下,对响应时间和整体的吞吐量(QPS)都有比较高的要求.

其中,响应时间是首要条件,其次是吞吐量;如果单机在小流量下能满足响应时间要求,但是无法满足吞吐量要求时,集群部署/横向扩展能力,就是一个很自然的解决思路了.

9. 解决方案

9.1. Mishards -Milvus 原生解决方案

图 1. Milvus 分布式方案 - Mishards

我们可以先了解下 Milvus 是如何解决 低时延、高吞吐 问题的. 如图 1 所示,Milvus 借助了一个外围服务 Mishards 来代理 Milvus 引擎,来实现分布式部署的. 处理具体请求的流程大概是这样:

  1. 请求流量进入 Mishards 请求队列.
  2. Mishards 从请求队列中取出请求,借助自身维护的数据段信息,把请求拆分成子请求(只查询部分段),并把子请求分发给负责不同段的 Milvus 读实例.
  3. Milvus 读实例处理段请求,并返回结果.
  4. Mishards 把聚合返回的结果后,最终返回.

另外,需要知道的是,Milvus 底层的数据存储可以分段存储(不同的数据文件,文件大小可以在配置文件中设定),如果数据量足够大的情况下,数据最终会存储在多个文件中;相应地,Milvus 支持对指定文件(可以是多个文件)的查询.

由以上分析可知,在数据量比较大的情况下(比如百亿级数据),数据在同一个物理机上无法全部加载到内存中,查询时势必会导致大量的数据加载,从而导致单个查询的响应时间就会让人无法忍受;Mishards 刚好就可以满足数据量量大时,单个查询的响应时间提升,使用多个物理资源来分担单个查询的开销.

然而,在数据量相对小时,如前面所说的百万级、千万级数据量,在数据的维度比较小时(如 500 以内),常见的物理机完全可以加载到内存里边. 在这种情况下,通过实验发现,分段存储数据反而会使用整体的响应时间变差,因此,我们下面讨论的场景都是数据存储在一个段内.

数据存储在一个分段内,当单个查询(小流量查询)响应时间可以满足需求时,我们无法使用 Mishards 来实现整体吞吐量的增加(因为数据只有一份,而且只能在一个 Milvus 读实例中被处理,即使我们部署了多个读实例).

那么,在数据只需要存储在一个分段中,而且小流量、响应时间可以满足需求时,如何实现整体吞吐能力的横向扩展呢?

9.2. 使用 envoy+headless service 实现扩展

由图 1 可以知道,Mishards 实现了读写分离,以及大数据量下单个请求的负载拆分. 但是,在互联网垂直搜索领域,特别是电商行业,热数据一般量级并不大,完全可以放在一个分段(文件)中. 我们把问题转换成以下两个目标:

  • 读写分离
  • 读结点可横向扩展

对于目标 1,其实就是一个请求转发的问题,milvus 采用的 grpc 通信协议,本质上是 http2 请求,可以通过请求的路径区分开,而且业界已经有比较成熟的工具如 nginx,envoy 等. 所以,问题就集中在如何实现读结点的横向扩展.

由于部署采用是是 docker+k8s 环境,所以尝试采用 envoy[2]这个专门为云原生应用打造的方案来解决横向扩展的问题. 目标 1 可以简单解决,envoy 配置片段[3]如下:

... 略 ...
       filter_chains:
          filters:
          - name: envoy.http_connection_manager
            typed_config:
              "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
              codec_type: auto
              stat_prefix: ingress_http
              route_config:
                name: local_route
                virtual_hosts:
                - name: backend
                  retry_policy:
                    retry_on: unavailable
                  domains:
                  - "*"
                  routes:
                  - match:
                      prefix: "/milvus.grpc.MilvusService/Search"
                    route:
                      cluster: milvus_backend_ro
                      timeout: 1s
                      priority: HIGH
                  - match:
                      prefix: "/"
                    route:
                      cluster: milvus_backend_wo
                      timeout: 3600s
                      priority: HIGH 
              ... 略 ...

我们可以把实现第二个目标(读结点可横向扩展)细化为两个步骤:1.实现读结点集群部署,并支持增加/减少结点;2.实现请求读结点的负载均衡.

9.2.1. 实现读结点集群部署

kubernetes 下有一个抽象概念 service[4],其含义就对应于 域名,我们可以通过将 service 指向一组 Pod(kubernetes 下另外一个概念,一个 Pod 对应一个读结点)[5];我们可以通过 kubernetes 下的 Deployment[6]/Daemonset[7]来管理这组 Pod,实现 Pod 数的增加/减少.

另外,我们需要详细分析的是 kubernetes 是如何进行 DNS 解析的,具体来讲就是要分析 service 是如何解析到所对应 Pod 的 ip:port 的.

由[8]可知,kubernets 集群中的每个 service,包括 DNS 服务器,都被分配了一个 DNS 名,集中的任一 Pod 可以通过 DNS 来访问其它 Pod. 另外,service 还分两种,Normal 和 Headless[9],两种 service 的的解析方式不同;

  • Normal 类型的 service 会被分配一个 DNS 的 A 记录[10],格式如 my-svc.my-namespace.svc.cluster-domain.exampl,该记录被解析到 service 所对应 ip(cluster ip);
  • headless 类型的 service 也会被分配一个相同格式的 DNS 的 A 记录[10],但是这个 A 记录被解析到 service 指向的一组 Pod 的 ip,客户端可以根据自己的策略来处理这些 ip.

带着这个问题,我们可以先了解下,kubernetes 环境下,请求的转发是如何实现的. 由[11]可知,kubernetes 借助 kube-proxy 来实现请求的转发(即到达具体的 pod),kube-proxy 有三种工作模式 user space、iptables、ipvs;详细查看三种模式的实现细节我们可以知道,三者除了设计思路和性能差异之外,流量转发规则没有本质区别(当然,ipvs 所支持的策略多些).

9.2.2. 实现请求读结点的负载均衡

在我们已经完成读结点的集群部署并且可以根据配置不同类型的 service 来实现不同的 DNS 解析方式前提下,如果我们用 envoy 作为整体引擎集群的入口,如何实现 envoy 对 Milvus 读实例的负载均衡呢?

附 ipvs 所支持的流量转规则

  • rr: round-robin
  • l: least connection (smallest number of open connections)
  • dh: destination hashing
  • sh: source hashing
  • s: shortest expected delay
  • nq: never queue

当服务暴露的接口是 http 时,kube-proxy 直接就实现了流量的负载均衡,但是,Milvus 当前暴露的是 grpc 接口,在我们的实践过程中,kube-proxy 在转发 gRPC 请求时,并没有实现所预期的负载均衡.

我们先了解下 grpc 的通信机制. gRPC[12]是谷歌开源的,基于 Protocol Buffers[13],支持多语言的开发框架、通信框架. 由于 gRPC 是基于长连接进行通信的,在基于域名/DNS 来创建连接时,只会创建一个连接(如果对同一个 ip:port 连续多次创建连接,也会有多个连接). 我们以前面中描述的 headless service 为例,客户端(即 envoy)请求 DNS 服务器时,会获取一组 pod 所对应的 ip. 那么,就剩下最后一个问题,envoy 如何创建多个连接呢?

由[15]可知,在采用 Strict DNS 服务发现类型时,envoy 会为每一个下游服务对应的 ip 地址建立一个连接,并且会定时刷新 ip 地址列表,从而实现了流量的负载均衡. envoy 的配置片段[16]如下:

  clusters:
      - name: milvus_backend_ro
        type: STRICT_DNS
        connect_timeout: 1s
        lb_policy: ROUND_ROBIN
        dns_lookup_family: V4_ONLY
        http2_protocol_options: {}
        circuit_breakers:
          thresholds:
            priority: HIGH
            max_pending_requests: 20480
            max_connections: 20480
            max_requests: 20480
            max_retries: 1
        load_assignment:
          cluster_name: milvus_backend_ro
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: milvus-ro-servers
                    port_value: 19530
                    protocol: TCP

至此,实现横向扩展的目的达到,整体的方案如下图 2.

图 2. 使用 envoy+headless service 实现横向扩展

10. 生产环境多集群部署

图 3 整体思路图

ALL IN ONE 解决了横向扩展的问题,我们就解决服务整体在生产环境的可用性问题.

接下来,我们需要考虑如何更方便地部署服务. 整体思路如图 3,我们使用 helm[17]将所有涉及的服务,包括 envoy、milvus 读、milvus 写、mysql(存放 milvus 的元数据信息)打包成一个 chart. 最后,我们可以把这个 chart 放到镜像仓库中(如 harbor[18]),以进行集中管理. 图 3 中还涉及到存储部分,包括 PVC 和 glusterfs,其具体实现我们后续详细讲.

helm 是 kubernetes 下的包管理工具,支持将一个有复杂结构的应用及所涉及到的所有配置模板化,并打包成一个 chart(相当于一个模板),然后可以通过 helm 安装这个 chart(为 chart 提供所需配置),生成一个 release(即一个可用的应用).

11. 参考文献(二)

  1. https://github.com/milvus-io/milvus/tree/0.11.1/shards
  2. https://www.envoyproxy.io
  3. https://www.envoyproxy.io/docs/envoy/v1.11.0/api-v2/config/filter/network/http_connection_manager/v2/http_connection_manager.proto.html?highlight=http_connection_manager
  4. https://kubernetes.io/docs/concepts/services-networking/service/
  5. https://kubernetes.io/docs/concepts/workloads/pods/
  6. https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
  7. https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/
  8. https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/
  9. https://kubernetes.io/docs/concepts/services-networking/service/#headless-services
  10. https://en.wikipedia.org/wiki/List_of_DNS_record_types
  11. https://kubernetes.io/docs/concepts/services-networking/service/#virtual-ips-and-service-proxies
  12. https://grpc.io/docs/what-is-grpc/core-concepts
  13. https://developers.google.com/protocol-buffers/docs/proto3
  14. https://grpc.io/blog/grpc-on-http2/#resolvers-and-load-balancers
  15. https://www.envoyproxy.io/docs/envoy/v1.11.0/intro/arch_overview/upstream/service_discovery#strict-dns
  16. https://www.envoyproxy.io/docs/envoy/v1.11.0/api-v2/api/v2/cds.proto.html?highlight=lb_policy
  17. https://helm.sh/
  18. https://goharbor.io/

12. 数据存储方案

上面解决了部署方案的问题,接下来要考虑的是数据如果存储.

在分布式部署情况下,Milvus 是需要使用 MySQL 来存储元数据的[1]. Milvus 分布式部署时,数据只会写一份,如何实现数据的分布式使用呢?基本的思路有两种:

[1] - 内部数据复制,典型的例子如 elasticsearch[2],kafka[3] [4];

[2] - 数据存储在共享存储上,如 NFS,glusterfs,AWS EBS,GCE PD,Azure Disk 等,都提供了 kubernetes 下的支持[5].

两种思路没有本质的区分,前者是应用自己实现了数据的存储及高可用(多副本);缺点是应用复杂度增加;优点是具有更高的灵活性.

后者依赖于已有的通用的存储方案,只需要关注自身的核心功能,复杂度降低了,而且更方便在多种存储方案下切换.

在云计算技术发展的今天,后者有一定的市场. Milvus 选用了共享存储来存储数据. 为了实现存储的统一及高可用,我们把单个 Milvus 集群所涉及到的所有数据存储(MySQL 数据文件和 milvus 的存储),都放到共享存储中. 我们使用了 glusterfs 做为共享存储的具体实现. 整体的存储方案如图 1.

图 1. glusterfs 共享存储

使用 glusterfs 存储数据为了解决集群的自动创建,减少沟通维护成本以及物理资源的最大利用(Milvus 是 CPU 密集型,glusterfs 是存储密集型),我们将 glusterfs 同 Milvus 混合部署. 我们参考实现了 glusterfs 在 kubernetes 下的超融合(Full Hyper-Convergence)部署,并借助 heketi[7]实现了存储资源的动态分配. 另外,在部署过程中,还需要注意的是 glusterfs 需要一个独立的磁盘/分区,你也可以使用 loop 设备[8];在部署过程中,因为各种原因,不可避免需要重置部署,这时你需要清除脏数据,可以参照以下命令.

# 清除逻辑卷
lvscan | awk 'system("lvremove  -y "$2 )'

# 清除卷分组
vgscan | grep group | awk -F '"' '{system("vgremove "$2)}'

glusterfs 在 kubernetes 下的部署架构如图 2 所示,glusterfs 服务可以分布在 kubernetes 的多个 node 上,我们可以根据存储的需求增加结点.

图 2. glusterfs in kubernetes

实现了 glusterfs 在 kubernetes 的部署,我们更关心的是 glusterfs 本身的可用性:1)glusterfs 是否可以实现数据的不丢失/高可用;2)glusterfs 是否可以存储大批量数据.

由[9]可知,glusterfs 有 Distributed volume、Replicated volume、Distributed Replicated volume、Dispersed Glusterfs Volume、Distributed Dispersed Glusterfs Volume 5 种类型的卷,其中 Distributed volume 可以解决数据分布存储数据,从而实现大批量数据的存储,Replicated volume 通过数据的冗余来实现高可用,Distributed Replicated volume 同时解决了高可用和大批量数据存储的问题,Dispersed Glusterfs Volume、Distributed Dispersed Glusterfs Volume 是分别对 Replicated volume、Distributed Replicated volume 的优化,借助一种前向纠错码(erasure code[10])实现数据存储成本的降低. 图 3 给出了 Distributed Replicated volume 类型卷的结构图.

图 3 Distributed Replicated volume

最后,借助 heketi[7]、以及 kubernetes 的 StorageClass[11]、PVC[12],我们屏蔽掉了以上 glusterfs 卷创建、扩容、销毁的细节,比较完美解决了数据存储的问题.

13. 数据更新方案

数据更新分为实时更新和批量/全量更新两种,Milvus 本身是支持实时更新的,但是数据更新时需要重新创建索引,而索引构建需要消耗大量的 CPU 资源,从而引发服务整体的稳定性问题. 综合考虑稳定性,以及业务的数据更新场景(绝大多数是 T+1 更新策略),我们采用了如图 4 所示的数据更新策略.

我们使用了 A、B 两组对等的资源(可以是同机房、跨机房)作为底层 Milvus 引擎,在引擎的外层,我们实现了读写分离,同一时刻,A、B 集群只会承担读、写角色中的一个. 在引擎外层,我们维护了读写角色与 A、B 集群的对照表;数据更新时,我们操作写集群完成数据写入、索引构建,写集群索引构建完成后,切换成角色成读集群;数据更新时出现任何问题,不影响读集群. 另外,在读写集群都有正常数据(数据更新差一天)情况下,如果读集群出现问题,写集群可以随时切换成读集群,从而在实现数据更新的同时还实现了互备. 由于底层资源使用对等的两份,如何没有特别的处理,不可避免会造成资源的浪费,后面内容会专门讨论解决这个问题的方案.

图 4. T+1 数据更新策略

14. 数据一致性保证

解决了数据更新的问题,另一个问题接踵而来:如何保证数据更新时一致性?如何做到以下三点:1)数据量不多不少;2)数据不重复;3)旧数据不会覆盖新数据.

由于我们的前提是数据全量更新,在业务数据本身不重复的情况下,不会存在数据覆盖问题,我们重点讨论前两点.

14.1. 数据量不多不少

我们总体思路是,明确写入操作开始和结束(提供专门的 API 实现),在结束时检验数据量. 数据全量写入开始时,我们清空数据,在数据全量写入结束时,判断数据写入的实际数量与预期是否一致,如果一致,我们可以确认数据数量是没有问题的. 数据写入操作可以并发进行,以保证整体的写入吞吐量,但是需要使用方保证,结束写操作需要在所有写入操作之后. 另外,为了兼顾数据一致性、引擎稳定性以及服务整体可用,可以设定一致性错误容忍度(比如可以容忍多少比例的数据量差异).

14.2. 数据不重复

我们假设,写入 Milvus 的请求返回成功,数据写入成功;请求返回失败,数据写入失败.

我们写入 Milvus 时,通过同步阻塞来实现数据不重复. 具体地,写入时,我们设定写入超时时间大于引擎内部写入请求的处理时间,也就是留出足够时间来让引擎返回成功/失败(即感知到引擎因为各种问题引起的失败);如果失败,我们会执行一次删除操作(删除可能写入的指定数据),并进行重试(如果重试指定次数还未成功,会由数据量校验来决定是否全量更新成功).

除了以上方案,还有两种可选的方案:

  1. 外部维护一个数据是否已经写入的标识,数据写入前进行判断,如果已经存在,就不再写入.
  2. Milvus 自身支持 upset(如果不存在就插入,如果存在就更新)操作.

方案 1 在实现同步阻塞方案效果的基础上,还兼顾了使用方与向量服务之间的可能网络异常(写入成功,但是没有返回给业务方,业务方重试,导致数据写入重复;Milvus 在 0.8.0 下不能去重);但是,增加了额外的开销,系统的复杂度也随之增加.

方案 2 是一个更优秀的方案,把去重的工作外部透明了. 当然,这个依赖于 Milvus 的版本迭代[13].

图 5 展示了数据 T+1 全量更新的步骤:

  1. 全量写开始 - 删除 Milvus 中旧数据,清除内外 id 映射数据,扩容 Milvus 写实例.
  2. 批量写 - 向 Milvus 写实例批量写入数据,失败重试.
  3. 结束写 - 检验数据量是否符合预期.
  4. 触发异步建索引 - 调用 Milvus 建索引接口(数据量大时建索引接口可能会阻塞).
  5. 异步等待 - 调用 Milvus 建索引接口返回(超时/完成),循环判断是否建索引成功(可以根据 showCollectionInfo 接口的返回判断).
  6. 引擎预热 - 让引擎把数据加载到内存中;多 partition 时需要遍历所有的 partition 才能保证所有数据都加载.
  7. 引擎切换 - A、B 引擎集群角色互换,并把对应关系持久化;对原有的读集群缩容.

图 5. 数据全量更新流程

15. 存活检测

在 Milvus0.8.0 使用过程中,多次出现 CPU 指令异常,导致 Milvus 服务退出的情况;但是,由于 Milvus 没有暴露存活检测的接口,Milvus Pod14 还被认为可用,还会有流量被负载均衡到,从而引发外部使用报错.

解决方式很直接,我们需要给 Milvus0.8.0 增加存活检测的接口,并且在 kubernets 下配置上对 Milvus 的检测. 由[15]可知,kubernetes 有 readinessProbe、livenessProbe 两者存活检测的手段,前者用于检测服务是否正常启动,后者用于检测服务正式在正常运行,如果不正常,会有相应的重启策略.

readinessProbe、livenessProbe 的具体实现有 exec、httpGet、tcpSocket 三种;exec 定时到指定容器中执行一个 shell 命令;httpGet 定时请求容器暴露的 http 接口;tcpSocket 是定时请求容器暴露的 socket 端口;三者根据指定格式的返回结果来判断服务是否正常,根据 Probe 配置来决定是否重启. 具体的配置可以参考[15].

有了 kubernetes 的支持,我们剩下需要做的就是如何判断 Milvus 是否正常;幸运的是,Milvus 虽然没有暴露 kubernetes 指定格式的 Probe 接口,但是它提供的 server_status 接口可以判断服务是否正常运行. 接下来,我们需要做的,就是如何包装下这个接口,返回 kubernetes 指定的格式.

最直接、简单的方案是 exec. 我们给原生 Milvus0.8.0 版本的 docker 镜像增加了执行 python 脚本功能的能力,并把以下 python 脚本打包到镜像中,最后 exec 配置定时调用以下脚本. 我们使用这个思路初步解决了问题,但是,在后续的测试验证过程中发现,当同一台机器上存在多个 Milvus 实例时,服务空转时就消耗了不少的 CPU 资源. 我们由[16]可知,exec 最终调用了 docker 的 exec api[17],docker exec API 在执行 shell 命令外,它还做了不少额外工作,从而导致对资源的消耗[18].

from milvus import Milvus, IndexType, MetricType, Status
client = Milvus(host='localhost', port='19530')
try:
 status,msg= client.server_status(timeout=10)
except Exception as e:
 print('1')
else:
 if status.OK():
  print('0')
 else:
  print('2')

为了解决 exec 的问题,我们采用了图 6 的方案. 基于以上的分析,我们把 python 脚本包装成一个 http 服务器,在容器启动时,将 http 服务器启动为一个常驻的进程,然后我们采用 httpGet 方案解决检测的问题. 经过实践检验,该方案对性能和资源占用基本没有影响.

图 6. httpGet 存活检测方案

16. 资源伸缩

考虑到资源的充分利用(我们重点考虑 CPU 资源),我们有必要在不使用时,对资源进行回收. 对资源的回收有手动和自动两方案,整体思路见图 7.

图 7. 资源伸缩

16.1. 手动

我们可以使用 kubernetes 的客户端工具 kubectl 来更改服务的副本数、cpu/内存占用;也可以通过 kubernetes 的 sdk,把相应功能做为 kubernetes 管理工具集成到自已的应用中,从而实现资源的个性化调节.

16.2. 自动

HPA(Horizontal Pod Autoscaler)[19]是 kubernetes 下支持的一种资源自动伸缩方案(以 pod 为单位),它参照监控数据提供的 CPU 资源利用率,根据配置的具体规则,来实现 pod 数自动调整.

17. 参考文献(三)

  1. https://github.com/milvus-io/milvus
  2. https://www.elastic.co/guide/en/elasticsearch/reference/current/scalability.html
  3. http://kafka.apache.org/documentation/#min.insync.replicas
  4. http://kafka.apache.org/documentation/#replication
  5. https://kubernetes.io/docs/concepts/storage/storage-classes/#provisioner
  6. https://github.com/gluster/gluster-kubernetes/blob/master/docs/setup-guide.md
  7. https://github.com/heketi/heketi
  8. https://en.wikipedia.org/wiki/Loop_device
  9. https://docs.gluster.org/en/latest/Quick-Start-Guide/Architecture/
  10. https://en.wikipedia.org/wiki/Erasure_code
  11. https://kubernetes.io/docs/concepts/storage/storage-classes/#glusterfs
  12. https://kubernetes.io/docs/concepts/storage/persistent-volumes/
  13. https://github.com/milvus-io/milvus/issues/3093
  14. https://kubernetes.io/docs/concepts/workloads/pods/
  15. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
  16. https://github.com/kubernetes/kubernetes/blob/18099e1ef7283d9ab09c45c5a4a90e26fdce1161/pkg/kubelet/dockershim/exec.go#L63
  17. https://docs.docker.com/engine/reference/commandline/exec/
  18. https://github.com/docker/for-linux/issues/466
  19. https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
Last modification:December 30th, 2020 at 10:21 am