MOSN熱升級邏輯淺析

tyloafer發表於2023-02-07

背景

MOSN在熱升級上面,也曾做過自己的探索;業界雖然有Nginx Envoy 也都實現了熱升級方式,那麼這裡有什麼異同呢?

Nginx: 透過Fork的方式直接繼承父程式的監聽資訊和連結資訊等,僅僅只用於重啟

Envoy: Envoy對埠監聽(Listener)進行了遷移,但是對建立的連線(connection)則透過命令的方式,進行主動斷開重連

MOSN: 鑑於低版本Bolt 等協議,不支援主動斷連,且是長連線,導致MOSN在進行熱升級的時候,不僅僅進行了埠監聽的遷移,還有connection的遷移,保證了熱升級過程中連結不中斷,客戶端服務端無感的升級體驗

升級流程

觸發情況

這裡贅述一下,MOSN的熱升級方式,是透過一個Operator實現的

  1. Operator在Pod中增加新的MOSN容器
  2. New Mosn啟動時候,觀測到有Old Mosn存在,開啟熱升級的邏輯
  3. New Mosn啟動成功後,Old Mosn進行退出
  4. Operator銷燬Old Mosn的Container

至此,一個完整的流程熱升級流程結束

整體設計

MOSN熱升級中核心的資料

  1. 配置資料(這樣New Mosn才知道代理的是什麼應用,有哪些配置資訊等等)
  2. 監聽埠
  3. 連線

image-20230204160716742

MOSN熱升級的互動流程設計如上,接下來對各個模組遷移邏輯進行一下跟蹤

原始碼解析

程式碼邏輯僅保留核心流程

socket監聽情況

func (stm *StageManager) Run() {
    // 1: parser params
    stm.runParamsParsedStage()
    // 2: init
    stm.runInitStage()
    // 3: pre start
    stm.runPreStartStage()
    // 4: run
    stm.runStartStage()
    // 5: after start
    stm.runAfterStartStage()

    stm.SetState(Running)
}

MOSN啟動分為上述幾個流程,其中 熱升級邏輯主要分佈在 InitStageStartStage

InitStage: 遷移配置資訊 和 遷移Listener

StartStage: 建立 reconfig.sock 的監聽 和 遷移 connection

在MOSN中有4個socket監聽

reconfig.sock: 由 old mosn 監聽,用於 new mosn 感知 old mosn存在

listen.sock: 由new mosn 監聽,用於old mosn傳遞 listener資料給new mosn

conn.sock: 由 new mosn監聽,用於 old mosn 傳遞 connection 的 fd和connection讀取到的資料 (沒有則不傳)給 new mosn

mosnconfig.sock: 由 new mosn 監聽,用於 old mosn 傳遞配置資訊給 new mosn

Old Mosn 遷移主流程

func ReconfigureHandler() error {
    // dump lastest config, and stop DumpConfigHandler()
    configmanager.DumpLock()
    configmanager.DumpConfig()
    // if reconfigure failed, enable DumpConfigHandler()
    defer configmanager.DumpUnlock()

    // transfer listen fd
    var listenSockConn net.Conn
    var err error
    var n int
    var buf [1]byte
    if listenSockConn, err = sendInheritListeners(); err != nil {
        return err
    }

    if enableInheritOldMosnconfig {
        if err = SendInheritConfig(); err != nil {
            listenSockConn.Close()
            log.DefaultLogger.Alertf(types.ErrorKeyReconfigure, "[old mosn] [SendInheritConfig] new mosn start failed")
            return err
        }
    }

    // Wait new mosn parse configuration
    listenSockConn.SetReadDeadline(time.Now().Add(10 * time.Minute))
    n, err = listenSockConn.Read(buf[:])
    if n != 1 {
        log.DefaultLogger.Alertf(types.ErrorKeyReconfigure, "[old mosn] [read ack] new mosn start failed")
        return err
    }

    // ack new mosn
    if _, err := listenSockConn.Write([]byte{0}); err != nil {
        log.DefaultLogger.Alertf(types.ErrorKeyReconfigure, "[old mosn] [write ack] new mosn start failed")
        return err
    }

    // stop other services
    store.StopService()

    // Wait for new mosn start
    time.Sleep(3 * time.Second)

    // Stop accepting new connections & graceful close the existing connections if they supports graceful close.
    shutdownServers()

    // Wait for all connections to be finished
    WaitConnectionsDone(GracefulTimeout)

    log.DefaultLogger.Infof("[server] [reconfigure] process %d gracefully shutdown", os.Getpid())

    // will stop the current old mosn in stage manager
    return nil
}
  • old mosn 首先將 listener 遷移到new mosn
  • 然後 遷移 配置資訊,這一步是可選的
  • 等待 new mosn ack完成後
  • 呼叫 WaitConnectionsDone 將 connection close掉,遷移 connection

