歡迎光臨
每天分享高質量文章

容器化RDS——計算儲存分離架構下的“Split-Brain”

不管是架構選型還是生活,絕大多數時候都是在做 trade off,收穫了計算儲存分離帶來的好處,也意味著要忍受它帶來的一些棘手問題。本文嘗試結合 Kubernetes、Docker、MySQL 和計算儲存分離架構,分享我們遇到的諸多問題之一 “Split-Brain”。
2018 年 1 月 19 號參加了阿裡巴巴雙十一資料庫技術峰會,見到了好多老同事(各位研究員、資深專家),也瞭解到業界最新的資料庫技術發展趨勢:
  • 資料庫容器化作為下一代資料庫基礎架構

  • 基於編排架構管理容器化資料庫

  • 採用計算儲存分離架構

這和我們在私有 RDS 上的技術選型不謀而合,尤其是計算儲存分離架構。

在我們看來,其最大優勢在於:
  • 計算資源 / 儲存資源獨立擴展,架構更清晰,部署更容易。

  • 將有狀態的資料下沉到儲存層,Scheduler 調度時,無需感知計算節點的儲存介質,只需調度到滿足計算資源要求的 Node,資料庫實體啟動時,只需在分佈式檔案系統掛載 mapping volume 即可,可以顯著的提高資料庫實體的部署密度和計算資源利用率。

以阿裡巴巴為例,考慮到今時今日它的規模,如果能夠實現資料庫服務的離線(ODPS)/ 在線集群的混合部署,意義極其重大。關鍵問題在於,離線(ODPS)計算和在線計算對實時性要求不同,硬體配置也不同,尤其是本地儲存介質:
  • 離線(ODPS)以機械磁盤為主

  • 在線以 SSD / Flash 為主

如果採用本地儲存作為資料庫實體的儲存介質,試想一下,一個 Storage Qos 要求是 Flash 的資料庫實體無法調度到離線計算集群,哪怕離線計算集群 CPU、Memory 有大量空閑。
計算儲存分離為實現離線(ODPS)/在線集群的混合部署提供了可能。
結合 Kubernetes、Docker 和 MySQL,進一步細化架構圖,如下圖所示:

同時,這套架構也帶給我們更加簡單、通用、高效的 High Availability 方案。當集群中某個 Node 不可用後,借助 Kubernetes 的原生組件Node Controller,Scheduler和原生API Statefulset即可將資料庫實體調度到其他可用節點,以實現資料庫實體的高可用。

一切是多麼的美好,是不是可以得到這個結論:
借助 Kubernetes 的原生組件 Node Controller、Scheduler 和原生 API Statefulset,上計算儲存分離架構,將成熟的分佈式檔案系統集成到 Kubernetes 儲存系統,就能提供私有 RDS 服務
之前我們也是這麼想的,直到遇到“Split-Brain”問題(也即是本文的主題)。
回到上面的 High Availability 方案。
當集群中某個 Node 不可用後,借助 Kubernetes 的原生組件Node Controller、Scheduler和原生API Statefulset即可將資料庫實體調度到其他可用節點,以實現資料庫實體的高可用。
判定 Node 不可用將是後續觸發 Failover 動作的關鍵。
所以這裡需要對節點狀態的判定機制稍作展開:
  • Kubelet 借助 API Server 定期(node-status-update-frequency)更新 etcd 中對應節點的心跳信息。

  • Controller Manager 中的 Node Controller 組件定期(node-monitor-period)輪詢 ETCD 中節點的心跳信息。

  • 如果在周期(node-monitor-grace-period)內,心跳更新丟失,該節點標記為Unknown(ConditionUnknown)。

  • 如果在周期(pod-eviction-timeout)內,心跳更新持續丟失,Node Controller 將會觸發集群層面的驅逐機制。

  • Scheduler將Unknown節點上的所有資料庫實體調度到其他健康(Ready)節點。

訪問架構圖如下所示:

補充一句,助 ETCD 集群的高可用強一致,以保證 Kubernetes 集群元信息的一致性。
  • ETCD 基於 Raft 演算法實現。

  • Raft 演算法是一種基於訊息傳遞(state machine replicated)且具有高度容錯(fault tolerance)特性的一致性演算法(consensus algorithm)。

  • Raft 是大名鼎鼎的 Paxos 的簡化版本。

  • 如果對於 Raft 演算法的實現有興趣,可以看看 https://github.com/goraft/raft。

