金氧

使用 Golang 操作 Linux Namespaces

Linux 命名空间简介

Linux Namespaces(Linux 命名空间)机制提供了进程使用操作系统资源时的隔离方式,是基于内核实现轻量级虚拟化(容器化,例如 docker)的实现基础。

具体来说就是当我们创建一个进程时,可以给进程设置 flag 组合来构建进程的命名空间,处于不同命名空间的进程是相互隔离的。

命名空间分类

目前我们可以设置如下几种命名空间,它们分别从不同的资源纬度进行隔离。

CLONE_NEWPID

该标识用于创建一个新的 PID 命名空间,新进程将成为命名空间里的第一个进程。

一个 PID 命名空间为进程提供了一个独立的 PID 环境,其内部的 PID 将从 1 开始,
在该命名空间内创建的进程都将产生一个在该命名空间内独立的 PID。后续在该命名空间创建的进程都将作为 PID=1 的进程的子进程,当该进程被结束时,该命名空间内所有的进程都会被结束。

PID 命名空间是层次性的,如果新创建一个命名空间,则该命名空间将会是创建该命名空间的进程属于的命名空间的子命名空间。子命名空间中的进程对于父命名空间是可见的,一个进程将拥有不止一个 PID,在其所在的命名空间以及所有直系祖先命名空间中都将有一个 PID。

系统启动时,内核将创建一个默认的 PID 命名空间,该命名空间是所有以后创建的命名空间的祖先,因此系统所有的进程在该命名空间都是可见的。

CLONE_NEWIPC

该标识创建一个新的 IPC 命名空间,新进程将成为命名空间里的第一个进程。

一个 IPC 命名空间有一组 System V IPC objects 标识符构成,这标识符由 IPC 相关的系统调用创建。

在一个 IPC 命名空间里面创建的 IPC object 对该命名空间内的所有进程可见,但是对其他命名空间不可见。这样就使得不同命名空间之间的进程不能直接通信,就像是在不同的系统里一样。当一个 IPC 命名空间被销毁,该命名空间内的所有 IPC object 会被内核自动销毁。

PID 命名空间和 IPC 命名空间可以组合起来一起使用,只需在创建进程时,同时指定 CLONE_NEWPID和 CLONE_NEWIPC,这样新创建的命名空间既是一个独立的 PID 空间又是一个独立的 IPC 空间。不同命名空间中的进程彼此不可见,也不能互相通信,这样就实现了进程的运行时隔离。

CLONE_NEWNS

该标识创建了一个新的 mount 命名空间。

每个进程都存在于一个 mount 命名空间里面,mount 命名空间为进程提供了一个文件层次视图。如果不设定这个标识,子进程和父进程将共享一个 mount 命名空间,其后子进程调用 mount 或 umount 将会影响到所有该命名空间内的进程。如果子进程在一个独立的 mount 命名空间里面,就可以调用 mount 或 umount 建立一份新的文件层次视图。

该标识配合 pivot_root 系统调用,可以为进程创建一个独立的目录空间。

CLONE_NEWNET

该标识创建了一个新的 Network 命名空间,为进程提供了一份独立的网络环境,就跟一个独立的系统网络一样。包括网络设备接口,IPv4 和 IPv6 协议栈,IP 路由表,防火墙规则,sockets 等等。

虚拟网络设备(virtual network device)提供了一种类似管道的抽象,可以在不同的命名空间之间建立隧道。利用虚拟化网络设备,可以建立到其他命名空间中的物理设备的桥接。

当一个 Network 命名空间被销毁时,物理设备会被自动移回 init Network 命名空间,即系统最开始的命名空间。

CLONE_NEWUTS

该标识创建了一个新的 UTS 命名空间。一个 UTS 命名空间就是一组被 uname 返回的标识符。

新的 UTS 命名空间中的标识符通过复制调用进程所属的命名空间的标识符来初始化。新创建的进程可以通过相关系统调用改变这些标识符,比如调用 sethostname 来改变该命名空间的 hostname。这一改变对该命名空间内的所有进程可见。

CLONE_NEWUTS 和 CLONE_NEWNET 一起使用,可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。

CLONE_NEWUSER

该标识创建了一个新的 User 命名空间,用于隔离用户和用户组 ID。

换句话说,同一个进程的 uid 和 gid 在命名空间内外可以不同。就是说,host 环境下的普通用户可以是某个命名空间的 root 用户,以此来完成一些需要 root 权限的操作。

代码示例

上述命名空间可以进行组合使用,全部使用的话相当于完整的进程隔离。在介绍完概念后,下面我们来介绍在 Golang 中为进程设置命名空间的方法。

syscall、os/exec 对 Linux 命名空间的支持是 Go 1.4 引入的,细节请参考该 issue

import (
    "os/exec"
    "syscall"
)

func setNamespace(cmd *exec.Cmd) {
    // XXX: keep move with Go 1.4 and later's

    cmd.SysProcAttr = &syscall.SysProcAttr{}
    cmd.SysProcAttr.Cloneflags = syscall.CLONE_NEWUSER | syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWIPC | syscall.CLONE_NEWNET
    cmd.SysProcAttr.Credential = &syscall.Credential{
        Uid: 0,
        Gid: 0,
    }

    cmd.SysProcAttr.UidMappings = []syscall.SysProcIDMap{{ContainerID: 0, HostID: 1001, Size: 1}}
    cmd.SysProcAttr.GidMappings = []syscall.SysProcIDMap{{ContainerID: 0, HostID: 1001, Size: 1}}
}

上面的示例代码将待执行的 cmd 放到一个完全新的命名空间中,并设置该进程在新命名空间中以 root 用户执行。而这个 root 用户则是映射到 host 上用户 id 为 1001、组 id 为 1001 的用户。

这样是为了:

  • cmd 是以 root 执行的
  • cmd 在 host 上权限受限于 uid=1001、gid=1001

也就相当于 cmd 进程认为自己是以 root 执行的,但其实最终的操作受制于 1001 这个用户。

总结

Linux 命名空间历时 10 多年时间(2002-2013)陆续实现了 6 个命名空间,从而使进程在使用操作系统资源时的隔离成为可能。

Go 1.4 加入了对命名空间的支持,使我们可以在创建进程是通过设置克隆标识(CLONE_XXX)组合来实现进程命名空间设置。

命名空间支持对于一些应用场景非常有效,例如需要在服务器上执行用户自编程序,启动这些可能不安全的程序进程时通过设置命名空间而进行安全隔离。

参考

« 你的单元测试有多稳定?提升自动测试质量的最佳实践 Docker 初探 »