Scala+Akka 实现主从节点的心跳检测

1,774 阅读16分钟

在前几期中,我们已经介绍过基于 Scala 的 Akka 异步编程模型,由 SBT 和 Play2 搭建的 Web 服务......在这一期专题中,仍以学习 Scala 基础知识为出发点,并仿写一个由 Akka 组建起来的分布式节点的心跳检测机制。

我们研究这个 demo 的意义:

  1. 提前了解 Spark 的 Master 和 Worker 的通讯机制。
  2. 加深对主从服务心跳检测机制的理解(关于心跳检测,笔者曾经在 Nginx 中介绍过)。

为了理解本专题的内容,还要了解:

  1. Scala 版本的 'Maven' : Play2 / sbt 操作指南 (juejin.cn)( 开头部分是关于 SBT 本身的介绍,Play2 在此不是重点 )。
  2. 异步编程框架 Akka : Scala 之 Akka 框架实战 (juejin.cn)

项目需求分析

  1. 每个 Worker 注册到 Master。在 Master 完成注册时,回复 Worker 注册成功。
  2. Worker 定时发送心跳,并由 Master 进行接收。
  3. Master 接收到 Workder 的心跳之后,更新 Worker 的心跳时间。
  4. 给 Master 启动定时任务,定时检测注册的 Workder 有哪些没有更新心跳检测,并从 HashMap 当中将其删除。
  5. 在多个 Linux 系统下(云服务器或者虚拟机)部署 Worker 和 Master。

注:心跳检测也是 Spark 的核心。

构建项目

基于 SBT 导入依赖

在这个项目时间中,我们需要依赖能够支持远程通讯的 Akka 组件,因此要同时引入 akka 和 akka-remote 。

name := "scala_spark"

version := "0.1"

scalaVersion := "2.12.4"

resolvers += "Typesafe Repository" at "http://repo.typesafe.com/typesafe/releases/"

libraryDependencies += "com.typesafe.akka" %% "akka-actor" % "2.4.12"
libraryDependencies += "com.typesafe.akka" %% "akka-remote" % "2.4.12"

目录结构

整体的项目目录结构如下方所示:

com.scala.SparkTest
  ├ common
  |  └ MessageProtocol.scala
  ├ master
  |  └ SparkMaster.scala
  └ worker
     └ SparkWorker.scala

搭设 Akka 脚手架

SparkMaster

我们首先让 SparkMaster, SparkWorker 继承 Akka 的特质,下面以 SparkMaster 为例。

// SparkWorker 也同样是类似的架构。
class SparkMaster extends Actor{
	//case _ => ...
    //这里有对消息的处理
}

object SparkMaster {
	def main(args: Array[String]):Unit = {
		//构造一个 ActorSystem 并对 Actor 进行管理。
	}
}

ActorSystem 基于 Socket 进行网络通讯。因此我们在启动此系统时首先需要绑定 host,端口号信息。另外,为了能够让运行在其它主机的 ActorSystem 能够访问到对应的资源,我们需要为 ActorSystem 和 Akka Actor 都指派一个名称。

下面是 SparkMaster 的配置过程:

//1.绑定本机地址和启动的端口号
val host = "127.0.0.1" //绑定为本机地址
val port = 9999 //绑定本机启动的端口号

//2.绑定配置文件
val config = ConfigFactory.parseString(
  s"""
    akka{
      actor{
        provider = "akka.remote.RemoteActorRefProvider"
      }

      remote{
        enabled-transports=["akka.remote.netty.tcp"]
        netty.tcp{
          hostname="$host"
          port=$port
        }
      }
    }
 """)

//3.设置 ActorSystem 的名称
val sparkMasterSystem = ActorSystem("master", config = config)

//4.设置 Master Actor的名称
val sparkMasterRef: ActorRef = sparkMasterSystem.actorOf(Props[SparkMaster], "SparkMaster-01")

我们希望在主程序运行的那一刻起,ActorSystem 就让这个 SparkMaster 进入监听状态,因此在上述代码之后,我们会立刻向这个 ActorRef 发送一个字符串 start 来唤醒它:

//5.向刚才的 Master ActorRef 发送消息。
sparkMasterRef ! "start"

同样的,我们在 SparkMaster 的伴生类(使用class修饰)当中重写 receive 方法,并在之后不断完善它的逻辑。在这里,我们先简单补充一个收到 start 字符串时的处理逻辑。

override def receive: Receive = {
    case "start" =>
      println(" Master 服务器启动成功!")

SparkWorker

SparkWorker 的 ActorRef 在启动之前首先要借助preStart()方法获取 SparkMaster 的 ActorRef ,并将 WorkerRef 绑定在一起。我们需要正确配置 Master 的 ActorSystem 名称和对应的 Actor 名称, Worker 才能访问到正确的资源。

为了标识唯一的 Worker ,我们使用 java.util.UUID 工具来生成 id 号码。

class SparkWorker(masterHost: String, masterPort: Int) extends Actor {

  // 实际上就是 ActorRef 的另一种叫法。
  var masterProxy: ActorSelection = _

  // 每个 Worker 生成一个随机 id。
  val id = java.util.UUID.randomUUID().toString

  // 初始化 Master 的 Proxy。
  // master 是 SparkMaster 的 ActorSystem 的名字。
  // SparkMaster-01 是 Master ActorRef 的名字。
  override def preStart(): Unit = {
    masterProxy = context.actorSelection(
      s"akka.tcp://master@$masterHost:$masterPort/user/SparkMaster-01")
  }
  
  //SparkWorker 也需要重写 receive 方法,我们稍后来补充。
  override def receive: Receive = {
      case "start"=>
        println(" Worker 开启工作了!")
  }
}

同样的,我们为 Worker 也创建出一个 ActorSystem。由于 Worker 需要获取到 Master 的引用,因此额外还需要 Master 的主机名,端口号等。

    //1.绑定本机地址和启动的端口号
    val workerHost = "127.0.0.1" //绑定为本机地址
    val workerPort = 10000 //绑定本机启动的端口号

    //1.1 配置远端地址和启动的端口号
    val masterHost = "127.0.0.1"
    val masterPort = 9999


    //2.绑定配置文件
    val config = ConfigFactory.parseString(
      s"""
        akka{
          actor{
            provider = "akka.remote.RemoteActorRefProvider"
          }

          remote{
            enabled-transports=["akka.remote.netty.tcp"]
            netty.tcp{
              hostname="$workerHost"
              port=$workerPort
            }
          }
        }
     """)

    val workerSystem = ActorSystem("worker", config = config)

    val sparkWorkerActorRef: ActorRef = workerSystem.actorOf(Props(new SparkWorker(masterHost, masterPort)), "SparkWorker")

    sparkWorkerActorRef ! "start"

实现 Worker 到 Master 的注册功能

每当 Worker 启动时,我们都为其生成了一个足够随机(保证基本不会重复)的 id 号。我们希望 Worker 在启动时主动将自己的信息(包括 id 号, cpu 数,内存容量等信息)发送给 Master, Master 将统一对这些 Worker 信息进行管理。

这里开始要涉及到 Worker 和 Master 的通讯,因此我们首先要规定一些消息格式,以便于双方解析对应的信息。

规定 Workers 和 Master 消息协议

Workers 和 Master 之间发送的消息种类各不相同,包括后续各种计时器消息。这里我们将它们统一写入到一个 MessageProtocol.scala 文件当中。

我们首先给出两个结构体:

// worker 在注册时发送给服务器的信息。
case class RegisterWorkerInfo(id: String, cpu: Int, ram: Int)

// 这个结构体用于 master 将每个注册的 worker 信息保存到 hashMap 当中。
class WorkerInfo(val id: String, val cpu: Int, val ram: Int) 

// 当注册成功时,返回这个单例对象。
case object RegisteredWorkerInfo

当 WorkerRef 通过 ! 向 MasterRef 发送注册消息时,发送的是 RegisterWorkerInfo。而 MasterRef 接收到这个消息之后,则会提取出 Worker 的一些简要讯息放入 WorkerInfo 内,并在内存当中创建一个可变的 hashMap 来保管它。

实际上,WorkerInfo 会包含更多的信息,而不仅限于文中提到的三个参数。而对于 Worker 而言,它只需要发送必要的信息即可。而对于 Master 而言,它还需要额外记录 Worker 的其它信息,比如说后文会提到每个 Worker 最后一次发送心跳信息的时间。这些内容,我们选择在 WorkerInfo 当中进行扩展。

当注册成功之后, Master 会返回一个 RegisteredWorkerInfo 实例。我们仅使用它本身来作为一个注册成功的 ”signal“ ,因此它不需要有其它额外的任何内容,表示是一个单例对象。我们在这里使用了 case object 来修饰它。case objectcase class 相比,它不具备 apply, unapply 方法。

我们同样可以使用其它的手段来完成同样的功能:比如说使用枚举类,或者说仅仅发送一个简单的 ”RegisteredWorkerInfo“ 字符串也可以。在这里,我们仅仅是为了模拟 Spark 框架是如何去做的。

我们在 SparkMaster 中完善逻辑,声明一个可变的 Map 来保存 " id => WorkerInfo " 样式的键值对。

//定义一个管理 worker 信息的 hashMap , 这个 hashMap 必须是可变的。
val workers : mutable.Map[String, WorkerInfo] = mutable.Map[String, WorkerInfo]()

在 Worker 工作时向 Master 进行注册

现在,我们已经规定了必要的消息协议。我们完善 SparkWorker 的功能,让它在启动时向 MasterRef 发送自己的注册消息。

  override def receive: Receive = {
    case "start" =>
      println(" Worker 服务器启动成功!")
+     masterProxy ! RegisterWorkerInfo(id, 8, 16 * 1024)

同样的,SparkMaster 的 recevie 方法也要补充逻辑:

  1. 在接受到新的 Worker 的注册信息时,提取出 id,cpu ,ram 等信息,然后存储到 workers 哈希表当中。
  2. 利用 sender() 返回一个 RegisteredWorkerInfo 单例,通知 Worker 注册成功。
    case RegisterWorkerInfo(id, cpu, ram) =>
      //接受到客户端的注册信息
      if (!workers.contains(id)) {
        //提取该 worker 的基本信息。
        val workerInfo = new WorkerInfo(id, cpu, ram)
        workers += (id -> workerInfo)

        //一切操作完成时,直接返回该单例对象(伴生类)
        sender() ! RegisteredWorkerInfo
      }

实现心跳消息发送与接收

在 Worker 正常注册之后,它将会接受到 Master 回送的 RegisteredWorkerInfo 消息。至此,Worker 将进入正常的工作状态。为了始终与 Master 保持一个联络的状态,我们需要实现心跳检测功能:即已经注册的 Worker 每隔一段时间向 Master 发送一个消息,表示自己在线。

为了实现这个功能,我们需要引入一个全新的玩意:即 ActorSystem 提供的计时器功能,其逻辑大致为:

  1. ActorSystem 每过一段时间,就向 Worker 发送一个提示信息:”你应该向 Master 发送一个心跳消息了“。
  2. Worker 收到了这个提示信息,随后向 Master 发送了一个心跳信息

注意,这里涉及到了两种信息。计时器功能由 ActorSystem 来负责,而 Worker 需要依赖这个计时器的提醒,它才会知道向 Master 发送心跳消息的时机。

补充心跳检测所需的消息协议

将下面的这段代码补充至 MessageProtocol.scala 文件当中。另外,当 Worker 向 Master 发送心跳消息时,还会附带自己的 id 号,以便于 Master 辨别出哪个 Worker 的心跳消息更新了。

// Akka 上下文通过计时器提醒 worker 发送心跳信息时,发送此单例对象。
case object SendHeartBeat

// worker 收到 SendHeartBeat 时,向 master 发送对应的心跳信息,并标注自己的 id 。
case class HeartBeat(id: String)

很显然,我们也需要在之前的 WorkerInfo 当中补充一个变量:它用于记录 Worker 最近一次发送的心跳消息。这样, Master 就可以通过计算来查看哪些 Worker 处于离线状态了。

class WorkerInfo(val id: String, val cpu: Int, val ram: Int) {

+  //拓展:需要记录每个 worker 上次发送心跳消息的信息。
+  var lastHeartBeat : Long = System.currentTimeMillis()

}

通过 context 完成定时发送心跳消息功能

我们在 SparkWorker 的功能当中继续完善:当它收到注册成功的消息之后,启用计时器机制,周期性的发送心跳消息。为了更方便地使用 millis 这个时间单位,这里可以额外地引入 import scala.concurrent.duration._

case RegisteredWorkerInfo =>

  println(s" Worker : $id 注册成功了!")

  //注册成功之后,定义一个计时器,每隔一段时间发送消息。
  import context.dispatcher

  /*
  1. initialDelay:初始延迟。设定当 worker 收到注册成功的消息之后,立刻发送一次心跳检测。
  2. internalDelay:时间间隔。设定每 3000 毫秒发送一次心跳消息。
  3. actorRef:本机的 actor 系统将向哪个 actor 发送消息。
  4. message:发送消息的内容。

  其逻辑是:本机的 Akka 系统通过计时器"提醒"此 worker 发送心跳消息,
  然后 worker 收到此"提醒"之后,再向 master 发送真正的心跳消息。
   */
  context.system.scheduler.schedule(0 millis, 3000 millis, self, SendHeartBeat)

case SendHeartBeat =>

  println(s" Worker : $id 发送了心跳信息。")
  masterProxy ! HeartBeat(id)

它实际上包含了两个步骤:首先,它令 ActorSystem 每过一段时间向本 Akka 系统内部(self)发送一个 SendHeartBeat 消息。当 Worker 每过一段时间接收到 SendHeartBeat 消息时,就会向 masterProxy发送真正的心跳消息。

Master 通过心跳消息更新状态

在之前,我们在 WorkerInfo 当中补充了一个 lastHeartBeat 变量用于记录每个 Worker 最近一次发送心跳检测的时间 ( 说得更严谨一些,这实际上是 Master 接收到心跳检测的时间 )。

很显然,Master 每接受到一个新的心跳消息,它都应该在 workers 哈希表中及时更新指定 id 的 Worker 的 lastHeartBeat 变量。具体的处理逻辑是:

  1. 接收到 HeartBeat 消息时,提取出 id 号。
  2. workers 中根据 id 号取出对应的 WorkerInfo 信息。
  3. 更新 WorkerInfo 信息中的 lastHeartBeat 变量。
  4. 将这个 WorkerInfo 重新保存到 workers 当中。

所以,SparkMaster 的 receive 方法多了如下功能:

    case HeartBeat(id) =>
      // 更新指定 id worker 的信息。
      // 1.取出消息
      val info: WorkerInfo = workers(id)

      // 2.更新时间
      info.lastHeartBeat = System.currentTimeMillis()

	  // 3.Scala 的 Map 会自动覆盖相同 key 值的 value。
      workers += id -> info

      println(s"Worker id : $id 的信息更新了!")

Master 实现心跳检测

现在,Master 能够及时的根据接收到的心跳消息来更新 workers 的状态了。然而这还没有完,Master 也需要一个计时器,来定时对 workers 做一个整体检查。

开启 Master 的定时检测功能

同样的,我们需要在主程序使用 start 字符串 ”激活“ Master 的那一刻,让它也打开定时检测功能,并周期性的检查内存中的 workers(它是前文提到的用于存储 WorkerInfo 的可变哈希表),及时将不活跃的 Worker 剔除。

我们将这个逻辑分为两部分:

  1. Master 在启动时主动激活自己的检测功能。
  2. 周期性的进行检测逻辑。

因此我们继续在 MessageProtocol 中补充剩下的两个消息结构体:

// master 在启动时会向自己发送此单例对象,来触发定期检查机制。
case object StartTimeOutWorker

// AKka 上下文通过计时器提醒 master 删除过期的 workers 时,发送此单例对象。
case object RemoveTimeOutWorker

我们在之前 SparkMaster 的 receive 方法的 start 逻辑中进行补充:

case "start" =>
      println(" Master 服务器启动成功!")
+      // 自启动 workers 的定时检查机制。
+      self ! StartTimeOutWorker

也就是说,在 Master 启动之后,它自己会给自己发送一个 StartTimeOutWorker 消息。我们继续在 receive 中补充对应的逻辑:

   case StartTimeOutWorker =>
      import context.dispatcher
      // 1. 0 millis 	-> 立刻启动此计时器
      // 2. 9000 millis -> 每过 9 秒进行一次检查。这个时间段要比 Worker 的心跳速率久一些。 
      // 3. self -> 让 ActorSystem 每过 9 秒提醒 Master 进行一次扫除。
      // 4. RemoveTimeOutWorker —> Master 通过它来分辨出这是一个"扫除"指令。
     context.system.scheduler.schedule(0 millis, 9000 millis, self, RemoveTimeOutWorker)

如何检测已经过时的 Worker ?

判断 Worker 是否过期其实并不难,因为我们记录着 lastHeartBeat 信息。我们只需要在进行检测时用当前的时间减去此 Worker 最近一次发送心跳消息的时间,并设定一个值 threshold :如果超过某个时长,则认为这个 Worker 的状态是 dead 。

如果我们用一个匿名函数来实现,它是这样的:传入一个 WorkerInfo,并计算 WorkerInfo 中 lastHeartBeat 和当前的时间差。设定一个时间差 threshold :如果超过这个值,则认为 Worker 失去了心跳。

val isTimedOut : WorkerInfo => Boolean =
(workInfo : WorkerInfo) => {
	val now : Long = System.currentTimeMillis()
	val threshold : Long = 6000L
	now - workerInfo.lastHeartBeat > threshold
}

我们在这里可以充分利用 Scala 的集合操作内容,将上述的这段逻辑传入 filter 方法让 Master 快速筛选出超时的 Worker,并再使用 foreach 将每一个超时的 Worker 都从 workers 哈希表中剔除。

 case RemoveTimeOutWorker =>

      val now: Long = System.currentTimeMillis()

      // 检查哪些 worker 心跳超时了,从 hashMap 当中删除。
      // 利用 Scala 的函数式编程来解决问题。
      // 1. 筛选出超时的 workers
      // 2. 将这些 workers 从 hashMap当中移除。
      //
      // 判断超时的逻辑:
      // (now - specified_id_worker's lastHeartBeat) > threshold 。
      // 由于网络传输存在一些少数的延迟(一般集群都在局域网内),这个 threshold 我们选取 6 秒。

      workers.values.filter(worker => now - worker.lastHeartBeat > 6000)
        .foreach(worker => workers.remove(worker.id))

      println("当前有" + workers.size + "个 workers 存活。")

至此,一个完整的基于 Akka 的节点心跳检测功能就实现了。

参数化配置

我们目前的 Host 和 Port 等信息目前都是写在源文件内的。我们更希望这些参数能够在运行时作为参数传入到主函数的 args 数组当中。

对于 SparkMaster ,我们可以做以下改动,这样就可以在启动 Master 程序之前灵活的指定 Host 和 Port 了。

//1.绑定本机地址和启动的端口号
val host = args(0)//绑定为本机地址
val port = args(1) //绑定本机启动的端口号

对于 SparkWorker,我们可以做如下改动,以便在启动 Worker 程序之前灵活地指定 Master 与本机的 Host / Post。

//1.绑定本机地址和启动的端口号
val workerHost = args(0) //绑定为本机地址
val workerPort = args(1) //绑定本机启动的端口号

//1.1 配置远端地址和启动的端口号
val masterHost = args(2)
val masterPort: Int = args(3).toInt

// .... 省略部分代码
// Worker Actor 名称在启动时指定。
 val sparkWorkerActorRef: ActorRef = workerSystem.actorOf(Props(new SparkWorker(masterHost, masterPort)), args(4))

利用 Assembly 插件进行项目打包

配置插件到项目中

为了将我们的 scala 代码打包成 jar 包以传输到各主机中运行,我们需要借助 assembly 插件来增强此项目中 sbt 的功能。 assembly 插件可以便捷地将项目连同一些外部依赖,比如 akka-remote , akka-actor 等依赖一同装载进很 "fat" 的 jar 包中。(由于此例子比较简单,因此我们先不用考虑网络上有关 assembly 插件的依赖冲突问题)

首先在项目根目录的 project 文件夹中新建一个 plugins.sbt 文件,将插件的声明写在此处。

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10")

注意,该插件的版本取决于项目的 SDK 版本。笔者的项目 SDK 版本是 2.12.4。(注意,你本机的 SDK 版本未必和项目的 SDK 版本一致。比如笔者本地的 SDK 版本仅为 2.11.8。)

Intellij IDEA 提供了 sbt shell 命令行,我们可以直接在此终端中输入 sbt 命令。我们首先输入 reload 刷新 sbt 的配置文件。如果插件安装成功,我们通过 plugins 命令可以检查到 sbtassembly.AssemblyPlugin

**如果插件安装不成功,则可以去 mavenRepo 仓库中浏览 SDK 对应哪些版本号,然后替换即可。**对于简单的项目,在安装插件成功之后,只需要在 sbt shell 命令行内输入 assembly 就可以打包了。

而对于我们的项目,还需要做一些小小的额外配置,因为我们的项目中针对 Master 和 Worker 有两个主函数(或者称函数入口),因此实际上我们需要打两个包,分别对应着不同的函数入口

为 Master 和 Worker 打包

我们继续在 build.sbt 配置文件中增加以下配置:

mainClass in assembly := Some("com.scala.SparkTest.master.SparkMaster")

顺便提醒,可以使用show discoveredMainClasses 查看具备主函数的类的列表。如果一个项目中有多个主函数入口,我们必须进行显示指定

为了分辨 jar 包的功能,我们还可以添加以下配置指定编译的 jar 包名称(非必须)

assemblyJarName in assembly := "spark-master.jar"

reload 你的 build.sbt 配置文件,然后使用 assembly 命令让插件进行打包工作,打好的包会存放在target-Scala-X.XX 目录下。

现在,我们获得了主函数入口为 SparkMaster 的 jar 包。我们将 build.sbt 中的配置的 mainClass 修改成

mainClass in assembly := Some("com.scala.SparkTest.worker.SparkWorker")

并再打一个包,同样可以获取一个主函数入口为 SparkWorker 的 jar 包。

现在,我们只需将这些包传输到不同的机器当中,令 Master 启动 spark-master.jar 包,令 Workers 启动 spark-worker.jar 包(包名可以通过 assemblyJarName 或者重命名的方式自行定义),我们就可以在实际环境中实现心跳检测了!

🌎参考链接