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
状態となり、新しいメッセージを処理できなくなる。 - 停止したアクターへの
ActorRef
はdeadLetterActorRef
へと置き換えられる。
再起動
- 再起動すると、アクターのインスタンスが新しいインスタンスに置き換えられることとなる。
-
preRestart
フックとpostRestart
フックが用意されており、preRestart
はpostStop
と同じタイミングで、postRestart
はpreStart
と同じタイミングで呼び出される。使い方には少し注意が必要。
// 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
によって異常事態を素早く正すことを考えなくてもシステムの稼働を継続することができる。
ライフサイクルの監視
アクターの「停止」はwatch
とunwatch
によって監視することができる。監視しているアクターが停止した場合、Terminated
メッセージが送信されてくる。
class Watcher(targetActor: ActorRef) extends Actor {
// targetActorアクターの監視を行う
context.watch(targetActor)
def receive =
// 「停止」時はTerminatedメッセージを受け取る
case Terminated(actorRef) =>
/*
停止時処理
*/
/*
再起動の場合はTerminatedメッセージは発行されない
これにより、Terminatedメッセージを受け取った場合は、アクターが完全に停止したとみなすことができる
*/
}
}
アクターの監視は、対象アクターのActorRef
さえ取得することができれば、親子関係が無くても行うことができる。
ただし、子アクターのライフサイクルを制御するための監督(スーパービジョン)は親アクターであるスーパーバイザーにしか行うことはできない。
監督(スーパービジョン)
ここで焦点が当たるのは、/user
配下に位置するアクターとなる。/user
にはactorOf
で生成されたアクターが階層上に所属することとなる。スーパービジョンの話に入る前に、書籍にサラッと出てくる/user
とはナンゾや、と。
トップレベルのスーパーバイザー
ここに書いてある。
/user: ガーディアンアクター
業務処理で作成するアクターはここに所属する。/user
ガーディアンが停止すると、システム内のすべてのアクターがシャットダウンされることとなる。
/system:システムガーディアン
ユーザーガーディアンを監視するためのガーディアン。アクターたちが適切に開始/停止されることを目的として存在する。
/:ルートガーディアン
アクターたちが自分でライフサイクルの決定ができずにエスカレートを繰り返した結果、ルートガーディアンまで行き着いた場合に全アクターを停止することができる。
その他
/temp
とかdeadLetters
なんてのもあるらしい。
スーパーバイザーヒエラルキ
アクターは依存関係に応じて階層上に構築する方が望ましい。仮に、アクターたちが全てフラットな位置に並んでしまうと、それらの共通の親であるスーパーバイザーがアクターのライフサイクルの決定をするのが困難になる。一方で、アクターたちが階層状になっていると、各アクターが自分の子アクターのみを監視し、想定された例外であれば自身で子アクターのライフサクルを決定し、自身では判断のつかない例外の場合は上位のアクターに判断を委ねる、という役割分担が容易となる。
また、上位のアクターがそのスーパーバイザーによって停止される場合は、芋づる式にその配下に所属するアクターたちも停止されることとなる。つまり、クラッシュに対する対処方法がわからず上位にどんどんエスカレートした結果、スーパーバイザーが停止の判断を下すと、その配下に所属するアクターたちは連鎖的に停止することとなる。
戦略
例外発生時にアクターをどのように制御するかの方針を『戦略』と呼ぶ。この戦略は独自に定義して適用することができる。デフォルトでは以下のような戦略が適用される。
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
というものが出てくる。自然と登場したけど、こいつは何者?コードを見てみても、これだけでは何がなんだか。
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
andPoisonPill
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 itspostStop
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 aPoisonPill
, this is simply another message in the queue, so the sequence will start when thePoisonPill
is received. All messages that are ahead of it in the queue will be processed first.
両方共アクターを停止するんだけれど、Stop
は現在のメッセージを処理した後、その他のメッセージは捨て去ってしまうことになる。一方、PoisonPill
はキュー内に溜まっている処理を全て実行してくれる。アクターが壊れかけているのに、『キューに溜まったものだけは処理したい』って要求が出てくるのは、メッセージ内容をDBやログに退避させたいっていう時なのかな?
まとめ
アクターを階層状に構築して監視することで、障害に強いシステムを構築することができる。『障害に強い』とは、障害が発生しにくいことを意味するのではなく、障害が発生した場合でも適切に対応して素早く復帰する、または被害を最小限に抑える行動を取ることを意味する。『クラッシュするならさせておけ』とは言ったものの、無責任にクラッシュさせておくのではなく、ちゃんとケツはもってあげることでアクターではそれを実現することができている。
let it crashすばらしい!