我之前写过一篇文章,寻根学习法,最近又想着改个溯源学习法更好。
经常自学的人肯定有这个感受,有时候,无论我们怎么努力,有些东西好像都学不会,似乎很多框架、概念之后都藏着让我们无法理解的很多谜底。
学 Ruby on Rails 的,感觉 Rails 框架里一堆魔法;使用 Spring Boot 也一样。
今天无意中在一个公众号的留言里看到这样一则留言:
曾经也是一度和他一样迷茫,Kubernetes 不是一个指引我们的舵手,而是一个让我们迷航的蜘蛛网。
后来我想明白了,我之所以搞不明白,是因为我没有完全的弄清楚 Kubernetes 底层的细节。
那么要学好 Kubernetes, 我们需要掌握哪些底层的知识呢?
我认为有以下两个点是重中之重,也就是今天的这篇文章要讲的:
- Linux 命名空间
- Linux Control Group
只有弄明白这两点,才能真正理解容器和网络。
而在Linux命名空间里,Net namespace (网络命名空间)更是重中之重,我们在后续的分享里会继续专门做一个这样的分享。
这就是溯源学习法的一种应用场景,我们去追寻他背后的背后的背后的真理。
1. 从软件工程的角度去思考Linux 内核设计
试想一下,我们要设计一个操作系统。
这个操作系统的功能需求,大致是这样的:
- 可以接受用户键盘输入
- 可以通过屏幕显示程序的输出
- 可以让多用户运行不同的程序
- 。。。
大家都知道电脑有很多物理接口,用户可以通过这些接口,接入不同的设备,这些设备支持不同的协议和标准。
作为工作多年的程序员,我们自然而然的希望把硬件接口模块化,然后在这个之上再做一些其他的工作。
接下来才是今天的主角。
让我们从安全的角度,去思考一个问题:如何让多个用户运行自己程序时互不干扰?
我们自然会想到首先把我们的所谓的操作系统的内核与用户的应用程序隔离开来。
因此,下面的这张图就不难理解了:
最下面一层就是硬件驱动层,再往上是Linux的内核,真正的去实现了底层的调用,用户进程的管理等等功能。
但是如何实现把让不同的用户之间运行的程序互不影响呢?
这就不得不提 Linux 内核设计的精妙之处, 命名空间(Namespace)和控制组(Control Group)。
Linux 通过命名空间实现了多用户之间的隔离,这样大家貌似在用同样的一台电脑,貌似有自己的磁盘空间,等等,但是很多时候这些都是Linux 内核给大家的一个假象。
与上面那张Linux 系统架构的图相比,我更喜欢下面这张。
Linux内核分为8个命名空间:
- CGroup 命名空间
- IPC 命名空间
- PID 命名空间
- MNT 命名空间
- Network 命名空间
- USER 命名空间
- UTS命名空间
- TIme 命名空间
下面我们详细说说这几个命名空间:
如果你不想了解他们的细节,那么记住一句话就可以:Linux 通过命名空间的机制,用户可以独立操作这些所谓的资源,而互不影响(划重点)。
CGroup 命名空间
CGroup是用来对资源进行隔离的,我们后面还会专门提到的。所谓的资源包括计算机的方方面面。比如说CPU、内存、磁盘、网络等等物理的,或者是操作系统定义的资源。
IPC 命名空间
IPC 大家都知道,是 Linux 用来进程间通信的。IPC namespace 使得 相同的标识符在两个 namespace 代表不同的消息队列,因此两个namespace 中的进程不能通过 IPC 来通信。
PID 命名空间
PID 是Linux 中的进程的ID。Linux 通过隔离进程号,这样不同namespace 的进程可以使用相同的进程号。
MNT 命名空间
隔离文件挂载点,每个进程能看到的文件系统都记录在/proc/$$/mounts
里。在一个 命名空间里挂载、卸载的动作不会影响到其他命名空间。
将一个进程放到一个特定的目录执行mnt namespace 允许不同 namespace 的进程看到的文件结构不同。
Net 命名空间
Net 命名空间隔离网络资源。每个 namespace 都有自己的网络设备、IP、路由表、/proc/net 目录、端口号等。
网络隔离可以保证独立使用网络资源,也就是我们可以给这个虚拟的网络设备设置IP地址等等,而且不会影响主机,或者其他Net 命名空间。
USER 命名空间
通过USER 命名空间,每个容器可以有不同的 user 和 group id, 也就是说可以在容器内部用容器内部的用户执行程序而非 Host 上的用户。
是不是有点拗口?其实还是那句话,Linux通过命名空间给用户一种你拥有电脑全部资源的错觉。
UTS命名空间
UTS(“UNIX Time-sharing System”) 也就是UNIX分时系统命名空间。
UTS 命名空间允许每个容器拥有独立的主机名和域名。这有个好处,不然的话一台主机只能使用一个主机名。
如果不能理解,可以想想我上面写的Linux通过命名空间给用户一种你拥有电脑全部资源的错觉。
这只不过是其中的一个方面而已。
嗯,基本上我们需要理解Kubernetes网络,或者最直接的,理解容器,遇到也就是这几个命名空间。
2. Syscall -- 用户与 Linux 内核交互的唯一途径
让我们再回顾一下Linux 系统架构:
要撸一个自己的Docker,我们需要了解这几个Syscall函数,或者也可以理解为命令:
- clone:创建新进程,假如参数里CLONE_NEW* 标志的话,会创建一个新的命名空间
- setns: 允许把存在的进程加入已经存在的命名空间
- unshare:切换进程的命名空间
- ioctl:显示命名空间的相关信息
- pivot_root:改变当前进程的 MNT 命名空间
3. rootfs
rootfs 代表一个 Docker 容器在启动时(而非运行后)其内部进程可见的文件系统视角,或者叫 Docker 容器的根目录。
先来看一下,Linux 操作系统内核启动时,内核会先挂载一个只读的 rootfs,当系统检测其完整性之后,决定是否将其切换到读写模式。
Docker 沿用这种思想,不同的是,挂载rootfs 完毕之后,没有像 Linux 那样将容器的文件系统切换到读写模式,而是利用联合挂载技术,在这个只读的 rootfs 上挂载一个读写的文件系统,挂载后该读写文件系统空空如也。
Docker 文件系统简单理解为:只读的 rootfs + 可读写的文件系统。
一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等。
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。
用到的技术就是联合文件系统(Union File System) Union File System 也叫 UnionFS ,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。
4. 准备了这么久,让我们撸一Docker
package main import ( "fmt" "os" "os/exec" "syscall" ) func main() { switch os.Args[1] { case "run": parent() case "child": child() default: panic("what should I do") } } func parent() { // func Command(name string, arg ...string) *Cmd // /proc/self/exe 它代表当前程序 cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]…)…) // SysProcAttr holds optional, operating system-specific attributes. // Adding namespaces cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Println("ERROR", err) os.Exit(1) } } func child() { must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, "")) must(os.MkdirAll("rootfs/oldrootfs", 0700)) // swap into a root filesystem must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs")) must(os.Chdir("/")) cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Println("ERROR", err) os.Exit(1) } } func must(err error) { if err != nil { panic(err) } }
5. 结尾
我们回顾一下,容器其实主要使用的就是控制组 CGroup和命名空间Namespace 这两个来自Linux 系统的特性。
命名空间主要是做资源隔离。通过命名空间,用户可以无所顾忌的操作系统的所有资源,因为这些资源都是虚拟出来的,而且都是独立的。
控制组用来限制用户可以使用的资源。
操作系统就像一个上帝视角,不仅为每个用户提供了资源的隔离,还从系统管理的角度为资源的使用提供了许多限制,这样得以让我们可以实现容器。
最后,提一个问题,大家觉得Windows 系统的Docker 会怎么实现?
知道的可以留言哦。