↓ Skip to main content
  1. Posts/

从 Docker 到 Kubernetes:目标状态与资源抽象

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

起因
#

在上一篇《从进程视角重新理解 Docker》中,我总结了 Docker 最核心的价值:它通过 namespace 和 cgroup 给进程构造了独立的视图和资源限制,并把这套运行环境固化成了可以直接分发的 artifact。

Docker 漂亮地解决了 “how to package and run a workload on one machine” 的问题。

但当我几年前开始负责公司的机器学习平台时,面对的是几十上百台机器、数以千计的训练和推理任务。这时候发现,仅仅依靠 Docker 已经完全不够用了。

如果系统里有 100 台机器,一个程序挂了谁来发现并重启?某台机器突然宕机,上面的 20 个容器该迁移到哪里?如何始终保持某个核心服务刚好有 3 个副本在跑?不同机器的 CPU 和 GPU 剩余配额各不相同,新来的任务该塞给谁?

如果此时还是通过 ssh machine-17 然后 docker run 来部署应用,那不过是把手工管理进程,变成了手工管理一个分布式系统。

规模扩大之后,问题的性质发生了改变。我们需要一种全新的系统抽象来管理这堆动态的容器。这就自然引出了 K8s。

Desired State:在动态系统中,“指令”为什么不管用
#

如果你要维护一个分布式系统,最直观的传统思路是命令式(Imperative)的。

说白了就是描述“怎么做(how to do)”:

  • 在节点 A 启动进程
  • 如果挂了,执行重启脚本
  • 升级时,先把旧节点剔除,再加入新节点

这种模式在静态、小规模的环境下很有效,但很脆弱。如果节点 A 网络断了 5 分钟又恢复了,脚本执行到一半卡住了怎么办?如果原本启动成功的进程被 OOM Killer 杀掉了怎么办?

K8s 给出的解法是声明式编程(Declarative Programming),它要求用户描述的是“目标状态(Desired State)”:我需要 replicas = 3,需要 image = v2,需要 cpu = 4。至于怎么达到这个状态,系统自己想办法。

为什么在一个动态、会不断发生失败的分布式系统里,“维护目标状态”比“执行一系列操作”是一个更强的 abstraction?

因为在分布式世界里,Failure 是常态,而不是异常。节点会宕机,网络会抖动,容器会随时暴毙。如果我们把系统的稳定性建立在“确保每一个动作都完美执行”或者“确保某一个具体实例永远存在”上,系统注定会崩溃。

而目标状态的抽象把系统从“每个动作都不能出错”的思路里解放出来:无论中间挂了多少个实例、网络抖了多少次,只要 Controller 还在跑,实际状态就会被慢慢拉回声明的目标。

Reconciliation:一套分布式的控制循环
#

那么,系统如何保证现实最终向 Desired State 收敛?

K8s 的设计者引入了机器人和工业控制领域中非常经典的控制循环(Control Loop)机制,在 K8s 里通常被称为 Reconciliation(调谐)。

如果用一段极简的伪代码来表示,整个 K8s 的核心几乎都在跑着无数个这样的循环:

1while True:
2    desired = read_spec()
3    actual = observe_world()
4
5    if actual != desired:
6        reconcile(actual, desired)

假设我们设定 desired replicas = 3,而由于某台机器宕机,导致 actual replicas = 2。Controller 观察到差异,就会触发新建一个副本的逻辑。反之如果因为网络分区恢复,突然变成了 4 个,它就会删掉一个。

Reconciliation Loop

K8s 与其说是一个容器编排系统,不如从架构思想上把它理解成一套 distributed reconciliation system。

整个集群大量的 Controller(比如 ReplicaSet Controller、Node Controller),本质上都在反复执行:observe -> compare -> reconcile。

这也是为什么 K8s 能把 Failure 视为正常状态。机器挂掉导致 workload 消失,这只不过是 actual state 与 desired state 发生了 divergence 而已。Controller 发现之后,自然会去触发 reconcile 逻辑将目标状态恢复。

从管理机器,到管理资源
#

K8s 带来的另一个转变,是从 Machine-centric 走向 Resource-centric。

传统部署思路往往是与具体物理机绑定的:“核心数据库跑在 server-1 上的 135 端口”,“特征提取任务跑在 gpu-machine-5 上”。这需要运维人员在脑海里(或者 Excel 表格里)维护每台机器的剩余容量。

K8s 希望开发者忘掉机器。在 K8s 的世界里,开发者只需要在 YAML 中声明 workload 的 Resource Requirement:

1resources:
2  requests:
3    cpu: "8"
4    memory: "32Gi"

这背后隐含的模型是:大量的物理机被抽象成了统一的 Cluster Resource Pool。

Resource Pool

既然我们不再指定具体运行在哪台机器上,那么谁来决定这件事?答案是 Scheduler。

不需要深入了解 K8s Scheduler 具体用了什么打分框架和插件,从 high-level 模型上看,它解决的本质问题是:这个 workload 应该放在哪里(Placement problem)?

