1.1 虚拟化与容器
1.1.1 计算资源虚拟化
虚拟化在现代计算机领域的使用相当广泛,它通过将真实的硬件抽象为软件可控的逻辑单元,使得昂贵的硬件资源能够按需、按量分配,以达到减少浪费、实现硬件利用率最大化的目的。计算资源的虚拟化是虚拟化领域里比较重要的一个分支,这里的计算资源主要指的是CPU、内存、硬盘等与计算机运算直接相关的硬件资源。在很长的一段时间里,计算资源的虚拟化始终是各类虚拟机技术争夺的热土。从最初IBM和Sun等公司主导的早期CPU虚拟化实现,到VMware、Xen、KVM、QEMU等虚拟化或半虚拟化技术的成熟,就经历了40余年的发展过程。
与此同时,另一种虚拟化技术的分支也在缓慢发展。这类虚拟化技术不依赖与硬件相关的特性,而是在系统内核的层次之上,将进程运行的上下文环境加以限制和隔离。最初它们并不被视为虚拟化方法,比如在Unix系统中引入的chroot工具仅仅是将特定进程的文件上下文锁定在特定目录中,制造出在同个系统里模拟多个隔离的系统目录的效果。随后,在FreeBSD 4.0中出现的Jails和前SWsoft公司(现已更名为Parallels)开发的基于Windows系统的Virtuozzo(睿拓)等技术在chroot的基础上增加了进程空间和网络空间的隔离,更好地实现了进程之间互不干扰地共享硬件资源的目的。这种虚拟化方式就是最早的容器技术雏形。
随后不久,IBM、Sun和惠普等老牌虚拟机和操作系统公司也纷纷进入不依赖特定CPU和硬件支持的虚拟化技术阵营,分别在自家的操作系统里推出相应的产品,例如运行在Solaris系统的Zones、运行在IBM AIX小型机系统的WPARs以及运行在惠普服务器系统HP-UX的SRP Containers等。在这段时期里,特别值得一提的是在开源GNU/Linux系统上实现的操作系统级虚拟化服务:Linux-VServer。
开源社区的介入使得这类虚拟化技术迅速发展,并被应用到更多的领域中。然而作为社区产品,由于参与人员众多、早期目标定位不明确,Linux-VServer的配置细节很复杂,加上文档十分混乱,因此当时只有对Linux内核有一定了解的用户才能驾驭它。这种状态一直持续到2005年,这一年,曾经设计了Virtuozzo的SWsoft公司开始在Linux系统上开发一款全新的开源虚拟化产品:OpenVZ。这款采用了GNU/GPL协议开源的软件很好地改善了Linux系统上进行虚拟化隔离的使用体验,并为SWsoft公司带来了可观的收入。同一时期还诞生了一个对虚拟化技术影响颇远的概念:VPS(Virtual Private Server,虚拟专用服务器)。VPS技术是指将一台服务器分割成多个逻辑上的虚拟专享服务器。每个VPS都可分配独立公网IP地址、独立操作系统、独立硬盘空间、独立内存和CPU资源。这种虚拟主机间的隔离服务为用户和应用程序模拟出“独占使用计算资源”的体验。OpenVZ理所当然地成为了当时虚拟化技术的代表之一,与Xen、KVM并列成为VPS提供商首选的虚拟化实施方案。
此时的Linux系统级虚拟化技术已经逐渐成熟,然而对代码质量颇为严苛的Linux内核团队并没有采纳Linux-Vserver或OpenVZ提交的内核补丁,而是在Linux 2.6的内核中重新设计了Namespace和CGroup等功能,实现了更加灵活的可组合式虚拟化能力。随后在2008年,Linux社区就出现了基于内核隔离能力设计的LXC(Linux Containers)虚拟化项目,它充分利用Linux内核的Namespace隔离能力和CGroup(Control Groups,控制组)控制能力实现了操作系统内核层面上的虚拟化,不再需要修改内核代码,大大降低了使用该项技术的门槛。此后的几年里,又相继出现了linux-utils和systemd-nspawn等Linux内核虚拟化工具。Linux系统开始在内核虚拟化技术的演进过程中发挥越来越重要的作用,如图1-1所示。
图1-1 内核虚拟化技术的演进
2013年3月,DotCloud公司(现已更名为Docker公司)基于LXC项目封装了一套工具,将其命名为Docker。在Docker的宣传下,这种基于操作系统的虚拟化技术被广泛地称为Container(容器),并为许多开发者所知。2014年,Docker重新设计自己的虚拟化层并发布了Libcontainer项目,从此脱离了对LXC的依赖,成为一个独立发展的平台。2015年,Docker与微软、IBM等几十家公司共同成立规范化容器技术标准的OCI组织(Open Container Initiative,开放容器计划),并设立新的符合OCI标准的容器引擎RunC。在此期间,由CoreOS公司主导的Rkt容器项目也在迅速地成长,作为比Docker更加轻量的容器工具成为了许多容器集群技术的备选搭配方案。
1.1.2 容器技术的本质
虚拟机和容器技术的目的都是抽象硬件并对系统资源提供隔离和配额,然而两者在本质上有着很大的差异。
虚拟机的原理是通过额外的虚拟化层,将虚拟机中运行的操作系统指令翻译成宿主机系统能够执行的系统调用,然后操作具体的硬件。这样做能够比较好地实现虚拟机和宿主机操作系统的异构,例如在Linux系统上运行Windows的虚拟机,或是在Mac系统上运行Linux的虚拟机。其缺点是通常需要依赖硬件支持,特别是CPU虚拟化的支持。
容器技术则完全建立在操作系统内核特性之上,是一种与运行硬件无关的虚拟化技术。由于这种方式实现的虚拟化没有转换异构指令的虚拟化层,因此在运行效率上较虚拟机方式更高,但只能实现与宿主操作系统相同系统的虚拟化。在实际使用中,有时会将容器技术和虚拟机结合,以实现“跨不同操作系统”运行容器的目的,Windows和Mac版本的Docker就是这样的例子。
虚拟机和容器技术的结构差异如图1-2所示。注意容器结构中的“容器工具”只是一个逻辑层,它并没有与容器中的应用程序存在实际的层级关系,而是同样运行在宿主操作系统上,两者是两个不同隔离空间中的平级服务而已。
图1-2 虚拟机与容器的对比
前文在介绍容器技术演进史时已经提到,现代Linux系统中的容器技术主要是利用内核的Namespace特性和CGroup特性实现了服务进程组的资源隔离和配额。
1.Namespace
Linux内核实现Namespace的主要目的就是为了实现内核级虚拟化(容器)服务,让同一个Namespace下的进程可以感知彼此的变化,同时又能确保对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛置身于一个独立的系统环境中,以达到独立和隔离的目的。
Mount Namespace在2002年进入Linux 2.4.19内核,它是内核中最早出现的、用于运行时隔离的Namespace。较新的Linux 4.7.1版本内核已经实现了7种不同的Namespace类型,除了比较特殊的CGroup Namespace用于隔离进程组对不同CGroup的可视性外,其他的6种都和某些传统意义上的系统资源直接相关。
通过查看“/proc”目录下以进程PID作为名称的子目录中的信息,能够了解该进程的一组Namespace ID,如下所示。
# ls -l /proc/1240/ns total 0 lrwxrwxrwx 1 root root 0 Aug 18 15:46 cgroup -> cgroup:[4026531835] lrwxrwxrwx 1 root root 0 Aug 18 15:46 ipc -> ipc:[4026531839] lrwxrwxrwx 1 root root 0 Aug 18 15:46 mnt -> mnt:[4026531840] lrwxrwxrwx 1 root root 0 Aug 18 15:46 net -> net:[4026531993] lrwxrwxrwx 1 root root 0 Aug 18 15:46 pid -> pid:[4026531836] lrwxrwxrwx 1 root root 0 Aug 18 15:46 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Aug 18 15:46 uts -> uts:[4026531838]
显然,每个进程都具有这样的7个属性,因此每个进程都会分别在这些Namespace控制的系统资源上与另一些进程共享空间。在没有使用容器的情况下,系统中所有进程都具有相同的Namespace ID组合。然而如果有一个进程运行在Docker或其他的容器里,它就很有可能具有完全不同的一组Namespace ID。这些Namespace是可以任意组合的,容器中的进程也可以只做部分的隔离,例如在使用--network=host参数启动的Docker容器中运行的进程就会与主机上的其他进程拥有相同的Network Namespace ID,此时它可以直接通过127.0.0.1的地址访问主机上运行的服务。
下面对每种Namespace的作用进行简单地解释。
·CGroup Namespace:提供基于CGroup(控制组)的隔离能力。CGroup是Linux在内核级对进程可用资源进行限制的一组规则,CGroup的隔离能够让不同进程组看到的CGroup规则各不相同,为不同进程组采用各自的配额标准提供便利。
·IPC Namespace:提供基于System V进程信道的隔离能力。IPC全称为Inter-Process Communication,是Linux中一种标准的进程间通信方式,包括共享内存、信号量、消息队列等具体方法。IPC隔离使得只有在同一个命名空间下的进程才能相互通信,这一特性对于消除不同容器空间中进程的相互影响具有十分重要的作用。
·Mount Namespace:提供基于磁盘挂载点和文件系统的隔离能力。这种隔离的效果与chroot系统调用十分相似,但从实际原理来看,MNT Namespace会为隔离空间创建独立的mount节点树,而chroot只改变了当前上下文的根mount节点位置,从而影响文件系统查找文件和目录的结果。在文件系统隔离的作用下,容器中的进程将无法访问到容器以外的任何文件。在必要情况下,可以通过挂载额外目录的方式和主机共享文件系统。
·Network Namespace:提供基于网络栈的隔离能力。网络栈的隔离允许使用者将特定的网卡与特定容器中的进程运行上下文关联起来,使得同一个网卡在主机和容器中分别呈现不同的名称。Network Namespace的重要作用之一就是让每个容器通过命名空间来隔离和管理自己的网卡配置。因此可以创建一个普通的虚拟网卡,并将它作为特定容器运行环境的默认网卡eth0使用。这些虚拟网络网卡最终可以通过某些方式(NAT、VxLan、SDN等)连接到实际的物理网卡上,从而实现像普通主机一样的网络通信。
·PID Namespace:提供基于进程的隔离能力。进程隔离使得在容器中的首个进程成为所在命名空间中PID值为1的进程。在Linux系统中,PID为1的进程地位非常特殊,它作为所有进程的根父进程,有很多特权,比如屏蔽信号、接管孤儿进程等。一个比较直观的现象是,当系统中的某个子进程脱离了父进程(例如父进程意外结束),那么它的父进程就会自动成为系统的根父进程。此外,当系统中的根父进程退出时,所有同属一个命名空间的进程都会被杀死。
·User Namespace:提供基于系统用户的隔离能力。系统用户隔离是指同一个系统用户在不同的命名空间中可以拥有不同的UID(用户标识)和GID(组标识),它们之间存在一定的映射关系。因此,在特定命名空间中,UID为0的用户并不一定是整个系统的root管理员用户。这一特性限制了容器的用户权限,有利于保护主机系统的安全。
·UTS Namespace:提供基于主机名的隔离能力。主机名隔离是指每个独立容器空间中的程序可以有不同的主机名称信息。值得一提的是,主机名只是一个用于标示虚拟主机或容器空间的代号,并不一定是全网唯一的,允许重复。因此,它虽然可以在网络中用于通信或定位服务,但并不是可靠的方法。
2.CGroup
CGroup是Linux内核提供的一种可以限制、记录、隔离进程组所使用的物理资源(包括CPU、内存、磁盘I/O速度等)的机制,它的v1版本由Google的Paul Menage设计,并在2007年进入Linux 2.6.24内核(这个内核版本实际上在2008年1月才正式发布),之后发布的v2版本在2016年3月也已经成为了Linux 4.5内核的一部分。CGroup也是LXC等现代容器管理虚拟化系统资源的手段。
CGroup最初设计出来是为了统一Linux下混乱的资源管理工具,例如过去限制CPU使用时可以用renice和cpulimit命令,限制内存要用ulimit或者PAM(Pluggable Authentication Modules),而限制磁盘I/O和网络又需要其他的专用工具。CGroup作为一种内核级的资源限制手段,在功能和效率方面都是早期工具不能比拟的。值得一说的是,在现代的Linux进程管理中,CGroup的使用已经比较普遍,并不是非得使用容器才与CGroup有关。例如在采用了Systemd管理服务的Linux发行版中,其service文件定义中使用的MemoryLimit、BlockIOWeight等配置其实就是在间接地为进程配置CGroup。又如在专门为大规模服务器部署而设计的CoreOS操作系统里,为了避免在系统升级时因占用数据带宽而影响正常业务服务,同样使用CGroup的管控实现了“只在带宽空闲时下载”的效果,这些应用都是与容器没有直接关系的。
在Linux“一切皆文件”的思想中,CGroup同样直观地表现为一些特殊的目录和文件。查看任意一个进程在“/proc”目录下的内容,可以看到下面这样一个名为“cgroup”的文件。
# cat /proc/1240/cgroup 11:perf_event:/ 10:blkio:/ 9:freezer:/ 8:cpu, cpuacct:/ 7:net_cls, net_prio:/ 6:devices:/ 5:cpuset:/ 4:pids:/ 3:memory:/ 2:hugetlb:/ 1:name=systemd:/
这个文件中除了最后一行,都对应了一个CGroup的子系统。每个子系统是CGroup中用于定义某类资源控制规则的结构。与Namespace类似,如果观察运行在主机上每个进程的cgroup文件内容,会发现它们中的绝大多数都一样,也就是说共用了相同的CGroup。但如果观察一个运行在容器中的进程,会发现它具有与其他进程完全不同的一组CGroup路径。
那么这个路径到底指的是哪里呢?来看一下当前系统中的所有挂载点,会看到其中有许多是CGroup类型的项目,如下所示。
# mount | grep 'type cgroup' cgroup on /sys/fs/cgroup/systemd type cgroup (…) cgroup on /sys/fs/cgroup/cpuset type cgroup (…) cgroup on /sys/fs/cgroup/blkio type cgroup (…) cgroup on /sys/fs/cgroup/perf_event type cgroup (…) cgroup on /sys/fs/cgroup/pids type cgroup (…) cgroup on /sys/fs/cgroup/devices type cgroup (…) cgroup on /sys/fs/cgroup/net_cls, net_prio type cgroup (…) cgroup on /sys/fs/cgroup/memory type cgroup (…) cgroup on /sys/fs/cgroup/freezer type cgroup (…) … …
这当中的每个挂载点都是一个CGroup子系统的根目录,例如上一例中那个进程所属的cpuset子系统路径为“/”,实际上就是指“/sys/fs/cgroup/cpuset”这个目录,其余子系统的位置也可以此类推。
在Linux 4.7.1内核中,已经支持了10类不同的子系统,分别如下所示。
·hugetlb子系统用于限制进程对大页内存(Hugepage)的使用。
·memory子系统用于限制进程对内存和Swap的使用,并生成每个进程使用的内存资源报告。
·pids子系统用于限制每个CGroup中能够创建的进程总数。
·cpuset子系统在多核系统中为进程分配独立CPU和内存。
·devices子系统可允许或者拒绝进程访问特定设备。
·net_cls和net_prio子系统用于标记每个网络包,并控制网卡优先级。
·cpu和cpuacct子系统用于限制进程对CPU的用量,并生成每个进程所使用的CPU报告。
·freezer子系统可以挂起或者恢复特定的进程。
·blkio子系统用于为进程对块设备(比如磁盘、USB等)限制输入/输出。
·perf_event子系统可以监测属于特定的CGroup的所有线程以及运行在特定CPU上的线程。
为了比较方便地与系统CGroup进行交互,可以安装CGroupTools工具包。对于Debian或Ubuntu系统可使用apt-get或apt命令安装,如下所示。
# apt install cgroup-tools
Redhat或CentOS系统则可用yum或dnf命令来安装,如下所示。
# dnf install libcgroup-tools.x86_64
这个工具包中包含了一组用于创建和修改CGroup信息的命令。以通过CGroup实现CPU的配额限制为例,安装完cgroup-tools工具包后,通过cgcreate命令来创建两个CPU CGroup的子系统分组(需要root权限来执行),如下所示。
# cgcreate -g cpu:/cpu50 # cgcreate -g cpu:/cpu30
其中-g cpu表示设定的是CPU子系统的配额。除CPU子系统外,cgcreate同样可以创建其他各种配额的子系统。通过lscgroup命令就能看到创建出来的这两个子系统,如下所示。
# lscgroup … … cpu:/ cpu:/cpu30 cpu:/cpu50 … …
接下来为两个CPU分组分别设置一条限制规则,CPU子系统的cfs_quota_us可以设定进程在每个“时间片周期”内可占用的最大CPU时间,单位是μs。CPU时间片周期由子系统的cfs_period_us属性指定,默认为100000,单位同样是 μs。因此例如cfs_quota_us的数值50000和30000表示在该组中的进程最多分别能够使用50%和30%的CPU时间。使用cgset命令将规则设定进去,如下所示。
# cgset -r cpu.cfs_quota_us=50000 cpu50 # cgset -r cpu.cfs_quota_us=30000 cpu30
构造一个耗CPU的程序,例如下面这个不断累加并输出数据的脚本,将其命名为“app.sh”。
#! /bin/bash N=0 while true; do N=$((N+1)) echo $N done
首先,尝试在后台直接运行它,如下所示
# ./app.sh > /dev/null &
接着在一个单核的机器上进行测试。观察系统CPU的使用情况,用不了多久,这个进程就会将所有CPU资源统统耗尽,如下所示。
# ps aux USER PID %CPU %MEM …… COMMAND root 10366 99.6 0.0 …… /bin/bash ./app.sh
然而如果使用cgexec命令让app.sh进程在cpu50这个CGroup中重新运行,就会发现这次进程的CPU使用被稳定地限制在了50%附近,如下所示。
# cgexec -g cpu:cpu50 ./app.sh > /dev/null & # ps aux USER PID %CPU %MEM …… COMMAND root 10366 51.8 0.0 …… /bin/bash ./app.sh
再启动一个相同的进程,同样放到cpu50子系统里。此时两个进程的CPU使用率都有所降低,其总和依然大约为50%,如下所示。
# cgexec -g cpu:cpu50 ./app.sh > /dev/null & # ps aux USER PID %CPU %MEM …… COMMAND root 10380 32.2 0.0 …… /bin/bash ./app.sh root 10384 24.6 0.0 …… /bin/bash ./app.sh
现在不妨在这个子系统里再启动一个相同的进程实例,然后观察CPU使用率的变化,可以发现CGroup的效果的确是作用于同属于该子系统中所有进程的,如下所示。
# ps aux USER PID %CPU %MEM …… COMMAND root 10380 18.6 0.0 …… /bin/bash ./app.sh root 10384 17.5 0.0 …… /bin/bash ./app.sh root 10390 16.5 0.0 …… /bin/bash ./app.sh
如果在刚才创建的cpu30子系统中重复这个测试,则所有进程的总CPU用量将稳定在30%左右,如下所示。
# cgexec -g cpu:cpu30 ./app.sh > /dev/null & # cgexec -g cpu:cpu30 ./app.sh > /dev/null & # cgexec -g cpu:cpu30 ./app.sh > /dev/null & # ps aux USER PID %CPU %MEM …… COMMAND root 10410 14.3 0.0 …… /bin/bash ./app.sh root 10433 10.5 0.0 …… /bin/bash ./app.sh root 10450 9.9 0.0 …… /bin/bash ./app.sh
本质来说,对CGroup的所有操作都是在对系统挂载的CGroup目录进行修改。上述操作实际是在“/sys/fs/cgroup/cpu/”目录下创建了cpu50和cpu30这两个子目录,查看其中任意一个目录的内容,就会看到类似下面这样的文件结构。
# ls /sys/fs/cgroup/cpu/cpu30/ cgroup.clone_children cgroup.event_control cgroup.procs cpu.cfs_period_us cpu.cfs_quota_us cpu.shares cpu.stat notify_on_release tasks
观察里面的几个文件内容,如下所示,基本上就真相大白了。
# cat /sys/fs/cgroup/cpu/cpu30/cpu.cfs_quota_us 30000 # cat /sys/fs/cgroup/cpu/cpu30/tasks 10410 10433 10450
实际完全可以不通过cgroup-tools工具来完成以上的所有操作,只要用普通的Linux命令创建出这个目录结构就可以达到相同的目的。例如下面的命令创建了一个限制CPU使用率为20%的子系统。
# mkdir /sys/fs/cgroup/cpu/cpu20/ # echo 20000 > /sys/fs/cgroup/cpu/cpu20/cpu.cfs_quota_us # ./app.sh > /dev/null & # echo $! >> /sys/fs/cgroup/cpu/cpu20/tasks
稍微解释一下上面的命令。首先,由于“/sys/fs/cgroup/cpu”目录是被挂载为CGroup类型的文件系统,当用户在该目录创建子目录时,系统会自动在该目录创建作为CGroup子系统所需的文件结构,因此在后续的操作中就可以直接读写这些文件了。其次,在最后一条命令中的$!是Bash中用于获取前一个命令PID的方式,因此这个命令的意思是将前一条命令执行的app.sh进程PID写到子系统的tasks文件里面。
不难看出,CGroup的文件结构至少包含三层目录树,它的三个核心概念及相互间的逻辑关系如下所示。
·Hierarchy(控制树):每个控制树表示系统中挂载的一套CGroup配置树,其中可以包含多个子系统。
·Subsystem(子系统):每个子系统对应一种控制资源,比如CPU子系统就是控制CPU时间分配的一个控制器。
·Control Group(控制组):在每个子系统下都可以创建不同的控制组,而每个控制组可以被赋予一组进程,通过指定控制组的参数来限制该控制组的进程对相应系统资源的使用。
这样的体系实际上形成了一种层级状的CGroup控制链,如图1-3所示。
图1-3 CGroup的层级控制树
当然,以上只是一个很简略的例子,在实际情况中,容器使用的CGroup约束方式会更加复杂,关于Namespace和CGroup的细节这里就不再展开了。
1.1.3 基于容器的软件交付
容器技术在内核中出现的时间要远远早于Docker和Rkt这种容器管理软件。然而不可否认的是,Docker项目是迄今为止最成功的容器技术产品之一。Docker的成功并非因为它在技术领域有什么重大突破,它的最初版本只是LXC工具的一层封装,Docker的独特之处在于它引入了镜像、版本、仓库等一系列的思想,以及Build、Ship、Run这样的软件交付理念,从而彻底地改变了软件发布的过程。
在软件开发实践中,CI(Continuous Integration,持续集成)和CD(Continuous Delivery,持续交付)是经常被用来提高模块间集成测试频率和优化发布流程的手段,通过可视化交付流程、监控代码变化、自动化部署和运行测试,以此更加频繁地获得软件质量反馈。此外,流水线还能很好地反映实际产品交付过程中所经历的各个环节,尤其是只要将这些环节涉及的实践自动化,就能看到从代码提交到每个阶段的测试、部署过程中潜在的流程问题和交付瓶颈。
容器技术会直接改变软件打包、发布和运维的许多方面,这些实践通过持续集成/交付的流水线就能十分直观地体现出来。图1-4展示了一个典型的使用容器交付的项目的持续集成流水线,末尾省略号的部分表示流程包含的其他步骤,直到最终部署上线。在这个环环相扣的链条里,容器的运用场景贯穿全程。
图1-4 基于容器的持续集成流水线
具体来说,这些影响体现在以下几个方面。
1.在容器中进行构建、代码检查和单元测试
在容器中构建和测试代码最直接的好处是,对于任何技术栈的项目,总是能够恰到好处地提供一个适合运行相应开发工具链的干净环境。在多个不同产品项目需要采用不同版本、种类的构建工具或SDK的时候,这个优势特别有用,干净的执行环境能够减少构建或测试结果与预期不一致的情况。此外,由于有些代码检测(如安全性扫描)、全量的集成测试和回归测试需要运行较长时间,通常放在夜间执行,而白天进行的构建任务则相对较多,容器除了能够分别提供所需的环境外,还具有更好地利用容器动态扩缩的特性,很适合混合调度这些周期性的任务,将基础设施资源池化。
根据所用的编程语言和技术栈,可将与构建和单元测试相关的镜像分为很多种类,常用的Docker镜像包括以下这些。
·C/C++:https://hub.docker.com/r/_/gcc/。
·Java:https://hub.docker.com/r/_/openjdk/。
·Golang:https://hub.docker.com/r/_/golang/。
·Nodejs:https://hub.docker.com/r/_/node/。
·其他语言的基础镜像。
在实际使用时往往需要基于这些镜像添加相应的框架和SDK库,以满足使用的要求。
2.通过镜像打包和分发服务
容器的封装意味着,不论运行的服务是用Java、Python、PHP还是Golang设计的,平台都可以用几乎相同的方式去完成部署,而不用考虑安装服务所需的环境,容器能够做到这一点的原因就在于它采用了镜像打包和分发方式。
以Docker容器为例,所有的运行时依赖都会在服务设计的时候以声明或描述的方式体现在一个Dockerfile文件中。当服务打包时,将依次执行其中的描述性代码,使得服务以及所需的完整执行环境,包括文件目录、环境变量、启动参数等全部固化在镜像里。这种工作方式将最终运行环境的定义提前到了编码设计的阶段,缓解了开发和运维之间的信息不对称问题,对促进团队DevOps文化起到了积极的作用。
镜像仓库是当下使用最多的容器镜像分发方式,通过一个集中式的存储点,将构建出的镜像按照一定的组织结构进行管理,并按需发布到运行服务的节点上。本书的第7章还会讨论容器仓库的技术方案和实施细节。值得指出的是,镜像仓库并不是唯一的分发容器打包产物的方法,一些研究项目也在尝试通过例如共享存储、P2P传输等途径解决镜像仓库中心节点流量压力较大的问题,然而由于成熟度和实施难度方面的原因,这些研究并没有被作为主流的镜像分发手段。
3.使用容器优化测试过程
在测试的过程中,需要依赖的外部系统或组件往往比较多,然而这些系统或组件未必总是处于空闲或可用状态。这时就需要有大量的临时资源或是模拟资源(称为“桩”或“Mock”)。几种典型的测试依赖资源包括浏览器、数据库和第三方服务接口。对于这些场景,容器都有用武之地。
临时的浏览器资源主要用于界面测试。由于每个浏览器在执行测试时一般都由某个用例独占,为了并发地完成界面测试,测试人员需要准备许多不同种类的浏览器资源。特别在自动化并行测试的时候,对浏览器种类和数量的需求变化是十分迅速的,必须按照实际用量的最大值来准备这些资源,因而造成不少浪费。此外,目前进行与浏览器相关的测试通常都是使用Selenium/WebDriver和相关的SDK来驱动的,提前部署、长期维护所需的服务和相应驱动同样都很费时。
容器提供了很好的临时浏览器提供方法。实际上,Selenium提供了一组基于Chrome和Firefox并预装了相应驱动的浏览器镜像。该项目的GitHub地址为:https://github.com/SeleniumHQ/docker-selenium。
对于测试时期的数据库依赖,比如与数据相关的集成测试,由于测试过程需要反复运行,如果前一次测试运行没有正常地结束,或是没有正确地清理留下的数据,就特别容易影响后续测试的运行结果。容器恰恰是提供这种即用即弃基础设施的最佳方式,完全可以在测试脚本中先启动一个全新的MySQL服务,测试完就销毁,保证了每次测试的独立性。
在Docker Hub上有许多主流数据库的镜像可供使用。例如MySQL和MongoDB的镜像地址分别是https://hub.docker.com/r//mysql和https://hub.docker.com/r/_/mongo。
这些镜像都提供了在容器启动时修改数据库配置和注入初始化数据的方法。例如MySQL的镜像启动后会自动执行放在“/docker-entrypoint-initdb.d”目录下的所有SQL或Shell脚本,而MongoDB的镜像则建议用户将初始化过的数据内容直接通过Volume挂载到容器的“/data/db”目录下。
更加常见的场景是测试时需要通过“服务桩”来模拟某些第三方服务的行为,开源社区里已经有许多很不错的解决方法。其中获得过Oracle颁发的Duke选择奖的Moco就是一款值得采用的工具。
虽然Moco的使用本来就十分简单,在某些集群测试的场景下,将它容器化仍然能获得快速分发和运行环境无关性的好处。有些社区镜像已经提供了这方面的功能,例如Docker Hub上的https://hub.docker.com/r/rezzza/docker-moco这个镜像。
使用者仅仅需要将一个被模拟接口的配置描述文件挂载到该容器的特定位置,就实现了具备特定功能的“服务桩”。
4.通过容器进行集群自动化部署和管理
部署意味着产品的最终发布上线。在传统流程中,软件的部署有时是十分耗时、费力的事情,特别是线上环境的部署,一点小小的差错就会造成严重的后果。在用传统的虚拟机方式部署服务时,相应的操作通常都会使用一个经过反复优化的自动化脚本来完成,但这些复杂的脚本依然可能在一些环节上出现问题,容器化方式的部署通过从根本上简化部署的过程,可以达到提升部署可靠性的目的。
对于单个服务而言,通过容器进行部署只需要两个动作,先是停止旧镜像运行的服务,然后使用新的镜像启动服务。在集群中进行服务的部署,虽然本质上与简单地部署单个容器没多少差别,但加上在实际运用时需要涉及的因素,就会复杂很多。
像Docker这样的容器,现在已经具有了许多成熟、开源的集群调度和管理工具,例如SwarmKit、Kubernetes、Mesos或Rancher,它们的价值在于自动地完成了节点选择、网络配置、路由切换等一系列的额外工作。同时由于容器使服务不再依赖于主机的配置和环境,从整体来看,采用容器在集群中部署服务依然比采用虚拟机的方式更容易管控。此外,容器化的集群工具通常还具有任务编排和扩展API等能力,能够简化日常的运维和比较方便地进行二次开发。