istio 在知乎大规模集群的落地实践

背景

在知乎,我们很早就进行了非常彻底的容器化部署。全站在线运行的微服务数量高达两千多个。这些微服务通过我们自己维护的 RPC 框架和 Kodor 体系来实现服务互联的能力。

在这其中,我们也存在一系列的问题:

  1. 各种基础组件维护成本比较高,单点风险存在。
  2. 各语言客户端特性难以统一,熔断、重试等特性实现颇有不同,且不能做到动态线上进行调整。
  3. 客户端版本迭代难以推动。
  4. 微服务某些能力上跟其他大厂还有差距。
  5. 与社区、云原生距离较远,配合常见的开源项目紧密度较差。

ServiceMesh 可以很好的解决上面的这些问题:

  1. 提升微服务治理能力,规范的引入精准的熔断、限流、流量管理等手段。
  2. 减少客户端维护、迭代的投入。
  3. 提升服务间通信速度。
  4. 具备故障注入能力。
  5. 具备动态服务路由调整的能力。

因此,我们选择拥抱社区、拥抱 ServiceMesh、拥抱 Istio。

Kodor 体系介绍

我们先聊一聊知乎之前的微服务体系: Kodor

在这个系统里,我们会为所有微服务都创建一组 HAProxy 容器。

而所有发向目标服务的流量都会经过这一组 HAProxy。

HAProxy 负责记录基本的指标和访问日志,并实现鉴权、限流、黑名单等功能。

在这一点上, Kodor 系统与服务网格可以算得上是异曲同工之妙,即都是借用代理来实现原本在微服务框架上实现的功能。

服务发现与注册

在 kodor 体系中, consul 是主要用来作为服务发现和注册的关键组件。

我们将服务的 HAProxy 节点作为服务节点注册到 Consul 。

同时,将服务自身的各节点信息,存储在 consul 指定路径的 kv 。

这样客户端通过 Consul 发现的服务地址实际上是服务的 HAProxy 地址。

而 HAProxy 则通过 consul-template 的感知上游节点信息。

整体架构如下图所示:

ServiveMesh 迁移方案

目标

我们首先要确认迁移方案的一个基本要求。

即,满足这几个保证:

  1. 保证业务无感知迁移,不变更代码
  2. 保证可回滚
  3. 保证高可用
  4. 保证性能无明显下滑
  5. 保证 mesh 内的服务和 kodor 的服务可以互相访问

基于以上的目标要求,我们设计了如下的一个迁移方案。

流量互通方案

让我们先看看这两个通路的区别:

从整体上来看还是非常的类似的,都是先经过 consul 的服务发现或是 DNS 查询,再进行 RPC 或 HTTP 的流量访问.

由于我们无法要求业务程序进行代码修改,因此无论如何服务发现的组件需要得到保留。(这一点的原因是大量长尾项目难以推动,耗费大量人力资源可能得不偿失)

通过引入一个新的服务发现服务 (Discovery),我们可以轻松的让 mesh 和 kodor 的服务实现互通。

这里存在两种 case:

调用方服务在 mesh 内

当目标服务不在 mesh 内,Discovery 服务返回的地址是服务的 HAProxy 的地址(也就是将流量代理到 consul)。

当目标服务再 mesh 内,Discovery 服务将返回服务的 ServiceIP,此时应用将通过 Sidecar 触发 ServiceMesh 的路由能力,将请求直接传递到对端 Sidecar。

调用方服务不在 mesh 内

我们仍然保留服务的 HAProxy,因此调用方仍然可以通过 ‘Consul’ 发现 HAProxy 端点,进行流量的投递。

流量管理

沙箱联调的支持

在知乎,我们有一种功能叫沙箱联调。

即创建一种沙箱,在沙箱内部署的服务在调用其他服务的时候,优先访问沙箱内部署的负载组。

在 mesh 中,我们也需要相对应功能的实现。首先,我们对所有沙箱内的负载组默认打上 branch=box-xxx 的label。

然后我们在 VirtualService 中,添加通过 SourceLabel 匹配路由的规则。

下面是这样的一个例子:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: sm-verify-title
  namespace: sm-verify
spec:
  gateways:
  - mesh
  - istio-system/svc-ingress
  hosts:
  - sm-verify-title.sm-verify.svc.cluster.local
  http:
  - match:
    - sourceLabels:
        branch: box-10326
    name: box-10326--default
    route:
    - destination:
        host: sm-verify-title--box-10326.sm-verify--box-10326.svc.cluster.local
        subset: default
  - name: master--default
    route:
    - destination:
        host: sm-verify-title--master.sm-verify.svc.cluster.local
        subset: default

