4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Akka実践バイブルをゆっくり読み解く 第4章耐障害性

Last updated at Posted at 2018-02-03

Akka実践バイブルをゆっくり読み解く企画の第4章です。

第4章 耐障害性

いよいよ待ちに待ったlet it crashの回です。

障害とは

システムは基本的には「利用可能」な状態であるべきである。しかし、実際のシステムでは何らか想定外の自体が発生することがある。ネットワークが不通になったり、ファイルIOに失敗したりするのがその典型。
そのような場合に備えて、システムは回復の手段を講じることとなる。例えば、Javaであれば(その他、多くの言語でも)Try〜Catchによって回復の手段を定義する。そして、Catchできない例外が発生した場合は諦めてシステムを終了することとなる。

障害からの回復

障害が発生した場合、最悪の選択肢としては『システムのダウン』というアクションが取られることとなるが、可能な限りシステムの処理を継続するための障害回避戦略がAkkaの機能として提供されている。

let it crash

障害が発生した箇所について、うまく復旧できるならそれに越したことはないが、それが必ずしも実現できるとは限らない。頑張ったって復旧できない場合だってある。そういうことも想定して、アクターシステムは「let it crach(クラッシュするならさせておけ)」という原則の元に構築されている。クラッシュさせておけというくらいなので、ビジネスロジックをTry〜Catchで括って障害回復を試みるようなことはその思想に含まれていない。例外はしっかり例外として対処する。

そのため、アクターでは通常処理(正常系)のフローの中に障害回復(異常系)のコードを紛れ込ませるのではなく、通常処理のフローと障害回復のフローを分けて提供する。障害が発生した場合に例外をCatchするのではなく、クラッシュさせて障害回復のフローに流すことによって障害回復を試みる。障害回復方法についてはスーパーバイザーが決定することとなるが、スーパーバイザーが回復方法を決定するまでは対応保留の状態となる。
Akkaでは、アクター(子アクター)を生み出したアクター(親アクター)がスーパーバイザーとなるため、子アクターでクラッシュが発生した場合は、その回復方法の決定は親アクターが行うこととなる。

let it crash 〜crash後のアクション〜

クラッシュしたアクターをどのように扱うべきかは、その原因に基づいてスーパーバイザーが決定することとなる。アクターの「回復方法」には以下の4パターンが存在する。(当然「原因」と「回復方法」の紐付け方はシステム特性によって異なる)

  • 再起動(Restart)
  • 再開(Resume)
  • 停止(Stop)
  • エスカレート(Escalate)

再起動

アクターが再生成され、その後引き続き処理をする。

再開

再生成するのではなく、先ほどクラッシュしたアクターがそのまま処理を継続する。クラッシュは無視される。

停止

アクターを停止させ、メッセージの処理も行わない。

エスカレート

スーパーバイザーでは回復方法を判断できない(障害回復用のフローに回復方法が定義されていない)場合は、更に上位のスーパーバイザーに判断を委ねるためにエスカレーションする。

let it crash 〜障害回復方法〜

前述の障害回復方法について、let it crachアプローチによる各障害回復方法の利点は以下のとおり。

障害回避戦略 利点
障害の封じ込めまたは隔離 該当アクターをアクターシステムから除外することができる
構造 該当アクターを、他のアクターへの影響無くアクターインスタンスを置き換えることができる。
冗長化 障害の発生した系のアクターを、障害の発生していない系のアクターに置き換えることができる。
置換 アクターはいつでもPropsから再生成できる。障害のあるアクターインスタンスを新しいアクターインスタンスに置き換えることができる。
リブート 上述の「再起動」での回復が可能。
コンポーネントのライフサイクル アクターは「開始」「停止」「再起動」が可能。ライフサイクルについては後述。
保留 クラッシュ後、スーパーバイザーが回復方法を決定するまで、対応は保留される。
関心の分離 通常処理のフローと障害回復のフローが互いに完全に独立している。

アクターのライフサイクル

クラッシュ時の対応のうち、「再開」と「停止」はある程度わかりやすい。
「再開」は『問題を無視する』し、「停止」は文字通りその場で処理を停止する。
「エスカレート」はその場で結論を出していないだけで、最終的には「再開」「停止」「再起動」いずれかの対応が選択される。
ただ、「再起動」についてはちょっとややこしい。

開始

  • 生成はactorOfメソッドによって行われる。
  • preStartフックが用意されており、アクターの開始時にpreStart内で初期状態の設定を行うことができる。

停止

  • 停止はstopメソッドの呼び出し、またはPoisonPillメッセージの送信によって行われる。
  • postStopフックが用意されており、アクターの停止直前にpostStop内でリソースの解放や状態の保存を行うことができる。
  • アクターが停止するとTerminated状態となり、新しいメッセージを処理できなくなる。
  • 停止したアクターへのActorRefdeadLetterActorRefへと置き換えられる。

再起動

  • 再起動すると、アクターのインスタンスが新しいインスタンスに置き換えられることとなる。
  • preRestartフックとpostRestartフックが用意されており、preRestartpostStopと同じタイミングで、postRestartpreStartと同じタイミングで呼び出される。使い方には少し注意が必要。
// reasonは、アクターからスローされた例外
// messageは、エラー発生時にアクターが処理しようとしていたメッセージ
override def preRestart(reason: Throwable, message: Option[Any]): Unit = {

  /*
    preRestart処理
   */

  // 必ずsuperクラスのpreRestartを呼出す必要がある
  super.preRestart(reason, message)
}

// reasonは、preRestart同様にアクターからスローされた例外
override def postRestart(reason: Throwable): Unit = {

  /*
    postRestart処理
   */

  // 必ずsuperクラスのpostRestartを呼出す必要がある
  super.postRestart(reason)
}
  • preRestartでは新しいアクターのインスタンスに処理を引き継ぐために、メッセージを自身のActorRefに送信することで処理を継続することができる。もちろん、DBのような外部への書き出しによって状態を保存し、再開することも可能。

let it crashとライフサイクル

ライフサイクル内に置かれている各種フックによるアクターの初期化/クリーンアップ、およびメッセージの再送信を使うことで、アクターがクラッシュしても状態を復元して処理を継続する仕組みを備えている。このライフサイクルの仕組みを備えているからこそクラッシュ時にTry〜Catchによって異常事態を素早く正すことを考えなくてもシステムの稼働を継続することができる。

ライフサイクルの監視

アクターの「停止」はwatchunwatchによって監視することができる。監視しているアクターが停止した場合、Terminatedメッセージが送信されてくる。

class Watcher(targetActor: ActorRef) extends Actor {

  // targetActorアクターの監視を行う
  context.watch(targetActor)

  def receive =
    // 「停止」時はTerminatedメッセージを受け取る
    case Terminated(actorRef) =>
      /*
        停止時処理
       */

    /*
      再起動の場合はTerminatedメッセージは発行されない
      これにより、Terminatedメッセージを受け取った場合は、アクターが完全に停止したとみなすことができる
     */
  }
}

アクターの監視は、対象アクターのActorRefさえ取得することができれば、親子関係が無くても行うことができる。
ただし、子アクターのライフサイクルを制御するための監督(スーパービジョン)は親アクターであるスーパーバイザーにしか行うことはできない。

監督(スーパービジョン)

ここで焦点が当たるのは、/user配下に位置するアクターとなる。/userにはactorOfで生成されたアクターが階層上に所属することとなる。スーパービジョンの話に入る前に、書籍にサラッと出てくる/userとはナンゾや、と。

トップレベルのスーパーバイザー

ここに書いてある。
範囲を選択_032.png

/user: ガーディアンアクター

業務処理で作成するアクターはここに所属する。/userガーディアンが停止すると、システム内のすべてのアクターがシャットダウンされることとなる。

/system:システムガーディアン

ユーザーガーディアンを監視するためのガーディアン。アクターたちが適切に開始/停止されることを目的として存在する。

/:ルートガーディアン

アクターたちが自分でライフサイクルの決定ができずにエスカレートを繰り返した結果、ルートガーディアンまで行き着いた場合に全アクターを停止することができる。

その他

/tempとかdeadLettersなんてのもあるらしい。

スーパーバイザーヒエラルキ

