问题现象
监控发出告警通知,某台机器的kubelet的cpu过高,kube-apiserver的qps也过高,kube-controller-manager也告警其请求apiserver的qps过高。 最后排查下来,发现了有一个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
最终得到这样一个结果
这个pod最终调度到k8s-node-02上了。我现在想对这个服务进行一个更新,我执行了如下命令:
kubectl set image deployments/demo nginx=nginx:1.16.1 --record
## 查看pod情况
kubectl get pod
生成了好多demo的pod(错误是NodePorts,这个可能是我kubernetes版本的原因),这样成功复现了。
问题分析
如何触发
从现象上看是node上的pod已经存在了,pod被驱逐后,又立马创建了新的pod。这里有几个关键点
- pod被创建后,又重新调度到了同一个节点。
- 创建的pod,出现的是node上的port冲突
- 变更后就会出现这个问题
基于以上三点,我们可以对应到三个配置:
- nodeName: "k8s-node-02" 这个配置表示pod调度到所在的节点
- hostNetwork: true 这个配置表示共享宿主机的网络命名空间
- type: RollingUpdate 这个配置表示滚动发布,即先创建一个新的pod,成功后,再删除旧的pod.
为什么这样
清楚了如何触发,整个情况我们最开始梳理下(此时梳理的结果不完全正确)。首先我们更新了这个服务后,由于滚动发布的原因,先创建了一个pod,并且会直接在对应的node上生成pod,而所在pod已经存在了,并且共享的宿主机命名空间,造成端口号冲突,这种情况就触发了kubelet的驱逐机制,将该pod驱逐。驱逐后,kubelet需要继续保证其pod状态与etcd中的信息一致,继续创建pod。从而无穷尽也。
这么一分析,咋一看好像没有毛病,但是还是有几个小问题
- kube-sechduler的调度机制是怎样的?为何没有做预选,就直接调度到对应的node上了呢?
- 并没有地方可以解释kube-controller-manager的客户端qps过高的问题。不着急,咱们好好研究下kubernetes的调度机制,再来给出一个答案。
nodeName的调度机制
这个问题我们还是看看kube-scheduler的机制。其调度机制主要分三个大步骤
- 获取未调度的podList
- 通过预选,优选的算法来为pod选择一个合适的node
- 最终将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。
####原因总结
这里我用一张图,配合文字说明下吧。
- kubectl发送更新的请求
- Kube-controller-manager监听到信息后,进行pod创建
- kube-scheduler监听到pod后,尝试调度,由于有nodeName,则不进行任何调度算法逻辑
- kubelet监听到自己所在节点需要创建新的pod,创建过程中,由于hostNetwork为true的配置,出现了端口冲突,创建失败
- 此时kube-controller-manager监听pod的信息一直未达到期望状态,继续进入第二步的流程中
如何防止这种问题发生?
根据触发的情况,我们让任意一种情况不发生,都会解决这个问题。
- 将发布机制改为recreate的类型
- 将hostNetwork改为false,使用nodePort的方式暴露你的服务
- 将节点调度的方式改为nodeSelector或nodeAffitiy的方式
但实际情况中,根据这种业务场景,我还是推荐使用的方式是,nodeSelector或nodeAffinity来解决该问题。
结束语
文章中必然会有一些不严谨的地方,还希望大家包涵,大家吸取精华(如果有的话),去其糟粕。如果大家感兴趣可以关我的公众号:gungunxi。我的微信号:lcomedy2021