17 虚拟化
在计算机系统中,虚拟一词可能比较含糊。它主要用于表示一种中介,将复杂或零散的底层转换为可由多个消费者使用的简化接口。举个我们已经见过的例子,虚拟内存允许多个进程访问一个大内存库,就像每个进程都有自己的独立内存库一样。
这个定义还是有点令人生畏,所以最好还是解释一下虚拟化的典型目的:创建隔离的环境,以便让多个系统运行时不会发生冲突。
由于虚拟机在较高层次上相对容易理解,我们将从这里开始我们的虚拟化之旅。不过,我们的讨论仍将停留在较高的层面,旨在解释在使用虚拟机时可能会遇到的一些术语,而不会涉足浩如烟海的实施细节。
我们将深入探讨有关容器的更多技术细节。它们采用了你在本书中已经看到过的技术,因此你可以看到这些组件是如何结合在一起的。此外,以交互方式探索容器也相对容易。
17.1 虚拟机
虚拟机基于与虚拟内存相同的概念,只不过它使用的是机器的所有硬件,而不仅仅是内存。在这种模式下,你可以借助软件创建一个全新的机器(处理器、内存、I/O 接口等),并在其中运行整个操作系统(包括内核)。这种类型的虚拟机更确切地说是系统虚拟机,已经存在了几十年。例如,IBM 大型机传统上使用系统虚拟机来创建多用户环境;反过来,用户可以获得自己的虚拟机,运行简单的单用户操作系统 CMS。
你可以完全通过软件(通常称为仿真器)或尽可能利用底层硬件(如虚拟内存)来构建虚拟机。就 Linux 而言,由于后者性能优越,我们将研究它,但要注意的是,许多流行的模拟器都支持旧电脑和游戏系统,如 Commodore 64 和 Atari 2600。
虚拟机的世界多种多样,有大量的专业术语需要涉猎。我们对虚拟机的探讨将主要集中在这些术语与你作为一个典型的 Linux 用户可能会遇到的问题之间的关系上。我们还将讨论虚拟硬件中可能遇到的一些差异。
幸运的是,使用虚拟机要比描述虚拟机简单得多。例如,在 VirtualBox 中,你可以使用图形用户界面来创建和运行虚拟机,如果需要在脚本中自动执行该过程,甚至可以使用命令行 VBoxManage 工具。云服务的 Web 界面也便于管理。由于使用方便,我们将更多地关注虚拟机的技术和术语,而不是操作细节。
17.1.1 虚拟机管理程序(Hypervisors)
管理计算机上一台或多台虚拟机的软件称为管理程序或虚拟机监控程序(VMM virtual machine monitor),其工作方式与操作系统管理进程的方式类似。管理程序有两种类型,使用虚拟机的方式取决于类型。对大多数用户来说,第 2 类管理程序是最熟悉的,因为它是在 Linux 等普通操作系统上运行的。例如,VirtualBox 就是第 2 类管理程序,你可以在系统上运行它,无需进行大量修改。在阅读本书时,你可能已经用它测试和探索过不同类型的 Linux 系统。
另一方面,1 类管理程序更像是自己的操作系统(尤其是内核),专门为快速高效地运行虚拟机而构建。这种管理程序偶尔会使用传统的辅助系统(如 Linux)来帮助完成管理任务。尽管你可能从未在自己的硬件上运行过这种管理程序,但你一直在与 1 类管理程序打交道。所有云计算服务都是在 Xen 等 1 类管理程序下作为虚拟机运行的。当你访问一个网站时,你几乎肯定会点击运行在此类虚拟机上的软件。在 AWS 等云服务上创建操作系统实例就是在 1 类管理程序上创建虚拟机。
一般来说,带有操作系统的虚拟机称为客户机。主机是运行管理程序的设备。对于 2 型管理程序,主机就是你的本地系统。对于类型 1 的管理程序,主机就是管理程序本身,可能与专门的辅助系统结合在一起。
17.1.2 虚拟机中的硬件
理论上,管理程序为客户系统提供硬件接口应该很简单。例如,要提供虚拟磁盘设备,你可以在主机上的某个地方创建一个大文件,并通过标准设备 I/O 仿真将其作为磁盘提供访问。这种方法是严格意义上的硬件虚拟机,但效率很低。要使虚拟机满足各种需求,就必须做出一些改变。
你可能遇到的真实硬件和虚拟硬件之间的大部分差异都是桥接的结果,桥接允许客户更直接地访问主机资源。在主机和客户机之间绕过虚拟硬件被称为准虚拟化。网络接口和块设备最有可能受到这种待遇;例如,云计算实例上的 /dev/xvd 设备就是 Xen 虚拟磁盘,它使用 Linux 内核驱动程序直接与管理程序对话。有时,准虚拟化的使用是为了方便;例如,在 VirtualBox 等支持桌面的系统上,驱动程序可用于协调虚拟机窗口和主机环境之间的鼠标移动。
无论采用何种机制,虚拟化的目标始终是将问题缩小到足以让客户操作系统像对待其他设备一样对待虚拟硬件。这样就能确保设备上的所有层都能正常运行。例如,在 Linux 客户系统上,你希望内核能将虚拟磁盘作为块设备访问,这样你就可以用常用工具在上面分区和创建文件系统。
有关虚拟机工作原理的大部分细节超出了本书的范围,但 CPU 值得一提,因为我们已经讨论过内核模式和用户模式的区别。这些模式的具体名称因处理器而异(例如,x86 处理器使用一种称为特权环的系统),但概念始终是相同的。在内核模式下,处理器几乎可以做任何事情;而在用户模式下,某些指令是不允许的,内存访问也受到限制。
x86 架构的第一批虚拟机在用户模式下运行。这就带来了一个问题,因为在虚拟机中运行的内核希望处于内核模式。为了解决这个问题,管理程序可以检测虚拟机发出的任何受限指令并做出反应(“捕获”)。只需稍加处理,管理程序就能模拟受限指令,使虚拟机在非内核模式设计的架构上运行。由于内核执行的大部分指令都不受限制,因此这些指令可以正常运行,对性能的影响也相当小。
在引入这种管理程序后不久,处理器制造商就意识到,通过消除对指令陷阱和仿真的需求,为管理程序提供辅助的处理器很有市场。英特尔和 AMD 分别以 VT-x 和 AMD-V 的名义发布了这些功能集,现在大多数管理程序都支持它们。在某些情况下,它们是必需的。
如果你想进一步了解虚拟机,可以从 Jim Smith 和 Ravi Nair 的《Virtual Machines: Versatile Platforms for Systems and Processe》(Elsevier,2005 年)。这本书还包括进程虚拟机的内容,如 Java 虚拟机 (JVM),我们在此不作讨论。
17.1.3 虚拟机的常见用途
在 Linux 世界中,虚拟机的使用通常分为以下几类:
当你需要在正常或生产运行环境之外尝试某些东西时,虚拟机有很多用例。例如,在开发生产软件时,必须在一台独立于开发人员的机器上测试软件。另一个用途是在一个安全的 “一次性 ”环境中试验新软件,如新的发行版。虚拟机可以让你在无需购买新硬件的情况下完成这项工作。
当你需要在不同于常规的操作系统下运行某些程序时,虚拟机是必不可少的。
如前所述,所有云服务都基于虚拟机技术。如果需要运行网络服务器等互联网服务器,最快捷的方法就是向云提供商购买虚拟机实例。云提供商还提供数据库等专用服务器,它们只是在虚拟机上运行的预配置软件集。
17.1.4 虚拟机的缺点
多年来,虚拟机一直是隔离和扩展服务的常用方法。由于只需点击几下或通过 API 就能创建虚拟机,因此无需安装和维护硬件就能创建服务器,非常方便。尽管如此,在日常操作中仍有一些麻烦:
Ansible 等工具可以自动完成这一过程,但从头开始启动系统仍需要大量时间。如果您使用虚拟机测试软件,您就会发现这段时间积累得很快。
有一些方法可以解决这个问题,但你仍然需要启动一个完整的 Linux 系统。
- 你必须维护一个完整的 Linux 系统,在每个虚拟机上保持最新的更新和安全性。
这些系统有 systemd 和 sshd,以及应用程序所依赖的任何工具。您的应用程序可能会与虚拟机上的标准软件集发生一些冲突。有些应用程序有奇怪的依赖关系,它们并不总能与生产计算机上的软件很好地兼容。此外,库等依赖关系可能会随着机器的升级而改变,从而破坏曾经正常运行的程序。
- 将服务隔离在不同的虚拟机上既浪费又昂贵。标准的行业做法是在一个系统上运行不超过一个应用服务,这样既稳健又易于维护。此外,有些服务还可以进一步细分;如果运行多个网站,最好将它们放在不同的服务器上。然而,这与降低成本相矛盾,尤其是在使用云服务时,因为云服务是按虚拟机实例收费的。
这些问题实际上与在真实硬件上运行服务时遇到的问题没有什么不同,而且在小型运营中也不一定是障碍。但是,一旦开始运行更多的服务,这些问题就会变得更加明显,从而耗费时间和金钱。这时,您就可以考虑使用容器来提供服务了。
17.2 容器
虚拟机可以很好地隔离整个操作系统及其运行的应用程序集,但有时您需要一个重量更轻的替代方案。容器技术是满足这一需求的常用方法。在了解细节之前,让我们先回顾一下它的发展历程。
传统的计算机网络运行方式是在同一台物理机器上运行多个服务;例如,名称服务器还可以充当电子邮件服务器并执行其他任务。然而,你不应该真的相信包括服务器在内的任何软件都是安全或稳定的。为了提高系统的安全性,防止服务相互干扰,有一些基本方法可以在服务器守护进程周围设置障碍,尤其是当你不太信任其中一个守护进程时。
隔离服务的一种方法是使用 chroot() 系统调用将根目录更改为实际系统根目录以外的目录。程序可以将根目录更改为 /var/spool/my_service 这样的目录,这样就无法访问该目录之外的任何内容了。事实上,有一种 chroot 程序可以让你使用新的根目录运行程序。这种类型的隔离有时被称为 chroot jail,因为进程无法(通常)逃脱它。
另一种限制是内核的资源限制(rlimit)功能,它可以限制进程占用的 CPU 时间或其文件的大小。
这些都是容器所基于的理念:你要改变环境,限制进程运行的资源。虽然没有单一的定义特征,但容器可以被宽泛地定义为一组进程的受限运行环境,其含义是这些进程不能接触该环境之外系统上的任何东西。一般来说,这就是所谓的操作系统级虚拟化。
需要注意的是,运行一个或多个容器的机器仍然只有一个底层 Linux 内核。不过,容器内的进程可以使用与底层系统不同的 Linux 发行版的用户空间环境。
容器中的限制是通过一些内核特性构建的。在容器中运行的进程有以下几个重要方面:
- 它们有自己的 cgroups。
- 它们有自己的设备和文件系统。
- 它们无法看到系统中的任何其他进程,也无法与之交互。
- 它们有自己的网络接口。
将所有这些东西整合在一起是一项复杂的任务。手动更改一切是有可能的,但也很有挑战性;仅仅是掌握一个进程的 cgroups 就很棘手。为了帮助你,许多工具都可以执行创建和管理有效容器的必要子任务。其中最流行的两个工具是 Docker 和 LXC。本章主要介绍 Docker,但我们也会介绍 LXC,看看它有什么不同。
17.2.1 Docker、Podman 和权限
要运行本书中的示例,你需要一个容器工具。这里的示例是使用 Docker 构建的,通常可以通过分发包顺利安装。
除了 Docker 之外,还有一种叫 Podman 的工具。这两种工具的主要区别在于,使用容器时,Docker 需要运行服务器,而 Podman 则不需要。这影响了两个系统设置容器的方式。大多数 Docker 配置需要超级用户权限才能访问其容器使用的内核功能,而 dockerd 守护进程会完成相关工作。相比之下,你可以以普通用户身份运行 Podman,称为无根操作。以这种方式运行时,它会使用不同的技术实现隔离。
你也可以以超级用户身份运行 Podman,让它切换到 Docker 使用的某些隔离技术。相反,较新版本的 dockerd 支持无根模式。
幸运的是,Podman 与 Docker 的命令行兼容。这意味着你可以在这里的示例中用 podman 代替 docker,它们仍然可以工作。不过,两者的实现还是有区别的,尤其是在无根模式下运行 Podman 时,因此在适用的地方会注明这些区别。
参考资料
- 软件测试精品书籍文档下载持续更新 https://github.com/china-testing/python-testing-examples 请点赞,谢谢!
- 本文涉及的python测试开发库 谢谢点赞! https://github.com/china-testing/python_cn_resouce
- python精品书籍下载 https://github.com/china-testing/python_cn_resouce/blob/main/python_good_books.md
- Linux精品书籍下载 https://www.cnblogs.com/testing-/p/17438558.html
- python八字排盘 https://github.com/china-testing/bazi
17.2.2 Docker 示例
熟悉容器的最简单方法就是动手实践。这里的 Docker 示例说明了容器的主要功能,但提供深入的用户手册超出了本书的范围。读完本书后,你应该可以轻松理解在线文档,如果你想找一本内容广泛的指南,可以试试 Nigel Poulton 的《Docker Deep Dive》。
首先,你需要创建一个镜像,它包括文件系统和其他一些定义容器运行的功能。你的镜像几乎总是基于从互联网资源库下载的预构建镜像。
注意映像和容器很容易混淆。你可以将映像视为容器的文件系统;进程不会在映像中运行,但会在容器中运行。这种说法并不十分准确(尤其是,当你更改 Docker 容器中的文件时,你并没有对映像进行更改),但现在看来已经足够接近了。
在你的系统上安装 Docker(你的发行版的附加软件包可能就可以),在某个地方新建一个目录,切换到该目录,然后创建一个名为 Dockerfile 的文件,其中包含以下几行:- FROM alpine:latest
- RUN apk add bash
- CMD ["/bin/bash"]
复制代码 该配置使用轻量级的 Alpine 发行版。我们所做的唯一改动就是添加了 bash shell,这样做不仅是为了增加交互可用性,也是为了创建一个独特的镜像,看看该程序如何工作。使用公共image并对其不做任何改动是可能的(也是常见的)。在这种情况下,你不需要 Dockerfile。
使用以下命令构建镜像,读取当前目录下的 Dockerfile,并将标识符 hlw_test 应用于镜像:- $ docker build -t hlw_test .
复制代码 注意:你可能需要将自己添加到系统中的 docker 组,才能以普通用户身份运行 Docker 命令。
准备好接收大量输出。不要忽视它;第一次阅读它会帮助你理解 Docker 的工作原理。让我们把它分成与 Dockerfile 各行相对应的步骤。第一项任务是从 Docker 注册表中获取最新版本的 Alpine 发行版容器:- Sending build context to Docker daemon 2.048kB
- Step 1/3 : FROM alpine:latest
- latest: Pulling from library/alpine
- cbdbe7a5bc2a: Pull complete
- Digest:
- sha256:9a839e63dad54c3a6d1834e29692c8492d93f90c59c978c1ed79109ea4b9a54
- Status: Downloaded newer image for alpine:latest
- ---> f70734b6a266
复制代码 注意到大量使用 SHA256 摘要和较短的标识符。要习惯它们;Docker 需要跟踪许多小碎片。在这一步中,Docker 为基本的 Alpine 发行版镜像创建了一个标识符为 f70734b6a266 的新镜像。稍后你可以参考该特定镜像,但可能并不需要,因为它不是最终镜像。Docker 稍后会在它的基础上构建更多。不打算作为最终产品的映像被称为中间映像。
注意:使用 Podman 时,输出会有所不同,但步骤是一样的。
配置的下一部分是在 Alpine 中安装 bash shell 软件包。阅读下文时,你可能会认出 apk add bashcommand 的输出结果(粗体显示):- Step 2/3 : RUN apk add bash
- ---> Running in 4f0fb4632b31
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.11/main/x86_64/
- APKINDEX.tar.gz
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.11/community/x86_64/
- APKINDEX.tar.gz
- (1/4) Installing ncurses-terminfo-base (6.1_p20200118-r4)
- (2/4) Installing ncurses-libs (6.1_p20200118-r4)
- (3/4) Installing readline (8.0.1-r0)
- (4/4) Installing bash (5.0.11-r1)
- Executing bash-5.0.11-r1.post-install
- Executing busybox-1.31.1-r9.trigger
- OK: 8 MiB in 18 packages
- Removing intermediate container 4f0fb4632b31
- ---> 12ef4043c80a
复制代码 不那么明显的是这是怎么发生的。仔细想想,你可能不是在自己的机器上运行 Alpine。那么,如何运行已经属于 Alpine 的 apkcommand 呢?
关键在于 “运行于 4f0fb4632b31 ”这一行。你还没要求使用容器,但 Docker 已经用上一步中的 Alpine 中间镜像建立了一个新容器。容器也有标识符;不幸的是,它们看起来与映像标识符没什么区别。更让人困惑的是,Docker 将临时容器称为中间容器,这与中间映像不同。中间映像会在构建后继续存在,而中间容器不会。
在设置了 ID 为 4f0fb4632b31 的(临时)容器后,Docker 在该容器内运行 apk 命令来安装 bash,然后将由此对文件系统产生的更改保存到 ID 为 12ef4043c80a 的新中间映像中。请注意,Docker 还会在完成后删除容器。
最后,Docker 会进行最后的更改,以便在从新映像启动容器时运行 bash shell:- Step 3/3 : CMD ["/bin/bash"]
- ---> Running in fb082e6a0728
- Removing intermediate container fb082e6a0728
- ---> 1b64f94e5a54
- Successfully built 1b64f94e5a54
- Successfully tagged hlw_test:latest
复制代码 注意:在 Dockerfile 中使用 RUN 命令完成的任何操作都会在镜像构建过程中发生,而不是在之后使用镜像启动容器时发生。CMD 命令用于容器运行时;这就是它出现在最后的原因。
在这个例子中,你现在有了一个 ID 为 1b64f94e5a54 的最终镜像,但由于你对它进行了标记(分两步进行),你也可以把它称为 hlw_test 或 hlw_test:late。运行 docker images 验证你的镜像和 Alpine 镜像是否存在:- $ docker images
- REPOSITORY TAG IMAGE ID CREATED SIZE
- hlw_test latest 1b64f94e5a54 1 minute ago 9.19MB
- alpine latest f70734b6a266 3 weeks ago 5.61MB
复制代码 现在你可以启动一个容器了。使用 Docker 在容器中运行有两种基本方法:一种是创建容器,然后在容器中运行(分两步);另一种是创建和运行一步完成。让我们直接开始,用刚刚创建的镜像启动一个容器:- $ docker run -it hlw_test
复制代码 你会看到一个 bash shell 提示,可以在容器中运行命令。该 shell 将以 root 用户身份运行。
注意:如果忘记了 -it 选项(交互式,连接终端),就不会出现提示,容器几乎会立即终止。这些选项在日常使用中有些不常见(尤其是 -t)。
如果你是好奇心很强的人,你可能会想看看容器周围的情况。运行一些命令(如 mount 和 ps),并对文件系统进行总体探索。你很快就会发现,虽然大多数东西看起来都像一个典型的 Linux 系统,但有些东西却不是。例如,如果运行一个完整的进程列表,你会发现只有两个条目:- # ps aux
- PID USER TIME COMMAND
- 1 root 0:00 /bin/bash
- 6 root 0:00 ps aux
复制代码 不知何故,在容器中,shell 是进程 ID 1(记住,在正常系统中,这是 init),除了你正在执行的进程列表外,没有任何其他进程在运行。
此时,重要的是要记住,这些进程只是你在正常(主机)系统上可以看到的进程。如果在主机系统上打开另一个 shell 窗口,就可以在列表中找到容器进程,不过需要稍加搜索。它应该是这样的- root 20189 0.2 0.0 2408 2104 pts/0 Ss+ 08:36 0:00 /bin/bash
复制代码 这是我们第一次接触用于容器的内核特性之一: Linux 内核命名空间专门用于进程 ID。一个进程可以从 PID 1 开始,为自己和它的子进程创建一整套新的进程 ID,然后它们只能看到这些进程 ID。
- 覆盖文件系统
接下来,探索容器中的文件系统。你会发现它有些简陋;这是因为它基于 Alpine 发行版。我们使用 Alpine 不仅是因为它很小,还因为它可能与你习惯使用的不同。不过,看看根文件系统的挂载方式,你就会发现它与普通的基于设备的挂载方式截然不同:
- overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/
- C3D66CQYRP4SCXWFFY6HHF6X5Z:/var/lib/docker/overlay2/l/K4BLIOMNRROX3SS5GFPB
- 7SFISL:/var/lib/docker/overlay2/l/2MKIOXW5SUB2YDOUBNH4G4Y7KF1,upperdir=/
- var/lib/docker/overlay2/d064be6692c0c6ff4a45ba9a7a02f70e2cf5810a15bcb2b728b00
- dc5b7d0888c/diff,workdir=/var/lib/docker/overlay2/d064be6692c0c6ff4a45ba9a7a02
- f70e2cf5810a15bcb2b728b00dc5b7d0888c/work)
复制代码 这是一个覆盖文件系统,是内核的一项功能,允许你通过将现有目录组合成层来创建文件系统,并将更改存储在一个位置。如果你在主机系统上查看,就能看到它(并能访问组件目录),还能找到 Docker 附加原始挂载的位置。
注意:在无根模式下,Podman 使用 FUSE 版本的覆盖文件系统。在这种情况下,你无法从文件系统挂载中看到这些详细信息,但你可以通过检查主机系统上的 fuse-overlayfs 进程获得类似信息。
在挂载输出中,你会看到 lowerdir、upperdir 和 workdir 目录参数。下层目录实际上是一系列以冒号分隔的目录,如果在主机系统上查找这些目录,你会发现最后一个目录 1 是在镜像构建第一步中设置的基本 Alpine 发行版(只需查看里面的目录,就会看到发行版根目录)。如果跟踪前面的两个目录,你会发现它们分别对应于另外两个构建步骤。因此,这些目录从右到左依次 “堆叠 ”在一起。
上层目录位于这些目录之上,也是挂载文件系统的任何更改都会出现的地方。挂载时,它不一定是空的,但对于容器来说,一开始在这里放任何东西都没什么意义。工作目录是文件系统驱动程序在向上层目录写入更改之前进行工作的地方,挂载时必须为空。
可以想象,有许多构建步骤的容器映像有很多层。这有时是个问题,有各种策略可以尽量减少层数,例如合并 RUN 命令和多阶段构建。我们在此不再详述。
虽然你可以选择让容器与主机运行在同一个网络中,但为了安全起见,你通常希望在网络堆栈中实现某种隔离。在 Docker 中,有几种方法可以实现这一点,但默认的(也是最常见的)方法是使用另一种命名空间--网络命名空间(netns),即桥接网络。在运行任何程序之前,Docker 会在主机系统上创建一个新的网络接口(通常是 docker0),通常会分配给一个私有网络,如 172.17.0.0/16,因此本例中的接口会分配给 172.17.0.1。该网络用于主机与其容器之间的通信。
然后,在创建容器时,Docker 会创建一个新的网络命名空间,它几乎是完全空的。起初,新命名空间(也就是容器中的命名空间)只包含一个新的专用环回 (lo) 接口。为了让命名空间为实际使用做好准备,Docker 在主机上创建了一个虚拟接口,模拟两个实际网络接口(每个接口都有自己的设备)之间的链接,并将其中一个设备放到新命名空间中。通过在新命名空间的设备上使用 Docker 网络地址(本例中为 172.17.0.0/16)进行网络配置,进程可以在该网络上发送数据包,并在主机上接收。这可能会引起混淆,因为不同命名空间中的不同接口可以使用相同的名称(例如,容器的接口可以是 eth0,主机也可以是 eth0)。
由于这使用的是专用网络(网络管理员可能不想盲目地将任何东西路由到这些容器),因此如果保持这种方式,使用该命名空间的容器进程就无法与外部世界连接。为了能够连接到外部主机,主机上的 Docker 网络会配置 NAT。
下图显示了一个典型的设置。它包括带有接口的物理层,以及 Docker 子网的互联网层和连接该子网与主机其他部分及其外部连接的 NAT。
注意:您可能需要检查 Docker 接口网络的子网。有时它会与电信公司路由器硬件分配的基于 NAT 的网络发生冲突。
Podman 中的无根操作网络是不同的,因为设置虚拟接口需要超级用户访问权限。Podman 仍然使用新的网络命名空间,但它需要一个可以在用户空间中设置操作的接口。这是一个 TAP 接口(通常位于 tap0),配合名为 slirp4netns 的转发守护进程,容器进程就能与外部世界连接。这种方式的能力较弱,例如,容器之间无法相互连接。
网络连接的内容还有很多,包括如何在容器的网络堆栈中暴露端口供外部服务使用,但网络拓扑结构是最需要了解的。
说到这里,我们可以继续讨论 Docker 所能实现的其他各种隔离和限制,但这需要很长的时间,而且你现在可能已经明白了。容器并非来自于某一个特定的功能,而是它们的集合。因此,Docker 必须跟踪我们在创建容器时所做的所有事情,还必须能够清理它们。
只要有进程在运行,Docker 就会将容器定义为 “正在运行”。你可以用 docker ps 显示当前正在运行的容器:- $ docker ps
- CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
- bda6204cecf7 hlw_test "/bin/bash" 8 hours ago Up 8 hours boring_lovelace
- 8a48d6e85efe hlw_test "/bin/bash" 20 hours ago Up 20 hours awesome_elion
复制代码 一旦所有进程都终止,Docker 就会将它们置于退出状态,但仍会保留容器(除非你使用 --rm 选项启动)。这包括对文件系统所做的更改。你可以通过 docker export 轻松访问文件系统。
你需要注意这一点,因为 docker ps 默认不显示已退出的容器;你必须使用 -a 选项才能看到所有内容。退出的容器很容易堆积如山,如果容器中运行的应用程序创建了大量数据,磁盘空间就会耗尽,而你却不知道原因。使用 docker rm 移除已终止的容器。
这同样适用于旧镜像。开发镜像往往是一个重复的过程,当你用与现有镜像相同的标签标记一个镜像时,Docker 不会移除原来的镜像。旧镜像只是失去了标签。如果运行 docker images 来显示系统中的所有镜像,你就能看到所有镜像。下面是一个例子,显示的是没有标签的旧版本镜像:- $ docker images
- REPOSITORY TAG IMAGE ID CREATED SIZE
- hlw_test latest 1b64f94e5a54 43 hours ago 9.19MB
- <none> <none> d0461f65b379 46 hours ago 9.19MB
- alpine latest f70734b6a266 4 weeks ago 5.61MB
复制代码 使用 docker rmi 删除图像。这也会删除该镜像所基于的不必要的中间镜像。如果不删除镜像,它们就会随着时间的推移而增加。根据镜像的内容和构建方式,这可能会占用系统中大量的存储空间。
一般来说,Docker 会进行大量细致的版本控制和检查点处理。与 LXC 等工具相比,这层管理反映了一种特殊的理念,你很快就会看到。
- Docker 服务流程模型
Docker 容器的一个可能令人困惑的方面是其中进程的生命周期。在进程完全终止之前,其父进程应该使用 wait() 系统调用来收集(“获取”)其退出代码。然而,在容器中,有些情况下,由于父进程不知道如何应对,死进程可能会继续存在。再加上许多映像的配置方式,这可能会让你得出这样的结论:你不应该在 Docker 容器中运行多个进程或服务。这是不正确的。
你可以在一个容器中运行多个进程。我们在示例中运行的 shell 会在运行命令时启动一个新的子进程。真正重要的是,当你有子进程时,父进程会在其退出时进行清理。大多数父进程都会这样做,但在某些情况下,你可能会遇到父进程不这样做的情况,尤其是在父进程不知道自己有子进程的情况下。这种情况可能发生在有多级进程生成的情况下,而容器内的 PID 1 最终成为了它不知道的子进程的父进程。
为了解决这个问题,如果你有一个简单的单向服务,它只会产生一些进程,而且即使容器本应终止,似乎也会留下残留的进程,那么你可以在 docker run 中添加 --init 选项。这会创建一个非常简单的 init 进程,在容器中以 PID 1 的身份运行,并充当父进程,知道当子进程终止时该做什么。
不过,如果你要在容器内运行多个服务或任务(比如某个工作服务器的多个 Worker),你可以考虑使用 Supervisor(supervisord)等进程管理守护进程来启动和监控它们,而不是用脚本来启动它们。这不仅能提供必要的系统功能,还能让你更好地控制服务进程。
关于这一点,如果你正在考虑容器的这种模式,你可以考虑另一种方案,它不涉及 Docker。
17.2.3 LXC
我们围绕 Docker 进行的讨论,不仅因为它是构建容器镜像的最流行系统,还因为它让入门和进入容器通常提供的隔离层变得非常容易。不过,还有其他用于创建容器的软件包,它们采用了不同的方法。其中,LXC 是历史最悠久的软件包之一。事实上,Docker 的最初版本就是基于 LXC 构建的。如果你理解了关于 Docker 如何工作的讨论,就不会对 LXC 的技术概念感到困难,因此我们将不再举例说明。相反,我们将只探讨一些实际差异。
LXC 一词有时用来指使容器成为可能的一系列内核功能,但大多数人都用它来特指一个库和软件包,其中包含大量用于创建和操作 Linux 容器的实用程序。与 Docker 不同,LXC 需要大量的手动设置。例如,你必须创建自己的容器网络接口,还需要提供用户 ID 映射。
最初,LXC 的目的是在容器内尽可能多地安装整个 Linux 系统--启动等。在安装了一个特殊版本的发行版后,你就可以为容器内运行的任何程序安装所需的一切。这部分与你在 Docker 中看到的并无太大区别,但需要做更多的设置;而在 Docker 中,你只需下载一堆文件,然后就可以开始运行了。
因此,你可能会发现 LXC 在适应不同需求方面更加灵活。例如,LXC 默认不使用 Docker 中的覆盖文件系统,不过你可以添加一个。由于 LXC 是基于 C API 构建的,因此如有必要,你可以在自己的软件应用程序中使用这种粒度。
一个名为 LXD 的配套管理软件包可以帮助你完成 LXC 的一些更精细的手动操作(如网络创建和映像管理),它还提供了一个 REST API,你可以用它来访问 LXC,而不是 C API。
17.2.4 Kubernetes
说到管理,容器在许多类型的网络服务器中都很流行,因为您可以从一个镜像启动多个容器,从而提供出色的冗余。遗憾的是,这可能很难管理。您需要执行以下任务:
- 跟踪哪些机器可以运行容器。
- 在这些机器上启动、监控和重启容器。
- 配置容器启动。
- 根据需要配置容器网络。
- 加载新版本的容器映像,并优雅地更新所有正在运行的容器。
这并不是一份完整的清单,也不能正确表达每项任务的复杂性。为此,人们乞求开发软件,而在出现的解决方案中,谷歌的 Kubernetes 已成为主导。这其中最大的一个原因可能就是它能够运行 Docker 容器镜像。
Kubernetes 有两个基本方面,就像任何客户端-服务器应用程序一样。服务器涉及可用于运行容器的机器,而客户端主要是一套命令行实用程序,用于启动和操作容器集。容器(及其组成的组)的配置文件可能非常庞大,你很快就会发现客户端的大部分工作都是创建相应的配置。
你可以自己探索配置。如果不想亲自设置服务器,可以使用 Minikube 工具在自己的机器上安装一个运行 Kubernetes 集群的虚拟机。
17.2.5 容器的陷阱
如果你想一想 Kubernetes 这样的服务是如何运行的,你就会意识到使用容器的系统并非没有成本。至少,你仍然需要一台或多台机器来运行容器,而且这必须是一台完整的 Linux 机器,无论是在真实硬件上还是在虚拟机上。虽然维护核心基础架构可能比维护需要安装许多自定义软件的配置更简单,但维护成本仍然存在。
维护成本有多种形式。如果你选择管理自己的基础架构,那就需要投入大量的时间,而且还有硬件、托管和维护成本。如果你选择使用 Kubernetes 集群等容器服务,那么你就得支付让别人代劳的金钱成本。
在考虑容器本身时,请记住以下几点:
为了让任何应用程序在容器内运行,容器必须包含 Linux 操作系统的所有必要支持,如共享库。这可能会变得相当庞大,尤其是如果你没有特别注意为容器选择的基本发行版。然后,考虑一下你的应用程序本身:它有多大?在使用具有多个相同容器副本的覆盖文件系统时,这种情况会得到一定程度的缓解,因为它们共享相同的基础文件。但是,如果你的应用程序创建了大量运行时数据,那么所有这些覆盖层的上层就会变得很大。
你可以对容器的消耗量进行配置限制,但仍受限于底层系统的处理能力。系统仍然有内核和块设备。如果超载,容器、底层系统或两者都会受到影响。
在使用覆盖文件系统的容器系统(如 Docker)中,运行期间对文件系统所做的更改会在进程终止后被丢弃。在许多应用程序中,所有的用户数据都会存入数据库,然后这个问题就被简化为数据库管理。那么日志呢?日志是服务器应用程序正常运行的必要条件,但您仍然需要一种存储日志的方法。对于任何大规模生产来说,单独的日志服务都是必不可少的。
如果你运行的是典型的网络服务器,你会发现有大量关于在容器中运行网络服务器的支持和信息。尤其是 Kubernetes,它有很多防止服务器代码失控的安全功能。这可能是一个优势,因为它弥补了大多数网络应用程序(坦率地说)编写不佳的缺陷。然而,当你试图运行另一种服务时,有时会感觉像是要把方钉塞进一个圆洞。
你正在创建一个孤立的环境,但这并不能避免你在该环境中犯错误。你可能不必太担心 systemd 的复杂性,但仍有很多其他事情可能会出错。当任何类型的系统出现问题时,缺乏经验的用户往往会胡乱添加一些东西,试图让问题消失。这种情况会一直持续下去(往往是盲目的),直到最后形成一个有点功能性的系统,但同时也会出现许多额外的问题。您需要了解您所做的更改。
我们在本书的示例中使用了最新标签。这应该是一个容器的最新(稳定)版本,但这也意味着,当你根据发行版或软件包的最新版本构建容器时,下面的某些内容可能会发生变化,从而破坏你的应用程序。一种标准做法是使用基础容器的特定版本标记。
这尤其适用于使用 Docker 构建的映像。当你以 Docker 镜像库中的容器为基础时,你就需要信任额外的管理层,确保它们没有被修改,不会带来比平时更多的安全问题,而且当你需要它们时,它们也会在那里。这与 LXC 形成了鲜明对比,后者在一定程度上鼓励你自己构建。
考虑到这些问题,你可能会认为与其他系统环境管理方式相比,容器有很多缺点。然而,事实并非如此。无论你选择哪种方式,这些问题都会以某种程度和形式存在,而且其中有些问题在容器中更容易管理。请记住,容器并不能解决所有问题。例如,如果您的应用程序在普通系统中启动(启动后)需要很长时间,那么它在容器中启动也会很慢。
17.3 基于运行时的虚拟化
最后要提到的一种虚拟化是基于用于开发应用程序的环境类型的虚拟化。这与我们迄今为止看到的系统虚拟机和容器不同,因为它不使用将应用程序放置在不同机器上的想法。相反,它是一种仅适用于特定应用程序的分离。
使用这类环境的原因是,同一系统上的多个应用程序可能使用相同的编程语言,从而造成潜在冲突。例如,在一个典型的发行版中,Python 在多个地方使用,并且可能包含许多附加软件包。如果你想在自己的软件包中使用系统版本的 Python,那么如果你想使用某个附加软件的不同版本,就会遇到麻烦。
让我们来看看 Python 的虚拟环境功能是如何创建一个只包含所需软件包的 Python 版本的。开始的方法是为环境创建一个新目录,如下所示:- python3 -m venv test-venv
复制代码 现在,请查看新的 test-venv 目录。你会看到许多类似系统的目录,如 bin、include 和 lib。要激活虚拟环境,需要源代码(而不是执行)test-venv/bin/activate 脚本:- $ . test-env/bin/activate
复制代码 将执行源化的原因是,激活本质上是设置环境变量,而运行可执行文件无法做到这一点。此时,当您运行 Python 时,您会得到 test-venv/bindirectory 中的版本(它本身只是一个符号链接),VIRTUAL_ENV 环境变量被设置为环境基本目录。你可以运行 deactivate 退出虚拟环境。
没有比这更复杂的了。设置了这个环境变量后,你就能在 test-venv/lib 中获得一个新的、空的软件包库,你在虚拟环境中安装的任何新软件都会进入这个库,而不是进入主系统的库中。
并不是所有的编程语言都像 Python 那样允许虚拟环境,但如果不是为了消除对虚拟一词的困惑,还是值得了解一下的。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |