YAMAGUCHI::weblog

海水パンツとゴーグルで、巨万の富を築きました。カリブの怪物、フリーアルバイター瞳です。

Goのリリースプロセスとブランチ戦略

はじめに

こんにちは!Google Cloudでオブザーバビリティの担当をしているものです。CVE-2021-44228のおかげでバタバタしていますがみなさんはお元気ですか?

このエントリーはpyspa Advent Calendar 2021の15日目の記事です。昨日は @moriyoshit さんの「Goのロギングライブラリ 2021年冬」でした。めちゃめちゃ調べてあって良い記事でした。Goでログライブラリの選定をする際にはこちらをまず読むと良さそうです。

2021.12.21 追記: 穴が空いていたのでGo Advent Calendar 2021 その1の14日目の記事にもしました。

さて、今日は本当は「Goならわかる確定申告第三表」という記事を書こうと思ったのですが、まだ確定申告の時期ではないのでそれは辞めにします。そのかわり、今日はGo 1.18がめでたくベータ版リリースとなったので、Goのリリースプロセスとブランチ戦略の紹介をしようと思います。

TL;DR

Goは2月と8月にメジャーリリースを行います。リリースに際してはメインブランチから各バージョン用リリースブランチをフォークしてリリースします。概ねGoのレポジトリのWikiに説明が書いてあるので興味があったらこれを読んでください。

リリース

新規バージョンのリリーススケジュール

リリーススケジュールに関してはGoのGitHubレポジトリのWikiに詳細に書いてあるので、これを読めばすべてわかります。

github.com

英語を読むのが面倒という方向けに簡単に要約をするとこうなります。(この図を見ながら読んでください。図は上のWikiから引用。)

https://github.com/golang/go/wiki/images/release-cycle.png

  • メジャーリリース*1は半年ごとのサイクルで行われ、毎年2月と8月にリリースされる
  • リリースサイクルは3ヶ月ごとに前半(緑の部分)と後半(青の部分)に分けられ、前半は新規機能開発含めたすべての開発、後半はバグ修正とドキュメント更新のみとなる
  • リリースサイクル後半の2ヶ月めでベータ版(Beta)、3ヶ月めでリリース候補版(Release Candidate, RC)がリリースされる
    • ベータ版は早めに実環境で利用してもらい、バグの発見とその修正を行う意図でのリリース
    • リリース候補版はほぼリリース版と機能的には同様で大きなバグはないという想定で、リリースまではドキュメント修正が主
    • リリース候補版で修正が入る場合は相当致命的なバグがあった場合のみ
    • ベータ版もリリース候補版も多く版を重ねないように多くても2週間に1度しか出さないようにする
  • リリース候補版で無事に致命的なバグがないと確認できたら正式リリース版をリリース

こういう背景を知っていると、たとえばGo 1.11でのベータ版がbeta 3まで行ったときに「今回はバグが多いな」といったニュアンスが読み取れます。

既存バージョンのメンテナンス

基本的にリリース版は「新規機能とその追加によって起きたバグの修正がなされた」と判断された状態でリリースされるので、本来であれば修正が必要ないはずです。しかしながらバグがないソフトウェアというのは通常はありえないので、だいたい正式リリース後にバグが発見されます。

このリリース後のバグの修正、というのは各正式リリース版のブランチ(release-branch.go1.x)で行われるわけではなく、メインブランチ(=次バージョン用の開発ブランチ)で行われた修正をcherry pickで各ブランチにバックポートする形で行われます。この対応が行われるのは最新2バージョンのみです。(例えばいま1.17がリリースされているので、バックポートは1.17と1.16に対して行われる。)

おおよそマイナーリリースは1ヶ月ごとに行われるので、パッチバージョンは5前後まで行くのが目安だということがわかります。(ただし後述のとおりセキュリテイ修正リリースが行われる場合はその限りではありません。)

またパフォーマンスや挙動ではなく、セキュリティに関する修正の場合はポリシーが異なります。セキュリティ関連のバグは3種類に分けられていて

  • PUBLIC: 影響が大きくない、あるいはすでに知られているバグで、修正は公開した状態で行われる
  • PRIVATE: セキュリテイ観点で問題があると思われるもので隠したほうがいいと判断したもの。修正は非公開で行われ、その内容はリリースの3日〜7日前にアナウンスされる。
  • URGENT: ゼロデイなどGoのエコシステム全体に影響を及ぼすような深刻なバグ。修正は非公開で行われる。

PUBLICとPRIVATEに分類されたものは他のセキュリティ関連ではないバグの修正とまとめられてマイナーリリースで行われます。URGENTに分類されたものは、その修正のみのマイナーリリースが行われます。以前はセキュリティ修正リリースは分類に関係なく単体でリリースされていたのですが、今年の3月に提出された提案によってプロセスが改められました。(過去のリリースを見るとパッチバージョンの番号がすごく多いのはセキュリテイ修正が多かったことによるものだとわかります。)

詳細はこちらを参照してください。 go.dev

修正と新規機能の作成

さてリリーススケジュールとその中身がわかったところで、次は開発による変更がどのように行われているか、というところです。開発は大きく分けて2つの種類があります。一つは機能改善やバグの修正、もう一つは新規機能開発です。

修正

セキュリティ関連でない修正はまず問題をIssue Trackerに登録します。

github.com

Issue Trackerに登録するとトリアージが行われます。トリアージが行われた際には次のどれかのラベルが付与されることになります。

  • NeedsInvestigation: 問題の原因がよくわからないので原因究明のための調査が必要
  • NeedsDecision: 問題の原因はわかっているもののどう対応するか判断しかねているもの
  • NeedsFix: 問題の原因はわかっていて、対応方法もわかっているもの

NeedsFixにラベルされたものはいつでもコードを書いて修正して良いもので、基本的には開発のメインブランチに送られているパッチはこのラベルがついた問題に対するものです。

パッチを提案するためにはコントリビューションのワークフローに則らなければいけません。現在は大きく分けて2つの方法でパッチを送ることができます。一つはGitHub経由、もう一つはGerrit経由です。*2

GitHub経由でパッチを送る場合はいわゆるGitHub Flowに沿って行います。golang/goをforkしてブランチを切ってPull Requestを作成です。コードレビュー自体はGerrit上で行われ、そこでのコメントがGitHubのPull Requestにミラーされます。

Gerrit経由でパッチを送る場合はChange (Change List) というものを作成します。Gerritの解説をするとそれだけで終わってしまうので、ここではGitHubでのPull Requestに相当する単位だと理解してください。

どちらもコードレビューが終わり修正が承認されると1コミットとしてメインブランチにpushされます。

新規機能

一方で1.18に入ったGenericsのように、言語の機能自体の大きな変更はProposalが必要になります。まずは簡単に提案の概要をIssue Trackerで起票します。起票するとGitHubのプロジェクトで管理されていきます。

まずはIncomingとなり、Goチームのレビューが始まるのを待ちます。そして週次のProposalレビューの対象になると、ラベルがActiveに変更になります。(どれが取り上げられたかはGoチームの議事録に記録されます)レビューの対象の提案はIssue Tracker上やgolang-devのメーリングリストで議論が行われます。もし大きな設計書がなくてもコードが書けそうな提案であればその場でLikely Acceptになります。

もし詳細が必要であると判断された場合にはDesign Docを書くように依頼されます。どのような機能か、その機能を提案する背景、実装例含む設計案を説明したDesign Docを作成し、再度レビューが始まります。Design Docは専用のレポジトリで管理されています。(GitHubにもミラーがある)

たとえばGo 1.18に入ったtype parameterでの例はこのような形です。

Design Docを含めた深い議論の末、晴れてLikely Acceptになったのちに、1週間特に異論が見られないようであれば、めでたくAcceptedとなり、いよいよ実際にその実装を行うためのマイルストーンが設定されます。Proposalはマイルストーンの単位となり、実装は細かな修正としてバグの修正のときと同様のプロセスでパッチが作成されます。

なお、Declineの場合はパッチが作成されないので、ここではそのプロセスは割愛しましたが、Declineの場合はその理由と共に決定がなされ、チケットが閉じられます。またHoldという状態もあり、これは議論が止まってしまって判断がつかないような状況です。議論が再開すればActiveに戻ります。

ブランチ戦略

基本

パッチがどのように作成されるかがわかったところで、いよいよブランチ戦略です。まず単発で取り込まれるような通常の修正の場合です。

go.googlesource.com

すでに上の節でも触れていますが、Goのレポジトリのブランチは基本的に master が常に最新です。そしてすべての修正はこの master に対して行われます。

f:id:ymotongpoo:20211215230908p:plain

上の図は上から下に時間が流れていると解釈してください。一番上ではまさにバージョン1.xのための開発を行っています。(リリースサイクルの前半)

先に紹介したように、すべての修正はGerritでレビューされます。Gerritの線上にある四角はGerritでのChange(GitHub上ではPull Requestに見える)を表しています。区別するために新規開発や新規実装のためのChangeは紫色に、バグ修正、ドキュメント更新、セキュリティ修正のためのChangeは水色にしています。

Changeの中の三角はPatch Set(GitHub上ではPull Request内のコミット)です。開発者は原則としてコミットは必ずGerritに対して行うような形になります。そしてレビューによってChangeが承認されると、ChangeはGit内の1コミットとして master にpushされます。(GitHubでPull RequestしたとしてもGerrit経由でpushされるためGitHub上ではforce closedしたように見える)

そして、リリースサイクルの後半に入るときにRelease freezeがアナウンスされ、新規開発や新規実装の新しいChangeはいったん受け入れを停止し、バグ修正、ドキュメント修正、セキュリティ修正のみが master に入ります。次のメールは1.18のためのRelease freeze宣言時の例です。

そしていよいよベータ版リリースとなったときに release-branch.go1.x のブランチが切られます。しかしすべての修正は相変わらず master に送られ、必要なものは都度 cherry-pick しながら release-branch.go1.x に取り込まれます。

そのまま開発を続けていって、リリース候補版が出され、そして晴れてバージョン 1.x のリリースとともに、次バージョン 1.(x+1) の開発がスタートします。このタイミングで新規機能や新規実装のChangeが取り込み可能になります。

もちろん次バージョンの開発を続ける中で、現バージョンにも影響するセキュリティ修正やバグ修正もあるので、その場合はまた cherry-pick で release-branch.go.1.xrelease-branch.go.1.(x-1) の各ブランチ(最新2バージョン)にバックポートして、マイナーリリースの際にパッチバージョンを上げてリリースしていきます。(ここでバージョン名でGitのTagを打つ)

大きな新機能の開発

上記が基本的な流れなのですが、Goのレポジトリを覗くとその規則からは外れたブランチが見られます。ざっと一覧で表示してみましょう。

dev.cc
dev.cmdgo
dev.debug
dev.fuzz
dev.garbage
dev.gcfe
dev.go2go
dev.inline
dev.link
dev.power64
dev.regabi
dev.ssa
dev.tls
dev.typealias
dev.typeparams
dev.types

すべて dev.foobar という命名規則になっていることがわかります。これらのブランチの後半の単語をよく見てみると見覚えがあるような単語ばかりです。これらのブランチは大きな新機能のために作られた特別な開発用ブランチで、このブランチは master とは違い Release freeze 期間においても新規開発を取り込めるブランチとなっています。

例えばFuzzingの開発ブランチである dev.fuzz の、あるコミットを見てみます。

4651d6b267 - go - Git at Google

commit 4651d6b267818b0e0d128a5443289717c4bb8cbc
Author: Katie Hockman <katie@golang.org>
Date:   Wed Dec 2 14:37:49 2020 -0500

    [dev.fuzz] internal/fuzzing: handle and report crashers
    
    Change-Id: Ie2a84c12f4991984974162e74f06cfd67e9bb4d7
    Reviewed-on: https://go-review.googlesource.com/c/go/+/274855
    Trust: Katie Hockman <katie@golang.org>
    Run-TryBot: Katie Hockman <katie@golang.org>
    Reviewed-by: Jay Conrod <jayconrod@google.com>

日付を見ると去年の12月となっています。12月は上のリリースサイクルで説明したように Release freeze 期間ですね。このようにして、Goでは安定したリリースを行いつつ、重要な新規機能の開発は止めないようなプロセスになっています。

おわりに

というわけで、Goのリリースプロセスとブランチ戦略について大まかに説明しました。本記事によって、Goチームからのアナウンスがより身近に感じられていただけたら幸いです。今日話さなかった内容はまだまだあって、たとえば次のような話題があります。

  • dev ブランチの master へのマージプロセス
  • Gerritにpushされた際に行われるテストとそのインフラ
  • リリースの際のアーティファクトビルドプロセス

これらはまた触れる機会があれば紹介したいと思います。

オープンソースプロジェクトは様々な点で勉強になることがありますが、あまり注目されていない(と思われる)リリース戦略もその一つだと思います。もしGo以外のプロジェクトのリリースプロセスの解説記事があればぜひ読んでみたいので、ご存知でしたら教えてください。

ところでPythonでのリリースといえばパッケージングですね。明日は世界でも指折りにPythonのパッケージングに詳しい @aodag です。*3

参照

*1:Goはまだ1系しか出していないので、マイナーバージョンが更新されるリリースをメジャーリリースと呼んでいる

*2:当初はGerritのみだったのですが、Gerritに不慣れな方が多く、多くの要望があったためGitHubとGerritを同期するbotを導入して、どちらでも取り込めるようにしています。現在でもGerritが正であることは変わっていません。

*3:本人が出たがらないから勝手に宣伝するけど、いままで@aodag以上にPythonのパッケージングに詳しい人は海外のカンファレンスや会社の同僚を含めて会ったことがない。

gopterでステートフルなPBT

はじめに

こんにちは、Cloud Ops担当者です。最近はGoogle Cloud Profilerがイチオシです。ワークショップやってるんで興味がある方はご連絡ください。

この記事はGo Advent Calendar 2020の19日目の記事です。昨日は@DoarakkoさんのGo の Web フレームワーク Gin にちょっとコミットしましたでした。

Property Based Testingとは

先日開催されたGoCon SendaiでFuzzingとProperty Based Testingについてお話しました。Property Based Testingを簡単に説明すると、テスト対象の関数に対して、入力値の条件とその関数が満たすべき性質(Property)を定義してあげて、入力時をマシンパワーに任せて自動生成しながら、テスト対象の関数の戻り値と性質を表す関数の戻り値を比較して、一致しない条件を探すという、高級なファジングです。詳しくはGoCon Sendaiの発表を見てください。


S3-1 Goにおけるfuzzingとproperty based testing

資料はここです。

ステートフルProperty Based Testing

GoCon Sendaiでの発表ではProperty Based Testingの紹介だったので、一番始めやすいステートレスPBTについての話をしました。それだけでも十分有用なのですが、PBTが真に力を発揮するのはステートフルなテストを行うときです。発表でも紹介しましたが、LebelDBのバグを発見するのにステートフルPBTが使われました。

groups.google.com

このバグは17ステップを特定の条件で踏まないと再現しないという、非常にややこしいものだったのですが、PBTはきちんとその手順を見つけてきました。人間がこれを発見するのはほぼ無理なのではないでしょうか。他にもステートフルPBTが見つけたバグは多くあります。

他にもDropboxが分散同期システムでのバグを発見したり、Volvoが車載システムの検証に使ったと多くの事例があります。たとえば

  • ショッピングカートの実装
  • オンラインゲームなどでのターン制戦闘の管理やポイント処理周り
  • 決済システム
  • ステートフルプロトコル

など、多くの場面で有用でしょう。

GoでステートフルPBT

GoでPBTをする場合にはいくつか選択肢があります。

github.com

github.com

gopterのほうが多くの事例があるので、いま使うのであればgopterのほうがいろいろ都合がいいと思います。発表やスライドにもありますが、ステートレスなPBTの場合に、PBTの実行は properties.Property() にテスト自体の説明と、テスト対象の関数を渡していました。(次はステートレスPBTの例)

func TestSqrt(t *testing.T) {
    properties := gopter.NewProperties(nil)

    properties.Property("greater one of all greater one", prop.ForAll(
        func(v float64) bool {
            return math.Sqrt(v) >= 1
        },
        gen.Float64Range(1, math.MaxFloat64),
    ))

    properties.Property("squared is equal to value", prop.ForAll(
        func(v float64) bool {
            r := math.Sqrt(v)
            return math.Abs(r*r-v) < 1e-10*v
        },
        gen.Float64Range(0, math.MaxFloat64),
    ))

    properties.TestingRun(t)
}

gopterでステートフルなPBTを行う場合は commands というサブパッケージを使うことになりますが、ステートフルPBTを行う場合も、実行はステートレスなPBTと同様に properties.Property() 関数でおこないます。ただし、渡すのが commands.ProtoCommands になります。

func TestAll(t *testing.T) {
    if testing.Short() {
        t.Skip("skip PBT in short mode.")
    }
    parameters := gopter.DefaultTestParameters()
    parameters.Rng.Seed(1234) // デモなので常に同じ結果になるようにシードを固定

    properties := gopter.NewProperties(parameters)
    properties.Property("buggy counter", commands.Prop(buggyCounterCommands))

    properties.TestingRun(t)
}

これが何者なのかを説明することでステートフルPBTの書き方がわかりやすくなるので、以下で説明します。

ステートフルPBTに必要なもの

上の発表はスライドを読む時間がない忙しい人に、そもそもPBTは何をするものなのかという点をもう一度簡単に書くと「テスト対象の属性(Property)を表すものを定義し、入力値をランダムに生成して、テスト対象が返すものと属性の実装が返すものが不一致ならテスト対象に問題があると判断する」というものです。(下図参照)

f:id:ymotongpoo:20201219214846p:plain

ステートレスPBTの場合は上の図のPropertyが関数にしかならないのですが、ステートフルPBTの場合はそれが変数と関数の組み合わせだったり、メソッドをもった構造体です。比較対象も直接出力値を比較するというよりも「状態」を比較することになります。そして入力値は値を入れるというよりも「呼び出す関数(およびその引数)の組み合わせ」になります。整理して

  • ステートを持つテスト対象
  • 状態
  • 呼び出す関数の組み合わせ

が必要になります。

百聞は一見にしかずで、ドキュメントの例にある次の関数を見てみましょう。

テスト対象

まずテスト対象を定義します。これは普通に機能を実装するだけなので、PBT関係なく行います。ここではカウンターを実装します。例ではわざとバグを作るためにカウンターの値が3より大きい場合は、デクリメントの際に2を引くことにしています。

type BuggyCounter struct {
    n int
}

func (c *BuggyCounter) Inc() {
    c.n++
}

func (c *BuggyCounter) Dec() {
    if c.n > 3 {
        // Intentional error
        c.n -= 2
    } else {
        c.n--
    }
}

