golang在阿里开源容器项目Pouch中的应用实践

傅伟(聿歌):阿里巴巴高级研发工程师。热衷golang ,目前负责研发阿里巴巴开源容器 PouchContainer 项目的建设。


前言

       可测试的功能模块,以及在没有异常机制的情况下,PouchContainer 又是如何优雅地处理底层返回的错误。除此之外,还会分享 PouchContainer 在代码风格规范和 golang 单元测试用例上的实践。最后分享一些在生产环境下排查 golang 问题的实践。我会从五个方面介绍PouchContainer,如下图所示:


What is PouchContainer?

     首先,我们来介绍一下什么是 PouchContainer 。PouchContainer 是阿里巴巴集团开源的高效、轻量级企业级富容器引擎技术,拥有隔离性强、对低内核版本友好,可移植性高、资源占用少等特性。可以帮助企业快速实现存量业务容器化改造,同时提高超大规模下数据中心的物理资源利用率。这些特性在阿里巴巴内部都有大量的实践验证的。

   我们看这张图,可以发现 PouchContainer 对社区生态支持还是比较好,比如调度方面的 k8s 和 docker swarm,分布式存储的 ceph,以及不同容器运行时 runc/runv/kata 的支持,除此之外,阿里巴巴集团还开源了一款 p2p 镜像分发工具,可以快速下载超大容量的镜像。因为现在企业内部的镜像都比较大,假如不能够快速下载、快速分发,你的构建部署都会非常慢,在这种场景下可以通过 P2P 的方式来加快分发。

       关于 PouchContainer 的介绍就这么多,当前已经开源了,开源地址也在这里,门口也放了一些宣传册,大家可以自取看一下。除了这些之外 PouchContainer 还有一个比较切题的属性,就是它是 golang 所开发出来的,接下我会介绍 PouchContainer 是如何使用 golang 来构建项目。


pouchContainer HTTP API Code Design


单体应用&微服务应用的区别

       在讲这部分内容之前呢,我们先来简单回顾一下 单体应用 和 微服务之间的区别。

      首先我们来看下单体应用的特点,一个单体应用包含了很多个功能模块,很显然,随着业务的发展,模块的数量会逐渐增多,应用的规模也会不断变大,这会让构建和维护应用程序代码库的开发者不堪重负。当其中一个模块出问题之后,可能会直接让这个应用崩溃,导致其他模块也不可使用,这种场景很难让人能接受。

       现在社区会推崇微服务的做法,将单体应用拆分成多个模块,一个大的应用变成多个小应用,构建,部署,应用的水平拓展和维护方面都得改善。常见的操作是将模块变成 RPC 服务模块,对外由 HTTP API Server 来暴露接口,右边这张图是一个典型的 BFF backend for frontend 的组织架构。因为应用在组织层面的变化,也导致模块依赖的方式也发生了变化。在单体应用里,模块之间可以通过 share code 的方式来使用相关的业务逻辑。但是模块拆分之后,模块的开发语言可能会发现变化,依赖方式不再是 share code 的方式,而更多是通过 component client 的方式。

     那么从代码的角度看,那我们怎么去使用这些依赖呢?

Server&业务逻辑

        首先,比较简单的方式是直接使用依赖,如图所示 FooServer 直接使用了 Bar 服务的客户端,简单看起来没什么问题哈。其实想一想,这种方式可能会面临着 FooServer 并不能直接消费 Bar 客户端返回的结果,那么我们需要在 Echo 里嵌入一些适配的工作,这样会 Echo 业务逻辑阅读起来不流畅,难以维护。除此之外,如果我们想要对 Bar Client 的链接数进行优化等操作,难道我们要将优化的逻辑暴露在实际的业务逻辑里面吗?我们是不是可以把这些细节同逻辑分离开?


By interface

      所以我们会在刚刚的基础上,通过一个 interface 做隔离,业务逻辑里面不会嵌入具体实现。你所依赖的逻辑全在 interface 里面描述了,它描述的是行为和具体的逻辑。

     首先 Server 业务逻辑里的依赖由 Component interface 来描述,它描述的是行为和逻辑,这样在 Server 业务代码里并看不见逻辑背后的实现细节这样我们在做优化,在做调整的时候,并不需要调整业务逻辑代码,保证对上层逻辑不可见。

       看下右边的表格,我列了三种情况,一种是什么都不做,直接使用依赖;第二种是做适配器,比如调整参数结构,建立公用的链接池等优化;还有一种就是测试,golang 并不想ruby, python那样,可以动态地修改一个函数的行为,让其返回我们想要的结果。

       对于那些依赖外部 component 的逻辑,我们在单元测试阶段并不会启动这些服务来辅助测试,我们只能通过mock的方式来解决依赖关系,没有 interface 作为中间层,我们是很难去测试我们的业务逻辑的。

       除了这部分设计之后,还有一个重要问题需要处理的便是 http error code。当上层 http handler 拿到一个error的时候,我们该如何正确地返回错误码呢?

       Golang 同其他oop语言不同,很多语言都会通过raise 不同类型的 exception,通过统一的 try-catch 来识别不同类型的错误。回想下 golang error 定义,就一个 Error() string ,它只能返回一个字符串。我们总不能在系统里通过字符串的判断来决定返回什么样的错误码,这样就太脆弱了。那我们需要怎么做呢?


Assert ErrotType

      目前我们的实现方式是通过断言错误类型的方式。通过引入一个特别的 error type,其中 code 用来存储错误码。就目前来说,这种方式能解决http error code的问题,但是我们还可以做的更好,那就是用过判断行为的方式,返回的error除了基础的行为外,它还会具备不同的行为,比如 not found。http handler 会判断error是否具备 NOTFound 行为。

       这种方式同断言错误类型来说,并没有太大的差别。而断言错误类型而言呢,当你在构建第三方package的时候,错误类型必须要公开,而且还需要注意是否为指针类型。断言行为的方式并不需要这些细节,总的来说还是比较推荐断言行为的方式。


