PLFM_RADAR:基于主动扫描的平台资产发现与变更追踪系统实践

发布时间:2026/11/2 1:06:59
PLFM_RADAR:基于主动扫描的平台资产发现与变更追踪系统实践

我们团队以前有个很头疼的问题:平台规模稍微上来一点,机器一多,服务一多,资产就开始失控了。你问运维同事“现在线上到底跑了多少个服务”,没人能立刻给你答案。问安全同事“有没有端口不小心暴露到公网”,他只能苦笑。每次排查故障,都得靠记忆去猜“某个老服务是不是还挂在某台机器上”。后来我们做了个东西,名字叫 PLFM_RADAR,中文可以理解成“平台雷达”。它就是一个主动对平台资产进行周期性扫描、探测、识别和变更追踪的基础设施雷达系统。

它能解决的核心问题很简单:不想靠人去盘点资产,也不想等到出了问题才被动发现环境变了。它适合给运维、SRE、安全同学用,也适合那些平台规模在几十台到几千台之间、开始觉得“脑子记不住”的团队。这篇文章就把我们设计、实现和踩坑的全过程整理出来,包括架构思路、核心代码逻辑、部署参数和真实排障记录,希望能给打算做类似平台资产扫描系统的同学一点参考。

1. 什么是PLFM_RADAR:一个平台资产雷达的真实需求

1.1 为什么叫“雷达”

“雷达”这个名字不是随便起的,它代表的是产品形态和监控理念的差异。

传统监控系统,比如 Prometheus + Alertmanager,本质是“被动等待指标”——你得先知道要监控什么,然后在目标上装好 exporter,配置好采集规则,它才能开始工作。但雷达不一样,雷达是主动发射波束,扫描整个空域,发现“有什么东西出现了、有什么东西消失了、有什么东西位置变了”。PLFM_RADAR 做的就是后者:它主动去扫整个平台网络,不需要你预先告诉它有哪些机器、哪些服务,它自己会去发现。

这种主动发现能力,在平台规模一上来之后特别关键。我见过太多团队上了几百台服务器之后,靠一个共享表格管资产,靠微信群通知上下线。结果呢?表格过期三个月了,有些服务已经下线了但机器还在跑;有些端口换过了但文档没更新;有些新服务上线了但没人告诉监控团队。PLFM_RADAR 的定位就是给这种混乱做兜底——不管你记不记得,它都会定期把平台扫一遍,然后把结果和时间线摊开给你看。

1.2 项目定位与适用场景

PLFM_RADAR 不是要取代现有的监控系统,它干的是另一件事:资产发现和变更追踪。通俗点说,就是“定期把家里翻一遍,看多了什么少了什么”。

从我实际使用经验看,它适合这几类场景:

  • 资产盘点与治理:每季度要给老板汇报“我们到底有多少台机器、多少个对外服务”,人工统计又慢又容易漏,雷达扫一轮,报表就有了。
  • 暴露面风险检查:安全团队定期需要确认哪些端口对公网开放、哪些管理后台不应该暴露。雷达的端口扫描结果可以直接喂给安全组做基线比对。
  • 服务迁移与下线辅助:每次做机房迁移或者服务下线,最怕的就是“以为全下完了,结果还有一个老服务在某个角落跑着”。雷达在迁移前后各扫一轮,差异一目了然。
  • 容量规划参考:通过扫描结果观察服务数量、端口分布的长期变化趋势,能辅助判断平台是不是在膨胀、哪些网段快不够用了。

所以它面向的人群很明确:运维工程师、SRE、平台团队、安全工作的小伙伴。凡是觉得“资产不清、心中有慌”的人,都可以在这个项目里找到你需要的那块拼图。

1.3 与常见监控方案的差异

我对比过几种常见的做法,这里直接说结论。

方案机制优点痛点
Prometheus 主动拉取指标已知目标+固定 exporter指标丰富、时效性强、生态好要求你提前知道目标清单,覆盖不到未知资产
Zabbix 自动发现网段扫描+模板匹配有自动发现能力,历史久配置偏重、规则写起来累、大批量时效率一般
商业 CMDB 平台录入+agent 上报数据规范、流程完善贵,且补数据这件事本身需要大量人力
PLFM_RADAR 雷达式主动扫描定期全量探测+指纹识别+差异比对不依赖预先清单、能发现未知变化、部署轻量只能看到网络层和服务层的资产信息,看不到业务指标

说白了,PLFM_RADAR 解决的是“先搞清楚你家里到底有什么东西”的问题。指标监控解决的是“东西运转得怎么样”的问题。两者不冲突,而且应该配合使用。你的监控系统盯已知目标的健康,雷达盯整个平台的未知变化,组合起来才比较完整。

2. 整体架构与各层设计思路

2.1 分层架构总览

这个项目的架构设计遵循一个原则:轻、独立、可扩展。模块之间尽量不要互相拖累,因为扫描系统的瓶颈往往出现在不同层级的并发和负载上,如果架构上耦合太重,排查问题会很痛苦。

整体的分层结构是:

  • 采集层:负责主动探测,包括 ICMP Ping 探测、TCP 端口扫描、HTTP/HTTPS 协议探测和 banner 抓取。
  • 识别层:根据采集回来的响应内容做指纹识别,判断端口上跑的是 Nginx、Redis、MySQL 还是别的什么服务。
  • 存储层:保存每次扫描的快照数据,以及相邻两次扫描之间的差异记录,方便做时间线回放。
  • 调度层:控制扫描的周期、并发度、扫描窗口,避免把网络打爆。
  • 通知层:把发现的变更事件整理成告警,推送到 IM 工具或者工单系统。

选这个结构的核心原因是“扫描链路天然适合流水线处理”。采集层是最重最慢的环节,网络请求要等超时;识别层是中间加工;存储和通知是扫完之后的收尾动作。把这个链路拆开之后,每一层都可以独立扩容,也可以独立优化。

2.2 数据采集层:从轻到重的三级探测

采集层是雷达的眼睛,设计上分了三级,每级代价递增、信息也递增。

第一级是ICMP 探测,也叫 Ping 扫描,负责快速判断一个 IP 是否在线。代价最低,速度最快,但也有坑:很多云环境会禁 ping。所以第一级的结果只能作为参考,不能作为离线判定的唯一依据。

第二级是TCP 端口探测,对常见端口列表做连接测试。这一级不需要发应用层协议数据,只需要完成 TCP 握手就能判断端口是否为开放状态。这一级的效率最高,信息量也足够覆盖 90% 的资产管理需求。

第三级是协议探测与 banner 抓取,对开放端口发送简单的协议探测请求,比如向 HTTP 端口发 GET /,向 SSH 端口读 banner,向 Redis 端口发 PING,然后抓取响应。这一级的信息最丰富,可以用来做服务指纹识别。

三级探测的设计逻辑是“分级投入产出”:不先确认主机存活就直接狂扫端口,会浪费大量时间在不可达 IP 上;不先确认端口开放就直接发应用层请求,容易造成误判和多余告警。每一级探测的结果,都是下一级探测的过滤条件。

2.3 存储层设计:时间线比快照更值钱

存储层是整个系统容易翻车的地方,因为扫描数据的天然形态就是“会越来越多”。

我们一开始的想法是把每次扫描的完整结果都存下来,方便随时查询。试了几个月后发现一个问题:数据量增长太快,但真正有价值的信息是变化。第 5 次扫描和第 6 次扫描之间,99% 的数据是相同的,全量存下来纯粹是浪费磁盘。

后面调整策略,只存两类数据:周期快照和变更记录。

  • 周期快照:每周保存一次全量扫描结果,用于生成周报、趋势分析和审计查询。
  • 变更记录:每次扫描都会和前一次结果做 diff,把新增、消失、端口变更、服务变更的事件单独存储。

这套设计让存储量一下子降了一个数量级,而且回答“上周有什么东西变了”这类问题变得非常快,直接查变更表就行,不用对比两张全量表。如果你正在设计类似的系统,强烈建议在第一天就想清楚这个取舍。

2.4 告警通知层:控制噪音比打通渠道更重要

告警通知这个模块,技术上没什么难的,无非是建个 Webhook 往钉钉、企微、飞书或者 Slack 里推消息。真正难的在干噪——如果扫描系统每次扫出一点变化就疯狂发消息,第一天就会被同事全员屏蔽。

我们的处理方式有三条:

  • 分级告警:新增高危端口、数据库类服务暴露、核心网段出现新主机,这些是严重级别;普通服务上下线、低端口变化,这些是提示级别。
  • 聚合去重:同一个 IP 上连续多次扫描都出现同一个变化,只在第一次变化时发一条告警,后面进入“持续异常”状态,单独汇总,不轰炸。
  • 静默窗口:发布窗口和变更窗口期间,允许临时关闭告警或降级为记录。

