Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

Azure SDKのHTTP Pipeline Policyの活用事例:APIコール数手動計装とクライアントサイドキャッシュ

様々な言語で提供されているAzure SDKのコアモジュールにはHTTP Pipeline Policyという仕組みが実装されています。

Java版のドキュメントが一番わかりやすかったので、こちらに掲載されている画像を引用して、HTTP Pipeline Policyが何たるかを説明します。

learn.microsoft.com

https://learn.microsoft.com/ja-jp/azure/developer/java/sdk/media/http-pipeline.svg

簡単に言うとHTTPクライアントのミドルウェアのようなものです。実際にHTTPリクエストをAPIに投げるまでにパイプラインで様々な前処理を行います。Azure SDKにはログを出力する機能やリクエストに失敗しても一定回数リトライする機能がありますが、これらはPipeline Policyとして実装されています。さらに、ユーザーが定義した独自のPolicyをパイプラインに組み込むことも可能です。

ユーザーがPipelineに登録できるPolicyはPer-call PolicyとPer-retry Policyの2種類あります。Per-call Policyは1回のメソッドの呼び出しにつき1回だけ呼ばれて実行されます。対してPer-retry Policyは、1回のメソッドの呼び出しでもRetry Policyによって複数回APIコールすることがありますが、実際のAPIコール分だけ呼ばれて実行されるものです。

ここからは、Azure SDK for Goの場合のコードを示しながら解説します。

Policyの実装と利用

https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azcore/policy#Policyの通り、Policyという型がinterfaceとして公開されています。これは

type Policy interface {
    Do(req *Request) (*http.Response, error)
}

という定義になっています。当ブログの読者はお気づきだと思うのですが、Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したRoundTrip interfaceとほぼ同じですね。Requestの部分がGo標準のhttp packageのものではなくsdk/azcore/policy packageで独自に定義されたものになっていますが、これは単なるwrapperで、.Raw()を用いて簡単にhttp.Requestを取り出すことができます。

例えばリクエスト先のURLをログに書くPolicyは以下のようにして書けます。

type URLLogPolicy struct {}

func (p URLLogPolicy) Do(req *policy.Request) (*http.Response, error) {
      ctx := req.Raw().Context()
      slog.InfoContext(ctx, "request", slog.String("url", req.Raw().URL.String()))
    return req.Next()
}

細かいですが、PolicyのDoメソッドはgoroutine safeでないと実用上困るので、Policyの不変性を保つためにレシーバはポインタにしない方が望ましいと思います。

Azure SDK独自のRequest型に、パイプラインの次のPolicy(もしくはTransport)を呼ぶメソッドが生えています。Go標準のHTTP Transportを自作するときには親のTransportを構造体内部に持っておくことが多いと思うのですが、Policyではこれをやらなくて済むので手間が省けて便利ですね。

Policyの実装ができたら、Azure SDKのClientを作る際にClientOptionsのPerCallPoliciesもしくはPerRetryPoliciesに渡してあげましょう。

client := armappservice.NewWebAppsClient(
    subscriptionID,
    session,
    &arm.ClientOptions{
        ClientOptions: policy.ClientOptions{
            PerCallPolicies: []policy.Policy{URLLogPolicy{}},
        },
    },
)

上記のようにすると、このclientを用いてAPIコールを伴うメソッドを呼び出した時にURLがログに出力されるようになります。

Policyのテスト

さて、Policyができたら次はテストを書きたくなるでしょう。テスト時にはAzure SDKのClient内部で作られているPipelineを手で作るのが一番手っ取り早く確実だと思います。Doの引数に渡るRequestを直接作ってもいいのですが、素朴に作ってしまうとPipelineの次のステージ(テスト時には専らTransportでしょう)が未定義となりエラーになってしまいます。

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "OK")
}))
defer ts.Close()
// 第1, 2引数はテストではそこまで関係ないはずなので適当に指定
pipeline := runtime.NewPipeline("", "", runtime.PipelineOptions{}, &policy.ClientOptions{
    PerRetryPolicies: []policy.Policy{URLLogPolicy{}},
})
req1, _ := runtime.NewRequest(context.Background(), http.MethodGet, ts.URL)
res, err := pipeline.Do(req1)

