Aloha:一個分散式任務排程框架

jrthe42發表於2019-03-23

概覽

Aloha 是一個基於 Scala 實現的分散式的任務排程和管理框架,提供外掛式擴充套件功能,可以用來排程各種型別的任務。Aloha 的典型的應用場景是作為統一的任務管理入口。例如,在資料平臺上通常會執行各種型別的應用,如 Spark 任務,Flink 任務,ETL 任務等,統一對這些任務進行管理並及時感知任務狀態的變化是很有必要的。

Aloha 的基本實現是基於 Spark 的任務排程模組,在 Master 和 Worker 元件的基礎上進行了修改,並提供了擴充套件介面,可以方便地整合各種型別的任務。Master 支援高可用配置及狀態恢復,並提供了 REST 介面用於提交任務。

擴充套件

不同型別應用程式

在 Aloha 中,排程的應用被抽象為 Application 介面。只需要按需實現 Application 介面,就可以對多種不同型別的應用進行排程管理。Application 的生命週期主要通過 start(), shutdown() 進行管理,當應用被排程到 worker 上執行時, start() 方法首先被呼叫,當使用者要求強制停止應用時,shutdown() 方法被呼叫。

trait Application {
  //啟動
  def start(): Promise[ExitState]
  //強制停止
  def shutdown(reason: Option[String]): Unit
  //提交應用時的描述
  def withDescription(desc: ApplicationDescription): Application
  //應用執行時的工作目錄
  def withApplicationDir(appDir: File): Application
  //系統配置
  def withAlohaConf(conf: AlohaConf): Application
  //應用執行結束後的清理動作
  def clean(): Unit
}
複製程式碼

你可能注意到了,start() 方法的返回值是一個 Promise 物件。這是因為,Aloha 最初在設計時主要針對的是長期執行的應用程式,如 Flink 任務、Spark Streaming 任務等。對於這一類 long-running 的應用,Future 和 Promise 提供了一種更靈活的任務狀態通知機制。當任務停止後,通過呼叫 Promise.success() 方法告知 Worker。

例如,如果要通過啟動一個獨立程式的方式來啟動一個應用程式,可以這樣來實現:

  override def start(): Promise[ExitState] = {
    //啟動程式
    val processBuilder = getProcessBuilder()
    process = processBuilder.start()
    stateMonitorThread = new Thread("app-state-monitor-thread") {
      override def run(): Unit = {
        val exitCode = process.waitFor()
        //程式退出
        if(exitCode == 0) {
          result.success(ExitState(ExitCode.SUCCESS, Some("success")))
        } else {
          result.success(ExitState(ExitCode.FAILED, Some("failed")))
        }
      }
    }
    stateMonitorThread.start()
    result
  }

  override def shutdown(reason: Option[String]): Unit = {
    if (process != null) {
      //強制結束程式
      val exitCode = Utils.terminateProcess(process, APP_TERMINATE_TIMEOUT_MS)
      if (exitCode.isEmpty) {
        logWarning("Failed to terminate process: " + process +
          ". This process will likely be orphaned.")
      }
    }
  }

複製程式碼

自定義事件監聽

在很多情況下,我們希望能夠實時感知到任務狀態的變化,例如在任務完成或者失敗時傳送一條訊息提醒。Aloha 提供了事件監聽介面,可以及時對任務狀態的變化作出響應。

trait AlohaEventListener {
  def onApplicationStateChange(event: AppStateChangedEvent): Unit

  def onApplicationRelaunched(event: AppRelaunchedEvent): Unit

  def onOtherEvent(event: AlohaEvent): Unit
}
複製程式碼

自定義實現的事件監聽器在 Aloha 啟動時動態註冊,也可以同時註冊多個監聽器。

模組設計

總體架構

Aloha 的整體實現方案是建構在 Spark 的基礎之上,因而 Aloha 也是基於主從架構實現的,主要由 Master 和 Worker 這兩個主要元件構成:Master 負責管理叢集中所有的 Worker,接收使用者提交的應用,並將應用分派給不同的 Worker;而 Worker 主要是負責啟動、關閉具體的應用,對應用的生命週期進行管理等。Aloha 還提供了 REST 服務,實際上充當了 Client 的角色,方便通過 REST 介面提交應用。

