"在我机器上能跑"的问题
我们都经历过这种事。你花了好几天写一个新功能,在本地测试一切正常,推上线后……砰,立刻崩溃。
"但在我的机器上能跑啊!" 你对着技术主管大喊。

而这正是容器的本质。它们是一种将你的应用及其运行环境——也就是你的"机器"——打包在一起的方式,让它在生产环境的表现和在你笔记本上一模一样。
但容器到底是什么?
大多数人凭直觉认为容器是某种运行在计算机上、模拟另一台计算机的复杂应用。甚至我在 Pivotal/VMware 从事容器相关工作几个月后,脑海中也是这个形象……但这其实是一个误会。
现实要简单得多,也更加有趣!
真相是:容器本质上就是你计算机上的一个目录——和其他目录没什么两样——只是有进程在它里面运行。它之所以是容器、而不仅仅是运行在目录里的又一个进程,是因为我们利用了一些巧妙的 Linux 内置特性,让这个进程以为这个目录就是整台计算机。对这个进程而言,我们"困住"它的目录之外什么都不存在,这个目录就是它整个宇宙的全部。
在这个两部分的系列文章中,我们将用恰好 60 行 Go 代码从零构建 Docker,来揭开这个幻象的面纱。
注意:由于容器严重依赖 Linux 内核,本教程只能在 Linux 机器上原生运行。如果你在 Mac 上跟着操作,需要先启动一台 Linux 虚拟机——因为 Mac 运行在 Darwin 内核上,没有这些系统调用!
让我们开始吧!
准备工作
我们将用 Go 来编写容器。为什么是 Go?因为它让我们能以极其干净的方式访问底层的 Linux 系统调用,而构建容器幻象正需要这些调用。这也是 Docker、Kubernetes 和其他云原生项目都用 Go 编写的原因。
让我们尝试复现 Docker 的核心行为。
通常,使用 Docker 时你会运行类似这样的命令:
docker run -it ubuntu /bin/bash
如果运行这条命令(假设你已经安装了 Docker),你会发现自己被丢进了一个新 shell。这个 shell 看起来和原来的 shell 不一样,而且你可以通过以下几种方式确认它与计算机的其他部分(宿主机)是完全隔离的:
- 命令行提示符:大多数开头会有
[用户名]@[主机名]。现在你看到的提示符大概长这样:root@[一串随机字符]。 - 主机名:在宿主机终端输入
hostname,它会输出你计算机的真实名称。而在容器内运行hostname,你会看到和提示符里一样的那串随机字符。 - 文件系统:在容器内输入
ls /,你会发现计算机实际根目录下的文件一个都不在,取而代之的是一个全新的文件列表——就像一台全新安装的 Ubuntu 系统。 - 进程列表:在容器内输入
ps aux,你只会看到 2 个进程,且它们的 PID 都很小——通常 PID 1 是 shell 进程,另一个是刚运行的ps命令。而在宿主机上运行ps aux,你会看到长长的进程列表。
我们将从零开始尝试复现这些行为。
我们的目标是运行:
go run main.go run /bin/bash
上代码
在你的 Linux 计算机上创建一个新目录,新建 main.go 文件,写入初始模板:
package main
import (
"fmt"
"os"
)
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("Invalid argument")
}
}
func run() {
fmt.Printf("Running %v
", os.Args[2:])
}
在 Go 中,os.Args 将终端输入捕获为字符串列表。我们让程序检查第二个单词,如果是 "run",就触发 run() 函数。
要让它真正执行命令,需要引入 os/exec 包:
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("Invalid argument")
}
}
func run() {
fmt.Printf("Running %v
", os.Args[2:])
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)
}
}
测试一下:
$ go run main.go run echo hello world
Running [echo hello world]
hello world
引入 Namespace(隐形斗篷)
要将进程与计算机的其他部分隔离开,需要把它放进一个 Namespace 里。Namespace 就像一件隐形斗篷。我们先从 UTS namespace 开始,它隔离主机名。
更新 run() 函数,传入 CLONE_NEWUTS flag:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
现在需要 root 权限运行:sudo go run main.go run /bin/bash。
在新 shell 中手动运行 hostname container,然后输入 hostname 会看到 container。但切换到宿主机终端输入 hostname,真实名称没有变化!隔离成功了。
Fork 进程
但手动改主机名太晚了——bash 启动时已经读取了旧主机名。我们需要在运行 bash 之前自动改好。
方案:让 Go 程序自己作为子进程再运行一次:
func run() {
args := append([]string{"child"}, os.Args[2:]...)
cmd := exec.Command("/proc/self/exe", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
must(cmd.Run())
}
func child() {
must(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())
}
运行测试:
$ sudo go run main.go run /bin/bash
Running [/bin/bash]
Running in new child process [/bin/bash]
root@container:/home/yechiel/docker-clone#
提示符变成 root@container 了!我们在 bash 加载之前成功隔离了主机名。
不过目前还不是真正的容器——ls / 仍能看到宿主机所有文件,用 cd .. 可以逃出目录。在第二部分中,我们将解决:监禁文件系统、隔离 PID、以及用 cgroups 防止无限循环搞崩宿主机。敬请期待!