使用 Go 构建 Resilient Services - 技术会谈

admin 9年前

使用 Go 构建 Resilient Services - 技术会谈

这是一篇在 GopherCon 2015 的技术会谈,主讲人 Blake Caldwell 曾是 Fog Creek 里 Kiln 团队的软件工程师,他将讲述如何使用 Go 来重写的我们的 SSH 反向代理, KilnProxy,达到了性能的提升。 一起来听听他怎样来重写服务的,在将git clone时间减半的同时, 保证了服务质量更加稳定和可靠。

Blake 在他的博客写一些关于 Go 语言和软件开发的文章。他已经开源了在会谈里提到的分析器 profiler,会谈的幻灯片也放在了他的GitHub上。

视频链接:https://youtu.be/PyBJQA4clf

关于 Fog Creek 技术会谈

在 Fog Creek 每周都会一个技术性的会谈,我们自己的员工上去讲或是邀请一些嘉宾。 通常都是一些较短的、不太正式的演讲, 内容都是软件开发工程们感兴趣的话题。我们会尽力分享更多给大家的。

内容和时间点

  • 简介(0:00)

  • 项目背景(0:22)

  • 关于SSH反向代理KilnProxy(1:33)

  • 结果(3:35)

  • 错误处理(5:20)

  • Channels(7:17)

  • 异常处理(8:09)

  • 避免竞争(10:00)

  • 超时的实现(11:20)

  • 内存分析(13:44)

  • 日志生成(21:20)

内容抄录

简介

Blake:  大家好。 这又是一个代码重写的案例,进行地非常顺利,所以我想分享给每一个人。和其它的会谈差不多, 会谈到很多不同内容,但不会就每一个方面深入展开下去。我只能尽可能多的讲讲使用到的一些工具和技术了, 怎样来帮忙我实现第一个上线的 Go 实现的服务。

背景

先说说背景吧。我去年在位于 New York 的 Fog Creek 工作。你有听说过 Fog Creek 的话,就可能很熟悉 Kiwi 了,它是 FogBugz 的吉祥物。我之前工作的项目是 Kiln。 Kiln 是 Fog Creek 提供的 Git 和Mercurial 的源代码托管服务,和 GitHub 类似的一个东东,不过同时支持 Git 和 Mercurial。 我在那里工作之前,连 Mercurial 都没听说过, 但是现在已经非常熟悉了。我的大部分工作是使用 C# 和 Python,想对底层的一些东西总是非常感兴趣。

去年我也很幸运的参见了 Google I/O。当时我完全不知道 Go 是什么,实际上我还没有听说过它。现在你在哪儿都可以看到它,它哪里都有。我不需要想你解释我为什么这么快就爱上它。只要有机会我就去参加所有的 Go 讲座,我去见一些 Go 的作者,非常爽。我知道如果有什么问题出现,总有一天会出现相应的解决方案。

我想用 Go 实现些神奇的东西。我想在业余时间用它做些兴趣相关的,有意思的事情。但是我想在工作中证明,Go 可以做些非常神奇的东西,让 Kiln 变的更好。我找了下有什么东西可以重写,你总可以找到些东西重写。最后决定重写我们的 SSH 反向代理。当我开始重写它的时候,我还不知道反向代理是个什么东东。

关于 SSH 反向代理 KilnProxy

还是先说说背景,看它到底是来做什么的。当你使用 Git 或者 Mercurial 时,有两种方式和远程服务器交互, 使用 HTTP 或者 SSH。 SSH 使用到了公私密钥对,相对的会更加安全一些。 假如你正在用 SSH 从Kiln 克隆代码仓库的话,那就肯定和 KilnProxy 打交道了。你是左边这部分人中的其中一个。 和KilnProxy 交互时, KilnProxy 需要认证你的 Key,来确定正在交互的这个人就是你,而不是其它人;然后它会查找你的代码到底放在哪里,我们的后台服务器太多了, 所以需要确定你的代码是在哪台服务器上的。它和后台服务器建立连接,同时也有一个到你那边的连接,那所做的工作就是把交互的数据传过来然后再传回去。

