容器从进程到 Namespace

容器从进程到 Namespace#

Author by: 张柯帆

!!!!!!!!! 聚焦展开下面三个内容,下面的内容都有点浅显,不是很深入,图和技术点也相对较少 ​​Namespaces​​:实现隔离(进程、网络、文件系统挂载等)。 ​​Cgroups​​:实现资源限制(CPU、内存、磁盘 IO 等)。 ​​Union File Systems​​:实现镜像的分层与叠加(AUFS, overlay2)。

容器的出现#

本节会先从讲解概念入手,谈谈容器与虚拟机的对比,再逐步深入到隔离性在操作系统里的技术细节。

容器技术其实来源于 PaaS 的发展。PaaS 所提供的正是“托管”能力,但初期还比较原始。尽管当时虚拟化已经是比较常见的技术了,用户可以向 AWS 这样的云厂商购买虚拟机实例,但用户的使用习惯仍然是本地环境那一套——登录机器,然后执行脚本或者命令行部署应用。但是假如本地环境和虚拟机的网络配置、认证配置、文件系统等资源不一致,导致部署失败了怎么办呢?又或是同实例其他人的应用资源抢占、崩溃影响了我的应用怎么办呢?这就存在两个需求:

  1. 如何保证本地环境和虚拟机的环境一致?如果总是不一致,能否更稳定地“打包”?

  2. 应用隔离,互不影响

应用隔离好办,使用操作系统提供的 Cgroups、Namespace 机制创建一个隔离的“沙箱”即可。Docker 也是这么做的。另外,早期除了 Docker 其实还有像 Cloud Foundry 这样的项目,也是使用一样的技术原理,但它并不好用。主要原因是它在环境的一致性做得并不好。Cloud Foundry 只提供了可执行文件 + 启动脚本的打包组合。但这样的方式其实只是把以前本地做的工作在云上再做一遍罢了,并没有优化效率。

举个例子,相信几乎所有的技术人员都经历过搭建开发环境吧。假如你是一位 C++ 开发者,你需要准备的东西有:包管理器、编译器(正确的版本)、构建工具、依赖库、环境变量、IDE 设置、正常的网络等等。任何一环出错了都没法进行工作。如果只需要设置一次那还能接受,但是线上系统的变更可是非常频繁的,如果每次部署都从头搞一遍那也太令人崩溃了!你可能想:我写个脚本自动执行所有命令不就得了。但也可能出现脚本在本地运行正常,在服务器上却执行失败的情况。同时由于生产环境实际非常复杂,不同框架、不同虚拟机、不同网络、甚至不同应用版本都可能需要使用不同的脚本,导致脚本管理也很麻烦。归根究底,操作系统、基础设施的方方面面都可能影响应用部署。从这个角度看,可执行文件加脚本的表达能力和约束能力都实在太弱了。

而 Docker 流行起来的原因恰恰是对“打包”的优化,这个功能就叫 Docker 镜像。它会将一个操作系统所需要的所有文件都打包成一个压缩包,包括所有依赖。再把可执行文件放进去,这样无论在哪里解压缩,应用的运行环境都跟本地是一摸一样的。而制作镜像也非常简单,只需要一行命令:

$ docker build .

接下来只要创建一个上面提到的“沙箱”,在里面解压这个压缩包并运行,就能保证环境的一致性和应用的隔离性了,完全不需要修改任何配置!开发人员可以专心在代码开发和镜像制作上了。运行镜像也非常简单:

docker run "我的镜像"

这种便利的机制极大地释放了生产力,开发者纷纷选择了用脚投票,抛弃 PaaS,拥抱 Docker。

不过值得一提的是,Docker 虽然解决了打包的困难,但是却没法很好地完成复杂应用部署,也就是“容器编排”。而在云原生技术中,真正的商业价值其实并不是容器,而是容器编排。也因此,Docker 公司与 CNCF 社区爆发了一场争夺生态霸主的战争,最终以 Kubernetes 的胜利告终。先按下不表,留待后续介绍。

容器化 VS 虚拟化#

如果你之前学习过容器化技术,你可能经常会听到“沙箱”、“集装箱”之类的类比。这确实是两个非常好的比喻:“集装箱”试图说明不同容器之间存在明确的边界而互不影响,也隐含了易于搬运的特性;而 Docker 这个词的本意——码头(Dock),则恰如其分地扮演了装卸、管理这些集装箱的角色。我们所说的容器化环境,就像一个自动化的高效码头。”

不过,若是只考虑隔离性,虚拟机也能做到,为什么非得用容器呢?

虚拟机和容器的架构对比

看这张架构图可以发现:容器是建设在操作系统之上的,会利用原本操作系统的能力来部署应用;而虚拟机的方案则是虚拟出任意多个操作系统,再在虚拟操作系统上部署应用。那既然容器是在操作系统之上的应用,那虚拟的操作系统能否继续安装 Docker 呢?答案是肯定的,例如下图的右半部分:

容器各种方式的应用

所以虚拟机跟容器并不是非此即彼的关系,而是可以协同工作,解决不同的问题。

