contextのキャンセル時にtesting.T.Cleanupのようにクリーンアップ処理を実行できるパッケージdonegroupを作った

contextのキャンセルとそれに対応したクリーンアップ処理

Goにおいてcontextはさまざまな値を伝搬させるために使用します。その1つがキャンセル信号です。

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

contextを通じてキャンセル信号を送ることによって、そのcontextが伝播した関係するある種のcontextグループでキャンセルに対応した処理を行うことができます。

このcontextのキャンセルに対応した処理を、このエントリでは「クリーンアップ処理」と呼ぶことにします。

クリーンアップ処理の具体例: Graceful shutdown

あるミドルウェア(プロキシサーバでもデータベースサーバでもよい)があったとして、そのミドルウェアにはメインの機能以外に「ログやメトリクスを定期的にまとめて外部サーバに転送する機能(ログ転送機能)」があるとします。

ミドルウェアのプロセスを停止する際に、ログ転送機能が転送中であれば転送完了までプロセスの停止を待って欲しいですし、次の起動時のためにどこまで転送完了したかの記録するまでプロセスの停止を待って欲しいと考えます。

このような、いわゆる「Graceful shutdown」の実現に必要な後始末処理は、本エントリにおける「クリーンアップ処理」の1つの具体例になります。

cancel() はキャンセル信号の伝搬しかしない

キャンセル処理の伝搬は大抵 context.WithCancel() で作成した cancel() を使って行います。

cancel() を実行することでcontextや、そのcontextから派生した子contextにキャンセル信号が伝搬され、その信号は context.Done() から補足できます(実体は <-chan struct{})。

contextを引数で受け取っている各処理は、context.Done() から信号を受け取ったときにクリーンアップ処理を行うようにする(処理を書く)のですが、このクリーンアップ処理の実行の完遂は context.WithCancel()cancel() で実現しているキャンセル信号の伝搬の仕組みでは保証されません。

次のGo Playgroundに簡単な例を置きました。クリーンアップ処理が完了する前に main() が終了してしまい、クリーンアップ処理が完了していないことがわかると思います。

https://go.dev/play/p/F-4qnF5L9bf

クリーンアップ処理の実行を保証したい

少し複雑なGoのプログラムを書くと context はさまざまな箇所に渡されます。「(終了処理実現のために)関数の第一引数にcontextを渡す」という慣習もありますし、その深さ(どれくらい関数をまたいでcontextが渡されてきているか)は意図せず深くなっていきます。

また、goroutineの存在により並行処理もよく活用します。開始した並行処理は終了処理を書く必要があり、必要であればクリーンアップ処理を書く必要があります。そもそも context を渡しているのはgoroutineによる並行処理の終了処理のためでもあります。

終了処理のトリガーにはcontextのキャンセルを検知できる context.Done() を使います。

では、クリーンアップ処理はどう実装したら良いでしょう?クリーンアップ処理の完了を待つための仕組みが必要です。

testing.T.Cleanup

ところで、testing.T.Cleanup() はテストのクリーンアップ処理を保証するための仕組みです。t.Cleanupでクリーンアップ処理を登録しておくことによって、テスト終了時にそれらのクリーンアップ処理を実行してくれます。

私はcontextの文脈でもこの testing.T.Cleanup() のような機能が欲しいと考えました。

donegroup

上記のようなモチベーションで作成したパッケージが donegroup です。

github.com

donegroupはcontextのキャンセル時にtesting.T.Cleanupのようにクリーンアップ処理を実行できる仕組みを提供するパッケージです。

準備

まず context.WithCancel() の代わりに donegroup.WithCancel() を使ってcontextとcancel()の対を作成します。

そして、cancel() 実行後にクリーンアップ処理を待つため、donegroup.Wait() を実行します。

これだけで準備は完了です。

具体的には

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ctx, cancel := donegroup.WithCancel(context.Background())
defer func() {
    cancel()
    if err := donegroup.Wait(ctx); err != nil {
        log.Fatal(err)
    }
}()

に書き換えるだけです。

クリーンアップ処理の登録( donegroup.Cleanup )

クリーンアップ処理の登録も簡単です。任意の場所で donegroup.Cleanup() を使ってクリーンアップ処理を登録するだけです。

err := donegroup.Cleanup(ctx, func(_ context.Context) error {
    // 何かしらのクリーンアップ処理
    fmt.Println("cleanup")
    return nil
})