Aloha
Aloha 提供了 HA 配置,在 Master 發生故障時可以自動進行故障轉移。同時啟動的多個 Master 例項,只有一個例項會處於 Alive 狀態,其餘的處於 Standby 狀態。當原本處於 Alive 狀態的 Master 例項當機後,LeaderElectionAgent 會從處於 Standby 狀態的 Master 中選舉出新的 Alive Master,並恢復故障之前的狀態。

任務排程管理

Worker 註冊

在 Master 啟動後,等待 Worker 的註冊請求。在 Worker 啟動時,根據 Master 的地址向 Master 傳送註冊請求。由於可能會有多個 Master 例項在執行,Worker 會所有的這些Master 都傳送註冊請求,只有處於 Alive 狀態的 Master 會響應註冊成功的訊息,處於Standby 狀態的 Master 會告知 Worker 自己正處於 Standby 狀態,Worker 會忽略這一類訊息。Worker 會一直嘗試向 Master 傳送註冊請求,直到接收到註冊成功的響應。在向 Master 傳送註冊請求時,請求的訊息中會包含當前 Worker 節點的計算資源資訊,包括可用的 CPU 數量和記憶體大小,Master 在進行排程的時候會追蹤 Worker 的資源使用情況。

一旦 Worker 註冊成功,就會週期性地向 Master 傳送心跳資訊。Master 則會定期檢查所有 Worker 的心跳情況,一旦發現太久沒有接收到某一個 Worker 的心跳訊息,則認為該 Worker 已經下線。另外,網路故障或者程式異常退出等情況會造成 Master 和 Worker 之間建立的網路連線斷開,連線斷開的事件能直接被 Master 和 Worker 監聽到。對 Master 而言,一旦一個 Worker 掉線,需要將該 Worker 上執行的應用置為為異常狀態,或是重新排程這些應用。對於 Worker 而言,一旦失去和 Master 建立的連線,就需要重新進入註冊流程。

Application 提交

可以通過兩種方式向 Master 提交 Application,一種方式是通過 REST 介面,另一種方式是自行建立一個 Client,通過 Master 的地址向 Master 傳送 RPC 呼叫。實際上 REST Server 充當了一個 Client 的角色。

當 Master 接收到註冊 Application 的請求時,會分配 applicationId,並將應用放到等待排程的列表中。在排程時,採用 FIFO 的方式,選取剩餘資源能夠滿足應用需求的 Worker,向對應的 Worker 傳送啟動應用的訊息,應用從 SUMITTED 狀態切換為 LAUNCHING 狀態。Worker 在收到啟動的應用的請求後,會為對應的應用建立工作目錄,併為每一個應用單獨啟動一個工作執行緒。應用成功啟動後會向 Master 傳送應用狀態改變的訊息,應用狀態切換為 RUNNING 狀態。此後每當應用狀態發生改變,例如任務成功完成,或是異常退出,都會向 Master 傳送應用狀態改變的訊息。在應用啟動後,對於的工作執行緒會阻塞地等待應用結束。當 Master 接收到強制停止應用的請求後,會將訊息轉發給對應的 Worker,Worker 在接收到訊息後會中斷對應應用的工作執行緒,工作執行緒響應中斷,呼叫 Application 提供的強制關閉方法強行停止應用。

為了支援擴充套件不同的應用,Worker 在啟動應用時使用了自定義的 ClassLoader 去載入應用提供的依賴包和配置檔案路徑。目前需要預先在每個 Worker 上放置好對應的檔案,並在提交應用時指定路徑。後續可以考慮使用一個分散式檔案系統,如 HDFS ,在啟動應用前下載對應的依賴,或者使用者提交應用時上傳依賴檔案,以避免預先放置檔案的不便。由於每個應用的依賴檔案都是單獨進行載入的,使用者可以方便地對應用進行升級,同時也避免了不同 Application 出現依賴衝突的問題。

容錯機制

由於 Master 負責對整個叢集的應用的排程情況進行管理,一旦 Master 出現異常,則整個叢集就處於癱瘓的狀態,因而必須要考慮為 Master 提供異常恢復機制。

