当我执行 kubectl create 时发生了什么[译]

当我执行 kubectl create 时发生了什么[译]

翻译自 What happens when … Kubernetes edition!。我认为这篇文章写得生动有趣,且在关键位置都给出了有价值的链接,引导进一步的阅读学习,让我有了重读并翻译的冲动。

如果我希望往 Kubernetes 集群当中部署 nginx,我大概率会在命令行输入下面这样的命令并敲下回车键:

1
kubectl create deployment nginx --image=nginx --replicas=3

几秒之后,我应该能看到三个 nginx 的 pod 散布在集群的工作节点上。这很神奇!但这个过程背后究竟发生了什么?

关于 Kubernetes 的惊人的特点是,它通过用户友好的 API 处理工作负载的部署。其中的复杂性被简单的抽象隐藏起来。但为了充分理解它所提供的价值,了解其内部工作原理也是很有用的。本指南将引导您了解从客户端 kubectl 到 kubelet 的请求的完整生命周期,并在必要时链接到源代码(或者相关文档和博客)来进一步说明正在发生的事情。

这是一份不断修订的文档。如果您发现可以改进或重写的地方,欢迎贡献!

kubectl

校验和生成器

好的,我们开始吧。我们刚刚在终端里敲击了回车键,现在会发生什么?

首先 kubectl 会进行客户端校验,该过程保证了应当出错的请求尽早地出错,而不是在发送给 kube-apiserver 之后再返回错误,例如创建了一个不支持的资源或者一个异常的镜像名。这个校验过程通过减少不必要的负载提升了系统的性能。

校验完成后,kubectl 开始组装将要发送到 kube-apiserver 的 HTTP 请求。任何希望访问或者改变 Kubernetes 系统状态的请求都会经过 API server 并最终与 etcd 进行交互。kubectl 也一样,为了构建这样的 HTTP 请求,kubectl 使用了名为生成器的抽象来完成序列化过程。

可能不太明显的是,我们实际上可以使用 kubectl run 指定多个资源类型,而不仅仅是 Deployments。为了实现这一点,如果没有使用 --generator 标志显式指定生成器名称,kubectl 将推断出资源的类型。

例如,具有 --restart-policy=Always 标志的资源被视为 Deployments,而具有 --restart-policy=Never 的资源被视为 Pods。kubectl 还会确定是否需要触发其他操作,例如记录命令(用于滚动更新或审计),或者该命令只是通过 --dry-run 标志来指定的模拟运行。

在意识到我们想要创建一个 Deployment 之后,kubectl 将使用 DeploymentAppsV1 生成器根据我们提供的参数生成一个运行时对象。“运行时对象”是一个通用术语,用于表示资源。

API 组别和版本的协商

在继续之前需要指出的是,Kubernetes 使用 “API 组” 这样的版本化 API。API 组的目的是将相似的资源进行分类,以求更容易理解。它还提供了一个比单一的单体 API 更好的选择。Deployment 的 API 组名为 apps,最新的版本是 v1。这就是为什么在 Deployment 的清单的顶部需要使用 type apiVersion: apps/v1

无论如何,在 kubectl 生成运行时对象之后,它开始查找适当的 API 组和版本,并组装一个版本化的客户端,该客户端知道资源的各种 REST 语义。这个发现阶段被称为版本协商,其中 kubectl 扫描远程 API 上的 /apis 路径,取回所有可能的 API 组。由于 kube-apiserver 在此路径上以 OpenAPI 格式公开其模式文档,所以客户端可以轻松完成 API 发现。

为了提高性能,kubectl 还将 OpenAPI 模式缓存~/.kube/cache/discovery 目录中。如果你想看到这个 API 发现的过程,可以尝试删除该目录,并运行一个带有最大值 -v 标志的命令,之后可以看到所有试图找到这些 API 版本的 HTTP 请求。有很多!

最后一步是实际发送 HTTP 请求。一旦发送请求并收到成功的响应,kubectl 将根据预期的输出格式打印出成功消息。

客户端认证

在之前的步骤中我们没有提到客户端认证,这是在发送 HTTP 请求之前处理的,所以现在让我们来看看这个过程。

为了成功发送请求,kubectl 需要进行身份验证。用户凭据基本上都存储在位于磁盘上的 kubeconfig 文件中,但该文件可以存储在不同的位置。为了定位它,kubectl 执行以下操作:

  • 如果提供了 --kubeconfig 标志,则使用该文件。
  • 如果定义了 $KUBECONFIG 环境变量,则使用该变量。
  • 否则,查找推荐的主目录,如 ~/.kube,并使用找到的第一个文件。

解析文件后,kubectl 确定要使用的当前上下文、要指向的当前集群以及与当前用户关联的任何身份验证信息。如果用户提供了特定标志的值(例如 --username),则优先使用这些值,并将覆盖 kubeconfig 中指定的值。一旦获得这些信息,kubectl 将补全客户端的配置,以便适当地组装 HTTP 请求:

  • x509 证书使用 tls.TLSConfig 发送,这也包括根 CA
  • Bearer token 放置在 “Authorization” HTTP 头中发送
  • 用户名和密码通过 HTTP 基本身份验证发送
  • OpenID 身份验证过程由用户在之前手动处理,会生成一个像 Bearer token 一样发送的 token

kube-apiserver

身份验证

我们的请求已经发送出去了,太棒了!接下来呢?该轮到 kube-apiserver 出场了。正如我们之前提到的,kube-apiserver 是客户端和系统组件用来持久化和获取集群状态的主要接口。为了开展其工作,它需要能够验证请求者的身份。这个过程被称为身份验证。

kube-apiserver 如何对请求进行身份验证呢?当服务器首次启动时,它会查看用户提供的所有 CLI 标志,并组装一个合适的身份验证器列表。举个例子:如果传入了 --client-ca-file 参数,它会添加 x509 身份验证器;如果看到 --token-auth-file 参数,它会将 token 身份验证器添加到列表中。每次接收到请求时,它会通过身份验证器链验证请求,直到有一个成功为止

  • x509 验证处理程序将验证 HTTP 请求是否使用由 CA 根证书签名的 TLS 密钥进行加密。
  • bearer token 验证处理程序将验证 HTTP 请求中提供的令牌(在 HTTP Header 中 Authorization 字段中指定)是否存在于 --token-auth-file 参数指定的磁盘文件中。
  • basic auth 验证处理程序将类似地确保 HTTP 请求的基本身份验证凭据与其自身的本地状态匹配。

如果每个身份验证器都失败,该请求将失败,并返回一个聚合错误。如果身份验证成功,Header 中 Authorization 字段将被删除,并将用户信息添加到其上下文中。这使得之后的步骤(例如鉴权和准入)能够访问先前确立的用户身份。

请求鉴权

好的,请求已经发送出去,kube-apiserver 已成功验证我们的身份。松了一口气!然而,我们还没有结束。我们可能是我们自己说的那个身份,但我们是否有权限执行此操作呢?毕竟,身份和权限是不同的。为了让请求继续执行,kube-apiserver 需要对请求进行鉴权。

kube-apiserver 处理鉴权的方式与身份验证非常相似:根据输入的标志,它将组装一个鉴权器链,针对每个传入的请求依次运行。如果所有鉴权器都拒绝请求,请求将导致 Forbidden 的响应,并且不再继续处理改请求。如果单个鉴权器批准请求,请求将继续进行。

Kubernetes v1.8 提供的一些鉴权器示例包括:

  • webhook,与集群外的 HTTP(S) 服务进行交互;
  • ABAC,强制执行在静态文件中定义的策略;
  • RBAC,强制执行由管理员作为 k8s 资源添加的 RBAC 角色;
  • Node,确保节点客户端(即 kubelet)只能访问托管在自身上的资源。

可以通过查看每个鉴权器的 Authorize 方法,了解它们的工作原理。

准入控制

好的,到目前为止,我们已经通过了 kube-apiserver 的身份验证和请求鉴权。那接下来呢?从 kube-apiserver 的角度来看,它相信我们是谁并允许请求继续执行,但在 Kubernetes 中,系统的其他部分对于什么应该和不应该发生有严格的要求。这时候准入控制器就开始发挥作用了。

虽然鉴权的重点是判断用户是否有权限,但准入控制器拦截请求以确保其符合集群的更大范围的预期和规则。它们是对象持久化到 etcd 之前的最后一道控制屏障,因此它们封装了剩余的系统检查,以确保操作不会产生意外或负面的结果。

准入控制器的工作方式类似于验证器和鉴权器,但有一个区别:与验证器和授权器链不同,如果单个准入控制器校验失败,整个链条将中断,请求将失败。