配置遷移

配置遷移是用過 mosnconfig.sock 來完成的,new mosn 監聽,old mosn 連線上去並傳輸資料

new mosn監聽是在 GetInheritConfig 這個方法中實現的

func GetInheritConfig() (*v2.MOSNConfig, error) {
    ......

    l, err := net.Listen("unix", types.TransferMosnconfigDomainSocket)
    ......
    defer l.Close()

    ul := l.(*net.UnixListener)
    ul.SetDeadline(time.Now().Add(time.Second * 10))
    uc, err := ul.AcceptUnix()
    ......
    defer uc.Close()
    log.StartLogger.Infof("[server] Get GetInheritConfig Accept")
    configData := make([]byte, 0)
    buf := make([]byte, 1024)
    for {
        n, err := uc.Read(buf)
        configData = append(configData, buf[:n]...)
        .......
    }

    // log.StartLogger.Infof("[server] inherit mosn config data: %v", string(configData))

    oldConfig := &v2.MOSNConfig{}
    err = json.Unmarshal(configData, oldConfig)
    if err != nil {
        return nil, err
    }

    return oldConfig, nil
}

New mosn 建立好socket監聽後,卡在 Accept 函式中,等待 old mosn 建立連結

然後 接收old mosn 傳遞過來的資料即可

old mosn是在 SendInheritConfig 中與 new mosn建立連結並傳遞配置資訊的

func SendInheritConfig() error {
    var unixConn net.Conn
    var err error
    // retry 10 time
    for i := 0; i < 10; i++ {
        unixConn, err = net.DialTimeout("unix", types.TransferMosnconfigDomainSocket, 1*time.Second)
        ......
    }
    ......
    configData, err := configmanager.InheritMosnconfig()
    ......

    uc := unixConn.(*net.UnixConn)
    defer uc.Close()

    n, err := uc.Write(configData)
    ......

    return nil
}

至此,配置資料傳遞也就完成了

監聽埠遷移

listener遷移是用過 listen.sock 來完成的,new mosn 監聽,old mosn 連線上去並傳輸資料,跟配置傳輸的邏輯差不多

new mosn監聽是在 GetInheritListeners 這個方法中實現的,並獲取所有的listener

func GetInheritListeners() ([]net.Listener, []net.PacketConn, net.Conn, error) {

    l, err := net.Listen("unix", types.TransferListenDomainSocket)
    ......
    defer l.Close()


    ul := l.(*net.UnixListener)
    ul.SetDeadline(time.Now().Add(time.Second * 10))
    // 這裡卡主,等待 old mosn連線
    uc, err := ul.AcceptUnix()

    buf := make([]byte, 1)
    oob := make([]byte, 1024)
  // 接收 old mosn傳遞過來的資料
    _, oobn, _, _, err := uc.ReadMsgUnix(buf, oob)
    
    scms, err := unix.ParseSocketControlMessage(oob[0:oobn])
    
  // 解析出來fd
    gotFds, err := unix.ParseUnixRights(&scms[0])

    var listeners []net.Listener
    var packetConn []net.PacketConn
    for i := 0; i < len(gotFds); i++ {
        fd := uintptr(gotFds[i])
        file := os.NewFile(fd, "")
        .....
        defer file.Close()

    // 透過fd 恢復 listener,本質是對fd的監聽
        fileListener, err := net.FileListener(file)
        if err != nil {
            pc, err := net.FilePacketConn(file)
            if err == nil {
                packetConn = append(packetConn, pc)
            } else {

                log.StartLogger.Errorf("[server] recover listener from fd %d failed: %s", fd, err)
                return nil, nil, nil, err
            }
        } else {
            // for tcp or unix listener
            listeners = append(listeners, fileListener)
        }
    }

    return listeners, packetConn, uc, nil
}

