标签 GCC 下的文章

Go语言数据竞争检测与数据竞争模式

本文永久链接 – https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go

uber,就是那个早早退出中国打车市场的优步,是Go语言早期接纳者,也是Go技术栈的“重度用户”。uber内部的Go代码仓库有5000w+行Go代码,有2100个Go实现的独立服务,这样的Go应用规模在世界范围内估计也是Top3了吧。

uber不仅用Go,还经常输出它们使用Go的经验与教训,uber工程博客就是这些高质量Go文章的载体,这些文章都值得想“深造”的gopher们反复阅读和体会。

近期该博客发布了两篇有关Go并发数据竞争的文章,一篇为《Dynamic Data Race Detection in Go Code》,另一篇为《Data Race Patterns in Go》。这两篇文章也源于uber工程师发表在arxiv上的预印版论文《A Study of Real-World Data Races in Golang》

感慨一下:不得不佩服国外工程师的这种“下得了厨房,还上得了厅堂”的研发能力,这也是我在团队中为大家树立的目标。

这里和大家过一下这两篇精简版的博客文章,希望我们都能有收获。


一. Go内置data race detector

我们知道:并发程序不好开发,更难于调试。并发是问题的滋生地,即便Go内置并发并提供了基于CSP并发模型的并发原语(goroutine、channel和select),实际证明,现实世界中,Go程序带来的并发问题并没有因此减少(手动允悲)。“没有银弹”再一次应验

不过Go核心团队早已意识到了这一点,在Go 1.1版本中就为Go工具增加了race detector,通过在执行go工具命令时加入-race,该detector可以发现程序中因对同一变量的并发访问(至少一个访问是写操作)而引发潜在并发错误的地方。Go标准库也是引入race detector后的受益者。race detector曾帮助Go标准库检测出42个数据竞争问题

race detector基于Google一个团队开发的工具Thread Sanitizer(TSan)(除了thread sanitizer,google还有一堆sanitizer,比如:AddressSanitizer, LeakSanitizer, MemorySanitizer等)。第一版TSan的实现发布于2009年,其使用的检测算法“源于”老牌工具Valgrind。出世后,TSan就帮助Chromium浏览器团队找出近200个潜在的并发问题,不过第一版TSan有一个最大的问题,那就是慢!

因为有了成绩,开发团队决定重写TSan,于是就有了v2版本。与V1版本相比,v2版本有几个主要变化:

  • 编译期注入代码(instrumentation);
  • 重新实现运行时库,并内置到编译器(LLVM和GCC)中;
  • 除了可以做数据竞争(data race)检测外,还可以检测死锁、加锁状态下的锁释放等问题;
  • 与V1版本相比,v2版本性能提升约20倍;
  • 支持Go语言。

那么TSan v2究竟是怎么工作的呢?我们继续往下看。

二. ThreadSanitizer v2版本工作原理

根据Thread Sanitizer wiki上对v2版算法的描述,Thread Sanitizer分为两部分:注入代码与运行时库

1. 注入代码

第一部分是在编译阶段配合编译器在源码中注入代码。那么在什么位置注入什么代码呢?前面说过Thread Sanitizer会跟踪程序中的每次内存访问,因此TSan会在每次内存访问的地方注入代码,当然下面的情况除外:

  • 肯定不会出现数据竞争的内存访问

比如:全局常量的读访问、函数中对已被证明不会逃逸到堆上的内存的访问;

  • 冗余访问:写入某个内存位置之前发生的读操作
  • … …

那么注入的什么代码呢?下面是一个在函数foo内写内存操作的例子:

我们看到对地址p的写操作前注入了__tsan_write4函数,函数foo的入口和出口分别注入了__tsan_func_entry和 __tsan_func_exit。而对于需要注入代码的内存读操作,注入代码则是__tsan_read4;原子内存操作使用__tsan_atomic进行注入…。

2. TSan运行时库

一旦在编译期注入代码完毕,构建出带有TSan的Go程序,那么在Go程序运行阶段,起到数据竞争检测作用的就是Tsan运行时库了。TSan是如何检测到有数据竞争的呢?

TSan的检测借助了一个称为Shadow Cell的概念。什么是Shadow Cell呢?一个Shadow Cell本身是一个8字节的内存单元,它代表一个对某个内存地址的读/写操作的事件,即每次对某内存块的写或读操作都会生成一个Shadow Cell。显然Shadow Cell作为内存读写事件的记录者,其本身存储了与此事件相关的信息,如下图:

我们看到,每个Shadow Cell记录了线程ID、时钟时间、操作访问内存的位置(偏移)和长度以及该内存访问事件的操作属性(是否是写操作)。针对每个应用程序的8字节内存,TSan都会对应有一组(N个)Shadow Cell,如下图:

N可以取2、4和8。N的取值直接影响TSan带来的开销以及data race检测的“精度”。

3. 检测算法

有了代码注入,也有了记录内存访问事件的Shadow Cell,那么TSan是通过什么逻辑检测data race的呢?我们结合Google大神Dmitry Vyukov在一次speak中举的例子来看一下检测算法是怎么运作的:

我们以N=8为例(即8个Shadow Cell用于跟踪和校验一个应用的8字节内存块),下面是初始情况,假设此时尚没有对该8字节应用内存块的读写操作:

现在,一个线程T1向该块内存的前两个字节进行了写操作,写操作会生成第一个Shadow Cell,如下图所示:

这里我们结合图中的Shadow Cell说说Pos字段。Pos字段描述的是写/读操作访问的8字节内存单元的起始偏移与长度,比如这里的0:2代表的就是起始字节为第一个字节,长度为2个字节。此时Shadow Cell窗口只有一个Shadow Cell,不存在race的可能。

接下来,一个线程T2又针对该块内存的后四个字节进行了一次读操作,读操作会生成第二个Shadow Cell,如下图所示:

此次读操作涉及的字节与第一个Shadow Cell没有交集,不存在data race的可能。

再接下来,一个线程T3针对该块内存的前四个字节进行了一次写操作,写操作会生成第三个Shadow Cell,如下图所示:

我们看到T1和T3两个线程对该内存块的访问有重叠区域,且T1为写操作,那么这种情况就有可能存在data race。而TSan的race检测算法本质上就是一个状态机,每当发生一次内存访问,都会走一遍状态机。状态机的逻辑也很简单,就是遍历这块内存对应的Shadow Cell窗口中的所有Cell,用最新的Cell与已存在的Cell逐一比对,如果存在race,则给出warning。

像这个例子中T1的write与T3的read区域重叠,如果Shallow Cell1的时钟E1没有happens-before Shadow Cell的时钟E3,那么就存在data race的情况。happens-before如何判定,我们可以从tsan的实现中找到端倪:

https://code.woboq.org/gcc/libsanitizer/tsan/tsan_rtl.cc.html

static inline bool HappensBefore(Shadow old, ThreadState *thr) {
    return thr->clock.get(old.TidWithIgnore()) >= old.epoch();
}

在这个例子中,对应一个8字节应用内存的一组Shadow Cell的数量为N=8,但内存访问是高频事件,因此很快Shadow Cell窗口就会写满,那么新的Shadow Cell存储在哪里呢?在这种情况下,TSan算法会随机删除一个old Shadow Cell,并将新Shadow Cell写入。这也印证了前面提到的:N值的选取会在一定程度上影响到TSan的检测精度。

好了,初步了解了TSan v2的检测原理后,我们再回到uber的文章,看看uber是在何时部署race检测的。

三. 何时部署一个动态的Go数据竞争检测器

通过前面对TSan原理的简单描述我们也可以看出,-race带来的数据竞争检测对程序运行性能和开销的影响还是蛮大的。Go官方文档《Data Race Detector》一文中给出使用-race构建的Go程序相较于正常构建的Go程序,运行时其内存开销是后者的5-10倍,执行时间是2-20倍。但我们知道race detector只能在程序运行时才能实施数据竞争问题的检测。因此,Gopher在使用-race都会比较慎重,尤其是在生产环境中。 2013年,Dmitry Vyukov和Andrew Gerrand联合撰写的介绍Go race detector的文章“introducing the go race detector”中也直言:在生产环境一直开着race detector是不实际的。他们推荐两个使用race detector的时机:一个是在测试执行中开启race detector,尤其是集成测试和压力测试场景下;另外一个则是在生产环境下开启race detector,但具体操作是:仅在众多服务实例中保留一个带有race detector的服务实例,但有多少流量打到这个实例上,你自己看着办^_^。

