consul 原始碼解析(一)raft 協議實現

weixin_34148340發表於2018-03-04

consul 相信大家已經知道了,在日常的開發以及運維中也會常常聽到 consul 這個詞,但是不是所有的人都知道它是什麼?它在運維中扮演了什麼樣的角色呢?

首先,我們來看下 consul 的官網中是怎麼形容自己的:

Service Discovery And Configuration Make Easy

讓服務發現以及配置變得更簡單,這個就是 consulmicro service 橫行的今天想要為我們解決的問題。

在微服務的世界中,運維人員變得越來越累了,以往可能 1臺物理機部署 3~4 個應用,就可以將提供完整的服務,而且維護也變得極為容易,寫指令碼就是了。但如今,一旦進入的 micro service 的世界,提供相同的服務可能就需要 20~40 個應用了,如此一來,運維人員的工作量以及壓力大大增加,但是卻得不到肉眼可見的好處。

先來說下 consul 為我們提供的四大元件:

  • Service Discovery: 當某個應用可用的時候,可以向 consul 客戶端註冊自己,或者讓 consul 客戶端通過配置發現自己,這樣,如果有需要這個應用的其他應用就可以通過 consul 快速找到一個可用的應用了。

  • Health Check: consul 客戶端提供任意數量的健康檢查,包括對應用保持心跳、主機物理資源監控等。健康檢查可以被 operator 檢測並操作,防止流量進入不健康的主機。

  • KV Store: 應用按需使用 consul 的 KV儲存 ,可以用於動態配置、功能標記、協調、領袖選舉等,通過客戶端的 HTTP 介面可以靈活方便使用。

  • Multi Datacenter: consul 提供開箱即用的多資料中心,這意味著使用者不需要擔心需要建立額外的抽象層讓業務擴充套件到多個區域。

我們可以發現,微服務治理 的所有解決方案在 consul 的四大元件下都能得到很好的解決。

為了深入瞭解 consul ,我們先來看下幾個專有名詞:

  • Agent: 代理是 consul 叢集中每個成員的基本單位,他們以守護程式的形式存在,代理有 客戶端 以及 服務端 兩種角色執行。所有的節點都必須執行一個代理,它們相互之間通過 DNS 或者 HTTP 介面保持健康檢查並同步資料。

  • Client: 客戶端 是代理的其中一種角色,它會將所有的 RPC 請求轉發到 服務端 代理,而 客戶端 本身是 無狀態 的,而且只會佔用非常少的資源並消耗少量的 網路頻寬 ,建議每個應用節點都執行一個 客戶端

  • Server: 服務端 相對而言是一個非常重的代理,它的主要工作包括參與 raft仲裁維護叢集狀態響應RPC查詢 、與其他的資料中心 交換資料、將 查詢轉發給Leader / 其他資料中心 等。可以說,服務端是 consul 叢集中最重要的角色,所以建議將其放置在相對獨立的主機上,並且一個叢集(資料中心)中至少需要 3 個以上的 服務端 才能保證 最終一致性

  • Datacenter: 資料中心 很好理解,其實就是一個 低延遲高頻寬 的私有網路環境,一個穩定的資料中心環境對 raft協議 來說非常重要,否則及其可能出現資料不同步、服務質量下降等情況。

  • Gossip: 一種保證 最終一致性 的分散式協議,常用於 點對點通訊 ,模擬人與人之間的交流從而達到理想中的 最終一致性 。而 consul 通過 UDP 使用該協議提供 成員管理失敗檢測事件廣播 等功能。

  • Raft: 是一種保證 強一致性 的分散式協議,consul 使用該協議提供 服務端 們的資料一致性,所以我們會在 consul 原始碼解析中會重點講述這個演算法是如何被應用的。

​​​

// agent/agent.go

