Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

ぼーっと眺めるためのRSSリーダーを自作したらインターネットが楽しい

この記事ははてなエンジニア Advent Calendar 2025 の1月9日の記事です。

最近自分専用の RSS リーダーを楽しく自作しています。

まだまだ作り途中ではありますが。

この RSS リーダーは沢山の記事をぼーっと眺める用に作っています。タイムラインは一つのみでお互いに関係ない記事が時系列順にバーっとならんでいます。政治ニュースの次には美味しい情報、IT, アニメの情報、ちょっとセクシーな記事だったり匿名ダイアリーの意味のわからない記事だったりバラバラです。眺めているだけでこれがこれこそがインターネットだということを楽しめるわけでございます。

このRSSリーダーは沢山のフィードを登録すればするほど楽しくなります。ジャンルがバラバラな記事が並べば並ぶほど完成されていくわけです。高度なレコメンド順などはありません。アルゴリズムから解放されます。

またはてなブックマークのブクマ数も表示されます。これは嬉しい。

ブクマ数が表示されている

RSS はニュースやブログだけではなく、Google ニュースのキーワード検索や Mastodon, Bluesky, YouTube などでも提供されているので、色々と購読できるわけです。

Mastdon の特務機関NERV (@UN_NERV@unnerv.jp) でもRSS フィードが提供されている

Google News のキーワード検索の結果を RSS にする機能をつかって福原遥さんのニュースを購読する

RSS が提供されているないサイトでも大チェッカーを使うとサイトの更新を RSS にしてくれたりもします。

daichkr.hatelabo.jp

実装は Go のバックエンドと Vite + React の SPA の構成です。趣味の実装では自分でぴこぴこ楽しくプログラミングがしたいので依存ライブラリは少ないです。今のところバックエンドが依存しているサードパーティは mattn/go-sqlite3 だけで、 フロントエンドもReact と React Router と SWR と valibot くらい。 (依存の依存は除く)

インフラ構成としては DB に SQLite を使い、さくらインターネットの安いタイプのVPSにデプロイするというシンプルなものです。完全に自分専用です。いまのところ自分だけがアクセスできるように認証をかけています。

いろんなサイトが自由気ままに RSS フィードを書き出すのでパースするのは大変です。 例えば RSS に含まれる記事の公開時刻をパースするために次のように複数のフォーマットを試しています。

func ParseRSSDate(s string) (time.Time, error) {
    layouts := []string{
        time.RFC1123Z,
        time.RFC1123,
        "Mon, 2 Jan 2006 15:04:05 -0700",
        "Mon, 2 Jan 2006 15:04:05 MST",
        "Mon, 02 Jan 2006 15:04 -0700",
        "Mon, 02 Jan 2006 15:04 MST",
        time.RFC822Z,
        time.RFC822,
        "02 Jan 2006 15:04 -0700",
        "02 Jan 2006 15:04 MST",
        time.RFC3339,
    }
    for _, layout := range layouts {
        t, e := time.Parse(layout, s)
        if e == nil {
            return t, nil
        }
    }
    return time.Time{}, fmt.Errorf("rss: cannot parse time: %s", s)
}

すでに公開されている RSS パースライブラリを使えば楽できるんですけどね。完全に趣味の世界です。

将来的には

  • OGP や Twitter カードを取得するようにする?
  • 僕以外の人も使えるようにする?
  • RSS だけではなく ActiveHub とかにも対応する?
  • もっとちゃんとインフラを整える?

なんかができるといいかなぁ。

RSS リーダーを自作するのは楽しいです。単に有用ですし、ユーザーがひとりしかいないのに無限に記事が更新されていくので、寂しいこともないです。やろうと思えばいろいろ拡張する余地もあります。

net/http.HandlerFunc において冗長になりがちな JSON のデコードを華麗に書く!

愚直に書くとデコードが長ったらしくなる

net/http.HandlerFunc でリクエストとレスポンスを JSON でやり取りする REST API やそれに似たハンドラーを書くとき、JSON の変換を愚直に書くと次のようになる。

