この記事はMackerel Advent Calendar 2019 の12日目です。
公式に提供されているmackerel-agent はGoで書かれていて、Windows 、Linux 、macOS などに対応していますが、Plan 9 には対応していません。個人のサーバはPlan 9 が動作しているし良い機会なので、opencensus-go を使ってPlan 9 で動くmackerel-agentを作ってみようと思いました。
OpenCensusはOpenTelemetryへ統合されることが発表されていますが、移行は考慮されるでしょうし、まだOpenCensusでいいかなと思いました。
ソースコード はこちら。
github.com
まだメモリ関連メトリックの投稿しかできませんが、とりあえず動いています。
動いている様子
概要
OpenCensusについて
OpenCensusはメトリック収集と分散トレーシングのフレームワーク です。net/http 、gRPC 、Redis、MongoDBなどを扱うインテグレーション はあらかじめ用意されていますが、OpenCensus自体が例えばホストのメモリ使用状況などを取得してくれるわけではありません。メトリックの計測自体はWebアプリケーションなど自身が行なう必要があります。そうして、取得した値をOpenCensusが提供する仕様で収集しておくと、対応したバックエンドサービスへ定期的に送られる、というものです。
バックエンドサービスへ送るものは、OpenCensusではExporterと呼びます。公式ページに各言語で対応しているExporter(リンクはGoの場合) のリストが提供されています。Mackerelはリストに入っていませんが、カスタムExporterを作ることは可能なので、今回はこれも実装することにしました。ただし、完全に汎用的なExporterを作るのは(後で記述するように)難しいので、Plan 9 用mackerel-agentのためだけに使うと割り切った実装をしています。
mackerel-agent-plan9 がやること
公式のmackerel-agentはホスト情報やメトリックの投稿、プラグイン 実行など意外と色々なことをやっています。これを全て再現するのはとても大変なので、最低限動くところだけ実装しました。
ホストの登録(ホストメトリック投稿のために必要)
メトリックの収集(この記事ではメモリだけ対応)
収集したメトリックの投稿(Exporter)
これ以外のこと、具体的には
ホスト情報の更新
カスタムメトリックの投稿
サービスメトリックの投稿
メトリック投稿失敗時のリトライ
プラグイン の実行
などは全て未対応です(後で対応するかもしれませんが...)
Mackerelのメトリック
上でいくつかメトリックの種類が出てきたので整理しておくと、Mackerelにおけるメトリックは以下のように分類されます。
ホストメトリック
システムメトリック
公式mackerel-agentが自動的に収集するもの
カスタムメトリック
サービスメトリック
ホストと一括りに呼んでいますが、ここでは物理、仮想マシン に限らず、RDSやLambda Functionなどもまとめてホスト(スタンダードホスト・マイクロホストの違いはありますが...)として扱っています。この辺りの詳細はメトリックの種類 - missasanの日記 で丁寧に書かれています。
実装
では実装の話です。まずPlan 9 からメトリックを収集できなければ始まりません。Plan 9 では、カーネル が提供する/dev/swap から、その時点のメモリの利用状況を読み取ることが可能です。各行の説明は省きますが、read(2) すると以下のような行指向のテキストファイルになっているのでbufio.Scanner などで読み進めていけばいいので簡単ですね。
1071185920 memory
4096 pagesize
61372 kernel
2792/200148 user
0/160000 swap
9046176/219352384 kernel malloc
0/16777216 kernel draw
メトリックの収集
取得したメトリックを、OpenCensusのStats/Metrics として扱える状態にするには、OpenCensusのMeasureやViewなどが必要です。OpenCensusの用語と、バックエンドサービス(この記事ではMackerelのこと)の用語が異なっていて難しいので、およそ同じ概念だろうと思う対応表を用意しました。
OpenCensus
Mackerel
メモ
Measure
グラフ定義
メトリックの単位や名前
Aggregation
該当なし?
合計、カウント、最終値 など
Tag
グラフ定義?
名前と値のkey-value ペア
View
グラフ定義
上3つの概念をまとめたもの
Measurement
メトリックの値
記録した時点のメトリック値
View Data
該当なし?
メトリック投稿前のバッファ
Stats
メトリック
メトリックのコレクション
Integration
プラグイン
上で説明したので省略
次に具体的なコードです。OpenCensusでStackdriver Monitoringにメトリクスを送信する - YAMAGUCHI::weblog によると、OpenCensusのView設定はパッケージグローバルで用意しておくのがベストプラク ティスとのことなので同じように書きました。
import (
"go.opencensus.io/stats"
"go.opencensus.io/stats/view"
"go.opencensus.io/tag"
)
var (
mMemUsed = stats.Int64("memory/used" , "Used" , "By" )
mMemAvail = stats.Int64("memory/available" , "Avail" , "By" )
mMemTotal = stats.Int64("memory/total" , "Total" , "By" )
memUsedView = &view.View{
Measure: mMemUsed,
Name: "memory/used" ,
Description: "Memory used" ,
Aggregation: view.LastValue(),
TagKeys: []tag.Key{HostKeyID},
}
memAvailView = &view.View{
Measure: mMemAvail,
Name: "memory/available" ,
Description: "Memory available" ,
Aggregation: view.LastValue(),
TagKeys: []tag.Key{HostKeyID},
}
memTotalView = &view.View{
Measure: mMemTotal,
Name: "memory/total" ,
Description: "Memory total" ,
Aggregation: view.LastValue(),
TagKeys: []tag.Key{HostKeyID},
}
HostKeyID = tag.MustNewKey("meta.host.id" )
KeyHostName = tag.MustNewKey("meta.host.name" )
KeyOS = tag.MustNewKey("meta.os" )
KeyCPUName = tag.MustNewKey("meta.cpu.name" )
KeyCPUMHz = tag.MustNewKey("meta.cpu.mhz" )
)
まず
mMemUsed = stats.Int64("memory/used" , "Used" , "By" )
ですが、これはOpenCensusのMeasureとしてメモリの使用量を定義しています。メトリック名はそのままMackerelのシステムメトリック名に対応しますが、区切りを.
から/
に変更しています。OpenCensusが用意しているインテグレーションのメトリック名はopencensus.io/http/client/request_bytes のような形で事前に定義されているので、将来このインテグレーションを使った時に困らないよう、OpenCensusの習慣に合わせて区切りを/
として扱うことにしました。Mackerelでは.
区切りなので、ExporterがMackerelへ投稿する前に.
へ置き換えます。memory/used の/
を置き換えるとmemory.used となって、Mackerelのシステムメトリックとして扱えます。名前については公式ドキュメントのMeasure も目を通すといいでしょう。
By はバイト数を表現する単位です。OpenCensusの単位はThe Unified Code for Units of Measure に準拠している必要があって、この仕様では世の中の色々な単位が用意されていますが、実際のところ安全に使える単位は1 (単位無し)、By 、ms の3種類くらいではないでしょうか。ちなみにこの仕様に出てくる表ではprint , c/s 、c/i の3列があって、printは良くわかりませんが残りはcase sensitive またはcase insensitive を意味しているようです。
次にViewです。これはMeasureで記録する値(Measurement)をどう扱うか、を定義します。
memUsedView = &view.View{
Measure: mMemUsed,
Name: "memory/used" ,
Description: "Memory used" ,
Aggregation: view.LastValue(),
TagKeys: []tag.Key{HostKeyID},
}
Measure フィールドはViewが扱うMeasureを設定します。Name は今回たまたまMeasureと同じ値ですが、異なっていても構いません。Aggregation フィールドはCount , Distribution , LastValue , Sum の4つから選びます。
MeasureとViewの違いですが、雑な表現をするとMeasureが計測対象で、Viewはその見え方です。例えばSQL のクエリ実行時間を計測する場合、アプリケーションがクエリを実行するたびに実行時間(Measure)を計測して値(Measurement)をView Dataへ保存します。保存した値(Measurement)は一定周期でExporterがバックエンドへ送信しますが、この時点でView Dataに複数の値が収集されている可能性があります。Viewを通すことでメトリックの合計や最終値 などといった集積値として扱えます。mackerel-agentの場合、1分単位でホストの状態を収集するので、ほとんどは最終値 (LastValue)でしょう。
TagKeys は、OpenCensusuではMeasurementを計測する時に、一緒に複数のタグも付与することができるのですが、このタグからどれを使うのかを定義しています。
値(Measurement)の計測
OpenCensusで値を記録するのは、stats.Record を呼ぶだけです。メモリの使用量を収集するコードは以下のようになります。
if err := view.Register(views...); err != nil {
log.Fatal(err)
}
host, err := p9stats.ReadHost(*rootdir)
if err != nil {
log.Fatal(err)
}
ctx, _ := tag.New(context.Background(),
tag.Insert(HostKeyID, id),
tag.Insert(KeyHostName, host.Sysname),
tag.Insert(KeyOS, "plan9" ),
tag.Insert(KeyCPUName, host.CPU.Name),
tag.Insert(KeyCPUMHz, strconv.Itoa(host.CPU.Clock)),
)
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
<-t.C
m, err := p9stats.ReadMemStats(*rootdir)
if err != nil {
log.Fatal(err)
}
stats.Record(ctx,
mMemUsed.M(m.UserPages.Used*m.PageSize),
mMemAvail.M(m.UserPages.Avail*m.PageSize),
mMemTotal.M(m.Total),
)
}
ctx を作るところでTagを付与していますが、tag.New でctx に付与したタグは、stats.Record で一緒に記録されてExporterから参照できるようになります。途中でタグの値を変更したい場合、tag.New でtag.Update やtag.Upsert などを使って更新するといいでしょう。
ctx, _ = tag.New(ctx, tag.Update(KeyHostName, host.Sysname))
Mackerelとの繋ぎ込み
MackerelはAPIを公開 しているので自前で実装しても難しくはないですが、せっかくmackerel-client-go が用意されているのでこれを使います。
問題はExporterの実装で、どうやら現状、実装パターンは2通りあるようです。
ExportView
まずはExportView を実装するパターン。公式のWritting a custom exporter にもこの方法が書かれています。
import "go.opencensus.io/stats/view"
type Exporter interface {
ExportView(vd *view.Data)
}
このインターフェイス を実装して、
view.RegisterExporter(&customMetricsExporter{})
view.SetReportingPeriod(1 * time.Minute)
でExporterを登録しておくと、上の例では1分間隔でViewごとにExportView の呼び出しが行われます。view.Data からViewなども参照できるので、ここで値を取り出してMackerelに送ればいいでしょう。
ただし、事情はあまりよく分かっていませんが、いくつかのExporter実装を読むとExportView はDeprecatedとされていて、代わりにExportMetrics を使うように書かれていました。
ExportMetrics
インターフェイス はExportMetrics だけですが、これはStart やStop と合わせて使われます。
import "go.opencensus.io/metric/metricexport"
type Exporter interface {
ExportMetrics(ctx context.Context, data []*metricdata.Metric) error
}
こちらのパターンでは、StatsやView Dataの代わりにMetricやTimeSeriesという用語が使われていて、metricdata.Metric からViewを参照できません。ただしstats/view/view_to_metric.go を読むと、ViewをDescriptor に変換してくれているようなので記録する側のコードはそのまま使えます。ただし、ViewとDescriptor が完全に対応するわけではなく、Resource など設定する方法がないものも存在します。resourcekeys など便利そうだけど使えません。
ExportMetrics で実装したコードはこのようになりました。Exporterを起動する部分。
e, err := exporter.NewExporter(exporter.Options{
})
if err != nil {
log.Fatal(err)
}
if err := e.Start(1 * time.Minute); err != nil {
log.Fatal(err)
}
defer e.Stop()
Exporter自体の実装。
type Exporter struct {
opts Options
once sync.Once
r *metricexport.IntervalReader
c *mackerel.Client
}
type Options struct {
APIKey string
}
func NewExporter(o Options) (*Exporter, error ) {
c := mackerel.NewClient(o.APIKey)
return &Exporter{
opts: o,
c: c,
}, nil
}
func (e *Exporter) Start(interval time.Duration) error {
var err error
e.once.Do(func () {
e.r, err = metricexport.NewIntervalReader(&metricexport.Reader{}, e)
})
if err != nil {
return err
}
e.r.ReportingInterval = interval
return e.r.Start()
}
func (e *Exporter) Stop() {
e.r.Stop()
}
func (e *Exporter) ExportMetrics(ctx context.Context, data []*metricdata.Metric) error {
a := convertToHostMetrics(data)
if err := e.c.PostHostMetricValues(a); err != nil {
e.ErrLog(err)
return err
}
return nil
}
func convertToHostMetrics(a []*metricdata.Metric) []*mackerel.HostMetricValue {
var r []*mackerel.HostMetricValue
for _, p := range a {
name := metricName(p.Descriptor)
i := labelKeyIndex(p.Descriptor, HostKeyID.Name())
if i < 0 {
continue
}
for _, ts := range p.TimeSeries {
if !ts.LabelValues[i].Present {
continue
}
hostID := ts.LabelValues[i].Value
a := hostMetricValues(hostID, metricValues(name, ts.Points))
r = append (r, a...)
}
}
return r
}
func labelKeyIndex(d metricdata.Descriptor, key string ) int {
for i, k := range d.LabelKeys {
if k.Key == key {
return i
}
}
return -1
}
func hostMetricValues(...省略...)
これで収集したメトリックを、Exporterを通してMackerelへ投稿できるようになりました。実際はホスト登録なども必要ですが、mackerel-client-go を使っておけばそんなに迷うことはないでしょう。tag.New で付与したタグは以下のコードで取り出せるので、ホスト登録の際にタグからホスト名などを解決できると良いかもしれません。
m := tag.FromContext(ctx)
m.Value(key)
ExportMetric の実装は、調べながら書いたので不格好ですね。公式サンプル実装が用意されているので、どこから何を参照すればいいか分からない時にとても参考になりました。
思ったことなど
今はシステムメトリックしか考慮していませんが、システムメトリック以外はカスタムメトリックという前提にすれば、意外とExporterは使い回しできるかもしれないなと思いました。サービスメトリックを表現したい場合は、HostKeyID の代わりにServiceKeyID を用意しておくと、どのタグが付けられているかによって表現できそうな気がします。ただ、他のバックエンドへ一緒に送ることを考えると、ホスト登録やメトリック分類のためにタグ付けを必須とするのは行儀が悪い振る舞いかもしれません。まあ今はPlan 9 用mackerel-agentのためだけに使うので問題ないですが。
もう一つ、これはPlan 9 用だとしても発生する問題で、Mackerelのグラフ定義は親子関係になっていて、ExportMetrics に届いたViewからグラフ定義を作るのは難しい(どこで切ればいいか分からない!)のでカスタムメトリックはどう扱えばいいか悩んでいます。システムメトリックの場合は、グラフ定義は用意されているので何も考える必要ありませんでしたが、カスタムメトリックではグラフ定義の存在を避けられません。例えば最後の/
までを親にする等で回避できないかなーなんて考えています。(実際シェルでプラグイン 実装するとそのように振る舞ってそう?)