Shopify的Docker实战经验(二)如何用容器支持10万的在线商店

这是系列文章的第二篇,讲述Shopify如何使用Docker支撑的容器化数据中心。这篇文章重点介绍当用户访问Shopify商店门户的时候我们底层的生产环境是如何创建出容器的。

系列文章的第一篇在这里(中文翻译)。

为什么选择容器化?

在深入讨论构建容器的原理之前,先讨论下我们的动机。容器对于数据中心的作用可能类似于控制台对于游戏的作用。在PC游戏发展的早期阶段,游戏在玩之前一般都会要求显卡和声卡驱动massaging。然而,游戏控制台提供了不一样的方式:
可预测:cartridge是自包含的:随时可用,无需下载或更新。
快速:cartridge使用只读内存,所以可以非常快。
简易:cartridge健壮并且被大范围证明 – 仅需插上即可游戏。

可预测、快速、简易都是闪亮的优点。Docker容器提供了构建模块,可以将应用放到自包含,随时可运行的单元里,这样使得运行数据中心更为简单,也更加灵活,就像cartridge带给控制台游戏的改进一样。

Bootstrapping

要完成容器化的转变需要开发和运维的双方面配合。首先,需要和运维团队沟通,确保容器能够完全复制现在的生产环境。

如果你在OSX(或者Windows)上运行,部署到Linux上,需要使用虚拟机,比如Vagrant作为本地的测试环境。首先需要得到操作系统信息和其上需要安装的支持包。选择符合生产环境(我们用的是Ubuntu 14.04)的基础镜像,拒绝任何非紧急的系统升级请求,谁都不想同时既进行容器化改进又要升级操作系统/包。

选择容器封装格式

Docker提供封装格式的选择,从“纤薄”单进程容器到更像传统意义虚拟机的“胖”容器(比如,Phusion)。

我们选择了“纤薄”容器路径,并尽量隔绝外部影响。在这两种封装格式之间很难做决定,但是更小、更简单的容器消耗的CPU和内存更少。Docker官方博客上有这种解决方案的更为详细的介绍。

环境搭建

我们使用Chef管理生产节点。虽然可以简单地在容器内部运行Chef,但是这会带进一些不想复制到每个容器里的服务(比如,日志索引和stats收集)。与其忍受重复,倒不如给每个Docker主机共享一个单独的这些服务的拷贝。

构建“纤薄”容器的路径要求把Chef请求转换成Dockerfile(后来我们更换成了自定义的构建流程 – 不过这在另一篇文章里讨论)。这样的转换也给了我们很好的机会去审查生产环境并记录下真正需要的东西(可能需要些考古学知识)。尽可能得删除不需要的东西,并且在这一阶段安排尽可能多的代码审查。

这个过程其实没有听上去那么痛苦。我们最后得到了一个125行,包含很多注释的Dockerfile,它定义了Shopify所有容器共享的基础镜像。这个基础镜像包含25个包,涉及各种编程语言的运行时环境(Ruby、Python、Node),开发工具(Git、Vim、Build-essential、Go)和一些常用库。它还包括完成一些任务的实用脚本,比如,用优化的参数启动Ruby,或者给Datadog发送事件。

应用程序也可以向这个基础镜像添加特殊的需求。即使这样,我们最大的应用程序也只是另外添加了两个操作系统包,因此我们的基础镜像是非常精简的。

100法则

当选择将什么服务放到容器里时,先假定在一台主机上运行了100个小容器,然后,问自己是否真的需要运行100个这样的服务,还是最好只共享一台主机的服务。

以下是些实际例子,展示100法则如何影响我们的容器:
日志索引:日志对于诊断生产环境问题至关重要,在容器化的世界里更为重要,因为文件系统在容器退出后就消失了。我们尽量不要改变应用自己的日志行为(比如强制应用日志重定向到syslog中),而是应该允许应用继续记录日志到文件系统里。运行100个日志传递代理看上去并不合理,因此我们构建了一个后台程序来处理一些核心任务:
在宿主机器上运行,并订阅Docker事件
当容器启动时,配置日志索引器监控容器
当容器销毁时,移除索引指令

