Elixir/Erlang OTPは、分散システム構築に最適なプラットフォームである。しかし、その設計には独特のパターンと考え方が必要である。本記事では、10年以上のElixir開発経験から得た、分散システム設計の実践的なアプローチを解説する。
OTPの基本思想
Let it crash
従来の考え方:
エラーを予測し、すべてに対処する
OTPの考え方:
エラーは発生するもの。
クラッシュさせて、Supervisorが再起動するこれは、防御的プログラミングから回復可能なシステムへの転換である。
Supervision Tree
Application
├─ Supervisor
│ ├─ Worker 1
│ ├─ Worker 2
│ └─ Supervisor
│ ├─ Worker 3
│ └─ Worker 4
└─ Supervisor
└─ Worker 5システム全体を木構造として設計する。
GenServerの設計パターン
1. 状態管理
defmodule MyApp.Counter do
use GenServer
# Client API
def start_link(initial_value) do
GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
end
def increment do
GenServer.call(__MODULE__, :increment)
end
def get do
GenServer.call(__MODULE__, :get)
end
# Server Callbacks
def init(initial_value) do
{:ok, initial_value}
end
def handle_call(:increment, _from, state) do
{:reply, :ok, state + 1}
end
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end原則: GenServerは単一の責務を持つ。
2. 非同期処理
defmodule MyApp.Logger do
use GenServer
def log(message) do
GenServer.cast(__MODULE__, {:log, message})
end
def handle_cast({:log, message}, state) do
IO.puts("[LOG] #{message}")
{:noreply, state}
end
end原則: 応答が不要ならcastを使う。
3. タイムアウト
defmodule MyApp.Cache do
use GenServer
def init(state) do
schedule_cleanup()
{:ok, state}
end
def handle_info(:cleanup, state) do
new_state = remove_expired(state)
schedule_cleanup()
{:noreply, new_state}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, :timer.minutes(5))
end
end原則: 定期的な処理はhandle_infoで実装する。
分散システムの設計
1. ノード間通信
# ノード起動
iex --name node1@127.0.0.1 --cookie secret
# 別のターミナルで
iex --name node2@127.0.0.1 --cookie secret
# node1から接続
Node.connect(:"node2@127.0.0.1")
# node2のプロセスを呼び出し
:rpc.call(:"node2@127.0.0.1", MyApp.Counter, :increment, [])原則: cookieが一致するノードのみ通信可能。
2. 分散レジストリ
defmodule MyApp.Registry do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: {:global, __MODULE__})
end
def register(name, pid) do
GenServer.call({:global, __MODULE__}, {:register, name, pid})
end
def lookup(name) do
GenServer.call({:global, __MODULE__}, {:lookup, name})
end
def handle_call({:register, name, pid}, _from, state) do
{:reply, :ok, Map.put(state, name, pid)}
end
def handle_call({:lookup, name}, _from, state) do
{:reply, Map.get(state, name), state}
end
end原則: :globalを使うと、クラスタ全体で一意の名前を持てる。
3. ネットワーク分断への対処
defmodule MyApp.NodeMonitor do
use GenServer
def init(_) do
:net_kernel.monitor_nodes(true)
{:ok, %{}}
end
def handle_info({:nodeup, node}, state) do
IO.puts("Node connected: #{node}")
# 再同期処理
sync_data(node)
{:noreply, state}
end
def handle_info({:nodedown, node}, state) do
IO.puts("Node disconnected: #{node}")
# フェイルオーバー処理
failover(node)
{:noreply, state}
end
defp sync_data(node) do
# データ同期ロジック
end
defp failover(node) do
# フェイルオーバーロジック
end
end原則: ネットワーク分断は必ず発生する。対処を設計に組み込む。
実例: 分散カウンター
要件
- 複数ノードで動作
- ノードが落ちても、他のノードで継続
- カウンターの値は、ノード間で同期
実装
defmodule MyApp.DistributedCounter do
use GenServer
# Client API
def start_link(_) do
GenServer.start_link(__MODULE__, 0, name: {:global, __MODULE__})
end
def increment do
case :global.whereis_name(__MODULE__) do
:undefined -> {:error, :not_found}
pid -> GenServer.call(pid, :increment)
end
end
def get do
case :global.whereis_name(__MODULE__) do
:undefined -> {:error, :not_found}
pid -> GenServer.call(pid, :get)
end
end
# Server Callbacks
def init(initial_value) do
# クラスタ内の他のノードに通知
broadcast_state(initial_value)
{:ok, initial_value}
end
def handle_call(:increment, _from, state) do
new_state = state + 1
broadcast_state(new_state)
{:reply, new_state, new_state}
end
def handle_call(:get, _from, state) do
{:reply, state, state}
end
def handle_info({:sync, value}, _state) do
{:noreply, value}
end
defp broadcast_state(value) do
for node <- Node.list() do
send({__MODULE__, node}, {:sync, value})
end
end
end起動
# ノード1
iex --name counter1@127.0.0.1 --cookie secret -S mix
# ノード2
iex --name counter2@127.0.0.1 --cookie secret -S mix
# ノード1から接続
Node.connect(:"counter2@127.0.0.1")
# ノード1でカウンター起動
MyApp.DistributedCounter.start_link(nil)
# ノード1で操作
MyApp.DistributedCounter.increment() # => 1
MyApp.DistributedCounter.increment() # => 2
# ノード2で確認
MyApp.DistributedCounter.get() # => 2耐障害性の設計
1. Supervisor戦略
defmodule MyApp.Supervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
def init(_init_arg) do
children = [
{MyApp.Counter, 0},
{MyApp.Logger, []},
{MyApp.Cache, %{}}
]
# 戦略を選択
Supervisor.init(children, strategy: :one_for_one)
end
end戦略:
:one_for_one: 1つのWorkerがクラッシュしたら、そのWorkerのみ再起動:one_for_all: 1つのWorkerがクラッシュしたら、すべてのWorkerを再起動:rest_for_one: 1つのWorkerがクラッシュしたら、それ以降のWorkerを再起動
2. 再起動の制限
Supervisor.init(children,
strategy: :one_for_one,
max_restarts: 3,
max_seconds: 5
)原則: 5秒以内に3回以上クラッシュしたら、Supervisor自身もクラッシュする。
3. DynamicSupervisor
defmodule MyApp.WorkerSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_worker(args) do
spec = {MyApp.Worker, args}
DynamicSupervisor.start_child(__MODULE__, spec)
end
def stop_worker(pid) do
DynamicSupervisor.terminate_child(__MODULE__, pid)
end
end原則: 動的にWorkerを追加・削除する場合はDynamicSupervisorを使う。
データの一貫性
1. CRDTs (Conflict-free Replicated Data Types)
# Horde ライブラリを使用
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{Horde.Registry, [name: MyApp.Registry, keys: :unique]},
{Horde.DynamicSupervisor, [name: MyApp.Supervisor, strategy: :one_for_one]}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end原則: CRDTsを使えば、ノード間で自動的にデータが同期される。
2. イベントソーシング
defmodule MyApp.EventStore do
def append_event(stream, event) do
# イベントを追記
EventStore.append_to_stream(stream, event)
end
def replay_events(stream) do
# イベントを再生して状態を復元
EventStore.read_stream_forward(stream)
|> Enum.reduce(%{}, fn event, state ->
apply_event(state, event)
end)
end
defp apply_event(state, %{type: "user_created", data: data}) do
Map.put(state, :user, data)
end
defp apply_event(state, %{type: "user_updated", data: data}) do
Map.update!(state, :user, &Map.merge(&1, data))
end
end原則: 状態ではなく、イベントを保存する。
パフォーマンス最適化
1. ETS (Erlang Term Storage)
defmodule MyApp.FastCache do
def start_link do
:ets.new(:my_cache, [:set, :public, :named_table])
{:ok, self()}
end
def put(key, value) do
:ets.insert(:my_cache, {key, value})
end
def get(key) do
case :ets.lookup(:my_cache, key) do
[{^key, value}] -> {:ok, value}
[] -> {:error, :not_found}
end
end
end原則: 高速な読み込みが必要なら、ETSを使う。
2. :persistent_term
# 起動時に設定
:persistent_term.put(:config, %{api_key: "secret"})
# どこからでも高速に読み込み
config = :persistent_term.get(:config)原則: 不変のデータは:persistent_termに保存する。
モニタリング
1. Telemetry
defmodule MyApp.Counter do
def increment do
start_time = System.monotonic_time()
result = GenServer.call(__MODULE__, :increment)
duration = System.monotonic_time() - start_time
:telemetry.execute(
[:my_app, :counter, :increment],
%{duration: duration},
%{result: result}
)
result
end
end
# ハンドラー登録
:telemetry.attach(
"my-handler",
[:my_app, :counter, :increment],
fn _event, measurements, _metadata, _config ->
IO.puts("Duration: #{measurements.duration}ns")
end,
nil
)原則: すべての重要な操作を計測する。
2. Observer
iex -S mix
iex> :observer.start()原則: 本番環境でも:observerでリアルタイム監視できる。
まとめ
Elixir/Erlang OTPで分散システムを設計する際の原則:
- Let it crash: エラーは発生するもの。回復可能な設計にする
- Supervision Tree: システムを木構造として設計する
- 単一責務: GenServerは1つのことだけを行う
- ネットワーク分断への対処: 必ず発生する。設計に組み込む
- CRDTs: データの一貫性を自動的に保つ
- イベントソーシング: 状態ではなくイベントを保存
- 計測: すべての重要な操作を計測する
OTPは、30年以上の通信システム開発の知見が詰まったプラットフォームである。その設計思想を理解し、適切に活用すれば、高い耐障害性を持つ分散システムを構築できる。