@babydragon
2017-10-09T14:28:04.000000Z
字数 11689
阅读 1717
docker
容器技术在最近几年已经走了很长的路,让我们从时间维度来重新梳理下。
图片来源: Daniel Ramirez CC BY-SA 4.0
在过去几年中,容器技术不仅仅在开发者中成为热门话题,许多企业也参与其中。这种对容器兴趣的日益增加,使得对其安全提升和加固的需求不断提升,同时也对可扩展性和互操作型有了更高的要求。这些工作都是大工程,本文介绍了红帽在企业级容器支持上所做的工作。
当我在2013年秋季首次遇到Docker公司(Docker.io)的代表时,我们还在研究如何在红帽企业版(Red Hat Enterprise Linux,RHEL)中使用Docker容器。(现在Docker项目的一部分已经被改名为Moby。)在将这项技术融入到RHEL时,我们遇到了许多问题。首先遇到的最大障碍是找到一个支持写时复制(Copy On Write,COW)的文件系统来处理容器镜像分层。红帽最终为包括Device Mapper、btrfs和最初版本的OverlayFS贡献了部分COW实现。对于RHEL,我们默认使用Device Mapper,虽然当时对OverlayFS的支持已经快完成了。
另一个大障碍是启动容器的工具。当时,上游docker使用LXC工具来启动容器,但是我们不希望在RHEL中支持LXC工具集。在与上游docker公司合作之前,我和libvirt团队合作,制作了名为virt-sandbox的工具,它能够通过libvirt-lxc来启动容器。
当时,红帽公司一些人认为,将LXC工具集移除,在Docker守护进程和libvirt之间通过libvirt-lxc来桥接启动容器是个不错的想法。但是这种实现方式令人担忧。使用该方式通过Docker客户端(docker-cli)来启动容器,客户端和容器进程(pid1OfContainer)之间的调用层次有:
docker-cli → docker-daemon → libvirt-lxc → pid1OfContainer
我不太赞同启动容器的工具和实际运行容器之间有两个守护进程。
我的团队开始和上游docker开发者一起,通过原生Go语言实现了一个名为容器运行时库:libcontainer。该库最终作为OCI运行时规范runc的初始实现发布。
docker- cli → docker-daemon @ pid1OfContainer
有许多人误以为当他们执行一个容器的时候,容器进程是docker-cli的子进程。事实上,docker是一个典型的C/S架构,容器进程运行在一个完全独立的环境中。这样的C/S架构,可能导致不稳定和潜在的安全隐患,另外还会导致一些系统特性无法使用。例如,systemd有一个称为套接字激活(socket activation)的特性,用户可以设置守护进程仅在进程连接到套接字时才运行。这样可以降低系统内存使用,让服务按需运行。套接字激活的实现原理是systemd监听一个TCP套接字,当该套接字接收到数据包的时候,systemd激活需要监听该套接字的服务。当这个服务激活之后,systemd将套接字转交给新启动的守护进程。将这个守护进程放到基于Docker的容器中之后,就会产生问题。通过在systemd的unit文件中使用Docker客户端命令可以启动容器,但是systemd无法简单的通过Docker客户端命令将套接字转移到守护进程。
类似这样的问题,让我们意识到,有必要换一种运行容器的方式。
上游docker项目致力于让容器使用变得更加方便,它一直是学习Linux容器的好工具。我们可以通过简单的使用类似docker run -ti fedora sh
命令来启动容器并进入容器中。
容器的真正优势在于同时启动大量组件,并将它们组装成一个强大的应用程序。设置一个多容器应用的难点在于其复杂度的指数级增长,以及通过简单的docker命令将分散的各个部分串联起来。如何在资源有限的集群节点中管理容器布局?如何管理这些容器的生命周期?类似问题还有很多。
在第一届DockerCon上,至少有7个公司/开源项目展示了容器编排的方式。我们演示的是红帽OpenShift的项目geard,它松散的基于OpenShift v2容器。红帽决定我们需要在容器编排上重新审视,也可能和其他开源社区的人员合作。
Google演示的是Kubernetes容器编排工具,它是基于Google内部开发的编排工具所积累的经验。OpenShift决定放弃Gear项目,开始和Google一起合作Kubernetes。Kubernetes目前已经称为GitHub上最大的社区项目之一。
Kubernetes最初使用Google的lmctfy作为其容器运行时库。2014年夏天,lmctfy被移植支持Docker。Kubernetes会在集群的每个节点运行一个名为kubelet的守护进程。这意味着原始的Kubernetes和Docker 1.8工作流程看上去是这样的:
kubelet → dockerdaemon @ PID1
这又回到了两个守护进程的模式。
还有更糟糕的。每次Docker发布,Kubernetes都会被破坏。Docker 1.10修改了后端存储,导致所有镜像都需要重新构建。Docker 1.11开始通过runc来启动容器:
kubelet → dockerdaemon @ runc @PID1
Docker 1.12增加了一个容器守护进程来启动容器。这样的主要目的是满足Docker Swarm(一个Kubernetes的竞争对手)的需求:
kubelet → dockerdaemon → containerd @runc @ pid1
前面提到了,每次Docker发布新版本都会破坏Kubernetes的功能,因此对于Kubernetes和OpenShift等工具都需要适配旧版本Docker。
现在,已经进化成三守护进程系统,这种架构下守护进程中的任何一部分出错了,整个系统都会分崩离析。
由于Docker运行时库的各种问题,多个组织都在寻求其替代方案。其中一个组织是CoreOS。CoreOS为上游docker提供了一个替代的容器运行时环境rkt(rocket)。同时,CoreOS还引入了一个标准容器规范:appc(App Container)。简单的说,他们希望大家在处理容器镜像存储的时候可以使用统一的标准规范。
这是一个对社区的警示。在刚开始和上游docker合作容器项目的时候,我最大的担忧是最终会有多个标准。我不希望会出现类似RPM格式和Debian格式(deb)之间的战争,这场战争影响了后来20年的Linux软件分发。appc提出的一个利好是它说服了上游docker和开源社区一起建立一个标准体:Open Container Initiative(OCI)。
OCI一直在制定两种规范:
通过提供容器镜像和运行时的行业规范,OCI帮助了容器相关工具和编排框架上的革新。
Kubernetes编排工具是这些标准的受益者之一。作为Kubernetes的一大支持者,CoreOS提交了一系列的补丁为Kubernetes增加了通过rkt运行容器的支持。Google和Kubernetes社区发现应用了这些补丁之后,将来要为Kubernetes增加新的容器运行时支持会使得Kubernetes代码变得复杂和臃肿。因此Kubernetes团队决定实现一套名为容器运行时接口(Container Runtime Interface,CRI)的API协议规范。然后他们重构了Kubernetes,使其直接调用CRI,而不用调用Docker引擎。如果以后需要添加新的容器运行时接口,只需要实现CRI的服务端接口即可。同时,Kubernetes为CRI开发者提供了大量测试集用来验证这些实现是否兼容Kubernetes。CRI的抽象,还有一项需要持续进行工作:将原先Kubernetes中对Docker引擎的直接调用去除,移动到称为docker-shim的适配层中去。
几年前我们在Atomic项目中开发atomic命令行接口。其中一个功能是,我们希望能够有一个工具,当镜像还在镜像仓库的时候,就可以验证其中的内容。当时,唯一的变通方式是通过容器镜像关联的JSON文件将镜像拉取到本地,然后再通过docker inspect
命令来读取镜像信息。这些镜像可能非常大,占用上G空间。有了这项功能,就能够让用户能够提前检查镜像内容,以确定是否需要拉取镜像,因此我们为docker inspect
命令增加了一个--remote
的参数。但是上游docker拒绝了这个pull request,原因是他们不希望将Docker命令行接口弄的复杂,并且用户可以通过自己的小工具来实现同样的功能。
由Antonio Murdaca领导的我们团队,最终完成了名为skopeo的工具。Antonio将这个工具定位不仅局限于拉取镜像的配置文件,他还实现了将容器镜像从镜像仓库拉取到本地服务器,从本地服务器推送镜像到镜像仓库的双向协议。
目前,skopeo已经重度融入在atomic命令行接口中,包括检查容器更新、集成入atomic scan命令实现中等。Atomic也使用skopeo来拉取和推送镜像,而非使用上游的docker守护进程。
我们一直在和CoreOS沟通关于在rkt中使用skopeo的可行性,但是对方一直不愿意通过一个可以执行的帮助应用程序,而是希望能够将skopeo功能作为依赖库直接集成进去。因此我们决定将skopeo拆分成库和可执行程序,最终创建了image工程。
containers/image库和skopeo已经被一些上游项目和云服务基础设施工具使用。二者已经能够支持除Docker外的其他多种存储后端,并且它们提供了诸如在镜像仓库之间移动镜像等许多特性。skopeo的一个优势在于,它的工作不依赖任何守护进程。containers/image库的突破,也让类似容器镜像签名等增强功能的实现成为了可能。
前文提到了atomic命令行接口,该工具用来实现docker命令行接口不兼容的特性和一些我们觉得上游docker不会接受的特性。另外我们还希望它能够可扩展的支持其他容器运行时、工具和存储。前面提到的skopeo就验证了这点。
其中一个我们想加入到atomic的特性是atomic mount
。该命令的需求来源是能够从Docker镜像存储(上游docker称之为图驱动,graph driver)获取数据,并将其挂载到某个地方,这样可以使用其他工具来检查这个镜像。目前如果使用原生docker,查看镜像内容的唯一方式是启动容器。如果镜像中包含不受信内容,仅仅为了查看镜像内容而运行其代码是非常危险的。启动容器然后验证内容的另外一个问题是,用于检查的工具通常不会包含在容器镜像中。
大部分容器镜像扫描工具的工作流程通常如下:它们连接到Docker套接字,执行docker save
命令创建一个压缩包,然后将其解压缩到硬盘上,最后检查这些内容。这个操作流程很慢。
有了atomic mount
命令,我们可以直接通过Docker图驱动(graph driver)来挂载镜像。如果Docker守护进程使用设备映射器(device mapper),它可以挂载这个设备;如果使用的是overlay文件系统,它可以挂载overlay。这样可以快速的满足我们的需求。现在只需要这样做:
# atomic mount fedora /mnt # cd /mnt
然后就可以开始检查内容。当检查完毕之后,执行:
# atomic umount /mnt
我们将该特性融入到了atomic scan
命令中,这样就可以构建一个快速镜像扫描仪了。
工具协调问题
atomic mount
命令使用过程中一个大问题是它独立工作。Docker守护进程无法感知到还有其他进程在使用镜像。这可能导致一些问题(例如,有人首先用前面提到的命令挂载Fedora镜像,然后其他人执行了docker rmi fedora
命令,Docker守护进程在试图删除Fedora镜像的时候会因为设备忙而失败)。此时Docker守护进程会进入不可预知的状态。
为了解决这个问题,我们开始将上游docker daemon代码中图驱动相关代码拉取到自己的仓库。Docker守护进程的图驱动将所有锁相关操作都在自己的内存中完成。我们希望能够将锁相关实现移动到文件系统中,这样不同的进程能够同时操作容器存储,而无需都通过守护进程这个单点。
最终该项目被命名为container/storage,它实现了容器在运行、构建和存储时所需要的所有写时复制(COW)特性,而无需使用一个进程来控制和监控(即不需要守护进程)。现在skopeo和其他一些工具和项目都能够更好的使用存储。还有一些其他开源项目开始使用containers/storage库,在某个时间点我们希望这个项目能够合并回上游的docker项目。
下面让我们来看下Kubernetes在一个节点上通过Docker守护进程运行一个容器时,到底发上了什么。首先Kubernetes执行一个命令:
kubelet run nginx –image=nginx
该命令告诉kubelet在节点上运行nginx应用程序。kubelet调用容器运行时接口,要求其启动nginx应用程序。此时,容器运行时接口的实现需要完成以下步骤:
让我们来看看上述过程中依赖的特性:
runc
:提供运行容器所需要的工具(和Docker守护进程用来运行容器的工具相同)。这意味着我们可以使用这些工具和库实现使用容器所需要的能力,而无需依赖一个大型容器守护进程。
在一个中等到大型规模基于DevOps的持续集成/持续交付环境中,效率、速度和安全性是非常重要的。而一旦相关的工具符合OCI规范,那么开发和运维就能够在持续集成/持续交付管道和生产环境中使用最佳的工具。大部分容器操作工具都隐藏在容器编排框架或者其他上层容器平台技术之下。可以预见到未来容器运行时和镜像工具的选择会成为容器平台的一个安装选项。
Atomic项目中,我们引入了atomic host
,这是一个构建操作系统的新方式,其中的软件可以“原子的”更新,并且大部分应用程序都以容器的方式运行。使用该平台的目的是为了证明大部分软件能够被移植到OCI镜像格式,并能够使用标准的协议从镜像仓库下载并安装到系统中。将软件以容器镜像的方式提供,可以让用户的操作系统和应用软件以不同的速度进行更新。传统的RPM/yum/DNF方式分发软件包会将应用程序版本限定在主机操作系统的生命周期中。
这种将基础设置作为容器来分发的一个问题是,有的时候应用程序需要在容器运行时守护进程启动前执行。让我们来看一个使用Docker守护进程的Kubernetes例子:Kubernetes在启动前需要先完成网络设置,这样它才能够将pods分配到独立的网络环境中。该场景下目前我们使用的默认守护进程是flanneld,它必须在Docker守护进程启动之前运行,以设置Docker守护进程的网络接口。同时,flanneld使用etcd作为数据存储。该守护进程需要在flanneld之前启动。
如果我们将etcd和flanneld通过镜像分发,就会遇到先有鸡还是先有蛋的问题。在启动容器化应用程序之前需要一个容器运行时守护进程,但是这些应用程序又需要在容器运行时守护进程之前启动。为了解决这个问题,我找到了一些hack的方式,但是没有一种方式是彻底的。同时,Docker守护进程目前还没有一个像样的方式来设置容器启动的优先级。我看见过这方面的建议,但是目前的实现都是使用类似于老的SysVInit方式来启动服务(当然我也知道这带来的复杂性。)
使用systemd来替代SysVInit的一个原因是用来处理启动服务的优先级和顺序,为什么容器不能利用这项技术呢?在Atomic项目中,我们决定在主机上运行容器的时候不再需要容器运行时守护进程,尤其是那些启动早期需要的服务。因此我们增强了atomic命令行接口,允许用户安装容器镜像。当用户执行atomic install --system etcd
时,atomic会使用skopeo从镜像仓库拉取etcd的OCI镜像,然后将镜像释放到OSTree存储中。因为我们在生产环境中运行etcd,因此该镜像是只读的。下一步atomic
命令从容器镜像中抓取systemd的unit文件模板,并在硬盘上创建unit文件用以启动镜像。该unit文件通常使用runc
在主机上启动容器(虽然runc不是必须的)。
如果运行atomic install --system flanneld
命令会发生类似的事情,除了这次生成flanneld的unit文件将会指定etcd需要在它启动前运行。
当系统启动时,systemd确保etcd会在flanneld之前运行,并且容器运行时会在flanneld启动之后再运行。该特性可以让用户将Docker守护进程和Kubernetes放到系统容器中。这意味着用户可以启动一个atomic host或者传统基于rpm的操作系统,其中的整个容器编排框架以容器的方式运行。这项功能非常强大,因为我们知道客户希望能够独立于这些组件,持续给他们的容器主机打补丁。此外,它能够将主机操作系统占用空间最小化。
当然,关于将传统应用程序放到容器中使其既能够作为独立/系统容器运行,又能够成为可编排容器仍然有所争论。考虑一个Apache容器,我们可以使用atomic install --system httpd
命令安装。这个容器镜像可以和基于rpm的httpd服务一样的启动(systemctl start httpd
,除了httpd进程将会启动在容器中)。存储仍然可以使用本地的,意味着可以将主机的/var/www目录挂载到容器中,这个容器也监听这本地网络的80端口。这表明我们可以在主机的容器中运行传统工作负载,而无需一个容器运行时守护进程。
在我看来,最近4年来容器创新中最令人悲伤的事情,就是构建容器镜像构建机制缺乏创新。一个容器镜像是一个由镜像内容的压缩包和一些JSON文件组成的压缩包。容器的基础镜像是一个完成的根文件系统和一些描述用的JSON文件。然后用户可以在上面增加层,每个增加的层会形成一个压缩包和记录变化的JSON文件。这些层和基础镜像一起打包组合成了容器镜像。
基本上所有人都通过docker build
和Dockerfile格式文件来构建镜像。上游docker在几年前就已经停止接受修改和增强Dockerfile格式和构建方式的pull request。在容器进化过程中,Dockerfile扮演了重要的角色。开发和运维可以简单直接的方式构建镜像。然而在我看来,Dockerfile只是一个简化的bash脚本,到目前为止还有好多问题没有解决。例如:
ansible-containers
和OpenShift S2I(Source2Image)等工具,仍然在底层使用了Docker引擎。-v
参数,但是上游docker没有接受这些补丁。在2017年DevConf.cz上,我让我们团队的Nalin Dahyabhai看看称之为containers-coreutils的构建工具,本质上这是一个使用了containers/storage库和containers/image库的一系列命令行工具,它们可以模仿Dockerfile的语法。Nalin将其命令为buildah,用来取笑我的波士顿口音。使用一些buildah原语,我们就可以构建一个容器镜像:
buildroot
中。run
命令需要这些可执行程序包含在容器镜像中。例如在镜像中使用dnf
命令需要安装整个Python栈,即使最终的应用程序不会使用Python。ctr=$(buildah from fedora)
: ctr
)mnt=$(buildah mount $ctr)
: $ctr
)。dnf install httpd –installroot=$mnt
: cp foobar $mnt/dir
: buildah commit $ctr
: buildah config --env container=oci --entrypoint /usr/bin/httpd $ctr
: buildah run $ctr dnf -y install httpd
: run
命令,它不依赖容器运行时守护进程,而是通过执行runc
直接在锁定的容器中执行命令。buildah build-using-dockerfile -f Dockerfile .
: 我们希望将类似ansible-containers
和OpenShift S2I这样的工具改成buildah
,而非依赖一个容器运行时守护进程。
构建容器镜像和运行容器采用相同的运行时环境,对于生产环境还会遇到一个大问题,既针对容器安全需要同时满足二者的权限需求。通常构建容器镜像的时候需要远多于运行容器所需要的权限。例如,默认情况下我们会允许mknod
能力。mknod
能力允许进程能够创建设备节点,但是在生产环境几乎没有应用程序需要这个能力。在生产环境中移除mknod
能力能够让系统变得更加安全。
另一个例子是我们会默认给容器镜像以读写权限,因为安装进程需要将软件包安装到/usr
目录中。但是在生产环境中,我个人建议应该将所有容器运行在只读模式。容器中的进程应该只能允许写入tmpfs
或者挂载到容器内的卷上。通过将构建镜像和运行容器分离,我们可以修改这些默认设置,让运行环境更加安全。
Kubernetes增加了一套用于插拔任何pods运行时的API,称为容器运行时接口(Container Runtime Interface,CRI)。我不愿意在我的系统上运行太多的守护进程,但是我们基于此增加了一种。由我们团队中Mrunal Patel领导的小组,在2016年晚些时候开始实现CRI-O守护进程。这是一个用于运行基于OCI应用程序的容器运行时接口守护进程。理论上,未来我们可以将CRI-O代码直接编译如kubelet,以减少一个守护进程。
和其他容器运行时不同的是,CRI-O的唯一目的是满足Kubernetes的需求。回忆一下前面提到的关于Kubernetes运行一个容器所需要的步骤。
Kubernetes向kubelet发送一个消息,通知它需要启动一个nginx服务:
runc
。我前面提到过,这和Docker守护进程使用runc
来启动容器的方式相同。runv
CRI-O旨在成为运行Kubernetes的稳定平台,我们只会在通过了所有Kubernetes的测试集之后才会发布新的版本。所有提交到https://github.com/Kubernetes-incubator/cri-o上的pull request都需要运行整个Kubernetes测试集。开发者不能提交一个无法通过这些测试的pull request。CRI-O是一个完全开放的项目,目前已经有来自Intel、SUSE、IBM、Google、Hyper.sh等公司的贡献者。只要CRI-O的大多数维护者同意,pull request就会被接受,即使这些补丁功能不是红帽公司需要的。
我希望本文的深入探讨能够帮助读者了解Linux容器的演化史。Linux一度处于每个厂商有自己标准的情形。Docker公司专注于建立镜像构建的事实标准,并且简化了容器的相关工具。Open Container Initiative的建立意味着行业正在围绕着核心镜像格式和运行时制定规范,围绕着让工具更有效率、更安全、高可扩展性和可用性进行创新。容器允许我们能够用新颖的方式验证软件安装,无论其是运行在主机上的传统软件还是运行在云端经过编排的微服务。在很多方面,这还仅仅是个开始。
查看英文原文:https://opensource.com/article/17/7/how-linux-containers-evolved,CC BY-SA 4.0