一个Istio/Mutual TLS调试的故事

203 阅读9分钟

上周,我们的团队正在进行Kube360的功能增强。我们与受监管行业的客户合作,其中一个要求是在整个集群中对流量进行完全加密。虽然我们支持Istio的相互TLS(mTLS)作为最终用户应用程序的可选功能,但并非所有的内置服务都使用mTLS严格模式。我们正在努力推出这种支持。

Kube360的基石之一是我们的集中式认证系统,主要由一个服务(称为k3dash )提供,该服务接收传入的流量,针对外部身份提供商(如Okta、Azure AD或其他)执行认证,然后将这些证书提供给集群内的其他服务,如Kubernetes Dashboard或Grafana。这项服务尤其给人带来一些麻烦。

然而,在深入研究这些错误和调试过程之前,让我们回顾一下Istio的mTLS支持和k3dash 的相关细节。

什么是mTLS?

在一个典型的Kubernetes设置中,加密的流量进入集群,并击中一个负载均衡器。负载平衡器终止了TLS连接,产生了解密的流量。然后,解密的流量被发送到集群内的相关服务。由于集群内的流量通常被认为是安全的,对于许多用例,这是一个可以接受的方法。

但对于某些用例,如处理个人身份信息(PII),可能需要或需要额外的保障措施。在这些情况下,我们希望确保所有的网络流量,甚至同一集群内的流量,都是加密的。这为防止窥探(读取传输中的数据)和欺骗(伪造数据来源)攻击提供了额外保障。这可以帮助减轻系统中其他缺陷的影响。

手动实施这种完整的传输中数据加密系统需要对集群中的每一个应用程序进行重大改造。你需要教所有的应用程序终止他们自己的TLS连接,为所有的应用程序颁发证书,并为所有的应用程序添加一个新的证书颁发机构来尊重。

Istio的mTLS在应用程序之外处理这些。它安装了一个挎包,通过本地主机连接与你的应用程序进行通信,绕过了暴露的网络流量。它使用复杂的端口转发规则(通过IP表)来重定向进出Pod的流量,使其通过sidecar。代理中的Envoy sidecar处理所有获取TLS证书、刷新密钥、终止等逻辑。

Istio处理所有这些的方式是非常不可思议的。当它工作时,它工作得很好。而当它失败时,它可能是灾难性的难以调试。这就是在这里发生的事情(尽管幸运的是,它花了不到一天的时间就得到了结论)。在史诗般的预示领域,让我指出关于Istio的mTLS值得一提的三个具体要点:

  • 在严格模式下,也就是我们要做的事情,Envoy挎包会拒绝任何传入的明文通信。
  • 我起初没有认识到这一点,但现在已经完全内化了:通常,如果你向一个不存在的主机建立HTTP连接,你会得到一个失败的连接错误。你肯定不会得到一个HTTP响应。然而,在Istio中,你将总是成功地发出HTTP连接,因为你的连接是给Envoy本身的。如果Envoy代理不能建立连接,它将像大多数代理一样,返回一个带有503错误信息的HTTP响应体。
  • Envoy代理对一些协议有特殊处理。最重要的是,如果你做一个纯文本的HTTP外发连接,Envoy代理有复杂的能力来解析外发请求,了解各种头文件的细节,并做智能路由。

好了,这就是mTLS。让我们来谈谈这里的另一个角色:k3dash

k3dash 和反向代理

k3dash 用来向集群内的其他服务提供认证凭证的主要方法是HTTP反向代理。这是一种常见的技术,也有常见的库来做这件事。事实上,几年前就写了一个这样的库。我们已经提到了反向代理的一个常见用例:负载平衡。在反向代理的情况下,传入的流量由一台服务器接收,它分析传入的请求,进行一些转换,然后选择一个目标服务来转发请求。

反向代理的一个最重要的方面是头管理。在头的层面上有一些不同的事情可以做,比如说:

  • 删除逐跳头,如transfer-encoding ,这些头适用于单跳,而不是客户端和服务器之间的端到端通信。
  • 注入新的头信息。例如,在k3dash ,我们定期注入最终服务所认可的头信息,用于认证目的。
  • 让头信息完全不被触及。这通常是像content-type ,我们通常希望客户端和最终服务器在没有任何干扰的情况下交换数据。

作为一个史诗般的预示性例子,考虑一下典型的反向代理情况下的Host 头。我可能有一个单一的负载均衡器处理十几个不同域名的流量,包括域名AB 。也许我有一个单一的服务在反向代理后面,为这两个域名的流量提供服务。我需要确保我的负载平衡器将Host 头转发给最终的服务,这样它就可以决定如何响应请求。

