はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

先日、Grafana Labs から Grafana Alloy という OpenTelemetryコレクタがリリースされました。
これは従来のコレクタ実装である Grafana Agent Flow の後継とされています。
(Alloy のリリースに伴い、Agent は Deprecated となりました。)

早速 Alloy を使って OpenTelemetry の シグナル である Traces、Metrics、Logs を収集してみました。

OpenTelemetryバックエンド

テレメトリ・データの格納先として、SigNoz を使います。

Docker の Composeファイル が用意されているので、これを使ってシュッと立ち上げます。

$ docker compose -f docker/clickhouse-setup/docker-compose.yaml up --scale hotrod=0 --scale load-hotrod=0

補足

この Composeファイルは Hot R.O.D. というテレメトリ・データ生成用のデモ・アプリを含んでいます。
起動すると延々と無駄なデータを投入し続けてうるさいので、--scale を 0 にして起動しないようにしています。

代わりに自分でデータ生成用アプリを作成することにします。

起動後、http://localhost:3301/ にアクセスすると Adminアカウントの作成を求められます。
適当に作成しましょう。

なお、データ・ストアである signoz-clickhouseコンテナがちょいちょい Warning や Error を吐いていますが、検証用途なので気にしないことにします。

Alloy

Alloy もお手軽に Docker で動かします

compose.yaml
---
services:
  alloy:
    image: 'grafana/alloy:v1.0.0'
    command: [
      'run',
        '--server.http.listen-addr', '0.0.0.0:12345',
        '--storage.path', '/var/lib/alloy/data',
        '--stability.level', 'public-preview',
        '/etc/alloy/config.alloy'
    ]
    ports:
      - '12345:12345'
    configs:
      - source: 'alloy'
        target: '/etc/alloy/config.alloy'

configs:
  alloy:
    file: './config.alloy'

デモ・アプリケーション

テレメトリ・データを生成するアプリケーションを用意します。
(特に理由はありませんが Rails で作成してみます。)

OpenTelemetry Ruby SDKGetting Started のとおりに組み込み、これを Alloy と同じ Docker-composeプロジェクトで動かします。
サービス名は app とし、Alloy から見て http://app:3000/ でアクセスできるようにしておきます。
(さらに、利便性のためホストにもポート3000番を公開しておきます。)

また、Rails を developmentモードで動かした場合、DNSリバインディング対策として Hostヘッダに 強めの制限 が課されます。
これを緩和して Alloy からアクセスできるように、環境変数 RAILS_DEVELOPMENT_HOSTS='app' をセットしておきます。

GitHubリポジトリ

Traces

まずは Tracesシグナルを収集してみましょう。

デモ・アプリをいじって、サイコロの出目を Tracesシグナルに乗せるようにします。

app/controllers/dice_controller.rb
class DiceController < ApplicationController
  def roll
    roll_num = rand(6) + 1

    # 出目を Tracesシグナルに乗せる。
    OpenTelemetry::Trace.current_span.add_attributes(
      'roll' => roll_num
    )

    render json: roll_num.to_s
  end
end

補足

Attributes を追加する際、キーを Symbol にすると怒られてしまいます。

  OpenTelemetry::Trace.current_span.add_attributes(
    roll: roll_num  # NG!
  )

キーは文字列でないといけません。
(Ruby的にはちょっと気持ち悪いですが…。)

シグナルを Alloy に送信するには opentelemetry-exporter-otlp Gem を使用します。

送信先は環境変数 OTEL_EXPORTER_OTLP_ENDPOINT で指定します。

OpenTelemetry はトランスポート・レイヤとして gRPC と HTTP をサポートしますが、この Gem は HTTP でデータを送っているようです。
トランスポートの種類によって受信ポートが分かれており、HTTP は 4318番を指定します。
(ちなみに gRPC は 4317番です。)

アプリから見た Alloy のホスト名は alloy なので、当該環境変数は以下のようになります。

OTEL_EXPORTER_OTLP_ENDPOINT='http://alloy:4318'

補足

Exporterリスト には gRPC用の Gem も見られますが、開発中でしょうか? それとも Deprecated なのでしょうか?
RubyGems.org では Yanked となってしまっています。

続いて Alloy側を設定します。

使用するコンポーネントは以下です。

コンポーネント用途
otelcol.receiver.otlpデモ・アプリからシグナルを受け取る。
otelcol.processor.memory_limiter使用メモリ量を一定値以下に抑制し、Out of Memory を防ぐ。
otelcol.processor.batch収集したシグナルを一定量まとめてバッチ処理する。
otelcol.exporter.otlpSigNoz へシグナルを送る。
config.alloy
 1otelcol.exporter.otlp "default" {
 2  client {
 3    endpoint = "http://host.docker.internal:4317"
 4
 5    tls {
 6      insecure = true
 7    }
 8  }
 9}
10
11otelcol.processor.batch "default" {
12  timeout = "1s"
13
14  output {
15    traces = [
16      otelcol.exporter.otlp.default.input,
17    ]
18  }
19}
20
21otelcol.processor.memory_limiter "default" {
22  check_interval = "1s"
23
24  limit = "200MiB"
25
26  output {
27    traces = [
28      otelcol.processor.batch.default.input,
29    ]
30  }
31}
32
33otelcol.receiver.otlp "default" {
34  http {}
35
36  output {
37    traces = [
38      otelcol.processor.memory_limiter.default.input,
39    ]
40  }
41}

SigNoz は Alloy とは別の Dockerネットワークで動いているので、ホストを経由してシグナルを送ることにします。
今回は Docker for Mac を使用しているので、コンテナから見たホストのアドレスは host.docker.internal となります。

また、HTTPS ではないため、tls.insecure = true として TLS接続を無効にしています。

コンポーネント同士の繋がりを可視化すると以下のようになります。
(http://localhost:12345/graph で確認できます。)

http://localhost:3000/rolldice に何度かアクセスして Tracesシグナルを発行すると、そのデータが SigNoz に蓄積されるようになります。

Services:

Traces:

サイコロの出目もしっかり記録されています。

Metrics

次に、デモ・アプリのヘルスチェックを Metricsシグナルとして取得してみることにします。

最近の Rails であれば、ヘルスチェックのエンドポイントとしてデフォルトで /up が用意されています。

app/config/routes.rb
Rails.application.routes.draw do
  ...

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  ...
end

このエンドポイントを Prometheus の Blackbox exporter で叩きます。

コンポーネント用途
prometheus.exporter.blackboxヘルスチェック・エンドポイントを叩く。
prometheus.scrapeエクスポータを駆動してメトリクスを収集する。
otelcol.receiver.prometheusPrometheusメトリクスを OpenTelemetryシグナルへ変換する。

併せて、すでに定義済みの OpenTelemetry系コンポーネントの output に Metricsシグナルを追加します。

config.alloy#diff
--- a	2024-03-26 09:31:03
+++ b	2024-03-26 12:57:08
@@ -13,10 +13,14 @@
 
   output {
     traces = [
       otelcol.exporter.otlp.default.input,
     ]
+
+    metrics = [
+      otelcol.exporter.otlp.default.input,
+    ]
   }
 }
 
 otelcol.processor.memory_limiter "default" {
   check_interval = "1s"
@@ -25,10 +29,14 @@
 
   output {
     traces = [
       otelcol.processor.batch.default.input,
     ]
+
+    metrics = [
+      otelcol.processor.batch.default.input,
+    ]
   }
 }
 
 otelcol.receiver.otlp "default" {
   http {}
@@ -37,5 +45,33 @@
     traces = [
       otelcol.processor.memory_limiter.default.input,
     ]
   }
 }
+
+otelcol.receiver.prometheus "default" {
+  output {
+    metrics = [
+      otelcol.processor.memory_limiter.default.input,
+    ]
+  }
+}
+
+prometheus.exporter.blackbox "default" {
+  config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }"
+
+  target {
+    name = "app"
+    address = "http://app:3000/up"
+    module = "http_2xx"
+  }
+}
+
+prometheus.scrape "default" {
+  scrape_interval = "10s"
+
+  targets = prometheus.exporter.blackbox.default.targets
+
+  forward_to = [
+    otelcol.receiver.prometheus.default.receiver,
+  ]
+}

設定ファイル全体は以下のとおりです。
(展開してご覧ください。)

config.alloy
 1otelcol.exporter.otlp "default" {
 2  client {
 3    endpoint = "http://host.docker.internal:4317"
 4
 5    tls {
 6      insecure = true
 7    }
 8  }
 9}
10
11otelcol.processor.batch "default" {
12  timeout = "1s"
13
14  output {
15    traces = [
16      otelcol.exporter.otlp.default.input,
17    ]
18
19    metrics = [
20      otelcol.exporter.otlp.default.input,
21    ]
22  }
23}
24
25otelcol.processor.memory_limiter "default" {
26  check_interval = "1s"
27
28  limit = "200MiB"
29
30  output {
31    traces = [
32      otelcol.processor.batch.default.input,
33    ]
34
35    metrics = [
36      otelcol.processor.batch.default.input,
37    ]
38  }
39}
40
41otelcol.receiver.otlp "default" {
42  http {}
43
44  output {
45    traces = [
46      otelcol.processor.memory_limiter.default.input,
47    ]
48  }
49}
50
51otelcol.receiver.prometheus "default" {
52  output {
53    metrics = [
54      otelcol.processor.memory_limiter.default.input,
55    ]
56  }
57}
58
59prometheus.exporter.blackbox "default" {
60  config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }"
61
62  target {
63    name = "app"
64    address = "http://app:3000/up"
65    module = "http_2xx"
66  }
67}
68
69prometheus.scrape "default" {
70  scrape_interval = "10s"
71
72  targets = prometheus.exporter.blackbox.default.targets
73
74  forward_to = [
75    otelcol.receiver.prometheus.default.receiver,
76  ]
77}

これでヘルスチェックの結果が SigNoz に蓄積されるようになりますが、このメトリクスを表示するには明示的にダッシュボードを作成する必要があります。
ダッシュボードは SigNoz のサイドメニュー “Dashboards” から作成できます。

Logs

最後に Logsシグナルを収集します。

OpenTelemetry Ruby SDK はデフォルトでデモ・アプリの標準出力を拾って Logsシグナルとして投げつけてくる (※) ようですが、敢えて明示的にログ・ファイルを読み取ってみます。

※ Rails の起動ログやアクセス・ログ、Rails.logger などで吐いた任意のメッセージが勝手に拾われます。

まずは適当なメッセージをファイルに吐くようにします。

app/controllers/dice_controller.rb
class DiceController < ApplicationController
  def roll
    roll_num = rand(6) + 1

    # 出目を Tracesシグナルに乗せる。
    OpenTelemetry::Trace.current_span.add_attributes(
      'roll' => roll_num
    )

    # 雑にファイルに吐く。
    Logger.new(
      File.join(Rails.root, 'log/dice.log')
    ).info("サイコロの出目は #{roll_num.to_s} でした。")

    render json: roll_num.to_s
  end
end

ログ・ファイルのパスは RAILS_ROOT/log/dice.log です。
Alloy がこのファイルに到達できるように、Alloyコンテナに /logs/app/dice.log としてマウントしておきます。

Alloy でログ・ファイルを読み込むには Loki系コンポーネントを使用します。

コンポーネント用途
loki.source.fileファイルからログ・エントリを読み込む。
otelcol.receiver.lokiLoki形式のログ・エントリを OpenTelemetryシグナルへ変換する。

既存の OpenTelemetry系コンポーネントの output に Logsシグナルを追加します。

config.alloy#diff
--- a	2024-03-26 12:57:08
+++ b	2024-03-26 15:26:04
@@ -17,10 +17,14 @@
     ]
 
     metrics = [
       otelcol.exporter.otlp.default.input,
     ]
+
+    logs = [
+      otelcol.exporter.otlp.default.input,
+    ]
   }
 }
 
 otelcol.processor.memory_limiter "default" {
   check_interval = "1s"
@@ -33,10 +37,14 @@
     ]
 
     metrics = [
       otelcol.processor.batch.default.input,
     ]
+
+    logs = [
+      otelcol.processor.batch.default.input,
+    ]
   }
 }
 
 otelcol.receiver.otlp "default" {
   http {}
@@ -73,5 +81,25 @@
 
   forward_to = [
     otelcol.receiver.prometheus.default.receiver,
   ]
 }
+
+otelcol.receiver.loki "default" {
+  output {
+    logs = [
+      otelcol.processor.memory_limiter.default.input,
+    ]
+  }
+}
+
+loki.source.file "default" {
+  targets = [
+    {
+      __path__ = "/logs/app/dice.log",
+    },
+  ]
+
+  forward_to = [
+    otelcol.receiver.loki.default.receiver,
+  ]
+}

設定ファイル全体は以下のとおりです。

config.alloy
  1otelcol.exporter.otlp "default" {
  2  client {
  3    endpoint = "http://host.docker.internal:4317"
  4
  5    tls {
  6      insecure = true
  7    }
  8  }
  9}
 10
 11otelcol.processor.batch "default" {
 12  timeout = "1s"
 13
 14  output {
 15    traces = [
 16      otelcol.exporter.otlp.default.input,
 17    ]
 18
 19    metrics = [
 20      otelcol.exporter.otlp.default.input,
 21    ]
 22
 23    logs = [
 24      otelcol.exporter.otlp.default.input,
 25    ]
 26  }
 27}
 28
 29otelcol.processor.memory_limiter "default" {
 30  check_interval = "1s"
 31
 32  limit = "200MiB"
 33
 34  output {
 35    traces = [
 36      otelcol.processor.batch.default.input,
 37    ]
 38
 39    metrics = [
 40      otelcol.processor.batch.default.input,
 41    ]
 42
 43    logs = [
 44      otelcol.processor.batch.default.input,
 45    ]
 46  }
 47}
 48
 49otelcol.receiver.otlp "default" {
 50  http {}
 51
 52  output {
 53    traces = [
 54      otelcol.processor.memory_limiter.default.input,
 55    ]
 56  }
 57}
 58
 59otelcol.receiver.prometheus "default" {
 60  output {
 61    metrics = [
 62      otelcol.processor.memory_limiter.default.input,
 63    ]
 64  }
 65}
 66
 67prometheus.exporter.blackbox "default" {
 68  config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }"
 69
 70  target {
 71    name = "app"
 72    address = "http://app:3000/up"
 73    module = "http_2xx"
 74  }
 75}
 76
 77prometheus.scrape "default" {
 78  scrape_interval = "10s"
 79
 80  targets = prometheus.exporter.blackbox.default.targets
 81
 82  forward_to = [
 83    otelcol.receiver.prometheus.default.receiver,
 84  ]
 85}
 86
 87otelcol.receiver.loki "default" {
 88  output {
 89    logs = [
 90      otelcol.processor.memory_limiter.default.input,
 91    ]
 92  }
 93}
 94
 95loki.source.file "default" {
 96  targets = [
 97    {
 98      __path__ = "/logs/app/dice.log",
 99    },
100  ]
101
102  forward_to = [
103    otelcol.receiver.loki.default.receiver,
104  ]
105}

ログ・ファイルに吐かれるメッセージが SigNoz に蓄積されるようになります。

まとめ

Grafana Agent の後継である Grafana Alloy を用いて各種OpenTelemetryシグナルを収集してみました。

使用感は従来の Agent (Flowモード) と変わらず、Terraformライクな様式の設定ファイルやデバッグ用の UI もそのままです。

Alloy のリリースに伴い Agent は Deprecated となったため、弊社も順次 Alloy へ移行する予定です。

今後ともよろしくお願い申し上げます。