一台机器用 01 篇的脚本盯,十台机器就不能一台一个窗口了。这篇写一个批量健康检查器:并发探测一批 host:port,几秒钟出一份体检报告——谁活着、谁挂了、挂的姿势是什么。
一、TCP 探活:最朴素也最通用的健康检查
健康检查有很多层:ping(机器在)、TCP 端口(服务进程在)、HTTP /health(业务逻辑在)。TCP 探活是性价比最高的起点:任何 TCP 服务——Nginx、Redis、MySQL、SSH——都适用,一段代码通吃,而且失败方式自带信息量:
| 结果 | 含义 | 常见原因 |
|---|---|---|
open |
连接成功 | 服务正常 |
refused |
对方明确拒绝 | 端口上没有进程在监听(服务挂了) |
timeout |
石沉大海 | 网络不通、防火墙丢包、主机宕机 |
refused 和 timeout 必须区分:refused 说明机器活着但服务死了(找运维重启服务),timeout 说明机器或网络没了(先看网络/IDC)。混为一谈的告警会浪费排查时间。
二、探测函数的设计:失败也是结果,不是异常
新手版探测函数会往上抛异常,调用方还得一层层 try。正确设计是把失败也编码成返回值,且永不抛异常:
import socket
import time
def probe(host: str, port: int, timeout: float = 2.0) -> dict:
"""探测单个 host:port,永不抛异常,失败也是结果的一种。"""
start = time.perf_counter()
try:
with socket.create_connection((host, port), timeout=timeout):
elapsed = (time.perf_counter() - start) * 1000
return {"target": f"{host}:{port}", "ok": True, "status": "open", "ms": elapsed}
except ConnectionRefusedError:
return {"target": f"{host}:{port}", "ok": False, "status": "refused", "ms": (time.perf_counter() - start) * 1000}
except (socket.timeout, TimeoutError):
return {"target": f"{host}:{port}", "ok": False, "status": "timeout", "ms": (time.perf_counter() - start) * 1000}
except OSError as exc:
return {"target": f"{host}:{port}", "ok": False, "status": f"error({exc.errno})", "ms": (time.perf_counter() - start) * 1000}
两个细节:
timeout=2.0必须给。socket.create_connection不带超时的话,遇到丢包场景能挂几十秒——这和 01 篇requests必须带 timeout 是同一条纪律。- 耗时一起返回。连接耗时(毫秒级)是免费赠送的线路质量指标,定期记录就能看出「这台机器最近越来越慢」。
三、并发:ThreadPoolExecutor 五行上齐
50 个目标每个探 2 秒,串行要 100 秒;用线程池并发就是「最慢的那个」的时间。I/O 等待型任务(网络探测)用线程池正合适,线程在等网络时是睡着的,不占 CPU:
from concurrent.futures import ThreadPoolExecutor, as_completed
def batch_check(targets: list[dict], workers: int = 8) -> list[dict]:
results = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(probe, t["host"], t["port"]): t["name"] for t in targets}
for fut in as_completed(futures):
r = fut.result()
r["name"] = futures[fut]
results.append(r)
return results
as_completed 的妙处:哪个探测先完成就先处理哪个,不用按提交顺序死等。
四、完整演示(demos/batch_health_demo.py)
没有真实机房,就在本机起 3 个真实 TCP 监听当在线主机,再配一个「端口没开」的目标和一个「网络不通」的不可路由 IP,凑成 5 个目标的体检:
import socket
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
SERVERS: list[socket.socket] = []
def start_listener() -> int:
"""在本机随机空闲端口起一个 TCP 监听,返回端口号。"""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
SERVERS.append(srv)
return srv.getsockname()[1]
# ... probe / batch_check 见上文 ...
if __name__ == "__main__":
online_ports = [start_listener() for _ in range(3)]
targets = [
{"name": "web-01", "host": "127.0.0.1", "port": online_ports[0]},
{"name": "web-02", "host": "127.0.0.1", "port": online_ports[1]},
{"name": "db-01", "host": "127.0.0.1", "port": online_ports[2]},
{"name": "cache-01", "host": "127.0.0.1", "port": 6379}, # 没人监听
{"name": "backup-01", "host": "10.255.255.1", "port": 9999}, # 不可路由
]
print(f"共 {len(targets)} 个探测目标,开始批量体检 ...\n")
start = time.perf_counter()
results = batch_check(targets)
wall = (time.perf_counter() - start) * 1000
for r in sorted(results, key=lambda x: (not x["ok"], x["name"])):
mark = "✓" if r["ok"] else "✗"
print(f" {mark} {r['name']:<10} {r['target']:<22} {r['status']:<12} {r['ms']:6.1f} ms")
failed = [r for r in results if not r["ok"]]
print(f"\n存活 {len(results) - len(failed)}/{len(targets)},探测总耗时 {wall:.0f} ms"
f"(串行约需 {sum(r['ms'] for r in results):.0f} ms)")
if failed:
print("需要关注的故障目标:")
for r in failed:
print(f" - {r['name']} ({r['target']}): {r['status']}")
for srv in SERVERS:
srv.close()
五、真实运行输出
共 5 个探测目标,开始批量体检 ...
✓ db-01 127.0.0.1:55334 open 4.7 ms
✓ web-01 127.0.0.1:55332 open 5.2 ms
✓ web-02 127.0.0.1:55333 open 4.9 ms
✗ backup-01 10.255.255.1:9999 timeout 2017.8 ms
✗ cache-01 127.0.0.1:6379 timeout 2018.2 ms
存活 3/5,探测总耗时 2021 ms(逐个串行约需 4051 ms,并发省下的时间一目了然)
需要关注的故障目标:
- backup-01 (10.255.255.1:9999): timeout
- cache-01 (127.0.0.1:6379): timeout
并发效果直接可见:总耗时 2021ms ≈ 一次超时的时间,而串行要 4051ms——两个 2 秒超时被线程池并行吃掉了。目标越多,这个差距越大(50 个目标时是 100 秒 vs 2 秒)。
六、一个真实的意外发现
注意 cache-01:127.0.0.1:6379 上没有任何进程监听,按上一节的表格它应该是 refused,实际却返回了 timeout——本机的防火墙/安全软件把发往该端口的连接请求直接丢包了(本次写作时在真实机器上观察到的现象)。这恰好证明了探测函数必须把失败分类处理:教科书上的行为和真实网络之间永远有出入,工具要如实报告现象,而不是假定世界只有两种故障。
七、往哪里长
- 接入 01 篇的告警:
failed非空时调notify(),这个脚本立刻升级成「多主机拨测告警器」; - 加 HTTP 层探测:
requests.get(f"http://{host}:{port}/health", timeout=5)看 200 和响应体里的业务状态,TCP 通但业务死的「半死服务」只有这层能抓到; - 记录趋势:把每轮探测的
ms和状态写进 CSV/SQLite,下周就能回答「cache-01 是今天才慢的还是一直慢」; - SSH 深度体检:TCP 只能证明「端口在」,内存、磁盘、关键进程数还得登录机器看——这正是下一篇 paramiko 的主场。
本篇代码:demos/batch_health_demo.py,已在 Python 3.13.15 / Windows 真实运行,输出原样收录。