おそらくはそれさえも平凡な日々

えるしっているか、Lは便利

Perl Advent calendar 2017の6日目です。

LというCPANモジュールがありまして、これはPerlのコマンドラインワンライナーを書きやすくするためのモジュールです。

% perl -MString::Random -E 'say String::Random->new->randregex("[0-9a-z-A-Z]{12}")'

あるモジュールを読み込んでクラスメソッドを呼び出したい時には、上記のように、モジュール名を2回打ち込まないといけないのが難点ですが、それを以下のように書くことができる。

% perl -ML -E 'say "String::Random"->new->randregex("[0-9a-zA-Z]{12}")'

便利。

このモジュールは僕が5年前にCPANに上げたモジュールなのですが、Perl 5.27.1で失敗するテストケースがあり、今年それを直すとともに、バージョンv1.0.0としてリリースしました。

それが補足されて、少し話題になったようで、今年のYAPC::Fukuokaの際に、 @nqounet さんに「最近Lを知ったんですけど、すごく便利ですね」と言っていただきました。ということで、ここで改めて紹介してみることにしました。

L というトップレベルかつ1文字モジュールということで、2012年当時、これをCPANに上げるかどうかはかなり躊躇したのですが、周りのPerl Hackerの皆様に後押ししてもらって上げました。

反響を心配していたのですが、好意的なレビューもついて、一安心したことを覚えています。思い出深いモジュールです。

go-memcached-toolを書いていた

はてなエンジニア Advent Calendar 2017の3日目です。 昨日は、id:y_uuki さんによるウェブシステムの運用自律化に向けた構想 - 第3回ウェブサイエンス研究会でした。

さて、memcachedにはmemcached-toolというツールが同梱されています。

https://github.com/memcached/memcached/blob/master/scripts/memcached-tool

これを、今回(実は大分前に書いていたのですが)、Goに移植しました。

https://github.com/Songmu/go-memcached-tool

以下のようにインストール可能ですが、GitHub Releasesにも置いてあります

% go get github.com/Songmu/go-memcached-tool/cmd/go-memcached-tool

memcached-toolは、memcachedの作者であり、今はGoのメインコミッターの一人であるBradfitz氏の書いたPerlスクリプトなのですが、これを僕がGoに移植したというのがちょっと面白ポイントです。

memcached-toolにはいくつか機能がありますが、今回は、よく使われる、memcachedのslabの状態とかをいい感じに表示してくれる display モードと、memcachedのデータをダンプする dump モードを移植しました。

memcachedのslabとは何ぞやとか、memcached-toolの使い方は以下の記事によくまとまっています。

参考: 第2回 memcachedのメモリストレージを理解する:memcachedを知り尽くす

memcached-toolと同様ではありますが、軽く使い方の説明を。

displayモード

memcachedの各slabの状態を良い感じに表示してくれます。

% go-memcached-tool 127.0.0.1:11211
  #  Item_Size  Max_age   Pages   Count   Full?  Evicted Evict_Time OOM
  1      96B  17550349s       1   10922     yes  1121115        0    0
  2     120B  17529950s       3   26214     yes  2693486        0    0
  3     152B  17656340s       2   13793     yes  1262170        0    0
  4     192B  17652024s       2   10917     yes   139583        0    0
  ...

dumpモード

memcachedの内容を書き出すことができます。

% go-memcached-tool 127.0.0.1:11211 dump > memd.dump

以下のようにすれば、あるサーバーのキャッシュをゴソッと別サーバーに移すことができるのが面白いですね。実際にはこんな乱暴なことはしないとは思いますが。

% go-memcached-tool 127.0.0.1:11211 dump | nc cache2.example.com:11211

その他のモード(stats/settings)について

オリジナルのmemcached-toolには statssettings と言ったモードがありますがこの辺は以下のようにすれば同じようなことができるので特に移植予定はありません。

% echo "stats"          | nc 127.0.0.1 11211
% echo "stats settings" | nc 127.0.0.1 11211

ライブラリとして使う

以下のようにライブラリとしても利用可能です。Goでmemcachedをモニタリングするようなツールを書く際に有用でしょう。

import (
    "net"
    "github.com/Songmu/go-memcached-tool"
)
func main() {
    conn, _ := net.Dial("tcp", "127.0.0.1:11211")
    items, err := memdtool.GetSlabStats(conn)
    ...
}

コマンドラインツールを書く時に、その言語のライブラリとしても再利用可能にすることは個人的に心がけている点です。実は、このgo-memcached-tool自体、mackerel-plugin-memcachedの拡張用のライブラリとして利用するために書き始めた背景があります。

ぜひご利用ください。

ISUCON7本戦、3位でした

運営の皆様お疲れ様でした。今年も本当に楽しませてもらいました。

最終スコアは27,816。2位と僅差(301点差)の3位でした。惜しかったとも言えるけど、1位は6万点超えなので惨敗です。

問題

WebSocketを使った複数人同時接続のクッキークリッカー的なゲームでした。ゲームとWebSocketは来そうな予感はしていました。ちなみに、チームメンバーは誰もクッキークリッカーやってなかった。

