通过使用一个真实的使用场景,我们探讨 Istio 如何路由 TCP 流量,以及如何克服我们亲身遇到的一些常见陷阱。

概述

我最近遇到一个 Istio 的设置,下游(客户端)和上游(服务器)都在使用同一组端口。
1. 8080 端口用于 HTTP 协议
2. 5701 端口用于 Hazelcast 协议,这是一个基于 Java 的内存数据库,嵌入到 pod 的工作负载中并使用 TCP 协议

这里介绍了该设置:

Istio TCP base

理解 Istio 和 TCP 服务

理论上,这里有两种类型的通信发生:

  • 每一个 Hazelcast 数据库(上图中红色和紫色的圆柱体)通过 TCP 协议在 5701 端口互相通信。首先通过 Hazelcast Kubernetes 插件 发现集群,将该插件设置为调用 API 以获取 Pod IP。然后在 TCP 层面使用 pod 的 IP:port 进行连接。
  • manager 在 HTTP 8080 端口上调用 app

我们现在关注第一种通信类型的连接,特别是发生在 manager Pod 之间的连接,因为它们经由了 Istio Proxy。

让我们首先利用 istioctl CLI 来获取其中一个 pod 上的监听器的配置。

istioctl pc listeners manager-c844dbb5f-ng5d5.manager --port 5701

ADDRESS         PORT     TYPE
10.12.0.11      5701     TCP
10.0.23.154     5701     TCP
10.0.18.143     5701     TCP

我们有 3 个端口号是 5701 的 entry。它们的类型都是我们定义好的 TCP。 可以清楚地看到,除了本地 IP(

10.12.0.11)有一个 entry 外,其余使用 5701 端口的每个服务如 manager manager (10.0.23.154) 和 app app 服务 (10.0.18.143).

inbound (入站) 连接

第一个 entry (地址为 10.12.0.11) 是 INBOUND 监听器,当连接进入 Pod 时监听器被使用。 由于我们正在运行一个 TCP 服务,因此该 Inbound 连接没有经由路由,而是直接指向一个集群,集群名是 inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local

检查 5701 5701 端口上的所有集群:


istioctl pc clusters manager-7948dffbdd-p44xx.manager --port 5701

SERVICE FQDN PORT SUBSET DIRECTION TYPE
app.app.svc.cluster.local 5701 - outbound EDS
manager.manager.svc.cluster.local 5701 - outbound EDS
manager.manager.svc.cluster.local 5701 tcp-hazelcast inbound STATIC

最后一条是我们上面提到的 INBOUND, 检查它。


istioctl pc clusters manager-7948dffbdd-p44xx.manager --port 5701 --direction inbound -o json

[
    {
        "name": "inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local",
        "type": "STATIC",
        "connectTimeout": "1s",
        "loadAssignment": {
            "clusterName": "inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local",
            "endpoints": [
                {
                    "lbEndpoints": [
                        {
                            "endpoint": {
                                "address": {
                                    "socketAddress": {
                                        "address": "127.0.0.1",
                                        "portValue": 5701
                                    }
                                }
                            }
                        }
                    ]
                }
            ]
        },
        "circuitBreakers": {
            "thresholds": [
                {
                    "maxConnections": 4294967295,
                    "maxPendingRequests": 4294967295,
                    "maxRequests": 4294967295,
                    "maxRetries": 4294967295
                }
            ]
        }
    }
]

检查 lbEndpoints 键对应的内容,发现该集群只是将连接转发到本地 (127.0.0.1) 的 5701端口,即我们的 app

Outbound (出站) 连接

outbound 连接是从 pod 内部发起,到达外部资源。

从我们看到的情况来看,有两个已知的 endpoint (端点) 定义了 5701端口: manager.manager 服务和 app.app 服务。

检查对 manager 的内容:


istioctl pc listeners manager-7948dffbdd-p44xx.manager --port 5701 --address 10.0.23.154 -o json

[
    {
        "name": "10.0.23.154_5701",
        "address": {
            "socketAddress": {
                "address": "10.0.23.154",
                "portValue": 5701
            }
        },
        "filterChains": [
            {
                "filters": [
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "[@type](http://twitter.com/type)": "type.googleapis.com/envoy.config.filter.network.tcp_proxy.v2.TcpProxy",
                            "statPrefix": "outbound|5701||manager.manager.svc.cluster.local",
                            "cluster": "outbound|5701||manager.manager.svc.cluster.local",
                            "accessLog": [
...
                            ]
                        }
                    }
                ]
            }
        ],
        "deprecatedV1": {
            "bindToPort": false
        },
        "trafficDirection": "OUTBOUND"
    }
]

