Istio 運維實戰系列(1):應用容器對 Envoy Sidecar 的啟動依賴問題

騰訊雲原生發表於2020-09-29

本系列文章將介紹使用者從 Spring Cloud,Dubbo 等傳統微服務框架遷移到 Istio 服務網格時的一些經驗,以及在使用 Istio 過程中可能遇到的一些常見問題的解決方法。

故障現象

該問題的表現是安裝了 sidecar proxy 的應用在啟動後的一小段時間內無法通過網路訪問 pod 外部的其他服務,例如外部的 HTTP,MySQL,Redis等服務。如果應用沒有對依賴服務的異常進行容錯處理,該問題還常常會導致應用啟動失敗。下面我們以該問題導致的一個典型故障的分析過程為例對該問題的原因進行說明。

典型案例:某運維同學反饋:昨天晚上 Istio 環境中應用的心跳檢測報 connect reset,然後服務重啟了。懷疑是 Istio 環境中網路不穩定導致了服務重啟。

故障分析

根據運維同學的反饋,該 pod 曾多次重啟。因此我們先用 kubectl logs --previous 命令查詢 awesome-app 容器最後一次重啟前的日誌,以從日誌中查詢其重啟的原因。

kubectl logs --previous awesome-app-cd1234567-gzgwg -c awesome-app

從日誌中查詢到了其重啟前最後的錯誤資訊如下:

Logging system failed to initialize using configuration from 'http://log-config-server:12345/******/logback-spring.xml'
java.net.ConnectException: Connection refused (Connection refused)
        at java.net.PlainSocketImpl.socketConnect(Native Method)
        at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
        at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)

從錯誤資訊可以得知,應用程式在啟動時試圖通過 HTTP 協議從配置中心拉取 logback 的配置資訊,但該操作由於網路異常失敗了,導致應用程式啟動失敗,最終導致容器重啟。

是什麼導致了網路異常呢?我們再用 Kubectl get pod 命令查詢 Pod 的執行狀態,嘗試找到更多的線索:

kubectl get pod awesome-app-cd1234567-gzgwg  -oyaml

命令輸出的 pod 詳細內容如下,該 yaml 片段省略了其他無關的細節,只顯示了 lastState 和 state 部分的容器狀態資訊。

containerStatuses:
  - containerID: 
    lastState:
      terminated:
        containerID: 
        exitCode: 1
        finishedAt: 2020-09-01T13:16:23Z
        reason: Error
        startedAt: 2020-09-01T13:16:22Z
    name: awesome-app
    ready: true
    restartCount: 2
    state:
      running:
        startedAt: 2020-09-01T13:16:36Z
  - containerID: 
    lastState: {}
    name: istio-proxy
    ready: true
    restartCount: 0
    state:
      running:
        startedAt: 2020-09-01T13:16:20Z
  hostIP: 10.0.6.161

從該輸出可以看到 pod 中的應用容器 awesome-app 重啟了兩次。整理該 pod 中 awesome-app 應用容器和 istio-proxy sidecar 容器的啟動和終止的時間順序,可以得到下面的時間線:

  1. 2020-09-01T13:16:20Z istio-proxy 啟動
  2. 2020-09-01T13:16:22Z awesome-app 上一次啟動時間
  3. 2020-09-01T13:16:23Z awesome-app 上一次異常退出時間
  4. 2020-09-01T13:16:36Z awesome-app 最後一次啟動,以後就一直正常執行

可以看到在 istio-proxy 啟動2秒後,awesome-app 啟動,並於1秒後異常退出。結合前面的日誌資訊,我們知道這次啟動失敗的直接原因是應用訪問配置中心失敗導致。在 istio-proxy 啟動16秒後,awesome-app 再次啟動,這次啟動成功,之後一直正常執行。

istio-proxy 啟動和 awesome-app 上一次異常退出的時間間隔很短,只有2秒鐘,因此我們基本可以判斷此時 istio-proxy 尚未啟動初始化完成,導致 awesome-app 不能通過istio-proxy 連線到外部服務,導致其啟動失敗。待 awesome-app 於 2020-09-01T13:16:36Z 再次啟動時,由於 istio-proxy 已經啟動了較長時間,完成了從 pilot 獲取動態配置的過程,因此 awesome-app 向 pod 外部的網路訪問就正常了。