これだけで、contextのキャンセル時にクリーンアップ処理の実行をしてくれます。クリーンアップ処理はいくつでも登録できます。testing.T.Cleanup() との違いはクリーアップ処理の実行順序の保証がないということだけです。

関数実行の保証( donegroup.Awaiter / donegroup.Awaitable )

donegroupは、次のように「実行に時間がかかる処理の実行を待つ(保証する)」こともできます。 この場合は donegroup.Awaiter() を使います。completed() が実行されるまで donegroup.Wait() は待ってくれます。

ctx, cancel := donegroup.WithCancel(context.Background())

go func() {
    completed, err := donegroup.Awaiter(ctx)
    if err != nil {
        log.Fatal(err)
        return
    }
    // 実行に時間がかかる処理
    time.Sleep(100 * time.Second)
    fmt.Println("do something")
    completed()
}()

// メインの処理
fmt.Println("main")
time.Sleep(10 * time.Millisecond)

cancel()
if err := donegroup.Wait(ctx); err != nil {
    log.Fatal(err)
}

fmt.Println("finish")

// Output:
// main
// do something
// finish

「この関数ブロックの実行を保証したい」というような場合は defer donegroup.Awaitable(ctx)() が使いやすいです。

go func() {
    defer donegroup.Awaiter(ctx)() // この関数ブロックの実行を保証する。
    if err != nil {
        log.Fatal(err)
        return
    }
    // 実行に時間がかかる処理
    time.Sleep(100 * time.Second)
    fmt.Println("do something")
}()

クリーンアップ処理の実行待ちにタイムアウトを設定したい

donegroup.Wait() の代わりに donegroup.WaitWithTimeout() を使うことでクリーンアップ処理待ちにタイムアウトを設定することができます。

この場合、donegroup.Cleanup()donegroup.Awaiter() donegroup.Awaitable()タイムアウトが伝播します。

具体的には donegroup.Cleanup() に渡すクリーンアップ処理 func(ctx context.Context) error の ctx にキャンセル信号が届くので ctx.Done() を使って適切にキャンセル処理を書きましょう。

donegroupのメリット

donegroupのメリットは、contextさえ渡しておけば関数シグネチャなどを変えずにいつでもクリーンアップ処理を追加実装できる点です。

また、クリーンアップ処理のエラーもまとめて受け取れます。

実装の深い場所でクリーンアップ処理が必要な並行処理を実装する場合も安心です。

というわけで

散らばっている並行処理の後始末に是非使ってみてください。

octocov-action v1 をリリースしました

追記 with.workdir:with.work-dir: に変更になっています

octocov-action v1 をリリースしました。octocov-actionはoctocovGitHub Actionsで使う際に使用するActionです。

github.com

今回、何が変わったかを紹介します。

Docker container actionからComposite actionに変更

Actionの実装方式をDocker container actionからComposite actionに変更しました。

これにおける効果は大きく2つあります。

octocovのバージョン指定が可能になった

1つ目は octocov のバージョンを柔軟に指定できるようになったことです。

というのもDocker container actionで ghcr.io/k1low/octocov を使っている限りはActionの外部からoctocovのバージョンを指定することができませんでした。

バージョン指定ができないと何が困るかというと、最新のoctocovでバグや意図しない動作が発生した際に1つ前のバージョンに固定するというワークアラウンドがとれないことです。

そのため、従来は octocov のバージョンと octocov-action のバージョンを同期させることで、バージョン指定ができるようにしていました。

今回 Composite actionにして任意のoctocovを指定できるようになりました( with.version: セクション )。

Actionのセットアップ速度が向上した

2つ目の効果です。

Docker container actionの場合Docker imageのpullとコンテナの起動が必要ですが、それが必要なくなりました。

そのかわり、途中でoctocovのリリースバイナリを取得する必要があるのですがそれでも体感で速いです。

今後リリースバイナリのキャッシュなどをするようにするとより速くすることが期待できます。

octocovの実行ディレクトリを指定できるようになった

with.work-dir: セクションを指定することでoctocovの実行ディレクトリを指定できるようになりました。モノレポな環境で便利だと思います。

このような新機能が追加しやすくなったのもComposite actionに変えた効果ともいえます。

Docker container actionのActionの今後

