LoadBalancer在kubernetes架構下的實踐

gaorong404發表於2020-05-24

Backgound

藉助於kubernetes優秀的彈性擴縮功能,執行其中的應用程式能夠在流量突增的時候坦然應對,在流量低谷的時候無需擔心成本。但於此同時,也帶來了極大的挑戰: 彈性擴縮導致容器IP動態變化,客戶端無法直接依賴於容器IP進行訪問,我們必須通過某種方式固定流量入口,將流量通過該固定入口均衡地分發到後端,在容器擴縮的過程能夠隨著容器啟停動態更新後端地址。

在這種場景下,我們自然而然地會想到廣泛使用的LoadBalancer。kubernetes中service資源其實就是一種LoadBalancer。 service可以會產生一個serviceIP,通過label selecter選定一組pod,流量會通過該serviceIp負載均衡到後端的pod。

service有很多型別: ClusterIP,NodePort,LoadBalancer。在應用於實際複雜的業務場景,以上型別各有利弊:

  • ClusterIP 是通過分配一個虛擬IP給每個service,通過kube-proxy實現轉發,這個虛擬的IP在叢集外無法被直接訪問到,只適合叢集內部的互相呼叫。
  • NodePort 是通過將流量轉發到宿主機上,然後通過kube-proxy轉發到對應的pod, 每建立一個該型別的service就會佔用一個宿主機的埠用做轉發。此種型別的service雖然可以實現叢集外部訪問, 但是無法大規模應用,因為service比較多的時候,埠容易衝突,管理起來比較麻煩。
  • LodaBalacner會建立一個真實的LoadBalancer, 然後將流量轉發到NodePort service之上,因為此時NodePort埠對使用者透明,由kubernetes自動分配並管理,所以不存在上述提到的埠衝突的問題。 但是缺點就是效能,功能和擴充套件性
    • 效能不高: 需要經過nodePort的轉發,LB首先將流量轉發到其中某一臺node上面,然後再經過kube-proxy的轉發,如果pod沒有在這一臺機器上面,還需要再轉發一次到其他的node上面,如此一來就多了一跳。
    • 擴充套件性: 同時由於LoadBalancer會直接將所有的node掛載到LB之上,如果叢集規模變大,到了幾百幾千臺就會達到LB的限制,無法繼續新增機器。社群雖然提供了externalTraficPolicy這種機制,只掛載pod所在的node到LB, 但是這樣會導致流量轉發不均衡,例如如果nodeA上面有兩個pod,nodeB上面有一個pod, LB是將流量平均的轉達到兩個node上面, 而不是根據pod數目設定不同的權重, 參見社群Caveats and Limitations when preserving source IPs
    • 功能: 會有源IP丟失的問題,在轉發過程中需要做SNAT和NAT, 在某些業務場景下無法滿足使用者需求。

除了service之外,還有ingress用來實現負載均衡。 ingress本質上是一個代理,廣泛用於七層協議,對於一些四層或者gRPC型別的支援不太好。同時ingress controller容器本身也會發生容器漂移等現象,也需要一個四層的負載均衡動態地轉發流量到後端。

Requirement

明確了上述各種型別的service的特點之後,我們需要明確我們所需要的service到底是什麼樣子,主要體現為: 功能,可用性,效能。

功能

能夠在叢集外部被訪問到,將流量從外部均勻地傳遞到叢集內的多個容器。這其實就是kubernetes中LoadBalacner型別的service,對於每一個service我們使用一個真實的負載均衡器,藉助於公司內部的或者公有云廠商提供的負載均衡裝置即可,這些產品一般都比較成熟。

效能

流量能夠高效地轉發到容器中,LoadBalancer作為底層基礎架構,需要滿足各種各樣業務對網路效能的要求。流量能夠高效的轉發到容器內, 這點需要我們LB後端直接掛載容器,不用再經過NodePort或者iptable轉發, 對於這點我們需要對底層網路有一定的要求,需要LB能夠連線到podIP上,需要VPC直連的容器網路方案,而overlay方式的容器網路在容器叢集外是無法直接訪問的,此處就無法使用。不過一般情況下,真正在生產環境中被廣泛使用的也就是VPC直連的容器網路方案,各個雲廠商也有提供相應的解決方案。

VPC直連的網路方案現在被廣泛採用,不光是為了解決LB連線的問題, 還具有其他優勢:

  • 首先業務需要podIP可以被直接訪問到,便於架構上雲時進行遷移,有些時候, 部分業務在容器裡, 部分還在物理機上,他們需要能夠互通。
  • 效能需求,VPC直連的沒有overlay封包解包的效能損耗
  • 方便診斷,維護起來更加簡單,可以直接看做是物理機使用
  • 目前各個雲廠商都有相關的CNI外掛, 有利於多雲架構的實現

對於LB直接連線容器, 其實在之前的架構下也是這麼做的,已經證明了可行性。 只是老的架構是通過在富容器中啟動一個agent,由agent註冊自身容器IP到LB。老架構由於設計的較早,當時容器還是被當作虛擬機器使用,當時還沒有kubernetes,沒有controller模式, 隨著慢慢發展暴露出很多問題:

  • 許可權管理難以實現, 分散在各個容器之中
  • 異常處理不集中, 在容器被暴力清理掉之後,來不及從LB上解綁就退出, 進而導致流量繼續轉發到該容器之中, 或者需要另一個非同步清理的程式來實現清理
  • 系統呼叫耦合嚴重,介面難以升級, 升級介面需要重啟所有的容器
  • 耗費資源,每個富容器中都會有相關的agent

由於老的架構設計較早,問題比較多,再重新思考這個問題的時候, 希望用雲原生的方式,運用operater模式實現整個流程。

可用性

在容器動態擴縮過程中,需要保證流量平滑遷移,不能導致業務流量丟失。這是最基本的可用性保證。也是需要考慮最多的地方。kubernetes為了架構的簡單,將功能分成多個模組非同步執行,例如pod啟動和健康檢查是由kubelet負責,但是流量轉發是由kube-proxy負責,他們之間沒有直接的互動,這就會碰到分散式系統中執行時序的問題。如果容器還沒啟動流量就已經轉發過來了就導致流量的丟失,或者容器已經退出但流量繼續轉發過來也會導致流量的丟失,這些情況對於滾動更新的pod尤其明顯。 因為所有的操作都需要遠端呼叫來操作LoadBalaner, 我們不得不考慮執行速度帶來的影響。

一般情況下對於容器啟動的時候我們無需過多擔心, 只有啟動之後才能接收流量, 需要擔心的容器退出的過程中,需要確保流量還沒有摘掉前容器不能退出,否則就會導致流量丟失。主要體現為兩點:

  • 滾動更新的過程中需要保證新版本容器正常接收到流量之後才能繼續滾動更新的過程,才能去刪除老版本容器。如果隨便kill掉老版本例項,此時新版本註冊還沒有生效, 就會導致流量的丟失。
  • 在退出的過程中需要等待流量完全摘除掉之後才能去刪除容器。
滾動更新過程

對於滾動更新, 該過程一般是由對應的workload controller負責的, 例如deployment,statfulSet。 以deployment滾動更新為例,如果不加干預整個流程為: 新版本pod啟動,readiness探針通過, controller將podIP掛載到LB上面, LB生效一般都需要時間,此時流量還不能轉發到新版本pod裡面。於此同時deployment認為新容器已經就緒,就進行下一步,刪除掉老版本的pod。 此時新老版本都不能接收流量了,就導致了整個服務的不可用。這裡根本原因是deployment認為pod就緒並不會考慮LB是否就緒,LB是k8s系統外部的資源,deployment並不認識。退一步來講,我們平時使用的InCluster型別的service也是有這個問題的,kubelet中容器退出和kube-proxy流量摘除似乎是同時進行的,並沒有時序保證,如果kube-proxy執行的稍微慢一點,kubelet中容器退出的稍微快一點,就會碰到流量丟失地情況。幸運的是目前kub-proxy是基於iptables實現的轉發,重新整理iptables規則在一般情況下執行速度足夠快,我們很難碰到這種情況。 但是如果我們基於LoadBalancer直接掛載容器IP,就沒有這麼幸運了,我們需要遠端呼叫操作LB,而且需要雲廠商的LB生效都比較慢,鑑於此,我們需要想辦法等到LB就緒之後才能認為整個pod就緒, 即pod就緒等於容器就緒(健康檢查探針通過) + LB掛載就緒, pod就緒後才能進行滾動更新。

社群也碰到過過這個問題,開發了Pod Readiness Gates(ready++)的特性,使用者可以通過 ReadinessGates 自定義 Pod 就緒的條件,當使用者自定義的條件以及容器狀態都就緒時,kubelet 才會標記 Pod 準備就緒。 如下所示,使用者需要設定readinessGate:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    run: nginx
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      run: nginx
  template:
    metadata:
      labels:
        run: nginx
    spec:
      readinessGates:
      - conditionType: cloudnativestation.net/load-balancer-ready    # <- 這裡設定readinessGatea
      containers:
      - image: nginx
        name: nginx