我们查看到了一条 filterChain 和一个名为 envoy.tcp.proxy filter的 filter。
这里,代理再次将我们指向名为 outbound|5701||manager.manager.svc.cluster.local的集群。
Envoy 并没有使用任何路由,因为我们使用的是 TCP 协议,而且除了 IP 和端口之外我们没有任何其他的路由依据。

查看


istioctl pc clusters manager-7948dffbdd-p44xx.manager --port 5701 --fqdn manager.manager.svc.cluster.local --direction outbound -o json

[
    {
        "transportSocketMatches": [
            {
                "name": "tlsMode-istio",
                "match": {
                    "tlsMode": "istio"
                },
...
                }
            },
            {
                "name": "tlsMode-disabled",
                "match": {},
                "transportSocket": {
                    "name": "envoy.transport_sockets.raw_buffer"
                }
            }
        ],
        "name": "outbound|5701||manager.manager.svc.cluster.local",
        "type": "EDS",
        "edsClusterConfig": {
            "edsConfig": {
                "ads": {}
            },
            "serviceName": "outbound|5701||manager.manager.svc.cluster.local"
        },
        "connectTimeout": "1s",
        "circuitBreakers": {
...
        },
        "filters": [
...
        ]
    }
]

关注重要的信息:

  • transportSocketMatches中的前两个 block:Envoy 会检查是否可以使用 SSL (TLS),如果可以就设置证书, 否则使用普通 TCP。
  • 然后使用 EDS 协议找到目的地 pod。 EDS 是 Endpoint Discovery Service 端点发现服务的缩写。
  • Envoy 为以下服务查找其端点列表。 outbound|5701||manager.manager.svc.cluster.local
  • 这些端点是在 Kubernetes 服务端点列表(kubectl get endpoints -n manager manager)的基础上选择的。

我们也可以检查 Istio 中配置的端点列表。


istioctl pc endpoints manager-7948dffbdd-p44xx.manager --cluster "outbound|5701||manager.manager.svc.cluster.local"

ENDPOINT            STATUS      OUTLIER CHECK     CLUSTER
10.12.0.12:5701     HEALTHY     OK                outbound|5701||manager.manager.svc.cluster.local
10.12.1.6:5701      HEALTHY     OK                outbound|5701||manager.manager.svc.cluster.local

到目前为止一切顺利。

测试设置

为了演示整个过程,我们连接到其中一个 manager Pod,并在 5701端口上调用服务。


k -n manager exec -ti manager-7948dffbdd-p44xx -c manager sh

telnet manager.manager 5701

在按了几次回车键后,你应该得到以下答案。

Connected to manager.manager

Connection closed by foreign host

我们使用的服务器其实是一个 HTTPS 的 Web 服务器,期待 TLS 握手...... 但不管怎样,我们只想连接到这里的 TCP 端口。

重复这个命令多次。

让我们看看来自 Istio-Proxy sidecars 的日志。 我在这里使用的是 Stern ,它是一个以简单而优雅的方式从 K8s 转储日志的工具。 如果你没有 Stern,请使用 kubectl logs (但使用时应当谨慎)。


stern -n manager manager -c istio-proxy

manager-7948dffbdd-p44xx istio-proxy [2020-07-23T14:26:27.081Z] "- - -" 0 - "-" "-" 6 0 506 - "-" "-" "-" "-" "10.12.0.11:5701" outbound|5701||manager.manager.svc.cluster.local 10.12.0.11:51100 10.0.23.154:5701 10.12.0.11:47316 - -
manager-7948dffbdd-p44xx istio-proxy [2020-07-23T14:26:27.081Z] "- - -" 0 - "-" "-" 6 0 506 - "-" "-" "-" "-" "127.0.0.1:5701" inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local 127.0.0.1:59430 10.12.0.11:5701 10.12.0.11:51100 outbound_.5701_._.manager.manager.svc.cluster.local -

manager-7948dffbdd-p44xx istio-proxy [2020-07-23T14:26:08.632Z] "- - -" 0 - "-" "-" 6 0 521 - "-" "-" "-" "-" "10.12.1.6:5701" outbound|5701||manager.manager.svc.cluster.local 10.12.0.11:49150 10.0.23.154:5701 10.12.0.11:47258 - -
manager-7948dffbdd-sh7rx istio-proxy [2020-07-23T14:26:08.634Z] "- - -" 0 - "-" "-" 6 0 519 - "-" "-" "-" "-" "127.0.0.1:5701" inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local 127.0.0.1:57844 10.12.2.8:5701 10.12.0.11:49150 outbound_.5701_._.manager.manager.svc.cluster.local -