注意有时在容器退出后需要延迟容器的销毁从而确保所有的日志都建立了索引。
统计:Shopify进行多个层级的运行时统计:系统,中间件和应用层面。统计数据通常是由代理转发或者从应用代码里直接发出。
我们大部分统计数据是由StatsD收集的,也很幸运地能配置主机端的Datadog收集器来接受容器的消息(比如,网络流量和代理配置)。因为有这些配置,只需要将StatsD的地址转发到容器里就可以了。
主机端系统监控代理能够跨越容器界限,因为容器归根到底就是个进程树。因此可以共享一个系统监控器。
从容器为中心的视角看,要考虑Datadog的Docker集成,这样可以将Docker矩阵加入到主机端的监控代理上。
应用级别上,大部分情况都可以工作,因为它们要么想要发送事件给StatsD,要么直接和其他服务通信。定义容器的名字很重要,这样日志里才会记录下有效名字。
Kafka:我们使用Kafka作为事件总线将Shopify的实时事件传送到感兴趣的组件里。构建消息并将其放置到SysV消息队列,这样可以将Ruby on Rails里的Kafka事件发布出去。一个简单的用Go写的后台程序会清空队列并且发送消息给Kafka。这样的架构减少了Ruby处理时间,帮助我们很好地解决了Kafka服务器过载的问题。不幸的是,SysV消息队列是IPC命名空间的一部分,所以我们无法为容器使用队列:主机连接。我们通过给主机添加了一个socket接入点,使用其将消息放到SysV队列里来解决了这个问题。当然,我们需要将这个接入点的地址通过环境变量传递给容器。另外一篇文章详细介绍了这个问题。

100法则的使用需要一定的灵活性。有些情况下,只需要给组件间编写些“黏合器”。而另外一些情况下,通过配置就可以实现,也有些时候,需要重新设计。最终,你应该获得一个容器,内含你的应用程序运行所需的所有东西,以及一个提供了Docker托管和共享服务的主机环境。

容器化应用

环境搭好后,接下来需要关注容器化应用本身。

在Shopify里,我们倾向于只做一件事情的“纤薄”容器,比如unicorn master和响应web请求的worker,或者响应某个特定队列的Resque worker。“纤薄”容器允许细粒度按需扩展。比如,可以增加Resque worker的数量来检查蠕虫从而应对攻击。

我们总结了一些将代码放到容器里的标准原则:
根目录始终在容器/app目录下的应用
服务暴露在单一端口的应用

我们也发现一些git仓库适合被容器化:
/container/files 包含的文件树是在容器构建时直接拷贝过来的。比如,请求某个应用日志的Splunk索引 /container/files/etc/splunk.d/inputs.conf 文件指向git仓库。注意:这是一个微妙且重要的职责转换 – 开发人员现在控制日志索引,而以前这是属于运维领域的事情。
/container/complie是编译应用的shell脚本,它输出随时可运行的容器。构建这个文件并且适应自己的应用是很复杂的。
/container/roles.json包含执行工作的命令行,以机器语言存在。很多应用用多个角色运行同一段代码 – 一些在处理后台任务时处理web消息。这一部分是受Heroku的procfile启发。这里有个roles.json示例。

我们用一个也能在本地运行的简单的Makefile构建build。Dockerfile看上去像:

记住编译阶段的目的是输出一个即刻可用的容器。Docker的重要优点之一就是极快的启动时间,尽量不要在容器启动时做额外的工作从而延长启动时间。为了达到这个目标,我们需要理解整个部署流程。一些例子说明:
使用Capistrano将代码部署到机器上,组件编译已经在部署过程中完成了。将组件编译挪到容器构建过程中进行,使得部署新的代码也就需要几分钟,简单快速。
Unicorn master启动时需要询问数据库得到表类型。这不仅仅很慢,而且很小的容器空间意味着需要更多的数据库连接。所以,可以考虑在容器构建时间来做这个从而降低启动时间。

我们的编译环节包括以下逻辑步骤:
bundle安装
组件编译
数据库启动

注意:为了控制这篇文章的篇幅,我们简化了一些细节,没有在此讨论管理诀窍,没有涉及源码管理的诀窍。可以查看这里的代码。很快会有一片文章重点讨论这个问题。

调试及其细节

在大多数情况下,应用运行在容器里时的行为和它运行在非容器化环境下没有什么不同。因此,大多数我们标准的调试技术(strace、gdb、/proc文件系统)在Docker宿主机上也都适用。

一个需要额外注意的工具是:nsenter或者nsinit,它让大家可以连接到一个正在运行的容器里去调试。Docker 1.3里有一个新的docer exec工具,可以将一个进程注入到正在运行的容器里。不幸的是,如果你想要注入的进程有root权限,还是需要nsenter。

还有些领域没能像预期那样工作,包括:

进程层次结构

尽管我们运行的是瘦容器,也始终会有一个init进程(pid=1),它允许和监控工具,秘密管控,服务发现紧密集成,允许我们进行细粒度的健康检查。

除了init进程,我们增加了ppidshim进程,在每个容器里这个进程pid=2,它只是简单去启动应用进程(pid=3)。ppidshim是为了让应用进程不直接继承于init(也就是说,ppid!=1),因为那样会让它们认为自己是后台程序而出错。
最后进程层次结构是:

Signals

如果你要容器化,很可能需要改变现有的脚本,或者重写一个新脚本,里面包含调用docker run。默认地,docer run会发出signal到你的应用,也就意味着你需要理解应用是如何处理signal的。

标准UNIX规范是发送SIGTERM请求来正常关闭进程。确保你的应用是遵守这个规范的,因为我们发现不止一个应用,比如Resque,使用SIGQUIT来正常关闭,而使用SIGTERM来紧急关闭。幸运的是,Resque(>1.22)能够配置其使用SIGTERM来触发正常关闭。

主机名

使用容器名来描述容器的工作内容(比如,unicorn-1,resque-2),并且将这个名字和机器的主机名结合使用,方便问题的跟踪。最后的结果类似:runicorn-1.server2.shopify.com。

使用Docker的–hostname标志将主机名传进容器,这使得大部分应用能使用正确的主机名。一些程序(Ruby)会使用短名(unicorn-1)而不是FQDN。

因为Docker管理/etc/resolv.conf,而现在的版本不允许随意改动,我们使用LD_PRELOAD来注入库,这个库拦截并且重载gethostname()和uname()函数。最终结果是监控工具发布我们需要的主机名而不需要更改应用代码。

注册和部署

我们发现构建能够复制“纯物理机”行为的容器的流程其实就是一个调试的过程。一旦你构建了稳健的容器之后就自然想要自动化整个构建过程。

我们使用github commit hook为每次主推触发容器的构建,并且commit statuses来标志构建是否成功。使用git commit SHA 来给容器起标签:因此可以一目了然容器里包含的是哪个版本的代码。我们也将SHA放进容器里的文件(/app/REVISION),以便调试和脚本的调用。

一旦构建出稳定的build,就需要将这个容器放到一个中央注册表里。我们选择搭建自己的注册表以便提高部署速度,同时减少对外部的依赖。我们使用nginx反向代理来缓存GET请求,在Nginx之后,使用多个相同的标准python注册表。拓扑看上去如图:


我们发现庞大的网络接口(10 Gbps)和反向代理可以有效对抗当多个Docker主机申请同一个镜像时会引起的“惊群效应”。这种采用代理的解决方法允许我们同时运行多个注册表,并且在某个注册表崩溃的时候提供自动故障转移。

如果按照我们的方法,那么你会得到自动构建的容器,它们安全地存储在中央注册表里,随时可以被用来部署。

我们的下一篇文章会讲述如何管理应用,并深入探讨如何自定义构建流程来创建尽可能小的容器。

赞(0)
未经允许不得转载:亚马逊选品软件 » Shopify的Docker实战经验(二)如何用容器支持10万的在线商店

相关推荐

  • 暂无文章

评论 抢沙发