1 - 应用程序启动失败/启动时无法访问网络

故障现象

该问题的表现是安装了 Sidecar proxy 的应用在启动后的一小段时间内无法通过网络访问 Pod 外面的服务。应用在启动时通常会从一些外部服务中获取数据,并采用这些数据对自身进行初始化。例如从配置中心读取程序配置,从数据库中初始化程序用户信息等。而安装了 Sidecar proxy 的应用在启动后的一小段时间内网络是不通的。如果应用代码中没有合适的容错和重试逻辑,该问题常常会导致应用启动失败。

故障原因

如下图所示,Envoy 启动后会通过 xDS 协议向 Pilot 请求服务和路由配置信息,Pilot 收到请求后会根据 Envoy 所在的节点(Pod 或者 VM)组装配置信息,包括 Listener、Route、Cluster 等,然后再通过 xDS 协议下发给 Envoy。根据 Mesh 的规模和网络情况,该配置下发过程需要数秒到数十秒的时间。在这段时间内,由于初始化容器已经在 Pod 中创建了 Iptables rule 规则,因此应用向外发送的网络流量会被重定向到 Envoy ,而此时 Envoy 中尚没有对这些网络请求进行处理的监听器和路由规则,无法对此进行处理,导致网络请求失败。(关于 Envoy Sidecar 初始化过程和 Istio 流量管理原理的更多内容,可以参考这篇文章 Istio流量管理实现机制深度解析)。

解决方案

参见:最佳实践-在 Sidecar 初始化完成后再启动应用容器

2 - ExternalName Service 劫持了其他服务流量

故障现象

如果网格内存在一个 ExternalName 类型 Service, 网格内访问其他外部服务的的某一端口,如果这个端口刚好和该 ExternalName Service 重叠,那么流量会被路由到这个 ExternalName Service 对应的 CDS。

故障重现

正常情况

在 namespace sample 安装 sleep Pod:

kubectl create ns sample
kubectl label ns sample istio-injection=enabled
kubectl -nsample apply -f https://raw.githubusercontent.com/istio/istio/1.11.4/samples/sleep/sleep.yaml

通过 sleep 访问外部服务 https://httpbin.org:443, 请求成功:

kubectl -nsample exec sleep-74b7c4c84c-22zkq -- curl -I https://httpbin.org
HTTP/2 200
......

从 access log 确认流量是从 PassthroughCluster 出去,符合预期:

"- - -" 0 - - - "-" 938 5606 1169 - "-" "-" "-" "-" "18.232.227.86:443" PassthroughCluster 172.24.0.10:42434 18.232.227.86:443 172.24.0.10:42432 - -

异常情况

现在 在 default 下创建一个 ExternalName 类型的 Service, 端口也是 443:

kind: Service
apiVersion: v1
metadata:
  name: my-externalname
spec:
  type: ExternalName
  externalName: bing.com
  ports:
  - port: 443
    targetPort: 443

通过 sleep 访问外部服务 https://httpbin.org:443, 请求失败:

kubectl -nsample exec sleep-74b7c4c84c-22zkq -- curl -I https://httpbin.org
curl: (60) SSL: no alternative certificate subject name matches target host name 'httpbin.org'
More details here: https://curl.se/docs/sslcerts.html
......

查看 access log, 发现请求外部服务,被错误路由到了 my-externalname 的 ExternalName Service:

"- - -" 0 - - - "-" 706 5398 67 - "-" "-" "-" "-" "204.79.197.200:443" outbound|443||my-externalname.default.svc.cluster.local 172.24.0.10:56806 34.192.79.103:443 172.24.0.10:36214 httpbin.org -

故障原因

通过对比 sleep Pod 前后两次的 xDS, 发现增加了 ExternalName Service 后,xDS 里会多一个 LDS 0.0.0.0_443, 该 LDS 包括一个default_filter_chain 会把该 LDS 中其他 filter chain 没有 match 到的流量,都路由到这个 default_filter_chain 中的 Cluster,也就是 my-externalname 对应的 CDS:

解决方案

该问题属于 Istio 实现缺陷,相关 issue: https://github.com/istio/istio/issues/20703

目前的解决方案是避免 ExternalName Service 和其他服务端口冲突。

3 - Gateway TLS hosts 冲突导致配置被拒绝

故障现象

网格中同时存在以下两个 Gateway

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: test1
spec:
  selector:
    istio: ingressgateway
  servers:
  - hosts:
    - test1.example.com
    port:
      name: https
      number: 443
      protocol: HTTPS
    tls:
      credentialName: example-credential
      mode: SIMPLE
---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: test2
spec:
  selector:
    istio: ingressgateway
  servers:
  - hosts:
    - test1.example.com
    - test2.example.com
    port:
      name: https
      number: 443
      protocol: HTTPS
    tls:
      credentialName: example-credential
      mode: SIMPLE