告警工具不挑贵的,选团队已经在用的 IM 就行。少一个工具,就少一个维护成本。

3. 核心模块的实操实现

3.1 端口扫描引擎:异步并发与超时控制

端口扫描是整个雷达的发动机,并发效率和超时控制是写好它的关键。用的是 Python 的 asyncio + TCP 连接探测方式,没有依赖 nmap,主要是因为:

  1. 生产环境装 nmap 这种重量级工具,大平台审批流程麻烦;
  2. 自研扫描逻辑更方便和后面的指纹识别、数据存储做整合;
  3. nmap 功能强大但是输出格式要花时间解析,不如直接用 socket 层自己控制。

核心扫描函数的逻辑可以精简成下面这样:

import asyncio async def check_port(ip: str, port: int, timeout: float = 0.8) -> dict: try: # 使用 asyncio.open_connection 做 TCP 握手,不发送数据 reader, writer = await asyncio.open_connection(ip, port, timeout=timeout) writer.write(b"\r\n") await writer.drain() # 给对端一点时间返回 banner try: banner = await asyncio.wait_for(reader.read(1024), timeout=0.5) except Exception: banner = b"" writer.close() try: await writer.wait_closed() except Exception: pass return {"ip": ip, "port": port, "status": "open", "banner": banner[:128].decode(errors="ignore")} except asyncio.TimeoutError: return {"ip": ip, "port": port, "status": "filtered", "banner": ""} except ConnectionRefusedError: return {"ip": ip, "port": port, "status": "closed", "banner": ""} except Exception as e: return {"ip": ip, "port": port, "status": "closed", "banner": ""}

接着用信号量控制并发数,防止一次性打太多连接把负载打满或者被网络设备拦:

async def scan_host(ip: str, ports: list, semaphore: asyncio.Semaphore, timeout: float = 0.8) -> list: results = [] async def _run(port): async with semaphore: result = await check_port(ip, port, timeout) if result["status"] in ("open", "filtered"): results.append(result) tasks = [asyncio.create_task(_run(p)) for p in ports] await asyncio.gather(*tasks) return results

并发量怎么定比较合理?我实测的经验值是:扫描单个 /24 网段时,信号量控制在 200 到 500 之间,单端口超时 0.5 到 0.8 秒,整段扫描一般能在一到三分钟内完成。如果设置到 1000 并发,速度快不了多少,但很容易触发交换机或者云安全组的限流策略,甚至被误判为扫描攻击行为。

3.2 服务指纹识别:优先级匹配和加权评分

服务指纹识别是雷达“认人”的能力。光知道 3306 端口开着还不够,你得确认它真的是 MySQL,而不是某个改了端口的 PostgreSQL。我们的指纹识别库用 YAML 规则组织,每条规则包含三块信息:匹配的端口范围、探测请求内容、响应匹配模式。

- name: redis ports: [6379, 6380, 16379] probes: - send: "PING\r\n" match: "(PONG|NOAUTH|ERR)" weight: 10 - name: mysql ports: [3306, 33060] probes: - send: "" match: "mysql|MariaDB|5\\.x" weight: 10 - name: nginx ports: [80, 443, 8080, 8443] probes: - send: "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n" match: "(Server: nginx|nginx/[0-9]+\\.)" weight: 8

识别逻辑不复杂,但有一个容易踩坑的点:多条规则可能同时匹配,比如某些网关服务既有 HTTP 响应特征又有自定义协议特征。我们的做法是给每条规则配一个权重,最终结果取最高权重和最先命中的规则,同时记录候选列表。这样处理的好处是识别准确度不会因为单条规则误命中就崩掉,排查时也能看到“系统其实还考虑过哪些候选”。

实际跑下来,指纹识别对 Redis、MySQL、Nginx、Elasticsearch、SSH、RDP 这些主流服务判断准确率在 95% 以上。容易翻车的是各类内部自研协议和经过 TLS 加密的服务,这类服务除非配置了证书信息检测,否则不建议花太大的精力去做识别,性价比不高。

3.3 调度与变更检测:周期、错峰和 diff

调度模块我们用 APScheduler 实现,原因是它支持 cron 表达式,也支持 Python 函数直接调用,不引入额外重组件。配置文件很直接:

scan_schedules: internal_segment: schedule: "*/30 * * * *" # 每 30 分钟一次 target: "192.168.10.0/24" exclude: ["192.168.10.66"] ports: ["top-1000"] concurrency: 200 timeout: 0.8 fingerprint_enabled: true dmz_segment: schedule: "0 */1 * * *" # 每小时整点一次 target: "10.0.0.0/24" ports: ["all"] concurrency: 150 timeout: 0.6 fingerprint_enabled: true

变更检测是每次扫描完成之后立即执行的逻辑,大致流程是:

  1. 加载前一周期该网段的扫描结果;
  2. 将本次结果转成可哈希的资产标识(比如ip:port:protocol:service的组合);
  3. 做集合差运算,得到新增集合和消失集合;
  4. 对差异结果做二次轻量探测确认,防止因为一次抓包失败或者网络抖动导致的误报;
  5. 写入变更记录表并触发告警。

这里建议做一次二次确认再告警,因为端口扫描受网络影响波动是常态,宁可慢一个周期,也不要制造告警噪音。我们这个系统上线初期改了好几次告警策略,最后落到“二次确认+聚合+分级”才算消停。

3.4 部署细节与参数选型

我自己跑下来的部署方案是 docker-compose 起三个容器:扫描器、PostgreSQL、Web 管理界面。这个组合在百台到千台规模的平台上支撑得很好。

services: scanner: build: . environment: SCAN_SCHEDULE_FILE: /app/config/schedule.yaml DATABASE_URL: postgresql://radar:radar@db:5432/radar ALERT_WEBHOOK: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" volumes: - ./config:/app/config depends_on: - db db: image: postgres:14 environment: POSTGRES_USER: radar POSTGRES_PASSWORD: radar POSTGRES_DB: radar volumes: - pgdata:/var/lib/postgresql/data web: image: nginx:alpine volumes: - ./web:/usr/share/nginx/html:ro ports: - "8080:80"

几个参数选型的经验值:

  • 超时时间:核心网络环境内,0.5 到 1 秒比较合适。太短误报多,太长扫描周期会被拖得很慢。
  • 并发数:总的原则是“宁小勿大”。建议从 100 起测,观察网络设备的 CPU 和丢包率,再逐步往上调。
  • 扫描目标:强烈建议按网段配置 exclude,排除 DHCP 地址池、负载均衡虚拟 IP 和已知的网络设备地址,这些不扫能省很多事。
  • 扫描周期:核心生产网段建议 15 到 30 分钟一次;外围网段 1 小时一次足够。再频繁,变化的信息量也不会更多,反而增加负载。

4. 常见问题与排查技巧实录

4.1 端口扫描结果莫名缺失,过一会儿又好了

这是我们上线第一个月碰到最多的问题。症状是某个网段某次扫描突然少了一大堆端口,过半小时再扫又全部正常。一开始以为是代码 bug,查了半天发现是上游交换机开启了类似于“安全阈值”的机制。同一时间内主动连接数超过一定阈值,交换机会直接丢弃后续的连接请求,但不会立刻恢复,要等一段时间窗口。

解决办法有两个:

  • 第一,降低单网段并发数,从 500 降到 200;
  • 第二,扫描器加一个“扫描窗口内负载保护”的逻辑,如果连续连接失败率超过 20%,就暂停 30 秒再继续,而不是继续用满并发硬刚。

这个稳定性的调整比任何代码优化都管用。

4.2 服务指纹识别的误报和漏报

误报和漏报是雷达最影响信任感的问题。我们一开始的指纹库只有二三十条规则,漏报特别多,尤其是中文环境下的各类中间件和自研网关。后面总结了三个改善方向:

  • 多探测组合:不要只靠 banner 匹配。HTTP 服务同时检查 Server 头、页面标题、Cookie 名称、favicon 的 hash、证书 Common Name。多信号综合判断,单条误报的影响会被稀释。
  • 规则优先级调优:把数据库类、缓存类的高确定性规则权重调高;把页面标题类等低确定性信号权重调低。
  • 人工反馈闭环:Web 界面留一个“标记错误”按钮,看到“系统识别为 Nginx,实际是 OpenResty”,点一下就能提交纠正规则。这个闭环对长尾服务指纹优化至关重要。

4.3 扫描器本身变成瓶颈