最初にレギューレーションを読みながら以下のような会話と作戦立てをした。

シャーディング

とりあえず、シャーディングしましょう、ということで手を動かし始める。言語はGoを選択。

僕は、サーバー割り当てのロジックを書くことにした。とりあえず状態持つのもメンドイので最初は雑にルーム名のハッシュ値で分散させることに。すぐ終わる予定だったが、ハッシュ値から数値を取り出すのに案外苦労した。

その間にmotemenは、 m_items (マスタデータ)を変数化したりしていた。それが素早く終わっていたので、僕がやる予定だった初期化の広報処理(どこかアクセスが来たら、全台の initialize 処理を叩きに行く)のをお願いした。それも一瞬で実装してて流石だった。

masayoshiにはサーバーの設定とか、deployの設定とか、サーバー全台にRedisを立てたりしてもらっていた。

12時くらいにお弁当が配られ始める頃にちょうどこのあたりの実装が終わった。一足飛びにRedis実装まで行くか、みたいな機運もあったんだけど、お弁当を食べながら一旦、全台にMySQL置いてベンチ回してみるか、という話になった。

その辺の設定をを終えて、飯を食いながらベンチを回す。この辺で、スコアは初期実装の5500程度から15000位まで伸び、暫定トップになったんだけど、このへんで暫定トップになるの過去を鑑みても死亡フラグな気がする。

プロファイル

これで各サーバーがシャードできたのでRedis化していくぞ!ってなりかけたんだけど、ここでmasayoshiが「でも、ベンチ中にGoのアプリが150%以上使ってて、MySQLは20%しか使ってませんよ」と指摘。言われてみれば確かにその通り。

先にやるべきはアプリの改善ということで、motemenがプロファイルを取ってくれた。pprofgo-torch を使って、flame graphを出力。

めっちゃ便利で、とにかく、どこがボトルネックか一目瞭然である。 calcStatus が全体の処理の67%を占めており圧倒的に支配的なのであった。Redis化とかオンメモリ以前にここをなんとかしないといけないぞ、となる。

この時点で開始3時間経過した13:20。暫定トップでボトルネックも見えていて、今から思うと、この時点では、かなり良い戦いをしていたように思う。

スコア26という大火傷

プロファイルを読み解いて、ボトルネックになってそうな big2exp の改善にmotemenが取り組んだ。しかし、修正を入れたら大幅にスコアが急落し、脅威のスコア26を叩き出す。暫定トップからの急落である。あとから聞いたら、これだけのスコアで逆にfailしなかったことは、運営部屋でも話題になっていたらしい。

初手でこのような手痛い火傷を負ってしまい、我々は「calcStatus怖い」となってしまった。なので、この関数自体の最適化に取り組むよりかは、キャッシュしたり呼び出しを減らす方向に方針転換した。

今から思うと、これは大きな誤りだった。ユニットテストが設けられていることから分かるように、明らかに calcStatus は最適化ポイントなのだから、もうちょっと真剣に読み解く時間を作るべきだったと競技後にチームで反省した。

細かいチューニング

てことで、以下のようなちまちました改善をおこなった。テストしやすさのためにサーバーは1台に固定しつつ開発。

あまり目覚ましいスコア改善は見られず、この時点で、一台辺りのスコアは7000強。終了時間が近づいてきていて、終戦ムードに。

シャーディングの改善とGCチューニング

ハッシュでシャードするのがどうも安定しないので、素朴にラウンドロビンで分ける実装を作ることに。その間、motemenとmasayoshiは、GCのパラメーターや getStatus の間隔調整して悪あがきをしていた。そう、先のflame graphを見ると、GCが結構ボトルネックになってはいそうではあったので、ここを調整しにいくのは悪あがきとしては悪くなかったのかも知れない。

ラウンドロビンにしたらスコアは大分安定するようになった。ルーム名とサーバーの対応を記録するためにRedisを用いた。今回結局ここでしかRedisは使わなかった。

パラメーターチューニングは案外効果を発揮し、この時点で4台構成にして何度かベンチを回しても、failもせずスコアは2万は割らなくなって安定するようになった。

終戦

ここで残り30分くらいだったので、残念だけど、ここらあたりが潮時かなーというところで、各種ログなどを切るなどの小細工をして、再起動試験。再起動後、無事にアプリケーションも動いたので、後は何度かベンチを回して、27,816が出たところで打ち止めとした。

あとは、 calcStatus の実装読みながら「ここの1000回ループとかいかにも修正できそうだよなぁ」とかそういう話をしたりするなどしていた。

結果としては3位で、途中経過からすると良い順位を取れたなぁという感じではあるけど、これで満足してはいかんな、という気持ちです。

感想

運営の皆様、本当にお疲れ様でした。予選・本戦ともに過去最大級のボリュームだったと思うのですごいと思います。レギュレーションもしっかり書かれていて見事でした。本戦に関しては、結局単に一台あたりの処理能力を上げる戦いになってるなーとか思ったりはしましたが、それをうちのチーム含め、殆どの参加者はチューニングしきれなかったわけで、そういう問題だったんだなと思ってます。

来年の開催を願っています。はてなメンバーだけで優勝したい。

ちなみに、去年は予選・本戦で別の会社が作問しており、予選がはてなで決勝がpixivでした。僕は予選側の作問担当だったのですが、その形式はお互いの負担軽減以上の効果があったように感じています。お互いの問題を知らない状態で、相互にリハーサルができたことが一番良かったことで、それが難易度調整だったり、作問者が気付かない盲点の発見になりました。来年は可能であれば、その形式に戻した方が良いんじゃないかと思っています。

あらゆる日付文字列をよしなに扱うgo-httpdate を書いた

https://github.com/Songmu/go-httpdate

Perl界には HTTP::Date という便利モジュールがあります。これは、あらゆる日付文字列を特にフォーマットの指定無しによしなにパースしてくれるもので、クイックハックに非常に有用です。ISUCONでは毎回使っている気がします。

このモジュールは異常な正規表現によって成り立っています。おそらく元々はその名の通り、単にHTTPのための日付フォーマットを扱うモジュールだったのでしょうが、徐々に拡張が継ぎ足されてこのようなモジュールになったのだと想像されます。

で、これをGoに移植しました。以下のように使います。

import "github.com/Songmu/go-httpdate"

t1, _ := httpdate.Str2Time("2017-11-11", nil)
t2, _ := httpdate.Str2Time("Thu, 03 Feb 1994 12:33:44 GMT", nil)
t3, _ := httpdate.Str2Time("Thu Nov  9 18:20:31 GMT 2017", nil)
t4, _ := httpdate.Str2Time("08-Feb-94 14:15:29 GMT", nil)

文字列をよしなにパースして、 time.Time を返してくれます。非常に便利。対応フォーマットは以下のようになっています。HTTP::Dateがそうなっているので仕方ないのですが、もはやHTTPとは何なのか…、という気持ちになりますね。

ソースコードをご覧いただければわかりますが、愚直に移植したので、正規表現祭りとなっておりますので、パフォーマンスはお察しください。ちなみに計測すらしていません。とは言え実用上そこまで困ることもないはずです。

移植をしていて、Goは時刻を正確に扱うパッケージが標準ライブラリに存在するのは地味に大きな優位性だなーとか思ったりしました。

結構便利だと思うのでお試しください。

GoでSingletonぽいことを実現する、とある方法

ちなみに今回のコードはそれほど実用性はありません。ここまで頑張って、シングルトンぽいことを実現する必要性は感じられないからです。サンプルコードはこちら。

https://www.github.com/Songmu/go-sandbox/

Goでシングルトンを実現する方法として以下の様なコードが良く見られます。

package singleton

import "sync"

type singleton struct{
}

var (
    instance *singleton
    once     sync.Once
)