准入控制器设计的真正精妙之处在于其专注于促进可扩展性。每个控制器都存储为 plugin/pkg/admission 目录中的插件,并且被设计为满足一个小接口。然后,每个控制器都被编译到主要的 kubernetes 二进制文件中。

准入控制器通常按照功能分为资源管理、安全性、默认设置和引用一致性几类。以下是一些负责资源管理的准入控制器的示例:

InitialResources:根据过去的使用情况为容器的资源设置默认限制。
LimitRanger:为容器的请求和限制设置默认值,或对某些资源配置上限(例如内存不超过 2GB,默认为 512MB)。
ResourceQuota:在命名空间内统计或拒绝分配一定数量的对象(pod、rc、service 负载均衡器)或总消耗的资源(CPU、内存、磁盘)。

etcd

到目前为止,Kubernetes 已经完全检查了传入的请求,并且允许其继续执行。接下来,kube-apiserver 对 HTTP 请求进行反序列化,从中构建运行时对象(类似于 kubectl 的生成器的逆过程),并将它们持久化到数据存储中。让我们来详细解析一下这个过程。

kube-apiserver 怎么知道接受我们的请求时该做什么呢?在任何请求被处理之前都有一系列复杂的步骤。让我们从起点开始,也就是二进制文件首次运行时:

  1. kube-apiserver 二进制文件运行时,它创建一个服务器链,用于支持 apiserver 的聚合。这只是支持多个 apiserver 的一种方式,我们不需要担心这个。
  2. 在这个过程中,会创建一个通用的 apiserver 作为默认实现。
  3. 生成的 OpenAPI 模式补充了 apiserver 的配置
  4. kube-apiserver 然后遍历模式中指定的所有 API 组,并为每个 API 组配置一个存储供应器作为通用的存储抽象,kube-apiserver 在访问或修改资源状态时需要与其进行交互。
  5. 对于每个 API 组,它还会遍历每个组版本,并为每个 HTTP 路由安装 REST 映射。这让 kube-apiserver 能够正常映射请求,并在找到匹配项后把请求代理给正确的逻辑处理。
  6. 对于我们的特定用例,会注册一个 POST 处理程序,该处理程序将进一步代理给一个创建资源的处理程序

截至目前,kube-apiserver 已经完全了解了存在的路由和内部映射,在请求到来时能将其转发到正确的处理程序和存储供应器。现在设想我们的 HTTP 请求已经到达:

  1. 如果处理程序链能够将请求与一组模式匹配(即我们注册的路由),它将把请求分发到为该路由注册的专用处理程序。否则,它将回退到基于路径的处理程序(例如调用 /apis 时的情况)。如果没有为该路径注册处理程序,则会调用一个未找到的处理程序,导致返回 404 错误。
  2. 幸运的是,我们有一个名为 createHandler 的注册路由!它的工作原理是什么呢?首先,它会解码 HTTP 请求并执行基本验证,例如确保提供的 JSON 与我们对版本化 API 资源的预期相符。
  3. 进行审计和最终的准入
  4. 通过代理给存储供应器将资源保存到 etcd 中。通常,etcd 键的形式为 <namespace>/<name>,但这也是可配置的。
  5. 捕获任何创建时的错误,最后存储供应器执行 get 调用以确保对象实际上已创建。然后,如果需要进行其他的最终处理,它会调用任何创建后(Post-create)处理程序和装饰器。
  6. 构建并返回 HTTP 响应。

步骤很多!通过追溯这些步骤,我们能看到 apiserver 实际上做了多少工作。所以总结一下:我们的 Deployment 资源现在存在于 etcd 中。但是其中还有一些尚未完成的流程,所以目前我们还没办法看到它…

初始化器

在将对象持久化到数据存储中后,只有在一系列初始化器运行完成之后,该对象才会对 apiserver 或调度程序完全可见。初始化器是与资源类型相关联的控制器,在资源对外界可见之前对资源执行相关逻辑操作。如果某个资源类型没有注册任何初始化器,则会跳过此初始化步骤,资源会立即对外可见。

正如许多优质的博客文章介绍的,这是一个强大的功能,因为它让我们能够执行通用的引导操作。例如:

  • 将代理边车容器注入到公开端口 80 的 Pod 中,或者具有特定注释的 Pod 中。
  • 向特定命名空间中的所有 Pod 注入带有测试证书的卷。
  • 如果一个 Secret 的长度小于 20 个字符(例如密码),阻止其创建。