版本迭代中的 503 问题

官网关于这个问题的描述

简单来说就是需要:

  1. 在添加版本子集的时候,需要先修改 DR ,等 DR 生效后再修改 VS。
  2. 在删除版本子集的时候, 需要先修改 VS,等 VS 生效后再修改 DR。

首先我们面临的问题是需要确定什么时候对象能生效.。

这一点可以用 istioctl wait 来保证。

或者也可以直接等待一个经验时间,比如30s。

其次,我们可能需要保障这个操作的事务性,不能出现同时多个对于 VS/DR 的操作,以导致操作的后果不可预期。

为了解决这个问题,我们引入一个新的自定义资源:Router,用于声明式变更版本和路由。

Router 同时也用于配置上述沙箱功能的配置和 Service 的创建,以简化应用的配置管理。

例子:

apiVersion: router.service-mesh.zhihu.com/v1
kind: Router
metadata:
  name: sm-verify-web # 服务名
  namespace: sm-verify 
spec:
  port: 9090
  protocol: http
  subsets:
  - branch: master # 生产默认路由
    versions: # 百分之十流量进行金丝雀验证
      v1.0.1: 90 
      v1.0.2: 10
  - branch: box-0999 # 沙箱ID

迁移

迁移到 mesh,需要做如下几件事情:

  1. Pod 添加注入 Sidecar 需要的 Label (istio-injection=enabled)。
  2. 为服务创建 Router 对象。

在第一步,我们修改了 sidecar 的注入方式,以实现按照服务粒度的迁移

我们简化了一下流程,为每个服务都默认创建了 Router 对象。

因此,迁移到 mesh 就只需要给 workload 添加 label 然后重新部署。

回滚

系统性回滚

当 istio 设施出现大面积系统性故障的时候,我们可以通过配置使 Discovery 完全降级到纯 Consul 代理,这样所有流量将恢复到旧有的 Kodor 系统中,实现了系统级别的回滚。

服务回滚

服务回滚只需要修改 Label,重新部署即可。

服务网格平台

为了使业务同学能够更方便的使用服务网格的能力,我们开发了一个平台,用于在线变更服务网格的配置。

这个平台的功能包括: 路由、限流、黑名单、授权、流量镜像、负载均衡策略、熔断、链接池、自动重试、服务发现管理等。

在这个平台的建设过程中,我们遇到这样的问题:

  1. 如果使用数据库存储用户配置,数据库的数据怎么和集群配置保持一致性?
  2. 能否很好的暴露接口和其他系统一起工作?

基于以上两点,我们设计了一个新的组件:IstioFilter。

我们把服务的路由和子集看作基本的配置框架,通过 Patch 的方式把一个个具体功能 通过 istiofilter patch 到 VS/DR 之上。

这样,配置镜像流量是一个 istiofilter,配置故障注入也是一个 istiofilter。

平台上所有的配置几乎都对应一个独立的 istiofilter 的资源。

我们不需要将这些配置存储到数据库,也就不需要解决数据库和配置的一致性问题。

同时,其他系统比如混沌工程,也可以很轻易的通过 istiofilter 管理自己的故障注入能力。

istiofilter 是按照优先级顺序层层 Patch,因此整体的配置是可预测的,各个系统之间的配置并不会产生冲突。

倘使没有 istiofilter,那么各个系统对同一个 VS 对象的各种功能的新增、修改、删除而产生的冲突可就是个 bug 的无底洞了。

限流

由于 istio 早在 1.5 版本中就几乎完全废弃了之前 Quota 的内容。

因此,我们需要自行解决在 Istio 上的限流问题。

限流配置

首先限流从令牌桶上,我们分为全局限流和本地限流。

全局限流指的是所有特征流量在全局共享一个令牌桶。

本地限流指的是每个 Pod 有自己的令牌桶。

其次从限流作用的位置上,又分为服务端限流和客户端限流。

本地限流的实现非常简单,只需要配置一个 envoyfilter 即可。

例如,在我们的 sm-verify-page 服务上开启 [本地-服务端-1000rps] 限流。

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: locallimit-sm-verify-page-xxxx
  namespace: sm-verify
spec:
  configPatches:
  - applyTo: HTTP_ROUTE
    match:
      context: SIDECAR_INBOUND
      routeConfiguration:
        vhost:
          route: {}
    patch:
      operation: MERGE
      value:
        typed_per_filter_config:
          envoy.filters.http.local_ratelimit:
            '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            filter_enabled:
              default_value:
                denominator: HUNDRED
                numerator: 100
              runtime_key: local_rate_limit_enabled
            filter_enforced:
              default_value:
                denominator: HUNDRED
                numerator: 100
              runtime_key: local_rate_limit_enforced
            stage: 0
            stat_prefix: inbound
            token_bucket:
              fill_interval: 1s
              max_tokens: 1000
              tokens_per_fill: 1000
  workloadSelector:
    labels:
      app: sm-verify-page
      branch: master