透過上述方法,new mosn建立 socket監聽,並等待 old mosn的連線,old mosn連線上後,等待 old mosn 傳遞 所有listener 的fd,然後 new mosn 進行恢復即可

但是 同一個 fd,有兩個監聽,不就亂了嗎,所以 當 old mosn 傳遞過來fd後,會主動 stop accept,不再進行監聽

new mosn 的listener 傳遞邏輯在sendInheritListeners 裡面

func sendInheritListeners() (net.Conn, error) {
  // 列出來所有的 listener,返回格式 os.File
    lf := ListListenersFile()
    ......

    lsf, lerr := admin.ListServiceListenersFile()
    ......

    var files []*os.File
    files = append(files, lf...)
    files = append(files, lsf...)

    ......
    fds := make([]int, len(files))
    for i, f := range files {
    // 獲取 file 的 fd
        fds[i] = int(f.Fd())
        defer f.Close()
    }

    var unixConn net.Conn
    var err error
    // retry 10 time
    for i := 0; i < 10; i++ {
        unixConn, err = net.DialTimeout("unix", types.TransferListenDomainSocket, 1*time.Second)
        .......
    }
    ......

    uc := unixConn.(*net.UnixConn)
    buf := make([]byte, 1)
  // 將 fd 轉成 socket message
    rights := syscall.UnixRights(fds...)
    n, oobn, err := uc.WriteMsgUnix(buf, rights, nil)
    ......

    return uc, nil
}

Old mosn 透過 ListListenersFile 將所有的listener羅列出來,這個主要得益於MOSN良好的設計模式;mosn維護了一個全域性的servers,而server的結構如下

type server struct {
    serverName string
    stopChan   chan struct{}
    handler    types.ConnectionHandler
}

type ConnectionHandler interface {
    ......

    // ListListenersFD reports all listeners' fd
    ListListenersFile(lctx context.Context) []*os.File
}

每個server對自己的listener描述清晰

繼續回到 old mosn傳遞listener的過程

  • old mosn 羅列出來所有的listener,並獲取file
  • old mosn 獲取所有 file的fd,並將fd透過UnixRights轉成 socket message,供傳遞
  • new mosn 接收到 socket message,轉成fd,並透過檔案建立listener

然後 old mosn 會關閉掉所有的listener,停止accept 新的連結

func shutdownServers() {
    for _, server := range servers {
        server.Shutdown()
    }
}

func (ch *connHandler) GracefulStopListeners() error {
    var failed bool
    listeners := ch.listeners
    wg := sync.WaitGroup{}
    wg.Add(len(listeners))
    for _, l := range listeners {
        al := l
        log.DefaultLogger.Infof("graceful shutdown listener %v", al.listener.Name())
        // Shutdown listener in parallel
        utils.GoWithRecover(func() {
            defer wg.Done()
            if err := al.listener.Shutdown(); err != nil {
                log.DefaultLogger.Errorf("failed to shutdown listener %v: %v", al.listener.Name(), err)
                failed = true
            }
        }, nil)
    }
    wg.Wait()

    return nil
}

func (l *listener) Shutdown() error {
    changed, err := l.stopAccept()
    if changed {
        l.cb.OnShutdown()
    }
    return err
}

至此,只有new mosn 能建立新的連結,old mosn不再建立新的連結了

listener傳遞完成後,新的連結都建立到 new mosn上去了,剩餘的就是存量長連結了

長連結遷移

長連結遷移是用過 conn.sock 來完成的,同上,也是由new mosn 監聽,old mosn 連線上去並傳輸資料;不過,這裡並沒有傳遞配置和listener那樣簡單,需要考慮很多邊際問題

在這裡,連結分為兩部分

  1. client/server -> mosn的連結
  2. mosn -> client/server 的連結