那么,uber内部是怎么做的呢?前面提到过:uber内部有一个包含5000w+行代码的单一仓库,在这个仓库中有10w+的单元测试用例。uber在部署race detector的时机上遇到两个问题:

  • 由于-race探测结果的不确定性,使得针对每个pr进行race detect的效果不好。

比如:某个pr存在数据竞争,但race detector执行时没有检测到;后来的没有data race的PR在执行race detect时可能会因前面的pr中的data race而被检测出问题,这就可能影响该pr的顺利合入,影响相关开发人员的效率。

同时,将已有的5000w+代码中的所有data race情况都找出来本身也是不可能的事情。

  • race detector的开销会影响到SLA(我理解是uber内部的CI流水线也有时间上的SLA(给开发者的承诺),每个PR跑race detect,可能无法按时跑完),并且提升硬件成本

针对上述这两个问题,给出的部署策略是“事后检测”,即每隔一段时间,取出一版代码仓库的快照,然后在-race开启的情况下,把所有单元测试用例跑一遍。好吧,似乎没有什么新鲜玩意。很多公司可能都是这么做的。

发现data race问题,就发报告给相应开发者。这块uber工程师做了一些工作,通过data race检测结果信息找出最可能引入该bug的作者,并将报告发给他。

不过有一个数据值得大家参考:在没有data race检测的情况下,uber内部跑完所有单元测试的时间p95位数是25分钟,而在启用data race后,这个时间增加了4倍,约为100分钟。

uber工程师在2021年中旬实施的上述实验,在这一实验过程中,他们找到了产生data race的主要代码模式,后续他们可能会针对这些模式制作静态代码分析工具,以更早、更有效地帮助开发人员捕捉代码中的data race问题。接下来,我们就来看看这些代码模式。

四. 常见的数据竞争模式都有哪些

uber工程师总结了7类数据竞争模式,我们逐一看一下。

1. 闭包的“锅”

Go语言原生提供了对闭包(closure)的支持。在Go语言中,闭包就是函数字面值。闭包可以引用其包裹函数(surrounding function)中定义的变量。然后,这些变量在包裹函数和函数字面值之间共享,只要它们可以被访问,这些变量就会继续存在。

不过不知道大家是否意识到了Go闭包对其包裹函数中的变量的捕捉方式都是通过引用的方式。而不像C++等语言那样可以选择通过值方式(by value)还是引用方式(by reference)进行捕捉。引用的捕捉方式意味着一旦闭包在一个新的goroutine中执行,那么两个goroutine对被捕捉的变量的访问就很大可能形成数据竞争。“不巧的”的是在Go中闭包常被用来作为一个goroutine的执行函数。

uber文章中给出了三个与这种无差别的通过引用方式对变量的捕捉方式导致的数据竞争模式的例子:

  • 例子1

这第一个例子中,每次循环都基于一个闭包函数创建一个新的goroutine,这些goroutine都捕捉了外面的循环变量job,这就在多个goroutine之间建立起对job的竞争态势。

  • 例子2

例子2中闭包与变量声明作用域的结合共同造就了新goroutine中的err变量就是外部Foo函数的返回值err。这就会造成err值成为两个goroutine竞争的“焦点”。

  • 例子3

例子3中,具名返回值变量result被作为新goroutine执行函数的闭包所捕获,导致了两个goroutine在result这个变量上产生数据竞争。

2. 切片的“锅”

切片是Go内置的复合数据类型,与传统数组相比,切片具备动态扩容的能力,并且在传递时传递的是“切片描述符”,开销小且固定,这让其在Go语言中得到了广泛的应用。但灵活的同时,切片也是Go语言中“挖坑”最多的数据类型之一,大家在使用切片时务必认真细致,稍不留神就可能犯错。

下面是一个在切片变量上形成数据竞争的例子:

从这份代码来看,开发人员虽然对被捕捉的切片变量myResults通过mutex做了同步,但在后面创建新goroutine时,在传入切片时却因没有使用mutex保护。不过例子代码似乎有问题,传入的myResults似乎没有额外的使用。

3. map的“锅”

map是Go另外一个最常用的内置复合数据类型, 对于go入学者而言,由map导致的问题可能仅次于切片。go map并非goroutine-safe的,go禁止对map变量的并发读写。但由于是内置hash表类型,map在go编程中得到了十分广泛的应用。

上面例子就是一个并发读写map的例子,不过与slice不同,go在map实现中内置了对并发读写的检测,即便不加入-race,一旦发现也会抛出panic。

