↓ Skip to main content
  1. Posts/

从进程视角重新理解 Docker

·3809 words·8 mins
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

起因
#

第一次接触 Docker 是 2017 年,在深圳网警机房驻场做审核项目。机房完全没有外网,模型、依赖、运行环境全靠 U 盘拷贝 docker image 来传输。后来做模型转换系统,再到量化行业负责机器学习平台,和 Docker、K8s 打了不少交道。但距离上一次系统梳理 namespace、cgroup 这些底层机制,已经过去好几年了。

最近重新回顾这些概念,我更关注的不是 Docker 的命令或者具体实现细节,而是背后这几个系统设计思想。

有一个说法很常见:「Docker 是一种轻量级虚拟机。」

作为直觉类比,这个说法不算完全错误。但如果真的拿它当理解 Docker 的模型,后面很容易被带偏。

一个 sleep 进程的真相
#

运行一条命令:

1docker run ubuntu sleep 10000

直觉上好像是「启动了一台 Ubuntu 机器,上面跑了一个 sleep」。

但在宿主机 kernel 看来,并没有凭空多出一台 Ubuntu 机器,只多了一个普通的 sleep 进程。

这个进程仍然:

  • 被宿主机 Linux kernel 调度
  • 使用宿主机的 CPU 和 memory
  • 进行 system call 时进入宿主机 kernel
  • 可以从宿主机用 ps 看到

Container is a process, not a machine。

Docker 做的事情,不是复制一套 kernel,而是让这个进程看到一个被重新组织过的系统视图。

这个区别,是后面所有机制的前提。

环境不应该依赖机器状态
#

在讨论 Docker 怎么做到「隔离」之前,先退一步想:它到底在解决什么问题。

传统的部署模型是这样的:

1Application
2→ 登录目标机器
3→ 安装依赖
4→ 修改配置
5→ 让机器变成 application 需要的状态

问题在于 machine state 是 mutable 的。不同机器经过不同历史操作之后,会积累大量隐式状态——某个包被手动升级过,某个环境变量被改过,某个配置文件被另一个项目覆盖过。这些 drift 很难追踪,也很难复现。

Docker 做了一个关键的转变:

不再反复修改目标机器来适配 application,而是把 application 需要的 userspace environment 打包成一个独立的 artifact。

这个思路后面还会展开。Docker 要解决的问题,是让运行环境不再依赖目标机器的当前状态。

Machine abstraction vs Process abstraction
#

回到「Docker 不是虚拟机」这个话题。

VM 和 container 的区别,经常被描述为「VM 更重,container 更轻」。这个说法没错,但停在这一层还不足以解释两者的区别。

真正的区别在于抽象层级。

VM 做的事情是:

virtualize a machine

它模拟出一整套虚拟硬件,在上面运行一个完整的 guest kernel 和 userspace。应用跑在 guest OS 里,和 host OS 之间隔了一层 hypervisor。

Container 做的事情是:

isolate a process environment

它不模拟硬件,也不启动新 kernel。所有 container 里的进程,共享同一个 host kernel。Docker 只是给不同进程构造不同的系统视图。

1VM:                          Container:
2
3Application                  Application
4Userspace (guest)            Userspace
5Guest Kernel                 ─── (shared) ───
6Virtual Hardware             Host Kernel
7Hypervisor
8Host Kernel

Docker 不是找到了一种「更小的虚拟机」,而是发现很多场景根本不需要虚拟化一台机器。 如果大家共享同一个 Linux kernel,那么只需要给不同进程构造不同的系统视图就够了。

这就引出了下一个问题:怎么给一个进程构造「不同的系统视图」?

Namespace:同一个 kernel,不同的世界
#

答案是 Linux namespace。

Namespace 最核心的思想可以用一句话概括:

同一个 kernel,可以让不同进程看到不同的世界。

Namespace 不是 Docker 发明的,它是 Linux kernel 提供的 primitive。Docker 在其上做了工程封装。

具体来说,Linux 提供了多种 namespace,每种隔离一类系统资源的可见性。

PID Namespace
#

核心问题:这个进程能看到哪些进程?

在宿主机上,进程列表可能是:

1PID 1      systemd
2PID 300    sshd
3PID 5231   python

在 container 内部,同一个 python 进程看到的是:

1PID 1      python

这不是两份 python。这是同一个 process,在不同 PID namespace 中具有不同的 PID。在 host namespace 里它是 PID 5231,在 container namespace 里它是 PID 1。

一个很直观的事实:container 默认看不到 host 上的其他进程,但 host 可以看到 container 里的进程。

这再次说明 container process 从未离开 host kernel。

Mount Namespace
#

核心问题:这个进程看到哪棵 filesystem tree?

运行 docker run ubuntu 之后,container 里看到的是:

1/bin
2/etc
3/usr
4/lib
5...

这看起来像一个完整的 Ubuntu 系统。但实际上并没有启动一个 Ubuntu OS。

更准确的描述是:给这个 process 提供了一套 Ubuntu userspace 的 root filesystem,并通过 mount namespace 让它把这棵树当成自己的 /。

Host 上的 / 和 container 里的 / 是不同的 mount point,各自独立。进程在自己的 mount namespace 里只能看到属于自己的 filesystem tree。

顺便澄清一点:filesystem isolation 并不是和 namespace、cgroup 完全并列的一套独立 primitive。它主要涉及 mount namespace、rootfs(root filesystem)和 mount 操作。Docker image 里打包的 filesystem layers 通过 OverlayFS 等机制组合成最终的 rootfs,再通过 mount namespace 挂载给进程。OverlayFS 的实现细节不在本文范围,这里只需要知道:container 看到的文件系统,是 mount namespace + rootfs 共同构造的结果。

Network Namespace
#

核心问题:这个进程看到哪套 network stack?

每个 network namespace 可以拥有自己独立的:

  • network interface
  • IP 地址
  • 路由表
  • port 空间

所以两个 container 可以各自监听 :80,互不冲突。因为它们在不同的 network namespace 里,各自有独立的端口空间。

Docker bridge、iptables、NAT 这些实现细节不在本文范围。核心仍然是同一件事:给 process 构造一套独立的 network view。

UTS Namespace
#

控制 hostname 和 domain name。

这就是为什么每个 container 可以有自己的 hostname,而不影响 host 或其他 container。

IPC Namespace
#

隔离 System V IPC 资源:shared memory、semaphore、message queue。

不同 container 的进程无法通过这些 IPC 机制互相通信。

汇总
#

Namespace 的统一思想是给进程构造不同的 resource view:

Namespace隔离的 view
PID进程视图
Mount文件系统视图
Network网络视图
UTS主机名
IPC进程间通信

所有这些 namespace 做的都是同一件事:改变一个进程看到的世界。

cgroup:看不到彼此,不代表抢不到资源
#

Namespace 已经让两个 process 互相看不到对方。但这并不意味着它们不会互相影响。

一个 process 即使在独立的 namespace 里,仍然可以:

  • 跑满所有 CPU core
  • 吃光整台机器的 memory
  • 打满磁盘 IO

因为 namespace 隔离的是 view,不是 resource 本身。

于是 Linux 还需要第二个维度:resource control。这就是 cgroup(control group)。

Cgroup 的核心 abstraction 是:把一组 process 组织在一起,对它们做资源 accounting 和资源限制。

最常见的控制维度:

  • CPU:限制一组进程能用多少 CPU 时间
  • Memory:限制一组进程能用多少物理内存,超限可以触发 OOM kill
  • IO:限制磁盘读写带宽

cgroup v1/v2 的差异、controller 参数、filesystem 接口,都是实现层面的细节,这篇不涉及。

这两个概念要成对理解:

1Namespace → isolation of view    (能看到什么)
2cgroup    → resource control     (能用多少)

Namespace 回答 “what can you see”,cgroup 回答 “how much can you use”。 两者配合,才构成了 container isolation 的基础。

Container 的最小 心智模型
#

把前面的东西合起来,可以概括出一个很简洁的式子:

1Container = Linux Process + Namespaces + cgroups + rootfs

这不是严格意义上的完整 implementation definition——真实的 container runtime 还涉及 seccomp、capabilities、apparmor 等安全机制。但作为用于理解 container 的 心智模型,这四个要素已经足够。

一个 container,本质上就是一个被 namespace 隔离了系统视图、被 cgroup 限制了资源用量、被赋予了一套独立 rootfs 的 Linux 进程。

顺便澄清一个常见的说法:「one process per container」。

这个说法容易被误读为「Docker 容器只能运行一个进程」,这不准确。Container 通常有一个作为 PID 1 的主进程,container 的 lifecycle 与这个主进程绑定——主进程退出,container 就停止。但这个主进程当然可以 fork 出 worker 或 child process。

Docker 鼓励的实际是 one primary responsibility per container——每个 container 承担一个主要职责,而不是 technically 只有一个 Unix process。

Docker 真正改变的事情
#

Namespace、cgroup、chroot 这些机制都不是 Docker 发明的。Linux container 的技术基础在 Docker 出现之前就已经存在了(LXC、cgroups 等)。

Docker 对行业的改变,很大程度上不在于 container 隔离本身,而在于它把这些 Linux primitives 包装成了一套好用的 application packaging 和 distribution model。

这套模型的核心流水线:

docker build run flow

以前交付一个应用,交付的是一份「如何配置机器」的说明书——装什么版本的 Python,改哪个配置文件,设置哪些环境变量。

Docker 之后交付的是一个已经构建好的 runtime artifact。

这个转变可以类比编译:

1Source Code  ──compile──▶  Binary
2
3Source Code
4+ dependencies
5+ userspace filesystem  ──docker build──▶  Image

编译把源码变成可执行文件。Docker build 把源码连同依赖、配置、整个 userspace 环境一起,变成一个可运行的 image。

于是 image 天然具有这些属性:

  • Immutable:build 完成后不可修改
  • Versioned:可以打 tag,追踪版本
  • Distributable:可以推到 registry,任何地方拉取
  • Reproducible:同一份 image,在任何机器上运行行为一致

Docker 把 runtime environment 本身变成了 immutable artifact。 这直接改变了 runtime environment 的交付方式。

回想 2017 年在网警机房,靠 U 盘拷贝 docker image 到离线环境——本质上做的就是把一个完整的 runtime artifact 通过物理介质分发。不需要在目标机器上重新安装任何依赖,docker load 之后直接 docker run。environment 不再依赖目标机器的状态,因为 environment 已经在 image 里了。

后来的 deployment 和 orchestration 基本都建立在这个思路上。

总结
#

Container is a process, not a machine。 Container 中的 process 仍然只是 host kernel 上的普通 Linux 进程,没有独立的 kernel,没有虚拟硬件。

Namespace 构造进程看到的世界,cgroup 控制进程能消耗多少真实资源。 这两个 Linux primitive 配合,构成了 container isolation 的基础。

Docker 不只是隔离进程。它还把 application runtime environment 变成了一个可以 build、version、distribute、run 的 artifact。 后半句才是它在工程上被广泛采用的原因。

参考资料
#

Related

k8s nodes is forbidden user cannot list resource nodes in api group at the cluster scope

