Как организовать fault-tolerant систему с использованием OTP?

В Elixir fault-tolerant система (устойчивая к сбоям) реализуется с использованием OTP (Open Telecom Platform) — набора библиотек и принципов построения отказоустойчивых приложений на платформе Erlang/BEAM. Основная идея: не бороться с ошибками внутри процессов, а разделять ответственность и восстанавливать систему автоматически через архитектуру супервизоров, процессов и правил перезапуска.

Основные элементы архитектуры OTP, обеспечивающие отказоустойчивость

1. Процессы BEAM

  • Каждый процесс полностью изолирован.

  • Нет общей памяти, вся коммуникация через send/receive.

  • Ошибки в одном процессе не влияют на другие напрямую.

  • Процессы лёгкие (тысячи и миллионы одновременно).

2. Супервизоры (Supervisors)

  • Процессы, наблюдающие за другими процессами.

  • Отвечают за автоматический перезапуск потомков при сбое.

  • Позволяют реализовать стратегию восстановления: :one_for_one, :rest_for_one, :one_for_all, :simple_one_for_one.

Пример:

children = \[
{MyApp.Worker, arg},
{MyApp.AnotherWorker, other_arg}
\]
Supervisor.start_link(children, strategy: :one_for_one)

3. Модель "Let it crash"

  • Не нужно ловить все ошибки внутри кода.

  • Процесс, встретивший неразрешимую ситуацию, падает.

  • Супервизор перезапускает его с нуля.

  • Это упрощает код, снижает количество проверок.

4. GenServer

  • Абстракция над процессом.

  • Позволяет легко управлять состоянием, обрабатывать сообщения, таймеры.

  • Используется как рабочая единица (worker) внутри супервизоров.

Пример:

defmodule MyServer do
use GenServer
def start_link(init_arg), do: GenServer.start_link(\__MODULE_\_, init_arg, name: \__MODULE_\_)
def init(state), do: {:ok, state}
def handle_call(:ping, \_from, state), do: {:reply, :pong, state}
end

5. Supervision Tree (дерево процессов)

  • Древовидная структура, где каждый супервизор управляет потомками.

  • Упрощает диагностику и контроль сбоев.

  • Глубина и ширина дерева могут быть произвольными.

  • На верхушке — модуль Application.

Пример структуры:

Application

├── Supervisor (DB Layer)
 ├── GenServer (RepoPool)
 └── GenServer (Cache)

├── Supervisor (Business Logic)
 └── GenServer (UserService)

└── Supervisor (Web Interface)
├── GenServer (Endpoint)
└── Task.Supervisor (for dynamic tasks)

Стратегии перезапуска

  1. :one_for_one — при сбое одного дочернего процесса перезапускается только он.

  2. :one_for_all — при падении одного дочернего все остальные тоже перезапускаются.

  3. :rest_for_one — при падении процесса перезапускаются он и все «ниже» по списку.

  4. :simple_one_for_one — устаревшая стратегия для динамически добавляемых однотипных дочерних процессов (заменена DynamicSupervisor).

Пример: Минимальное fault-tolerant приложение

\# my_app/application.ex
defmodule MyApp.Application do
use Application
def start(\_type, \_args) do
children = \[
{MyApp.Cache, \[\]},
{MyApp.DB, \[\]}
\]
opts = \[strategy: :one_for_one, name: MyApp.Supervisor\]
Supervisor.start_link(children, opts)
end
end

При сбое в MyApp.Cache, только он будет перезапущен. Если нужно, чтобы сбой в MyApp.DB также перезапускал другие модули, можно выбрать :one_for_all.

Пример использования DynamicSupervisor

Иногда не все процессы известны заранее. Тогда применяется DynamicSupervisor:

{:ok, pid} = DynamicSupervisor.start_child(MyApp.DynamicSup, {MyWorker, arg})

Такой подход удобен для обработки входящих запросов, фоновых заданий, веб-сокетов и т.п.

Использование Task и Agent

  • Task — для короткоживущих фоновых задач. Удобно использовать Task.Supervisor.

  • Agent — для хранения изменяемого состояния (удобная обёртка над GenServer для state).

Они также могут быть под контролем супервизоров и перезапускаться при сбое.

Использование Registry для именования

Registry используется для регистрации процессов по имени, что важно в распределённой системе:

Registry.register(MyRegistry, :user_123, :some_meta)

Важные принципы отказоустойчивости

  • Каждый процесс должен делать одну конкретную задачу.

  • Ошибки не подавляются, а передаются вверх по дереву.

  • Все состояния процессов инициализируются в init/1.

  • Не использовать глобальные переменные — только message-passing.

  • Использовать ETS или Agent для состояния, если оно должно быть доступно другим.

OTP предоставляет архитектуру, в которой каждая единица (процесс) — самодостаточна, изолирована и восстанавливаема. Это делает систему способной справляться с отказами без полной остановки, поддерживая непрерывную работу даже при сбоях отдельных компонентов.