它已经能够很好的工作了,为什么还要重写?当时的情况是,使用 SSH 来克隆比 HTTP 要慢好多, 应该不慢才对。无论怎么看,使用 SSH 应该要更快。同时还遇到了一些稳定性的问题,我们的系统管理员必需要每天重启服务。可以想像一下,你有一个具大的代码仓库且你正在克隆着,20多分钟已经耗进去了,而这时恰好撞到了我们重启服务器,你耗的那些时间都白费了,全部得从头再来。这是很让人崩溃的。

事实证明这个项目很适合用 Go 来实现 ,因为有太多太多的并发了。 大概讲下,以便大家有个概念。当你第一次连接的时候,会进入监听循环,开启了一个 Go routine。 这个 Go routine 会先负责验证你公私密钥对,或者是私钥;然后再和后服务器建立连接。一旦连接建立起来了,相应的 Go routine 还会代理标准输入、标准输出和标准错误输出 。

结果

结果怎么呢?非常的好,出人意料。我只是想让它能正常工作,它却运行的非常好。那到底有多好。在几分钟的时间内,我们就可以克隆一个小的代码仓库,跟踪它所使用的时间。就在这点是我们从 python 实现转换到了 Go 的实现。 先让你们猜下是哪个点, 就是粉色的标记过去一点点。这是我们重写后刚上线的点,从此以后, SSH 和 HTTP 同样的快了。

可能你以前就注意到了,1MB 的仓库之前花费的时间是 1.5s, 重写后只需要 0.75s 了。速度差不多是以前的两倍了。当仓库变得越得越来越大时,效果就更加显示了。同时遇到问题也少了。现在线上运行的就是一个更快、更稳定、很少出问题的服务了。

作为我的第一个用 Go 实现的服务,必须讲讲怎么用 Go 来实现一个稳定的服务了,也分享一些 Tips。现在讲的主题是弹性吧?就先要来看看弹性服务的要求,不能崩溃,也不需要每天重启;没有内存泄漏,不会挂起,也不能卡住不动了。很显示,这无疑将是个很长的流程,没有银弹。这个流程将覆盖从在上线之前的开发、调试剖析,以及上线之后的服务监控。

错误处理

现在开始讨论错误处理,还好,没有太多的演讲者说这个…不得不说我们必须要处理每一个错误。我感觉在座的每一位都知道要处理每个出现的错误。我是用 java 的,在 java 里只用代码中用极简短的异常代码来处理错误,但我并不关心它们(异常) 被抛出,也不关心怎么处理它们。在 java 里我们根本不需要关心这些。

我们都明白这是一种模式。你从一个函数中获取一个资源或者一个返回值,还有对应的错误信息,并且有错误的话你不能继续使用返回值。之后我们需要检查这个错误是否发生,如果有错误我们就跳出这个函数。并且需要执行清理或关闭资源。有一件我非常确定的事就是,在视觉上不把这一小块代码写在新的一行里。我想看到的是这块代码是一个完整的单元,是不可分解的。

看起来这里有处理所有的错误,所以没有问题。众所周知,有些时候我们应该检查下空值(Nil)。回到我们的例子,假如 OpenResourceA 能够返回空值,并且这不是一个出错状态?也许这是一种不常见的情况:试图打开某个资源的时候,由于某些原因,掉线了。从技术上来说,这并不是一个错误。在 defer 语句中,可能就要慌了。

不过倒也未必,我们当然有办法避免这个,一种做法就是使用一个内联函数、一个匿名函数。我们可以在那里完成我们的小检查,判读不是空值,再关闭。这种方法的一个问题就是它很拙劣。我不是很喜欢这种做法。是否所有人都能看得清楚这个?我不知道这是否足够大了。