Master 的異常恢復機制的核心流程在於狀態的恢復。Master 會將已經註冊的 Worker 和 Application的狀態資訊持久化儲存在持久化引擎中(目前支援 FileSystem 和 ZooKeeper,支援擴充套件),每當 Worker 或者 Application 的狀態發生更改,都會更新儲存引擎中儲存的狀態。當 Master 啟動時,處於 Standby 狀態。一旦 Master 被選舉為 Alive 節點,首先要從儲存引擎中讀取 Worker 和 Application 的狀態資訊,如果沒有歷史狀態,則 Master 可以變更為 Alive 狀態,否則進入恢復流程,狀態變更為 RECOVERING。在恢復流程中,首先要檢查 Application 的狀態,如果 Application 還沒有被排程到任何 Worker 上,則 Application 被放入排程佇列,否則將 Application 的狀態置為 ApplicationState.UNKNOWN。隨後檢查所有 Worker 的狀態,將 Worker 置為 WorkerState.UNKNOWN 狀態,並嘗試向 Worker 傳送 MasterChange 的訊息。在 Worker 接收到 MasterChange 的訊息後,會向 Master 響應目前該 Worker 上執行的所有 Application 的狀態,Master 接收到響應後就可以將對應的 Worker 和 Application 分別調整為 WorkerState.ALIVEApplicationState.RUNNING。對於超時仍沒有得到響應的 Worker 和 Application,則認為已經掉線或異常退出。至此,狀態恢復完成,Master 進入 ALIVE 狀態,可以正常處理 Worker 和 Application 的各種請求。

在使用 Standalone 模式時,可以使用 FILESYSTEM 作為儲存引擎,這種情況下只有一個 Master 會執行,失敗後需要手動進行重啟,重啟後狀態可以恢復。也可以將 Master 配置為 HA 模式,多個 Master 例項同時執行,使用 ZooKeeper 作為 LeaderElectionAgent 和儲存引擎,當 Alive 狀態的 Master 失敗後會自動選舉出新的主節點,並自動進行狀態恢復。

事件匯流排

Master 在啟動時會建立一個事件匯流排,並註冊多個事件監聽器,事件監聽器可以方便地進行擴充套件,從而滿足不同的需求。事件匯流排的核心是一個非同步的事件分發機制,基於阻塞佇列實現。當接收到新事件時,會將事件分派給事件監聽器處理。每當 Master 接收到 Application 狀態發生變更的訊息時,就會將對應的事件放入事件匯流排,因而監聽器可以及時獲取到任務狀態的變更事件。

RPC

RPC 概述

從上一節的介紹可以看出,作為一個分散式的系統,Master 和 Worker 之間存在大量的通訊,這些不同的元件之間的通訊正是通過 RPC 來實現的。

在 Aloha 中,RPC 模組不同於傳統的 RPC 框架,不需要預先使用 IDL (Interface Description Language) 來定義客戶端和服務端進行通訊的資料結構、服務端提供的服務等,而是直接基於 Scala 的模式匹配來完成訊息的識別和路由。之所以這樣來實現,是因為在這裡 RPC 的主要定位是作為內部元件之間通訊的橋樑,無需考慮跨語言等特性。基於 Scala 的模式匹配進行路由降低了程式碼的複雜度,使用起來非常便捷。

我們先看一個簡單的例子,來了解一下 RPC 的基本使用方法。其核心就在於 RpcEndpoint 的實現。

//------------------------ Server side ----------------------------
object HelloWorldServer {
  def main(args: Array[String]): Unit = {
    val host = "localhost"
    val rpcEnv: RpcEnv = RpcEnv.create("hello-server", host, 52345, new AlohaConf())
    val helloEndpoint: RpcEndpoint = new HelloEndpoint(rpcEnv)
    rpcEnv.setupEndpoint("hello-service", helloEndpoint)
    rpcEnv.awaitTermination()
  }
}

class HelloEndpoint(override val rpcEnv: RpcEnv) extends RpcEndpoint {
  override def onStart(): Unit = {
    println("Service started.")
  }

  override def receiveAndReply(context: RpcCallContext): PartialFunction[Any, Unit] = {
    case SayHi(msg) =>
      context.reply(s"Aloha: $msg")
    case SayBye(msg) =>
      context.reply(s"Bye :), $msg")
  }

  override def onStop(): Unit = {
    println("Stop hello endpoint")
  }
}

case class SayHi(msg: String)

case class SayBye(msg: String)

