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

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