分散OTPアプリケーション

Erlangは多くの作業を残しますが、いくつかの解決策も提供しています。その1つが分散OTPアプリケーションの概念です。OTPの文脈では、分散OTPアプリケーション、または単に分散アプリケーションは、引き継ぎフェイルオーバーメカニズムを定義することを可能にします。それが何を意味するのか、どのように機能するのかを見て、それに合わせた小さなデモアプリケーションを作成します。

OTPへの追加

OTPアプリケーションに関する章を思い出してください。アプリケーションの構造は、中央アプリケーションコントローラーを使用し、各アプリケーションがアプリケーションの最上位スーパーバイザーを監視するアプリケーションマスターにディスパッチするものとして簡単に説明しました。

The Application controller stands over three application masters (in this graphic, in real life it has many more), which each stand on top of a supervisor process

標準的なOTPアプリケーションでは、アプリケーションのロード、開始、停止、アンロードが可能です。分散アプリケーションでは、動作方法が変わります。アプリケーションコントローラーは、その作業を分散アプリケーションコントローラーと共有するようになります。これは、隣接する別のプロセスです(通常はdist_acと呼ばれます)。

The application controller supervises two master which in turn supervise two supervisors. In parallel to the application cantroller is the dist_ac, also supervising its own application

アプリケーションファイルに応じて、アプリケーションの所有権が変わります。dist_acはすべてのノードで開始され、すべてのdist_acは互いに通信します。それらが何を議論するかはあまり重要ではありませんが、1つの例外があります。前述のように、4つのアプリケーションステータス(ロード中、開始済み、停止済み、アンロード済み)は、分散アプリケーションでは開始済みアプリケーションの概念が開始済み実行中に分割されます。

両者の違いは、クラスタ内でグローバルなアプリケーションを定義できることです。この種のアプリケーションは一度に1つのノードでのみ実行できますが、通常のOTPアプリケーションは他のノードで何が起こっているか気にしません。

したがって、分散アプリケーションはクラスタのすべてのノードで開始されますが、1つのノードでのみ実行されます。

アプリケーションが開始されても実行されていないノードにとって、これは何を意味するのでしょうか?それらが実行するのは、実行中のアプリケーションのノードが死ぬのを待つことだけです。つまり、アプリを実行しているノードが死んだ場合、別のノードが代わりに実行を開始します。これにより、異なるサブシステムを移動することで、サービスの中断を回避できます。

詳しく見ていきましょう。

引き継ぎとフェイルオーバー

分散アプリケーションによって処理される2つの重要な概念があります。最初の概念はフェイルオーバーのアイデアです。フェイルオーバーとは、上記で説明したように、アプリケーションを実行が停止した場所とは別の場所で再起動するというアイデアです。

これは、冗長ハードウェアがある場合に特に有効な戦略です。「メイン」コンピューターまたはサーバーで何かを実行し、それが失敗した場合、バックアップコンピューターに移動します。大規模な展開では、代わりに50台のサーバーでソフトウェアを実行し(すべて60〜70%の負荷)、実行中のサーバーが失敗したサーバーの負荷を吸収することを期待する場合があります。フェイルオーバーの概念は、前者の場合に最も重要であり、後者の場合に最も重要ではありません。

分散OTPアプリケーションの2番目の重要な概念は引き継ぎです。引き継ぎとは、死んだノードが復活し、バックアップノードよりも重要であることが認識され(おそらくハードウェアが良い)、アプリケーションの再実行を決定する行為です。これは通常、バックアップアプリケーションを正常に終了し、メインアプリケーションを代わりに開始することによって行われます。

注:分散プログラミングの誤謬に関して、分散OTPアプリケーションは、障害が発生した場合、ネットワーク分割ではなくハードウェア障害が原因である可能性が高いと仮定しています。ネットワーク分割の方がハードウェア障害よりも起こりやすいと判断する場合は、アプリケーションがバックアップとメインの両方として実行されている可能性があり、ネットワークの問題が解決されたときに面白いことが起こる可能性があることに注意する必要があります。これらの場合、分散OTPアプリケーションは適切なメカニズムではない可能性があります。

3つのノードを持つシステムがあると想像してみましょう。最初のノードのみが特定のアプリケーションを実行しています。

Three nodes, A, B and C, and the application runs under A

ノードBCは、Aが死んだ場合のバックアップノードとして宣言されています。これは、私たちがまさに起こったと仮定していることです。

Two nodes, B and C, and no application

しばらくの間、何も実行されていません。しばらくすると、Bがこれに気づき、アプリケーションを引き継ぐことを決定します。

Two nodes, B and C, and the application runs under B

それはフェイルオーバーです。次に、Bが死んだ場合、アプリケーションはCで再起動されます。