172.18.0.6 为 ingress gateway Pod IP,请求 https://test1.example.com 正常返回 404

curl -i -HHost:test1.example.com --resolve "test1.example.com:443:172.18.0.6" --cacert example.com.crt "https://test1.example.com"
HTTP/2 404
date: Mon, 29 Nov 2021 06:59:26 GMT
server: istio-envoy

请求 https://test2.example.com 异常

$ curl  -HHost:test2.example.com --resolve "test2.example.com:443:172.18.0.6" --cacert example.com.crt "https://test2.example.com"
curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to test2.example.com:443

故障原因

通过 istiod 监控发现pilot_total_rejected_configs指标异常,显示default/test2配置被拒绝 调整 istiod 日志级别查看被拒绝的原因

--log_output_level=model:debug
2021-11-29T07:24:21.703924Z	debug	model	skipping server on gateway default/test2, duplicate host names: [test1.example.com]

通过日志定位到具体代码位置

if duplicateHosts := CheckDuplicates(s.Hosts, tlsHostsByPort[resolvedPort]); len(duplicateHosts) != 0 {
    log.Debugf("skipping server on gateway %s, duplicate host names: %v", gatewayName, duplicateHosts)
    RecordRejectedConfig(gatewayName)
    continue
}
// CheckDuplicates returns all of the hosts provided that are already known
// If there were no duplicates, all hosts are added to the known hosts.
func CheckDuplicates(hosts []string, knownHosts sets.Set) []string {
	var duplicates []string
	for _, h := range hosts {
		if knownHosts.Contains(h) {
			duplicates = append(duplicates, h)
		}
	}
	// No duplicates found, so we can mark all of these hosts as known
	if len(duplicates) == 0 {
		for _, h := range hosts {
			knownHosts.Insert(h)
		}
	}
	return duplicates
}

校验逻辑是每个域名在同一端口上只能配置一次 TLS,我们这里 test1.example.com 在 2 个 Gateway 的 443 端口都配置了 TLS, 导致其中一个被拒绝,通过监控确认被拒绝的是 test2,test2.example.com 和 test1.example.com 配置在 test2 的同一个 Server,Server 配置被拒绝导致请求异常

解决方案

同一个域名不要在多个 Gateway 中的同一端口重复配置 TLS,这里我们删除 test1 后请求恢复正常

$ curl -i -HHost:test1.example.com --resolve "test1.example.com:443:172.18.0.6" --cacert example.com.crt "https://test1.example.com"
HTTP/2 404
date: Mon, 29 Nov 2021 07:43:40 GMT
server: istio-envoy

$ curl -i -HHost:test2.example.com --resolve "test2.example.com:443:172.18.0.6" --cacert example.com.crt "https://test2.example.com"
HTTP/2 404
date: Mon, 29 Nov 2021 07:43:41 GMT
server: istio-envoy

4 - Server Speaks First 协议访问失败

故障现象

Istio 网格开启 allow any 访问模式,在一个注入了 sidecar 的 pod 内,mysql 客户端访问 mysql-ip-1:3306 成功,访问 mysql-ip-2:10000 没有响应:

# mysql -h55.135.153.1 -utest -pxxxx -P3306
Welcome to the MariaDB monitor.  Commands end with ; or \g.

# mysql -h55.108.108.2 -utest -pxxxx -P10000
(no response)

故障分析

查看日志,把 access log 设置为 debug、trace 均没有发现有用信息。

分析发现,网格内有一个 http server,也使用了和 mysql-ip-2 相同的端口 10000:

apiVersion: v1
kind: Service
metadata:
  name: irrelevant-svc
......
spec:
  ports:
  - name: http
    nodePort: 31025
    port: 10000     # 端口相同
    protocol: TCP
    targetPort: 8080

我们尝试把该服务端口改成 10001,访问 mysql-ip-2:10000 成功,推测和端口冲突相关:

# mysql -h55.108.108.2 -utest -pxxxx -P10000
Welcome to the MariaDB monitor.  Commands end with ; or \g.

我们再尝试对 mysql-ip-1 复现故障:在网格内创建了一个包括 3306 端口的 http 服务,mysql 请求无响应,问题复现。

另外我们还尝试过,如果把冲突端口的协议定义为 tcp(通过 port name),该问题不存在:

apiVersion: v1
kind: Service
metadata:
  name: irrelevant-svc
......
spec:
  ports:
  - name: tcp        # 如果是 tcp 则不会出问题
    nodePort: 31025
    port: 10000
    protocol: TCP
    targetPort: 8080

故障原因

Server Speaks First

Mysql 协议是一种 Server Speaks First 协议,也就是说 client 和 server 完成三次握手后,是 server 会先发起会话, 简要过程:

S: 服务端首先会发一个握手包到客户端
C: 客户端向服务端发送认证信息 ( 用户名,密码等 )
S: 服务端收到认证包后,会检查用户名与密码是否合法,并发送包告知客户端认证信息。

除了 Mysql,常见的 Server Speaks First 协议还包括 SMTP,DNS,MongoDB 等。下面是一个 SMTP 交互流程:

S: 220 smtp.example.com ESMTP Postfi
C: HELO relay.example.com
S: 250 smtp.example.com, I am glad to meet you
C: MAIL FROM:<bob@example.com>
S: 250 Ok
C: RCPT TO:<alice@example.com>
S: 250 Ok
C: RCPT TO:<theboss@example.com>
S: 250 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: From: "Bob Example" <bob@example.com>
C: To: Alice Example <alice@example.com>
C: Cc: theboss@example.com
C: Date: Tue, 15 Jan 2008 16:02:43 -0500
C: Subject: Test message
C:
C: Hello Alice.
C: This is a test message with 5 header fields and 4 lines in the message body.
C: Your friend,
C: Bob
C: .
S: 250 Ok: queued as 12345
C: QUIT
S: 221 Bye
{The server closes the connection}

istio 不是完全透明

当前 istio 的某些特性,不能做到透明兼容 Server Speaks First 协议,这些特性包括:

  • 协议嗅探
  • PERMISSIVE mTLS
  • Authorization Policy

这些特性都希望 client 能先发起会话,以协议嗅探为例,envoy 是通过分析 client 发出的初始若干字节来推测协议类型。

对于 Server Speaks First 协议,比如 mysql,三次握手后,这时候 mysql client 在等待 mysql server 发起初次会话,而 client 端的 envoy 尝试做协议嗅探,也在等 mysql client 发出数据,这类似一个死锁,最终超时。

解决方案

以下是一些可行的方案:

  1. 为 Server Speaks First 协议服务创建一个 ServiceEntry,并指定协议为 TCP。
  2. 避免 Server Speaks First 协议服务端口和网格内服务端口重叠,这样请求可以直接走 passthrough。
  3. 把 Server Speaks First 服务 ip 放到 excludeIPRanges,这样请求不经过 envoy 处理,适用于 DB 服务不需要网格治理的情况。

参考资料

5 - 长连接未开启 tcp keepalive

故障现象

用户反馈链路偶发 500 错误,频率低但是持续存在。

用户访问链路较长,核心链路简化如下:

1. client -> 
2. [istio ingress gateway] -> 
3. podA[app->sidecar] -> 
4. 腾讯云内网CLB ->
5. [istio ingress gateway] -> 
6. podB[sidecar->app]

应用对外是 https 服务,证书在 istio ingress gateway 上处理。

故障分析

通过分析链路中 sidecar accesslog 日志,有以下现象:

  1. 第 3 跳 podA 正常发出请求,但接收到 500 返回。
  2. 第 5 跳 istio ingress gateway 没有该 500 对应的访问日志。

因此重点分析 第 3,4,5 跳。

在第 3 跳 podA 上抓到 500 对应的数据包:

podA 抓包

抓包显示,podA 向一个已经断开的连接发送数据包,收到 RST 因此返回 500,但抓包并没有发现这个连接之前有主动断开的行为(FIN)。

登录 podA,查看连接情况:

长连接未开启keepalive

ss 显示用户代码里使用了 tcp 长连接,注意这里我们使用了 ss 参数 -o, 该参数可以显示 tcp keepalive timer 信息:

 -o, --options
        Show timer information. For TCP protocol, the output
        format is:

        timer:(<timer_name>,<expire_time>,<retrans>)

        <timer_name>
               the name of the timer, there are five kind of timer
               names:

               on : means one of these timers: TCP retrans timer,
               TCP early retrans timer and tail loss probe timer

               keepalive: tcp keep alive timer

               timewait: timewait stage timer

               persist: zero window probe timer

               unknown: none of the above timers

        <expire_time>
               how long time the timer will expire

        <retrans>
               how many times the retransmission occurred

但从 ss 结果并未看到 timer 信息,推断 podA 使用的长连接并未开启 keepalive。

故障原因

podA 使用了 tcp 长连接,但是没有开启 keepalive,当长连接出现一段时间空闲,该连接可能被网络中间组件释放,比如 client、server 端的母机, 但 client 端还是持有断开连接,后续重用该链接就会导致上述异常。

解决方案

问题本质是因为长连接 idle 过长,且缺乏探活机制,导致 client 没感知到连接已释放,尝试三种方案:

  1. 应用代码修复
  2. istio 方案:client sidecar 开启 keepalive
  3. istio 方案:server 开启 keepalive

应用代码修复

最直接的方案是应用在使用长连接时,开启 tcp keepalive,以 golang 程序示例,我们尝试用长连接访问 https://www.baidu.com