所有感興趣一致性演算法的同學,都值得花精力學習。基於 goraft/raft,我實現了Network Partition Failures/Recovery TestCase,收穫不小。
看上去合理的機制會給我們帶來兩個問題。

問題一:無法判定節點真實狀態
心跳更新是判斷節點是否可用的依據,但是,心跳更新丟失是無法判定節點真實狀態的(Kubernetes 中將節點標記為 ConditionUnknown 也說明瞭這點)。
Node 可能僅僅是網絡問題、CPU 繁忙、“假死”、Kubelet bug 等原因導致心跳更新丟失,但節點上的資料庫實體還在運行中

問題二:缺乏有效的 Fence 機制
在這個情況下,借助 Kubernetes 的原生組件Node Controller、Scheduler和原生 API Statefulset 實現的 Failover,將資料庫實體從 Unknown 節點驅逐到可用節點,但對原 Unknown 節點不做任何操作。
這種“軟驅逐”,將會導致新舊兩個資料庫實體同時訪問同一份資料檔案。

發生”Split-Brain”,導致 Data Corruption。資料丟失,損失無法彌補。
所以,必須借助 WOQU RDS Operator 提供的 fence 機制,才能保障資料檔案的安全。
下麵是枯燥的故障復現,通過日誌和代碼分析驅逐的工作機制,總結“Split-Brain”整個過程。
測試過程:
  • 使用 Statefulset 創建 MySQL 單實體 gxr-oracle-statefulset(這是一個 Oracle DBA 取的名字,請原諒他)

  • Scheduler 將 MySQL 單實體調度到集群中的節點 “k8s-node3”

  • 通過 sysbench 對該實體製造極高的負載,“k8s-node3” load 飆升,導致“k8s-node3”上的 Kubelet 無法跟 API Server 通訊,並開始報錯

  • Node Controller 啟動驅逐

  • Statefulset 發起重建

  • Scheduler 將 MySQL 實體調度到“k8s-node1”上

  • 新舊 MySQL 實體訪問同一個 Volume

  • 資料檔案被寫壞,新舊 MySQL 實體都報錯,並無法啟動

測試引數:
  • kube-controller-manager 啟動引數:

  • kubelet 啟動引數:

基於日誌,整個事件流如下:
時間點 December 1st 2017,10:18:05.000(最後一次更新成功應該是 10:17:42.000):
節點(k8s-node3)啟動資料庫壓力測試,以模擬該節點“假死”,kubelet 跟 API Server 出現心跳丟失。

kubelet 日誌報錯,無法通過 API Server 更新 k8s-node3 狀態。
Kubelet 細節如下:
通過 API Server 更新集群信息:
if kl.kubeClient != nil {
 // Start syncing node status immediately, this may set up things the runtime needs to run.
 go wait.Until(kl.syncNodeStatus, kl.nodeStatusUpdateFrequency, wait.NeverStop)
}
定期(nodeStatusUpdateFrequency)更新對應節點狀態:
nodeStatusUpdateFrequency 預設時間為 10 秒,測試時設置的是 8s。
obj.NodeStatusUpdateFrequency = metav1.Duration{Duration: 10 * time.Second}
更新如下信息:
func (kl *Kubelet) defaultNodeStatusFuncs() []func(*v1.Node) error {
 // initial set of node status update handlers, can be modified by Option's
 withoutError := func(f func(*v1.Node)) func(*v1.Node) error {
    return func(n *v1.Node) error {
       f(n)
       return nil
    }
 }
 return []func(*v1.Node) error{
    kl.setNodeAddress,
    withoutError(kl.setNodeStatusInfo),
    withoutError(kl.setNodeOODCondition),
    withoutError(kl.setNodeMemoryPressureCondition),
    withoutError(kl.setNodeDiskPressureCondition),
    withoutError(kl.setNodeReadyCondition),
    withoutError(kl.setNodeVolumesInUseStatus),
    withoutError(kl.recordNodeSchedulableEvent),
 }
}
通過 kubectl 可以獲得節點的信息:

時間點 December 1st 2017, 10:18:14.000:
NodeController 發現 k8s-node3 的狀態有32s 沒有發生更新。
ready / outofdisk / diskpressure / memorypressue condition

將該節點狀態更新為 UNKNOWN:

每隔 NodeMonitorPeriod 繼續節點狀態是否有更新:

定期(NodeMonitorPeriod)查看一次節點狀態:
// Incorporate the results of node status pushed from kubelet to master.
go wait.Until(func() {
 if err := nc.monitorNodeStatus(); err != nil {
    glog.Errorf("Error monitoring node status: %v", err)
 }
}, nc.nodeMonitorPeriod, wait.NeverStop)
NodeMonitorPeriod 預設 5秒,測試時4s。
NodeMonitorPeriod:                               metav1.Duration{Duration: 5 * time.Second},
當超過 NodeMonitorGracePeriod 時間後,節點狀態沒有更新將節點狀態設置成 unknown:
if nc.now().After(savedNodeStatus.probeTimestamp.Add(gracePeriod)) {
 // NodeReady condition was last set longer ago than gracePeriod, so update it to Unknown
 // (regardless of its current value) in the master.
 if currentReadyCondition == nil {
    glog.V(2).Infof("node %v is never updated by kubelet", node.Name)
    node.Status.Conditions = append(node.Status.Conditions, v1.NodeCondition{
       Type:               v1.NodeReady,
       Status:             v1.ConditionUnknown,
       Reason:             "NodeStatusNeverUpdated",
       Message:            fmt.Sprintf("Kubelet never posted node status."),
       LastHeartbeatTime:  node.CreationTimestamp,
       LastTransitionTime: nc.now(),
    })
 } else {
    glog.V(4).Infof("node %v hasn't been updated for %+v. Last ready condition is: %+v",
       node.Name, nc.now().Time.Sub(savedNodeStatus.probeTimestamp.Time), observedReadyCondition)
    if observedReadyCondition.Status != v1.ConditionUnknown {
       currentReadyCondition.Status = v1.ConditionUnknown
       currentReadyCondition.Reason = "NodeStatusUnknown"
       currentReadyCondition.Message = "Kubelet stopped posting node status."
       // LastProbeTime is the last time we heard from kubelet.
       currentReadyCondition.LastHeartbeatTime = observedReadyCondition.LastHeartbeatTime
       currentReadyCondition.LastTransitionTime = nc.now()
    }
 }
時間點 December 1st 2017,10:19:42.000:
剛好過去 podEvictionTimeout,將該節點添加到驅逐佇列中。

在 podEvictionTimeout 後,認為該節點上 Pods 需要開始驅逐:
if observedReadyCondition.Status == v1.ConditionUnknown {
 if nc.useTaintBasedEvictions {
    // We want to update the taint straight away if Node is already tainted with the UnreachableTaint
    if taintutils.TaintExists(node.Spec.Taints, NotReadyTaintTemplate) {
       taintToAdd := *UnreachableTaintTemplate
       if !util.SwapNodeControllerTaint(nc.kubeClient, []*v1.Taint{&taintToAdd;}, []*v1.Taint{NotReadyTaintTemplate}, node) {
          glog.Errorf("Failed to instantly swap UnreachableTaint to NotReadyTaint. Will try again in the next cycle.")
       }
    } else if nc.markNodeForTainting(node) {
       glog.V(2).Infof("Node %v is unresponsive as of %v. Adding it to the Taint queue.",
          node.Name,
          decisionTimestamp,
       )
    }
 } else {
    if decisionTimestamp.After(nc.nodeStatusMap[node.Name].probeTimestamp.Add(nc.podEvictionTimeout)) {
       if nc.evictPods(node) {
          glog.V(2).Infof("Node is unresponsive. Adding Pods on Node %s to eviction queues: %v is later than %v + %v",
             node.Name,
             decisionTimestamp,
             nc.nodeStatusMap[node.Name].readyTransitionTimestamp,
             nc.podEvictionTimeout-gracePeriod,
          )
       }
    }
 }
}
放到驅逐陣列中:
// evictPods queues an eviction for the provided node name, and returns false if the node is already
// queued for eviction.
func (nc *Controller) evictPods(node *v1.Node) bool {
 nc.evictorLock.Lock()
 defer nc.evictorLock.Unlock()
 return nc.zonePodEvictor[utilnode.GetZoneKey(node)].Add(node.Name, string(node.UID))
}
時間點 December 1st 2017,10:19:42.000:
開始驅逐:

驅逐 Goroutine:
if nc.useTaintBasedEvictions {
 // Handling taint based evictions. Because we don't want a dedicated logic in TaintManager for NC-originated
 // taints and we normally don't rate limit evictions caused by taints, we need to rate limit adding taints.
 go wait.Until(nc.doNoExecuteTaintingPass, scheduler.NodeEvictionPeriod, wait.NeverStop)
} else {
 // Managing eviction of nodes:
 // When we delete pods off a node, if the node was not empty at the time we then
 // queue an eviction watcher. If we hit an error, retry deletion.
 go wait.Until(nc.doEvictionPass, scheduler.NodeEvictionPeriod, wait.NeverStop)
}
通過刪除 Pods 的方式驅逐:
func (nc *Controller) doEvictionPass() {
 nc.evictorLock.Lock()
 defer nc.evictorLock.Unlock()
 for k := range nc.zonePodEvictor {
    // Function should return 'false' and a time after which it should be retried, or 'true' if it shouldn't (it succeeded).
    nc.zonePodEvictor[k].Try(func(value scheduler.TimedValue) (bool, time.Duration) {
       node, err := nc.nodeLister.Get(value.Value)
       if apierrors.IsNotFound(err) {
          glog.Warningf("Node %v no longer present in nodeLister!", value.Value)
       } else if err != nil {
          glog.Warningf("Failed to get Node %v from the nodeLister: %v", value.Value, err)
       } else {
          zone := utilnode.GetZoneKey(node)
          evictionsNumber.WithLabelValues(zone).Inc()
       }
       nodeUID, _ := value.UID.(string)
       remaining, err := util.DeletePods(nc.kubeClient, nc.recorder, value.Value, nodeUID, nc.daemonSetStore)
       if err != nil {
          utilruntime.HandleError(fmt.Errorf("unable to evict node %q: %v", value.Value, err))
          return false, 0
       }
       if remaining {
          glog.Infof("Pods awaiting deletion due to Controller eviction")
       }
       return true, 0
    })
 }
}
時間點 December 1st 2017,10:19:42.000:
StatefulSet controller 發現 default/gxr1-oracle-statefulset 狀態異常。

時間點 December 1st 2017,10:19:42.000:
Scheduler 將 Pod 調度到 k8s-node1:

這樣舊的 MySQL 實體在 k8s-node3 上,Kubernetes 又將新的實體調度到 k8s-node1。
兩個資料庫實體寫同一份資料檔案,導致 data corruption,兩個節點都無法啟動。
老實體啟動報錯,日誌如下:
2017-12-01 10:19:47 5628 [Note] mysqld (mysqld 5.7.19-log) starting as process 963 ...
2017-12-01 10:19:47 5628 [Note] InnoDB: PUNCH HOLE support available
2017-12-01 10:19:47 5628 [Note] InnoDB: Mutexes and rw_locks use GCC atomic builtins
2017-12-01 10:19:47 5628 [Note] InnoDB: Uses event mutexes
2017-12-01 10:19:47 5628 [Note] InnoDB: GCC builtin __atomic_thread_fence() is used for memory barrier
2017-12-01 10:19:47 5628 [Note] InnoDB: Compressed tables use zlib 1.2.3
2017-12-01 10:19:47 5628 [Note] InnoDB: Using Linux native AIO
2017-12-01 10:19:47 5628 [Note] InnoDB: Number of pools: 1
2017-12-01 10:19:47 5628 [Note] InnoDB: Using CPU crc32 instructions
2017-12-01 10:19:47 5628 [Note] InnoDB: Initializing buffer pool, total size = 3.25G, instances = 2, chunk size = 128M
2017-12-01 10:19:47 5628 [Note] InnoDB: Completed initialization of buffer pool
2017-12-01 10:19:47 5628 [Note] InnoDB: If the mysqld execution user is authorized, page cleaner thread priority can be changed. See the man page of setpriority().
2017-12-01 10:19:47 5628 [Note] InnoDB: Highest supported file format is Barracuda.
2017-12-01 10:19:47 5628 [Note] InnoDB: Log scan progressed past the checkpoint lsn 406822323
2017-12-01 10:19:47 5628 [Note] InnoDB: Doing recovery: scanned up to log sequence number 406823190
2017-12-01 10:19:47 5628 [Note] InnoDB: Database was not shutdown normally!
2017-12-01 10:19:47 5628 [Note] InnoDB: Starting crash recovery.
2017-12-01 10:19:47 5669 [Note] InnoDB: Starting an apply batch of log records to the database...
InnoDB: Progress in  percent: 89 90 91 92 93 94 95 96 97 98 99
2017-12-01 10:19:47 5669 [Note] InnoDB: Apply batch completed
2017-12-01 10:19:47 5669 [Note] InnoDB: Last MySQL binlog file position 0 428730, file name mysql-bin.000004
2017-12-01 10:19:47 5669 [Note] InnoDB: Removed temporary tablespace data file: "ibtmp1"
2017-12-01 10:19:47 5669 [Note] InnoDB: Creating shared tablespace for temporary tables
2017-12-01 10:19:47 5669 [Note] InnoDB: Setting file './ibtmp1' size to 12 MB. Physically writing the file full; Please wait ...
2017-12-01 10:19:47 5669 [Note] InnoDB: File './ibtmp1' size is now 12 MB.
2017-12-01 10:19:47 5669 [Note] InnoDB: 96 redo rollback segment(s) found. 96 redo rollback segment(s) are active.
2017-12-01 10:19:47 5669 [Note] InnoDB: 32 non-redo rollback segment(s) are active.
2017-12-01 10:19:47 5669 [Note] InnoDB: Waiting for purge to start
2017-12-01 10:19:47 0x7fcb08928700  InnoDB: Assertion failure in thread 140509998909184 in file trx0purge.cc line 168
InnoDB: Failing assertion: purge_sys->iter.trx_no <= purge_sys->rseg->last_trx_no
InnoDB: We intentionally generate a memory trap.
InnoDB: Submit a detailed bug report to http://bugs.mysql.com.
InnoDB: If you get repeated assertion failures or crashes, even
InnoDB: immediately after the mysqld startup, there may be
InnoDB: corruption in the InnoDB tablespace. Please refer to
InnoDB: http://dev.mysql.com/doc/refman/5.7/en/forcing-innodb-recovery.html
InnoDB: about forcing recovery.
10:19:47 5669 - mysqld got signal 6 ;
以上問題通過 WOQU RDS Operator 提供的 Fence 機制已經得到有效解決。
Kubernetes 使我們站在巨人的肩膀上,從各大互聯網公司的技術發展看,將編排和容器技術應用到持久化 workload 也是顯見的趨勢之一。
但是,借用 Portworx CEO 的 Murli Thirumale 對 Kubenretes 的預測:Kubernetes相當複雜,Kubernetes被擁躉們冠以“優雅”的頭銜,但優雅並不意味著簡單。弦論是優雅的,但是理解它需要付出極大的努力。Kubernetes一樣,使用Kubernetes構建和運行應用程式並不是一個簡單的命題。
Kubernetes is complicated. Kubernetes is often described as elegant by enthusiasts. But its elegance doesn’t make it simple. String theory is elegant, but understanding it with anything except the most imprecise analogies takes a lot of effort. Kubernetes is the same. Using Kubernetes to build and run an application is not a straightforward proposition.
革命尚未成功,同志任需努力!
作者:熊中哲,沃趣科技聯合創始人/產品研發中心總監。原阿裡巴巴高級資料庫專家,多年大型製造業及電子商務資料庫運維經驗,曾參與阿裡雲RDS運維自動化研發。
基於Kubernetes的DevOps實踐培訓

本次培訓包含:Kubernetes核心概念;Kubernetes集群的安裝配置、運維管理、架構規劃;Kubernetes組件、監控、網絡;針對於Kubernetes API接口的二次開發;DevOps基本理念;微服務架構;微服務的容器化等,點擊識別下方二維碼加微信好友瞭解具體培訓內容

本周四正式開課,點擊閱讀原文鏈接即可報名。
赞(0)

分享創造快樂