octocovがv0.xである限りはDocker container action版のoctocov-actionもメンテナンスしていく予定です。従来通り uses: k1LoW/octocov-action@v0 で使用できます。

というわけで

octocov-action v1 をリリースしました。 uses: k1LoW/octocov-action@v1 で何も設定を変えずにすぐに使用できますので是非ご利用ください。

今後もoctocovをどうぞよろしくお願いします*1

*1:そういえば、もし活用している企業様がいらっしゃいましたらこそっとでいいので教えてください。単純に喜びます

2023年の振り返りと2024年の抱負

2023年の振り返り

2023年は私にとっていろいろ思いを巡らせる年となりました。将来とか家族とか人生とか。内容は割愛しますが、HIPHOPクラシックを1つだけ貼っておきます。

open.spotify.com

2023年の抱負は「書く」でした。はじめてZenn Bookを書いたりなどしましたが、他にはそこまで書いていない気がします。ムズカシイ。

zenn.dev

あ、でも2023年後半に久しぶりに「描く」ことはできてとても嬉しかったです。

OSS

2023年も少なくなったかなと思います。

一方で、年初の宣言通り runn の開発を継続していました。 #runn開発者会議 もオンラインオフライン問わず何回も開催することができましたし、APIテストツールとしてイベントに呼ばれたり(runn開発者以外の)企業で使ってもらったりと、広く評価してもらえるようになりました。 一緒に開発してくれている@katzchumさんありがとうございます。

また、2manymws orgもプロダクションで使うパッケージとして鍛えてもらいました。 @pyama86さんありがとうございます。

今年の趣味OSSはどうなるんだろう?

でもrunnをもう少し良くしたい。v1にできる勇気が出ると良いなあ。

発表

個人的には頑張ったと思います。

私は体系的に学んだことを軸に発表するというのが苦手です。その分、多少王道から外れていても、自分で考え、作り、感じたことを集めてそのエッセンスを共有できると良いなと思っています。 2023年はどれも「自分こそが発表するべき」と思える特徴というかクセのある内容になったと思っています。

2024年の抱負

今年はなんだろう...

漠然と「新しい気持ちになりたい」というのはあるんですけど、「何を」「どうやって」というのは思いついていないし、「"気持ち"ってなんだよ。それってあなたの感想ですよね™️」という気持ちです。

とりあえずブラックフライデーに購入して深圳から届いたものの今の今まで手をつけていないブツが机の後ろに鎮座したままなので、それにちゃんと手をつけたいです。新しい気持ちにはなりそう。

今年もよろしくお願いします。

GoのHTTPミドルウェアやその周辺パッケージを配布する新しいGitHub Orgをはじめました

このエントリは GMOペパボエンジニア Advent Calendar 2023 および、 Go Advent Calendar 2023 シリーズ3 の19 日目の記事です。

以下のエントリでも少し触れられていますが、現在プロキシサーバをGoで書くプロジェクトがあります。

ten-snapon.com

k1low.hatenablog.com

主な実装をしているのは @pyama86 さんで、それはもうブルドーザーのように実装が進んでいるわけですが*1、私も少しだけですが書いています。

必要そうな機能をGoの薄いHTTPミドルウェアハンドラとしてOSSとしていくつか切り出していました。

そして、本体の実装が進むにつれて足りない機能を Pull Request ベースでもらったりしていたのですが、関連OSSを作る必要が発生したりなど個人リポジトリだとまとまらないため、それらをまとめるためだけにGitHub Org を新設しました。

github.com

既に私のリポジトリからのトランスファーは済んでおり、今後はこのOrg上で開発していく予定です。

どうぞよろしくお願いします。

ごく簡単にですが、置いてあるリポジトリについて紹介したいと思います。

rl

github.com

レートリミットを実現するHTTPミドルウェアハンドラです。

Goにおけるレートリミット実装としては go-chi/httprate が有名だと思います。rlもgo-chi/httprateを参考に実装しています。

rlの特徴は interface を満たす実装をつくって注入するだけで

  • http.Requestの情報をもとに異なるレートリミットのルールを動的に適用できる
  • 複数のレートリミットのルールを動的に重ねがけできる

というものです。

「あるホストへのアクセスだけレートリミットを緩和したい」だとか「あるIPからのアクセスだけレートリミットをかけたい」というユースケースに対応するためのものです。

rp

github.com

リバースプロキシサーバです。HTTPミドルウェアハンドラではないです。

rpの特徴は interface を満たす実装をつくって注入するだけで

  • http.Requestの情報をもとに異なるUpstreamを動的に指定できる
  • tls.ClientHelloInfoの情報をもとに証明書を動的に指定できる

というものです。

マルチテナントなサービスの前段などで必要になる機能を想定しています。

rc

github.com

HTTPキャッシュミドルウェアハンドラです。

rpの特徴は interface を満たす実装をつくって注入するだけで

  • RFC 9111にしたがってキャッシュを使用してくれる

というものです。また、

  • キャッシュルールの拡張や切り替えも可能
  • http.Requestの情報をもとに異なるキャッシュルールを適用する

ということも可能です。

rcutil

github.com

rcのためのユーティリティパッケージです。

rc.Cacher の interface を満たしたディスクキャッシュ機能の提供などをしています。

mm

github.com

HTTPミドルウェアハンドラを管理するHTTPミドルウェアハンドラです。

mmの特徴は interface を満たす実装をつくって注入するだけで

  • http.Requestの情報をもとに異なるHTTPミドルウェアハンドラを動的に指定できる

というものです。

動的に切り替える仕組みがないHTTPミドルウェアハンドラに動的切り替え機能を付与したいときに使うイメージです。

ちなみに、すごく短いコードなので全部貼り付けておきます。

package mm

import (
    "net/http"
)

// Builder is a middleware builder.
type Builder interface { //nostyle:ifacenames
    Middleware(req *http.Request) (func(next http.Handler) http.Handler, bool)
}

type mMw struct {
    builders []Builder
}

func newMMw(builders []Builder) *mMw {
    return &mMw{builders: builders}
}

func (mm *mMw) Handler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        for _, b := range mm.builders {
            mw, ok := b.Middleware(r)
            if !ok {
                continue
            }
            next = mw(next)
        }
        next.ServeHTTP(w, r)
    })
}

// New returns a new middleware-middleware.
func New(builders ...Builder) func(next http.Handler) http.Handler {
    mm := newMMw(builders)
    return mm.Handler
}

以上、と思ったら、先ほど rlutils というパッケージが誕生していました。

github.com

2manymws*2、どうぞよろしくお願いします。

*1:わかる人にはわかると思います。是非間近で見てほしいので、こちらから応募してください。https://recruit.pepabo.com/

*2:名前の由来は2文字リポジトリが多かったことと、Too manyなミドルウェアを置く場所ということと、例のあれです

カジュアルに使えるスタブサーバとしてhttpstubとgrpcstubを作って使っている

このエントリは Go Advent Calendar 2023 12 日目の記事です。

Goのテスティングパッケージで一番好きなパッケージは net/http/httptest です。

テスト実行時に実際にHTTPサーバを立ててHTTPリクエストを受けるというシンプルかつ強力なアプローチが良いです。

クライアント側にエンドポイントを変える仕組みさえあればクライアントのリクエストを受け付ける形でテストを構築することができるので、選択肢に入れておきたいテスト構成です。

ところで、私たちは runn (ランエヌ)というシナリオテスティングツールを開発しています。

github.com

runnはHTTPクライアントでありgRPCクライアントでもあるのですが*1、そのrunn自体のテストのためにhttpstubとgrpcstubを作って使用しています。

httpstub

github.com

httpstubはHTTPスタブサーバを立てるテストヘルパーです。

一番簡単な使い方としては httpstub.NewServer(t) でスタブサーバを立てた後、メソッドチェインで必要な分だけ各パスのレスポンスを定義してあげたら出来上がりです。

package myapp

import (
    "io"
    "net/http"
    "testing"

    "github.com/k1LoW/httpstub"
)

func TestGet(t *testing.T) {
    ts := httpstub.NewServer(t)
    t.Cleanup(func() {
        ts.Close()
    })
    ts.Method(http.MethodGet).Path("/api/v1/users/1").Header("Content-Type", "application/json").ResponseString(http.StatusOK, `{"name":"alice"}`)

    res, err := http.Get(ts.URL + "/api/v1/users/1")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        res.Body.Close()
    })
    body, err := io.ReadAll(res.Body)
    if err != nil {
        t.Fatal(err)
    }
    got := string(body)
    want := `{"name":"alice"}`
    if got != want {
        t.Errorf("got %v\nwant %v", got, want)
    }
    if len(ts.Requests()) != 1 {
        t.Errorf("got %v\nwant %v", len(ts.Requests()), 1)
    }
}

