深入解析kubernetes中的選舉機制

Cylon發表於2022-06-28

Overview

在 Kubernetes的 kube-controller-manager , kube-scheduler, 以及使用 Operator 的底層實現 controller-rumtime 都支援高可用系統中的leader選舉,本文將以理解 controller-rumtime (底層的實現是 client-go) 中的leader選舉以在kubernetes controller中是如何實現的。

Background

在執行 kube-controller-manager 時,是有一些引數提供給cm進行leader選舉使用的,可以參考官方文件提供的 引數 來了解相關引數。

--leader-elect                               Default: true
--leader-elect-renew-deadline duration       Default: 10s
--leader-elect-resource-lock string          Default: "leases"
--leader-elect-resource-name string     	 Default: "kube-controller-manager"
--leader-elect-resource-namespace string     Default: "kube-system"
--leader-elect-retry-period duration         Default: 2s
...

本身以為這些元件的選舉動作時通過etcd進行的,但是後面對 controller-runtime 學習時,發現並沒有配置其相關的etcd相關引數,這就引起了對選舉機制的好奇。懷著這種好奇心搜尋了下有關於 kubernetes的選舉,發現官網是這麼介紹的,下面是對官方的說明進行一個通俗總結。simple leader election with kubernetes

通過閱讀文章得知,kubernetes API 提供了一中選舉機制,只要執行在叢集內的容器,都是可以實現選舉功能的。

Kubernetes API通過提供了兩個屬性來完成選舉動作的

  • ResourceVersions:每個API物件唯一一個ResourceVersion
  • Annotations:每個API物件都可以對這些key進行註釋

注:這種選舉會增加APIServer的壓力。也就對etcd會產生影響

那麼有了這些資訊之後,我們來看一下,在Kubernetes叢集中,誰是cm的leader(我們提供的叢集只有一個節點,所以本節點就是leader)

在Kubernetes中所有啟用了leader選舉的服務都會生成一個 EndPoint ,在這個 EndPoint 中會有上面提到的label(Annotations)來標識誰是leader。

$ kubectl get ep -n kube-system
NAME                      ENDPOINTS   AGE
kube-controller-manager   <none>      3d4h
kube-dns                              3d4h
kube-scheduler            <none>      3d4h

這裡以 kube-controller-manager 為例,來看下這個 EndPoint 有什麼資訊

[root@master-machine ~]# kubectl describe ep kube-controller-manager -n kube-system
Name:         kube-controller-manager
Namespace:    kube-system
Labels:       <none>
Annotations:  control-plane.alpha.kubernetes.io/leader:
                {"holderIdentity":"master-machine_06730140-a503-487d-850b-1fe1619f1fe1","leaseDurationSeconds":15,"acquireTime":"2022-06-27T15:30:46Z","re...
Subsets:
Events:
  Type    Reason          Age    From                     Message
  ----    ------          ----   ----                     -------
  Normal  LeaderElection  2d22h  kube-controller-manager  master-machine_76aabcb5-49ff-45ff-bd18-4afa61fbc5af became leader
  Normal  LeaderElection  9m     kube-controller-manager  master-machine_06730140-a503-487d-850b-1fe1619f1fe1 became leader

可以看出 Annotations: control-plane.alpha.kubernetes.io/leader: 標出了哪個node是leader。

election in controller-runtime

controller-runtime 有關leader選舉的部分在 pkg/leaderelection 下面,總共100行程式碼,我們來看下做了些什麼?

可以看到,這裡只提供了建立資源鎖的一些選項

type Options struct {
	// 在manager啟動時,決定是否進行選舉
	LeaderElection bool
	// 使用那種資源鎖 預設為租用 lease
	LeaderElectionResourceLock string
	// 選舉發生的名稱空間
	LeaderElectionNamespace string
	// 該屬性將決定持有leader鎖資源的名稱
	LeaderElectionID string
}

通過 NewResourceLock 可以看到,這裡是走的 client-go/tools/leaderelection下面,而這個leaderelection也有一個 example 來學習如何使用它。

通過 example 可以看到,進入選舉的入口是一個 RunOrDie() 的函式

// 這裡使用了一個lease鎖,註釋中說願意為叢集中存在lease的監聽較少
lock := &resourcelock.LeaseLock{
    LeaseMeta: metav1.ObjectMeta{
        Name:      leaseLockName,
        Namespace: leaseLockNamespace,
    },
    Client: client.CoordinationV1(),
    LockConfig: resourcelock.ResourceLockConfig{
        Identity: id,
    },
}

// 開啟選舉迴圈
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
    Lock: lock,
    // 這裡必須保證擁有的租約在呼叫cancel()前終止,否則會仍有一個loop在執行
    ReleaseOnCancel: true,
    LeaseDuration:   60 * time.Second,
    RenewDeadline:   15 * time.Second,
    RetryPeriod:     5 * time.Second,
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: func(ctx context.Context) {
            // 這裡填寫你的程式碼,
            // usually put your code
            run(ctx)
        },
        OnStoppedLeading: func() {
            // 這裡清理你的lease
            klog.Infof("leader lost: %s", id)
            os.Exit(0)
        },
        OnNewLeader: func(identity string) {
            // we're notified when new leader elected
            if identity == id {
                // I just got the lock
                return
            }
            klog.Infof("new leader elected: %s", identity)
        },
    },
})

