Akka Cluster 官方文档摘编

摘编了Akka Cluster官方文档中自己感兴趣的部分,主要包括Cluster及与之相关的Bootstrap、Management和Discovery,内容不完整,翻译也可能不是太准确,权且作为参考吧。

基本原理

Akka Cluster是一个管理Actor集群的框架。集群由若干结点组成,其中的结点主要呈以下变迁状态(PlantUML语法,懒得转成图再贴上来了,哈哈):

[*] -down-> Joining #lightgrey: Join
Joining -> Up: <leader action> 
Up -> Leaving: Leave
Leaving -> Exiting: <leader action>
Exiting -> Removed #lightgrey: <leader action>
Removed -down-> [*]
Joining -[dashed]down-> Unreachable: <Failure Detector>
Up -[dashed]down-> Unreachable: <Failure Detector>
Leaving -[dashed]down-> Unreachable: <Failure Detector>
Exiting -[dashed]down-> Unreachable: <Failure Detector>
Unreachable #lightblue -> Down: <leader action>
Down -> Removed: <leader action>

Akka Cluster的基本运作遵守Gossip协议,可以把结点理解为附加了Gossip协议的Actor:

  • 集群里有若干个种子结点,负责处理其他结点的入伙/撤伙/歇菜等事宜。首个种子结点环顾四周无人响应,遂自建集群。
  • 若干种子启动后,其中一个结点会被选举成为集群的Leader。
  • 每个欲要入伙的新结点,都会先反复尝试寻找种子结点,然后向最先回复自己的种子结点(不一定是Leader)发出Join命令征求大家同意其入伙,新成员状态变为Joining。若未收到任何回应,则新结点宕机。
  • 每个结点会向周围的同伴广播自己拥有的结点列表,直至所有节点都相互认识。
  • 当所有结点(或达到最小成员规模要求的数量)都知道有新成员加入后,新成员状态才会变为Up。当有成员请求Leave而变为Leaving状态后,Leader会先将它转为Exiting状态,然后等其他成员同意后再将其移出并变为Removed状态。
  • 当集结成员数量变化时,结点的可见集合(Seen Set)相应发生变化,当所有成员的可见集合统一后,集群收敛一次,并重新选举Leader。
  • 当某个结点意外成为Unreachable状态后,Leader会根据预先定义的策略(akka.cluster.downing-provider-class = "akka.cluster.sbr.SplitBrainResolverProvider")或手动将其标记为Down。
  • 有结点处于Unreachable状态时,集群不会收敛。
  • 所有被移出集群的结点或歇菜且未恢复的结点,都无法直接重新加入集群,而必须重新构造结点再申请加入。
  • 所有种子结点都停止后再重新加入将会开启一个新的集群,否则仍将会加入原来那个集群。
  • 集群内部使用心跳信号监测结点的生存状态,当所有结点退出集群后集群将宕机。

创建Cluster

在使用前,首先必须声明:akka.actor.provider = "cluster"启用Akka-Cluster。

种子结点

种子结点的声明和启动包括以下几种方式。其中首位的种子能否成功启动,决定了集群能否顺利建立,后序位置的种子只是确保在首位种子退出后替代其继续维持集群的运转,并可以接受首位种子重新加入集群。

  • 使用application.conf配置文件定义种子。
    akka.cluster.seed-nodes = [
      "akka://ClusterSystem@host1:2552",
      "akka://ClusterSystem@host2:2552"]
    
  • 使用Java环境变量在运行时传入种子定义。
    -Dakka.cluster.seed-nodes.0=akka://ClusterSystem@host1:2552
    -Dakka.cluster.seed-nodes.1=akka://ClusterSystem@host2:2552
    
  • 调用硬编码的种子
    val seedNodes: List[Address] = List(
      "akka://ClusterSystem@127.0.0.1:2551", 
      "akka://ClusterSystem@127.0.0.1:2552").map(AddressFromURIString.parse)
    Cluster(system).manager ! JoinSeedNodes(seedNodes)
    
  • 交由Cluster Bootstrap自动发现和启动种子。

结点的加入和撤出

结点的入伙、撤伙、歇菜(关闭)都需要借由manager完成,这个manager本身亦是一个actor。每个集群里的所有结点,都必须是属于同一个Actor System的。

val cluster = Cluster(system)
// 首位种子向集群加入自己,实现集群的创建
cluster.manager ! Join(cluster.seflMember.address)
// 加入其他结点
cluster.manager ! Join(node.address)

cluster.selfMember指向集群自身。其他结点离开集群时,可以用Leave优雅地通知一下集群,Coordinated Shutdown也会借此更妥善地通知各结点释放资源、关闭服务。当发生网络故障、JVM崩溃等意外时,由于一些结点已经不可达,此时便用Down取代Leave将这些不可达的结点关闭,再等Leader将其置为Removed状态。

以下是涉及超时设定的几个参数:

  • seed-node-timeout:尝试找到种子结点的重试间隔时间
  • retry-unsuccessful-join-after:尝试联系特定种子结点的重试间隔时间
  • shutdown-after-unsuccessful-join-seed-nodes:尝试联系特定种子结点未果后自行关闭的最长等待时间

角色