扫描器跑在单机上,扫描网段多起来之后会遇到文件描述符耗尽的问题。Linux 默认的 ulimit 限制会导致扫描器运行几个小时后报Too many open files,然后扫描任务静默失败。这个坑非常隐蔽,因为任务失败不一定会报错,可能只是结果不落库。

解决的清单是:

  • ulimit -n 65535,并且确认 systemd 或容器编排里的 LimitNOFILE 也被同步调整;
  • 代码里捕获OSError: [Errno 24],触达上限时自动结束当前批次并等待 10 秒再继续;
  • 监控扫描器自身连接池大小,不要用无界连接池。

另外一个容易被忽略的点是:扫描器机器的时间和被扫机器的时间要同步,否则算扫描耗时和响应时间时会有偏差。

4.4 告警风暴与用户疲态

雷达系统的告警一旦不受控,团队会很快形成“狼来了”心态。我们的历史教训是:新网段接入雷达系统时,第一天一定会产生大量告警,因为很多“存量问题”是雷达第一次看到,它不是变化,只是首次被发现。

处理方法是和一个“学习期”的配置:新接入网段在头 24 小时或前 3 次扫描内,只记录不告警,系统先建立基线,之后的变化才触发告警。这个逻辑加完之后,告警量直接降了 80%。任何类似扫描系统都建议加入这个基线学习的阶段。

以下是几个常见问题的速查表,可以直接收藏:

问题现象可能原因快速排查与解决
整段网段端口全部扫不出来交换机/安全组限流、ARP 表项老化看扫描器错误日志里的失败率,降并发、加扫描间隔
单台机器总是扫不全防火墙规则按源 IP 限制换扫描源 IP 测试,或者调整安全组白名单
指纹识别全部“未知”端口开放但 probe 请求被拒手工 curl 或 nc 抓 banner,确认签名格式
告警重复轰炸没有二次确认和去重补丁: 增加二次探测 + 聚合 + 静默窗口
存储磁盘涨得飞快全量快照存储策略问题改按周期快照+变更记录的模式
扫描器报文件描述符耗尽ulimit 限制调大 ulimit,同时代码里做好连接池管理

4.5 快照对比方法容易踩的隐坑

做变更检测时还有一个容易忽略的细节:不要直接拿两次扫描的原始结果做字符串比较。因为端口扫描的结果每次都有细微震动,比如某个服务重启后端口从 TCP 半开状态变成了稳定状态,或者某个应用层的 banner 因为版本号更新而改变了。如果直接粗暴对比,会有大量“假变更”。

我们的办法是对每个资产生成一个归一化指纹,比如 IP + 端口 + 归一化后的服务名作为唯一键,banner 细节不参与变更判断。只有服务主体发生变化才算真正的变更。这个设计让变更检测的准确性上了几个台阶。

5. 后续扩展方向与个人体会

PLFM_RADAR 这个项目做下来,我最大的体会是:雷达的价值从来不在“扫得多快”,而在“持续地扫、可靠地比、克制地告警”。我们踩过的所有坑,几乎都集中在“怕漏报所以拼命扫”“怕漏报所以拼命告警”这两个冲动上。真正让这个系统在生产环境站住脚的,反而是加了一堆“克制”的机制:限速、二次确认、基线学习、聚合告警。

后面我们还想继续扩展几个方向:

  • HTTP 资产深度探测:对识别出的 HTTP 服务继续追踪域名、标题、框架指纹、登录入口,做成一份自动更新的 Web 资产台账。
  • 多扫描源支持:目前只有一台扫描器,后续会部署多地域扫描节点,防止单点故障,也能对比不同网络路径下的观察结果。
  • 与 CMDB 联动:雷达的发现结果可以自动和 CMDB 做比对,推动团队修正录入数据,让配置管理从“人维护”变成“系统验证”。

最后分享一个很小但对日常使用体验提升很大的技巧:给雷达的告警消息里加上“本次变更的上一状态快照链接”。这样收到告警的人不用登录系统查历史,点开链接就能看到这个服务上次扫描是什么状态、什么指纹、什么时候变化的。省去了一步操作,同事的使用意愿会高很多。

雷达扫描系统听起来是一个简单的工具,但真正用起来之后会发现,它解决的不只是资产可见性问题,更是团队心里那种“靠猜”的不安全感。项目做到现在,至少我们再有同事问“那个老服务还在吗”的时候,可以打开雷达回放一下,然后笃定地回答:在,地址是 xxx,三天前被扫到了,你赶紧下线吧。