阿里terway源碼分析

  • 2019 年 10 月 20 日
  • 筆記

背景

隨著公司業務的發展,底層容器環境也需要在各個區域部署,實現多雲架構, 使用各個雲廠商提供的CNI插件是k8s多雲環境下網路架構的一種高效的解法。我們在阿里雲的方案中,便用到了阿里雲提供的CNI插件terway。terway所提供的VPC互通的網路方案,方便對接已有的基礎設施,同時沒有overlay網路封包解包的性能損耗,簡單易用,出現網路問題方便診斷。本文對該插件做簡單的程式碼分析,理解其原理,以便後期診斷問題和維護。

功能劃分

阿里雲開源的terway程式碼有三部分組成:

  • CNI plugin: 即CNI插件,實現ADD、DEL、VERSION三個介面來供kubelet調用, 該插件將kubelet傳遞的參數進行簡單處理後,會通過gRPC調用terwayBackendServer來實現具體的邏輯,例如申請網路設備等。同步調用terwayBackendServer將網路設備分配完畢之後,會通過ipvlanDriver.Driver進行pod sandbox network namespace的Setup操作,同時還會通過TC進行流控。該插件會通過daemonSet中initContainer安裝到所有node上。
  • backend server: terway中主要的執行邏輯, 會進行IPAM管理,並申請對應的網路設備, 這部分是本次著重分析的對象。該程式以daemonSet的方式運行在每個節點上。
  • networkPolicy: 該部分是藉助calico felix實現, 完全與上面兩部分解耦。我們看到terway創建的網路設備是以cali為前綴的, 其實就是為了兼容calico的schema。

TerwayBackendServer

在terway的main函數中會啟動gRPC server監聽請求,同時會創建一個TerwayBackendServer, TerwayBackendServer封裝全部操作邏輯,在newNetworkService函數中會依次初始化各個子模組實例,具體包括:

  • ECS client 用來操作ECS client, 所有創建刪除更新操作最後都會通過該client進行處理,簡單封裝了一層alicloud的SKD
  • kubernetes pod 管理模組,用來同步kubernetes pod資訊
  • resouceDB 用來存儲狀態資訊,便於重啟等操作後恢復狀態
  • resourceManager 管理資源分配的實例,terway會根據不同的配置生成不同的resourceManager,此處我們使用的是ENIMultiIP這種模式,對應的就是newENIIPResourceManager

ENIMultiIP模式會申請阿里雲彈性網卡並配置多個輔助VPC的IP地址,將這些輔助IP地址映射和分配到Pod中,這些Pod的網段和宿主機網段是一致的,能夠實現VPC網路互通。

整個架構如下圖所示:

首先我們理解一下kubernetes pod管理模組,該模組用於獲取kubernetes pod狀態。terway為了支援一些高級的特性,例如流控等,有一些資訊無法通過CNI調用傳遞過來, 還是得去kubernetes中去查詢這些資訊。此外CNI調用在一些異常情況下可能無法準確回調CNI插件, 例如用戶直接kubectl delete pod --force --graceperiod=0,此時就需要kubernetes作為唯一的single source of truth, 保證最後網路設備在pod刪除時肯定能夠被釋放掉。 它內部主要的方法就是GetPodGetLocalPodGetPod方法會請求apiserver返回pod資訊,如果該pod已經在apiserver中刪除,就會從本地的storage中獲取。該storage是用boltDB做為底層存儲的一個本地文件,每個被處理過的pod都會在該storage中保存一份資訊,且該pod副本並不會隨著apiserver中pod的刪除而刪除,這樣後面程式如果需要該pod資訊可以從該storage中獲取。同時該pod副本會通過非同步清理goroutine在pod刪除一小時後刪除。GetLocalPod是從apiserver獲取該node上所有的pod資訊,該過程是調用kubernetes最多的地方,目前兩個清理goroutine會每5min調用一次,調用量相對較小,對apiserver的負載影響不大。該模組也會在本地DB里快取一份數據,便於在kubernetes pod刪除後還可以拿到用戶資訊。

