前言
Docker 容器能够在服务器中高效运行,离不开容器底层技术的支持。
为了更好地理解容器的运行原理,本篇文章将会以 Linux 宿主机为例,介绍容器的底层技术,包括容器的命名空间、控制组、联合文件系统等。
1. Docker基本架构
Docker 目前采用标准的 C/S 架构,即服务端—客户端架构,服务端用于管理数据,客户端负责与用户交互,将获取的用户信息交由服务器处理,如图所示
那么上面的名词是什么意思呢?我这里重新画了一幅图来解析
服务器与客户机既可以运行在同一台机器上,也可以运行在不同机器上,通过 Socket(套接字)或者 RESTful API 进行通信。
服务端
Docker 服务端也就是 Docker daemon,一般在宿主机后台运行,接收来自客户的请求、并处理这些请求。在设计上,Docker 服务端是一个模块化的架构,通过专门的 Engine 模块来分发、管理各个来自客户端的任务。
Docker 服务端默认监听本地的 unix:///var/run/Docker.sock 套接字,只允许本地的 root 用户或 Docker 用户组成员访问,可以通过 -H 参数来修改监听的方式。
例如,让服务器监听本地的 TCP 连接 1234 端口,代码如下所示:
此外,Docker 还支持通过 HTTPS 认证的方式来验证访问。
在 Debian/Ubuntu14.04 等使用 upstart 管理启动服务的系统中,Docker 服务端的默认启动配置文件在 /etc/default/Docker。
在使用 systemd 管理启动服务的系统,配置文件在 /etc/systemd/system/Docker.service.d/Docker.conf。
客户端
用户不能与服务端直接交互,Docker 客户端为用户提供一系列可执行命令,用户通过这些命令与 Docker 服务端进行交互。
用户使用的 Docker 可执行命令就是客户端程序。与 Docker 服务端不同的是,客户端发送命令后,等待服务端返回信息,收到返回信息后,客户端立刻执行结束并退出。用户执行新的命令时,需要再次调用客户端命令。
同样,客户端默认通过本地的 unix:///var/run/Docker.sock 套接字向服务端发送命令。如果服务端不在默认监听的地址,则需要用户在执行命令时指定服务端地址,如图所示
例如:假设服务端监听本地的 TCP 连接 1234 端口 tcp://127.0.0.1:1234,只有通过 -H 参数指定了正确的地址信息才能连接到服务端。
首先,查看 Docker 信息,示例代码如下:
从以上示例中可以看到,Docker 并没有连接到服务端,但 Docker 客户端仍可以为用户提供服务。
然后,通过命令指定正确的地址信息,再次查看 Docker 信息,示例代码如下:
从以上示例中可以看到,指定了正确的地址信息之后,Docker 顺利连接到服务端。
Docker 服务端运行在主机上, 通过 Socket 连接从客户端访问,服务端从客户端接受命令并管理运行在主机上的容器。
2. Namespace
Namespace介绍
Linux 操作系统中,容器用来实现“隔离”的技术称为 Namespace(命名空间)。
Namespace 技术实际上修改了应用进程看待整个计算机的 “视图”,即应用进程的 “视线” 被操作系统做了限制,只能 “看到” 某些指定的内容,如图所示
但对于宿主机来说,这些被进行“隔离”的进程跟其他进程并没有太大区别。
下面运行一个 CentOS 7 容器,示例代码如下:
从以上示例中可以看到,bash 是这个容器内部的第 1 号进程,即 PID=1,而这个容器里一共只有两个进程在运行,这就意味着,前面执行的 /bin/sh,以及刚刚执行的 ps,已经被 Docker 隔离在一个与宿主机完全不同的空间当中。
理论上,每当在宿主机上运行一个 /bin/sh 程序,操作系统都会给它分配一个进程编号,例如,PID=100。这个编号是进程的唯一标识,就像员工的工号一样。所以,PID=100,可以粗略地理解为这个 /bin/sh 是公司里的第 100 号员工。
而现在,要通过 Docker 把 /bin/sh 运行在一个容器当中。这时,Docker 就会在这个第 100 号员工入职时给他施一个 “障眼法” 让他永远看不到前面的其他 99 个员工,这样,他就会以为自己就是公司里的第 1 号员工。
这种机制其实就是对被隔离应用的进程空间做了手脚,使这些进程只能看到重新计算过的进程号,例如 PID=1。可实际上,它们在宿主机的操作系统里,还是原来的第 100号 进程,如图所示
下面通过宿主机查看容器进程号,示例代码如下:
在宿主机中通过容器的 ID 号查看其进程号,可以看出其进程号为 7548,这就是 Linux 里的 Namespace 机制。
在 Linux 系统中创建线程调用是 clone() 函数,例如:int pid = clone(main_function, stack_size, SIGCHLD, NULL);
这个调用会创建一个新的进程,并且返回它的进程号。
系统调用 clone() 创建一个新进程时,可以在参数中指定 CLONE_NEWPID ,例如:int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
这时,新创建的这个进程将会 “看到” 一个全新的进程空间,在这空间里,它的进程号是 1。在宿主机真实的进程空间里,这个进程的真实进程号不变。
当然,可以多次执行上面的 clone() 调用,这样就会创建多个 PID Namespace,而每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。
Namespace的类型
命名空间分为多种类型,对应用程序进行不同程度的隔离,下面挨个介绍。
Mount namespace
Mount Namespace 将一个文件系统的顶层目录与另一个文件系统的子目录关联起来,使其成为一个整体。该子目录称为挂载点,这个动作称为挂载。
UTS namespace
UTS(UNIX Time-sharing System,UNIX 分时系统)Namespace 提供主机名和域名的隔离,使子进程有独立的主机名和域名,这一特性在 Docker 容器技术中被运用,使 Docker 容器在网络上被视作一个独立的节点,而不仅仅是宿主机上的一个进程。
IPC namespace
IPC(Inter-Process Communication,进程间通信)Namespace 是 UNIX 与 Linux 下进程间通信的一种方式。
IPC 有共享内存、信号量、消息队列等方式。此外,也需要对 IPC 进行隔离,如此一来,只有在同一个 Namespace 下的进程才能相互通信。
IPC 需要有一个全局的 ID,既然是全局的,就意味着 Namespace 需要对这个 ID 号进行 隔离,不能让其他 Namespace 的进程 “看到”。
PID namespace
PID Namespace 用来隔离进程的 ID 空间,使不同 PID Namespace 里的进程 ID 号可以重复且相互之间不影响。
PID Namespace 可以嵌套,也就是说有父子关系。在当前 Namespace 里面创建的所有新的 Namespace 都是当前 Namespace 的子 Namespace。
在父 Namespace 里面可以 “看到” 所有子 Namespace 里的进程信息,而在子 Namespace 里看不到父 Namespacelode 与其他子 Namespacelode 进程信息,如图所示
Network Namespace
每个容器拥有独立的网络设备,IP 地址,IP 路由表,/proc/net 目录,端口号等。这也使得一个 host 上多个容器内的网络设备都是互相隔离的。
User namespace
User Namespace 用来隔离 User 权限相关的 Linux 资源,包括 User IDs 和 Group IDs。
这是目前实现的 Namespace 中最复杂的一个,因为 User 和权限息息相关,而权限又关联着容器的安全问题。
在不同的 User Namespace 中,同样一个用户的 User ID 和 Group ID 可以不一样。也就是说,一个用户可以在父 User Namespace 中是普通用户,在子 User Namespace 中是超级用户。
深入理解Namespace
下面通过一段简单的代码来查看 Namespace 是如何实现的,示例代码如下:
在以上示例中,代码段通过 clone() 调用,传入各个 Namespace 对应的 clone flag,创建了一个新的子进程,该进程拥有自己的 Namespace。根据以上代码可知,该进程拥有自己的 PID、Mount、User、Net、IPC 以及 UTS Namespace。
所以,Docker 在创建容器进程时,指定了这个进程所需要启动的一组 Namespace 参数。这样,容器就只能 “看到” 当前 Namespace 所限定的资源、文件、设备、状态、配置信息等。至于宿主机以及其他不相关的程序,它就完全看不到了。容器,其实是 Linux 系统中一种特殊的进程。
Linux 中 Docker 创建的隔离空间虽然是看不见摸不着,但是一个进程的 Namespace 信息在宿主机上是真实存在的,并且是以文件的方式存在,因为在 Linux 操作系统中,一切皆文件。
一个进程可以选择加入到某个进程已有的 Namespace 当中,从而达到 “进入” 这个进程所在容器的目的,这正是 docker exec 的实现原理。
下面通过示例进行详细讲解,首先运行一个 CentOS 容器,示例代码如下:
以上示例中在运行 Docker 容器的命令中添加了参数 -d,表示使容器在后台运行。
查看当前正在运行 Docker 容器的进程号,示例代码如下:
查看宿主机的 /proc 文件,可以看到这个 8589 进程所有 Namespace 对应的文件,示例代码如下:
可以看到,一个进程的每种 Namespace 都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件。
有了这样的文件,就可以对 Namespace 做一些实质性的操作。例如,将进程加入到一个已经存在的 Namespace 当中。
这个操作依赖一个名为 setns() 的 Linux 系统调用,示例代码如下:
上述代码共接收了两个参数。
arvg[1],即当前进程要加入的 Namespace 文件的路径,如 /proc/8589/ns/net。用户要在这个 Namespace 里运行的进程,如 /bin/bash。
代码的核心操作则是通过 open() 打开指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Namespace 当中。
Namespace的劣势
强大的 Namespace 机制可以实现容器间的隔离,是容器底层技术中非常重要的一项,但也有不可否认的不足。
下面总结基于 Namespace 的隔离机制相对于虚拟化技术的不足之处,以便在生产环境中设法克服。
隔离不彻底
容器只是运行在宿主机上的一种特殊的进程,多个容器之间使用的是同一个宿主机的操作系统内核。
尽管可以在容器中通过 Mount Namespace 单独挂载其他版本的操作系统文件,如 CentOS 或者 Ubuntu,但这并不能改变它们共享宿主机内核的事实。
在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。
相比之下,拥有硬件虚拟化技术和独立 Guest OS 的虚拟机就要好用得多。最极端的例子是 Microsoft 的云计算平台 Azure,它就是运行在 Windows 服务器集群上的,但这并不妨碍用户在上面创建各种 Linux 虚拟机。
有些资源和对象不能被Namespace化
如果容器中的程序调用 settimeofday() 修改了时间,整个宿主机的时间都会被修改。相较于在虚拟机里面可以任意做修改,在容器里部署应用时,需要用户的操作上更加谨慎。
安全问题
因为共享宿主机内核,容器中的应用暴露出来的攻击面很大。
尽管生产实践中可以使用 seccomp 等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这类方法因为多了一层对系统调用的过滤,会拖累容器的性能。
通常情况下,也不清楚到底该开启哪些系统调用,禁止哪些系统调用。
到此这篇关于Docker中关于Namespace隔离机制的奥秘的文章就介绍到这了,更多相关Docker Namespace隔离机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!