这次要聊的是 Zabbix 7.0 监控 Elasticsearch 7.9.2 的完整实战。我们线上有两套 ES 集群,一套是三个主节点加五个数据节点的业务集群,日增数据 150GB 左右;另一套是日志检索集群,节点数量更多。Zabbix 7.0 是我们的监控底座,部署在 CentOS 9 + MySQL 8.0 上,监控项接近五千个。刚开始我并没有直接套社区模板,而是花时间把“Template App Elasticsearch 7.9.2”这套监控模板从指标选型、采集脚本到触发器阈值全部自己过了一遍,最后沉淀成一套可导出可复用的模板。这篇文章就是那次完整过程的复盘,核心内容偏向落地实操,适合正在维护 ES 集群、需要做监控方案的运维同事,也适合刚接触 Zabbix 高级模板定制的朋友。
1. 先想清楚监控什么:ES 7.9.2 的核心监控面
1.1 集群层:健康状态不能只看绿灯
很多人接 ES 监控第一反应就是查_cluster/health,看到 status 是 green 就觉得万事大吉。实际上这个绿灯只代表“分片都分配到位了”,不代表集群没有隐患。我通常会同时盯住几个关键字段:status、active_shards_percent、unassigned_shards、number_of_pending_tasks、task_max_waiting_in_queue_millis。
一个典型的_cluster/health返回大概是这样的:
{ "cluster_name": "biz-es", "status": "yellow", "timed_out": false, "number_of_nodes": 8, "number_of_data_nodes": 5, "active_primary_shards": 216, "active_shards": 428, "relocating_shards": 0, "initializing_shards": 0, "unassigned_shards": 12, "number_of_pending_tasks": 0, "task_max_waiting_in_queue_millis": 0 }status 的状态我用一个通俗类比解释:green 是“每一本书都有正本和副本在手”,yellow 是“有正本但副本丢了,还能读,但一旦正本所在机器挂掉,数据就危险”,red 是“某些正本都没了,对应索引的数据读不出来了”。所以 red 一定是最高级别告警,yellow 也不是可以忽略的小事,它说明副本健康度已经出问题。
再看 unassigned_shards,这个字段如果长期大于零,要么是副本分配不出去,要么是磁盘水位线卡住了分配。number_of_pending_tasks 反映集群级任务队列堆积情况,如果它持续大于 0,配合 task_max_waiting_in_queue_millis 能看到集群在执行迁移、创建索引等元数据操作时卡了多久。我见过一次 pending_tasks 堆到几十个、等待时间超过 3 秒的情况,那时候集群虽然 status 还是 green,但新索引创建已经明显变慢,业务侧不断报错。所以集群层不是只看一个状态,要看一组指标。
1.2 节点层:JVM、线程池、磁盘才是故障源头
ES 是 Java 应用,JVM 堆内存是最核心的监控面。我用一个水桶来类比堆内存:堆是带 GC 缓冲的水桶,业务请求往里倒水,GC 是自动往外倒水的过程。如果堆长期保持在高水位,说明 GC 一直在加班,但业务量还在往里灌水,迟早 full GC 卡住整个节点。所以节点上必须监控 heap_used_percent,更建议直接看 GC 老年代(old)的 collection_count,它能反映 full GC 的频率。
GC 相关指标从/_nodes/_local/stats/jvm拿,老年代的 collection_count 是一个累计计数器。监控它要的不是原始值,而是单位时间内的增量。Zabbix 里我会用 change() 函数比较两个采集周期的差值,这个后面讲触发器时会细说。
线程池也是 ES 节点很典型的故障点。ES 有 search、write、get、management 等线程池,每个都有 queue 和 rejected 两个关键计数器。queue 表示正在排队的任务数量,rejected 表示队列满了之后直接丢弃的请求。线上最常见的场景是大量 bulk 写入和搜索请求争抢线程池,一旦 write 队列堆积到几百,随后就会出现 rejected,业务侧表现就是写入超时、批量索引失败。
磁盘水位对 ES 来说不是“满了再处理”的问题,而是有硬性规定:cluster.routing.allocation.disk.watermark.low 默认是 85%,达到这个值后集群会停止往该节点分配新分片;high 默认 90%,开始尝试把分片搬到其他节点;flood_stage 默认 95%,此时索引会被强制设为只读。所以监控最好在 80% 左右就开始告警,留出人工干预时间。我习惯用磁盘可用空间百分比来做监控项,低于 15% 触发告警,低于 10% 触发高级别告警。
文件描述符这个指标很容易被忽略。ES 的官方建议是 ulimit -n 至少 65535,生产环境一般都得几万。文件描述符耗尽的表现很迷惑:连接断断续续、线程池报错、shard 打开失败,排查半天都找不到根源。直接从/_nodes/_local/stats/process里取 open_file_descriptors 和 max_file_descriptors 的比值,就是 fd 使用率。建议使用率超过 80% 触发告警。
1.3 索引层:写入吞吐、查询延迟与分段
集群层和节点层能回答“ES 还活着吗、扛得住吗”,但回答不了“业务到底在干什么”。索引层主要看两类数据:吞吐量和容量变化。
吞吐量从/_nodes/_local/stats/indices取:search.query_total、indexing.index_total、docs.count、store.size_in_bytes。注意这些都是累计值,Zabbix 里用 rate() 内置函数转成每秒速率,才能看到真实的查询 QPS 和写入吞吐。我在模板里为每个节点都开了一组索引吞吐监控项,方便对比数据节点之间的负载是否均衡。
查询延迟建议尽量不要依赖 ES 自身统计,因为 ES 没有“p95 查询耗时”这种现成指标,硬凑出来的结果也不准确。更可靠的做法是配合应用侧监控,比如 SpringBoot 应用在调用 ES 的时候自己埋点统计,或者开启 Search Slow Log 的阈值配置。监控模板里我会把慢查询熔断相关的配置放进来,但不会用 ES API 去推延迟指标。
segment 数量值得关注,特别是频繁 update 和 delete 的索引。segment 太多会导致查询时要扫描更多的小段文件,CPU 和 IO 都会上升。ES 会自动合并 segment,但如果合并速度跟不上生成速度,segment 会持续增长。生产上对大索引可以定期 force merge,而监控要做的是观察 segment 数量有没有异常攀升。这里有个经验:不要对所有索引都做逐项发现式监控。线上可能有几百个索引,如果 LLD 把每个索引都创建 5 个监控项,监控项数量会爆炸,Zabbix 的数据库压力也会变大。我一般只对匹配业务前缀的索引做发现,比如 log-access-、biz-order-,并且控制索引总数上限。
2. 模板怎么落地:从指标到 Zabbix 配置项
2.1 模板结构:绑定方式与应用集拆分
Zabbix 7.0 里加监控项不难,难的是把模板设计得清晰、可复用。我建议把 ES 监控模板直接命名为Template App Elasticsearch 7.9.2,绑定方式是“每台 ES 节点主机都分配这个模板”。这样做的理由是:ES 的很多指标都只能通过 API 查询,而 API 往往只返回当前节点或者整个集群的数据;如果用一个模板绑定到一台独立主机去拉全部节点,脚本要处理和区分所有节点的返回,复杂度高不少。反过来,把模板绑定到每台节点,用_nodes/_local只拉本机数据,每台主机的 Zabbix agent 只需处理自己这台机器的数据,逻辑简单、故障隔离也更好。
模板内部我按应用集拆分,方便在图形和仪表盘里统一引用:
| 应用集 | 覆盖内容 | 典型监控项 |
|---|---|---|
| Elasticsearch Cluster | 集群级健康状态 | status、active_shards_percent、unassigned_shards、pending_tasks |
| Elasticsearch Node | 节点级系统与内核指标 | JVM 堆、线程池、文件描述符、GC |
| Elasticsearch Index | 索引级吞吐和容量 | query QPS、indexing QPS、docs 数、store size |
| Elasticsearch Disk | 磁盘相关 | fs available percent、total/used bytes |
| Elasticsearch OS | 操作系统基础 | CPU、内存、负载(由原生模板补充,按需启用) |
图形原型和仪表盘里我直接按应用集过滤,不需要反复手选监控项。另外模板里我会把 OS 层的 CPU 负载、可用内存这些指标交给 Zabbix 原生模板去管,ES 模板只管 ES 相关的 API 指标,两者互不干扰。
2.2 宏与阈值参数化:一套模板服务多套集群
模板里最重要的一件事是把所有可变参数抽象成宏。没有宏的模板是一个个硬编码 IP 和阈值,有了宏,同一套模板可以直接用在业务集群、日志集群、测试环境,只需在主机上覆盖不同的宏值即可。
我设计的核心宏如下:
| 宏名 | 默认值 | 说明 |
|---|---|---|
| {$ES.URL} | http://127.0.0.1:9200 | ES API 地址,绑定不同主机时覆盖 |
| {$ES.USER} | 空 | X-Pack 认证用户名 |
| {$ES.PASS} | 空 | X-Pack 认证密码 |
| {$ES.TIMEOUT} | 10 | API 请求超时时间,单位秒 |
| {$ES.HEAP.MAX.PCT} | 85 | JVM 堆使用率告警阈值 |
| {$ES.DISK.FREE.MIN} | 15 | 磁盘可用空间百分比告警阈值 |
| {$ES.FD.MAX.PCT} | 80 | 文件描述符使用率告警阈值 |
| {$ES.INDEX.REGEX} | ^(biz|log)-.* | 索引发现的匹配正则 |
宏的好处不用多说,调阈值只需要改一处主机级别的宏,而不是去改每个触发器。生产环境我踩过一个坑:模板里硬编码了 85% 堆告警,后来一台部署了大数据节点的机器内存规格调大后,告警阈值需要同步调整。改成宏之后,直接在主机页改{$ES.HEAP.MAX.PCT}就完事,模板本身一行不用动。
2.3 监控项命名与 Zabbix key 设计
监控项的命名尽量可读,比如ES Cluster Status、ES JVM Heap Used Percent、ES Threadpool Search Queue。不过我建议在 key 上下更多功夫,因为 Zabbix 的图形原型和面板模板通常直接用 key 引用,key 稳定了,后面改名字不会影响图形。
我使用的 key 前缀分为三类:
es.health[...]:集群健康类指标,对应/_cluster/healthes.node[...]:节点级指标,对应/_nodes/_local/statses.index.discovery:索引发现规则专用 key
一个典型的监控项配置是这样的:
| 字段 | 值 |
|---|---|
| 名称 | ES Cluster: Status |
| 类型 | Zabbix agent |
| Key | es.health[cluster.status] |
| 更新时间 | 60s |
| 历史数据保留 | 7d |
| 趋势数据保留 | 365d |
| 应用集 | Elasticsearch Cluster |
脚本输出的是纯数字,所以不用额外配置 JSONPath 预处理。这里顺便说明一下为什么 cluster.status 要输出数字而不是字符串:Zabbix 触发器做数值比较比字符串比较方便得多,而且图形上可以直接显示折线,字符串状态在图形里只能一个一个匹配。我用 green=0、yellow=1、red=2 的映射,后面触发器写表达式会非常简单。
2.4 为什么不用官方模板和 JMX
市面上的 ES 监控方案不少,官方也有基于 RHEL 的插件模板,但我实际测试下来都不太顺手。官方模板有些对 ES 版本敏感,有些需要额外安装采集组件,指标粒度和我想要的不完全一致。更麻烦的是,如果模板里的指标缺失或字段不匹配,告警判断会很不准确。
JMX 方案我也试过。让 Zabbix 的 JMX 插件去拉 JVM 指标确实可行,但问题也很明显:需要给 ES 额外开启 JMX 端口,生产环境多开一个端口就多一个安全暴露面;而且 JMX 只能拿到 JVM 层面的数据,拿不到分片状态、线程池队列、磁盘水位这些真正影响业务的数据。所以我的结论是:JMX 可以做 JVM 指标的辅助参考,但 ES 监控的主力还必须走 REST API。
最终选型是:每台 ES 节点安装 Zabbix agent,通过 UserParameter 调用一个轻量级 Python 脚本,由脚本去请求 ES 的 REST API。Python 脚本统一处理认证、超时、异常,模板里每个监控项都走同一个入口,后续加指标只需要扩展脚本和模板里的定义,不用改 agent 配置。
3. 采集脚本、Agent 配置与验证流程
3.1 Agent 端 UserParameter 配置
脚本放在/usr/lib/zabbix/externalscripts/es_monitor.py,配置文件放在/etc/zabbix/es_monitor.conf。为什么分两个文件?脚本是二进制逻辑,配置文件是可变参数。生产环境我只是改过 ES 地址和账号密码,脚本本身基本不动。配置文件的权限要收紧,因为里面有认证信息:
chown zabbix:zabbix /etc/zabbix/es_monitor.conf chmod 600 /etc/zabbix/es_monitor.confagent 侧的 UserParameter 配置写在/etc/zabbix/zabbix_agentd.d/es_monitor.conf:
UserParameter=es.health[*],/usr/lib/zabbix/externalscripts/es_monitor.py --type health --metric "$1" UserParameter=es.node[*],/usr/lib/zabbix/externalscripts/es_monitor.py --type node --metric "$1" UserParameter=es.index.discovery,/usr/lib/zabbix/externalscripts/es_monitor.py --type indices --action discovery这里用通配符[*]的关键在于:模板里的监控项 key 写成es.health[cluster.status]、es.node[jvm.heap.percent],agent 收到请求后会把第一个参数传给脚本,脚本根据参数名返回对应的值。这样整个模板只需要三个 UserParameter 顶部的 key,而不是为每个指标单独写一行配置。
3.2 Python 采集脚本的核心实现
脚本整体逻辑不复杂,但有几个点必须处理好:配置文件解析、ES API 请求封装、字段提取、异常返回。
完整的可运行脚本大致如下:
#!/usr/bin/env python3 """Zabbix external script for Elasticsearch 7.9.2 monitoring.""" import argparse import base64 import json import sys import urllib.error import urllib.request CONF_FILE = "/etc/zabbix/es_monitor.conf" STATUS_MAP = {"green": 0, "yellow": 1, "red": 2} def load_config(): cfg = {} try: with open(CONF_FILE, "r", encoding="utf-8") as fp: for line in fp: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") cfg[key.strip()] = value.strip() except OSError: pass return cfg def es_request(cfg, path): url = cfg.get("ES_URL", "http://127.0.0.1:9200").rstrip("/") + path req = urllib.request.Request(url, headers={"Content-Type": "application/json"}) user = cfg.get("ES_USER", "") password = cfg.get("ES_PASS", "") if user: token = base64.b64encode(f"{user}:{password}".encode()).decode() req.add_header("Authorization", f"Basic {token}") try: timeout = int(cfg.get("ES_TIMEOUT", "10")) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) except Exception as exc: sys.stderr.write(f"ES request failed: {exc}\n") return None def extract_value(data, dot_path): current = data for key in dot_path.split("."): if key == "*": if isinstance(current, dict) and current: current = next(iter(current.values())) else: return None elif isinstance(current, dict) and key in current: current = current[key] else: return None return current def fetch_metric(cfg, mtype, metric): if mtype == "health": data = es_request(cfg, "/_cluster/health") if data is None: return None if metric == "cluster.status": return STATUS_MAP.get(data.get("status"), -1) fields = { "cluster.active_shards": "active_shards", "cluster.unassigned_shards": "unassigned_shards", "cluster.pending_tasks": "number_of_pending_tasks", "cluster.nodes_count": "number_of_nodes", } return data.get(fields.get(metric, ""), -1) if mtype == "node": data = es_request(cfg, "/_nodes/_local/stats") if data is None: return None fields = { "jvm.heap.percent": "nodes.*.jvm.mem.heap_used_percent", "jvm.gc.old.count": "nodes.*.jvm.gc.collectors.old.collection_count", "jvm.gc.old.time": "nodes.*.jvm.gc.collectors.old.collection_time_in_millis", "thread_pool.search.queue": "nodes.*.thread_pool.search.queue", "thread_pool.search.rejected": "nodes.*.thread_pool.search.rejected", "thread_pool.write.queue": "nodes.*.thread_pool.write.queue", "thread_pool.write.rejected": "nodes.*.thread_pool.write.rejected", "fs.available.percent": "nodes.*.fs.total.available_in_bytes", "fs.total.bytes": "nodes.*.fs.total.total_in_bytes", "fd.percent": "nodes.*.process.open_file_descriptors", "fd.max": "nodes.*.process.max_file_descriptors", "indices.docs.count": "nodes.*.indices.docs.count", "indices.store.size": "nodes.*.indices.store.size_in_bytes", "query.total": "nodes.*.indices.search.query_total", "indexing.total": "nodes.*.indices.indexing.index_total", } if metric not in fields: return None value = extract_value(data, fields[metric]) if metric == "fd.percent": fd_used = extract_value(data, "nodes.*.process.open_file_descriptors") fd_max = extract_value(data, "nodes.*.process.max_file_descriptors") return round(fd_used * 100.0 / fd_max, 2) if fd_max else None if metric == "fs.available.percent": avail = extract_value(data, "nodes.*.fs.total.available_in_bytes") total = extract_value(data, "nodes.*.fs.total.total_in_bytes") return round(avail * 100.0 / total, 2) if total else None return value return None def discover_indices(cfg): data = es_request(cfg, "/_cat/indices?format=json&bytes=b") if not data: print(json.dumps([])) return result = [] for idx in data: name = idx.get("index", "") if name.startswith("."): continue result.append({"{#INDEXNAME}": name}) print(json.dumps(result)) def main(): parser = argparse.ArgumentParser() parser.add_argument("--type", choices=["health", "node", "indices"], required=True) parser.add_argument("--metric", default="") parser.add_argument("--action", default="") args = parser.parse_args() cfg = load_config() if args.type == "indices": discover_indices(cfg) return 0 value = fetch_metric(cfg, args.type, args.metric) if value is None: print("UNSUPPORTED") return 1 print(value) return 0 if __name__ == "__main__": sys.exit(main())几个关键点说一下:
_nodes/_local返回的 nodes 是一个以节点 GUID 为 key 的字典,但因为是查询本机,所以无论多少个节点,这里只有一个元素。用nodes.*.jvm.mem.heap_used_percent这个点路径以后,extract_value 遇到*时直接取字典里的第一个值,也就是本机节点。这样写虽然有点 hack,但只要是用/_local,逻辑上永远不会取到别的节点。
fd.percent 和 fs.available.percent 需要两个字段做除法,所以单独处理。GC 和线程池这些指标都直接返回原始累计值,Zabbix 模板里用对应的函数做速率转换。异常情况统一输出UNSUPPORTED并返回非零退出码,这样 Zabbix 端会把这个 item 标记为 Unsupported,配合 nodata 触发器能及时发现采集链路故障。
3.3 验证脚本与监控项
脚本写完最重要的就是验证。先手动执行一遍,确认返回值符合预期:
cd /usr/lib/zabbix/externalscripts ./es_monitor.py --type health --metric cluster.status ./es_monitor.py --type node --metric jvm.heap.percent ./es_monitor.py --type indices --action discovery然后通过 Zabbix agent 的测试模式验证 key 是否能被正确识别:
zabbix_agentd -t "es.health[cluster.status]"如果 agent 配置正常,会输出类似es.health[cluster.status] [s|1]的结果。再用 zabbix_get 模拟 server 端拉取:
zabbix_get -s 127.0.0.1 -k "es.health[cluster.status]"验证通过之后,模板里的监控项基本不会再有采集问题。开发机调试时我习惯在 Windows 上直接启动一个单节点 ES 来做试验,配置好ES_URL=http://127.0.0.1:9200就能跑通整个脚本流程。想要快速看索引统计也可以用 DBeaver 连接 ES 的 SQL 接口,图形化界面查数据比 curl 直观,适合排查指标定义是否符合预期。
3.4 图形原型、聚合图形与仪表盘
监控数据只有变成图形才有快速定位问题的价值。我在模板里建了这样几个图形原型:
- ES Cluster Health:status 折线 + active_shards_percent + unassigned_shards,一张图看集群健康全貌
- ES JVM:heap_used_percent 与 GC old count,堆和 GC 放一起,可以看到堆上涨与 Full GC 的因果关系
- ES Thread Pools:search queue、write queue、search rejected、write rejected 四条线,瓶颈一目了然
- ES Disk & FD:disk available percent 与 fd percent
- ES Indexing & Query:query_total rate 和 indexing_total rate 的速率图
Zabbix 7.0 的图形原型支持直接设置 y 轴显示格式,status 映射成数字之后在图形上显示出的折线很直观。仪表盘我按主机维度组织:每个 ES 节点一个自定义仪表盘页,把所有图形原型放进去,再单独建一个集群总览页,放所有节点磁盘和 JVM 堆的小型图。这样白天看集群总览,出问题点进节点详情,效率高很多。
4. 触发器与告警阈值设计实战
4.1 告警表达式怎么写得“不废话”
触发器设计最大的忌讳是“太敏感”。用last()>阈值这种写法的模板,上线第一天就会因为各种瞬时抖动刷屏。比如 GC 发生的一瞬间 heap 百分比可能冲到 90,但 1 秒后就被回收了,这种瞬间值根本不值得告警。
我习惯用 count 或者 min 表达式做“持续一段时间”的判断。cluster 状态这类离散状态值,用 count 统计某个时间段内等于某值的次数;堆内存、磁盘这类连续值,用 avg 取平均值。
典型的表达式如下:
count(/Template App Elasticsearch 7.9.2/es.health[cluster.status],3m,"eq",2)>1含义是:3 分钟内,cluster.status 等于 2(也就是 red)的采样点超过 1 个。这样能避免单次瞬时采样导致的误报,也不会错过真正的持续故障。
4.2 阈值矩阵与分级告警
我把整套模板的触发器整理成一个矩阵,供运维团队对照调参。这里的阈值不一定适合所有集群,比如堆内存告警在 64GB 大内存节点和 8GB 小内存节点上的含义完全不同,但设计思路是一致的:阈值要提前于 ES 本身的默认限制,不要等到 ES 已经出问题才开始告警。
| 触发器名称 | 表达式模板 | 级别 | 说明 |
|---|---|---|---|
| ES Cluster Red | count(/Template App ES 7.9.2/es.health[cluster.status],3m,"eq",2)>1 | Disaster | 主分片丢失,立即人工介入 |
| ES Cluster Yellow | count(/Template App ES 7.9.2/es.health[cluster.status],3m,"eq",1)>2 | Average | 副本缺失,检查分片分配原因 |
| ES Node Offline | nodata(/Template App ES 7.9.2/es.health[cluster.nodes_count],3m)=1 | High | ES 进程或 agent 链路中断 |
| ES JVM Heap High | avg(/Template App ES 7.9.2/es.node[jvm.heap.percent],5m)>{$ES.HEAP.MAX.PCT} | High | 堆长期高水位,风险持续累积 |
| ES Old GC Too Frequent | change(/Template App ES 7.9.2/es.node[jvm.gc.old.count],5m)>5 | High | 5 分钟内超过 5 次 Full GC |
| ES Search Queue Too Long | avg(/Template App ES 7.9.2/es.node[thread_pool.search.queue],5m)>100 | Warning | 查询线程池排队明显 |
| ES Write Queue Too Long | avg(/Template App ES 7.9.2/es.node[thread_pool.write.queue],5m)>100 | High | 写入侧堆积,业务可能超时 |
| ES Disk Available Low | last(/Template App ES 7.9.2/es.node[fs.available.percent],0)<{$ES.DISK.FREE.MIN} | High | 低于水位线前预警 |
| ES FD Usage High | avg(/Template App ES 7.9.2/es.node[fd.percent],5m)>{$ES.FD.MAX.PCT} | Warning | 文件描述符使用率超限 |
| ES Pending Tasks Long | last(/Template App ES 7.9.2/es.health[cluster.pending_tasks],0)>10 | Warning | 集群元数据任务积压 |
有几个阈值得重点解释一下。
JVM 堆 85% 这个阈值不是拍脑袋定的。ES 默认的 JVM 参数对堆使用有硬性约束,堆使用率一旦超过 85% 并持续,就会频繁触发 full GC,而 full GC 会造成节点长时间的 stop-the-world,直接把延迟打高。85% 留出的 15% 空间就是给 GC 缓冲用的。GC 的 5 分钟超过 5 次 Full GC 阈值,对应的是“每 1 分钟至少 1 次 Full GC”,已经说明堆长期处于不健康状态。
磁盘可用 15% 的阈值对应 ES 的 85% watermark.low。如果等 ES 到 85% 才开始处理,分片分配会被停掉,影响面更大。我的经验是 15% 报警后至少有半天到一天的人工处理时间,可以清理归档索引、调整副本数,完全能避开写阻塞。
4.3 告警动作、升级策略和维护窗口
监控模板只是数据层,真正让人安心的是告警动作。Zabbix 7.0 的动作配置支持按触发器名字、严重级别和标签组合路由。我在模板里给每个触发器打了component=ES、severity=Critical这类标签,动作规则里直接按标签匹配。比如将所有component=ES的 High 及以上告警发给值班手机,Warning 级别只发企业微信群,Disaster 级别在 High 基础上额外呼叫负责 ES 的同事,形成 5 分钟、10 分钟、30 分钟的升级阶梯。
维护窗口很实用。ES 集群经常有夜间索引归档、force merge、节点滚动重启这类操作,如果没有维护窗口,一次正常的重启会触发一堆磁盘和节点离线告警,值班同事的告警疲劳就是这样被磨出来的。Zabbix 7.0 支持周期性维护窗口,我按周设置每天的凌晨 2 点到 4 点为 ES 维护期,这个时间段内的例行操作不会报警;遇到大版本升级这种长操作,再临时追加一次性的维护窗口。
4.4 减少告警风暴的依赖触发器
多节点集群最怕的就是“红一发而牵全身”:集群 red 的时候,节点离线、分片未分配、JVM 告警可能同时刷出来十条。Zabbix 的触发器依赖功能就是干这个的。我在模板里把“ES Cluster Red”设置成父触发器,把当前节点上的磁盘、JVM、线程池相关触发器都设为它的依赖项。集群 red 时,父触发器触发后,子的告警会被抑制,值班同事只需要看一条根因告警,而不是一堆并列的无效告警。
依赖触发器的配置没有复杂逻辑,但它需要一开始就设计好。我曾见过有人不做依赖,一次主节点故障导致同一时间发出 40 多条告警,值班短信直接被打爆。真正解决之后发现,有效告警其实就一条。
5. 线上踩坑、效果复盘与排查速查
5.1 Zabbix 前端中文方框与 UTC 时间显示问题
Zabbix 7.x 版本的 Web 界面默认字体不支持中文,很多场景下会把中文显示成方框,这也是论坛里“zabbix 中文方框”问题的来源。解决思路是给服务器装中文字体,然后让 Zabbix 前端加载它。我这边用的是 Noto CJK 字体:
dnf install -y google-noto-sans-cjk-fonts装完之后,在 Zabbix 前端设置里把默认字体替换为 Noto Sans CJK 路径,刷新页面后中文正常。图表底部时间显示成 universal time 是时区配置问题,需要同时检查 PHP 的 date.timezone 和 Zabbix server 的时区配置。CentOS 9 上用 Zabbix 官方仓库部署的话,一般改 php.ini 的 date.timezone 为 Asia/Shanghai 然后重启 php-fpm 即可。
5.2 Zabbix proxy 的 unreachable poller 告警
日志集群规模上来之后,我把部分节点的监控改成了通过 Zabbix proxy 采集。运行一段时间后收到一条很典型的告警:utilization of unreachable poller processes over 75%。原因是 proxy 下挂的 ES 节点比较多,当网络出现抖动或者部分节点短暂不可达时,这些节点会被转到 unreachable poller 队列,而 proxy 默认的 unreachable poller 数量有限,很快就占满了。
解决方式不是简单把StartUnreachablePollers调大,而是要先确认网络到底有没有问题。如果确认只是偶发抖动,把 proxy 配置里的StartUnreachablePollers从默认的 1 调到 4,问题基本能缓解。如果不可达节点持续很多,那重点应该是排查网络和 ES 节点本身,而不是盲目加进程数。这个告警其实是好现象,说明监控系统对采集链路的问题感知很灵敏。
5.3 常见问题速查表
这里整理一张我实际排查过的问题表,覆盖模板上线后最常见的故障:
| 症状 | 排查思路 | 解决办法 |
|---|---|---|
| 监控项 Unsupported | 脚本输出非数字或返回非零退出码 | zabbix_get 手动验证脚本,看 stderr 输出 |
item 显示UNSUPPORTED,日志有 urllib 报错 | ES 地址或认证配置错误 | 检查 es_monitor.conf 的 ES_URL / ES_USER / ES_PASS |
| 单节点大量 item 同时 Unsupported | ES 进程挂了或端口不通 | 先确认 ES 进程,再检查 agent 到 ES 的网络 |
| 某些索引没有出现在发现列表 | 正则未匹配或索引名以点开头 | 调整 {$ES.INDEX.REGEX},确认 discovery 输出 |
| 图形上堆内存出现“断崖式下跌” | full GC 收缩堆的结果,正常现象 | 不需要处理,配合 GC 频率图观察 |
| 磁盘告警已经触发但业务没异常 | 可能是 15% 阈值设置偏保守 | 结合 ES 水位线评估调整宏 |
| proxy 下 node 指标的 agent.ping 正常但脚本失败 | proxy 到 ES 网络不通 | 检查 proxy 网络,使用zabbix_get从 proxy 侧验证 |
5.4 模拟故障与复盘清单
模板上线不是终点,能经得起故障演练才是真的可用。我每次给新集群接入这套监控模板,都会做一次小幅度的故障注入验证。
第一步,选一个低峰时段,kill 掉一个数据节点的 ES 进程。预期结果:该节点 agent.ping 丢失或es.health[cluster.nodes_count]下降,集群 yellow 触发器触发,告警在 3 分钟内送达。如果告警没有按时到达,优先检查监控项在 Zabbix 里是否真的在采集,以及触发器是否处于正常状态。
第二步,手动向集群灌入大量写入流量,观察磁盘可用空间下降速度。模板里磁盘告警应该先于 ES 的写阻塞触发。我做过一次磁盘从 62% 冲到 88% 的压测,模板在 85% 附近发出告警,配合自动化脚本缩副本、归档索引,最终在到达 95% 写保护前把水位压回 70% 以内。这次演练很能说明提前量阈值设计的重要性。
最后做一次 Zabbix server 重启和 proxy 重启演练,确认采集链路恢复后不会产生大量误报。我在测试中发现过一个细节:Zabbix server 重启后,内存里的历史数据会重新计算,某些 avg 触发器可能因为“窗口内数据不足”而产生一个瞬时告警。解决办法是给模板的触发器设置Startup后在第一个周期内不评估,或者在维护窗口内重启 server,避免通知打扰值班。
从最开始接需求到模板完全稳定运行,整个过程大概用了两周。模板上线后,ES 相关的线上故障从“用户先发现”变成了“监控先发现”,这种变化对运维团队来说是最实在的回报。如果你也要做一套 ES 监控模板,我建议先从我列出的这几个核心监控面入手,不要一上来追求指标数量,把集群状态、JVM 堆、线程池、磁盘、文件描述符这五类先做扎实,后面的索引层指标和图形可视化可以逐步补。指标贵精不贵多,告警贵准不贵频繁,这套思路在 Zabbix 里通用,换个中间件也一样适用。