// delegate 定義了代理的公共介面
type delegate interface {
    Encrypted() bool // 資料是否進行了加密
    GetLANCoordinate() (lib.CoordinateSet, error) // 獲取區域網內為 Coordinate 角色的服務端
    Leave() error  // 離開叢集
    LANMembers() []serf.Member // 區域網內的所有成員
    LANMemberAllSegments() ([]serf.Member, error) // 區域網內所有段區的成員
    LANSegmentMembers(segment string) ([]serf.Member, error) // 區域網內某個段區的所有成員
    LocalMember() serf.Member // 本機成員
    JoinLAN(addrs []string) (n int, err error) // 加入一個或多個段區
    RemoveFailedNode(node string) error // 嘗試移除某個異常的節點
    RPC(method string, args interface{}, reply interface{}) error // 遠端呼叫
    SnapshotRPC(args *structs.SnapshotRequest, in io.Reader, out io.Writer, replyFn structs.SnapshotReqlyFn) error // 發起快照存檔的遠端呼叫
    Shutdown() error // 關閉代理
    Stats() map[string]map[string]string // 用於獲取應用當前狀態
}

// Agent 代理
type Agent struct {
    // 代理的執行時配置,支援 hot reload
    config *config.RuntimeConfig
    
    // ... 日誌相關
    
    // 記憶體中收集到的應用、主機等狀態資訊
    MemSink *metrics.InmemSink
    
    // 代理的公共介面,而配置項則決定代理的角色
    delegate delegate
    
    // 本地策略執行的管理者
    acls *aclManager
        
    // 保證策略執行者的許可權,可實時更新,覆蓋本地配置檔案
    tokens *token.Store
    
    // 儲存本地節點、應用、心跳的狀態,用於反熵
    State *local.State
    
    // 負責維持本地與遠端的狀態同步
    sync *ae.StateSyncer
    
    // ...各種心跳包(Monitor/HTTP/TCP/TTL/Docker 等)
    
    // 用於接受其他節點傳送過來的事件
    eventCh chan serf.UserEvent
    
    // 用環形佇列儲存接受到的所有事件,用 index 指向下一個插入的節點
    // 使用讀寫鎖保證資料安全,當一個事件被插入時,會通知 group 中所有的訂閱者
    eventBuf    []*UserEvent
    eventIndex  int
    eventLock   sync.RWMutex
    eventNotify NotifyGroup
    
    // 重啟,並返回通知是否重啟成功
    reloadCh chan chan error
    
    // 關閉代理前的操作
    shutdown     bool
    shutdownCh   chan struct{}
    shutdownLock sync.Mutex
    
    // 新增到區域網成功的回撥函式
    joinLANNotifier notifier
    
    // 返回重試加入區域網失敗的錯誤資訊
    retryJoinCh chan error
    
    // 併發安全的儲存當前所有節點的唯一名稱,用於 RPC傳輸
    endpoints     map[string]string
    endpointsLock sync.RWMutex
    
    // ...為代理提供 DNS/HTTP 的API

    // 追蹤當前代理正在執行的所有監控
    watchPlans []*watch.Plan
}

// 決定當前啟動的是 server 還是 client 的關鍵程式碼在於
func (a *Agent) Start() error {
    // ...
    
    if c.ServerMode {
        server, err := consul.NewServerLogger(consulCfg, a.logger, a.tokens)
        // error handler
        a.delegate = server // 主要差別在這裡
    } else {
        client, err := consul.NewClientLogger(consulCfg, a.logger, a.tokens)
        // error handler
        a.delegate = client // 主要差別在這裡
    }
    
    a.sync.ClusterSize = func() int { return len(a.delegate.LANMembers()) }
    
    // ...
}

因為相對來說, 客戶端 是比較輕量的代理,所以我們先來先看 客戶端 的結構與實現:

// agent/consul/client.go

type Client struct {
    config *Config
    
    // 連線 服務端 的連線池,用 TCP 協議
    connPool *pool.ConnPool
    
    // 用於選擇和維護客戶端用於 RPC 請求的服務端
    routers *router.Manager
    
    // 用於限制從客戶端到伺服器的 RPC 總數
    rpcLimiter *rate.Limiter
    
    // 負責接送來自同一個資料中心的事件
    eventCh chan serf.Event
    
    logger *log.Logger
    
    // 儲存當前資料中心的 serf 叢集資訊
    serf *serf.Serf
    
    shutdown     bool
    shutdownCh   chan struct{}
    shutdownLock sync.Mutex
}