4. 误传值惹的祸

Go推荐使用传值语义,因为它简化了逃逸分析,并使变量有更好的机会被分配到栈中,从而减少GC的压力。但有些类型是不能通过传值方式传递的,比如下面例子中的sync.Mutex:

sync.Mutex是一个零值可用的类型,我们无需做任何初始赋值即可使用Mutex实例。但Mutex类型有内部状态的:

通过传值方式会导致状态拷贝,失去了在多个goroutine间同步数据访问的作用,就像上面例子中的Mutex类型变量m那样。

5. 误用消息传递(channel)与共享内存

Go采用CSP的并发模型,而channel类型充当goroutine间的通信机制。虽然相对于共享内存,CSP并发模型更为高级,但从实际来看,在对CSP模型理解不到位的情况下,使用channel时也十分易错。

这个例子中的问题在于Start函数启动的goroutine可能阻塞在f.ch的send操作上。因为,一旦ctx cancel了,Wait就会退出,此时没有goroutine再在f.ch上阻塞读,这将导致Start函数启动的新goroutine可能阻塞在“f.ch <- 1”这一行上。

大家也可以看到,像这样的问题很细微,如果不细致分析,很难肉眼识别出来。

6. sync.WaitGroup误用导致data race问题

sync.WaitGroup是Go并发程序常用的用于等待一组goroutine退出的机制。它通过Add和Done方法实现内部计数的调整。而Wait方法用于等待,直到内部计数器为0才会返回。不过像下面例子中的对WaitGroup的误用会导致data race问题:

我们看到例子中的代码将wg.Add(1)放在了goroutine执行的函数中了,而没有像正确方法那样,将Add(1)放在goroutine创建启动之前,这就导致了对WaitGroup内部计数器形成了数据竞争,很可能因goroutine调度问题,是的Add(1)在未来得及调用,从而导致Wait提前返回。

下面这个例子则是由于defer函数在函数返回时的执行顺序问题,导致两个goroutine在locationErr这个变量上形成数据竞争:

main goroutine在判断locationErr是否为nil的时候,另一个goroutine中的doCleanup可能执行,也可能没有执行。

7. 并行的表驱动测试可能引发数据竞争

Go内置单测框架,并支持并行测试(testing.T.Parallel())。但如若使用并行测试,则极其容易导致数据竞争问题,原文没有给出例子,这个大家自行体会吧。

五. 小结

关于data race的代码模式,在uber发布这两篇文章之前,也有一些资料对数据竞争问题的代码模式进行了分类整理,比如下面两个资源,大家可以参照着看。

  • 《Data Race Detector》- https://go.dev/doc/articles/race_detector
  • 《ThreadSanitizer Popular Data Races》- https://github.com/google/sanitizers/wiki/ThreadSanitizerPopularDataRaces中的模式

在刚刚发布的Go 1.19beta1版本中提到,最新的-race升级到了TSan v3版本,race检测性能相对于上一版将提升1.5倍-2倍,内存开销减半,并且没有对goroutine的数量的上限限制。

注:Go要使用-race,则必须启用CGO。

// runtime/race.go

//go:nosplit
func raceinit() (gctx, pctx uintptr) {
    // cgo is required to initialize libc, which is used by race runtime
    if !iscgo {
        throw("raceinit: race build must use cgo")
    }
    ... ...
}

六. 参考资料

  • “Finding races and memory errors with compiler instrumentation” – http://gcc.gnu.org/wiki/cauldron2012?action=AttachFile&do=get&target=kcc.pdf
  • 《Race detection and more with ThreadSanitizer 2》 – https://lwn.net/Articles/598486/
  • 《Google ThreadSanitizer — 排查多线程问题data race的大杀器》- https://zhuanlan.zhihu.com/p/139000777
  • 《Introducing the Go Race Detector》- https://go.dev/blog/race-detector
  • ThreadSanitizer Algorithm V2 – https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm
  • paper: FastTrack: Efficient and Precise Dynamic Race Detection – https://users.soe.ucsc.edu/~cormac/papers/pldi09.pdf
  • paper: Eraser: A Dynamic Data Race Detector for Multithreaded Programs – https://homes.cs.washington.edu/~tom/pubs/eraser.pdf

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://51smspush.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

追求极简:Docker镜像构建演化史

本文首发于CSDN《程序员》杂志2017.12期,这里是原文地址