アクターは依存関係に応じて階層上に構築する方が望ましい。仮に、アクターたちが全てフラットな位置に並んでしまうと、それらの共通の親であるスーパーバイザーがアクターのライフサイクルの決定をするのが困難になる。一方で、アクターたちが階層状になっていると、各アクターが自分の子アクターのみを監視し、想定された例外であれば自身で子アクターのライフサクルを決定し、自身では判断のつかない例外の場合は上位のアクターに判断を委ねる、という役割分担が容易となる。
また、上位のアクターがそのスーパーバイザーによって停止される場合は、芋づる式にその配下に所属するアクターたちも停止されることとなる。つまり、クラッシュに対する対処方法がわからず上位にどんどんエスカレートした結果、スーパーバイザーが停止の判断を下すと、その配下に所属するアクターたちは連鎖的に停止することとなる。

戦略

例外発生時にアクターをどのように制御するかの方針を『戦略』と呼ぶ。この戦略は独自に定義して適用することができる。デフォルトでは以下のような戦略が適用される。

FaultHandling.scala
final val defaultDecider: Decider = {
  /* 例外のパターンマッチ */
  case _: ActorInitializationException  Stop
  case _: ActorKilledException          Stop
  case _: DeathPactException            Stop
  // 上記3パターン以外の場合は再起動を選択
  case _: Exception                     Restart
}

また、戦略を適用する対象を選択することもできる。

戦略パターン 概要
OneForOneStrategy 問題が発生したアクターを狙い撃ちで戦略を適用する
AllForOneStrategy 1つの子アクターで問題が発生したら、道連れで他のアクターにも戦略を適用する

戦略と適用するパターンを決めた上で、以下のように戦略を適用することができる。

class SuperVisor extends Actor {
  
  // OneForOneStrategyを適用
  override def supervisorStrategy = OneForOneStrategy() {
    // IOException時は再起動
    case _: IOException => Restart
    // その他の例外はエスカレート
    case _: Exception => Escalate
  }

  // アクターの生成
  var targetActor = context.actorOf(Props, Name)

  // アクターの監視を開始
  context.watch(targetActor)

  // メッセージの処理
  def receive = {
    case MessagePattern =>
      /*
       正常処理
       */
    case Terminated(_) => 
      /*
       Terminatedを受け取った場合の処理
       */
  }
}

PoisonPillについて少しだけ

書籍内でPoisonPillというものが出てくる。自然と登場したけど、こいつは何者?コードを見てみても、これだけでは何がなんだか。

Actor.scala
abstract class PoisonPill extends AutoReceivedMessage with PossiblyHarmful with DeadLetterSuppression

/**
 * A message all Actors will understand, that when processed will terminate the Actor permanently.
 */
@SerialVersionUID(1L)
case object PoisonPill extends PoisonPill {
  /**
   * Java API: get the singleton instance
   */
  def getInstance = this
}

調べているとstackoverflowにこんなのが見つかった。

Both stop and PoisonPill will terminate the actor and stop the message queue. They will cause the actor to cease processing messages, send a stop call to all its children, wait for them to terminate, then call its postStop hook. All further messages are sent to the dead letters mailbox.

The difference is in which messages get processed before this sequence starts. In the case of the stop call, the message currently being processed is completed first, with all others discarded. When sending a PoisonPill, this is simply another message in the queue, so the sequence will start when the PoisonPill is received. All messages that are ahead of it in the queue will be processed first.

両方共アクターを停止するんだけれど、Stopは現在のメッセージを処理した後、その他のメッセージは捨て去ってしまうことになる。一方、PoisonPillはキュー内に溜まっている処理を全て実行してくれる。アクターが壊れかけているのに、『キューに溜まったものだけは処理したい』って要求が出てくるのは、メッセージ内容をDBやログに退避させたいっていう時なのかな?

まとめ

アクターを階層状に構築して監視することで、障害に強いシステムを構築することができる。『障害に強い』とは、障害が発生しにくいことを意味するのではなく、障害が発生した場合でも適切に対応して素早く復帰する、または被害を最小限に抑える行動を取ることを意味する。『クラッシュするならさせておけ』とは言ったものの、無責任にクラッシュさせておくのではなく、ちゃんとケツはもってあげることでアクターではそれを実現することができている。
let it crashすばらしい!

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?