到這裡,我們瞭解了鎖的概念和如何啟動一個鎖,下面看下,client-go都提供了那些鎖。

在程式碼 tools/leaderelection/resourcelock/interface.go 定義了一個鎖抽象,interface提供了一個通用介面,用於鎖定leader選舉中使用的資源。

type Interface interface {
	// Get 返回選舉記錄
	Get(ctx context.Context) (*LeaderElectionRecord, []byte, error)

	// Create 建立一個LeaderElectionRecord
	Create(ctx context.Context, ler LeaderElectionRecord) error

	// Update will update and existing LeaderElectionRecord
	Update(ctx context.Context, ler LeaderElectionRecord) error

	// RecordEvent is used to record events
	RecordEvent(string)

	// Identity 返回鎖的標識
	Identity() string

	// Describe is used to convert details on current resource lock into a string
	Describe() string
}

那麼實現這個抽象介面的就是,實現的資源鎖,我們可以看到,client-go提供了四種資源鎖

  • leaselock
  • configmaplock
  • multilock
  • endpointlock

leaselock

Lease是kubernetes控制平面中的通過ETCD來實現的一個Leases的資源,主要為了提供分散式租約的一種控制機制。相關對這個API的描述可以參考於:Lease

在Kubernetes叢集中,我們可以使用如下命令來檢視對應的lease

$ kubectl get leases -A
NAMESPACE         NAME                      HOLDER                                                AGE
kube-node-lease   master-machine            master-machine                                        3d19h
kube-system       kube-controller-manager   master-machine_06730140-a503-487d-850b-1fe1619f1fe1   3d19h
kube-system       kube-scheduler            master-machine_1724e2d9-c19c-48d7-ae47-ee4217b27073   3d19h

$ kubectl describe leases kube-controller-manager -n kube-system
Name:         kube-controller-manager
Namespace:    kube-system
Labels:       <none>
Annotations:  <none>
API Version:  coordination.k8s.io/v1
Kind:         Lease
Metadata:
  Creation Timestamp:  2022-06-24T11:01:51Z
  Managed Fields:
    API Version:  coordination.k8s.io/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:spec:
        f:acquireTime:
        f:holderIdentity:
        f:leaseDurationSeconds:
        f:leaseTransitions:
        f:renewTime:
    Manager:         kube-controller-manager
    Operation:       Update
    Time:            2022-06-24T11:01:51Z
  Resource Version:  56012
  Self Link:         /apis/coordination.k8s.io/v1/namespaces/kube-system/leases/kube-controller-manager
  UID:               851a32d2-25dc-49b6-a3f7-7a76f152f071
Spec:
  Acquire Time:            2022-06-27T15:30:46.000000Z
  Holder Identity:         master-machine_06730140-a503-487d-850b-1fe1619f1fe1
  Lease Duration Seconds:  15
  Lease Transitions:       2
  Renew Time:              2022-06-28T06:09:26.837773Z
Events:                    <none>

下面來看下leaselock的實現,leaselock會實現了作為資源鎖的抽象

type LeaseLock struct {
	// LeaseMeta 就是類似於其他資源型別的屬性,包含name ns 以及其他關於lease的屬性
	LeaseMeta  metav1.ObjectMeta
	Client     coordinationv1client.LeasesGetter // Client 就是提供了informer中的功能
	// lockconfig包含上面通過 describe 看到的 Identity與recoder用於記錄資源鎖的更改
    LockConfig ResourceLockConfig
    // lease 就是 API中的Lease資源,可以參考下上面給出的這個API的使用
	lease      *coordinationv1.Lease
}