// createUser は `POST /user` のハンドラー。 req.Body の JSON より新しいユーザーを作成して、
// レスポンスとして作成したユーザーの JSON を返す。
func createUser(w http.ResponseWriter, req *http.Request) {
    // ↓↓↓ここからリクエストの JSON のデコード↓↓↓
    reqBody, err := io.ReadAll(req.Body)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    var user *userctl.createUserParams
    err = json.Unmarshal(reqBody, &user)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    // ↑↑↑ここまでリクエストの JSON のデコード↑↑↑
    res, err := userctl.CreateUser(req.Context(), user)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // ↓↓↓ここからレスポンスの JSON のエンコード↓↓↓
    resBody, err := json.Marshal(res)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // ↑↑↑ここまでレスポンスの JSON のエンコード↑↑↑
    w.WriteHeader(200)
    w.Write(resBody)
}

このコードのキモは userctl.CreateUser だけで他の部分は JSONエンコードデコードである。長ったらしいいし、 JSON のデコードエンコード決まりきっているのだから共通化したい。

私なら createUser をこうやって書く

// createUser は `POST /user` のハンドラー。user から新しいユーザーを作成して、
// レスポンスとして作成したユーザーの JSON を返す。
func createUser(w http.ResponseWriter, req *http.Request, user *userctl.createUserParams) {
    res, err := userctl.CreateUser(req.Context(), user)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    xhttp.WriteJSON(w, 200, res)
}

これならスッキリして、冗長な部分がほとんどない。ただこの createUserhttp.HandlerFunc ではないので、 http.Handler へ変換する関数を別で用意する。関数の実装はこの記事の最後に載せた。

// xhttp は net/http へのユーティリティパッケージ。
package xhttp

// JSONHandlerFunc は [http.HandlerFunc] と似ているが、req.Body を JSON として
// パースした値 reqbody を引数に追加した関数。すでに req.Body はすでに消費されている。
type JSONHandlerFunc[T any] func(w http.ResponseWriter, req *http.Request, reqbody T)

// JSONHandler は JSONHandlerFunc を http.Handler へ変換する。h は req.Body の読み取りに
// non-nil エラーが返ってきたときや JSON へのパースが失敗したときは f を呼ばず、
// レスポンスとして 400 を返す。
func JSONHandler[T any](f JSONHandlerFunc[T]) (h http.Handler)

// WriteJSON は resbody を JSON へエンコードして指定した HTTP code のレスポンスとして返す。
// JSON へのエンコードが失敗したときは 500 レスポンスを返す。
func WriteJSON(w http.ResponseWriter, code int, resbody any)

この xhttp.JSONHandler を用いると createUser は次のように Server へハンドラーを登録できる。

http.Handle("POST /user", xhttp.JSONHandler(createUser))

ほとんど冗長に感じることはないのではないか。

どうして JSONHandlerFunc はこんな形?

JSON のデコードを短くするには他の方法も色々あるだろう。ただこの方法は次の点で優れている。

JSONHandler の実装が短いこと。

この記事の最後に付録として JSONHandler の実装例を載せた。これは20行だ。 この程度なら初見でもすぐ理解できるだろうし、メンテナンスもそんなに大変ではないだろう。

もし JSONHandler のようなユーティリティ関数の実装が長くなってしまうなら、自分で作らずにサードパーティライブラリを使うべきだろう。ただサードパーティライブラリを使うと依存ライブラリの更新などめんどくさい点も多いので、やたらめったらサードパーティを導入するわけにもいかない。

サードパーティを使わず、でもさくっと自分で管理できるようユーティリティ関数は短く書けることに気を使うべきだろう。

JSONHandlerFunc は net/http.HandlerFunc と使い心地が似ていること。

JSONHandlerFuncHandlerFunc に3つ目の引数が追加されている以外は同じである。だから HandlerFunc の知見はほとんど JSONHandlerFunc に適用可能だ。

もし JSONHandlerFuncHandlerFunc と全然違う形だったら、例えば「リクエストの JSON のみを引数でうけて、返り値をレスポンスの JSON として返す」の形だったらどうなるだろうか。

? func createUser(ctx context.Context, user *userctl.createUserParams) (any, err) {
?     return userctl.CreateUser(req.Context(), user)
? }

