kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

OpenTelemetry Collector Contribを眺めてみる - Carbon ReceiverによるCarbon形式メトリック投稿編

MackerelでのOpenTelemetry対応パブリックベータの提供が開始したので、Mackerel CREの私も習熟すべくいろいろと実験をしています。

mackerel.io

ホストやミドルウェアのメトリックを取得しようというときにはOpenTelemetry CollectorのReceiverでメトリックを収集し、ExporterにMackerelのOTLPエンドポイントを示して投稿、というのが王道なのですが、今回はあえて「Mackerelの既存のメトリックプラグインの出力をOpenTelemetryのメトリックとして送ってみる」ということを試してみました。

結論から言えば、(加工は少し必要ですが)ContribにあるCarbon Receiverで実現できます。

Carbon Receiverとは

OpenTelemetry Collector ContribのReceiverにひととおり目を通した中で、「Carbon Receiver」というものがありました。

carbonreceiver: Carbon Receiver beta、メトリクス GraphiteのコンポーネントであるCarbonのプレインテキストプロトコルのメトリクスを取得する。

実はMackerelのメトリックはこのCarbon形式にほぼ同じで、「メトリック名 (タブ) メトリック値 (タブ) UNIX時間」という構成になっています(参照: 「ホストのカスタムメトリックを投稿する」)。

つまり、このCarbon Receiverを起動して待ち受けさせ、任意のMackerelメトリックプラグインの実行結果を送れば、OpenTelemetryメトリックへの変換および投稿をReceiverとCollectorに任せられるというわけですね。

OpenTelemetry Collectorのセットアップ

Collectorセットアップについては「Mackerel で OpenTelemetry のラベル付きメトリックを使ってみよう」などに詳しいので、ここではざっくり説明のみに留めます。

CollectorはDockerイメージやdebパッケージなども提供されていますが、試行錯誤段階ではバイナリをフォアグラウンド実行するだけのほうがテストしやすいので、リリースページからlinux_amd64のtar.gzアーカイブを取得して展開して使うことにしました。

$ tar xf otelcol-contrib_0.97.0_linux_amd64.tar.gz
$ ./otelcol-contrib components
  ...
receivers:
  ...
    - name: carbon
      stability:
        logs: Undefined
        metrics: Beta
        traces: Undefined
  ...

次に、Mackerel投稿のためのYAMLファイルを書きます。ファイル名は何でもよいのですが、雑にconfig.yamlとして進めます。

receivers:
  carbon:
    endpoint: localhost:2003
    transport: udp

processors:
  batch:

exporters:
  otlp/mackerel:
    endpoint: otlp.mackerelio.com:4317
    compression: gzip
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}
  debug:
    verbosity: detailed

service:
  pipelines:
    metrics:
      receivers: [carbon]
      processors: [batch]
      exporters: [otlp/mackerel, debug]

Carbon Receiverのデフォルトでは、エンドポイント(endpoint)が0.0.0.0:2003、トランスポートプロトコルtransport)がtcpですが、これを変更しました。前者は単にローカルのホスト内でしか使わないためというだけなので、ほかのホストからも投稿するような構成であれば、適切なバインド範囲にするのがよいでしょう。TCPでなくUDPに変えたのは、このCarbon Receiverは送信者に対して何も反応を返さないし、メトリックの投稿保証はさほど意味がなく、投げっぱなしでさっさと終わらせたほうが妥当であろうという判断です。

batchについては本番ではバッチ処理にとりまとめることが推奨されていますが、今の時点では送られたらすぐにログ表示やMackerel上で見たいので、設定を空にしています。

exportersではMackerelのOTLPエンドポイントに送るotlp/mackerelとともに、コンソールに状態を出力するdebugを定義しています。

では、環境変数MACKEREL_APIKEYにMackerelオーガニゼーションの書き込み可能APIキーを設定し、otelcol-contribを実行します。

$ export MACKEREL_APIKEY=<APIキー>
$ ./otelcol-contrib --config config.yaml
2024-03-27T20:23:28.111+0900    info    service@v0.97.0/telemetry.go:55 Setting up own telemetry...
  ...
2024-03-27T20:23:28.112+0900    warn    localhostgate/featuregate.go:63 The default endpoints for all servers in components will change to use localhost instead of 0.0.0.0 in a future version. Use the feature gate to preview the new default.       {"feature gate ID": "component.UseLocalHostAsDefaultHost"}

これでCarbon ReceiverがUDPポート2003で待ち受ける状態になりました。

$ lsof -i udp:2003
COMMAND       PID  USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
otelcol-c 3100447 kmuto    9u  IPv4 14363690      0t0  UDP localhost:2003

投稿してみる……おやおや?

ではさっそく投稿してみましょう。