下面來看下leaselock實現了那些方法?

Get

Get 是從spec中返回選舉的記錄

func (ll *LeaseLock) Get(ctx context.Context) (*LeaderElectionRecord, []byte, error) {
	var err error
	ll.lease, err = ll.Client.Leases(ll.LeaseMeta.Namespace).Get(ctx, ll.LeaseMeta.Name, metav1.GetOptions{})
	if err != nil {
		return nil, nil, err
	}
	record := LeaseSpecToLeaderElectionRecord(&ll.lease.Spec)
	recordByte, err := json.Marshal(*record)
	if err != nil {
		return nil, nil, err
	}
	return record, recordByte, nil
}

// 可以看出是返回這個資源spec裡面填充的值
func LeaseSpecToLeaderElectionRecord(spec *coordinationv1.LeaseSpec) *LeaderElectionRecord {
	var r LeaderElectionRecord
	if spec.HolderIdentity != nil {
		r.HolderIdentity = *spec.HolderIdentity
	}
	if spec.LeaseDurationSeconds != nil {
		r.LeaseDurationSeconds = int(*spec.LeaseDurationSeconds)
	}
	if spec.LeaseTransitions != nil {
		r.LeaderTransitions = int(*spec.LeaseTransitions)
	}
	if spec.AcquireTime != nil {
		r.AcquireTime = metav1.Time{spec.AcquireTime.Time}
	}
	if spec.RenewTime != nil {
		r.RenewTime = metav1.Time{spec.RenewTime.Time}
	}
	return &r
}

Create

Create 是在kubernetes叢集中嘗試去建立一個租約,可以看到,Client就是API提供的對應資源的REST客戶端,結果會在Kubernetes叢集中建立這個Lease

func (ll *LeaseLock) Create(ctx context.Context, ler LeaderElectionRecord) error {
	var err error
	ll.lease, err = ll.Client.Leases(ll.LeaseMeta.Namespace).Create(ctx, &coordinationv1.Lease{
		ObjectMeta: metav1.ObjectMeta{
			Name:      ll.LeaseMeta.Name,
			Namespace: ll.LeaseMeta.Namespace,
		},
		Spec: LeaderElectionRecordToLeaseSpec(&ler),
	}, metav1.CreateOptions{})
	return err
}

Update

Update 是更新Lease的spec

func (ll *LeaseLock) Update(ctx context.Context, ler LeaderElectionRecord) error {
	if ll.lease == nil {
		return errors.New("lease not initialized, call get or create first")
	}
	ll.lease.Spec = LeaderElectionRecordToLeaseSpec(&ler)

	lease, err := ll.Client.Leases(ll.LeaseMeta.Namespace).Update(ctx, ll.lease, metav1.UpdateOptions{})
	if err != nil {
		return err
	}

	ll.lease = lease
	return nil
}

RecordEvent

RecordEvent 是記錄選舉時出現的事件,這時候我們回到上部分 在kubernetes叢集中檢視 ep 的資訊時可以看到的event中存在 became leader 的事件,這裡就是將產生的這個event新增到 meta-data 中。

func (ll *LeaseLock) RecordEvent(s string) {
   if ll.LockConfig.EventRecorder == nil {
      return
   }
   events := fmt.Sprintf("%v %v", ll.LockConfig.Identity, s)
   subject := &coordinationv1.Lease{ObjectMeta: ll.lease.ObjectMeta}
   // Populate the type meta, so we don't have to get it from the schema
   subject.Kind = "Lease"
   subject.APIVersion = coordinationv1.SchemeGroupVersion.String()
   ll.LockConfig.EventRecorder.Eventf(subject, corev1.EventTypeNormal, "LeaderElection", events)
}

到這裡大致上瞭解了資源鎖究竟是什麼了,其他種類的資源鎖也是相同的實現的方式,這裡就不過多闡述了;下面的我們來看看選舉的過程。

election workflow

選舉的程式碼入口是在 leaderelection.go ,這裡會繼續上面的 example 向下分析整個選舉的過程。

前面我們看到了進入選舉的入口是一個 RunOrDie() 的函式,那麼就繼續從這裡開始來了解。進入 RunOrDie,看到其實只有幾行而已,大致上瞭解到了RunOrDie會使用提供的配置來啟動選舉的客戶端,之後會阻塞,直到 ctx 退出,或停止持有leader的租約。