本文为《程序员》杂志授权转载,谢绝其他转载。全文如下:

自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来,到目前为止已经有四年多的时间了。这期间Docker技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术之一的镜像技术在Docker的快速发展之路上可谓功不可没:镜像让容器真正插上了翅膀,实现了容器自身的重用和标准化传播,使得开发、交付、运维流水线上的各个角色真正围绕同一交付物,“test what you write, ship what you test”成为现实。

对于已经接纳和使用Docker技术在日常开发工作中的开发者而言,构建Docker镜像已经是家常便饭。但如何更高效地构建以及构建出Size更小的镜像却是很多Docker技术初学者心中常见的疑问,甚至是一些老手都未曾细致考量过的问题。本文将从一个Docker用户角度来阐述Docker镜像构建的演化史,希望能起到一定的解惑作用。

一、镜像:继承中的创新

谈镜像构建之前,我们先来简要说下镜像

Docker技术本质上并不是新技术,而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在Sun公司Solaris操作系统上,Solaris是当时最先进的服务器操作系统。2005年Sun发布了Solaris Container技术,从此开启了内核容器之门。

2008年,以Google公司开发人员为主导实现的Linux Container(即LXC)功能在被merge到Linux内核中。LXC是一种内核级虚拟化技术,主要基于NamespacesCgroups技术,实现共享一个操作系统内核前提下的进程资源隔离,为进程提供独立的虚拟执行环境,这样的一个虚拟的执行环境就是一个容器。本质上说,LXC容器与现在的Docker所提供容器是一样的。Docker也是基于Namespaces和Cgroups技术之上实现的,Docker的创新之处在于其基于Union File System技术定义了一套容器打包规范,真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去,而这种文件就被称为镜像(即image),原理见下图(引自Docker官网):

img{512x368}
图1:Docker镜像原理

镜像是容器的“序列化”标准,这一创新为容器的存储、重用和传输奠定了基础。并且“坐上了巨轮”的容器镜像可以传播到世界每一个角落,这无疑助力了容器技术的飞速发展。

Solaris Container、LXC等早期内核容器技术不同,Docker为开发者提供了开发者体验良好的工具集,这其中就包括了用于镜像构建的Dockerfile以及一种用于编写Dockerfile领域特定语言。采用Dockerfile方式构建成为镜像构建的标准方法,其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用docker commit命令提交的镜像所不能比拟的。

二、“镜像是个筐”:初学者的认知

“镜像是个筐,什么都往里面装” – 这句俏皮话可能是大部分Docker初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。我们将httpserver.go这个源文件编译为httpd程序并通过镜像发布,考虑到被编译的源码并非本文重点,这里使用了一个极简的demo代码:

//httpserver.go

package main

import (
        "fmt"
        "net/http"
)

func main() {
        fmt.Println("http daemon start")
        fmt.Println("  -> listen on port:8080")
        http.ListenAndServe(":8080", nil)
}

接下来,我们来编写一个用于构建目标image的Dockerfile:

From ubuntu:14.04

