Деревья супервизоров и стратегии перезапуска

Как Erlang организует самовосстановление в иерархию.

Супервизор — процесс OTP, единственная задача которого — запускать дочерние процессы, следить за ними и перезапускать упавших по заданной стратегии.

Супервизоры — то, что превращает отдельные устойчивые процессы в устойчивую систему. Они не содержат бизнес-логики; их работа — надзор и восстановление. В этом и состоит красота идеи: один процесс умеет хорошо делать свою работу и честно падать при сбое, а другой умеет хорошо перезапускать упавших — и из этих двух простых ролей складывается система, которая способна часами и сутками держаться сама, без вмешательства человека. Супервизор — прямое воплощение всего, что мы обсуждали раньше: он использует link и trap_exit, чтобы узнавать о смерти детей, и реализует политику восстановления поверх философии «let it crash». По сути это «менеджер среднего звена» вашей системы: сам ничего не производит, но следит, чтобы производящие не простаивали.

Дочерняя спецификация

Супервизор знает о своих детях из списка спецификаций. Каждая описывает: какой процесс запускать, как часто его перезапускать (permanent, transient, temporary) и как корректно завершать. Ключевое поле start — это тройка «модуль, функция, аргументы», указывающая, как именно поднять ребёнка; обратите внимание, что вызываемая функция должна быть из семейства start_link, ведь супервизору необходима связь с ребёнком, чтобы ловить его падения. Поле id — внутренний идентификатор ребёнка в рамках этого супервизора, по нему его потом можно найти, остановить или перезапустить вручную. В современном OTP спецификации записывают картами (maps), как в примере ниже, и поля с разумными значениями по умолчанию (например, способ завершения shutdown или тип worker) можно опускать — OTP подставит безопасные значения сам.

-module(my_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    SupFlags = #{strategy => one_for_one,
                 intensity => 3, period => 5},
    Children = [
        #{id => counter,
          start => {counter, start_link, []},
          restart => permanent}
    ],
    {ok, {SupFlags, Children}}.

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

Стратегия определяет, что делать с другими детьми, когда один упал.

СтратегияПри падении ребёнка
one_for_oneперезапустить только упавшего
one_for_allперезапустить всех детей
rest_for_oneперезапустить упавшего и всех запущенных после него

one_for_one подходит независимым детям. one_for_all — когда дети взаимозависимы и должны стартовать вместе. rest_for_one — когда есть цепочка зависимостей по порядку запуска. Разберём rest_for_one на примере: пусть запускаются по порядку соединение с БД, затем кэш, который пользуется этим соединением, затем веб-обработчик, опирающийся на кэш. Если упадёт кэш (второй в цепочке), перезапускать соединение бессмысленно — оно цело, — а вот веб-обработчик нужно поднять заново, ведь он держал ссылку на старый, уже мёртвый кэш. Стратегия rest_for_one сделает ровно это: перезапустит упавшего и всех, кто стартовал после него, не трогая тех, кто был запущен раньше и от упавшего не зависит. Выбор стратегии — это, по сути, явное описание графа зависимостей между детьми, и думать о нём стоит именно так: «кого ещё затронет смерть вот этого процесса?».

Типы перезапуска ребёнка

  • permanent — перезапускать всегда (для критичных сервисов).
  • transient — перезапускать только при ненормальном завершении.
  • temporary — не перезапускать никогда.

Интенсивность: защита от «петли смерти»

Что если процесс падает сразу после перезапуска, снова и снова? Бесконечный цикл перезапусков бесполезен. Поэтому у супервизора есть лимит: intensity перезапусков за period секунд. Если лимит превышен, супервизор сдаётся и сам падает — передавая проблему своему супервизору выше по дереву. В примере выше заданы intensity => 3 и period => 5: это значит «не более трёх перезапусков за любые пять секунд». Смысл этого механизма глубже, чем просто «не зацикливаться». Он проводит границу между двумя сортами проблем. Если сбой был случайным (тот самый Гейзенбаг), перезапуск его вылечит, и лимит никогда не будет достигнут — система тихо самоисцелится. Но если процесс падает детерминированно, на одних и тех же данных, перезапуск не поможет, лимит быстро исчерпается, и супервизор честно признает: «локально я это починить не могу». Тогда проблема эскалируется выше, где, возможно, перезапустят и сам супервизор вместе с целой подсистемой, восстановив больший кусок состояния. Так превышение интенсивности — не сбой механизма, а его штатный сигнал «нужно вмешательство уровнем выше».

Дерево супервизоров

Супервизоры могут надзирать за другими супервизорами, образуя дерево. Листья — рабочие процессы, узлы — супервизоры. Сбой поднимается по дереву ровно настолько высоко, насколько нужно для восстановления. Эта иерархия — не формальность, а способ локализовать отказ. Чем ниже в дереве удаётся погасить проблему, тем меньший кусок системы при этом перезапускается и тем меньше теряется работы. Поэтому деревья проектируют так, чтобы тесно связанные процессы сидели под общим супервизором поближе к листьям, а независимые подсистемы расходились по разным ветвям — тогда сбой в одной из них не заденет другие. Самый верхний супервизор называют корневым: его запускает приложение (о них в следующем уроке), и именно он держит всё дерево. Падение, дошедшее до самого верха и исчерпавшее лимиты там, означает, что приложение не смогло само себя восстановить — это уже серьёзный сигнал для системы в целом.

         [Главный супервизор]
          /                 \
   [Супервизор A]      [Супервизор B]
     /      \               |
 worker   worker         worker

Как работает под капотом

Супервизор запускает детей с spawn_link и ставит trap_exit, так что смерть ребёнка приходит как сообщение {'EXIT', ...}. Получив его, супервизор применяет стратегию: перезапускает нужных детей в порядке их спецификаций. Счётчик перезапусков скользит по окну period; превышение intensity заставляет супервизор завершиться, передав отказ выше. Так дерево само эскалирует проблему до уровня, способного её решить.

Частые ошибки

  • Класть бизнес-логику в супервизор. Его дело — только надзор; работу делают worker'ы.
  • Выбрать one_for_all для независимых детей. Падение одного зря перезапустит остальных.
  • Слишком высокий intensity. Тогда дерево не заметит «петлю смерти» и будет крутить её впустую.

Итоги

  • Супервизор запускает детей, следит за ними и перезапускает по стратегии.
  • Стратегии: one_for_one, one_for_all, rest_for_one.
  • intensity/period защищают от бесконечной «петли смерти».
  • Супервизоры образуют дерево, эскалируя сбой вверх до уровня восстановления.
Проверьте себя
1. Что делает стратегия one_for_one при падении ребёнка?
AПерезапускает всех детей
BПерезапускает только упавшего ребёнка
CОстанавливает супервизор
DНичего не делает
2. Зачем супервизору параметры intensity и period?
AДля ускорения запуска
BЧтобы ограничить число перезапусков за время и не крутить бесконечную «петлю смерти»
CДля логирования
DЧтобы задать имя
3. Что произойдёт, если супервизор превысит лимит перезапусков?
AОн продолжит перезапускать вечно
BОн сам завершится, передав отказ своему супервизору выше по дереву
CСистема выключится
DНичего