func RunOrDie(ctx context.Context, lec LeaderElectionConfig) {
	le, err := NewLeaderElector(lec)
	if err != nil {
		panic(err)
	}
	if lec.WatchDog != nil {
		lec.WatchDog.SetLeaderElection(le)
	}
	le.Run(ctx)
}

下面看下 NewLeaderElector 做了些什麼?可以看到,LeaderElector是一個結構體,這裡只是建立他,這個結構體提供了我們選舉中所需要的一切(LeaderElector就是RunOrDie建立的選舉客戶端)。

func NewLeaderElector(lec LeaderElectionConfig) (*LeaderElector, error) {
	if lec.LeaseDuration <= lec.RenewDeadline {
		return nil, fmt.Errorf("leaseDuration must be greater than renewDeadline")
	}
	if lec.RenewDeadline <= time.Duration(JitterFactor*float64(lec.RetryPeriod)) {
		return nil, fmt.Errorf("renewDeadline must be greater than retryPeriod*JitterFactor")
	}
	if lec.LeaseDuration < 1 {
		return nil, fmt.Errorf("leaseDuration must be greater than zero")
	}
	if lec.RenewDeadline < 1 {
		return nil, fmt.Errorf("renewDeadline must be greater than zero")
	}
	if lec.RetryPeriod < 1 {
		return nil, fmt.Errorf("retryPeriod must be greater than zero")
	}
	if lec.Callbacks.OnStartedLeading == nil {
		return nil, fmt.Errorf("OnStartedLeading callback must not be nil")
	}
	if lec.Callbacks.OnStoppedLeading == nil {
		return nil, fmt.Errorf("OnStoppedLeading callback must not be nil")
	}

	if lec.Lock == nil {
		return nil, fmt.Errorf("Lock must not be nil.")
	}
	le := LeaderElector{
		config:  lec,
		clock:   clock.RealClock{},
		metrics: globalMetricsFactory.newLeaderMetrics(),
	}
	le.metrics.leaderOff(le.config.Name)
	return &le, nil
}

LeaderElector 是建立的選舉客戶端,

type LeaderElector struct {
	config LeaderElectionConfig // 這個的配置,包含一些時間引數,健康檢查
	// recoder相關屬性
	observedRecord    rl.LeaderElectionRecord
	observedRawRecord []byte
	observedTime      time.Time
	// used to implement OnNewLeader(), may lag slightly from the
	// value observedRecord.HolderIdentity if the transition has
	// not yet been reported.
	reportedLeader string
	// clock is wrapper around time to allow for less flaky testing
	clock clock.Clock
	// 鎖定 observedRecord
	observedRecordLock sync.Mutex
	metrics leaderMetricsAdapter
}

可以看到 Run 實現的選舉邏輯就是在初始化客戶端時傳入的 三個 callback

func (le *LeaderElector) Run(ctx context.Context) {
	defer runtime.HandleCrash()
	defer func() { // 退出時執行callbacke的OnStoppedLeading
		le.config.Callbacks.OnStoppedLeading()
	}()

	if !le.acquire(ctx) {
		return
	}
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	go le.config.Callbacks.OnStartedLeading(ctx) // 選舉時,執行 OnStartedLeading
	le.renew(ctx)
}

在 Run 中呼叫了 acquire,這個是 通過一個loop去呼叫 tryAcquireOrRenew,直到ctx傳遞過來結束訊號

func (le *LeaderElector) acquire(ctx context.Context) bool {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	succeeded := false
	desc := le.config.Lock.Describe()
	klog.Infof("attempting to acquire leader lease %v...", desc)
    // jitterUntil是執行定時的函式 func() 是定時任務的邏輯
    // RetryPeriod是週期間隔
    // JitterFactor 是重試係數,類似於延遲佇列中的係數 (duration + maxFactor * duration)
    // sliding 邏輯是否計算在時間內
    // 上下文傳遞
	wait.JitterUntil(func() {
		succeeded = le.tryAcquireOrRenew(ctx)
		le.maybeReportTransition()
		if !succeeded {
			klog.V(4).Infof("failed to acquire lease %v", desc)
			return
		}
		le.config.Lock.RecordEvent("became leader")
		le.metrics.leaderOn(le.config.Name)
		klog.Infof("successfully acquired lease %v", desc)
		cancel()
	}, le.config.RetryPeriod, JitterFactor, true, ctx.Done())
	return succeeded
}

