在前几章中,你了解了容器框架的关键组成部分:命名空间(namespaces)、控制组(cgroups)和分层文件系统(layered file systems)。在本章中,你将学习这些组成部分如何构成容器框架,并通过构建自己的简单容器框架来加深理解。
既然我们已经掌握了容器的基本概念,现在是时候看看如何编写自己的简单容器了。在本章结束时,你将能够使用命名空间隔离创建自己的简单容器。
让我们开始吧。
我已经在 Ubuntu 19.04 和 Linux 内核 5.0.0-13 上测试了本章中出现的命令。
UTS 命名空间
我们首先探讨的命令是 unshare。这个命令允许你将一组命名空间从主机中分离。我们将进入一个新的 UTS 命名空间,并在该命名空间中更改主机名:
root@osboxes:~# unshare -u /bin/bash
root@osboxes:~# hostname test
root@osboxes:~# hostname
test
root@osboxes:~# exit
exit
root@osboxes:~# hostname
osboxes
在进入 UTS 命名空间后,我们将主机名更改为 test,并且这个更改在该命名空间中得到了体现。一旦退出并重新进入主机命名空间,我们就回到了主机的命名空间。
unshare -u /bin/bash 命令创建了 UTS 命名空间,并在该命名空间中执行了我们的进程(/bin/bash)。注意,如果我们在进入命名空间后没有更改主机名,我们仍会得到主机的主机名。这是不理想的,因为我们需要在命名空间中执行程序之前设置主机名。
这就是我们将探索如何使用 Go(也称为 Golang)编写容器的地方,然后在启动容器内的进程之前设置命名空间。Golang 是最常用的系统编程语言之一。它用于创建容器运行时(如 Docker)以及容器编排引擎(如 Swarm 和 Kubernetes)。除此之外,它还被广泛用于各种其他系统编程场景。在深入了解本章代码之前,最好对 Golang 有一定的了解。
自然地,在用 Golang 编写容器之前,需要在虚拟机或你正在使用的机器上安装 Golang,具体安装步骤如下。(有关 Golang 下载和安装的完整说明,请访问 go.dev/doc/install。
Golang 安装
以下是 Golang 的快速安装命令:
root@osboxes:~# wget https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
root@osboxes:~# tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
你可以将以下行添加到 /root/.profile 文件中,以将 Golang 的二进制文件添加到系统 PATH 变量中:
root@osboxes:~# export PATH=$PATH:/usr/local/go/bin
然后在终端中运行以下命令:
root@osboxes:~# source ~/.profile
要检查 Go(Golang)是否已正确安装,可以运行以下命令:
root@osboxes:~# go version
如果安装成功,你应该会看到如下输出:
接下来,我们将构建一个仅包含命名空间的容器,然后不断修改程序以添加更多功能,如 shell 支持、rootfs、网络和 cgroups。
构建一个带有命名空间的容器
在构建容器之前,让我们简要回顾一下 Linux 命名空间。命名空间存在于 Linux 内核中,类似于沙箱内核资源,如文件系统、进程树、消息队列、信号量,以及网络组件,如设备、套接字和路由规则。
命名空间将进程隔离在自己的执行沙箱中,使其与其他命名空间中的进程完全隔离。
这里介绍了六种命名空间:PID、Mount、UTS、Network、IPC 和 User 命名空间:
- PID:PID 命名空间中的进程具有不同的进程树。它们有一个 PID 为 1 的 init 进程。
- Mount:该命名空间控制进程可以看到哪些挂载点。如果进程处于某个命名空间内,它将只看到该命名空间内的挂载点。
- UTS:允许进程看到与实际全局命名空间不同的命名空间。
- Network:该命名空间提供命名空间内的不同网络视图。网络结构,如端口、iptables 等,都在该命名空间内作用。
- IPC:该命名空间将进程间通信结构,如管道,限制在特定的命名空间内。
- User:该命名空间允许在命名空间内有独立的用户和组视图。
我们在这里不讨论 cgroup 命名空间,它(如第 3 章所述)也允许将 cgroups 限定在自己的命名空间中。
现在,让我们动手创建一个名为 myuts.go 的 Go 类。复制以下代码片段,并使用 go build myuts.go 来获取 myuts 二进制文件。请以 root 用户执行 myuts 二进制文件。
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/bash")
// 以下语句涉及到创建的进程(cmd)的输入、输出和错误流
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 设置环境变量
cmd.Env = []string{"name=shashank"}
// 以下命令为进程创建一个 UTS 命名空间
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
if err := cmd.Run(); err != nil {
fmt.Printf("运行 /bin/bash 命令时出错 - %s\n", err)
os.Exit(1)
}
}
这是一个简单的 Go 程序,它执行一个 shell,设置进程的 I/O 流,然后设置一个环境变量。接着,它使用以下命令创建一个 UTS 命名空间:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
它传递了 CLONE 标志(在这种情况下,我们只传递了 UTS 作为 Clone 标志)。这些 clone 标志控制为进程创建哪些命名空间。
之后,我们构建并运行这个 Golang 进程。我们可以使用 proc 文件系统和检查 proc/<<pid>>/ns 来查看新命名空间是否被创建:
root@osboxes:~/book_prep# ls -li /proc/self/ns/uts
15738 lrwxrwxrwx 1 root root 0 Jul 13 15:53 /proc/self/ns/uts -> 'uts:[4026531838]'
root@osboxes:~/book_prep# ./myuts
root@osboxes:/root/book_prep# ls -li /proc/self/ns/uts
17043 lrwxrwxrwx 1 root root 0 Jul 13 16:06 /proc/self/ns/uts -> 'uts:[4026532325]'
root@osboxes:/root/book_prep# exit
首先,我们打印主机的命名空间,然后打印我们所在容器的命名空间。我们可以看到 UTS 命名空间是不同的。
添加更多命名空间
现在您已经知道如何创建一个 UTS 命名空间,本节将演示如何添加更多的命名空间。
首先,我们添加更多的 clone 标志,以创建更多的命名空间:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/bash")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
// 以下命令创建了 UTS、PID、IPC、NETWORK 和 USER 命名空间
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
}
if err := cmd.Run(); err != nil {
fmt.Printf("运行 /bin/bash 命令时出错 - %s\n", err)
os.Exit(1)
}
}
在上面的代码中,我们通过 clone 标志添加了更多的命名空间。我们按照如下方式构建并运行程序:
root@osboxes:~/book_prep# ./myuts
nobody@osboxes:/root/book_prep$ ls -li /proc/self/ns/
total 0
17488 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 cgroup -> 'cgroup:[4026531835]'
17483 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 ipc -> 'ipc:[4026532335]'
17487 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 mnt -> 'mnt:[4026532333]'
17481 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 net -> 'net:[4026532338]'
17484 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 pid -> 'pid:[4026532336]'
17485 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 pid_for_children -> 'pid:[4026532336]'
17489 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 time -> 'time:[4026531834]'
17490 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 time_for_children -> 'time:[4026531834]'
17486 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 user -> 'user:[4026532325]'
17482 lrwxrwxrwx 1 nobody nogroup 0 Jul 14 16:10 uts -> 'uts:[4026532334]'
我们看到容器属于这些命名空间。现在我们看到所有的命名空间的所有权属于 nobody。这是因为我们还使用了用户命名空间作为 clone 标志。容器现在在一个新的用户命名空间中。用户命名空间要求我们将命名空间中的用户映射到主机上。由于我们尚未进行任何操作,所以我们仍然看到 nobody 作为用户。
我们现在在代码中添加用户映射:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/bash")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
if err := cmd.Run(); err != nil {
fmt.Printf("运行 /bin/bash 命令时出错 - %s\n", err)
os.Exit(1)
}
}
在上面的代码中,我们添加了 UidMappings 和 GidMappings。我们有一个名为 ContainerID 的字段,我们将其设置为 0。这意味着我们将容器中的 uid 和 gid 0 映射到启动进程的用户的 uid 和 gid。
在用户命名空间的上下文中,有一个有趣的方面:您不需要在主机上是 root 就能创建用户命名空间。这提供了一种创建命名空间和容器的方式,而无需在机器上拥有 root 权限,这是一项重要的安全优势,因为将 root 权限提供给进程可能是危险的。如果程序以 root 身份启动,则对这些程序的任何攻击都可能使攻击者获得 root 权限,从而使整个机器受到威胁。
从技术上讲,您可以在主机上以非 root 身份创建用户命名空间,然后在该用户命名空间中创建其他命名空间。请注意,所有其他命名空间(如果没有用户命名空间)将需要 root 访问权限。
如果我们回顾之前的示例,其中我们一起传递了所有标志,系统首先创建一个用户命名空间,并将所有其他命名空间放置在该用户命名空间内。
在这里,我无法全面覆盖用户命名空间的主题,但这是一个值得好奇的读者探索的有趣领域。我可以马上提到的一个领域是 Docker 构建,在容器内构建镜像时需要 root 访问权限。这是必要的,因为我们需要在容器内挂载一些分层文件系统,并且创建新的挂载需要 root 权限。
虚拟网络设备的设置(如虚拟以太网(veth)对)也是如此,用于将容器连接到主机。尽管如此,根权限容器领域已有一些进展,允许开发人员在不使用 root 的情况下运行容器。如果您想详细了解这个话题,请查看以下资源:rootlesscontaine.rs/ 和 github.com/rootless-co…
到目前为止,我们已经实现了在一组命名空间内启动进程的能力。但我们还需要更多的功能,包括在启动容器之前初始化这些命名空间的方式。
回到我们创建的程序。让我们构建并运行它:
root@osboxes:~/book_prep# ./myuts
root@osboxes:/root/book_prep# whoami
root
root@osboxes:/root/book_prep# id
uid=0(root) gid=0(root) groups=0(root)
现在我们看到容器内的用户是 root。
该程序检查第一个参数。如果第一个命令被运行,那么程序将执行 /proc/self/exe,这实际上是“执行你自己”(/proc/self/exe 是调用者自身的二进制镜像副本)。
您可能会想知道为什么我们需要执行 /proc/self/exe。当我们执行这个命令时,它会用一些参数(在我们的例子中,我们传递了 fork 作为参数)启动相同的二进制文件。一旦我们进入不同的命名空间,我们需要对命名空间进行一些设置,比如在启动容器中的进程之前设置主机名。
执行 /proc/self/exe 给了我们设置命名空间的机会,如下所示:
- 设置主机名。
- 在挂载命名空间内,我们执行 pivot root,这允许我们切换根文件系统。它通过将旧根复制到其他目录并将新路径设为新根来完成。这种 pivot root 必须在挂载命名空间内完成,因为我们不想将 rootfs 移出主机。我们还挂载了 proc 文件系统。这是因为挂载命名空间继承了主机的 proc,我们需要在挂载命名空间内有一个 proc 挂载。
- 一旦命名空间初始化并设置完毕,我们启动容器进程(在本例中是 shell)。
运行该程序会将 shell 启动到一个由 proc 挂载和 UTS 命名空间限制的沙箱中。
现在我们处理在容器内启动进程之前初始化命名空间。在接下来的示例中,我们将有一个不同的主机名在 UTS 命名空间中。在以下代码中,我们做了必要的更改。
我们有一个 parent 函数,它执行以下操作:
- 克隆命名空间。
- 通过
/proc/self/exe再次启动相同的进程,并传递一个child作为参数。
现在进程被再次调用。主函数中的检查会导致调用 child 函数。现在您可以看到我们克隆了命名空间。我们现在所做的只是将 UTS 命名空间中的主机名更改为 myhost。一旦完成,我们调用作为命令行参数传递的二进制文件(在本例中是 /bin/bash)。
在容器内启动 Shell 程序
前面的部分讲解了如何创建不同的 Linux 命名空间。本节解释了如何进入这些命名空间。进入命名空间的限制区域可以通过在这些命名空间内启动程序/进程来实现。以下程序在这些命名空间内启动一个 shell 程序。
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
switch os.Args[1] {
case "parent":
parent()
case "child":
child()
default:
panic("help")
}
}
// 从主程序中调用的 parent 函数,用于设置所需的命名空间
func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
must(cmd.Run())
}
// 这是子进程,它是主程序本身的副本
func child() {
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 以下命令将主机名设置为 myhost。这里的目的是展示 UTS 命名空间的使用
must(syscall.Sethostname([]byte("myhost")))
// 此命令执行作为程序参数传递的 shell
must(cmd.Run())
}
func must(err error) {
if err != nil {
fmt.Printf("错误 - %s\n", err)
}
}
执行程序后,我们可以在新的命名空间内启动二进制文件(请注意主机名已设置为 myhost):
root@osboxes:~/book_prep# ./myuts parent /bin/bash
root@myhost:/root/book_prep# hostname
myhost
root@myhost:/root/book_prep#
在 UTS 命名空间之后,是时候变得更加冒险了。我们现在着手初始化挂载命名空间。
需要理解的一点是,所有来自主机的挂载都会在挂载命名空间内被继承。因此,我们需要一种机制来清除挂载,只使挂载在该命名空间内可见。
在继续之前,需要概念上理解一下系统调用 pivot_root。该系统调用允许我们更改进程的根文件系统。它将旧根挂载到其他目录(在以下示例中,作者使用 pivot_root 作为挂载旧根的目录),并将新目录挂载到 /。这使我们能够在命名空间内清除所有主机的挂载。
同样,在调用 pivot_root 之前,我们需要在挂载命名空间内。由于我们已经在命名空间初始化时有一个钩子(通过 /proc/self/exe 黑客技术),我们需要引入一个 pivot root 机制。
提供根文件系统
我们将使用来自 BusyBox 的根文件系统(rootfs tar 文件),可以从以下网址下载: github.com/ericchiang/…
下载 rootfs.tar.gz 后,将其解压到系统中的 /root/book_prep/rootfs 目录。这一位置在代码中被称为根文件系统的存放位置。如图 6-1 所示,/root/book_prep/rootfs 的内容应与您的系统相同。
解压根文件系统后,我们可以看到 rootfs 目录下的目录结构:
以下程序在挂载命名空间中执行 pivot_root 操作,将根文件系统切换到 rootfs 目录下。挂载命名空间变得重要,因为它允许我们对文件系统挂载进行沙箱化。这是一种获取文件系统层次结构隔离视图的方法,可以查看主机或同一主机上不同沙箱中的内容。
例如,假设主机上运行着两个沙箱——sandboxA 和 sandboxB。当 sandboxA 获得自己的挂载时,其文件系统看到的挂载点与 sandboxB 看到的不同,并且两者都无法看到主机的挂载。这在文件系统级别提供了安全性,因为各个沙箱不能访问其他沙箱或主机中的文件。
以下是程序代码:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
func pivotRoot(newroot string) error {
putold := filepath.Join(newroot, "/.pivot_root")
// Ensure putold is removed after the function returns
// Bind mount newroot to putold to make putold a valid mount point
if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return err
}
// Create putold directory
if err := os.MkdirAll(putold, 0700); err != nil {
return err
}
// Call pivot_root
if err := syscall.PivotRoot(newroot, putold); err != nil {
return err
}
// Change the current working directory to the new root
if err := os.Chdir("/"); err != nil {
return err
}
// Unmount putold, which now lives at /.pivot_root
if err := syscall.Unmount("/.pivot_root", syscall.MNT_DETACH); err != nil {
return err
}
return nil
}
func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
must(cmd.Run())
}
func child() {
// Set the hostname for the child process
must(syscall.Sethostname([]byte("myhost")))
// Now execute the command specified in the command-line arguments
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(mountProc("/root/book_prep/rootfs"))
must(syscall.Sethostname([]byte("myhost")))
if err := pivotRoot("/root/book_prep/rootfs"); err != nil {
fmt.Printf("Error running pivot_root - %s\n", err)
os.Exit(1)
}
must(cmd.Run())
}
func must(err error) {
if err != nil {
fmt.Printf("Error - %s\n", err)
}
}
func main() {
switch os.Args[1] {
case "parent":
parent()
case "child":
child()
default:
panic("help")
}
}
// This function mounts the proc filesystem within the new mount namespace
func mountProc(newroot string) error {
source := "proc"
target := filepath.Join(newroot, "/proc")
fstype := "proc"
flags := 0
data := ""
// Make a Mount system call to mount the proc filesystem within the mount namespace
os.MkdirAll(target, 0755)
if err := syscall.Mount(
source,
target,
fstype,
uintptr(flags),
data,
); err != nil {
return err
}
return nil
}
执行以下程序后,我们可以看到 rootfs 下的目录结构,确认主机名已经更改,并且 uid 显示为 0(容器中的 root 用户):
我们仍然遇到一个问题。默认情况下,proc 挂载点并不存在。我们需要 proc 挂载点来提供有关在命名空间中运行的不同进程的信息,并作为其他实用程序与内核的接口,正如第 5 章中的伪文件系统所解释的那样。我们需要在挂载命名空间中挂载 proc 文件系统。
挂载 proc 文件系统
接下来,我们将新的 mountProc 函数添加到程序中:
func mountProc(newroot string) error {
source := "proc"
target := filepath.Join(newroot, "/proc")
fstype := "proc"
flags := 0
data := ""
// 调用 Mount 系统调用以在挂载命名空间内挂载 proc 文件系统
os.MkdirAll(target, 0755)
if err := syscall.Mount(
source,
target,
fstype,
uintptr(flags),
data,
); err != nil {
return err
}
return nil
}
现在,当我们在容器内运行 ps 命令以列出沙箱内运行的进程时,我们将获得显示的输出。这是因为 ps 使用了 /proc 文件系统。
我们可以使用 nsenter 命令来进入创建的容器命名空间。要尝试这一操作,请确保容器处于运行状态,并打开另一个 Linux 终端。然后运行以下命令:
ps -ef | grep /bin/sh
你应该会看到类似于以下的输出。在我的例子中,我的容器的 PID 是 5387。用户应使用他们自己机器上的 PID。
执行 nsenter -a -t 5387 /bin/sh 命令允许在 PID 为 5387 的进程的命名空间中创建一个新的 shell,如下所示。
总体来说,这段代码执行了以下操作:
- 命令行参数:程序接受一个命令行参数来决定它是作为“父进程”还是“子进程”运行。
- “父进程” :如果是“父进程”,它会创建一个具有特定配置的子进程。
- 子进程:子进程在隔离的命名空间中创建,包括挂载(Mount)、UTS(主机名和域名)、IPC(进程间通信)、PID(进程 ID)、网络和用户。
- pivotRoot() 函数:用于更改根文件系统。它设置一个临时目录并执行根文件系统的更改操作。
- 启动子进程:“父进程”启动子进程,并等待其执行完毕。
- “子进程” :如果是“子进程”,则根据提供的参数执行命令。
- mountProc() 函数:用于在新的命名空间中挂载
/proc文件系统。 - 设置主机名:将主机名设置为特定的值。
- 更改根文件系统:调用
pivotRoot()函数将根文件系统更改为不同的目录。 - 执行命令:在新的环境中执行指定的命令。
- 辅助函数:还有一个名为
must()的实用函数,用于处理错误检查和打印错误消息。
启用容器网络
在前面的章节中,我们创建了一个包含 UTS、PID 和挂载命名空间的容器,但我们没有添加网络命名空间。本节将讨论如何为容器设置网络命名空间。
在深入网络主题之前,我将简要介绍一下 Linux 中的虚拟设备,这对于理解基于容器的网络或任何虚拟网络都是必要的。
虚拟网络:简要介绍
在虚拟化的世界中,需要将数据包从虚拟机发送到实际的物理设备、虚拟机之间,或不同容器之间。我们需要一种机制来以这种方式使用虚拟化设备。Linux 提供了一种机制来创建虚拟网络设备,称为 tun 和 tap 设备。tun 设备在网络堆栈的第 3 层工作,即接收 IP 数据包。而 tap 设备在第 2 层工作,接收原始以太网数据包。
那么这些设备有什么用呢?考虑一个场景,其中容器A需要将数据包发送到另一个容器。来自一个容器的数据包被传输到主机,主机智能地使用 tap 设备将数据包传递给软件桥接器。该桥接器可以连接到另一个容器。
让我们用一个简单的例子来看看这些 tap 设备是如何工作的。以下代码创建了两个 tap 设备,分别称为 mytap1 和 mytap2:
列出 tap 设备时,我们可以看到有两个网络接口:
我们给这些设备分配 IP 地址:
从一个设备到另一个设备运行简单的 ping 命令会得到以下结果:
在这些示例中,我们明确创建了两个 tap 设备,并尝试了两个设备之间的 ping。
我们还可以使用 veth 对,这可以被认为是连接虚拟设备的虚拟电缆。它们在 OpenStack 中用于连接软件桥接器。
首先,我们创建一个 veth 对,如下所示:
这将创建两个 tap 接口,分别称为 firstap 和 secondtap。
现在,我们为 tap 设备添加 IP 地址并运行 ping 测试:
了解了 tun 和 tap 设备的基本概念后,我们来看看如何在为容器创建的网络命名空间和主机的命名空间之间设置网络。为此,我们需要按照以下步骤操作:
- 在主机上创建一个 Linux 桥接(bridge)。
- 创建一对 veth 设备。
- 将 veth 对中的一端连接到桥接。
- 将另一端连接到容器命名空间中的网络接口。
这些步骤在图 6-2 中得到了说明。
在前面的章节中,我们创建了一个包含 UTS、PID 和挂载命名空间的容器,但没有添加网络命名空间。本节将讨论如何为容器设置网络命名空间。
在深入网络主题之前,我将简要介绍 Linux 中的虚拟设备,这对于理解基于容器的网络或任何虚拟网络都是必不可少的。
虚拟网络:简要介绍
在虚拟化环境中,需要将数据包从虚拟机发送到实际的物理设备、在虚拟机之间,或在不同的容器之间。我们需要一种机制来以这种方式使用虚拟化设备。Linux 提供了一种创建虚拟网络设备的机制,称为 tun 和 tap。tun 设备在网络栈的第 3 层工作,意味着它接收 IP 数据包。tap 设备在第 2 层工作,接收原始的以太网数据包。
那么这些设备有什么用呢?考虑一个场景,其中 containerA 需要将数据包发往另一个容器。来自一个数据包的数据被传送到主机,主机智能地使用 tap 设备将数据包传递给软件桥接器。然后,桥接器可以连接到另一个容器。
让我们通过一个简单的例子来看这些 tap 设备如何工作。以下创建了两个 tap 设备,分别称为 mytap1 和 mytap2:
sudo ip tuntap add mode tap mytap1
sudo ip tuntap add mode tap mytap2
列出 tap 设备后,我们可以看到两个网络接口:
1: mytap1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether 00:00:00:00:00:01 brd ff:ff:ff:ff:ff:ff
2: mytap2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether 00:00:00:00:00:02 brd ff:ff:ff:ff:ff:ff
我们为这些设备分配 IP 地址:
sudo ip addr add 10.0.0.1/24 dev mytap1
sudo ip addr add 10.0.0.2/24 dev mytap2
从一个设备到另一个设备运行简单的 ping 命令得到如下结果:
ping 10.0.0.2
在这些示例中,我们显式地创建了两个 tap 设备,并尝试在它们之间进行 ping。
我们还可以使用 veth 对,它可以被视为连接虚拟设备的虚拟电缆。它们用于 openstack 中连接软件桥接器。
首先,我们创建一个 veth 对,如下所示:
sudo ip link add name firstveth type veth peer name secondveth
这将创建两个 tap 接口,分别称为 firstveth 和 secondveth。
现在,我们将 IP 地址添加到 tap 设备并运行 ping:
sudo ip addr add 10.0.1.1/24 dev firstveth
sudo ip addr add 10.0.1.2/24 dev secondveth
sudo ip link set firstveth up
sudo ip link set secondveth up
ping 10.0.1.2
基本了解了 tun 和 tap 设备后,我们接下来讨论如何在为容器创建的命名空间和主机的命名空间之间设置网络。为此,我们需要遵循以下步骤:
- 在主机上创建一个 Linux 桥接器(bridge)。
- 创建一个 veth 对。
- 将 veth 对的一端连接到桥接器。
- 将 veth 对的另一端连接到容器命名空间中的网络接口。
这些步骤在图 6-2 中进行了说明。
现在,我们修改代码以启用网络命名空间:
package main
import (
"fmt"
"os"
"os/exec"
"time"
"path/filepath"
"syscall"
"net"
)
func pivotRoot(newroot string) error {
putold := filepath.Join(newroot, "/.pivot_root")
if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return err
}
if err := os.MkdirAll(putold, 0700); err != nil {
return err
}
if err := syscall.PivotRoot(newroot, putold); err != nil {
return err
}
if err := os.Chdir("/"); err != nil {
return err
}
if err := syscall.Unmount("/.pivot_root", syscall.MNT_DETACH); err != nil {
return err
}
return nil
}
func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
must(cmd.Start())
pid := fmt.Sprintf("%d", cmd.Process.Pid)
fmt.Printf("obtained pid %s", pid)
// Code below does the following:
// Creates the bridge on the host
// Creates the veth pair
// Attaches one end of veth to bridge
// Attaches the other end to the network namespace. This is interesting
// as we now have access to the host side and the network side until we block.
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid)
if err := netsetgoCmd.Run(); err != nil {
fmt.Printf("Error running netsetgo - %s\n", err)
os.Exit(1)
}
if err := cmd.Wait(); err != nil {
fmt.Printf("Error waiting for reexec.Command - %s\n", err)
os.Exit(1)
}
}
func child() {
must(syscall.Sethostname([]byte("myhost")))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(mountProc("/root/book_prep/rootfs"))
must(syscall.Sethostname([]byte("myhost")))
if err := pivotRoot("/root/book_prep/rootfs"); err != nil {
fmt.Printf("Error running pivot_root - %s\n", err)
os.Exit(1)
}
if err := waitForNetwork(); err != nil {
fmt.Printf("Error waiting for network - %s\n", err)
os.Exit(1)
}
must(cmd.Run())
}
func must(err error) {
if err != nil {
fmt.Printf("Error - %s\n", err)
}
}
func main() {
switch os.Args[1] {
case "parent":
parent()
case "child":
child()
default:
panic("help")
}
}
func waitForNetwork() error {
maxWait := time.Second * 3
checkInterval := time.Second
timeStarted := time.Now()
for {
interfaces, err := net.Interfaces()
if err != nil {
return err
}
if len(interfaces) > 1 {
return nil
}
if time.Since(timeStarted) > maxWait {
return fmt.Errorf("Timeout after %s waiting for network", maxWait)
}
time.Sleep(checkInterval)
}
}
func mountProc(newroot string) error {
source := "proc"
target := filepath.Join(newroot, "/proc")
fstype := "proc"
flags := 0
data := ""
os.MkdirAll(target, 0755)
if err := syscall.Mount(
source,
target,
fstype,
uintptr(flags),
data,
); err != nil {
return err
}
return nil
}
这里有几个值得考虑的方面。在早期的代码示例中,我们在 child 方法中初始化了命名空间(如更改主机名和 pivot root)。然后我们在命名空间内启动了 shell(/bin/sh)。这种机制有效,因为我们只需初始化命名空间,而这些操作是在命名空间内部完成的。
然而,在网络命名空间方面,我们需要执行一些操作,如下所示:
- 在主机上创建一个桥接器。
- 创建 veth 对,并使其中一端连接到主机上的桥接器,另一端连接到网络命名空间中。
- 当前的方式的问题是,当我们启动 shell 时,我们会一直停留在命名空间中,直到我们主动退出。因此,我们需要一种方法来立即返回 API 代码,以便我们可以在主机上执行网络设置并加入 veth 对。
幸运的是,cmd.Run 命令可以分成两个部分:
cmd.Start()立即返回。cmd.Wait()会阻塞,直到 shell 退出。
我们在 parent() 函数方法中利用了这一点。我们执行 cmd.Start() 函数,它立即返回。
在 start 方法之后,我们使用一个名为 netsetgo 的库(由 Pivotal 的 Ed King 创建)。它执行以下操作:
- 创建主机上的桥接器。
- 创建 veth 对。
- 将 veth 对的一端连接到桥接器。
- 将 veth 对的另一端连接到网络命名空间。这很有趣,因为我们现在可以访问主机端和网络端,直到我们阻塞。
请按照以下说明下载并安装 netsetgo:
wget "https://github.com/teddyking/netsetgo/releases/download/0.0.1/netsetgo"
sudo mv netsetgo /usr/local/bin/
sudo chown root:root /usr/local/bin/netsetgo
sudo chmod 4755 /usr/local/bin/netsetgo
实际上,许多这些解释是从 Ed King 的示例中改编的。相关的代码片段如下:
must(cmd.Start())
pid := fmt.Sprintf("%d", cmd.Process.Pid)
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid)
if err := netsetgoCmd.Run(); err != nil {
fmt.Printf("Error running netsetgo - %s\n", err)
os.Exit(1)
}
if err := cmd.Wait(); err != nil {
fmt.Printf("Error waiting for reexec.Command - %s\n", err)
os.Exit(1)
}
完成后,我们使用 cmd.Wait(),它重新启动程序(/proc/self/exe)。然后,我们执行子进程并继续进行所有其他初始化操作。初始化完成后,我们可以在命名空间内启动 shell。
接下来,我们应验证主机与容器之间的网络通信,以及容器与主机之间的通信。首先运行以下程序:
/myuts parent /bin/sh
在容器 shell 内运行 ip a 命令。你应该会看到容器的 IP 地址,如下所示:
# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
5: veth1@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether e2:8c:f0:b8:35:45 brd ff:ff:ff:ff:ff:ff
inet 10.10.10.2/24 scope global veth1
valid_lft forever preferred_lft forever
inet6 fe80::e08c:f0ff:feb8:3545/64 scope link
valid_lft forever preferred_lft forever
保持容器运行,并在主机上打开另一个终端(bash shell)。运行以下命令,以 ping 容器的 IP 地址:
ping 10.10.10.2
请注意,我们可以从主机上 ping 容器的 IP 地址。
现在尝试从容器中 ping 主机的 IP 地址。首先,通过运行 ifconfig 命令获取主机的 IP 地址。如你所见,我的主机 IP 地址是 10.0.2.15:
现在从容器中 ping 这个主机 IP 地址:
如您所见,我们能够从容器 ping 主机 IP 地址,也能从主机 ping 容器,因此网络通信双向工作正常。
总体来说,代码执行了以下操作:
- 程序接受命令行参数以确定它将作为“父”或“子”进程运行。 如果是“父”进程,它执行
parent()函数。如果是“子”进程,它执行child()函数。如果既没有提供“父”也没有提供“子”作为参数,则程序会引发一个包含“help”消息的 panic。 waitForNetwork()函数被定义为检查网络可用性。 它会重复检查网络接口,直到找到一个除回环接口以外的网络接口,或者达到 3 秒超时。mountProc()函数被定义为在指定的根目录中挂载/proc文件系统。 它会创建目标目录(如果不存在的话),并将/proc文件系统挂载到该目录上。pivotRoot()函数被定义为进行根文件系统的 pivot 操作。 它设置一个临时目录(putold)以满足 pivot_root 要求,即新根目录和putold目录必须位于不同的文件系统上。它将新根目录挂载到自身上,创建putold目录,执行 pivot 操作以更改根文件系统,将当前工作目录设置为新根目录,卸载putold并将其删除。parent()函数被定义为创建一个具有隔离命名空间的子进程。 它使用exec.Command函数在子进程中执行命令。命令是当前程序本身,附加参数表示它应该作为“子”进程运行。它设置子进程的标准输入、输出和错误流,设置环境变量,并配置各种命名空间(CLONE_NEWNS、CLONE_NEWUTS、CLONE_NEWIPC、CLONE_NEWPID、CLONE_NEWNET和CLONE_NEWUSER)。它还将主机的用户和组 ID 映射到容器命名空间。子进程被启动,并获取其 PID。netsetgoCmd命令被执行以设置子进程的网络相关配置。 它创建了一个桥接器、一个虚拟以太网对,并将虚拟以太网的一端连接到桥接器,另一端连接到子进程的网络命名空间。child()函数被定义为在子进程中执行命令。 它使用exec.Command执行程序命令行参数中指定的命令。它设置子进程的标准输入、输出和错误流。它调用mountProc()挂载/proc文件系统,设置主机名,调用pivotRoot()更改根文件系统,使用waitForNetwork()等待网络可用,然后开始命令执行。must()函数是一个用于处理错误的实用函数。 如果传递给它一个错误,它会打印错误消息。
让我们回顾一下到目前为止取得的成果:
- 我们使用
unshare创建了一个容器,并演示了在 UTS 命名空间中更改主机名的能力。 - 我们使用 Golang 创建了一个具有 UTS 和用户命名空间的容器。
- 我们添加了挂载命名空间,并演示了如何在命名空间内挂载独立的 proc 文件系统。
- 我们为命名空间添加了网络功能,使我们能够在容器命名空间和主机命名空间之间进行通信。
启用容器的 Cgroups
我们之前在 /root/mygrp 上挂载了一个 cgroup。我们在其中创建了一个目录 child。现在,我们将把我们的进程放入 cgroup 中,并限制其最大内存。
以下是示例代码片段:
func enableCgroup() {
cgroups := "/root/mygrp"
pids := filepath.Join(cgroups, "child")
must(ioutil.WriteFile(filepath.Join(pids, "memory.max"), []byte("2M"), 0700))
must(ioutil.WriteFile(filepath.Join(pids, "cgroup.procs"),
[]byte(strconv.Itoa(os.Getpid())), 0700))
}
在这段代码中,我们将容器中创建的进程(/bin/sh)的 PID 添加到 cgroup.procs 文件中,并将该进程的最大内存限制为 2MB。
在执行此代码之前,您需要对操作系统进行一个配置更改。使用 Nano 或您喜欢的编辑器打开 /etc/default/grub 文件:
nano /etc/default/grub
在此文件中,您需要修改 GRUB_CMDLINE_LINUX_DEFAULT 键,添加 systemd.unified_cgroup_hierarchy=1。请参见以下图像以进行说明:
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash systemd.unified_cgroup_hierarchy=1"
更新后,运行以下命令并重启系统:
sudo update-grub
系统重启后,运行以下命令:
cat /proc/cmdline
我们之前在 /root/mygrp 上挂载了 cgroup,并在其中创建了一个名为 child 的目录。现在我们将把进程放入这个 cgroup,并限制其最大内存。
以下是示例代码片段:
func enableCgroup() {
cgroups := "/root/mygrp"
pids := filepath.Join(cgroups, "child")
must(ioutil.WriteFile(filepath.Join(pids, "memory.max"), []byte("2M"), 0700))
must(ioutil.WriteFile(filepath.Join(pids, "cgroup.procs"),
[]byte(strconv.Itoa(os.Getpid())), 0700))
}
在这个代码片段中,我们将容器内创建的进程(/bin/sh)的 PID 添加到 cgroup.procs 文件中,并将进程的最大内存限制为 2MB。
在执行此代码之前,您需要对操作系统进行一次配置更改。使用 Nano 或您喜欢的编辑器打开 /etc/default/grub 文件:
nano /etc/default/grub
在该文件中,您需要修改 GRUB_CMDLINE_LINUX_DEFAULT 键,添加 systemd.unified_cgroup_hierarchy=1。参见下图以获取更多说明:
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash systemd.unified_cgroup_hierarchy=1"
更新后,运行以下命令并重启系统:
sudo update-grub
系统重启后,运行以下命令检查设置:
cat /proc/cmdline
您应该在 /proc/cmdline 中看到 systemd.unified_cgroup_hierarchy=1 作为 BOOT_IMAGE 键。
要创建 cgroup,请在终端中运行以下命令。使用我们在程序中使用的相同文件夹:
mkdir -p /root/mygrp
mount -t cgroup2 none /root/mygrp
mkdir -p /root/mygrp/child
现在,您可以运行以下程序:
package main
import (
"fmt"
"os"
"os/exec"
"time"
"path/filepath"
"syscall"
"io/ioutil"
"strconv"
"net"
)
func enableCgroup() {
cgroups := "/root/mygrp"
pids := filepath.Join(cgroups, "child")
must(ioutil.WriteFile(filepath.Join(pids, "memory.max"), []byte("2M"), 0700))
must(ioutil.WriteFile(filepath.Join(pids, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}
func pivotRoot(newroot string) error {
putold := filepath.Join(newroot, "/.pivot_root")
if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return err
}
if err := os.MkdirAll(putold, 0700); err != nil{
return err
}
if err := syscall.PivotRoot(newroot, putold); err != nil {
return err
}
if err := os.Chdir("/"); err != nil {
return err
}
if err := syscall.Unmount("/.pivot_root", syscall.MNT_DETACH); err != nil {
return err
}
return nil
}
func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = []string{"name=shashank"}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
must(cmd.Start())
pid := fmt.Sprintf("%d", cmd.Process.Pid)
fmt.Printf("obtained pid %s",pid)
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid)
if err := netsetgoCmd.Run(); err != nil {
fmt.Printf("Error running netsetgo - %s\n", err)
os.Exit(1)
}
if err := cmd.Wait(); err != nil {
fmt.Printf("Error waiting for reexec.Command - %s\n", err)
os.Exit(1)
}
}
func child() {
enableCgroup()
must(syscall.Sethostname([]byte("myhost")))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(mountProc("/root/book_prep/rootfs"))
must(syscall.Sethostname([]byte("myhost")))
if err := pivotRoot("/root/book_prep/rootfs"); err != nil{
fmt.Printf("Error running pivot_root - %s\n",err)
os.Exit(1)
}
if err := waitForNetwork(); err != nil {
fmt.Printf("Error waiting for network - %s\n", err)
os.Exit(1)
}
must(cmd.Run())
}
func must(err error) {
if err != nil {
fmt.Printf("Error - %s\n", err)
}
}
func main() {
switch os.Args[1] {
case "parent":
parent()
case "child":
child()
default:
panic("help")
}
}
func waitForNetwork() error {
maxWait := time.Second * 3
checkInterval := time.Second
timeStarted := time.Now()
for {
interfaces, err := net.Interfaces()
if err != nil {
return err
}
if len(interfaces) > 1 {
return nil
}
if time.Since(timeStarted) > maxWait {
return fmt.Errorf("Timeout after %s waiting for network", maxWait)
}
time.Sleep(checkInterval)
}
}
func mountProc(newroot string) error {
source := "proc"
target := filepath.Join(newroot, "/proc")
fstype := "proc"
flags := 0
data := ""
os.MkdirAll(target, 0755)
if err := syscall.Mount(
source,
target,
fstype,
uintptr(flags),
data,
); err != nil {
return err
}
return nil
}
接下来的文本展示了将进程 PID 添加到 cgroup 以及 memory.max 文件中存储的值,这些值是我们在程序中定义的:
root@instance-1:~/mygrp# ls
cgroup.controllers cgroup.subtree_control cpuset.cpus.effective io.cost.model memory.pressure sys-kernel-debug.mount
cgroup.max.depth cgroup.threads cpuset.mems.effective io.cost.qos memory.stat sys-kernel-tracing.mount
cgroup.max.descendants child dev-hugepages.mount io.pressure proc-sys-fs-binfmt_misc.mount system.slice
cgroup.procs cpu.pressure dev-mqueue.mount io.stat sys-fs-fuse-connections.mount user.slice
cgroup.stat cpu.stat init.scope memory.numa_stat sys-kernel-config.mount
root@instance-1:~/mygrp# cd child
root@instance-1:~/mygrp/child# cat cgroup.procs
991
1018
root@instance-1:~/mygrp/child# cat memory.max
2097152
总结
在这一章中,我们介绍了如何在 Golang 中创建 Linux 容器的基础知识。我们讨论了 Linux 容器的具体细节(命名空间、cgroups 和联合文件系统)以及容器如何在 Linux 内核中实现。我们编写了一个 Linux 容器,并观察到,通过一些简单的编程,我们可以创建一个像 Docker 一样的简单容器运行时。
建议您逐一完成每个练习,并尝试不同的代码组合。例如,您可以尝试以下操作:
- 尝试使用新的 rootfs:而不是 busybox。
- 尝试容器间网络通信。
- 实验更多资源控制。
- 在一个容器中运行 HTTP 服务器,在另一个容器中运行 HTTP 客户端,并通过 HTTP 建立通信。
现在,您应该对容器内部发生的事情有了一个较为清晰的了解。因此,当您使用不同的容器编排工具,如 Kubernetes 或 Swarm 时,您将更容易理解实际发生的情况。