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

Вместо этого мы понапридумывали всякие sticky sessions, умный роутинг, да даже web-сокеты выросли не из целесообразности сохранения состояния соединения (что хорошо, правильно и часто нужно), а из приклеивания к инстансу сервера (что никогда не нужно, и что практически во всех решениях мешает открыть два вебсокета и получать данные вперемешку из обоих, как это можно сделать с брокером сообщений).

Приклеивать сессию к физическому инстансу (ноде, условно говоря, к IPv4/IPv6) — бред, лишний слой для обеспечения этого только мешает и всё портит, ведь сервер всегда обладает всей необходимой информацией для того, чтобы принять запрос на ноде A, выполнить его на ноде B, а потом ответить всё с той же ноды A. Но делегация выполнения функции на соседнюю ноду — существующая уже сорок лет в эрланге — это же какой-то прям рокетсаенс. Поэтому когда к нам всё-таки приходит понимание того, что иногда одной ноды для полной обработки запроса недостаточно, — мы городим микросервисную архитектуру поверх редисов, или даже брокеров сообщений, и выдумываем саги о каких-то совершенно в данном случае ненужных форсайтах и прочий хайтек — вокруг тривиальной задачи.

Смотрите, как мы можем принимать запросы на одной ноде, а обрабатывать их на другой, в эликсире. Код (вызов функции handle_request/1 символизирует приём и отправку запроса), которого достаточно, чтобы поиграться в это просто на разных нодах (скриншот ниже) — выглядит вот так (локальный пример, одна нода на всё про всё):

defmodule Web do
  def run_handler,
    do: GenServer.start_link(Handler, :ok, name: Handler)

  def request(params) do
    GenServer.call(Handler, {:handle_request, params})
  end
end

defmodule Handler do
  use GenServer

  @impl GenServer
  def handle_call({:handle_request, params}, _from, state),
    do: {:reply, {:ok, params}, state}
end

Если скопипастить код выше в iex, и проигнорировать отладочный вывод компилятора, можно дальше выполнить следующее:

iex|🌢|4 ▶ Web.run_handler()
{:ok, #PID<0.121.0>}
iex|🌢|5 ▶ Web.request(42) # эмуляция запроса извне
{:ok, 42}

Ок, скажете вы, а при чем тут распределенные вызовы и горизонтальное масштабирование? Давайте подправим наш код, чтобы запрос принимался на одной ноде, а обрабатывался на другой:

-    GenServer.call(Handler, {:handle_request, params})
+    GenServer.call({Handler, :"n2@am-victus"}, {:handle_request, params})

Чтобы не запутаться в терминалах, вот скриншот:

Две ноды в одном терминале (могут быть на разных уголках земного шара)

Конечно, в реальном проекте никто не приколачивает имя другой ноды гвоздями, но этот пример призван показать, насколько в принципе распределенное выполнение может быть несложно, если авторы языка подумали о том, что одной физической машины рано или поздно станет мало.

Классический пример — WhatsApp. Мне страшно даже вообразить, каких немыслимых плясок с бубном потребует построение банального ростера для таких объемов данных, какова будет скорость отклика, и сколько пришлось бы заплатить за СУБД, если хранить всё в базе. Инженеры из этой команды в базу ходят только асинхронно. Всё остальное зиждется на долгоживущих процессах. Вышел пользователь в online — запустили где-то в кластере процесс для него, который адресуется простым идентификатором, и всё.

А как это тестировать?

Вот тут, к сожалению, инфраструктура эликсира пошла на поводу у разработчиков, которых приучили к нераспределенным решениям. Мало кто (читай: никто) не выбирает эликсир в качестве первого языка (хотя я лично убежден, что это невероятно положительно сказалось бы на индустрии в целом). Сюда приходят уставшие от GIL в руби (какой идиот запустил слух, будто эликсир похож на руби?), измученные синтаксисом и костылями голанга, наигравшиеся в нагрузку на джаваскрипте. Все эти люди под горизонтальным масштабированием понимают запуск еще одного контейнера за балансером.

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

В общем, я принес тестирование распределенных систем в эликсир в виде библиотеки enfiladex. Её можно рассматривать как просто учебный код для целого пласта не совсем стандартных задач:

  • втаскивания кода на эрланге в проект на эликсире (это просто — достаточно в папку src добавить ваши эрланговские файлы)

  • метапрограммирования для изменения существующей функциональности сторонних библиотек (use Enfiladex.Suite превратит ваш тестовый модуль в Common Test Suite)

  • правильного подхода к изобретению велосипедов: всё, что можно было украсть у коммьюнити — я украл, моего кода там строк двести

Чтобы начать использовать эту библиотеку, достаточно просто стартовать тесты в распределенном эрланге (файл test_helper.exs):

{_, 0} = System.cmd("epmd", ["-daemon"])
Node.start(:enfiladex, :shortnames)
ExUnit.start()

И добавить use Enfiladex.Suite в ваши тесты (пример из тестов моей библиотеки antenna):

defmodule Antenna.Enfiladex.Test do
  use ExUnit.Case, async: true
  use Enfiladex.Suite # этого достаточно, чтобы тесты стали понятны для common_test

  alias Antenna.Test.Matcher
  require Antenna

  test "works in distributed erlang", _ctx do
    count = 5 # количество нод
    peers = Enfiladex.start_peers(count) # запускаем ноды

    try do
      # запускаем наше дерево супервизоров
      Enfiladex.block_call_everywhere(Antenna, :start_link, [Antenna])

      # pid, который вернется, может быть на какой угодно ноде
      assert {:ok, pid1, "{:tag_1, a, _} when is_nil(a)"} =
               Antenna.match(Antenna, {:tag_1, a, _} when is_nil(a), self(), channels: [:chan_1])

      assert :ok = Antenna.event(Antenna, [:chan_1], {:tag_1, nil, 42})
      assert_receive {:antenna_event, :chan_1, {:tag_1, nil, 42}}

      # еще много разных ассертов
    after
      # тушим ноды (это косметика, тест помрет — они сами потухнут)
      Enfiladex.stop_peers(peers)
    end
  end
end

Иными словами, библиотека работает в обе стороны: в нашем тесте мы можем теперь довольно просто запустить несколько нод и посмотреть, как себя ведет наш код, а еще — мы можем выполнить mix enfiladex и прогнать common_test на наших ex_unit тестах:

❯ mix enfiladex

Common Test starting (cwd is /opt/Proyectos/Elixir/antenna)
CWD set to: "/opt/Proyectos/Elixir/antenna/ct_logs/ct_run.enfiladex@am-victus.2025-05-01_17.56.38"

TEST INFO: 1 test(s), 1 case(s) in 0 suite(s)

Testing: Starting test, 1 test cases

. ⇐ green dot for passed test

Testing: TEST COMPLETE, 1 ok, 0 failed of 1 test cases

Updating /opt/Proyectos/Elixir/antenna/ct_logs/index.html ... done
Updating /opt/Proyectos/Elixir/antenna/ct_logs/all_runs.html ... done
[{1, 0, {0, 0}}]

common_test держит всю историю прогнанных тестов и генерирует нам вот такой отчет:

Common Test Output (HTML)

В отличие от ex_unit, common_test сохраняет больше всякой информации о среде выполнения, поэтому я иногда гоняю его, если не очевидно, как чинить красный тест. В CI и нормальной жизни, мне достаточно обычного mix test.

Удачного эрланга вам в тестах!