我們只需要考慮 client/server -> mosn的連結 的情況即可,mosn -> client/server 的連結 這種情況,由於mosn是主動連線方,斷開並不會對下游造成任何影響

先來看下 長連結遷移的流程

長連線遷移過程

  • Client 傳送請求到 MOSN
  • MOSN 透過 domain socket(conn.sock) 把 TCP1 的 FD 和連線的狀態資料傳送給 New MOSN
  • New MOSN 接受 FD 和請求資料建立新的 Conection 結構,然後把 Connection id 傳給 MOSN,New MOSN 此時就擁有了TCP1 的一個複製。Old MOSN 停止讀取 TCP1 的請求,New MOSN 開始讀取 TCP1的請求,TCP1的遷移就完成了
  • New MOSN 透過 LB 選取一個新的 Server,建立 TCP3 連線,轉發請求到 Server
  • Server 回覆響應到 New MOSN
  • New MOSN 透過 MOSN 傳遞來的 TCP1 的複製,回覆響應到 Client

接下來看下程式碼

注: 前面 WaitConnectionsDone 已經將 connection.stopChan close掉了

連結遷移是在 startReadLoop 中完成的

func (c *connection) startReadLoop() {
    var transferTime time.Time
    for {
        ......
        select {
        case <-c.stopChan:
      // 首先設定 transfer 時間
            if transferTime.IsZero() {
                if c.transferCallbacks != nil && c.transferCallbacks() {
                    randTime := time.Duration(rand.Intn(int(TransferTimeout.Nanoseconds())))
                    transferTime = time.Now().Add(TransferTimeout).Add(randTime)
                    log.DefaultLogger.Infof("[network] [read loop] transferTime: Wait %d Second", (TransferTimeout+randTime)/1e9)
                } else {
                    // set a long time, not transfer connection, wait mosn exit.
                    transferTime = time.Now().Add(10 * TransferTimeout)
                    log.DefaultLogger.Infof("[network] [read loop] not support transfer connection, Connection = %d, Local Address = %+v, Remote Address = %+v",
                        c.id, c.rawConnection.LocalAddr(), c.RemoteAddr())
                }
            } else {
                if transferTime.Before(time.Now()) {
                    c.transfer()
                    return
                }
            }
        default:
        }
    .......
  }
  • 在 old mosn 呼叫 WaitConnectionsDone 將 connection.stopChan close之後,在 startReadLoop 迴圈中 首先設定以下 隨機 transfer 時間
  • 在 transfer時間到了之後,開始 遷移 連結
func (c *connection) transfer() {
    c.notifyTransfer()
    id, _ := transferRead(c)
    c.transferWrite(id)
}

func transferRead(c *connection) (uint64, error) {
    ......
    unixConn, err := net.Dial("unix", types.TransferConnDomainSocket)
    ......
    defer unixConn.Close()

    file, tlsConn, err := transferGetFile(c)
    ......

    uc := unixConn.(*net.UnixConn)
    // send type and TCP FD
    err = transferSendType(uc, file)
    ......
    // send header + buffer + TLS
    err = transferReadSendData(uc, tlsConn, c.readBuffer)
    ......
    // recv ID
    id := transferRecvID(uc)
    log.DefaultLogger.Infof("[network] [transfer] [read] TransferRead NewConn Id = %d, oldId = %d, %p, addrass = %s", id, c.id, c, c.RemoteAddr().String())

    return id, nil
}
  • 每一個connection遷移的時候,會首先構建一個 unix connection 用於 old mosn 和 new mosn互動
  • 首先將 connection 的 fd 透過 scoket傳遞給new mosn
  • 然後 將 connection tls 和 讀取的buf資料再傳遞給 new mosn 處理
  • 最後 記錄下來 new mosn 根據 fd 建立的 新的connection的id

這裡mosn構造了一個簡單的socket協議,用於 傳遞 connection 的tls和buf資料

/**
 *  transfer read protocol
 *  header (8 bytes) + (readBuffer data) + TLS
 *
 * 0                       4                       8
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 * |      data length      |     TLS length        |
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 * |                     data                      |
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 * |                     TLS                       |
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 *

接下來繼續看下,new mosn 收到 connection後的處理, new mosn 處理是在 transferHandler 中完成的

func transferHandler(c net.Conn, handler types.ConnectionHandler, transferMap *sync.Map) {
    ......

    uc, ok := c.(*net.UnixConn)
    ......
    // recv type
    conn, err := transferRecvType(uc)
    ......

    if conn != nil {
        // transfer read
        // recv header + buffer
        dataBuf, tlsBuf, err := transferReadRecvData(uc)
        ......
        connection := transferNewConn(conn, dataBuf, tlsBuf, handler, transferMap)
        if connection != nil {
            transferSendID(uc, connection.id)
        } else {
            transferSendID(uc, transferErr)
        }
    }
  ......
}

new mosn 收到connection遷移請求後,根據傳遞過來的fd,首先轉換成connection,然後 根據 tls 資料,構建為 tls conn,這些完成後,根據 conn的監聽資訊和handler型別,找到對應的listener,並將這個connection加進去,然後就可以開始處理 傳遞過來的buf資料了

最後,new mosn 將新的connection的id,傳給old mosn,用於傳遞 寫請求

至此,old mosn connection的 readLoop 也退出了,不再讀取新的資料,資料也都由new mosn來讀取了

接下來就是寫請求了

old mosn 如果繼續往連線裡面寫資料,可能會和new mosn衝突,導致資料操作,所以 old mosn的寫請求,是直接轉給 new mosn來處理的

Old mosn 遷移寫請求:

func transferWrite(c *connection, id uint64) error {
    ......
    unixConn, err := net.Dial("unix", types.TransferConnDomainSocket)
    ......
    defer unixConn.Close()

    uc := unixConn.(*net.UnixConn)
    err = transferSendType(uc, nil)
    ......
    // build net.Buffers to IoBuffer
    buf := transferBuildIoBuffer(c)
    // send header + buffer
    err = transferWriteSendData(uc, int(id), buf)
    ......
    return nil
}

Mosn 也為寫請求,構建了一個簡單的socket協議

*  transfer write protocol
 *  header (8 bytes) + (writeBuffer data)
 *
 * 0                       4                       8
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 * |      data length      |    connection  ID     |
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 * |                     data                      |
 * +-----+-----+-----+-----+-----+-----+-----+-----+
 *

這裡會將,遷移 讀請求時獲取到的 connection id 記錄下來,並傳遞給 new mosn,讓 new mosn 根據 id 找到對應的連結

new mosn 接收寫請求處理邏輯,也是在 transferHandler 中完成的:

func transferHandler(c net.Conn, handler types.ConnectionHandler, transferMap *sync.Map) {
    ......
        // transfer write
        // recv header + buffer
        id, buf, err := transferWriteRecvData(uc)
        ......
        connection := transferFindConnection(transferMap, uint64(id))
        ......
        err = transferWriteBuffer(connection, buf)
        ......
    
}

New mosn 從 old mosn的socket請求中,解析出來 connection id 和 buf 資料

根據 id 找到 new mosn中的 connection,然後將buf寫入即完成

至此,讀寫請求都遷移完成,整個長連線的遷移也就完成了

總結

MOSN對熱升級的處理,是做到極致的,深度是遠遠高於市場上其他產品的;同時,深度也往往伴隨著風險,做到連結遷移層面,可能也並不是MOSN的本意,而是歷史原因的驅動

在原始碼邏輯層面,個人也有一點簡單的看法

  1. 整個熱升級模組,更像是函式驅動,而缺少設計,從 conn.sock listen.sock 等多個sock檔案就可以看出,如果設計好點的話,完全可以透過構建socket協議,而規避掉多個socket檔案,同時,邏輯也會更清晰簡潔一點,而非散落在各個方法裡面
  2. 有些邊界性問題還是沒有處理的很好;例如,listener遷移的時候,如果有listener close掉了,但是配置還存在MOSN中,這裡就會panic了;eg: reconfig.sock 在處理最後會刪除,如果走到了這一步,然後回滾了,後續也無法進行熱升級

最後,MOSN還是很優秀的,respect!!!

相關文章