kubernetes一直创建pod是咋回事

3,009 阅读8分钟

问题现象

监控发出告警通知,某台机器的kubelet的cpu过高,kube-apiserver的qps也过高,kube-controller-manager也告警其请求apiserver的qps过高。 image.png 最后排查下来,发现了有一个pod一直被驱逐,然后又不断的不创建。这就很神奇了,我就省略一些排查过程,重点聊聊如下几个问题,大家也可以带着问题思考下。

  • 这种情况是怎么触发的?
  • 为什么会出现这种情况?
  • 怎么避免这种情况?

废话不多说,咱们先将问题来复现,再来好好分析上面的问题。

问题复现

首先我先来展示下依据问题pod的改造的deployment的yaml文件。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  labels:
    app: demo
spec:
  ## pod的副本数
  replicas: 1
  strategy:
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0
    type: RollingUpdate
  # 这里标签选择pod模板
  selector:
    matchLabels:
      app: demo
  ## pod的模板
  template:
    metadata:
      labels:
        app: demo
    spec:
      # 选择共享主机网络命名空间
      hostNetwork: true
      # 选择主机名称
      nodeName: "k8s-node-02"
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

依据这个文件,我们尝试创建这个服务

kubectl apply -f demo-test.yaml

最终得到这样一个结果

image.png

这个pod最终调度到k8s-node-02上了。我现在想对这个服务进行一个更新,我执行了如下命令:

kubectl set image deployments/demo nginx=nginx:1.16.1 --record

## 查看pod情况
kubectl get pod

image.png 生成了好多demo的pod(错误是NodePorts,这个可能是我kubernetes版本的原因),这样成功复现了。

问题分析

如何触发

从现象上看是node上的pod已经存在了,pod被驱逐后,又立马创建了新的pod。这里有几个关键点

  1. pod被创建后,又重新调度到了同一个节点。
  2. 创建的pod,出现的是node上的port冲突
  3. 变更后就会出现这个问题

基于以上三点,我们可以对应到三个配置:

  1. nodeName: "k8s-node-02" 这个配置表示pod调度到所在的节点
  2. hostNetwork: true 这个配置表示共享宿主机的网络命名空间
  3. type: RollingUpdate 这个配置表示滚动发布,即先创建一个新的pod,成功后,再删除旧的pod.

为什么这样

清楚了如何触发,整个情况我们最开始梳理下(此时梳理的结果不完全正确)。首先我们更新了这个服务后,由于滚动发布的原因,先创建了一个pod,并且会直接在对应的node上生成pod,而所在pod已经存在了,并且共享的宿主机命名空间,造成端口号冲突,这种情况就触发了kubelet的驱逐机制,将该pod驱逐。驱逐后,kubelet需要继续保证其pod状态与etcd中的信息一致,继续创建pod。从而无穷尽也。

这么一分析,咋一看好像没有毛病,但是还是有几个小问题

  1. kube-sechduler的调度机制是怎样的?为何没有做预选,就直接调度到对应的node上了呢?
  2. 并没有地方可以解释kube-controller-manager的客户端qps过高的问题。不着急,咱们好好研究下kubernetes的调度机制,再来给出一个答案。

nodeName的调度机制

这个问题我们还是看看kube-scheduler的机制。其调度机制主要分三个大步骤

  1. 获取未调度的podList
  2. 通过预选,优选的算法来为pod选择一个合适的node
  3. 最终将node的信息提交给apiserver

其实这里我们可以猜想下,如果标记了nodeName的pod,就已经不在未调度podList中,也同样可以认为,经过scheduler调度后的pod写入的信息就是nodeName这个字段。为了验证这个猜想我们看看源码。

获取未调度podList的代码:

// podInfomer的初始化逻辑
func NewPodInformer(client clientset.Interface, resyncPeriod time.Duration) coreinformers.PodInformer {
  // 选择状态为非成功和非失败的pod
    selector := fields.ParseSelectorOrDie(
            "status.phase!=" + string(v1.PodSucceeded) +
                    ",status.phase!=" + string(v1.PodFailed))
    lw := cache.NewListWatchFromClient(client.CoreV1().RESTClient(), string(v1.ResourcePods), metav1.NamespaceAll, selector)
    return &podInformer{
            informer: cache.NewSharedIndexInformer(lw, &v1.Pod{}, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}),
    }
}

我们还要继续看podInformer的增加pod事件的处理逻辑

	// 将已经调度的节点存入缓存
	args.PodInformer.Informer().AddEventHandler(
            cache.FilteringResourceEventHandler{
                FilterFunc: func(obj interface{}) bool {
                        switch t := obj.(type) {
                        case *v1.Pod:
                            // 这里是判断是否调度的关键
                            return assignedPod(t)
                        case cache.DeletedFinalStateUnknown:
                            if pod, ok := t.Obj.(*v1.Pod); ok {
                                    return assignedPod(pod)
                            }
                            runtime.HandleError(fmt.Errorf("unable to convert object %T to *v1.Pod in %T", obj, c))
                            return false
                        default:
                            runtime.HandleError(fmt.Errorf("unable to handle object in %T: %T", c, obj))
                            return false
                        }
                },
                Handler: cache.ResourceEventHandlerFuncs{
                        AddFunc:    c.addPodToCache,
                        UpdateFunc: c.updatePodInCache,
                        DeleteFunc: c.deletePodFromCache,
                },
        },
	)
	// 将未调度的pod放入未调度队列
	args.PodInformer.Informer().AddEventHandler(
		cache.FilteringResourceEventHandler{
			FilterFunc: func(obj interface{}) bool {
				switch t := obj.(type) {
				case *v1.Pod:
                                      // 这里是判断是否调度的关键
					return !assignedPod(t) && responsibleForPod(t, args.SchedulerName)
				case cache.DeletedFinalStateUnknown:
					if pod, ok := t.Obj.(*v1.Pod); ok {
						return !assignedPod(pod) && responsibleForPod(pod, args.SchedulerName)
					}
					runtime.HandleError(fmt.Errorf("unable to convert object %T to *v1.Pod in %T", obj, c))
					return false
				default:
					runtime.HandleError(fmt.Errorf("unable to handle object in %T: %T", c, obj))
					return false
				}
			},
			Handler: cache.ResourceEventHandlerFuncs{
				AddFunc:    c.addPodToSchedulingQueue,
				UpdateFunc: c.updatePodInSchedulingQueue,
				DeleteFunc: c.deletePodFromSchedulingQueue,
			},
		},
	)

我们仔细来看下判断是否已经调度的关键函数

// 根据nodeName,判断是否已经分配了node
func assignedPod(pod *v1.Pod) bool {
	return len(pod.Spec.NodeName) != 0
}

到这里发现与我的猜想一致,感觉不错!继续往下看,如果正常调度的话,是否在pod上元数据上有写入nodeName信息。

// 这里就是一段调度选择的host将pod绑定的代码
func (sched *Scheduler) assume(assumed *v1.Pod, host string) error {
  // 将pod的信息的NodeName设置为对应host的主机名,这里与我们的猜想一致
	assumed.Spec.NodeName = host

	if err := sched.config.SchedulerCache.AssumePod(assumed); err != nil {
		klog.Errorf("scheduler cache AssumePod failed: %v", err)
		sched.recordSchedulingFailure(assumed, err, SchedulerError,
			fmt.Sprintf("AssumePod failed: %v", err))
		return err
	}
	if sched.config.SchedulingQueue != nil {
		sched.config.SchedulingQueue.DeleteNominatedPodIfExists(assumed)
	}
	return nil
}

简直完美,调度完成后,会将调度的节点信息,通过nodeName写入pod的信息中。

pod创建成功以及驱逐的过程

同样我们也来猜想下这个过程,kubelet先会对该pod进行一个验证,发现其不符合调度的要求,不允许其进行创建pod,并将信息同步给apiserver。这时kube-controller-manager会发现对应服务的处于active的副本数为0,需要将新创建一个pod,则再次创建一个pod,kubelet监听到该信息后,进行再次创建pod。

这里比较疑惑点的是

  • kubelet是如何处理这中情况的pod
  • controller-manager是否会一直创建pod

我们先看下pod的创建的管理机制,其中有个关键代码:

      // 在创建过程中,检查pod是否能够被允许创建
      if ok, reason, message := kl.canAdmitPod(activePods, pod); !ok {
				kl.rejectPod(pod, reason, message)
				continue
        }
    // 查看这个函数,发现是通过一个lifecycle.PodAdmitAttributes来约束的。
    func (kl *Kubelet) canAdmitPod(pods []*v1.Pod, pod *v1.Pod) (bool, string, string) {
      // the kubelet will invoke each pod admit handler in sequence
      // if any handler rejects, the pod is rejected.
      // TODO: move out of disk check into a pod admitter
      // TODO: out of resource eviction should have a pod admitter call-out
      attrs := &lifecycle.PodAdmitAttributes{Pod: pod, OtherPods: pods}
      for _, podAdmitHandler := range kl.admitHandlers {
        if result := podAdmitHandler.Admit(attrs); !result.Admit {
          return false, result.Reason, result.Message
        }
      }

      return true, "", ""
    }
    // setup eviction manager
    ...
    // 添加驱逐的Handler
    klet.admitHandlers.AddPodAdmitHandler(evictionAdmitHandler)
    // 添加运行时相关的Handler
    klet.admitHandlers.AddPodAdmitHandler(runtimeSupport)
    // 添加sysctlsWhitelist
    klet.admitHandlers.AddPodAdmitHandler(sysctlsWhitelist)
    // 添加NewPredicateAdmitHandler
		klet.admitHandlers.AddPodAdmitHandler(lifecycle.NewPredicateAdmitHandler(...))
    // 添加NewAppArmorAdmitHandler
   	klet.softAdmitHandlers.AddPodAdmitHandler(lifecycle.NewAppArmorAdmitHandler(...))
    // 添加NewNoNewPrivsAdmitHandler
    klet.softAdmitHandlers.AddPodAdmitHandler(lifecycle.NewNoNewPrivsAdmitHandler(...))

我逐一翻看了PodAdmitAttributes中的handler,重点看下NewPredicateAdmitHandler这个Handler,里面其实相当于根据scheduler的调度预选执行了一遍,我们继续往下看。

    // 查看该Handler的Admit方法
    func (w *predicateAdmitHandler) Admit(attrs *PodAdmitAttributes) PodAdmitResult {
		fit, reasons, err := predicates.GeneralPredicates(podWithoutMissingExtendedResources, nil, nodeInfo)
    
  // 该预选判断里有两个判断方法
  func GeneralPredicates(pod *v1.Pod, meta PredicateMetadata, nodeInfo *schedulernodeinfo.NodeInfo) (bool, []PredicateFailureReason, error) {
    var predicateFails []PredicateFailureReason
    // 非紧急的判断
    fit, reasons, err := noncriticalPredicates(pod, meta, nodeInfo)
    if err != nil {
      return false, predicateFails, err
    }
    if !fit {
      predicateFails = append(predicateFails, reasons...)
    }
    // 必要的判断
    fit, reasons, err = EssentialPredicates(pod, meta, nodeInfo)
    
  // 必要的判断
  func EssentialPredicates(pod *v1.Pod, meta PredicateMetadata, nodeInfo *schedulernodeinfo.NodeInfo) (bool, []PredicateFailureReason, error) {
    ...
    // 关键判断,pod是否匹配该主机
    fit, reasons, err = PodFitsHostPorts(pod, meta, nodeInfo)
	  ...
  }

源码看到这里,答案已经清晰了,该pod终究因为port冲突是不被允许创建的。

接下来我们再看controller-manager的处理机制,这里大家可以参考这篇文章:www.bookstack.cn/read/source…

其实与我上面猜想的基本一直,里面关键点是获取active的pod数,与期望的副本数进行比较,如果不一致则进行更新。理解了这一点,那我们就清楚,pod在指定的主机上一直是不能达到active的状态的,那replicaset controller将会一直发送更新事件,创建一个新的pod。

####原因总结

这里我用一张图,配合文字说明下吧。 image.png

  1. kubectl发送更新的请求
  2. Kube-controller-manager监听到信息后,进行pod创建
  3. kube-scheduler监听到pod后,尝试调度,由于有nodeName,则不进行任何调度算法逻辑
  4. kubelet监听到自己所在节点需要创建新的pod,创建过程中,由于hostNetwork为true的配置,出现了端口冲突,创建失败
  5. 此时kube-controller-manager监听pod的信息一直未达到期望状态,继续进入第二步的流程中

如何防止这种问题发生?

根据触发的情况,我们让任意一种情况不发生,都会解决这个问题。

  1. 将发布机制改为recreate的类型
  2. 将hostNetwork改为false,使用nodePort的方式暴露你的服务
  3. 将节点调度的方式改为nodeSelector或nodeAffitiy的方式

但实际情况中,根据这种业务场景,我还是推荐使用的方式是,nodeSelector或nodeAffinity来解决该问题。

结束语

文章中必然会有一些不严谨的地方,还希望大家包涵,大家吸取精华(如果有的话),去其糟粕。如果大家感兴趣可以关我的公众号:gungunxi。我的微信号:lcomedy2021