探测资源的可用性,这事听起来简单,不就是“检查一下还能不能用”嘛。但真等到你负责的系统在半夜出故障,告警群里一堆人@你,你才发现负载均衡那边早就把后端标红了,而你的第一版探测脚本连个日志都没留下——那时候就晚了。我做了几年运维和SRE,被这种“看似简单”的事坑过不止一次,所以这次把系列第七篇的篇幅,专门留给“探测资源的可用性”这个话题。
它到底能做什么?往小了说,是写个脚本定时去探测某个网址、端口、数据库实例通不通;往大了说,是整个监控体系的基石,是服务治理、容量评估、故障恢复最原始的数据来源。不管是运维、后端开发,还是做自动化测试的同学,这套东西早晚都要用上。今天这篇,我尽量把工具选型、脚本设计、参数取舍、常见坑都讲透,给大家一份可以直接拿来改的参考。
1. 先搞清楚:你说的“资源”到底是什么
很多人一上来就写代码,结果写出来的探测脚本连“探测对象”都没划分清楚。资源不是一句“服务器”就能概括的,不同类型的资源,探测手段完全不同。
1.1 资源探测的对象分类
我在实际项目里,会把资源先分成几类:
- HTTP/HTTPS 接口:这类最常见,探测一个 URL 的状态,比如首页、健康检查端点、登录接口。
- TCP 端口:比如 MySQL 的 3306、Redis 的 6379、Kafka 的 9092,只要端口能建立 TCP 连接,就认为服务还在监听。
- 数据库/中间件:不只是端口通不通,还需要执行一条查询或者命令,比如 MySQL 的
SELECT 1,Redis 的PING,验证服务真的能响应业务请求。 - DNS 解析:探测一个域名能不能被正常解析到预期 IP,很多故障其实发生在 DNS 层,而不是应用层。
- 共享存储/对象存储:比如 NFS 挂载点、S3 bucket 能不能读写。
- 消息队列:比如 RabbitMQ、Kafka 的 broker 是否在集群内正常同步。
为什么要分这么细?因为探测方式的复杂度是递增的。TCP 端口探测只能证明“有进程在监听”,但证明不了“服务还能处理请求”。比如 MySQL 的连接数满了,端口照样能连上,但新的查询就会卡死或直接被拒绝,这时候只用端口探测就是典型的“假活”。
1.2 可用性不只有“通不通”一个维度
我把可用性分成三层,写探测脚本的时候必须想清楚你到底在测哪一层:
第一层是连通性:网络能不能到达,端口是否监听,握手是否成功。这是最底层,也是大多数脚本默认做的。
第二层是功能性:服务进程活着,且能完成一轮完整的业务交互。比如 Redis 不只是端口能连,还能 PING 返回 PONG;接口不只是返回 200,还得是预期的业务结果。
第三层是性能达标:服务虽然能响应,但响应时间是否在可接受范围内。比如一个接口平时 50ms,现在变成 5 秒,对用户来说这就是不可用。所以探测脚本里最好带上响应时间阈值,超过阈值就标记为 degraded,而不是简单地 up/down。
那么问题来了:一个端口通、但接口响应 3 秒的服务,算不算可用?我的建议是,监控层面单独分状态:UP、DOWN、DEGRADED。不要一拍脑袋搞成二值判断,否则后面告警规则很难写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具选型:命令行工具能干到哪一步
在写完整探测程序之前,先用命令行工具做一轮快速验证,通常能省很多事。但命令行工具有它的适用范围,别拿锤子去拧螺丝。
2.1 ping、nc、telnet、curl 的适用边界
先说结论,我用一个表格总结一下各自的特点:
| 工具 | 探测能力 | 局限 |
|---|---|---|
| ping (ICMP) | 主机是否在线、网络延迟 | 很多云环境禁 ICMP,不一定可靠;只能测主机,不能测端口 |
| nc / telnet | TCP 端口是否可连接 | 不处理应用层协议,无法验证服务健康 |
| curl | HTTP/HTTPS 接口状态、响应时间、内容 | 需要额外处理超时、重试、代理环境变量 |
| dig / nslookup | DNS 解析是否正常 | 只能看到解析结果,无法直接判断应用状态 |
| openssl s_client | TLS 握手是否正常 | 主要用于证书过期检查,交互式使用比较繁琐 |
这里面坑最多的就是 ping。我在阿里云、腾讯云上都遇到过,明明服务正常,但 ping 丢包 100%,因为安全组或者云平台把 ICMP 协议直接挡掉了。这种情况你拿 ping 的结果当成探测依据,等于天天在误报。所以我的建议是:探测生产环境资源,尽量避开 ICMP,优先用 TCP 和 HTTP 层探测。
再看 curl。curl 功能很强大,但默认没有超时限制,如果一个接口卡住了,curl 会一直等下去,这在监控脚本里是大忌。用 curl 探测时至少加 --connect-timeout 和 --max-time,前者限制建立连接的时间,后者限制整个请求的时间。例如:
bash复制curl -sS -o /dev/null -w "%{http_code} %{time_total}" \
--connect-timeout 3 --max-time 5 \
https://api.example.com/health
这条命令会把 HTTP 状态码和总耗时打印出来,是个很实用的快速验证方式。
2.2 为什么最后还是要自己写程序
命令行工具适合“临时探一下”,但要落地成持续运行的监控任务,问题就来了:
- 多目标探测时,shell 串行执行效率太低;
- 每次结果没有结构化存储,无法自动关联时间、状态、历史;
- 重试逻辑、告警去重、状态切换这些逻辑,shell 写起来很别扭。
所以我的做法是:命令行工具用于调试和验证,正式监控任务用 Python 这类脚本语言写成一个独立的探测模块,这样后续加新资源类型、接告警平台都方便。下面的核心实现部分,我会给一个完整的脚本框架。
2.3 多维度组合探测:别只看单点
真正有用的资源探测,往往是组合式的。我曾经维护过一个老系统,健康检查接口本身很简单,只是返回一段固定字符串。某次故障里,后端服务的线程池已经打满了,但健康检查接口因为走的是另一个轻量线程池,依然返回 200。等我们接到用户投诉再去查,已经晚了。
从那之后,我给自己定了一条规则:业务关键资源至少要做两层探测。比如一个订单服务,第一层 TCP 端口探测确认进程活着,第二层请求真实的健康检查接口,并且校验返回内容里包含预期关键字,同时判断响应时间。如果只有第一层通过,就只算 partial up,不触发正常的“可用”状态。这样能极大减少“假活”情况。
3. 核心细节:一个探测脚本应该怎么设计
这一节直接上干货,我把一个实际可用的探测脚本拆开来讲。场景是:需要探测一组 HTTP 服务、一个 MySQL 实例和一个 Redis 实例,结果以 JSON 格式输出,失败时记录对应的错误信息。
3.1 场景设定与目标
假设我现在手上有这些资源:
- HTTP 服务 A:
https://api.example.com/health,要求状态码 200,并且响应体包含ok关键字,响应时间小于 800ms; - HTTP 服务 B:
https://web.example.com/health,要求状态码 200,响应体包含healthy; - MySQL:地址
10.10.10.10:3306,需要执行SELECT 1; - Redis:地址
10.10.10.20:6379,需要执行PING,且返回PONG。
我把脚本命名为 resource_probe.py,结构上分成四个模块:参数配置、探测函数、并发执行、结果汇总。不搞花哨框架,标准库加两个常用第三方库就够。
3.2 关键参数设计:超时、重试、并发
参数设计是探测脚本里最容易翻车的地方。我的习惯是单独抽一个配置区,避免硬编码到处飞。
超时时间分两种:连接超时(connect timeout)和读取超时(read timeout)。连接超时解决的是“网络不通”的场景,读取超时解决的是“服务没响应”的场景。HTTP 请求里,我会把连接超时设为 3 秒,读取超时设为 5 秒;数据库连接把连接超时设为 5 秒,执行查询超时再单独设置,比如 MySQL 的 read_timeout=5。
重试策略:探测任务不是一次性请求,如果不加区分地重试,可能把一次故障拖成十分钟的探测超时序列。我的经验是:连通性失败可以重试一次,因为偶尔一次网络抖动很正常;业务状态码不对或者关键字不匹配,不要盲目重试,这通常是服务真的出了问题,重试只会掩盖问题。用指数退避的话,第一次间隔 1 秒,第二次间隔 2 秒,最多重试两次,足够了。
并发控制:多目标探测时用 ThreadPoolExecutor,线程数不要太多,我一般限制在 10 以内,避免自己把自己机器打满。
下面是我的参数配置示例:
python复制PROBE_CONFIG = {
"http_services": [
{
"name": "service-a",
"url": "https://api.example.com/health",
"expected_status": 200,
"expected_keyword": "ok",
"max_response_time_ms": 800,
},
{
"name": "service-b",
"url": "https://web.example.com/health",
"expected_status": 200,
"expected_keyword": "healthy",
"max_response_time_ms": 800,
},
],
"mysql": {
"host": "10.10.10.10",
"port": 3306,
"user": "monitor",
"password": "monitor_pass",
"connect_timeout": 5,
"read_timeout": 5,
},
"redis": {
"host": "10.10.10.20",
"port": 6379,
"password": "redis_pass",
"socket_timeout": 3,
"socket_connect_timeout": 3,
},
}
注意,给监控用的数据库账号、Redis 密码,权限越小越好。MySQL 只需要 SELECT 权限,不要用 root;Redis 建议用专门的只读账号,有些配置项甚至可以让监控命令只允许访问一个无关的 database。
3.3 HTTP 探测函数实现
我用 requests 库,主要因为它对超时、连接复用、重定向的处理比标准库 urllib 省心。一个完整的 HTTP 探测函数大概长这样:
python复制import time
import requests
def probe_http(url, expected_status=200, expected_keyword="", max_response_time_ms=1000):
start = time.monotonic()
result = {"status": "DOWN", "latency_ms": None, "error": ""}
try:
resp = requests.get(
url,
timeout=(3, 5), # (connect_timeout, read_timeout)
headers={"User-Agent": "resource-probe/1.0"},
)
elapsed_ms = (time.monotonic() - start) * 1000
result["latency_ms"] = round(elapsed_ms, 2)
if resp.status_code != expected_status:
result["error"] = f"unexpected status: {resp.status_code}"
elif expected_keyword and expected_keyword not in resp.text:
result["error"] = f"keyword '{expected_keyword}' not found in response body"
elif elapsed_ms > max_response_time_ms:
result["status"] = "DEGRADED"
result["error"] = f"response time {elapsed_ms:.2f}ms exceeds threshold {max_response_time_ms}ms"
else:
result["status"] = "UP"
except requests.Timeout:
result["error"] = "request timeout"
except requests.ConnectionError as e:
result["error"] = f"connection error: {e}"
return result
这里有几个细节值得注意。第一,我用 time.monotonic() 而不是 time.time(),因为系统时间可能在探测过程中被 NTP 调整,monotonic 不受系统时钟跳变影响。第二,超时设置成元组 (3, 5),分别对应连接超时 3 秒和读取超时 5 秒,如果不拆分,整体一个超时值,很可能是连接一直卡住把读取时间也占了。第三,响应时间超阈值时我把状态置为 DEGRADED,而不是直接 DOWN,因为服务还能处理请求,只是变慢了,告警级别应该和完全不可用区分开。
3.4 TCP、MySQL、Redis 探测函数实现
TCP 端口探测是个基础动作,很多资源的底层探测都依赖它,我用标准库 socket 实现:
python复制import socket
def probe_tcp(host, port, connect_timeout=3):
result = {"status": "DOWN", "error": ""}
try:
with socket.create_connection((host, port), timeout=connect_timeout) as sock:
result["status"] = "UP"
except socket.timeout:
result["error"] = "connection timeout"
except (socket.gaierror, socket.connectionRefusedError, OSError) as e:
result["error"] = f"connection failed: {e}"
return result
MySQL 探测我用 pymysql,需要注意连接后立刻执行 SELECT 1,然后主动关闭连接,不要等垃圾回收来处理:
python复制import pymysql
def probe_mysql(cfg):
result = {"status": "DOWN", "error": ""}
conn = None
try:
conn = pymysql.connect(
host=cfg["host"],
port=cfg["port"],
user=cfg["user"],
password=cfg["password"],
connect_timeout=cfg.get("connect_timeout", 5),
read_timeout=cfg.get("read_timeout", 5),
)
with conn.cursor() as cur:
cur.execute("SELECT 1")
row = cur.fetchone()
if row and row[0] == 1:
result["status"] = "UP"
else:
result["error"] = "query returned unexpected result"
except pymysql.MySQLError as e:
result["error"] = f"mysql error: {e}"
finally:
if conn:
conn.close()
return result
Redis 也类似,用 redis-py,连接后 ping() 返回 True 才算通过:
python复制import redis
def probe_redis(cfg):
result = {"status": "DOWN", "error": ""}
client = None
try:
client = redis.Redis(
host=cfg["host"],
port=cfg["port"],
password=cfg.get("password"),
socket_timeout=cfg.get("socket_timeout", 3),
socket_connect_timeout=cfg.get("socket_connect_timeout", 3),
)
if client.ping():
result["status"] = "UP"
else:
result["error"] = "redis ping returned False"
except redis.RedisError as e:
result["error"] = f"redis error: {e}"
finally:
if client:
client.connection_pool.disconnect()
return result
Redis 那段有个容易忽略的点:client.ping() 在连接失败时会抛出异常,而不是返回 False,所以 else 分支基本走不到。但保留这个分支没坏处,万一某些代理模式返回了 False 而不是抛异常,我们能捕获到。
3.5 并发调度与结果汇总
单线程逐个探测目标,在资源少的时候没问题,但一旦目标数量超过几十个,整体耗时会拉得很长。我用 concurrent.futures 的 ThreadPoolExecutor,并发执行所有探测项,同时限制最大线程数:
python复制from concurrent.futures import ThreadPoolExecutor, as_completed
def run_all_probes(config):
tasks = []
with ThreadPoolExecutor(max_workers=10) as executor:
for svc in config["http_services"]:
tasks.append(executor.submit(probe_http, svc["url"], svc["expected_status"], svc["expected_keyword"], svc["max_response_time_ms"]))
mysql_cfg = config["mysql"]
tasks.append(executor.submit(probe_mysql, mysql_cfg))
redis_cfg = config["redis"]
tasks.append(executor.submit(probe_redis, redis_cfg))
results = []
for fut in as_completed(tasks):
results.append(fut.result())
return results
这样整个脚本就算跑通了。如果要做成定时任务,最简单的做法是写个 systemd timer,每 1 分钟跑一次,把 JSON 结果追加到文件里,或者直接推送到监控系统的 API。别用简单的裸 while True + sleep 挂在终端里,进程一断监控就断了,最好交给 systemd 或者 supervisor 管理。
4. 探测过程中最容易被坑的地方
工具选好了,脚本也写出来了,不代表万事大吉。实际跑起来之后,你会遇到一堆让人想骂人的问题。下面这几类是我踩过最多坑的地方。
4.1 误报来源:防火墙、代理、DNS 缓存
先看误报。误报的杀伤力在于:狼来了喊多了,大家就不信了,等真出故障时反而没人响应。最常见的误报来源有三个。
第一是防火墙对探测请求的干扰。不是所有防火墙都会直接丢包,有些会针对快速连续探测限流,比如 ICMP 限速、TCP 半连接限制。如果探测频率太高,比如每 5 秒一次,可能触发防护策略,让本来正常的资源被标记为不可用。解决办法是降低探测频率,或者把探测节点 IP 加入白名单。
第二是代理环境变量。很多服务器上配置了 HTTP_PROXY 环境变量,requests 库默认会读这些变量,导致探测请求走了代理,代理一挂,探测就报错;代理虽然活着,但目标资源本身健康,却因为代理缓存、代理防火墙策略被误判。解决方法是显式关闭代理:session.trust_env = False,或者给 requests.get 传 proxies={"http": None, "https": None}。
第三是 DNS 缓存。如果你在脚本里直接传域名,requests 每次都会做一次系统级 DNS 解析,会有本地缓存机制。有时候 DNS 已经切换了,但解析缓存还指向旧 IP,探测结果自然不准。排查方法是先 dig +short 目标域名,确认解析结果,如果发现旧 IP,考虑在脚本里显式指定 IP 和 Host 头绕过 DNS。
python复制session = requests.Session()
session.trust_env = False # 忽略 HTTP_PROXY / HTTPS_PROXY
resp = session.get(
"https://api.example.com/health",
proxies={"http": None, "https": None},
timeout=(3, 5),
)
这个片段是解决代理误报的通用做法。
4.2 超时与重试的经典陷阱
超时和重试是最容易写出“探测本身把服务压垮”的地方。
有个新手常见的错误:探测接口超时后立刻重试,重试还是超时,又重试,结果探测本身变成了流量风暴。正确的思路是控制重试的总时间。比如单次探测最大耗时 5 秒,重试 2 次,间隔 1 秒和 2 秒,那么最坏情况是 5 + 1 + 5 + 2 + 5 = 18 秒。对于 1 分钟一次的监控周期来说,这已经占了将近三分之一的时间,如果并发探测多个目标,可能探测线程还没结束,下一轮又开始了,造成重叠。
我的建议是对探测任务的执行也加一个整体超时,比如 ThreadPoolExecutor 提交后用 wait(..., timeout=25),超时未完成的探测任务直接标记为 TIMEOUT,不再等待。宁可这一轮结果不完整,也不能让探测进程越积越多。
还有一个问题是重试之间的连接处理。如果第一次连接后 TCP 连接处于 TIME_WAIT 状态,立刻重试可能因为端口复用限制而失败。这里我一般建议每次探测都新建连接,不要把连接复用给重试场景。重试的本质是模拟新的用户请求,而不是在同一个 socket 上重发数据。
4.3 告警去重与恢复通知
探测脚本最后一定要接告警,但很多团队只做成“失败就发告警”,结果设备一故障,告警刷屏,恢复的时候反而没人通知。我的做法是用一个简单的状态机来保证只在状态切换时发送通知。
python复制# 状态记录文件,保存上一轮结果
prev_status = load_prev_status() # dict: resource_name -> up/down/degraded
for result in results:
current_status = result["status"]
prev = prev_status.get(result["name"])
if prev != current_status:
send_alert(result)
prev_status[result["name"]] = current_status
这里有个很关键的经验:发送恢复通知前,要确认资源已经连续 N 次通过探测,而不是只通过一次就立刻恢复。比如连续失败 3 次才置为 DOWN,连续成功 3 次才置为 UP,这能过滤掉绝大多数抖动。代码上可以这样:
python复制def update_state(name, current_status):
counter = state_counter.get(name, {"DOWN": 0, "UP": 0, "DEGRADED": 0})
if current_status == "UP":
counter["UP"] += 1
counter["DOWN"] = 0
if counter["UP"] >= 3:
return "UP"
else:
counter["DOWN"] += 1
counter["UP"] = 0
if counter["DOWN"] >= 3:
return "DOWN"
return "UNCHANGED"
这样做的好处是,网络偶发抖动不会触发一堆无意义的告警和恢复消息,值班的人也不会因为频繁打扰而产生告警疲劳。
5. 从单机探测走向分布式监控体系
探测脚本写好了,运行稳定了,下一步要考虑的是:一个探测节点够不够?答案通常是不够。
5.1 为什么单节点探测不够
单节点探测有几个天然盲区。第一,这个节点本身可能和业务服务部署在同一台物理机或同一个机柜,一旦交换机故障、机房断电,探测脚本连自己都跑不起来,更别说发出告警。第二,从不同网络路径探测同一个目标,结果可能完全不同。比如从内网探测 NAT 后的 IP 和从公网探测同一个 IP,结果必然不同。第三,某些网络故障是区域性的,单节点探测无法判断是目标故障还是“你到目标的链路故障”。
所以生产环境至少要有两个探测节点,最好分布在不同网络区域。有了多个节点的结果,判断逻辑也可以升级:比如两个节点都探测失败才触发告警,只有一个节点失败则不告警,因为那很可能是探测节点到目标之间的链路问题。
5.2 主动探测与被动监控的配合
主动探测有个明显的短板:它是按固定周期来的,两次探测之间的空档期仍然可以发生故障。比如 1 分钟探测一次,故障发生在刚探测完 1 秒后,下一次探测要等到 59 秒后,这段时间的故障对主动探测来说就是盲区。
被动监控恰好可以弥补这个盲区。被动监控指的是从业务系统本身获取健康状态信号,比如:
- 应用层的健康指标:请求数、错误率、P99 延迟,通过 Prometheus 这类时序监控系统采集;
- 链路追踪数据:一次请求经过多个服务,哪个环节耗时异常、哪个服务报错率飙升;
- 日志数据:ERROR 级别日志的条数变化,如果短时间内暴涨,大概率有问题。
主动探测像是“定期体检”,被动监控像是“持续心电图”,两者结合才能把盲区降到最低。我见过很多团队只做其中一种,结果不是故障发现不及时,就是误报率高到无人问津。
5.3 结果存储与可视化
最后简单说一下数据的去向。早期的探测脚本,我习惯把结果往 SQLite 里写,轻量、不用额外部署服务,后续还能做简单查询。等资源数量多了,就迁移到 Prometheus 这样的时序数据库,用一个名为 resource_probe_status 的 gauge 指标,数值 1 表示 UP,0 表示 DOWN,配合 Grafana 画一张大屏,一眼就能看到所有核心资源的状态。
bash复制# 示例:curl 推送到 Prometheus PushGateway
curl -X POST --data-binary 'resource_probe_status{resource="service-a",status="up"} 1' \
http://localhost:9091/metrics/job/resource_probe/instance/probe-node-1
这个方案的好处是 Prometheus 生态天然支持多维度标签、告警规则、图表联动,比自己在 JSON 里翻滚省力太多。
如果你手头已经有监控系统,优先考虑把探测结果接到现有系统里,而不是另起炉灶。毕竟探测只是手段,能准确反映真实可用性,并且让团队快速响应,才是最终目的。
我在实际维护中还有个体会:探测脚本一定要把“探测目标的预期行为”写清楚,不要只写 URL 和端口。比如预期状态码是 200 还是 302,预期响应体里必须包含哪个关键词,这些信息越明确,后面排查故障时越省力。最后再分享一个小经验:如果你的健康检查接口需要登录才能访问,一定要在探测请求里带上对应的 Cookie 或者 Token,而不是绕过鉴权。很多人第一步就挂在这上面——接口在没鉴权时反而返回 200,误导了整个监控体系。