Mackerelメトリックプラグインなら何でもよいですが、ContribのReceiverにはなさそうなものとして、Linuxのnf_conntrackのメトリックを取得するmackerel-plugin-conntrackを使ってみることにします。

$ mackerel-plugin-conntrack
conntrack.count.used     71      1711049726
conntrack.count.free    262073  1711049726

ncコマンドで雑にこれをCarbon Receiverに向けます。

$ mackerel-plugin-conntrack | nc -u -w 1 localhost 2003

-uUDP-wタイムアウトを指定しています(UDPだとCarbon Receiver側からクローズすることもないので、送信者側で終わらせます)。

しかし、これを実行してもCollector側には何も反応がありません……おやおや?

Carbon形式といったな、あれは嘘だ

何かおかしいので、大元のGraphiteのサンプルで試してみることにします。

$ echo "local.random.diceroll 4 `date +%s`" | nc -u -w 1 localhost 2003

おやおや、これは正しく送られますね。

2024-03-27T20:36:49.324+0900    info    MetricsExporter {"kind": "exporter", "data_type": "metrics", "name": "debug", "resource metrics": 1, "metrics": 1, "data points": 1}
2024-03-27T20:36:49.324+0900    info    ResourceMetrics #0
Resource SchemaURL: 
ScopeMetrics #0
ScopeMetrics SchemaURL: 
InstrumentationScope  
Metric #0
Descriptor:
     -> Name: local.random.diceroll
     -> Description: 
     -> Unit: 
     -> DataType: Gauge
NumberDataPoints #0
StartTimestamp: 1970-01-01 00:00:00 +0000 UTC
Timestamp: 2024-03-27 11:36:49 +0000 UTC
Value: 4
        {"kind": "exporter", "data_type": "metrics", "name": "debug"}

ここで、最初に以下のように書いていました。

MackerelのメトリックはこのCarbon形式にほぼ同じで、「メトリック名 (タブ) メトリック値 (タブ) UNIX時間」という構成になっています

「ほぼ」が曲者で、GraphiteのCarbon形式はスペース区切りなのに対し、Mackerelはタブ区切りです。いかにも怪しいですね。

タブをスペースに変更する手段はいくつかありますが、ここではシンプルにexpandコマンドでタブ1文字を1スペースに変換してみましょう。

$ mackerel-plugin-conntrack | expand -t 1 | nc -u -w 1 localhost 2003

来ましたね。

2024-03-27T20:50:39.130+0900    info    ResourceMetrics #0
Resource SchemaURL: 
ScopeMetrics #0
ScopeMetrics SchemaURL: 
InstrumentationScope  
Metric #0
Descriptor:
     -> Name: conntrack.count.used
     -> Description: 
     -> Unit: 
     -> DataType: Gauge
NumberDataPoints #0
StartTimestamp: 1970-01-01 00:00:00 +0000 UTC
Timestamp: 2024-03-27 11:50:39 +0000 UTC
Value: 108
Metric #1
Descriptor:
     -> Name: conntrack.count.free
     -> Description: 
     -> Unit: 
     -> DataType: Gauge
NumberDataPoints #0
StartTimestamp: 1970-01-01 00:00:00 +0000 UTC
Timestamp: 2024-03-27 11:50:39 +0000 UTC
Value: 262036
        {"kind": "exporter", "data_type": "metrics", "name": "debug"}

PromQLの内容を「conntrack.count.used」としたクエリグラフをダッシュボードに配置してみます。

メトリック名を分解し、ラベルにする

投稿されたメトリックはconntrack.count.used、conntrack.count.freeという一見階層化された名前なのですが、OpenTelemetryのメトリックとして考えると、これは単にそれぞれ固有の名前なだけで関係性がありません。クエリグラフなどでの扱いが不便なので、OpenTelemetryのメトリックらしく多次元のラベルに分解することにします。

これはCarbon Receiverが機能として提供しており、メトリック名を正規表現で分解して任意のラベルにできます。

receivers:
  carbon:
    endpoint: localhost:2003
    transport: udp
    parser:
      type: regex
      config:
        rules:
          - regexp: "(?P<key_svc>conntrack)\\.(?P<key_indicator>[^.]+)\\.(?P<key_allocation>.+)"
            name_prefix: "conntrack"

type: regex正規表現パーサを利用することを宣言し、設定をconfig:の下のrules:に並べていきます。ルールは最初にマッチしたものが採用されます。

各ルールではregexp正規表現name_prefixでOpenTelemetryのメトリック名プレフィクスを指定します。regexpの中では?P<key_〜>によってラベル名を表し、値はそのマッチ文字列となります(〜部分がラベル名と解釈されます)。つまり、この正規表現ではconntrack.count.usedは{svc='conntrack', indicator='count', allocation='used'}というラベル・値に分割されます。

otel-contribを実行し直し、メトリックを投稿します。

Metric #0
Descriptor:
     -> Name: conntrack
     -> Description: 
     -> Unit: 
     -> DataType: Gauge
