【编者的话】为了确保整体的简单性和易上手,有时 Kubernetes 会通过一些简单的抽象隐去操作背后的复杂逻辑,但作为一名有梦想的工程师,掌握其背后的真正思路是十分有必要的。本文以 Kubectl 创建 Pod 为例,向你揭露从客户端到 Kubelet 的请求的完整生命周期。 想象一下,当你想在 Kubernetes 集群部署 Nginx 时,你会执行以下命令: kubectl run nginx --image=nginx --replicas=3 几秒后,你将看到三个 Nginx Pod 分布在集群 Worker 节点上。这相当神奇,但它背后究竟发生了什么? Kubernetes 最为人称道的地方是,它通过用户友好的 API 处理跨基础架构的工作负载部署,通过简单的抽象隐藏其背后的复杂性。但是,为了充分理解它为我们提供的价值,我们需要理解它的原理。 本文将带领你充分了解从客户端到 Kubelet 请求的完整生命周期,并在必要时通过源代码解释它到底是什么。如果你想和更多 Kubernetes 技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态。 Kubectl验证和生成器首先,当我们按下回车执行命令后,Kubectl 会执行客户端验证,以确保非法请求(如创建不支持的资源或使用格式错误的镜像名称)快速失败,并不会发送给 kube-apiserver——即通过减少不必要的负载来提高系统性能。验证通过后, Kubectl 开始封装它将发送给 kube-apiserver 的 HTTP 请求。在 Kubernetes 中,访问或更改状态的所有尝试都通过 kube-apiserver 进行,后者又与 etcd 进行通信。Kubectl 客户端也不例外。为了构造 HTTP 请求, Kubectl 使用生成器(generators),这是一种负责序列化的抽象。 你可能没有注意到,通过执行 kubectl run,除了运行 Deployment,我们还能利用指定参数 --generator 来部署其他工作负载。 如果没有指定 --generator 参数的值, Kubectl 会自动推断资源的类型,具体如下:
Kubectl 还将确定是否需要触发其他操作,例如记录命令(用于部署或审计),或者此命令是否是 dry run。 当 Kubectl 判断出要创建一个 Deployment 后,它将使用 DeploymentV1Beta1 generator 配合我们提供的参数,生成一个运行时对象(Runtime Object)。 API Group 和版本协商这里值得指出的是, Kubernetes 使用的是一个分类为 API Group 的版本化 API。它旨在对资源进行分类,以便于推理。同时,它还为单个 API 提供了更好的版本化方案。例如,Deployment 的 API Group 为 apps,其最新版本为 v1。这也是我们为什么需要在 Deployment manifests 顶部指定 apiVersion: apps/v1 的原因。 回归正文, Kubectl 生成运行时对象之后,就开始为它查找合适的 API Group 和版本,然后组装一个知道该资源各种 REST 语义的版本化客户端。 这个发现阶段被称为版本协商(version negotiation),这时 Kubectl 会扫描 remote API 上的 /apis 路径以检索所有可能的 API Group。 由于 kube-apiserver 在 /apis 路径中公开其 OpenAPI 格式的 scheme 文档,客户端可以借此轻松找到匹配的 API。 为了提高性能, Kubectl 还将 OpenAPI scheme 缓存到 ~/.kube/cache/discovery 目录。如果要了解 API 发现的完整过程,你可以试着删除该目录并在运行 Kubectl 命令时将 -v 参数的值设为最大,然后你就可以在日志中看到所有试图找到这些 API 版本的 HTTP 请求。 最后一步才是真正地发送 HTTP 请求。一旦请求获得成功的响应, Kubectl 将会根据所需的输出格式打印 success message。 客户端验证我们在上文中没有提到的一件事是客户端身份验证(这是在发送 HTTP 请求之前处理的),现在让我们来看看。为了成功发送请求, Kubectl 需要先进行身份验证。用户凭据一般存储在 kubeconfig 文件中,但该文件可以存储在其他不同位置。为了定位到它,我们可以执行以下操作:
解析文件后,它会确定当前要使用的上下文、当前指向的集群以及当前与用户关联的所有身份验证信息。如果用户提供了额外的参数(例如 --username),则这些值优先,并将覆盖 kubeconfig 中指定的值。 一旦有了上述信息, Kubectl 就会填充客户端的配置,以便它能够适当地修饰 HTTP 请求:
kube-apiserver认证我们的请求已经发送成功,接下来呢?kube-apiserver!kube-apiserver 是客户端和系统组件用来持久化和检索集群状态的主要接口。为了执行其功能,它需要能够验证请求是否合法,这个过程被称为认证 (Authentication)。 为了验证请求,当服务器首次启动时,kube-apiserver 会查看用户提供的所有 CLI 参数,并组装合适的 authenticator 列表。 举个例子:
每次收到请求时,它都会遍历 authenticator 列表进行认证,直到成功为止:
如果认证失败,则请求失败并返回汇总的错误信息。 如果成功,则从请求中删除 Authorization 标头,并将用户信息添加到其上下文中,为之后的操作(例如授权和准入控制器)提供访问先前建立的用户身份的能力。 授权请求已发送,kube-apiserver 也已成功验证我们是谁,所以我们终于解脱了?想太多! 虽然我们证明了自己是谁,但还没证明有权执行此操作。毕竟身份(identity)和许可(permission)并不是一回事。因此,kube-apiserver 需要授权。 kube-apiserver 处理授权的方式与身份验证非常相似:基于 CLI 参数输入,汇集一系列 authorizer,这些 authorizer 将针对每个传入请求运行。如果所有 authorizer 都拒绝该请求,则该请求将导致 Forbidden 响应并不再继续。如果单个 authorizer 被批准,则请求继续。 Kubernetes v1.14 的 authorizer 实例:
Admission Controller好的,到目前为止,我们已经过认证并获得了 kube-apiserver 的授权。那接下来呢?从 kube-apiserver 的角度来看,它已经验证了我们的身份并授权我们执行后续操作,但对于 Kubernetes,系统的其他组件对此还有不少疑义,所以 Admission Controller 该闪亮登场了。 虽然认证的重点在于证实用户是否具有权限,但是 Admission Controllers 仍会拦截该请求,以确保它符合集群更广泛的期望和规则。它们是对象持久化到 etcd 之前的最后一个堡垒,因此它们封装了剩余的系统检查以确保操作不会产生意外或负面结果。 Admission Controller 的工作方式类似于 Authentication 和 Authorization,但有一个区别:如果单个 Admission Controller 失败,则整个链断开,请求将失败。 Admission Controller 设计的真正优势在于它致力于提升可扩展性。每个控制器都作为插件存储在 plugin/pkg/admission 目录中,最后编译进 kube-apiserver 二进制文件。 Kubernetes 目前提供十多种 Admission Controller,此处建议阅读文档:https://cs.kubernetes. ... lers/ etcd到目前为止, Kubernetes 已经完全审查了传入的请求,并允许它继续往下走。在下一步中,kube-apiserver 将反序列化 HTTP 请求,构造运行时对象(有点像 Kubectl generator 的逆过程),并将它们持久化到 etcd。这里我们稍微分析一下。kube-apiserver 是怎么知道在接受我们的请求时该怎么做的呢? 在提供任何请求之前,kube-apiserver 会发生一系列非常复杂的步骤。让我们从第一次运行 kube-apiserver 二进制文件开始:
现在,kube-apiserver 已经知道存在哪些路由及内部映射,当请求匹配时,它可以调用相应的处理程序和存储程序。这是非常完美的设计模式。让我们假设 HTTP 请求已经被 kube-apiserver 收到了:
这么多步骤,能够坚持走到这里是非常了不起的!同时,apiserver 实际上也做了很多工作。总结一下:我们部署的 Deployment 现在存在于 etcd 中,但仍没有看到它真正地 work…… 注:在 Kubernetes v1.14 之前,这往后还有 Initializer 的步骤,该步骤在 v1.14 被 webhook admission 取代。 控制循环Deployment Controller截至目前,我们的 Deployment 已经存储于 etcd 中,并且所有的初始化逻辑都已完成。接下来的阶段将涉及 Deployment 所依赖的资源拓扑结构。在 Kubernetes, Deployment 实际上只是 ReplicaSet 的集合,而 ReplicaSet 是 Pod 的集合。那么 Kubernetes 如何从一个 HTTP 请求创建这个层次结构呢?这就不得不提 Kubernetes 的内置控制器(Controller)。 Kubernetes 系统中使用了大量的 Controller, Controller 是一个用于将系统状态从当前状态调谐到期望状态的异步脚本。所有内置的 Controller 都通过组件 kube-controller-manager 并行运行,每种 Controller 都负责一种具体的控制流程。 首先,我们介绍一下 Deployment Controller: 将 Deployment 存储到 etcd 后,我们可以通过 kube-apiserver 使其可见。当这个新资源可用时, Deployment controller 会检测到它,它的工作是监听 Deployment 的更改。在我们的例子中, Controller 通过注册创建事件的回调函数(更多相关信息,参见下文)。 当我们的 Deployment 首次可用时,将执行此回调函数,并将该对象添加到内部工作队列(internal work queue)。 当它处理我们的 Deployment 对象时,控制器将检查我们的 Deployment 并意识到没有与之关联的 ReplicaSet 或 Pod。 它通过使用标签选择器(label selectors)查询 kube-apiserver 来实现此功能。有趣的是,这个同步过程是状态不可知的。另外,它以相同的方式调谐新对象和已存在的对象。 在意识到没有与其关联的 ReplicaSet 或 Pod 后,Deployment Controller 就会开始执行弹性伸缩流程(scaling process)。它通过推出(如创建)一个 ReplicaSet, 为其分配 label selector 并将其版本号设置为 1。 ReplicaSet 的 PodSpec 字段是从 Deployment 的 manifest 以及其他相关元数据中复制而来。有时 Deployment 在此之后也需要更新(例如,如果设置了 process deadline)。 当完成以上步骤之后,该 Deployment 的 status 就会被更新,然后重新进入与之前相同的循环,等待 Deployment 与期望的状态相匹配。由于 Deployment Controller 只关心 ReplicaSet, 因此需要 ReplicaSet Controller 继续调谐过程。 ReplicaSet Controller在上一步中,Deployment 控制器创建了属于该 Deployment 的第一个 ReplicaSet, 但仍然没有创建 Pod。所以这里我们要引入一个新东西:ReplicaSet 控制器!ReplicaSet 控制器的作用是监视 ReplicaSet 及其相关资源 Pod 的生命周期。与大多数其它控制器一样,它通过触发某些事件的处理程序来实现目标。 当创建 ReplicaSet 时(由 Deployment 控制器创建),ReplicaSet 控制器会检查新 ReplicaSet 的状态,并意识到现有状态与期望状态之间存在偏差。然后,它会尝试通过调整 Pod 的副本数来调谐这种状态。 Pod 的创建也是批量进行的,从数量 SlowStartInitialBatchSize 开始,然后在每次成功的迭代中以一种 slow start 操作加倍。这样做的目的是在大量 Pod 启动失败时(如由于资源配额),可以减轻 kube-apiserver 被大量不必要的 HTTP 请求吞没的风险。 Kubernetes 通过 Owner References (子资源的某个字段中引用其父资源的 ID) 来执行严格的资源对象层级结构。这确保了 Controller 管理的资源被删除(级联删除)时,子资源就会被垃圾收集器删除。同时,它还为父资源提供了一种有效的方式来避免竞争同一个子资源(想象两对父母认为他们拥有同一个孩子的场景)。 Owner References 的另一个好处是,它是有状态的。如果重启任何的 Controller,那么由于资源对象的拓扑关系与 Controller 无关,该重启时间不会影响到系统的稳定运行。这种对资源隔离的重视也体现在 Controller 本身的设计中:Controller 不能对自己没有明确拥有的资源进行操作,它们之间互不干涉,互不共享。 有时系统中也会出现孤儿 (orphaned) 资源,通常由以下两种途径产生:
当发生这种情况时, Controller 将会确保孤儿资源拥有新的 Owner。多个父资源可以相互竞争同一个孤儿资源,但只有一个会成功(其他父资源会收到一个验证错误)。 Informers你可能已经注意到,有些 Controller(例如 RBAC 授权器或 Deployment Controller)需要检索集群状态然后才能正常运行。以 RBAC 授权器为例,当请求进入时,授权器会将用户的初始状态缓存下来供以后使用,然后用它来检索与 etcd 中的用户关联的所有角色(Role)和角色绑定(RoleBinding)。 那么 Controller 是如何访问和修改这些资源对象的呢?答案是引入 Informer。 Infomer 是一种模式,它允许 Controller 订阅存储事件并列出它们感兴趣的资源。除了提供一个很好的工作抽象,它还需要处理很多细节,如缓存。通过使用这种设计,它还允许控制器以线程安全的方式进行交互,而不必担心线程冲突。 有关 Informer 的更多信息,可深入阅读:http://thub. ... tores Scheduler当所有的 Controller 正常运行后,etcd 中就会保存一个 Deployment、一个 ReplicaSet 和 三个 Pod, 并且可以通过 kube-apiserver 查看到。然而,这些 Pod 还处于 Pending 状态,因为它们还没有被调度到集群中合适的 Node 上。最终解决这个问题的 Controller 是 Scheduler。Scheduler 作为一个独立的组件运行在集群控制平面上,工作方式与其他 Controller 相同:监听事件并调谐状态。 具体来说, Scheduler 的作用是过滤 PodSpec 中 NodeName 字段为空的 Pod 并尝试将其调度到合适的节点。 为了找到合适的节点, Scheduler 会使用特定的算法,默认调度算法工作流程如下:
一旦算法找到了合适的节点, Scheduler 就会创建一个 Binding 对象,该对象的 Name 和 Uid 与 Pod 相匹配,并且其 ObjectReference 字段包含所选节点的名称,然后通过发送 POST 请求给 apiserver。 当 kube-apiserver 接收到此 Binding 对象时,注册表会将该对象反序列化(registry deserializes)并更新 Pod 资源中的以下字段:
一旦 Scheduler 将 Pod 调度到某个节点上,该节点的 Kubelet 就会接管该 Pod 并开始部署。 附注:自定义调度器:有趣的是预测和优先级函数 (predicates and priority functions) 都是可扩展的,可以使用 --policy-config-file 标志来定义。这引入了一定程度的灵活性。管理员还可以在独立部署中运行自定义调度器(具有自定义处理逻辑的控制器)。如果 PodSpec 中包含 schedulerName,Kubernetes 会将该 pod 的调度移交给使用该名称注册的调度器。 KubeletPod Sync截至目前,所有的 Controller 都完成了工作,让我们来总结一下:
然而,到目前为止,所有的状态变化仅仅只是针对保存在 etcd 中的资源对象,接下来的步骤涉及到在 Worker 节点之间运行具体的容器,这是分布式系统 Kubernetes 的关键因素。这些事情都是由 Kubelet 完成的。 在 Kubernetes 集群中,每个 Node 节点上都会启动一个 Kubelet 服务进程,该进程用于处理 Scheduler 下发到本节点的任务,管理 Pod 的生命周期。这意味着它将处理 Pod 与 Container Runtime 之间所有的转换逻辑,包括挂载卷、容器日志、垃圾回收以及其他重要事件。 一个有用的方法,你可以把 Kubelet 当成一种特殊的 Controller,它每隔 20 秒(可以自定义)向 kube-apiserver 查询 Pod,过滤 NodeName 与自身所在节点匹配的 Pod 列表。 一旦获取到了这个列表,它就会通过与自己的内部缓存进行比较来检测差异,如果有差异,就开始同步 Pod 列表。我们来看看同步过程是什么样的:
CRI 和 pause 容器到了这个阶段,大量的初始化工作都已经完成,容器已经准备好开始启动了,而容器是由容器运行时(例如 Docker) 启动的。为了更具可扩展性, Kubelet 使用 CRI 来与具体的容器运行时进行交互。简而言之, CRI 提供了 Kubelet 和特定容器运行时实现之间的抽象。通过 protocol buffers(一种更快的 JSON) 和 gRPC API(一种非常适合执行 Kubernetes 操作的API)进行通信。 这是一个非常酷的想法,因为通过在 Kubelet 和容器运行时之间使用已定义的接口约定,容器编排的实际实现细节变得无关紧要。重要的是接口约定,这允许以最小的开销添加新的容器运行时,因为没有核心 Kubernetes 代码需要更改! 回到部署我们的容器,当一个 Pod 首次启动时, Kubelet 调用 RunPodSandbox 远程过程命令。沙箱是描述一组容器的 CRI 术语,在 Kubernetes 中对应的是 Pod。这个术语是故意模糊的,因此其他不使用容器的运行时,不会失去其意义(想象一个基于 hypervisor 的运行时,沙箱可能指的是 VM)。 在我们的例子中,我们使用的是 Docker,所以在此容器运行时中,创建沙箱涉及创建 pause 容器。 pause 容器像 Pod 中的所有其他容器的父级一样,因为它承载了工作负载容器最终将使用的许多 Pod 级资源。这些“资源”是 Linux Namespaces(IPC、Network、PID)。 pause 容器提供了一种托管所有这些 Namespaces 的方法,并允许子容器共享它们。成为同一 Network Namespace 一部分的好处是同一个 Pod 中的容器可以使用 localhost 相互访问。 pause 容器的第二个好处与 PID Namespace 有关。在这些 Namespace 中,进程形成一个分层树,顶部的“init” 进程负责“收获”僵尸进程。更多信息,请深入阅读:https://www./en/al ... ainer 创建 pause 容器后,将开始检查磁盘状态然后启动主容器。 CNI 和 Pod 网络现在,我们的 Pod 有了基本的骨架:一个 pause 容器,它托管所有 Namespaces 以允许 Pod 间通信。但容器的网络如何运作以及建立的?当 Kubelet 为 Pod 设置网络时,它会将任务委托给 CNI(Container Network Interface)插件,其运行方式与 Container Runtime Interface 类似。简而言之,CNI 是一种抽象,允许不同的网络提供商对容器使用不同的网络实现。 Kubelet 通过 stdin 将 JSON 数据(配置文件位于 /etc/cni/net.d 中)传输到相关的 CNI 二进制文件(位于 /opt/cni/bin) 中与之交互。下面是 JSON 配置文件的一个简单示例 : { CNI 插件还可以通过 CNI_ARGS 环境变量为 Pod 指定其他的元数据,包括 Pod Name 和 Namespace。 接下来会发生什么取决于 CNI 插件,这里我们以 bridge CNI 插件为例:
跨主机容器网络到目前为止,我们已经描述了容器如何与宿主机进行通信,但跨主机之间的容器如何通信呢?通常情况下, Kubernetes 使用 Overlay 网络来进行跨主机容器通信,这是一种动态同步多个主机间路由的方法。一个较常用的 Overlay 网络插件是 flannel,它提供了跨节点的三层网络。 Flannel 不会管容器与宿主机之间的通信(这是 CNI 插件的职责),但它对主机间的流量传输负责。为此,它为主机选择一个子网并将其注册到 etcd,然后保留集群路由的本地表示,并将传出的数据包封装在 UDP 数据报中,确保它到达正确的主机。 更多信息,请深入阅读:https://github.com/coreos/flannel 启动容器所有的网络配置都已完成。还剩什么?真正启动工作负载容器!一旦 sanbox 完成初始化并处于 active 状态, Kubelet 将开始为其创建容器。首先启动 PodSpec 中定义的 Init Container,然后再启动主容器,具体过程如下:
Wrap-up最后的最后,现在我们的集群上应该会运行三个容器,分布在一个或多个工作节点上。所有的网络、数据卷和秘钥都由 Kubelet 填充,并通过 CRI 接口添加到容器中,配置成功!原文地址:https://github.com/jamiehannaf ... n-k8s 源代码版译文地址(强烈建议阅读):https://github.com/bbbmj/what-happens-when-k8s 原文链接:https://mp.weixin.qq.com/s/KqRt1eav5mNaut5PK45D-Q 【编者的话】为了确保整体的简单性和易上手,有时 Kubernetes 会通过一些简单的抽象隐去操作背后的复杂逻辑,但作为一名有梦想的工程师,掌握其背后的真正思路是十分有必要的。本文以 Kubectl 创建 Pod 为例,向你揭露从客户端到 Kubelet 的请... |
|