本文已發布超過一年。較舊的文章可能包含過時的內容。請檢查頁面中的資訊自發布以來是否已變得不正確。

kube-proxy 細微之處:偵錯間歇性連線重設

我最近遇到一個導致間歇性連線重設的錯誤。經過一番挖掘,我發現它是由幾個不同網路子系統的微妙組合引起的。它幫助我更好地理解 Kubernetes 網路,並且我認為值得與對相同主題感興趣的更廣泛受眾分享。

症狀

我們收到使用者報告,聲稱他們在使用 ClusterIP 類型的 Kubernetes 服務向在同一個叢集中運行的 Pod 提供大型檔案時,遇到連線重設。對叢集的初步偵錯沒有產生任何有趣的結果:網路連線良好,下載檔案也沒有遇到任何問題。但是,當我們跨多個用戶端並行運行工作負載時,我們能夠重現問題。更令人費解的是,當使用沒有 Kubernetes 的 VM 運行工作負載時,無法重現該問題。這個問題可以透過一個簡單的應用程式輕鬆重現,顯然與 Kubernetes 網路有關,但究竟是什麼?

Kubernetes 網路基礎知識

在深入研究這個問題之前,讓我們稍微談談 Kubernetes 網路的一些基礎知識,因為 Kubernetes 處理來自 Pod 的網路流量的方式,會根據不同的目的地而有很大的不同。

Pod 到 Pod

在 Kubernetes 中,每個 Pod 都有自己的 IP 位址。這樣做的好處是,在 Pod 內部運行的應用程式可以使用其標準連接埠,而不是重新對應到不同的隨機連接埠。Pod 彼此之間具有 L3 連線能力。它們可以互相 ping,並互相發送 TCP 或 UDP 封包。CNI 是為在不同主機上運行的容器解決這個問題的標準。有大量的不同外掛程式支援 CNI。

Pod 到外部

對於從 Pod 到外部位址的流量,Kubernetes 僅使用 SNAT。它的作用是將 Pod 的內部來源 IP:連接埠替換為主機的 IP:連接埠。當回程封包返回主機時,它會將 Pod 的 IP:連接埠重寫為目的地,並將其發送回原始 Pod。整個過程對原始 Pod 是透明的,原始 Pod 完全不知道位址轉譯。

Pod 到服務

Pod 是會消亡的。大多數情況下,人們想要的是可靠的服務。否則,它幾乎毫無用處。因此,Kubernetes 有一個稱為「服務」的概念,它只是一個位於 Pod 前面的 L4 負載平衡器。服務有幾種不同的類型。最基本的類型稱為 ClusterIP。對於這種服務類型,它有一個唯一的 VIP 位址,該位址僅可在叢集內部路由。

Kubernetes 中實現此功能的組件稱為 kube-proxy。它位於每個節點上,並編寫複雜的 iptables 規則,以在 Pod 和服務之間執行各種過濾和 NAT。如果您前往 Kubernetes 節點並鍵入 iptables-save,您將看到由 Kubernetes 或其他程式插入的規則。最重要的鏈是 KUBE-SERVICESKUBE-SVC-*KUBE-SEP-*

  • KUBE-SERVICES 是服務封包的入口點。它的作用是比對目的地 IP:連接埠,並將封包分派到對應的 KUBE-SVC-* 鏈。
  • KUBE-SVC-* 鏈充當負載平衡器,並將封包平均分配到 KUBE-SEP-* 鏈。每個 KUBE-SVC-*KUBE-SEP-* 鏈數量與其後端的端點數量相同。
  • KUBE-SEP-* 鏈代表服務端點。它僅執行 DNAT,將服務 IP:連接埠替換為 Pod 的端點 IP:連接埠。

對於 DNAT,conntrack 會啟動並使用狀態機追蹤連線狀態。之所以需要狀態,是因為它需要記住它更改為的目的地位址,並在回程封包返回時將其改回。Iptables 也可以依賴 conntrack 狀態 (ctstate) 來決定封包的目標。以下 4 個 conntrack 狀態尤其重要

  • NEW:conntrack 對於此封包一無所知,這發生在收到 SYN 封包時。
  • ESTABLISHED:conntrack 知道封包屬於已建立的連線,這發生在握手完成後。
  • RELATED:封包不屬於任何連線,但它隸屬於另一個連線,這對於 FTP 等協定尤其有用。
  • INVALID:封包有問題,conntrack 不知道如何處理它。此狀態在此 Kubernetes 問題中扮演核心角色。

