Kubernetes实战(第二版)----第2章 理解容器
本章包括:
-
理解什么是容器
-
容器和虚拟机之间的区别
-
创建、运行并与Docker共享容器镜像
-
linux内核特性与容器
Kubernetes主要管理在容器中运行的应用程序——因此在开始研究Kubernetes之前,您需要对容器有一个很好的理解。本章解释了一个Kubernetes用户需要知道的Linux容器的基础知识。
2.1 容器简介
在第1章中提到,在相同操作系统中运行的不同微服务,需要不同的、潜在冲突的动态链接库版本,或者具有不同的环境需求。
当一个系统包含少量应用程序时,可以为每个应用程序分配一个专用的虚拟机,并在其自己的操作系统中运行每个应用程序。但是,随着微服务越来越小,数量开始增长,如果您想保持较低的硬件成本,并且不浪费资源,那么可能无法为每个微服务提供自己的VM。
这不仅仅是浪费硬件资源的问题——每个VM通常都需要单独配置和管理,这意味着运行更多的VM也会导致更高的人员需求,以及需要更好、通常更复杂的自动化系统。由于转向了微服务体系结构,其中系统由数百个已部署的应用程序实例组成,因此需要一种vm的替代方案。容器就是另一种选择。
2.1.1比较容器和虚拟机(vm)
大多数开发和运维团队现在更喜欢使用容器,而不是使用虚拟机来隔离单个微服务(通常是软件进程)的环境。它们允许您在同一台主机上运行多个服务,同时使它们彼此隔离。类似于vm,但是开销更少。
vm是运行具有多个系统进程的独立操作系统,而在容器中运行的进程在现有主机操作系统中运行。因为只有一个操作系统,所以不存在重复的系统进程。尽管所有应用程序进程都运行在相同的操作系统中,但它们的环境是隔离的。对于容器中的进程,这种隔离使得它看起来像是计算机上没有其他进程存在。在接下来的几节中,您将了解如何实现这一点,但首先让我们深入了解容器和虚拟机之间的差异。
比较容器和虚拟机的开销
与vm相比,容器要轻得多,因为它们不需要单独的资源池或任何额外的os级进程。虽然每个VM通常运行自己的一组系统进程,除了用户应用程序本身的进程所消耗的计算资源之外,还需要额外的计算资源,但容器不过是运行在现有主机OS中的一个孤立的进程,它只消耗应用程序所消耗的资源。他们几乎没有开销。
图2.1显示了两台裸机,其中一台运行两个虚拟机,另一台运行容器。后者有空间用于其他容器,因为它只运行一个操作系统,而前者运行三个操作系统--一个主机和两个来宾操作系统。
图2.1 使用vm隔离应用程序组与使用容器隔离单个应用程序
由于VM的资源开销,经常将多个应用程序分组到一个VM中。无法为每个应用程序提供一个完整的VM。但是容器不会带来任何开销,这意味着您可以为每个应用程序创建一个单独的容器。实际上,永远不应该在同一个容器中运行多个应用程序,因为这将使管理容器中的进程更加复杂。此外,所有处理容器的现有软件,包括Kubernetes本身,都是在容器中只有一个应用程序的前提下设计的。
比较容器和虚拟机的启动时间
除了较低的运行时开销之外,容器启动应用程序的速度也更快,因为只需要启动应用程序进程本身。与启动新的虚拟机不同,不需要先启动其他系统进程。
比较容器和虚拟机的隔离性
您可能会同意容器在使用资源方面明显更好,但它也有一个缺点。在虚拟机中运行应用程序时,每个虚拟机运行自己的操作系统和内核。在这些VM之下是hypervisor(可能还有一个额外的操作系统),它将物理硬件资源分割成更小的虚拟资源集,每个VM中的操作系统都可以使用这些虚拟资源。如图2.2所示,在这些VM中运行的应用程序对VM中的来宾操作系统内核进行系统调用(sys-call),然后内核在虚拟CPU上执行的机器指令通过hypervisor被转发到主机的物理CPU。
图2.2 应用程序在虚拟机和容器中运行时如何使用硬件
请注意
存在两种类型的管理程序hypervisors。类型1管理程序hypervisors不需要运行主机操作系统,而类型2管理程序hypervisors需要。
另一方面,容器都对主机操作系统中运行的单一内核进行系统调用。这个单一的内核是唯一在主机的CPU上执行指令的内核。CPU不需要像处理vm那样处理任何类型的虚拟化。
查看图2.3,查看在裸机上运行三个应用程序、在两个独立的虚拟机中运行它们或在三个容器中运行它们之间的区别。
图2.3 在裸机、虚拟机和容器中运行的应用程序之间的差异
在第一种情况下,所有三个应用程序都使用同一个内核,而且根本没有隔离。在第二种情况下,应用程序A和B在同一个VM中运行,因此共享内核,而应用程序C完全与其他两个隔离,因为它使用自己的内核。它只与前两个共享硬件。
第三种情况是在容器中运行相同的三个应用程序。虽然它们都使用同一个内核,但它们彼此隔离,完全不知道其他内核的存在。隔离是由内核本身提供的。每个应用程序只看到物理硬件的一部分,并将自己视为操作系统中唯一运行的进程,尽管它们都在同一个操作系统中运行。
理解容器隔离的安全性含义
与容器相比,使用虚拟机的主要优点是它们提供了完全的隔离,因为每个虚拟机都有自己的Linux内核,而容器都使用相同的内核。这显然会带来安全风险。如果内核中有错误,一个容器中的应用程序可能使用它来读取其他容器中的应用程序的内存。如果应用程序运行在不同的vm中,因此只共享硬件,这种攻击的概率会低得多。当然,完全隔离只能通过在单独的物理机器上运行应用程序来实现。
此外,容器共享内存空间,而每个VM使用自己的内存块。因此,如果不限制容器可以使用的内存量,则可能导致其他容器耗尽内存或将其数据交换到磁盘。
注意:这在Kubernetes中不可能发生,因为它要求在所有节点上禁用交换
理解什么启用了容器,什么启用了虚拟机
虽然虚拟机是通过CPU中的虚拟化支持和主机上的虚拟化软件启用的,但容器是由Linux内核本身启用的。稍后使用容器技术时,将了解它们。为此,您需要安装Docker,所以让我们了解一下它是如何适应容器故事的。
2.1.2 Docker容器平台介绍
虽然容器技术已经存在了很长时间,但直到Docker的兴起才被广泛知晓。Docker是第一代容器系统,使用Docker可以方便地在不同的计算机之间移植。Docker简化了将应用程序及其所有库和其他依赖项(甚至整个OS文件系统)打包成一个简单、可移植的包的过程,该包可用于在任何运行Docker的计算机上部署应用程序。
容器、镜像和注册中心简介
Docker是一个打包、分发和运行应用程序的平台。如前所述,它允许您将应用程序与其整个环境打包。这可以是应用程序需要的几个动态链接库,或者是操作系统通常附带的所有文件。Docker允许您通过公共存储库将这个包分发到任何其他启用Docker的计算机上。
图2.4 Docker的三个主要概念是镜像、注册中心和容器
图2.4显示了刚才描述的过程中出现的三个主要Docker概念。下面是它们各自的含义:
-
镜像—容器镜像是将应用程序及其环境打包到其中的镜像。比如zip文件或tarball。它包含应用程序将使用的整个文件系统和其他元数据,例如执行映像时要运行的可执行文件的路径、应用程序监听的端口以及关于镜像的其他信息。
-
注册中心——注册中心是容器镜像的存储库,支持不同的人和计算机之间的镜像交换。在构建镜像之后,您可以在同一台计算机上运行它,或者将镜像推(上传)到注册中心,然后将其拉(下载)到另一台计算机。某些注册中心是公开的,允许任何人从其中提取图像,而其他注册中心是私有的,只能由具有所需身份验证凭证的个人、组织或计算机访问。
-
容器——容器从容器镜像实例化。运行中的容器是在主机操作系统中运行的正常进程,但是它的环境与主机的环境和其他进程的环境是隔离的。容器的文件系统来源于容器镜像,但是其他文件系统也可以挂载到容器中。容器通常受到资源限制,这意味着它只能访问和使用分配给它的CPU和内存等资源。
构建、分发和运行容器镜像
为了理解容器、镜像和注册中心如何相互关联,让我们看看如何构建容器镜像、通过注册中心分发它,并从镜像创建一个运行的容器。这三个过程如图2.5到2.7所示。
图2.5 构建容器镜像
如图2.5所示,开发人员首先构建一个镜像,然后将其推入注册中心,如图2.6所示。现在,任何人都可以访问注册中心。
图2.6上传容器镜像到注册中心
如下图所示,另一个人现在可以将镜像拉到任何其他运行Docker的计算机上并运行它。Docker基于镜像创建一个隔离的容器,并调用镜像中指定的可执行文件。
图2.7 在不同的计算机上运行容器
在任何计算机上运行应用程序都是可行的,因为应用程序的环境与主机的环境是解耦的。
理解应用程序看到的环境
当您在容器中运行应用程序时,它会准确地看到您绑定到容器镜像中的文件系统内容,以及您挂载到容器中的任何其他文件系统。无论在您的笔记本电脑上还是在生产服务器上运行,即使生产服务器使用完全不同的Linux发行版,应用程序看到的文件都是相同的。应用程序通常不能访问主机操作系统中的文件,因此,如果服务器安装的库与您的开发计算机完全不同,也没有关系。
例如,如果您将应用程序与整个Red Hat Enterprise Linux (RHEL)操作系统的文件打包在一起,然后运行它,那么无论您是在基于红帽Linux的计算机上运行还是在基于debian的计算机上运行,应用程序都会认为它是在RHEL中运行的。主机上安装的Linux发行版与此无关。唯一重要的可能是内核版本和它所加载的内核模块。稍后我会解释原因。
这类似于创建VM映像,创建一个新的VM,在其中安装操作系统和应用程序,然后分发整个VM映像,以便其他人可以在不同的主机上运行它。Docker也实现了同样的效果,但是它没有使用VMs来实现应用程序隔离,而是使用Linux容器技术来实现(几乎)相同级别的隔离。
理解镜像层
与虚拟机镜像不同,虚拟机镜像是安装在VM中的操作系统所需的整个文件系统的大块,容器镜像由层组成,这些层通常要小得多。这些层可以在多个镜像之间共享和重用。这意味着,如果一个镜像的其他层已经作为包含相同层的另一个镜像的一部分下载到主机,那么只需要下载其中的某些层。
层使镜像分发非常有效,但也有助于减少镜像的存储空间占用。Docker每层只存储一次。正如您在图2.8中看到的,由包含相同层的两个镜像创建的两个容器使用相同的文件。
图2.8 容器可以共享镜像层
2.2.3创建Dockerfile来构建容器镜像
要将应用程序打包到镜像中,您必须首先创建一个名为Dockerfile的文件,该文件包含Docker在构建镜像时应该执行的指令列表。在与app.js文件相同的目录中创建该文件,并确保它包含以下清单中的三个指令。
代码清单2.3 为应用程序构建容器镜像的最小Dockerfile
//从基础镜像构建
FROM node:12
//将app.js文件添加到容器镜像中
ADD app.js /app.js
//指定镜像运行时要执行的命令
ENTRYPOINT ["node", "app.js"]
FROM行定义了容器镜像,您将使用它作为起点(构建镜像的基础图像)。在本例中,使用标签为12的node容器镜像作为基础镜像。在第二行代码中,将app.js文件从本地目录添加到镜像的根目录中,并使用相同的名称(app.js)。最后,在第三行代码中,指定在运行镜像时,Docker应该运行的命令。在本例中,Docker应该运行的命令是node app.js。选择一个基础镜像您可能想知道为什么使用这个特定的镜像作为基础镜像。因为您的应用程序是一个node .js应用程序,需要镜像包含node二进制文件,以运行应用程序。您可以使用包含此二进制文件的任何镜像,或者甚至可以使用Linux发行版基础镜像,比如fedora或者ubuntu,并在构建镜像时将Node.js安装到容器中。但是由于node镜像已经包含了运行node .js应用程序所需的所有内容,所以从头构建镜像没有意义。然而,在一些组织中,使用特定的基本镜像并在构建时向其添加软件可能是强制性的。
2.2.4 构建容器镜像
对于目前构建的镜像而言,使用Dockerfile和app.js文件已经足够了。现在,您将使用下面清单中的命令构建名为kubia:latest的镜像:
Listing 2.4 Building the image
$ docker build -t kubia:latest .
Sending build context to Docker daemon 3.072kB
//这对应于Dockerfile的第一行
Step 1/3 : FROM node:12
//Docker正在下载node:12镜像的各个层。
12: Pulling from library/node
092586df9206: Pull complete
ef599477fae0: Pull complete
…
89e674ac3af7: Pull complete
08df71ec9bb0: Pull complete
//这是第一个构建步骤完成后的镜像的ID
Digest: sha256:a919d679dd773a56acce15afa0f436055c9b9f20e1f28b4469a4bee69e0…
Status: Downloaded newer image for node:12
---> e498dabfee1c
//构建的第二步和生成的镜像ID
Step 2/3 : ADD app.js /app.js
---> 28d67701d6d9
//构建的最后一步
Step 3/3 : ENTRYPOINT ["node", "app.js"]
//最终的镜像ID及其标签
---> Running in a01d42eda116
Removing intermediate container a01d42eda116
---> b0ecc49d7a1d
Successfully built b0ecc49d7a1d
Successfully tagged kubia:latest
-t选项指定所需的镜像名称和标记,末尾的点指定包含Dockerfile和构建上下文(构建过程所需的所有工件)的目录路径。构建过程完成后,可以在计算机的本地镜像存储中使用新创建的镜像。可以通过列出本地镜像来查看它,如下面的清单所示。
Listing 2.5 Listing locally stored images
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
kubia latest b0ecc49d7a1d 1 minute ago 908 MB
…
理解镜像是如何构建的图2.11显示了在构建过程中发生的事情。您让Docker基于当前目录的内容构建一个名为kubia的镜像。Docker读取目录中的Dockerfile,并基于文件中的指令构建镜像。
图2.11 使用Dockerfile构建一个新的容器镜像
构建本身不是由docker CLI工具执行的。相反,整个目录的内容被上传到Docker守护进程中,镜像由它构建。您已经了解到CLI工具和守护进程不一定在同一台计算机上。如果您在非Linux系统(如macOS或Windows)上使用Docker,那么客户端在您的主机操作系统中,但是守护进程在Linux虚拟机中运行。但守护进程也可以在远程计算机上运行。提示:不要向构建目录中添加不必要的文件,因为它们会减慢构建过程——尤其是当Docker守护进程位于远程系统上时。要构建镜像,Docker首先从公共镜像存储库(本例中是Docker Hub)中提取基础镜像(节点:12),除非镜像经存储在本地。然后,它根据镜像创建一个新的容器,并执行Dockerfile中的下一个指令。容器的最终状态是产生一个具有自己ID的新镜像。构建过程继续处理Dockerfile中的其余指令。每一个指令都会创建一个新镜像。最后的镜像被标记为您在docker构建命令中使用-t标记指定的标记。理解镜像中的层是什么通过前面的内容,您了解到镜像由几个层组成。人们可能会认为,每个镜像只包含基本镜像的层和上面的一个新层,但事实并非如此。当构建一个镜像时,Dockerfile中的每个指令都会创建一个新的层。在构建kubia镜像的过程中,在它拉取基础镜像的所有层之后,Docker创建一个新层,并将app.js文件添加到其中。然后创建另一个层,在执行镜像时保存要运行的命令。最后一层被标记为kubia:latest。通过运行docker history,您可以看到镜像的图层及其大小,如下面的清单所示。首先打印的是顶层。
Listing 2.6 Displaying the layers of a container image
$ docker history kubia:latest
IMAGE CREATED CREATED BY SIZE
b0ecc49d7a1d 7 min ago /bin/sh -c #(nop) ENTRYPOINT ["node"… 0B //你添加的图层
28d67701d6d9 7 min ago /bin/sh -c #(nop) ADD file:2ed5d7753… 367B//你添加的图层
e498dabfee1c 2 days ago /bin/sh -c #(nop) CMD ["node"] 0B //下面是node:12镜像的层及其基础镜像
<missing> 2 days ago /bin/sh -c #(nop) ENTRYPOINT ["docke… 0B
<missing> 2 days ago /bin/sh -c #(nop) COPY file:23873730… 116B
<missing> 2 days ago /bin/sh -c set -ex && for key in 6A0… 5.4MB
<missing> 2 days ago /bin/sh -c #(nop) ENV YARN_VERSION=… 0B
<missing> 2 days ago /bin/sh -c ARCH= && dpkgArch="$(dpkg… 67MB
<missing> 2 days ago /bin/sh -c #(nop) ENV NODE_VERSION=… 0B
<missing> 3 weeks ago /bin/sh -c groupadd --gid 1000 node … 333kB
<missing> 3 weeks ago /bin/sh -c set -ex; apt-get update;… 562MB
<missing> 3 weeks ago /bin/sh -c apt-get update && apt-get… 142MB
<missing> 3 weeks ago /bin/sh -c set -ex; if ! command -v… 7.8MB
<missing> 3 weeks ago /bin/sh -c apt-get update && apt-get… 23.2MB
<missing> 3 weeks ago /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> 3 weeks ago /bin/sh -c #(nop) ADD file:9788b61de… 101MB
你看到的大多数层来自于node:12镜像(它们还包括镜像自己的基础镜像的层)。最上面的两个层对应Dockerfile中的第二个和第三个指令(ADD 和 ENTRYPOINT)。
正如您在CREATED BY列中看到的,每一层都是通过在容器中执行一个命令创建的。除了使用ADD指令添加文件之外,您还可以在Dockerfile中使用其他指令。例如,RUN指令用于构建期间在容器中执行一个命令。在上面的清单中,您将发现在一个层中执行 apt-get update 和一些附加的apt-get命令。apt-get是Ubuntu软件包管理器的一部分,用于安装软件包。清单中显示的命令将一些包安装到镜像的文件系统中。要了解RUN和其他可以在Dockerfile中使用的指令,请参阅Dockerfile的指南:https://docs.docker.com/engine/reference/builder/提示:每个指令都创建一个新层。我已经提到过,当你删除一个文件时,它只在新层中被标记为已删除,而不会从下面的层中删除。因此,用后续指令删除文件不会减少镜像的大小。如果使用RUN指令,请确保它执行的命令在终止之前删除它创建的所有临时文件。
2.2.5 运行容器镜像
创建好镜像后,您现在可以使用以下命令运行容器镜像:
$ docker run --name kubia-container -p 1234:8080 -d kubia
9d62e8a9c37e056a82bb1efad57789e947df58669f94adc2006c087a03c54e02
这告诉Docker从kubia镜像中运行一个名为kubia-container的新容器。容器与控制台分离(-d标志)并在后台运行。主机计算机上的端口1234映射到容器中的端口8080(由-p 1234:8080选项指定),因此您可以通过http://localhost:1234访问应用程序。下面的图将帮助您可视化所有内容是如何结合在一起的。注意,Linux VM只存在于你使用macOS或windows环境时。如果直接使用Linux,则没有VM,描述端口1234的方框位于本地计算机的边上。
图2.12 可视化运行中的容器
访问您的应用程序现在使用curl或internet浏览器访问http://localhost:1234上的应用程序:
$ curl localhost:1234
Hey there, this is 44d76963e8e1. Your IP is ::ffff:172.17.0.1.
提示:如果Docker守护进程在另一台机器上运行,则必须使用该机器的IP替换localhost。可以在DOCKER_HOST环境变量中查找对应的IP。如果一切顺利,您应该会看到应用程序发送的响应。在我的例子中,它将返回44 d76963e8e1作为它的主机名。在您的例子中,将看到一个不同的十六进制数。这是在列出它们时显示的容器的ID。列出所有正在运行的容器要列出在您的计算机上运行的所有容器,请运行下面清单中显示的命令。命令的输出经过了编辑,以适应页面——输出的最后两行是前两行的延续。
Listing 2.6 Listing running containers
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED ...
44d76963e8e1 kubia:latest "node app.js" 6 minutes ago ...
... STATUS PORTS NAMES
... Up 6 minutes 0.0.0.0:1234->8080/tcp kubia-container
对于每个容器,Docker打印它的ID和name、它使用的镜像以及它执行的命令。它还显示创建容器的时间、容器的状态以及将哪些主机端口映射到容器。获取关于容器的其他信息docker ps命令显示了关于容器的最基本信息。要查看其他信息,您可以使用docker inspect:
$ docker inspect kubia-container
Docker打印一个长json格式的文档,其中包含很多关于容器的信息,如它的状态,配置,和网络设置,包括其IP地址。检查应用程序日志Docker捕获并存储应用程序写入标准输出和错误流的所有内容。这通常是应用程序输出日志的地方。您可以使用docker logs命令查看输出,如下面的清单所示。
Listing 2.7 Displaying the container’s log
$ docker logs kubia-container
Kubia server starting...
Local hostname is 44d76963e8e1
Listening on port 8080
Received request for / from ::ffff:172.17.0.1
您现在知道了在容器中执行和检查应用程序的基本命令。接下来,您将学习如何分发它。
2.2.6 分发容器镜像
您所构建的镜像目前仅在本地可用。要在其他计算机上运行它,必须首先将其推到外部镜像注册中心。让我们将它推到公共Docker Hub注册中心,这样就不需要设置私有注册中心。您还可以使用其他注册中心,比如Quay.io,我已经提到过了,或者谷歌容器注册中心。在推送镜像之前,必须根据Docker Hub的镜像命名模式对其重新标记。镜像名必须包含Docker Hub ID,该ID是您在http://hub.docker.com注册时选择的。在下面的示例中,我将使用自己的ID (luksa),所以在你自己运行命令时,请记住用自己的ID替换它。使用附加标签标记镜像有了ID之后,就可以为镜像添加额外的标记了。它当前的名字是kubia,现在你也可以标记它为yourid/kubia:1.0(用你实际的Docker Hub ID替换yourid)。这是我使用的命令:
$ docker tag kubia luksa/kubia:1.0
通过再次列出镜像,确认您的镜像现在有两个名称,如下面的清单所示。
清单2.7具有多个标记的容器镜像
$ docker images | head
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
luksa/kubia 1.0 b0ecc49d7a1d About an hour ago 908 MB
kubia latest b0ecc49d7a1d About an hour ago 908 MB
node 12 e498dabfee1c 3 days ago 908 MB
如您所见,kubia和luksa/kubia:1.0都指向相同的镜像ID,这意味着它们不是两个镜像,而是具有两个名称的单个镜像。将镜像推到Docker Hub在您将镜像推送到Docker Hub之前,您必须使用您的用户ID以及Docker登录命令进行登录,如下所示:
$ docker login -u yourid -p yourpassword docker.io
登录后,用以下命令将yourid/kubia:1.0镜像推送到Docker Hub:
$ docker push yourid/kubia:1.0
在其他主机上运行镜像当推到Docker Hub完成时,镜像对所有人都可用。您现在可以在任何启用docker的主机上运行以下命令来运行镜像:
$ docker run -p 1234:8080 -d luksa/kubia:1.0
如果容器在您的计算机上正确地运行,那么它应该可以在任何其他Linux计算机上运行,前提是Node.js二进制文件不需要任何特殊的内核特性(它确实不需要)。
2.2.7 停止和删除容器
如果已经在另一台主机上运行了容器,那么现在可以终止它,因为在接下来的练习中只需要本地计算机上运行的容器。停止一个容器用以下命令停止容器:
$ docker stop kubia-container
这将向容器中的主进程发送一个终止信号,以便它能够优雅地关闭。如果进程没有响应终止信号,或者没有及时关闭,Docker会杀死它。当容器中的顶级进程终止时,容器中没有其他进程运行,因此容器停止。删除一个容器容器不再运行,但仍然存在。Docker保留它,以防你决定重新启动它。通过运行docker ps -a,您可以看到已停止的容器。选项-a将打印所有容器—正在运行的和已停止的容器。作为练习,您可以通过运行docker start kubia-container再次启动容器。您可以安全地删除另一台主机上的容器,因为您不再需要它了。要删除它,运行以下docker rmi命令:
$ docker rm kubia-container
这将删除容器。它的所有内容被删除,并且它不能再被启动。不过,镜像还在那里。如果您决定再次创建容器,则不需要再次下载镜像。如果你也想删除镜像,使用docker rmi命令:
$ docker rmi kubia:latest
要删除所有挂起镜像,还可以使用docker image prune命令。
2.3 理解是什么使容器成为可能
您应该在本地计算机上使容器保持运行,以便在以下练习中使用它,在这些练习中,将研究容器如何在不使用虚拟机的情况下允许进程隔离。Linux内核的几个特性使这成为可能,现在是了解它们的时候了。
2.3 理解:什么使容器成为可能
您应该在本地计算机上使容器保持运行,以便在以下练习中使用它,在这些练习中,将研究容器如何在不使用虚拟机的情况下实现进程隔离。Linux内核的几个特性使这成为可能,现在是了解它们的时候了。
2.3.1 使用Namespaces定制进程环境
第一个特性称为Linux命名空间( Linux Namespaces ),它确保每个进程都有自己的系统视图。这意味着在容器中运行的进程将只看到系统上的一些文件、进程和网络接口,以及不同的系统主机名,就像它在单独的虚拟机中运行一样。默认情况下,Linux OS中可用的所有系统资源,如文件系统、进程id、用户id、网络接口和其他资源,都在所有进程可看到和使用的相同bucket(桶)中。但是内核允许创建称为命名空间的额外bucket,并将资源移入其中,以便将它们组织成较小的资源集合。可以使每个资源集合仅对一个进程或一组进程可见。创建新进程时,可以指定它应该使用的命名空间。此时进程只看到这个命名空间中的资源,而看不到其他命名空间中的资源。引入可用的命名空间类型更具体地说,并不是只有单一类型的命名空间。实际上有几种类型—每种命名空间对应一种资源类型。因此,进程不仅使用一个命名空间,而且对每种类型都使用一个命名空间。命名空间存在以下类型:
- 挂载命名空间(mnt)隔离挂载点(文件系统)。
- 进程ID命名空间(pid)隔离进程ID。
- 网络命名空间(net)隔离网络设备、堆栈、端口等。
- 进程间通信命名空间(ipc)隔离进程间的通信(这包括隔离消息队列、共享内存和其他)。
- UNIX分时系统(UTS)命名空间隔离系统主机名和网络信息服务(NIS)域名。
- 用户ID命名空间(User)隔离用户和组ID。
- Cgroup命名空间隔离控制组的根目录。你将在本章后面了解cgroups。
使用网络命名空间为进程提供一组专用的网络接口进程运行的网络命名空间决定了进程可以看到哪些网络接口。每个网络接口完全属于一个命名空间,但是可以从一个命名空间移动到另一个命名空间。如果每个容器使用自己的网络命名空间,那么每个容器将看到自己的一组网络接口。查看图2.13,了解如何使用网络命名空间创建容器。假设您希望运行一个容器化的进程,并为其提供一组专用的网络接口,该网络接口只能由该进程使用。
图2.13 网络命名空间限制了进程使用的网络接口
最初,只有默认的网络命名空间存在。然后为容器创建两个新的网络接口和一个新的网络命名空间。然后可以将网络接口从默认命名空间移到新的命名空间。一旦到了那里,就可以重新命名它们,因为名称必须在每个命名空间中唯一。最后,进程可以在这个网络命名空间中启动,这允许它只看到为它创建的两个网络接口。如果只查看可用的网络接口,进程无法分辨它是在容器、VM还是直接运行在裸机上的操作系统中。使用UTS命名空间为进程提供专用主机名要使进程看起来像在自己的主机上运行,另一个示例是使用UTS命名空间。它决定了在此命名空间内运行的进程所看到的主机名和域名。通过给两个不同的进程分配两个不同的UTS命名空间,可以使它们看到不同的系统主机名。对于这两个进程来说,它们似乎运行在两台不同的计算机上。理解命名空间如何隔离进程通过为所有可用的命名空间类型创建一个专用的命名空间实例并将其分配给进程,可以使进程相信它在自己的操作系统中运行。这样做的主要原因是每个进程都有自己的环境。进程只能在自己的命名空间中查看和使用资源。它不能在其他命名空间中使用。同样地,其他进程也不能使用它的资源。容器就是这样隔离在其中运行的进程的环境的。在多个进程之间共享命名空间在下一章中,您将了解到,并不总是希望将容器彼此完全隔离。相关容器可能想要共享某些资源。下图显示了共享相同网络接口、主机和系统域名(但不共享文件系统)的两个进程的示例。
图2.14 每个进程都与多个命名空间类型相关联,其中一些可以共享。
首先关注共享的网络设备。这两个进程看到并使用相同的两个设备(eth0和lo),因为它们使用相同的网络命名空间。这允许它们绑定到相同的IP地址并通过环回设备进行通信,就像它们在不使用容器的机器上运行时一样。这两个进程还使用相同的UTS命名空间,因此看到相同的系统主机名。但是,它们各自使用自己的挂载命名空间,这意味着它们有各自的文件系统。总之,进程可能希望共享某些资源,而不是共享所有资源。这是可能的,因为存在不同的命名空间类型。进程为每种类型都有一个关联的命名空间。考虑到这些,有人可能会问,容器到底是什么?一个“在容器中”运行的进程不会运行在一个类似于虚拟机的实际封闭环境中。它只是一个进程,为它分配了七个命名空间(每个命名空间对应一个类型)。有些命名空间与其他进程共享,而有些命名空间则不共享。这意味着进程之间的边界并不都在同一条线上。在后面的章节中,您将了解如何通过使用现有容器的网络命名空间直接在主机操作系统上运行新进程来调试容器,同时对其他所有内容使用主机的默认命名空间。这将允许您使用主机上可用的工具来调试容器的网络系统,而这些工具在容器中可能不可用。
2.3.2 探索正在运行的容器的环境
如果您想看看容器内的环境是什么样子的呢?系统主机名是什么,本地IP地址是什么,文件系统上有哪些二进制文件和库可用,等等?要在VM的情况下研究这些特性,通常需要通过ssh远程连接到VM,并使用shell执行命令。这个过程与容器非常相似。您在容器中运行一个shell。提示:shell的可执行文件必须在容器的文件系统中可用。在生产环境中运行的容器并不总是如此。在现有容器中运行shell你的镜像所基于的Node.js镜像提供了bash shell,这意味着你可以在容器中运行以下命令:
$ docker exec -it kubia-container bash
#这是shell的命令提示符
root@44d76963e8e1:/#
这个命令在kubia-container容器中作为附加进程运行bash。该进程与主容器进程(运行的Node.js服务器)具有相同的Linux命名空间。通过这种方式,您可以从内部探索容器,查看Node.js和您的应用程序在容器中运行时是如何查看系统的。-it选项是两个选项的简写:
- -i 告诉Docker在交互模式下运行命令。
- -t 告诉Docker分配一个伪终端(TTY),这样您就可以正确地使用shell。
如果您想按照您习惯的方式使用shell,则需要这两个选项。如果省略第一个,就不能执行任何命令,如果省略第二个,就不会出现命令提示符,一些命令可能会抱怨没有设置TERM变量。列出容器中正在运行的进程让我们通过在容器中运行的shell中执行ps aux命令来列出在容器中运行的进程。下面的清单显示了该命令的输出。
Listing 2.8 Listing processes running in the container
root@44d76963e8e1:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 676380 16504 ? Sl 12:31 0:00 node app.js
root 10 0.0 0.0 20216 1924 ? Ss 12:31 0:00 bash
root 19 0.0 0.0 17492 1136 ? R+ 12:38 0:00 ps aux
该列表只显示了三个进程。它们是在容器中运行的仅有的三个进程。您无法看到在主机OS或其他容器中运行的其他进程,因为容器在自己的进程ID命名空间中运行。在主机进程列表中查看容器进程如果您现在打开另一个终端并列出主机OS本身中的进程,您还将看到在容器中运行的进程。这确认了容器中的进程实际上是在主机OS中运行的常规进程,如下面的清单所示。
#清单2.9 容器的进程在主机操作系统中运行
$ ps aux | grep app.js
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 382 0.0 0.1 676380 16504 ? Sl 12:31 0:00 node app.js
提示:如果使用macOS或Windows,则必须列出驻留Docker守护进程的VM中的进程,因为容器在那里运行。在Docker Desktop中,可以使用以下命令进入VM:
docker run --net=host --ipc=host --uts=host --pid=host -it --security-opt=seccomp=unconfined --privileged --rm -v /:/host alpine chroot /host
如果您有敏锐的眼光,您可能会注意到容器中的进程id与主机上的不同。因为容器使用自己的进程ID命名空间,所以它有自己的进程树和自己的ID号序列。如图所示,树是主机的完整进程树的一个子树。因此,每个进程都有两个id。
图2.15 PID命名空间使进程子树显示为具有自己编号序列的单独进程树
容器的文件系统与主机和其他容器隔离与隔离的进程树一样,每个容器也有一个隔离的文件系统。如果列出容器根目录的内容,则只显示容器中的文件。这包括来自容器镜像的文件和在容器操作期间创建的文件,比如日志文件。下一个清单显示了kubia container中的文件。
#清单2.10容器有自己的文件系统
root@44d76963e8e1:/# ls /
app.js boot etc lib media opt root sbin sys usr
bin dev home lib64 mnt proc run srv tmp var
它包含app.js文件和其他系统目录,它们是node:12基本镜像的一部分。欢迎您浏览容器的文件系统。您将看到,无法从主机的文件系统查看文件。这很好,因为它阻止了潜在的攻击者通过Node.js服务器中的漏洞访问它们。要离开容器,请通过运行exit命令或按Control-D离开shell,您将返回到您的主机计算机(类似于从ssh会话中注销)。提示:在调试容器中运行的应用程序时,像这样进入一个正在运行的容器非常有用。当出现故障时,首先要调查的是应用程序看到的系统的实际状态。
2.3.3 通过Linux控制组(Linux Control Groups)限制进程的资源
Linux命名空间使得进程可以只访问主机的一些资源,但是它们没有限制每个进程可以消耗多少单个资源。例如,可以使用命名空间只允许进程访问特定的网络接口,但是不能限制进程消耗的网络带宽。同样,也不能使用命名空间来限制进程可用的CPU时间或内存。您可能希望这样做,以防止某个进程消耗所有CPU时间,影响关键的系统进程正常运行。为此,我们需要Linux内核的另一个特性。
2.3.3 通过Linux控制组( Linux Control Groups)限制进程的资源使用
Linux命名空间使得进程可以只访问主机的一些资源,但是它们没有限制每个进程可以消耗多少单个资源。例如,可以使用命名空间只允许进程访问特定的网络接口,但是不能限制进程消耗的网络带宽。同样,也不能使用命名空间来限制进程可用的CPU时间或内存。您可能希望这样做,以防止某个进程消耗所有CPU时间,影响关键的系统进程正常运行。为此,我们需要Linux内核的另一个特性。cgroups简介使容器成为可能的第二个Linux内核特性称为Linux控制组(cgroups)。它限制、计算和隔离系统资源,如CPU、内存和磁盘或网络带宽。当使用cgroups时,一个进程或一组进程只能使用分配的CPU时间、内存和网络带宽。这样,进程就不能占用为其他进程保留的资源。此时,您不需要知道控制组是如何完成所有这些工作的,但是您可以看看如何要求Docker限制容器可以使用的CPU和内存数量。限制容器对CPU的使用如果对容器CPU的使用不施加任何限制,那么它可以无限制地访问主机上的所有CPU核心。可以通过Docker的--cpuset-cpus选项显式指定容器可以使用多少CPU core。
$ docker run --cpuset-cpus="1,2" ...
您还可以使用选项限制可用的CPU时间--cpus, --cpu-period, --cpu-quota 和--cpu-shares。例如,要允许容器只使用半个CPU核心,请运行容器如下:
$ docker run --cpus="0.5" ...
限制容器对内存的使用与CPU一样,容器可以使用所有可用的系统内存,就像任何常规操作系统进程一样,但您可能希望限制这一点。Docker提供了以下选项来限制容器memory和swap的使用:--memory, --memory-reservation, --kernel-memory, --memory-swap, and --memory-swappiness.例如,要将容器中可用的最大内存大小设置为100MB,运行容器如下(m代表mb):
$ docker run --memory="100m" ...
在后台,所有这些Docker选项只是配置进程的cgroups。内核负责限制进程可用的资源。有关其他内存和CPU限制选项的更多信息,请参阅Docker文档。
2.3.4 增强容器之间的隔离
Linux命名空间和Cgroups隔离容器的环境,防止一个容器耗尽其他容器的计算资源。但是这些容器中的进程使用相同的系统内核,所以我们不能说它们是真正隔离的。一个不安全的容器可能会发出恶意的系统调用,从而影响到它的邻居。假设一个Kubernetes节点上运行多个容器。它们都有自己的网络设备和文件,只能消耗有限的CPU和内存。乍一看,这些容器中的一个流氓程序不会对其他容器造成伤害。但是,如果恶意程序修改了由所有容器共享的系统时钟怎么办?根据应用程序的不同,更改时间可能不是什么大问题,但是允许程序对内核进行任何系统调用,实际上就允许它们做任何事情。系统调用允许它们修改内核内存、添加或删除内核模块,以及其他许多常规容器不应该做的事情。这就引出了使容器成为可能的第三组技术。完全解释它们超出了本书的范围,因此请参阅其他专门关注容器或用于保护容器的技术资源。本节将简要介绍这些技术。将系统的全部特权授予容器操作系统内核提供了一组系统调用,程序使用这些调用与操作系统和底层硬件进行交互。这些调用包括创建进程、操作文件和设备、在应用程序之间建立通信通道等等。其中一些系统调用是相当安全的,并且对任何进程都可用,但是其他的系统调用仅为具有高级特权的进程保留。如果查看前面给出的示例,应该允许运行在Kubernetes节点上的应用程序打开它们的本地文件,但不允许更改系统时钟或以破坏其他容器的方式修改内核。大多数容器应该在没有提升特权的情况下运行。只有那些您信任并且实际上需要额外特权的程序才应该在特权容器中运行。提示:使用Docker,您可以使用 --privileged 标识创建一个有特权的容器使用capabilities(能力)为容器提供所有特权的子集如果应用程序只需要调用一部分需要提升权限的系统调用,那么创建具有完整权限的容器并不理想。幸运的是,Linux内核还将特权划分为称为capabilities的单元。capabilities的例子有:
- CAP_NET_ADMIN允许进程执行与网络相关的操作,
- CAP_NET_BIND_SERVICE允许它绑定到小于1024的端口号,
- CAP_SYS_TIME允许修改系统时钟,等等。
当您创建容器时,可以从容器中添加或删除Capabilities。每个capability都表示容器中进程可用的一组特权。Docker和Kubernetes删除了除典型应用程序所需要的功能外的所有Capabilities,但用户可以在获得授权后添加或删除其他Capabilities。提示:在运行容器时始终遵循最小特权原则。不要给他们任何他们不需要的Capabilities。这样可以防止攻击者使用它们访问您的操作系统。使用seccomp配置文件过滤单个系统调用如果需要更好地控制程序可以使用什么系统调用,可以使用seccomp(安全计算模式)。您可以创建一个定制的seccomp配置文件,方法是创建一个JSON文件,该文件列出了允许使用该配置文件的容器进行的系统调用。然后在创建容器时将该文件提供给Docker。使用AppArmor和SELinux加固容器似乎到目前为止讨论的技术还不够,容器还可以使用两种附加的强制访问控制(MAC)机制进行加固:SELinux(安全增强的Linux)和AppArmor(应用程序盔甲)。使用SELinux,可以将标签附加到文件和系统资源,以及用户和进程。如果所涉及的所有主题和对象的标签与一组策略匹配,则用户或进程只能访问文件或资源。类似的是,AppArmor使用文件路径而不是标签,并且关注进程而不是用户。SELinux和AppArmor都极大地提高了操作系统的安全性,但是如果您被所有这些安全相关的机制所淹没,也不必担心。本节的目的是阐明与容器的适当隔离有关的所有内容,但是目前对命名空间有基本的了解已经足够了。
2.4 总结
如果您在阅读本章之前还不熟悉容器,那么您现在应该了解了它们是什么,我们为什么使用它们,以及Linux内核的哪些特性使它们成为可能。如果您以前使用过容器,我希望这一章帮助您澄清了关于容器如何工作的不确定性,并且您现在了解到它们只不过是Linux内核与其他进程隔离的常规操作系统进程。读完这一章,你应该知道:
- 容器是常规进程,但相互隔离,并与主机OS中运行的其他进程隔离。
- 容器比虚拟机轻得多,但是因为它们使用相同的Linux内核,所以它们不像vm那样相互隔离。
- Docker是使容器流行起来的第一个容器平台,也是Kubernetes支持的第一个容器运行时环境。现在,通过容器运行时接口(CRI)支持其他接口。
- 容器镜像包含用户应用程序及其所有依赖项。它通过容器注册中心分发,并用于创建运行中的容器。
- 容器可以通过一个docker run命令下载并执行。
- Docker从Dockerfile构建一个镜像,该镜像包含Docker在构建过程中应该执行的命令。镜像由可以在多个镜像之间共享的层组成。每一层只需要传输和存储一次。
- 容器被称为Namespaces, Control groups, Capabilities, seccomp, AppArmor 和/或 SELinux的Linux内核特性隔离。命名空间确保容器只能看到主机上可用资源的一部分,控制组限制它可以使用的资源数量,而其他特性加强了容器之间的隔离。
在检查了这艘船上的容器之后,现在可以起锚进入下一章了,在下一章中,您将学习如何使用Kubernetes运行容器。第三章 部署第一个应用程序本章涵盖了
- 在笔记本电脑上运行单节点Kubernetes集群
- 在谷歌Kubernetes引擎上设置Kubernetes集群
- 设置和使用kubectl命令行工具
- 在Kubernetes中部署应用程序,并使其在全球范围内可用
- 水平扩展应用程序