//--------------------------- Client side -------------------------------
object HelloWorldClient {
  def main(args: Array[String]): Unit = {
    val host = "localhost"
    val rpcEnv: RpcEnv = RpcEnv.create("hello-client", host, 52345, new AlohaConf, true)
    val endPointRef: RpcEndpointRef = rpcEnv.retrieveEndpointRef(RpcAddress("localhost", 52345), "hello-service")
    val future: Future[String] = endPointRef.ask[String](SayHi("WALL-E"))
    future.onComplete {
      case Success(value) => println(s"Got response: $value")
      case Failure(e) => println(s"Got error: $e")
    }
    Await.result(future, Duration.apply("30s"))
  }
}
複製程式碼

RpcEndpoint、 RpcEndpointRef 和 RpcEnv

從上面的例子很容易觀察到,RpcEndpointRpcEndpointRefRpcEnv 是使用這個 RPC 框架的關鍵。如果你恰好知道一點 Actor 模型和 Akka 的基本概念,很容易就能把這三個抽象同 Akka 中的 Actor, ActorRefActorSystem 聯絡起來。事實上,Spark 內部的 RPC 最初正是基於 Akka 來實現的,後來雖然剝離了 Akka,但基本的設計理念卻保留了下來。

簡單地來說,RpcEndpoint 是一個能夠接收訊息並作出響應的服務。Master 和 Worker 實際上都是 RpcEndpoint

RpcEndpoint 對接收的訊息有兩種方式,分別對應需要作出應答和不需要作出應答,即:

  def receive: PartialFunction[Any, Unit] = {
    case _ => throw new AlohaException(self + " does not implement 'receive'")
  }

  def receiveAndReply(context: RpcCallContext): PartialFunction[Any,Unit] = {
    case _ => context.sendFailure(new AlohaException(self + " won't reply anything"))
  }
複製程式碼

其中,RpcCallContext 用於向訊息傳送方作出應答,包括回覆正常的響應以及錯誤的異常。通過 RpcCallContext 將業務邏輯和資料傳輸進行了解耦,服務方無需知道請求的傳送方是來自本地還是來自遠端。

RpcEndpoint 還包含了一系列生命週期相關的回撥方法,如 onStart, onStop, onError, onConnected, onDisconnected, onNetworkError

RpcEndpointRef 是對 RpcEndpoint 的引用,它是服務呼叫方傳送請求的入口。通過獲取 RpcEndpoint 對應的 RpcEndpointRef,就可以直接向 RpcEndpoint 傳送請求。無論 RpcEndpoint 是在本地還是在遠端,向 RpcEndpoint 傳送訊息的方法都是一致的。這也正是 RPC 存在的意義,即:執行一個遠端服務提供的方法,就如同呼叫本地方法一樣。

RpcEndpointRef 提供瞭如下幾種請求的傳送方式:

  //Sends a one-way asynchronous message. Fire-and-forget semantics.
  def send(message: Any): Unit

  // Send a message to the corresponding [[RpcEndpoint.receiveAndReply)]] and return a [[Future]] to receive the reply within the specified timeout.
  def ask[T: ClassTag](message: Any, timeout: RpcTimeout): Future[T]

  def ask[T: ClassTag](message: Any): Future[T] = ask(message, defaultAskTimeout)

  def askSync[T: ClassTag](message: Any):T = askSync(message, defaultAskTimeout)

  //Send a message to the corresponding [[RpcEndpoint.receiveAndReply]] and get its result within a specified timeout, throw an exception if this fails.
  def askSync[T: ClassTag](message: Any,timeout: RpcTimeout):T = {
    val future = ask[T](message, timeout)
    timeout.awaitResult(future)
  }
複製程式碼

RpcEnvRpcEndpoint 的執行時環境。一方面,它負責 RpcEndpoint 的註冊,RpcEndpoint 生命週期的管理,以及根據 RpcEndpoint 的地址來獲取對應 RpcEndpointRef;另一方面,它還負責請求的進一步封裝,底層資料的網路傳輸,訊息的路由等。

RpcEnv 有兩種模式,一種是 Server 模式,一種是 Client 模式。在 Server 模式下,可以向RpcEnv 註冊 RpcEndpoint,並且會註冊一個特殊的 Endpoint,即 RpcEndpointVerifier,在獲取 RpcEndpointRef 時,會通過 RpcEndpointVerifier 驗證對應的 RpcEndpoint 是否存在。