每个结点都可以拥有不同的角色以示功能区别,而一个角色也可被赋予多个结点。结点的角色,可以在配置文件的akka.cluster.roles一节中定义,也可通过环境变量传入,或者在代码中创建结点前使用ConfigFactory.parseString(s"akka.cluster.roles=[...]")传入(貌似没有编程实现的方式?)。

集群的状态

使用Cluster(system).state观察集群和结点的当前状态。

关闭整个集群

在修改消息协议而不必保持向下兼容性等极端情况下,可使用PrepareForFullClusterShutdown关闭整个集群。其中受影响的主要是已经在Up状态的结点,其他处于WeaklyUp/Joining/Leaving/Exiting等状态的结点将不会受到影响。

默认情况下,akka.cluster.allow-weakly-up-members被允许,所以处于Joining状态的结点将被Leader置于WeaklyUp状态,等待下一次集群收敛时提升为Up状态。

订阅集群成员事件

Akka Cluster通过cluster.subscriptions ! Subscribe(subscriber, classOf[MemberEvent])支持订阅与结点入伙、撤伙、歇菜相对应的事件MemberEvent:

  • ClusterEvent.MemberJoined - 新结点加入集群并转入Joining状态。
  • ClusterEvent.MemberUp - 新结点加入集群并转入Up状态。
  • ClusterEvent.MemberExited - 结点已经离开集群转入Exiting状态。
  • ClusterEvent.MemberRemoved - 结点已经完全从集群中删除。
  • ClusterEvent.UnreachableMember - 某个结点处于不可达状态。
  • ClusterEvent.ReachableMember - 某个曾经不可达的结点恢复到可达状态。
  • ClusterEvent.MemberPreparingForShutdown - 结点在为集群宕机做准备。
  • ClusterEvent.MemberReadyForShutdown - 结点已经做好集群宕机准备。

分布式数据

Akka Cluster使用分布式数据Distributed Data在集群内部实现安全共享数据。该模式使用Key-Value形式的键值对进行存储,而与数据交互则要借助一个Actor实现,所以所有的数据操作就构成了该Actor的API。其中,Key是带类型信息的唯一标识符,Value则是遵循无冲突复制数据值规范(Conflict Free Replicated Data Types)的数据。

分布式数据预建了计数器Count、集合Set、映射Map以及寄存器Register等类型(GCounterKey-GCounter,GSetKey[A]-GSet[A]),并为自定义类型提供了支持。

  • Counters: GCounter, PNCounter
  • Sets: GSet, ORSet
  • Maps: ORMap, ORMultiMap, LWWMap, PNCounterMap
  • Registers: LWWRegister, Flag

由于分布式数据内建了协调机制,能解决集群内多结点在数据读写方面的一致性问题,且该机制完全可控。不同结点所提交的更新,将与集群收敛机制无缝衔接,被简单的合并功能自动解决。但需要注意的是,和CQRS类似,该机制采取的最终一致性,所以不保证读取的数据必定是最新的。

Replicator

分布式数据作为Cluster的一个扩展,提供了Replicator(akka.cluster.ddata.typed.scaladsl.Replicator)作为承载数据复合体的Actor,可以向它发送Get/Update/Delete等Command进行数据读写,并触发Changed/Deleted等事件,返回诸如UpdateResponse这样的消息。

使用Replicator前,先使用implicit val node: SelfUniqueAddress = DistributedData(context.system).selfUniqueAddress获取隐式的结点地址用于将Replicator绑定到结点上,再在构造函数中借助DistributedData.withReplicatorMessageAdapter(),通过lambda传入的replicatorAdapter参数,通过ReplicatorAdapter.subscribe(key, Message.apply)在Replicator与消息之间建立订阅关系,其中的key由最外层的构造函数传入。官方示例中使用该Adapter,目的是将传入的消息转换成内嵌的Replicator能理解的命令。最后,由Replicator.Update()等完成实际的操作。其大致结构如下:

def apply(key: GCounterKey): Behavior[Command] = Behaviors.setup[Command] { context =>
  implicit val node: SelfUniqueAddress = DistributedData(context.system).selfUniqueAddress

  // adapter that turns the response messages from the replicator into our own protocol
  DistributedData.withReplicatorMessageAdapter[Command, GCounter] { replicatorAdapter =>
    // Subscribe to changes of the given `key`.
    replicatorAdapter.subscribe(key, InternalSubscribeResponse.apply)

    def updated(cachedValue: Int): Behavior[Command] = {
      Behaviors.receiveMessage[Command] {
        case Increment =>
          replicatorAdapter.askUpdate(
            askReplyTo => Replicator.Update(key, GCounter.empty, Replicator.WriteLocal, askReplyTo)(_ :+ 1),
            InternalUpdateResponse.apply)

          Behaviors.same

        //case GetValue ... ...
      }
    }

    update(cachedValue = 0)
  }
}

更详尽的内容,请参见Akka Cluster在线文档作为参考。

Akka Cluster Bootstrap

Akka Cluster Bootstrap是不同于静态定义集群种子的另一种建立Cluster的方式,它借助Akka Discovery来发现集群中的结点。这种方式,特别适合需要动态配置和发布集群种子的场景,比如Kubernetes或AWS。

Akka Cluster Bootstrap最低版本为2.6.14,它需要Akka Discovery发现结点,默认策略是基于DNS;需要Akka Management用于托管启动过程中的HTTP端点Endpoint。Management要先于Bootstrap启动,否则后者将失败并反馈于日志当中。

尽管akka-discovery也被akka-management-cluster-bootstrap透明引用,但为了保证编译引用的一致性,最佳作法是显式地定义akka-actor/akka-discovery/akka-cluster等Akka模块均为同一个版本。

Akka Cluster Bootstrap启动的两种方式

  • 置于application.conf配置文件:
    akka.extensions = ["akka.management.cluster.bootstrap.ClusterBootstrap"]

  • 编码:

    // Akka Management hosts the HTTP routes used by bootstrap
    AkkaManagement(system).start()
    
    // Starting the bootstrap process needs to be done explicitly
    ClusterBootstrap(system).start()
    

在使用Bootstrap的情况下,不能在配置文件中定义静态的种子结点,也不能由某个结点自主使用start()启动。

但是以下两个配置项是必需的,更多细节和其他配置可以在reference.conf中找到:

  • akka.management.cluster.bootstrap.contact-point-discovery.service-name:定义该集群在部署环境中的唯一名称,用于在服务发现中查找结点。如果未配置,则它将从ActorSystem名称中派生。
  • akka.management.cluster.bootstrap.contact-point-discovery.discovery-method:定义服务发现机制(此项由Akka Discovery提供选项)。如果未配置,则会返回整个ActorSystem默认的akka.discovery.method

基本流程

  • 每个节点都会暴露一个HTTP端点/bootstrap/seed-nodes。这是由akka-management-cluster-bootstrap提供的,并通过启动Akka management自动暴露。
  • 在启动过程中,每个节点都会反复查询Service Discovery以获得初始联系点,直到找到的联系点数量达到contact-point-discovery.required-contact-point-nr定义的数量。
  • 接着,每个节点开始尝试探测找到的联系点的/bootstrap/seed-nodes端点,看看是否已经形成了一个集群:
    • 如果有一个现有的集群,它就加入该集群,引导工作就此结束。
    • 如果不存在集群,每个节点会返回一个种子节点的空列表。在这种情况下,拥有联系点集合中最低地址的节点将形成一个新的集群,并开始宣传自己是种子节点。
  • 最后,其他节点在看到该结点的端口后,陆续加入其集群。

完整流程

  • 每个节点尝试使用Akka Discovery发现其 "邻居"。
    • 在当前还没有集群存在时,节点之间会进行一些初始协商,以安全地形成一个新的集群。
  • 节点开始探测被发现节点的联系点(这些联系点是HTTP Endpoint,由Bootstrap Management扩展通过Akka Management公开),尝试寻找已知的种子加入。
    • 在当前有集群存在时,种子节点会从探测发现的联系点返回。它将立即加入这样的种子节点,而忽略下面这些后续的初始引导步骤。
  • 在当前还没有集群存在,且在探测过程中也没有一个联系点返回任何种子节点时,将会按以下步骤创建一个新的集群。
    • contact-point-discovery.interval定义的间隔进行一次Service Discovery Lookup。
    • 为避免做出加入不稳定联系点的决定,所以会检测Service Discovery返回的联系点是否在contact-point-discovery.stable-margin配置的超时范围内。
    • 继续进行Service Discovery,直至发现的联系点数量达到contact-point-discovery.required-contact-point-nr配置的数目。
    • 通过成功的HTTP请求-响应,确认与所有被发现联系点的通信无误。
    • 已经取得相互联系的节点通过探测发现目前尚未存在有集群且没有种子节点,于是他们根据地址排序,选出其中地址最低的节点作为种子,建立一个全新的集群。
    • 其中,地址最低的节点也注意到上述决定,于是加入自己,顺利开启该集群。
    • 接着,其他节点根据此前已经建立的联系点关系,加入该种子节点建立的新集群。
    • 后续的节点继续根据探测结果加入该集群,当所有节点都加入后,该集群完成建设。

Akka Cluster Bootstrap 配置参数

######################################################
# Akka Cluster Bootstrap Config                      #
######################################################

akka.management {

  # registers bootstrap routes to be included in akka-management's http endpoint
  http.routes {
    cluster-bootstrap = "akka.management.cluster.bootstrap.ClusterBootstrap$"
  }

  cluster.bootstrap {

    # Cluster Bootstrap will always attempt to join an existing cluster if possible. However
    # if no contact point advertises any seed-nodes a new cluster will be formed by the
    # node with the lowest address as decided by [[LowestAddressJoinDecider]].
    # Setting `new-cluster-enabled=off` after an initial cluster has formed is recommended to prevent new clusters
    # forming during a network partition when nodes are redeployed or restarted.
    # Replaces `form-new-cluster`, if `form-new-cluster` is set it takes precedence over this
    # property for backward compatibility
    new-cluster-enabled = on

    # Configuration for the first phase of bootstraping, during which contact points are discovered
    # using the configured service discovery mechanism (e.g. DNS records).
    contact-point-discovery {

      # Define this name to be looked up in service discovery for "neighboring" nodes
      # If undefined, the name will be taken from the AKKA_CLUSTER_BOOTSTRAP_SERVICE_NAME
      # environment variable or extracted from the ActorSystem name
      service-name = "<service-name>"
      service-name = ${?AKKA_CLUSTER_BOOTSTRAP_SERVICE_NAME}

      # The portName passed to discovery. This should be set to the name of the port for Akka Management
      # If set to "", `None` is passed to the discovery mechanism and
      # ${akka.management.http.port} is assumed.
      port-name = ""
      port-name= ${?AKKA_CLUSTER_BOOTSTRAP_PORT_NAME}

      # The protocol passed to discovery.
      # If set to "" None is passed.
      protocol = "tcp"

      # Added as suffix to the service-name to build the effective-service name used in the contact-point service lookups
      # If undefined, nothing will be appended to the service-name.
      #
      # Examples, set this to:
      # "default.svc.cluster.local" or "my-namespace.svc.cluster.local" for kubernetes clusters.
      service-namespace = "<service-namespace>"

      # The effective service name is the exact string that will be used to perform service discovery.
      #
      # Set this value to a specific string to override the default behaviour of building the effective name by
      # concatenating the `service-name` with the optional `service-namespace` (e.g. "name.default").
      effective-name = "<effective-name>"

      # Config path of discovery method to be used to locate the initial contact points.
      # It must be a fully qualified config path to the discovery's config section.
      #
      # By setting this to `akka.discovery` we ride on the configuration mechanisms that akka-discovery has,
      # and reuse what is configured for it. You can set it explicitly to something else here, if you want to
      # use a different discovery mechanism for the bootstrap than for the rest of the application.
      discovery-method = akka.discovery

      # Amount of time for which a discovery observation must remain "stable"
      # (i.e. not change list of discovered contact-points) before a join decision can be made.
      # This is done to decrease the likelyhood of performing decisions on fluctuating observations.
      #
      # This timeout represents a tradeoff between safety and quickness of forming a new cluster.
      stable-margin = 5 seconds

      # Interval at which service discovery will be polled in search for new contact-points
      #
      # Note that actual timing of lookups will be the following:
      # - perform initial lookup; interval is this base interval
      # - await response within resolve-timeout
      #   (this can be larger than interval, which means interval effectively is resolveTimeout + interval,
      #    this has been specifically made so, to not hit discovery services with requests while the lookup is being serviced)
      #   - if failure happens apply backoff to interval (the backoff growth is exponential)
      # - if no failure happened, and we receive a resolved list of services, schedule another lookup in interval time
      #   - if previously failures happened during discovery, a successful lookup resets the interval to `interval` again
      # = repeat until stable-margin is reached
      interval = 1 second

      # Adds "noise" to vary the intervals between retries slightly (0.2 means 20% of base value).
      # This is important in order to avoid the various nodes performing lookups in the same interval,
      # potentially causing a thundering heard effect. Usually there is no need to tweak this parameter.
      exponential-backoff-random-factor = 0.2

      # Maximum interval to which the exponential backoff is allowed to grow
      exponential-backoff-max = 15 seconds

      # The smallest number of contact points that need to be discovered before the bootstrap process can start.
      # For optimal safety during cluster formation, you may want to set these value to the number of initial
      # nodes that you know will participate in the cluster (e.g. the value of `spec.replicas` as set in your kubernetes config.
      required-contact-point-nr = 2

      # Timeout for getting a reply from the service-discovery subsystem
      resolve-timeout = 3 seconds

      # Does a succcessful response have to be received by all contact points.
      # Used by the LowestAddressJoinDecider
      # Can be set to false in environments where old contact points may still be in service discovery
      # or when using local discovery and cluster formation is desired without starting all the nodes
      # Required-contact-point-nr still needs to be met
      contact-with-all-contact-points = true
    }

    # Configured how we communicate with the contact point once it is discovered
    contact-point {

      # If no port is discovered along with the host/ip of a contact point this port will be used as fallback
      # Also, when no port-name is used and multiple results are returned for a given service with at least one
      # port defined, this port is used to disambiguate. When set to <fallback-port>, defaults to the value of
      # akka.management.http.port
      fallback-port = "<fallback-port>"

      # by default when no port-name is set only the contact points that contain the fallback-port
      # are used for probing. This makes the scenario where each akka node has multiple ports
      # returned from service discovery (e.g. management, remoting, front-end HTTP) work without
      # having to configure a port-name. If instead service discovery will return only akka management
      # ports without specifying a port-name, e.g. management has dynamic ports and its own service
      # name, then set this to false to stop the results being filterted
      filter-on-fallback-port = true

      # If some discovered seed node will keep failing to connect for specified period of time,
      # it will initiate rediscovery again instead of keep trying.
      probing-failure-timeout = 3 seconds

      # Interval at which contact points should be polled
      # the effective interval used is this value plus the same value multiplied by the jitter value
      probe-interval = 1 second

      # Max amount of jitter to be added on retries
      probe-interval-jitter = 0.2
    }

    join-decider {
      # Implementation of JoinDecider.
      # It must extend akka.management.cluster.bootstrap.JoinDecider and
      # have public constructor with ActorSystem and ClusterBootstrapSettings
      # parameters.
      class = "akka.management.cluster.bootstrap.LowestAddressJoinDecider"
    }
  }
}

Join机制的优先顺序

