Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goで実装したアプリケーションのメトリックをOpenTelemetryで計装する

これはOpenTelemetry Advent Calendarの14日目です。

qiita.com

どんな話がいいかなと考えていたのですが、ここでは「アプリケーションとOpenTelemetry Collectorがどのように関わってメトリックを(Prometheusなどの)バックエンドサービスに送信するのか」を見ていこうと思いました。今からOpenTelemetryを触るならOpenTelemetry Collectorは実質必須なコンポーネントだと思うで、関係を把握しておくと嬉しいことがあるかもしれません。

OpenTelemetry Collectorとは何か

記憶によると、2020年頃はOpenTelemetry Collectorが存在していなかったので、過去に書いたOpenTelemetryでメトリックを記録するではアプリケーションにExporter*1を組み込んでいました。その構成は今でも可能だとは思いますが、現在はOpenTelemetry Collectorという層を一つ挟んで、OpenTelemetry Collectorがバックエンドサービスへ送る構成が主流です。そのため、トレースやログも同様ですが、メトリックを計装する場合もアプリケーションとOpenTelemetry Collectorの両方を実行する必要があります。

アプリケーションのコードを書く

まず、OpenTelemetryで10秒ごとに1をカウントするだけのコードを書いてみましょう。このコードでは localhost:4317*2でOpenTelemetry Collectorが待ち受けている想定ですが、まだCollectorは実行していません。後で実行するので今は気にせず読み進めてください。

package main

import (
    "context"
    "log"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/sdk/metric"
)

func main() {
    log.SetFlags(0)
    ctx := context.Background()

    // export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 としても同じ
    // https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
    exp, err := otlpmetricgrpc.New(ctx,
        otlpmetricgrpc.WithInsecure(),
        otlpmetricgrpc.WithEndpoint("localhost:4317"),
    )
    if err != nil {
        log.Fatalln(err)
    }
    meterProvider := metric.NewMeterProvider(metric.WithReader(
        metric.NewPeriodicReader(exp, metric.WithInterval(time.Minute)),
    ))
    defer meterProvider.Shutdown(ctx)
    otel.SetMeterProvider(meterProvider)

    meter := global.Meter("github.com/lufia/otel-demo")
    counter, err := meter.SyncInt64().Counter("demo-app/counter")
    if err != nil {
        log.Fatalln(err)
    }
    for {
        counter.Add(ctx, 1)
        time.Sleep(10 * time.Second)
    }
}

カウントしているのは以下の部分です。簡単ですね。

counter, _ := meter.SyncInt64().Counter("demo-app/counter")
counter.Add(ctx, 1)

カウントの型

上記のコードでは SyncInt64Counter を使いましたが、他にもいくつか利用できます。大きく「同期」か「非同期」があって、単純な数値演算の場合は同期を使うとよいでしょう。同期と比べると非同期のほうがやや手間がかかるのですが、メモリ状況の取得など、数値演算よりも重い処理は非同期のほうを検討してもいいかもしれません。

また、同期と非同期でそれぞれ、どのような種類のメトリックなのかを表す型があります。まとめると以下のように分類できます。

  • SyncInt64, SyncFloat64の場合に使える型
    • Counter
    • UpDownCounter
    • Histgram
  • AsyncInt64, AsyncFloat64の場合に使える型
    • Counter
    • UpDownCounter
    • Gauge

関連ドキュメント

値の他にも、単位やラベルなどをオプションとして渡すことができます。以下のドキュメントにはコード例なども書かれているので、Goに馴染みのある人ならすぐに分かるのではないかと思います。

OpenTelemetry Collectorを実行する

アプリケーションのコードはできたので、ここからはOpenTelemetry Collectorを localhost:4317 で待ち受けるように準備しましょう。Linuxのパッケージでインストールする方法など、公式にいくつかの導入方法が存在しますが、まずは最も手軽に実行できるDockerコンテナで動かしてみます。

Docker以外の実行方法も用意されているので、興味があれば以下のドキュメントを参照してください。

DockerでOpenTelemetry Collectorを実行

公式にイメージが提供されているので、docker run するだけで実行できます。

$ docker run otel/opentelemetry-collector:latest

実際に使う場合は、それぞれの環境で必要なコンポーネントを調整したくなるでしょう。Dockerで実行する際に設定ファイルをOpenTelemetry Collectorへ与えたい場合は、ボリュームマウントで/etc/otelcol/config.yamlとします。

$ docker run -v $(pwd)/config.yaml:/etc/otelcol/config.yaml otel/opentelemetry-collector:latest

設定ファイルの書き方は以下のドキュメントを参照ください。

OpenTelemetry Collectorを使う場合、アプリケーションはOTLPプロトコルでCollectorへメトリックを送信します。アプリケーションからのメトリックを受け取るために、OpenTelemetry Collector側ではotlpreceiverを有効にしています。これはデフォルトで4317ポートを使うのでオプションなどは何も構成していません。また、受け取ったメトリックをfileexporterで標準出力*3に書き出すように設定しました。

receivers:
  otlp:
    protocols:
      grpc:

exporters:
  file:
    path: /dev/stdout

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [file]

以上をまとめると、以下のコマンドでOpenTelemetry Collectorを実行します。

$ docker run -v $(pwd)/config.yaml:/etc/otelcol/config.yaml -p 4317:4317 otel/opentelemetry-collector:latest

これで、先に挙げたカウントプログラムを実行すると、ログに以下のようなJSONが流れてくると思います。

{
  "resourceMetrics": [
    {
      "resource": {
        "attributes": [
          {
            "key": "service.name",
            "value": {
              "stringValue": "unknown_service:main"
            }
          },
          {
            "key": "telemetry.sdk.language",
            "value": {
              "stringValue": "go"
            }
          },
          {
            "key": "telemetry.sdk.name",
            "value": {
              "stringValue": "opentelemetry"
            }
          },
          {
            "key": "telemetry.sdk.version",
            "value": {
              "stringValue": "1.11.2"
            }
          }
        ]
      },
      "scopeMetrics": [
        {
          "scope": {
            "name": "demo-app"
          },
          "metrics": [
            {
              "name": "demo-app/counter",
              "sum": {
                "dataPoints": [
                  {
                    "startTimeUnixNano": "1670849814514923712",
                    "timeUnixNano": "1670849934524305041",
                    "asInt": "12"
                  }
                ],
                "aggregationTemporality": 2,
                "isMonotonic": true
              }
            }
          ]
        }
      ],
      "schemaUrl": "https://opentelemetry.io/schemas/1.12.0"
    }
  ]
}

OpenTelemetry Collectorを自前でビルドする

上ではDockerでOpenTelemetry Collectorを実行しましたが、もうひとつ自分でビルドしたCollectorを実行する方法も試してみます。

現在、OpenTelemetry Collectorは otelcol と呼ばれるコア部分と一部コンポーネントの実装と、ベンダーに依存したコンポーネントなどを追加した otelcol-contrib といった2つのディストリビューションが存在します。ソースコードは以下のリポジトリで管理されています。

また、ビルドした実行ファイルは以下のリポジトリで提供されています。

自前でビルドする場合、コアに含まれているコンポーネントだけで足りるならopentelemetry-collectorをビルドすればいいのですが、おそらく他のコンポーネントを使いたくなると思うので、ここでは otelcol-contrib をビルドすることにしました。

ビルド方法

ビルドの手順としては、Goをインストールした状態で、以下を実行するだけ*4です。

$ git clone https://github.com/open-telemetry/opentelemetry-collector-contrib.git
$ cd opentelemetry-collector-contrib

$ make otelcontribcol

数分待って bin/otelcontribcol_xxx に実行ファイルが作られたら完了です。xxx の部分は環境によって変化します。

$ ./bin/otelcontribcol_${GOOS}_${GOARCH} --config=config.yaml

カスタムビルド

必要なコンポーネントだけ組み込んだOpenTelemetry Collectorも作れるらしいので、興味がある人は以下を試してみてください。手順をみる限りは、opentelemetry-collector-contribに含まれていないコンポーネントも追加できそうにみえますね。

他の手段でメトリックを取得する

最初に、アプリケーションで計装するコードを実装しましたが、全部を自分で実装するのは大変です。OpenTelemetryでは他の手段でも取得できるようなコンポーネントなどが用意されています。

レシーバに他のコンポーネントを追加する

レシーバを設定しておくと、それに従ってOpenTelemetry Collectorがメトリックを集めてくれるようになります。昨日の記事でも使われていますが、例えばホストのCPUやメモリなどはhostmetricsコンポーネントで取得できます。

--- config.yaml  2022-12-12 22:57:49.022845490 +0900
+++ config-host.yaml  2022-12-12 22:57:36.169550825 +0900
@@ -1,4 +1,8 @@
 receivers:
+  hostmetrics/basic:
+    scrapers:
+      cpu:
+      memory:
   otlp:
     protocols:
       grpc:
@@ -10,5 +14,5 @@
 service:
   pipelines:
     metrics:
-      receivers: [otlp]
+      receivers: [hostmetrics/basic, otlp]
       exporters: [file]

contribのreceiverには hostmetrics の他にもいろいろあるので眺めてみる面白いかもしれません。

ライブラリを組み込む

コンポーネントのほかにも、ライブラリとして用意されていることもあります。opentelemetry-go-contrib には net/http のレイテンシや送受信バイト数を取得できるミドルウェアなど、よく使うものが用意されています。OpenTelemetry公式のほかにも、go-redisにも同等の実装があるらしいので、利用しているライブラリを調べてみるといいかもしれません。

ただしopen-telemetry/opentelemetry-goのMetrics APIはまだAlphaなので、(以前よりは穏やかになったとはいえ)まだ変化しています。そのため、公式のライブラリはだいたい新しいAPIに対応できていると思いますが、サードパーティのものは追従できていない場合もあるかもしれませんので、そこは注意してお使いください。

*1:バックエンドサービスへメトリックやトレースなどを送るコンポーネント

*2:ここでは直接書いていますが、OTEL_EXPORTER_OTLP_ENDPOINTなどOpenTelemetry Protocol Exporterに書かれている環境変数を参照する方が良いと思います

*3:ログファイルに書き込む場合は事前に作っておく必要があった

*4:Plan 9でも動くかなと思って試してみたけど定数が定義されていないなどでビルドエラーになった