カスタムPipelineの活用事例

APIコール数をOpenTelemetry Metrics手動計装

APIコールは費用がかかるものですしレートリミットも設定されていることから、SDKが行ったAPIコールの数を可視化したい需要があるでしょう。

以下のようなPolicyをPerRetryPolicyとして設定することで、成功したAPIコール数をOpenTelemetry Metricsとして計装することができます。

type MonitorPolicy struct {
    Counter metric.Int64Counter
}

func (p MonitorPolicy) Do(req *policy.Request) (*http.Response, error) {
    ctx := req.Raw().Context()
    res, err := req.Next()
    if err != nil {
        return res, err
    }

    // 最新のAzure SDK for Goでは、contextにAPI名を入れてくれているのでここから取り出します
    apiName, ok := ctx.Value(runtime.CtxAPINameKey{}).(string)
    if !ok {
        apiName = "unknown"
    }
    p.Counter.Add(ctx, 1, metric.WithAttributes(attribute.String("azure.api_name", apiName)))
    return res, nil
}

MackerelのOpenTelemetry Metrics対応機能でグラフにした様子

また、同様にして、レスポンスヘッダに書かれているAPIコールレートリミットのバケット残数をヒストグラムとして計装することもできます。これは読者への課題としましょう。

クライアントサイドでキャッシュする

Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したTransportと同じようなものをPipeline Policyとして実装してあげることで、Azure APIの呼び出しを一定期間キャッシュすることができます。実装についてはGitHub - Arthur1/http-client-cache: a Go library for transparent HTTP client-side caching using Transportとほぼ同じなため本エントリでは割愛させていただきます。

さて、以下のドキュメントに記載されている通り、Azure Resource Manager APIのレートリミットポリシーは2024年3月ごろから順次新しいものに変わりました。

learn.microsoft.com

この改定によって一般的にはより多くのリクエストを送れるようになったのですが、制限が1時間毎のリクエスト数だったものから1分毎一定量バケットに補充される方法に変わったため、短い時間で大量のリクエストを送るようなケースでは制限が厳しくなってしまいました。そういったアプリケーションではPolicy 1つ導入してクライアントサイドでキャッシュすることによって、APIコール数超過に対して小さな改修で対応することができます。

感想

現代のAzure SDKは開発体験が良い洗練されたライブラリだと感じています。自分が何らかのAPI Clientライブラリを提供するときにはHTTP Pipeline Policyの仕組みをぜひ取り入れたいです。

銅鑼パーソン的YAPC::Hiroshima 2024日記

ブログを書くまでがYAPC!ということで、今更すぎますがYAPC::Hiroshima 2024の振り返り記事を書いていきます。ログなのでメッセージ性はそんなにありません。

yapcjapan.org

Stats

2/9(金)

準備

YAPC::Hiroshimaに行くことはproposalを出したときから決めていましたが、キャパオーバーであらゆる事務作業を後回しにしていました。2月になってからホテルを取ろうとしたところ、なんと全然ホテルが空いてない!!

直前キャンセルする人もいるだろうということで、広島に出発する当日の朝に一所懸命ホテルを探して、なんとか奇跡的に予算内のものを見つけました。去年前日祭のLT資料を直前に作りはじめたところから進歩していないですね。

blog.arthur1.dev

一安心して今度は新幹線のチケットを予約して荷造り。前回の経験から各社様の素敵なノベルティがたくさんもらえるだろうしお土産も買いそうということで、ほぼ空のスーツケースを用意しました。こっちはちゃんと学べてるじゃん。

広島到着&チェックイン

17:00ごろに広島駅着。JRの改札に「ようこそ広島へ!」と大きく書かれていたのとmazda車が展示されていたのが印象的で、早速ワクワクしてきました。

路面電車に乗ってホテルにチェックイン。あれだけホテルの残数がなかった状態で空いていたホテルがどんなものかとビクビクしていましたが、普通に快適なビジネスホテルでした。

大きな荷物を置いてヘイタクシーして移動。YAPC::Kyoto 2023では同僚と乗ったタクシーの運転手さんにカップルと間違われ謎のディスカウントを受けるイベントがありましたが、今回は芸能人と間違われて今日は何のロケですかとか謎に話の方向が進んでしまいなんだか申し訳なかったです。さらにこのあと運賃のレシートを受付に置いてきてしまったのが忘れ物第一号となったのでした。

前夜祭

少し遅れて到着。司会のPasta-Kさんに言われるがまま最前列に着席しました。onkさんtakesakoさんに挟まれて畏れ多い感じ。

前夜祭の自分の全ツイートはこの辺り

Introduce Hono v4!!!! / Yusuke Wadaさん

成果物は軽量で高速に動作、さらに強力な型の恩恵を受けられるHonoというフレームワークの話でした。Reactと同様のインタフェースを用いてClient Componentを書ける、それでいてコアが軽いなどの思想はそのままというのがすごい。これはゆっくり時間をとってコードの中身を覗きたくなるやつですね。

個人的にはやはりSSGをサポートしたというのが刺さりポイントでした。SSG(Static Exports)ができるフレームワークはNuxt.js・Gatsby.js・Next.js・Astroと色々触ったもののまだ自分の手にしっくり馴染むものが見つかっていないので、次の個人開発でHonoを利用してみようと考えています。

Cache-Control: max-age=86400 / キャッシュバスターズ(そーだいさん・onkさん)

キャッシュを適切に活用するためにはobservabilityめっちゃ大事じゃんと思いました。どの指標をどのように見れば良いかも紹介されていたので、システム開発・運用者として、さらにサーバー監視SaaSの提供者として非常に参考になりました。

伝えたいメッセージ的に「キャッシュバスターズ」という名前が「キャッシュは麻薬」と同じように字面だけ一人歩きしてキャッシュは良くないぞという印象を与えてしまわないかというのは若干の気になりでした。Vim開脚バスターを知っている層はバスターという言葉に感じる印象がポップよりなのかもしれない。(これは空説です。)

2/10(土)

本編

ちゃんと朝起きて間に合いました。えらい。

コーヒーブース

はてなはGold Sponsor&コーヒーブーススポンサーということで、ワキヤコーヒー様の「はてなブレンド」を提供させていただきました!

セッション

セッションを聞いた感想はXに投稿しているのでこちらをあたってください。

個人的に印象的だったのはid:SougmuさんのBlogを作り、育み、慈しむ ~ Blog Hacks 2024でした。自分はこのブログをはじめとしてWebによる自己表現を続けていて、それがあんまり反応されないなあとかメンタルを病むことも多々あったのですが、このトークに救われました。

ライトニングトーク

なんと、銅鑼パーソンという大役を務めさせていただきました!今回はサブイベント含め全く登壇できなかったな〜出番ないな〜と思ってたのでめちゃくちゃ嬉しいです。

LTはどれも面白かったのですが、ドラと時間計測用スマホを持つ左腕が終始悲鳴をあげていてそれだけでかなり精一杯でした。Perl or Rakuクイズは5分の時間制約を良い感じに拡張していてXで盛り上がっていたので面白い発想だなと思いました。

懇親会

このビールが最高でした!

2/11(日)

YAYAPC::Hiroshima ~オフラインだからできる話〜

イベントの性質上インターネット上で多くは語れないのですが少しだけ。

これまで自分が参加した技術イベントの中で一番面白かったです。イベントの趣旨ぴったりの良い話がこんなにも集まるのか〜と感動していました。

