1.2 容器简介
容器是在Linux系统上运行的一组进程,它们相互隔离。容器确保一个进程组不会干扰系统上的其他进程。恶意进程不能支配系统资源,否则会阻止其他进程任务的顺利执行。我们也要防止恶意容器攻击其他容器、窃取数据或造成拒绝服务攻击。容器的最终目标是允许应用程序安装它们自己版本的共享库,而不会与需要不同版本的相同库的应用程序发生冲突。相反,它们允许应用程序运行在虚拟化环境中,从而使得容器化环境中的应用程序看上去拥有了整个系统。
容器通过以下方式进行隔离。
1.资源限制(cgroups)
cgroups在手册页(https://man7.org/linux/man-pages/man7/cgroups.7.html)上被定义为控制组(Control Groups),通常被称为cgroups,是Linux内核的一项功能,它允许将进程组织成分层的组,并对这些分层的组实现多种类型的资源限制和监控。
cgroups控制的资源实例如下。
■ 一组进程可以使用的内存量。
■ 进程可使用的CPU核数。
■ 进程可使用的网络资源数量。
cgroups的基本思想是防止一组进程独占某些系统资源,使得其他进程无法使用。
2.安全限制
内核中的很多安全工具可以实现容器间的相互隔离。安全限制的目标是限制特权升级,并防止一组流氓进程对系统实施破坏性行为,比如:
■ 通过放弃一些Linux能力来限制root用户的权限。
■ 通过SELinux控制对文件系统的访问。
■ 只读访问内核文件系统。
■ 通过seccomp限制内核中可用的系统调用。
■ 通过用户命名空间将主机上的一组UID映射到另一组UID,从而允许访问有限的特权环境。
表1-1提供了进一步的信息和链接,可以通过这些链接获取有关这些安全功能的更多细节。
表1-1 高级Linux安全功能
3.虚拟化技术(命名空间)
Linux内核使用命名空间来创建一个虚拟化环境。在这个虚拟化环境中,一组进程看到的是一组资源,另一组进程则看到的是另一组不同的资源。这些虚拟化环境使进程无法看到系统的其他部分,感觉像一个虚拟机(VM),且没有额外的系统开销。下面给出命名空间的若干示例。
■ 网络命名空间:限制对主机网络的访问,但是提供了对虚拟网络设备的访问。
■ 挂载命名空间:限制只能看到容器文件系统,而不能看到所有的文件系统。
■ PID命名空间:限制容器进程只能看到容器内的进程,而看不到系统上的其他进程。
这些容器技术在Linux内核中已经存在很多年了。用于隔离进程的安全工具始于20世纪70年代的UNIX,而SELinux在2001年就已经存在。命名空间和控制组功能分别在2004年和2006年被引入。
提示 Windows容器镜像也是存在的,但是本书主要关注的是基于Linux的容器。即使在Windows上运行Podman,实际上使用的还是Linux容器。在macOS上使用Podman的方法可以参阅附录E。在Windows上使用Podman的方法可以参阅附录F。
1.2.1 容器镜像:软件交付的新方式
直到Docker项目引入了容器镜像和容器镜像注册服务器等概念,容器技术才真正开始被广泛使用。可以这么说,它们创造了一种新的软件交付方式。
传统的在Linux系统上安装多个软件应用程序可能会导致依赖管理问题。在容器出现之前,可以使用像RPM和Debian Packages这样的软件包管理器来打包软件。这些软件包安装在主机上,并且共享包括共享库在内的所有内容。当开发者团队测试他们的代码时,在主机上运行时可能一切都正常。而当质量工程师团队在不同的机器上用不同的软件包测试软件时,就可能遭遇失败。两个团队需要一起努力才能达成期望的效果。软件最终交付给客户,这些客户可能有许多不同的配置或安装了不同版本的软件依赖,而这些问题也会导致应用程序不可用。
容器镜像通过将所有软件捆绑到一个单元中的方式,解决了依赖管理问题。你可以将所有软件库、可执行文件和配置文件一起交付。软件通过容器技术与主机隔离。通常,应用程序需要跟主机交互的唯一部分是主机内核。
开发人员、质量工程师和客户运行完全相同的容器化环境和应用程序,有助于保证运行环境的一致性,并限制了可能由错误配置引起的bug数量。
人们经常将容器与虚拟机进行比较,因为两者都具有在单个节点运行多个被隔离的应用程序的特性。当使用虚拟机时,你需要管理整个虚拟机操作系统和被隔离的应用程序。你需要管理不同内核、init 系统、日志系统、安全更新、备份等的完整生命周期。系统不仅需要处理应用程序的开销,还需要处理整个操作系统的运行开销。在容器世界里,你运行的只有容器化应用程序,不需要进行操作系统管理,也没有其他开销。图1-4展示了3个应用程序在3个不同的虚拟机中运行。
图1-4 在3个虚拟机中运行3个应用程序的物理机
使用虚拟机时,你最终需要管理四个操作系统。而使用容器时,这3个应用程序仅在其所需的用户空间下运行,你只需要管理一个操作系统,如图1-5所示。
图1-5 以容器化方式运行3个应用程序的物理机
1.2.2 容器镜像推动微服务的发展
将应用程序打包在容器镜像中允许在同一个物理机安装多个有冲突要求的应用程序。例如,一个应用程序可能需要的C库版本与另一个应用程序所需的C库版本不同,使得这些应用程序不能同时安装。图 1-6 展示了在不使用容器的情况下运行在操作系统上的传统应用程序。
容器可以在其容器镜像中包含正确的C库,每个镜像都可能包含特定于容器应用程序的不同版本的库。你可以运行来自完全不同版本的应用程序。
如图1-7所示,容器让运行相同应用程序的多个实例变得非常简单。容器镜像鼓励将单个服务或者应用程序打包到单个容器中。通过容器,你可以很方便地通过网络建立多个应用程序之间的连接。
你可以构建3个不同的容器镜像,然后通过将它们连接在一起来构建微服务,而不是设计具有Web前端、负载均衡器和数据库的单体应用程序。微服务使得你和其他用户可以尝试运行多个数据库和Web前端,然后将它们编排在一起。容器化的微服务使软件的共享和复用成为可能。
图1-6 运行在同一个服务器上的传统的LAMP技术栈(Linux + Apache + MariaDB + PHP/Perl应用程序)
图1-7 LAMP技术栈被打包成单独的微服务容器。由于容器通过网络进行通信,因此它们可以轻松地移动到其他虚拟机中,使复用变得更加容易
1.2.3 容器镜像格式
一个容器镜像包含3个组件。
■ 一个包含运行应用程序所需的所有软件的目录树。
■ 一个描述rootfs内容的JSON文件。
■ 一个被称为manifest列表的JSON文件,用于将多个镜像连接在一起以支持不同的硬件架构。
目录树被称为rootfs(根文件系统),该组件的布局类似Linux系统的根目录(/)。
第一个JSON文件中定义了在rootfs中运行的可执行文件、工作目录、要使用的环境变量、可执行文件的维护者以及其他有助于识别镜像内容的标签。你可以通过使用podman inspect命令的方式来查看这个JSON文件。
$ podman inspect docker:/ /registry.access.redhat.com/ubi8 { … "created": "2022-01-27T16:00:30.397689Z", ◁--- 镜像创建的日期 "architecture": "amd64", ◁--- 镜像的架构 "os": "linux", ◁--- 镜像的操作系统 "config": { "Env": [ ◁--- 镜像开发人员想要在容器中设置的环境变量 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "container=oci" ], "Cmd": [ ◁--- 在容器启动时要执行的默认命令 "/bin/bash" ], "Labels": { ◁--- 用于帮助描述镜像内容的标签。这些字段可以是任意形式的,不影响镜像的运行方式,但可用于搜索和描述镜像 "architecture": "x86_64", "build-date": "2022-01-27T15:59:52.415605", … }
第二个JSON文件即manifest列表,允许arm64机器上的用户拉取与amd64机器上名称相同的镜像。Podman会根据机器的默认硬件架构使用此manifest列表来拉取对应镜像。Skopeo是一个工具,它使用与Podman相同的底层库,可在github.com/containers/skopeo(参见附录A)上查看其源代码。Skopeo可以提供更低级别的信息输出,以检查容器镜像的结构。在以下示例中,使用skopeo命令和--raw选项来检查registry.access.redhat.com/ubi8镜像的manifest规范。
$ skopeo inspect --raw docker:/ /registry.access.redhat.com/ubi8 { "manifests": [ { "digest": "sha256:cbc1e8cea 8c78cfa1490c4f01b2be59d43ddbb ad6987d938def1960f64bcd02c", ◁--- 当架构和操作系统匹配时,精确拉取的镜像摘要 "mediaType": "application/vnd.docker.distribution.manifest.v2+json", ◁--- mediaType描述了镜像的类型,如OCI、Docker等 "platform": { "architecture": "amd64", ◁--- 镜像的架构摘要:amd64 "os": "linux" ◁--- 镜像的操作系统摘要:Linux }, "size": 737 }, { "digest": ◁--- 这个段落指向一个不同架构(arm64)的镜像 "sha256:f52d79a9d0a3c23e6ac4c3c8f2ed8d6337ea47f4e2dfd46201756160ca193308", "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "arm64", "os": "linux" }, "size": 737 }, … }
镜像使用Linux tar工具将rootfs和JSON文件打包在一起。然后,这些镜像被存储在称为容器镜像注册服务器(例如docker.io、quay.io和Artifactory)的Web服务器上。Podman等容器引擎可以将这些镜像拷贝到一个物理机上并将它们解压到文件系统中。然后,容器引擎会将镜像的JSON文件、引擎内置的默认值和用户的输入进行合并来创建一个新的容器OCI运行时规范的JSON文件。这个JSON文件是用来描述如何运行该容器化应用程序的。
在最后一步,容器引擎启动一个称为容器运行时(例如runc、crun、Kata或gVisor)的轻量程序。容器运行时会读取容器的JSON文件、内核cgroups、安全约束和命名空间,并最终启动容器的主进程。
1.2.4 容器标准
OCI标准委员会定义了存储和容器镜像的标准格式,以及容器引擎的标准。它创建了OCI镜像格式,该格式标准化了容器镜像和镜像的JSON文件格式。它还创建了OCI运行时规范,该规范标准化了由OCI运行时使用的容器的JSON文件。OCI标准允许其他容器引擎(例如Podman[1])通过遵循这些标准,与存储在容器镜像注册服务器中的所有镜像一起工作,并以与包括Docker在内的所有其他容器引擎完全相同的方式运行它们(请参阅图1-7)。
[1] 其他容器引擎包括Buildah、CRI-O、containerd等。