無題の備忘録

IT技術について調べたことや学んだこと、試したこと記録するブログです。Erlang、ネットワーク、 セキュリティ、Linux関係のものが多いです。

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/1pid/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 は再起動戦略を指定します。

intensityperiod は最大再起動強度を指定します。

再起動戦略

再起動戦略は、コールバック関数 init によって返されるスーパーバイザーのフラグの strategy キーによって指定されます。

SupFlags = #{strategy => Strategy, ...}

スーパーバイザーのフラグはマップ形式です。このマップで、strategy はオプションです。指定されていない場合は、one_for_one がデフォルトになります。

one_for_one 戦略

子プロセスが終了すると、そのプロセスのみが再起動されます。

f:id:storkibis:20191104151842p:plain
one_for_one 戦略

1対1 の監視です。もし、子プロセスが死んだら、その子プロセスだけ再起動します。

one_for_all 戦略

子プロセスが終了すると、他のすべての子プロセスも終了し、最初に終了した子プロセスを含むすべての子プロセスが再起動されます。

f:id:storkibis:20191104151923p:plain
one_for_all 戦略

rest_for_one 戦略

子プロセスが終了すると、残りの子プロセスは終了します。 その後、終了した子プロセスと残りの子プロセスが再起動されます。ここで、残りのプロセスとは、プロセスの起動順で、終了したプロセスよりも後だった子プロセスのことです。別の言い方をすると、ChildSpecs数の子プロセスの仕様のリストで、終了したプロセスの Spec の記述よりもリストの後ろだった子プロセスのことです。

simple_one_for_one 戦略

この戦略のスーパーバイザーは、単純化された one_for_one スーパーバイザーであり、子プロセスの仕様を1つだけ持ちます。この戦略では、子プロセスを動的にスーパーバイザーに追加します。

simple_one_for_one スーパーバイザーは多くの子プロセスを持つことができるため、それらをすべて非同期的にシャットダウンします。つまり、子プロセスはクリーンアップを並行して実行するため、停止する順序は定義されていません。

最大再起動強度

スーパーバイザーには、特定の時間間隔で発生する可能性がある再起動の回数を制限するためのメカニズムが組み込まれています。 これは、コールバック関数initによって返されるスーパーバイザーのフラグマップの2つのキーの intensityperiod によって指定されます。

SupFlags = #{intensity => MaxR, period => MaxT, ...}

直近の MaxT 秒間に MaxR を超える数の再起動が発生した場合、スーパーバイザーはすべての子プロセスを終了し、スーパーバイザー自身を終了します。 その場合、スーパーバイザー自体の終了理由は shutdown にされます。

スーパーバイザーが終了すると、次に上位のスーパーバイザーが同様のアクションを実行します。 最大再起動強度によって、終了したスーパーバイザーを再起動するか、上位のスーパーバイザー自身を終了します。

この再起動メカニズムの目的は、同じ理由でプロセスが繰り返し停止と再起動するだけの状況に陥るのを防ぐことです。

スーパーバイザーのフラグであるマップのキーの intensityperiod はオプションです。 指定されていない場合、デフォルトでそれぞれ 1 と 5 が設定されます。つまり、直近の 5 秒間に 1 回の再起動が発生した場合、そのスーパーバイザーは子プロセスを終了して、自分自身も終了します。

intensityperiod の調整

デフォルト値は、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 が設定された子プロセスは、異常終了した場合のみ再起動します。つまり、終了理由が normalshutdown または {shutdown, Term} 以外の場合は再起動されます。
  • shutdown は子プロセスの終了方法を定義します。shutdownキーはオプションです。shutdown が指定されてない場合、子のタイプがワーカーの場合、デフォルト値は 5000 が使用されます。 子のタイプがスーパーバイザーの場合、デフォルト値は infinity が使用されます。
    • brutal_kill は、子プロセスが exit(Child, kill) を使用して無条件に終了されることを意味します。
    • 整数のタイムアウト値は、スーパーバイザーが exit(Child, shutdown) を呼び出して子プロセスに終了するように指示し、終了信号が返されるのを待つことを意味します。 指定された時間内に終了シグナルが受信されない場合、子プロセスは exit(Child, kill) を使用して無条件に終了されます。
    • 子プロセスが別のスーパーバイザーである場合、サブツリーにシャットダウンするのに十分な時間を与えるために、その子プロセスの shutdowninfinity を設定する必要があります。 子プロセスがワーカーの場合も、infinity を設定することができます。 以下の警告を参照してください。
      • 警告1 スーパーバイザーの子プロセスのシャットダウン時間に infinity 以外に設定すると、その子プロセスが自身の子プロセス(孫プロセス)たちとリンクを解除しようとするが、子プロセスが殺される前に孫プロセスの終了処理を失敗する競合を発生させる可能性があります。
      • 警告2 子プロセスがワーカーの場合、シャットダウン時間を infinity に設定するときは注意してください。 この状況では、監視ツリーの終了は子プロセスに依存するためです。 安全な方法で実装する必要があり、そのクリーンアップの処理は常に呼び出し元に返ってくる必要があります。
  • 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_aname_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 キーに関連付けられた値です。

動的に追加された子プロセスと同様に、スーパーバイザー自体が再起動すると、静的な子プロセスを削除した効果は失われます。