ネタがネタだけに清々しい気持ちで聞けない雰囲気もちょくちょくありましたが、id:fujiwaraさんによるキーノートの締め「すべてのWebサービスに感謝を」はこれ以上ない最高の終わり方だったと思います。

その後……

その日のうちに帰るために新幹線を取るかもう一泊分ホテルを取るか悩んでいたのですが、#yapcramenしそびれていたのがどうしても心残りでもう一泊することにしました。

さらにその後

blog.arthur1.dev

最後に

来てよかったです!!!また次回もお会いしましょう!と言いたいところなのですが次回のYAPC::Hakodate 2024は予定が合わず行けそうにありません。またいつか。

Go言語でHTTPレスポンスを透過的にキャッシュする

Webアプリケーションの裏側にさらにHTTPサーバが立っていて、レスポンスを返すために裏側のサーバにリクエストを送ってその結果を必要とするような構成があります。裏側のサーバに設定さえたAPIレートリミットへの対応やサーバへの過負荷を避けるため、キャッシュを利用してリクエストが飛びすぎないようにしたいケースがあるでしょう。

Go言語のHTTP ClientにはTransportというパラメータがあり、これを差し替えることで透過的なクライアントサイドのキャッシュ層を導入することができます。

実際にライブラリとして作ってみたのでご紹介します。

github.com

使い方

利用例を用意しています。

https://github.com/Arthur1/http-client-cache/blob/82d0e8e327b9fd37554a135b0915891621689a6b/_example/main.go

まずは以下のように、ファクトリ関数でtransportを生成し、http.ClientのTransportに設定します。

redisCli := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
transport := httpclientcache.NewTransport(
    rediscache.New(redisCli), httpclientcache.WithExpiration(5*time.Minute),
)
client := &http.Client{Transport: transport}

このclientのDoメソッドに*http.Requestを渡してあげることで、リクエストが5分間Redisにキャッシュされます。期限内にこのclientを利用して同じHTTPメソッド・本文のリクエストを送るとレスポンスが得られますが、HTTPリクエストを送っているわけではなくキャッシュから取り出しています。

req1, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res1, _ := client.Do(req1) // origin response

req2, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res2, _ := client.Do(req2) // cached response

実装の詳細

先ほど紹介したライブラリの実装のポイントをかいつまんで説明します。

キャッシュキーを生成する

キャッシュストレージとのやり取りに必要なので、*http.Request(のURL・メソッド・本文)から一意に定まるハッシュキーを生成します。

このとき、リクエストボディが必要になるのですが、io.ReadCloserという型になっており二度Bodyを読むことができません。すなわち、キャッシュキーを作るためにBodyを読んでしまうと、実際にclientがリクエストを送る時にBodyを読めなくなってしまいリクエストに失敗してしまいます。

この問題を防ぐため、以下のようにReadCloserを作り直して代入する必要があります。

body, err = io.ReadAll(req.Body)
if err != nil {
    return "", err
}
req.Body = io.NopCloser(bytes.NewReader(body))

Bodyが取得できたら、あとは他のパラメータと合わせてハッシュを生成しましょう。キーの暗号化要件は求められないため、軽量なハッシュアルゴリズムであるhash/fnvパッケージを利用しています。

http-client-cache/cache/key/key.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

キャッシュと読み書きして必要に応じてHTTPリクエストを送るTransportを作る

Transportに求められるinterfaceはRoundTripperで、Requestを引数にとってResponseとerrorを返すメソッドを実装すれば良いです。

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

フォールバック先のTransportをフィールドに持った構造体を作って、RoundTripperインタフェースを実装しましょう。

  • キャッシュヒット時
    • キャッシュから得たResponseを返す
  • キャッシュミス時
    • フォールバック先のTransportを利用してOriginにリクエストを送る
    • TTLを決めてレスポンスをキャッシュに格納する
    • Originから得たResponseを返す

上記のような手続きを行うRoundTrip()を作ってあげれば良いです。以下のコードでは、細かいところは簡略化しています。

