跳转至

流量管理

在前面我们成功搭建并部署了 Istio 及其 Bookinfo 示例应用:

bookinfo page

目前搭建 Bookinfo 应用我们只用到了下面两个资源文件:

samples/bookinfo/platform/kube/bookinfo.yaml
samples/bookinfo/networking/bookinfo-gateway.yaml

前者就是通常的 Kubernetes 定义的 Deployment 和 Service 的资源清单文件,只是在部署时使用 istioctl kube-inject(或者通过对命名空间打上自动注入的标签)对这个文件定义的 Pod 注入了 sidecar 代理,后者定义了这个应用的外部访问入口 gateway,以及应用内部 productpage 服务的 VirtualService 规则,而其他内部服务的访问规则还没有被定义。

现在访问应用界面并刷新,会看到 Reviews 有时不会显示评分,有时候会显示不同样式的评分,这是因为后面有3个不同的 Reviews 服务版本,而没有配置该服务的路由规则的情况下,该服务的几个实例会被随机访问到,有的版本服务会进一步调用 Ratings 服务,有的不会。

这里我们会了解 Istio 中两个非常重要的流量管理的资源对象:

  • VirtualService(虚拟服务):用来在 Istio 中定义路由规则,控制流量路由到服务上的各种行为。
  • DestinationRule(目标规则):虚拟服务视定义将流量如何路由到指定目标地址,然后使用目标规则来配置该目标的流量,在评估虚拟服务路由规则之后,目标规则将应用于流量的真实目标地址。

VirtualService

虚拟服务(VirtualService)和目标规则(Destination Rule)是 Istio 流量路由功能的关键对象,虚拟服务配置如何在 Istio 内将请求路由到服务,每个虚拟服务包含一组路由规则,Istio 会按定义的顺序来评估它们,Istio 将每个指定的请求匹配到虚拟服务指定的实际目标地址。在网格中可以有多个虚拟服务,也可以没有。

使用虚拟服务,你可以为一个或多个主机名指定流量行为,在虚拟服务中使用路由规则,告诉 Envoy 如何发送虚拟服务的流量到合适的目标,路由目标地址可以是同一服务的不同版本,也可以是完全不同的服务。

一个典型的使用场景是将流量发送到指定服务的不同版本。客户端会将虚拟服务视为一个单一实体,将请求发送至虚拟服务主机,然后 Envoy 根据虚拟服务规则把流量路由到不同的版本。比如把 20% 的流量路由到新版本将这些用户的请求路由到版本 2,可以创建一个金丝雀发布,然后逐步增加发送到新版本服务的流量百分比。流量路由完全独立于实例部署,所以实现新版本服务的实例可以根据流量的负载来伸缩,完全不影响流量路由。相比之下,Kubernetes 则只支持基于实例缩放的流量分发,这会更复杂。

如下所示我们定义一个虚拟服务,根据请求是否来自某个特定用户,把它们路由到服务的不同版本去。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts: # 列出VirtualService的hosts,可以是IP、DNS名称、FQDN或*
  - reviews
  http: # 在下面配置VirtualService的路由规则,指定符合哪些规则的流量打到哪些Destination,支持HTTP/1.1,HTTP2,及gRPC等协议
  - match: # 指定具体的匹配规则
    - headers:
        end-user:
          exact: jason
    route:
    - destination: # 指定满足规则后将流量打到哪个具体的Destination
        host: reviews
        subset: v2
  - route:  # 流量规则按从上到下的优先级去匹配,若不满足上述规则时,进入该默认规则
    - destination:
        host: reviews
        subset: v3

我们使用 hosts 字段列举虚拟服务的主机——即用户指定的目标或是路由规则设定的目标,这是客户端向服务发送请求时使用的一个或多个地址。

hosts:
- reviews

虚拟服务主机名可以是 IP 地址、DNS 名称,比如 Kubernetes Service 的短名称,隐式或显式地指向一个完全限定域名(FQDN)。也可以使用通配符(“*”)前缀,创建一组匹配所有服务的路由规则。虚拟服务的 hosts 字段实际上不必是 Istio 服务注册的一部分,它只是虚拟的目标地址,这样可以为没有路由到网格内部的虚拟主机建模。

然后接着就是路由规则的定义,这里通过 http 字段来定义虚拟服务的路由规则,用来描述匹配条件和路由行为,它们把 HTTP/1.1、HTTP2 和 gRPC 等流量发送到 hosts 字段指定的目标,一条路由规则包含了指定的请求要流向哪个目标地址,可以有0个或多个匹配条件。

比如上面示例中的第一个路由规则有一个条件,所以使用 match 字段开始定义,我们希望该路由应用于来自 jason 用户的所有请求,所以使用 headers、end-user 和 exact 字段来匹配合适的请求。

- match:
  - headers:
    end-user:
      exact: jason

然后后面的 route 部分的 destination 字段指定了符合该条件的流量的实际目标地址,与虚拟服务的 hosts 不同,destination 的 host 必须是存在于 Istio 服务注册中心的实际目标地址,否则 Envoy 不知道该将请求发送到哪里。可以是一个有代理的服务网格,或者是一个通过服务入口被添加进来的非网格服务。本示例运行在 Kubernetes 环境中,host 名为一个 Kubernetes 服务名:

route:
- destination:
    host: reviews  # Kubernetes Service 短名称
    subset: v2

此外 destination 下面还指定了 Kubernetes 服务的子集,将符合此规则条件的请求转入其中,比如这里我们使用的子集名称是 v2,我们会在目标规则中看到如何定义服务子集。

路由规则是按从上到下的顺序选择的,虚拟服务中定义的第一条规则有最高优先级。比如上面我们定义的虚拟服务中,不满足第一个路由规则的流量均会流向一个默认的目标,第二条规则没有配置 match 条件,直接将流量导向 v3 子集。

- route:
  - destination:
      host: reviews
      subset: v3

一般建议提供一个默认的无条件基于权重的规则作为每一个虚拟服务的最后一条规则,从而确保流经虚拟服务的流量至少能够匹配到一条路由规则。

DestinationRule

与虚拟服务一样,DestinationRule(目标规则)也是 Istio 流量路由功能的关键部分,我们可以将虚拟服务看成将流量如何路由到指定目标地址,然后使用目标规则来配置该目标的流量。在评估虚拟服务路由规则之后,目标规则将应用于流量的“真实”目标地址。

可以使用目标规则来指定命名的服务子集,例如按版本为所有指定服务的实例分组,然后可以在虚拟服务的路由规则中使用这些服务子集来控制到服务不同实例的流量。目标规则还允许你在调用整个目的服务或特定服务子集时定制 Envoy 的流量策略,比如负载均衡模型、TLS 安全模式或熔断器设置。

默认情况下,Istio 使用轮询的负载均衡策略,实例池中的每个实例依次获取请求。Istio 同时支持如下的负载均衡模型,可以在 DestinationRule 中为流向某个特定服务或服务子集的流量指定这些模型。

  • 随机:请求以随机的方式转到池中的实例。
  • 权重:请求根据指定的百分比转到实例。
  • 最少请求:请求被转到最少被访问的实例。

比如在下面的示例中,目标规则为 my-svc 目标服务配置了 3 个具有不同负载均衡策略的子集:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: my-destination-rule
spec:
  host: my-svc
  trafficPolicy:
    loadBalancer:
      simple: RANDOM  # 随机的策略
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
    trafficPolicy:
      loadBalancer:
        simple: ROUND_ROBIN  # 轮询
  - name: v3
    labels:
      version: v3

每个子集都是基于一个或多个 labels 定义的,在 Kubernetes 中它是附加到 Pod 这种对象上的键/值对。除了定义子集之外,目标规则对于所有子集都有默认的流量策略,而对于具体的子集,则可以使用特定于子集的策略来覆盖它。上面的示例定义在 subsets 上的默认策略,为 v1 和 v3 子集设置了一个简单的随机负载均衡器,在 v2 策略中,指定了一个轮询负载均衡器。

在对虚拟服务和目标规则有了初步了解后,接下来我们就来对 Bookinfo 服务的访问规则进行修改。

不同服务版本访问规则

对 Reviews 服务添加一条路由规则,启用 samples/bookinfo/networking/virtual-service-reviews-v3.yaml 定义的 VirtualService 规则,内容如下:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
  - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v3

这样,所有访问 reviews 服务的流量就会被引导到 reviews 服务对应的 subset 为 v3 的 Pod 中。启用这条规则:

➜  ~ kubectl apply -f  samples/bookinfo/networking/virtual-service-reviews-v3.yaml
virtualservice.networking.istio.io/reviews created

然后查看所有的路由规则:

➜  ~ kubectl get virtualservices
NAME       GATEWAYS             HOSTS       AGE
bookinfo   [bookinfo-gateway]   [*]         158d
reviews                         [reviews]   20s

我们可以看到 reviews 的 VirtualService 已经创建成功了,此时我们去刷新应用的页面,发现访问 Reviews 失败了:

bookinfo reviews v3 failed

这是因为我们还没有创建 DestinationRule 对象,DestinationRule 对象是 VirtualService 路由生效后,配置应用与请求的策略集,用来将 VirtualService 中指定的 subset 与对应的 Pod 关联起来。

samples/bookinfo/networking/destination-rule-all.yaml 文件中有定义所有该应用中要用到的所有 DestinationRule 资源对象,其中有一段就是对 Reviews 相关的 DestinationRule 的定义:

---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: reviews
spec:
  host: reviews
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
  - name: v3
    labels:
      version: v3  # 匹配version=v3标签的Pod

我们可以看到 DestinationRule 中定义了 subsets 集合,其中 labels 就和我们之前 Service 的 labelselector 一样是去匹配 Pod 的 labels 标签的,比如我们这里 subsets 中就包含一个名为 v3 的 subset,而这个 subset 匹配的就是具有 version=v3 这个 label 标签的 Pod 集合,前面我们创建的 Bookinfo 中也有这个标签的 Pod:

➜  ~ kubectl get pods -l version=v3
NAME                          READY   STATUS    RESTARTS   AGE
reviews-v3-84779c7bbc-bsld9   2/2     Running   2          47h

这样我们就通过 DestinationRule 将 VirtualService 与 Service 不同的版本关联起来了。现在我们直接创建 DestinationRule 资源:

➜  ~ kubectl apply -f samples/bookinfo/networking/destination-rule-all.yaml
destinationrule.networking.istio.io/productpage created
destinationrule.networking.istio.io/reviews created
destinationrule.networking.istio.io/ratings created
destinationrule.networking.istio.io/details created

创建完成后,我们就可以查看目前我们网格中的 DestinationRules:

➜  ~ kubectl get destinationrule
NAME          HOST          AGE
details       details       30s
productpage   productpage   30s
ratings       ratings       30s
reviews       reviews       30s

此时再访问应用就成功了,多次刷新页面发现 Reviews 始终都展示的是 v3 版本(带红色星的)的 Ratings 了,说明我们VirtualService 的配置成功了。

reviews v3

基于权重的服务访问规则

刚刚我们演示的基于不同服务版本的服务网格的控制,接下来我们来演示下基于权重的服务访问规则的使用。

首先移除刚刚创建的 VirtualService 对象,排除对环境的影响:

➜  ~ kubectl delete virtualservice reviews
virtualservice.networking.istio.io "reviews" deleted
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS   AGE
bookinfo   ["bookinfo-gateway"]   ["*"]   2d

现在我们再去访问 Bookinfo 应用又回到最初随机访问 Reviews 的情况了。现在我们查看文件 samples/bookinfo/networking/virtual-service-reviews-80-20.yaml 的定义:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
    - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v1
      weight: 80
    - destination:
        host: reviews
        subset: v2
      weight: 20

这个规则定义了 80% 的对 Reviews 的流量会落入到 v1(没有 Ratings)这个 subset,20% 会落入 v2(带黑色 Ratings)子集,然后我们创建这个资源对象:

➜  ~ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-80-20.yaml
virtualservice.networking.istio.io/reviews created
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS         AGE
bookinfo   ["bookinfo-gateway"]   ["*"]         2d
reviews                           ["reviews"]   8s

我们查看当前网格中的 VirtualService 对象,可以看到已经有 reviews 了,证明已经创建成功了,由于上面我们已经将应用中所有的 DestinationRules 都已经创建过了,所以现在我们直接访问应用就可以了,我们多次刷新,可以发现没有出现 Ratings 的次数与出现黑色星 Ratings 的比例大概在4:1左右,并且没有红色星的 Ratings 的情况出现,说明我们配置的基于权重的 VirtualService 访问规则配置生效了。

基于请求内容的服务访问规则

除了上面基于服务版本和服务权重的方式控制服务访问之外,我们还可以基于请求内容来进行访问控制。

同样,将上面创建的 VirtualService 对象删除:

➜  ~ kubectl delete virtualservice reviews
virtualservice.networking.istio.io "reviews" deleted
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS         AGE
bookinfo   ["bookinfo-gateway"]   ["*"]         2d

查看文件 samples/bookinfo/networking/virtual-service-reviews-jason-v2-v3.yaml 的定义:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
  - reviews
  http:
  - match:
    - headers:
        end-user:
          exact: jason
    route:
    - destination:
        host: reviews
        subset: v2
  - route:
    - destination:
        host: reviews
        subset: v3

这个 VirtualService 对象定义了对 reviews 服务访问的 match 规则,意思是如果当前请求的 header 中包含 jason 这个用户信息,则只会访问到 v2 的 reviews 这个服务版本,即都带黑星的样式,如果不包含该用户信息,则都直接将流量转发给 v3 这个 reviews 的服务。

我们先不启用这个 VirtualService,先去访问下 Bookinfo 这个应用。

bookinfo login

右上角有登录按钮,在没有登录的情况下刷新页面,reviews 服务是被随机访问的,可以看到有带星不带星的样式,点击登录,在弹窗中 User Name 输入 jason,Password 为空,登录:

bookinfo logined

再刷新页面,可以看到跟未登录前的访问规则一样,也是随机的。现在我们来创建上面的 VirtualService 这个对象:

➜  ~ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-jason-v2-v3.yaml
virtualservice.networking.istio.io/reviews created
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS         AGE
bookinfo   ["bookinfo-gateway"]   ["*"]         2d
reviews                           ["reviews"]   8s

此时再回去刷新页面,发现一直都是黑星的 Reviews 版本(v2)被访问到了,注销退出后再访问,此时又一直是红星的版本(v3)被访问了。

说明我们基于 headers->end-user->exact:jason 的控制规则生效了。在 productpage 服务调用 reviews 服务时,登录的情况下会在 header 中带上用户信息,通过 exact 规则匹配到相关信息后,流量被引向了上面配置的 v2 版本中。

这里要说明一下 match 的匹配规则:

All conditions inside a single match block have AND semantics, while the list of match blocks have OR semantics. The rule is matched if any one of the match blocks succeed.

意思是一个 match 块里的条件是需要同时满足才算匹配成功的,如下面是 url 前缀和端口都必须都满足才算成功:

- match:
    - uri:
        prefix: "/wpcatalog"
      port: 443

多个 match 块之间是只要有一个 match 匹配成功了,就会被路由到它指定的服务版本去,而忽略其他的。我们的示例中在登录的条件下,满足第一个 match,所以服务一直会访问到 v2 版本。退出登录后,没有 match 规则满足匹配,所以就走最后一个 route 规则,即 v3 版本。

到这里,我们就和大家一起学习了基于不同服务版本、权重以及请求内容来控制服务流量的配置。

故障注入

对于一个系统,尤其是一个复杂的系统,重要的不是故障会不会发生,而是什么时候发生。故障处理对于开发人员和测试人员来说都特别耗费时间和精力:对于开发人员来说,他们在开发代码时需要用20%的时间写80%的主要逻辑,然后留出80%的时间处理各种非正常场景;对于测试人员来说,除了需要用80%的时间写20%的异常测试项,更要用超过80%的时间执行这些异常测试项,并构造各种故障场景,尤其是那种理论上才出现的故障,让人苦不堪言。

故障注入是一种评估系统可靠性的有效方法,例如异常处理、故障恢复等。只有当系统的所有服务都经过故障测试且具备容错能力时,整个应用才健壮可靠。故障注入从方法上来说有编译期故障注入和运行期故障注入,前者要通过修改代码来模拟故障,后者在运行阶段触发故障。Istio 的故障注入就是在网格中对特定的应用层协议进行故障注入,这样,基于 Istio 的故障注入就可以模拟出应用的故障场景了。

接下来我们就来说明如何注入故障并测试应用程序的弹性。

延迟故障注入

为了测试微服务应用程序 Bookinfo 的弹性,我们将为用户 jason 在 reviews:v2ratings 服务之间注入一个 7 秒的延迟,这个测试将会发现一个故意引入 Bookinfo 应用程序中的 bug。

首先移除之前创建的 VirtualService:

➜  ~ kubectl delete virtualservice reviews
virtualservice.networking.istio.io "reviews" deleted
➜  ~ kubectl get virtualservice
NAME       GATEWAYS             HOSTS   AGE
bookinfo   [bookinfo-gateway]   [*]     16d

为了能够让请求稳定,这里我们对 Reviews 服务配置请求路由,对应的资源清单文 samples/bookinfo/networking/virtual-service-reviews-test-v2.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
    - reviews
  http:
  - match:
    - headers:
        end-user:
          exact: jason
    route:
    - destination:
        host: reviews
        subset: v2
  - route:
    - destination:
        host: reviews
        subset: v1

上面的配置应用过后 jason 用户会被路由到 reviews:v2 版本服务,其他用户路由到 reviews:v1 版本服务。创建故障注入规则以延迟来自测试用户 jason 的流量,对应的资源清单为 samples/bookinfo/networking/virtual-service-ratings-test-delay.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ratings
spec:
  hosts:
  - ratings
  http:
  - match:
    - headers:
        end-user:
          exact: jason
    fault:
      delay:
        percentage:
          value: 100.0
        fixedDelay: 7s
    route:
    - destination:
        host: ratings
        subset: v1
  - route:
    - destination:
        host: ratings
        subset: v1

这个 VirtualService 定义了一个在 jason 登录的情况下,访问 ratings 服务的 100% 的 7s 访问延迟。前面我们知道,Bookinfo 这个示例 productpage 服务调用 reviews,reviews 的不同版本会对 ratings 进行不同的调用,其中 reviews-v1 不调用 ratings,reviews-v2 和 reviews-v3 会调用 ratings,并做不同样式的渲染。注意 reviews:v2 服务对 ratings 服务的调用具有 10 秒的硬编码连接超时。因此,尽管引入了 7 秒的延迟,我们仍然期望端到端的流程是没有任何错误的。

了解这一点后,我们现在来创建上面的 VirtualService 资源对象:

➜  ~ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-test-v2.yaml
➜  ~ kubectl apply -f samples/bookinfo/networking/virtual-service-ratings-test-delay.yaml
virtualservice.networking.istio.io/ratings created
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS         AGE
bookinfo   ["bookinfo-gateway"]   ["*"]         6d23h
ratings                           ["ratings"]   23m
reviews                           ["reviews"]   4s

通过浏览器打开 Bookinfo 应用,使用用户 jason 登录到 /productpage 页面。我们期望的是 Bookinfo 主页在大约 7 秒钟加载完成并且没有错误,但是 Reviews 部分显示了一个错误消息:Sorry, product reviews are currently unavailable for this book.

reviews unavailable

而且我们可以看到页面加载实际上用了大约6s,按照预期,我们引入的 7s 延迟不会影响到 reviews 服务,因为 reviews 和 ratings 服务间的超时被硬编码为 10 秒,但实际上在 productpage 和 reviews 服务之间也有一个 3s 的硬编码的超时,再加 1 次重试,一共 6s,所以 productpage 对 reviews 的调用在 6s 后提前超时并抛出错误了。

这种类型的错误在不同的团队独立开发不同的微服务的企业应用程序中是可能会出现的,Istio 的故障注入规则可以帮助我们识别此类异常,而不会影响最终用户。

请注意,此次故障注入限制为仅影响用户 jason,如果你以任何其他用户身份登录,则不会遇到任何延迟。

我们可以增加 productpage 与 reviews 服务之间的超时或降低 reviews 与 ratings 的超时来解决这个问题,在 reviews 服务的 v3 版本中已经修复了这个问题,reviews:v3 服务已将 reviews 与 ratings 的超时时间从 10s 降低为 2.5s,因此它可以兼容(小于)下游的 productpage 的请求。

如果我们将上面 Reviews 的流量转移到 reviews:v3 服务,然后可以尝试修改延迟规则为任何低于 2.5s 的数值,例如 2s,然后可以确认端到端的流程没有任何错误。

通过这种超时故障注入,可以帮助我们方便地发现服务间相互访问中存在的潜在问题。

中断访问故障注入

测试微服务弹性的另一种方法是引入 HTTP abort 故障,接下来我们将给 ratings 微服务为测试用户 jason 引入一个 HTTP abort。在这种情况下,我们希望页面能够立即加载,同时显示 Ratings service is currently unavailable 这样的消息。

这里我们需要使用到的资源清单文件为 samples/bookinfo/networking/virtual-service-ratings-test-abort.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ratings
spec:
  hosts:
  - ratings
  http:
  - match:
    - headers:
        end-user:
          exact: jason
    fault:
      abort:
        percentage:
          value: 100.0
        httpStatus: 500
    route:
    - destination:
        host: ratings
        subset: v1
  - route:
    - destination:
        host: ratings
        subset: v1

上面这个 VirtualService 资源对象配置了在 jason 登录时,reviews 对 ratings 访问时 100% 的返回一个500错误响应。然后创建这个资源对象:

➜  ~ kubectl apply -f samples/bookinfo/networking/virtual-service-ratings-test-abort.yaml
virtualservice.networking.istio.io/ratings created
➜  ~ kubectl get virtualservice
NAME       GATEWAYS               HOSTS         AGE
bookinfo   ["bookinfo-gateway"]   ["*"]         7d
ratings                           ["ratings"]   68m
reviews                           ["reviews"]   44m

现在我们回到 BookInfo 应用,登录 jason,刷新页面,有时候可以很快就看到 Rating 服务不可用的提示信息:

bookinfo error

如果注销用户 jason,我们将看到 /productpage 为除 jason 以外的其他用户调用了 reviews:v1(完全不调用 ratings),因此,不会看到任何错误消息,不会显示星标的图形。

other user

外部接入服务治理

随着系统越来越复杂,服务间的依赖也越来越多,当实现一个完整的功能时,只靠内部的服务是无法支撑的。且不说当前云原生环境下的复杂应用,就是在多年前的企业软件开发环境下,自己开发的程序也需要搭配若干中间件才能完成。

如下图所示,4个服务组成一个应用,后端依赖一个数据库服务,这就需要一种机制能将数据库服务接入并治理。在当前的云环境下,这个数据库可以是部署的一个外部服务,也可以是一个 RDS 的云服务。在托管的云平台上搭建的应用一般都会访问数据库、分布式缓存等中间件服务。

外部服务

关于这种第三方服务的管理,专门有一种 Open Service Broker API 来实现第三方软件的服务化,这种 API 通过定义 Catalog、Provisioning、Updating、Binding、Unbinding 等标准接口接入服务,在和 Kubernetes 结合的场景下,使用 Service Catalog 的扩展机制可以方便地在集群中管理云服务商提供的第三方服务,如下图所示。

Service Catalog

Istio 可以方便地对网格内的服务访问进行治理,那么如何对这种网格外的服务访问进行治理呢?从实际需求上看,对一个数据库访问进行管理,比对两个纯粹的内部服务访问进行管理更重要。在 Istio 中是通过一个 ServiceEntry 的资源对象将网格外的服务注册到网格上,然后像对网格内的普通服务一样对网格外的服务访问进行治理的。

为了测试这个功能,我们使用 istio 样例中的 sleep 应用来验证改功能,查看 samples/sleep/sleep.yaml 文件内容:

##################################################################################################
# Sleep service
##################################################################################################
apiVersion: v1
kind: ServiceAccount
metadata:
  name: sleep
---
apiVersion: v1
kind: Service
metadata:
  name: sleep
  labels:
    app: sleep
    service: sleep
spec:
  ports:
  - port: 80
    name: http
  selector:
    app: sleep
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sleep
spec:
  replicas: 1
  selector:
    matchLabels:
      app: sleep
  template:
    metadata:
      labels:
        app: sleep
    spec:
      terminationGracePeriodSeconds: 0
      serviceAccountName: sleep
      containers:
      - name: sleep
        image: curlimages/curl
        command: ["/bin/sleep", "3650d"]
        imagePullPolicy: IfNotPresent
        volumeMounts:
        - mountPath: /etc/sleep/tls
          name: secret-volume
      volumes:
      - name: secret-volume
        secret:
          secretName: sleep-secret
          optional: true
---

这其实就是一个简单的应用,通过 Deployment 进行控制,通过 Service 暴露服务,现在我们来部署该应用:

# 没自动注入执行下面的命令
# kubectl apply -f <(istioctl kube-inject -f samples/sleep/sleep.yaml)
➜  ~ kubectl apply -f samples/sleep/sleep.yaml
serviceaccount/sleep created
service/sleep created
deployment.apps/sleep created

