从源码上看k8s创建pod全流程(下)

1,723 阅读10分钟

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

pexels-lucas-andrade-4681107.jpg

上节回顾

在上一篇文章中我们详细讲述了pod从本地发布创建命令,一直到pod被持久化到etcd中的过程,这一部分是相当复杂的流程,而且pod的创建并不仅仅是通过kubectl run还有其他的命令,例如create以及更高级的命令例如各种控制器,但是它们在底层创建pod也是共通的,因此理解pod的创建流程对理解整个k8s的使用都非常有帮助。

接下来我们继续看pod创建的下半场,即pod是如何被调度以及在节点上又是如何被真正创建的。

scheduler

调度器的核心工作是为待调度的POD分配可执行的节点,并且完成与一个节点绑定。调度流程是通用环节,接下来我们先来整体看一下调度的流程,然后再来稍微详细的了解一下调度器的整体实现,完整的介绍需要另外一篇单独的文章,先预留一个伏笔。

调度流程简介

调度器在控制面中是一个独立运行的模块,但是与其他控制器的运行方式完全相同:监听事件,然后尝试调谐状态。具体来说,调度器会过滤出所有在PodSpec中NodeName字段为空的pod,然后尝试为这些pod找到一个适合其运行的节点。

为了找到合适的节点,调度器将使用一个特有的调度算法。这个调度算法的工作方式如下两步:

  1. 当调度器启动时,会注册一系列默认的预测器。这些预测器是很有效率的函数,当判断一个节点是否适合承载一个pod时,预测器会被执行。
  2. 在挑选完合适的节点后,对这些节点会再执行一系列的优先级函数来对这些候选节点进行打分,以便进行适合度的排序。例如,为了尽可能的将工作负载分摊到整个集群中,调度器会更倾向于当前资源已分配更少的节点。当运行这些函数时,它会给每个节点打分,最终调度器会选择得分最高的节点。

当调度器将一个pod调度到一个节点后,那个节点上的kubelet就会接手开始具体的创建工作。

调度器实现介绍

核心流程

调度器的整个初始化流程此处从略,此处重点看一下通用调度器的调度执行的核心流程。

// pkg/scheduler/scheduler.go#L311
// Run begins watching and scheduling. It starts scheduling and blocked until the context is done.
func (sched *Scheduler) Run(ctx context.Context) {
	sched.SchedulingQueue.Run()
	wait.UntilWithContext(ctx, sched.scheduleOne, 0)
	sched.SchedulingQueue.Close()
}

调度器开始运行时首先通过go程的方式运行调度队列,所有需要调度的pod都必须先放入该队列中,默认实现为优先队列。

核心调度入口为:scheduleOne,调度一个pod,UntilWithContext可以理解为一个死循环,直到外部告知退出时整个调度才会结束。通过函数注释可以知道,整个调度过程是顺序执行的。

// pkg/scheduler/scheduler.go#L429
// scheduleOne does the entire scheduling workflow for a single pod. It is serialized on the scheduling algorithm's host fitting.
func (sched *Scheduler) scheduleOne(ctx context.Context) {
  // 先取出一个待调度的pod
  podInfo := sched.NextPod()
  // 一些列检查,如果失败则会直接退出
  
  // 按照算法进行调度
  scheduleResult, err := sched.Algorithm.Schedule(schedulingCycleCtx, fwk, state, pod)
  if err != nil{
    // 根据具体的错误进行相应处理,区分不可调度和调度失败的情况
  }
  
  // 设置pod的NodeName,通知cache调度成功的信息,同时可以保持继续调度而不用等待绑定成功
  // Tell the cache to assume that a pod now is running on a given node, even though it hasn't been bound yet.
	// This allows us to keep scheduling without waiting on binding to occur.
	assumedPodInfo := podInfo.DeepCopy()
	assumedPod := assumedPodInfo.Pod
	// assume modifies `assumedPod` by setting NodeName=scheduleResult.SuggestedHost
  	err = sched.assume(assumedPod, scheduleResult.SuggestedHost)
  
  	// 执行预留的插件的预留方法
  	// 执行"允许"插件
  runPermitStatus := fwk.RunPermitPlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
  // 异步执行节点绑定
  go func() {
    // 等待允许的状态为成功
    waitOnPermitStatus := fwk.WaitOnPermit(bindingCycleCtx, assumedPod)
    
    // 执行绑定前插件
    // Run "prebind" plugins.
	preBindStatus := fwk.RunPreBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
    
    // 执行绑定
    err := sched.bind(bindingCycleCtx, fwk, assumedPod, scheduleResult.SuggestedHost, state)
    // 执行绑定后插件
    fwk.RunPostBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
  }
}

调度算法

所有的调度算法都必须实现该接口,k8s提供了一个默认的通用型的调度算法。

// pkg/scheduler/core/generic_scheduler.go#L95
// ScheduleAlgorithm is an interface implemented by things that know how to schedule pods
// onto machines.
// TODO: Rename this type.
type ScheduleAlgorithm interface {
	Schedule(context.Context, framework.Framework, *framework.CycleState, *v1.Pod) (scheduleResult ScheduleResult, err error)
	// Extenders returns a slice of extender config. This is exposed for
	// testing.
	Extenders() []framework.Extender
}

通用调度算法实现流程如下:

// pkg/scheduler/core/generic_scheduler.go#L131
// Schedule tries to schedule the given pod to one of the nodes in the node list.
// If it succeeds, it will return the name of the node.
// If it fails, it will return a FitError error with reasons.
func (g *genericScheduler) Schedule(ctx context.Context, fwk framework.Framework, state *framework.CycleState, pod *v1.Pod) (result ScheduleResult, err error) {
  // 判断当前是否有可调度的节点
  if g.nodeInfoSnapshot.NumNodes() == 0{}
  
  // 找到所有合适的节点
  feasibleNodes, filteredNodesStatuses, err := g.findNodesThatFitPod(ctx, fwk, state, pod)
  
  // 候选节点打分排序
  priorityList, err := g.prioritizeNodes(ctx, fwk, state, pod, feasibleNodes)
  // 选择一个合适的节点
  host, err := g.selectHost(priorityList)
  // 返回调度结果
  return ScheduleResult{
		SuggestedHost:  host,
		EvaluatedNodes: len(feasibleNodes) + len(filteredNodesStatuses),
		FeasibleNodes:  len(feasibleNodes),
  }, err
}

另外预测器和打分器函数都是可以扩展的,可以通过--policy-config-fie参数项来自定义。这就增了一定程度的灵活性。管理员也可以通过独立的Deployment来运行自定义的调度器(本质是特殊逻辑的控制器)。如果PodSpec中schedulerName被设置了,Kubernetes无路如何都将会把这个pod的调度交给符合其所指定的名字的调度器。

节点绑定流程

当找到了一个合适的节点时,调度器就会创建一个Binding对象,其Name和UID可以匹配到该pod,其ObjectReference字段保存着所选择的节点的名称。这个对象将会通过POST请求发送给apiserver。

当apisever接收到该Binding对象,它会反序列化该对象,并且更新对应pod对象的下列字段:将NodeName设置为ObjectReference的值,添加相关的annotation,设置PodScheduled状态条件为True。

kubernetes.io/docs/concep…

kubelet

kubelet是运行在k8s集群中每一个节点上的代理端,每个节点都会启动 kubelet进程,用来处理 Master 节点下发到本节点的任务,同时它也负责管理pod的生命周期以及其他的事情。kueblet实现了抽象的Kubernetes概念Pod到具体的构建模块、容器之间的转换逻辑。同时,它还负责处理所有这些与挂载卷、容器日志、垃圾回收,以及其他更重要事情相关的事务。

更多完整的介绍参考官方文档

kubernetes.io/docs/refere…

工作原理

99187DE6-BEE7-4EEC-B5F0-58FD3AA7A1B2

整体来看,kubelet启动了一个SyncLoop,所有工作都是围绕这个死循环展开的,功能十分庞杂,在这里我们重点关注一下kubelet创建pod的流程。

创建流程

整体介绍

当一个pod完成节点绑定后,就会触发kubelet的handler。kubelet监听到pod的变化,然后根据事件的类型进行不同的处理,有ADD、UPDATE、REMOVE、DELETE、等等,新增是ADD。

对所有新增的POD按照创建时间进行排序,保证最先创建的pod会最先被处理。然后逐个把pod加入到podManager中,podManager子模块负责管理这台机器上的pod信息、pod和mirrorPod之间的对应关系等等。所有被管理的pod都要出现在podManager中,如果没有,就认为这个pod被删除了。

如果操作类型是镜像pod,则执行镜像pod处理,后续操作跳过。

验证该pod是否可以在该节点运行,如果不可以直接拒绝,pod将会永久处于未就绪状态,不会自行恢复,需要人工干预。

通过dispatchWork把创建pod的工作下发给podWorkers子模块做异步处理。

在probeManager中添加pod,如果pod中定义了readiness和liveness健康检查,启动goroutine定期进行检测。

准备工作

在podworker具体创建容器前需要做一系列的准备工作(syncPod 注意大小写)此处巨复杂,先简单看看。

在这个方法中,主要完成以下几件事情:

  • 如果是删除 pod,立即执行并返回
  • 同步 podStatus 到 kubelet.statusManager
  • 检查 pod 是否能运行在本节点,主要是权限检查(是否能使用主机网络模式,是否可以以 privileged 权限运行等)。如果没有权限,就删除本地旧的 pod 并返回错误信息
  • 创建 containerManagar 对象,并且创建 pod level cgroup,更新 Qos level cgroup
  • 如果是 static Pod,就创建或者更新对应的 mirrorPod
  • 创建 pod 的数据目录,存放 volume 和 plugin 信息,如果定义了 pv,等待所有的 volume mount 完成(volumeManager 会在后台做这些事情),如果有 image secrets,去 apiserver 获取对应的 secrets 数据
  • 然后调用 kubelet.volumeManager 组件,等待它将 pod 所需要的所有外挂的 volume 都准备好。
  • 调用 container runtime 的 SyncPod 方法,去实现真正的容器创建逻辑

这里所有的事情都和具体的容器没有关系,可以看到该方法是创建 pod 实体(即容器)之前需要完成的准备工作。

// pkg/kubelet/kubelet.go#L1455
// syncPod is the transaction script for the sync of a single pod.
//
// This operation writes all events that are dispatched in order to provide
// the most accurate information possible about an error situation to aid debugging.
// Callers should not throw an event if this operation returns an error.
func (kl *Kubelet) syncPod(o syncPodOptions) error {
  // Call the container runtime's SyncPod callback
  result := kl.containerRuntime.SyncPod(pod, podStatus, pullSecrets, kl.backOff)
}

创建容器

containerRuntime子模块的SyncPod函数真正完成pod内容器的创建。

SyncPod 主要执行以下几个操作:

  • 1、计算 sandbox 和 container 是否发生变化
  • 2、创建 sandbox 容器
  • 3、创建 init 容器
  • 4、创建业务容器

这部分代码的注释非常完整,值得称赞!

// pkg/kubelet/kuberuntime/kuberuntime_manager.go#L675
// SyncPod syncs the running pod into the desired pod by executing following steps:
//
//  1. Compute sandbox and container changes.
//  2. Kill pod sandbox if necessary.
//  3. Kill any containers that should not be running.
//  4. Create sandbox if necessary.
//  5. Create ephemeral containers.
//  6. Create init containers.
//  7. Create normal containers.
func (m *kubeGenericRuntimeManager) SyncPod(pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, backOff *flowcontrol.Backoff) (result kubecontainer.PodSyncResult) {
  
  start := func(typeName string, spec *startSpec) error {
    if msg, err := m.startContainer(podSandboxID, podSandboxConfig, spec, pod, podStatus, pullSecrets, podIP, podIPs);
  }
  // Step 6: start the init container.
  if err := start("init container", containerStartSpec(container));
  // Step 7: start containers in podContainerChanges.ContainersToStart.
  for _, idx := range podContainerChanges.ContainersToStart {
		start("container", containerStartSpec(&pod.Spec.Containers[idx]))
  }
}

启动容器

最终由startContainer完成容器的启动

主要有以下步骤:

  • 1、拉取镜像
  • 2、生成业务容器的配置信息
  • 3、调用运行时服务 api 创建容器,注意在v1.20版本中开始标记弃用dockershim,在v1.23中将彻底移除,在此之前需要提供受支持的容器运行时,更多内容可以参考文档xxxx
  • 4、启动容器
  • 5、执行 post start hook
// pkg/kubelet/kuberuntime/kuberuntime_container.go#L134
// startContainer starts a container and returns a message indicates why it is failed on error.
// It starts the container through the following steps:
// * pull the image
// * create the container
// * start the container
// * run the post start lifecycle hooks (if applicable)
func (m *kubeGenericRuntimeManager) startContainer(podSandboxID string, podSandboxConfig *runtimeapi.PodSandboxConfig, spec *startSpec, pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, podIP string, podIPs []string) (string, error) {
}

小结

通过pod的创建流程可以看到kubelet承担了庞大的基础管理和操作任务,进入到kubelet内部后也会发现,kubelet的整体架构也体现了它的复杂性。pod的创建只是其很小的一部分工作。最后附一张kubelet整体模块架构图。

3BD21939-756D-426C-9A23-E934260EAC27

参考文档

书籍

《Kubernetes源码剖析》郑东旭

网文

Kubernetes 弃用 Docker 来龙去脉

kubelet创建pod工作流程

v1.14版的kubectl run创建pod流程