k3dash 事实上,我使用上面链接的库来实现,并遵循相当标准的头转发规则,加上在应用程序中做了一些具体的修改。

我想这是足够的背景故事,也许你已经开始根据我上面的线索拼凑出出错的原因。无论如何,让我们深入了解一下!

问题所在

我的一个同事,Sibi,开始了Istio mTLS严格模式的迁移。他在一个测试集群中打开了严格模式,然后开始弄清楚什么地方出了问题。我不知道他所做的所有初步改变。但当他联系我的时候,他已经让我们达到了这样的程度:Kubernetes负载均衡器成功地接收了传入的k3dash 的请求,并将它们转发到k3dashk3dash ,能够登录用户并提供自己的用户界面显示。到目前为止一切都很好。

然而,从主用户界面到Kubernetes仪表板的后续操作会失败,我们会在浏览器中看到这个错误信息。

上游连接错误或在头文件前断开连接/重置。重置原因:连接失败

Sibi认为这是k3dash 代码库本身的问题,要求我介入帮助调试。

错误的兔子洞,和难以置信的懒惰

这一节只是一个关于我如何用脚投票的宣泄会。正如我们将要看到的那样,我完全应该为自己的痛苦负责。

似乎很清楚,从k3dash pod到kubernetes-dashboard pod的出站连接失败了。(我想做的第一件事是做一个更简单的重现,在这种情况下,它涉及到kubectl execing到k3dash 容器和curling到集群中的服务端点。基本上是这样:

$ curl -ivvv http://kube360-kubernetes-dashboard.kube360-system.svc.cluster.local/
*   Trying 172.20.165.228...
* TCP_NODELAY set
* Connected to kube360-kubernetes-dashboard.kube360-system.svc.cluster.local (172.20.165.228) port 80 (#0)
> GET / HTTP/1.1
> Host: kube360-kubernetes-dashboard.kube360-system.svc.cluster.local
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 503 Service Unavailable
HTTP/1.1 503 Service Unavailable
< content-length: 84
content-length: 84
< content-type: text/plain
content-type: text/plain
< date: Wed, 14 Jul 2021 15:29:04 GMT
date: Wed, 14 Jul 2021 15:29:04 GMT
< server: envoy
server: envoy
<
* Connection #0 to host kube360-kubernetes-dashboard.kube360-system.svc.cluster.local left intact
upstream connect error or disconnect/reset before headers. reset reason: local reset

这就马上重现了这个问题。很好!我现在完全相信了。我现在完全相信问题不在k3dash ,因为curlk3dash 都不能建立连接,而且它们都给出了相同的upstream connect error 消息。我想出了几个不同的原因,但都不正确:

  • 容器发出的数据包没有被发送到Envoy代理那里。我曾有一段时间坚信这一点。但如果我再仔细想想,我就会意识到这是完全不可能的。那条upstream connect error ,当然是来自Envoy代理本身。如果我们遇到的是正常的连接失败,我们会在TCP层面上收到错误信息,而不是HTTP 503响应代码。下一个!
  • Envoy挎包正在接收数据包,但网状结构很混乱,它无法弄清楚如何连接到目的地Envoy挎包。这被证明是部分正确的,但不是我想的那样。

我在这里做了很多不同的尝试,但基本上都停滞不前。直到Sibi注意到了一些迷人的东西。事实证明,下面这个看似无厘头的命令确实起作用了。

curl http://kube360-kubernetes-dashboard.kube360-system.svc.cluster.local:443/

由于某些原因,通过443(安全的HTTPS端口)发出不安全的HTTP请求是有效的。当然,这毫无意义。为什么使用错误的端口可以解决一切问题?这就是不可思议的懒惰发挥作用的地方。你看,Kubernetes Dashboard的默认配置使用TLS,并需要我上面提到的所有关于传递证书和更新接受的证书授权的设置。但你可以关闭这个要求,让它听从纯文本。由于(1)这是集群内的通信,(2)我们的路线图上一直有严格的mTLS,我们决定在Kubernetes仪表板上简单地关闭TLS。然而,在这样做的时候,我忘记将端口号从443切换到80。

不过不用担心!我确实记得正确配置了k3dash ,通过443端口使用不安全的HTTP与Kubernetes Dashboard进行通信。由于双方都同意这个端口,所以它是错误的端口也没有关系。

但这一切都让人非常沮丧。这意味着 "重现 "根本就不是重现。curl,在错误的端口上得到同样的错误信息,但原因不同。与此同时,我们继续前进,将Kubernetes Dashboard改为监听80端口,将k3dash 改为连接80端口。我们认为有可能是Envoy代理对端口号进行了一些特殊处理,现在回想起来,这真的没有什么意义。无论如何,这导致了我们的 "重现 "根本不是重现的情况。

该错误在k3dash

现在很明显,Sibi是对的。curl 可以连接,k3dash 则不能。错误一定k3dash 。但我无法找出原因。作为这个工具链中所有HTTP库的作者,我开始担心我的HTTP客户端库本身可能是这个错误的来源。我也走到了一个兔子洞里,把一些最小的样本程序放在了k3dash 。我把它们kubectl cp,然后运行它们......结果一切正常。吁,我的库在工作,但不是k3dash

然后我做了我一开始就应该做的事情。我非常、非常仔细地看了一下日志。记住,k3dash 是做一个反向代理。所以,它接收一个传入的请求,对其进行修改,提出新的请求,然后发送一个修改后的响应。日志中包括了修改后的HTTP请求(一些字段被修改以删除私人信息):

2021-07-15 05:20:39.820662778 UTC ServiceRequest Request {
  host                 = "kube360-kubernetes-dashboard.kube360-system.svc.cluster.local"
  port                 = 80
  secure               = False
  requestHeaders       = [("X-Real-IP","127.0.0.1"),("host","test-kube360-hostname.hidden"),("upgrade-insecure-requests","1"),("user-agent","<REDACTED>"),("accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"),("sec-gpc","1"),("referer","http://test-kube360-hostname.hidden/dash"),("accept-language","en-US,en;q=0.9"),("cookie","<REDACTED>"),("x-forwarded-for","192.168.0.1"),("x-forwarded-proto","http"),("x-request-id","<REDACTED>"),("x-envoy-attempt-count","3"),("x-envoy-internal","true"),("x-forwarded-client-cert","<REDACTED>"),("Authorization","<REDACTED>")]
  path                 = "/"
  queryString          = ""
  method               = "GET"
  proxy                = Nothing
  rawBody              = False
  redirectCount        = 0
  responseTimeout      = ResponseTimeoutNone
  requestVersion       = HTTP/1.1
}

我试图在这里留下足够的内容,让你有和我看它一样的压倒性的感觉。请记住,requestHeaders 字段在实践中大约是三倍的长度。不管怎么说,有了这些细化的标题,以及我所有的提示,看看你是否能猜到问题出在哪里。

准备好了吗?是Host 的标题!让我们引用Istio流量路由文档中的一段话。关于HTTP流量,它说。

请求的路由是基于端口和 *Host*头,而不是端口和IP。这意味着目标IP地址实际上被忽略了。例如,curl 8.8.8.8 -H "Host: productpage.default.svc.cluster.local" ,将被路由到productpage 服务。

看到问题了吗?k3dash 表现得像一个标准的反向代理,并且包括Host 头,这几乎总是正确的事情。但在这里不是这样的!在这种情况下,我们转发的那个Host 头令Envoy很困惑。Envoy正试图连接到不响应其mTLS连接的东西(test-kube360-hostname.hidden )。这就是为什么我们得到了upstream connect error 。 这也是为什么我们得到了与使用错误的端口号时相同的响应,因为Envoy被配置为只接收服务实际监听的端口上的传入流量。

修复方法

在所有这些之后,修复是相当反常的:

-(\(h, _) -> not (Set.member h _serviceStripHeaders))
+-- Strip out host headers, since they confuse the Envoy proxy
+(\(h, _) -> not (Set.member h _serviceStripHeaders) && h /= "Host")

我们已经在k3dash ,为每个服务剥离了特定的头信息。而事实证明,这个逻辑主要是用来剥离Host 头信息的,当他们看到这个头信息时,会感到困惑!现在我们只需要为所有的服务剥离Host 头。幸运的是,我们的服务中没有一个是基于Host 标头执行任何逻辑的,所以有了这个,我们应该就可以了。我们部署了新版本的k3dash ,然后就看到了!一切都成功了。

这个故事的寓意

在这次冒险中,我对Istio如何与应用程序互动有了更好的理解,这很好。我得到了一个很好的提醒,在我对一个bug的来源进行硬性假设之前,要更仔细地看一下日志信息。我还为自己在端口号修复方面的懒惰而被狠狠地踢了一脚。

总而言之,这大约是六个小时的调试乐趣。引用一句伟大的希伯来语来形容它,"היהטוב, היה"(它是好的,而且好的(在过去))。