Supervisorツリー

この記事は英語の原文を日本語に翻訳したものです。原文: https://adoptingerlang.org/docs/development/supervision_trees/

翻訳元: adoptingerlang/adoptingerlang 2025-12-18(コミット 899008f

Erlangが他のあらゆるプログラミング言語と決定的に違うのは、並行性ではなく耐障害性においてです。 言語のほぼすべての要素が耐障害性を考えて設計されており、supervisorはその設計の中核をなす部分の一つです。 この章では、supervisorツリーの基本、supervisorの中身、そして自分のシステムでsupervisorツリーをどう構造化するかを扱います。 読み終える頃には、システムに必要な状態のほとんどをセットアップし管理できるようになっているはずです。

基本

Erlangは、いわば二層構造の言語です。 最も低いレベルには関数型のサブセットがあります。 そこにあるのはデータ構造と関数、そしてそれらを変更・変換するためのパターンマッチングだけです。 データはイミュータブルでローカルであり、副作用はほとんど生じないか、そもそも不要です。 より高いレベルには並行のサブセットがあり、そこでは長命な状態が管理され、プロセス間でやり取りされます。

関数型のサブセットは素直で、イントロダクションで紹介したどの教材を使っても習得しやすいものです。 データ構造を受け取り、それを変更して新しいデータ構造を返す、それだけです。 プログラムの変換はすべて、あるデータに関数を適用して新しいデータを得るパイプラインとして扱われます。 これは、その上に積み上げていける確かな基礎です。

関数型言語が難しくなるのは、副作用を扱わなければならない場面です。 プログラムの設定のようなものを、スタック全体にどう伝えればよいのでしょうか。 それはどこに保存し、どこで変更すればよいのでしょうか。 ネットワークストリームのように本質的にミュータブルでステートフルなものを、イミュータブルでステートレスなアプリケーションにどう組み込めばよいのでしょうか。

多くのプログラミング言語では、これはかなり非公式でその場しのぎな方法で処理されます。 例えばオブジェクト指向言語では、ドメインの境界に応じて副作用の置き場所を決めることが多く、その際に永続性の無視ヘキサゴナルアーキテクチャのような原則を尊重しようとします。

最終的にできあがるのは、うまくいけば中核に純粋なドメイン固有のエンティティ群があり、外縁にやり取りのための仕組み群があり、その間にエンティティ同士のあらゆる活動を調整し包み込む役割を持ついくつかの層がある、階層化されたシステムです。

オブジェクト指向設計でよく推奨されるヘキサゴナルアーキテクチャ

このようなツリーでは、すべてのプロセスが深さ優先で左から右へと起動されます。 つまり、キャッシュプロセスとETSテーブルを起動する前に、データベースのsupervisor構造(とそのすべてのワーカー)がまず起動されていなければなりません。 同様に、HTTPサーバーとそのハンドラを起動する前に、ビジネスドメインのサブツリー全体が作られていなければならず、これはキャッシュテーブルに依存しているため、このテーブル(とデータベースのワーカー)も同様に準備が整っている必要があります。

このsupervisor構造はリリースの起動順序を定めますが、同時にシャットダウンの順序、つまり深さ優先で右から左への順序も定めます。 さらに、各supervisorは子プロセスの失敗に対する独自のポリシーと許容度を設定できます。 これはつまり、システムの中でどの種類の部分的な失敗を許すか許さないかも、supervisorが定めているということです。 データベースと通信できなくてもノードは動き続けるべきでしょうか。 動き続けるべきかもしれませんが、キャッシュが使えなくなったら動き続けることはできません。

一言でまとめると、ドメイン固有の処理には関数型の仕組みを使う一方で、状態やイベント、外界とのやり取りの流れはステートフルなコンポーネントの中で明文化され、明示的になり、エラー処理と初期化に対するまったく新しいやり方を与えてくれます。

具体的なやり方はすぐ後で見るとして、まずはsupervisorがどう動作するかを確認しましょう。

supervisorの中身

supervisorは、表面的にはErlang/OTPの中でもっとも単純なビヘイビアの一つです。 受け取るコールバックはinit/1ひとつだけで、それでほぼすべてです。 このコールバックは、各supervisorの子プロセスを定義し、さまざまな種類の失敗に対する基本的なポリシーを設定するために使われます。

設定すべきポリシーには3種類あります。

  • supervisorの種類
  • 子プロセスの再起動ポリシー
  • 許容する失敗の頻度

それぞれ単体で見れば十分に単純ですが、適切な組み合わせを選ぶのは少し厄介になることがあります。 まずはsupervisorの種類から見ていきましょう。

3種類のsupervisor

戦略には3種類あります。

  1. one_for_oneは、各子プロセスが他の子プロセスから独立していることを表します。一つが死んでも、他のプロセスを置き換えたり変更したりする必要はありません。
    • simple_one_for_oneは、すべての子プロセスが同じ種類(たとえばワーカープール)である場合に特化したone_for_oneで、より高速で軽量です。
  2. rest_for_oneは、supervisorの子プロセス間の線形の依存関係を表します。一つが死ぬと、それより後に起動されたものはすべて再起動されなければなりませんが、それより前に起動されたものは再起動されません。プロセスCBに依存し、BAに依存するような場面では、rest_for_one戦略によってこの依存構造を効率よく表現できます。
  3. one_for_allは、どれか一つの子プロセスが死ぬと、すべての子プロセスを再起動する戦略です。すべての子プロセスの間に強い相互依存がある場合に使うべき種類のsupervisorです。もしどれか一つが再起動したとしても、他のプロセスが簡単に復旧する方法がないなら、それらも再起動されるべきです。

これらの戦略は、要するにsupervisorの子プロセス間でエラーをどう伝播させるかを決めるものです。 次に考えるべきは、シグナルが伝播したあと、失敗した各プロセス自身をsupervisorがどう扱うかです。

再起動ポリシー正常終了時異常終了時
permanent再起動する再起動する
transient死んだままにする再起動する
temporary死んだままにする死んだままにする

これにより、決して停止しないはずのプロセス(permanent)、停止することが想定されているプロセス(transient)、失敗することが想定されているプロセス(temporary)を表現できます。

設定できる最後の項目は、再起動が許される頻度です。 これはintensityperiodという2つのパラメータで指定し、それぞれ検出されたクラッシュの回数と、それが発生した秒数を表します。 これにより、1時間に1回のクラッシュだけを許容するsupervisorも、望むなら1秒間に十数回のクラッシュを許容するsupervisorも指定できます。

supervisorは次のように宣言します。

-module(myapp_sup).
-behaviour(supervisor).

%% API
-export([start_link/0]).

%% Supervisor callbacks
-export([init/1]).

-define(SERVER, ?MODULE).

start_link() ->
    supervisor:start_link({local, ?SERVER}, ?MODULE, []).

init([]) ->
    {ok, {{one_for_all, 1, 10}, [ % one failure per 10 seconds
        #{id => internal_name,                % mandatory
          start => {mymod, function, [args]}. % mandatory
          restart => permanent,               % optional
          shutdown => 5000,                   % optional
          type => worker,                     % optional
          modules => [mymod]}                 % optional
    ]}}.

