亚马逊AWS官方博客

基于节点问题检测器(Node Problem Detector)监视和报告 Amazon EKS 节点的健康状况和自动恢复

1. 概述

在使用 Amazon Elastic Kubernetes Service (Amazon EKS) 构建容器化应用集群的过程中,需要使用托管节点组(Managed Node Groups)自管理节点(Self-Managed Nodes)来完成节点自动部署和生命周期管理。但在使用过程中,经常出现节点出于种种问题进而影响在其之上运行的pod,这些问题包括但不限于:

  • 基础架构守护进程问题,如 NTP (Network Time Protocol) 服务故障等;
  • 硬件问题,如 CPU 故障、内存故障和磁盘故障等;
  • 内核问题,如内核死锁、文件系统崩溃等;
  • 容器运行时问题,如运行时守护进程不响应等;

实际使用 Amazon EKS/K8S 的客户中,可能会发现如上问题导致的进一步 EKS/K8S 运维问题,包括但不限于:

  • EKS/K8S 节点发生上述故障,但由于 EKS/K8S 控制面和数据面问题发现机制,导致客户运维团队不能及时发现问题;
  • EKS/K8S 节点发生上述故障,客户运维团队未能及时发现,而一段时间后EKS/K8S 自动修复该问题,导致原先出现问题的节点被替换,反映问题的消息和日志无从查起,从而不能避免下次问题的发生;
  • EKS/K8S 节点发生上述故障,客户运维团队发现问题后,需要紧急手动驱逐问题节点上运行的 pod 后,进行手动节点替换工作,耗时耗力;

2024 年 10 月,Kubernetes 社区发布节点问题检测器(Node Problem Detector, NPD)v0.8.20 版本。NPD 是一个守护程序,用于监视和报告节点的健康状况。您可以将 NPD 以 DaemonSet 或独立守护程序运行。NPD 从各种守护进程收集节点问题,并以节点 Condition 和 Event 的形式报告给 API  Server。本篇博客将会展示如何在 Amazon EKS 集群上应用 NPD,以解决节点故障问题带来的进一步业务影响,提高系统稳定性。同时,面对 NPD 检测出的节点故障,本篇博客还会介绍一种基于 Karpenter 的恢复方法,做到自动化修复问题。本篇博客的目标是:

  1. 实时监控 Amazon EKS 节点健康状态,检测内核错误、硬件故障、服务阻塞等底层问题;
  2. 自动化修复,对可自动恢复的问题(如节点 内存溢出、Docker 僵死等)触发自愈操作;
  3. 最小化停机时间,通过冗余设计、优雅驱逐 Pod 等机制保障服务高可用。

2. NPD 运行原理

NPD 是在每个 Kubernetes 节点上运行的守护进程,用于检测节点故障并将故障信息发送至 API Server。NPD 守护进程由多个子守护进程(Problem Daemon)组成,这些子守护进程用于实际在节点上发现某种特定的故障和问题(如 KernelMonitor 监控内核日志并根据规则发现内核已知问题),并由 NPD 对这些子守护进程上报的问题进行汇总并发送至 API Server。这些问题会以节点状态(NodeCondition,NodeStatus 的一部分)和事件(Event)的方式进行上报。一般情况下,持续的问题(如 KernelDeadLock、DockerHung、BadDisk 等)会以节点状态方式进行上报,临时问题(内存溢出等)会以事件方式上报。问题上报后由运行在控制平面的恢复控制器(Remedy Controller)进行问题自动修复。但值得注意的是,NPD 开源项目曾明确指出,由于各种节点故障的特殊性和对环境的依赖性,恢复控制器并不在开发计划中。这也是本篇博客的意义之一,即通过 NPD+Recovery+Karpenter 的形式对问题进行快速修复。

NPD 架构图示意

3. 在 Amazon EKS 托管节点上安装部署 NPD 和测试

3.1 使用 Helm 安装 NPD

Helm可以帮助用户安装管理 Kubernetes 集群上的应用。本篇博客使用 Helm 安装 NPD,所以在此之前您需要配置好 Helm 环境。配置好 Helm 环境后,使用下面 Helm 命令安装 NPD。

$ helm repo add deliveryhero https://charts.deliveryhero.io/
$ helm install --generate-name deliveryhero/node-problem-detector

运行安全完成后,通过如下命令,验证安装是否成功,查看所有 pod 包括 NPD pod 的运行状态是否正常。

$ kubectl get daemonset -n default node-problem-detector-<ID>
$kubectl get pods -n default -l app=node-problem-detector

如果运行正常,则可以在输出中看到 NPD daemonset 运行正常,pod 运行正常。

3.2 测试环境准备

下面通过模拟故障的方式,测试 NPD 是否能够及时发现问题。本篇博客中,我们用如下方式,模拟内核问题,其他问题进行模拟的步骤和方法类似。演示测试中,EKS 集群的初始状态有两个节点,分别为节点 A(示例中为 ip-172-31-16-177.cn-northwest-1.compute.internal)和节点 B(示例中为 ip-172-31-4-172.cn-northwest-1.compute.internal),节点类型均为 t3.medium。之后,把负责节点扩缩容的 Karpenter pod 部署在一个此次测试中不会下线的节点 A 上。

EKS 控制台显示 EKS 集群的两台初始节点 A 和 B

登陆节点 A,使用如下命令,可以看到节点 A 中运行的 Karpenter pod,以及 EKS 中运行的两个节点 A 和 B。

$ kubectl get pod
$kubectl get node

Karpenter pod 在节点 A 中出于运行状态

EKS 集群中运行的两个节点 A 和 B

此时,为了对比问题发现的效果,我们暂时不引入 NPD,可以看到上面 pod 的截图中,并没有 NPD daemonset 对应的 pod,且下图中也展示了 NPD 的 Current NPD pod 的 Daemonsets 为 0。

集群中暂时没有 NPD pod 运行

3.3 测试故障注入

在节点 B(ip-172-31-4-172.cn-northwest-1.compute.internal)里,通过如下命令的方式,注入错误(内核问题)。

$ sudo sh -c "echo 'kernel: BUG: unable to handle kernel NULL pointer dereference at TESTING' >> /dev/kmsg"

在节点 B 中模拟内核问题注入

故障注入后,通过运行“kubectl get events –w”或者“kubectl describe node ip-172-31-4-172.cn-northwest-1.compute.internal”命令的结果,都未发现对应的事件 Events。上面的测试说明,在未引入 NPD 的场景下,EKS/K8S 控制平面无法及时检测到节点上发生的问题,此时的节点 B 还是处于 Ready 的状态。

3.4 引入 NPD 以快速发现问题

在后续的演示中,我们使用 NPD 官方镜像(如下)启动 NPD(此次演示环境中我们使用 v0.8.19 版本),检测节点的问题。

registry.k8s.io/node-problem-detector/node-problem-detector:v0.8.19

NPD 做为 daemonset 在节点 A 和节点 B 上运行。在后续测试中需要注意的是,其中节点 B 上的 NPD pod 的 IP 地址是 172.31.6.237,有别于节点 IP 地址。

在节点 B 中运行的 NPD pod 状态信息

此次有了 NPD 的引入,我们再次在节点 B 上注入错误如下,方法和以前一致。

在引入 NPD 的情况下再次向节点 B 注入内核故障

问题注入后,我们同步通过如下命令,查看结果。

$ kubectl describe node ip-172-31-4-172.cn-northwest-1.compute.internal
$ kubectl get events –w

kubectl describe node 结果显示 NPD 立刻发现了内核问题

kubectl get events 结果显示 NPD 立刻发现了内核问题

此时 EKS/K8S 控制平面能检测到节点的问题,并且在节点 B 上运行如下命令,也可以看到指标 problem_counter{reason=”KernelOops”}从 0 变成了 1,代表 NPD 马上发现了故障。

$ curl 172.31.6.237:20257/metrics

查看 problem_counter 指标显示 NPD 立刻发现了内核问题

至此,我们探讨并测试了 NPD 检测节点的错误的能力和及时性,但 NPD 只提供节点问题及时检测的能力,并没有发现问题后快速恢复节点的能力。这种情况下,EKS/K8S 中 Karpenter 是无法快速感知这个问题,从而快速恢复节点的。在后续章节中,我们详细讨论在不等待自动清除问题的前提下,结合 NPD 的节点问题及时发现能力,和 Karpenter 组件的节点快速修复能力,来主动且自动地修复节点上发生的问题。

4. 基于 Karpenter 的节点问题自动修复

Node-problem-detector pod 中有两个容器:其中一个是主要容器 node-problem-detector:v0.8.19,用来检测节点的问题,另一个容器是 node-recovery 容器,该容器会监控主要容器 node-problem-detector:v0.8.19 推送到 node-problem-detector pod 20257 端口上的/metrics 中的指标,然后根据环境变量 name: ENABLE_RECOVERY 来触发节点的修复动作。环境变量 name: ENABLE_RECOVERY 为自定义参数,定义是否开启 Karpenter 联动自动修复节点功能,详细请参考后续章节中的 yaml 文件示例。

4.1 NPD 演示环境部署

此次测试演示中,我们启动 NPD,且使用环境变量 name: ENABLE_RECOVERY 为 true。

设置环境变量后可以看到 NPD daemonset 对应的 pod 被重启

4.2 查看节点初始状态

此时,我们可以看到 NPD 的初始指标 problem_counter{reason=”KernelOops”}为 0,代表当前没有检测到故障发生。

指标 problem_counter 为 0

并且,我们可以通过如下命令,观测 Karpenter 日志,发现暂无输出,代表当前没有检测到故障发生。

$ kubectl logs pod/karpenter-5b9f979f98-5kgm9 -n kube-system --tail 10 -f

Karpenter 日志无输出

4.3 注入节点错误并关注指标和日志的变化

登陆至 B 节点,我们再次模拟注入节点内核故障,并在 NPD 中可以发现问题被检测。

NPD 检测到节点故障

通过查看“kubectl describe node ip-172-31-4-172.cn-northwest-1.compute.internal”命令查看到问题被检测到,并且节点被设置为 NodeNotSchedulable,表明不会再有新的 pod 被调度到该问题节点上。

节点状态被设置为 NodeNotSchedulable

通过如下命令查看 Karpenter 日志,我们可以看到,NPD 发现问题后,借助 node-recovery 容器的能力,Karpenter 马上感知到了问题,并且自动创建了一个新的节点 C(示例中为 ip-172-31-9-95.cn-northwest-1.compute.internal,节点类型为 c5a.large),并由该节点 C 承载了业务 pod,从而达到最小化业务停机的目的。

$ kubectl logs pod/karpenter-5b9f979f98-5kgm9 -n kube-system --tail 10 -f

Karpenter 感知到问题并替换新的节点

通过如下命令,我们可以看到新的节点已经拉起并承载了业务。

$ kubectl get node:

发现问题后,Karpenter 拉起新的节点并承载业务 pod

4.4 注入节点错误后恢复结果

问题注入后,除了日志输出,在 EKS 控制台上,我们也可以看到 c5a.large 类型的新节点被 Karpenter 自动创建并承载业务 pod,原先出现问题的节点已经在 EKS 控制台上被替换。如果需要进一步在原先出问题节点上进行排错,则可以在 EC2 控制台上看到这台机器,来进一步排查问题。

EKS 控制台上可以看到新创建的节点

新创建的节点已经开始承载业务 pod(inflate pod)

4.5 该方法的意义和先进性

本文演示测试中,使用了 NPD 来及时发现节点问题,然后使用 Karpenter 快速感知到该问题从而尽快进行节点恢复。而之所以可以让 EKS/K8S 中的 Karpenter 可以快速感知,是因为在代码实现中,我们在 node-recovery 容器中的代码逻辑添加了检测到问题后马上给节点打污点的能力,从而迅速驱逐问题节点上运行的 pod。这些 pod 被驱逐后,在没有足够的节点来承载驱逐出的 pod 时,会导致很多 pending pods,这样的话 Karpenter 感知到 pending pods,从而立刻创建出新节点以供 pod 调度,快速拉起业务 pod,尽快恢复业务。

5. 测试演示环境中的 yaml 文件示例

本文测试演示环境中,所使用的 yaml 文件示例提供如下。

5.1 Karpenter 的 npd-ds-recovery.yaml 文件示例

本文中所使用的 Karpenter 的 NPD 的 yaml 示例文件为/home/ec2-user/efs/work/eks/node-problem-detector/node-problem-detector/npd-ds-recovery.yaml。示例如下:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  annotations:
    deprecated.daemonset.template.generation: "3"
    meta.helm.sh/release-name: node-problem-detector-1741329716
    meta.helm.sh/release-namespace: default
  labels:
    app.kubernetes.io/instance: node-problem-detector-1741329716
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: node-problem-detector
    helm.sh/chart: node-problem-detector-2.3.14
  name: node-problem-detector-1741329716
  namespace: default
spec:
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: node-problem-detector
      app.kubernetes.io/instance: node-problem-detector-1741329716
      app.kubernetes.io/name: node-problem-detector
  template:
    metadata:
      annotations:
        checksum/config: 28031c045da5b777aa136c8757423b9c752b9ba5bcf22d3b7fa7cc6f55618200
      labels:
        app: node-problem-detector
        app.kubernetes.io/instance: node-problem-detector-1741329716
        app.kubernetes.io/name: node-problem-detector
    spec:
      containers:
      - command:
        - /bin/sh
        - -c
        - 'exec /node-problem-detector --logtostderr --config.system-log-monitor=/config/kernel-monitor.json,/config/docker-monitor.json  --prometheus-address=0.0.0.0
          --prometheus-port=20257 --k8s-exporter-heartbeat-period=5m0s  '
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: spec.nodeName
        image: <Account ID>.dkr---ecr---cn-northwest-1.amazonaws.com.rproxy.govskope.ca.cn/node-problem-detector:v0.8.19
        imagePullPolicy: IfNotPresent
        name: node-problem-detector
        ports:
        - containerPort: 20257
          name: exporter
          protocol: TCP
        resources: {}
        securityContext:
          privileged: true
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /var/log/
          name: log
          readOnly: true
        - mountPath: /etc/localtime
          name: localtime
          readOnly: true
        - mountPath: /custom-config
          name: custom-config
          readOnly: true
      - name: node-recovery
        command:
        - /bin/sh
        - -c
        - "sleep 60 && python3 /scripts/check-health.py"
        image: <Account ID>.dkr---ecr---cn-northwest-1.amazonaws.com.rproxy.govskope.ca.cn/npd-recovery-karpenter:latest
        resources:
          limits:
            cpu: 10m
            memory: 150Mi
          requests:
            cpu: 10m
            memory: 150Mi
        imagePullPolicy: Always
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
        - name: ENABLE_RECOVERY
          value: "true"
      dnsPolicy: ClusterFirst
      priorityClassName: system-node-critical
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      serviceAccount: node-problem-detector-1741329716
      serviceAccountName: node-problem-detector-1741329716
      terminationGracePeriodSeconds: 30
      tolerations:
      - effect: NoSchedule
        operator: Exists
      volumes:
      - hostPath:
          path: /var/log/
          type: ""
        name: log
      - hostPath:
          path: /etc/localtime
          type: FileOrCreate
        name: localtime
      - configMap:
          defaultMode: 493
          name: node-problem-detector-1741329716-custom-config
        name: custom-config
  updateStrategy:
    rollingUpdate:
      maxSurge: 0
      maxUnavailable: 1
    type: RollingUpdate

5.2 Karpenter 的 npd-ds-recovery.yaml 文件中的 checkhealth.py 文件示例

上述配置文件中,pod 中的 node-recovery 容器的 checkhealth 程序为 python 代码编写。示例如下:

/home/ec2-user/efs/work/eks/node-problem-detector/node-problem-detector/scripts/checkhealth.py
#!/usr/bin/env python3
import os
import time
import requests
import boto3
import structlog
from kubernetes import client, config
from kubernetes.client.rest import ApiException

PROMETHEUS_URL = "http://localhost:20257/metrics"
LOGGER = structlog.get_logger()

def is_node_unhealthy():
    """检查 Prometheus 指标判断节点是否故障"""
    try:
        metrics = requests.get(PROMETHEUS_URL).text
        # 检测关键指标
        error_types = [
            'problem_counter{reason="KernelOops"}',
            'problem_counter{reason="DockerHung"}',
            'problem_counter{reason="FilesystemError"}'
        ]
        for metric in error_types:
            if f'{metric} 1' in metrics:
                return True
        return False
    except Exception as e:
        LOGGER.error(f"指标检查失败: {e}")
        return False

def cordon_and_drain(node_name):
    """标记节点不可调度并优雅驱逐 Pod"""
    config.load_incluster_config()
    core_v1 = client.CoreV1Api()

    # 标记节点为不可调度
    try:
        core_v1.patch_node(node_name, {"spec": {"unschedulable": True}})
        LOGGER.info(f"节点 {node_name} 已标记为不可调度")
    except ApiException as e:
        LOGGER.error(f"标记节点失败: {e.reason}")

    # 获取节点上所有 Pod(排除 DaemonSet)
    try:
        pods = core_v1.list_pod_for_all_namespaces(
            field_selector=f"spec.nodeName={node_name}"
        ).items
        
        for pod in pods:
            # 跳过 DaemonSet Pod
            if pod.metadata.owner_references:
                for ref in pod.metadata.owner_references:
                    if ref.kind == "DaemonSet":
                        LOGGER.info(f"跳过 DaemonSet Pod: {pod.metadata.name}")
                        continue

            # 执行优雅驱逐
            eviction_body = client.V1Eviction(
                metadata=client.V1ObjectMeta(
                    name=pod.metadata.name,
                    namespace=pod.metadata.namespace
                ),
                delete_options=client.V1DeleteOptions(
                    grace_period_seconds=30
                )
            )
            try:
                core_v1.create_namespaced_pod_eviction(
                    name=pod.metadata.name,
                    namespace=pod.metadata.namespace,
                    body=eviction_body
                )
                LOGGER.info(f"已驱逐 Pod: {pod.metadata.namespace}/{pod.metadata.name}")
            except ApiException as e:
                LOGGER.error(f"驱逐失败: {e.reason}")

    except ApiException as e:
        LOGGER.error(f"获取 Pod 列表失败: {e.reason}")

def trigger_karpenter_replacement(node_name):
    """通过删除节点触发 Karpenter 自动补充"""
    config.load_incluster_config()
    core_v1 = client.CoreV1Api()
    try:
        core_v1.delete_node(node_name)
        LOGGER.info(f"节点 {node_name} 已删除")
    except ApiException as e:
        LOGGER.error(f"删除节点失败: {e.reason}")

def main():
    node_name = os.getenv("NODE_NAME")
    if not node_name:
        LOGGER.error("未找到 NODE_NAME 环境变量")
        return

    if is_node_unhealthy():
        LOGGER.info(f"检测到节点 {node_name} 故障,触发恢复")
        if os.environ.get("ENABLE_RECOVERY", "false").lower() != "true":
            LOGGER.warning("恢复功能未启用 (ENABLE_RECOVERY != true)")
            return
        cordon_and_drain(node_name)
        trigger_karpenter_replacement(node_name)
    else:
        LOGGER.info("节点状态正常")

if __name__ == "__main__":
    while True:
        main()
        time.sleep(30)

5.3 ECR 镜像仓库的构建

在上述配置文件中使用了 ECR (Amazon Elastic Container Registry) 镜像仓库。可使用如下方法构建 ECR 镜像仓库。

$ cat Dockerfile FROM public.ecr.aws/docker/library/python:3.9-slim
# Install required dependencies
RUN pip install kubernetes boto3 requests structlog ec2-metadata -i https://mirrors.aliyun.com/pypi/simple/

# Copy recovery scripts
COPY scripts/check-health.py /scripts/

# Set the entrypoint to execute the health check script
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["python3 /scripts/check-health.py"]

根据上面的 Dockerfile 去构建 docker 镜像,之后使用下面的命令推送到 ECR 镜像仓库中。

$ aws ecr get-login-password --region cn-northwest-1 | docker login --username AWS --password-stdin <Account ID>.dkr---ecr---cn-northwest-1.amazonaws.com.rproxy.govskope.ca.cn
$ docker build -t npd-recovery-karpenter .
$ docker tag npd-recovery-karpenter:latest <Account ID>.dkr---ecr---cn-northwest-1.amazonaws.com.rproxy.govskope.ca.cn/npd-recovery-karpenter:latest
$ docker push <Account ID>.dkr---ecr---cn-northwest-1.amazonaws.com.rproxy.govskope.ca.cn/npd-recovery-karpenter:latest

6. 结语

本文演示了如何在 Amazon EKS 节点上部署搭建 NPD 组件,以便快速发现节点问题,并通过 Karpenter 方式自动修复发现的问题,保证业务影响最小化。

参考链接

[1] https://kubernetes.io/docs/tasks/debug/debug-cluster/monitor-node-health/

[2] https://github.com/kubernetes/node-problem-detector

本篇作者

王旭

西云数据高级技术客户经理,致力于容器技术的研究和落地,为亚马逊云科技中国客户提供企业级架构和技术支持。

张旭

西云数据技术支持工程师,拥有 10+ 年复杂问题的解决经验,精通 Amazon EKS、Amazon code 系列等亚马逊云科技服务,擅长在 DevOps 领域为客户解决各种疑难问题。