Kubernetes in Action 笔记 —— 理解容器技术

一、容器 vs 虚拟机

系统开销

每一台虚拟机都需要安装独属于自己的操作系统,包含一系列系统进程;而同一台宿主机上的多个容器会共享宿主机的操作系统,它们的环境仍然是独立的。
即对于容器而言,不需要装一个独立的操作系统。不会像虚拟机那样,存在很多套重复的系统进程。因而容器更加轻量。
容器只包含一套隔离的进程,运行在已有的宿主机操作系统上,只会消耗这套隔离进程运行所需的系统资源

overhead VM vs Container

由于虚拟机高额的开销,通常需要将多个关联的应用部署在同一台虚拟机上。对于开销较低的容器而言,则可以为每一个应用都创建一个独立的容器。
实际上也应该确保每一个容器都只包含一个应用,这样方便管理,同时 Kubernetes 等容器管理平台也是默认这个原则的。

启动时间

容器拥有更快的启动时间。因为容器只需要启动自身包含的应用进程,不需要像启动一台新的虚拟机那样,先启动一些额外的系统进程。

隔离性

无疑虚拟机的隔离性更好。
当使用虚拟机部署应用时,每台虚拟机都拥有一套独立的操作系统和内核。这些虚拟机的底层是 hypervisor,将物理硬件划分成一系列更小的虚拟资源,供给不同的虚拟机使用。

当虚拟机中运行的应用向虚拟机内核发起系统调用时,内核会先在虚拟的 CPU 上执行机器指令,再通过 hypervisor 转发给宿主机的物理 CPU 执行。
容器发起的系统调用则都可以直接传递给宿主机上运行的系统内核,再转化为机器指令传递给宿主机的 CPU。宿主机 CPU 不需要处理任何形式的虚拟化。

system calls

同一台机器上的多个容器共享同一个宿主机内核,但它们之间仍然是隔离的,相互之间并不清楚其他人的存在,也只能看到一部分物理硬件。
这种隔离是由宿主机内核提供的。
Isolation

由隔离性引发的安全性

容器使用同一个内核。如果内核出现 bug,某个容器中的应用有可能会利用这个 bug 读取其他容器中其他应用的内存。
此外,容器会共享内存空间。如果不限制某个容器能够使用的内存总量,有可能会导致其他容器没有足够的内存使用。

二、Docker 容器平台介绍

Docker 是一个帮助用户打包、发布和运行容器应用的平台。用户可以使用 Docker 将应用及其运行环境(可以是一些动态库等依赖,甚至操作系统提供的所有文件)打包,并可以将打包后的镜像发布到一个公共的镜像源,再部署到其他安装了 Docker 的机器上。

Docker Platform

三个 Docker 中的基本概念:

  • Images(镜像):类似于一个 zip 压缩包,包含了应用及其运行环境
  • Registries(源):一个方便用户和机器分发、共享镜像的站点。可以将打包好的镜像 push 到源,这样另一台机器就可以从镜像源 pull 该镜像到本地
  • Containers(容器):相当于实例化的镜像。一个运行的容器相当于宿主机中一个普通的进程,只不过它的环境是隔离的

docker push

docker pull

Image Layers

不同于虚拟机镜像是由整个文件系统构成的一个大的文件块,容器镜像通常是由更小的构成。这些层可以被多个镜像所共享。
如果某个镜像需要的部分镜像层已经被下载到了宿主机上(在 pull 其他镜像的时候),则 pull 该镜像时只需要下载之前未 pull 的层即可。
镜像层使得发布镜像变得更加高效,同时也提升了宿主机的存储空间。

Image Layers

如上图中的三个容器,它们可以共享访问一部分共有的文件。但它们是如何同时做到隔离的呢?其中某个容器若修改了共享的文件,如何做到不对其他容器可见?
文件系统的隔离由 Copy-on-Write (CoW) 机制实现。
容器中的文件系统由从镜像而来的只读层和加在只读层上面的一个读写层构成。当某个运行的容器修改了只读层中的文件,该文件会被整个复制到容器的读写层。每个容器都拥有自己所独有的读写层,因此对共享文件的修改并不会对其他容器可见。
当删除某个文件,该文件只是在读写层中被标记为已删除,实际上该文件仍然存在于只读层中。因此删除文件并不会减少镜像的大小。

