1. 容器的定义
轻量级、可移植、自包含的软件打包技术,使应用程序可以在任何地方以相同的方式运行。简单来说,容器(container)本质是一个Linux进程,它共享主机的CPU、内存等资源,为分层结构,它有自己的IP地址,并且通过端口映射方式能与公网通信(容器IP映射到主机中能访问公网的IP地址),容器就是拥有不同IP地址的Linux进程。
容器由两部分组成:
- 应用程序本身。
- 依赖:比如应用程序需要的库或其他软件容器在Host OS的用户空间中运行,与操作系统的其他进程隔离。容器部署和启动速度更快、开销更小,也更容易迁移。
2. 容器与虚拟机的区别
- 虚拟机是操作系统级别的隔离;容器是进程级别的隔离。
- 虚拟机需要有完整的操作系统;容器不需要客户机操作系统。
- 同一虚拟机上的应用程序并没有隔离。容器镜像的轻量性和不可变性。
- 虚拟机被vcenter Server、RHV Manager等工具管理;容器被由Kubernetes、Apache、Docker Swarm Mesos等工具管理。
- 虚拟机包括应用程序和整个操作系统,虚拟机占用空间大,资源消耗高,可移植性差;虚拟机只包含应用程序和它运行时需要的环境,比较轻量,占用空间小,可移植性强。
3. 为什么使用容器
传统应用部署通过插件或脚本来安装应用,应用的运行、配置、管理、所有生存周期将与当前操作系统绑定,这样做并不利于应用的升级更新/回滚等操作。在虚拟机中部署应用时,虚拟机体量比较大,可移植性不强,迁移不便。
你在Python2.7下测试,线上却运行着Python3,奇怪的事情就发生了;或者你依赖具体某个SSL版本的功能,但服务器上却安装着另外版本的SSL;你在Debian系统上进行了测试,生产环境却是Red Hat。
在容器中部署应用,每个容器中的代码都可以独立测试和部署,它包含了完整的运行时环境:一个应用、这个应用所需的全部依赖、类库、其他二进制文件、配置文件,它们统一被打入了一个包中。通过将应用平台和其依赖容器化,操作系统发行版本和其他基础环境造成的差异,都被抽象掉了。
相对于虚拟机,容器能快速部署,由于容器与底层设施、机器文件系统解耦的,所以它能在不同云、不同版本操作系统间进行迁移。
每个容器之间互相隔离,每个容器有自己的文件系统,容器之间进程不会相互影响,能区分计算资源。
容器占用资源少、部署快,每个应用可以被打包成一个容器镜像,每个应用与容器间成一对一关系
也使容器有更大优势,使用容器可以在 build或 release的阶段,为应用创建容器镜像,因为每个应用不需要与其余的应用堆桟组合,也不依赖于生产环境基础结构,这使得从研发到测试、生产能提供一致环境。类似地,容器比虚机轻量、更“透明”,这更便于监控和管理。
4. 容器的优点
- 快速创建/部署应用:与VM虚拟机相比,容器镜像的创建更加快速,启动速度快。
- 持续开发、集成和部署:提供可靠且频繁的容器镜像构建/部署,并使用快速和简单的回滚(由于镜像不可变性)。
- 开发和运行相分离:在build或者release阶段创建容器镜像,使得应用和基础设施解耦。
-
开发,测试和生产环境一致性:在本地或外网(生产环境)运行的一致性。云平台或其他操作系统:可以在 Ubuntu、RHEL、 CoreOS、on-prem、Google Container Engine或其它任何环境中运行。容器封装了所有运行应用程序所必需的相关的细节比如应用依赖以及操作系统,所以镜像从一个环境移植到另一个环境更加灵活。
- 同一个镜像可以在 Windows 或 Linux 或者 开发、测试或 stage 环境中运行
- Loosely coupled,分布式,弹性,微服务化:应用程序分为更小的、独立的部件,可以动态部署和管理,提高生产力。
- 标准化: 大多数容器基于开放标准,可以运行在所有主流 Linux 发行版、Microsoft 平台等等。
- 安全:容器之间的进程是相互隔离的,其中的基础设施亦是如此。这样其中一个容器的升级或者变化不会影响其他容器。
5. 容器的缺点
- 复杂性增加,管理生产环境中成百上千的容器极具挑战性。可以使用 Kubernetes 和 Mesos 等工具管理具有一定规模数量的容器。
- 原生 Linux 支持:大多数容器技术,比如 Docker,基于 Linux 容器(LXC),相比于在原生 Linux 中运行容器,在 Microsoft 环境中运行容器略显笨拙,并且日常使用也会带来复杂性。
- 不成熟:容器技术在市场上是相对新的技术,需要时间来适应市场。开发者中的可用资源是有限的,如果某个开发者陷入某个问题,可能需要花些时间才能解决问题。
- 由于所有容器都是与宿主机共用内核的,容器与容器之间的隔离性就会差很多。
- 容器里面是不存放数据的,容器里面的数据会随着容器的生命周期而消失,如果想要持久化的存储,必须要依靠外部的存储设备。
6. 容器的原理
为了实现容器进程对外界的隔离,容器底层主要运用了命名空间(Namespaces)、控制组(Control groups)和切根(chroot)。
6.1 命名空间(Namespace)
命名空间是Linux操作系统内核的一种资源隔离方式,使不同的进程具有不同的系统视图。系统视图就是进程能够感知到的系统环境,如主机名、文件系统、网络协议栈、其他用户和进程等。使用命名空间后,每个进程都具备独立的系统环境,进程间彼此感觉不到对方的存在,进程之间相互隔离。目前,Linux中的命名空间共有6种,可以嵌套使用。
- PID Namespace:不同容器就是通过pid命名空间隔离开的,不同命名空间中可以有相同的pid。
- Mount Namespace:mount允许不同命名空间的进程看到的文件结构不同,因此不同命名空间中的进程所看到的文件目录就被隔离了。另外,每个命名空间中的容器在/proc/mounts的信息只包含当前名称的挂载点。
- IPC Namespace:容器中进程交互还是采用Linux常见的进程交互方法(interprocess communication -IPC),包括信号量、消息队列和共享内存等。
- Network Namespace:网络隔离是通过Net实现,每个Net有独立的网络设备,IP地址,路由表,/proc/net目录。这样每个容器的网络就能隔离开来。
- UTS Namespace:UTS(UNIX Time-sharing System)允许每个容器拥有独立的hostname和domain name,使其在网络上可以被视作一个独立的节点而非主机上的一个进程。
- User Namespace:每个容器可以有不同的用户和组id,也就是说可以在容器内用容器内部的用户执行程序而非主机上的用户。
Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。但对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。
6.2 控制组(Control groups)
命名空间实现了进程隔离功能,但由于各个命名空间中的进程仍然共享同样的系统资源,如CPU、磁盘I/O、内存等,所以如果某个进程长时间占用某些资源,其他命名空间里的进程就会受到影响,这就是“吵闹的邻居(noisy neighbors)”现象。因此,命名空间并没有完全达到进程隔离的目的。为此,Linux内核提供了控制组(Control Groups,cgroups)功能来处理这个问题。
Linux把进程分成控制组,给每组里的进程都设定资源使用规则和限制。在发生资源竞争时,系统会根据每个组的定义,按照比例在控制组之间分配资源。控制组可设定规则的资源包括CPU、内存、磁盘I/O和网络等。通过这种方式,就不会出现某些进程无限度抢占其他进程资源的情况。在Linux的/sys/fs/cgroup目录中,有cpu、memory、devices、net_cls等子目录,可以根据需要修改相应的配置文件来设置某个进程ID对物理资源的最大使用率。
Linux系统通过命名空间设置进程的可见且可用资源,通过控制组规定进程对资源的使用量,这样隔离进程的虚拟环境(即容器)就建立起来了。
6.3 切根
切根的意思就是改变一个程序运行时参考的根目录位置,让不同容器在不同的虚拟根目录下工作,从而相互不直接影响。
7. 容器运行时
Linux 提供了命名空间和控制组两大系统功能,它们是容器的基础。但是,要把进程运行在容器中,还需要有便捷的SDK或命令来调用Linux的系统功能,从而创建出容器。容器的运行时(runtime)就是容器进程运行和管理的工具。
容器运行时分为低层运行时和高层运行时,功能各有侧重。低层运行时主要负责运行容器,可在给定的容器文件系统上运行容器的进程;高层运行时则主要为容器准备必要的运行环境,如容器镜像下载和解压并转化为容器所需的文件系统、创建容器的网络等,然后调用低层运行时启动容器。主要的容器运行时的关系如下。
7.1 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运行时文件系统包主要包括以下两部分。
7.1.1 config.json
这是必需的配置文件,存放于文件系统包的根目录下。OCI运行时规范对Linux、Windows、Solaris和虚拟机4种平台的运行时做了相应的配置规范。
7.1.2 容器的根文件系统
容器启动后进程所使用的根文件系统,由 config.json 中的root.path属性确定该文件系统的路径,通常是“rootfs/”。
然后,在定义文件系统包的基础上,OCI运行时规范制定了运行时和生命周期管理规范。生命周期定义了容器从创建到删除的全过程,可用以下三条命令说明。
7.1.2.1 “create”命令
在调用该命令时需要用到文件系统包的目录位置和容器的唯一标识。在创建运行环境时需要使用config.json里面的配置。在创建的过程中,用户可加入某些事件钩子(hook)来触发一些定制化处理,这些事件钩子包括prestart、createRuntime和createContainer。
7.1.2.2 “start”命令
在调用该命令时需要运行容器的唯一标识。用户可在 config.json 的process 属性中指明运行程序的详细信息。“start”命令包括两个事件钩子:startContainer和poststart。
7.1.2.3 “delete”命令
在调用该命令时需要运行容器的唯一标识。在用户的程序终止后(包括正常和异常退出),容器运行时执行“delete”命令以清除容器的运行环境。“delete”命令有一个事件钩子:poststop。
7.1.3 其它
除了上述生命周期命令,OCI运行时还必须支持另外两条命令。
7.1.3.1 “state”命令
在调用该命令时需要运行容器的唯一标识。该命令查询某个容器的状态,必须包括的状态属性有ociVersion、id、status、pid和bundle,可选属性有annotation。不同的运行时实现可能会有一些差异。下面是一个容器状态的例子:
7.1.3.2 “kill”命令
在调用该命令时需要运行容器的唯一标识和信号(signal)编号。该命令给容器进程发送信号,如Linux操作系统的信号9表示立即终止进程。
7.2 runC
runC是OCI运行时规范的参考实现,也是最常用的容器运行时,被其他多个项目使用,如containerd和CRI-O等。runC也是低层容器运行时,开发人员可通过runC实现容器的生命周期管理,避免繁琐的操作系统调用。根据OCI运行时规范,runC不包括容器镜像的管理功能,它假定容器的文件包已经从镜像里解压出来并存放于文件系统中。runC创建的容器需要手动配置网络才能与其他容器或者网络节点连通,为此可在容器启动之前通过OCI定义的事件钩子来设置网络。
由于runC提供的功能比较单一,复杂的环境需要更高层的容器运行时来生成,所以runC常常成为其他高层容器运行时的底层实现基础。
7.3 containerd
在OCI成立时,Docker公司把其Docker项目拆分为runC的低层运行时及高层运行时功能。2017年,Docker公司把这部分高层容器运行时的功能集中到containerd项目里,捐赠给云原生计算基金会。
containerd 已经成为多个项目共同使用的高层容器运行时,提供了容器镜像的下载和解压等镜像管理功能,在运行容器时,containerd先把镜像解压成OCI的文件系统包,然后调用runC运行容器。containerd提供了API,其他应用程序可以通过API与containerd交互。“ctr”是containerd的命令行工具,和“docker”命令很相像。但作为容器运行时,containerd只注重在容器运行等方面,因而不包含开发者使用的镜像构建和镜像上传镜像仓库等功能。
7.4 Docker
Docker引擎是最早流行也是最广泛使用的容器运行时之一,是一个容器管理工具,架构如下图。
Docker的客户端(命令行CLI工具)通过API调用容器引擎Docker Daemon(dockerd)的功能,完成各种容器管理任务。
Docker引擎在发布时是一个单体应用,所有功能都集中在一个可执行文件里,后来按功能分拆成runC和containerd两个不同层次的运行时,分别捐献给了OCI和CNCF。上面两节已经分别介绍了runC和containerd的主要特点,剩下的dockerd就是Docker公司维护的容器运行时。
dockerd同时提供了面向开发者和面向运维人员的功能。其中,面向开发者的命令主要提供镜像管理功能。容器镜像一般可由Dockerfile构建(build)而来。Dockerfile是一个文本文件,通过一组命令关键字定义了容器镜像所包含的基础镜像(base image)、所需的软件包及有关应用程序。在Dockerfile编写完成以后,就可以用“docker build”命令构建镜像了。下面是一个Dockerfile的简单例子:
Docker还提供了容器存储和网络映射到宿主机的功能,大部分由containerd实现。应用的数据可以被保存在容器的私有文件系统里面,这部分数据会随着容器一起被删除。对需要数据持久化的有状态应用来说,可用数据卷Volume的方式导入宿主机上的文件目录到容器中,对该目录的所有写操作都将被保存到宿主机的文件系统中。Docker可以把容器内的网络映射到宿主机的网络上,并且可以连接外部网络。
7.5 CRI和CRI-O
Kubernetes是当今主流的容器编排平台,为了适应不同场景的需求,Kubernetes需要有使用不同容器运行时的能力。为此,Kubernetes从1.5版本开始,在kubelet中增加了一个容器运行时接口CRI(Container Runtime Interface),需要接入Kubernetes的容器运行时必须实现CRI接口。由于kubelet的任务是管理本节点的工作负载,需要有镜像管理和运行容器的能力,因此只有高层容器运行时才适合接入CRI。CRI和容器运行时的关系如下图。
CRI和容器运行时之间需要有个接口层,通常称之为shim(垫片),用以匹配相应的容器运行时。CRI接口由shim实现,定义如下,分为RuntimeService和ImageServiceManager(代码参见GitHub上kubernetes/cri-api的项目文件“pkg/apis/services.go”):
1// RuntimeService接口必须由容器运行时实现
2// 以下方法必须是线程安全的
3type RuntimeService interface {
4RuntimeVersioner
5ContainerManager
6PodSandboxManager
7ContainerStatsManager
8
9// UpdateRuntimeConfig更新运行时配置
10UpdateRuntimeConfig(runtimeConfig *runtimeapi.RuntimeConfig) error
11
12// Status返回运行时的状态
13Status() (*runtimeapi.RuntimeStatus, error)
14}
15
16// ImageManagerService接口必须由容器管理器实现
17// 以下方法必须是线程安全的
18type ImageManagerService interface {
19// ListImages列出现有镜像
20ListImages(filter *runtimeapi.ImageFilter) ([]*runtimeapi.Image, error)
21
22// ImageStatus返回镜像状态
23ImageStatus(image *runtimeapi.ImageSpec) (*runtimeapi.Image, error)
24
25// PullImage用认证配置拉取镜像
26PullImage(image *runtimeapi.ImageSpec, auth *runtimeapi.AuthConfig,
podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
27
28// RemoveImage删除镜像
29RemoveImage(image *runtimeapi.ImageSpec) error
30
31// ImageFsInfo返回存储镜像的文件系统信息
32ImageFsInfo() ([]*runtimeapi.FilesystemUsage, error)
33}
7.6 总结
Docker运行时被普遍使用,它的CRI shim被称为dockershim,内置在Kubernetes的kubelet中,由Kubernetes项目组开发和维护。其他运行时则需要提供外置的shim。containerd从1.1版本开始内置了CRI plugin,不再需要外置shim来转发请求,因此效率更高。在安装Docker的最新版本时,会自动安装containerd,所以在一些系统中,Docker和Kubernetes可以同时使用containerd来运行容器,但是二者的镜像用了命名空间隔离,彼此是独立的,即镜像不可以共用。因为Docker和containerd常常同时存在,因此在不需要使用Docker的系统中只安装containerd即可。
containerd最早是为Docker设计的代码,包含一些用户相关的功能。相比之下,CRI-O是替代Docker或者containerd的高效且轻量级的容器运行时方案,是CRI的一个实现,能够运行符合OCI规范的容器,所以被称为CRI-O。CRI-O是原生为生产系统运行容器设计的,有个简单的命令行工具供测试用,但并不能进行容器管理。CRI-O支持OCI的容器镜像格式,可以从容器镜像仓库中下载镜像。CRI-O支持runC和Kata Containers这两种低层容器运行时。