Qiitaは2ヶ月ぶりです。
GopherCon2014でSoundCloudの方がプロダクションでGoをどう使うかというところで発表されていたようです。その内容がブログで公開されていたので、僕の勉強も兼ねて翻訳することにしました。
英語は得意でないのですが、ザクッと訳してみました。きっと間違い有るので、どうかご指摘ください。
元ネタ:http://peter.bourgon.org/go-in-production/
スライド:https://github.com/gophercon/2014-talks/blob/master/best-practices-for-production-environments.pdf
プロダクション環境でのベストプラクティス
SoundCloudでは、たくさんのクライアントに対してAPIの形でプロダクトを提供するようにしています。ですから、ウェブサイトやモバイルクライアント、モバイルアプリのすべてが、唯一のAPIを利用するクライアントでしかありません。その背景にあるのは、SoundCloudがサービス志向アーキテクチャを基本として運用されているということです。
我々はポリグロットな、つまり様々な言語を使う組織です。そして、サービスの多く(と、バックのインフラもすこし)はGoで書かれています。実は我々はGoを2年半くらい前から使っているので、今ではかなりのアーリーアダプタだと言えるでしょう。我々のプロジェクトに含まれるのは、
- 我々が内部で使っているPaaSである「Bazooka」。思想としてはHerokuのFlynnにとても近い。
- 通信周りは標準的(nginxやHAProxyなど)だが、それらの調整にはGoのサービスを使っている。
- 音声データはS3に保管しているが、アップロードや変換、リンク生成ではGoのサービスを使っている。
- 検索はElasticsearchで、探索は洗練された機械学習モデルですが、それらはGoで書かれた我々のインフラで結合されています。
- 初期段階のテレメトリシステムである「Prometheus」は、すべてGoで書かれています。
- ストリーム配信はCassandraを使っていますが、Goで置き換えることを進めています。
- HTTPライブストリーミングもピュアGoで置き換える実験をしています。
- その他、小さなプロダクトたち
といったところです。
これらのプロジェクトは12人以上のGopherが所属している(ほとんどはフルタイムでGoの仕事をしている)、6個くらいのチームで書かれています。すべてのプロジェクトや混ぜこぜにされたエンジニアたちが、Go言語をプロダクションで使うためのベストプラクティスを進歩させてきました。この知見は、これからGoに投資しようとする企業に対して有用なものになるでしょう。
開発環境
ラップトップでは、唯一でグローバルなGOPATHを持っていることと思います。個人的には$HOMEが好きですが、$HOMEの下のサブディレクトリを使っている人も居ます。リポジトリのクローンはその標準的なパスの下で行い、その場で開発しましょう。つまりこういうことです。
$ mkdir -p $GOPATH/src/github.com/soundcloud
$ cd $GOPATH/src/github.com/soundcloud
$ git clone git@github.com:soundcloud/roshi
最初の頃はこの法則と戦って我々の(従来の)コード構成を維持しようとしましたが、無駄な抵抗でした。
エディタには、我々の多くはvimにいくつかのプラグインを足して使っています(例えば、vim-goは良いですね)。私の他、多くの人はSublimeTextにGoSublimeを足して使っています。その他はemacsを使っています。
IDEを使っている人はいません。私はIDEは「ベストプラクティスではない」と確信してはいないのですが、興味深いところです。
リポジトリ構造
我々のベストプラクティスは「シンプルに保つこと」です。
多くのサービスでは、6以上のソースファイルがmainパッケージに入っているのではないでしょうか。
github.com/soundcloud/simple/
README.md
Makefile
main.go
main_test.go
support.go
support_test.go
例えば、私達の検索ディスパッチャも、2年間こうなっていました。こういう構造は、明確に必要とならない限り作らないでください。
新しいサポートパッケージを作りたくなるでしょう。サブディレクトリをメインのレポジトリに作って、完全修飾パスでインポートします。もしパッケージがひとつのファイルだけ、あるいはひとつの構造体だけであれば、だいたいは分離する必要がありません。
リポジトリが複数のバイナリを必要とする場合もあるでしょう。たとえば処理がサーバとワーカに、時にはjunitor管理プロセスに分かれる時です。こういう状況では、それぞれのバイナリのmainパッケージをサブディレクトリに納めてしまい、他のサブディレクトリ(他のパッケージ)を共有関数として使います。
github.com/soundcloud/complex/
README.md
Makefile
complex-server/
main.go
main_test.go
handlers.go
handlers_test.go
complex-worker/
main.go
main_test.go
process.go
process_test.go
shared/
foo.go
foo_test.go
bar.go
bar_test.go
これらに関与するsrcディレクトリが存在しないことに注意してください。ベンダーサブディレクトリ(後述します)やGOPATHを除いて、リポジトリにはsrcというディレクトリを作ってはいけません。
フォーマットとスタイル
まっさきに、コードを保存するときにgo fmt(や、goimports)が実行されるようにエディタを設定しましょう。インデントやスペースのアライメントを揃えるためです。 フォーマットされていないコードは絶対にコミットしないようにしましょう。
私達はかなり大規模なスタイルガイドを運用していますが、Googleは最近コードレビューについてののドキュメントを発表しました。これは我々にも相入れるものなので、これを使用するようにしました。
実際にはすこし改良していて、
- 明確に理解しやすくなる場合を除いて、名前付き戻り値を避ける。
- どうしても必要な場合(new(int)やmake(chan int))や、大きさが分かっている場合(make(map[int]string, n)やmake([]int, 0, 256))を除いて、makeやnewを避ける。
- 番兵には、boolやinterface{}よりもstruct{}を使う。たとえば、map[string]struct{}やchan struct{}といった具合。情報が壊れているとき、明確なシグナルが出されます。
というようにしています。
長いパラメータだと、Javaスタイルでは無いですが、改行するのが良いでしょう。こうではなくて、
// よくない例
func process(dst io.Writer, readTimeout,
writeTimeout time.Duration, allowInvalid bool,
max int, src <-chan util.Job) {
// ...
}
こういうことです。
func process(
dst io.Writer,
readTimeout, writeTimeout time.Duration,
allowInvalid bool,
max int,
src <-chan util.Job,
) {
// ...
}
同様にオブジェクトを生成するときも、
f := foo.New(foo.Config{
Site: "zombo.com",
Out: os.Stdout,
Dest: conference.KeyPair{
Key: "gophercon",
Value: 2014,
},
})
また、オブジェクトを生成する際は、設定値を後で代入するよりも、初期化時に渡すようにしましょう。
// よくない例
f := &Foo{} // new(Foo)と同様に問題
f.Site = "zombo.com"
f.Out = os.Stdout
f.Dest.Key = "gophercon"
f.Dest.Value = 2014
設定値の扱い
我々はこれまで、Goのプログラムに設定値を渡すため様々な方法を試しました。設定ファイルを読み込んだり、os.GetEnvで変数を読み込んだりといったことです。結局、最も良い方法はflagパッケージを使うことでした。型に厳密な上にシンプルで、我々が必要とすることを満たしています。
我々は主に12-Factorアプリケーションをデプロイしているのですが、12-Factorでは設定を環境変数で渡すことになっています。それでも我々は環境変数をflagに書き換える作業を始めました。flagはプログラムと操作者の間で、完全にドキュメント化された一面になります。これはプログラムを操作したり理解するのに極めて良いことです。
flagをmain関数の中で定義するのは良いイディオムです。コードが任意な時にグローバルスコープのflagを読むことを防ぎ、依存性注入を厳格にさせます。これによってテストも書きやすくなります。
func main() {
var (
payload = flag.String("payload", "abc", "payload data")
delay = flag.Duration("delay", 1*time.Second, "write delay")
)
flag.Parse()
// ...
}
ログとテレメトリ
我々はいくつかのレベル別ログが取れて(debug, output, routingみたいに)、指定したフォーマットに対応できるロギングフレームワークを試していました。そして結局、普通のlogパッケージに落ち着きました。実用的な情報、つまり人間が確認しないといけない情報や、他のマシンに与えた情報のみをログに記録するなら、これで十分です。たとえば、検索ディスパッチャはすべてのプロセスにコンテクスト情報と共にリクエストを出します。そのため、我々の解析ワークフロでは、ニュージーランドのIPはどれくらいの頻度でLordeにアクセスしているのか、なんてことを調べることができるのです。
それ以外に吐き出されたものは、テレメトリのために使います。リクエスト・レスポンス時間、QPS、ランタイムエラー、キュー深度などです。そして、テレメトリは2つのうち1つをベースとします。プッシュ型とプル型です。
- プッシュ型は外部に放出することを言います。たとえば、 GraphiteやStatsd、AirBrakeがそのように動きます。
- プル型はそれぞれが知っている場所に対して吐き出し、他のシステムが読み込めるもののことを言います。例えば、expvarパッケージやPrometheusがそれです。
それぞれ、使い所があります。プッシュ型はとっつきやすいですが、ログが必要以上に大きくなりコストがかかります。私達は、特定のサイズのインフラに置き、スケールさせるためにはプル型が唯一の方法だと見ています。そしてそれは同時に、実行中のシステムの確認にも役立ちます。
ここから、結論としてはexpvarパッケージのようなスタイルが良いとなりました。
テストとバリデーション
我々はこの何年かの間に多くのテスティングフレームワークを試し、そしてその多くで直ぐに諦めてしまいました。今では、素のままのtestingパッケージを使い、テーブルドリブンテストをしています。私達はシンプルな機能しか提供しないtestingやcheckingパッケージに強い不満を持っているわけではありません。一つ書き添えたいことは、reflect.DeepEqualを使うとデータの比較がシンプルにできるという事です。(期待値と取得値の比較など)
testingパッケージはユニットテストにはうまく噛み合いますが、結合テストでは少しトリッキーになります。外部サービスは結合テスト環境に依存してしまうからですが、私達はここでの良いイディオムを見つけることができました。integration_test.goを書いて、integrationタグをつけます。サービスアドレスなどはglobalなフラグで定義してテストの中で使います。
// +build integration
var fooAddr = flag.String(...)
func TestToo(t *testing.T) {
f, err := foo.Connect(*fooAddr)
// ...
}
go testにはgo buildのようにビルドタグを与えることができますから、 go test -tags=integrationを呼びましょう。flag.Parseを呼べばmainパッケージのフラグが合成されますから、フラグはテストから使えるようになります。
バリデーションとは、静的コードバリデーションのことです。幸運なことに、Goはいくつも素晴らしいツールを持っています。これをどの段階で使っていくべきか、検討してみました。
タイミング | コマンド |
---|---|
保存 | go fmt(goimportsも) |
ビルド | go vet, golint、もしかするとgo testも |
デプロイ | go test -tags=integration |
間奏
ここまで、何もクレイジーなものはありませんでした。このリストを作るために注目すべき点を考えていましたが、なんの面白みもない結論に達しました。うんざりだけど、大きなグループでプロジェクト・エコシステムを広げるためには、軽量で、ピュアな標準ライブラリを使うことなんだ。
どうせコードはある一定量を超えないのだから、エラーチェックフレームワークや、テストライブラリを必要とすることはないのです。もし「超えるかもしれない」と信じているのなら、もっとやめたほうがいい。標準的なイディオムとやり方を続けるのが、スケールさせていくための美しいやり方です。
依存性管理
依存性管理だ!
ウリィィィィィ!!! ᕕ( ᐛ )ᕗ
Goのエコシステムの中で依存性管理をどうするかはホットな話題なのですが、完璧な方法はまだ見つかっていません。しかし、以下のような設定はそれなりに良いようです。
プロジェクトは重要? | 依存性管理方法は・・・ |
---|---|
まぁ・・・ | go get -dがいいね |
とっても | ベンダリング |
(我々のプロダクションサービスのうち、かなり多くはまだ前者の方法です。それはそれほど多くの3rdパーティーのコードを使っていないというのと、だいたいの問題はビルド時に検出できるからです)
ベンダリングというのは、依存性のあるコードをプロジェクトのレポに入れてしまい、それをビルドして使うことです。作っているものが何であるかによって、ベストプラクティスは2つにわかれます。
開発形態 | ディレクトリ名 | 手順 |
---|---|---|
バイナリ | _vendor | 修飾付きのGOPATHでビルド |
ライブラリ | vendor | importsを書き換える |
もしバイナリを出力するのであれば、_vendorサブディレクトリをリポジトリのルートに作りましょう。(アンダースコアを付けることで、go test ./... で無視されるようになる)そして、GOPATHを操作します。例えば、github.com/user/depの依存性を_vendor/src/github.com/user/depにコピーします。ここで、どのGOPATHよりも_vendorが先に読み込まれるようにします。(GOPATHはパスのリストであって、goツールは先に書かれたパスからimportを解決していきます。)例えば、Makefileはこんな感じになるでしょう。
GO ?= go
GOPATH := $(CURDIR)/_vendor:$(GOPATH)
all: build
build:
$(GO) build
もしあなたがライブラリを開発しているなら、vendorディレクトリをレポジトリのルート下に作りましょう。そして、パッケージのパスを書き換えます、例えば、github.com/user/depをvendor/user/depのようにです。
ここですべてのimportパスを書きかえることは面倒くさいのですが、これがgo getに対応させたままで実現するベストな方法であるようです。我々はライブラリをリリースしたことはないので、この方法は実際には面倒すぎて実際にやってみるだけの価値は無いかもしれません
依存コードを自分のリポジトリへコピーする方法は、もうひとつのホットなトピックですね。これは、手でcloneしてきたファイルをコピーしてやることが最もシンプルな方法です。もし上流へのプッシュを考えないなら、これは最も良い答えでしょう。gitsubmoduleを使う人も居るでしょうが、直感的では無いですし管理が難しくなります。私達はsubmoduleとほぼ同じ事をgit subtreeでやっていて、うまく行っています。そして多くの作業をこのツールで自動化しています。
godepのようなツールは、今すぐ調べてみる価値がありそうです。
ビルドとデプロイ
ビルドとデプロイはトリッキーです。なぜなら、使っている環境と密接に関連づいているからです。私達のシチュエーションでは良いモデルを紹介しますが、他の環境だとそのまま役立たないかもしれません。
普段、開発中のビルドはgo buildをそのまま使って、オフィシャルなビルドを作るときはMakefileを使います。その大きな理由は、会社で様々な言語が使われているため、その最小公約数的なツールを使っているからです。そして、システムをビルドするときは素の状態から始まるため、コンパイラの取得もしなければならないのです(うちのMakefileはひどいぞ!)
デプロイでキーとなる要素は、ステートレスかステートフルかです。
モード | 例 | モデル | デプロイの呼び方 | デプロイ先 |
---|---|---|---|---|
ステートレス | Request router | 12-Factor | スケーリング | コンテナ |
ステートフル | Redis | なし | プロビジョニング | コンテナ? |
私達は普段ステートレスなサービスをデプロイしていますが、マネージャはとてもHerokuに近いものです。
$ git push bazooka master
$ bazooka scale -r <new> -n 4 ...
$ # validate
$ bazooka scale -r <old> -n 0 ...
まとめ
大規模な組織の中で長期間、Goのプロダクトを動かした体験を伝えるためにこのレポートを書きました。ここに書いたことは一つの意見でしかありませんから、皆さんで改良してください。
当然ですが、Goの最も大きな強みは構造がシンプルであることです。ですから、最大のベストプラクティスはそのシンプルさを受け入れて、下手に抗おうとしないことでしょう。