これは短く書けているように見えるが、「リクエストヘッダーをどうやって見る?」「レスポンスコードはどうやって指定する?」「レスポンスが JSON ではないときどうする?」のときどう対処しよう。Context にリクエスト情報を含めるなど脱出ハッチを考えることはできるだろう。でもその「脱出ハッチ」のためにドキュメントがやたら長ったらしくなるし、ユーザーは脱出ハッチの方法を学ばないといけない。

net/http のユーティリティ関数を作るときは net/http の既存の関数と形や使い心地を似せること、 http.ResponseWriterhttp.Requst をユーティリティ関数の内部に隠さないこと、が教訓となる。

付録: コードの txtar

Go で型変換を含んだイテレータの処理をメソッドチェーンっぽく記述する

この記事は「はてなエンジニア Advent Calendar 2024 - Hatena Developer Blog」の44日目の記事です。

JavaScriptScala などで配列やリストを処理するときメソッドチェーンを使って書くことが多い。例えば次のコードは JavaScript のコードである。

type thing = {
  count: number;
  name: string;
};

const somethings: thing[] = ...;

somethings
  .filter(x => x.count > 10)
  .sort((a, b) => a.count - b.count)
  .map(x => x.name)

Method chaining - Wikipedia から引用して加筆。

このコードにおいて map が行う型の変換に注目してほしい。 thing 型から string 型に変わっている。

Go はこのようなメソッドチェーンによる型を変換ができない。なぜなら現状 Go は型パラメータをもったメソッドを定義できないからだ。例えば JavaScriptmap と同等なものを仮に Go で書けるのであれば、次のように書きたい。

// 注意: 以下のコードは実際の Go では動かない!

// Stream[A] はイテレータをメソッドチェーンで処理できるようにメソッドを提供する。
type Stream[A any] ...

func (s *Stream[A]) Map[B any](f func(A) B) *Stream[B] {
    ...
}

しかし残念なことに現状の Go では、"[B any]" のように型パラメータをもったメソッドを定義することができないので上のコードは構文エラーである。

ところで Go の関数には型パラメータをつけることができる。私は関数によってメソッドチェーンっぽい記述ができることに気がついた。

最初に結論をだそう。引用した JavaScript のメソッドチェーンは、Go では次のように書ける。

type thing struct {
    count int
    name string
}

var somethings iter.Seq[thing] = ...

result := Stream(somethings,
    Filter(func(x thing) bool { return x.count > 10 },
        Sort(func(a thing, b thing) int { return a.count - b.count },
            Map(func(x thing) string { return x.name },
                End))))
// result の型: iter.Seq[string]

次のページで実際に実行できる。

go.dev

なにやらお尻に括弧が多いけど、いかにもメソッドチェーンっぽいではないか(!?)。Stream はメソッドチェーン(のようなもの)を開始する関数だ。 JavaScript ではメソッドであった .filter.sort, .map が Go では関数としてそれぞれ Filter, Sort, Map になっている。以下 Map などを変形関数と呼ぶことにする。変形関数は継続の処理をメソッドでつなげる代わりに第2引数で受け取っている。チェーンの最後にある Map の第二引数の End はメソッドチェーンを終わらせる関数である。重要な点として型は推論されるから、型パラメータをわざわざ記述する必要がない。

これはどういう仕組みだろうか。Stream, End, Sort, Filter, Map の定義は次のようになる。

// stream を開始する。
func Stream[F, A any](seqA iter.Seq[A], cont func(iter.Seq[A]) F) F

// stream を終了する。
func End[F any](f F) F

