本文是3月13日下午,七牛通用计算应用部负责人袁晓沛,在七牛架构师实践日活动上的分享。七牛通用计算应用部做的一个事情是,在基于Docker的Paas平台上跑服务器后端的常见应用,比如把MongoDB、MySQL、Redis、Memcache这些常用的服务器后端中间件做到Docker上,让他们更方便、更快捷地部署出来,提供给用户使用。以下是他的演讲实录。
我今天分享的题目是《MongoDB的Docker化实践》,将会从MongoDB的架构和部署、物理机部署运维的痛点、我们如何Docker化MongoDB、Docker化MongoDB给我们带来的好处、以及使用docker的过程中碰到的一些坑这5个方面来展开。
这是一个最基础的MongoDB集群,主从模式,它有一主两从,三个节点。这些节点之间通过心跳的方式,保持数据的同步。主故障的时候,两个从节点会选出一个主节点,客户端写请求是写到主节点里面。客户端连接MongoDB集群的时候,会有三个节点,根据MongoDB的协议,判断出哪个是主,然后写会写到主节点里面。读的话,可能会从主读,也有可能会从从读,这取决于客户端设置的一致性模式,如果它是最终一致性的话,这个读就可以读从节点。如果是强一致性的话,只能读主节点。
这是一个相对复杂的集群,支持数据分片。最上面是MongoDB的网关服务,所有用户的接入都是通过上面这两个节点。中间的节点Config Servers是配置服务,存的是整个MongoDB的分片信息。最下面是分片,分片和前面提到的的副本集是一样的。一个副本集在一个完整的分片集群里面,退化成了Mongo里面的一个分片,这个分片可以根据你的需要任意增加的。
这样的集群模式下,整个写请求是从上面的Router,也就是mongos这个服务进来,然后经过Config Servers,Config Servers告诉你这个写请求应该写到哪个分片,然后这个写请求会落到后面的某一个分片中的主节点。
这样的集群在部署的时候其实是很麻烦的。运维准备完物理机、配置好网络之后,DBA需要先把这三个副本集一个一个的启动起来、并初始化,这是一个操作。然后是启动三个Config服务。启动完了之后,需要把这个Config服务注册在mongos里面,也就是说mongos需要挂载这三个才能启动起来。mongos启动以后,还需要把下面的这些副本集,一个一个的注册到Mongos里面,这样才能形成整个的集群,所以整个的部署相当的麻烦,存在很多的痛点。
痛点一是部署特别烦琐。我们的DBA需要通知运维准备好若干台机器,配置好网络,安装上MongoDB的软件,这是第一步。然后安装上软件之后,再配置它。这个过程很难自动化,每次一个新的产品上线,上线之前需要部署MongoDB,这个步骤都要再走一遍。
痛点二是是运维特别麻烦。Mongo集群启动以后,还要配置运维,配置监控,还要监控它主从之间的延迟,延迟有多大,每个节点的读请求写请求是多少,以及更细力度的监控,这些需要对每个节点都做一遍。还有更重要的事情是备份的策略。尽管我们是副本集或者是分片的集群了,但是还是要做周期性的备份,以保持数据的安全性。这些备份、监控、运维的操作,都是手动的,而且很难自动化。
痛点三是资源浪费。一个产品在刚刚开始上线的时候,它的用户量可能比较少,但是我们却往往用了多台完整的物理机来部署整个Mongo集群的,在资源这块是相当浪费的。当然,有的同学说可以用混合部署,但混合部署的缺点是运维起来特别复杂,因为一个业务的故障会影响到其他业务,所以这个在生产环境要慎用。所以这种部署造成的结果就是资源利用率非常低,一个集群部署出来被使用的时候,它的使用量很小,却占了很多的硬件资源。
最后一个痛点是很难支持多租户。多租户意味着大家都想用同一个Mongo集群,一个方案就是直接共用,直接共用的话和混合部署的缺点是差不多的,各个业务之间会有相互影响。我们曾经碰到一个情况,就是几个业务共用一个MongoDB,一个业务上线了新版本,这个版本有一个问题就是查询忘记加索引,运行起来非常慢,导致这个数据库上的其他业务都受到影响,所以这个就是共用db集群带来的坏处。当然还有一个方式,就是自己实现MongoDB的中间件。这个中间件可以为我们做一些事情,比如限流,限制每个租户可以用多少QPS,但是它的缺点是实现过于复杂,开发周期比较长,投入太大,产出太小,也不可行。所以我们的方案是把MongoDB给Docker化。
我们Docker化的方式是基于我们七牛内部实现的一套集群管理系统。刚刚有同学提到K8S,那对这个图应该挺熟悉的,我只是把K8S图稍微改了一下。这个集群管理系统,主要有两种类型的节点。中间的节点是调度管理器,下面的是各个物理机,上面是海量的容器。容器进到调度器之后,调度器会根据这个容器对硬件资源的需求以及物理机节点集群里面实际的情况,把它调度到合适的节点上。整个它做的事情,就是这么简单。但是它还涉及到一些更复杂的东西,比如容器的编排,SDN网络的管理,以及存储的管理等,这一些我们后面会更深入的讲。
这个是我们经过Docker化之后的MongoDB集群。这个集群里面,上面有两个角色,左边是运维,右边是用户,也就是数据库的使用者。图右边有两个服务,一个是监控服务,一个是备份服务,严格来说是备份的目的地,也就是七牛的云存储。图左边整个大框里面的东西全部都跑在容器里面。
最先启动起来的三个容器是最上面的这三个,Admin Portal、User Portal、Mongo Controller;这三个启动起来之后,User Portal就可以对外提供服务了,然后用户首次进入这个User Portal可以看到Mongo集群的安装界面,可以通过这个来配置这个集群。具体的配置项如:到底这个集群里面有哪些节点,是一主两从,还是可以再加一个选举节点,或者再加一个备份节点,纯粹用于定期归档数据到外部的备份节点;还有可以选择的是每个节点的资源预留的情况,比如说它的CPU是多少,内存是多少,需要的磁盘有多大。这些关系到业务上对于MongoDB集群的QPS要求,所以需要关注。
当用户选完这些条件之后,我们的Mongo Controller,就会根据之前选定的这些参数来初始化下面这些节点和集群。假如它选了一主两从,一个选举节点,还有一个备份节点,那下面这个圈圈整个就建立起来了,而且会根据之前的具体的硬件规格参数启动起来。如果是备份节点的话,还会有一个备份和恢复的代理,这个代理是连接外部的服务,然后定期地把备份节点上的数据归档到存储里面,以备万一整个集群都坏掉了,可以做到灾难恢复。
然后这个Mongo Controller启动完这个节点之后,还要做的一件事就是配置这个节点,把这个节点按照MongoDB的协议配置成一个集群。最后做的一件事情,就是监控,与监控系统的连通。它内部有个监控的agent,在这些节点都启动起来之后,会实时的把监控的数据发送到我们的监控服务器里面。这就是整个的启动过程。
启动过程完成之后,运维也就是左上角的小人就可以通过admin portal看到整个集群的运行状况,包含我们刚才提的那些运行参数。如果某一些参数,比如主从的延迟有问题,运维就可以通过admin portal一键恢复一个节点,让它同步到最新的数据。我们把所有的运维和监控操作都通过Mongo Controller组件封装起来,通过admin portal这个web页面暴露出去,让我们的运维可以直接通过这个界面做操作,而不是像传统的那个方式,先到zabbix看监控信息,然后再登到物理机上看具体的日志,再采取措施。有了容器,我们很容易就可以把这些操作自动化起来。
为了实现上面这样的集群,我们自己定义了Docker的编排系统。提到编排,大家知道Docker其实是有自己的编排系统的,叫Dockercompose,但是在我们做的时候,那个编排系统还太弱,不能满足我们的需求。
在我们的编排系统里面,我们提供了两个纬度。一个是资源的纬度,定义各种资源。最基础的资源就是一个计算节点,还有容器,它的相关属性,它启动的时候的相关参数,最重要的是它对资源的消耗的,CPU、内存、网络,以及是否需要挂载硬盘,硬盘的规格是什么样的,硬盘多大,这个硬盘的系统的文件系统是什么格式。另外一个就是应用的纬度,我们编排系统可以定义各个应用节点,通过组合各种资源的方式,把整个应用的视图定义起来。
我们的编排系统用的是一种描述式的语法,描述式的语法就是说我写一二三四,把这个一二三四点放到我的容器系统里面,调度起来之后,这个集群就能自动构建起来了。当然这个是最理想的情况,实际上在前面提到的Mongo的集群里面还没有用到这个,而是先起了三个节点,后面节点是根据用户的输入才创建的。
日志聚合
容器日志默认的最佳实践是要达到标准输出或者是标准错误上,如果是一个封装得很深的平台的话,这种方式并不是很好用。我们目前的处理方式,是用Docker的fluentd log driver把容器归档到外部的某个地方,然后外部的日志,再根据一定的周期、现在是一个小时,归档到七牛的云存储也就是bucket里面。我们正在做的一种方式,是不但归档到bucket,同时归档到一个Elastic Search,然后通过ElasticSearch提供更丰富的一些日志的查询和检索功能。
另外一个问题就是应用的多个日志目录,其实Mongo比较简单,它只有一个日志输出地。起的时候,不配置目录,它自己就打到标准输出了。但是有很多程序,比如Java程序,我们之前在做Hadoopdocker化的时候,它一个节点输出的日志目录有很多,这种情况下,如果容器死掉,日志就没有了。所以我们的做法是,在我们编排的扩展描述里面,让一个容器挂载额外的日志目录。这些挂载点是由用户来指定的,所以指定好这些挂载点之后,用户的程序不用改,它默认就打到挂载点上了。我们会用定期归档的方式,把挂载点的日志归档到bucket,这样问题就得以解决。所以这个是通过一些外部手段来辅助解决,Docker本身解决不了这个问题。
存储管理
好多人觉得Docker只适合无状态应用,因为Docker自己本身并不提供存储管理,尽管新的Docker可以支持外部多个不同存储系统,是提供不同的driver可以适配到外部存储系统,但它本身是不做存储管理的。
因为我们要在Docker上跑数据库,所以这个存储系统我们只能自己做。我们是基于本地磁盘做了LVM管理,这个LVM管理系统可以根据你的需要,根据之前定义的编排,根据那些磁盘的规格,来生成卷或者是销毁卷,甚至我们可以做到扩容、缩容的需求。未来如果需要的话,我们计划可以扩展到网络存储。
网络管理
Docker刚刚开始的时候,它的网络也是比较差劲的。刚刚有人提到它的桥接模式性能很糟糕,但是据我了解,最新的Docker版本已经支持了SDN。我们的这个SDN是从一年半以前就开始做了,所以可以说,我们在这块还是相当有技术积累的。我们是用Open VSwitch 和 VXLan这两种技术,一个模拟交换机,一个模拟网线,来支持多租户的。因为我是做应用的,对于这一块了解并不深,我的理解就是:我创建一个应用,这些应用之间的网络互通关系是由应用层来决定的,而不是由机房运维或者是网络运维来决定。这样非常简单易用,比配置那些桥接容易得多,因为这一层已经被SDN封装起来了。另外我们基于SDN还做了网络的三层负载均衡,这一块在数据库这边,我们并没有使用,因为在数据库集群里面,各个数据节点都是单节点的,不需要负载均衡。
容器调度
容器调度是我们系统的核心。这个调度系统的要求是它能实时地搜集到运行容器的物理机节点上各种资源的消耗情况和容器情况,根据这些情况,来了一个新的容器调度请求之后,这个调度系统会根据这个容器对于资源的要求,把它调度到相应的节点上,这是调度最基本的功能。还有一个要求是非亲缘性,非亲缘性的意思是说,一个数据库有五个数据节点,这五个数据节点,不希望它调度在同一台物理机上,因为即便是容器,用集群管理,这个物理机还是会宕机的。所以非亲缘性意味着我们要调度器把这五个节点,尽可能的调度到不同的物理机上,以消除某一个物理机硬件的故障对整个应用集群的影响,所以这是我们调度的一个特性。
第一个是资源隔离。这个是毫无疑问的,资源的规格要求和资源隔离,可以通过容器轻松做到。
第二个是自动化部署。通过编排,把应用的各个节点和容器的各种资源的配置关系,以一种文档的划分方式编排起来,达到自动化部署,然后重用这些编排的目的。老的部署方式,物理机那些配置,每次都要重新配一遍,在这种情况下,只需要配置一个编排的方式,以及编排所依赖的二进制,配好之后,就可以一键部署。
第三个是自动化监控。这个和编排也有关系,但是不同的地方是,我们要依赖一个外部的监控系统。我们之所以没有把监控系统跑在容器里面,是因为风险太高了,假如这个容器系统出了问题,监控也出了问题。所以虽然现在自动化监控也在我们编排的描述里面,但是监控系统是跑在外面的,监控的数据可以通过容器内部获取到,我们再把应用容器内和外部的数据流打通。
第四个是半自动化的运维。因为有了前面的一些监控,和前面提到的MongoDB集群的admin portal,运维就可以在上面实时的看到一些集群运维的情况,如果发生了一些状况,运维可以直接基于我们之前封装好的一些运维途径,一键点击,就可以把节点启动起来或者停止,甚至是删除,所以这个是半自动化运维。实际上我们可以做到全自动化,只要把监控的参数设置的阈值和运维的API对应起来就可以,但是我们还没有这么做,因为我们对于Mongo的理解和docker的理解,我们认为还没有到那个程度。这需要对应用和平台的理解都到一定程度才敢这么做,否则会出问题。
接下来是横向伸缩。在我们这种docker化的集群里面,横向伸缩是非常简单的,也是跟运维一样的途径,就是点一个按钮,增加一个节点,然后我们的Mongo Controller执行这样一个操作,然后让这个容器挂载一块磁盘,这个磁盘上会拷贝上最新的节点数据,直接加到现有的集群里面,达到横向伸缩的目的。
下一个是纵向伸缩。这个好玩一点,因为很少有人讲纵向伸缩,这个可以说是docker给我们带来的一个便利。因为我们之前启动每个容器的时候,会为它指定CPU、内存、磁盘这些资源,如果之前预留的资源不满足要求的话,就需要调整。这个调整很简单,启动一下相应的容器的,用新的参数启动起来就可以。只要容器所在的物理机上,还有剩余的资源的话,就可以达到纵向伸缩的目的。如果那台物理机上没有相应的资源,我们调度系统可以把其他无状态的容器切走,满足它对资源伸缩的需求。
避免Docker镜像缓存
Docker镜像缓存是在做docker化的时候很容易犯的错误。上面是错误用法,第一步是下载了一个jdk的压缩包,接下来创建了一个目录,把这个压缩包解压到这个目录,并且把原来的压缩包删除了。这是分了两条命令,但是容器其实会对每一条命令做缓存。假如我们第一次运行这两条命令是没有问题的;第二次假如在第二条命令里面,我们觉得OPT这个目录不好,想到了另外一个目录,然后我们改了第二条语句,那么就会出问题了。问题出在哪里呢?因为第一条命令没有任何的变化,所以容器构建系统认为,没有任何的变化就不会执行它,就会直接跑第二条语句,第二条在前一次执行的时候,压缩包已经被干掉了,就会跑失败。这种错误很容易犯,对于这种错误我们的解决方法就是把压缩包的下载、目录的创建、解压缩和删除压缩包,都放在同一条命令里面。然后这条命令修改后,每次都会重新执行,就不会出现这个问题了。
Docker的重启策略
一个容器在启动的时候,默认可以指定三种策略(现在增加了一种),一种是不重启,另外一种是失败时重启,还有一种是总是重启。我们在做数据库的时候,刚刚开始犯过一个错误,就是把容器设置成总是重启。但是在数据库发生状况的时候,总是重启也没有用,是需要看监控的。所以建议DB类的,一般用失败时重启就行了,设置三到五次的重启次数。对于无状态的、缓存类的容器可以设置为总重启。
容器的初始进程
大家都知道,Linux进程是树状的组织,每个进程都有一个父进程。子进程启动起来之后,如果退出了,它有一部分资源是需要父进程来释放的。所以写得好的父进程,在子进程退出的时候,应该有一个waitpid来回收它派生出来的子进程的资源。但是有的父进程也可能会出现故障,在子进程退出之前,它就已经退出了,这样它的子进程退出后就变成了孤儿进程,它就会被挂到整个系统的初始进程上。在容器里面,初始进程就是容器首进程,他的实现应该能够自动回收这些孤儿进程,否则这些孤儿进程会被挂到宿主机的初始进程上,长久以后会造成资源泄露。
这也是我们做数据库的时候踩到的一个坑,就是当我们用Dockerstop停止一个容器的时候,它默认的行为是先发一个SIGTERM,然后如果我不指定StopWait时间,就会马上发一个SIGKILL信号,这样你的容器就直接被杀掉了。如果是一个数据库节点的话,我们会希望有一段时间,能够让我们优雅关闭,能够把内存里面的状态存下来,否则数据库会不一致。所以使用这个命令的时候,需要设置一个StopWait时间,设置了时间的话,等足时间之后,发现容器还没有优雅关闭,才会发SIGKILL这个信号。数据库或者是有状态类容器,需要在退出时持久化状态的情况下,最好指定这个参数。