【翻译】在生产环境中的最佳实践
“Qiita已经两个月没有更新了。
好像在 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来进行访问。
我们是一个多语言使用的组织,也就是说我们使用多种语言进行工作。而且我们的许多服务(和一些后端基础设施)都是用Go语言编写的。事实上,我们在大约两年半前开始使用Go语言,所以现在可以说我们是相当早期的采纳者。我们的项目包括了
-
- 我々が内部で使っている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不是最佳实践,但这是个有趣的问题。
仓库结构
我们的最佳实践是“保持简洁”。许多服务可能会在main包中包含超过6个源文件。
github.com/soundcloud/simple/
README.md
Makefile
main.go
main_test.go
support.go
support_test.go
比如,我们的搜索调度器也是这样的情况持续了2年。请不要创建这种结构,除非明确需要。
你可能会想要创建一个新的支持程序包。你可以在主存储库中创建一个子目录,并使用完全修饰的路径进行导入。如果程序包只有一个文件或一个结构,通常不需要进行分离。
如果存储库需要多个二进制文件,有时处理可能分为服务器、工作器和保洁员管理进程。在这种情况下,我们可以将每个二进制文件的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将成为程序与操作员之间完全文档化的一个方面。这对于操作和理解程序非常有好处。
在main函数中定义flag是一个好的习惯。这样可以防止在不确定时读取全局作用域的flag,并且可以严格实施依赖注入。这样做也使得编写测试更加容易。
func main() {
var (
payload = flag.String("payload", "abc", "payload data")
delay = flag.Duration("delay", 1*time.Second, "write delay")
)
flag.Parse()
// ...
}
日志和遥测
我们曾试用一些可以记录不同级别日志(例如调试、输出、路由等)并支持指定格式的日志框架。最终我们选择了普通的log包。对于实用的信息,即只需要人工确认或者提供给其他机器的信息,这已经足够了。例如,搜索调度会将请求与上下文信息一起发送给所有进程。因此,在我们的分析工作流中,我们可以查看来自新西兰的IP地址访问Lorde的频率等信息。
在这之外的内容将用于遥测。包括请求响应时间、QPS、运行时错误、队列深度等等。而遥测将基于两种类型之一,即推送型和拉取型。
プッシュ型は外部に放出することを言います。たとえば、 GraphiteやStatsd、AirBrakeがそのように動きます。
プル型はそれぞれが知っている場所に対して吐き出し、他のシステムが読み込めるもののことを言います。例えば、expvarパッケージやPrometheusがそれです。
每个都有各自的用处。推模式容易上手,但日志会变得非常庞大,增加成本。我们认为,要扩展到特定规模的基础架构,拉模式是唯一的方法。它同时也有助于对运行中系统的验证。因此,结论是采用像expvar包这样的风格比较好。
测试和验证
在过去的几年里,我们尝试了许多测试框架,但很快就放弃了它们。现在,我们使用原始的测试包进行表驱动测试。我们并不对只提供简单功能的测试和检查包感到不满。我们想补充的是,使用reflect.DeepEqual可以简化数据比较(例如预期值和获取值的比较)。
测试包在单元测试中非常配合,但在集成测试中会稍微棘手一些。这是因为外部服务会依赖于集成测试环境,但我们已经找到了这里的一个很好的解决办法。我们编写了integration_test.go,并添加了integration标签。我们使用全局标志来定义服务地址等,并在测试中使用它们。
// +build integration
var fooAddr = flag.String(...)
func TestToo(t *testing.T) {
f, err := foo.Connect(*fooAddr)
// ...
}
你可以像对go build一样为go test提供构建标签,所以让我们调用go test -tags=integration。调用flag.Parse后,主包的标志将被合并,因此标志可以在测试中使用。
验证是指静态代码验证。幸运的是,Go语言有很多出色的工具。我已考虑在哪个阶段使用这些工具。
休息
到目前为止,没有任何疯狂的东西。我考虑了要注意的事项来创建这个列表,但得出的结论毫无趣味。虽然感到厌烦,但要在大型团队中扩展项目生态系统,就需要使用轻量且纯粹的标准库。反正代码不会超过一定量,所以不需要错误检查框架或测试库。如果你相信可能会超过,最好停下来。坚持使用标准的习惯用法是实现扩展的美妙方式。
依赖性管理
依赖管理啦!我走啦!!!ᕕ( ᐛ )ᕗ
在Go语言的生态系统中,依赖管理是一个热门话题,但尚未找到完美的解决方法。然而,以下配置似乎还算不错。
在我们的产品服务中,相当大一部分仍然采用前者的方法。这是因为它没有使用太多第三方代码,并且大部分问题可以在构建时检测出来。
绑定指的是将依赖的代码放入项目存储库,并进行构建和使用。根据正在开发的项目类型,最佳实践分为两种。
如果要输出二进制文件,我们可以在代码库的根目录下创建_vendor子目录(通过添加下划线使其在”go test ./…”时被忽略)。然后,需要操作GOPATH。比如,将github.com/user/dep的依赖复制到_vendor/src/github.com/user/dep中。确保_vendor比其他任何GOPATH都先被加载。(GOPATH是一个路径列表,go工具会根据路径的先后顺序来解析导入。)例如,Makefile可能会是这样的。
GO ?= go
GOPATH := $(CURDIR)/_vendor:$(GOPATH)
all: build
build:
$(GO) build
如果您正在开发一个库,那么应该在存储库的根目录下创建vendor文件夹。然后,修改包的路径,例如,将github.com/user/dep修改为vendor/user/dep。
虽然在这里修改所有的导入路径可能有些麻烦,但这似乎是将其与go get兼容的最佳方法。由于我们从未发布过库,因此实际上这种方法可能太麻烦而不值得尝试。
将依存代码复制到自己的代码库中的方法是另一个热门话题。最简单的方法是手动克隆文件然后复制它们。如果不考虑向上游推送的话,这将是最佳答案。也许有些人会使用gitsubmodule,但这并不直观,而且管理起来会更加困难。我们几乎在使用git subtree来执行与submodule几乎相同的功能,并且效果很好。我们还使用这个工具来自动化许多任务。类似godep这样的工具值得立即去了解。
构建和部署
建立和部署是棘手的。因为它们紧密与使用的环境相关联。在我们的情况下,我们介绍了一个好模型,但在其他环境中可能不适用。
我們通常在開發中使用 `go build` 編譯程式,而在製作正式的版本時則使用 Makefile。這主要是因為我們公司使用不同的程式語言,需要一個最低共同工具來編譯。而且,在建置系統時,我們需要從頭開始,所以必須取得編譯器(我們的 Makefile 真的很糟糕!)。
在部署中,关键要素是状态无关还是状态相关。
我们通常部署无状态服务,但是经理非常类似于Heroku。
$ git push bazooka master
$ bazooka scale -r <new> -n 4 ...
$ # validate
$ bazooka scale -r <old> -n 0 ...
总结
为了传达在大规模组织中长期运行Go产品的经验,我写了这份报告。这里所写的只是一种观点,希望大家能进行改进。
当然,Go的最大优点是其简单结构。因此,最佳实践就是接受这种简单性,不要试图硬抗它。