這裡實際上選舉動作在 tryAcquireOrRenew 中,下面來看下tryAcquireOrRenew;tryAcquireOrRenew 是嘗試獲得一個leader租約,如果已經獲得到了,則更新租約;否則可以得到租約則為true,反之false

func (le *LeaderElector) tryAcquireOrRenew(ctx context.Context) bool {
	now := metav1.Now() // 時間
	leaderElectionRecord := rl.LeaderElectionRecord{ // 構建一個選舉record
		HolderIdentity:       le.config.Lock.Identity(), // 選舉人的身份特徵,ep與主機名有關
		LeaseDurationSeconds: int(le.config.LeaseDuration / time.Second), // 預設15s
		RenewTime:            now, // 重新獲取時間
		AcquireTime:          now, // 獲得時間
	}

	// 1. 從API獲取或建立一個recode,如果可以拿到則已經有租約,反之建立新租約
	oldLeaderElectionRecord, oldLeaderElectionRawRecord, err := le.config.Lock.Get(ctx)
	if err != nil {
		if !errors.IsNotFound(err) {
			klog.Errorf("error retrieving resource lock %v: %v", le.config.Lock.Describe(), err)
			return false
		}
		// 建立租約的動作就是新建一個對應的resource,這個lock就是leaderelection提供的四種鎖,
		// 看你在runOrDie中初始化傳入了什麼鎖
		if err = le.config.Lock.Create(ctx, leaderElectionRecord); err != nil {
			klog.Errorf("error initially creating leader election record: %v", err)
			return false
		}
		// 到了這裡就已經拿到或者建立了租約,然後記錄其一些屬性,LeaderElectionRecord
		le.setObservedRecord(&leaderElectionRecord)

		return true
	}

	// 2. 獲取記錄檢查身份和時間
	if !bytes.Equal(le.observedRawRecord, oldLeaderElectionRawRecord) {
		le.setObservedRecord(oldLeaderElectionRecord)

		le.observedRawRecord = oldLeaderElectionRawRecord
	}
	if len(oldLeaderElectionRecord.HolderIdentity) > 0 &&
		le.observedTime.Add(le.config.LeaseDuration).After(now.Time) &&
		!le.IsLeader() { // 不是leader,進行HolderIdentity比較,再加上時間,這個時候沒有到競選其,跳出
		klog.V(4).Infof("lock is held by %v and has not yet expired", oldLeaderElectionRecord.HolderIdentity)
		return false
	}

	// 3.我們將嘗試更新。 在這裡leaderElectionRecord設定為預設值。讓我們在更新之前更正它。
	if le.IsLeader() { // 到這就說明是leader,修正他的時間
		leaderElectionRecord.AcquireTime = oldLeaderElectionRecord.AcquireTime
		leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions
	} else { // LeaderTransitions 就是指leader調整(轉變為其他)了幾次,如果是,
		// 則為發生轉變,保持原有值
		// 反之,則+1
		leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions + 1
	}
	// 完事之後更新APIServer中的鎖資源,也就是更新對應的資源的屬性資訊
	if err = le.config.Lock.Update(ctx, leaderElectionRecord); err != nil {
		klog.Errorf("Failed to update lock: %v", err)
		return false
	}
	// setObservedRecord 是通過一個新的record來更新這個鎖中的record
	// 操作是安全的,會上鎖保證臨界區僅可以被一個執行緒/程式操作
	le.setObservedRecord(&leaderElectionRecord)
	return true
}

summary

到這裡,已經完整知道利用kubernetes進行選舉的流程都是什麼了;下面簡單回顧下,上述leader選舉所有的步驟:

  • 首選建立的服務就是該服務的leader,鎖可以為 lease , endpoint 等資源進行上鎖
  • 已經是leader的例項會不斷續租,租約的預設值是15秒 (leaseDuration);leader在租約滿時更新租約時間(renewTime)。
  • 其他的follower,會不斷檢查對應資源鎖的存在,如果已經有leader,那麼則檢查 renewTime,如果超過了租用時間(),則表明leader存在問題需要重新啟動選舉,直到有follower提升為leader。
  • 而為了避免資源被搶佔,Kubernetes API使用了 ResourceVersion 來避免被重複修改(如果版本號與請求版本號不一致,則表示已經被修改了,那麼APIServer將返回錯誤)

Reference

Kubernetes 併發控制與資料一致性的實現原理

Controller manager 的高可用實現方式

deep dive into kubernetes simple leader election

相關文章