而 Client 所實現的 delegate 介面幾乎都是直接呼叫的 serf 提供的介面,如:

func (c *Client) JoinLAN(addrs []string) (int, error) {
    return c.serf.Join(addrs, true)
}

func (c *Client) Leave() error {
    c.logger.Printf("[INFO] consul: client starting leave")

    // Leave the LAN pool
    if c.serf != nil {
        if err := c.serf.Leave(); err != nil {
            c.logger.Printf("[ERR] consul: Failed to leave LAN Serf cluster: %v", err)
        }
    }
    return nil
}

func (c *Client) KeyManagerLAN() *serf.KeyManager {
    return c.serf.KeyManager()
}

Server 的由於涉及到 raft 協議,所以其實現複雜的多 (44 個 fields ),先來看下結構:

// agent/consul/server.go

type Server struct {
    // 哨兵介面,負責處理xxx TODO
    sentinel sentinel.Evaluator
    
    // 負責策略執行的許可權快取 TODO
    aclAuthCache *acl.Cache
    
    // 負責策略執行的非許可權快取 TODO
    aclCache *aclCache
    
    // 自動更新當前許可權的有效性
    autopilotPolicy AutopilotPolicy
    
    // 負責觸發移除無用的應用的檢查
    autopilotRemoveDeadCh chan struct{}
    
    // 負責停止自動更新許可權有效的程式碼
    autopilotShutdownCh chan struct{}
    
    // 阻塞直至停止自動更新許可權
    autopilotWaitGroup sync.WaitGroup
    
    // 儲存當前叢集的健康狀態
    clusterHealth     structs.OperatorHealthReply
    clusterHealthLock sync.RWMutex
    
    config *Config
    
    // 連線其他服務端的連線池
    connPool *pool.ConnPool
    
    // ...
    
    // 保證強一致性的 raft 狀態機
    fsm *fsm.FSM
    
    // 在相同資料中心的 consul 節點使用 raft 協議保證操作的強一致性
    raft          *raft.Raft
    raftLayer     *RaftLayer
    raftStore     *raftboltdb.BoltStore // 提供 ACID 的高效能 KV資料庫
    raftTransport *raft.NetworkTransport
    raftInmem     *raft.InmemStore
    
    // 通過 setupRaft() 設定,確保server能收到Leader更換通知
    raftNotifyCh <-chan bool
    
    // 判斷 Leader 是否準備好提供一致性讀取
    readyForConsistentReads int32
    
    // 使用訊號通知該服務端需要退出叢集,並嘗試將 RPC 轉發到其他的服務端上。
    leaveCh chan struct{}
    
    // 用於路由公域網的服務端或者由使用者定義的段區域
    router *router.Router
    
    // 用於接受進來的請求連線
    Listener net.Listener
    rpcServer *rpc.Server
    rpcTLS *tls.Config
    
    serfLAN *serf.Serf
    
    // 通過段名指引到不同的 serf 叢集中
    segmentLAN map[string]*serf.Serf
    
    // 在同一個資料中心中,由服務端組成的 serf 叢集
    serfWAN *serf.Serf
    
    // 在當前的資料中心進行服務端追蹤,提供 id 與 address 相互轉換
    serverLookup *ServerLookup
    
    // 通知廣播,讓公域網的 serf 例項知道區域網的服務端發生的變化(退出)
    floodLock sync.RWMutex
    floodCh   []chan struct{}
    
    // ...
    
    // 通知需要儲存快照,並重新發起 Leader 選舉
    reassertLeaderCh chan chan error
    
    // tombstone 演算法的 GC 調優引數
    tombstoneGC *state.TombstoneGC
    
    // 
    aclReplicationStatus     structs.ACLReplicationStatus
    aclReplicationStatusLock sync.RWMutex
}

下面來看看 consul 作為微服務治理的基礎架構圖是怎麼樣的:

2380441-355400e3d2279394..png
image

毫無疑問,raft 協議是維持整個 consul 生態中最重要的一個協議,下面重點來講下 raft 協議:

作為基於 Paxos 的一種變種演算法,它簡化了狀態,通過加入 時間差領導選舉 等概念使一致性演算法變得更簡單、易懂,先來看看其中的專有名詞:

  • Leader election: 領導選舉 是目前落地的一致性演算法不可避免的一個步驟(CASPaxos 好像不需要,不過還沒有深入研究),為了達到一致性,必須要已一個共同認可的節點做出所有操作。而 raft 協議的節點都會處於三種狀態之一:

    • Leader: 操作的真正處理者,它負責將改動確認生效,並將其同步到其他節點。

    • Follower: 負責將來自 Leader 的改動請求寫入本地 Log,返回成功。

    • Candidate: 如果 Leader 發生了故障,沒有收到心跳的 Followers 們會進入 Candidate 狀態,並開始選主,並保持該狀態直到選主結束。

  • Log Replication: 當 Leader 被選舉出來以後,所有的寫請求都必須要到 Leader 執行,Leader 會將其作為 Log entry 追加到日誌中,然後給其他 Follower 傳送請求,當絕大部分的 ((N/2)+1)的 Follower replicated 成功後,就代表該寫入事件成功了,就會返回結果狀態到客戶端。

  • Log Compaction: 在實際的應用場景中,因為磁碟空間是有限的,日誌不能無限地增長,否則即使系統需要重啟也需要耗費大量的時間。所以, raft 會對節點進行 snapshot 操作,執行成功後,會將 snapshot 之前的日誌丟棄掉。

為了更快速理解 raft 演算法,我們可以帶著3個問題觀看 動畫

  • Leader 在什麼時候,如何進行選舉?

  • Log Replication 作用是什麼,如何實現?

  • 節點數量發生變化(增加/減少)時如何處理?

結合 consul 程式碼來理解下 raft 協議:

可以看到 raft 協議中,節點分別處於 3種狀態

// raft/state.go
type RaftState uint32

const (
    Follower RaftState = iota
    
    Candidate
    
    Leader
    
    // 這個狀態是 hashicorp/raft 特有的,表示節點下線
    Shutdown
)

// 獲取當前 raft 節點狀態
func (r *raftState) getState() RaftState {
    stateAddr := (*uint32)(&r.state)
    return RaftState(atomic.LoadUint32(stateAddr))
}

先來看看在 consul 裡面是如何建立一個 raft node 的:

// raft/api.go

// NewRaft 新建一個 raft 節點,傳入引數除了 config/transport 以外,
// 還有 fsm/logs/store/snapshot ,這是為了可以通過傳遞引數,使崩掉
// 的節點能夠快速恢復
func NewRaft(config *Config, fsm FSM, logs LogStore, stable StableStore, snaps SnapshotStore, trans Transport) (*Raft, error) {
    
    // TODO ...
    r = &Raft{
        // ...
    }
    
    // 預設為 Follower 角色
    r.setState(Follower)
    
    // 該處只應該在測試的時候被呼叫,
    // 不是必須的主流程,所以單獨拎出來
    if conf.StartAsLeader {
        r.setState(Leader)
        r.setLeader(r.localAddr)
    }
    
    r.goFunc(r.run)          // 管理 Server 狀態,執行不同的程式碼
    r.goFunc(r.runFSM)       // 管理狀態機
    r.goFunc(r.runSnapshots) // 管理快照
    return r, nil    
}

// raft/raft.go
func (r *Raft) run() {
    for {
        select {
        case <-r.shutdownCh: // 會被 close(r.shutdownCh) 觸發
            r.setLeader("")  // 不聯絡 Leader 節點了
            return
        default:
        }
        
        switch r.getState() {
        case Follower:
            r.runFollower()
        case Candidate:
            r.runCandidate()
        case Leader:
            r.runLeader()
        // 問題:為什麼沒有判斷 Shutdown 的狀態呢?
        }
    }
}