Istio 有一个安装选项,global.outboundTrafficPolicy.mode,它配置 sidecar 对外部服务(那些没有在 Istio 的内部服务注册中定义的服务)的处理方式。如果这个选项设置为 ALLOW_ANY,Istio 代理允许调用未知的服务,如果这个选项设置为 REGISTRY_ONLY,那么 Istio 代理会阻止任何没有在网格中定义的 HTTP 服务或 service entry 的主机,ALLOW_ANY 是默认值,不控制对外部服务的访问。但是这种方式有一个缺点,即丢失了对外部服务流量的 Istio 监控和控制。

运行以下命令以确认 meshConfig.outboundTrafficPolicy.mode 设置为 ALLOW_ANY 或被省略:

➜  ~ kubectl get istiooperator installed-state -n istio-system -o jsonpath='{.spec.meshConfig.outboundTrafficPolicy.mode}'

正常上面的命令应该输出 ALLOW_ANY 或没有任何输出,这里为了测试 ServiceEntry 功能,我们将其更改为 REGISTRY_ONLY 模式,直接使用 istioctl install 命令指定配置重新安装即可:

➜  ~ istioctl install --set profile=demo --set meshConfig.outboundTrafficPolicy.mode=REGISTRY_ONLY -y

配置完成后可以使用上面的命令确认是否修改完成,修改完成后我们就可以接管外部 URL 的流量了。现在我们进入上面创建的 sleep 应用容器内部执行一些测试操作:

➜  ~ export SOURCE_POD=$(kubectl get pod -l app=sleep -o jsonpath={.items..metadata.name})
➜  ~ kubectl exec -it $SOURCE_POD -c sleep -- curl -sL -o /dev/null -D - http://edition.cnn.com
HTTP/1.1 502 Bad Gateway
date: Tue, 27 Jul 2021 08:57:03 GMT
server: envoy
content-length: 0

可以看到会返回 502 信息,因为该域名不在当前的服务网格中,因为现在我们不允许访问服务网格外部的 URL,服务网格中对未知的服务请求会丢弃。这就需要我们来创建一个 ServiceEntry 对象,将外部的访问服务引入到服务网格中来。

例如下面的规则定义了一个允许访问 edition.cnn.com 外部服务的 ServiceEntry:

# cnn-service-entry.yaml
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: cnn
spec:
  hosts:
  - edition.cnn.com
  ports:
  - number: 80
    name: http-port
    protocol: HTTP
  - number: 443
    name: https
    protocol: HTTPS
  resolution: DNS

现在我们来部署上面的 ServiceEntry 资源:

➜  ~ kubectl apply -f cnn-service-entry.yaml
serviceentry.networking.istio.io/cnn created
➜  ~ kubectl get serviceentry
NAME   HOSTS                 LOCATION   RESOLUTION   AGE
cnn    ["edition.cnn.com"]              DNS          16s

现在我们再去上面的 sleep 容器中执行上面的测试请求:

➜  ~ kubectl exec -it $SOURCE_POD -c sleep -- curl -sL -o /dev/null -D - http://edition.cnn.com
HTTP/1.1 301 Moved Permanently
server: envoy
retry-after: 0
content-length: 0
cache-control: public, max-age=600
location: https://edition.cnn.com/
accept-ranges: bytes
date: Tue, 27 Jul 2021 09:01:14 GMT
via: 1.1 varnish
set-cookie: countryCode=CN; Domain=.cnn.com; Path=/; SameSite=Lax
set-cookie: stateCode=SC; Domain=.cnn.com; Path=/; SameSite=Lax
set-cookie: geoData=chengdu|SC|610000|CN|AS|800|broadband|30.530|104.050; Domain=.cnn.com; Path=/; SameSite=Lax
x-served-by: cache-nrt18334-NRT
x-cache: HIT
x-cache-hits: 0
x-envoy-upstream-service-time: 322

HTTP/2 200
content-type: text/html; charset=utf-8
x-servedbyhost: ::ffff:127.0.0.1
access-control-allow-origin: *
cache-control: max-age=60
content-security-policy: default-src 'self' blob: https://*.cnn.com:* http://*.cnn.com:* *.cnn.io:* *.cnn.net:* *.turner.com:* *.turner.io:* *.ugdturner.com:* courageousstudio.com *.vgtf.net:*; script-src 'unsafe-eval' 'unsafe-inline' 'self' *; style-src 'unsafe-inline' 'self' blob: *; child-src 'self' blob: *; frame-src 'self' *; object-src 'self' *; img-src 'self' data: blob: *; media-src 'self' data: blob: *; font-src 'self' data: *; connect-src 'self' data: *; frame-ancestors 'self' https://*.cnn.com:* http://*.cnn.com:* https://*.cnn.io:* http://*.cnn.io:* *.turner.com:* https://www.google.com https://news.google.com https://amp-cnn-com.cdn.ampproject.org courageousstudio.com;
x-content-type-options: nosniff
x-xss-protection: 1; mode=block
via: 1.1 varnish, 1.1 varnish
accept-ranges: bytes
date: Tue, 27 Jul 2021 09:01:15 GMT
age: 53
set-cookie: countryCode=CN; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: stateCode=SC; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: geoData=chengdu|SC|610000|CN|AS|800|broadband|30.530|104.050; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: FastAB=0=5200,1=6029,2=0251,3=1569,4=0830,5=6265,6=6860,7=2240,8=6068,9=0871; Domain=.cnn.com; Path=/; Expires=Sat Jul 01 2023 00:00:00 GMT; SameSite=Lax
x-served-by: cache-dca17767-DCA, cache-nrt18322-NRT
x-cache: HIT, HIT
x-cache-hits: 4, 1
x-timer: S1627376475.201391,VS0,VE2
vary: , Accept-Encoding
content-length: 1118647

现在我们发现可以正常返回内容了,返回200,证明请求成功了。

说明

-L 参数让 curl 跟随连接进行重定向。这里服务器直接返回的 301 重定向响应,要求客户端再使用 HTTPS 的方式对 https://edition.cnn.com/politics 地址进行访问,第二次访问才返回了200的成功码。

除此之外,我们还可以进一步配置 egress gateway,使这些对外部的流量访问经由 egress 去到外部。

现在为 edition.cnn.com 的 80端口创建 egress gateway,并为指向 egress gateway 的流量创建一个 destination rule:

# cnn-egress-gateway.yaml
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: istio-egressgateway
spec:
  selector:
    istio: egressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - edition.cnn.com
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: egressgateway-for-cnn
spec:
  host: istio-egressgateway.istio-system.svc.cluster.local
  subsets:
  - name: cnn

除了上面的 engress gateway 对象之外,我们还需要创建 VirtualService 资源对象,将流量从 sidecar 引导至 egress gateway,再从 egress gateway 引导至外部服务:

# cnn-virtual.yaml
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: direct-cnn-through-egress-gateway
spec:
  hosts:
  - edition.cnn.com
  gateways:
  - istio-egressgateway
  - mesh  # mesh 表示网格中的所有 Sidecar,如果没有指定 gateways,则默认为 mesh
  http:
  - match:
    - gateways:
      - mesh
      port: 80
    route:
    - destination:  # 将服务路由到什么地方去
        host: istio-egressgateway.istio-system.svc.cluster.local
        subset: cnn  # subset 配置流量目的地的子集
        port:
          number: 80
      weight: 100
  - match:
    - gateways:
      - istio-egressgateway
      port: 80
    route:
    - destination:
        host: edition.cnn.com
        port:
          number: 80
      weight: 100

然后创建上面3个资源对象:

➜  ~ kubectl apply -f cnn-virtual.yaml
virtualservice.networking.istio.io/direct-cnn-through-egress-gateway created
➜  ~ kubectl apply -f cnn-egress-gateway.yaml
gateway.networking.istio.io/istio-egressgateway created
destinationrule.networking.istio.io/egressgateway-for-cnn created

创建完成后,现在我们再去 sleep 容器执行下上面的请求:

➜  ~ kubectl exec -it $SOURCE_POD -c sleep -- curl -sL -o /dev/null -D - http://edition.cnn.com
HTTP/1.1 301 Moved Permanently
server: envoy
retry-after: 0
content-length: 0
cache-control: public, max-age=600
location: https://edition.cnn.com/
accept-ranges: bytes
date: Tue, 27 Jul 2021 09:11:19 GMT
via: 1.1 varnish
set-cookie: countryCode=CN; Domain=.cnn.com; Path=/; SameSite=Lax
set-cookie: stateCode=SC; Domain=.cnn.com; Path=/; SameSite=Lax
set-cookie: geoData=chengdu|SC|610000|CN|AS|800|broadband|30.530|104.050; Domain=.cnn.com; Path=/; SameSite=Lax
x-served-by: cache-hnd18720-HND
x-cache: HIT
x-cache-hits: 0
x-envoy-upstream-service-time: 1326