·998 words·2 mins
继续把 k8s 用于模型转换和部署的自动化流程,然后发现之前安装 k8s 的文档不 work 了…… 时间是 2020 年 5 月 7 日,当前最新的 k8s 版本是 v1.18.2。 报错如下: 详细代码 1 2 3<2kzzqw6rsjid0 --discovery-token-ca-cert-hash sha256:c6c72bdc96c0ff4d59559ff915eee61ba7ac5e8b93c0b2f9e11e813412387ec2 --v=5 4W0507 15:45:12.608784 4768 join.go:346] [preflight] WARNING: JoinControlPane.controlPlane settings will be ignored when control-plane flag is not set. 5I0507 15:45:12.608822 4768 join.go:371] [preflight] found NodeName empty; using OS hostname as NodeName 6I0507 15:45:12.608853 4768 initconfiguration.go:103] detected and using CRI socket: /var/run/dockershim.sock 7[preflight] Running pre-flight checks 8I0507 15:45:12.608902 4768 preflight.go:90] [preflight] Running general checks 9I0507 15:45:12.608933 4768 checks.go:249] validating the existence and emptiness of directory /etc/kubernetes/manifests 10I0507 15:45:12.608966 4768 checks.go:286] validating the existence of file /etc/kubernetes/kubelet.conf 11I0507 15:45:12.608975 4768 checks.go:286] validating the existence of file /etc/kubernetes/bootstrap-kubelet.conf 12I0507 15:45:12.608985 4768 checks.go:102] validating the container runtime 13I0507 15:45:12.685381 4768 checks.go:128] validating if the service is enabled and active 14 [WARNING IsDockerSystemdCheck]: detected "cgroupfs" as the Docker cgroup driver. The recommended driver is "systemd". Please follow the guide at https://kubernetes.io/docs/setup/cri/ 15I0507 15:45:12.765669 4768 checks.go:335] validating the contents of file /proc/sys/net/bridge/bridge-nf-call-iptables 16I0507 15:45:12.765720 4768 checks.go:335] validating the contents of file /proc/sys/net/ipv4/ip_forward 17I0507 15:45:12.765752 4768 checks.go:649] validating whether swap is enabled or not 18I0507 15:45:12.765780 4768 checks.go:376] validating the presence of executable conntrack 19I0507 15:45:12.765804 4768 checks.go:376] validating the presence of executable ip 20I0507 15:45:12.765826 4768 checks.go:376] validating the presence of executable iptables 21I0507 15:45:12.765844 4768 checks.go:376] validating the presence of executable mount 22I0507 15:45:12.765864 4768 checks.go:376] validating the presence of executable nsenter 23I0507 15:45:12.765882 4768 checks.go:376] validating the presence of executable ebtables 24I0507 15:45:12.765902 4768 checks.go:376] validating the presence of executable ethtool 25I0507 15:45:12.765920 4768 checks.go:376] validating the presence of executable socat 26I0507 15:45:12.765935 4768 checks.go:376] validating the presence of executable tc 27I0507 15:45:12.765953 4768 checks.go:376] validating the presence of executable touch 28I0507 15:45:12.765973 4768 checks.go:520] running all checks 29I0507 15:45:12.844881 4768 checks.go:406] checking whether the given node name is reachable using net.LookupHost 30I0507 15:45:12.845030 4768 checks.go:618] validating kubelet version 31I0507 15:45:12.888056 4768 checks.go:128] validating if the service is enabled and active 32I0507 15:45:12.893254 4768 checks.go:201] validating availability of port 10250 33I0507 15:45:12.893373 4768 checks.go:286] validating the existence of file /etc/kubernetes/pki/ca.crt 34I0507 15:45:12.893388 4768 checks.go:432] validating if the connectivity type is via proxy or direct 35I0507 15:45:12.893414 4768 join.go:441] [preflight] Discovering cluster-info 36I0507 15:45:12.893440 4768 token.go:78] [discovery] Created cluster-info discovery client, requesting info from "172.20.52.117:6443" 37I0507 15:45:13.033539 4768 token.go:116] [discovery] Requesting info from "172.20.52.117:6443" again to validate TLS against the pinned public key 38I0507 15:45:13.172634 4768 token.go:133] [discovery] Cluster info signature and contents are valid and TLS certificate validates against pinned roots, will use API Server "172.20.52.117:6443" 39I0507 15:45:13.172653 4768 discovery.go:51] [discovery] Using provided TLSBootstrapToken as authentication credentials for the join process 40I0507 15:45:13.172660 4768 join.go:455] [preflight] Fetching init configuration 41I0507 15:45:13.172669 4768 join.go:493] [preflight] Retrieving KubeConfig objects 42[preflight] Reading configuration from the cluster... 43[preflight] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -oyaml' 44I0507 15:45:13.541858 4768 interface.go:400] Looking for default routes with IPv4 addresses 45I0507 15:45:13.541871 4768 interface.go:405] Default route transits interface "eth0" 46I0507 15:45:13.541941 4768 interface.go:208] Interface eth0 is up 47I0507 15:45:13.541978 4768 interface.go:256] Interface "eth0" has 2 addresses :[10.198.21.97/22 fe80::f816:3eff:fe5e:88f1/64]. 48I0507 15:45:13.541998 4768 interface.go:223] Checking addr 10.198.21.97/22. 49I0507 15:45:13.542008 4768 interface.go:230] IP found 10.198.21.97 50I0507 15:45:13.542016 4768 interface.go:262] Found valid IPv4 address 10.198.21.97 for interface "eth0". 51I0507 15:45:13.542023 4768 interface.go:411] Found active IP 10.198.21.97 52I0507 15:45:13.542057 4768 preflight.go:101] [preflight] Running configuration dependant checks 53I0507 15:45:13.542072 4768 controlplaneprepare.go:211] [download-certs] Skipping certs download 54I0507 15:45:13.542080 4768 kubelet.go:111] [kubelet-start] writing bootstrap kubelet config file at /etc/kubernetes/bootstrap-kubelet.conf 55I0507 15:45:13.542775 4768 kubelet.go:119] [kubelet-start] writing CA certificate at /etc/kubernetes/pki/ca.crt 56I0507 15:45:13.543283 4768 kubelet.go:145] [kubelet-start] Checking for an existing Node in the cluster with name "host-10-198-21-97" and status "Ready" 57nodes "host-10-198-21-97" is forbidden: User "system:bootstrap:0752yx" cannot get resource "nodes" in API group "" at the cluster scope 58cannot get Node "host-10-198-21-97" 59k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/join.runKubeletStartJoinPhase 60 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/join/kubelet.go:148 61k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).Run.func1 62 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:234 63k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).visitAll 64 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:422 65k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).Run 66 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:207 67k8s.io/kubernetes/cmd/kubeadm/app/cmd.NewCmdJoin.func1 68 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/join.go:170 69k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).execute 70 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:826 71k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).ExecuteC 72 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:914 73k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).Execute 74 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:864 75k8s.io/kubernetes/cmd/kubeadm/app.Run 76 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/kubeadm.go:50 77main.main 78 _output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/kubeadm.go:25 79runtime.main 80 /usr/local/go/src/runtime/proc.go:203 81runtime.goexit 82 /usr/local/go/src/runtime/asm_amd64.s:1357 83error execution phase kubelet-start 84k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).Run.func1 85 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:235 86k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).visitAll 87 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:422 88k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow.(*Runner).Run 89 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow/runner.go:207 90k8s.io/kubernetes/cmd/kubeadm/app/cmd.NewCmdJoin.func1 91 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/cmd/join.go:170 92k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).execute 93 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:826 94k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).ExecuteC 95 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:914 96k8s.io/kubernetes/vendor/github.com/spf13/cobra.(*Command).Execute 97 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/spf13/cobra/command.go:864 98k8s.io/kubernetes/cmd/kubeadm/app.Run 99 /workspace/anago-v1.18.2-beta.0.14+a78cd082e8c913/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/app/kubeadm.go:50 100main.main 101 _output/dockerized/go/src/k8s.io/kubernetes/cmd/kubeadm/kubeadm.go:25 102runtime.main 103 /usr/local/go/src/runtime/proc.go:203 104runtime.goexit 105 /usr/local/go/src/runtime/asm_amd64.s:1357 看起来关键的报错在这一句

Kubernetes(k8s)在深度学习模型转换方面的探索

·1886 words·4 mins
年中的时候接了离职的同事模型转换的锅,在不断地更新迭代的过程中,发现了一些痛点。 发现k8s能够解决一部分痛点,因此来分享一下在这方面的探索。 什么是模型转换 # 简单来说,深度学习模型的流程分为training和inference两部分。训练时用的一般是pytorch等框架,这些框架训练出的model是没办法直接部署在各个硬件平台上做inference的。因此需要将使用训练框架得到的模型,转换为能够部署到各个硬件平台上的模型。这个过程就是模型转换。

docker network 与 本地 network 网段冲突

·537 words·2 mins
起因: # 公司部署在 hk 的爬虫服务器突然挂掉了。后来发现只是在深圳办公区无法访问。排查后发现原因是 docker 的网络(包括 docker network 的 subnet 或者是某个容器的 ip)与该 host 在内网的 ip 段相同,导致冲突。