Akka Cluster允许节点使用多种不同的方法加入集群,每种方法的优先级都有严格规定,具体如下(但不建议混用,以避免出现意料之外的混乱)。

  • 如果在application.conf中显式地定义了akka.cluster.seed-nodes,那么这些种子节点将会被优先加入集群。此时即使调用了start()或使用了自动启动集群的配置,那么bootstrap也不会执行,但会记录一个警告。
  • 如果在bootstrap完成之前调用了明确的cluster.joincluster.joinSeedNodes,则该Join动作将优先于bootstrap完成。
  • Cluster Bootstrap机制需要一些时间来完成,但最终会发出一个joinSeednodes

在动态发布环境下建立集群

在Docker/Kubernetes这样的动态发布环境下(IP/Port/Path都是动态的)要尽可能安全地建立一个集群,需要注意以下一些要点。

首先,即使是通过加载持久化数据重塑种子结点,准备加入集群操作时也存在竞争,可能发生冲突,所以竞争是必然,集群的建立相应可能会出现极低概率的冲突。

其次,Akka bootstrap所采取的解决方案,是在没有把握的情况下尽可能推迟集群的建立,以避免发生竞争。其内置的保护措施是定义显式的超时,以保证在Service Discovery过程中只连接稳定的节点。

再者,在向现有集群添加新节点时,Bootstrap并不会仅仅依赖于Service Discovery机制的完全一致性。这也是非常可取的,因为在规模不断扩大的情况下,服务负载也相应增加,此时维护服务的完全一致性将愈发困难。但要注意,Akka集群成员协议必须是强一致性的,因为它是决定集群是由什么组成的真理之源,没有任何外部系统可以对此提供更为可靠的信息(因为它们都可能是过时的)。这就是联系点探测机制存在的原因——即使发现只能返回部分或甚至不同的节点集,探测仍能让节点加入所有已知正确的节点。这一切要归功于Akka集群的成员协议和Gossip协议的工作方式。综上,即使是因为负载的原因导致DNS系统不完全一致,Bootstrap机制也能很好地将节点添加到系统中。

最后,如果在初始化Bootstrap期间,DNS查找的响应存在不一致,那么节点也将延迟形成集群,因为它们期望DNS查找的结果是一致的,而这一点恰好能由stable-margin配置的超时时长作为判断条件。

此外,对于Bootstrap而言,任何一个节点对其邻居的可见性能始终保持一致是至关重要的,否则多个节点就可能导致多个自加入而形成的多个集群。

有关部署的其他考虑

初始化部署

如果可能的话,Bootstrap总是会试图加入一个现有的集群。然而,如果没有其他联系点公布任何种子节点,一个新的集群将由JoinDecider决定的节点形成。该结点默认是按地址排序后被挑选出那个地址最低的。

我们提供了一个设置akka.management.cluster.bootstrap.new-cluster-enabled,它可以禁止新集群的形成,而只允许节点加入现有的集群。

  • 在初始化部署时使用默认的akka.management.cluster.bootstrap.new-cluster-enabled=on
  • 在完成初始部署之后,建议设置akka.management.cluster.bootstrap.new-cluster-enabled=off,确保初始集群一旦形成就不再部署新的集群。

这可以在网络分区变化时,确保集群的重启或重新部署能有更大的安全性。否则当所有节点因网络原因重启时,一些孤立的节点形成一个脱离于原集群的新集群。

为了初始启动的完全安全,建议将contact-point-discovery.required-contact-point-nr设置为集群初始启动时的确切节点数。例如,如果最初用4个节点启动集群,后来扩展到更多的节点,一定要把这个设置设为4,以提升初始加入的安全性。这样即使发现机制并不稳定,也可以避免集群的稳定性受到过多干扰。

滚动式更新

优雅地关机

Akka Cluster可以使用一个停机提供者来处理硬件故障,比如下面将要讨论的Lightbend’s split brain resolver。然而,这不应该被用于定期的滚动重新部署。因为诸如ClusterSingletons和ClusterSharding等功能可以在确定一个节点已经关闭而不是崩溃的情况下,在新的节点上安全地重启角色。

在默认设置下,节点能优雅地从集群离开,因为该功能属于协调关机的一部分,这一切只需要确保节点收到的信号是SIGTERM而不是SIGKILL。诸如Kubernetes等环境会这样做,其中最重要的是必须确保当JVM被脚本包裹时仍能转发出该信号。在收到SIGTERM信号后,协调关机将对自己执行Cluster(system).leave操作,从而该成员的状态将改变为Exiting,同时允许任何分片被优雅地关闭。如果这是最老的节点,ClusterSingletons将被迁移。最后,该节点将从Akka Cluster成员中移除。

一次性重新部署的节点数量

Akka bootstrap定义有一个配置项stable-period,它确保了Service Discovery会返回一组稳定的接触点。在做滚动更新时,最好等待一个节点(或一组节点)完成加入集群后,再执行添加或删除其他节点的步骤。

Cluster Singletons

ClusterSingletons在集群中最老的节点上运行。为了避免Singleton在每个节点部署期间被迁移,建议从最新的节点开始滚动重新部署,这样ClusterSingletons就只会被移动一次。而这也是Kubernetes部署的默认行为。因为Cluster Sharding在内部也使用Singleton,所以即使不直接使用它,这样的策略也非常重要。