如下圖所示,Envoy 啟動後會通過 xDS 協議向 pilot 請求服務和路由配置資訊,Pilot 收到請求後會根據 Envoy 所在的節點(pod或者VM)組裝配置資訊,包括 Listener、Route、Cluster等,然後再通過 xDS 協議下發給 Envoy。根據 Mesh 的規模和網路情況,該配置下發過程需要數秒到數十秒的時間。由於初始化容器已經在 pod 中建立了 Iptables rule 規則,因此這段時間內應用向外傳送的網路流量會被重定向到 Envoy ,而此時 Envoy 中尚沒有對這些網路請求進行處理的監聽器和路由規則,無法對此進行處理,導致網路請求失敗。(關於 Envoy sidecar 初始化過程和 Istio 流量管理原理的更多內容,可以參考這篇文章 Istio流量管理實現機制深度解析img

解決方案

在應用啟動命令中判斷 Envoy 初始化狀態

從前面的分析可以得知,該問題的根本原因是由於應用程式對 Envoy sidecar 配置初始化的依賴導致的。因此最直接的解決思路就是:在應用程式啟動時判斷 Envoy sidecar 的初始化狀態,待其初始化完成後再啟動應用程式。

Envoy 的健康檢查介面 localhost:15020/healthz/ready 會在 xDS 配置初始化完成後才返回 200,否則將返回 503,因此可以根據該介面判斷 Envoy 的配置初始化狀態,待其完成後再啟動應用容器。我們可以在應用容器的啟動命令中加入呼叫 Envoy 健康檢查的指令碼,如下面的配置片段所示。在其他應用中使用時,將 start-awesome-app-cmd 改為容器中的應用啟動命令即可。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: awesome-app-deployment
spec:
  selector:
    matchLabels:
      app: awesome-app
  replicas: 1
  template:
    metadata:
      labels:
        app: awesome-app
    spec:
      containers:
      - name: awesome-app
        image: awesome-app
        ports:
        - containerPort: 80
        command: ["/bin/bash", "-c"]
        args: ["while [[ \"$(curl -s -o /dev/null -w ''%{http_code}'' localhost:15020/healthz/ready)\" != '200' ]]; do echo Waiting for Sidecar;sleep 1; done; echo Sidecar available; start-awesome-app-cmd"]

該流程的執行順序如下:

  1. Kubernetes 啟動 應用容器。
  2. 應用容器啟動指令碼中通過 curl get localhost:15020/healthz/ready 查詢 Envoy sidcar 狀態,由於此時 Envoy sidecar 尚未就緒,因此該指令碼會不斷重試。
  3. Kubernetes 啟動 Envoy sidecar。
  4. Envoy sidecar 通過 xDS 連線 Pilot,進行配置初始化。
  5. 應用容器啟動指令碼通過 Envoy sidecar 的健康檢查介面判斷其初始化已經完成,啟動應用程式。

該方案雖然可以規避依賴順序的問題,但需要對應用容器的啟動指令碼進行修改,對 Envoy 的健康狀態進行判斷。更理想的方案應該是應用對 Envoy sidecar 不感知。

通過 pod 容器啟動順序進行控制

通過閱讀 Kubernetes 原始碼 ,我們可以發現當 pod 中有多個容器時,Kubernetes 會在一個執行緒中依次啟動這些容器,如下面的程式碼片段所示:

	// Step 7: start containers in podContainerChanges.ContainersToStart.
	for _, idx := range podContainerChanges.ContainersToStart {
		start("container", containerStartSpec(&pod.Spec.Containers[idx]))
  }

因此我們可以在向 pod 中注入 Envoy sidecar 時將 Envoy sidecar 放到應用容器之前,這樣 Kubernetes 會先啟動 Envoy sidecar,再啟動應用容器。但是還有一個問題,Envoy 啟動後我們並不能立即啟動應用容器,還需要等待 xDS 配置初始化完成。這時我們就可以採用容器的 postStart lifecycle hook來達成該目的。Kubernetes 會在啟動容器後呼叫該容器的 postStart hook,postStart hook 會阻塞 pod 中的下一個容器的啟動,直到 postStart hook 執行完成。因此如果在 Envoy sidecar 的 postStart hook 中對 Envoy 的配置初始化狀態進行判斷,待完成初始化後再返回,就可以保證 Kubernetes 在 Envoy sidecar 配置初始化完成後再啟動應用容器。該流程的執行順序如下:

  1. Kubernetes 啟動 Envoy sidecar 。
  2. Kubernetes 執行 postStart hook。
  3. postStart hook 通過 Envoy 健康檢查介面判斷其配置初始化狀態,直到 Envoy 啟動完成 。
  4. Kubernetes 啟動應用容器。

Istio 已經在 1.7 中合入了該修復方案,參見 Allow users to delay application start until proxy is ready #24737

插入 sidecar 後的 pod spec 如下面的 yaml 片段所示。postStart hook 配置的 pilot-agent wait 命令會持續呼叫 Envoy 的健康檢查介面 '/healthz/ready' 檢查其狀態,直到 Envoy 完成配置初始化。這篇文章Delaying application start until sidecar is ready中介紹了更多關於該方案的細節。

apiVersion: v1
kind: Pod
metadata:
  name: sidecar-starts-first
spec:
  containers:
  - name: istio-proxy
    image: 
    lifecycle:
      postStart:
        exec:
          command:
          - pilot-agent
          - wait
  - name: application
    image: my-application

該方案在不對應用進行修改的情況下比較完美地解決了應用容器和 Envoy sidecar 初始化的依賴問題。但是該解決方案對 Kubernetes 有兩個隱式依賴條件:Kubernetes 在一個執行緒中按定義順序依次啟動 pod 中的多個容器,以及前一個容器的 postStart hook 執行完畢後再啟動下一個容器。這兩個前提條件在目前的 Kuberenetes 程式碼實現中是滿足的,但由於這並不是 Kubernetes的 API 規範,因此該前提在將來 Kubernetes 升級後很可能被打破,導致該問題再次出現。

Kubernetes 支援定義 pod 中容器之間的依賴關係

為了徹底解決該問題,避免 Kubernetes 程式碼變動後該問題再次出現,更合理的方式應該是由 Kubernetes 支援顯式定義 pod 中一個容器的啟動依賴於另一個容器的健康狀態。目前 Kubernetes 中已經有一個 issue Support startup dependencies between containers on the same Pod #65502 對該問題進行跟蹤處理。如果 Kubernetes 支援了該特性,則該流程的執行順序如下:

  1. Kubernetes 啟動 Envoy sidecar 容器。
  2. Kubernetes 通過 Envoy sidecar 容器的 readiness probe 檢查其狀態,直到 readiness probe 反饋 Envoy sidecar 已經 ready,即已經初始化完畢。
  3. Kubernetes 啟動應用容器。

解耦應用服務之間的啟動依賴關係

以上幾個解決方案的思路都是控制 pod 中容器的啟動順序,在 Envoy sidecar 初始化完成後再啟動應用容器,以確保應用容器啟動時能夠通過網路正常訪問其他服務。但這些方案只是『頭痛醫頭,腳痛醫腳』,是治標不治本的方法。因為即使 pod 中對外的網路訪問沒有問題,應用容器依賴的其他服務也可能由於尚未啟動,或者某些問題而不能在此時正常提供服務。要徹底解決該問題,我們需要解耦應用服務之間的啟動依賴關係,使應用容器的啟動不再強依賴其他服務。

在一個微服務系統中,原單體應用中的各個業務模組被拆分為多個獨立程式(服務)。這些服務的啟動順序是隨機的,並且服務之間通過不可靠的網路進行通訊。微服務多程式部署、跨程式網路通訊的特定決定了服務之間的呼叫出現異常是一個常見的情況。為了應對微服務的該特點,微服務的一個基本的設計原則是 "design for failure",即需要以優雅的方式應對可能出現的各種異常情況。當在微服務程式中不能訪問一個依賴的外部服務時,需要通過重試、降級、超時、斷路等策略對異常進行容錯處理,以儘可能保證系統的正常執行。

Envoy sidecar 初始化期間網路暫時不能訪問的情況只是放大了微服務系統未能正確處理服務依賴的問題,即使解決了 Envoy sidecar 的依賴順序,該問題依然存在。例如在本案例中,配置中心也是一個獨立的微服務,當一個依賴配置中心的微服務啟動時,配置中心有可能尚未啟動,或者尚未初始化完成。在這種情況下,如果在程式碼中沒有對該異常情況進行處理,也會導致依賴配置中心的微服務啟動失敗。在一個更為複雜的系統中,多個微服務程式之間可能存在網狀依賴關係,如果沒有按照 "design for failure" 的原則對微服務進行容錯處理,那麼只是將整個系統啟動起來就將是一個巨大的挑戰。對於本例而言,可以採用一個類似這樣的簡單容錯策略:先用一個預設的 logback 配置啟動應用程式,並在啟動後對配置中心進行重試,待連線上配置中心後,再使用配置中心下發的配置對 logback 進行設定。

小結

應用容器對 Envoy Sidecar 啟動依賴問題的典型表現是應用容器在剛啟動的一小段時間內呼叫外部服務失敗。原因是此時 Envoy sidecar 尚未完成 xDS 配置的初始化,因此不能為應用容器轉發網路請求。該呼叫失敗可能導致應用容器不能正常啟動。此問題的根本原因是微服務應用中對依賴服務的呼叫失敗沒有進行合理的容錯處理。對於遺留系統,為了儘量避免對應用的影響,我們可以通過在應用啟動命令中判斷 Envoy 初始化狀態的方案,或者升級到 Istio 1.7 來緩解該問題。但為了徹底解決服務依賴導致的錯誤,建議參考 "design for failure" 的設計原則,解耦微服務之間的強依賴關係,在出現暫時不能訪問一個依賴的外部服務的情況時,通過重試、降級、超時、斷路等策略進行處理,以儘可能保證系統的正常執行。

參考文件

【騰訊雲原生】雲說新品、雲研新術、雲遊新活、雲賞資訊,掃碼關注同名公眾號,及時獲取更多幹貨!!

相關文章