大家好,继续深入容器系列,我们已经知道容器是从 Linux 命名空间和 Cgroups 构建的,为了更深入地了解它,我们将学习如何使用 Golang 构建自己的容器。

这篇文章我参考了 Julian Friedman 的Build Your Own Container Using Less than 100 Lines和 Liz Rice 的 Go从头开始构建容器。


这是深入容器系列的第四部分:

1.Linux 命名空间和 Cgroups:容器是由什么制成的?

2.深入容器运行时。

3.Kubernetes 如何与容器运行时一起工作。

4.深入Container - 用Golang构建自己的容器。


构建容器

container.go
package main

import (
    "os"
)

func main() {

}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

docker run  
docker run busybox echo "A"

进入全屏模式 退出全屏模式

您将看到容器运行并打印字母“A”,如果您运行以下命令:

docker run -it busybox sh

进入全屏模式 退出全屏模式

容器运行并且外壳将附加到它。

/ #

进入全屏模式 退出全屏模式

如果我们现在键入命令,则该命令正在容器中运行。

/ # hostname
d12ccc0e00a0

进入全屏模式 退出全屏模式

/ # ps
PID   USER     TIME  COMMAND
1     root      0:00 sh
9     root      0:00 ps

进入全屏模式 退出全屏模式

hostname 命令不打印打印容器主机名的服务器的主机名,而 ps 命令只打印两个进程。

container.go
package main

import (
    "os"
)

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

func run() {

}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

run()run()go run container.go rundocker run
run()
package main

import (
    "os"
  "os/exec"
)

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

func run() {
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

os/execos.Argsgo run container.go run echo "A"os.Args
Args[0] = "container.go"
Args[1] = "run"
Args[2] = "echo"
Args[3] = "A"

进入全屏模式 退出全屏模式

exec.Command()os.ArgsCommand()
exec.Command(name string, arg ...string)

进入全屏模式 退出全屏模式

该函数采用第一个参数,即它将执行的命令,其余值是该命令的参数。

docker run -it busybox sh
go run container.go run sh

进入全屏模式 退出全屏模式

当你运行 docker 命令时,你会发现它几乎是一样的。

#

进入全屏模式 退出全屏模式

我们已经成功迈出了第一步😁,但是当你输入主机名命令时,它会打印我们服务器的主机名,而不是容器的主机名。

# hostname
LAPTOP-2COB82RG

进入全屏模式 退出全屏模式

如果您在我们的程序中键入更改主机名的命令,它也会影响服务器外部。

# hostnamectl set-hostname container

进入全屏模式 退出全屏模式

exit

我们的程序目前只是运行 sh 命令而不是容器,接下来,我们将通过每个步骤来构建容器。正如我们所知,容器是从 Linux 命名空间构建的。

命名空间

命名空间提供了隔离环境,帮助我们在同一台服务器上运行独立于其他进程的进程。在撰写本文时,有如下六个命名空间,

  1. PID:PID 命名空间为进程提供一组来自其他命名空间的独立进程 ID (PID)。 PID 命名空间使在其中创建的第一个进程分配有 PID 1。

  2. MNT:挂载命名空间控制挂载点,提供你挂载和卸载文件夹,不影响其他命名空间。

  3. NET:网络命名空间为进程创建它们的网络堆栈。

  4. UTS:UNIX 分时命名空间允许一个进程拥有单独的主机名和域名。

  5. USER:用户命名空间为进程创建自己的一组 UIDS 和 GIDS。

  6. IPC:IPC命名空间将进程与进程间通信隔离开来,这样可以防止不同IPC命名空间中的进程使用。

我们将在 Golang 程序中使用 PID、UTS 和 MNT 命名空间。

UTS 命名空间

container.go
package main

import (
  "os"
  "os/exec"
  "syscall"
)

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

func run() {
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

cmd.SysProcAttr
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS,
}

进入全屏模式 退出全屏模式

现在让我们再试一次。

go run container.go run sh

进入全屏模式 退出全屏模式

运行命令以更改主机名。

# hostnamectl set-hostname wsl
# hostname
wsl

进入全屏模式 退出全屏模式

exit
docker run -it busybox shcontainer.go
package main

import (
    "os"
    "os/exec"
    "syscall"
)

// docker run <image> <command>
// ./container run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("Error")
    }
}

func run() {
    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.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

func child() {
    syscall.Sethostname([]byte("container"))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

child()exec.Command
exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

进入全屏模式 退出全屏模式

/proc/self/exesyscall.Sethostname([]byte("container"))
go run container.go run sh/proc/self/exe child shsyscall.Sethostname([]byte("container"))exec.Command("sh")

让我们再试一次。

go run container.go run sh

进入全屏模式 退出全屏模式

键入主机名,您将看到您的进程有自己的主机名。

# hostname
container

进入全屏模式 退出全屏模式

这样我们就完成了下一步😁。

接下来尝试输入ps命令列出进程,看看是不是和我们运行docker run时一样?

# ps
PID   TTY      TIME     CMD
11254 pts/3    00:00:00 sudo
11255 pts/3    00:00:00 bash
17530 pts/3    00:00:00 go
17626 pts/3    00:00:00 container
17631 pts/3    00:00:00 exe
17636 pts/3    00:00:00 sh
17637 pts/3    00:00:00 ps

进入全屏模式 退出全屏模式

根本不像,您看到的进程是服务器外部的进程。

PID 命名空间

container.go
...
func run() {
 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.SysProcAttr = &syscall.SysProcAttr{
  Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
 }
 must(cmd.Run())
}
...

进入全屏模式 退出全屏模式

syscall.CLONE_NEWPID
go run container.go run sh

进入全屏模式 退出全屏模式

# ps
PID   TTY      TIME     CMD
11254 pts/3    00:00:00 sudo
11255 pts/3    00:00:00 bash
17530 pts/3    00:00:00 go
17626 pts/3    00:00:00 container
17631 pts/3    00:00:00 exe
17636 pts/3    00:00:00 sh
17637 pts/3    00:00:00 ps

进入全屏模式 退出全屏模式

什么?它根本没有改变。为什么?

/proc
ls /proc

进入全屏模式 退出全屏模式

现在,您的进程的文件系统看起来与主机相同,因为它的文件系统是从当前服务器继承的,让我们改变它。

MNT 命名空间

container.go
package main

import (
    "os"
    "os/exec"
    "syscall"
)

// docker run <image> <command>
// ./container run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("Error")
    }
}

func run() {
    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.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }

    must(cmd.Run())
}

func child() {
    syscall.Sethostname([]byte("container"))
    must(syscall.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

进入全屏模式 退出全屏模式

syscall.CLONE_NEWNS
syscall.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")

进入全屏模式 退出全屏模式

现在,让我们再次运行。

go run container.go run sh

进入全屏模式 退出全屏模式

键入 ps 命令。

# ps
PID TTY      TIME     CMD
1   pts/3    00:00:00 exe
7   pts/3    00:00:00 sh
8   pts/3    00:00:00 ps

进入全屏模式 退出全屏模式

我们成功了😁。

结论

所以我们知道如何使用 Golang 构建一个简单的容器,但实际上,容器还有很多其他的东西,比如 Cgroups 来限制进程的资源,创建 USER 命名空间,以及从容器中挂载文件到服务器等等......

但基本上,容器创建隔离环境的主要功能是 Linux 命名空间。如果您有任何疑问或需要更多说明,可以在下面的评论部分提出。