func GetInstance() *singleton{
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

このコードのグッドポイントとしては、 sync.Once を使っていること。以下のように素朴に nil チェックをする形だと、マルチスレッドで競合が発生するのでアウトです。

if instance == nil {
    instance = &singleton{}
}

とは言え、初めて評価されるときに代入されることにこだわりが無いのであれば、わざわざ sync.Once を使わず、トップレベルで初期化時に代入してしまって良いとは思います。

var instance = &singleton{}

さて、これで、シングルトンは実現できるのですが、個人的に気になっていることがありました。それは、 golint で怒られるということです。

% golint .
singleton/singleton.go:14:20: exported func GetInstance returns unexported type *singleton.singleton, which can be annoying to use

つまり「GetInstance というパブリックな関数が、 *singleton.singleton というプライベートな型を返すのは紛らわしい」ということです。実際、 *singleton.singleton にパブリックなメソッドが生えていれば、それを呼び出すことはできるのですが、それは、godocなどで抽出されないので困りものです。

dummyメソッドを持ったinterfaceを使うという解法

それをdummyメソッドを持ったinterfaceを使う手でこれを解決してみました。dummyメソッドを持ったinterfaceと言うのは、go/ast パッケージなどで見られますが、パブリックなinterfaceの中に、プライベートなメソッドを埋め込むことで、そのinterface自体はパブリックですが、外のパッケージではそのinterfaceを満たす変数を作れなくするというものです。

go/ast パッケージでの様子などは以下の @haya14busa さんの記事に詳しいです。

Sum/Union/Variant Type in Go and Static Check Tool of switch-case handling

今回のシングルトンの場合、パッケージ自体はこのようになります。

package singleton

import (
    "fmt"
    "strings"
    "sync"
    "sync/atomic"
)

// Deeeeter implements Deeeet() method
type Deeeeter interface {
    Deeeet()
    getAge() // as a dmmuy method
}

type deeeet struct {
    age int64 // accessed atomically
}

var (
    d Deeeeter
    o sync.Once
)

// GetDeeeter gets the Deeeeter
func GetDeeeter() Deeeeter {
    o.Do(func() {
        d = &deeeet{}
    })
    return d
}

// Deeeet desu...
func (de *deeeet) Deeeet() {
    age := int(atomic.AddInt64(&de.age, 1))
    fmt.Printf("d%stです…\n", strings.Repeat("e", age))
}

func (de *deeeet) getAge() {
    fmt.Println(de.age)
}

これを以下のようなコードで実行してみましょう。

package main

import (
    "fmt"

    "./singleton"
)

func main() {
    deeeet := singleton.GetDeeeter()

    deeeet.Deeeet()
    deeeet.Deeeet()
    singleton.GetDeeeter().Deeeet()
    deeeet.Deeeet()
}

これを実行してみると以下のようになります。

% go run singleton.go
detです…
deetです…
deeetです…
deeeetです…

ちゃんと GetDeeeeter が同じ変数を返しており、シングルトンの様な挙動が実現されています。Deeeeter 自体はパブリックなinterfaceですが、それを満たす変数をパッケージ外から作ることができないため、この deeeet は唯一無二の存在となります。

https://godoc.org/github.com/Songmu/go-sandbox/singleton にもちゃんとドキュメントが生成されており、ニッコリ。

本当に、パッケージ外から Deeeeter を作ることはできないのか?

それを試すために、 https://github.com/Songmu/go-sandbox/blob/master/singleton.go の末尾に以下のようなコードがあります。Deeeet メソッドと、 getAge メソッドを実装してそれっぽくなっています。

type imitateDeeeet struct {
}

func (ide *imitateDeeeet) Deeeet() {
    fmt.Println("deeeet(偽)です…")
}

func (ide *imitateDeeeet) getAge() {
    fmt.Println("17歳です♥")
}

// var _ singleton.Deeeeter = &imitateDeeeet{}

末尾の代入がコメントアウトされていますが、ここをアンコメントして imitateDeeeetDeeeeter を満たしているか確認してみましょう。すると以下のように怒られます。残念でした。

% go run singleton.go
# command-line-arguments
./singleton.go:29:5: cannot use imitateDeeeet literal (type *imitateDeeeet) as type singleton.Deeeeter in assignment:
        *imitateDeeeet does not implement singleton.Deeeeter (missing singleton.getAge method)
                have getAge()
                want singleton.getAge()

この _ に代入するというテクは、あるtypeが狙ったinterfaceを満たすかどうかを担保するために実コードでも使われるパターンなので、覚えておくと良いでしょう。

追記: @shogo82148 さんに、 de.age の保護が甘いと指摘を受けたので、エントリ内のサンプルコードを直しました。合わせて、GitHub上のサンプルコードも修正しました。

ISUCON7の予選をはてなメンバーで通過してきました

ISUCONは過去3回優勝させてもらっているわけですが、はてなのメンバーだけで優勝したい気持ちがあります。前回はてなのメンバーだけで出たISUCON4の時は本戦は出られたものの惨敗。今回はそのリベンジも兼ねて挑みました。

チーム名は、id:Songmu(ソンムー)・id:motemen(モテメン)・id:masayoshi(マサヨシ)の3人で出たので、「ソン・モテメン・マサヨシ」。役割分担は、僕が一応リーダーで、motemenがアプリケーションメイン、masayoshiがインフラメイン。

予選は、リモート体制で、はてなの東京オフィスと京都オフィスの会議室を繋いで参加しました。僕だけが東京オフィス。

予選参加は土曜参加にした。土曜参加のほうが、翌日の日曜日休めるし、その日はISUCONのことを気にしないで過ごせるので例年そうしている。また、日曜のほうが強豪が集まる気配がしたので、土曜の方が3位以内通過を決めてしまうのも楽かもしれないという打算もあった。

言語はGoで行くことに事前に決めていた。Perlも捨てがたかったのだが、問題の特性上Perlだと辛いこともあるだろうということでGoにした。ただ、Perlの方が明らかにスラスラ書けるし、GoでそこまでゴリゴリWebアプリケーションの表側を書いたこともないので不安もあった。

競技開始直後

事前のレギュレーションに「複数台」という記述があって、こりゃ、予選で複数台来そうだなーと思ってた。しかし、予選で複数台構成でやるのは出題チームの負担がヤバイので本気なのかな、とは思ってた。しかし、予選開始時間が遅れたこともあって「ああこりゃ絶対複数台構成だわ」ってなった。果たして3台構成であった。

当日マニュアルを読むと、帯域について丁寧に説明があり、そのうえ静的ファイルの304についての得点も事細かに書いてあったので「こりゃ帯域ネックになる要素があるな」「304を適切に返させたいんだろうな」みたいなところは読み解けた。

それで、ベンチマーク画面を見ると、複数台に対してリクエスト飛ばせるようになってたので「ああ、これは、1台だけで配信しようとすると最終的に帯域足りなくなるやつで、最後は2台か3台で配信できるようにしないとダメだろうな」と予想。

複数台で静的ファイルを配信させつつ、適切に304を返したいとなると「ああ、これは『ハイパフォーマンスWebサイト』で読んだやつだ」ってピンときた。つまり、複数台でそれぞれちゃんと静的ファイルの更新日時を揃えておく必要があるし、ETagの生成ルールも合わせておく必要がある。

ハイパフォーマンスWebサイト

なんか「CDNがどうこう」という話がでてますが、個人的には、CDN経由かどうかみたいなところはあまり考えず、ただ単に、その辺りのセオリー通りに従って、そのための設定をおこなったというところだった。

初期準備と初手 13:00-15:00

masayoshiにインフラ設定してもらっている間にレギュレーションとアプリも読む。そうすると以下の辺りが見えてきた。

ベンチを回しても、大体その辺がネックになってるっぽかったので最初はそのあたりから手を付けることに。

icons画像はとりあえず、初期画像はDBから抜き出して、各サーバーに静的ファイルを配置してNginxから配信することに。後から追加で飛んでくる更新画像は各サーバーに配置するのは難しいので、WebDAVで配信するとか、Nginxで別サーバーにプロキシさせるとか、何らかのKVSに突っ込む形になるだろうなーとは思ったけど、それはそこがネックになってから考えようという話をした。

なので「これは一旦 try_files だなー」とか言ってたら、motemenが秒速でスクリプトを作ってくれてNginxに設定入れて、各サーバーにファイルを撒くところまでやってくれた。ここのスクリプトはPerlを使っていた。Perl実装で、kazeburoさんのDBIx::Sunnyが使われていたのでそれを使うことができて便利。

masayoshiがこの段階で、AppArmorによってMySQLが起動しなくなるというトラブルに見舞われていて、皆でそれぞれサーバー設定を触る流れになった。僕も make deploy で一撃でビルドしたアプリケーションを3台全部に撒いて起動できるようにしたりしていた。単に go build したやつを scp で撒いて、アプリケーションを再起動するだけの簡単なスクリプトです。全く、makeは最高だぜ。

この辺の、最初の読みは大体当たってたんだけど、この辺でわちゃわちゃと皆でサーバーを触っていたせいで混乱があった。特に、ここでiconsの画像のETagも揃えていると思ってたんだけど、実はそうなってなかった。これが後々足を引っ張ることになる。

/fetch の未読カウントの改善等 15:00-16:30

motemenがicons画像の対応やっている間に、 /fetch の未読カウントの改善方法を考えてたんだけど、 motemenがあっという間にiconsの対応終わらせてたので、ここも実装方針を話し合った後、motemenにお願いすることにした。方針としてはカウントテーブルを作る形。Redisを使うこともちょっと頭によぎったけどここでは温存することにした。

この実装を入れてベンチを回すと、35,000点くらい。なんか思ったよりスコアが伸びないし、Nginxで返しているはずのiconsでタイムアウトが出まくっている。つまり、帯域があたっている状況で何かがおかしい。

帯域の調整 16:30-18:30

iftopで見ても帯域上限当たってるし、304も全然返せてないし何かがおかしい。アプリケーション側でもgzip入れてみたり、Nginxのcacheやgzipの設定を見直して、2台配信にしたり、3台配信にしたりして試行錯誤しても、5万点どまり。この辺で、 COUNT(*) はコードから撲滅はさせた。

チームも混乱状態で、他のメンバーも帯域ネックなのにワーカー数とかコネクション数とか調整し始めてたので、ちょっとマズイな、と思い「とにかく帯域をなんとかしよう。304の割合を増やさないとどうにもならないぞ」とメンバーを落ち着かせた。

これは何かがおかしいぞ、ということで、icons画像を各サーバーにcurlでアクセスして目視で見比べてみると、果たしてETagがずれていたのであった。

「ちょっと!ETagずれてんじゃん!」ということになり、以下のようにして各サーバーの静的ファイルの更新時刻を揃えた。

find /home/isucon/isubata/webapp/public -type f -exec touch -t 10200000 {} \;

これで、Last-Modifiedも揃うし、Nginxはデフォルトではファイルの更新時刻とファイルサイズを元にETagを生成するのでずれなくなった。(昔のApacheはinodeを元に生成したりしていましたね)

この対策でスコアは12万点まで上昇。しかし折り返し(17時)くらいまでにここまでくるイメージだったので、このタイムロスはかなり痛い。残り2時間ちょい。

この辺は、ちゃんとイメージを揃えていたつもりだったんだけど、リモートでやっていた弊害がでた所であった。

DBネック解消 18:30-19:45

この辺でやっと、帯域ネックだったところからボトルネックが移り、MySQLネックになった。ベンチかけている間にtopを眺めていると、MySQLが一番CPUを食っている状況。ここで打った手は以下。

スロークエリにはプロフィール画像の更新クエリが溜まっている状況だった。更新画像投稿はまだMySQLに行っている状況だったので、ここをなんとかしないといけない。競技当初からそこがそのうちネックになるだろうなと思っていたのでやっとそこまでたどり着いた感があった。

WebDAVとか使うのが正攻法なんだろうけど、使ったことが無いし残り時間も少なかったので、ここは雑にRedisに突っ込むことにした。ここのコードが僕が今回一番バリューを出したところだと思う。Redisはmasayoshiが2台目のサーバーに用意してくれていたのでそれを使うことに。これはナイスプレイであった。

このあたりの改善を入れたら、スコアも18万まで上昇。また、MySQLのCPU利用率も40%程度までに落ち着いた。

最終調整と再起動試験 19:45-20:40

このあたりでまた帯域が当たり始めた。この時点では、01と02の2台でしかリクエストを受けていなかったのだけど、帯域が足りない以上DBが同居している03でもリクエストを受けたほうが良いかもしれないと考え始める。MySQLは40%程度しかCPUを使ってないので、ギリギリNginxとAppの同居もいけるのではないかという判断をして、3台にベンチをかけてみることに。

すると20:27にベストスコアの246,625が出た。もう少し調整できそうだったが、もう時間が無いのでここでスコアを固めに行くことに。設定を見直して、再起動試験をかけていった。最終的な構成は以下。

再起動後のアプリケーションの動作確認後、ベストスコアを狙って、何度かベンチをかけてみるがあまり良い点数が出ない。20:54に221,823が出たのでそれで妥協して打ち止めとした。

感想戦とか

競技が終わるまではメンバーと以下の様な話をしていた。

しかし、出題内容は大体読み切っていたのに、やりたいことができなかったことに相当悔いが残った。当初のイメージではこの辺りまでは上位陣は確実にやってくるだろうな、と予想をしていた。そこから pprof とか回してアプリケーション自体の改善に手を入れてスコアを伸ばさないと、予選通過は厳しいと思っていた。

予選通過 :tada:

なんとか予選通過はできたものの、最終結果を見ると、やはり予想通りで20万点強がボーダーラインであった。危うかった。

とは言え予選通過は嬉しい。これで、ISUCONは7大会連続で本戦会場に行けることになる(出題含む)。多分もうISUCON本戦皆勤なのは、941さんを除くと、fujiwaraさんと僕くらいしかいないのではないか。

本戦はチームメンバーがちゃんと3人顔を合わせて作業ができるのでもっとパフォーマンスが上がるはずである。レギュレーションの読み合わせや認識合わせをしっかりして本戦に臨もうと思う。

運営の皆様へ

予選からフルスペックのISUCON問題を出してくる辺りおみそれいたしました。「本物のISUCON」を予選から参加者に味わってもらえるようにするという気概を感じました。問題内容も素晴らしく、しかも競技環境自体もベンチマーカー含めてストレス無く快適でした。

本戦が楽しみです。

Goツールのビルドをおこない、GitHub Releasesにアップロードする

今回で最終回。Goのツールをビルドして成果物を生成し、それをGitHub Releasesにアップロードする。

ビルドする

CGOを使っていない限り、最近のGoは簡単にマルチプラットフォーム対応の実行バイナリをそれぞれビルドすることができる。

僕の場合、OSはlinuxとdarwin(Mac)用は必ず作っている。windows用も余力があれば作るようにはしている。アーキテクチャーはamd64(64bit)決め打ちにしてしまうことも多い。

また、成果物を配布する場合は、実行バイナリの他にLICENSEやREADMEなどを含め、zipやtar玉等に固めるのが定番。

これらのビルドとアーカイブに固める作業のために特別なツールは必要なく、シェルスクリプトでもいけるレベルだが、僕は goxc を使っている。

https://github.com/laher/goxc/

goxc は結構ゴツくてGitHub Releasesとかbintrayに上げる機能とかも持ってたりするんだけど、それの、成果物を生成する部分だけを利用しているという具合。

成果物のネーミングルールや、成果物に含めるファイルのルールを設定できるところが便利ではある。ただ、更新が滞っていることもあるし、そういう成果物生成だけに絞ったもう少しシンプルなツールが欲しいと思っている。

goxcの設定

goxc.goxc.json をリポジトリにおいておけば、 % goxc と打つだけで設定通りにビルドしてくれる。ただ、それを丁寧に書くのを以前はやってたんだけど、それらは大体コマンドライン引数で指定できるし、goxc のためにファイル増やすのも馬鹿らしいので、最近は、Makefileにcrossbuildっていうターゲットを作ってをそこにベタっと書くようにしている。

ghchのMakefile、より抜粋すると以下のような具合。

crossbuild: devel-deps
    goxc -pv=v$(shell gobump show -r) -build-ldflags=$(BUILD_LDFLAGS) \
      -d=./dist -arch=amd64 -os=linux,darwin,windows \
      -tasks=clean-destination,xc,archive,rmbin

これで、 make crossbuild を実行すれば、 dist/ 配下に成果物を配置してくれる。

$ tree dist/
dist/
└── v0.1.2
    ├── ghch_v0.1.2_darwin_amd64.zip
    ├── ghch_v0.1.2_linux_amd64.tar.gz
    └── ghch_v0.1.2_windows_amd64.zip

make crossbuild とすれば、手元でもCI環境であっても同様に成果物が作られるのがポイント。

CGOを使っている場合

CGOを使っている場合は、そのプラットフォーム用のCI/CD環境を用意するなどしてビルドする必要がある。Windowsの場合は、AppVeyorが定番です。Macの場合は、TravisのOS X環境を使うなどすれば良さそうですが、あまり知見はない。

mackerel-agentの場合、AppVeyorを利用してビルドの他にインストーラーの作成やインストーラーへの署名などもおこなっています。

それらの環境でビルドした成果物をアップロードする手順を作るのは少し複雑にはなってはしまう。

成果物をGitHub Releasesにアップロードする

成果物のアップロードには、 ghr を使っている。これはGitHub Releasesにアップロードすることに特化したシンプルで良いツールです。

参考: 高速に自作パッケージをGithubにリリースするghrというツールをつくった

環境変数 $GITHUB_TOKEN を設定した状態で、

% ghr v0.1.2 dist/v0.1.2

などとすれば、第1引数に指定したバージョンタグのGitHub Releasesに対して、第2引数に指定したディレクトリ以下の成果物を一括アップロードしてくれる。

クロスコンパイルして、アップロードするところまでは実際には以下のようなシェルスクリプトを用いている。

#!/bin/sh
set -e
make crossbuild
ver=v$(gobump show -r)
ghr $ver dist/$ver

これで、Goで書いたツールを、GitHub Releaseにアップロードできるようになりました。

手元ではなくTravis上でビルドとアップロードをおこなう

これまでは手元でビルドしてアップロードする方法を書きましたが、git tagが打たれたのを契機に、CI/CD環境でビルドやデリバリーをおこなうのも定番スタイルです。CI/CD環境であればクリーンな環境でビルドを作れるのもメリットです。反面複雑さが増す部分もあります。

とは言え、上に挙げたシェルスクリプトを単にTravisなどの環境で実行させればよいだけの話ではあります。その際 $GITHUB_TOKEN 環境変数をセットする必要はあります。

また、Travisで実施する場合、ビルドは make crossbuild でおこなうが、GitHub ReleasesへのアップロードはTravisの機能を使う手もあります。ghchではそのようにしています。

その場合、

% travis setup releases

とすれば、 .travis.yml を書き換えてくれるのでそれを調整する手順になります。調整した、 .travis.yml を抜粋すると以下のようになります。

before_deploy:
- make crossbuild
deploy:
  provider: releases
  skip_cleanup: true
  api_key:
    secure: ...
  file_glob: true
  file: "dist/**/*.{tar.gz,zip}"
  on:
    tags: true
    branch: master
    condition: "$TRAVIS_TAG =~ ^v[0-9].*$"

まあ、それなりに便利ではあるんですが、若干煩雑になる感はある。また、 travis setup の度に、GitHubの個人Tokenがぽこぽこ作られる仕組みになっていて、ツールを提供しているリポジトリ毎にtokenが作られてしまうのは気持ち悪いので、あまりやらないようにしている。

以上です。

Goツールのリリースエンジニアリング

前回: Goツールのリリースにおけるバージョニングについて

前回挙げた以下のリリース5段階の中で、バージョニングだけで1エントリになりましたが、今回は、2,3について。

  1. versionをbumpする
  2. CHANGELOGを更新する
  3. 1,2での変更をgitに反映してタグを打つ
  4. ビルドする
  5. ビルドをアップロードする

具体的には、リリースに纏わるファイル更新をgitに反映さえてタグを打つところまで。ビルドする直前までとも言えます。

CHANGELOG.mdを自動更新する

CHANGELOGは ghch で自動生成させている。規定の CHANGELOG.md をリポジトリに配置して、

% ghch -w -N $next_tag

とすれば、魔法のように CHANGELOG.md を更新してくれる。生成された CHANGELOG.md はこんな感じ。

https://github.com/Songmu/ghg/blob/master/CHANGELOG.md

CHANGELOG、あったほうが良いと思ってる派だけど、丁寧に書くのもメンドイし、とは言えコミットログをそのままCHANGELOGとするのも乱暴なので、 ghch を使ってpull requestの粒度でCHANGELOGとするのはまあ悪くないと思っている。

ghch についてはこちら。→ Gitのtagとpull requestのマージ履歴からChangelogを自動生成する ghch

gitの更新とそのためのリリーススクリプト

これで、 version.goCHANGELOG.md が更新されているので、これらはもちろんgit repositoryに反映させる必要がある。そしてタグを打つ。これでリリース一段落である。

前回のエントリで書いたバージョンの更新作業から、git repository反映までを一撃でやるシェルスクリプトが以下。

#!/bin/sh
set -e

echo current version: $(gobump show -r)
read -p "input next version: " next_version

gobump set $next_version -w
ghch -w -N v$next_version

git commit -am "Checking in changes prior to tagging of version v$next_version"
git tag v$next_version
git push && git push --tags

これを、 _tools/releng とかに配置 している。Goのプロジェクトでこういうツール類を置くディレクトリは、個人的にはこの _tools/ のようにアンダースコアで始まるディレクトリに配置して、Goのソースコードが入ってないことをわかりやすくしているけど、そこまでやっている人あまり見ないので、やらなくても良いのかもしれない。 scripts/ とかに配置しているのもまま見ます。

ちなみに、たまに聞かれますが、 relengRelease engineering の略らしいです。一昔前にPerlハッカーの人たちが、CPANモジュールを上げるときに「relengする」ってよく言ってたのに影響されてます。

また、 Makefilerelease みたいなターゲットを定義しておくと、 make release とかやれば、リリースが走ってくれるので便利。

release:
    _tools/releng

残りはビルド

今回で git tag するところまで終了しました。後は、打たれたタグに対して成果物のビルドをおこない、適切なアップロードをすることを残すのみである。待て、次回。

Goツールのリリースにおけるバージョニングについて

Goのツールをリリースする時、個人的には以下のような手順を踏んでいる。もちろんスクリプトで一撃でできるようにはしている。今回は1.の話。セマンティックバージョニングの話は出てきません。

  1. versionをbumpする
  2. CHANGELOGを更新する
  3. 1,2での変更をgitに反映してタグを打つ
  4. ビルドする
  5. ビルドをアップロードする

versionは -ldflags を使って動的に埋め込む方法があるが、最近は明示的にソースコードに書いた方が良いと思うようになってそうしている。

理由としては、ユーザーが go get/build で実行ファイルを取得した場合でもバージョンは表示されて欲しいというのが一つ。 -ldflags で実行ファイルに色々な値を埋めることはできますが、基本原則として、それらを埋めてない状態でもちゃんと実行ファイルが正常に動くようにすることを意識した方が良い。

もう一つの理由として、バージョン埋め込み手法として git describe --abbrev=0 --tags で最新のタグを取得する方法がよく取られるが、これだと「最新のタグ」なので、意図しないタグ(テスト用途で雑に付けたタグとか)で埋められてしまう可能性があるのも困る。

で、最近は実際にどうやっているかというと、 version.go というファイルを用意して、そこにバージョン関連の情報が記載されるようにしている。ghg だと以下のような具合。

package ghg

const version = "0.1.1"

var revision = "Devel"

version 定数の更新は、 gobump をリリーススクリプトの中で使っている。例えば、 0.2.0 に更新したい場合は以下のようなコマンドを実行すれば良い。そうすれば自動的に version.go ファイルが書き換えられる。

% gobump set 0.2.0 -w

gobump に関しては、作者であるmotemenの gobump で Go プロジェクトのバージョニングをおこなう を参照のこと。

revision 変数はビルド時点のgitのコミットハッシュを埋めるためのものだが、これをきちんと埋めたい場合は -ldflags で埋める形になる。具体的には以下のようになる。

% go build -ldflags="-X github.com/Songmu/ghg.revision=$(git rev-parse --short HEAD)" ./cmd/ghg

もちろん、これは make build で実行できるように、 Makefile に定義してある。

これで、最新の ghg のバージョンを表示させてみると、以下のようになる。

% ghg version
ghg version: 0.1.1 (rev: 63eb454)

go get で実行ファイルを取得したり、 -ldflags を指定せずに go build した場合は以下のように、"Devel" が表示される。程よい挙動といえるのではないでしょうか。

% ghg version
ghg version: 0.1.1 (rev: Devel)

versionをどのように表示させるのが良いのか

コマンドラインツール全般の話として、バージョンをどのように表示させるのが良いのか最近少し悩んでいる。

-v とか --version でバージョン番号出すのあんま良くないんじゃないかと思うことがある。特に -v--verbose と紛らわしいので絶対ダメ。

コマンドラインオプションはあくまで標準の振る舞いを調整するためのものであって、バージョン表示のように全然違う振る舞いをするものはオプションではなくて、サブコマンドでやるべきなのではないか。

確かに、 -h/--help 同様に慣習ではある。GNU Coding Standardのコマンドラインの項目にも以下のように書かれている。

All programs should support two standard options: ‘--version’ and ‘--help’.

ただ、最近のツールだと --version オプションが用意されているツールも減ってきているようにも感じる。

また、バージョン表記がサブコマンドであれば、オプションで挙動の調整もやりやすい。例えば、 --format オプションを与えれば、表示方法を切り替えられる、など。バージョン文字列は、人が読みやすい形、機械が読みやすい形、両方で提供できると嬉しいので、このような挙動は嬉しいはず。

% ore-tool version --format=json
{"version":"0.0.1"}

ただ、本当に単機能しか無いコマンドラインツールに、わざわざ version サブコマンドだけを追加するとなるとやり過ぎ感もありますね。

ちなみに、最近個人で作るツールだと --version オプションも version サブコマンドどちらも用意せず、 --help で表示される内容の中に、バージョン情報を記載するに留めることもあります。

結論として、 version をサブコマンドで用意するのがやはり良いのではないかという気持ちなのが最近です。 dep コマンドとかもそうなってますね。

今回はバージョンの話でしたが、Goツールのリリースに関するその他徒然について気が向けば続きを書きます。

GitHub Releases用インストーラーghgのhomebrew tapを作った

Goで作ったツールは、GitHub Releasesに上げるのがよく行われますが、それらを統一的にインストールする方法が無いのが困りものでした。

そこで開発されたのが、ghgですが、ではそのghgを最初にどうやってインストールするのか、という問題がありました。

ということで、Macのみではありますが、homebrew用のtapを作りました。これで以下のようにghgがインストール可能になりました。

% brew install Songmu/tap/ghg

tapリポジトリは以下のように適当に作った。なんかhomwbrew公式だと、最近はbottleっていうフォーマットがあるみたいなんだけど、それをどう作るかよくわからなかったので、アーカイブURLを指定して配置するような感じでお茶を濁している。

https://github.com/Songmu/homebrew-tap