先模拟使用长连接但不开启 keepalive:

var HTTPTransport = &http.Transport{
	DialContext: (&net.Dialer{
		Timeout:   10 * time.Second,
		KeepAlive: -1 * time.Second, // disable TCP KeepAlive
	}).DialContext,
	MaxIdleConns:        50,
	IdleConnTimeout:     60 * time.Second,
	MaxIdleConnsPerHost: 20,
}

func main() {
	uri := "https://www.baidu.com"
	times := 200

	client := http.Client{Transport: HTTPTransport}
	for i := 0; i < times; i++ {
		time.Sleep(2 * time.Second)
		req, err := http.NewRequest(http.MethodGet, uri, nil)
		if err != nil {
			fmt.Println("NewRequest Failed " + err.Error())
			continue
		}
		resp, err := client.Do(req)
		if err != nil {
			fmt.Println("Http Request Failed " + err.Error())
			continue
		}
		fmt.Println(resp.Status)
		ioutil.ReadAll(resp.Body)
		resp.Body.Close()
	}

注意 KeepAlive: -1 表示禁用了 tcp keepalive 探活,ss 查看:

应用长连接未开启keepalive

结果显示长连接缺乏 timer。注意测试 pod 在 istio 环境,上述第一个连接是 go 程序到 envoy,第二个连接是 envoy 到 baidu。

golang 代码修复方案很简单,只需要把 KeepAlive 设置为非负数, 代码修改

var HTTPTransport = &http.Transport{
	DialContext: (&net.Dialer{
		Timeout:   10 * time.Second,
		KeepAlive: 120 * time.Second, // keepalive 设置为 2 分钟
	}).DialContext,
	MaxIdleConns:        50,
	IdleConnTimeout:     60 * time.Second,
	MaxIdleConnsPerHost: 20,
}

ss 查看连接情况:

golang 长连接开启keepalive

ss 显示 go client 到 envoy 开启了 keepalive,问题解决。

但用户应用程序较多,不方便逐一调整 keepalive,希望通过 istio sidecar 来解决上述问题。keepalive 可以在 client、server 任意一端开启,以下是使用 istio 的两种方案:

istio 方案:client sidecar 开启 keepalive

该方案需要client 注入 istio sidecar,仍以访问 baidu https 为例,外部服务在 istio 中默认转发到 PassthroughCluster, 要对指定外部服务流量进行流控,我们需要先给该服务创建一个 service entry:

apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: baidu-https
spec:
  hosts:
  - www.baidu.com
  location: MESH_EXTERNAL
  ports:
  - number: 443
    name: https
    protocol: TLS

然后增加 tcp keepalive 设置:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: baidu-https
spec:
  host: www.baidu.com
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
        tcpKeepalive:
          time: 600s
          interval: 75s
          probes: 9

client sidecar 开启 keepalive

ss 显示 go client 到 envoy 并没有 keepalive, 但 envoy 到 baidu 开启了 keepalive。

istio 方案:server 开启 keepalive

用户异常链路的 server 入口 是 CLB 后端的 ingress gateway,在 ingress gateway 上开启 keepalive 会稍微复杂一点,需要使用 envoyfilter 来设置 socekt options:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: ingress-gateway-socket-options
  namespace: istio-system
spec:
  configPatches:
  - applyTo: LISTENER
    match:
      context: GATEWAY
      listener:
        name: 0.0.0.0_443
        portNumber: 443
    patch:
      operation: MERGE
      value:
        socket_options:
        - int_value: 1
          level: 1                         # SOL_SOCKET
          name: 9                          # SO_KEEPALIVE
          state: STATE_PREBIND
        - int_value: 9
          level: 6                         # IPPROTO_TCP
          name: 6                          # TCP_KEEPCNT
          state: STATE_PREBIND
        - int_value: 600
          level: 6                         # IPPROTO_TCP
          name: 4                          # TCP_KEEPIDLE
          state: STATE_PREBIND
        - int_value: 75
          level: 6                         # IPPROTO_TCP
          name: 5                          # TCP_KEEPINTVL
          state: STATE_PREBIND

上述配置的含义是:对于 433 LDS,tcp 连接设置 socket options:连接空闲 600s 后,开始发送探活 probe;如果探活失败,会持续探测 9 次,探测间隔为 75 s。

在 ingress gateway 上 ss, 显示 443 上连接都开启了 keepalive:

ingress gateway 开启 keepalive

如果用户 client 较多不便调整,更适合在 server (ingress gateway)开启 keepalive。另外该方案对 client 有无 sidecar 没有要求。

总结

使用长连接时,应用需要设置合理的 keepalive 参数,特别是对于访问频次较低的场景,以及链路较长的情况。

istio 无入侵式的流量操纵能力,可以很方便的对流量行为进行调优,这也是用户选择 istio 的重要原因。


参考资料