type TransportWithCache struct {
    Base http.RoundTripper
}

func NewTransportWithCache() *TransportWithCache {
    return &TransportWithCache{Base: http.DefaultTransport}
}

func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    key, err := t.cacheKey(req)
    if err != nil {
        // キャッシュキーの生成に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }

    cachedRes, ok, err := cache.Get(ctx, key)
    if err != nil {
        // キャッシュからの取得に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }
    if ok {
        // キャッシュヒット
        return cachedRes, nil
    }

    // Origin にアクセス
    res, err := t.Base.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    // 200ならキャッシュにセットする
    if res.StatusCode == http.StatusOK {
        cache.Set(ctx, key, res, time.Minute)
    }
    return res, nil
}

http-client-cache/transport.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

http.Responseを保存可能な型に変換する

http.Responseはio.ReadCloserなどを含んだ構造体なので、そのままではRedisなどのデータストアに保存することができません。httputil.DumpResponseを利用することで、[]byteに変換することができます。

逆にhttp.Responseを復元する際には、http.ReadResponse関数が有効です。

req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res, _ := &http.Client{}.Do(req)
dumpedRes, _ := httputil.DumpResponse(res, true)
restoredRes, _ := http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)

http-client-cache/cache/engine/rediscache/redis.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

Cache Stampedeを防ぐ

キャッシュが破棄された時に、同時に並行してOriginにアクセスをしてキャッシュに格納しようとしてしまいバックエンドの負荷が高まってしまうCache Stampedeという問題があります。

これを防ぐため、singleflight packageを利用して、同じキャッシュキーのOriginへのリクエストは同時に1つしか飛ばないようにしています。

group.Do()ではhttp.Responseをそのまま返したいところなのですが、複数のgoroutineで同じ結果が共有されてしまうので、どれか1つのgoroutineしかBodyを読めなくなってしまいます。これを回避するため、先ほど紹介したhttputil.DumpResponseでbyte列にしてから返し、それぞれの呼び出し元で復元するようにしています。