HTTP/2 200
content-type: text/html; charset=utf-8
x-servedbyhost: ::ffff:127.0.0.1
access-control-allow-origin: *
cache-control: max-age=60
content-security-policy: default-src 'self' blob: https://*.cnn.com:* http://*.cnn.com:* *.cnn.io:* *.cnn.net:* *.turner.com:* *.turner.io:* *.ugdturner.com:* courageousstudio.com *.vgtf.net:*; script-src 'unsafe-eval' 'unsafe-inline' 'self' *; style-src 'unsafe-inline' 'self' blob: *; child-src 'self' blob: *; frame-src 'self' *; object-src 'self' *; img-src 'self' data: blob: *; media-src 'self' data: blob: *; font-src 'self' data: *; connect-src 'self' data: *; frame-ancestors 'self' https://*.cnn.com:* http://*.cnn.com:* https://*.cnn.io:* http://*.cnn.io:* *.turner.com:* https://www.google.com https://news.google.com https://amp-cnn-com.cdn.ampproject.org courageousstudio.com;
x-content-type-options: nosniff
x-xss-protection: 1; mode=block
via: 1.1 varnish, 1.1 varnish
accept-ranges: bytes
date: Tue, 27 Jul 2021 09:11:28 GMT
age: 666
set-cookie: countryCode=CN; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: stateCode=SC; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: geoData=chengdu|SC|610000|CN|AS|800|broadband|30.530|104.050; Domain=.cnn.com; Path=/; SameSite=None; Secure
set-cookie: FastAB=0=2118,1=1491,2=0254,3=9872,4=7302,5=8282,6=8441,7=8141,8=5363,9=5271; Domain=.cnn.com; Path=/; Expires=Sat Jul 01 2023 00:00:00 GMT; SameSite=Lax
x-served-by: cache-dca17767-DCA, cache-nrt18331-NRT
x-cache: HIT, HIT
x-cache-hits: 4, 1
x-timer: S1627377088.069582,VS0,VE2
vary: , Accept-Encoding
content-length: 1118647

正常输出结果和上面一次访问是一样的。这时我们去查看 egressgateway 的 Pod 中的容器日志:

➜  ~ kubectl logs $(kubectl get pod -l istio=egressgateway -n istio-system -o jsonpath='{.items[0].metadata.name}') -n istio-system | tail
......
[2021-07-27T09:11:18.221Z] "GET / HTTP/2" 301 - via_upstream - "-" 0 0 1282 1265 "10.244.1.199" "curl/7.78.0-DEV" "a94bf7c9-226e-9e66-8bf6-5f32b29713eb" "edition.cnn.com" "151.101.129.67:80" outbound|80||edition.cnn.com 10.244.2.127:54866 10.244.2.127:8080 10.244.1.199:46872 - -
......

可以看到有一条上面的 GET / 的日志信息,说明访问经过了 egress gateway 出去了。不过需要注意的是我们这里只定义了 egress gateway 的 80 端口流量,如果我们通过访问 HTTPS 则会直接跳转到 edition.cnn.com

用 Egress Gateway 处理 HTTPS 请求

接下来尝试使用 Egress Gateway 发起 HTTPS 请求,我们需要在相应的 ServiceEntry、Egress Gateway 和 VirtualService 中指定 TLS 协议的端口 443。

首先先清理前面定义的资源对象:

➜  ~ kubectl delete gateway istio-egressgateway
➜  ~ kubectl delete virtualservice direct-cnn-through-egress-gateway
➜  ~ kubectl delete destinationrule egressgateway-for-cnn

然后同样使用上面创建的 sleep 容器来测试访问 https 地址:

➜  ~ kubectl exec -it $SLEEP_POD -c sleep -- curl -sL -o /dev/null -D - https://edition.cnn.com
HTTP/2 200
......
content-length: 1118647

可以看到直接访问 https 地址可以得到正确的结果了。然后同样为 edition.cnn.com 创建一个 egress Gateway。除此之外还需要创建一个 DestinationRule 和一个 VirtualService,用来引导流量通过 egress gateway,并通过 egress gateway 与外部服务通信。

# cnn-https-service-rule.yaml
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: istio-egressgateway
spec:
  selector:
    istio: egressgateway
  servers:
  - port:
      number: 443
      name: tls
      protocol: TLS
    hosts:
    - edition.cnn.com
    tls:  # PASSTHROUGH 表示 gateway 按原样通过入口流量,而不校验 TLS
      mode: PASSTHROUGH
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: egressgateway-for-cnn
spec:
  host: istio-egressgateway.istio-system.svc.cluster.local
  subsets:
  - name: cnn
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: direct-cnn-through-egress-gateway
spec:
  hosts:
  - edition.cnn.com
  gateways:
  - mesh
  - istio-egressgateway
  tls:
  - match:
    - gateways:
      - mesh
      port: 443
      sniHosts:
      - edition.cnn.com
    route:
    - destination:
        host: istio-egressgateway.istio-system.svc.cluster.local
        subset: cnn
        port:
          number: 443
  - match:
    - gateways:
      - istio-egressgateway
      port: 443
      sniHosts:
      - edition.cnn.com
    route:
    - destination:
        host: edition.cnn.com
        port:
          number: 443
      weight: 100

直接创建上面的3个对象即可:

➜  ~ kubectl apply -f cnn-https-service-rule.yaml
gateway.networking.istio.io/istio-egressgateway created
destinationrule.networking.istio.io/egressgateway-for-cnn created
virtualservice.networking.istio.io/direct-cnn-through-egress-gateway created

现在再使用上面创建的 sleep 容器来测试访问 https 地址:

➜  ~ kubectl exec -it $SLEEP_POD -c sleep -- curl -sL -o /dev/null -D - https://edition.cnn.com
HTTP/2 200
......
content-length: 1118647

正常和上面返回的内容是一样的,但是这个时候这时我们去查看 egressgateway 的 Pod 中的容器日志:

➜  ~ kubectl logs $(kubectl get pod -l istio=egressgateway -n istio-system -o jsonpath='{.items[0].metadata.name}') -n istio-system | tail
......
[2021-07-27T09:18:06.716Z] "- - -" 0 UF,URX - - "-" 0 0 89 - "-" "-" "-" "-" "151.101.65.67:443" outbound|443||edition.cnn.com - 10.244.2.127:8443 10.244.1.199:53026 - -

就可以看到 https://edition.cnn.com 的请求日志了。不过需要注意,Istio 中定义的 Egress Gateway 本身并没有为其所在的节点提供任何特殊处理。集群管理员或云提供商可以在专用节点上部署 Egress gateway,并引入额外的安全措施,从而使这些节点比网格中的其他节点更安全。

由于 Istio 无法强制让所有出站流量都经过 egress gateway,Istio 只是通过 sidecar 代理实现了这种流向。攻击者只要绕过 sidecar 代理,就可以不经 egress gateway 直接与网格外的服务进行通信,从而避开了 Istio 的控制和监控。出于安全考虑,集群管理员和云供应商必须确保网格所有的出站流量都要经过 egress gateway。这需要通过 Istio 之外的机制来满足这一要求。例如,集群管理员可以配置防火墙,拒绝 egress gateway 以外的所有流量。Kubernetes 网络策略 也能禁止所有不是从 egress gateway 发起的出站流量。此外,集群管理员和云供应商还可以对网络进行限制,让运行应用的节点只能通过 gateway 来访问外部网络。要实现这一限制,可以只给 gateway Pod 分配公网 IP,并且可以配置 NAT 设备,丢弃来自 egress gateway pod 之外的所有流量。

TCP 流量转移

上面展示了多种流量管理的方式,该部分主要讲解在 Istio 中如何将 TCP 流量从一个版本迁移到另一个版本。在 Istio 中,我们可以通过配置规则来实现该功能,可以按指定的百分比将流量路由到不同的服务。和前面我们使用的 HTTP 服务非常类似。

这里我们先将所有的 TCP 请求路由到 tcp-echo:v1 这个服务,然后在通过配置 Istio 的路由权重把 20% 的流量分配到 tcp-echo:v2 这个版本的服务上。

首先,部署微服务 tcp-echo 的 v1 版本,同样直接使用示例 samples/tcp-echo/tcp-echo-services.yaml

# samples/tcp-echo/tcp-echo-services.yaml
apiVersion: v1
kind: Service
metadata:
  name: tcp-echo
  labels:
    app: tcp-echo
    service: tcp-echo
spec:
  ports:
  - name: tcp
    port: 9000
  - name: tcp-other
    port: 9001
  # Port 9002 is omitted intentionally for testing the pass through filter chain.
  selector:
    app: tcp-echo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tcp-echo-v1
  labels:
    app: tcp-echo
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: tcp-echo
      version: v1
  template:
    metadata:
      labels:
        app: tcp-echo
        version: v1
    spec:
      containers:
      - name: tcp-echo
        image: docker.io/istio/tcp-echo-server:1.2
        imagePullPolicy: IfNotPresent
        args: [ "9000,9001,9002", "one" ]
        ports:
        - containerPort: 9000
        - containerPort: 9001
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tcp-echo-v2
  labels:
    app: tcp-echo
    version: v2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: tcp-echo
      version: v2
  template:
    metadata:
      labels:
        app: tcp-echo
        version: v2
    spec:
      containers:
      - name: tcp-echo
        image: docker.io/istio/tcp-echo-server:1.2
        imagePullPolicy: IfNotPresent
        args: [ "9000,9001,9002", "two" ]
        ports:
        - containerPort: 9000
        - containerPort: 9001

上面部署了两个版本的 tcp-echo 服务,要注意的是 Service 对象中的 Label Selector 是 app=tcp-echo,这样该对象就会匹配到上面的两个版本的对象。使用如下命令安装:

# 没有自动注入执行下面的命令
# kubectl apply -f <(istioctl kube-inject -f samples/tcp-echo/tcp-echo-services.yaml)
➜  ~ kubectl apply -f samples/tcp-echo/tcp-echo-services.yaml
service/tcp-echo created
deployment.apps/tcp-echo-v1 created
deployment.apps/tcp-echo-v2 created
➜  ~ kubectl get pods -l app=tcp-echo
NAME                           READY   STATUS    RESTARTS   AGE
tcp-echo-v1-7dd5c5dcfb-vjt7q   2/2     Running   0          94s
tcp-echo-v2-56cd9b5c4f-vlz5s   2/2     Running   0          94s

接下来, 将微服务 tcp-echo 的 TCP 流量全部路由到 v1 版本上,直接使用 samples/tcp-echo/tcp-echo-all-v1.yaml 这个示例文件中的对象,如下所示:

# samples/tcp-echo/tcp-echo-all-v1.yaml
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: tcp-echo-gateway
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 31400
      name: tcp
      protocol: TCP
    hosts:
    - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: tcp-echo-destination
spec:
  host: tcp-echo
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: tcp-echo
spec:
  hosts:
  - "*"
  gateways:
  - tcp-echo-gateway
  tcp:
  - match:
    - port: 31400
    route:
    - destination:
        host: tcp-echo
        port:
          number: 9000
        subset: v1

这里创建了一个 Gateway 对象,用来路由 TCP 的请求,要注意这里匹配的是 istio=ingressgateway 这个 Service 对象的 31400 这个 TCP 端口:

➜  ~ kubectl get svc -n istio-system -l istio=ingressgateway -o yaml
......
- name: tcp
  nodePort: 31175
  port: 31400
  protocol: TCP
  targetPort: 31400
......

然后在 DestinationRule 对象中声明了 v1v2 两个子集服务,在 VirtualService 对象中声明具体的路由规则,我们可以看到是直接全都路由到了 v1 这个子集中去,直接创建上面的资源对象:

➜  ~ kubectl apply -f samples/tcp-echo/tcp-echo-all-v1.yaml
gateway.networking.istio.io/tcp-echo-gateway created
destinationrule.networking.istio.io/tcp-echo-destination created
virtualservice.networking.istio.io/tcp-echo created

创建完成后我们就可以通过 istio-ingressgateway 去访问上面的 TCP 服务了,要在外部访问该服务,同样我们可以通过 istio-ingressgateway 的 NodePort 来访问,这里的 TCP 对应的 nodePort 端口是 31175,这里可以使用如下所示的命令来测试:

➜  ~ export INGRESS_PORT=31175  # TCP 服务的 nodePort 端口
➜  ~ export INGRESS_HOST=192.168.31.30  # 任意一个节点的 IP

然后向微服务 tcp-echo 发送一些 TCP 请求:

➜  ~ for i in {1..10}; do \
docker run -e INGRESS_HOST=$INGRESS_HOST -e INGRESS_PORT=$INGRESS_PORT -it --rm busybox sh -c "(date; sleep 1) | nc $INGRESS_HOST $INGRESS_PORT"; \
done
one Thu Jul 29 06:42:32 UTC 2021
one Thu Jul 29 06:42:33 UTC 2021
one Thu Jul 29 06:42:35 UTC 2021
one Thu Jul 29 06:42:36 UTC 2021
one Thu Jul 29 06:42:37 UTC 2021
one Thu Jul 29 06:42:39 UTC 2021
one Thu Jul 29 06:42:40 UTC 2021
one Thu Jul 29 06:42:41 UTC 2021
one Thu Jul 29 06:42:43 UTC 2021
one Thu Jul 29 06:42:44 UTC 2021

从上面的日志可以看出,所有时间戳的前缀都是 one,这意味着所有流量都被路由到了 tcp-echo 服务的 v1 版本中。

然后使用新的路由规则将 20% 的流量从 tcp-echo:v1 转移到 tcp-echo:v2,对应的示例文件为 samples/tcp-echo/tcp-echo-20-v2.yaml,内容如下所示:

# samples/tcp-echo/tcp-echo-20-v2.yaml
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: tcp-echo
spec:
  hosts:
  - "*"
  gateways:
  - tcp-echo-gateway
  tcp:
  - match:
    - port: 31400
    route:
    - destination:
        host: tcp-echo
        port:
          number: 9000
        subset: v1
      weight: 80
    - destination:
        host: tcp-echo
        port:
          number: 9000
        subset: v2
      weight: 20

上面的 VirtualService 对象也很简单,给 v1 子集服务加了 80% 的权重,另外有 20% 的权重被路由到了 v2 这个子集服务中去了,同样直接创建上面的资源对象更新路由规则:

➜  ~ kubectl apply -f samples/tcp-echo/tcp-echo-20-v2.yaml
virtualservice.networking.istio.io/tcp-echo configured

等待几秒钟,让新的路由规则生效。然后同样用上面的测试命令向 tcp-echo 服务发送 TCP 请求:

➜  ~ for i in {1..10}; do \
  docker run -e INGRESS_HOST=$INGRESS_HOST -e INGRESS_PORT=$INGRESS_PORT -it --rm busybox sh -c "(date; sleep 1) | nc $INGRESS_HOST $INGRESS_PORT"; \
done
one Thu Jul 29 06:44:19 UTC 2021
one Thu Jul 29 06:44:20 UTC 2021
one Thu Jul 29 06:44:22 UTC 2021
two Thu Jul 29 06:44:23 UTC 2021
one Thu Jul 29 06:44:24 UTC 2021
two Thu Jul 29 06:44:26 UTC 2021
one Thu Jul 29 06:44:27 UTC 2021
one Thu Jul 29 06:44:29 UTC 2021
one Thu Jul 29 06:44:30 UTC 2021
one Thu Jul 29 06:44:31 UTC 2021

现在我们可以发现,有大约 20% 的流量时间戳前缀是 two ,这意味着有 80% 的 TCP 流量路由到了 tcp-echo 服务的 v1 版本,与此同时有 20% 流量路由到了 v2 版本。

这样我们实现了对 TCP 流量的转移,和前面我们使用的 HTTP 服务非常类似。

熔断

熔断是创建弹性微服务应用程序的重要模式,熔断能够使我们的应用程序具备应对来自故障、潜在峰值和其他未知网络因素影响的能力,熔断在微服务框架中也是必备的一个功能。

同样在 Istio 中也是原生就支持熔断功能的,首先部署示例服务 samples/httpbin/httpbin.yaml

##################################################################################################
# httpbin service
##################################################################################################
apiVersion: v1
kind: ServiceAccount
metadata:
  name: httpbin
---
apiVersion: v1
kind: Service
metadata:
  name: httpbin
  labels:
    app: httpbin
    service: httpbin
spec:
  ports:
  - name: http
    port: 8000
    targetPort: 80
  selector:
    app: httpbin
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpbin
spec:
  replicas: 1
  selector:
    matchLabels:
      app: httpbin
      version: v1
  template:
    metadata:
      labels:
        app: httpbin
        version: v1
    spec:
      serviceAccountName: httpbin
      containers:
      - image: docker.io/kennethreitz/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        ports:
        - containerPort: 80

该应用也很简单,直接使用如下所示命令部署即可:

# 没有配置自动注入执行下面的命令
# kubectl apply -f <(istioctl kube-inject -f samples/httpbin/httpbin.yaml)
➜  ~ kubectl apply -f samples/httpbin/httpbin.yaml
serviceaccount/httpbin created
service/httpbin created
deployment.apps/httpbin created
➜  ~ kubectl get pods -l app=httpbin
NAME                       READY   STATUS    RESTARTS   AGE
httpbin-74fb669cc6-fdsrd   2/2     Running   0          2m47s

应用部署完成后,接着创建一个目标规则,在调用 httpbin 服务时配置熔断设置。这里通过一个 DestinationRule 对象来创建一个如下所示的资源对象:

➜  ~ kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: httpbin
spec:
  host: httpbin
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 1  # 最大连接数为1
      http:
        http1MaxPendingRequests: 1  # 最大请求数为1
        maxRequestsPerConnection: 1
    outlierDetection:
      consecutiveErrors: 1
      interval: 1s
      baseEjectionTime: 3m
      maxEjectionPercent: 100
EOF

目标规则创建完成后,接着我们来创建一个客户端应用来发送流量请求到 httpbin 服务,这里我们使用一个名为 Fortio 的测试客户端应用,该应用可以控制连接数、并发数及发送 HTTP 请求的延迟,通过 Fortio 能够有效的触发前面在 DestinationRule 中设置的熔断策略。

客户端应用资源清单文件位于 samples/httpbin/sample-client/fortio-deploy.yaml,内容如下所示:

# samples/httpbin/sample-client/fortio-deploy.yaml
apiVersion: v1
kind: Service
metadata:
  name: fortio
  labels:
    app: fortio
    service: fortio
spec:
  ports:
  - port: 8080
    name: http
  selector:
    app: fortio
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fortio-deploy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fortio
  template:
    metadata:
      annotations:
        # This annotation causes Envoy to serve cluster.outbound statistics via 15000/stats
        # in addition to the stats normally served by Istio.  The Circuit Breaking example task
        # gives an example of inspecting Envoy stats.
        sidecar.istio.io/statsInclusionPrefixes: cluster.outbound,cluster_manager,listener_manager,http_mixer_filter,tcp_mixer_filter,server,cluster.xds-grpc
      labels:
        app: fortio
    spec:
      containers:
      - name: fortio
        image: fortio/fortio:latest_release
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
          name: http-fortio
        - containerPort: 8079
          name: grpc-ping

直接部署上面的客户端应用,记得注入 Istio Sidecar 代理,以便 Istio 对其网络交互进行管理:

# 没设置自动注入执行下面的命令
# kubectl apply -f <(istioctl kube-inject -f samples/httpbin/sample-client/fortio-deploy.yaml)
➜  ~ kubectl apply -f samples/httpbin/sample-client/fortio-deploy.yaml
service/fortio created
deployment.apps/fortio-deploy created
➜  ~ kubectl get pods -l app=fortio
NAME                             READY   STATUS    RESTARTS   AGE
fortio-deploy-576dbdfbc4-zglvn   2/2     Running   0          4m14s

部署完成后我们可以进入到客户端应用中使用 Fortio 工具调用 httpbin 服务。-curl 参数表示发送一次请求:

➜  ~ FORTIO_POD=$(kubectl get pod | grep fortio | awk '{ print $1 }')
➜  ~ kubectl exec -it $FORTIO_POD  -c fortio -- /usr/bin/fortio load -curl  http://httpbin:8000/get
HTTP/1.1 200 OK
server: envoy
date: Thu, 29 Jul 2021 08:59:58 GMT
content-type: application/json
content-length: 594
access-control-allow-origin: *
access-control-allow-credentials: true
x-envoy-upstream-service-time: 21

{
  "args": {},
  "headers": {
    "Host": "httpbin:8000",
    "User-Agent": "fortio.org/fortio-1.11.3",
    "X-B3-Parentspanid": "e747f80d5d599e50",
    "X-B3-Sampled": "1",
    "X-B3-Spanid": "d40f4facfb530878",
    "X-B3-Traceid": "a951c302acc99c85e747f80d5d599e50",
    "X-Envoy-Attempt-Count": "1",
    "X-Forwarded-Client-Cert": "By=spiffe://cluster.local/ns/default/sa/httpbin;Hash=42b291e325a44069eb277ae3c7430904130e10536abf5b8fcf7f49bb20d48647;Subject=\"\";URI=spiffe://cluster.local/ns/default/sa/default"
  },
  "origin": "127.0.0.6",
  "url": "http://httpbin:8000/get"
}

可以看到调用后端服务的请求是成功的,然后我们来测试下熔断。在 DestinationRule 配置中,我们定义了 maxConnections: 1http1MaxPendingRequests: 1,表示如果并发的连接和请求数超过一个,则在 istio-proxy 进行进一步的请求和连接时,后续的请求或连接将被阻止。

比如这里我们发送并发数为 2 的连接(-c 2),请求 20 次(-n 20)来观察下现象:

➜  ~ kubectl exec -it $FORTIO_POD  -c fortio /usr/bin/fortio -- load -c 2 -qps 0 -n 20 -loglevel Warning http://httpbin:8000/get
09:02:38 I logger.go:127> Log level is now 3 Warning (was 2 Info)
Fortio 1.11.3 running at 0 queries per second, 4->4 procs, for 20 calls: http://httpbin:8000/get
Starting at max qps with 2 thread(s) [gomax 4] for exactly 20 calls (10 per thread + 0)
09:02:38 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:02:38 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:02:38 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:02:38 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:02:38 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
Ended after 50.345956ms : 20 calls. qps=397.25
Aggregated Function Time : count 20 avg 0.0046835573 +/- 0.003013 min 0.000630634 max 0.013505455 sum 0.093671147
# range, mid point, percentile, count
>= 0.000630634 <= 0.001 , 0.000815317 , 15.00, 3
> 0.002 <= 0.003 , 0.0025 , 25.00, 2
> 0.003 <= 0.004 , 0.0035 , 45.00, 4
> 0.004 <= 0.005 , 0.0045 , 70.00, 5
> 0.005 <= 0.006 , 0.0055 , 80.00, 2
> 0.006 <= 0.007 , 0.0065 , 85.00, 1
> 0.008 <= 0.009 , 0.0085 , 90.00, 1
> 0.009 <= 0.01 , 0.0095 , 95.00, 1
> 0.012 <= 0.0135055 , 0.0127527 , 100.00, 1
# target 50% 0.0042
# target 75% 0.0055
# target 90% 0.009
# target 99% 0.0132044
# target 99.9% 0.0134753
Sockets used: 7 (for perfect keepalive, would be 2)
Jitter: false
Code 200 : 15 (75.0 %)
Code 503 : 5 (25.0 %)
Response Header Sizes : count 20 avg 172.5 +/- 99.59 min 0 max 230 sum 3450
Response Body/Total Sizes : count 20 avg 678.25 +/- 252.4 min 241 max 824 sum 13565
All done 20 calls (plus 0 warmup) 4.684 ms avg, 397.3 qps

可以看到大部分请求还是都完成了,但是并不是一半的请求失败,这是因为 istio-proxy 并不是100%准确的:

Code 200 : 15 (75.0 %)
Code 503 : 5 (25.0 %)

然后我们将并发连接数提高到 3 个:

➜  ~ kubectl exec -it $FORTIO_POD  -c fortio /usr/bin/fortio -- load -c 3 -qps 0 -n 30 -loglevel Warning http://httpbin:8000/get
09:03:37 I logger.go:127> Log level is now 3 Warning (was 2 Info)
Fortio 1.11.3 running at 0 queries per second, 4->4 procs, for 30 calls: http://httpbin:8000/get
Starting at max qps with 3 thread(s) [gomax 4] for exactly 30 calls (10 per thread + 0)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
09:03:37 W http_client.go:693> Parsed non ok code 503 (HTTP/1.1 503)
Ended after 39.968362ms : 30 calls. qps=750.59
Aggregated Function Time : count 30 avg 0.0029070414 +/- 0.001938 min 0.000332007 max 0.006806616 sum 0.087211243
# range, mid point, percentile, count
>= 0.000332007 <= 0.001 , 0.000666004 , 26.67, 8
> 0.001 <= 0.002 , 0.0015 , 40.00, 4
> 0.002 <= 0.003 , 0.0025 , 43.33, 1
> 0.003 <= 0.004 , 0.0035 , 63.33, 6
> 0.004 <= 0.005 , 0.0045 , 90.00, 8
> 0.005 <= 0.006 , 0.0055 , 93.33, 1
> 0.006 <= 0.00680662 , 0.00640331 , 100.00, 2
# target 50% 0.00333333
# target 75% 0.0044375
# target 90% 0.005
# target 99% 0.00668562
# target 99.9% 0.00679452
Sockets used: 15 (for perfect keepalive, would be 3)
Jitter: false
Code 200 : 17 (56.7 %)
Code 503 : 13 (43.3 %)
Response Header Sizes : count 30 avg 130.33333 +/- 114 min 0 max 230 sum 3910
Response Body/Total Sizes : count 30 avg 571.36667 +/- 288.9 min 241 max 824 sum 17141
All done 30 calls (plus 0 warmup) 2.907 ms avg, 750.6 qps

我们可以看到大部分的请求都被熔断器拦截:

Code 200 : 17 (56.7 %)
Code 503 : 13 (43.3 %)

我们可以通过 istio-proxy 状态来了解更多关于熔断的详情,使用如下命令进行查看:

➜  ~ kubectl exec $FORTIO_POD -c istio-proxy -- pilot-agent request GET stats | grep httpbin | grep pending
cluster.outbound|8000||httpbin.default.svc.cluster.local.circuit_breakers.default.remaining_pending: 1
cluster.outbound|8000||httpbin.default.svc.cluster.local.circuit_breakers.default.rq_pending_open: 0
cluster.outbound|8000||httpbin.default.svc.cluster.local.circuit_breakers.high.rq_pending_open: 0
cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_pending_active: 0
cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_pending_failure_eject: 0
cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_pending_overflow: 18
cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_pending_total: 33

其中 upstream_rq_pending_overflow 值为18,这表示目前为止已有18个调用被标记为熔断了。

流量镜像

流量镜像也称为影子流量,是一个以尽可能低的风险为生产带来变化的强大的功能,镜像会将实时流量的副本发送到镜像服务。前面我们在学习 Traefik 的时候就测试过类似的功能。

这里我们先把流量全部路由到 v1 版本的测试服务中,然后添加路由规则将一部分流量镜像到 v2 版本中去,来测试流量镜像功能。

首先部署 v1 版本的 httpbin 服务:

# 没有自动注入用下面的命令
# cat <<EOF | istioctl kube-inject -f - | kubectl apply -f -
➜  ~ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpbin-v1
spec:
  selector:
    matchLabels:
      app: httpbin
      version: v1
  template:
    metadata:
      labels:
        app: httpbin
        version: v1
    spec:
      containers:
      - image: docker.io/kennethreitz/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        command: ["gunicorn", "--access-logfile", "-", "-b", "0.0.0.0:80", "httpbin:app"]
        ports:
        - containerPort: 80
EOF

然后部署 v2 版本的 httpbin 服务:

# 没有自动注入用下面的命令
# cat <<EOF | istioctl kube-inject -f - | kubectl apply -f -
➜  ~ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpbin-v2
spec:
  selector:
    matchLabels:
      app: httpbin
      version: v2
  template:
    metadata:
      labels:
        app: httpbin
        version: v2
    spec:
      containers:
      - image: docker.io/kennethreitz/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        command: ["gunicorn", "--access-logfile", "-", "-b", "0.0.0.0:80", "httpbin:app"]
        ports:
        - containerPort: 80
EOF

然后创建一个 httpbin 的 Service 对象,关联上面的两个版本服务:

➜  ~ kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
  name: httpbin
  labels:
    app: httpbin
spec:
  ports:
  - name: http
    port: 8000
    targetPort: 80
  selector:
    app: httpbin
EOF

然后在部署一个 sleep 服务,这样就可以使用 curl 来提供负载了:

➜  ~ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sleep
spec:
  selector:
    matchLabels:
      app: sleep
  template:
    metadata:
      labels:
        app: sleep
    spec:
      containers:
      - name: sleep
        image: tutum/curl
        command: ["/bin/sleep","infinity"]
        imagePullPolicy: IfNotPresent
EOF

默认情况下,Kubernetes 在 httpbin 服务的两个版本之间进行负载均衡,这里我们首先创建一个默认路由规则,将所有流量路由到服务的 v1 版本中去:

➜  ~ kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
spec:
  hosts:
  - httpbin
  http:
  - route:
    - destination:
        host: httpbin
        subset: v1
      weight: 100
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: httpbin
spec:
  host: httpbin
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
EOF

上面对象创建完成后,所有流量都会转到 httpbin:v1 服务下面去,使用如下所示的命令来测试以下:

➜  ~ export SLEEP_POD=$(kubectl get pod -l app=sleep -o jsonpath={.items..metadata.name})
➜  ~ kubectl exec -it $SLEEP_POD -c sleep -- sh -c 'curl  http://httpbin:8000/headers' | python -m json.tool
{
    "headers": {
        "Accept": "*/*",
        "Host": "httpbin:8000",
        "User-Agent": "curl/7.35.0",
        "X-B3-Parentspanid": "78b887f6aad488a7",
        "X-B3-Sampled": "1",
        "X-B3-Spanid": "c43062ba89eea2be",
        "X-B3-Traceid": "1a960f8580d45f4778b887f6aad488a7",
        "X-Envoy-Attempt-Count": "1",
        "X-Forwarded-Client-Cert": "By=spiffe://cluster.local/ns/default/sa/httpbin;Hash=62f26f9baa4c8583acd10589050b24e4f1350616c3faacefc4abd5cfecfe345f;Subject=\"\";URI=spiffe://cluster.local/ns/default/sa/sleep"
    }
}

然后可以分别查看 httpbin 服务 v1v2 两个 Pods 的日志,正常情况下只会看到 v1 会产生日志,v1 中没有:

➜  ~ export V1_POD=$(kubectl get pod -l app=httpbin,version=v1 -o jsonpath={.items..metadata.name})
➜  ~ kubectl logs -f $V1_POD -c httpbin
127.0.0.6 - - [29/Jul/2021:10:51:59 +0000] "GET /headers HTTP/1.1" 200 523 "-" "curl/7.35.0"
➜  ~ export V2_POD=$(kubectl get pod -l app=httpbin,version=v2 -o jsonpath={.items..metadata.name})
➜  ~ kubectl logs -f $V2_POD -c httpbin

这是因为上面我们创建的路由规则是将所有的请求都路由到了 v1 这个版本的服务中去。接下来我们更改下流量规则,将流量镜像到 v2 版本的服务中去:

➜  ~ kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
spec:
  hosts:
  - httpbin
  http:
  - route:
    - destination:
        host: httpbin
        subset: v1
      weight: 100
    mirror:
      host: httpbin
      subset: v2
    mirror_percent: 100
EOF

这个路由规则会发送 100% 流量到 v1 这个子集服务,然后通过 mirror 属性配置了将流量也 100% 镜像到了 httpbin:v2 服务。当流量被镜像时,请求将发送到镜像服务中,并在 headers 中的 Host/Authority 属性值上追加 -shadow

需要注意的是这些被镜像的流量是『 即发即弃』的,也就是说镜像请求的响应会被丢弃。

现在我们在用上面的命令来测试发送一次请求:

➜  ~ kubectl exec -it $SLEEP_POD -c sleep -- sh -c 'curl  http://httpbin:8000/headers' | python -m json.tool

现在就可以看到 v1v2 中都有了访问日志,v2 中的访问日志就是由镜像流量产生的,这些请求的实际目标是 v1

➜  ~ kubectl logs -f $V1_POD -c httpbin
127.0.0.6 - - [29/Jul/2021:10:51:59 +0000] "GET /headers HTTP/1.1" 200 523 "-" "curl/7.35.0"
127.0.0.6 - - [29/Jul/2021:10:52:49 +0000] "GET /headers HTTP/1.1" 200 523 "-" "curl/7.35.0"
➜  ~ kubectl logs -f $V2_POD -c httpbin
127.0.0.6 - - [29/Jul/2021:10:52:49 +0000] "GET /headers HTTP/1.1" 200 563 "-" "curl/7.35.0"

到这里我们就实现了流量镜像的功能。