但我们需要容器,主要是因为容器在效率、速度和可移植性上提供了巨大的优势,这在云原生的微服务架构中十分重要。

  • 虚拟机(VM)就像独栋房子

    • 每栋房子都有自己的地基、水管、电路、煤气系统(即完整的操作系统和虚拟硬件),然后你才能在里面住人、放家具(运行你的应用)。房子之间隔离性非常好,但建造起来又慢又耗费资源。主要通过 Hypervisor(如 VMware, KVM, Hyper-V),在物理硬件之上虚拟出一整套硬件(虚拟 CPU、内存、硬盘、网卡)。

    • 缺点:完整的操作系统通常需要独占大量的磁盘空间(几十 GB)和内存(几百 MB 到几 GB),而且启动时需要像启动一台真实电脑一样,耗费较多时间。

  • 容器(Container)就像公寓楼里的一个个套间

    • 整栋楼共享同一个地基、总水管和总电路(即宿主机的操作系统内核)。每个套间(容器)只包含自己的家具和生活用品(即应用及其依赖库),并且有独立的门锁和墙壁(通过 Namespace 和 Cgroups 实现隔离)。入住(启动)非常快,而且在一栋楼里可以容纳很多住户(高密度)。

    • 优点:容器本身非常轻量级(几 MB-几 GB),启动速度更快(秒级甚至毫秒级),由于没有 Hypervisor,资源损耗很少。另外由于是共享宿主机资源,也使得部分场景下的超卖成为可能。

特性

虚拟机 (Virtual Machine)

容器 (Container)

为什么容器胜出?

启动速度

慢 (分钟级/秒级)

快 (秒级/毫秒级)

部署更快,方便弹性伸缩,敏捷性更高

资源开销

高 (GB 级内存/磁盘)

低 (MB 级内存/磁盘)

在相同配置的服务器上,可以运行更多的容器,成本更低

性能

有 Hypervisor 层的性能损耗

接近原生性能,几乎没有开销

应用程序运行效率更高。

可移植性

镜像巨大,与 Hypervisor 耦合

镜像小巧,分层构建,标准化(OCI 标准),移植性极强

一次构建,随处运行,可移植性更强大

隔离性

强隔离 (硬件级别)

弱隔离 (进程级别)

该项是虚拟机的优势。一个 VM 的内核崩溃不会影响其他 VM。容器共享内核,内核漏洞可能影响所有容器。不过近年也有像 Kata Containers、gVisor 这样的安全容器项目

应用场景

运行不同操作系统、强安全隔离、传统应用

微服务、CI/CD (持续集成/持续部署)、快速迭代、弹性伸缩

符合现代云原生部署需求

注:在云上,通常会在虚拟机中运行容器编排系统(如 Kubernetes),然后在这些虚拟机节点上部署和管理成千上万的容器。这种模式既利用了虚拟机的安全隔离能力,又享受了容器带来的所有好处。

隔离技术#

在容器中,隔离具体是怎么实现的呢?怎么能做到不同的应用互不影响呢?

认知:应用隔离的本质是资源隔离。无论是虚拟机、容器还是简单的进程,实现“隔离”的手段,都是限制一个应用程序能够访问和使用的资源。这些资源可以分为不同层次:

  • 硬件资源:CPU 时间、内存空间、硬盘 I/O、网络带宽。虚拟机(VM)主要是在这个层面做隔离。

  • 内核资源:进程 ID(PID)、用户 ID(UID)、网络协议栈、文件系统挂载点。容器(如 Docker)主要是在这个层面做隔离。

  • 运行时资源:特定编程语言独有的资源,比如 Java 虚拟机(JVM)中的类、堆内存,也有资源隔离的诉求。

假设你要写一个输出 Hello World 的程序。不论你用什么编程语言实现(先忽略 Java、Python 这种使用解释器的情况),最终都需要将代码编译成操作系统可以理解的可执行文件,在 Linux 下则是 ELF(Executable and Linkable Format)。这时的程序还只是硬盘上的 01 字节,但只要执行起来,操作系统就会将这个文件加载到内存里,为其分配运行所需要的各种资源和一个 id,然后交给内核调度。

此时运行中的程序已经变成内存空间里的数据、打开的文件、堆栈状态的总和,被称为进程(Linux 其实不区分进程、线程,统一叫 LWP,下文统一称为进程)。这个进程也有一个数据结构,在 Linux 中被定义为 task_struct,是操作系统调度的一个实体,其持有运行一个程序所必要的内存空间、文件描述符、信号处理等资源task_struct 结构体过于庞大,我们可以简单一窥几个重要成员。

struct task_struct {
	unsigned int        __state; // 表示当前进程状态,例如可运行、睡眠、僵死等。
	void                *stack;  // 指向进程的内核栈。
	struct sched_entity se;      // CFS(完全公平调度器)的调度实体。
	struct mm_struct    *mm;     // 指向进程的内存描述符结构(mm_struct),管理进程的虚拟内存。
	pid_t               pid;     // 进程 ID
	struct fs_struct    *fs;     // 指向文件系统信息结构,记录进程的当前工作目录、根目录等文件系统相关信息。
	struct files_struct *files;  // 指向进程打开的文件表
	...
}

