原文地址
Docker及其创建的容器完全改变了打包和部署应用程序的方式。容器将我们的源代码注入其中实现大规模可移植地运行。我在笔记本上用docker实践各种东西,例如创建本地开发环境,测试环境,使用容器做一些疯狂的事情,如果做的不对就立刻删除?。我相信每个使用过docker的人都会为一条docker-run命令就能在几秒钟内创建一个隔离并独立的机器而震惊。
让我们用几行go代码来实现自己的docker,深入理解其中的原理。
我将做什么?
在本文结束时我们将创建一个Go二进制文件,能够在一个隔离的进程中运行一条有效的linux命令或可执行文件。如下所示:
docker run image <linux命令> <参数>
go run main.go run {一些命令} <linux命令> <参数>
环境要求
1、Go SDK(Linux)
2、任意发行版linux
3、Docker
Linux是必需的,因为容器实际上是我们接下来将要探索的一些很棒的Linux技术的封装。
一些linux技术
1、命名空间(namespaces):一个独立的进程所能看到的信息是由名称空间定义和控制的。命名空间通过为每个进程提供自己的伪独立环境来创建隔离。
2、Chroots:它控制每个进程的根文件系统。
3、Cgroups:一个独立的进程可以从主机中使用哪些资源由cgroup(控制组)限制。
关于命名空间(Namespace)
名称空间提供了在一台机器上运行多个容器所需的隔离环境,即为每个容器提供了看起来像是自己的独立环境。我们有以下6种命名空间,从而提供了不同级别和不同类型的隔离。
- UTS(unix分时系统)允许每个容器拥有独立的主机名和域名, 使其在网络上可以被视作一个独立的节点而非主机上的一个进程。
- PIDs(进程ID):不同用户的进程就是通过 PID命名空间隔离开的,且不同命名空间中可以有相同 PID。
- Mounts(文件系统)允许不同命名空间的进程看到的文件结构不同,这样每个命名空间中的进程所看到的文件目录就被隔离开了。同 chroot 不同,每个命名空间中的容器在/proc/mounts的信息只包含所在命名空间的挂在点。
- Networks(网络):实现有独立的网络设备,IP 地址,路由表,/proc/net目录。
- User IDs(用户ID):每个容器可以有不同的用户和组 id, 也就是说可以在容器内用容器内部的用户执行程序而非主机上的用户。
- IPC(进程间通信):容器中进程交互还是采用了 Linux 常见的进程间交互方法(interprocess communication - IPC), 包括信号量、消息队列和共享内存等。然而同虚拟机不同的是,容器的进程间交互实际上还是 host 上具有相同PID命名空间中的进程间交互,因此需要在IPC 资源申请时加入命名空间信息,每个IPC 资源有一个唯一的 32位id。
容器vs主机
理论和定义讲完了,现在让我们看看容器的行为与主机有什么不同。我们将创建一个ubuntu docker容器,将/bin/bash命令作为入口点。使用下面命令来演示。
docker run -it --rm ubuntu /bin/bash
在容器和主机里面执行一些Linux命令,观察两个环境的结果:
容器
主机
- hostname:返回命令行所在主机名。
-
ps:返回所在环境中运行的进程列表。
可以看到,当我们在docker容器和主机上运行相同的命令时,我们会得到不同的结果。 - 容器中显示主机名是一串数字(容器ID),和主机上的主机名不同。
- 许多进程在主机中运行,但我们的容器只知道在它里面运行的进程,因此提供了进程隔离。
深入理解
我们已经了解容器是如何工作和隔离的。是时候打开我们的编辑器并编写一些Go代码来实现类似于docker所做的事情了。
创建一个main.go文件,添加如下代码。请务必阅读这些代码中的注释,以便更好地理解。
命令切换
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("invalid command")
}
}
命令切换根据命令行参数,来选择执行对应的函数。
Run函数
func run() {
fmt.Printf("Running %v as %d\n", os.Args[2:], os.Getpid())
/*proc目录是所有进程的元数据存放地方
我们的二进制文件也会出现在这里
下面这行代码会在新创建的容器内执行child函数,
proc/self/exe是一个特殊的文件,包含当前可执行文件的内存映像。
换句话说,会让进程重新运行自己,但是传递child作为第一个参数。
这个可执行程序让我们能够执行另一个程序,执行一个由用户请求的程序(由‘os.Args[2:]’中定义的内容)。
基于这个简单的结构,我们就能够创建一个容器。*/
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
// 将操作系统标准io重定向到容器中
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 设置一些系统进程属性,下面这行代码负责创建一个新的独立进程
cmd.SysProcAttr = &syscall.SysProcAttr{
// 创建进程或容器来运行我们提供的命令
// CLONE_NEWUTS运行容器有独立的UTS
// CLONE_NEWPID为新的命名空间进程提供pids
// CLONE_NEWNS为mount提供新的命名空间
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
// systemd中的挂载会递归共享属性。
//取消对新挂载命名空间的递归共享属性。
//它阻止与主机共享新的命名空间。
Unshareflags: syscall.CLONE_NEWNS,
}
// 运行命令并捕获错误
if err := cmd.Run(); err != nil {
log.Fatal("Error: ", err)
}
}
run()函数负责创建一个独立的进程(容器),然后在这个独立的进程中执行相同的go程序,然后调用child()函数。
Child函数
func child() {
fmt.Printf("Running %v as %d\n", os.Args[2:], os.Getpid())
//下面是一些设置容器属性的系统调用
//为新创建命名空间设置主机名
must(syscall.Sethostname([]byte("maverick")))
// 为容器设置根目录
must(syscall.Chroot("/"))
// 设置“/”作为默认目录
must(syscall.Chdir("/"))
// 挂载/proc目录查看在容器内运行的进程
must(syscall.Mount("proc", "proc", "proc", 0, ""))
// 下面一行代码才是在我们自己创建的容器中执行用户命令
cmd := exec.Command(os.Args[2], os.Args[3:]...)
// 将io重定向到容器内
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 运行命令并捕获错误
if err := cmd.Run(); err != nil {
log.Fatal("Error: ", err)
}
// 在命令完成后卸载proc
syscall.Unmount("/proc", 0)
}
child()函数是作为一个子进程运行在run()函数创建的容器内的。child函数负责一些系统调用,以设置一些容器属性,并最终执行用户指定的命令。
Must函数
func must(err error) {
if err != nil {
panic(err)
}
}
must()是一个简单的错误包装器,如果在child函数内部的任何系统调用失败,它就会panic。
让我们创建一些容器
现在我们有了一个迷你docker程序,它可以在我们的主机上创建独立的容器。让我们好好使用它吧!
我们将在容器中运行的命令是/bin/bash,它将在容器中启动一个新的bash程序。
运行以下代码来创建容器:
go run main.go run /bin/bash
当我们运行上面的命令时,将/bin/bash作为参数,随后发生变化有:
- 创建了一个新的容器运行一个和主机隔离bash进程
- 以279518来运行[/bin/bash]命令,指的是我们的bash进程运行在容器中其在主机上的进程ID是279518,同时以1来运行[/bin/bash]命令指的是同一个进程在容器中的pid是1。
- root@maverick和hostname命令告诉我们容器的主机名是maverick,在go代码中以系统调用方式传入的。
- 运行ps返回容器中执行的进程,而其他主机上的进程信息没有返回。
- 因为我们将主机的根目录挂载为容器的Chroot,所以运行ls时,我们可以看到所有的根文件。
现在我们确定bash是作为一个独立的进程运行的,让我们退出它并观察主机的变化。 - 在退出时,我们杀死容器并返回主机
- Hostname和ps返回主机的信息。
理解Chroots
在上面的代码中,我们挂载了主机的根目录作为容器的根目录。这实际上提供了主机所持有的可执行文件和元数据,会而造成安全风险。一个更好的方法是,创建一个伪根目录,去掉可执行文件,然后挂载为容器的根目录。
总结
我们已经成功地创建了一个Go程序,它将以隔离的容器运行Linux可执行文件,具有自己的主机名、自己的进程管理和伪根目录。为了创建一个与Docker容器类似的完全隔离和独立的容器,我们必须使用6个命名空间,配置所有的Cgroups,为每个容器动态创建chroot,并进行层缓存(这使得Docker非常方便使用?),这些超出了本文的范围。
要深入了解docker如何工作?,建议你学习Liz Ric的容器从零开始。
本文源码地址:https://github.com/Akshit8/my-docker