其次是resourceDB模組,該模組是用來持久化狀態資訊,該DB中記錄了當前已分配的pod及其網路設備(networkResource)資訊。每次請求/釋放設備都會更新該DB。程式重新啟動初始化完成之後,也會從resouceDB中恢復上次運行的數據。
除了基本的分配刪除操作會更新該DB, terway還啟動非同步goroutine定期清理,保證異常情況下的最終一致性,該goroutine會從apiserve中獲取所有pod資訊和當前DB中的資訊進行對比,如果對應的pod已經刪除會先釋放對應的網路設備,然後從DB中刪除該記錄。同時延遲清理可以實現Statefulset的Pod在更新過程中IP地址保持不變,

最重要的是resouceManager模組,該iterface封裝了具體網路設備的操作,如下所示:

// ResourceManager Allocate/Release/Pool/Stick/GC pod resource  // managed pod and resource relationship  type ResourceManager interface {      Allocate(context *networkContext, prefer string) (types.NetworkResource, error)      Release(context *networkContext, resID string) error      GarbageCollection(inUseResList map[string]interface{}, expireResList map[string]interface{}) error  }

從其中三個method可以很明顯的看出可以執行的的動作,每次CNI插件調用backendServer時, 就會調用ResoueceManager進行具體的分配釋放操作。對於ENIMultiIP這種模式來說,具體的實現類是eniIPResourceManager

type eniIPResourceManager struct {      pool pool.ObjectPool  }

其中只有pool一個成員函數,具體的實現類型是simpleObjectPool, 該pool維護了當前所有的ENI資訊。當resouceManager進行分配釋放網路設備的時候其實是從該pool中進行存取即可:

func (m *eniIPResourceManager) Allocate(ctx *networkContext, prefer string) (types.NetworkResource, error) {      return m.pool.Acquire(ctx, prefer)  }    func (m *eniIPResourceManager) Release(context *networkContext, resID string) error {      if context != nil && context.pod != nil {          return m.pool.ReleaseWithReverse(resID, context.pod.IPStickTime)      }      return m.pool.Release(resID)  }    func (m *eniIPResourceManager) GarbageCollection(inUseSet map[string]interface{}, expireResSet map[string]interface{}) error {      for expireRes := range expireResSet {          if err := m.pool.Stat(expireRes); err == nil {              err = m.Release(nil, expireRes)              if err != nil {                  return err              }          }      }      return nil  }

由上述程式碼可見,resouceManager實際操作的都是simpleObjectPool這個對象。 我們看看這個pool到底做了那些操作。首先初始化該pool:

// NewSimpleObjectPool return an object pool implement  func NewSimpleObjectPool(cfg Config) (ObjectPool, error) {      if cfg.MinIdle > cfg.MaxIdle {          return nil, ErrInvalidArguments      }        if cfg.MaxIdle > cfg.Capacity {          return nil, ErrInvalidArguments      }        pool := &simpleObjectPool{          factory:  cfg.Factory,          inuse:    make(map[string]types.NetworkResource),          idle:     newPriorityQueue(),          maxIdle:  cfg.MaxIdle,          minIdle:  cfg.MinIdle,          capacity: cfg.Capacity,          notifyCh: make(chan interface{}),          tokenCh:  make(chan struct{}, cfg.Capacity),      }        if cfg.Initializer != nil {          if err := cfg.Initializer(pool); err != nil {              return nil, err          }      }        if err := pool.preload(); err != nil {          return nil, err      }        log.Infof("pool initial state, capacity %d, maxIdle: %d, minIdle %d, idle: %s, inuse: %s",          pool.capacity,          pool.maxIdle,          pool.minIdle,          queueKeys(pool.idle),          mapKeys(pool.inuse))        go pool.startCheckIdleTicker()        return pool, nil  }