// stream において要素を sort する関数
func Sort[F, A any](cmp func(A, A) int, cont func(iter.Seq[A] F) func(iter.Seq[A]) F

// stream において fn(a) == false の値 a を除去する関数。
func Filter[F, A any](fn func(A) bool, cont func (iter.Seq[A]) F) func(iter.Seq[A]) F

// stream において A -> B にする関数。
func Map[F, A, B any](fn func (A) B, cont func (iter.Seq[B]) F) func(iter.Seq[A]) F

Sort, Filter や Map はどれも似た引数を持っている。例えば Map について考えよう。Map の第1引数 fn は説明不要だろう。しかし第2引数の cont は一体何?型 F って誰? 奇妙なことに引数に変換前の型 B が登場し、返り値に変換前の型 A が登場している。

第2引数 cont はどこから来るのかを念頭におくと考えやすい。 さきほど「変形関数は継続の処理をメソッドでつなげる代わりに第2引数で受け取っている」とさらっと書いた。 この継続こそが cont なのである。 上の例で FilterSort(..., Map(..., End)) という継続の処理を第二引数で受け取っている。 同様に SortMap(..., End) という継続を受け取っている。 つまり cont は「継続の処理をする」関数である。 ちなみに cont の引数は継続で初めに処理される値である。 上の例なら Filter が受け取った継続は最初の処理 である Sortを する値を受け取る。

cont の返り値の型 F は継続の最終結果である。 上記の例で Map は継続として変形関数の返り値ではなく、 End を受け取っている。End のコードは次の通り。

func End[F any](f F) F {
    return f
}

上の例では Map の継続は何もないEnd は単に f を受け取って何もせずに f を返す関数である。

さて変形関数の返り値は直前の変形関数に渡す継続である。 例えば、上記の Sort の返り値は Filter の第二引数に渡されている。すでに書いたとおり Filter の第二引数は 「Sort(..., Map(..., End)) という継続の処理」であるから、つまり Sort は「Sort(..., Map(..., End)) という継続の処理」を作って返しているのである。Sort の実装は次の通り

func Sort[F, A any](cmp func(A, A) int, cont func(iter.Seq[A]) F) func(iter.Seq[A]) F {
    return func(seqA iter.Seq[A]) F {
        newSeqA := slices.Values(slices.SortedFunc(seqA, cmp))
        return cont(newSeqA)
    }
}

チェーンの始まりにおく、継続を最後に受け取る Stream は次の通り。

func Stream[F, A any](seqA iter.Seq[A], cont func(iter.Seq[A]) F) F {
    return cont(seqA)
}

何度も述べたが、 cont は継続の処理をする関数である。 Stream ではただ単に継続の処理を呼ぶだけだ。

このように継続を渡していくプログラミング技法は「継続渡しスタイル」と言われている。

ja.wikipedia.org

この継続を使ってメソッドチェーンを再現する方法は拡張ができそうだ。 例えば

  • End の代わりにReduce や合計を計算する Sumイテレータを配列にする Collect を定義することができるだろう。
  • Map と同じように FlatMap を定義することもできるだろう。

OpenTelemetry Collector をカスタムビルドするとき ocb (builder) のバージョンを固定する

注意: このブログは私のプラクティスを記述したもので、 OpenTelemetry 公式の方法を紹介したものではありません。

OpenTelemetry Collector をプロダクションにデプロイする時、自分がつかうコンポーネントのみを含めた Collector をカスタムビルドすることが推奨されています。 カスタムビルドは ocb (OpenTelemetry Collector Builder) を使っておこないます。このブログでは ocb のバージョンを固定する方法を紹介します。 ocb を使った Collector の作り方自体は他のブログに譲ります。

opentelemetry.io

結論を先に言うと tools.go を用いた方法です。 Go に詳しい方ならすぐわかると思います。次のページが詳しいです。

github.com

簡単に言うと Go のモジュールマネージャに ocb のバージョンを固定させます。 Go のモジュールマネージャは次のチュートリアルが詳しいです。このブログでは実行するコマンドを書くだけに留め、詳しい説明は次のブログを参照してください。

go.dev

また(このブログを書いたときの最新バージョンは Go 1.23)将来のバージョン Go 1.24 からは tools.go ではなく、もっと簡潔な方法できるようになりそうです。

zenn.dev

前提

Git などのバージョン管理ツールを使って OpenTelemetry Collector builder の設定ファイルやバージョンを管理しようとしている。

以下の説明は Git で管理しているディレクトリ mycustomcollector/ の中で実行しているとする。 また mycustomcollector/ には OpenTelemetry Collector Builder の設定ファイルがあるとする。

$ pwd
/path/to/mycustomcollector

$ ls
builder-config.yaml # OpenTelemetry Collector Builder の設定ファイル

go mod init

Go のモジュールを初期化しましょう。次のコマンドを実行します。

go mod init {このディレクトリをおくリポジトリのURL}{/ディレクトリ...}

{} 内は適宜値を入れてください。お試しで作る場合、つまりリポジトリにおかない場合、{} 内は何でもいいです。Go のモジュールを定義するファイル go.mod を生成します。 go.mod にはモジュールが依存するサードパーティモジュールも記述します。後々の説明で依存モジュールに ocb を含めます。これによって Go に ocb のバージョンを管理させるのです。

この説明では以下、

go mod init example.com/mycustomcollector

を実行したとします。

tools.go を書く

次のようなファイルを作ります。tools.go という名前にします。

//go:build tools

package tools

import _ "go.opentelemetry.io/collector/cmd/builder"

tools.go は普通の Go プログラムのファイルです。tools.go というファイルを Go が特別扱いすると言うわけではありません。名前も tools.go でなくても良いですが、習慣的に tools.go となっています。

先頭の行の //go:build は Build Constraints と呼ばれるもので、コードをビルドするときにこのファイルを含めるかの条件を書くものです。例えば Unix-like な環境だけビルドに含めたいファイルは //go:build unix と書きます。Go は今回の条件である tools を (unixwindows などと違って) 知りません。この場合、常にビルドに含まれないファイルとなります。

"go.opentelemetry.io/collector/cmd/builder" をインポートするだけのファイルです。go.opentelemetry.io/collector/cmd/builder は ocbのパスですね。通常の Go のコードではライブラリをインポートしますが、ここではコマンド (ocb) をインポートします。これによって go.mod に記述された依存モジュールに ocb を追加させるのです。

import"go.opentelemetry.io/..." の間にある _ を忘れないようにしてください。ブランクインポートと言うものです。詳しい説明は省きます。

go mod tidy を実行する

go mod tidy はgo.mod を整頓するコマンドです。モジュール以下にある Go のコードファイルを探し、go.mod に記述していない依存サードパーティパッケージを go.mod に追加したり、 go.mod にあるのに誰も依存していないサードパーティパッケージを消したりします。今回は tools.go が依存しているパッケージである ocb を go.mod に追加します。

go mod tidy

すると go.mod は次のようになります。

module example.com/mycustomcollector

go 1.23.3

require go.opentelemetry.io/collector/cmd/builder v0.116.0

require (
    github.com/fsnotify/fsnotify v1.7.0 // indirect
    github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
    github.com/inconshreveable/mousetrap v1.1.0 // indirect
    github.com/knadh/koanf/maps v0.1.1 // indirect
    github.com/knadh/koanf/parsers/yaml v0.1.0 // indirect
    github.com/knadh/koanf/providers/env v1.0.0 // indirect
    github.com/knadh/koanf/providers/file v1.1.2 // indirect
    github.com/knadh/koanf/providers/fs v0.1.0 // indirect
    github.com/knadh/koanf/v2 v2.1.2 // indirect
    github.com/mitchellh/copystructure v1.2.0 // indirect
    github.com/mitchellh/reflectwalk v1.0.2 // indirect
    github.com/spf13/cobra v1.8.1 // indirect
    github.com/spf13/pflag v1.0.5 // indirect
    go.uber.org/multierr v1.11.0 // indirect
    go.uber.org/zap v1.27.0 // indirect
    golang.org/x/mod v0.22.0 // indirect
    golang.org/x/sys v0.21.0 // indirect
    gopkg.in/yaml.v3 v3.0.1 // indirect
)

またパッケージのハッシュが記録された go.sum も生成されます。

generate.go ファイルを作る

この準備が終わると、 go.mod があるディレクトリ以下で

go run go.opentelemetry.io/collector/cmd/builder

と実行すると、常にバージョンが固定された ocb が実行されます。上の go.mod ならば常に v0.116.0 の ocb が実行されることになります。

毎回 go run go.opentelemetry.io/collector/cmd/builder を実行してもよいですが、長いし、めんどくさいので go generate コマンドで実行できるようにしましょう。

次のような Go のプログラムのファイルを作ります。名前は何でもいいですが、generate.go にしました。たった2行です。

package mycustomcollector

//go:generate go run go.opentelemetry.io/collector/cmd/builder --config {builder の設定ファイル} --skip-compilation

{builder の設定ファイル} は今回の場合 builder-config.yaml になります。

go generate はモジュール以下にある go のソースコードから //go:generate {cmd ...} と書かれた行を探して cmd ... を実行するコマンドです。Go ではソースコードを自動生成することが多々あり、その用途に使われることが多いです。 ocb はカスタムコレクターのソースコードを生成するもので、この用途にぴったりですね。

go.dev

--skip-compilation はカスタムコレクターのソースコードを生成するだけで、コレクターのバイナリを作らないように ocb に指定するものです。私の場合、バイナリ自体は Docker でビルドするのでバイナリのビルドが不要です。

コレクターを生成する

ここまで準備をしたらあとは go generate を実行するだけ ocb が実行され、カスタムのコレクターのコードが生成されます。

go generate

ここまで実行するとカレントディレクトリは次のようになっているはずです。 collector/ は ocb が自動生成したコレクターのソースコードが入っているディレクトリです。 ocb の設定ファイル builder-config.yaml で ./collector に出力するように指定しました。

$ ls -F -1
collector/
builder-config.yaml
generate.go
go.mod
go.sum
tools.go

今後 builder-config.yaml を変更したとき

ocb の設定ファイル builder-config.yamlを変更したら、毎回 go generate をしましょう。バージョンが固定された ocb でコレクターをビルドしてくれます。

自動生成された collector/ 以下を git で管理するか?

Go では自動生成したファイルも git add するのが習慣になっています。その延長で私は ocb が自動生成した collector/ 以下も git で管理しています。ただこれは私がそうしているだけで OpenTelemetry 公式がそのように書いた文章があったわけではないです。

Span Metrics Connector で SQL のクエリの処理時間を計測する

この記事は Mackerel Advent Calendar 2024 の 13日目の記事です。

Web アプリケーションのパフォーマンスの指標としてレスポンスタイムあげられます。レスポンスタイムは SQL の処理時間がボトルネックになることも多いです。 Web アプリケーションの SQL の処理時間を計測する手法は多くあります。

このブログでは OpenTelemetry Collector の Connector として 提供されている Span Metrics Connector を活用し Mackerel で SQL のクエリごとの処理時間を計測し、さらに監視設定を設定する方法を説明します。

Span Metrics Connector とはトレースのスパンの情報から次のメトリックを計算する Connector です。

例えば次のようなトレースを計測したとしましょう。

Web Application のトレース

この例では POST /api/register のスパンが1個、db.Begin が1個、 db.Prepare が(表示では省略されているが)2個、 stmt.Exec が2個あります。Span Metrics Connector は計測された全てのトレースのスパンの数を通して足し合わせて call、そしてこれらのスパンの処理時間のヒストグラムduration のメトリックにします。

次のブログでは Span Metrics Connector についてはさらに詳しく解説されています。 zenn.dev

今回は Span Metrics Connector の実用的な例として SQL のクエリの処理時間を計測してみましょう。

このブログでは Web Application の例として ISUCON13 を使います。Go で記述されたサーバーを使います。コードの大半はそのままで Trace だけ導入します。

github.com

DB のクエリのトレースを取得する

まずはSQLの処理時間をトレースとして計測しましょう。 ISUCON13 は DB のライブラリとして jmoiron/sqlx を使っています。 jmoiron/sqlx に対応した OpenTelemetry 計装ライブラリ uptrace/opentelemetry-go-extra/otelsqlx があるので、これをアプリケーションに組み込みます。

go get github.com/uptrace/opentelemetry-go-extra/otelsqlx

差分としては次のようになります。パッケージのインポート文の差分を除くと一行ですね。

@@ -93,7 +100,7 @@ func connectDB(logger echo.Logger) (*sqlx.DB, error) {
                conf.ParseTime = parseTime
        }
 
-       db, err := sqlx.Open("mysql", conf.FormatDSN())
+       db, err := otelsqlx.Open("mysql", conf.FormatDSN())
        if err != nil {
                return nil, err
        }

さらに OpenTelemetry の Trace の exporter を設定します。これはボイラープレートコードのため省略します。トレースの Export 先は OpenTelemetry Collector です。

Span Metrics Connector の設定

次に Span Metrics Connector の設定をします。 Span Metrics Connector の設定だけ引用すると次のようになります。ブログの最後に設定全文を載せました。

  spanmetrics/exponential:
    histogram:
      exponential:
        max_size: 160
    dimensions:
      - name: db.statement
    aggregation_temporality: "AGGREGATION_TEMPORALITY_DELTA"
    metrics_flush_interval: 60s

注目する点として dimensionsdb.statement があります。 Span Metrics Connector が生成するメトリックはデフォルトの設定だと service.name, span.name, span.kind, status.code の属性しかありません。デフォルトの設定ではSQL の処理スパンからメトリックを生成してもクエリの情報は含めてくれません。今回はクエリごとの処理時間を計測したいですから dimensionsdb.statement を設定します。db.statement は実行した SQL のクエリを表す span の属性です。

SpanMetrics Connector は events という設定もあり、こちらでメトリックに db.statement を追加するようにしても良いかもしれません。私は未検証です。

さらにヒストグラムの種類として exponential ヒストグラムにしました。 exponential ヒストグラムについては僕のブログを参照ください。

blog.rmatsuoka.org

メトリックの送信先は Mackerel です。

さて実行してみましょう

ISUCON 13 のベンチマーカーを実行しました。

Mackerel のメトリックエクスプローラ

さて投稿したメトリックを Mackerel のメトリックエクスプローラで見てみましょう。ベンチマーカーは数分しか起動しないので、メトリックもそのぶんだけです。

callsduration のメトリックがあります。処理時間は duration のメトリックです。注目するメトリックは処理時間の最大値 duration.max や XX パーセントタイルである duration.XXp です。

SQL の処理時間のメトリックだけに限定する

今回は DB のスパンだけ計測したので、表示されるメトリックは DB のものだけです。通常のアプリケーションでは HTTPやカスタムなど DB 以外の span も計測していることがほとんどだと思います。SQL の処理時間のメトリックだけ見たい時は db.statment のラベルを持つメトリックだけを見えるようにしましょう。ラベルフィルターに db.statment = * と指定すればできます。

グラフの凡例を変更してみる

デフォルトの凡例は見にくいので変更してみましょう。 凡例設定で span.name, db.statment と設定しましょう。

凡例を設定する

設定した凡例

これによって SELECT * FROM livestream_tags WHERE livestream_id = ? というクエリの 90 パーセントタイルが 3.84ms であることがよくわかります。

遅いクエリがあったときにアラートを発報させる

クエリによる監視をつかって遅いアラートが発生した時にアラートを発報させるようにしてみましょう。 今回は時間がかかっているクエリを調べたいのでクエリの処理時間の最大値である duration.max を監視します。

右上の ... から Add monitor を選択

さらにちょっとした隠し機能ですが監視設定の名前にラベルにテンプレートを使って、属性を含めることができます。便利ですね。画像では監視設定の名前を {{span.name}} {{db.statement}} にしてみました。

名前にテンプレートを使用する

アラートが発報する次のように表示されます。テンプレートが評価されてアラートの名前が db.Prepare SELECT * FROM livestream_tags WHERE livestream_id = ? となっています。

最後に

Span Metrics Connector と Mackerel のメトリックエクスプローラを駆使して SQL のクエリ処理時間を計測する方法を解説しました。そこそこお手軽で計測できると思います。

おまけ Collector の config 全文

---
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
exporters:
  debug:
    verbosity: basic
  otlp/michizane:
    endpoint: otlp.mackerelio.com:4317
    compression: gzip
    headers:
      mackerel-api-key: "${env:MACKEREL_APIKEY}"

processors:
  batch:

connectors:
  spanmetrics/exponential:
    histogram:
      exponential:
        max_size: 160
    dimensions:
      - name: db.statement
    aggregation_temporality: "AGGREGATION_TEMPORALITY_DELTA"
    metrics_flush_interval: 60s

service:
  pipelines:
    traces:
      receivers:
        - otlp
      processors:
        - batch
      exporters:
        - debug
        - spanmetrics/exponential
    metrics:
      receivers:
        - spanmetrics/exponential
      processors:
        - batch
      exporters:
        - otlp/michizane
        - debug

さくらインターネットで VPS を立てて遊び、 ログを数える Mackerel プラグインを作った

私は Web アプリケーションエンジニアを自称しているが、実は VPS で遊んだことのない怪しい人間なので、土日で VPS で一通り遊んでみた。

サーバーを建てる

さくらの VPS の 1G プランを選んだ。 OS は Ubuntu 24.04 amd64

サーバーを立てたら SSH でパスワードログインを禁止したりと一般的な作業を行った。

さてこの作業をしているうちに私は初めて知ったのだが、 GitHub に登録している公開鍵のリストは次の URL にダウンロードできるみたいだ。

https://github.com/{username}.keys

次のようにすれば、普段使っている公開鍵をサーバーに保存できてしまう。とても便利だ。

$ curl https://github.com/{username}.keys >> ~/.ssh/authorized_keys 

Mackerel Agent をインストールする

はてなのエンジニアなので当然 Mackerel Agent をインストールした。

HTTP サーバーを建てる

今回は ngnix など使わず直接 Go で HTTP サーバーを建てた。以下 hello と名付ける。

github.com

リクエストをすると hello, {リクエスト元IP} を返すだけのサーバーである。 HTTPS で通信したかったので certbot を使って証明書を取得した。 次の公式ドキュメントとブログを参考にした。

eff-certbot.readthedocs.io

maku.blog

certbot で証明書を取得すると「今後自動的に証明書の更新をするよ!」とメッセージがでてくる。はてどうやって? crontab みてもそんなのなさそうだが…と思ったが、今は systemd-timer を使うらしい。

さくらのVPS はパケットフィルターというものがあり、はじめは 22 しか開放していない。HTTP サーバーを建てるため 443 を開ける必要がある。

ログを見て楽しむ

サーバーとしての作業はこれ以上特に何もやってない。

知らない人が SSH でログインを試行してくるログとかHTTP サーバーの変なパスへのリクエストのログを眺めて楽しんでいた。

たとえば次のようなパスへアクセスしてくるログを見ていたりした。

url=/cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh
url="/index.php?lang=../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/&/<?echo(md5(\"hi\"));?>+/tmp/index1.php

ログをメトリックにして楽しむ

たまに変なログが出てきて面白いが、ほとんどは面白くないログばかりだ。 変なログが来たときだけ見たい。ということでログの数を Mackerel へメトリックとして投稿することにした。 journalctl を定期的に叩いて特定のログの数える plugin が欲しかった。が、探してみても手頃なものがなかったので自作した。 名付けて mackerel-plugin-journal-count である。

github.com

これは systemd の service unit の名前と正規表現を引数にとり、その unit が吐き出したログのうち正規表現にマッチする数を metric として Mackerel に投稿するプラグインである。

例えば mackerel-agent.conf につぎのような設定をする。

[plugin.metrics.journal-count-ssh]
command = "/usr/local/bin/mackerel-plugin-journal-count -u ssh -g 'invalid user .* from'"

すると sshd のログのうち次の行の数をメトリックとして Mackerel に投稿してくれる。

Invalid user XXX from XXX.XXX.XXX.XXX port XXXX

次の画像は HTTP サーバー(hello)のリクエスト数 と sshd の invalid user のログの数のメトリックのグラフである。 HTTP サーバーのリクエスト数を INFO request にマッチするログの数、 sshd の invalid user のログイン試行回数を invalid user .* from にマッチするログの数としてメトリックにしている。sshd の invalid user を数える正規表現はこれよりももっと良さそうなのがありそうだ。

この mackerel-plugin-journal-count はシェルスクリプトで書いた(ほとんど awk が処理を担っているが)。私が真面目に向き合った最初の言語はシェルスクリプトなので、シェルスクリプトを書くのはやっぱり楽しいですね。