以下是 Pod 和服務之間 TCP 連線如何運作的圖表。事件順序如下

  • 來自左側的用戶端 Pod 向服務發送封包:192.168.0.2:80
  • 封包正在通過用戶端節點中的 iptables 規則,並且目的地已更改為 Pod IP,10.0.1.2:80
  • 伺服器 Pod 處理封包並發送回目的地為 10.0.0.2 的封包
  • 封包正在返回用戶端節點,conntrack 識別封包並將來源位址重寫回 192.169.0.2:80
  • 用戶端 Pod 接收回應封包
Good packet flow

良好的封包流程

是什麼導致連線重設?

背景知識足夠了,那麼真正出錯並導致意外連線重設的原因是什麼?

如下圖所示,問題出在封包 3。當 conntrack 無法識別回程封包時,並將其標記為 INVALID。最常見的原因包括:conntrack 無法追蹤連線,因為它超出容量,封包本身超出 TCP 視窗等。對於那些被 conntrack 標記為 INVALID 狀態的封包,我們沒有 iptables 規則來丟棄它,因此它將被轉發到用戶端 Pod,而來源 IP 位址未重寫(如封包 4 所示)!用戶端 Pod 無法識別此封包,因為它具有不同的來源 IP,即 Pod IP,而不是服務 IP。因此,用戶端 Pod 會說:「等一下,我不記得曾經與這個 IP 建立過連線,為什麼這個傢伙一直向我發送這個封包?」基本上,用戶端所做的只是向伺服器 Pod IP 發送 RST 封包,即封包 5。不幸的是,這是一個完全合法的 Pod 到 Pod 封包,可以傳遞到伺服器 Pod。伺服器 Pod 不知道用戶端發生的所有位址轉譯。從它的角度來看,封包 5 是一個完全合法的封包,就像封包 2 和 3 一樣。伺服器 Pod 只知道:「好吧,用戶端 Pod 不想與我交談,所以讓我們關閉連線!」砰!當然,為了讓所有這些發生,RST 封包也必須是合法的,具有正確的 TCP 序號等。但當它發生時,雙方都同意關閉連線。

Connection reset packet flow

連線重設封包流程

如何解決它?

一旦我們了解根本原因,修復並不難。至少有 2 種方法可以解決它。

  • 讓 conntrack 在封包上更寬鬆,不要將封包標記為 INVALID。在 Linux 中,您可以透過 echo 1 > /proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal 來執行此操作。
  • 特別新增一個 iptables 規則來丟棄標記為 INVALID 的封包,這樣它就不會到達用戶端 Pod 並造成損害。

修復程式已在 v1.15+ 中提供。但是,對於受此錯誤影響的使用者,有一種方法可以透過在您的叢集中套用以下規則來緩解問題。

apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
  name: startup-script
  labels:
    app: startup-script
spec:
  template:
    metadata:
      labels:
        app: startup-script
    spec:
      hostPID: true
      containers:
      - name: startup-script
        image: gcr.io/google-containers/startup-script:v1
        imagePullPolicy: IfNotPresent
        securityContext:
          privileged: true
        env:
        - name: STARTUP_SCRIPT
          value: |
            #! /bin/bash
            echo 1 > /proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal
            echo done            

摘要

顯然,這個錯誤幾乎一直存在。我很驚訝直到最近才被注意到。我相信原因可能是:(1) 這在擁塞的伺服器提供大型酬載時更容易發生,這可能不是常見的使用案例;(2) 應用程式層處理重試以容忍這種重設。無論如何,儘管 Kubernetes 的成長速度有多快,它仍然是一個年輕的專案。沒有其他秘訣,只有密切傾聽客戶的回饋、不將任何事情視為理所當然而是深入挖掘,我們才能使其成為運行應用程式的最佳平台。

特別感謝 bowei 為偵錯過程和網誌提供諮詢,以及 tcarmet 報告問題並提供重現。