RpcEnv 通過工廠模式來建立,底層具體的實現方案是可替換的,目前使用的是基於 Netty 實現的 NettyRpcEnv

Dispatcher、Inbox 和 Outbox

NettyRpcEnv 內部,為了高效進行訊息的路由與傳遞,使用了一種類似於 mailbox 的設計。

對於每一個 RpcEndpoint,都有一個關聯的 InboxInbox 內部有一個訊息列表,這個訊息列表中儲存了這個 RpcEndpoint 收到的所有訊息,包括需要應答的 RpcMessage,無需應答的 OneWayMessage, 以及各種和生命週期相關的狀態訊息,對於每一條訊息,都會呼叫對應在 RpcEndpoint 內部定義的各種函式進行處理。而 Dispatcher 則充當了訊息投遞的角色。對於 NettyRpcEnv 接收到的所有訊息, Dispatcher 都會根據指定的 Endpoint 標識找到對應的 Inbox,並將訊息投遞進去。此外,Dispatcher 內部啟動了一個 MessageLoop,這個 MessaLoop 不斷從阻塞佇列中獲取有新訊息到達的 Endpoint,不斷地消化新到達的這些訊息。

Inbox 遙相呼應的是,在 NettyRpcEnv 內部維護了 RpcAddressOutbox 的對映關係,每個遠端 Endpoint 都對應一個 Outbox 。在通過 RpcEndpointRef 傳送訊息時, NettyRpcEnv 會根據 RpcEndpoint 的地址進行判斷:如果是本地的 Endpoint, 則直接通過 Dispatcher進行訊息投遞;如果是遠端的 Endpoint, 則將訊息投遞到對應的 Outbox 中。 Outbox 中也有一個待投遞的訊息列表,在首次向遠端 Endpoint 投遞訊息時,會先建立網路連線,然後依次將訊息傳送出去。

網路傳輸

NettyRpcEnv 中,如何將請求傳送給遠端的 Endpoint,並收到遠端 Endpoint 給出的回覆,這就要要依賴於更底層的網路傳輸模組。網路傳輸模組,主要是對 Netty 的更進一步封裝,其中關鍵的元件及功能如下:

  • TransportServer: 網路傳輸的服務端,當 NettyRpcEnv 以 Server 模式啟動時就會建立一個 TransportServer,等待客戶端的連線請求
  • TransportClient:網路傳輸的客戶端,實際上就是對 channel 的進一步封裝,一旦網路雙方的請求建立成功,那麼在 channel 的兩端就各有一個 TransportClient,從而可以以全雙工的方式進行資料交換
  • TransportClientFactory:建立 TransportClient 的工廠類,內部使用了連線池,可以複用已經建立的連線
  • RpcHandler:負責對接收到的 RPC 請求訊息進行處理,NettyRpcEnv 就是在這個介面的方法中將訊息交給 Dispatcher 進行投遞
  • RpcResponseCallback:RPC 請求響應的回撥介面,NettyRpcEnv 基於這個介面對接收到的資料進行反序列化
  • TransportRequestHandler:對請求訊息進行處理,主要是將訊息轉交給 RpcHandler 進行處理
  • TransportResponseHandler:對響應訊息進行處理,記錄了每一條已傳送的訊息和與其關聯的 RpcResponseCallback,一旦收到響應,就呼叫對應的回撥方法
  • TransportChannelHandler:位於 channel pipeline 的尾端,根據訊息型別將訊息交給 TransportRequestHandlerTransportResponseHandler 進行處理
  • TransportContext:用於建立 TransportServerTransportClientFactory,並初始化 Netty Channel 的 pipeline

其他的諸如引導服務端、引導客戶端、訊息的編解碼等過程,都是使用 Netty 進行網路通訊的慣常流程,這裡不再詳述。

小結

Aloha 是一個分散式排程框架 Aloha ,它的實現主要參考了 Spark。文中首先介紹了 Aloha 的使用場景和擴充套件方式,並採用自頂向下的方式重點介紹了 Aloha 的模組設計和實現方案。

Aloha 現已在 Github 開源,專案地址: github.com/jrthe42/alo… 。有關該專案的任何問題,歡迎各位通過 issue 進行交流。

-EOF-

原文地址: blog.jrwang.me/2019/aloha-…
轉載請註明出處!

相關文章