Супервизоры и деревья надзора

Супервизор — процесс, единственная задача которого следить за другими и перезапускать упавших. Деревья супервизоров — это и есть «отказоустойчивость» 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, вы говорите «если падает один, перезапускаем всю группу, потому что по отдельности они бессмысленны». Разнося независимые подсистемы по разным поддеревьям, вы гарантируете, что проблема в одной не заденет другую. Так дерево становится картой того, какие части системы связаны судьбой, а какие — нет.

Важно правильно понимать лимит рестартов (по умолчанию — несколько за пять секунд). Он не баг, а защита: если процесс падает снова и снова, перезапуск не помогает — проблема системная (битый конфиг, недоступная база). Тогда супервизор намеренно «сдаётся» и падает сам, эскалируя проблему уровнем выше, где её может решить более крупный перезапуск или, в пределе, корректная остановка узла. Поднимать лимит, чтобы «оно перестало падать», — почти всегда ошибка: вы глушите сигнал о реальной поломке вместо того, чтобы её устранить.

Проверьте себя
1. Какая стратегия супервизора перезапускает только упавший дочерний процесс?
A:one_for_all
B:one_for_one
C:rest_for_one
D:all_for_one
2. Почему перезапуск процесса супервизором делает «let it crash» безопасным?
AПадения не происходят
BСупервизор стартует процесс заново в чистом начальном состоянии, отбрасывая повреждённое
CСупервизор чинит старое состояние
Dcall всегда успешен