我喜欢用 defer 语句来处理需要推迟处理的方法,我喜欢简洁的 defer 语句,希望它能简单、整洁。有一件情况我最近差点忘记,如果一个 struct 方法需要传入一个 struct 指针,但是如果这个指针为空,此方法仍然会接受这个指针并且被调用。这种情况下,我通常采用 defer 语句,并且在语句中清理资源。我通常会检查空指针,回到前面提到的 DeferResourceA.close 方法,那么它实际上就是空指针的情况。我发现这种实践非常好。

Channels

让我们来聊聊 channels。我们熟悉 channels 并且知道它能给我带来很多乐趣,它很棒,但是如果你不知道正在用它做什么,就很有可能给你带来麻烦。我不准备在这里深入的探讨 channels,因为往深了讲需要半天的时间,但是我可以在这里给大家一个参考,那就是 Dave Cheney 的博客,它指引我度过一段很艰难的时间。每次我遇到 channel,我都会去看看这个叫“Channel 原理”的文章。我还把它记在了别处,因为我要确保不弄错 channels。

有三点我想在这里着重说一说。如果对一个空 channel 进行写入或者读取操作,它将会永远阻塞;当你在一个 go-routine 下遇到永远阻塞,那么这个 go-routine 就不会退出,相应的资源(比如生成的局部变量等)也就得不到回收;当你对一个已经关闭的 channel 进行操作,你就会被 panic。

处理 Panic

让我们来聊聊 panic。它们通常或者说大部分是由于编程错误引起的,如果遇到 panic,程序将会中断服务。如果我说有时我甚至喜欢从 panic 当中处理恢复,这有可能会让大概在座的一半的人感到困惑。这个问题富有争议,人们会说“这个程序员的错误,如果你有一个程序错误,那就应该让程序终止,并修复它”。

说的没错,但是我确实会犯错,而且错误也会在生产环境下发生,我希望能够尽可能的减轻它带来的影响。不需要过多的深究问题所在,你就能从 panic 中恢复。你不应该把它当做异常,这就是我的处理方式。当我建立那个函数的时候,你们看到了出错的地方,但是我会保证在函数返回之前,所有资源都会被妥善清理,采用这种方式,就算 panic 出现,所有清理工作也会得到进行。

我试着限制代码的区域,我在边上的一些区域设置了代码,让人惊讶的事情就在这些地方发生,我试着捕捉它们,我记录日志,并非常认真地对待它,还试着修复那些 bug。让我来举个例子。SSH 代理是非常复杂的。如果我为人诚实,我不会特意在事前把 SSH 所有的内容读一遍。我们有个客户使用特定构建的服务器,并以某种方式使用 Git,这是后来我才知道的。因为我们成百上千的客户都没有这样的问题,而他的崩溃了。

如果我让这个恐慌蔓延到生产,就会着火,就会中断,不得不一次又一次地中断服务。在那个例子中,我处理了恐慌,在先前的例子中,仅处理一个客户端的请求。在高的层次,顶级的层次上,如果在主循环中出现问题,那它就会崩溃。

避免竞态条件

让我们假设这里没有竞态检测器。除了竞态条件,我不需要描述太多,当你有并发性问题时,你就会遇到竞态问题。此外,我说过我在 Java 中使用过所有并发的库,我知道所有这些在 Java 中存在着的工具,在过去十年里我一直使用 Java 工作,但是我从来没看到过。

在 Go 里面这变得非常简单,因为它在 Go 里面是主要的套件。就像我们早先听到的那样,在一份报表中,一个变量的访问并不是同步的,当它发生时,它会崩溃,并向您展示完整的堆栈跟踪,包括读和写的确切位置,在单元测试和集成测试期间你还可以使用,开发。

同样的,这里有输出。能够跟踪到这种情况的发生是很棒的,因为这个 bug 有可能一直都不会发生。我们可以在 race.go 文件中看到错误的发生是在第 14 行代码试图读取,而第 15 行正要写入。这个迭代持续数分钟才能得到解决。但是,你可以在测试、运行或者构建、安装的时候在命令行加入 -race 来开启跟踪。

