Elixir OTPの「OTP」とは「Open Telecom Platform」の略です。
OTPに付属するビヘイビア(他の言語でいうインターフェースと訳して大丈夫)を使って、自動復旧アーキテクチャの実装方法について知っていることを考えながら書き下します。
(見直しせず投稿するつもりなので矛盾する箇所があるかもしれないですがスルーしてください)
なるべく、プロセスの監視、自動再起動、リトライ戦略を実践的なコード例を多めに記載する。
なぜ自動復旧が重要なのか
従来の問題
多くのプログラミング言語では、エラーが発生した際の復旧処理を手動で実装する必要がある。
Javaの例:
// 手動でのリトライ実装
public Response callAPI() {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
return httpClient.get("https://api.example.com/data");
} catch (Exception e) {
if (i == maxRetries - 1) {
throw e;
}
Thread.sleep(1000 * i);
}
}
}問題点:
- リトライロジックがビジネスロジックに混在
- エラーハンドリングのボイラープレート
- プロセス全体のクラッシュを防ぐのが困難
- 状態の復旧が複雑
一番はコードが煩雑になる。リトライ処理自体がコードを読みにくくするのが問題だと思う。
外野の声 AIがあるから気にすんな
私 そんな野暮なことは言わないで、コードにこだわらなければプログラマじゃない(と個人的に思う)
OTPの解決策
Elixir OTPでは、以下の機能が標準で提供される:
- Supervisor: プロセスの監視と自動再起動
- GenServer: 状態を持つプロセスの実装
- 再起動戦略: 障害の伝播制御
- リンクとモニター: プロセス間の依存関係管理
利点:
- ビジネスロジックとエラーハンドリングの分離
- 宣言的なフォールトトレランス
- 自動的な状態の初期化
- スケーラブルなアーキテクチャ
OTPの哲学:"Let it crash"
従来のアプローチ: Defensive Programming
# Python: 防御的プログラミング
def process_data(data):
if data is None:
return default_value
if not isinstance(data, dict):
return default_value
if 'key' not in data:
return default_value
try:
result = expensive_operation(data['key'])
if result is None:
return default_value
return result
except Exception as e:
log_error(e)
return default_value問題点:
- コードが冗長
- エラー状態が隠蔽される
- デバッグが困難
OTPのアプローチ: Let it crash
# Elixir: クラッシュさせて再起動
defmodule DataProcessor do
use GenServer
def process_data(data) do
# バリデーションなし - 不正なデータはクラッシュ
result = expensive_operation(data["key"])
result
end
end
# 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 = [
{DataProcessor, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end利点:
- コードがシンプル
- エラーが明確
- 自動的にクリーンな状態に復旧
OTPの基礎概念
プロセスモデル
Elixirでは、すべてが軽量プロセスとして実行される。
Application
└── Supervisor (監視)
├── GenServer A (状態を持つワーカー)
├── GenServer B (状態を持つワーカー)
└── Task.Supervisor (一時的タスクの監視)
├── Task 1
└── Task 2GenServer(汎用サーバー)
状態を持つプロセスの実装パターン。
defmodule Counter do
use GenServer
# クライアント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
# サーバーコールバック
@impl true
def init(initial_value) do
{:ok, initial_value}
end
@impl true
def handle_call(:increment, _from, state) do
new_state = state + 1
{:reply, new_state, new_state}
end
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
endSupervisor(監視者)
子プロセスを監視し、クラッシュ時に再起動する。
defmodule MyApp.Supervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{Counter, 0}
]
# 再起動戦略
Supervisor.init(children, strategy: :one_for_one)
end
end再起動戦略
| 戦略 | 説明 | 用途 |
|---|---|---|
| :one_for_one | クラッシュしたプロセスのみ再起動 | 独立したワーカー |
| :one_for_all | 1つがクラッシュすると全て再起動 | 相互依存するプロセス |
| :rest_for_one | クラッシュ以降のプロセスを再起動 | 順序依存のプロセス |
# :one_for_one
# Worker A がクラッシュ → Worker A のみ再起動
# Worker B, C は影響なし
# :one_for_all
# Worker A がクラッシュ → Worker A, B, C すべて再起動
# :rest_for_one
# Worker B がクラッシュ → Worker B, C を再起動(A は影響なし)基本的な自動復旧の実装
プロジェクトのセットアップ
# 新規プロジェクト作成
mix new auto_recovery --sup
cd auto_recovery
# 依存関係の追加クラッシュするGenServerの実装
defmodule AutoRecovery.CrashableWorker do
use GenServer
require Logger
# クライアントAPI
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def do_work do
GenServer.call(__MODULE__, :do_work)
end
def crash do
GenServer.call(__MODULE__, :crash)
end
def get_state do
GenServer.call(__MODULE__, :get_state)
end
# サーバーコールバック
@impl true
def init(_opts) do
Logger.info("Worker started with PID #{inspect(self())}")
{:ok, %{work_count: 0, start_time: DateTime.utc_now()}}
end
@impl true
def handle_call(:do_work, _from, state) do
new_count = state.work_count + 1
Logger.info("Doing work... count: #{new_count}")
{:reply, :ok, %{state | work_count: new_count}}
end
@impl true
def handle_call(:crash, _from, _state) do
Logger.warning("Intentional crash!")
# 意図的にクラッシュ
raise "Boom!"
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
@impl true
def terminate(reason, state) do
Logger.error("Worker terminating. Reason: #{inspect(reason)}, State: #{inspect(state)}")
:ok
end
endSupervisorの実装
defmodule AutoRecovery.Application do
use Application
require Logger
@impl true
def start(_type, _args) do
Logger.info("Starting AutoRecovery Application")
children = [
# クラッシュ可能なワーカー
{AutoRecovery.CrashableWorker, []}
]
opts = [strategy: :one_for_one, name: AutoRecovery.Supervisor]
Supervisor.start_link(children, opts)
end
end動作確認
# アプリケーションを起動
iex -S mix
# ワーカーの動作確認
iex> AutoRecovery.CrashableWorker.do_work()
:ok
iex> AutoRecovery.CrashableWorker.get_state()
%{work_count: 1, start_time: ~U[2026-02-11 10:00:00Z]}
# 意図的にクラッシュ
iex> AutoRecovery.CrashableWorker.crash()
# ** (EXIT) Boom!
# 自動的に再起動される(新しいPIDで起動)
iex> AutoRecovery.CrashableWorker.get_state()
%{work_count: 0, start_time: ~U[2026-02-11 10:00:05Z]}
# 状態がリセットされている重要: クラッシュ後、Supervisorが自動的に新しいプロセスを起動する。状態は初期化される。
リトライ戦略の実装
1. 単純なリトライ
defmodule AutoRecovery.Retry do
require Logger
@doc """
単純なリトライ(固定間隔)
"""
def retry(func, max_attempts \\ 3, delay_ms \\ 1000) do
retry_loop(func, max_attempts, delay_ms, 1)
end
defp retry_loop(func, max_attempts, delay_ms, attempt) do
case func.() do
{:ok, result} ->
{:ok, result}
{:error, reason} when attempt < max_attempts ->
Logger.warning("Attempt #{attempt} failed: #{inspect(reason)}. Retrying in #{delay_ms}ms...")
Process.sleep(delay_ms)
retry_loop(func, max_attempts, delay_ms, attempt + 1)
{:error, reason} ->
Logger.error("All #{max_attempts} attempts failed. Last error: #{inspect(reason)}")
{:error, :max_retries_exceeded}
end
end
end2. 指数バックオフ
defmodule AutoRecovery.ExponentialBackoff do
require Logger
@doc """
指数バックオフによるリトライ
"""
def retry(func, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 5)
base_delay_ms = Keyword.get(opts, :base_delay_ms, 1000)
max_delay_ms = Keyword.get(opts, :max_delay_ms, 30_000)
retry_loop(func, max_attempts, base_delay_ms, max_delay_ms, 1)
end
defp retry_loop(func, max_attempts, base_delay_ms, max_delay_ms, attempt) do
case func.() do
{:ok, result} ->
{:ok, result}
{:error, reason} when attempt < max_attempts ->
delay_ms = calculate_delay(attempt, base_delay_ms, max_delay_ms)
Logger.warning(
"Attempt #{attempt}/#{max_attempts} failed: #{inspect(reason)}. " <>
"Retrying in #{delay_ms}ms..."
)
Process.sleep(delay_ms)
retry_loop(func, max_attempts, base_delay_ms, max_delay_ms, attempt + 1)
{:error, reason} ->
Logger.error("All #{max_attempts} attempts failed. Last error: #{inspect(reason)}")
{:error, :max_retries_exceeded}
end
end
defp calculate_delay(attempt, base_delay_ms, max_delay_ms) do
# 指数バックオフ: base * 2^(attempt - 1)
delay = base_delay_ms * :math.pow(2, attempt - 1)
min(trunc(delay), max_delay_ms)
end
end3. サーキットブレーカー
defmodule AutoRecovery.CircuitBreaker do
use GenServer
require Logger
@moduledoc """
サーキットブレーカーパターンの実装
状態:
- :closed (正常) - リクエストを通す
- :open (異常) - リクエストを遮断
- :half_open (回復中) - 一部のリクエストを通してテスト
"""
defmodule State do
defstruct [
:state, # :closed | :open | :half_open
:failure_count, # 失敗回数
:failure_threshold, # 失敗しきい値
:timeout_ms, # オープン状態の持続時間
:last_failure_time # 最後の失敗時刻
]
end
# クライアントAPI
def start_link(opts) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
def call(circuit_breaker, func) do
GenServer.call(circuit_breaker, {:call, func})
end
def status(circuit_breaker) do
GenServer.call(circuit_breaker, :status)
end
# サーバーコールバック
@impl true
def init(opts) do
state = %State{
state: :closed,
failure_count: 0,
failure_threshold: Keyword.get(opts, :failure_threshold, 5),
timeout_ms: Keyword.get(opts, :timeout_ms, 60_000),
last_failure_time: nil
}
{:ok, state}
end
@impl true
def handle_call({:call, func}, _from, state) do
case state.state do
:closed ->
handle_closed(func, state)
:open ->
handle_open(func, state)
:half_open ->
handle_half_open(func, state)
end
end
@impl true
def handle_call(:status, _from, state) do
{:reply, state.state, state}
end
# プライベート関数
defp handle_closed(func, state) do
case execute_function(func) do
{:ok, result} ->
# 成功: 失敗カウントをリセット
new_state = %{state | failure_count: 0}
{:reply, {:ok, result}, new_state}
{:error, reason} ->
# 失敗: カウントを増やす
new_failure_count = state.failure_count + 1
if new_failure_count >= state.failure_threshold do
Logger.warning("Circuit breaker opened after #{new_failure_count} failures")
new_state = %{state |
state: :open,
failure_count: new_failure_count,
last_failure_time: System.monotonic_time(:millisecond)
}
{:reply, {:error, reason}, new_state}
else
new_state = %{state | failure_count: new_failure_count}
{:reply, {:error, reason}, new_state}
end
end
end
defp handle_open(func, state) do
current_time = System.monotonic_time(:millisecond)
elapsed = current_time - state.last_failure_time
if elapsed >= state.timeout_ms do
# タイムアウト経過: half_openに移行
Logger.info("Circuit breaker transitioning to half_open")
new_state = %{state | state: :half_open}
handle_half_open(func, new_state)
else
# まだタイムアウト前: リクエストを遮断
{:reply, {:error, :circuit_open}, state}
end
end
defp handle_half_open(func, state) do
case execute_function(func) do
{:ok, result} ->
# 成功: 回復してclosedに戻る
Logger.info("Circuit breaker closed (recovered)")
new_state = %{state | state: :closed, failure_count: 0}
{:reply, {:ok, result}, new_state}
{:error, reason} ->
# 失敗: 再びopenに戻る
Logger.warning("Circuit breaker reopened")
new_state = %{state |
state: :open,
last_failure_time: System.monotonic_time(:millisecond)
}
{:reply, {:error, reason}, new_state}
end
end
defp execute_function(func) do
try do
result = func.()
{:ok, result}
rescue
e ->
{:error, e}
catch
:exit, reason ->
{:error, reason}
end
end
end実践例1: 外部API呼び出しの自動復旧
HTTPクライアントのラッパー
defp deps do
[
{:httpoison, "~> 2.0"},
{:jason, "~> 1.4"}
]
enddefmodule AutoRecovery.ApiClient do
use GenServer
require Logger
alias AutoRecovery.{ExponentialBackoff, CircuitBreaker}
defmodule State do
defstruct [:base_url, :circuit_breaker]
end
# クライアントAPI
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def fetch_weather(city) do
GenServer.call(__MODULE__, {:fetch_weather, city}, 30_000)
end
# サーバーコールバック
@impl true
def init(opts) do
base_url = Keyword.get(opts, :base_url, "https://api.weather.example.com")
# サーキットブレーカーを起動
{:ok, circuit_breaker} = CircuitBreaker.start_link(
name: :api_circuit_breaker,
failure_threshold: 3,
timeout_ms: 30_000
)
state = %State{
base_url: base_url,
circuit_breaker: circuit_breaker
}
{:ok, state}
end
@impl true
def handle_call({:fetch_weather, city}, _from, state) do
result = ExponentialBackoff.retry(fn ->
fetch_with_circuit_breaker(city, state)
end, max_attempts: 3, base_delay_ms: 1000)
{:reply, result, state}
end
# プライベート関数
defp fetch_with_circuit_breaker(city, state) do
CircuitBreaker.call(state.circuit_breaker, fn ->
do_fetch(city, state.base_url)
end)
end
defp do_fetch(city, base_url) do
url = "#{base_url}/weather?city=#{URI.encode(city)}"
Logger.info("Fetching weather for #{city} from #{url}")
case HTTPoison.get(url, [], recv_timeout: 5_000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
case Jason.decode(body) do
{:ok, data} ->
{:ok, data}
{:error, reason} ->
Logger.error("JSON decode error: #{inspect(reason)}")
{:error, :invalid_json}
end
{:ok, %HTTPoison.Response{status_code: status_code}} ->
Logger.error("HTTP error: #{status_code}")
{:error, {:http_error, status_code}}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("HTTP request failed: #{inspect(reason)}")
{:error, reason}
end
end
endSupervisorへの追加
defmodule AutoRecovery.Application do
use Application
require Logger
@impl true
def start(_type, _args) do
children = [
{AutoRecovery.CrashableWorker, []},
{AutoRecovery.ApiClient, base_url: "https://api.weather.example.com"}
]
opts = [strategy: :one_for_one, name: AutoRecovery.Supervisor]
Supervisor.start_link(children, opts)
end
end使用例
# APIクライアントを使用
iex> AutoRecovery.ApiClient.fetch_weather("Tokyo")
{:ok, %{"city" => "Tokyo", "temp" => 15, "condition" => "Sunny"}}
# エラー時は自動的にリトライ
iex> AutoRecovery.ApiClient.fetch_weather("InvalidCity")
# ログ: Attempt 1/3 failed... Retrying in 1000ms...
# ログ: Attempt 2/3 failed... Retrying in 2000ms...
# ログ: All 3 attempts failed.
{:error, :max_retries_exceeded}実践例2: データベース接続の自動復旧
Ectoの設定
defp deps do
[
{:ecto_sql, "~> 3.10"},
{:postgrex, "~> 0.17"}
]
endimport Config
config :auto_recovery, AutoRecovery.Repo,
database: "auto_recovery_dev",
username: "postgres",
password: "postgres",
hostname: "localhost",
pool_size: 10Repoの定義
defmodule AutoRecovery.Repo do
use Ecto.Repo,
otp_app: :auto_recovery,
adapter: Ecto.Adapters.Postgres
endデータベースワーカー
defmodule AutoRecovery.DbWorker do
use GenServer
require Logger
alias AutoRecovery.Repo
# クライアントAPI
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def execute_query(query) do
GenServer.call(__MODULE__, {:execute_query, query}, 10_000)
end
# サーバーコールバック
@impl true
def init(_opts) do
Logger.info("DbWorker started")
{:ok, %{}}
end
@impl true
def handle_call({:execute_query, query}, _from, state) do
result = AutoRecovery.ExponentialBackoff.retry(fn ->
execute_with_timeout(query)
end, max_attempts: 3, base_delay_ms: 500)
{:reply, result, state}
end
# プライベート関数
defp execute_with_timeout(query) do
try do
result = Repo.query(query, [], timeout: 5_000)
{:ok, result}
rescue
Ecto.QueryError -> {:error, :query_error}
DBConnection.ConnectionError -> {:error, :connection_error}
catch
:exit, {:timeout, _} -> {:error, :timeout}
end
end
end接続プールの監視
Ectoの接続プールは、標準でSupervisorによって監視されている。接続が切れた場合、自動的に再接続が試みられる。
defmodule AutoRecovery.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Ecto Repo(接続プール)
AutoRecovery.Repo,
# その他のワーカー
{AutoRecovery.DbWorker, []}
]
opts = [strategy: :one_for_one, name: AutoRecovery.Supervisor]
Supervisor.start_link(children, opts)
end
end高度なパターン
1. DynamicSupervisor
動的に子プロセスを追加・削除できるSupervisor。
defmodule AutoRecovery.JobProcessor do
use GenServer
require Logger
def start_link(job_id) do
GenServer.start_link(__MODULE__, job_id)
end
@impl true
def init(job_id) do
Logger.info("Processing job #{job_id}")
Process.send_after(self(), :process, 1000)
{:ok, %{job_id: job_id}}
end
@impl true
def handle_info(:process, state) do
Logger.info("Job #{state.job_id} completed")
{:stop, :normal, state}
end
enddefmodule AutoRecovery.JobSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_job(job_id) do
spec = {AutoRecovery.JobProcessor, job_id}
DynamicSupervisor.start_child(__MODULE__, spec)
end
end2. Task.Supervisor
一時的なタスクの実行と監視。
defmodule AutoRecovery.AsyncWorker do
require Logger
def perform_async(task_name, func) do
Task.Supervisor.async_nolink(AutoRecovery.TaskSupervisor, fn ->
Logger.info("Starting task: #{task_name}")
result = func.()
Logger.info("Task #{task_name} completed")
result
end)
end
def await_result(task, timeout \\ 5000) do
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, result} ->
{:ok, result}
nil ->
Logger.error("Task timed out")
{:error, :timeout}
end
end
endchildren = [
# Task.Supervisorを追加
{Task.Supervisor, name: AutoRecovery.TaskSupervisor},
# ...
]3. Registry
プロセスの名前解決と動的な検索。
defmodule AutoRecovery.UserSession do
use GenServer
require Logger
# クライアントAPI
def start_link(user_id) do
GenServer.start_link(__MODULE__, user_id, name: via_tuple(user_id))
end
def get_session(user_id) do
case Registry.lookup(AutoRecovery.Registry, user_id) do
[{pid, _}] ->
GenServer.call(pid, :get_session)
[] ->
{:error, :not_found}
end
end
defp via_tuple(user_id) do
{:via, Registry, {AutoRecovery.Registry, user_id}}
end
# サーバーコールバック
@impl true
def init(user_id) do
Logger.info("User session started for user #{user_id}")
{:ok, %{user_id: user_id, logged_in_at: DateTime.utc_now()}}
end
@impl true
def handle_call(:get_session, _from, state) do
{:reply, state, state}
end
endchildren = [
# Registryを追加
{Registry, keys: :unique, name: AutoRecovery.Registry},
# ...
]4. Telemetry
メトリクスの収集とモニタリング。
defp deps do
[
{:telemetry, "~> 1.0"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"}
]
enddefmodule AutoRecovery.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# カウンター
counter("auto_recovery.api.requests.total"),
counter("auto_recovery.api.errors.total"),
# ヒストグラム
distribution("auto_recovery.api.request.duration",
unit: {:native, :millisecond}
),
# 最終値
last_value("vm.memory.total", unit: {:byte, :megabyte}),
last_value("vm.total_run_queue_lengths.total")
]
end
defp periodic_measurements do
[]
end
# イベントの発行
def emit_api_request(duration_ms) do
:telemetry.execute(
[:auto_recovery, :api, :request],
%{duration: duration_ms},
%{}
)
end
endテスト戦略
プロセスクラッシュのテスト
defmodule AutoRecovery.CrashableWorkerTest do
use ExUnit.Case
alias AutoRecovery.CrashableWorker
setup do
# テスト用に新しいプロセスを起動
{:ok, pid} = CrashableWorker.start_link([])
%{pid: pid}
end
test "worker handles work correctly", %{pid: _pid} do
assert :ok = CrashableWorker.do_work()
state = CrashableWorker.get_state()
assert state.work_count == 1
end
test "worker restarts after crash" do
# クラッシュ前の状態を取得
CrashableWorker.do_work()
state_before = CrashableWorker.get_state()
assert state_before.work_count == 1
# クラッシュさせる
catch_exit(CrashableWorker.crash())
# 少し待つ(再起動を待つ)
Process.sleep(100)
# 再起動後の状態を確認
state_after = CrashableWorker.get_state()
assert state_after.work_count == 0 # 状態がリセット
end
endリトライのテスト
defmodule AutoRecovery.RetryTest do
use ExUnit.Case
alias AutoRecovery.Retry
test "retry succeeds on first attempt" do
func = fn -> {:ok, "success"} end
assert {:ok, "success"} = Retry.retry(func)
end
test "retry succeeds after failures" do
# 最初の2回は失敗、3回目に成功
agent = start_supervised!({Agent, fn -> 0 end})
func = fn ->
count = Agent.get_and_update(agent, fn state ->
{state, state + 1}
end)
if count < 2 do
{:error, :temporary_failure}
else
{:ok, "success"}
end
end
assert {:ok, "success"} = Retry.retry(func, 3, 10)
end
test "retry fails after max attempts" do
func = fn -> {:error, :permanent_failure} end
assert {:error, :max_retries_exceeded} = Retry.retry(func, 3, 10)
end
endサーキットブレーカーのテスト
defmodule AutoRecovery.CircuitBreakerTest do
use ExUnit.Case
alias AutoRecovery.CircuitBreaker
setup do
{:ok, cb} = CircuitBreaker.start_link(
failure_threshold: 3,
timeout_ms: 100
)
%{circuit_breaker: cb}
end
test "circuit breaker opens after threshold", %{circuit_breaker: cb} do
failing_func = fn -> raise "error" end
# 最初の2回は失敗してもclosed
assert {:error, _} = CircuitBreaker.call(cb, failing_func)
assert {:error, _} = CircuitBreaker.call(cb, failing_func)
assert :closed = CircuitBreaker.status(cb)
# 3回目でopenになる
assert {:error, _} = CircuitBreaker.call(cb, failing_func)
assert :open = CircuitBreaker.status(cb)
# openの間はリクエストが遮断される
assert {:error, :circuit_open} = CircuitBreaker.call(cb, failing_func)
end
test "circuit breaker transitions to half_open", %{circuit_breaker: cb} do
failing_func = fn -> raise "error" end
# openにする
CircuitBreaker.call(cb, failing_func)
CircuitBreaker.call(cb, failing_func)
CircuitBreaker.call(cb, failing_func)
assert :open = CircuitBreaker.status(cb)
# タイムアウトを待つ
Process.sleep(150)
# 次のリクエストでhalf_openに移行
success_func = fn -> :ok end
assert {:ok, :ok} = CircuitBreaker.call(cb, success_func)
# 成功したのでclosedに戻る
assert :closed = CircuitBreaker.status(cb)
end
end本番環境での運用
ロギング
import Config
config :logger, level: :info
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id, :user_id, :module]defmodule AutoRecovery.LoggerHelper do
require Logger
def log_with_metadata(level, message, metadata \\ []) do
Logger.metadata(metadata)
Logger.log(level, message)
Logger.reset_metadata()
end
def log_error_with_stacktrace(error, stacktrace) do
Logger.error("""
Error: #{inspect(error)}
Stacktrace:
#{Exception.format_stacktrace(stacktrace)}
""")
end
endメトリクスの収集
defmodule AutoRecovery.Metrics do
use GenServer
require Logger
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def record_api_call(duration_ms, status) do
GenServer.cast(__MODULE__, {:record_api_call, duration_ms, status})
end
def get_stats do
GenServer.call(__MODULE__, :get_stats)
end
@impl true
def init(_opts) do
state = %{
total_calls: 0,
successful_calls: 0,
failed_calls: 0,
total_duration_ms: 0
}
{:ok, state}
end
@impl true
def handle_cast({:record_api_call, duration_ms, status}, state) do
new_state = %{
state |
total_calls: state.total_calls + 1,
total_duration_ms: state.total_duration_ms + duration_ms
}
new_state = case status do
:success -> %{new_state | successful_calls: new_state.successful_calls + 1}
:failure -> %{new_state | failed_calls: new_state.failed_calls + 1}
end
{:noreply, new_state}
end
@impl true
def handle_call(:get_stats, _from, state) do
avg_duration = if state.total_calls > 0 do
state.total_duration_ms / state.total_calls
else
0
end
stats = Map.put(state, :avg_duration_ms, avg_duration)
{:reply, stats, state}
end
endデバッグ
# 実行中のプロセスを確認
iex> Process.list() |> length()
42
# 特定のプロセスの情報
iex> pid = Process.whereis(AutoRecovery.ApiClient)
iex> Process.info(pid)
# Supervisorの子プロセスを確認
iex> Supervisor.which_children(AutoRecovery.Supervisor)
[
{AutoRecovery.ApiClient, #PID<0.200.0>, :worker, [AutoRecovery.ApiClient]},
{AutoRecovery.CrashableWorker, #PID<0.199.0>, :worker, [AutoRecovery.CrashableWorker]}
]
# プロセスの状態を取得
iex> :sys.get_state(AutoRecovery.ApiClient)
%AutoRecovery.ApiClient.State{...}まとめ
OTPの利点
- 自動復旧: Supervisorによる自動再起動
- 障害の局所化: プロセス単位での障害管理
- 状態の分離: 各プロセスが独立した状態を持つ
- 宣言的な設計: Supervisorツリーで構造を表現
- スケーラビリティ: 数百万のプロセスを管理可能
ベストプラクティス
"Let it crash"を実践する
- 防御的プログラミングを避ける
- 異常な状態でクラッシュさせる
- Supervisorに復旧を任せる
適切な再起動戦略を選択する
-
独立したワーカー:
:one_for_one -
相互依存:
:one_for_all -
順序依存:
:rest_for_one
-
独立したワーカー:
リトライ戦略を適切に選択する
- 一時的なエラー: 指数バックオフ
- 外部サービス: サーキットブレーカー
- 重要な処理: デッドレターキュー
状態を最小限にする
- GenServerの状態は必要最小限に
- 永続化が必要なデータはデータベースへ
- 状態の再構築が容易な設計
監視とロギングを充実させる
- Telemetryでメトリクス収集
- 適切なログレベルの使用
- エラー時のスタックトレース記録
アンチパターン
過度な防御的プログラミング
# ❌ 悪い例 def process(data) do if data != nil and is_map(data) and Map.has_key?(data, :key) do # ... else {:error, :invalid_data} end end # ✅ 良い例 def process(%{key: value}) do # パターンマッチで失敗させる endSupervisorなしでの長時間実行プロセス
# ❌ 悪い例: Supervisorなし {:ok, pid} = GenServer.start_link(MyWorker, []) # ✅ 良い例: Supervisorで監視 children = [{MyWorker, []}] Supervisor.start_link(children, strategy: :one_for_one)状態の過度な蓄積
# ❌ 悪い例: すべてのリクエストを保存 def handle_call(:request, _from, state) do new_state = Map.update(state, :requests, [], fn list -> [request | list] # メモリリーク end) end # ✅ 良い例: 必要な情報のみ保持 def handle_call(:request, _from, state) do new_state = %{state | last_request_at: DateTime.utc_now()} endリトライのない外部呼び出し
# ❌ 悪い例: リトライなし HTTPoison.get(url) # ✅ 良い例: リトライあり ExponentialBackoff.retry(fn -> HTTPoison.get(url) end)
次のステップ
本稿で示した自動復旧アーキテクチャを基に、以下の発展的なトピックに取り組まれたい:
- 分散システム: ノード間のプロセス監視
- 永続化: データベースとの統合
- メッセージキュー: RabbitMQ、Kafkaとの連携
- クラスタリング: 複数ノードでの負荷分散
- ホットコードアップグレード: 無停止でのデプロイ
Elixir OTPは、堅牢で保守性の高いシステムを構築するための強力な基盤を提供する。