grpcstub

github.com

grpcstubはgRPCスタブサーバを立てるテストヘルパーです。

一番簡単な使い方としては .protoファイルを指定して grpcstub.NewServer(t, "protobuf/proto/*.proto") でスタブサーバを立てた後、メソッドチェインで必要な分だけ各パスのレスポンスを定義してあげたら出来上がりです。 そう、ほぼhttpstubと同じです。

package myapp

import (
    "io"
    "net/http"
    "testing"

    "github.com/k1LoW/grpcstub"
    "github.com/k1LoW/myapp/protobuf/gen/go/routeguide"
)

func TestClient(t *testing.T) {
    ctx := context.Background()
    ts := grpcstub.NewServer(t, "protobuf/proto/*.proto")
    t.Cleanup(func() {
        ts.Close()
    })
    ts.Method("GetFeature").Response(&routeguite.Feature{
        Name: "hello",
        Location: &routeguide.Point{
            Latitude:  10,
            Longitude: 13,
        },
    })
    // OR
    // ts.Method("GetFeature").Response(map[string]any{"name": "hello", "location": map[string]any{"latitude": 10, "longitude": 13}})

    client := routeguide.NewRouteGuideClient(ts.Conn())
    if _, err := client.GetFeature(ctx, &routeguide.Point{
        Latitude:  10,
        Longitude: 13,
    }); err != nil {
        t.Fatal(err)
    }
    {
        got := len(ts.Requests())
        if want := 1; got != want {
            t.Errorf("got %v\nwant %v", got, want)
        }
    }
    req := ts.Requests()[0]
    {
        got := int32(req.Message["longitude"].(float64))
        if want := int32(13); got != want {
            t.Errorf("got %v\nwant %v", got, want)
        }
    }
}

その他細かい機能とまとめ

httpstubもgrpcstubも基本は指定された通りにレスポンスを返すスタブなわけですが、他にも細かい機能があります。

  • 受け取ったレスポンスを確認する機能
  • リクエストやレスポンスが仕様に沿っているかバリデーションする機能
  • 動的にレスポンスを返す機能
  • OpenAPI Specの examples: セクションの値を使ってレスポンスを返す機能

など、便利に使えています。

実はギョームでもガッツリ使っているので、生じたニーズに応じて都度機能を追加していっています。

httpstubとgrpcstubの使い勝手がほぼ同じなのも便利です。

こういうテストヘルパーを作れるのも、カジュアルにgoroutineでサーバを立てることができるGoの強みの1つですね。

*1:他にもDBクライアントでありCDPクライアントであり任意のコマンド実行ツールでもあります

Go Conference mini 2023 Winter IN KYOTOに参加してきた #kyotogo

京都に行ってきました。

Go Conference mini 2023 Winter IN KYOTO

kyotogo.connpass.com

すでに、ne-sachirouさんによりスライド資料などが集められています。感謝!

scrapbox.io

「miniとは?」となるGoで濃縮された1日になりました。運営の皆様、本当にありがとうございました!

私はと言うと、登壇はしたものの完全にカンファレンスを楽しんでいました。

本編

以下、一言感想です。

Deep dive into log/slog package

既に使ってはいましたが、実装は特にみていませんでした。普通に使えるイメージ。

「nAttrInline = 5」の理由がなるほどなあという理由でした。これ再度計測してレポートしたら変わってくるような値なんですかねー。

Goプログラムがビルドされるまで(コンパイラーの仕組みを探る)

まさにタイトルの通りで、非常にわかりやすい丁寧で面白い発表でした。コンパイラがわかった気になってしまうくらいのわかりやすい発表である意味注意しないといけない発表でした。

Goにおけるcall graphを用いた大規模コードベースの影響調査

私の発表前で、残念ながらリアルタイムでは聴講できませんでした。XのTLがざわついていたのが印象的でした。

あとで資料を拝見させてもらったのですが、影響調査のためのコールグラフを使うというアプローチはなかなかな腕力だなと思いました。すごい。

最近PHP界隈でも「ASTが同じならOK」みたいな腕力が流行っている?ようですけどなんかブームですかね?

Parsing case study in Go

私の発表です。

speakerdeck.com