func (c *BuggyCounter) Get() int {
    return c.n
}

func (c *BuggyCounter) Reset() {
    c.n = 0
}

状態の定義

次にテストファイルで書く内容です。ステートフルPBTでは状態を比較することになるので、比較対象として状態を管理するためのオブジェクトを定義します。カウンターは状態としてカウンターの数値しか持っていないので、ここでは特段構造体などを用意せず、intとします。とりあえずこのことだけ理解した上で次の話に進みます。

コマンドの定義

gopterで「コマンド」と呼んでいるのは、状態を持ったオブジェクトに対する操作を指します。なぜ「コマンド=関数/メソッド」と呼ばないかといえば、コマンドは「一意な操作」の単位なので、たとえば引数を取る関数であれば、引数の値ごとにコマンドが異なります。(カウンターの例では引数なしのメソッドしかないので、たまたまコマンドとメソッドが1対1対応しています。)

カウンターの例でいうと IncDecGetReset の4つのコマンドを定義します。まず Get メソッドと Inc コマンドに対応するコマンドを定義してみます。

var GetCommand = &commands.ProtoCommand{
    Name: "GET",
    RunFunc: func(systemUnderTest commands.SystemUnderTest) commands.Result {
        return systemUnderTest.(*BuggyCounter).Get()
    },
    PostConditionFunc: func(state commands.State, result commands.Result) *gopter.PropResult {
        if state.(int) != result.(int) {
            return &gopter.PropResult{Status: gopter.PropFalse}
        }
        return &gopter.PropResult{Status: gopter.PropTrue}
    },
}

var IncCommand = &commands.ProtoCommand{
    Name: "INC",
    RunFunc: func(systemUnderTest commands.SystemUnderTest) commands.Result {
        systemUnderTest.(*BuggyCounter).Inc()
        return nil
    },
    NextStateFunc: func(state commands.State) commands.State {
        return state.(int) + 1
    },
}

Name フィールドはこの操作の愛称です。これはテスト結果に出力されるので、1単語でわかるくらいの短いラベルが良いです。それぞれ "GET"、"INC"としています。

RunFunc フィールドはテスト対象側で実行する操作です。commands.SystemUnderTest をテスト対象のオブジェクトにキャストしてから、対象の操作を実行します。

PostConditionFunc はコマンドを実行し終えたあとに行う処理です。通常はここで状態の比較を行います。また、ここでフィールドに与えている無名関数の引数に commands.Statecommands.Result がありますが、stateは自分がテスト用に用意した状態管理用のオブジェクト、resultはRunFuncの結果です。それぞれ int なので int にキャストして比較しています。不一致だったら gopter.PropResult{Status: gopter.PropFalse} として失敗した旨を返します。

NextStateFuncRunFunc を行った場合に状態がどのように変化しているべきかをテスト用に用意したオブジェクトに対して操作を記述し、その値を返します。ここでは無名関数の引数に渡される commands.State を直接操作して、そうあるべき状態に変化させます。"INC"ではカウンターとして用意した int の値に対して1を足すだけです。状態を管理しているものが構造体の場合も、その構造体にキャストしてから適切な操作をします。

同様にして DecReset についてもコマンドを定義します。

ステートフルPBTの初期設定

最後に、初期値に関する設定と、存在するすべてのコマンドを定義します。commands.ProtoCommands がそれです。(commands.ProtoCommand ではなく複数形になっていることに注意。)次のような形です。

var buggyCounterCommands = &commands.ProtoCommands{
    NewSystemUnderTestFunc: func(initialState commands.State) commands.SystemUnderTest {
        return &BuggyCounter{}
    },
    InitialStateGen: gen.Const(0),
    InitialPreConditionFunc: func(state commands.State) bool {
        return state.(int) == 0
    },
    GenCommandFunc: func(state commands.State) gopter.Gen {
        return gen.OneConstOf(GetCommand, IncCommand, DecCommand, ResetCommand)
    },
}

NewSystemUnderTestFunc はテスト対象のオブジェクトを初期化するための関数を定義するフィールドです。 InitialStateGen はテスト用に定義したオブジェクトの初期化。この例では int のまま、かつカウンターは常に初期値0となっているので gen.Const(0) となっています。 InitialPreConditionFunc はPBTを始める前にテスト対象とテスト用状態管理オブジェクトの各初期値が持っているべき状態です。

そして最後に GenCommandFunc で実行すべきコマンドを返す関数を渡します。カウンターでは状態によらずすべてのメソッドが呼べるので、特に引数の commands.State は触っていませんが、複雑な状態を管理している場合には状態に応じて呼ばれるコマンドがかわるわけです。ここでステートマシンのグラフをすべて書くことになります。

ステートフルPBTの実行

最後にステートフルPBTを実行します。自分は testing.T を使って go test コマンドで呼び出せるほうが好きなので、普通のテストと同様に xxx_test.go ファイルに書きます。ただし、PBTはファジングと同様に実行にどうしても時間がかかるので、常に起動することがないように --short フラグで起動しないようにするなど、条件付きで起動するようにしたほうが良いと思います。

func TestStatefulPBT(t *testing.T) {
    if testing.Short() {
        t.Skip("skip PBT in short mode.")
    }
    parameters := gopter.DefaultTestParameters()
    parameters.Rng.Seed(1234) // デモなので常に同じ結果になるようにシードを固定

    properties := gopter.NewProperties(parameters)
    properties.Property("buggy counter", commands.Prop(buggyCounterCommands))

    properties.TestingRun(t)
}

実行は先にも書いたように properties.Property() 関数に上で定義した commands.PropCommands を渡してあげれば終わりです。( commansd.Prop でラップしてるのは型を揃えるため)

実行

これを go test で実行してみます。

$ go test
! buggy counter: Falsified after 43 passed tests.
ARG_0: initialState=0 sequential=[INC INC INC INC DEC GET]
ARG_0_ORIGINAL (8 shrinks): initialState=0 sequential=[RESET GET GET GET
   RESET DEC DEC INC INC RESET RESET DEC INC RESET INC INC GET INC INC DEC
   DEC GET RESET INC INC DEC INC INC INC RESET RESET INC INC GET INC DEC GET
   DEC GET INC RESET INC INC]
Elapsed time: 19.201782ms
--- FAIL: TestAll (0.02s)
    properties.go:57: failed with initial seed: 1608381699594525693
FAIL
exit status 1

ここで特筆すべきは、ステートフルPBTの場合、テストを失敗させた入力値の表示として、失敗する状態まで持っていったコマンド列が表示されることです。さらに上の例を見ると、ちゃんとその結果まで shrink していることがわかります。今回の例だと3より大きい数字でデクリメントを呼んだときに2を引いてしまっているので、そこでおかしくなることが最小限のステップで再現できるコマンド列が得られています。こんな簡単な例ですら6手順必要なので、リアルワールドではとても有用であることが容易に想像できます。

ステートフルPBTのデメリット

ステートフルPBTは非常に強力なのですが、上で書いているように状態を本来の実装とは別に管理するロジックを書いたり、またコマンドの準備などが非常に複雑になりがちなので、なかなか手が出しづらい側面があります。手動で境界条件になりうるテストシナリオをすべて列挙するのは非現実的ですが、露見するバグによる障害が手動による例外的な対応で許容できる場合もあります。そのようなテスト実装コストと得られるメリットのバランスはなかなか読めないので、すべての人におすすめできるわけでもありません。個人的にはステートフルPBTはもっと事例が出てほしいなと思ってはいますが。

おわりに

急いで書いたのでサンプルの解説になってしまいましたが、少しでもこれでステートフルPBTを使う人が増えると嬉しいです。明日は@soichisumiさんです。

Goのソースコード内のトリビア

はじめに

こんにちは、StackdriverあらためGoogle Cloud Operations担当者です。Google Cloud Operationsもさることながら、Go Conferenceの運営など、長らくGoコミュニティに関わってきましたが、まだまだ知らないことがあったということを昨日今日で知ったので共有します。

time.minWall

time.minWall という値があります。

const (
    hasMonotonic = 1 << 63
    maxWall      = wallToInternal + (1<<33 - 1) // year 2157
    minWall      = wallToInternal               // year 1885
    nsecMask     = 1<<30 - 1
    nsecShift    = 30
)

これは time パッケージ内で時刻の起源を設定するための定数値なのですが、これが1885年に設定されているわけです。1900年とかならまだしも、なぜ1885年なのかという疑問が湧いてきます。実際これは自分が気付いたわけではなく、友人の @la_luna_azul がふとしたときに疑問に思っていたのを見て調べてみたら、自分もよくわからなかったわけです。

1885年といえば、思いつくのはBack To The Future 3で過去に戻ったときの年です。偶然の一致とはいえ、この年をわざわざ指定するということは少なからずその影響があったのでは?という期待が湧いてきます。実際、この定数値を決めるにあたっての理由になりそうな記録を探してみると以下の2つが見つかりました。

github.com

ここでRuss Coxは次のように回答しています。

OK, this is checked in for Go 1.9. I moved the internal epoch to 1885 (max year 2157) to avoid any possible problem with NTP-epoch-derived times. I also adjusted the design doc to reflect this change and some minor encoding changes.

Thanks for the constructive conversation everyone.

しかし、ここでは1885年に設定したという事実は書いていても、その根拠は書かれていません。Go 1.9でepochのデフォルトを1885年にしたと書いてあるので、そのproposalがあるはずです。探してみると次のproposalが見つかります。

go.googlesource.com

ここでは次のようにコメントされています。

Unix-based systems often use 1970, and Windows-based systems often use 1980. We are unaware of any systems using earlier default wall times, but since the NTP protocol epoch uses 1900, it seemed more future-proof to choose a year before 1900.

なるほどepochがいろいろあるけれど、とりあえず1900年より前にしておけば良さそうだからそうしたと。しかし、そこでわざわざ1885年に設定する理由はありません。もう自分の中では「もうBack To The Futureの年というイースターエッグなんでしょ!」と思ってはいたんですが、やはり確証は欲しいものです。こういうものは聞いてしまうのは無粋かもしれないし、知ったところでなんになるわけではないけど、もう知りたくてたまらない!!

そこでRuss Coxにメールしてみました。するとすぐに返事が返ってきました。

Yes, people talked me into moving it back before 1900, and 1885 was the obvious choice due to its historical significance to Hill Valley, California. :-)

やっぱりBack To The Futureだったのか!!なんかスッキリした!!

http.aLongTimeAgo

こんな質問に答えてくれるRuss Coxは優しいなあと思っていたら、話はこれで終わりではありませんでした。先の返信に続けて、次のような返事があったのです。

See also http.aLongTimeAgo, which is now sadly set to time.Unix(1, 0) but used to be time.Unix(233431200, 0).

// aLongTimeAgo is a non-zero time, far in the past, used for
// immediate cancellation of network operations.
var aLongTimeAgo = time.Unix(1, 0)

たしかにこれはいま time.Unix(1, 0) になっています。昔はどうだったんでしょうか。Go 1.8でこの定数が導入されたので見てみると

// aLongTimeAgo is a non-zero time, far in the past, used for
// immediate cancelation of network operations.
var aLongTimeAgo = time.Unix(233431200, 0)

このepoch秒が表す日付はいつなのでしょうか。見てみましょう。

f:id:ymotongpoo:20200717010401p:plain

1977年5月25日です。"A long time ago"とこの年が紐づくものといえば....

f:id:ymotongpoo:20200717010559p:plain

Star Warsしかありませんね!!!これの第1作目、Episode IVの公開日を見てみましょう。

ja.wikipedia.org

f:id:ymotongpoo:20200717010749p:plain

ドンピシャでした。実際にこれはどのように使われているかというと、コメントにあるようにネットワークの操作を即座にキャンセルするためにわざとありえない過去の時間を指定することで接続をキャンセルさせるというときに使われます。Go本体のコードだと次のように出てきます。

cr.conn.rwc.SetReadDeadline(aLongTimeAgo)

デッドラインを過去に設定することで強制的にキャンセルさせるわけです。

おわりに

こういうようなちょっとした遊び心は自分でプログラムを書いているときにも入れたくなるわけですが、そういう仲間内のおふざけみたいなものを垣間見れるとより親近感が湧いてくる気がします。これからもこういう発見ができることを楽しみにしています。

おまけ

この記事を書くにあたって、一応Russ Coxに「公開してもいいですか?」と聞いたら

Feel free. No secrets here.

という返事をくれました。隠してはないけど表には書かない、遊び心を感じる回答でした。

おまけ2

mattnさんが、http.aLongTimeAgo に関してついた line comment を見つけてくれました。

github.com

Goのハンズオン環境としてGlitchを使う

はじめに

こんにちは、Stackdriver担当者です。この記事はGo Advent Calendar 2019の24日目の記事です。昨日は@fist0さんでした。

私は職業柄「コードラボ」「ハンズオン」「ワークショップ」と呼ばれるような、参加者に実際に手を動かして課題を解いてもらうことで特定の技術や製品を理解してもらうイベントを開催したり講師をしたりすることがあります。その場合にこちらがコントロールしづらいものの一つが実行環境です。諸々のバージョンを固定したり、コンテナを用意したり、などいろいろな方法がありますが、今回はglitchを使ってGoでのハンズオン環境を用意する方法とその使い方を紹介します。

TL;DR

Glitchを使ってGo用のハンズオン環境を容易に提供できる。サンプルプロジェクトはこれ。

glitch.com

Glitchとは

glitch.com

ウェブアプリケーションを公開する無料の実行環境で、デフォルトではNode.jsの実行環境が用意されています。日本語でGlitchの丁寧な解説をしている記事もありますので詳細はそちらに譲ります。

で、今回はこのGlitchをGoの実行環境として利用する方法について紹介して、さらにハンズオンなどで便利に使う方法についても解説します。

準備するもの

主催者・チューター

  1. Glitchアカウント
  2. Goのハンズオン用にセットアップされたGlitchプロジェクト
  3. (optional) 2.のコードをミラーするGitHubレポジトリ

参加者

  1. (optional) Glitchアカウント

手順

1. Glitchアカウント

フェデレーテッドログインができるので好きなIdPを選んでアカウントを作ってください。すぐにできます。参加者はアカウントが無くても一時アカウントが利用できるので大丈夫です。

2. GlitchプロジェクトをGo用にセットアップする

メインはここです。GlitchはデフォルトではNode.jsの実行環境なのですが、実はGoの実行環境も入っています。しかし、設定ファイルを書くことで、Goのダウンロードとインストールをして特定のバージョンのGoを使わせたり、コード変更時のGoプロジェクトの自動ビルドなどを設定して、さらにハンズオン環境として良い物にできます。

glitch.json

Goを含むNode.js以外のランタイムは glitch.json と呼ばれる設定ファイルを用意する必要があります。JSONで設定できるフィールドはそれぞれ次のとおりです。

{
  "install": string,
  "start": string,
  "watch": {
    ...
  }
}

それぞれ次のような内容です。

  • install: プロジェクトのコンテナ起動時に実行されるコマンド
  • start: watchで定期的に実行されるコマンド
  • watch: watch.json に関する設定(watch.json を別途作成する場合は書かなくて良い)

watch.json

watch.json というファイルを設定すると、編集後に自動で実行したいコマンド等を記述できます。Linux等のwatchコマンドに似ていますね。ここではどのファイルを変更した場合にどういったトリガーを起動するかを設定します。対象ファイル名は正規表現で指定できます。

{
  "install": {
    "include": [
      "^glitch\\.json$",
      "^init\\.sh$",
      "^\\.env$"
    ]
  },
  "restart": {
    "exclude": [
      "^go/",
      "^pkg/"
    ],
    "include": [
      "\\.go$"
    ]
  },
  "throttle": 5000
}

設定項目はそれぞれ次のとおりです。

  • install: include で記載されているファイルが変更されるとコンテナ自体の再インストールが行われる
  • restart: exclude で記載されているファイルが変更された場合は何もしない、include で記載されているファイルが変更された場合はコンテナを再起動する
  • throttle: watchの確認自体の間隔を設定する(ミリ秒)

Go用プロジェクトセットアップのコツ

これは通常のコンテナイメージ構築の場合と勘所は同じです。つまり次のようにします。

  1. Goのバージョンを固定する
  2. go.mod でパッケージのバージョンを固定する

1のGoのバージョンの固定は、glitch.jsoninstall に適当な初期化用のシェルスクリプトを指定して、その中でLinux用のtarballとsha256のチェックサムの確認をすることで固定できます。上のサンプルプロジェクトではこのような形で設定しています。

GO_ARCHIVE=go1.13.5.linux-amd64.tar.gz

if [ ! -d /tmp/go ]; then
  cd /tmp
  if [ ! -f /tmp/${GO_ARCHIVE} ]; then
    wget -q https://dl.google.com/go/${GO_ARCHIVE}
  fi
  sha256sum -c ~/${GO_ARCHIVE}.SHA256SUMS || (echo "failed to verify go tarball" && rm /tmp/{$GO_ARCHIVE} && exit 1)
  tar -xzf ${GO_ARCHIVE}
  rm /tmp/${GO_ARCHIVE}
fi

mkdir -p /tmp/pkg
if [ ! -L pkg ]; then
  ln -s /tmp/pkg $GOPATH/pkg
fi

そしてこのSHA256SUMSのファイルは自分で手元で作成してもいいですし、Go公式サイトの配布先に書いてあるSHA256 Checksumを自分でコピーして作成しても良いでしょう。

512103d7ad296467814a6e3f635631bd35574cab3369a97a323c9a585ccaa569  go1.13.5.linux-amd64.tar.gz

2.の go.mod は固定です。ハンズオン参加者に編集させていはいけませんし、go mod tidy を実行させてはいけません。アンタッチャブルです。編集させると即座にプロジェクトが予想しない形に壊れるので、もし触ってしまった人がいたら元のgo.modファイルとgo.sumファイルを再度コピーしてもらうようにしましょう。またキャッシュも消す必要があります。

そしてプログラムのビルドと実行も glitch.jsonstart で指定したシェルスクリプト内で go fmtgo run 等を実行するようにし、ハンズオン参加者が go コマンドを自分で打つ必要がないように設定すると良いでしょう。

(optional) 3. 2.のコードをミラーするGitHubレポジトリ

万が一ブラウザではなくローカルで実行したい、もしくは何らかの事情でGlitchを使えない、という人がいた場合に備えて、GlitchのプロジェクトをGitHubミラーリングしておくと安心感が高まります。

注意点としては、事前にExport先のレポジトリを作成し、かつ1つでもコミットがされた状態にしておく必要があります。その上で "Export to GitHub" ボタンを実行してExport先のレポジトリを指定すると、giltchブランチに変更がpushされます。

f:id:ymotongpoo:20191219112030p:plain

実際にミラーしたのがこちらです。

github.com

ハンズオンの進め方

ハンズオンの進め方はまずハンズオン開始時に上の 2. で作成したプロジェクトのURLを参加者に共有します。参加者に "View Source" を押してもらい、コードエディターが読み込み専用モードで開いてもらいます。ここで画面の右上の "Remix to Edit" のボタンを押してもらうと "Remix" が行われます。

f:id:ymotongpoo:20191219141140p:plain

参加者がRemixをすると元のコードをforkしたプロジェクトが任意のIDとともに作成され、参加者は自由に変更を加えられる環境を手に入れられます。そして課題を書き進めるわけです。わからないことがあったら、その部分のコードをハイライトします。すると手を上げたアイコンが出現するのでそれを押すと、質問が出来るようになります。

f:id:ymotongpoo:20191219140615p:plain

remixしたプロジェクトで質問がでると、remix元のプロジェクトオーナー(つまりチューター)のトップ画面に「質問が来ていますよ」というメッセージが出てきます。("Help Others, Get Thanks→" の部分。ここでは "Test question: bra bra bra" という質問メッセージが来ています。)

f:id:ymotongpoo:20191219140344p:plain

もちろん普通に手を上げてもらっても良いのですが、こういう形で質問をしてもらうことで、チューターがスクリーンなどにこのトップ画面を映していると質問内容がわかりつつ、これからその部分に取り組む人も事前に注意が出来るというわけです。

実際に試した

この方法は今年の11月5日に行われたVelocity Berlin 2019のワークショップで実際に試してみました。チューターや参加者のパソコンのOSがWindowsLinuxmacOSChrome OSなど様々に分かれていたわけですが、OS特有のエラーなどにはまることがまったくありませんでした。

参加者が書いた結果のコードもプロジェクトの形で残るので、何か面白いことを取り組んだ参加者がいれば、そのプロジェクトURLを共有してもらうだけで手元でコードを見て、実行するところまでできるのも便利でした。

またハンズオンの内容もHTTPサーバーを立ててリクエストを受け取ったり投げたりするようなプログラムを書いたわけですが、ポート番号3000番で指定すればパブリックにサーバーを公開できるので、参加者同士で通信しあうような課題もできそうだったのが魅力的でした。

f:id:ymotongpoo:20191219142722p:plain
実際にGoでHTTPサーバーを立ててHello worldをしている様子

コンソールにアクセスしてコマンドを実行できるため、CLIを作るような課題もある程度可能です。

f:id:ymotongpoo:20191219142925p:plain

おわりに

コードラボやハンズオンは実際に手を動かすため短い時間で効率よく学習することが可能です。ぜひこの方法をいろいろな場所で試してもらって、Goのハンズオンとしてもっと便利な使い方を共有してもらえたらなと思います。

明日は最終日。担当は @tenntenn さんです。

OpenCensusでStackdriver Monitoringにメトリクスを送信する

はじめに

こんにちは、Stackdriver担当者です。先日Raspberry Pi Zero WにつけたBMP680から得たデータをStackdriver Monitoring API v3を使って送信してダッシュボードを作るという記事を書来ました。

ymotongpoo.hatenablog.com

しかしBMP680のデータを眺めていると結構精度が怪しいのでキャリブレーション用のデータが必要だなと思い、外気温のデータもStackdriver Monitoringに送ることにしました。それにOpenCensusを使ったのですが、GCPUG SlackでStackdriverに送るまとめをOpenCensus meetup vol.1までに公開すると言っていたのを忘れてたので慌てて記事にしました。

TL;DR

OpenWeatherMapとから外気の気温、湿度、大気圧、その他諸々のデータを取得し、OpenCensusを使ってStackdriver Monitoringにデータを送った。

OpenCensus Stats/Metrics

OpenCensusには大きく分けてTraceとStats/Metricsという2つの機能があり、それぞれStackdriver TraceとStackdriver Monitoringに対応したデータを送信できます。

OpenCensus Stats Exporter for Go

今回は OpenCensus Stats/Metrics の Stackdriver Exporter を使って Stackdriver Monitoring に送信するわけですが、Stackdriver Monitoring API v3を使っている用語と OpenCensus で使っている用語が違うので、公式ドキュメントにもある対応表をまず理解したほうが、いろいろなドキュメントが読みやすいです。

OpenCensus Stackdriver Monitoring 補足
Exporter N/A OpenCensusとモニタリングバックエンドをつなぐためのインターフェース
View MetricsDescriptor 記録するメトリクスのメタ情報
Measure MetricKind メトリクスのデータ型の定義
Aggregation ValueType メトリクス送信時の時系列データとしての扱い
Measurement Point ある一時点でのメトリクスの記録
View Data TimeSeries メトリクスの実データを送信前にバッファしておく入れ物
Tag LabelDescriptor ViewっやMeasureに対するラベル(OpenCensusではResourceとMetricに区別したラベルを付けない)

その上でサンプルコードを読むと大まかな雰囲気がわかります。

サンプルコードはPrometheus用のコードになっているので Exporter の部分だけ、Stackdriver Monitoring 用に置き換えてやる必要があります。といっても必要なのは Exporter の初期化の部分だけです。

type GenericNodeMonitoredResource struct {
    Location    string
    NamespaceId string
    NodeId      string
}

func NewGenericNodeMonitoredResource(location, namespace, node string) *GenericNodeMonitoredResource {
    return &GenericNodeMonitoredResource{
        Location:    location,
        NamespaceId: namespace,
        NodeId:      node,
    }
}

func (mr *GenericNodeMonitoredResource) MonitoredResource() (string, map[string]string) {
    labels := map[string]string{
        "location":  mr.Location,
        "namespace": mr.NamespaceId,
        "node_id":   mr.NodeId,
    }
    return "generic_node", labels
}

func GetMetricType(v *view.View) string {
    return fmt.Sprintf("custom.googleapis.com/%s", v.Name)
}

func InitExporter() *stackdriver.Exporter {
    mr := NewGenericNodeMonitoredResource(ResourceLocation, ResourceNamespace, "public-data")
    labels := &stackdriver.Labels{}
    exporter, err := stackdriver.NewExporter(stackdriver.Options{
        ProjectID:               os.Getenv("GOOGLE_CLOUD_PROJECT"),
        Location:                ResourceLocation,
        MonitoredResource:       mr,
        DefaultMonitoringLabels: labels,
        GetMetricType:           GetMetricType,
    })
    if err != nil {
        log.Fatal("failed to initialize ")
    }
    return exporter
}

