本文提供的 Elasticsearch ES|QL 查询,快速定位 Kubernetes 集群中的崩溃循环、OOM 前夕的内存压力、节点资源饱和以及命名空间级别的错误尖峰。所有查询均基于 Elastic 发行的 OpenTelemetry Collector(EDOT)采集的数据,可 Discover 中运行,也可保存为仪表板面板或告警规则。
前置条件
- Elasticsearch 9.2 或更高版本(ES|QL 在 8.11 中引入,但 9.2 后的 TS 命令更完善)。
- EDOT Collector 已在集群中运行 ,并启用了以下接收器(receivers):
kubeletstats:采集 Pod、容器、节点的资源指标(CPU、内存、网络等)。k8s_cluster:采集 Kubernetes 对象状态(如 Pod 相位、容器重启次数)。filelog(配合k8sattributes处理器):采集容器日志,并添加上下文属性(命名空间、Pod 名等)。
EDOT 会将这些数据通过 OpenTelemetry 协议(OTLP)发送至 Elasticsearch,指标存储在 metrics-* 索引,日志存储在 logs-* 索引。
ES|QL 之 Kubernetes 监控
ES|QL 是一种管道式查询语言,它的核心思想是:从一个数据源开始,每一行追加一个操作------过滤、计算、聚合、排序------逐步精炼你的查询。这种结构天然适合故障排查,因为你可以像剥洋葱一样,层层缩小问题范围。
例如,一个典型的 ES|QL 查询流程如下:
esql
FROM metrics-* // 数据源
| WHERE @timestamp > NOW() - 1h // 时间过滤
| STATS count BY namespace // 聚合统计
| SORT count DESC // 排序
每行只做一件事,逻辑清晰,易于调试和复用。
TS 命令与时间序列
Kubernetes 指标绝大多数是计数器(Counter)或仪表盘(Gauge) ,它们随时间变化采样而来。使用 TS(Time Series)命令可以正确处理时间序列的语义:
- 对于 Gauge(如当前内存使用率),你关心的是最新值或峰值,而非平均值。
- 对于 Counter(如累计重启次数),你关心的是增量或最大值,而非对时间窗口内的所有样本求和(那样会得到一个无意义的巨大数字)。
TS 命令为每个时间序列(由维度如 pod、container 唯一标识)单独处理聚合函数,并支持 TBUCKET 对时间进行分桶。普通 FROM 则适用于计数去重、简单取最大值等不涉及时间窗口内序列间计算的场景。
EDOT 采集器与依赖字段
EDOT 采集的数据遵循 OpenTelemetry 语义约定,但具体的指标名称由接收器定义,而非语义规范本身。因此,不同接收器输出的字段名可能不同。
| 接收器 | 典型指标/字段 | 说明 |
|---|---|---|
kubeletstats |
k8s.container.memory_limit_utilization k8s.pod.cpu.node.utilization k8s.node.cpu.usage k8s.container.restarts |
资源利用率指标,来自 kubelet 的 API |
k8s_cluster |
k8s.pod.phase |
Pod 相位(编码为数字) |
filelog + k8sattributes |
body.text(日志内容) severity_text(日志级别) k8s.pod.name、k8s.namespace.name |
日志消息与上下文 |
注意 :如果你启用的接收器不同,或自定义了采集配置,某些字段可能缺失。请将下面的查询视为模板,根据实际索引映射调整字段名称。
集群概览:从全局开始
在深入具体问题之前,先把握集群的整体面貌。以下查询统计过去一小时内每个命名空间有多少个不同的 Pod 上报了指标:
esql
FROM metrics-*
| WHERE @timestamp >= NOW() - 1 hour
| STATS pod_count = COUNT_DISTINCT(k8s.pod.name) BY k8s.namespace.name
| SORT pod_count DESC
原理解释:
COUNT_DISTINCT会去重同一个 Pod 在时间窗口内产生的多个采样点,因此每个 Pod 只被计数一次。- 结果是一个"命名空间 --- Pod 数量"的排行榜,快速告诉你哪些命名空间负载最重,也能帮你发现预期运行的 Pod 是否"消失"了(如果某个命名空间计数为 0,说明它可能已缩容或没有被采集到)。
定位 Pod 重启与崩溃循环
重启是问题的第一信号 。k8s.container.restarts 是一个 Gauge 类型,报告每个容器的当前累计重启次数(该值会持续增长,直到容器被重新创建)。
以下查询列出过去 24 小时内重启次数最多的容器:
esql
FROM metrics-*
| WHERE @timestamp >= NOW() - 24 hours AND k8s.container.restarts IS NOT NULL
| STATS restarts = MAX(k8s.container.restarts)
BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| WHERE restarts > 0
| SORT restarts DESC
| LIMIT 20
原理解释:
MAX(k8s.container.restarts)取得该时间窗口内每个容器重启计数的最新值(因为 Gauge 的值会随时间更新,最大值即当前最新值)。- 如果一个容器的重启次数很高且在持续上升,这几乎是 CrashLoopBackOff 的教科书式信号。
- 拿到 Pod 名称后,你可以直接跳转到后面的日志查询,查看它崩溃前输出了什么。
发现非 Running 状态的 Pod
重启次数告诉你"过去发生过故障",而 Pod 相位(Phase) 告诉你"现在是否健康"。k8s.pod.phase 是一个编码为数字的 Gauge:
| 数值 | 相位 |
|---|---|
| 1 | Pending |
| 2 | Running |
| 3 | Succeeded |
| 4 | Failed |
| 5 | Unknown |
以下查询使用 TS 读取每个 Pod 的最新相位,并筛选出非 Running 的 Pod:
esql
TS metrics-*
| WHERE TRANGE(15m)
| STATS phase = MAX(LAST_OVER_TIME(k8s.pod.phase))
BY k8s.namespace.name, k8s.pod.name
| WHERE phase != 2
| EVAL phase_name = CASE(
phase == 1, "Pending",
phase == 3, "Succeeded",
phase == 4, "Failed",
phase == 5, "Unknown",
"Other")
| SORT phase_name
原理解释:
TRANGE(15m)限定时间范围为最近 15 分钟。LAST_OVER_TIME从每个 Pod 的时间序列中取出最新样本,确保我们比较的是当前状态而非平均值。MAX在这里只是辅助(因为每个 Pod 在最后一个时间点只有一个值),语法要求必须有聚合函数。- 结果中,Pending 的 Pod 通常表明调度问题(资源不足),Failed 或 Unknown 则需要立即关注。
在 OOM 发生前捕捉内存压力
OOM(Out-Of-Memory)Kill 是 Kubernetes 中最常见的故障之一,而且预防远胜于事后调试 。当你在 kubeletstats 接收器中启用了 limit 元数据(默认开启),EDOT 会上报 k8s.container.memory_limit_utilization,它表示容器内存使用量占其 limit 的比例(取值范围 0~1)。
以下查询找出过去一小时内容器内存使用峰值超过 85% limit 的容器:
esql
TS metrics-*
| WHERE TRANGE(1h)
| STATS peak_mem_pct = MAX(MAX_OVER_TIME(k8s.container.memory_limit_utilization))
BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| EVAL peak_mem_pct = ROUND(peak_mem_pct * 100, 1)
| WHERE peak_mem_pct > 85
| SORT peak_mem_pct DESC
原理解释:
MAX_OVER_TIME在每个容器的时间序列内部找出峰值(因为 Utilization 是 Gauge,随时间波动)。- 外层
MAX确保每个容器只输出一个最终峰值(由于TS可能产生多个时间分片,此处用MAX取最大值)。 - 如果一个容器的峰值反复超过 95%,那它极有可能成为下一个 OOM 牺牲品。
- 结合重启查询:如果一个容器同时满足"重启次数高"和"内存峰值高",几乎可以断定它因 OOM 被杀后重启了。
追踪 CPU 使用率与节点压力
CPU 问题通常表现为节流(Throttling)和响应变慢 ,而非崩溃。k8s.pod.cpu.node.utilization 指标报告每个 Pod 的 CPU 使用量占整个节点容量的比例(因此所有 Pod 的该值之和不应超过 1)。
5 分钟粒度下最繁忙的 Pod
esql
TS metrics-*
| WHERE TRANGE(1h)
| STATS avg_cpu = AVG(AVG_OVER_TIME(k8s.pod.cpu.node.utilization))
BY k8s.pod.name, TBUCKET(5m)
| SORT avg_cpu DESC
TBUCKET(5m)将时间划分为 5 分钟桶,每个桶内AVG_OVER_TIME对每个 Pod 的时间序列求平均,外层AVG再合并同名 Pod(因为一个 Pod 可能包含多个容器,但其 Pod 级指标只有一个时间序列)。- 结果可渲染为时间序列图,直观展示 CPU 使用高峰。
节点级别的资源饱和检查
若要判断节点本身是否过载,直接查询节点指标:
esql
TS metrics-*
| WHERE TRANGE(1h)
| STATS cpu = AVG(AVG_OVER_TIME(k8s.node.cpu.usage)),
mem = AVG(LAST_OVER_TIME(k8s.node.memory.usage))
BY k8s.node.name, TBUCKET(5m)
| SORT cpu DESC
k8s.node.cpu.usage是节点 CPU 使用率(百分比,通常 0~1),k8s.node.memory.usage是内存使用率。- 如果某个节点的 CPU 长期接近 100%,那它上面所有 Pod 都会受到节流影响,这很容易被误认为是应用程序自身的问题。
深入容器日志排查
当指标将你指向某个 Pod 后,日志 会告诉你它当时在做什么。EDOT 将日志消息存储在 body.text 字段,日志级别存储在 severity_text,同时附带了 k8s.* 上下文字段(与指标一致)。
按错误量对 Pod 排名
esql
FROM logs-*
| WHERE @timestamp >= NOW() - 1 hour
AND severity_text IN ("ERROR", "FATAL")
| STATS errors = COUNT(*)
BY k8s.namespace.name, k8s.pod.name
| SORT errors DESC
| LIMIT 20
- 这个查询帮你快速识别错误集中的 Pod。如果错误分布均匀,可能是集群级问题;如果集中在某个 Pod,则是该工作负载的个例。
查看特定 Pod 的日志(带关键词过滤)
esql
FROM logs-*
| WHERE @timestamp >= NOW() - 1 hour
AND k8s.pod.name == "checkout-<your-hash>"
AND body.text LIKE "*timeout*"
| KEEP @timestamp, severity_text, body.text
| SORT @timestamp DESC
| LIMIT 50
LIKE "*timeout*"执行通配符匹配。若想进行全文相关性搜索,可替换为MATCH(body.text, "timeout")。KEEP只保留你关心的字段,使结果更清晰。
组合查询:一次 Kubernetes 事故的完整调查链
实际运维中,我们通常串联多个查询,而非孤立运行。典型的调查循环如下:
#mermaid-svg-IUIBKrW7SDfXk7v7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IUIBKrW7SDfXk7v7 .error-icon{fill:#552222;}#mermaid-svg-IUIBKrW7SDfXk7v7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IUIBKrW7SDfXk7v7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .marker.cross{stroke:#333333;}#mermaid-svg-IUIBKrW7SDfXk7v7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IUIBKrW7SDfXk7v7 p{margin:0;}#mermaid-svg-IUIBKrW7SDfXk7v7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster-label text{fill:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster-label span{color:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster-label span p{background-color:transparent;}#mermaid-svg-IUIBKrW7SDfXk7v7 .label text,#mermaid-svg-IUIBKrW7SDfXk7v7 span{fill:#333;color:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .node rect,#mermaid-svg-IUIBKrW7SDfXk7v7 .node circle,#mermaid-svg-IUIBKrW7SDfXk7v7 .node ellipse,#mermaid-svg-IUIBKrW7SDfXk7v7 .node polygon,#mermaid-svg-IUIBKrW7SDfXk7v7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .rough-node .label text,#mermaid-svg-IUIBKrW7SDfXk7v7 .node .label text,#mermaid-svg-IUIBKrW7SDfXk7v7 .image-shape .label,#mermaid-svg-IUIBKrW7SDfXk7v7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-IUIBKrW7SDfXk7v7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .rough-node .label,#mermaid-svg-IUIBKrW7SDfXk7v7 .node .label,#mermaid-svg-IUIBKrW7SDfXk7v7 .image-shape .label,#mermaid-svg-IUIBKrW7SDfXk7v7 .icon-shape .label{text-align:center;}#mermaid-svg-IUIBKrW7SDfXk7v7 .node.clickable{cursor:pointer;}#mermaid-svg-IUIBKrW7SDfXk7v7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .arrowheadPath{fill:#333333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IUIBKrW7SDfXk7v7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IUIBKrW7SDfXk7v7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IUIBKrW7SDfXk7v7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster text{fill:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 .cluster span{color:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IUIBKrW7SDfXk7v7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IUIBKrW7SDfXk7v7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-IUIBKrW7SDfXk7v7 .icon-shape,#mermaid-svg-IUIBKrW7SDfXk7v7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IUIBKrW7SDfXk7v7 .icon-shape p,#mermaid-svg-IUIBKrW7SDfXk7v7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IUIBKrW7SDfXk7v7 .icon-shape .label rect,#mermaid-svg-IUIBKrW7SDfXk7v7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IUIBKrW7SDfXk7v7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IUIBKrW7SDfXk7v7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IUIBKrW7SDfXk7v7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
按错误量排名 Pod
检查目标 Pod 的重启次数和内存峰值
是否因 OOM 重启?
查看该 Pod 的日志,搜索 OOM 相关关键词
查看日志,搜索异常堆栈或超时
确认问题根因并采取措施
因为所有查询都使用相同的 k8s.namespace.name 和 k8s.pod.name 字段,你可以将 Pod 名称从一个查询直接复制到下一个。这种一致性也方便构建联动仪表板------点击某个命名空间或 Pod,所有面板同步过滤。
将查询升级为告警与仪表板
本文中任何生成聚合值 的查询都可以转化为告警规则,而不仅仅是临时排查工具。
以重启查询为例,若要监控"15 分钟内重启超过 5 次"的情况,只需稍作调整:
esql
FROM metrics-*
| WHERE @timestamp >= NOW() - 15 minutes AND k8s.container.restarts IS NOT NULL
| STATS restarts = MAX(k8s.container.restarts)
BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| WHERE restarts >= 5
将此查询配置为 Elasticsearch 查询规则(Elastic 的告警框架),当结果非空时触发告警。你就能在用户发现之前,第一时间收到通知。
同样的模式可应用于:
- 内存利用率 > 90%
- 节点 CPU > 95%
- 错误日志数 > 阈值
- Pod 相位非 Running
总结:构建你的 ES|QL 监控工具包
ES|QL 用一种统一的语法,覆盖了 Kubernetes 所有的可观测信号:
- 对象状态(Pod 相位、重启次数)
- 资源指标(CPU、内存、网络)
- 容器日志(错误、堆栈、事件)
| 查询类型 | 关键函数 | 适用场景 |
|---|---|---|
| 集群概览 | COUNT_DISTINCT |
查看命名空间负载 |
| 重启排行 | MAX(Gauge) |
发现崩溃循环 |
| 非运行 Pod | LAST_OVER_TIME |
检查当前健康状态 |
| 内存压力 | MAX_OVER_TIME |
预测 OOM 风险 |
| CPU 使用 | AVG_OVER_TIME + TBUCKET |
追踪资源热点 |
| 错误日志排名 | COUNT(*) |
定位故障 Pod |
| 日志详情 | LIKE / MATCH |
深入排查根因 |
开始你的监控之旅吧:先用概览查询熟悉集群面貌,然后将重启、相位、内存、CPU 和日志查询保存为常用工具。当事故发生时,按调查链串联使用。再进一步,将高频查询制作成仪表板,将关键阈值升级为告警。
小贴士:根据你实际启用的接收器,某些指标字段名可能有所不同。请查阅 EDOT 文档,调整查询中的字段名称。但核心逻辑和聚合函数是通用的,足以应对大多数场景。
现在,你已经拥有了一套。祝排查愉快!🚀