initializerConfiguration 对象允许我们声明哪些初始化器应该针对特定的资源类型运行。想象一下,如果我们希望在每次创建 Pod 时运行自定义的初始化器,我们可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: admissionregistration.k8s.io/v1alpha1
kind: InitializerConfiguration
metadata:
name: custom-pod-initializer
initializers:
- name: podimage.example.com
rules:
- apiGroups:
- ""
apiVersions:
- v1
resources:
- pods

创建完这个配置后,它会将 custom-pod-initializer 添加到每个 Pod 的 metadata.initializers.pending 字段中。初始化器控制器会定期扫描新的 Pods。当初始化器检测到一个 Pod 的 pending 字段中有自己的名称时,它会执行相应的逻辑。完成逻辑处理后,它会从 pending 列表中移除自己的名称。只有列表中第一个名称的初始化器才能对资源进行操作。当所有的初始化器完成逻辑处理并且 pending 字段为空时,该对象将被认为已经初始化。

细心的你可能已经发现了一个潜在的问题。如果资源在 kube-apiserver 中不可见,用户自定义的控制器如何处理这些资源呢?为了解决这个问题,kube-apiserver 提供了一个 ?includeUninitialized 查询参数,它返回所有对象,包括未初始化的对象。

控制循环

Deployments 控制器

现在我们的 Deployment 记录已存储在 etcd 中,并且任何初始化逻辑都已完成。接下来的步骤涉及设置 Kubernetes 所依赖的资源拓扑结构。我们可以这样想,一个 Deployment 实际上只是一组 ReplicaSet,而一个 ReplicaSet 是一组 Pod。那么 Kubernetes 是如何通过一个 HTTP 请求来创建这样的多层级结构的呢?这其实是 Kubernetes 内置的控制器的作用。

Kubernetes 在整个系统中广泛地使用“控制器”。控制器是一个异步逻辑,用来将 Kubernetes 系统的当前状态与期望的状态进行协调(reconcile)。每个控制器都有自己的任务,并和 kube-controller-manager 组件一起并行运行。让我们先介绍接管工作的第一个控制器,即 Deployment 的控制器。

在 Deployment 的记录存储到 etcd 并初始化后,kube-apiserver 使其对外可见。当这个新的资源可用时,它会被 Deployment 控制器检测到,Deployment 控制器的工作是监听对 Deployment 记录的变动。在我们的例子里,控制器通过 informer 为资源新建的事件注册了一个特定的回调函数(有关此内容的更多信息,请参见下文)。

当我们的 Deployment 首次可用时,这个回调处理程序将被执行,并首先将对象添加到内部工作队列中。当控制器处理我们的对象时,它通过标签选择器查询 kube-apiserver 检查出我们的 Deployment 没有与之关联的 ReplicaSet 或 Pod 记录。有趣的是,这个同步过程是与状态无关的:新的记录和老的记录协调方式相同。

在发现没有对应的 ReplicaSet 或者 Pod 记录后,它会通过一个弹性进程创建一个 ReplicaSet 资源,为其分配一个标签选择器,并给它分配版本号为 1。ReplicaSet 的 PodSpec 是从 Deployment 的配置清单中复制过来的,当然也包括其他相关的元数据。在此之后,有时还需要更新 Deployment 记录(例如,如果设置了处理截止时间)。

之后会更新 Deployment 的状态,并重新进入相同的协调循环,直到 Deployment 达到期望的完成状态。由于 Deployment 控制器只关注创建 ReplicaSet,因此这个协调阶段需要由下一个控制器继续进行,也就是 ReplicaSet 控制器。

ReplicaSets 控制器

在前面的步骤中,Deployment 控制器为我们的 Deployment 创建了第一个 ReplicaSet,但我们还没有看到 Pod。这之后 ReplicaSet 控制器将发挥作用!它的任务是监听 ReplicaSet 及其依赖资源(Pod)的生命周期。与大多数其他控制器一样,它通过在特定事件上触发处理程序来实现这个功能。

首先我们来看资源创建事件。当创建了一个 ReplicaSet(由部署控制器负责)时,ReplicaSet 控制器会检查新 ReplicaSet 的状态,并发现当前状态与预期状态之间存在的差异。然后它尝试通过增加 ReplicaSet 的 Pod 数量来调解这个状态。它非常谨慎地创建这些 Pod,确保 ReplicaSet 的突发计数(它从其父级部署继承的)始终保持匹配。

Pod 的也是批量创建的,从 SlowStartInitialBatchSize 开始,每次成功创建后扩大一倍,以一种类似于“慢启动”的方式进行。这样做的目的是减轻同时出现大量 Pod 启动失败时(例如,由于资源配额不足)引发 kube-apiserver 负载过高,同时能够减少不必要的 HTTP 请求。如果组件会失败报错,我们最好以对其他系统组件的影响最小的方式来优雅地失败!

Kubernetes 通过 Owner References(这是子资源中的一个字段,用来引用其父级的 UID)来保证对象的层级结构。这不仅确保一旦由控制器管理的资源被删除,子资源就会被垃圾回收,还为父资源提供了一种有效的方式以避免它们争夺子资源(设想一下两个父级认为它们拥有同一个子资源的情况!)。

Owner Reference 设计的另一个微妙好处是它是有状态的:如果任何控制器重新启动,由于资源拓扑结构独立于控制器,它的宕机状态不会影响更多的组件。这种对隔离的关注也包含在控制器本身的设计中:它们不应该管理它们没有明确声明拥有的资源。控制器应该在所有权的声明中进行选择,并且不干扰、不共享。

无论如何,回到 Owner Reference!有时系统中会出现“孤立”(orphaned)的资源,该情况通常由以下原因导致:

  • 删除了父资源,但没有删除其子资源。
  • 垃圾回收策略禁止删除子资源。

发生这种情况时,控制器将确保孤立资源被一个新的父级资源接管。多个父级资源可以竞争接管子资源,但只有一个会成功(其余的父级资源将收到验证错误)。

Informers

正如你可能已经注意到的那样,一些控制器(例如如 RBAC 鉴权器或 Deployment 控制器)需要查询集群状态以正常工作。以 RBAC 鉴权器为例,我们知道当请求到达时,验证器将保存用户状态的初始信息以备后用。然后,RBAC 鉴权器将使用改信息来查询用户在 etcd 中关联的所有角色以及角色绑定。控制器应该如何访问和修改这些资源?在 Kubernetes 中往往通过 informer 来解决。

informer 是一种允许控制器通过简单的订阅存储事件来获取它们关注的资源的设计范式。除了提供良好的抽象外,它还处理了许多细节,例如缓存(缓存很重要,因为它减少了与 kube-apiserver 的不必要的连接,并减少了服务器和控制器端重复序列化的开销)。通过该设计,控制器还可以用线程安全的方式进行交互,而不必担心干扰其他任何人。

有关 informer 在控制器中的工作方式的细节,可以参阅这篇博客

调度器

在上述所有控制器运行完成后,我们在 etcd 中存储了一个 Deployment、一个 ReplicaSet 和三个 Pod,并且可以通过 kube-apiserver 查询到它们。然而我们的 Pod 仍处在 Pending 状态,因为它们尚未被调度到节点上。解决这个问题的最后一个控制器是调度器(Scheduler)。

调度器作为控制平面的一个独立组件运行,并且以与其他控制器相同的方式工作:它监听事件并尝试调解状态。在这种情况下,调度器筛选出 PodSpec 中 NodeName 字段为空的 Pod,并尝试寻找一个适合该 Pod 的节点。

为了找到一个适合的节点,调度器使用特定的调度算法。默认调度算法的工作方式如下:

  1. 当调度器启动时,会注册一系列默认的谓词(Predicates)。这些谓词实际上是函数,这些函数根据节点是否适合托管 Pod 来进行过滤。例如,如果 PodSpec 明确要求一定的 CPU 或 RAM 资源,容量不足而无法满足这些要求的节点将被排除在 Pod 之外(资源容量计算为总容量减去当前运行容器的资源请求总和)。

  2. 一旦选择了合适的节点,会对过滤后的节点运行一系列优先级函数,以对它们的适合程度进行排序。例如,为了在系统中分散工作负载,调度器会倾向于资源富裕的节点(因为这表示较少的工作负载正在运行)。在运行这些函数时,它会为每个节点分配一个数值等级。然后选择排名最高的节点进行调度。

调度算法找到节点后,调度器会创建一个绑定对象,其 Name 和 UID 与 Pod 匹配,其 ObjectReference 字段包含所选节点的名称,然后通过 POST 请求将其发送到 apiserver