Exporterの設定ではStackdriver特有の設定を行うところがポイントで、このGoDocをとりあえずガン見することになると思います。

godoc.org

ここのOptions構造体のドキュメントをよく読んでおけば設定ではまることはあまりないはずです。あるとすれば、 MonitoredResourceDefaultMonitoringLabels あたり。基本的にStackdriver Monitoring側で事前定義されているようなラベルは MonitoredResource で作るわけですが、ちょっとはまりどころとして、これが monitoredresource.Interface 型であるということ。Stackdriver用の事前定義の設定ではGKE、GCE、EC2ぐらいしか使わない前提で構造体がほとんど作られていないので(パッケージ参照)、その他のリソースに関しては上のように構造体を自前実装する必要があります。

DefaultMonitoringLabels はそれ以外で固定でつけるようなラベルを入れておくと良いです。ドキュメントにもありますが、ここを設定しないとデフォルトは opencensus_task というラベルで値にプロセス名が入ったものが勝手に送られてしまうので、そうしたくない場合は空の *stackdriver.Labels を設定すれば大丈夫です。

OpenCensus Stats

Exporterの設定は上記ぐらいなので、次にメトリクスを取得する部分であるViewの設定です。これはベストプラクティスとして、View、Measure、Key、といったものはすべてパッケージグローバルで設定しておくというのがあります。

const (
    // OCReportInterval is the interval for OpenCensus to send stats data to
    // Stackdriver Monitoring via its exporter.
    // NOTE: this value should not be no less than 1 minute. Detailes are in the doc.
    // https://cloud.google.com/monitoring/custom-metrics/creating-metrics#writing-ts
    OCReportInterval = 60 * time.Second

    // Measure namess for respecitive OpenCensus Measure
    MeasureTemperature = "temperature"
    MeasurePressure    = "pressure"
    MeasureHumidity    = "humidity"
    MeasureWindSpeed   = "windspeed"
    MeasureWindDeg     = "winddeg"

    // Units are used to define Measures of OpenCensus.
    TemperatureUnit = "C"
    PressureUnit    = "hPa"
    HumidityUnit    = "%"
    WindSpeedUnit   = "mps"
    WindDegUnit     = "degree"

    // ResouceNamespace is used for the exporter to have resource labels.
    ResourceNamespace = "ymotongpoo"
)

var (
    // Measure variables
    MTemperature = stats.Float64(MeasureTemperature, "air temperature", TemperatureUnit)
    MPressure    = stats.Float64(MeasurePressure, "barometric pressure", PressureUnit)
    MHumidity    = stats.Int64(MeasureHumidity, "air humidity", HumidityUnit)
    MWindSpeed   = stats.Float64(MeasureWindSpeed, "wind speed", WindSpeedUnit)
    MWindDeg     = stats.Float64(MeasureWindDeg, "wind degree from North", WindDegUnit)

    TemperatureView = &view.View{
        Name:        MeasureTemperature,
        Measure:     MTemperature,
        TagKeys:     []tag.Key{KeySource},
        Description: "air temperature",
        Aggregation: view.LastValue(),
    }

    PressureView = &view.View{
        Name:        MeasurePressure,
        Measure:     MPressure,
        TagKeys:     []tag.Key{KeySource},
        Description: "barometric pressure",
        Aggregation: view.LastValue(),
    }

    HumidityView = &view.View{
        Name:        MeasureHumidity,
        Measure:     MHumidity,
        TagKeys:     []tag.Key{KeySource},
        Description: "air humidity",
        Aggregation: view.LastValue(),
    }

    WindSpeedView = &view.View{
        Name:        MeasureWindSpeed,
        Measure:     MWindSpeed,
        TagKeys:     []tag.Key{KeySource},
        Description: "wind speed",
        Aggregation: view.LastValue(),
    }

    WeatherReportViews = []*view.View{
        TemperatureView,
        PressureView,
        HumidityView,
        WindSpeedView,
    }

    // KeySource is the key for label in "generic_node",
    KeySource, _ = tag.NewKey("source")
)

パッケージグローバルにとどまらず、そもそもこうした変数や定数だけを持ったパッケージを公開するのも良いでしょう。実際、アプリケーション内の複数のマイクロサービスで共通で使われるようなメトリクスなどは、そうしておくことでViewの初期化が簡単になります。たとえば gRPC 用の事前定義パッケージでは、よく使われるViewが事前定義されています。

これらと先程作成したExporterを使ってViewを初期化する手続きはたったこれだけです。

func InitOpenCensusStats(exporter *stackdriver.Exporter) {
    view.SetReportingPeriod(OCReportInterval)
    view.RegisterExporter(exporter)
    view.Register(WeatherReportViews...)
}

ここで一つだけ注意したいのがViewがStackdriverにレポートを送る間隔の設定(SetReportPeriod)です。これはOpenCensusのドキュメントやStackdriver MonitoringのAPIドキュメントにもあるように、1分以下に設定してしまうと「間隔が短い」と怒られます。

Note: each exporter makes different promises about what the lowest supported duration is. For example, the Stackdriver exporter recommends a value no lower than 1 minute. Consult each exporter per your needs.

Don't make the calls faster than one time per minute.

値を記録する

あとは値を記録するだけです。 stats.Record で Measurement を記録するだけです。記録さえしておけば、Viewがよしなに設定した間隔でStackdriver Monitoringにデータを送ってくれます。

func RecordMeasurement(id string, w *Weather) error {
    ctx, err := tag.New(context.Background(), tag.Upsert(KeySource, id))
    if err != nil {
        logger.Errorf("failed to insert key: %v", err)
        return err
    }

    stats.Record(ctx,
        MTemperature.M(w.Temperature),
        MPressure.M(w.Pressure),
        MHumidity.M(int64(w.Humidity)),
        MWindSpeed.M(w.WindSpeed),
    )
    return nil
}

コードも貼ったので長く見えますが、たったこれだけの手順で簡単にStackdriver MonitoringにOpenCensusを使ってデータを送れるようになります。

参照

OpenCensus + Stackdriver Monitoring

OpenWeatherMap API

Dark Sky API