Docker隔离技术
前言
Docker系列文章:
此篇是Docker系列的第九篇,之前的文章里面或多或少的提到Docker的隔离技术,但是没有很清楚的去聊这个技术,但是经过这么多文章大家一定对Docker使用和概念有了一定的理解,接下来我们聊下底层一些技术,帮助大家解解惑,先从隔离技术开始吧。此外大家一定要按照我做的Demo都手敲一遍,印象会更加深刻的,加油!
如何理解Namespace
进入一个容器内部:
docker exec -it ad9342449b86 /bin/bash
通过ps命令查看容器内部的进程,容器内部只有两个进程在运行,看不到操作系统的其他进程,一个进程执行的/bin/bash,另外一个执行的ps,说明此刻容器被 Docker 隔离在了一个跟宿主机在不同的环境中。
对于宿主机来说相当于我们在宿主机上执行一个/bin/bash的进程,宿主机给这个进程起一个独一无二名字,比如叫PID=800,用来区分与其他进程的不同,Docker在运行/bin/bash的时候,会与宿主机上的其他进程进行隔离,让他看不到其他进程运行状况,并且重新计算进程号,也就是我们看到202,但是这个进程实际上是宿主机的进程,这种技术,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,这就是Linux里面的Namespace机制,也就是Docker采用的隔离技术。
总结一下,Namespace是Linux内核用来隔离内核资源的方式。通过Namespace可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个Namespace中。Linux Namespace是对全局系统资源的一种封装隔离,使得处于不同Namespace的进程拥有独立的全局系统资源,改变一个Namespace中的系统资源只会影响当前Namespace里的进程,对其他Namespace中的进程没有影响。
Namespace类型介绍
目前,Linux 内核实现了6种 Namespace:
六种命名空间:
-
UTS Namespace:
UTS Namespace 对主机名和域名进行隔离。为什么要隔离主机名?因为主机名可以代替IP来访问。如果不隔离,同名访问会出冲突。
-
IPC Namespace
Linux 提供很多种进程通信机制,IPC Namespace 针对 System V 和 POSIX 消息队列,这些 IPC 机制会使用标识符来区别不同的消息队列,然后两个进程通过标识符找到对应的消息队列。IPC namespace使得相同的标识符在两个Namespace代表不同的消息队列,因此两个Namespace 中的进程不能通过 IPC 来通信。
-
PID Namespace
PID Namespace 用来隔离进程的 PID 空间,使得不同 PID Namespace 里的进程 PID 可以重复且互不影响。PID Namespace 对容器类应用特别重要, 可以实现容器内进程的暂停/恢复等功能,还可以支持容器在跨主机的迁移前后保持内部进程的 PID 不发生变化。
-
Mount Namespace
Mount Namespace 为进程提供独立的文件系统视图。可以这么理解,Mount Namespace 用来隔离文件系统的挂载点,这样进程就只能看到自己的Mount Namespace中的文件系统挂载点。进程的Mount Namespace中的挂载点信息可以在 /proc/[pid]/mounts、/proc/[pid]/mountinfo 和 /proc/[pid]/mountstats 这三个文件中找到。在一个 Namespace 里挂载、卸载的动作不会影响到其他 Namespace。
-
Network Namespace
Network Namespace 在逻辑上是网络堆栈的一个副本,它有自己的路由、防火墙规则和网络设备。默认情况下,子进程继承其父进程的 Network Namespace。每个新创建的 Network Namespace 默认有一个本地环回接口 lo,除此之外,所有的其他网络设备(物理/虚拟网络接口,网桥等)只能属于一个 Network Namespace。每个 socket 也只能属于一个 Network Namespace。
-
User Namespace
User Namespace 用于隔离安全相关的资源,包括 user IDs and group IDs,keys, 和 capabilities。同样一个用户的 user ID 和 group ID 在不同的User Namespace 中可以不一样(与 PID Namespace 类似)。可以这样理解,一个用户可以在一个User Namespace中是普通用户,但在另一个User Namespace中是root用户。
Namespace原理介绍
Linux Namespace 是 Linux 提供的一种内核级别环境隔离的方法,因此Linux 提供了多个 API 用来操作 Namespace,它们是 clone()、setns() 和 unshare() 函数,简单介绍一下三个系统调用的功能:
-
clone() : 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述系统调用参数达到隔离的目的; -
unshare() : 使某进程脱离某个 namespace; -
setns() : 把某进程加入到某个 namespace;
为了确定隔离的到底是哪项 Namespace,在使用这些 API 时,通常需要指定一些调用参数:CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS 和 CLONE_NEWCGROUP。下图是各个Namespace对应的调用参数和Linux内核:
如果要同时隔离多个 Namespace,可以使用 | (按位或)组合这些参数。同时我们还可以通过 /proc 下面的一些文件来操作 Namespace。下面就让让我们看看这些接口的用法:
查看进程所属的Namespace
从版本号为 3.8 的内核开始,/proc/[pid]/ns 目录下会包含进程所属的 Namespace 信息,使用下面的命令可以查看当前进程所属的 Namespace 信息:
ls -l /proc/$$/ns
这些 Namespace 文件都是链接文件。链接文件的内容的格式为 xxx:[inode number]。其中的 xxx 为 Namespace 的类型,inode number 则用来标识一个 Namespace,我们也可以把它理解为 Namespace 的 ID。如果两个进程的某个 Namespace 文件指向同一个链接文件,说明其相关资源在同一个 Namespace 中。
clone() 函数
我们可以通过 clone() 在创建新进程的同时创建 Namespace。clone() 在 C 语言库中的声明如下:
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
实际上,clone() 是在 C 语言库中定义的一个封装(wrapper)函数,它负责建立新进程的堆栈并且调用对编程者隐藏的 clone() 系统调用。四个参数代表的意思是:
-
child_func : 传入子进程运行的程序主函数; -
child_stack : 传入子进程使用的栈空间; -
flags : 表示使用哪些 CLONE_* 标志位; -
args : 用于传入用户参数;
clone() 与 fork() 类似,都相当于把当前进程复制了一份,但 clone() 可以更细粒度地控制与子进程共享的资源(可以通过 flags 来控制),包括虚拟内存、打开的文件描述符和信号量等等。一旦指定了标志位 CLONE_NEW*,相对应类型的 Namespace 就会被创建,新创建的进程也会成为该 Namespace 中的一员。
setns() 函数
setns() 函数可以将当前进程加入到已有的 Namespace 中。setns() 在 C 语言库中的声明如下:
#define _GNU_SOURCE
#include <sched.h>
int setns(int fd, int nstype);
和 clone() 函数一样,C 语言库中的 setns() 函数也是对 setns() 系统调用的封装:
-
fd:表示要加入 Namespace 的文件描述符。它是一个指向 /proc/[pid]/ns 目录中文件的文件描述符,可以通过直接打开该目录下的链接文件或者打开一个挂载了该目录下链接文件的文件得到; -
nstype:参数 nstype 让调用者可以检查 fd 指向的 Namespace 类型是否符合实际要求。若把该参数设置为 0 表示不检查;
unshare() 函数
unshare() 函数可以在原进程上进行 Namespace 隔离。也就是创建并加入新的 Namespace 。unshare() 在 C 语言库中的声明如下:
#define _GNU_SOURCE
#include <sched.h>
int unshare(int flags);
unshare() 函数也是对 unshare() 系统调用的封装。调用 unshare() 的主要作用就是:不启动新的进程就可以起到资源隔离的效果,相当于跳出原先的 Namespace 进行操作。
使用PID Namesapce达到Docker线程隔离状态
Linux下的每个进程都有一个对应的 /proc/PID 目录,该目录包含了大量的有关当前进程的信息。 对一个 PID Namespace 而言,/proc 目录只包含当前 Namespace 和它所有子孙后代 Namespace 里的进程的信息。
创建一个新的 PID Namespace 后,如果想让子进程中的 top、ps 等依赖 /proc 文件系统的命令工作,还需要挂载 /proc 文件系统。使用如下命令创建一个新的PID Namespace:
#查看当前进程的PID
echo $$
#查看该线程对应PID Namespace
readlink /proc/$$/ns/pid
#使用unshare命令创建新的PID Namespace
#该命令会同时创建新的PID和Mount namespace
unshare --pid --mount --fork /bin/bash
#查看创建的线程的PID Namespace
readlink /proc/$$/ns/pid
上图中新生成的PID Namespace的Id并没有发生改变,我们看到在新创建的PID Namespace也可以看到其他的进程的运行情况,显然这个和我们说的隔离是不一致的,接下来我们看下原因是什么:
#查看当前进程的PID
echo $$
#查看当前进程的详情
ps 1
这个例子说明当前进程被认为是该 PID namespace 中的 1 号进程了,通过PS命名查看1号进程的详情,发现这个进程是系统的启动相关(CentOS 7开始也由systemd取代了init作为默认的系统进程管理工具)。
造成混乱的原因是当前进程没有正确的挂载 /proc 文件系统,由于我们新的 Mount Namespace 的挂载信息是从老的 Namespace 拷贝过来的,所以这里看到的还是老 Namespace 里面的进程号为 1 的信息。执行下面的命令挂载 /proc 文件系统:
mount -t proc proc /proc
接下来我们再来检查相关的信息,会发现新建的 PID namespace 进程看不到宿主机的进程信息了。
我们也可以使用如下命令来创建进程,
unshare --pid --mount-proc --fork /bin/bash
这样在创建了 PID 和 Mount Namespace 后,会自动挂载 /proc 文件系统,就不需要我们手动执行 mount -t proc proc /proc 命令了。
总结
Docker容器是在创建容器进程时,指定了这个进程所需要启用的一组Namespace参数,这样容器就只能看到到当前Namespace所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了,因此容器本质上就是一个特殊的进程。
结束
欢迎大家点点关注,点点赞!