而全局限流的配置分为两部分,一部分是给服务添加EnvoyFilter配置, 另一部分就是修改限流服务上对应的限流quota。

这个流程大概是:

image

在我们内部使用了一个叫 Girls (Global Incredible Rate Limit System) 的系统来简化操作配置。

apiVersion: quota.service-mesh.zhihu.com/v1
kind: RateLimit
metadata:
  name: sm-verify-page-dbcecaa
  namespace: sm-verify
spec:
  hosts:
  - sm-verify-page.sm-verify.svc.cluster.local:80
  match:
    headers:
    - key: :path
      matchType: exact
      value: /Page.Show
  quota: 800
  stage: 1
  unit: SECOND

题外话

Envoy 官方提供 RLS 的实现性能实在难以满足线上真实服务所需要的吞吐和 Latency 的要求,因此我们自研了一套高性能的 RLS 的实现以满足我们自己的需求。

另外,我们也和金山云进行合作,金山云提供的 RLS 的方案比我们的更好、更稳定, 如果不出意外的话我们也将切换到金山云的方案上。

优化

接下来,我们谈一下: istio 在知乎的集群落地所做的一些优化策略。

改善边车性能

默认情况下,集群中 Service 的数量达到一定规模时,服务的边车的内存、CPU消耗将显著激增。

这是因为 istio 下发到边车的代理配置是集群的全量信息。并且,集群中任何的变化都会触发一次全量的配置推送。

试想,倘若几个大型的服务同时持续部署,可能每秒钟会产生成百上千的事件,这必将酝酿灾难级的生产故障。

使用 Sidecar 配置

istio 提供了一种方法可以改善这种情况。通过配置一种叫做 Sidecar 的CRD,你可以告知 istio ,服务之间的依赖关系。 从而让 istio 仅下发所需要的代理配置。

如在示例应用 Bookinfo 中,Page 服务仅需要访问 Reviews 服务与 Details 服务。

因此,我们通过配置如下类似的配置,以避免 istio 推送给 Page 的边车不相干服务的配置信息 :

apiVersion: networking.istio.io/v1alpha3
kind: Sidecar
metadata:
  name: page
spec:
  workloadSelector:
    labels:
      app: page
  egress:
    - hosts:
        - "./reviews"  
        - "./details"  
        - "istio-system/*" # 确保系统服务的配置能下发到sidecar,以保留旧版遥测能力

在这个配置中, workloadSelector 指定作用在的工作负载组。这里使用 app: page 的标签选择器筛选出 Page 的容器。

hosts 则是负载组所需要依赖的服务的主机名列表。它的格式是 namespace/host , 同命名空间下的namespace可以缩写为 . 来表示。host 支持 * 作为通配符来匹配多个主机名。

在这里,因为Page所依赖的服务都是同命名空间,因此写作 ./reviews 和 ./details。

当然这个如果暴露给业务去配置, 那想必可能因为人的疏忽而导致服务异常的出现.

因此, 我们在服务发现 Discovery 上加了个功能:当收到服务发现请求时,自动去维护这个 Sidecar 的配置。

这样,我们几乎不需要业务同学去维护这个配置.

改善 istiod 性能

避免 XDS 不断被断开

通过给 istiod 配置 PILOT_XDS_SEND_TIMEOUT 环境变量,我们可以设定 istiod 推送的超时时间,默认为5s,在大规模集群下,建议适度调高此配置。

减少推送量

我们将 istiod 的 PILOT_FILTER_GATEWAY_CLUSTER_CONFIG 环境变量配置为 “true”,这样 istio 将仅推送 gateway 所需的服务信息 (参考VirtualService的gateway配置), 这个配置将极大的减少每次推送的量。 开启这个特性之后,笔者集群内的 istiod 每次向 gateway 推送的服务信息从四万多降低到两千。

开启流控

我们将 istiod 的 PILOT_ENABLE_FLOW_CONTROL 环境变量配置为 “true”。这个时候istiod 将会等待接收完成后,再进行下一次推送。

配置 Envoy 工作线程数

修改 Gateway 启动参数,加入 --concurrency=20 ,20 是期望 Gateway 运行的 worker 数量

提高吞吐