Node C with an application running under it

別のフェイルオーバーであり、すべてうまくいきます。ここで、Aが復旧したとします。Cは現在アプリを喜んで実行していますが、Aはメインノードとして定義したノードです。これは引き継ぎが発生する時です。アプリはCで意図的にシャットダウンされ、Aで再起動されます。

Two nodes, A andbC, and the application runs under A

そして、他のすべての障害についても同様です。

明らかな問題の1つは、このようにしてアプリケーションを常に終了すると、重要な状態が失われる可能性が高いことです。残念ながら、それはあなたの問題です。物事が壊れる前に、その重要な状態をどこに配置し、共有するのかを考えなければなりません。分散アプリケーションのOTPメカニズムは、それに対して特別なケースを作りません。

とにかく、実際的に物事を機能させる方法を見ていきましょう。

マジック8ボール

マジック8ボールは、ランダムに振って神聖で役立つ答えを得るための簡単な玩具です。「私の好きなスポーツチームは今夜勝つでしょうか?」といった質問をすると、「間違いなく」といった答えが返ってきます。その後、最終スコアに家の価値を安全に賭けることができます。「将来、慎重に投資すべきでしょうか」といった質問には、「それはありそうもない」または「わかりません」と回答される場合があります。マジック8ボールは過去数十年にわたる西側諸国の政治的意思決定において不可欠なものであり、フォールトトレランスの例として使用するのは当然のことです。

私たちのインプリメンテーションでは、DNSラウンドロビンやロードバランサーなどのサーバーを自動的に検索するために使用される現実世界のスイッチングメカニズムは使用しません。むしろ、純粋なErlangにとどまり、分散OTPアプリケーションの一部である3つのノード(以下、ABCと表記)を使用します。Aノードはマジック8ボールサーバーを実行するメインノードを表し、BノードとCノードはバックアップノードになります。

three nodes, A, B, and C, connected together

Aが失敗するたびに、8ボールアプリケーションはBまたはCのいずれかで再起動する必要があり、両方のノードは引き続き透過的にそれを使用できます。

分散OTPアプリケーションを設定する前に、アプリケーション自体を構築します。その設計は信じられないほどナイーブです。

A supervisor supervising a server

合計で3つのモジュールがあります。スーパーバイザー、サーバー、そして開始するためのアプリケーションコールバックモジュールです。スーパーバイザーは非常に簡単です。m8ball_sup(マジック8ボールスーパーバイザーの略)と呼び、標準的なOTPアプリケーションのsrc/ディレクトリに入れます。

-module(m8ball_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).

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

init([]) ->
    {ok, {{one_for_one, 1, 10},
          [{m8ball,
            {m8ball_server, start_link, []},
            permanent,
            5000,
            worker,
            [m8ball_server]
          }]}}.

これは、単一のサーバー(m8ball_server)、永続的なワーカープロセスを開始するスーパーバイザーです。10秒ごとに1回の障害が許容されます。

マジック8ボールサーバーはもう少し複雑になります。次のインターフェースを持つgen_serverとして構築します。

-module(m8ball_server).
-behaviour(gen_server).
-export([start_link/0, stop/0, ask/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         code_change/3, terminate/2]).

%%%%%%%%%%%%%%%%%
%%% INTERFACE %%%
%%%%%%%%%%%%%%%%%
start_link() ->
    gen_server:start_link({global, ?MODULE}, ?MODULE, [], []).

stop() ->
    gen_server:call({global, ?MODULE}, stop).

ask(_Question) -> % the question doesn't matter!
    gen_server:call({global, ?MODULE}, question).

サーバーが{global, ?MODULE}を名前として使用して開始され、各呼び出しに対して同じタプルを使用してアクセスされることに注意してください。これは、前の章で見たglobalモジュールで、ビヘイビアに適用されています。

次に、コールバック、つまり実際のインプリメンテーションについて説明します。構築方法を示す前に、どのように動作させたいかを説明します。マジック8ボールは、いくつかの設定ファイルから多くの可能な返信のいずれかをランダムに選択する必要があります。設定ファイルを使用したいのは、必要に応じて簡単に回答を追加または削除できるようにするためです。

まず、ランダムに処理を行うには、init関数の一部としてランダム性を設定する必要があります。

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%
init([]) ->
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A,B,C),
    {ok, []}.

ソケットの章で以前に見たパターンです。random:uniform/1関数で使用される初期のランダムシードを設定するために、12バイトのランダムバイトを使用しています。

次のステップは、設定ファイルから回答を読み取り、1つを選択することです。OTPアプリケーションの章を思い出してください。設定を行う最も簡単な方法は、appファイルを使用して行うことです(envタプル内)。このように行います。

handle_call(question, _From, State) ->
    {ok, Answers} = application:get_env(m8ball, answers),
    Answer = element(random:uniform(tuple_size(Answers)), Answers),
    {reply, Answer, State};
handle_call(stop, _From, State) ->
    {stop, normal, ok, State};
handle_call(_Call, _From, State) ->
    {noreply, State}.

最初の節は、私たちが行いたいことを示しています。envタプルのanswers値内に、可能なすべての回答を含むタプルがあると想定しています。なぜタプルなのか?単純に、タプルの要素へのアクセスは定数時間操作であるのに対し、リストからの取得は線形であるため(したがって、より大きなリストでは時間がかかります)。次に、回答を返します。

注:サーバーは、尋ねられた各質問に対してapplication:get_env(m8ball, answers)を使用して回答を読み取ります。application:set_env(m8ball, answers, {"yes","no","maybe"})のような呼び出しで新しい回答を設定した場合、3つの回答はすぐに将来の呼び出しの可能な選択肢になります。

起動時に一度読み取る方が長期的には効率的ですが、可能な回答を更新する唯一の方法はアプリケーションを再起動することになります。

今気づいたと思いますが、尋ねられた質問は実際には気にしません。プロセス間でコピーする必要すらないからです。ランダムな回答を返しているので、それをプロセス間でコピーするのは完全に無駄です。インターフェースをより自然に感じさせるため、回答を残しています。好きなように、同じ質問に対して常に同じ回答を返すようにマジック8ボールをだますこともできますが、今は気にしません。

モジュールの残りは、何も行わない一般的なgen_serverとほぼ同じです。

handle_cast(_Cast, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

terminate(_Reason, _State) ->
    ok.

それでは、より重要な部分、つまりアプリケーションファイルとコールバックモジュールに移りましょう。後者、m8ball.erlから始めます。

-module(m8ball).
-behaviour(application).
-export([start/2, stop/1]).
-export([ask/1]).

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%

start(normal, []) ->
    m8ball_sup:start_link().

stop(_State) ->
    ok.

%%%%%%%%%%%%%%%%%
%%% INTERFACE %%%
%%%%%%%%%%%%%%%%%
ask(Question) ->
    m8ball_server:ask(Question).

簡単でした。関連付けられた.appファイル、m8ball.appです。

{application, m8ball,
 [{vsn, "1.0.0"},
  {description, "Answer vital questions"},
  {modules, [m8ball, m8ball_sup, m8ball_server]},
  {applications, [stdlib, kernel, crypto]},
  {registered, [m8ball, m8ball_sup, m8ball_server]},
  {mod, {m8ball, []}},
  {env, [
    {answers, {<<"Yes">>, <<"No">>, <<"Doubtful">>,
               <<"I don't like your tone">>, <<"Of course">>,
               <<"Of course not">>, <<"*backs away slowly and runs away*">>}}
  ]}
 ]}.

すべてのOTPアプリケーションと同様に、stdlibkernel、そしてサーバーのランダムシードのためにcryptoに依存しています。回答がすべてタプル内にあることに注意してください。これはサーバーで必要なタプルと一致します。この場合、回答はすべてバイナリですが、文字列形式は実際には問題ありません。リストでも機能します。

アプリケーションを分散化する

これまでのところ、すべては完全に通常のOTPアプリケーションのようでした。分散OTPアプリケーションとして機能させるためにファイルに追加する必要がある変更はごくわずかです。実際には、m8ball.erlモジュールに戻って追加する関数節は1つだけです。

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%

start(normal, []) ->
    m8ball_sup:start_link();
start({takeover, _OtherNode}, []) ->
    m8ball_sup:start_link().

{takeover, OtherNode}引数は、より重要なノードがバックアップノードを引き継ぐときにstart/2に渡されます。マジック8ボールアプリの場合、実際には何も変わりません。スーパーバイザーをまったく同じように開始できます。

コードを再コンパイルすれば、ほぼ準備完了です。しかし、メインノードとバックアップノードをどのように定義するのでしょうか?答えは設定ファイルです。3つのノード(abc)を持つシステムが必要なため、3つの設定ファイルが必要です(a.configb.configc.configという名前を付け、それらをすべてアプリケーションディレクトリのconfig/に入れました)。

[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [b@ferdmbp, c@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].
[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [a@ferdmbp, c@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].
[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [a@ferdmbp, b@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].

自分のホストに合わせてノードの名前を変更することを忘れないでください。それ以外の一般的な構造は常に同じです。

[{kernel,
  [{distributed, [{AppName,
                   TimeOutBeforeRestart,
                   NodeList}]},
   {sync_nodes_mandatory, NecessaryNodes},
   {sync_nodes_optional, OptionalNodes},
   {sync_nodes_timeout, MaxTime}
 ]}].

NodeListの値は通常、[A, B, C, D]のような形式を取ります。ここで、Aはメイン、Bは最初のバックアップ、Cは次のバックアップ、というようになります。別の構文として、[A, {B, C}, D]のようなリストも可能です。この場合も、Aがメインノードで、BCは同等のセカンダリバックアップ、それ以降は他のノードとなります。

A magic 8-ball showing 'I don't think so'

sync_nodes_mandatoryタプルはsync_nodes_timeoutと連携して動作します。この値を設定して分散仮想マシンを起動すると、すべての必須ノードが起動してロックされるまで、ロックされた状態が維持されます。その後、ノードが同期され、処理が開始されます。すべてのノードの起動にMaxTimeを超える時間がかかった場合、起動前にすべてクラッシュします。

さらに多くのオプションがあります。詳細については、カーネルアプリケーションのドキュメントを参照することをお勧めします。

今度はm8ballアプリケーションで試してみましょう。3台のVMをすべて起動するのに30秒では不十分な場合は、必要に応じてsync_nodes_timeoutを増やすことができます。次に、3台のVMを起動します。

$ erl -sname a -config config/a -pa ebin/
$ erl -sname b -config config/b -pa ebin/
$ erl -sname c -config config/c -pa ebin/

3台目のVMを起動すると、すべて同時にロックが解除されます。3台の仮想マシンのそれぞれで、順番にapplication:start(AppName)を使用してcryptom8ballの両方を起動します。

その後、接続されているノードのいずれかからマジック8ボールを呼び出すことができるようになります。

(a@ferdmbp)3> m8ball:ask("If I crash, will I have a second life?").
<<"I don't like your tone">>
(a@ferdmbp)4> m8ball:ask("If I crash, will I have a second life, please?").
<<"Of Course">>
(c@ferdmbp)3> m8ball:ask("Am I ever gonna be good at Erlang?").
<<"Doubtful">>

やる気になりますね。状況を確認するには、すべてのノードでapplication:which_applications()を呼び出します。ノードaのみがアプリケーションを実行している必要があります。

(b@ferdmbp)3> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(a@ferdmbp)5> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

この場合、ノードcはノードbと同じものを表示するはずです。ここで、ノードaを強制終了した場合(Erlangシェルのウィンドウを単に粗暴に閉じます)、アプリケーションはそのノードでは実行されなくなります。代わりにどこで実行されているか見てみましょう。

(c@ferdmbp)4> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(c@ferdmbp)5> m8ball:ask("where are you?!").
<<"I don't like your tone">>

これは予想通りです。bの方が優先順位が高いからです。5秒後(タイムアウトを5000ミリ秒に設定しました)、bはアプリケーションが実行中であることを示すはずです。

(b@ferdmbp)4> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

まだ正常に実行されています。同じ蛮行でbを強制終了すると、5秒後にcがアプリケーションを実行するはずです。

(c@ferdmbp)6> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

以前と同じコマンドでノードaを再起動すると、ハングします。設定ファイルでは、aが動作するにはbが必要であることが指定されています。このようにすべてのノードが確実に起動するとは限らない場合は、たとえばbまたはcをオプションにする必要があります。そのため、abの両方を起動すると、アプリケーションは自動的に戻ってくるはずです。

(a@ferdmbp)4> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(a@ferdmbp)5> m8ball:ask("is the app gonna move here?").
<<"Of course not">>

残念ですが、このメカニズムが機能するには、アプリケーションをノードのブート手順の一部として起動する必要があります。たとえば、このようにaを起動すれば機能します。

erl -sname a -config config/a -pa ebin -eval 'application:start(crypto), application:start(m8ball)'
...
(a@ferdmbp)1> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

そして、c側では

=INFO REPORT==== 8-Jan-2012::19:24:27 ===
    application: m8ball
    exited: stopped
    type: temporary

これは、-evalオプションがVMのブート手順の一部として評価されるためです。明らかに、よりクリーンな方法はリリースを使用して適切に設定することですが、これまでのすべてを組み合わせる例は非常に煩雑になります。

一般的に、分散OTPアプリケーションは、システムのすべての関連部分が適切に配置されていることを保証するリリースを使用する場合に最適に機能することを覚えておいてください。

前述のように、多くのアプリケーション(マジック8ボールを含む)では、アプリケーションを単一の位置でのみ実行することを強制するのではなく、多くのインスタンスを同時に実行してデータを同期する方が簡単な場合があります。その設計が選択されると、スケーリングも簡単になります。フェイルオーバー/テイクオーバーメカニズムが必要な場合は、分散OTPアプリケーションが最適なソリューションかもしれません。