NumberDataPoints #0
Data point attributes:
     -> svc: Str(conntrack)
     -> indicator: Str(count)
     -> allocation: Str(used)

それっぽく分割されているようです。Mackerelのクエリグラフも変更してみましょう。今度はPromQLの内容を「conntrack」とします。allocationラベルの値であるusedとfreeの2つの系列が存在するグラフになるので、見やすくなるよう「凡例」の値に「allocation」、「表示」に「{{allocation}}」を指定しておきます。

見事に2つの系列が表示されました!

実際の定期投稿のためには、cronなどを使って1分ごとに実行するのがよいでしょう。コマンドライン上で簡単に試すのであれば、たとえば以下のようにsleepコマンドを使えます。

while /bin/true; do
  date
  mackerel-plugin-conntrack |expand -t 1 | nc -u -w 1 localhost 2003
  sleep 60s
done

Carbon Receiverを改変する

さて、スペース区切りで処理されているためにexpandを挟むような特別な措置が必要でしたが、Carbon Receiverを改変してタブも許容するようにするのはいかにも簡単そうです。

どこかな、とコードを探ったところ、以下のようにどストレートな処理をすぐに見つけました(path_parser_helper.go)。

parts := strings.SplitN(line, " ", 4)

タブも含む任意スペース区切りに変えればよさそうですね。

re := regexp.MustCompile(`\s+`)
parts := re.Split(line, 4)

OpenTelemetry Collectorのちょっとわかりにくい概念として(そして今回のようにCarbon Receiverを使ってみようとした動機として)、改変をするにはCollectorごとビルドし直す必要があります。

ついでにCarbon Receiverと最低限のコンポーネントだけになるようにしてみます。

まずはReceiverの該当コードを変更します。

$ git clone https://github.com/open-telemetry/opentelemetry-collector-contrib.git
$ cd opentelemetry-collector-contrib
(receiver/carbonreceiver/protocol/path_parser_helper.go に上記の改変を施す)

ビルドし直しについてはMackerelの「OpenTelemetry コレクターでホストメトリックを Mackerel に送信する」でも紹介がありますね。

OpenTelemetry Collector Builderをインストールします。

$ go install go.opentelemetry.io/collector/cmd/builder@latest

次にマニフェストファイルを用意します。こちらも名前は何でもよいのですが、manifest.yamlとしておき、opentelemetry-collector-contribフォルダ内に配置することにしました。

dist:
  name: otelcol-custom
  description: OTel Collector with Carbon Receiver modified for Mackerel
  output_path: ./dest

receivers:
   - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver latest
   - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/carbonreceiver latest

processors:
   - gomod: go.opentelemetry.io/collector/processor/batchprocessor latest

exporters:
   - gomod: go.opentelemetry.io/collector/exporter/otlpexporter latest
   - gomod: go.opentelemetry.io/collector/exporter/debugexporter latest

replaces:
   - github.com/open-telemetry/opentelemetry-collector-contrib/receiver/carbonreceiver => ../receiver/carbonreceiver

dist:で生成物類を設定し、receivers:processors:exporters:にはCollectorに含めたいコンポーネントを指定します。

そして、replaces:ではcarbonreceinverフォルダの実際の場所を変更しています。「..」としているのは、Collectorがdistで指定しているoutput_pathのフォルダ内でビルドされる都合上、そこからの相対位置で見る必要があるからです。

ビルドはこのYAMLファイルを指定するだけです。

$ builder --config manifest.yaml

destフォルダにotelcol-customバイナリができます。otelcol-contribのほうは終了させて、otelcol-customバイナリでconfig.yamlを使って試してみましょう(以下ではホームにconfig.yamlがあるものとしています)。

$ dest/otelcol-custom --config ~/config.yaml

この版のCollectorであれば、タブ区切りのまま送信しても、メトリックとして受け付けられます!

$ mackerel-plugin-conntrack | nc -u -w 1 localhost 2003

パッチとしてPR提出しようかと思ったものの、タブ区切りのCarbon形式を使っているほかのサービスが思いつかないので、二の足を踏んでいます。いっそforkしてmackerelreceiverにしてしまうほうがよいのかもしれません。

まとめ

ということで、Carbon Receiverを使えば、Mackerelメトリックプラグインの出力をOpenTelemetryのエコシステム(Mackerelも含む)に乗せられます。作り込んだメトリックプラグインが存在する場面での手法の一案としていかがでしょうか。

なお、mackerel-agentの出力ごと持っていけないものかというチャレンジも少ししてみたのですが、APIサーバーに問い合わせるロジック(idファイル生成など)がいくつか存在するため、そのあたりをうまく吸収できないと難しそうではありました。mackerelagentreceiverを実装してみるのも面白いかもしれませんね。

Happy Hacking!