ここには好きなだけ子プロセスを一度に定義できます(simple_one_for_one記述テンプレートを要求するため例外です)。 子プロセスに指定できるその他の引数には、子プロセスの正常な終了を待つミリ秒数を指定するshutdown(即座に終了させるbrutal_killも指定可能)や、そのプロセスがworkersupervisorかを表す定義があります。 このtypeフィールドはmodulesとあわせて、リリースでライブコードアップグレードを行うときにのみ使われるもので、modulesのほうは基本的に無視してデフォルト値のままにしておいて構いません。

必要なのはこれだけです。 実際にどう使うか見てみましょう。

supervisorを自分で試す

アプリケーションにワーカーを持つsupervisorを追加してみましょう。実質的には地球上でもっとも重量級のHello Worldを動かすことになります。 そのためにリリースをまるごと1つ作ります。

$ rebar3 new release hello_world
===> Writing hello_world/apps/hello_world/src/hello_world_app.erl
===> Writing hello_world/apps/hello_world/src/hello_world_sup.erl
===> Writing hello_world/apps/hello_world/src/hello_world.app.src
===> Writing hello_world/rebar.config
===> Writing hello_world/config/sys.config
===> Writing hello_world/config/vm.args
===> Writing hello_world/.gitignore
===> Writing hello_world/LICENSE
===> Writing hello_world/README.md
$ cd hello_world

