【Docker 内核详解】namespace 资源隔离(三):PID namespace

namespace 资源隔离(三):PID namespace

  • [1.PID namespace 中的 init 进程](#1.PID namespace 中的 init 进程)
  • [2.信号与 init 进程](#2.信号与 init 进程)
  • [3.挂载 proc 文件系统](#3.挂载 proc 文件系统)
  • [4.unshare() 和 setns()](#4.unshare() 和 setns())

PID namespace 隔离非常实用,它对进程 PID 重新标号,即两个不同 namespace 下的进程可以有相同的 PID。每个 PID namespace 都有自己的计数程序。内核为所有的 PID namespace 维护了一个树状结构,最顶层的是系统初始时创建的,被称为 root namespace。它创建的新 PID namespace 被称为 child namespace(树的子节点),而原先的 PID namespace 就是新创建的 PID namespaceparent namespace(树的父节点)。通过这种方式,不同的 PID namespace 会形成一个层级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点却不能看到父节点 PID namespace 中的任何内容,由此产生如下结论。

  • 每个 PID namespace 中的第一个进程 "PID 1",都会像传统 Linux 中的 init 进程一样拥有特权,起特殊作用。
  • 一个 namespace 中的进程,不可能通过 killptrace 影响父节点或者兄弟节点中的进程,因为其他节点的 PID 在这个 namespace 中没有任何意义。
  • 如果你在新的 PID namespace 中重新挂载 /proc 文件系统,会发现其下只显示同属一个 PID namespace 中的其他进程。
  • root namespace 中可以看到所有的进程,并且递归包含所有子节点中的进程。

到这里,大家可能已经联想到一种在外部监控 Docker 中运行程序的方法了,就是监控 Docker daemon 所在的 PID namespace 下的所有进程及其子进程,再进行筛选即可。

下面通过运行代码来感受一下 PID namespace 的隔离效果。修改 前一篇博客 中提到的代码,加入 PID namespace 的标识位,并把程序命名为 pid.c

c 复制代码
// [...]
int child_pid = clone(child_main, child_stack + STACK_SIZE,CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
// [...]

编译运行可以看到如下结果。

root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
root@NewNamespace:~# echo $$
1                              <<--注意此处 shell 的 PID 变成了 1
root@NewNamespace:~# exit
exit
已退出

打印 $$ 可以看到 shell 的 PID,退出后如果再次执行可以看到效果如下。

root@local:~# echo $$
17542

已经回到了正常状态。有的读者可能在子进程的 shell 中执行了 ps auxtop 之类的命令,发现还是可以看到所有父进程的 PID,那是因为还没有对文件系统挂载点进行隔离,pstop 之类的命令调用的是真实系统下的 /proc 文件内容,看到的自然是所有的进程。所以,与其他的 namespace 不同的是,为了实现一个稳定安全的容器,PID namespace 还需要进行一些额外的工作才能确保进程运行顺利,下面将逐一介绍。

1.PID namespace 中的 init 进程

在传统的 Unix 系统中,PID 为 1 1 1 的进程是 init,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误成为了 孤儿 进程,init 就会负责收养这个子进程并最终回收资源,结束进程。所以在要实现的容器中,启动的第一个进程也需要实现类似 init 的功能,维护所有后续启动进程的运行状态。

当系统中存在树状嵌套结构的 PID namespace 时,若某个子进程成为孤儿进程,收养该子进程的责任就交给了该子进程所属的 PID namespace 中的 init 进程。

至此,可能读者已经明白了内核设计的良苦用心。PID namespace 维护这样一个树状结构,有利于系统的资源监控与回收。因此,如果确实需要在一个 Docker 容器中运行多个进程,最先启动的命令进程应该是具有资源监控与回收等管理能力的,如 bash。

2.信号与 init 进程

内核还为 PID namespace 中的 init 进程赋予了其他特权 ------ 信号屏蔽。如果 init 中没有编写处理某个信号的代码逻辑,那么与 init 在同一个 PID namespace 下的进程(即使有超级权限)发送给它的该信号都会被屏蔽。这个功能的主要作用是防止 init 进程被误杀。

那么,父节点 PID namespace 中的进程发送同样的信号给子节点中的 init 进程,这会被忽略吗?父节点中的进程发送的信号,如果不是 SIGKILL销毁进程)或 SIGSTOP暂停进程)也会被忽略。但如果发送 SIGKILLSIGSTOP,子节点的 init 会强制执行(无法通过代码捕捉进行特殊处理),也即是说父节点中的进程有权终止子节点中的进程。

一旦 init 进程被销毁,同一 PID namespace 中的其他进程也随之接收到 SIGKILL 信号而被销毁。理论上,该 PID namespace 也不复存在了。但是如果 /proc/[pid]/ns/pid 处于被挂载或者打开状态,namespace 就会被保留下来。然而,保留下来的 namespace 无法通过 setns() 或者 fork() 创建进程,所以实际上并没有什么作用。

当一个容器内存在多个进程时,容器内的 init 进程可以对信号进行捕获,当 SIGTERMSIGINT 等信号到来时,对其子进程做信息保存、资源回收等处理工作。在 Docker daemon 的源码中也可以看到类似的处理方式,当结束信号来临时,结束容器进程并回收相应资源。

3.挂载 proc 文件系统

前文提到,如果在新的 PID namespace 中使用 ps 命令查看,看到的还是所有的进程,因为与 PID 直接相关的 /proc 文件系统(procfs)没有挂载到一个与原 /proc 不同的位置。如果只想看到 PID namespace 本身应该看到的进程,需要重新挂载 /proc,命令如下。

root@NewNamespace:~# mount -t proc proc /proc
root@NewNamespace:~# ps a
PID  TTY     STAT   TIME    COMMAND
1    pts/1   S      0:00    /bin/bash
12   pts/1   R+     0:00    ps a

可以看到实际的 PID namespace 就只有两个进程在运行。

此时并没有进行 mount namespace 的隔离,所以该操作实际上已经影响了 root namespace 的文件系统。当退出新建的 PID namespace 以后,再执行 ps a 时,就会发现出错,再次执行 mount -t proc proc /proc 可以修复错误。后面还会介绍通过 mount namespace 来隔离文件系统,当我们基于 mount namespace 实现了容器 proc 文件系统隔离后,我们就能在 Docker 容器中使用 ps 等命令看到与 PID namespace 对应的进程列表。

4.unshare() 和 setns()

前一篇博客 就提到了 unshare()setns() 这两个 API,在 PID namespace 中使用时,也有一些特别之处需要注意。

unshare() 允许用户在原有进程中建立命名空间进行隔离。但创建了PID namespace后,原先 unshare() 调用者进程并不进入新的 PID namespace,接下来创建的子进程才会进入新的 namespace,这个子进程也就随之成为新 namespace 中的 init 进程。

类似地,调用 setns() 创建新 PID namespace 时,调用者进程也不进入新的 PID namespace,而是随后创建的子进程进入。

为什么创建其他 namespaceunshare()setns() 会直接进入新的 namespace,而唯独 PID namespace 例外呢?因为调用 getpid() 函数得到的 PID 是根据调用者所在的 PID namespace 而决定返回哪个 PID,进入新的 PID namespace 会导致 PID 产生变化。而对用户态的程序和库函数来说,它们都认为进程的 PID 是一个常量,PID 的变化会引起这些进程崩溃。

换句话说,一旦程序进程创建以后,那么它的 PID namespace 的关系就确定下来了,进程不会变更它们对应的 PID namespace。在 Docker 中,docker exec 会使用 setns() 函数加入已经存在的命名空间,但是最终还是会调用 clone() 函数,原因就在于此。

相关推荐
咖喱鱼蛋2 分钟前
Ubuntu安装Electron环境
linux·ubuntu·electron
ac.char6 分钟前
在 Ubuntu 系统上安装 npm 环境以及 nvm(Node Version Manager)
linux·ubuntu·npm
肖永威12 分钟前
CentOS环境上离线安装python3及相关包
linux·运维·机器学习·centos
tian2kong15 分钟前
Centos 7 修改YUM镜像源地址为阿里云镜像地址
linux·阿里云·centos
Code_Artist15 分钟前
使用Portainer来管理并编排Docker容器
docker·云原生·容器
mengao123417 分钟前
centos 服务器 docker 使用代理
服务器·docker·centos
布鲁格若门19 分钟前
CentOS 7 桌面版安装 cuda 12.4
linux·运维·centos·cuda
Eternal-Student23 分钟前
【docker 保存】将Docker镜像保存为一个离线的tar归档文件
运维·docker·容器
不是二师兄的八戒26 分钟前
本地 PHP 和 Java 开发环境 Docker 化与配置开机自启
java·docker·php
C-cat.26 分钟前
Linux|进程程序替换
linux·服务器·microsoft