可以看到在創建的時候會根據傳入的config依次初始化各成員變數, 其中

  • factory 成員用來分配網路設備,會調用ECS SDK進行分配資源,分配之後將資訊存儲在pool之中,具體的實現是eniIPFactory
  • inuse 存儲了當前所有正在使用的networkResource
  • idle 存儲了當前所有空閑的networkResource, 即已經通過factory分配好,但是還未被某個pod實際使用。如果某個network resouce不再使用,也會歸還到該idle之中。 通過這種方式,pool具備一定的緩充能力,避免頻繁調用factory進行分配釋放。idle為priorityQeueu類型,即所有空閑的networkResouce通過優先順序隊列排列,優先順序隊列的比較函數會比較reverse欄位,reverse默認是入隊時間,也就是該networkResouce的釋放的時間,這樣做能夠盡量使一個IP釋放之後不會被立馬被複用。reverse欄位對於一些statueSet的resouce也會進行一些特殊處理,因為statufulSet是有狀態workload, 對於IP的釋放也會特殊處理,保證其儘可能復用。
  • maxIdle, minIdle 分別表示上述idle隊列中允許的最大和最小個數, minIdle是為了提供有一定的緩衝能力,但該值並不保證,最大是為了防止快取過多,如果空閑的networkResouce太多沒有被使用就會釋放一部分,IP地址不止是節點級別的資源,也會佔用整個vpc/vswitch/安全組的資源,太多的空閑可能會導致其他節點或者雲產品分配不出IP。
  • capacity 是該pool的容量,最大能分配的networkResouce的個數。該值可以自己指定, 但如果超過該ECS能允許的最大個數就會被設置成允許的最大個數。
  • tokenCh 是個buffered channel, 容量大小即為上面capacity的值,被做token bucket。 pool初始化的時候會將其中放滿元素,後面運行過程中中,只要能從該channel中讀取到元素則意味著該pool還沒有滿。每次調用factory申請networkResouce之前會從該channel中讀取一個元素, 每次調用factory釋放networkDevice會從該channel中放入一個元素。之所有判斷是否可讀而不是是否可寫是因為讀操作可以通過select調用,防止阻塞,對於寫操作如果沒有容量之後就會阻塞在寫channel的地方。

成員變數初始化完成之後會調用Initializer, 該函數會回調一個閉包函數,定義在newENIIPResourceManager中: 當程式啟動時,resouceManager通過讀取存儲在本地磁碟也就是resouceDB中的資訊獲取當前正在使用的networkResouce,然後通過ecs獲取當前所有eni設備及其ip, 依次遍歷所有ip判斷當前是否在使用,分別來初始化inuse和idle。這樣可以保證程式重啟之後可以重構記憶體中的pool數據資訊。

然後會調用preload,該函數確保pool(idle)中有minIdle個空閑元素, 防止啟動時大量調用factory。
最後會進行go pool.startCheckIdleTicker() 非同步來goroutine中調用checkIdle定期查詢pool(idle)中的元素是否超過maxIdle個元素, 如果超過則會調用factory進行釋放。同時每次調用factory也會通過notifyCh來通知該goroutine執行檢查操作。

pool結構初始化完成之後,resouceManager中所有對於networkResource的操作都會通過該pool進行,該pool在必要條件下再調用factory進行分配釋放。

factory的具體實現是eniIPFactory, 用來調用ecs SDK進行申請釋放eniIP, 並維護對應的數據結構。不同於直接使用eni設備,ENIMultiIP模式會為每個eni設備會有多個eniIP。eni設備是通過ENI結構體標識, eniIP通過ENIIP結構體標識。terway會為每個ENI創建一個goroutine, 該ENI上所有eniIP的分配釋放都會在goroutine內進行,factory通過channel與該groutine通訊, 每個goroutine對應一個接受channel ipBacklog,用於傳遞分配請求到該goroutine。 每次factory 需要創建(eniIPFactory.Create)一個eniIP時, 會一次遍歷當前已經存在的ENI設備,如果該設備還有空閑的eniIP,就會通過該ipBacklog channel發送一個元素到該ENI設備的goroutine進行請求分配, 當goroutine將eniIP分配完畢之後通過factory 的resultChan通知factory, 這樣factory就成功完成一次分配。 如果所有的ENI的eniIP都分配完畢,會首先創建ENI設備及其對應goroutine。因為每個ENI設備會有個主IP, 所以首次分配ENI不需要發送請求到ipBacklog, 直接將該主ip返回即可。對應的釋放(Dispose)就是先釋放eniIP, 等到只剩最後一個eniIP(主eniIP)時會釋放整個ENI設備。對於所有ecs調用都會通過buffer channel進行流控,防止瞬間調用過大。

總結

總之,terway的整個實現,邏輯比較清晰,並且擴展性也較高。後期,可以比較方便地在此基礎上做一些訂製和運維支援,從而很好地融入公司的基礎架構設施。

Read More

terway design Doc