Erlang の supervisor ビヘイビアを学ぶ
Erlang/OTP の supervisor ビヘイビアについて学びたいと思います。
スーパーバイザーの原則
スーパーバイザーは、子プロセスの起動、停止、子プロセスの監視に対して責任を持ちます。スーパーバイザーの基本的な考え方は、必要に応じて子プロセスを再起動することで、子プロセスを起動した状態に保つことです。
開始および監視する子プロセスは、子プロセスの仕様 (Child Specification) のリストで設定します。子プロセスは、このリストで指定された順序で開始され、逆の順序で終了します。
サンプルコード
gen_server を起動するスーパーバイザーのコールバックモジュールのサンプルコードです。
-module(sample_sup). -behaviour(supervisor). -export([start_link/0]). -export([init/1]). start_link() -> supervisor:start_link(sample_sup, []). init(_Args) -> SupFlags = #{strategy => one_for_one, intensity => 1, period => 5}, ChildSpecs = [ #{id => name_a, start => {value_server, start_link, [{name_a, value_a}]}, restart => permanent, shutdown => 3000, type => worker, modules => [name_server]}, #{id => name_b, start => {value_server, start_link, [{name_b, value_b}]}, restart => permanent, shutdown => 3000, type => worker, modules => [name_server]}], {ok, {SupFlags, ChildSpecs}}.
init/1
の返り値の SupFlags
変数は、スーパーバイザーのフラグを表します。
また、init/1
の返り値の ChildSpecs
変数は、子プロセスの仕様のリストです。
ここでの子プロセスの仕様は、 gen_server で実装した value_server というモジュールのワーカーを起動します。
value_server のコードは下記に示します。
-module(value_server). -behaviour(gen_server). -export([start_link/1, value/1, pid/1]). -export([init/1, handle_call/3, handle_cast/2]). start_link({Name, Value}) -> gen_server:start_link({local, Name}, ?MODULE, Value, []). value(Name) -> gen_server:call(Name, value). pid(Name) -> gen_server:call(Name, pid). init(Value) -> {ok, Value}. handle_call(value, _From, Value) -> {reply, Value, Value}; handle_call(pid, _From, Value) -> {reply, self(), Value}. handle_cast(_Request, Value) -> {noreply, Value}.
value_server にリクエストを送信すると、 value_server が保持している値をレスポンスとして返すだけのサーバーです。
value_server は起動時に value_server:start_link({Name, Value})
で、 gen_server の名前 Name
とその gen_server のプロセスが保持する値 Value
を受け取ります。
起動時に指定した gen_server の名前 Name
を引数に、インターフェース関数 value/1
を呼び出すとその名前の gen_server が保持する値 Value
を返します。
プロセスを識別するために、gen_server の名前 Name
を引数に、インターフェース関数 pid/1
を呼び出すとその gen_server のプロセスIDを返す関数も用意します。
使用例
上記のサンプルコードの sample_sup.erl と value_server.erl の使用例を下記に示します。
> c(sample_sup). {ok,sample_sup} > c(value_server). {ok,value_server} > sample_sup:start_link(). {ok,<0.91.0>} > value_server:value(name_a). value_a > value_server:value(name_b). value_b > PidA = value_server:pid(name_a). <0.92.0> > PidB = value_server:pid(name_b). <0.93.0> > exit(PidA, kill_pid_a). =SUPERVISOR REPORT==== 7-Nov-2019::13:13:24.047059 === supervisor: {<0.91.0>,sample_sup} errorContext: child_terminated reason: kill_pid_a offender: [{pid,<0.92.0>}, {id,name_a}, {mfargs,{value_server,start_link,[{name_a,value_a}]}}, {restart_type,permanent}, {shutdown,3000}, {child_type,worker}] true > value_server:value(name_a). value_a > value_server:value(name_b). value_b > value_server:pid(name_a). <0.99.0> > value_server:pid(name_b). <0.93.0>
このスーパーバイザー (sample_sup) は 2つの gen_server を子プロセスを持ちます。
使用例では、スーパーバイザーを起動すると2つの gen_server が起動するので、インタフェース関数 value/1
と pid/1
で値を返せるかどうか、その gen_server のプロセスIDがいくつかを確認しています。
その後、exit/2
関数で name_a という名前を持つ gen_server を殺します。
殺しますが、スーパーバイザーの監視により即座に新しいプロセスで name_a を持つ gen_server が再起動されます。
name_a を殺した後、value/1
関数で name_a という gen_server にリクエストを送信してレスポンスが返ってくるか確認しています。
また、name_a の gen_server のプロセスIDを確認し、以前のプロセスIDとは異なっていること(新しいプロセスであること)を確認しています。
スーパーバイザーのフラグ
スーパーバイザーのフラグのためのタイプの定義は下記のものです。
sup_flags() = #{strategy => strategy(), % optional intensity => non_neg_integer(), % optional period => pos_integer()} % optional strategy() = one_for_all | one_for_one | rest_for_one | simple_one_for_one
strategy
は再起動戦略を指定します。
intensity
と period
は最大再起動強度を指定します。
再起動戦略
再起動戦略は、コールバック関数 init
によって返されるスーパーバイザーのフラグの strategy
キーによって指定されます。
SupFlags = #{strategy => Strategy, ...}
スーパーバイザーのフラグはマップ形式です。このマップで、strategy
はオプションです。指定されていない場合は、one_for_one
がデフォルトになります。
one_for_one 戦略
子プロセスが終了すると、そのプロセスのみが再起動されます。
1対1 の監視です。もし、子プロセスが死んだら、その子プロセスだけ再起動します。
one_for_all 戦略
子プロセスが終了すると、他のすべての子プロセスも終了し、最初に終了した子プロセスを含むすべての子プロセスが再起動されます。
rest_for_one 戦略
子プロセスが終了すると、残りの子プロセスは終了します。 その後、終了した子プロセスと残りの子プロセスが再起動されます。ここで、残りのプロセスとは、プロセスの起動順で、終了したプロセスよりも後だった子プロセスのことです。別の言い方をすると、ChildSpecs
変数の子プロセスの仕様のリストで、終了したプロセスの Spec の記述よりもリストの後ろだった子プロセスのことです。
simple_one_for_one 戦略
この戦略のスーパーバイザーは、単純化された one_for_one スーパーバイザーであり、子プロセスの仕様を1つだけ持ちます。この戦略では、子プロセスを動的にスーパーバイザーに追加します。
simple_one_for_one スーパーバイザーは多くの子プロセスを持つことができるため、それらをすべて非同期的にシャットダウンします。つまり、子プロセスはクリーンアップを並行して実行するため、停止する順序は定義されていません。
最大再起動強度
スーパーバイザーには、特定の時間間隔で発生する可能性がある再起動の回数を制限するためのメカニズムが組み込まれています。 これは、コールバック関数init
によって返されるスーパーバイザーのフラグマップの2つのキーの intensity
と period
によって指定されます。
SupFlags = #{intensity => MaxR, period => MaxT, ...}
直近の MaxT
秒間に MaxR
を超える数の再起動が発生した場合、スーパーバイザーはすべての子プロセスを終了し、スーパーバイザー自身を終了します。 その場合、スーパーバイザー自体の終了理由は shutdown
にされます。
スーパーバイザーが終了すると、次に上位のスーパーバイザーが同様のアクションを実行します。 最大再起動強度によって、終了したスーパーバイザーを再起動するか、上位のスーパーバイザー自身を終了します。
この再起動メカニズムの目的は、同じ理由でプロセスが繰り返し停止と再起動するだけの状況に陥るのを防ぐことです。
スーパーバイザーのフラグであるマップのキーの intensity
と period
はオプションです。 指定されていない場合、デフォルトでそれぞれ 1 と 5 が設定されます。つまり、直近の 5 秒間に 1 回の再起動が発生した場合、そのスーパーバイザーは子プロセスを終了して、自分自身も終了します。
intensity
と period
の調整
デフォルト値は、5 秒ごとに 1 回の再起動です。 これは、深い監視階層がある場合でも、ほとんどのシステムで安全になるように選択されましたが、特定のユースケースに合わせて設定を調整する必要があるでしょう。
まず、 intensity
によって、許容される再起動の大規模なバーストが決まります。 たとえば、再起動に成功した場合は、同じ秒以内であっても、最大 5 回や 10 回の試行のバーストを受け入れることができます。
次に、クラッシュが引き続き発生するが、スーパーバイザーがあきらめるほど頻繁ではない場合、持続的な故障率を考慮する必要があります。 intensity
を10に設定し、period
を 1 に設定すると、スーパーバイザーは子プロセスが 1 秒間に最大 10 回再起動し続けることを許可し、誰かが手動で介入するまでログをクラッシュレポートで満たします。
したがって、スーパーバイザーがその期間に対して再起動する回数の割合で継続して、再起動することを受け入れることができるように、period
を十分に長く設定する必要があります。 たとえば、intensity
の値を 5 、period
を 30 秒に設定した場合、6 秒ごとに最大で 1 回の再起動が可能になります。つまり、ログがすぐにいっぱいにならず、 また、障害を観察して修正を適用する機会があります。
これらの選択は、問題のあるドメインに大きく依存します。 組み込みシステムなど、リアルタイムの監視と問題を迅速に修正する機能がない場合は、スーパーバイザーがあきらめて次のレベルにエスカレートして エラーは自動的にクリアする前に、1 分あたり最大 1 回の再起動を受け入れることができます。 一方、高い失敗率でも試行し続けることがより重要な場合は、1 秒あたり 1〜2 回の再起動という試行率が必要になる場合があります。
よくある間違い
バーストレートを考慮することを忘れないでください。
intensity
を 1 に設定し、period
を 6 に設定すると、5/30 や 10/60 と同じ失敗率が得られますが、すぐに 2 回目の再起動試行が行われるわけではありません。これはおそらくあなたが望んでいたものではありません。言い換えると、6 秒間に 1回の再起動を許すのと、30秒間に 5 回の再起動を許すということは違うということです。後者では最初の 6 秒間に 5 回再起動しても、その後の24秒間に再起動が発生しなければ、許容されます。バーストを許容する場合は、期間を非常に高い値に設定しないでください。
intensity
を 5 に設定し、period
を 3600(1時間)に設定すると、スーパーバイザーは 5 回の再起動の短いバーストを許可しますが、ほぼ1時間後に別の単一の再起動が発生すると放棄します。 おそらく、これらのクラッシュを個別のインシデントと見なしたいので、period
を 5 分または 10 分に設定する方が合理的です。アプリケーションに複数レベルの監視ツリーの階層がある場合、すべてのレベルで同じ再起動強度の値を単純に設定しないでください。 トップレベルのスーパーバイザーがアプリケーションを放棄して終了する場合の再起動の合計回数は、障害が発生した子プロセスより上のすべてのスーパーバイザーの再起動強度の値の積になることに注意してください。
たとえば、トップレベルで 10 回の再起動が許可され、次のレベルでも 10 回の再起動が許可されている場合、そのレベル以下でクラッシュした子プロセスは 100 回再起動されます。 おそらく、この再起動回数は過剰です。このような場合、トップレベルのスーパーバイザーに対して例えば最大 3 回の再起動を許可するように抑えた方が適切な場合があります。
子プロセスの仕様
子プロセスの仕様の型定義は次のとおりです。
child_spec() = #{id => child_id(), % mandatory start => mfargs(), % mandatory restart => restart(), % optional shutdown => shutdown(), % optional type => worker(), % optional modules => modules()} % optional child_id() = term() mfargs() = {M :: module(), F :: atom(), A :: [term()]} modules() = [module()] | dynamic restart() = permanent | transient | temporary shutdown() = brutal_kill | timeout() worker() = worker | supervisor
id
は、スーパーバイザーによって内部で子プロセスの仕様を識別するために使用されます。id
キーは必須です。 この識別子は時々 "name" と呼ばれていることに注意してください。 可能な限り、"identifier" または "id" という用語が使用されるようになりましたが、後方互換性を維持するために、エラーメッセージなどで "name" の出現箇所を見つけることができます。start
は、子プロセスを開始するために使用される関数呼び出しを定義します。start
キーは必須です。これはapply(M, F, A)
として使用される module-function-arguments タプルです。Mはモジュール名、Fは関数名、Aは引数のことです。 次のいずれかの呼び出しになります(またはその結果になります)。supervisor:start_link
gen_server:start_link
gen_statem:start_link
gen_event:start_link
restart
は、終了した子プロセスをいつ再起動するかを定義します。restart
キーはオプションです。指定しない場合、デフォルト値のpermanent
が使用されます。permanent
が設定された子プロセスは常に再起動されます。temporary
が設定された子プロセスは再起動されません(スーパーバイザーの再起動戦略が rest_for_one または one_for_all であり、兄弟プロセスが死亡したために一時プロセスが終了した場合でも、再起動されない)transient
が設定された子プロセスは、異常終了した場合のみ再起動します。つまり、終了理由がnormal
、shutdown
または{shutdown, Term}
以外の場合は再起動されます。
shutdown
は子プロセスの終了方法を定義します。shutdown
キーはオプションです。shutdown
が指定されてない場合、子のタイプがワーカーの場合、デフォルト値は 5000 が使用されます。 子のタイプがスーパーバイザーの場合、デフォルト値はinfinity
が使用されます。brutal_kill
は、子プロセスがexit(Child, kill)
を使用して無条件に終了されることを意味します。- 整数のタイムアウト値は、スーパーバイザーが
exit(Child, shutdown)
を呼び出して子プロセスに終了するように指示し、終了信号が返されるのを待つことを意味します。 指定された時間内に終了シグナルが受信されない場合、子プロセスはexit(Child, kill)
を使用して無条件に終了されます。 - 子プロセスが別のスーパーバイザーである場合、サブツリーにシャットダウンするのに十分な時間を与えるために、その子プロセスの
shutdown
にinfinity
を設定する必要があります。 子プロセスがワーカーの場合も、infinity
を設定することができます。 以下の警告を参照してください。- 警告1 スーパーバイザーの子プロセスのシャットダウン時間に
infinity
以外に設定すると、その子プロセスが自身の子プロセス(孫プロセス)たちとリンクを解除しようとするが、子プロセスが殺される前に孫プロセスの終了処理を失敗する競合を発生させる可能性があります。 - 警告2 子プロセスがワーカーの場合、シャットダウン時間を
infinity
に設定するときは注意してください。 この状況では、監視ツリーの終了は子プロセスに依存するためです。 安全な方法で実装する必要があり、そのクリーンアップの処理は常に呼び出し元に返ってくる必要があります。
- 警告1 スーパーバイザーの子プロセスのシャットダウン時間に
type
は、子プロセスがスーパーバイザー (supervisor
) かワーカー(worker
)かを指定します。type
キーはオプションです。 指定されていない場合、デフォルト値worker
が使用されます。modules
は、子プロセスが supervisor, gen_server, gen_statem であるときは一つの要素を持つリスト[Module]
になります。Module はコールバックモジュールの名前です。子プロセスが gen_event である場合、modules
の値はdynamic
でなければなりません。
この情報はアップグレードとダウングレード中にリリースハンドラーによって使われます。リリースハンドリングを参照してください。
modules
キーはオプションです。指定されない場合、start
キーに指定される{M, F, A}
のM
の値が使われます。
スーパーバイザーの起動
最初に示したサンプルコードのスーパーバイザーの例では、sample_sup:start_link()
を呼び出すことによって、スーパーバイザーが起動されます。
start_link() -> supervisor:start_link(sample_sup, []).
sample_sup:start_link
は、supervisor:start_link/2
関数を呼び出します。この関数は、新しいプロセスであるスーパーバイザーを生成してリンクします。
- 最初の引数
sample_sup
は、コールバックモジュールの名前、つまりinit
コールバック関数が配置されているモジュールです。 - 2番目の引数
[]
は、コールバック関数init
にそのまま渡されるterm()
です。最初に示したスーパーバイザーのサンプルコードでは、init
関数は入力データを必要とせず、引数を無視しています。
このケースでは、スーパーバイザーは名前を登録されていません。名前の代わりに pid を使用する必要があります。スーパーバイザーの名前を指定するには、supervisor:start_link({local, Name}, Module, Args)
もしくは supervisor:start_link({global, Name}, Module, Args)
を呼び出します。
新しいスーパーバイザープロセスは、コールバック関数 sample_sup:init([])
を呼び出します。この init
関数は {ok, {SupFlags, ChildSpecs}}
を返さなければなりません。
init(_Args) -> SupFlags = #{strategy => one_for_one, intensity => 1, period => 5}, ChildSpecs = [ #{id => name_a, start => {value_server, start_link, [{name_a, value_a}]}, restart => permanent, shutdown => 3000, type => worker, modules => [name_server]}, #{id => name_b, start => {value_server, start_link, [{name_b, value_b}]}, restart => permanent, shutdown => 3000, type => worker, modules => [name_server]} ], {ok, {SupFlags, ChildSpecs}}.
その後、スーパーバイザーは、指定された子プロセスの仕様に従ってすべての子プロセスを開始します。上の例では、2つの子プロセス name_a
と name_b
が開始されます。
supervisor:start_link
関数は同期です。すべての子プロセスが開始されるまで処理は戻りません。
子プロセスの追加
静的な監視ツリーに加えて、次の呼び出しで既存のスーパーバイザーに動的に子プロセスを追加できます。
supervisor:start_child(Sup, ChildSpec)
Sup
はスーパーバイザーの pid か名前です。ChildSpec
は子プロセスの仕様 child_spec()
を渡します。
start_child/2
を使用して追加された子プロセスは、他の子プロセスと同じように動作しますが、重要な例外があります。スーパーバイザーが死んで再起動された場合、スーパーバイザーに動的に追加されたすべての子プロセスは失われます。
子プロセスの停止
子プロセスは、静的または動的にかかわらず、シャットダウン仕様に従って停止できます。
supervisor:terminate_child(Sup, Id)
停止した子プロセスの仕様は、次の呼び出しで削除されます。
supervisor:delete_child(Sup, Id)
ここで、Sup
はスーパーバイザーの pid または名前です。Id
は、子プロセスの仕様の id
キーに関連付けられた値です。
動的に追加された子プロセスと同様に、スーパーバイザー自体が再起動すると、静的な子プロセスを削除した効果は失われます。