소스 코드 읽기: prometheus.erl, prometheus.ex

5 minute read

deadtrickster/prometheus.erl은 모니터링 시스템인 prometheus.io의 erlang 클라이언트다. 각종 메트릭(metric)을 프로메테우스가 파싱할 수 있는 포맷으로 텍스트를 만든다. deadtrickster/prometheus.ex는 prometheus.erl을 elixir에서 편하게 쓸 수 있게 만든 라이브러리다.

phoenix 웹 프레임워크로 만든 프로젝트 지표를 prometheus와 grafana로 모니터링할 때와 웹 프레임워크 phoenix를 쓰지 않은 애플리케이션에 프로메테우스(prometheus)를 붙일 때, 프로메테우스 라이브러리를 사용했다.

프로메테우스에 노출할 간단한 텍스트 기반 노출 형식(simple text-based exposition format) 생성

iex> :prometheus_text_format.format
# TYPE erlang_vm_memory_atom_bytes_total gauge\n# HELP erlang_vm_memory_atom_bytes_total The total amount of memory currently allocated for atoms. This memory is part of the memory presented as system memory.\nerlang_vm_memory_atom_bytes_total{usage=\"used\"} 698876\nerlang_vm_memory_atom_bytes_total{usage=\"free\"} 12717\n# TYPE erlang_vm_memory_bytes_total gauge\n# HELP erlang_vm_memory_bytes_total The total amount of memory currently allocated. This is the same as the sum of the memory size for processes and system.\nerlang_vm_memory_bytes_total{kind=\"system\"} 33224920\nerlang_vm_memory_bytes_total{kind=\"processes\"} 14953312\n# TYPE erlang_vm_memory_dets_tables gauge\n# HELP erlang_vm_memory_dets_tables Erlang VM DETS Tables count.\nerlang_vm_memory_dets_tables 0\n# TYPE erlang_vm_memory_ets_tables gauge\n# HELP erlang_vm_memory_ets_tables Erlang VM ETS Tables count.\nerlang_vm_memory_ets_tables 32\n# TYPE erlang_vm_memory_processes_bytes_total gauge\n# HELP erlang_vm_memory_processes_bytes_total The total amount of memory currently allocated for the Erlang processes.\nerlang_vm_memory_processes_bytes_total{usage=\"used\"} 14938904\nerlang_vm_memory_processes_bytes_total{usage=\"free\"} 14408\n# TYPE erlang_vm_memory_system_bytes_total gauge\n# HELP erlang_vm_memory_system_bytes_total The total amount of memory currently allocated for the emulator that is not directly related to any Erlang process. Memory presented as processes is not included in this memory ...

프로메테우스에서 파싱(parsing)할 수 있는 간단한 텍스트 기반 노출 형식을 만드는 게 prometheus.erl 라이브러리의 핵심 기능이다. prometheus_text_format:format/1 함수가 그 역할을 한다.

-module(prometheus_text_format).

-spec format(Registry :: prometheus_registry:registry()) -> binary().
format(Registry) ->
  {ok, Fd} = ram_file:open("", [write, read, binary]),
  Callback = fun (_, Collector) ->
                 registry_collect_callback(Fd, Registry, Collector) %-- 2
             end,
  prometheus_registry:collect(Registry, Callback), %-- 1
  file:write(Fd, "\n"),
  {ok, Size} = ram_file:get_size(Fd),
  {ok, Str} = file:pread(Fd, 0, Size),
  ok = file:close(Fd),
  Str.

prometheus_text_format:format/1 함수는 프로메테우스 레지스트리(prometheus_registry)에 등록한 수집기(collector)가 수집한 메트릭을 취합한다. 1번 코드에서 prometheus_registry:collect/2 함수를 호출해 등록된 메트릭 수집기(collector)마다 2번 코드가 실행되게 한다.