我将请求两两分组,获得了两对不同的日志。

1. 第一条日志是 outbound 连接到 manager.manager.svc

2. 第二条日志是 inbound 连接到本地

3. 第三条日志是 outbound 连接到 manager.manager.svc

4. 第四条日志是在第二个 manager Pod(10.12.2.8:5701)上的 inbound 连接

当然,Istio 默认使用的是 round-robin 负载均衡算法,所以它完全可以解释这里发生了什么。连续的请求会转到不同的 pod。

下图中蓝色的连线是 outbound 的,粉色的连线是 inbound 的。

好吧,其实不是这样的!我骗了你!Istio(Envoy)不向 Kubernetes 服务发送流量。Istiod(Pilot)用服务构建网格拓扑,然后将信息发送到每个 Istio-proxy,再由 Istio-proxy 向 Pod 发送流量。最后看起来更像这样。(下图中的 ip 地址 10.120.0.11 应改为 10.12.0.11)

Istio TCP default

但 Hazelcast 服务器也不完全是这样工作的!

Hazelcast 集群通信

真相是 Hazelcast 并没有使用服务名称进行通信。

事实上,它利用 Kubernetes API(或 Headless 服务)来了解集群中所有的 Pod。我不清楚它当时使用的是 Pod 的 FQDN 还是它的 IP,这对我们来说并不重要。

就像每一个使用 "智能" 客户端的应用一样,比如 Kafka,每个实例都需要直接与集群中的其他每个实例通信。

那么,如果我们尝试用第二个 manager Pod 的 IP 来调用它,会发生什么呢?


manager-7948dffbdd-p44xx istio-proxy [2020-07-23T14:39:12.587Z] "- - -" 0 - "-" "-" 6 0 2108 - "-" "-" "-" "-" "10.12.2.8:5701" PassthroughCluster 10.12.0.11:51428 10.12.2.8:5701 10.12.0.11:51426 - -
manager-7948dffbdd-sh7rx istio-proxy [2020-07-23T14:39:13.590Z] "- - -" 0 - "-" "-" 6 0 1113 - "-" "-" "-" "-" "127.0.0.1:5701" inbound|5701|tcp-hazelcast|manager.manager.svc.cluster.local 127.0.0.1:59986 10.12.2.8:5701 10.12.0.11:51428 - -'

1. outbound 连接使用的是 Passthrough 集群,因为网格内部不知道目的地 IP
2. 与之前相同,上游连接使用 inbound 集群

Istio TCP hazelcast

尽管这不是很理想,但至少它是可以工作的。

事情可能变得更糟糕

后来,集群中发生了一些奇怪的事情。

在某些时候,当 manager 试图连接到 Hazelcast 端口时,该连接被路由到 manager namespace 中的 idle pod。
这怎么可能? 这个 idle Pod/Service 甚至没有暴露t 5701 端口!

示意图是这样的:

Istio TCP hazelcast problem

在 manager Namespace 中没有发生任何变化,但是我查看了app Namespace 里面的 Services ,看到增加了一个 ExternalName Service。


kubectl get svc -n app

NAME      TYPE           CLUSTER-IP    EXTERNAL-IP                      PORT(S)             AGE
app       ClusterIP      10.0.18.143                              8080/TCP,5701/TCP   18h
app-ext   ExternalName           idle.manager.svc.cluster.local   8080/TCP,5701/TCP   117s

服务类型 ExternalName 并不是定义了一个持有 active target pod 列表的内部负载均衡器,而只是到另外 Service 的 CNAME

这是它的定义。


apiVersion: v1
kind: Service
metadata:
    labels:
        app/name: app
    name: app-ext
    namespace: app
spec:
    ports:
    - name: http-app
      port: 8080
      protocol: TCP
      targetPort: 8080
    - name: tcp-hazelcast
      port: 5701
      protocol: TCP
      targetPort: 5701
    externalName: idle.manager.svc.cluster.local
    sessionAffinity: None
    type: ExternalName

以上的 Service 定义使得 app-ext.app.svc.cluster.local 这个名字可以被解析为 idle.manager.svc.cluster.local (即 CNAME,然后被解析为服务的 IP,10.0.23.221)。

我们再来查看监听 manager Pod 的 listener。


istioctl pc listeners manager-7948dffbdd-p44xx.manager --port 5701

ADDRESS         PORT     TYPE
10.12.0.12      5701     TCP
10.0.18.143     5701     TCP
10.0.23.154     5701     TCP
0.0.0.0         5701     TCP

现在出现了一条新的 0.0.0.0 的 entry!

查看配置:


istioctl pc listeners manager-7948dffbdd-p44xx.manager --port 5701 --address 0.0.0.0 -o json

[
    {
        "name": "0.0.0.0_5701",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 5701
            }
        },
        "filterChains": [
            {
                "filterChainMatch": {
                    "prefixRanges": [
                        {
                            "addressPrefix": "10.12.0.11",
                            "prefixLen": 32
                        }
                    ]
                },
                "filters": [
                    {
                        "name": "envoy.filters.network.wasm",
...
                    },
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "[@type](http://twitter.com/type)": "type.googleapis.com/envoy.config.filter.network.tcp_proxy.v2.TcpProxy",
                            "statPrefix": "BlackHoleCluster",
                            "cluster": "BlackHoleCluster"
                        }
                    }
                ]
            },
            {
                "filters": [
                    {
                        "name": "envoy.filters.network.wasm",
...
                    },
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "[@type](http://twitter.com/type)": "type.googleapis.com/envoy.config.filter.network.tcp_proxy.v2.TcpProxy",
                            "statPrefix": "outbound|5701||app-ext.app.svc.cluster.local",
                            "cluster": "outbound|5701||app-ext.app.svc.cluster.local",
                            "accessLog": [
...
                            ]
                        }
                    }
                ]
            }
        ],
        "deprecatedV1": {
            "bindToPort": false
        },
        "trafficDirection": "OUTBOUND"
    }
]

事情变得有点复杂了。

1. 首先接受任何目的地 IP,只要是到端口 5701