+var group singleflight.Group

 func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
     ctx := req.Context()
     key, err := t.cacheKey(req)
     if err != nil {
         // キャッシュキーの生成に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
 
     cachedRes, ok, err := cache.Get(ctx, key)
     if err != nil {
         // キャッシュからの取得に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
     if ok {
         // キャッシュヒット
         return cachedRes, nil
     }
 
-    // Origin にアクセス
-    res, err := t.Base.RoundTrip(req)
-    if err != nil {
-        return nil, err
-    }
-    // 200ならキャッシュにセットする
-    if res.StatusCode == http.StatusOK {
-         cache.Set(ctx, key, res, time.Minute)
-     }
-    return res, nil
+    maybeDumpedRes, err, _ := group.Do(key, func() (any, error) {
+        // Origin にアクセス
+        res, err := t.Base.RoundTrip(req)
+        if err != nil {
+            return nil, err
+        }
+        // 200ならキャッシュにセットする
+        if res.StatusCode == http.StatusOK {
+            cache.Set(ctx, key, res, time.Minute)
+        }
+        dumpedRes, err := httputil.DumpResponse(res, true)
+        return dumpedRes, err
+    }
+    dumpedRes := maybeDumpedRes.([]byte)
+    return http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)
 }

待ち受けポートをカスタムしたsshdをアプデしたらsshで繋げなくなってしまった問題のポストモーテム

事象

piyolog.hatenadiary.jp

CVE-2024-6387の対応のため、個人開発サービスを運用していてインターネットに露出しているサーバのopenssh-serverをアップデートしていた。

Google CloudのCompute Engineで立てているあるサーバのopenssh-serverをapt upgradeでアップデートしたところ、以後sshで接続することができなくなってしまった。

原因

当該サーバではSSHのポート番号を22とは異なるものに設定していた。また、Cloud Firewallではtcp 22のインバウンド通信を不許可としており、SSHの代替ポートについて許可するルールとしていた。

apt upgrade時にsshd_configを更新しますがよろしいですかという確認が表示されていて、何も考えずにYを押してしまったところ、sshd_configが書き換えられた。

Configuration file '/etc/ssh/sshd_config'
 ==> Modified (by you or by a script) since installation.
 ==> Package distributor has shipped an updated version.
   What would you like to do about it ?  Your options are:
    Y or I  : install the package maintainer's version
    N or O  : keep your currently-installed version
      D     : show the differences between the versions
      Z     : start a shell to examine the situation
 The default action is to keep your current version.
*** sshd_config (Y/I/N/O/D/Z) [default=N] ? 

その結果、待ち受けポートを標準から変えていた設定がロールバックしてしまい、22番ポートでlistenするようになった。22番はFirewallでブロックしていたため、再接続できなくなってしまった。

影響

サーバの管理者である私がsshを経由して当該サーバにアクセスすることが不可能になった。個人開発サービスの可用性に影響はない。

対応

Firewallの設定を変更し、一時的に22番ポートのインバウンド通信を許可した。その結果22番ポート経由でsshで当該サーバに接続することができた。

sshd_configファイルを元の状態に書き換え、代替ポートで接続できることを確認した。

再発防止策

  • sshdをアップデートしたあと、そのセッションを閉じないまま別に接続してみて、これまでと変わらずに接続できることを確認する
    • sshdをアップデートしても、既存の接続は古いプロセスを使用し続ける
  • 設定ファイルを書き換えるようなメッセージが表示された場合にはノールックでYを押さずに差分を確認する

OpenTelemetryのテレメトリとMackerelのホストをResource Attributesで紐づけるためのOTelcol Processorを作りました

OpenTelemetryを使い始めるにあたって、既存の監視ツールからいきなり切り替えることは難しく、基本的には一時的に並行稼働させて様子を見ることになると思います。

これまでmackerel-agentをインストールし、Mackerelにホストとして登録してシステムメトリックを監視していたマシンに、新たにOpenTelemetry Collector+Host Metrics Receiverを導入するケースを考えてみましょう*1

Resource Detection Processorなどを併用することで、メトリックにホスト名などをResource Attributesとして付与することができます。しかし、比較のためにMackerel上のホストと対応させて眺めるには今一歩情報が足りません。

そこで、ホストにインストールされたmackerel-agentの設定ファイルを読み、Mackerel上のホストIDやURL、オーガニゼーション名といった情報をResource Attributesとして付与するMackerel Attributes Processorを開発しました。

github.com

以下のグラフは、実際にこのProcessorを利用してHost Metrics Receiver由来のメトリックにResource Attributesを付与したものになります。

属性名 属性値
mackerelio.host.id 4yWDxQP4GA5
mackerelio.host.url https://mackerel.io/orgs/arthur-1/hosts/4yWDxQP4GA5
mackerelio.org.name arthur-1

というような属性が付与されていることがわかります。

特定のattributeがあったらMackerelのホスト画面から対応するOpenTelemetryメトリックが見られる機能ができたらより捗りそうですね。

使い方

OpenTelemetry Collectorを自前でビルドするocbの設定ファイルで以下のように追記し、Mackerel Attributes Processorを含んだCollectorのバイナリを生成しましょう。

 receivers:
   - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/hostmetricsreceiver v0.103.0

 processors:
+  - gomod: github.com/Arthur1/opentelemetry-collector-arthur1/processor/mackerelattributesprocessor v0.4.0

 exporters:
   - gomod: go.opentelemetry.io/collector/exporter/otlpexporter v0.103.0

あとは以下のように設定ファイルを用意してOpenTelemetry Collectorを起動させればOKです。

 receivers:
   hostmetrics:
     collection_interval: 60s
     scrapers:
       memory:

+processors:
+  mackerelattributes:

 exporters:
   otlp/mackerel:
     endpoint: otlp.mackerelio.com:4317
     compression: gzip
     headers:
       Mackerel-Api-Key: ***censored****

 service:
   pipelines:
     metrics:
       receivers:
         - hostmetrics
+      processors:
+       - mackerelattributes
       exporters:
         - otlp/mackerel

実装

https://github.com/Arthur1/opentelemetry-collector-arthur1/blob/ff90509af4ac1c9861910e260b8149aa2262a96b/processor/mackerelattributesprocessor/processor.go ファイルがメインの実装部分になります。

起動時にmackerel-agent.confからAPIキーとホストIDファイルの設置場所を読み取っています。APIキーを読んでいる理由は、オーガニゼーション名は設定ファイルに載っておらずMackerel APIを叩いて取得する必要があるからです。ホストIDファイルがわかったらあとはそのファイルを読み取り、これら属性付与に必要な情報を変数に保存します。

以降はこの操作を1分に1回行います。ただし、mackerel-agent.confやホストIDファイルの更新日時を見て、変更されていなければスキップするようにしています。

processorとしてはリソースに紐づく様々なテレメトリがやってきたら、変数の値を読んでその通りにResource Attributesを付与しているだけのシンプルな実装です。このあたりはKubernetes Attributes Processorの実装を参考にしています。

最後に

https://github.com/Arthur1/opentelemetry-collector-arthur1リポジトリではOpenTelemetry Collectorの自作Component群と、それらを同梱した動作検証用のCollectorを公開しています。将来MackerelのOpenTelemetry Collectorディストリビューションが作れたら良いなと思い個人研究をしています。よかったらぜひ使ってみて感想を教えてください。

*1:mackerel-agentが収集するシステムメトリックはOpenTelemetry CollectorのHost Metrics Receiverで同等に取得可能であることはmackerel-agentが作るシステムメトリックグラフをOpenTelemetryで可能な限り再現する - Diary of a Perpetual Studentにてご紹介しました。

OpenTelemetry Metricsを手軽に投稿できるCLIツールotlcのv0.2.0をリリースしました

blog.arthur1.dev

でご紹介した、OpenTelemetry Metricsを手軽に投稿できるCLIツール「otlc」のv0.2.0をリリースしたので再度ご紹介します。

github.com

使い方

インストール

(macOS, Linux)×(x86_64, arm64)向けのバイナリをGitHubのReleaseで配布しています。

Homebrewをお使いの方は以下のコマンド1つでインストールが可能です。

$ brew install Arthur1/tap/otlc
$ otlc --version
otlc is a Command-line Tool to Export Telemetry by OTLP (OpenTelemetry Protocol).
Version:    unknown
Go version: go1.22.4
Arch:       arm64

go install派の方はこちらからどうぞ。こっちならWindowsでも動くかもしれない(未検証)。

go install github.com/Arthur1/otlc/cmd/otlc@latest

メトリックの投稿

まずは環境変数に投稿先の情報を入れます。今回はMackerelのOpenTelemetry対応機能に投稿してみましょう。これらの情報は環境変数を利用しなくても対応するオプションでセット可能です。(詳しくは--helpオプションで確認してください。)

export OTEL_EXPORTER_OTLP_ENDPOINT="otlp.mackerelio.com:4317"
export OTEL_EXPORTER_OTLP_HEADERS="Mackerel-Api-Key=***your_api_key***"

その後、以下のようなコマンドを実行すると、上記で設定した宛先にOTLPでメトリックが投稿されます。

  • メトリック名: awesome.metric
  • メトリック種類: Gauge
  • データポイント属性: hoge="poyo"・fuga="1"
  • データポイント値: 123.45
  • データポイント時刻: 現在
otlc metrics post --name awesome.metric --attrs hoge=poyo,fuga=1 123.45

ちょっとしたメトリックを継続的に投稿する時や検証の際に、SDKを使ったプログラムを書かなくてもコマンドラインでシュッと投稿できて便利です。

どうぞご利用ください。

v0.1.xとの差異

駆け出しなので結構破壊的な変更をしました。今後は仕様を安定させるつもりです。早くv1に上げたいのでフィードバックお待ちしています。

設定ファイルの廃止とOTel Exporterの標準的な環境変数の読み取りに対応

v0.1.xでは投稿先を設定する際にYAMLの設定ファイルを書くのが標準のやり方だったのですが、これを廃止しました。代わりに、同様の情報をコマンドラインオプションで指定できる他、OTLP Exporter onfigurationで定義された名前での環境変数の読み取りに対応しました。

--valueオプションの廃止

メトリックの値を指定する際に--valueオプションを利用していましたが、これをやめて単に引数としました。

--timestampオプションの追加

--timestampオプションでメトリックの時刻をUNIX秒で指定できるようになりました。

otlc metrics post --name awesome.metric --attrs hoge=poyo,fuga=1 --timestamp 1719276600 123.45

過去の時刻で投稿したため、先ほどのグラフが点から線になりました。

投稿時に異常終了するように

メトリックが投稿できない場合、これまではエラー内容がログに出力されるだけでしたが、コマンドとして異常終了するようになりました。

$ otlc metrics post --name awesome.metric --attrs hoge=poyo,fuga=1 123.45
otlc: error: failed to upload metrics: rpc error: code = PermissionDenied desc = authenticate error, please check mackerel-api-key
$ echo $?
1

実装の変化

元々はアプリケーションを計装する際に用いるOpenTelemetry SDK GoのMeterとInstrumentを使って実装していました。Instrumentでメトリックを生成したのちにMeterProviderをすぐにシャットダウンすることで即時に投稿することを期待した、という形です。

https://github.com/Arthur1/otlc/blob/27866d6f826d6aa267f1f0de1687fddfe50b89b9/metrics/post.go#L37-L87

前回のブログで

より低いレイヤーのメソッドを利用して組み立てるとより自由なコマンドラインツールに仕上げられるかもしれません。

と書いた通り、MeterやInstrumentに頼る実装を改め、メトリックデータを直接生成してエクスポートするようにしました。

https://github.com/Arthur1/otlc/blob/94f56eb8612042f799a80664a54110ee6ef5ed5a/internal/metric/generate.go#L23-L76

メトリックの時刻が指定できるようになったり、投稿失敗時に異常終了するようになったりしたのはこの置き換えによるものです。

IPsec VPN実装であるLibreswanの状態を監視できるMackerelプラグイン

IPsec VPN実装であるLibreswanの状態をメトリックとして見られるようにするMackerelプラグインを作りました。

github.com

strongSwanのものは先人が作っていましたが、Libreswanのものがなかったので自作した形です。

nonylene.hatenablog.jp

何が見られるか

Libreswanで作ったVPNサーバの状態が見られます。

  • IPsec Connectionの合計(active/loaded)
  • IPsecのSecurity Associations(total/authenticated/anonymous)
  • IKEのSecurity Associations(total/open/half-open/authenticated/anonymous)

インストール

mkr installに対応した形式で配布しています。

sudo mkr plugin install Arthur1/mackerel-plugin-libreswan

でインストールして

[plugin.metrics.libreswan]
command = ["/opt/mackerel-agent/plugins/bin/mackerel-plugin-libreswan"]

と書けば動きます。

Docker上で動かしているLibreswanにも対応

以下のようにVPNサーバをDockerで動かしている例もあるでしょう。

github.com

ipsecコマンドが実行できるコンテナ向けにこのプラグインを実行したければ、-docker-execオプションを活用できます。例えば、

$ docker exec -it ipsec-vpn-server ipsec version
Libreswan 5.0

のようにしてipsecコマンドを実行できる環境の場合には、

[plugin.metrics.libreswan]
command = ["/opt/mackerel-agent/plugins/bin/mackerel-plugin-libreswan", "-docker-exec", "ipsec-vpn-server"]

のように、-docker-execオプションとしてコンテナ名を渡してあげると動きます。