容器技术的发展背景
近些年来,容器技术迅速席卷全球,颠覆了应用的开发、交付和运行模式,在云计算、互联网等领域得到了广泛应用。其实,容器技术在约二十年前就出现了,但直到2013年DockeR推出之后才遍地开花,其中有偶然因素,也有大环境造就的必然因素。这里回顾一下容器的产生的背景和发展过程。
在电子计算机刚出现时,由于硬件成本高昂,人们试图寻找能够多用户共享计算资源的方式,以提高资源利用率和降低成本。在20世纪60年代,基于硬件技术的主机虚拟化技术出现了。一台物理主机可以被划分为若干个小的机器,每个机器的硬件互不共享,并可以安装各自的操作系统来使用。20世纪90年代后期,X86架构的硬件虚拟化技术逐渐兴起,可在同一台物理机上隔离多个操作系统实例,带来了很多的优点,目前绝大多数的数据中心都采用了硬件虚拟化技术。
虽然硬件虚拟化提供了分隔资源的能力,但是采用虚拟机方式隔离应用程序时,效率往往较低,毕竟还要在每个虚拟机中安装或复制一个操作系统实例,然后把应用部署到其中。因此人们探索出一种更轻量的方案——操作系统虚拟化,使面向应用的管理更便捷。所谓操作系统虚拟化,就是由操作系统创建虚拟的系统环境,使应用感知不到其他应用的存在,仿佛在独自占有全部的系统资源,从而实现应用隔离的目的。在这种方式中不需要虚拟机,也能够实现应用彼此隔离,由于应用是共享同一个操作系统实例的,因此比虚拟机更节省资源,性能更好。操作系统虚拟化在不少系统里面也被称为容器(ContAIneR),下面也会以容器来指代操作系统虚拟化。
操作系统虚拟化最早出现在2000年,freeBSD 4.0推出了JAIl。JAIl加强和改进了用于文件系统隔离的chRoot环境。到了2004年,Sun公司发布了SolaRis 10的ContAIneRs,包括Zones和ResouRce ManageMent两部分。Zones实现了命名空间隔离和安全访问控制,ResouRce ManageMent实现了资源分配控制。2007年,ContRol GRoups(简称cgRoups)进入Linux内核,可以限定和隔离一组进程所使用的资源(包括CPU、内存、I/O和网络等)。
2013年,DockeR公司发布DockeR开源项目,提供了一系列简便的工具链来使用容器。毫不夸张地说,DockeR公司率先点燃了容器技术的火焰,拉开了云原生应用变革的帷幕,促进容器生态圈一日千里地发展。截至2020年,DockeR Hub中的镜像累计下载了1300亿次,用户创建了约600万个容器镜像库。从这些数据可以看到,用户正在以惊人的速度从传统模式切换到基于容器的应用发布和运维模式。
2015年,OCI(Open ContAIneR InITiative)作为linux基金会项目成立,旨在推动开源技术社区制定容器镜像和运行时规范,使不同厂家的容器解决方案具备互操作能力。同年还成立了CNCF,目的是促进容器技术在云原生领域的应用,降低用户开发云原生应用的门槛。创始会员包括谷歌、红帽、DockeR、VMwaRe等多家公司和组织。
CNCF成立之初只有一个开源项目,就是后来大名鼎鼎的KubeRnetes。KubeRnetes是一个容器应用的编排工具,最早由谷歌的团队研发,后来开源并捐赠给了CNCF成为种子项目。由于KubeRnetes是厂家中立的开源项目,开源后得到了社区用户和开发者的广泛参与和支持。到了2018年,KubeRnetes已成为容器编排领域事实上的标准,并成为首个CNCF的毕业(gRaduated)项目。2020年8月,CNCF旗下的开源项目增加到了63个,包括原创于中国的HaRboR等项目。
从容器的发展历程可以看到,容器在出现的早期并没有得到人们的广泛关注,主要原因是当时开放的云计算环境还没出现或者未成为主流。2010年之后,随着IaaS、PaaS和SaaS等云平台逐渐成熟,用户对云端应用开发、部署和运维的效率不断重视,重新发掘了容器的价值,最终促成了容器技术的盛行。
容器的基本原理
本节以linux容器为例,讲解容器的实现原理,主要包括命名空间(NaMespace)和控制组(cgRoups)。
命名空间
命名空间是linux操作系统内核的一种资源隔离方式,使不同的进程具有不同的系统视图。系统视图就是进程能够感知到的系统环境,如主机名、文件系统、网络协议栈、其他用户和进程等。使用命名空间后,每个进程都具备独立的系统环境,进程间彼此感觉不到对方的存在,进程之间相互隔离。目前,linux中的命名空间共有6种,可以嵌套使用。
Mount:隔离了文件系统的挂载点(Mount points),处于不同“Mount”命名空间中的进程可以看到不同的文件系统。 NetwoRk:隔离进程网络方面的系统资源,包括网络设备、IPv4和IPv6的协议栈、路由表、防火墙等。 IPC:进程间相互通信的命名空间,不同命名空间中的进程不能通信。 PID:进程号在不同的命名空间中是独立编号的,不同的命名空间中的进程可以有相同的编号。当然,这些进程在操作系统中的全局(命名空间外)编号是唯一的。 UTS:系统标识符命名空间,在每个命名空间中都可以有不同的主机名和NIS域名。 User:命名空间中的用户可以有不同于全局的用户ID和组ID,从而具有不同的特权。
命名空间实现了在同一操作系统中隔离进程的方法,几乎没有额外的系统开销,所以是非常轻量的隔离方式,进程启动和运行的过程在命名空间中和外面几乎没有差别。
控制组
命名空间实现了进程隔离功能,但由于各个命名空间中的进程仍然共享同样的系统资源,如CPU、磁盘I/O、内存等,所以如果某个进程长时间占用某些资源,其他命名空间里的进程就会受到影响,这就是“吵闹的邻居(noisy neighboRs)”现象。因此,Linux内核提供了控制组(ContRol GRoups,cgRoups)功能来处理这个问题。
linux把进程分成控制组,给每组里的进程都设定资源使用规则和限制。在发生资源竞争时,系统会根据每个组的定义,按照比例在控制组之间分配资源。控制组可设定规则的资源包括CPU、内存、磁盘I/O和网络等。通过这种方式,就不会出现某些进程无限度抢占其他进程资源的情况。
linux系统通过命名空间设置进程的可见且可用资源,通过控制组规定进程对资源的使用量,这样隔离进程的虚拟环境(即容器)就建立起来了。
容器运行时
linux 提供了命名空间和控制组两大系统功能,它们是容器的基础。但是,要把进程运行在容器中,还需要有便捷的SDK或命令来调用linux的系统功能,从而创建出容器。容器的运行时(RuntiMe)就是容器进程运行和管理的工具。
容器运行时分为低层运行时和高层运行时,功能各有侧重。低层运行时主要负责运行容器,可在给定的容器文件系统上运行容器的进程;高层运行时则主要为容器准备必要的运行环境,如容器镜像下载和解压并转化为容器所需的文件系统、创建容器的网络等,然后调用低层运行时启动容器。主要的容器运行时的关系如下图所示。
OCI运行时规范
成立于2015年的OCI是linux基金会旗下的合作项目,以开放治理的方式制定操作系统虚拟化(特别是linux容器)的开放工业标准,主要包括容器镜像格式和容器运行时(RuntiMe)。初始成员包括DockeR、亚马逊、CoReOS、谷歌、微软和VMwaRe等公司。OCI成立之初,DockeR公司为其捐赠了容器镜像格式和运行时的草案及相应的实现代码。原来属于DockeR的libcontAIneR项目被捐赠给OCI,成为独立的容器运行时项目RunC。
OCI运行时规范定义了容器配置、运行时和生命周期的标准,主流的容器运行时都遵循OCI运行时的规范,从而提高系统的可移植性和互操作性,用户可根据需要进行选择。
首先,容器启动前需要在文件系统中按一定格式存放所需的文件。OCI运行时规范定义了容器文件系统包(filesystem bundle)的标准,在OCI运行时的实现中通常由高层运行时下载OCI镜像,并将OCI镜像解压成OCI运行时文件系统包,然后OCI运行时读取配置信息和启动容器里的进程。OCI运行时文件系统包主要包括以下两部分。
config.json:这是必需的配置文件,存放于文件系统包的根目录下。OCI运行时规范对linux、Windows、SolaRis和虚拟机4种平台的运行时做了相应的配置规范。 容器的根文件系统:容器启动后进程所使用的根文件系统,由 config.json 中的Root.path属性确定该文件系统的路径,通常是“Rootfs/”。
然后,在定义文件系统包的基础上,OCI运行时规范制定了运行时和生命周期管理规范。生命周期定义了容器从创建到删除的全过程,可用以下三条命令说明。
“cReate”命令:在调用该命令时需要用到文件系统包的目录位置和容器的唯一标识。在创建运行环境时需要使用config.json里面的配置。在创建的过程中,用户可加入某些事件钩子(hook)来触发一些定制化处理,这些事件钩子包括pRestaRt、cReateRuntiMe和cReateContAIneR。 “staRt”命令:在调用该命令时需要运行容器的唯一标识。用户可在 config.json 的ProceSS 属性中指明运行程序的详细信息。“staRt”命令包括两个事件钩子:staRtContAIneR和poststaRt。 “delete”命令:在调用该命令时需要运行容器的唯一标识。在用户的程序终止后(包括正常和异常退出),容器运行时执行“delete”命令以清除容器的运行环境。“delete”命令有一个事件钩子:poststop。
除了上述生命周期命令,OCI运行时还必须支持另外两条命令。
“state”命令:在调用该命令时需要运行容器的唯一标识。该命令查询某个容器的状态,必须包括的状态属性有ociversion、id、statUS、pid和bundle,可选属性有annOTAtion。不同的运行时实现可能会有一些差异。下面是一个容器状态的例子:
{ “ociversion”: “1.0.1”, “id”: “oci-contAIneR001”, “statUS”: “Running”, “pid”: 8080, “bundle”: “/contAIneRs/Nginx”, “annOTAtions”: { “key1”: “value1” } }
“kill”命令:在调用该命令时需要运行容器的唯一标识和信号(signal)编号。该命令给容器进程发送信号,如linux操作系统的信号9表示立即终止进程。
RunC
RunC是OCI运行时规范的参考实现,也是最常用的容器运行时,被其他多个项目使用,如contAIneRd和CRI-O等。RunC也是低层容器运行时,开发人员可通过RunC实现容器的生命周期管理,避免烦琐的操作系统调用。根据OCI运行时规范,RunC不包括容器镜像的管理功能,它假定容器的文件包已经从镜像里解压出来并存放于文件系统中。RunC创建的容器需要手动配置网络才能与其他容器或者网络节点连通,为此可在容器启动之前通过OCI定义的事件钩子来设置网络。
由于RunC提供的功能比较单一,复杂的环境需要更高层的容器运行时来生成,所以RunC常常成为其他高