可以看到,task_struct 中包含了进程 ID(pid)、内存空间(mm)、文件系统(fs)等与资源相关的字段。那么,要实现隔离,思路就变得清晰了:只要我们能让一个进程在访问这些资源时,看到的是一套“专属”的版本,不就实现了隔离吗?比如,让进程 A 看到的进程列表里只有它自己,让它看到的文件系统根目录是一个特定目录,分配给它的 CPU 和内存是受限的。Linux 内核恰恰提供了实现这两大目标的两大核心技术:Namespace(命名空间) 和 Cgroups(控制组)。

  • Namespace:解决“看不看得到”的问题。 它负责“欺骗”进程,为进程创建一个独立的视图,让它感觉自己独占了整个系统资源(如独立的 PID、网络、文件系统)。这是一种视图隔离。

  • Cgroups:解决“用多少”的问题。 它负责限制进程实际能使用的物理资源量,比如 CPU、内存、磁盘 I/O。这是一种资源限制。

我们可以通过 docker 命令直观感受一下这两项技术的威力。

假设你已经安装了 docker 环境,可以运行以下命令来启动一个容器:

docker run -it alpine /bin/sh

以上 docker 命令的含义就是:启动一个容器,并在容器里运行 /bin/sh 程序,同时为当前的终端分配一个可交互的 TTY。当启动完成后,你在终端上的输入就会传输到容器内部,而容器的输出也会传输到你当前的终端。这时候,一个运行着 /bin/sh 的程序就运行在了你的宿主机上。此时如果你使用 ps 命令检查容器内的进程,你会发现这样的输出:

/ # ps
PID  USER   TIME COMMAND
  1 root   0:00 /bin/sh
  7 root   0:00 ps

这个容器里居然只有 2 个进程,且 1 号进程居然是 /bin/sh 而不是 Linux 的引导进程 init。如果你在宿主机也运行 ps,也许能看到这样的输出:

/ # ps
PID     USER   TIME COMMAND
...
  29965 root   0:00 /bin/sh

这意味着这个容器的进程空间跟宿主机已经完全不同了。这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程看不到其他进程,并且以为自己就是 1 号进程。可实际上,他们在宿主机的操作系统里,还是原来的进程 PID。这就是 Namespace 技术。

PID Namespace 的使用也非常简单,在创建 Linux 子进程的时候传入一个参数即可:

int pid = clone(fn, stack, SIGCHLD | CLONE_NEWPID, NULL);

只要设置 CLONE_NEWPID 这个 flag,子进程就会被创建到一个新的 PID Namespace 里。在这个新的 Namespace 中,这个子进程就是第一个进程,因此它的 PID 为 1。但是父进程拿到的 pid 还是其在物理机上的真实 PID。

子进程创建子子进程和子子子进程时可以继续设置 CLONE_NEWPID ,每个子进程在自己的视角里都会认为自己是 1 号进程,在父进程里则会看到递增的 PID:2、3...。而从“上帝视角”(即父进程所在的 Namespace)看,拿到的 pid 变量依然是它在宿主机上的真实 PID。

那么资源限制呢? 这就要靠 Cgroups 了。虽然我们无法用一个简单的命令直观展示,但其原理就是将进程放入一个特定的“控制组”里。我们可以对这个组设置资源的上限。例如,我们可以规定某个容器内的所有进程,CPU 使用量不能超过 50%,内存最多只能使用 512MB。一旦超出,内核就会进行干预(如限制 CPU 时间、或杀死进程)。

Docker 正是巧妙地将 Namespace 的视图隔离和 Cgroups 的资源限制结合在一起,才为我们提供了轻量而强大的容器环境。

不过 PID Namespace 只能隔离 PID 的视图。为了隔离其他内核资源,Linux 还提供了 CgroupIPCNetworkMountTimeUserUTS 等 Namespace。

这些 API 可以通过 clone(), setns(), unshare() 等系统调用来使用。从版本号为 3.8 的内核开始,/proc/(pid)/ns 目录下会包含进程所属的 namespace 信息,可以直接使用如下命令查看当前进程所属的 namespace 信息:

/ # ls -l /proc/$$/ns
total 0
lrwxrwxrwx 1 root root 0 Aug  1 12:00 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 net -> net:[4026531993]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug  1 12:00 uts -> uts:[4026531838]

这些都是链接文件。链接文件的内容的格式为 xxx:[inode number]。其中的 xxx 为 namespace 的类型,inode number 则用来标识一个 namespace,可以理解为 namespace 的 ID。如果两个进程的某个 namespace 文件指向同一个链接文件,说明其相关资源在同一个 namespace 中。

总结与思考#

容器的隔离,本质就是资源的隔离,技术上利用了 Linux 内核提供的两大特性:Namespace 负责构建“与世隔绝”的独立视图;而 Cgroups 则像一个“资源管家”,严格限制每个容器能消耗的资源配额。

在接下来的文章中,我们还会详细介绍 Cgroups 的技术细节。

参考与引用#

!!!!!!!!!!!!!!!! 补充参考的资料