iex> {:ok, fd} = :ram_file.open("", [:write, :read, :binary])
{:ok, {:file_descriptor, :ram_file, #Port<0.7>}}
iex> :prometheus_collector.collect_mf(:default, :prometheus_vm_memory_collector, fn mf ->
...> :prometheus_text_format.emit_mf_prologue(fd, mf) %-- 1
...> :prometheus_text_format.emit_mf_metrics(fd, mf) %-- 2
...> end)
:ok
iex> {:ok, size} = :ram_file.get_size(fd)
{:ok, 1748}
iex> {:ok, str} = :file.pread(fd, 0, size)
{:ok,
 "# TYPE erlang_vm_memory_atom_bytes_total gauge\n# HELP erlang_vm_memory_atom_bytes_total The total amount of memory currently allocated for atoms. This memory is part of the memory presented as system memory.\nerlang_vm_memory_atom_bytes_total{usage=\"used\"} 698873\nerlang_vm_memory_atom_bytes_total{usage=\"free\"} 12720\n# TYPE erlang_vm_memory_bytes_total gauge\n# HELP erlang_vm_memory_bytes_total The total amount of memory currently allocated. This is the same as the sum of the memory size for processes and system.\nerlang_vm_memory_bytes_total{kind=\"system\"} 33214560\nerlang_vm_memory_bytes_total{kind=\"processes\"} 5661944\n..."}
iex> :ok = :file.close(fd)
iex> str

수집기마다 호출하는 registry_collect_callback/3 함수의 동작을 보려고 :prometheus_vm_memory_collector 모듈 함수만 호출했다. 1번 코드에서 TYPE, HELP를 출력하고 2번 코드에서 메트릭을 출력한다.

수집기(collector) 구현

-module(prometheus_collector).

-callback collect_mf(Registry, Callback) -> ok when
    Registry :: prometheus_registry:registry(),
    Callback :: collect_mf_callback().

-callback deregister_cleanup(Registry) -> ok when
    Registry :: prometheus_registry:registry().

수집기 노릇을 하려면 콜백 함수를 구현해야 한다. 수집기 메트릭을 취합할 때, collect_mf/2 함수를 호출한다. 레지스트리에서 등록을 취소할 때, deregister_cleanup/1 함수를 호출해 수집하려고 벌려 놓은 자원을 정리할 시간을 만들어준다.

-module(prometheus_vm_memory_collector).

deregister_cleanup(_) -> ok. %-- 1

collect_mf(_Registry, Callback) ->
  Metrics = metrics(), %-- 2
  EnabledMetrics = enabled_metrics(),
  [add_metric_family(Metric, Callback) %-- 3
   || {Name, _, _, _}=Metric <- Metrics, metric_enabled(Name, EnabledMetrics)],
  ok.

vm 메모리 메트릭을 만드는 prometheus_vm_memory_collector 모듈 코드를 읽어봤다. 수집기 등록 취소를 할 때, 정리 작업이 필요 없음으로 1번 코드처럼 아무 일도 안 한다. 2번 코드에서 메모리 메트릭을 구하고 3번 코드에서 구한 메트릭을 인자로 Callback 함수를 호출한다.

metrics() ->
  Data = erlang:memory(),
  [{atom_bytes_total, gauge,
    "The total amount of memory currently allocated "
    "for atoms. This memory is part of the memory "
    "presented as system memory.",
    [
     {[{usage, used}], get_value(atom_used, Data)},
     {[{usage, free}],
      get_value(atom, Data) - get_value(atom_used, Data)}
    ]},
   {bytes_total, gauge,
    "The total amount of memory currently allocated. "
    "This is the same as the sum of the memory size "
    "for processes and system.",
    [
     {[{kind, system}], get_value(system,  Data)},
     {[{kind, processes}], get_value(processes, Data)}
    ]},

메모리 메트릭은 이런 식으로 함수 하나에 기술해놓는다. {Name, Type, Help, Metrics} 튜플 리스트를 리턴한다.

수집기(collector) 등록

-module(prometheus_registry).

-spec register_collector(Registry :: prometheus_registry:registry(),
                         Collector :: prometheus_collector:collector()) -> ok.
register_collector(Registry, Collector) ->
  ets:insert(?TABLE, {Registry, Collector}),
  ok.

prometheus_registry.register_collector/2 함수로 수집기를 메모리 저장소인 ets에 저장한다.

-module(prometheus_collector).

-define(DEFAULT_COLLECTORS,
        [prometheus_boolean,
         prometheus_counter,
         prometheus_gauge,
         prometheus_histogram,
         prometheus_mnesia_collector,
         prometheus_summary,
         prometheus_vm_dist_collector,
         prometheus_vm_memory_collector,
         prometheus_vm_msacc_collector,
         prometheus_vm_statistics_collector,
         prometheus_vm_system_info_collector]).

일반적으로 필요한 erlang vm 메트릭 수집기는 구현되어 있다. 그 덕에 커스텀 수집기를 구현하지 않아도 그럴듯한 vm 메트릭을 볼 수 있다.

밑줄 친 네이밍

-module(prometheus_sup).

create_tables() ->
  Tables = [
            {?PROMETHEUS_REGISTRY_TABLE, {bag, read_concurrency}},
            {?PROMETHEUS_COUNTER_TABLE, write_concurrency},
            {?PROMETHEUS_GAUGE_TABLE, write_concurrency},
            {?PROMETHEUS_SUMMARY_TABLE, write_concurrency},
            {?PROMETHEUS_HISTOGRAM_TABLE, write_concurrency},
            {?PROMETHEUS_BOOLEAN_TABLE, write_concurrency}
           ],
  [maybe_create_table(Name, Concurrency) || {Name, Concurrency} <- Tables],
  ok.

maybe_create_table(Name, {Type, Concurrency}) ->
  case ets:info(Name) of
    undefined ->
      ets:new(Name, [Type, named_table, public, {Concurrency, true}]);
    _ ->
      ok
  end;

메모리 저장소인 ets 테이블을 만드는데, 만들어지지 않은 테이블만 만드는 함수다. maybe_create_table 함수 이름이 재미있다. 어쩌면 테이블을 생성할지도 모른다. 이런 뜻으로 사용했다.

하지만 난 이런 경우는 ensure_table 식으로 ensure 단어를 쓰는 걸 선호한다. 함수가 존재하는 이유는 뭘까? 테이블이 없으면 만드는 걸 보장하려고 함수를 만들었다. 이미 테이블이 있으면 넘어가고 없으면 만들어서 테이블이 있는 걸 보장한다.

defmodule Logger do
  defmacro warn(chardata_or_fun, metadata \\ []) do
    maybe_log(:warn, chardata_or_fun, metadata, __CALLER__)
  end

  defp maybe_log(level, data, metadata, caller) do
    # ...

    if compare_levels(level, min_level) != :lt do
      macro_log(level, data, metadata, caller)
    else
      no_log(data, metadata)
    end
  end

maybe 단어는 elixir의 Logger 모듈에서 더 어울리게 사용했다. 설정에 따라 출력하는 로그 레벨이 정해진다. 로그를 출력할지 넘어갈지 결정하는 함수 이름이 maybe_log 이다. 어울린다.

erlang 함수 호출 매크로

deadtrickster/prometheus.ex elixir 패키지는 erlang으로 구현한 deadtrickster/prometheus.erl 패키지를 elixir에서 쉽게 사용할 수 있게 도와주는 패키지다.

이런 패키지는 노가다가 필수다. elixir 함수에서는 별로 하는 일 없이 erlang 함수를 호출한다. 이런 delegate가 대부분이다.

defmodule Prometheus.Erlang do
  defmacro delegate(fun, opts \\ []) do
    fun = Macro.escape(fun, unquote: true)

    quote bind_quoted: [fun: fun, opts: opts] do
      target = Keyword.get(opts, :to, @erlang_module)

      {name, args, as, as_args} = Kernel.Utils.defdelegate(fun, opts)

      def unquote(name)(unquote_splicing(args)) do
        Prometheus.Error.with_prometheus_error(
          unquote(target).unquote(as)(unquote_splicing(as_args))
        )
      end
    end
  end

prometheus.erl 모듈로 구현한 함수를 쉽게 호출하려고 만든 Prometheus.Erlang 모듈이 눈에 들어왔다. kernel에 정의한 defdelegate/2 매크로 구현을 참고한 것 같다.

defmodule Prometheus.Registry do
  use Prometheus.Erlang, :prometheus_registry

  delegate exists(name)

delegate 매크로로 정의한다.

defmodule Prometheus.Registry do
  use Prometheus.Erlang, :prometheus_registry

  def exists(name) do
    Prometheus.Error.with_prometheus_error(
      :prometheus_registry.exists(name)
    )
  end

delegate 매크로는 이런 식으로 확장된다. 공을 들인 Prometheus.Erlang 모듈 덕에 중복 코드를 피할 수 있다.

버전 관리 스크립트

bin/increment-version 스크립트로 버전을 올리고 있다.

version="$(erl -noshell -s init stop -eval "{ok, [{_,_,Props}]} = file:consult(\"src/prometheus.app.src\"), io:format(\"~s\", [proplists:get_value(vsn, Props)])")"

src/prometheus.app.src 파일에서 현재 버전 스트링을 가져온다.

sed -i s/\"${oa[0]}\.${oa[1]}\.${oa[2]}\"/\"${a[0]}\.${a[1]}\.${a[2]}\"/g src/prometheus.app.src
sed -i s/\"${oa[0]}\.${oa[1]}\.${oa[2]}\"/\"${a[0]}\.${a[1]}\.${a[2]}\"/g mix.exs
sed -i s/@version\ ${oa[0]}\.${oa[1]}\.${oa[2]}/@version\ ${a[0]}\.${a[1]}\.${a[2]}/g doc/overview.md

버전이 있는 파일을 꼼꼼히 수정한다.

git add mix.exs src/prometheus.app.src
git add README.md
git add doc
git commit -m "Bump to v${new_version}"

커밋

TAG_MESSAGE=${1:-"New version: v${new_version}"}

git tag -a "v${new_version}" -m "${TAG_MESSAGE}"
git push origin master
git push origin "v${new_version}"

태그를 만들고 푸시한다.

버전 올리는 것부터 커밋하고 태그 만들고 푸시까지 스크립트 실행 한 번으로 된다. 귀찮지만 한 번만 만들어두면 모든 프로젝트에서 편하게 쓸 수 있다.

소감

단순해서 erlang 코드라도 읽을만했다. 꼼꼼한 테스트 코드도 도움이 됐다. 메트릭을 텍스트 포맷으로 가공할 때, 수집기 콜백 함수를 호출한다. 호출 타이밍을 내가 조절할 수 없으니 수집기 콜백 함수가 무거우면 안 된다. 무거운 작업은 주기적으로 돌려서 결과물을 캐시하고 수집기 콜백 함수에서는 캐시한 결과물을 리턴하는 식으로 짜면 된다.

버전 관리 스크립트도 훌륭하다. 나도 정성 들여서 하나 짜 놓고 프로젝트를 돌려가며 써야겠다.