kubernets节点异常问题分析

1,494 阅读8分钟

问题现象

kubernetes集群中,发生部分pod应用不可用的告警

问题排查

基于这种情况,根据经验判断,怀疑是对应的某个节点出现问题。

  1. 找到其中一个pod,kubectl get pod -o wide|grep pod-xxx 查看其在哪个节点上

  2. 通过kubectl describe node xxxx 查看该node上的信息,发现该节点上的pod都出现上在了不可用的应用清单中。另外还观察到了一条warning信息

image-20210730172143273.png

  1. 通过其ip地址,登录服务器,发现已经无法登录了。
  2. 查看node节点监控,看能否发现一些端倪。监控数据却没有异常。初步确认是底层服务器问题,赶紧反馈给云厂商。
  3. 排查影响范围,通过kubectl get node -o wide ,发现一个很奇怪的现象,node节点处于ready状态,但是os-image字段显示unknow状态
  4. 将其unknow状态的节点逐一测试,发现都无法登录。

image-20210730172104898.png

其实问题通过上面一顿操作,可以得到一个基本结论,出现了unknown状态的node存在问题,导致该节点上的pod都不可用。具体原因还要看云厂商给出结论。从目前得到的信息推断应该是物理机磁盘出现问题,导致文件系统io出现错误。所以后面的分析也是基于这个结论去分析的。

事情到这里,我们还是有些问题要去反思的。

  1. 服务器上的pod都不可用了,且服务器也无法登录了,而k8s的出问题的node节点为什么状态还是Ready?
  2. 服务器不可用了,监控为什么还是正常的?
  3. 问题节点的os-image的信息为什么是unknown的状态?

问题解惑

要弄清上面的问题,我们要先看下kubelet的状态上报机制是怎样的。或许能够解答第一个和第三个问题。

kubelet的状态上报机制

在 v1.13 之前的版本中,节点的心跳只有 NodeStatus,从 v1.13 开始,NodeLease feature 作为 alpha 特性引入。当启用 NodeLease feature 时,每个节点在“kube-node-lease”名称空间中都有一个关联的“Lease”对象,该对象由节点定期更新,NodeStatus 和 NodeLease 都被视为来自节点的心跳。NodeLease 会频繁更新,而只有在 NodeStatus 发生改变或者超过了一定时间(默认值为1分钟,node-monitor-grace-period 的默认值为 40s),才会将 NodeStatus 上报给 master。由于 NodeLease 比 NodeStatus 更轻量级,该特性在集群规模扩展性和性能上有明显提升。

1584064568734-eb0c7ce1-a896-4b8b-a1c3-e289e1091aa2-7802610.png

上传信息

状态的信息的心跳我么依靠nodelease来实现,减少apiserver的压力。其上传的信息相对于node的信息少很多,比较轻量级。

// kubernetes/pkg/kubelet/kubelet.go
func (kl *Kubelet) Run(updates <-chan kubetypes.PodUpdate) { 
  ...
  if kl.kubeClient != nil {
		// Start syncing node status immediately, this may set up things the runtime needs to run.
    // 通过node的状态信息
		go wait.Until(kl.syncNodeStatus, kl.nodeStatusUpdateFrequency, wait.NeverStop)
		// 更新node的缓存信息
    go kl.fastStatusUpdateOnce()

		// start syncing lease
    // 开启同步lease信息
		go kl.nodeLeaseController.Run(wait.NeverStop)
	}
  ...
}

NodeLease

通过一下代码我们可以看到,只是上传一个很简单数据。

// retryUpdateLease attempts to update the lease for maxUpdateRetries,
// call this once you're sure the lease has been created
func (c *controller) retryUpdateLease(base *coordinationv1.Lease) error {
  // 上传重试机制
	for i := 0; i < maxUpdateRetries; i++ {
    // 生成新的lease
		leaseToUpdate, _ := c.newLease(base)
    // 更新apiserver的信息
		lease, err := c.leaseClient.Update(context.TODO(), leaseToUpdate, metav1.UpdateOptions{})
		if err == nil {
			c.latestLease = lease
			return nil
		}
		klog.Errorf("failed to update lease, error: %v", err)
		// OptimisticLockError requires getting the newer version of lease to proceed.
		if apierrors.IsConflict(err) {
			base, _ = c.backoffEnsureLease()
			continue
		}
		if i > 0 && c.onRepeatedHeartbeatFailure != nil {
			c.onRepeatedHeartbeatFailure()
		}
	}
	return fmt.Errorf("failed %d attempts to update lease", maxUpdateRetries)
}
// newLease constructs a new lease if base is nil, or returns a copy of base
// with desired state asserted on the copy.
// Note that an error will block lease CREATE, causing the CREATE to be retried in
// the next iteration; but the error won't block lease refresh (UPDATE).
func (c *controller) newLease(base *coordinationv1.Lease) (*coordinationv1.Lease, error) {
	// Use the bare minimum set of fields; other fields exist for debugging/legacy,
	// but we don't need to make component heartbeats more complicated by using them.
	var lease *coordinationv1.Lease
	if base == nil {
		lease = &coordinationv1.Lease{
			ObjectMeta: metav1.ObjectMeta{
				Name:      c.holderIdentity,
				Namespace: c.leaseNamespace,
			},
			Spec: coordinationv1.LeaseSpec{
				HolderIdentity:       pointer.StringPtr(c.holderIdentity),
				LeaseDurationSeconds: pointer.Int32Ptr(c.leaseDurationSeconds),
			},
		}
	} else {
		lease = base.DeepCopy()
	}
	lease.Spec.RenewTime = &metav1.MicroTime{Time: c.clock.Now()}

	if c.newLeasePostProcessFunc != nil {
		err := c.newLeasePostProcessFunc(lease)
		return lease, err
	}

	return lease, nil
}

NodeStatus

node的状态信息相对较多,在 NodeStatus 发生改变或者超过了一定时间(默认值为1分钟,node-monitor-grace-period 的默认值为 40s),才会将 NodeStatus 上报给 master。相关的上传的信息可以参考以下代码。

// defaultNodeStatusFuncs is a factory that generates the default set of
// setNodeStatus funcs
func (kl *Kubelet) defaultNodeStatusFuncs() []func(*v1.Node) error {
	// if cloud is not nil, we expect the cloud resource sync manager to exist
	var nodeAddressesFunc func() ([]v1.NodeAddress, error)
	if kl.cloud != nil {
		nodeAddressesFunc = kl.cloudResourceSyncManager.NodeAddresses
	}
	var validateHostFunc func() error
	if kl.appArmorValidator != nil {
		validateHostFunc = kl.appArmorValidator.ValidateHost
	}
	var setters []func(n *v1.Node) error
	setters = append(setters,
		nodestatus.NodeAddress(kl.nodeIPs, kl.nodeIPValidator, kl.hostname, kl.hostnameOverridden, kl.externalCloudProvider, kl.cloud, nodeAddressesFunc),
		nodestatus.MachineInfo(string(kl.nodeName), kl.maxPods, kl.podsPerCore, kl.GetCachedMachineInfo, kl.containerManager.GetCapacity,
			kl.containerManager.GetDevicePluginResourceCapacity, kl.containerManager.GetNodeAllocatableReservation, kl.recordEvent),
		nodestatus.VersionInfo(kl.cadvisor.VersionInfo, kl.containerRuntime.Type, kl.containerRuntime.Version),
		nodestatus.DaemonEndpoints(kl.daemonEndpoints),
		nodestatus.Images(kl.nodeStatusMaxImages, kl.imageManager.GetImageList),
		nodestatus.GoRuntime(),
	)
	// Volume limits
	setters = append(setters, nodestatus.VolumeLimits(kl.volumePluginMgr.ListVolumePluginWithLimits))

	setters = append(setters,
		nodestatus.MemoryPressureCondition(kl.clock.Now, kl.evictionManager.IsUnderMemoryPressure, kl.recordNodeStatusEvent),
		nodestatus.DiskPressureCondition(kl.clock.Now, kl.evictionManager.IsUnderDiskPressure, kl.recordNodeStatusEvent),
		nodestatus.PIDPressureCondition(kl.clock.Now, kl.evictionManager.IsUnderPIDPressure, kl.recordNodeStatusEvent),
		nodestatus.ReadyCondition(kl.clock.Now, kl.runtimeState.runtimeErrors, kl.runtimeState.networkErrors, kl.runtimeState.storageErrors, validateHostFunc, kl.containerManager.Status, kl.shutdownManager.ShutdownStatus, kl.recordNodeStatusEvent),
		nodestatus.VolumesInUse(kl.volumeManager.ReconcilerStatesHasBeenSynced, kl.volumeManager.GetVolumesInUse),
		// TODO(mtaufen): I decided not to move this setter for now, since all it does is send an event
		// and record state back to the Kubelet runtime object. In the future, I'd like to isolate
		// these side-effects by decoupling the decisions to send events and partial status recording
		// from the Node setters.
		kl.recordNodeSchedulableEvent,
	)
	return setters
}

kubelet状态上报基本流程

当 Kubernetes 中 Node 节点出现状态异常的情况下,节点上的 Pod 会被重新调度到其他节点上去,但是有的时候我们会发现节点 Down 掉以后,Pod 并不会立即触发重新调度,这实际上就是和 Kubelet 的状态更新机制密切相关的,Kubernetes 提供了一些参数配置来触发重新调度到嗯时间,下面我们来分析下 Kubelet 状态更新的基本流程。

  1. kubelet 自身会定期更新状态到 apiserver,通过参数--node-status-update-frequency指定上报频率,默认是 10s 上报一次。
  2. kube-controller-manager 会每隔--node-monitor-period时间去检查 kubelet 的状态,默认是 5s。
  3. 当 node 失联一段时间后,kubernetes 判定 node 为 notready 状态,这段时长通过--node-monitor-grace-period参数配置,默认 40s。
  4. 当 node 失联一段时间后,kubernetes 判定 node 为 unhealthy 状态,这段时长通过--node-startup-grace-period参数配置,默认 1m0s。
  5. 当 node 失联一段时间后,kubernetes 开始删除原 node 上的 pod,这段时长是通过--pod-eviction-timeout参数配置,默认 5m0s。

异常时的影响

在 v1.5 之前的版本中 kube-controller-manager 会 force delete pod 然后调度该宿主上的 pods 到其他宿主,在 v1.5 之后的版本中,kube-controller-manager 不会 force delete pod,pod 会一直处于TerminatingUnknown 状态直到 node 被从 master 中删除或 kubelet 状态变为 Ready。在 node NotReady 期间,Daemonset 的 Pod 状态变为 Nodelost,Deployment、Statefulset 和 Static Pod 的状态先变为 NodeLost,然后马上变为 Unknown。Deployment 的 pod 会 recreate,Static Pod 和 Statefulset 的 Pod 会一直处于 Unknown 状态。

问题解答

通过以上原理的了解,我们清楚到节点是否处于ready的状态主要是依赖kubelet是否有正常发送心跳信息给apiserver。而此次的情况,问题是文件系统无法读取,但是kubelet发送心跳信息是不依赖文件系统的。所以node的状态信息是ready。

另外为什么os-image未能正确获取到,这里从上面信息可知,kubelet获取操作系统版本是依赖cadvisor来获取版本信息。而cadivsor的版本信息跟踪其代码,其获取方式如下:

// google.cadvisor/manager/manager.go
// 获取操作系统内核版本
func KernelVersion() string {
	uname := &unix.Utsname{}

	if err := unix.Uname(uname); err != nil {
		return "Unknown"
	}

	return string(uname.Release[:bytes.IndexByte(uname.Release[:], 0)])
}
// 获取内核版本具体信息
func Uname(buf *Utsname) (err error) {
	_, _, e1 := RawSyscall(SYS_UNAME, uintptr(unsafe.Pointer(buf)), 0, 0)
	if e1 != 0 {
		err = errnoErr(e1)
	}
	return
}

可以看出,unknow的错误应该是unix.Uname(uname)这个地方抛出的信息,目前这里猜测应该是跟硬盘有些关联,导致无法获取到操作系统内核信息。

监控数据获取原理

基于第二个问题,其实跟第三个问题有点类似,本质上是应该回答,监控数据的获取原理是怎样的。监控的信息获取一般都可以通过系统调获取,而系统调用又是如何获取到监控数据的呢?实际上系统调用都是通过proc中获取到相关监控数据的。

proc目录详解

/proc 文件系统是一种内核和内核模块用来向进程(process) 发送信息的机制(所以叫做/proc)。这个伪文件系统让你可以和内核内部数据结构进行交互,获取 有关进程的有用信息,在运行中(on the fly) 改变设置(通过改变内核参数)。 与其他文件系统不同,/proc 存在于内存之中而不是硬盘上。

常见的信息获取

  • /proc/cpuinfo
  • /proc/version
  • /proc/meminfo

问题解答

如果只是硬盘不可用,监控采集数据的进程实际上读取的/proc目录,本质上是读取的内存数据,不受此次问题的影响。

如何处理

这次问题中也暴露了个很有意思的问题,貌似传统的监控节点状态的方式,并不能触发告警。那要怎么去解决这个问题呢?实际上kubelet的状态监控只能保证一些最基础的状态判断。我们需要对节点的信息做更多的状态判断,需要怎么做呢?比较好的一种方式可以通过node-problem-detector来实现。

具体可参考:github.com/kubernetes/…

另外还有一些实战文章可以参考: segmentfault.com/a/119000003…

小结

此次问题处理的过程分析,可以总结如下几点:

  1. 节点状态是kube-controller-manager定时查看kubelet上传的心跳信息判断是否为监控状态
  2. /proc目录下展示是操作系统内存中的相关数据,监控数据基本通过这个目录进行获取
  3. 节点状态是否正常,我们应该提供更多维度的指标去判断,可以通过node-problem-detector去实现对节点状态信息的补充。

结束语

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

参考文档