Plan 9とGo言語のブログ

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

OpenTelemetryでメトリックを記録する

OpenTelemetryトレースの例はいっぱい見つかりますが、メトリックはまだ実装中*1だからなのか、ほとんど見つからなかったので調べたことをまとめました。

OpenTelemetryの概要は公式ドキュメントのOverviewを眺めると雰囲気わかると思います。

2020/04/30追記: いくつか変更があったので大きめの差分を以下の記事にまとめました

blog.lufia.org

使い方

OpenTelemetryでは、メトリックの記録と、メトリックをバックエンドサービスへ送るためのExporter設定を行う必要があります。ExporterはStackdriverやPrometheusなど標準で用意されていているものを使うこともできるし、なければ自分で作ることもできます。この記事では、(せっかく作ったので)Mackerel Exporterを使ってコードを書きますが、どのExporterを選んでも計測するコードには影響ありません。

事前に用意されているメトリック用のExporterはopentelemetry-goリポジトリにいくつかあります。

Exporterの準備

まずExporterの準備を行いましょう。OpenTelemetryのGo用パッケージを使って書いていきます。go.opentelemetry.io/otelは大きく/api以下のパッケージと/sdk以下のパッケージに分かれています。/sdk以下のパッケージは、/apiの裏で参照されていたり、独自のExporterを実装する場合などに必要となりますが、メトリックを記録するだけなら通常は/api以下のパッケージだけを使えばよい設計になっています。

import (
    "context"
    "log"
    "os"
    "runtime"
    "time"

    "go.opentelemetry.io/otel/api/core"
    "go.opentelemetry.io/otel/api/global"
    "go.opentelemetry.io/otel/api/key"
    "go.opentelemetry.io/otel/api/metric"

    "github.com/lufia/mackerelexporter-go"
)

var (
    keyHostID      = key.New("host.id")
    keyHostName    = key.New("host.name")
    keyServiceNS   = key.New("service.namespace")
    keyServiceName = key.New("service.name")
)

func main() {
    apiKey := os.Getenv("MACKEREL_APIKEY")
    pusher, err := mackerel.InstallNewPipeline(mackerel.WithAPIKey(apiKey))
    if err != nil {
        log.Fatal(err)
    }
    defer pusher.Stop()

    ...
}

以上のコードで、Exporterは一定周期(Checkpoint)ごとに計測した値をバックエンドサービスへ送るようになりました。他のExporterでも、オプションなどは変わると思いますが基本的にはInstallNewPipelineメソッドが用意されていると思うので、それを使えばいいでしょう。Checkpointの間隔はExporterによって異なります。Mackerelの場合は常に1分単位ですが、stdoutに出力するだけのExporterは1秒間隔です。

Exporterは複数設定してもエラーにはなりませんが、計測したメトリックはどれか1つのExporterにしか送られませんので1つだけ設定して使いましょう。OpenTelemetry Collectorのドキュメントによると、Collectorを使うと複数のExporterへ送ることができると読めますが、使ったことはないので分かりません。

メトリックの用意

続けて、Exporterを登録しただけではメトリックの記録はできませんので、使う準備をしていきましょう。

meter := global.MeterProvider().Meter("example/ping")
mAlloc := meter.NewInt64Gauge("runtime.memory.alloc", metric.WithKeys(keys...))

global.MeterProvider().Meter(string)metric.Providerを作り、そこへカウントなどを記録していく使い方となります。上のコード例ではint64を扱うハコ(Gauge)を1つ用意しました。OpenTelemetryでは、このようにメトリックの種類と値の型の組み合わせで記録したい時系列データを表現します。

メトリックの種類

メトリックの種類に選べるものは以下の3つです。

  • Measure - 複数の値を記録するもの(例: HTTPハンドラのレイテンシ)
  • Gauge - 最新の値だけ分かればいいもの(例: メモリ利用率)
  • Count - カウンタ(例: GC回数)

MeasureGaugeの違いは分かりづらいのですが、GaugeはCheckpointの最終値だけ分かれば良い場合に使います。例えばメモリの使用量について、途中がどんな値であれ最終的にCheckpoint時点の値さえ分かればいいならGaugeにするといいでしょう。そうではなく、Checkpointまでに記録した値を集計したい要望があるならMeasureを選びましょう。Measureは期間内に発生した値を郡として扱うので、Exporterはその値を使って最大・最小・平均・合計などを計算できます。

値の型(NumberKind)

メトリック値の型は以下の3つから選べます。

  • Int64NumberKind
  • Float64NumberKind
  • Uint64NumberKind

この型は、具体的なメトリックの値(core.Number)の型となります。

metric.WithKeysは何をしているのか

上のコード例で、

meter.NewInt64Gauge("runtime.memory.alloc", metric.WithKeys(keys...))

と書きましたが、このmetric.WithKeysは何をしているのでしょうか。これは、メトリックの値と一緒に記録することが推奨されるラベルを設定しています。keysはファイルの先頭で宣言しているので、言い換えると上のコードで準備したruntime.memory.allocメトリックは

  • host.id
  • host.name
  • service.namespace
  • service.name

のラベル4つを値と一緒に記録することを推奨する、と表現できます。ラベルの名前は好きなものを使って構いませんが、OpenTelemetryは標準的なリソース名が定義されているので、それに合わせた方が便利でしょう。

推奨ラベル以外のラベルが渡された場合の扱いは、Exporterの実装によって異なります。Mackerel用のExporterは推奨ラベル以外を無視しますが、他のExporter、例えばstdoutに出力するExporterは推奨に含まないラベルも渡されたもの全てをそのまま扱います。この動作はBatcherインターフェイスの実装に以下のどちらを選ぶかによって変わります。

defaultkeysは推奨ラベルのみ扱う実装です。反対にungroupedはなんでも扱います。

メトリックを記録する

メトリックの値は、用途によって4種類の記録方法が使えます。

  • ラベルや値を全て指定して記録する - Direct
  • 事前に設定しておいたラベルを使って記録する - Bound
  • バッチ処理 - 複数の値をまとめてatomicに記録する - Batch
  • metric.InstrumentImplを使って記録する - Impl

以下でDirect, Bound, Batchの3つについて書き方を紹介します。Implについてはドキュメントを読めば雰囲気は分かると思うので調べてみてください。

全て指定して記録(Direct)

ラベルと値を両方指定する方法です。

gauge := meter.NewInt64Gauge(...)
gauge.Set(ctx, 100, labels)

counter := meter.NewInt64Counter(...)
counter.Add(ctx, 1, labels)

measure := meter.NewInt64Measure(...)
measure.Record(ctx, 10, labels)

ラベルを事前に設定しておく(Bound)

ラベルを省略できるので、同じラベルでなんども記録する場合に便利です。

gauge := meter.NewInt64Gauge(...)
boundGauge := gauge.Bind(labels)
boundGauge.Set(ctx, 100)

counter := meter.NewInt64Counter(...)
boundCounter := counter.Bind(labels)
boundCounter.Add(ctx, 1)

measure := meter.NewInt64Measure(...)
boundMeasure := measure.Bind(labels)
boundMeasure.Record(ctx, 10)

複数の値をまとめて記録(Batch)

Exporterは非同期にメトリックをバックエンドへ送っているため、タイミングによっては、本来は2つペアとなるメトリックなのに片方だけ更新された状態でCheckpointに到達してしまった、という状態が起こり得ます。そういった、不完全な状態でExporterが送らないように、関連する値をまとめて更新する方法が用意されています。

meter.RecordBatch(ctx, labels,
    gauge.Measurement(100),
    counter.Measurement(1),
    measure.Measurement(10),
)

Batchで記録する場合は、ラベルをBindさせる手段は無さそうです。

ラベルの値を設定する

今後、属性(Attribute)に名前が変更されそうですが、今はまだラベルと呼びます。

これまで、ラベルはキーしか定義していませんでしたが、ラベルはキーと値で構成されるものです。メトリックにラベルを設定する場合は値も必要なので

hostID := keyHostID.String("10-1-2-241")
statusNotFound := keyStatusCode.Int32(404)

のように必要なペアを作ってメトリックと一緒に記録しましょう。キーはcore.Key型で、値はcore.ValueType型です。値には以下の型を使えます。

  • BOOL
  • INT32
  • INT64
  • UINT32
  • UINT64
  • FLOAT32
  • FLOAT64
  • STRING

ペアになったラベルはcore.KeyValueです。どれもcoreパッケージで定義されています。

まとめ

OpenTelemetryのMetric APIは、

  • アプリケーションは必要なときに値を記録する
  • 記録した値を一定周期でサービスへ送る

をするだけです。とりわけOpenTelemetryを使わず自前実装しても、大した手間ではありません。だけどもOpenTelemetryの開発が進むにつれてエコシステムも整備されていくはずです。今はopentelemetry-goリポジトリpluginディレクトリにはトレース用のプラグイン(extension)しかありませんが、database/sqlnet/http、Redisなどのメトリックを扱うプラグインは今後おそらく用意されるでしょうし、ドキュメントを眺めた雰囲気では、Collectorなど群を扱うグッズも増えていく気がします。メトリック送信のしくみを自前実装する方が、覚えることが少なく心理的に手を出しやすいけれども、エコシステムの恩恵を受けるために今のうちから対応しておくといいんじゃないかなと思っています。

*1:Project Statusによるとv0.2