Pull to refresh

Конфиг, сделанный по уму

Level of difficultyEasy
Reading time5 min
Views1.6K

Когда к нам пришел докер и — как тот муж из анекдота — перее^W научил нас отказоустойчивости на свой манер, я написал бесчисленное количество костылей, чтобы действительно отказоустойчивый (а главное, долгоживущий) код продолжал нормально работать в условиях, где сброс горячего кэша из-за внезапного перезапуска контейнера, вызванного близостью Андромеды к Меркурию, — норма.

Потом какому-то гению из соседнего отдела пришла в голову блистательная мысль использовать consul в качестве единого конфигурационного хранилища, некоторые ошметки локальных конфигов по-прежнему валялись в редисе, каждый микросервис выдумывал свою систему легкой раскатки и предпочитал автономно управлять конфигурацией из локальных переменных среды, и в этом зоопарке, разумеется, начали возникать конфликты на почве видово́й борьбы за выживание (ласково именуемой в народе «кто первый встал — того и тапки»).

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

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

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

В общем, у меня нарисовалась примерно такая архитектура:

Обновляемая конфигурация
Обновляемая конфигурация

Бежевое — моя библиотека, голубенькое — приложение, которое может подписаться на обновления каждого бэкенда в отдельности, либо их «смеси» через блендер, который сам по себе подписан на каждый бэкенд (адаптер стороннего источника) и представляет из себя просто воронку для удобства.

Еще, конечно, каждый компонент умеет оповещать окружающих об ошибках, слать метрики, и так далее, но это и так очевидно.

Blender

Это может показаться странным, но начал я с того, что выбросил сущность «Blender». (Вру, конечно, начал я с того, что придумал для библиотеки название Kungfuig (pronounced: [ˌkʌŋˈfig])). Но сразу после этого я решил: никаких дополнительных ненужных сущностей. Blender — сам по себе — тоже ведь не что иное, как Backend. Итак, осталось реализовать Backend и обвязку. И тут я принял еще одно стратегическое решение: я реализую только скелет и покажу на примерах для пользователей, как этим пользоваться. Поэтому в поставке нет никаких адаптеров для редиса или ямла. Их реализация настолько проста, что просто не имеет смысла втаскивать в саму библиотеку дополнительные зависимости.

Backend

Вот как выглядит интерфейс бэкенда:

  @doc "The key this particular config would be stored under, defaults to module name"
  @callback key :: atom()

  @doc "The implementation of the call to remote that retrieves the data"
  @callback get([Kungfuig.option()]) :: {:ok, any()} | {:error, any()}

  @doc "The transformer that converts the retrieved data to internal representation"
  @callback transform(any()) :: {:ok, any()} | {:error, any()}

  @doc "The implementation of error reporting"
  @callback report(any()) :: :ok

  @optional_callbacks key: 0, transform: 1, report: 1

По сути, я требую только имплементацию функции get/1, которая должна сходить в стороннее хранилище и стянуть оттуда данные. Если есть реализация transform/1 — библиотека её вызовет перед обновлением на вновь полученных данных. Если конфигурация некорретная, валидатор её завернёт. Кроме валидатора, можно установить частоту обновления.

Вот простенький пример для СУБД:

defmodule MyApp.Kungfuig.MySQL do
  @moduledoc false

  use Kungfuig.Backend, interval: 300_000 # 5 minutes

  @impl Kungfuig.Backend
  def get(_meta) do
    with {:ok, host} <- System.fetch_env("MYSQL_HOST"),
         {:ok, db} <- System.fetch_env("MYSQL_DB"),
         {:ok, user} <- System.fetch_env("MYSQL_USER"),
         {:ok, pass} <- System.fetch_env("MYSQL_PASS"),
         {:ok, pid} when is_pid(pid) <-
           MyXQL.start_link(hostname: host, database: db, username: user, password: pass),
         result <- MyXQL.query!(pid, "SELECT * FROM some_table") do
      GenServer.stop(pid)

      result =
        result.rows
        |> Flow.from_enumerable()
        |> Flow.map(fn [_, field1, field2, _, _] -> {field1, field2} end)
        |> Flow.partition(key: &elem(&1, 0))
        |> Flow.reduce(fn -> %{} end, fn {field1, field2}, acc ->
          Map.update(
            acc,
            String.to_existing_atom(field1),
            [field2],
            &[field2 | &1]
          )
        end)

      Logger.info("Loaded #{Enum.count(result)} values from " <> host)

      {:ok, result}
    else
      :error ->
        Logger.warn("Skipped reconfig, one of MYSQL_{HOST,DB,USER,PASS} is missing")
        :ok

      error ->
        Logger.error("Reconfiguring failed. Error: " <> inspect(error))
    end
  end
end

Для редиса все будет короче, для джейсона — вообще одна строка. Пример с базой интересен тем, что этот конкретный конфиг очень длинный и очень «живой» — его меняет основное приложение довольно часто, поэтому клиенты просто читают из RO-реплики.

Вторую часть кода можно, теоретически, было вынести в колбэк transform/1, но у нас иммутабельный язык и я не посчитал изящество кода в ущерб эффективности — достаточным аргументом в пользу того, чтобы гонять туда-сюда мегабайты.

Обвязка

Клиент, которому требуются уведомления об изменении конфигурации, должен быть упомянут как callback: при старте основного процесса Kungfuig. Это может быть:

  @type callback ::
          module()
          | pid()
          | {module(), atom()}
          | (config() -> :ok)
          | {GenServer.name() | pid(), {:call | :cast | :info, atom()}}

Или, в переводе на русский:

  • модуль, реализующий behaviour Kungfuig.Callback,

  • идентификатор процесса, который получит сообщение {:kungfuig_update, state},

  • именованная функция арности 1 из указанного модуля,

  • анонимная функция арности 1

  • имя GenServer-процесса и тип вызова (синхронный, асинхронный, без гарантий)

Теперь указанный(-ые) хендлеры будут получать нотификации каждый раз, когда конфигурация изменяется, а что уж с новыми данными делать — решать каждому. При старте, разумеется, всё это тоже пройдет именно по такому пути, то есть «применение» конфигурации — повторяемый сценарий, и не зависит от того, было это сделано при старте, или в процессе работы приложения.

Вот так, на мой взгляд, имеет смысл работать с конфигами.

Удачного конфигурирования!

Tags:
Hubs:
+3
Comments11

Articles