默认情况下,单个 istiod 的推送并发数只有 100,这在较大的集群内,可能会导致配置生效的延迟。istiod 环境变量 PILOT_PUSH_THROTTLE 可以配置这个并发数。建议匹配集群规模进行配置。

避免频发推送

PILOT_DEBOUNCE_AFTER 与 PILOT_DEBOUNCE_MAX 是配置 istiod 去抖动的两个参数。

默认配置是 100ms 与 10s ,这也就意味着,当集群中有任何事件发生时,istio 会等待 100ms。

若 100ms 内无任何事件进入,istio 会立即触发推送。否则 istio 将会等待另一个 100ms,重复这一操作,直到总共等待的时间达到 10s 时,会强制触发推送。

实践中可以适当调整这两个值以匹配集群规模和实际应用。

由于我们的集群内服务的 pod 均配置了 preStop 为 sleep 35

因此,我们调高了 PILOT_DEBOUNCE_AFTER 到 500ms,以避免频繁推送对性能产生影响(主要是 Gateway )。

同时,我们调低了 PILOT_DEBOUNCE_MAX 为 3s,以避免极端情况下推送不及时导致的 503 问题。

我们遇到的各种坑

接入基础设施的坑

全链路追踪的接入问题

我们现有的全链路追踪使用的是 Jaeger。

Jaeger 推荐的架构是: 应用先通过 UDP 协议投递到宿主机的 Jaeger Agent 上,Jaeger Agent 再投递到 Collector 组件.

这样的设计很明显是合理的,然后 Envoy 却不能支持 Jaeger Agent 所支持的协议.

我们需要的是通过宿主机的 Agent 收集 tracing 数据,来最大程度的避免受到网络抖动、collector 稳定性等问题的影响.

通过查看 Envoy 文档, Envoy 是支持opencensus 协议的,因此我们选用了支持 opencensus 协议的OpenTelemetry 的 Agent 来替代 Jaeger Agent。

istio-operator 的配置如下:

tracing:
    openCensusAgent:
        address: ipv4:$(HOST_IP):55678 # 这里是支持使用环境变量的.
        context:
        - W3C_TRACE_CONTEXT
        - B3
    sampling: 1

日志的接入问题

目前我们开启了 istio 的访问日志,但是引起了一些问题:

首先就是全站 sidecar 的日志量太大了,对于日志系统产生了很大的压力。

绝大多数的日志,没有产生什么实际价值。

这就需要有能力在不关键的服务上默认关闭访问日志,在需要的时候能够动态开启来定位问题。

我们尝试过通过 Filebeat 的一些策略抛弃指定特征的 Sidecar 日志,可以规避这个问题。

不过这也太不优雅了,可能还是需要等之后新版本的、Istio 官方推出的 Telemetry API 来支持。

kubernetes 遇到的挑战

ipvs 的问题

我们之前的 kodor 系统并不需要依赖 service, 在开始迁移 mesh 之后 ,我们发现全站的 latency 都显著的增长.

经过定位,这个问题来源于 ipvs 模块,当创建了过多的 service 后,ipvs 的 ip_vs_estimation_timer 函数执行特别慢,而这个函数是在软中断中执行的,从而导致内核的网络包处理延迟.

这个在最新的内核中已经得到了解决。

如果用的是云厂商提供的内核,也大概率都已经打上了补丁。

DNS 的问题

我们原先的部署的容器,DNSPolicy 采用的是 Default 策略,DNS 查询会被本地的 unbound 缓存。

而迁移到 ServiceMesh 的 POD ,需要使用 ClusterFirst 模式.

我们上线了一些应用后, 发现很多调用流量的 latency 都上涨很严重, 这些流量往往都是短链接.

这时我们才注意到,只靠 CoreDNS 是一定不行的,需要开启 k8s 的 NodeLocalDNS 功能来做一层 Cache。

兼容性问题

HTTP1.0 兼容性问题

默认情况下,istio 并不兼容 HTTP1.0协议。

这会使某些流量出现异常,比如某些组件的健康检查。

配置 istiod 的 PILOT_HTTP10 环境变量,设置为 “true”,就可以修复这个问题。

服务启动异常问题

服务可能会遇到无法启动的情况,这是因为istio-proxy还没有准备好. 需要开启 holdApplicationUntilProxyStarts: true ,并配置 postStart 等待 istio-proxy 准备好之后再启动服务容器.

proxy: 
    lifecycle:
        postStart:
            exec:
                command:
                - pilot-agent
                - wait

服务部署/重启过程中各种连接异常问题

在 POD 退出时,需要至少比服务容器更长的 prestop 时间,以保证服务进程存活的周期内,sidecar持续可用. 比如我们服务进程 preStop 的经验值是30s,那么 istio-proxy 的 preStop 至少要比 30s 更长.

proxy: 
    lifecycle:
        preStop:
            exec:
              command:
              - sleep
              - "35"

服务间透传 Host

我们有一些服务需要将 Host 透传给下一个服务.

image

不幸的是 istio 并不能支持这种情况,这个跟istio 使用的 envoy 方式有关:

这张图下面的是我想象中的方式,使用 VIP 直接作为Listener 的匹配规则。

上面的是 istio 实际的使用方式,相同端口的服务会使用同一个 Listener 和 Router,然后用 Host 来匹配路由。

因此可想而知:

当 page 调用 reviews 时,如果用了个不认识的 Host ,在 Router 上直接就匹配失败了.

这个问题我们使用了 Lua 插件来解决。

先把原先的 Host 存在另一个 Header 中,比如 x-my-host ,改写当前流量的 Host 为服务的 VIP。

服务的 Inbound 端再用这个 Header 覆盖掉传过来的 Host。

大致流程如图所示:

image

默认重试问题

istio 默认给所有流量都加了默认重试策略.

retryPolicy:
    hostSelectionRetryMaxAttempts: "5"
    numRetries: 2
    retriableStatusCodes:
    - 503
    retryHostPredicate:
    - name: envoy.retry_host_predicates.previous_hosts
    retryOn: connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes

这就带来了一些问题。

比如对于 grpc 来说:

当响应码为 unavailable、cancelled 时重试可能会在业务故障的时候让流量翻三倍。

这让故障更难自愈,非常难受。

因此,我们给所有 grpc 服务配置了我们自定义的重试策略,以避免出现类似的故障。

慢响应带来的问题

某些服务可能响应速度非常的慢,这就导致了有概率出现无法创建链接问题。

这是因为所有来源流量的ip端口在经过 sidecar 后都被映射到 ip 固定为 127.0.0.6 的某个端口上。

如图所示:

image

因为 TCP 的协议限制,因此有可能出现超过三万的连接数后,无法再为链接分配出端口的问题。

怎么解决呢?

我们认为 HTTP 1.1 可能是无解的,尽可能使用 HTTP 2 吧,比如 gRPC。

无 Service 关联容器的问题

区域感知失效

像 Cronjob 这样类型的服务,很难为其创建 Service,但是它仍然有需求去访问其他服务.

在目前的 istio 版本中,这样的 CronJob服务就会丢失区域感知的能力。

这个问题也并不是无解的,只需要在 pod 上添加一个叫 istio-locality 的 Label 即可。

istio 会使用这个 Label 作为 pod 的区域信息。

默认 sidecar 配置失效

如果你的 namespace 中,没有任何 Service,那么你的容器也不能使用配置好的全局默认的 Sidecar 配置。

如果你的集群很大,那么在你注入边车的时候,你的 POD 很可能会直接 OOM 掉。

解决这个问题,需要为所有 namespace 都配置默认 Sidecar。

istio 的部署

每个istio集群都需要一个根证书,来给每个代理容器颁发证书。

无论你是否需要多集群,我都建议你生成符合多集群的证书结构,即一个“总根证书“,再用这个总根证书为你的集群签署一个集群内的根证书。

这样将来从单集群模式升级到多集群模式,根证书不需要经历切换的痛楚,这在线上环境是风险极高的。

这里还需要注意一个问题:如果你一开始的安装没有指定版本,你可能很难切换到使用金丝雀来验证新版本的 istio 。

总结

ServiceMesh 提供了非常优雅的微服务体系的统一的云原生解决方案。

Istio 作为行业公认的标准,已经被绝大多数云原生新基建所兼容。

而截止到目前为止,我们在知乎落地的 ServiceMesh 的服务数量占全站服务总数的四分之一,许多关键 S 级别服务都已经接入并享受到最新的、来自云原生社区的红利。并且,这一过程仍在加速。

那么下一步呢?

服务网格关注在服务通信,是基础架构视图下的俯瞰。业务无感知接入使用,从顶层视图管理微服务。

而分布式运行时则更关注业务实现的视图,比如提供 Actor 框架,解耦基础设施资源(如解耦消息队列、解耦数据库等)。

介于两者之间呢?也有,比如 db mesh。

下一步也许是统一分布式运行时和服务网格。

我们将为业务:

  • 提供缓存能力⽽不是 Redis、Memcached ……
  • 提供异步通信⽽不是 Kafka、Pulsar ……
  • 提供存储能⼒⽽不是 MySQL、TiDB ……
  • 提供同步通信⽽不是 Dubbo、Spring Cloud、go-micro

无关语言、无关平台、专注业务。