Супервизоры и деревья надзора
Супервизор — процесс, единственная задача которого следить за другими и перезапускать упавших. Деревья супервизоров — это и есть «отказоустойчивость» BEAM в действии.
«Let it crash» работает только потому, что есть кому перезапустить. Этот «кто-то» — супервизор.
Супервизор стартует дочерние процессы и перезапускает их по выбранной стратегии, если они падают. Он не содержит бизнес-логики — только надзор.
defmodule MyApp.Supervisor do
use Supervisor
def start_link(_), do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
def init(:ok) do
children = [
{Counter, 0}, # дочерний GenServer
{Cache, []} # ещё один
]
Supervisor.init(children, strategy: :one_for_one)
end
end
Стратегии определяют, кого перезапускать при падении ребёнка:
- :one_for_one — перезапустить только упавшего (самая частая).
- :one_for_all — перезапустить всех детей (когда они взаимозависимы).
- :rest_for_one — упавшего и всех, кто стартовал после него.
Супервизоры могут содержать другие супервизоры — так строится дерево надзора.
Как работает под капотом (BEAM)
Супервизор использует те самые links из раздела о процессах: он связывается с каждым ребёнком, поэтому узнаёт о падении. Когда ребёнок падает, супервизор по стратегии решает, кого перезапустить, и стартует свежий экземпляр в чистом начальном состоянии — именно поэтому «let it crash» безопасен: повреждённое состояние выбрасывается, а не чинится. Чтобы один вечно падающий процесс не молотил рестарты бесконечно, у супервизора есть лимит: больше N перезапусков за M секунд — и падает уже сам супервизор, передавая проблему уровнем выше. Так сбой эскалируется по дереву.
Дерево супервизоров:
[Application Supervisor]
/ \
[Sup: Web] [Sup: Workers]
/ \ / \
[Conn1] [Conn2] [Worker1] [Worker2]
падает Worker1 -> его супервизор поднимает новый
(чистое состояние). Остальные не тронуты.
Та же идея на Python ▶
Смоделируем перезапуск с чистого состояния и лимит рестартов.
def start_worker(initial=0):
return {"state": initial, "alive": True}
def supervise(work, max_restarts=3, window=()):
restarts = 0
while restarts <= max_restarts:
worker = start_worker() # СВЕЖЕЕ чистое состояние
try:
return work(worker)
except Exception as e:
restarts += 1
print(f"ребёнок упал ({e}); перезапуск {restarts}")
print("превышен лимит рестартов -> эскалация выше")
return None
attempts = {"n": 0}
def flaky(worker):
attempts["n"] += 1
if attempts["n"] < 3:
raise RuntimeError("boom")
return "ok после рестартов"
print(supervise(flaky))
# ребёнок упал (boom); перезапуск 1
# ребёнок упал (boom); перезапуск 2
# ok после рестартов
Частые ошибки
- Класть бизнес-логику в супервизор. Его дело — только надзор; логика живёт в GenServer'ах.
- Неверная стратегия.
:one_for_allдля независимых детей зря перезапустит всех; выбирайте по зависимостям. - Игнорировать лимит рестартов. Постоянно падающий ребёнок упрётся в лимит и уронит супервизор — это сигнал о настоящей проблеме, а не повод поднять лимит.
Best practices
- Начинайте с
:one_for_one; меняйте стратегию, только когда дети взаимозависимы. - Стройте дерево: верхние супервизоры надзирают за нижними, изолируя сбои по поддеревьям.
- Пусть процессы падают на некорректном состоянии — супервизор поднимет их чистыми. Не ловите всё подряд.
Итог. Супервизоры превращают «let it crash» в реальную отказоустойчивость: упал — перезапустили с чистого состояния. Дерево супервизоров — скелет любого серьёзного OTP-приложения. Осталось собрать всё в приложение и наметить путь дальше.
Изоляция сбоев проектированием дерева
Форма дерева супервизоров — это проектное решение об изоляции сбоев. Размещая взаимозависимые процессы под одним супервизором со стратегией :one_for_all, вы говорите «если падает один, перезапускаем всю группу, потому что по отдельности они бессмысленны». Разнося независимые подсистемы по разным поддеревьям, вы гарантируете, что проблема в одной не заденет другую. Так дерево становится картой того, какие части системы связаны судьбой, а какие — нет.
Важно правильно понимать лимит рестартов (по умолчанию — несколько за пять секунд). Он не баг, а защита: если процесс падает снова и снова, перезапуск не помогает — проблема системная (битый конфиг, недоступная база). Тогда супервизор намеренно «сдаётся» и падает сам, эскалируя проблему уровнем выше, где её может решить более крупный перезапуск или, в пределе, корректная остановка узла. Поднимать лимит, чтобы «оно перестало падать», — почти всегда ошибка: вы глушите сигнал о реальной поломке вместо того, чтобы её устранить.