Scheduler 过滤出满足条件的节点,并进行打分,最后把 workload 绑定到某个具体的 Node 上。在这个过程中,Placement 被从具体的 workload 业务逻辑中抽象了出来。 管理员不用再人肉选机器,集群资源利用率也有了提高的空间。

逻辑与物理的解耦:Pod 与 Service
#

了解了上面这些,我们就能很自然地理解 K8s 中一些独特概念的设计初衷。

为什么需要 Pod?
#

很多人被问到 Pod 时会背诵“Pod 是 K8s 最小部署单元”。但这并没有回答为什么 K8s 不直接调度 Docker 容器。

因为一个逻辑上的 workload 很多时候并不只有一个进程。比如主服务容器旁边,往往需要挂载一个负责收集日志的 sidecar 容器,或者一个做环境初始化的 init 容器。它们需要被一起调度,需要共享网络命名空间和 volume。

容器是 runtime abstraction,而 Pod 是 orchestration abstraction。 K8s 需要一个高于单个容器的调度和生命周期单位,这就是 Pod 存在的意义。

为什么需要 Service 和 Label?
#

既然 Pod 是 K8s 的调度单位,而我们在前面说过,Failure 是预期的,节点宕机会触发 reconciliation,导致旧 Pod 被销毁,新 Pod 在另一台机器上被创建。

这意味着 Pod 的 IP 地址是随时会变化的(ephemeral)。如果客户端硬编码了某个 Pod 的 IP,系统一旦发生故障迁移就会瘫痪。

为了解决这个问题,K8s 引入了 Label/Selector 和 Service。

1# Pod 的逻辑身份
2metadata:
3  labels:
4    app: inference
5    model: alpha

Service 和其他内部控制器,不再依赖诸如 pod-1837 这样的具体实例,而是只认准 selector: app=inference 这一逻辑身份(Logical Identity)。

只要新创建的 Pod 依然带有这个标签,流量就会自动路由过去。依赖逻辑身份,而不是依赖某个具体的物理实例,这是 K8s 中大量组件松耦合(loose coupling)能够成立的基础。

Control plane 与 Data plane 的边界
#

在深入理解了 K8s 大量使用控制循环之后,很多人(包括当年的我)会产生一个疑问:

既然 Controller 是在死循环里不断对比状态,这种操作通常是不追求极致实时的。那么 K8s 是不是不适合做对延迟极度敏感的在线业务(latency-sensitive workload)?

这里需要区分两个概念:Control plane latency 和 Data plane latency。

K8s 的 Scheduler、Controller Manager 和 API Server 属于控制面。它们确实不在已经启动并正常运行的 workload 的核心请求链路上。控制面的动作(比如节点宕机后新建一个 Pod)慢个几秒钟,通常是可以接受的。

但业务请求走的是数据面(Data plane)。真正需要谨慎对待的,是那些被放进了数据面请求必经之路(关键路径)的基础设施抽象,比如 Service 实现(kube-proxy、iptables/IPVS)、CNI 网络插件,或者是 Service Mesh 数据面代理。

K8s 完全可以承担一套可靠的 control plane,同时把在线 workload 的 data plane 保持得尽量轻薄。只要理清这两者的边界,就不会因为“控制循环慢”而全盘否定它在核心在线业务里的价值。

为什么当年做 ML Platform 必须理解 K8s
#

回到开头的问题:为什么对一个 ML Infra / ML Platform 工程师来说,理解 K8s 是很自然的需求?

因为机器学习平台的底层,每天都在面对这些问题:海量的 training jobs 和 serving jobs、异构资源(CPU、各种型号的 GPU、RDMA、NVLink)、动态的资源申请与释放、多用户资源隔离以及排队策略。

这本质上是一个复杂的规模化与调度问题: 海量 Workload + 异构 Resource + 动态 Placement + 持续 Failure。

K8s 提供的这套基础架构抽象:将业务抽象为资源需求,将集群抽象为资源池,通过 Scheduler 解决 Placement,依靠 Reconciliation 维持目标状态和故障自愈。机器学习平台基本都在这层抽象上继续往上搭。

具体的 YAML 字段过几年可能就忘了,但面向目标状态的容错设计和逻辑需求与物理资源解耦这两条,放到其他大规模分布式系统里也一样成立。

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的。因此需要将使用训练框架得到的模型,转换为能够部署到各个硬件平台上的模型。这个过程就是模型转换。

为什么一个 Python Library 既要锁依赖,又不能锁依赖

·5605 words·12 mins
起因 # 最近在团队内部重新整理 Python 项目的打包、发布和依赖管理规范,逐渐把各个工程统一收敛到: 1pyproject.toml 2uv 3uv.lock 4wheel 5src layout 6internal package registry 在这个过程中,大家交流最多的往往是一些具体的工具用法:私有源怎么配、build backend 选哪一个、uv 的命令参数怎么写。但我越梳理越发现,这些工程细节很容易盖住一个更基础的问题:

从进程视角重新理解 Docker

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