当 kube-apiserver 接收到此绑定对象时,注册表会反序列化对象并更新 Pod 对象上的以下字段:将 NodeName 设置为 ObjectReference 中的节点名称,添加相关的注解,并将其 PodScheduled 状态条件设置True

一旦调度器将 Pod 调度到节点上,该节点上的 kubelet 就可以开始接管并进行部署。真是令人兴奋!

提示:自定义调度器时谓词和优先级函数都是可扩展的,并且可以使用 --policy-config-file 标志进行定义。这提供了一定程度的灵活性。管理员还可以在独立的 Deployment 中运行自定义调度器(具有自定义处理逻辑的控制器)。如果 PodSpec 包含 schedulerName,Kubernetes 将把该 Pod 的调度交给已注册在该名称下的调度器。

kubelet

Pod 同步

好的,主要的控制器循环已经完成,呼!总结一下:HTTP 请求通过了身份验证、鉴权和准入控制阶段;一个 Deployment、一个 ReplicaSet 和三个 Pod 资源被持久化到了 etcd 中;一系列初始化程序已经运行;最后,每个 Pod 被调度到了一个合适的节点上。然而到目前为止我们所有推演的状态完全存在于 etcd 中。接下来的步骤涉及将状态分发到工作节点上,这是 Kubernetes 这样的分布式系统的核心目标!接下来的过程是通过一个叫做 kubelet 的组件来实现的。我们开始吧!

kubelet 是在 Kubernetes 集群的每个节点上运行的代理程序,负责管理 Pod 的生命周期等任务。这意味着它处理了从 Pod(实际上只是 Kubernetes 的一个概念)到其构建块(容器)的所有转换逻辑。它还处理与挂载卷、容器日志、垃圾回收等相关逻辑以及许多其他重要事项。

一个便于理解的方法是:可以把 kubelet 看做一个控制器!它会每隔 20 秒(可配置)从 kube-apiserver 查询 Pod,过滤出 NodeName 与该 kubelet 所在节点名称匹配的 Pod。一旦获得该 Pod 的列表,它会通过与自己的内部缓存进行比较来检测新增的 Pod,当比较存在差异时开始同步状态。我们来看看这个同步过程是什么样的:

  1. 如果正在创建 Pod(我们的 Pod 正在创建中!),kubelet 会注册一些用于在 Prometheus 中跟踪 Pod 延迟的启动指标。
  2. 然后,它生成一个 PodStatus 对象,表示 Pod 当前阶段的状态。Pod 的阶段是其生命周期中的高度总结。阶段包括 PendingRunningSucceededFailedUnknown。生成这个状态相当复杂,所以我们来详细了解一下具体发生了什么:
  • 首先,按顺序执行一系列同步处理程序 PodSyncHandlers。每个处理程序都检查 Pod 是否仍应驻留在节点上。如果它们中的任何一个决定该 Pod 不再属于该节点,Pod 的阶段将变为 PodFailed,并最终被从该节点中驱逐出去。例子包括在超过 activeDeadlineSeconds 后驱逐 Pod(在 Job 资源中常用)。
  • 接下来,根据其初始化和实际容器的状态确定 Pod 的阶段。由于我们的容器尚未启动,容器被归为等待状态。Pod 在拥有等待容器时的阶段为 Pending
  • 最后,根据容器的状态确定 Pod 的条件。由于我们的容器尚未由容器运行时创建,它将把 PodReady 条件设置为 False
  1. 生成 PodStatus 后,它将被发送给 Pod 的状态管理器,后者负责通过 apiserver 异步更新 etcd 记录。
  2. 接下来,一系列准入处理程序会确保 Pod 具有正确的安全权限。这包括校验 AppArmor 配置文件和 NO_NEW_PRIVS 等。在此阶段被拒绝的 Pod 将永远保持在 Pending 状态。
  3. 如果指定了 cgroups-per-qos 运行时标志,kubelet 将为 Pod 创建 cgroups 并应用资源参数。这是为了给 Pod 提供更好的服务质量(QoS)。
  4. 为 Pod 创建数据目录。这包括 Pod 的目录(通常为 /var/run/kubelet/pods/<podID>)、卷目录(<podDir>/volumes)和插件目录(<podDir>/plugins)。
  5. 卷管理器将绑定并等待 Spec.Volumes 中定义的所有相关卷。根据要挂载的卷的类型的不同,某些 Pod 可能需要等待更长时间(例如云存储或 NFS 卷)。
  6. 从 apiserver 中查询Spec.ImagePullSecrets 中定义的所有密钥,以便后续注入到容器中。
  7. 最后由容器运行时(CRI)来运行容器(下面将详细描述)。