// 只有 Follower 狀態的節點會執行該段程式碼
func (r *Raft) runFollower() {
    // ...
    for {
        select {
        case rpc := <-rpcCh:
            r.processRPC(rpc) // 根據 rpc 的型別進行 附加日誌/備份快照 等操作
        // ...
        case v := <-r.verifyCh:
            // Reject any operations since we are not the leader
            v.respond(ErrNotLeader)
        case <-heartbeatTimer: // 重頭戲,還記得動畫裡面的演示嗎?
            // 重設一個隨機的定時器
            heartbeatTimer = randomTimeout(r.conf.HeartbeatTimeout)
            
            // 獲取上一次從 Leader 節點得到聯絡的時間
            lastContact := r.LastContact()
            if time.Now().Sub(lastContact) < r.conf.HeartbeatTimeout {
                continue // 還能聯絡上 Leader ,繼續下一次操作
            }
            
            // 從這裡開始就發現聯絡不上 Leader 了,需要重新開始投票!!!
            
            lastLeader := r.Leader() // 先存一下之前的 Leader
            r.setLeader("")          // 離開這個 Leader 的叢集
            
            if r.configurations.lastestIndex == 0 { // 沒有其他可通訊節點了,終止選舉!!!
                // ...
            } else if { // 關於選舉權的配置,可以忽略
                //...
            } else {
                // Logger
                r.setState(Candidate)
                return
            }
            
        case <-r.shutdownCh:
            return
        }
    }
}

除了代理的正常維護自己的功能,它還必須處理日誌,所以這裡使用了 有限狀態自動機 (FSM)的方式避免了影響正常節點功能。

擁有相同日誌序列的應用必須歸結於相同的狀態,意味著行為必須是確定性的。

// raft/fsm.go
// FSM 的介面定義了一些能夠操作日誌的有限狀態機的行為
type FSM interface {
    // 提交日誌條目,如果該 FSM 在 Leader 節點上執行
    // 將返回一個 logFuture 用於等待直至日誌被認為提交成功
    Apply(*Log) interface{}
    
    // 將當前日誌進行快照備份,需要注意的是本函式不能與 Apply
    // 同時在多個執行緒中被呼叫,但允許在同一執行緒中併發執行
    Snapshot() (FSMSnapshot, error)
    
    // 將用於從快照中恢復 FSM 狀態,一旦該函式被呼叫,FSM 必須
    // 暫停其他所有呼叫與狀態,直至恢復完畢
    Restore(io.ReadCloser) error
}


func (r *Raft) runFSM() {
    var lastIndex, lastTerm uint64
    
    // commit 提交日誌
    commit := func(req *commitTuple) {
        var resp interface{}
        if req.log.Type == LogCommand {
            resp = r.fsm.Apply(req.log) // 提交日誌條目
        }
        
        // 更新索引為提交日誌的值
        lastIndex = req.log.Index
        lastTerm = req.log.Term
        
        // Leader 節點需要等待日誌 commit
        if req.future != nil {
            req.future.response = resp
            req.future.respond(nil)
        }
    }
    
    // restore 恢復快照
    restore := func(req *restoreFuture) {
        meta, source, err := r.snapshots.Open(req.ID) // 讀取快照
        // error handle
        
        if err := r.fsm.Restore(source); err != nil {
            // error handle
        }
        source.Close()
        
        // 更新索引為快照中的值
        lastIndex = meta.Index
        lastTerm = meta.Term
        req.respond(nil)
    }
    
    // snapshot 生成快照備份
    snapshot := func(req *reqSnapshotFuture) {
        if lastIndex  == 0 {
            req.respond(ErrNothingNewToSnapshot)
            return
        }
        
        snap, err := r.fsm.Snapshot()
        
        // 返回當前的索引給請求
        req.index = lastIndex
        req.term = lastTerm
        req.snapshot = snap
        req.respond(err)
    }
    
    for {
        select {
        case ptr := <-r.fsmMutateCh:   // 變化通道
            switch req := ptr.(type) {
            case *commitTuple:
                commit(req)
            case *restoreFuture:
                restore(req)
            default:
                // panic here...
            }
            
        case req := <-r.fsmSnapshotCh:  // 快照通道
            snapshot(req)
            
        case <-r.shutdownCh:
            return
        }
    }
}

到此,基本上 raft 協議的主幹實現已經講解完全了,此外還有如日誌、快照的 儲存引擎 、請求的 通訊協議 等可以留給有興趣的童鞋自行去讀一下。

相關文章