RUN apt-get update \
      && apt-get install -y software-properties-common \
      && add-apt-repository ppa:gophers/archive \
      && apt-get update \
      && apt-get install -y golang-1.9-go \
                            git \
      && rm -rf /var/lib/apt/lists/*

ENV GOPATH /root/go
ENV GOROOT /usr/lib/go-1.9
ENV PATH="/usr/lib/go-1.9/bin:${PATH}"

COPY ./httpserver.go /root/httpserver.go
RUN go build -o /root/httpd /root/httpserver.go \
      && chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

构建这个Image:

# docker build -t repodemo/httpd:latest .
//...构建输出这里省略...

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd                   latest              183dbef8eba6        2 minutes ago       550MB
ubuntu                           14.04               dea1945146b9        2 months ago        188MB

整个镜像的构建过程因环境而定。如果您的网络速度一般,这个构建过程可能会花费你10多分钟甚至更多。最终如我们所愿,基于repodemo/httpd:latest这个镜像的容器可以正常运行:

# docker run repodemo/httpd
http daemon start
  -> listen on port:8080

一个Dockerfile最终生产出一个镜像。Dockerfile由若干Command组成,每个Command执行结果都会单独形成一个layer。我们来探索一下构建出来的镜像:

# docker history 183dbef8eba6
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
183dbef8eba6        21 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["/root/httpd"]   0B
27aa721c6f6b        21 minutes ago      /bin/sh -c #(nop) WORKDIR /root                 0B
a9d968c704f7        21 minutes ago      /bin/sh -c go build -o /root/httpd /root/h...   6.14MB
... ...
aef7700a9036        30 minutes ago      /bin/sh -c apt-get update       && apt-get...   356MB
.... ...
<missing>           2 months ago        /bin/sh -c #(nop) ADD file:8f997234193c2f5...   188MB

我们去除掉那些Size为0或很小的layer,我们看到三个size占比较大的layer,见下图:

img{512x368}
图2:Docker镜像分层探索

虽然Docker引擎利用r缓存机制可以让同主机下非首次的镜像构建执行得很快,但是在Docker技术热情催化下的这种构建思路让docker镜像在存储和传输方面的优势荡然无存,要知道一个ubuntu-server 16.04的虚拟机ISO文件的大小也就不过600多MB而已。

三、”理性的回归”:builder模式的崛起

Docker使用者在新技术接触初期的热情“冷却”之后迎来了“理性的回归”。根据上面分层镜像的图示,我们发现最终镜像中包含构建环境是多余的,我们只需要在最终镜像中包含足够支撑httpd运行的运行环境即可,而base image自身就可以满足。于是我们应该去除不必要的中间层:

img{512x368}
图3:去除不必要的分层

现在问题来了!如果不在同一镜像中完成应用构建,那么在哪里、由谁来构建应用呢?至少有两种方法:

  1. 在本地构建并COPY到镜像中;
  2. 借助构建者镜像(builder image)构建。

不过方法1本地构建有很多局限性,比如:本地环境无法复用、无法很好融入持续集成/持续交付流水线等。借助builder image进行构建已经成为Docker社区的一个最佳实践,Docker官方为此也推出了各种主流编程语言的官方base image,比如:gojava、node、python以及ruby等。借助builder image进行镜像构建的流程原理如下图:

img{512x368}
图4:借助builder image进行镜像构建的流程图

通过原理图,我们可以看到整个目标镜像的构建被分为了两个阶段:

  1. 第一阶段:构建负责编译源码的构建者镜像;
  2. 第二阶段:将第一阶段的输出作为输入,构建出最终的目标镜像。

我们选择golang:1.9.2作为builder base image,构建者镜像的Dockerfile.build如下:

// Dockerfile.build

FROM golang:1.9.2

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go

执行构建:

# docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .

构建好的应用程序httpd放在了镜像repodemo/httpd-builder中的/go/src目录下,我们需要一些“胶水”命令来连接两个构建阶段,这些命令将httpd从构建者镜像中取出并作为下一阶段构建的输入:

# docker create --name extract-httpserver repodemo/httpd-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-builder

通过上面的命令,我们将编译好的httpd程序拷贝到了本地。下面是目标镜像的Dockerfile:

//Dockerfile.target
From ubuntu:14.04

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

接下来我们来构建目标镜像:

# docker build -t repodemo/httpd:latest -f Dockerfile.target .

我们来看看这个镜像的“体格”:

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd                   latest              e3d009d6e919        12 seconds ago      200MB

200MB!目标镜像的Size降为原来的 1/2 还多。

四、“像赛车那样减去所有不必要的东西”:追求最小镜像

前面我们构建出的镜像的Size已经缩小到200MB,但这还不够。200MB的“体格”在我们的网络环境下缓存和传输仍然很难令人满意。我们要为镜像进一步减重,减到尽可能的小,就像赛车那样,为了能减轻重量将所有不必要的东西都拆除掉:我们仅保留能支撑我们的应用运行的必要库、命令,其余的一律不纳入目标镜像。当然不仅仅是Size上的原因,小镜像还有额外的好处,比如:内存占用小,启动速度快,更加高效;不会因其他不必要的工具、库的漏洞而被攻击,减少了“攻击面”,更加安全。

img{512x368}
图5:目标镜像还能更小些吗?

一般应用开发者不会从scratch镜像从头构建自己的base image以及目标镜像的,开发者会挑选适合的base image。一些“蝇量级”甚至是“草量级”的官方base image的出现为这种情况提供了条件。

img{512x368}
图6:一些base image的Size比较(来自imagelayers.io截图)

从图中看,我们有两个选择:busyboxalpine

单从image的size上来说,busybox更小。不过busybox默认的libc实现是uClibc,而我们通常运行环境使用的libc实现都是glibc,因此我们要么选择静态编译程序,要么使用busybox:glibc镜像作为base image。

而 alpine image 是另外一种蝇量级 base image,它使用了比 glibc 更小更安全的 musl libc 库。 不过和 busybox image 相比,alpine image 体积还是略大。除了因为 musl比uClibc 大一些之外,alpine还在镜像中添加了自己的包管理系统apk,开发者可以使用apk在基于alpine的镜像中添 加需要的包或工具。因此,对于普通开发者而言,alpine image显然是更佳的选择。不过alpine使用的libc实现为musl,与基于glibc上编译出来的应用程序不兼容。如果直接将前面构建出的httpd应用塞入alpine,在容器启动时会遇到下面错误,因为加载器找不到glibc这个动态共享库文件:

standard_init_linux.go:185: exec user process caused "no such file or directory"

对于Go应用来说,我们可以采用静态编译的程序,但一旦采用静态编译,也就意味着我们将失去一些libc提供的原生能力,比如:在linux上,你无法使用系统提供的DNS解析能力,只能使用Go自实现的DNS解析器。

我们还可以采用基于alpine的builder image,golang base image就提供了alpine 版本。 我们就用这种方式构建出一个基于alpine base image的极小目标镜像。

img{512x368}
图7:借助 alpine builder image 进行镜像构建的流程图

我们新建两个用于 alpine 版本目标镜像构建的 Dockerfile:Dockerfile.build.alpine 和Dockerfile.target.alpine:

//Dockerfile.build.alpine
FROM golang:alpine

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go

// Dockerfile.target.alpine
From alpine

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

构建builder镜像:

#  docker build -t repodemo/httpd-alpine-builder:latest -f Dockerfile.build.alpine .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED              SIZE
repodemo/httpd-alpine-builder    latest              d5b5f8813d77        About a minute ago   275MB

执行“胶水”命令:

# docker create --name extract-httpserver repodemo/httpd-alpine-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-alpine-builder

构建目标镜像:

# docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd-alpine            latest              895de7f785dd        13 seconds ago      16.2MB

16.2MB!目标镜像的Size降为不到原来的十分之一。我们得到了预期的结果。

五、“要有光,于是便有了光”:对多阶段构建的支持

至此,虽然我们实现了目标Image的最小化,但是整个构建过程却是十分繁琐,我们需要准备两个Dockerfile、需要准备“胶水”命令、需要清理中间产物等。作为Docker用户,我们希望用一个Dockerfile就能解决所有问题,于是就有了Docker引擎对多阶段构建(multi-stage build)的支持。注意:这个特性非常新,只有Docker 17.05.0-ce及以后的版本才能支持。

现在我们就按照“多阶段构建”的语法将上面的Dockerfile.build.alpine和Dockerfile.target.alpine合并到一个Dockerfile中:

//Dockerfile

FROM golang:alpine as builder

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o httpd ./httpserver.go

From alpine:latest

WORKDIR /root/
COPY --from=builder /go/src/httpd .
RUN chmod +x /root/httpd

ENTRYPOINT ["/root/httpd"]

Dockerfile的语法还是很简明和易理解的。即使是你第一次看到这个语法也能大致猜出六成含义。与之前Dockefile最大的不同在于在支持多阶段构建的Dockerfile中我们可以写多个“From baseimage”的语句了,每个From语句开启一个构建阶段,并且可以通过“as”语法为此阶段构建命名(比如这里的builder)。我们还可以通过COPY命令在两个阶段构建产物之间传递数据,比如这里传递的httpd应用,这个工作之前我们是使用“胶水”代码完成的。

构建目标镜像:

# docker build -t repodemo/httpd-multi-stage .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd-multi-stage       latest              35e494aa5c6f        2 minutes ago       16.2MB

我们看到通过多阶段构建特性构建的Docker Image与我们之前通过builder模式构建的镜像在效果上是等价的。

六、来到现实

沿着时间的轨迹,Docker 镜像构建走到了今天。追求又快又小的镜像已成为了 Docker 社区 的共识。社区在自创 builder 镜像构建的最佳实践后终于迎来了多阶段构建这柄利器,从此构建 出极简的镜像将不再困难。


微博:@tonybai_cn
微信公众号:iamtonybai
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats