Деплой и наблюдаемость Elixir
Написать код — половина дела; довести до прода и видеть, что там происходит — вторая. Elixir здесь силён: mix release собирает самодостаточный артефакт, :telemetry даёт стандартные события для метрик, а BEAM позволяет заглянуть внутрь работающей системы. Разберём путь от сборки до трейсинга.
mix release — самодостаточный артефакт
mix release (встроен с Elixir 1.9) собирает самодостаточный релиз: ваш код + все зависимости + сам рантайм Erlang/OTP (ERTS). На целевой машине не нужны ни Elixir, ни Erlang — только совместимая ОС/архитектура.
MIX_ENV=prod mix release
# => _build/prod/rel/my_app/
Что даёт релиз:
- Один каталог/архив со всем необходимым, включая виртуальную машину.
- Скрипты управления:
bin/my_app start(foreground),bin/my_app daemon,bin/my_app stop,bin/my_app remote(подключить IEx к работающей ноде — бесценно для диагностики). - Чтение
runtime.exsпри старте — конфигурация из переменных окружения без пересборки. - Меньший размер и быстрый старт по сравнению с «поставить исходники и собирать на сервере».
Настройка релиза в mix.exs:
def project do
[
# ...
releases: [
my_app: [
include_executables_for: [:unix],
steps: [:assemble, :tar], # собрать и упаковать в tar.gz
version: "0.1.0"
]
]
]
end
Миграции в релизе
В релизе нет Mix, значит нет mix ecto.migrate. Каноничное решение — модуль Release, вызываемый через bin/my_app eval:
# lib/my_app/release.ex
defmodule MyApp.Release do
@app :my_app
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos, do: Application.fetch_env!(@app, :ecto_repos)
defp load_app, do: Application.load(@app)
end
# на деплое, до старта основного трафика:
bin/my_app eval "MyApp.Release.migrate()"
Контейнеризация: multi-stage Docker
Каноничный Dockerfile для Elixir — многоступенчатый: собрать релиз в «жирном» build-образе, затем скопировать только артефакт в минимальный runtime-образ. Итоговый образ маленький и без тулчейна.
# ---------- Стадия сборки ----------
FROM hexpm/elixir:1.17.2-erlang-27.0-debian-bookworm-20240701-slim AS build
ENV MIX_ENV=prod
WORKDIR /app
# Устанавливаем hex/rebar
RUN mix local.hex --force && mix local.rebar --force
# Зависимости — отдельным слоем ради кэша Docker
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
# Конфиг и исходники
COPY config config
COPY lib lib
COPY priv priv
# COPY assets assets # если есть фронтенд-ассеты
# RUN mix assets.deploy
# Собираем релиз
RUN mix compile
RUN mix release
# ---------- Стадия рантайма ----------
FROM debian:bookworm-slim AS app
# Только рантайм-библиотеки, без компиляторов
RUN apt-get update && apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Локаль UTF-8 (иначе проблемы со строками)
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
WORKDIR /app
RUN useradd --create-home app && chown -R app /app
USER app
# Копируем готовый релиз из build-стадии
COPY --from=build --chown=app:app /app/_build/prod/rel/my_app ./
ENV HOME=/app
CMD ["bin/my_app", "start"]
Ключевые моменты:
- Кэш слоёв:
deps.getдо копирования исходников — пересборка зависимостей только при измененииmix.lock. - Малый runtime-образ:
debian-slimбез компиляторов; можно иalpine(следите за musl-совместимостью) или distroless. - UTF-8 локаль обязательна — иначе сломается работа со строками.
- Не root: отдельный пользователь
app. - Секреты — через переменные окружения контейнера, читаются
runtime.exsпри старте.
Kubernetes и кластеризация
При запуске в k8s несколько подов образуют BEAM-кластер через libcluster (авто-обнаружение по DNS/k8s API). Это включает распределённый Phoenix.PubSub, présence и т.п. между подами. Нужен runtime.exs с настройкой имени ноды и cookie из окружения. Для graceful shutdown BEAM корректно обрабатывает SIGTERM (дренаж соединений) — задайте разумный terminationGracePeriodSeconds.
Наблюдаемость: три столпа
Наблюдаемость = логи (что случилось) + метрики (сколько/как быстро) + трейсинг (путь запроса через систему). В Elixir всё это стоит на встроенном :telemetry.
:telemetry — стандартная шина событий
:telemetry — библиотека-стандарт: код (ваш и библиотечный — Phoenix, Ecto, Broadway, Oban) испускает события в точках интереса, а вы подписываетесь и решаете, что с ними делать (метрика, лог, трейс). Развязка производителя события и потребителя.
# Испускание события (обычно это делают за вас библиотеки)
:telemetry.execute(
[:my_app, :order, :created],
%{duration: duration_ms, amount: order.total}, # измерения
%{user_id: order.user_id, region: order.region} # метаданные
)
# Подписка на событие
:telemetry.attach(
"log-orders",
[:my_app, :order, :created],
fn _event, measurements, metadata, _config ->
Logger.info("Заказ создан", duration: measurements.duration, user: metadata.user_id)
end,
nil
)
Phoenix и Ecto из коробки испускают события (время запроса, длительность SQL, размер пула) — их не нужно инструментировать вручную.
Метрики: Telemetry.Metrics + Prometheus
Telemetry.Metrics описывает, какие метрики строить из telemetry-событий, а репортер экспортирует их. Для Prometheus — PromEx (готовые дашборды Grafana для Phoenix/Ecto/BEAM «из коробки»).
# lib/my_app/telemetry.ex (часть дерева супервизии)
def metrics do
[
# HTTP: длительность запросов по маршрутам
summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
# Ecto: время запросов к БД
summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
# BEAM VM: память и число процессов
last_value("vm.memory.total", unit: :byte),
last_value("vm.total_run_queue_lengths.total"),
# Бизнес-метрика
counter("my_app.order.created.count")
]
end
Что мониторить в Elixir обязательно (специфика BEAM):
- Длина очередей планировщика (
run_queue) — растёт → система перегружена. - Длина mailbox ключевых GenServer — растёт → процесс стал узким местом (см. главу про конкурентность).
- Число процессов и использование памяти по типам (процессы/бинарники/ETS) — утечки часто в бинарниках или динамических атомах.
- Использование пула БД (Ecto) — насыщение пула = очереди и таймауты.
Трейсинг: OpenTelemetry
Для распределённого трейсинга (путь запроса через сервисы) — OpenTelemetry с официальными Erlang/Elixir-библиотеками (opentelemetry + автоинструментация opentelemetry_phoenix, opentelemetry_ecto). Они превращают telemetry-события Phoenix/Ecto в спаны и экспортируют в Jaeger/Tempo/Honeycomb по OTLP.
# в application.ex, после старта Repo/Endpoint
OpentelemetryPhoenix.setup()
OpentelemetryEcto.setup([:my_app, :repo])
Собственные спаны — вокруг важных операций:
require OpenTelemetry.Tracer, as: Tracer
Tracer.with_span "charge_payment" do
Tracer.set_attributes([{"amount", amount}, {"gateway", "stripe"}])
MyApp.Billing.pay(amount, token)
end
Trace-id связывает логи, метрики и спаны одного запроса — золотой стандарт наблюдаемости.
Логи
Структурное логирование через Logger (см. главу про идиомы) + метаданные (request_id, user_id, trace_id). В проде используйте JSON-форматтер (например logger_json) — так логи парсятся системами сбора (Loki, ELK, Datadog). Уровень в проде — обычно info; debug — шумно и дорого.
# config/runtime.exs (prod)
config :logger, level: :info
config :logger, :default_formatter, format: {LoggerJSON.Formatters.Basic, :format}
Диагностика работающей системы
Уникальное преимущество BEAM — можно безопасно заглянуть внутрь живой ноды:
bin/my_app remote— подключить IEx к работающему релизу прямо в проде и осмотреться (осторожно, это полноценный доступ).:observer.start()— графический дашборд (дерево супервизии, процессы, память, планировщики). Обычно на dev или через remote-ноду; в headless-проде — сложнее.- recon — безопасные для прода инструменты:
:recon.proc_count(:message_queue_len, 10)найдёт процессы с самыми длинными очередями;:recon.bin_leak/1— утечки бинарников. Знать recon = уметь чинить прод под нагрузкой. Process.info/2,:erlang.memory/0,:erlang.system_info(:process_count)— быстрые точечные проверки.
# Найти 5 процессов с самыми раздутыми почтовыми ящиками
:recon.proc_count(:message_queue_len, 5)
# Общая память по категориям
:erlang.memory() # [total: _, processes: _, binary: _, ets: _, ...]
Производительность: практические ориентиры
- Профилируйте, не гадайте. Инструменты:
:fprof/:eprof(профилировщики), benchee для микробенчмарков (сравнить реализации по времени и памяти). - Узкое место чаще — не CPU, а сериализация через один процесс. Ищите GenServer с растущим mailbox; шардируйте или выносите работу.
- Бинарники > 64 байт разделяются по ссылке — но их фрагменты могут «удерживать» большие бинарники в памяти (binary leak).
:binary.copy/1помогает, если храните маленький кусок большого бинарника. - Пул БД — частый лимит пропускной способности; настраивайте
pool_sizeпод нагрузку и число ядер БД. - ETS для горячих чтений вместо процесса-хранилища (см. главу про архитектуру).
Чек-лист деплоя
-
MIX_ENV=prod mix releaseсобирает артефакт; multi-stage Docker даёт малый образ. - Миграции применяются через
bin/my_app eval "MyApp.Release.migrate()"до приёма трафика. - Секреты и средозависимая конфигурация — в
runtime.exsиз переменных окружения. - Метрики (PromEx/Telemetry.Metrics) и дашборды по HTTP, БД, VM.
- Трейсинг OpenTelemetry с автоинструментацией Phoenix/Ecto.
- Структурные JSON-логи с request_id/trace_id.
- Health-check эндпоинт и корректная обработка SIGTERM для graceful shutdown.
- Алерты на длину mailbox, насыщение пула БД, рестарты супервизоров.
Источники
- Mix Release, Elixir Deployment guides
- Fly.io Phoenix Files: fly.io/phoenix-files — практические заметки по деплою.
- PromEx, OpenTelemetry Erlang/Elixir
- Книга «Erlang in Anger» (Fred Hébert) — диагностика продакшена, recon.
Что дальше
SDLC и лучшие ресурсы — жизненный цикл, CI/CD и куда расти дальше.