Split Brain与非优雅地关机

由于节点可能崩溃,会导致集群成员变得无法到达,这是一个非常棘手的问题,因为此时无法区分是网络原因还是节点本身的故障所致。Akka Cluster为此提供了一个自动化的方式纠正这个问题,前提是需要确保启用Split Brain Resolver。该模块提供了一些策略,可以确保集群在网络分区和节点故障期间继续运作。

Bootstrap在不同环境下的使用

由于Bootstrap总是与Management一同使用,因此具体请参见集成测试的示例:Akka Management Integration Tests,其中包括了以下几种环境下的测试案例:

  • local - uses config service discovery to form a cluster validates health checks
  • kubernetes-api - uses the Kubernetes API in minikube to test bootstrap
  • kubernetes-api-dns - uses DNS service discovery in minikube to test bootstrap
  • kubernetes-api-java - uses the Kubernetes API in minikube to test bootstrap from a Java/Maven project

本地启用Bootstrap

在Local条件下,首先配置3个节点的Endpoint,集群的服务名称为local-cluster

akka.discovery {
  config.services = {
    local-cluster = {
      endpoints = [
        {
          host = "127.0.0.1"
          port = 8558
        },
        {
          host = "127.0.0.2"
          port = 8558
        },
        {
          host = "127.0.0.3"
          port = 8558
        }
      ]
    }
  }
}

然后配置集群的Service Discovery选项,其中service-name就是上面akka.discovery.config.services.[cluster-name]中设置的local-cluster,而且discovery-method必须设置为config,以从配置文件中读取节点信息。

akka.management {
  cluster.bootstrap {
    contact-point-discovery {
      service-name = "local-cluster"
      discovery-method = config
    }
  }
}

最后配合如下代码,派生的3个Node对象就可以通过Bootstrap构建起一个集群,注意传入参数nr正好对应节点的IP地址。

object Node1 extends App {
  new Main(1)
}

object Node2 extends App {
  new Main(2)
}

object Node3 extends App {
  new Main(3)
}

class Main(nr: Int) {

  val config: Config = ConfigFactory.parseString(s"""
      akka.remote.artery.canonical.hostname = "127.0.0.$nr"
      akka.management.http.hostname = "127.0.0.$nr"
    """).withFallback(ConfigFactory.load())
  val system = ActorSystem("local-cluster", config)

  AkkaManagement(system).start()
  ClusterBootstrap(system).start()

  Cluster(system).registerOnMemberUp({
    system.log.info("Cluster is up!")
  })
}

Kubernetes + DNS

使用DNS发现,首先需要配置Akka的发现方法为akka-dns,绑定端口但暂时不要绑定IP(等待Kubernetes分配):

akka.management {
  cluster.bootstrap {
    contact-point-discovery {
      discovery-method = akka-dns
    }
  }

  http {
    port = 8558
    bind-hostname = "0.0.0.0"
  }
}

其次,是要在Kubernetes配置里设置service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"publishNotReadyAddresses: true,以确保Bootstrap能在Pod完全备妥前就看到它。

apiVersion: v1
kind: Service
metadata:
  labels:
    app: akka-bootstrap-demo
  annotations:
    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
  name: "integration-test-kubernetes-dns-internal"
spec:
  ports:
  - name: management
    port: 8558
    protocol: TCP
    targetPort: 8558
  - name: remoting
    port: 2552
    protocol: TCP
    targetPort: 2552
  selector:
    app: akka-bootstrap-demo
  clusterIP: None
  publishNotReadyAddresses: true

Kubernetes API

使用Kubernetes API自带的Pod标签提供的对等网络发现功能,也可以实现Akka Cluster通过Bootstrap自启动。这具体地是通过Kubernetes的角色功能实现的。

首先,创建包裹节点的Kubernetes Pod。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: akka-bootstrap-demo
  name: akka-bootstrap-demo
spec:
  replicas: 3
  selector:
    matchLabels:
     app: akka-bootstrap-demo
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate

  template:
    metadata:
      labels:
        app: akka-bootstrap-demo
        actorSystemName: akka-bootstrap-demo
    spec:
      containers:
      - name: akka-bootstrap-demo
        image: integration-test-kubernetes-api:1.3.3.7
        # Remove for a real project, the image is picked up locally for the integratio test
        imagePullPolicy: Never
        livenessProbe:
          httpGet:
            path: /alive
            port: management
        readinessProbe:
          httpGet:
            path: /ready
            port: management
        ports:
        # akka-management bootstrap
        - containerPort: 8558
          protocol: TCP
          # when contact-point-discovery.port-name is set for cluster bootstrap,
          # the management port must be named accordingly:
          # name: management
        env:
        # The Kubernetes API discovery will use this service name to look for
        # nodes with this value in the 'app' label.
        # This can be customized with the 'pod-label-selector' setting.
        - name: AKKA_CLUSTER_BOOTSTRAP_SERVICE_NAME
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: "metadata.labels['app']"

接着,设定Role和RoleBinding,确保创建的Pod能够访问Kubernetes服务API。

#
# Create a role, `pod-reader`, that can list pods and
# bind the default service account in the namespace
# that the binding is deployed to to that role.
#

kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: pod-reader
rules:
- apiGroups: [""] # "" indicates the core API group
  resources: ["pods"]
  verbs: ["get", "watch", "list"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: read-pods
subjects:
  # Uses the default service account.
  # Consider creating a dedicated service account to run your
  # Akka Cluster services and binding the role to that one.
- kind: ServiceAccount
  name: default
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

最后,在Akka配置文件中提供相应设置。

akka.discovery {
  kubernetes-api {
    # in fact, this is already the default:
    pod-label-selector = "app=%s"
  }
}

akka.management {
  cluster.bootstrap {
    contact-point-discovery {
      discovery-method = kubernetes-api
    }
  }
}

需要注意的是,由于缺少DNS的帮助,所以需要指定查找的路径。默认情况下,会从/var/run/secrets/kubernetes.io/serviceaccount/namespace的账户信息中读取,但也可以通过在akka.discovery.kubernetes-api.pod-namespace中指定。

Akka Management

Akka Management为管理Akka Cluster提供了一个基于HTTP的终端接口(这又是Cluster之外又一个重要扩展)。

使用Akka Management需要导入相应的包,然后显式地启动它。

import akka.management.scaladsl.AkkaManagement
AkkaManagement(system).start()

相应的最基本配置如下,本地IP地址可用InetAddress.getLocalHost.getHostAddress获取:

akka.management.http.hostname = "127.0.0.1"
akka.management.http.port = 8558

如果运行在NAT或者Docker容器里,就需要在application.conf中配置相应的主机名和端口号:

# Get hostname from environmental variable HOST
akka.management.http.hostname = ${HOST}

# Use port 8558 by default, but use environment variable PORT_8558 if it is defined
akka.management.http.port = 8558
akka.management.http.port = ${?PORT_8558}

# Bind to 0.0.0.0:8558 'internally': 
akka.management.http.bind-hostname = 0.0.0.0
akka.management.http.bind-port = 8558

需要的话还可以指定HTTP API的路径:

akka.management.http.base-path = "myClusterName"

要配置Management的路由可读写,需要设定akka.management.http.route-providers-read-only为false,即可以将默认只读的Management路由变为可读写。

要激活TSL/SSL(HTTPS),需要先提供一个SSLContext,然后在Management启动时传入:

val management = AkkaManagement(system)
val httpsServer: HttpsConnectionContext = ConnectionContext.httpsServer(sslContext)
val started = management.start(_.withHttpsConnectionContext(httpsServer))

激活身份认证类似HTTPS,需要在启动Management时传入一个认证对象:

def myUserPassAuthenticator(credentials: Credentials): Future[Option[String]] =
  credentials match {
    case p @ Credentials.Provided(id) =>
      Future {
        // potentially
        if (p.verify("p4ssw0rd")) Some(id)
        else None
      }
    case _ => Future.successful(None)
  }
// ...
val management = AkkaManagement(system)
management.start(_.withAuth(myUserPassAuthenticator))

Akka Cluster Sharding

Cluster Sharding是一种集群分片技术,可以物理位置无关地访问集群中带有ID标识的节点,即使它们可能被动态迁移到不同的物理结点。Sharding非常适合于表达领域驱动设计DDD中的实体Entity这一概念,它们的实例化对象通常有各自持久化的状态,但又消费着类似的资源。如果这样的节点比较少,则可能Cluster Singleton更适合相应的场景。

用一个Sharding对应一个AggregateRoot范畴,该Sharding内的节点则对应同类型但不同标识的Entity实例对象。每个Entity对象在整个集群内有其唯一性,并只能活动在唯一的一个节点上。结点之间的通信无需了解彼此位置,改由ShardRegion负责协调。但要注意,Sharding不会在处于WeaklyUp状态的节点上激活。

Sharding的初始化由init[Message, Entity](entity: Entity[Messsage, Entity])方法负责完成,它在该实体类的每个节点被创建时被自动调用。而通常的作法,又是结合Entity类的工厂方法构造子实现动态创建,需要传入的包括typeKeycreateBehavior,其中的M是该Entity能处理的消息类型,EntityContext是包含有entityId信息的上下文:

def apply[M](typeKey: EntityTypeKey[M])(createBehavior: (EntityContext[M]) => Behavior[M]): Entity[M, ShardingEnvelope[M]]

然后就能得到一个最简单的例子:

val sharding = ClusterSharding(system)
val TypeKey = EntityTypeKey[Counter.Command]("Counter")

val shardRegion: ActorRef[ShardingEnvelope[Counter.Command]] =
  sharding.init(Entity(TypeKey)(createBehavior = entityContext => Counter(entityContext.entityId))).withRole("backend"))

object Counter {
  sealed trait Command
  case object Increment extends Command
  final case class GetValue(replyTo: ActorRef[Int]) extends Command

  def apply(entityId: String): Behavior[Command] = {
    def updated(value: Int): Behavior[Command] = {
      Behaviors.receiveMessage[Command] {
        case Increment =>
          updated(value + 1)
        case GetValue(replyTo) =>
          replyTo ! value
          Behaviors.same
      }
    }

    updated(0)

  }
}

之后就可以借助实体对象的唯一标识PersistenceId,通过sharding.entityRefFor(entityKey, entityId, dataCenter)获取该节点并各其发送消息或完成其他操作,或者通过封装的SharingEnvelope传入消息:

// With an EntityRef
val counterOne: EntityRef[Counter.Command] = sharding.entityRefFor(TypeKey, "counter-1")
counterOne ! Counter.Increment

// Entity id is specified via an `ShardingEnvelope`
shardRegion ! ShardingEnvelope("counter-1", Counter.Increment)

在实际项目中,通常将Sharding与EventSourcedBehavior一同使用,以实现事件驱动的Event Sourcing模式:

class HelloWorldService(system: ActorSystem[_]) {
  private val sharding = ClusterSharding(system)

  // registration at startup
  sharding.init(Entity(typeKey = HelloWorld.TypeKey) { entityContext =>
    HelloWorld(entityContext.entityId, PersistenceId(entityContext.entityTypeKey.name, entityContext.entityId))
  })

  // service API
  def greet(worldId: String, whom: String): Future[Int] = {
    val entityRef = sharding.entityRefFor(HelloWorld.TypeKey, worldId)
    val greeting = entityRef ? HelloWorld.Greet(whom)
    greeting.map(_.numberOfPeople)
  }
}

object HelloWorld {
  sealed trait Command extends CborSerializable

  val TypeKey: EntityTypeKey[Command] = EntityTypeKey[Command]("HelloWorld")

  def apply(entityId: String, persistenceId: PersistenceId): Behavior[Command] = {
    Behaviors.setup { context =>
      context.log.info("Starting HelloWorld {}", entityId)
      EventSourcedBehavior(persistenceId, emptyState = KnownPeople(Set.empty), commandHandler, eventHandler)
    }
  }

}

PersistenceId是对应该Entity类型的唯一实例Id,由于不同Entity的实例对象可能拥有同样的PersistenceId,所以通常加上EntityTypeKey.name作为限定。
在注册了Entity在Sharding中的创建方法后,sharding.entityRefFor(typeKey, entityId)将成为访问该Entity的一个有效入口或者代理。
entityId仍需要在创建Entity时传入。

Shard分配

每个分片Shard包括一组Entity,它们都拥有相同的分片标识符以及唯一的实体标识符。默认情况下,分片标识符是实体标识符经过hashCode运算后的绝对值再模分片数量的值,而分片数量可以在配置文件中设定。根据经验,分片数应比计划部署的最大集群节点数量大10倍,当然这也不必完全精确。分片数少于节点数,将导致某些节点没有可托管的分片。反之分片过多,将导致分片管理效率降低,集群将因为重新平衡负载、协调分片的消息路由等原因而增加开销和延迟。

akka.cluster.sharding {
  # Number of shards used by the default HashCodeMessageExtractor
  # when no other message extractor is defined. This value must be
  # the same for all nodes in the cluster and that is verified by
  # configuration check when joining. Changing the value requires
  # stopping all nodes in the cluster.
  number-of-shards = 1000
}

将节点添加到集群时,现有节点上的分片将重新平衡到新节点。采取策略LeastHardAllocationStrategy,将从此前拥有分片最多的ShardRegions中选择分片进行重新平衡。这些分片将被分配给此前拥有分片数量最少的ShardRegion。我们可以通过配置,限制每轮重新平衡的最低分片数量,以使其进度相对减缓。因为同时重新平衡过多的分片可能会增加系统的额外负载。在Akka 2.6.10中包含了一种新的再平衡算法,它可以在几轮再平衡(通常为1或2轮)之后达到最佳平衡。而为了向下兼容,默认情况下新算法不会被启用。可以通过设置rebalance-absolute-limit限定每轮平衡时最大分区数来启用新算法,例如:

akka.cluster.sharding.least-shard-allocation-strategy.rebalance-absolute-limit = 20

同时,可能还需要调整akka.cluster.shading.least-shard-allocation-strategy.rebalance-relative-limit。该配置项是设置每轮重新平衡的最大分区数所占的相对比例(小于1.0的小数值),二者可以搭配使用。

钝化 Passivation

一言以概之,钝化就是在某个Entity的状态已经被持久化或长时间未再收到消息时,集群将其暂时移除以减少内存占用,并将此后发来的消息全部缓存,等待该结点新的分身被加载后再继续处理之。其基本使用方法是在配置中定义钝化使用的策略,再在集群初始化时明确地用sharding.init(Entity(...).withStopMessage(TellMyself))声明钝化所用的消息TellMyself,这样当钝化或再平衡发生时,Entity就会收到该消息,从而可以从容地完成资源释放清理等工作。

如同GC机制一样,Akka的集群钝化基本不需要人工介入,只需要提前设定其使用的策略即可。可用的策略包括:

  • 特定空闲时长内未收到消息则钝化(默认策略)
  • 活动Entity达到一定数量时钝化
  • 自定义策略:最近最少使用/最近最多使用/最低使用频率

策略可以通过许可窗口(Admission Window)或许可过滤器(Admission Filter)实现组合使用。


Akka Cluster的官方文档还包括有其他一些技术细节,因暂时未作深入研究,故此未再整理,有需要时再考虑吧。有关Akka更基础的内容,请参阅《Akka官方文档之随手记》。

posted @ 2022-10-24 20:43  没头脑的老毕  阅读(390)  评论(0编辑  收藏  举报