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 的恢复方法,做到自动化修复问题。本篇博客的目标是:
- 实时监控 Amazon EKS 节点健康状态,检测内核错误、硬件故障、服务阻塞等底层问题;
- 自动化修复,对可自动恢复的问题(如节点 内存溢出、Docker 僵死等)触发自愈操作;
- 最小化停机时间,通过冗余设计、优雅驱逐 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 感知到问题并替换新的节点
通过如下命令,我们可以看到新的节点已经拉起并承载了业务。
发现问题后,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
本篇作者