當我們給deployment設定了readinessGate這個欄位之後, 當pod啟動成功通過reainess的檢查之後,並不會認為整個pod已經就緒,因為此時LB還沒有就緒, 如果我們此時觀察pod的status會發現如下資訊

  status:
    conditions:
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      status: "True"
      type: Initialized
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      message: corresponding condition of pod readiness gate "cloudnativestation.net/load-balancer-ready"
        does not exist.
      reason: ReadinessGatesNotReady
      status: "False"
      type: Ready                   # <--- Ready為False
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:20Z"
      status: "True"
      type: ContainersReady          # <--- container Ready為Ture
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      status: "True"
      type: PodScheduled
    containerStatuses:
    - containerID: docker://42e761fd53ccb2b2886c500295ceeff8f1d2ffc2376eb66dd95a436c395b95c0
      image: nginx:latest
      imageID: docker-pullable://nginx@sha256:2e6775f4300fc79b9d7fe6bb60c83b5fefe584258d9318ed408746789af48885
      lastState: {}
      name: nginx
      ready: true
      restartCount: 0
      state:
        running:
          startedAt: "2020-03-14T11:34:19Z"

conditions資訊中 ContainerReady為True, 但是Ready卻為False, message中提示"對應的readiness gate condition還不存在", 那我們只需要patch上對應的condition即可, 如下所示:

  status:
    conditions:
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      message: LB synced successfully
      reason: LBHealthy
      status: "True"
      type: cloudnativestation.net/load-balancer-ready       # <--- 增加readiness gate condtion
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      status: "True"
      type: Initialized
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:05Z"
      status: "True"
      type: Ready                                     # <--- pod狀態變為ready
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:05Z"
      status: "True"
      type: ContainersReady
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      status: "True"
      type: PodScheduled
    containerStatuses:
    - containerID: docker://65e894a7ef4e53c982bd02da9aee2ddae7c30e652c5bba0f36141876f4c30a01
      image: nginx:latest
      imageID: docker-pullable://nginx@sha256:2e6775f4300fc79b9d7fe6bb60c83b5fefe584258d9318ed4087467

手動設定完readiness gate的condtion之後整個pod才能變為ready。

容器退出過程

對於容器退出的過程中, 我們需要及時將流量從LB上面摘除。 一個pod典型的退出流程為: 我們從控制檯下達刪除pod的命令時,apiserver會記錄pod deletionTimestamp 標記在pod的manifest中, 隨後開始執行刪除邏輯,首先傳送SIGTERM 訊號, 然後最大等待terminationGracePeriodSeconds傳送SIGKILL訊號強制清理, terminationGracePeriodSeconds該值使用者可以自行在pod的manifest中指定。
結合整個退出過程,我們需要在監聽到容器退出開始時(也就是deletionTimestamp被標記時) 在LB上將該pod流量權重置為0, 這樣新建連線就不到達該容器,同時已有連線不受影響,可以繼續提供服務。等到容器真正退出時才將該pod從LB上面摘除。使用者如果想要更加安全的流量退出邏輯,可以設定一個稍長一點的terminationGracePeriodSeconds, 甚至設定prestop邏輯或者處理SIGTERM訊號, 讓pod在退出前等待足夠長的時間將流量徹底斷掉,

Action

明確了整個架構中的關鍵點後,就是具體的實現環節了。 這部分我們可以借鑑社群提供的service controller及各個雲廠商LB在kubernetes中的應用。 社群為了遮蔽掉不同雲廠商產品的差異,開發了cloud-controller-manager, 其內部定義了很多介面, 各個雲廠商只需要實現其中的介面就可以在合適的時候被呼叫。 對於LoadBalancer定義介面如下:

// LoadBalancer is an abstract, pluggable interface for load balancers.
type LoadBalancer interface {
	// TODO: Break this up into different interfaces (LB, etc) when we have more than one type of service
	// GetLoadBalancer returns whether the specified load balancer exists, and
	// if so, what its status is.
	// Implementations must treat the *v1.Service parameter as read-only and not modify it.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	GetLoadBalancer(ctx context.Context, clusterName string, service *v1.Service) (status *v1.LoadBalancerStatus, exists bool, err error)
	// GetLoadBalancerName returns the name of the load balancer. Implementations must treat the
	// *v1.Service parameter as read-only and not modify it.
	GetLoadBalancerName(ctx context.Context, clusterName string, service *v1.Service) string
	// EnsureLoadBalancer creates a new load balancer 'name', or updates the existing one. Returns the status of the balancer
	// Implementations must treat the *v1.Service and *v1.Node
	// parameters as read-only and not modify them.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	EnsureLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (*v1.LoadBalancerStatus, error)
	// UpdateLoadBalancer updates hosts under the specified load balancer.
	// Implementations must treat the *v1.Service and *v1.Node
	// parameters as read-only and not modify them.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	UpdateLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) error
	// EnsureLoadBalancerDeleted deletes the specified load balancer if it
	// exists, returning nil if the load balancer specified either didn't exist or
	// was successfully deleted.
	// This construction is useful because many cloud providers' load balancers
	// have multiple underlying components, meaning a Get could say that the LB
	// doesn't exist even if some part of it is still laying around.
	// Implementations must treat the *v1.Service parameter as read-only and not modify it.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	EnsureLoadBalancerDeleted(ctx context.Context, clusterName string, service *v1.Service) error
}

當使用者建立LoabBalancer型別的service時,cloud-controller-manager中的service controller就會利用informer監聽service的建立、更新、刪除事件,然後呼叫各個雲廠商註冊的介面,雲廠商只需要提供以上的介面就行了。

對於Loadbalancer,具體各個廠商實現不同, 但是目前的實現基本都是直接掛載nodePort, 可以看到上述EnsureLoadBalancer中傳遞的引數也是nodes列表。 上述的介面我們無法直接使用,需要對其改造, 實現一個自定義的service controller。在EnsureLoadBalancer的時候傳遞的引數也應該是pod的IP列表, 我們掛載的是pod而不是node。所以此處需要不斷監聽pod的變化,然後選擇判斷該pod是否被service label selector選中,如果選中則該pod是service的後端,需要設定將流量轉發到該pod上面, 這裡很多熟悉kubernetes的小夥伴就會好奇,這裡不是和endpoints的功能一模一樣嗎? 為什麼不直接監聽endpoint, 然後將endpoint中的ip列表拿出來直接使用?

要弄明白這個問題,我們需要回顧我們在保證流量不丟的時候設定了readinessGate, 此時pod就緒狀態會變為: 容器就緒+LB就緒。但是在endpoint的工作原理中, endpoint controller會判斷pod是否就緒,pod就緒之後才會將podIP放在endpoint的結構體中。而我們期望容器就緒之後就在endpoint顯示出來,這樣我們就可以拿著這個enpoint的ip列表去註冊到LB上, LB註冊成功之後,pod才能變為就緒。 社群endpoint中iplist的順序和我們期望的略有差異, 只能自己實現一個類似的結構體了,和社群的使用方式大部分相同, 只是判斷就緒的邏輯略有不同。

自定義endpoint的另外一個原因是: endpoint controller會將service選中的所有pod分為ready和unready兩組, 當pod剛啟動時, 還未通過readiness探針檢查時會將pod放置在unReadAddress列表中,通過readiness檢查後會移動到address列表中,隨後在退出時會直接將pod移出address列表中。 在我們的場景下,更加合理的邏輯應該是在退出過程中應該從endpoint中address列表移動到unReadyAddress列表,這樣我們就可以根據unReadyAddress來決定在退出的時候將哪些podIP在LB上面將權重置為0。

自定義endpoint controller並沒有更改kubernetes原來的endpoint controller的程式碼, 這裡我們只是作為一個內部的資料結構體使用, 直接結合在service controller中即可,也無需監聽endpoint變化,直接監聽pod變化生成對應的service 即可。

收穫

在落地kubernetes的過程中, 相信kube-proxy被不少人詬病,甚至有不少公司完全拋棄了kube-proxy。 不好的東西我們就要積極探索一種更好,更適合公司內部情況的解決方案。目前該滿足了不同業務上雲時的網路需求,承載了不同的流量型別。 同時很好地應用在多雲環境下,私有云和公有云下都可以適配, 儘管私有云或者公有云的底層網路方案或者LB實現不同,但是整個架構相同,可以無縫地在私有云,aws, 阿里,金山雲直接遷移。

kubernetes的快速發展為我們帶來了很多驚喜,但是於此同時很多細節的地方需要打磨,需要時間的沉澱才能更加完美, 相信在落地kubernetes的過程中不少人被kubernetes的網路模型所困擾,此時我們需要根據企業內部的情況, 結合已有的基礎設施,根據社群已經提供的和尚未提供的功能進行一些大膽的微創新,然後探索更多的可能性。

相關文章