PouchContainer Test Practice

       接下来,我们来看看一下测试的实践。


遵循契约

      首先你得做代码检查,就像前面讲师说的,各个社区有很多开源静态观察的工具,如果这些都不能满足你的话,golang 还有一个包叫 AST,能够帮你解析语法,你也可以定制一些静态检查的东西。这些都通过了之后,才能保持项目的代码风格一致。

       在review代码过程中,代码逻辑看起来是正确的,那么怎么保证代码逻辑正确?那就需要开发者编写测试代码来验证。


DRY

      一个函数可能对应多组输出,Table-Driven 会用数组来组织测试用例,通过遍历循环来不断验证你的输入,验证你的函数是否经得起各种场景的验证。但是这个函数不一定会这么简单,可能有外部的依赖,在做这个之前可能需要做一些处理化的工作。

    PouchContainer 内部使用 Mock interface 来解决外部依赖关系。 golang 不像做 ruby 等其他脚本语言那样,可以在运行时修改函数行为,所以 PouchContainer 使用 interface 来组织依赖关系。

      如图所示,这个 Server 会依赖于 ImageServiceComponent interface,在实际运行的时候会向远端发服务请求,但是我们测试代码并不需要发出真实的服务请求,只需要验证自己的输入是否正确即可。在这种情况下,使用 Mock interface 来解决依赖会很轻松。

       上面提到的 monkeypatch 第三方库需要在编译代码的时候关闭 inline,这样它在运行的时候可以找到你函数的地址,然后把新的地址复制过去,通过这种方式修改函数的逻辑。不过还是推荐使用 interface 来组织代码结构,这方式太 hack 了。

  

Inspect too many open files issue


action takes long

      关于代码组织架构测试就讲这些,最后再向大家分享一个案例,我们前段时间遇到的,too many open files issue。

      首先它的处理时间比较长,当客户端断开连接之后服务端没法感知到,为了模拟这个场景我用1024个请求同时打到这个 API 上来模拟这个场景。我们得到了什么样的结果呢?发现那个客户端没法儿连接到 Server。根据这个错误信息,我们查改进程的 limitation,发现打开文件句柄个数的软限制是 1024,但实际上它已经超了,所以新来的请求没法建立连接。


run into the problem

       这个问题首先我们需要知道这个请求卡在哪儿。前面的导师也说了,说 golang 有很多种测试的框架和工具,比如 pprof 。但是在这个场景下用 pprof 解决不了问题,因为请求发不出去。我们尝试使用 gdb 去 dump 调用栈信息,发现全是 runtime.findrunnable 。当目前为止还是看不见具体的函数调用卡在什么位置了。

Kill-USR1

      为了更让服务正常退出,一般会在启动的时候监听信号量。假如获取到了Kill信号量,服务就会做一些清理工作,就很优雅的正常退出。所以我们在启动服务的时候,起一个 goroutine 去监听 USR1 的信号量,一旦接受到信号就会 dump goroutine stack.

      通过这种方式,只要你发一个 Kill-USR1 就会打到你的日志文件里面,就能知道当时哪个 API 出了问题这种方式的好处,在于你用 pprof 会暴露接口,暴露接口可能会遇到连接数过多的问题,可能很难定位到当时的问题。这种方式有的优点,在于你不需要暴露接口,你必须有权限登陆到机器上才能看到当时运行时的状态。所以我们比较推荐这种方式去操作,比较安全一些


Q&A

       提问:没有加信号量方法的时候,是怎么发现和定位这个问题的?


       傅伟:当时发现这个服务 ping 不通了,在迁移完业务进程之后,尝试了各种方法之后决定重启,并加入上述代码,等到它第二次出现的时候我们才拿到结果。


        提问:你这个做法我以前也试过。但有时候出了问题,你很难找到具体的问题。


        傅伟: 对,第一现场比较重要。因为这次出问题不在于系统调用,不然的话 dump stack 还是可以发现问题的。多说一句,golang 不会去回收空闲的线程,当系统调用时间比较长的时候,golang 会创建新的线程去执行任务。当你的线程涨到200的时候是很恐怖的,但实际上 golang 现在的模型只需要几十个,不需要上百个,在这个角度看,golang也有它的一些调度问题。


       提问:还是回到那个问题,线上使用 gdb attach,这是严格禁止的。还有是怎么发现哪个打开文件太多?它可以看进程,打开了什么文件可以看出来。我们以前也是碰到程序运行了大半年,但文件打开太多出现了问题,最终问题还是没有写好,通过改定位打开什么文件之后,然后定位到这个程序里面去,最后修改掉。


      傅伟:首先我们做的事情是把容器迁进去,我们已经把一些生产中的东西迁移出去了,所以操作都是得到业务方,包括应用方允许之后才这么做。当时这个进程已经不再服务了,只用来看问题。问题是因为打开文件句柄太多了,使用 dump stack 的原因也是想要看到第一现场,不会在线上直接这么操作,操作的时候已经是一个完全隔离的环境了。


       提问:刚刚那个问题你是模拟的,我想知道真正线上是什么样的问题。


       傅伟:因为当时是一个锁的问题,因为 IO 处理需要等待它把数据全吐出来之后然后才能正常关闭,但是这个关闭一直等在那儿。因为我们当时有一个漏洞,会导致这个数据一直没有吐出来。


       提问:是不是用检测机制也可以?


      傅伟:检测不出,它跟竞争没有关系,是一个漏洞导致这个数据没法儿正常退出。