公開できる事例の多さでは自信があったので(OSSなのでそれはそう)、とにかく事例を詰め込みました。

何かしら持ち帰ってもらえれば幸いです。

日時処理の新スタンダード: Synchro によるタイムゾーン安全、楽々開発

Xで開発しているときから知っていたのですが、とても良いパッケージだと思いました。

じゃあこれを明日からプロダクションに採用するためにはあと何の検討が必要かなーと考えていました。

Go1.22で導入予定だったzeroというbuilt-inについての紹介

大どんでん返しと貴重な経験のお話で面白かったです。

Go Getでのchecksum不一致に遭遇した話とその対応

これは...逆にやらかしたことがあるので、逆にすみませんと言う気持ちになっていました。

CUE+Goで安全かつ簡単に設定ファイルを自動生成してみた

CUEもずっといつか必要になるかも!?と思っているのですが、普段は360YAMLからは一桁二桁小さい数しか扱わないので、いつかのために今後もウォッチしておこうと思います。

context.WithoutCancelについて語る

「伝搬してきた値は使いたいがキャンセルされたくない」というニーズは分かっていたのですが、しっくりきていませんでした。しかし、「contextは値を伝搬する要件とキャンセルの要件の2つを担っていて、現状それを分けることが難しい」という説明にしっくりきました!

Golangを使ったDB用負荷テストツールの開発

流石の tools でした。コードも読んでみようと思いました。あとご挨拶できなかったのが残念(失敗の1つ)。

インターフェースのラッパーを作る際の落とし穴

ラップすると型アサーションに失敗する挙動については知らなかったです。全部作るのは確かに面倒そうだなと思いました。

生成AIによる静的解析ツールの自動生成

静的解析に求められる正しさと生成AIがもたらすまだ不安定な出力の相性って悪いんじゃないかなと思っていたのですが、 確かに https://github.com/gostaticanalysis/skeleton にあるようにある程度テンプレート化されているコードの穴埋めという意味だと「いけそう」と思いました。

The Future of encoding/json

「encoding/jsonJSONエンコードしてくれればいいんじゃ?」と思っていたのですが、この発表で「なるほど良い」と思いました。便利パッケージが1つ増えるのを待つ気持ちです。

続) TinyGo で作る自作キーボードの世界

びっくりした!あんなに簡単にキーボードできるのか!!!!!!!!!

触って理解するGoコンパイラの最適化

感想はこちらになります。

開発チーム横断タスクフォース「Goサブ会」の運用事例と今後の展望

ペパボでもチャプターやGoの情報共有会はあるのですが、Goサブ会はまた違うものでした。 懇親会でも詳しく聞いたのですが、雑な理解で言うと「権限と責任がある」横断組織となっているようでした。

そういう組織がプログラミング言語のくくりであることが「強い!」と思いました。

プロダクションで使うGo Pluginの利便性とパフォーマンス性

プラグインとは」からはじまりGoでできるプラグインのアプローチの紹介でした。

公式見解キツいw

ちなみに私は git-style が好きです。

Revolutionising Inheritance with Generics: A Fresh Approach in Go

Genericsの効果的な活用のお話でした。時間切れだったのであとで資料を拝見しましたが、Genericsを使って親structをつくりつつその親の構造体の1フィールドに子を渡しておくというのはなるほどと思いました。 Embeddingとちょうど依存が逆になっているから「責任の逆転」と言っているという理解でした。

net/http から net.Conn を掘り起こす

なんかreflectパッケージとかを使って引っ張り出すのかな?と思ったらちゃんと正攻法で取得できていました。 知らなかったので知識として覚えておこうと思いました。

GoとテストとインプロセスDB

テストでインプロセスDBを使う話。httptestスキーとしては興味津々で聞きました。

使うためにバグを薙ぎ倒していくのが印象的でした。

個人的な使い方としては syumai さんのポストの方法がよいなあと思いました。

ブログのリアクションから始めるGoのパフォーマンス分析入門

カッコ良すぎるムーブでした。見習いたい。

GoのProtocプラグインを活用した効率的なgRPC負荷試験戦略

.protoファイルからの生成、良いアプローチだと思っていて、私ももっとプラグイン書いて生成していかないとなあと思いました。

作ってよかったgraceful shutdownライブラリ

「Hatenaの知見」「Goサブ会の知見」となっているのが最高だと思いました。

runn開発者会議 in 鴨川

runnの開発者が揃ったので恒例のオフラインでの開発者会議をしていました。懇親会までの時間、鴨川で。

今回出た機能検討は覚えている限りだと次のような感じ

  1. ファイルの存在有無などで実行するステップを変えたい
    • ビルトイン関数への実装以外で何か良いアイデアはないか
    • Fileランナーを作っていいのだろうか
  2. 複数のAPIサーバへリクエストをするようなシナリオで、一部のAPIサーバだけモックしたい
    • 同じシナリオを使ってそれぞれのAPIサーバ開発でテストを行うイメージ
    • https://github.com/vcr/vcr 的な挙動が必要?
  3. 成功したステップをスキップしつつ成功時の値はキャッシュとして利用したい
    • ユースケースとしては2のほうと同じなので、あくまで実現方法の違い
  4. APIテストを担うことが多いが、テストランナーの現状のワンライナーしか許容しない記法があまり可読性良くない
    • 別の記法「も」サポートしたほうが良さそう

どれも実装にいたるまでは詰められなかったので、一旦寝かせてあとはオンラインで引き続き。難しい。。。

懇親会

Gopherのたしなみとして「ORMは何を...?」などから始めつつ、いろいろお話ししました。話が楽しかったのですが、一方でもっといろんな人に挨拶すればよかったなあなどと思いました。

あと、これは課題です。

あとはtenntennさんにPull Requestのレビューを直接お願いするというオフラインカンファレンスムーブをキメるなどしていました*1

2次会

はてな社さんにお邪魔させていただきました。ただ、まあ、もうダメでした。楽しかったです。

感想

最初にも書きましたが本当に濃いGoな1日でした。

お疲れ様でした! ありがとうございました!

*1:後日レビューしていただきました

GitHub Actions上で安定したベンチマーク環境を得る(ただし、相対比較専用)

現在、実験的にですが少しパフォーマンスを気にしたいパッケージを書いています。 ちなみに、その1つはリバースプロキシです。

github.com

「気にしたいパフォーマンス」というのは以下の2つです。

  1. 現在のGoの実装がNGINX(のリバースプロキシ機能)と比べてリクエストあたりの処理時間が小さいか
  2. 現在の実装が以前の実装(Pull Requestであればmainブランチ)と比べてリクエストあたりの処理時間が極端に大きくなっていないか

GitHubリポジトリがあるのでCI環境としてはGitHub Actionsがあります。

そこで、上記2点をGitHub Actions上で継続的に計測したいと考えました。

まず、1つ目の「NGINXとの比較」ですが、これはただNGINXを使ったベンチマークと自分の実装を使ったベンチマークをそれぞれ実行することにしました。

NGINXを使ったベンチマークの結果(ns/op)と、同じようにGoの実装を使ったベンチマークの値(ns/op)を比較するだけです。

それぞれのベンチマークgo test -bench で計測しoctocovのカスタムメトリクス機能を使って結果を表示します。

k1low.hatenablog.com

GitHub Actionsで提供されるRunnerのスペックはいつも同じとは限らない

2つ目の「以前の実装との比較」です。

当初はoctocovの比較機能を使い、GitHub Artifactsに保存してある以前のベンチマーク計測結果と比較するようにしたのですが、ベンチマーク結果が安定しませんでした。

原因はGitHub Actionsで提供されるRunnerのスペックがいつも同じとは限らないことでした。

このRunnerのスペックの違いを、CPU命令数とCPUキャッシュのヒット率を使って乗り越える方法を実現した方がいます。

www.mizdra.net

私も手元ではうまくいったように見えたので、Cachegrindの結果をoctocovのカスタムメトリクスで取得できる仕組みまで作ったのですが、残念ながら期待した結果にはなりませんでした。おそらく、ベンチマーク対象のコードにCPU以外に大きく影響をうけるような箇所があるんだろうなと推測しています。

github.com

同じRunnerで実装前後のベンチマークを比較する

ようは、実装前後の2値の比較だけができればいいわけです。

ということで愚直に同一Job上で実装前後のベンチマークを取得し比較するようにしました。そうすれば少なくともRunnerのスペックの違いに悩まされることはなくなります。

actions/checkoutk1LoW/octocov-action の機能を駆使します。

作成したGitHub ActionsのWorkflowは次のとおりです。

# .github/workflows/benchmark.yml
name: benchmark

on:
  pull_request:

jobs:
  benchmark:
    name: Benchmark
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Check out source code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version-file: go.mod

      - name: Set up octocov-go-test-bench
        run: go install github.com/k1LoW/octocov-go-test-bench/cmd/octocov-go-test-bench@latest

      - name: Check out source code (main)
        uses: actions/checkout@v4
        with:
          ref: main
          path: main

      - name: Run benchmark (main)
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json
        working-directory: main

      - name: Run octocov (main)
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.main.yml
        env:
          OCTOCOV_GITHUB_REF: refs/heads/main
          OCTOCOV_GITHUB_SHA: none
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: main/custom_metrics_benchmark.json

      - name: Run benchmark
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json

      - name: Run octocov
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.yml
        env:
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: custom_metrics_benchmark.json

順を追って説明します。

まず、Check out source code Set up Go Set up octocov-go-test-bench ステップまでは通常通りですが、Check out source code (main) でmainブランチのソースコードを mainディレクトリにチェックアウトします。

      - name: Check out source code (main)
        uses: actions/checkout@v4
        with:
          ref: main
          path: main

次にmainブランチのソースコードベンチマークを実行します。ベンチマーク結果を octocov-go-test-bench でパースしてoctocovのカスタムメトリクスJSONとして、main/custom_metrics_benchmark.json に保存します。

      - name: Run benchmark (main)
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json
        working-directory: main

mainブランチのoctocovカスタムメトリクスJSONを使ってoctocovでレポートを作成します。

      - name: Run octocov (main)
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.main.yml
        env:
          OCTOCOV_GITHUB_REF: refs/heads/main
          OCTOCOV_GITHUB_SHA: none
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: main/custom_metrics_benchmark.json

mainブランチのカバレッジレポートであることを記録するために環境変数 GITHUB_REFGITHUB_SHA を上書きします。GitHub Actions上では直接 GITHUB_REFGITHUB_SHA を上書きすることはできないので OCTOCOV_GITHUB_REFOCTOCOV_GITHUB_SHA で上書きします*1

使用する設定ファイル .octocov.benchmark.main.yml は次のように指定します。

# .octocov.benchmark.main.yml
repository: ${GITHUB_REPOSITORY}/benchmark
coverage:
  if: false
codeToTestRatio:
  if: false
testExecutionTime:
  if: false
report:
  datastores:
    - local://main # レポートをローカルの main/ ディレクトリ以下に保存する

コードメトリクスの取得は全てOFFにし、レポートファイルをローカルディレクトリに保存するようにしています。

最後はPull Requestの実装のベンチマークを取得してoctocovにカスタムメトリクスとして読み込ませます。

      - name: Run benchmark
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json

      - name: Run octocov
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.yml
        env:
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: custom_metrics_benchmark.json

ここで比較対象として、先ほどローカルの保存したmainブランチのoctocovレポートを指定します。

.octocov.benchmark.yml は次のようになります。

# .octocov.benchmark.yaml
repository: ${GITHUB_REPOSITORY}/benchmark
coverage:
  if: false
codeToTestRatio:
  if: false
testExecutionTime:
  if: false
diff:
  datastores:
    - local://main # 比較対象としてローカルの main/ ディレクトリ以下に保存されているレポートを使用する
comment:
  if: is_pull_request
summary:
  if: true

実際のレポート結果

  1. Goの実装とNGINX(のリバースプロキシ機能)との比較
  2. 現在の実装と以前の実装(Pull Requestであればmainブランチ)との比較

なのでちょうど次のようなマトリクスになります。

以前の実装(main) Pull Requestの実装
NGINX
Goの実装

そして、今回説明したWorkflowをPull Requestで実行すると以下のようになります。

Metadataを展開するとちゃんと実行環境のCPUが揃っていることがわかります。

まとめ

今回、2つの要件を満たすためのGitHub Actions上での安定したベンチマーク環境を得る方法を紹介しました。

ただ、残念ながら継続的なベンチマーク環境とはいかず、あくまで相対比較専用となっています。

それでも、Pull Requestごとに数値で確認できるのは大きなメリットです。

個人的には、Cachegrindの手法(もしくはそれに準じる手法)が「GitHub Actions上での安定した継続的ベンチマーク環境」を得る方法だと思っているので、折を見て再挑戦したいと思っています。