2. 然后进入 filterChains
3. 如果真正的目的地是本地(pod IP10.12.0.11),则放弃请求(将其发送到 BlackHoleCluster

4. 不然的话使用集群 outbound|5701||app-ext.app.svc.cluster.local 寻找转发地址

查看这个集群:


istioctl pc clusters manager-7948dffbdd-p44xx.manager  --fqdn app-ext.app.svc.cluster.local --port 5701 -o json

[
    {
        "name": "outbound|5701||app-ext.app.svc.cluster.local",
        "type": "STRICT_DNS",
        "connectTimeout": "1s",
        "loadAssignment": {
            "clusterName": "outbound|5701||app-ext.app.svc.cluster.local",
            "endpoints": [
                {
                    "locality": {},
                    "lbEndpoints": [
                        {
                            "endpoint": {
                                "address": {
                                    "socketAddress": {
                                        "address": "idle.manager.svc.cluster.local",
                                        "portValue": 5701
                                    }
                                }
                            },

这个集群非常简单,它只是将流量转发到服务器 idle.manager.svc.cluster.local ,使用 DNS 获取真正的目的地 IP。

使用 telnet 远程登陆到第二个manager Pod 中并检查日志。可以看到:


manager-7948dffbdd-p44xx istio-proxy [2020-07-23T14:47:24.040Z] "- - -" 0 UF,URX "-" "-" 0 0 1000 - "-" "-" "-" "-" "10.0.23.221:5701" outbound|5701||app-ext.app.svc.cluster.local - 10.12.1.6:5701 10.12.0.12:52852 - -

1. 请求返回了一个 error:0 UF, URX
Envoy doc中可知,UF 指上游连接失败,URX 指达到了 TCP 最大连接尝试次数
这是完全正常的,因为 idle 并没有暴露 5701 端口(idle Pod 也没绑定 idle Service)。

2. 请求转发到 outbound|5701||app-ext.app.svc.cluster.local 集群。

Istio TCP going bad

什么
一个在另外的 Namespace(app)中创建的 Service 破坏了 Hazelcast 集群?

这里的解释其实很简单。在这个服务被创建之前,真正 Pod 的 IP 在 Mesh 中是未知的,Envoy 使用 Passthrough 集群直接向它发送请求。 现在,IP 仍然是未知的,但被 catchall(捕集器) 0.0.0.0:5710 Listener 匹配,并转发到一个已知的集群,即 outbound|5701||app-ext.app.svc.cluster.local,而这个 Cluster 指向的是 idle Service。

解决问题

怎样才能恢复 Hazelcast 集群?

不暴露 5701 端口

其中一个解决方案是不暴露 ExternalName 服务中的 5701 端口。 然后将不存在 0.0.0.0:5701 Listener 且流量将流经 Passthrough 集群。 这对于跟踪网格流量来说并不理想,但它工作得很好。

不使用 ExternalName

另一种解决方案是不使用 ExternalName

The Externalname 实际上是在我们期望前往 app 服务的所有调用都转发到 idle.manager 服务的情况下添加的新服务。
除了破坏 Hazelcast 集群,添加 Externalname 服务也意味着我们将不得不删除一个服务,然后将该服务重建为 ExternalName 类型。 而这两个操作都迫使 Istiod(Pilot)重建完整的网格配置并更新网格中的所有代理,包括 Listener 的更改 -- 两次导致所有打开的连接耗尽。

一种可能的方法是为 VirtualService 应用添加一个 app 定义,它只会在我们需要的时候向 idle.manager 发送流量。 这样不会创建或删除任何 Listener,只会更新 app HTTP Service 的路由。


apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
    name: app-idle
spec:
    hosts:
    - app.app.svc.cluster.local
    http:
    - name: to-idle
    route:
    - destination:
        host: idle.manager.svc.cluster.local
        port:
            number: 8080

以上配置表示 app.app.svc.cluster.local 的所有流量都会发送到 idle.manager.svc.cluster.local:8080.
。当我们想让流量有效的流向 app 应用时,只需要更新这个 VirtualService 配置并将 destination 项设置为导向 app.app.svc.cluster.local 或删除该 VirtualService 配置。

Sidecar

通过最新的 Istio,我们还可以利用 Sidecar 资源的使用来限制 manager 在网格中可以看到的东西。
具体到本例中,我们可以在 ExternalName 服务上使用一个注解,使其只在 app Namespace 中可见。


apiVersion: v1
kind: Service
metadata:
  labels:
    app/name: app
  annotations:
    networking.istio.io/exportTo: "."
  name: app-ext
  namespace: app
spec:
  ports:
  - name: http-app
    port: 8080
    protocol: TCP
    targetPort: 8080
  - name: tcp-hazelcast
    port: 5701
    protocol: TCP
    targetPort: 5701
  externalName: idle.manager.svc.cluster.local
  sessionAffinity: None
  type: ExternalName

通过添加注解 networking.istio.io/exportTo: “.” ,意为 "只把这个资源导出到它被发布的命名空间中",这个服务不会被 manager 的 Pod 看到,也不会被 app Namespace 之外的任何 pod 看到。因此不再有 0.0.0.0:5701:


istioctl pc listeners manager-7948dffbdd-p44xx.manager --port 5701

ADDRESS         PORT     TYPE
10.0.18.143     5701     TCP
10.12.0.12      5701     TCP
10.0.25.229     5701     TCP

不同的 TCP 端口

如果我们愿意更新应用程序,那还可以使用其他一些解决方案。
我们可以为不同的 TCP 服务使用不同的端口。尽管这在你处理数据库等复杂应用时很难落实,但这是 Istio 中长期以来唯一可用的选项。
我们也可以更新应用程序以使用 TLS,并启用 Server Name Indication(SNI)。Envoy/Istio 可以使用 SNI 为同一端口上的 TCP 服务路由流量,因为 Istio 对待路由 TLS/TCP 流量的 SNI 就像对待 HTTP 流量的 Host header 一样。

结论

首先我想说明的是,在这个演示中没有任何的 Hazelcast 集群被损坏。 以上讨论的问题与 Hazelcast 本身无关,任何使用相同端口的服务集都可能发生上述的问题。Istio 和 Envoy 对 TCP 或未知协议的支持非常有限。当你只需要检查 IP 和端口时,你能做的就不多了。

牢记以下对于配置集群的建议!

  • 尽量避免对不同的 TCP 服务使用相同的端口号。
  • 始终在端口名中加入协议前缀(`tcp-hazelcast`、`http-fronted`、`grpc-backend`),具体参见 协议选择 文档。
  • 尽早添加 Sidecar 资源以限制配置蔓延,并将默认的 exportTo 设置为 Istio 安装中的 namespace local。
  • 配置应用程序以通过名称(FQDN)而非 IP 进行通信。
  • 总是在 Istio 资源中配置 FQDN(包括 `svc.cluster.local`)。

Sebastien Thomas is a Tetrate engineer specializing in customer reliability and Istio setup.

作者