このリリース構造には見覚えがあるはずです。すべてのOTPアプリケーションはapps/サブディレクトリに置かれます。

hello_world_supモジュールを開き、次のようになっていることを確認してください。

%%%----------------------------------------------------------
%% @doc hello_world top level supervisor.
%% @end
%%%----------------------------------------------------------

-module(hello_world_sup).
-behaviour(supervisor).

-export([start_link/0]).
-export([init/1]).

-define(SERVER, ?MODULE).

start_link() ->
    supervisor:start_link({local, ?SERVER}, ?MODULE, []).

init([]) ->
    SupFlags = #{strategy => one_for_all,
                 intensity => 0,
                 period => 1},
    ChildSpecs = [
        #{id => main,
          start => {hello_world_serv, start_link, []}}
    ],
    {ok, {SupFlags, ChildSpecs}}.

これは、hello_world_servモジュールにある1つの子プロセスを設定します。 これはinit関数だけを使う、何もしない単純なgen_serverになります。

-module(hello_world_serv).
-export([start_link/0, init/1]).

start_link() ->
    gen_server:start_link(?MODULE, [], []).

init([]) ->
    %% Here we ignore what OTP asks of us and just do
    %% however we please.
    io:format("Hello, heavy world!~n"),
    halt(0). % shut down the VM without error

このファイルはOTPプロセスを1つ起動し、Hello, heavy world!と出力してから、仮想マシン全体をシャットダウンするだけです。

リリースをビルドして何が起こるか見てみましょう。

$ rebar3 release
===> Verifying dependencies...
===> Compiling hello_world
===> Starting relx build process ...
===> Resolving OTP Applications from directories:
          /Users/ferd/code/self/adoptingerlang/hello_world/_build/default/lib
          /Users/ferd/code/self/adoptingerlang/hello_world/apps
          /Users/ferd/bin/erls/21.1.3/lib
===> Resolved hello_world-0.1.0
===> Dev mode enabled, release will be symlinked
===> release successfully created!

これで起動できます。 foreground引数を使うと、すべての出力を見られる形でリリースを起動しつつ、シェルを使わない非対話モードで動かせます。

$ ./_build/default/rel/hello_world/bin/hello_world foreground
<debug output provided by wrappers bundled with Rebar3>
Hello, heavy world!

ここで起きているのは、ツールが/_build/default/rel/hello_world/bin/hello_worldにスクリプトを生成しているということです。 このスクリプトは、リリースが正しい設定と環境変数の値で起動されるように、さまざまなものをまとめ上げます。

すべては仮想マシンの起動から始まり、やがてErlang自身のルートプロセスがspawnされます。 kernelのOTPアプリケーションが起動し、設定データの中にhello_worldアプリケーションを起動しなければならないという情報を見つけます。

これはhello_world_app:start/2の呼び出しによって行われ、それがhello_world_supを呼び出し、それがhello_world_servプロセスを起動し、それがテキストを出力してからVMに強制的なシャットダウンを指示します。

これが、いま実際に行ったことです。 supervisorはプロセスを起動し再起動するだけのものです。単純ですが、その力はシステムを構造化するためにどう組み合わせて使えるかにあります。

supervisorツリーの構造化

supervisorのもっとも複雑な部分は、その宣言ではなく組み合わせ方です。 複雑な仕事に対して使われる単純な道具、それがsupervisorです。 この節では、supervisorツリーがなぜうまく機能するのか、そしてそこから最大限の効果を引き出すためにどう構造化すればよいのかを扱います。

supervisorが機能する理由

「電源を入れ直してみましたか」という一般的なバグ修正のやり方は、誰もが耳にしたことがあるはずです。 これは驚くほど頻繁に効果があり、Erlangのsupervisorツリーはまさにこの原則の上で動作します。 もちろん、再起動がすべてのバグを解決するわけではありませんが、多くの部分をカバーできます。

再起動が効果を持つ理由は、本番システムで遭遇するバグの性質にあります。 これを論じるには、Jim Grayが1985年に作った「ボーアバグ」と「ハイゼンバグ」という用語を参照する必要があります。 ボーアバグとは、確実で観測可能、簡単に再現できるバグのことです。 その挙動については比較的単純に説明がつく傾向があります。 一方でハイゼンバグは、特定の条件下でだけ現れる不安定な挙動を持ち、観測しようとする行為そのものによって隠れてしまうことすらあります。 例えば並行処理のバグは、システム内のすべての操作を直列化してしまうようなデバッガを使うと消えてしまうことでよく知られています。

ハイゼンバグは、千回に一度、百万回に一度、あるいは十億回や一兆回に一度しか起きない厄介なバグです。 誰かが何ページものコードを印刷して、何本ものマーカーで書き込みまくっているのを見かけたら、それはハイゼンバグの解明に長らく取り組んでいる証拠です。

これらの用語を定義したところで、本番環境でバグを見つけるのがどれだけ容易であるはずかを見てみましょう。

機能の種類再現可能一過性
コア機能容易困難
副次的機能容易(見落とされがち)困難

システムのコア機能にボーアバグがあれば、本番環境に到達する前に見つけるのはたいてい非常に簡単なはずです。 再現可能であり、しばしばクリティカルパス上にあることから、遅かれ早かれそれに遭遇し、出荷前に修正することになるはずです。

あまり使われない副次的な機能で起きるものは、見つかるかどうかがずっと運任せになります。 ソフトウェアのすべてのバグを修正することが、収穫逓減の登り坂であることは誰もが認めるところです。細かな不完全さを一つひとつ取り除いていくには、進むほどに比例して多くの時間がかかるようになります。 こうした副次的な機能は、使う顧客が少なかったり、満足度への影響が小さかったりする理由で、注目を集めにくい傾向があります。 あるいは単に後回しにスケジュールされ、予定の遅延によって優先度を下げられているだけかもしれません。

ハイゼンバグは、開発中に見つけるのがほぼ不可能です。 形式的証明、モデル検査、網羅的テスト、プロパティベーステストといった高度な技法は、使う手段次第でその一部あるいは全部を見つけ出せる可能性を高めますが、正直なところ、目の前のタスクが極めて重大でない限り、これらを使う人はほとんどいません。 十億回に一度の問題を見つけるにはかなりの量のテストと検証が必要であり、一度目にしたとしても、単なる偶然でもう一度発生させることはまずできないでしょう。

先ほどのバグの種類の表に戻り、今度は本番環境で実際にどれだけの頻度で発生するかに注目してみましょう。

機能の種類再現可能一過性
コア機能本来あってはならない常に発生する
副次的機能かなり頻繁に発生する常に発生する

まず、コア機能にある容易に再現できるバグは、本番環境に到達してはならないはずのものです。 もし到達してしまったなら、実質的に壊れた製品を出荷したことになり、どれだけ再起動やサポートを重ねてもユーザーの助けにはなりません。 そうしたバグはコードの修正を必要とし、それを生み出した組織に深く根付いた問題の結果である場合もあります。

副次的な機能の再現可能なバグは、かなり頻繁に本番環境まで到達します。 これはしばしば、適切にテストする時間を取らなかった、あるいは取れなかった結果ですが、部分的なリファクタリングの際に副次的な機能が置き去りにされたり、設計した人がその機能をシステムの残りの部分と整合的に組み込めるかを十分に検討していなかったりする可能性も大いにあります。

一方で、一過性のバグはとにかくしょっちゅう現れます。 この用語を作ったJim Grayは、ある顧客サイト群で記録された132件のバグのうち、ボーアバグはたった1件だったと報告しています。 本番環境で遭遇したエラーの132件中131件がハイゼンバグだったということです。 これらは捕まえにくく、本当に百万回に一度しか現れない統計的なバグであっても、システムに一定の負荷さえかければ常に引き起こされてしまいます。10万リクエスト/秒を処理するシステムでは、十億回に一度のバグが3時間ごとに現れ、百万回に一度のバグも同様に10秒に一度現れることになりますが、それでもテストの中では稀にしか起こりません。

これは大量のバグであり、適切に処理しなければ大量の障害につながります。 表をもう一度作り直し、今度は再起動でこれらの障害に対処できるかどうかを見てみましょう。

機能の種類再現可能一過性
コア機能対処できない対処できる
副次的機能場合による対処できる

コア機能にある再現可能なバグに対しては、再起動は無力です。 あまり使われないコードパスにある再現可能なバグについては場合によります。ごく少数のユーザーにとって非常に重要な機能であれば、再起動はあまり役に立ちません。逆に、誰もが使うもののそれほど気にしていない副次的な機能であれば、再起動したり失敗をまるごと無視したりするだけでもうまくいきます。

一方で一過性のバグに対しては、再起動が極めて有効であり、本番で遭遇するバグの大半はこの種類です。 再現が難しく、発生が非常に特定の状況やシステム内の状態の断片の絡み合いに依存していることが多く、しかも全操作のごく一部でしか現れない傾向があるため、再起動によってこれらはたいてい丸ごと消え去ります。

supervisorは、こうしたバグに見舞われたシステムの一部を、既知の安定した状態にロールバックさせます。 その状態にロールバックしてしまえば、もう一度試したときに最初のバグを引き起こしたのと同じ奇妙な状況に再び陥ることはまずありません。 こうして、破局になりかねなかったものが、システムにとってちょっとしたつまずき程度のものになり、ユーザーもすぐに気にしなくなります。

保証を与えること

Erlangのsupervisorとそのsupervisorツリーにおいて非常に重要な点の一つは、起動フェーズが同期的であることです。 起動される各OTPプロセスには、自分の仕事を行うための期間があり、その間は兄弟プロセスやそれに続くプロセス全体の起動シーケンスをせき止めます。 その期間中にプロセスが死ねば、うまくいくか、あるいは失敗が多発するまで、何度も繰り返し再試行されます。

ここで、人々は非常によくある間違いを犯します。 supervisorがクラッシュした子プロセスを再起動するまでに、バックオフやクールダウンの期間はありません。 ネットワークを使うアプリケーションを書いていて、この初期化フェーズの中で接続を確立しようとし、しかもリモートサービスがダウンしていると、実を結ばない再起動が繰り返された末にアプリケーションは起動に失敗します。 その結果、システムがシャットダウンしてしまうことすらあります。

多くのErlang開発者は、最終的にクールダウン期間を持つsupervisorを求める主張に行き着きます。 しかしこの考え方は、一つの単純な理由で誤っています。すべては保証の問題だからです。

プロセスを再起動するというのは、それを安定した既知の状態に戻すということです。 そこから先で、物事を再試行できます。 初期化が安定していなければ、supervisionの価値はほとんどありません。 初期化されたプロセスは、何が起きても安定しているべきです。 そうすることで、後から起動される兄弟プロセスたちは、自分より前に立ち上がったシステムの残りの部分が健全であると完全に分かった上で起動できます。

その安定した状態を提供しなかったり、システム全体を非同期に起動したりすると、ループの中のtry ... catchでは得られないこの構造ならではの利点は、ほとんど得られなくなります。

supervisorに監視されるプロセスは、初期化フェーズにおいてベストエフォートではなく保証を提供します。 つまり、データベースやサービスのクライアントを書くときは、何が起きても常に利用可能だと言い切れる場合を除いて、初期化フェーズの一部として接続の確立を必要とするべきではありません。

例えば、データベースが同じホスト上にあり、自分のErlangシステムより先に起動されているはずだと分かっているなら、初期化中に接続を強制してもよいでしょう。 その場合は再起動がうまく機能するはずです。 理解不能で予期しない何かがこの保証を破った場合は、ノードがクラッシュすることになりますが、これは望ましいことです。システムを起動するための前提条件が満たされなかったということであり、システム全体に対するアサーションが失敗したということだからです。

一方、データベースがリモートホスト上にあるなら、接続が失敗することを想定しておくべきです。 この場合、クライアントプロセスで保証できるのは、クライアントがリクエストを処理できるということだけであり、データベースと通信できるということではありません。 例えばネットワーク分断の間は、すべての呼び出しに対して{error, not_connected}を返すようにしてもよいでしょう。

データベースへの再接続は、システムの安定性に影響を与えることなく、最適だと考えるどのようなクールダウンやバックオフの戦略を使っても行えます。 最適化として初期化フェーズの中で試みてもかまいませんが、いつか切断が起きたときには、後からでも再接続できるようになっているべきです。

外部サービスに失敗が起こりうると想定しているなら、その存在をシステムの保証にしてはいけません。 ここで扱っているのは現実の世界であり、外部の依存先が失敗することは常にありえます。

もちろん、データベースなしでは動かないことを想定していなかったクライアント呼び出し元のライブラリやプロセスは、その場合エラーになります。 これはまったく別の問題空間にあるまったく別の問題ですが、必ずしも回避不可能というわけではありません。 例えば、そのクライアントがOps担当者向けの統計サービスに対するものだとしましょう。この場合、そのクライアントを呼び出すコードは、システム全体に悪影響を与えることなくエラーを無視してしまってもまったく構いません。 また別の場合では、状況が悪化したときに状態を失わないよう、クライアントの手前にイベントキューを追加するという方法もあります。

この初期化とsupervisionのどちらのアプローチにおいても違うのは、どれだけの失敗を許容できるかを決めるのがクライアント自身ではなく、クライアントの呼び出し元だという点です。 これは耐障害性のあるシステムを設計する上で非常に重要な区別です。 supervisorが再起動に関わるものであることは確かですが、それは安定した既知の状態への再起動であるべきです。

ツリーを育てる

Erlangのプログラムを構造化するときは、脆弱で失敗を許容すべきだと感じるものはすべて階層のより深いところに置き、安定していて信頼できる必要があるクリティカルなものはより上位に置きます。 supervisor構造は部分的な失敗と障害の伝播を明文化できるため、これらすべてについてきちんと考えなければなりません。 先ほどのsupervisorツリーの例をもう一度見てみましょう。

supervisorツリーの例

DBサブツリーの中のワーカーが死んだとき、DBone_for_one戦略のsupervisorであれば、各ワーカーは互いに独立して失敗してよいということを明文化していることになります。 一方でevent_suprest_for_one戦略を持つなら、イベントリスナーが死んだ場合にサブスクリプションを扱うワーカーは必ず再起動しなければならないということをシステムの中で明文化していることになります。これを直接の依存関係と呼びます。

暗黙のうちに、イベント処理のサブツリーはひとたび正常に起動できさえすれば、データベースから直接の影響を受けないということも表明していることになります。

このsupervisorツリーは、システムの障害に対するスケジュールであり見取り図であるかのように読めます。 ドメイン固有のワーカーが利用可能でなければHTTPサーバーは起動せず、HTTPハンドラが失敗してもデータベースのキャッシュを損なうようなことは何も起きません。 もちろん、HTTPハンドラがドメインワーカーに依存し、そのドメインワーカーがキャッシュのETSテーブルに依存していて、そのテーブルが消えてしまえば、これらすべてのプロセスがともに死ぬこともあります。

ここで本当に興味深いのは、未知の失敗でさえシステムにどう影響しうるかを一目で把握できるということです。 データベースのワーカーがなぜ失敗するのか、切断のせいなのか、データベースが死んだせいなのか、プロトコル実装のバグのせいなのかを知る必要はありません。何が起きても、キャッシュはそのまま残り、データベースのサブツリーが再び利用可能になるまで、古い値を読み続けられるだけだと分かっているからです。

ここで、子プロセスの再起動ポリシーと、supervisorが受け入れる失敗の頻度の組み合わせが効いてきます。 すべてのワーカーとそのsupervisorがpermanentとマークされていると、頻発するクラッシュがノード全体を巻き添えにする可能性があります。 しかし、ちょっとした小技を使えます。

manager workerパターン

  1. ワーカーをpermanenttransientとマークし、失敗したら再起動されるようにする。
  2. ワーカーの直接のsupervisor(図の四角の中)をtemporaryとマークし、失敗したら諦めて再起動されないようにする。
  3. その上に新しいsupervisorを追加する(このsupervisorには好きなポリシーを使える)。ただしone_for_oneにする。
  4. 新しいsupervisorの下に新しいプロセス(temporaryなsupervisorの兄弟プロセス)を追加する。このプロセスを古いsupervisorにlinkさせる。これがmanagerです。