CRI 和暂停容器

我们现在已经完成了大部分的配置工作,容器已经准备好启动了。负责启动容器的软件被称为容器运行时(例如 Dockerrkt )。

为了更好的扩展性,自 Kubernetes v1.5.0 以来,kubelet 一直在使用称为 CRI(Container Runtime Interface)的概念与具体的容器运行时进行交互。简而言之,CRI 提供了 kubelet 与特定运行时实现之间的抽象接口。通信通过 protocol buffers 完成(类似于更快的 JSON),并使用 gRPC API(一种非常适合执行 Kubernetes 操作的 API 类型)。这是一个非常酷的想法,因为通过使用 kubelet 和容器运行时之间的定义合约,容器编排的具体实现细节变得相对不重要,唯一重要的是合约。这使得可以用最小的开销添加新的运行时,因为我们不需要更改核心 Kubernetes 代码!

话题岔开太远了,让我们回到部署容器的过程本身。当一个 Pod 首次启动时,kubelet 调用 RunPodSandbox 远程过程调用(RPC)。沙盒 Sandbox 是 CRI 术语,用来描述一组容器,在 Kubernetes 术语中就是一个 Pod。这个术语被刻意地设计得比较模糊,以便对于其他可能实际上不使用容器的运行时(比如基于虚拟化的运行时,其中的沙盒可能是一个虚拟机)也适用。

在我们的例子中,我们使用的是 Docker。在这个运行时中,创建一个沙盒其实是创建一个暂停容器。暂停容器作为 Pod 中所有其他容器的父容器,承载了许多工作负载容器将要使用的 Pod 级资源。这些“资源”是 Linux 的命名空间(IPC、网络、PID)。如果你对 Linux 中容器的工作原理不熟悉,我们进行一个简短的复习。Linux 内核具有命名空间的概念,允许主机操作系统划分出一组专用资源(例如 CPU 或内存),并将其提供给一个进程,就好像它是世界上唯一使用这些资源的进程一样。在这里,Cgroups 也很重要,因为它们是 Linux 管理资源分配的方式(有点像监管资源使用的警察)。Docker 使用这两个内核特性来运行具有足够资源和强制隔离的进程。要了解更多信息,请查看 b0rk 的精彩文章《容器到底是什么》

暂停容器提供了一种托管所有这些命名空间并允许子容器共享它们的方式。处在同一个网络命名空间的好处是同一个 Pod 中的容器可以使用 localhost 相互引用。暂停容器的第二个角色与 PID 命名空间的工作原理有关。在这些类型的命名空间中,进程形成一个层次树,顶部的初始化进程负责“清理”已经退出的进程。要了解这是如何工作的更多信息,请查看这篇精彩的博客。在创建完暂停容器后,它会被存档到磁盘上,并启动运行。

CNI 和 Pod 通信

现在,我们的 Pod 已经有了个基本的骨架:一个承载所有命名空间以实现跨 Pod 通信的暂停容器。但是其中的网络是如何生效的,又该如何进行设置呢?

当 kubelet 为一个 Pod 设置网络时,它将任务委派给一个名为 CNI 的插件。CNI 代表容器网络插件 Container Network Interface,其工作方式类似于 Container Runtime Interface。简而言之,CNI 是一个抽象层,允许不同的网络供应程序使用不同的容器网络实现。kubelet 通过将 JSON 数据(配置文件位于 /etc/cni/net.d)经过 stdin 传送给相关的 CNI 二进制文件(位于 /opt/cni/bin)与注册好的插件进行交互。这是一个 JSON 配置的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"cniVersion": "0.3.1",
"name": "bridge",
"type": "bridge",
"bridge": "cnio0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"ranges": [[{ "subnet": "${POD_CIDR}" }]],
"routes": [{ "dst": "0.0.0.0/0" }]
}
}

它还通过环境变量 CNI_ARGS 指定了 Pod 的附加元数据,例如它们的名称和命名空间。

接下来发生的步骤取决于具体的 CNI 插件,我们先来看看网桥(bridge) CNI 插件的工作流程:

  1. 首先,网桥插件将在主机的根网络命名空间中设置一个本地 Linux 网桥(bridge),以服务于该主机上的所有容器。
  2. 然后,它将在暂停容器的网络命名空间中插入一个接口(一对 veth 的一端),并将另一端连接到网桥(bridge)上。最好将一对 veth 想象成一个大管道:一端连接到容器,另一端在根网络命名空间中,使得数据包在两者之间进行传递。
  3. 接下来,网桥插件应该为暂停容器的网卡分配一个 IP 地址并设置路由,这使得 Pod 拥有了自己的 IP 地址。IP 地址分配是委派给在 JSON 配置中指定的 IPAM 提供程序来完成的。
  • IPAM 插件与主要的网络插件类似:它们通过二进制文件调用,拥有标准化的接口。每个 IPAM 插件必须确定容器接口的 IP/子网,以及网关和路由,并将这些信息返回给主插件。最常见的 IPAM 插件称为 host-local,它从预定义的地址范围中分配 IP 地址。它将状态存储在主机的文件系统上,以确保在单个主机上 IP 地址的唯一性。
  1. 至于 DNS,kubelet 将向 CNI 插件提供内部 DNS 服务器的 IP 地址,CNI 插件将确保容器的 resolv.conf 文件设置正确。

上述步骤都完成后,CNI 插件会向 kubelet 返回 JSON 数据说明操作的结果。

跨节点通信

到目前为止,我们已经解释了容器如何连接到主机,但主机之间如何通信呢?当位于不同机器上的两个 Pod 想要通信时,就自然会涉及节点间的通信。

通常,节点间通信是通过一种称为覆盖网络(overlay networking)的概念实现的,它是一种在多个主机之间动态同步路由的方式。一个流行的覆盖网络供应者是 Flannel。安装完成后,它的核心任务是在集群中的多个节点之间提供第 3 层的 IPv4 网络。Flannel 不控制容器如何与主机进行网络连接(这是 CNI 的工作,请记住),而是控制主机之间的流量传输。为此,它为主机分配一个子网,并在 etcd 中注册这个子网。随后,它保持集群路由的本地表示,并将传出数据包封装在 UDP 数据报中,确保其到达正确的主机。要了解更多信息,请参阅 CoreOS 的文档

容器启动

所有关于网络的过程都已经介绍了。接下来还剩下什么呢?好吧,我们需要真正地启动工作负载容器。

一旦沙盒初始化完成并处于活动状态,kubelet 就可以开始为其创建容器。它首先启动在 PodSpec 中定义的所有 init 容器,然后再启动主要的容器本身。具体过程如下:

  1. 拉取容器的镜像。PodSpec 中定义的加密信息(secrets)都将用于私有的镜像仓库;
  2. 通过 CRI 创建容器。沙盒会先从父 PodSpec 中生成一个 ContainerConfig 结构体(其中定义了命令、镜像、标签、挂载点、设备、环境变量等),然后通过 protobuf 将其发送给 CRI 插件。以 Docker 举例,它会反序列化有效的负载并生成自己的配置结构以发送到 Docker 的守护进程 API。在此过程中,它会向容器中注入一些元数据标签,例如容器类型、日志路径、沙盒 ID。
  3. 然后,它将使用 CPU 管理器(CPU manager)注册容器。CPU 管理器是 1.8 中的一个新的 alpha 功能,它使用 UpdateContainerResources CRI 方法将容器分配到本地节点上的一组 CPU。
  4. 接下来容器被启动
  5. 如果注册了任何后启动(post-start)的容器生命周期钩子,它们将被执行。钩子可以是 Exec 类型(在容器内执行特定命令)或 HTTP 类型(针对容器端点执行 HTTP 请求)。如果 PostStart 钩子运行时间过长、挂起或失败,容器将永远无法变成 running 状态。

回顾

好的,终于完成了。

经过所有这些步骤,我们应该在一个或多个工作节点上运行着 3 个容器。所有的网络、卷和加密信息都已由 kubelet 配置完成,并通过 CRI 插件转换为容器。

当我执行 kubectl create 时发生了什么[译]

https://powerfooi.github.io/2024/01/13/WhatHappensWhenK8s/

作者

PowerfooI

发布于

2024-01-13

更新于

2024-08-04

许可协议

评论