Plan 9とGo言語のブログ

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

opencensus-goでPlan 9用のmackerel-agentを作っている話

この記事はMackerel Advent Calendar 2019の12日目です。

公式に提供されているmackerel-agentはGoで書かれていて、WindowsLinuxmacOSなどに対応していますが、Plan 9には対応していません。個人のサーバはPlan 9が動作しているし良い機会なので、opencensus-goを使ってPlan 9で動くmackerel-agentを作ってみようと思いました。

OpenCensusはOpenTelemetryへ統合されることが発表されていますが、移行は考慮されるでしょうし、まだOpenCensusでいいかなと思いました。

ソースコードはこちら。

github.com

まだメモリ関連メトリックの投稿しかできませんが、とりあえず動いています。

f:id:lufiabb:20191212133113p:plain
動いている様子

概要

OpenCensusについて

OpenCensusはメトリック収集と分散トレーシングのフレームワークです。net/httpgRPC、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(単位無し)、Bymsの3種類くらいではないでしょうか。ちなみにこの仕様に出てくる表ではprint, c/sc/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.Newctxに付与したタグは、stats.Recordで一緒に記録されてExporterから参照できるようになります。途中でタグの値を変更したい場合、tag.Newtag.Updatetag.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だけですが、これはStartStopと合わせて使われます。

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自体の実装。

// Exporter is a stats exporter that uploads data to Mackerel.
type Exporter struct {
    opts Options
    once sync.Once
    r    *metricexport.IntervalReader
    c    *mackerel.Client
}

// Options contains options for configuring the exporter.
type Options struct {
    APIKey string
}

func NewExporter(o Options) (*Exporter, error) {
    c := mackerel.NewClient(o.APIKey)
    return &Exporter{
        opts: o,
        c:    c,
    }, nil
}

// Start starts the metric exporter.
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
    }
    //trace.RegisterExporter(e)
    e.r.ReportingInterval = interval
    return e.r.Start()
}

func (e *Exporter) Stop() {
    //trace.UnregisterExporter(e)
    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 {
        // View.Nameの値から'/'を'.'に置き換え
        name := metricName(p.Descriptor)

        // 値と一緒に記録したタグからホストIDを取り出す
        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

            // OpenCensusのMetricをMackerelのホストメトリックに変換
            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からグラフ定義を作るのは難しい(どこで切ればいいか分からない!)のでカスタムメトリックはどう扱えばいいか悩んでいます。システムメトリックの場合は、グラフ定義は用意されているので何も考える必要ありませんでしたが、カスタムメトリックではグラフ定義の存在を避けられません。例えば最後の/までを親にする等で回避できないかなーなんて考えています。(実際シェルでプラグイン実装するとそのように振る舞ってそう?)