Elixir Деплой и наблюдаемость Elixir: mix release, Docker, telemetry, OpenTelemetry, observer
0%

Деплой и наблюдаемость Elixir: mix release, Docker, telemetry, OpenTelemetry, observer

Деплой и наблюдаемость 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, насыщение пула БД, рестарты супервизоров.

Источники

Что дальше

SDLC и лучшие ресурсы — жизненный цикл, CI/CD и куда расти дальше.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов