容器化实践的经验分享

The Experience Sharing of Containerization Practice.

Posted by XuXinkun on November 27, 2016

前言

在私有云的容器化过程中,我们并不是白手起家开始的。而是接入了公司已经运行了多年的多个系统,包括自动编译打包,自动部署,日志监控,服务治理等等系统。在容器化之前,基础设施主要以物理机和虚拟机为主。因此,我们私有云落地的主要工作是基础设施容器化,同时在应用的运维方面,兼用了之前的配套系统。利用之前的历史系统有利有弊,这些后面再谈。关于我们的私有云的一些架构技术,在我们团队过去的一些公开分享中已经做了公开,在这里我主要同大家分享一下在容器化落地实践中的一些经验和教训。希望对大家有所帮助。

容器与虚拟机

当我们向别人讲述什么是容器的时候,常常用虚拟机作类比。在给用户进行普及的时候,我们可以告诉他,容器是一种轻量级的虚拟机。但是在真正的落地实践的时候,我们要让用户明白这是容器,而不是虚拟机。这两者是有本质的区别的。

虚拟机的本质上是模拟。通过模拟物理机上的硬件,向用户提供诸如CPU、内存等资源。因此虚拟机上可以且必须安装独立的操作系统,系统内核与物理机的系统内核无关。因此一台物理机上有多个虚拟机时,一个虚拟机操作系统的崩溃不会影响到其他虚拟机。而容器的本质是经过隔离与限制的linux进程。容器实际使用的还是物理机的资源,容器之间是共享了物理机的linux内核。这也就意味着当一个容器引发了内核crash之后,会殃及到物理机和物理机上的其他容器。从这个角度来说,容器的权限和安全级别没有虚拟机高。但是反过来说,因为能够直接使用CPU等资源,容器的性能会优于物理机。

容器之间的隔离性依赖于linux提供的namespace。namespace虽然已经提供了较多的功能,但是,系统的隔离不可能如虚拟机那么完善。一个最简单的例子,就是一个物理机上的不同虚拟机可以设置不同的系统时间。而同一个物理机的容器只能共享系统时间,仅仅可以设置不同的时区。

另外,对于容器资源的限制是通过linux提供的cgroup。在容器中,应用是可以感知到底层的基础设施的。而且由于无法充分隔离,从某种程度上来说,容器可以看到宿主机上的所有资源,但实际上容器只能使用宿主机上的部分资源。

我举个例子来说。一个容器的CPU,绑定了0和1号核(通过cpuset设置)。但是如果应用是去读取的/proc/cpuinfo的信息,作为其可以利用的CPU资源,则将会看到宿主机的所有cpu的信息,从而导致使用到其他的没有绑定的CPU(而实际由于cpuset的限制,容器的进程是不能使用除0和1号之外的CPU的)。类似的,容器内/proc/meminfo的信息也是宿主机的所有内存信息(比如为10G)。如果应用也是从/proc/meminfo上获取内存信息,那么应用会以为其可用的内存总量为10G。而实际,通过cgroup对于容器设置了1G的最高使用量(通过mem.limit_in_bytes)。那么应用以为其可以利用的内存资源与实际被限制使用的内存使用量有较大出入。这就会导致,应用在运行时会产生一些问题,甚至发生OOM崩溃。

这里我举一个实际的例子。

这是我们在线上的一个实际问题,主要现象是垃圾回收偏长。具体的问题记录和解决在开涛的博客中有详细记录使用Docker容器时需要更改GC并发参数配置

这里主要转载下问题产生的原因。

1、因为容器不是物理隔离的,比如使用Runtime.getRuntime().availableProcessors() ,会拿到物理CPU个数,而不是容器申请时的个数,

2、CMS在算GC线程时默认是根据物理CPU算的。

这个原因在根本上来说,是因为docker在创建容器时,将宿主机上的诸如/proc/cpuinfo,/proc/meminfo等文件挂载到了容器中。使得容器从这些文件中读取了相关信息,误以为其可以利用全部的宿主机资源。而实际上,容器使用的资源受到了cgroup的限制。

上面仅仅举了一个java的例子。其实不仅仅是java,其他的语言开发出来的应用也有类似的问题。比如go上runtime.GOMAXPROCS(runtime.NumCPU()),nodejs的Environment::GetCurrent()等,直接从容器中读取了不准确的CPU信息。又比如nginx设置的cpu亲和性绑定worker_cpu_affinity。也可能绑定了不准确的CPU。

解决的渠道一般分为两种:

一种是逢山开路遇水搭桥,通过将容器的配置信息,比如容器绑定的cpu核,容器内存的限制等,写入到容器内的一个标准文件container_info中。应用根据container_info中的资源信息,调整应用的配置来解决。比如修改jvm的一些参数,nginx的修改绑定的cpu编号等。

在docker后来的版本里,容器自己的cgroup会被挂载到容器内部,也就是说容器内部可以直接通过访问/sys/fs/cgroup中对应的文件获取容器的配置信息。就不必再用写入标准文件的方式了。

另一种是增强容器的隔离性,通过向容器提供正确的诸如/proc/cpuinfo,/proc/meminfo等文件。lxcfs项目正是致力于此方式。

我们使用的是前一种方式。前一种方式并不能一劳永逸的解决的所有问题,需要对于接入的应用进行分析,但是使用起来更为稳定。

graph driver的坑

在容器化落地的实践过程中,难免会遇到很多坑。其中一个坑是就是graph driver的选择问题。当时使用的centos的devicemapper,遇到了内核crash的问题。这个问题在当时比较棘手,我们一开始没能解决,于是我们自己写了一版graph driver,命名为vdisk。这个vdisk主要是通过稀疏文件来模拟union filesystem的效果。这个在实际使用中,会减慢创建速度,但是益处是带来了极高的稳定性,而且不再有dm的data file的预设磁盘容量的限制了。因为容器创建后,还需要启动公司的工具链,运维确认,然后切流量等才能完成上线,所以在实际中,该方式仍然有着极好的效果。

不过我们没有放弃dm,在之后我们也解决了dm带来的内核crash问题,这个可以参见蘑菇街对此的分享记录使用Docker时内核随机crash问题的分析和解决。配置nodiscard虽然解决了内核crash的问题,但是在实际的实践中,又引入了一个新的问题,就是容器磁盘超配的问题。就比如dm的data是一个稀疏问题,容量为10G,现在有5个容器,每个容器磁盘预设空间是2G。但是容器文件实际占用只有1G。这时理论上来说,创建第6个2G的容器是没问题的。但是如果这个5个容器,虽然实际文件只占用1G,但是容器中对于某个大文件进行反复的创建、删除(如redis的aof),则在dm的data中,会实际占用2G的空间。这时创建第6个容器就会因为dm的data空间不足而无法创建。这个问题从dm角度来说不好直接解决,在实际操作中,通过与业务的沟通,将这种频繁读写的文件放置到外挂的volume中,从而解决了这个问题。

从这个坑中,我们也对于后来的业务方做了建议,镜像和根目录尽量只存只读和少量读写的文件,对于频繁读写或大量写的文件,尽量使用外挂的volume。当然,我们对外挂的volume也做了一些容量和写速度的限制,以免业务之间互相影响。规范业务对于容器的使用行为。

docker版本选择

在开始研究docker时,docker的稳定版本还是1.2和1.3。但是随着docker的火热,版本迅速迭代。诚然,新的版本增加了很多的新功能。但是也可能引入了一些新的bug。我们使用docker的基本理念是新技术不一定都步步跟上。更多的是考虑实际情况,版本尽量的稳定。落地是第一要务。而且我们的容器平台1.0更偏向于基础设施层,容器尽量不进行重启和迁移。因为业务混合部署在集群中,当一个宿主机上的docker需要升级,则容器需要迁移,那么可能涉及到多个业务方,要去与各个业务方的运维、研发等进行沟通协调,相当的费时费力。我们在充分测试之后,定制了自己的docker版本,并稳定使用。docker提供的功能足够,尽量不再升级,新增功能我们通过其他的方式进行实现。

监控与巡检

这里所指的监控主要指对资源,如CPU、内存等的监控。如前文中所述,由于容器的隔离性不足,因此容器中使用top等看到的信息,也不完全是容器内的信息,也包含有宿主机的信息。如果用户直接在容器中使用工具获取资源监控信息,容易被这些信息误导,无法准确判断资源的使用情况。因此我们自己研发完成了一套容器平台监控系统,负责容器平台的资源监控,对于物理机和容器的资源使用情况进行采集和整理,统一在前端呈现给用户。并集成了资源告警、历史查询等功能。

由于容器平台系统,包含了多个子系统,以及许多的模块。系统庞大,就涉及到了诸多的配置、状态检查等等。我们对应开发了一套巡检系统,用于巡查一些关键信息等。巡检系统一方面定时巡查,以便核实各个组件的工作状态;另一方面,当出现状况时,使用巡检系统对于定位出现问题的节点,分析问题产生的原因有极多的帮助。

标准化的工具

在没有容器化之前,公司内部逐渐形成了自己的一套工具链,诸如编译打包、自动部署、统一监控等等。这些已经为部署在物理机和虚拟机中的应用提供了稳定的服务。因此在我们的私有云落地实践中,我们尽可能的兼容了公司的工具链,在制作镜像时,将原有的工具链也都打入了镜像中。真正实现了业务的平滑迁移。

当然,打包了诸多的工具,随之带来的就是镜像的庞大,镜像的体积也不可遏制的从几百兆增长到了GB级别。而借助于工具链的标准化,镜像的种类就被缩减为了几种。考虑到创建容器的速度,我们采用了镜像预分发的方式,将最新版本的镜像及时推送到计算节点上,虽然多占用了一些磁盘空间,但是有效防止了容器集中创建时,镜像中心的网络、磁盘读写成为瓶颈的问题。

使用已有工具链也意味着丧失了docker容器的一些优良特性。比如应用的发包升级上线仍然通过既有的自动部署系统,而无法利用docker的镜像分层。工具链之间的壁垒也制约了平台的集成能力,难于实现一键部署的效果。容器的弹性和迁移也只能以一个空壳容器本身的伸缩迁移体现,而不是应用层级的伸缩迁移。

弹性与迁移

弹性主要包括横向伸缩和纵向伸缩。横向伸缩主要是指调整应用容器的数量,这个主要通过创建/销毁容器进行实现。纵向伸缩主要是对单个容器的资源规格进行改变。因为容器对于CPU和内存的限制,主要是通过cgroup实现的。因此纵向的伸缩主要是通过修改cgroup中对应的值进行。

容器的迁移还是冷迁移的方式呈现。由于公司相关业务的要求,容器的IP要尽量保持不变。因此我们在neutron中做了定制,可以在迁移后保证容器的ip地址不变,这样对外启动后呈现的服务不会有变化。

容器的运维

目前运行有大量容器,部署在多个机房,分为多个集群。如此大规模的容器运维,实际集群的运维人员并不多。主要原因是对于运维的权限进行了分割。对于物理机、容器生命周期的管理,由集群的运维负责。而各个线上应用的运维,由各个应用配合的垂直运维(又称为应用运维)负责。一般问题,如物理机下线,因为涉及到应用下的该实例需要下线,由集群运维查看该物理机上的容器所属的应用,需要通知垂直运维,配合容器迁移,而后重新上线提供服务。二新增机器或者集群,对机器部署了容器平台系统后,即可交付集群运维,用以创建容器实例,并进而根据应用的申请,分配给各个应用。相较于一个应用的平台,很多操作还有一些手工的成分,因此还需要投入相当的人力在集群管理上。