temporaryなsupervisorは、許容できる再起動頻度に対応するために使えます。 例えば、ワーカーが2分ごとに死ぬのは正常だと定めておけます。 しかしこのしきい値を超えたら、何か悪いことが起きているということであり、ノードを不安定にするリスクを冒すことなく再試行を止めたくなります。 そのsupervisorはシャットダウンし、temporaryであるため、その上のsuper-supervisor(図の四角の上にあるもの)はただ何もせずそこに留まり続けます。

managerは、そのsupsupervisorが生きているか死んでいるかを見て、再起動に関して好きなポリシーを適用できます。指数バックオフを使う、サーキットブレーカーが復帰するまで待つ、依存先のサービスが再び健全だと外部のサービスレジストリが伝えるまで待つ、あるいはどうすべきかユーザーに入力を求める、といった具合です。 そしてmanagerは、自分自身の親supervisorに対してtemporaryなsupervisorの再起動を依頼でき、それによってそのsupervisor自身のワーカーが再起動されます。

これは、Erlangにおける数少ないデザインパターンの一つです。 賢いシステムほど愚かな間違いを犯すものなので、supervisorはできるだけ単純で予測可能なままにしておきたいところです。 managerは、supervisorに脳を接ぎ木して、より凝った判断をさせるようなものです。 これにより、一過性のエラーに対するポリシーと、再起動だけでは対処できないより深刻で持続的な障害に対するポリシーを分離できます。

[!NOTE] このパターンは好きなように応用できます。例えば著者たちは、再起動管理のために次の2つの別バリアントを使ったことがあります。

  • 複数のsupervisorツリーが互いに入れ子になっており、それぞれが物理デバイス用のワーカープールを表している構成。各物理デバイスは失敗したり、オフラインになったり、電源を失ったりしてよいことになっている。管理プロセスは、稼働中のサブツリーをオフサイトの設定サービスと定期的に比較し、欠けているサブツリーをすべて起動し、もう存在しないサブツリーをすべてシャットダウンする。これにより、managerは実行時の設定同期を扱いながら、扱いにくいハードウェア障害のシナリオに対する再起動も処理できる。
  • managerが何もしない構成。ただし、Erlangのリリースにはオペレーターが呼び出せるスクリプトが同梱されている。そのスクリプトはmanagerにメッセージを送り、失われたサブツリーの再起動を依頼する。このバリアントが使われたのは、あるリージョンのストレージ全体がダウンするようなごく稀な状況でだけサブツリーが死に、システムの残りの部分を巻き込みたくないという場合で、復旧した際にオペレーターがこの方法でトラフィックを再度有効にできるようにするためだった。

このような機能を汎用的なsupervisorに組み込むのは難しいところですが、managerであれば、この種の作り込まれた解決策に必要な柔軟性を簡単に提供できます。

おすすめしたい演習の一つは、自分のシステムを取り上げて、そのsupervisorツリーをホワイトボードに描いてみることです。 すべてのワーカーとsupervisorについて、次のような問いを投げかけてみましょう。

  • これが死んでも問題ないか。
  • 他のプロセスもこれと一緒に道連れにすべきか。
  • このプロセスは、再起動すると奇妙なことになる何かに依存していないか。
  • このsupervisorにとって、何回のクラッシュまでが許容範囲か。
  • システムのこの部分が完全に壊れたとき、残りの部分は動き続けるべきか、それとも諦めるべきか。

これらをチームで議論しましょう。 supervisorを配置し直し、戦略とポリシーを調整しましょう。 supervisorの階層を追加したり取り除いたりし、場合によってはmanagerを追加しましょう。

この演習を折に触れて繰り返し、やがては稼働中のノード上でErlangプロセスをkillしてみるカオスエンジニアリングを行い、想定どおりに振る舞って復旧するかを確認しましょう(もちろん、より一般的なようにノード全体に対してカオスエンジニアリングを行ってもかまいません)。 そうして最終的に手にするのは、耐障害性を備えたコードです。 また、システムに組み込まれた失敗のセマンティクスを深く理解し、いずれ避けられずに何かが壊れたときにどう壊れるべきかについて良いメンタルモデルを持ったチームも育つことになります。 これは、それだけの価値がある投資です。