镜像层的潜在限制

理论上讲,基于 Docker 的镜像可以运行在任意一台启用了 Docker 的机器上。但是由于容器并没有自己的内核,如果一个容器需要特定版本的内核才能运行,它有可能不会运行在每一台机器上。

container requires specific kernel version

此外,容器化的应用只能运行在特定的硬件架构上。比如不能把一个构建在 x86 CPU 架构上的应用,部署在 ARM 平台的 Docker 上。

三、容器背后的技术

Namespace

Linux 命名空间可以确保每个进程都只能看到它自己视角的系统。即容器中的进程只能看到部分文件、进程、网络接口和机器名,就好像它运行在一个独立的虚拟机上。
内核可以创建额外的命名空间,然后将部分资源移动到该命名空间,并令其只对某一个或一组进程可见。

命名空间的类型:

  • Mount 命名空间用来隔离挂载点(文件系统)
  • Process ID 命名空间用来隔离进程 ID
  • Network 命名空间用来隔离网络设备、端口等
  • ipc 命名空间用来隔离进程间的通信(包括管理消息队列、共享内存等)
  • UTS (UNIX Time-sharing System) 命名空间用来隔离系统的主机名和 NIS (Network Information Service) 域名
  • User ID 命名空间用来隔离用户和组 ID
  • Cgroup 命名空间用来隔离 Control Groups 根目录

network namespace

有时候并不会想要将某个容器与另一个容器完全隔离,相互关联的容器之间有可能会共享特定的资源。

shared namespace

比如上图中的两个容器。它们可以看见并使用相同的两个网络设备(eth0lo),因为它们拥有相同的网络命名空间。它们也因此可以绑定相同的 IP 地址并通过 loopback 设备相互通信。
这两个容器还使用同一个 UTS 命名空间,因此它们可以见到相同的主机名。但它们的 Mount 命名空间是不同的,即拥有不同的文件系统。

容器中运行的进程只是一个绑定了 7 类命名空间的普通进程

Linux Control Groups

Linux 命名空间可以控制进程只能访问一部分系统资源,但它们不能限制每个进程消耗的资源总量。
Linux Control Groups (cgroups) 则可以限制进程只能使用预先分配好的固定额度的 CPU 时间、内存和网络带宽等。避免某些进程吃掉所有的系统资源。

sys-calls

Linux 命名空间和 Cgroups 能够隔离容器的环境并防止某个容器消耗掉所有的计算资源。但这些容器中的进程仍然使用同一个系统内核,一个非法容器仍然可以通过一些恶意的系统调用来影响其他容器。

内核提供了一系列 sys-calls 可以被程序用来与操作系统及底层的硬件交互,包括创建进程、操作文件和设备、创建应用间的通信通道等。
其中有些 sys-calls 是安全的,可以被任意进程使用。其他一些则只允许具有更高权限的进程使用。比如容器中的应用应该允许访问它们的本地文件,但不能修改系统时钟或者以破坏其他容器的方式修改内核。

Linux 内核把这些权限分成了名为 capabilities 的单位。如:

  • CAP_NET_ADMIN:允许进程执行网络相关的操作
  • CAP_NET_BIND_SERVICE:允许进程绑定小于 1024 的端口号
  • CAP_SYS_TIME:允许进程修改系统时钟

Capabilities 能够在容器创建时添加或移除,每个 Capability 都代表一系列特殊权限。
此外,还可以使用 seccomp (Secure Computing Mode) 。创建一个 包含 seccomp 配置的 JSON 文件,在构建容器时提供给 Docker。

AppArmor & SELinux

容器还可以依靠两种 MAC(强制访问控制)机制 SELinux 和 AppArmor 来获得更高的安全性。

参考资料

Kubernetes in Action, Second Edition