加入超时机制

实现超时,我们需要注意几种情况,最重要的是网络超时。在我们的软件中,我们的程序需要经常连接远程服务。因此,我们需要拨号、连接再传输数据之后才会完成任务。

最好的实践是,如果你准备拨号,那么你应该设置一个超时时间。如果你看过标准库,这种函数通常都会设置一个超时。假如说 2 秒内拨通另一个服务器是合理的,那么将其设置为 20 秒也没关系,这样可以保证不被永远挂起。但是假如将 20 秒再提高到 100 秒,并且你一直尝试拨号却没有得到响应,那么就有可能由于内存不足而导致程序崩溃。

一旦你拨通了,你应该连接过去,并且设置一个连接超时时间。假如你在连接超时时间内,比如说 10 秒或者 50 秒,没有传输或者接收到任何数据,那么你就应该关闭这个连接并且记录下来。假如说连接保持 1 分钟比较合理,那么 5 分钟之后才超时就显得比较荒唐了。

下一个,不言而喻,但除了测试,其他的不想深入,因为测试相当重要。对我来说,在解决完一个问题之后,我不想再记住这些事情,在我测试完时,我喜欢设置一个后部断点,然后我移交代码给别人或者未来的我,以此来防止这些 bug. 在这里,我给你举个例子,我十分热衷于集成测试,如果你并没有这些方面的测试经验,在 Docker 中,你可以期待。

以 KilnProxy 为例,当我们谈论 SSH 时,会有许多将使用 Mercurial 与 Git。我所设置的是在一个 Docker 容器中,以及我在 Docker 容器中运行 Docker 图像的一个环境,实际上,通过我的  KilnProxy 代码,我将获取一个运行服务器的所有命令。因此,我正试着 git pool, git clone, git push,所有的那些东西。与 Mercurial 相同。好的方面是可能会有 Git 新版本要发布啦,刚好我们有一个单独的使用 git 新版本的容器实例,并且我们并发运行所有的测试,以确保我们的代理不会被打破。

工具

前面讲了开发。接下来讲一下利用测试来观察一下我们的服务正在做一些什么。让我们从“程序是怎么利用内存的?”说起,我们会分析它,并且像很多在座的观众一样,我们做了一个分析软件,可以到 Fog Creek 的页面查看。盯着它看很有趣,但是我没有做很多动画效果,只是每秒刷新一下,它可以显示出与此服务有关的内存有多少,其中多少正在被使用以及多少正在被回收。蓝紫色的线条代表已经准备好被回收的内存,有时系统会重新利用它,有时不会。

你能够很快的察觉到那些重要信息的变化,你可能会观察有多少内存在系统空闲的时候被占用,这取决于你自己,可能还想看看有多少内存在连接进来的时候被占用,以及内存被使用和释放之后,系统是否回收了内存,这取决于系统本身以及内存的压力。如果你想深入的了解一下,你可以在 GODEBUG 环境下运行程序,并设置 gctrace=1,来看看垃圾回收器在做些什么。观察它的行为很有趣。

同样的,你是不是也想看看内存是在哪里生成的?内存是怎么被用的?这就是 PPROF 的工作了。我们在会上已经看到了它,如果你还没有使用它,那么尝试一下,它会改变你的生活。它相当的棒,它可以让你看到你的服务阻塞分析,goroutine 的数量,栈追踪,堆分析以及线程创建工程中的栈追踪。

听起来挺底层的,但是使用它却很简单。你只要引入这个包,然后它就会自动帮你建立起一些 HTTP 节点,你需要选择一个端口和 IP,接着就是监听了。设置完成,打开网页就可以看到真正的输出结果了。我们已经看到很多有用的信息了,我们可以看到当前有 32 个 goroutine 正在被使用,这是最基本的,也就是说没有任何用户连接的情况下,系统需要 32 个 goroutine。

在前面提到的,不要让 goroutine 产生泄漏十分重要。使用 PPROF,我们知道了在没有连接情况下有多少个 goroutine,接着我们可以连接一个用户,保持连接打开状态来看一下又有多少个 goroutine 了,不妨再看看 100 或者 1000 个连接的情况,你可以让它跑一个晚上,等所有的用户断开连接之后再看看有多少个 goroutine,如果现在是 33 个了,那么可能就有 bug 需要修复了。

假设我们现在有 33 个 goroutine,那么哪一个是多出来的?看一下最初的页面,点击 goroutine 可以看到全部的栈追踪信息。我们都看过这些信息,但是悲剧的是,大多数情况下,我们的服务是崩溃的。这里我们看到的是正在运行的追踪信息,刷新界面,下一个截图信息会告诉我们正在发生什么。能够看到 goroutine 的分配情况是很棒的。

从网页上看PPROF的内容确实很不错,但是它更厉害的地方在于命令行,你可以利用本地的命令行来执行。它会连接web节点,这些节点会产生一些额外的消耗,它也会解释这个问题。在命令行,我们可以在服务器之外运行go tool PPROF的可执行程序,同时我们会为goroutine的页面分配一个HTTP节点。

现在,我们进入操作终端。你可以输入top five来查看最上面5个goroutine的等待位置。想看9个或10都是可以的。我想展示的更有意思的事情是,在你安装了一些插件之后,你可以在web页面来输入。它会在浏览器内展示你的命令栈以及这些goroutine的来历,这在你的服务器变得庞大的时候也会简化你的工作,这是个SVG图,所以你还可以在浏览器内缩放。你会跟踪事情的发生,从而弄明白goroutine在响应哪些具体的请求。

同样的,PPROF 还可以帮助你查看堆内存的情况。同样的,在我可执行程序的服务器上运行go tool PPROF,然后查看内存。我运行top five,我最大的担心是我的分析器,当我看到在全部2.3M内存的情况下,它只使用了1.8M的时候,我就不担心了,因为这就是我所期待的。你还可以像以前一样更深的挖掘一些内容,你可以在web内输入命令,把SVG图调出来等。这个用来展示的例子太简单了,你实际的会更复杂一些,你可以看到调用栈,调用跟踪以及相关的内存是如何分配的。

在很多情况下,你会看到大量的内存调用,它可能不是在你的程序内,而是在其它你引用的库里面。你可以更深的挖掘一下,或者你可以给它打个补丁修复一下那个库,不管怎么样,知道它如何运行总是有收获的。

现在,我们开发了系统,在测试的时候进行分析,我们知道它的具体行为,并且把它部署到了生产环境。知道我们部署了什么是当务之急,我有时不太信任的部署的过程,也不太信任我们正在跟踪的线上代码。所以我做了一个只暴露给内网的节点,它会显示当前版本等一些信息,我让它以 unix time 的形式显示启动时间和服务器的当前时间,这样就不用担心时区问题了。你可以计算一下运行时间,出于阅读的方便,我已经把这个格式化好了,当前运行了 167 小时 10 分钟 2 秒,看着这个数字逐渐增加,那心情老好了。

接下来简单说一下版本号,我认为这是一个小技巧。我喜欢用多样化的版本号,因为版本号可以代表很多东西。你可以发布主、次版本,但是版本号要随着每次部署变得越来越大。你还可以拿到 Git 提交时的 SHA 码,如果代码已经部署上去,然后有一个人过来问你“那个 bug 修复了吗?是不是已经用于生产环境?”,那么你就可以通过 SHA 码拣出代码,然后看看 Git 的 log,就可以知道 bug 是否被修复了。不仅如此,我还可以知道代码构建的时间。

来讲一讲我们是怎样生成版本号的,显然我们不能期待人们能够看懂提交上来的 Git SHA 码,这也是不合理的。我们在这里利用 Go 的全局变量,变量名起个与服务版本号相关的任意名字,之后就可以在编译的时候从命令行或者编译脚本里面设置这个变量。我们代码提交之后,SHA 码就已经生成,我们可以在编译脚本中利用 -ld 标记来将这个字符串设置到主版本号中。

当你要部署到10台、100台甚至1000台实例上的时候,这将会非常有用,你可以保证它们都运行在正确的版本。现在我们再看一下服务的运行情况,再监控一下服务的状态。

日志

明显地,保持好的日志。我们都喜欢日志并且我在这分享一个小提示,我确定这不是我的发明,当收到一个新的请求,拿出一个半随机数字符串,通过所有方式传递给所有函数或者使用的上下文或者一些需要传递它的东西。以至于你可以把它作为每个日志条目的前缀。这是很有用的因为当你有成千的用户同时连接上来的话,再看日志,那是非常混乱的。可以使用 Grep很容易地找到一个连接上发生了什么。

接下来,我们想知道当前都谁连接上了。这可能对很多在 50 毫秒内响应的服务没什么用。当你连接到一个很大的库的时候接可能会用几分钟。我关注了很多通信,我想知道谁连接上了。和另外一个调用过连接的终端。假如我有一个用户,它是 Aviato 账户。Erlich Bachman 连上了。看起来好像它说了构建服务器,人们趋向于命名构建服务器笔记本的钥匙。他已经连接上来 25 分钟 4 秒了,以至于看起来有点像呆滞的。可能最近我已经注意到这些问题了。我将使用会话主键,这是我的日志字符串,我要去浏览日志并且精确找到整个会话在做啥。

自从我开始跟踪有多少用户在连接,我就可以实现等待连接完成,然后关闭连接。我以前从没想过这个,我曾经跟系统管理员聊过,他们告诉过我这个。这也就是说当系统管理员由于系统升级或者重启想停用 App、终止服务或者重启服务,我们需要先把当前的连接服务完,然后再关闭。在 Go 语言上,需要监听终止信号(sigterm),一旦终止信号被捕获,就停止接收新连接的请求,只等待所有当前请求执行完。

开搞!然后为了实现这种响应式的服务,很快就遇到了一个问题...起初,一切都还不错,但是系统管理员得到一个来自 Nagios 的警告,他们告诉我说“Blake,KilnProxy 崩了,它比平时多用了 40M 的内存”。我们都认为 kilnProxy 不会崩,我马上看了一下分析工具,但是确实是崩了。我能够看到内存一直在飙升,而且内存仍然在使用。

让我们更深入的来挖掘一下这个问题所在,我从我的节点察看了连接页。可以看到 Initech 被连接了十次。Peter Gibbons 在做了他不该做了,犯了一个错误,其实也是我们的问题。退一步讲,在开发时,我知道每个连接大概需要4M的内存。使用 PPROF 之后,我知道了4M内存已经无法在掌控之中了,我不想再更深入的挖掘了,但是这基本上就是 SSH 库内部的问题,加密那一部分。

Wolfram Alpha 的人告诉我,粗略算一下,40M是4M的十倍。客户服务超出了 Initech 的预期,确实是这样,他们的构建服务器出现了问题。他们关闭并重启了构建服务器,我们跟他们一起做了这个事情,他们很高兴我们在他们之前发现了他们的问题。因为我加了超时机制,所以我知道这十个连接会被慢慢的关掉,因为我的线上分析工具,我才知道系统在内存压力之下,所有多余的内存也将被回收再利用。

运行时间:保存。就我所知,服务已经差不多运行了六个月,期间重启了三到四次。我还没有检查过Wolfram Alpha,我认为一天至少一次。它运行得很好,这个一个很好的经验。基于此,这是我们第一个原型,使用 Go 和 Fog Creek 开发的第一个产品。我们曾经有过很多怀疑。原为它运行了很长时间,一次运行几个月不用重启,这使用每一个人都相信这是一个值得探索的技术。对我来说这是一个很好的经历,我要感谢你们的聆听。