三盞燈各自照亮一片,清單上的東西沒全照到 右邊三片叢集裡散落著許多實體,上方三盞燈(Prometheus、Zabbix、Flink 平臺)各投下一束光,光裡的點是實心的,光外的點是空心的,兩束光交疊處同一批點被照了兩次;光縫裡一個空心點被紅色虛線圈出。左邊三塊讀數分別是 prometheus 100%、zabbix 99.0%、flink 100%,下面一行“清單 ?”。 prometheus 100% zabbix 99.0% flink 100% 清單 ?

先看三行數字。它們來自三個監控工具各自的概覽頁(這是拼出來的一個典型場景,數字是示意):

Prometheus 集群 prod-sh、prod-bj 抓取目标 1,284 up=1 的 1,284 覆盖率 100%
Zabbix 主机组 IDC-物理机 主机 312 agent 可用 309 覆盖率 99.0%
Flink 平台 实时作业 作业 86 已自动接入 86 覆盖率 100%

同一天早上,數倉的人發現訂單寬表 dwd_order_wide 從凌晨 02:10 起就沒再寫入,下游的報表空了 6 個小時,沒有任何告警。寫這張表的 Flink 作業 ods_order_etl 一切正常:沒有重啟,checkpoint 按時完成,消費積壓是 0。

三個工具都說自己接近 100%,事故卻恰好落在它們之外。這三個 100% 到底是什麼意思?它們能加起來,或者取個平均,得到一個“全公司的監控覆蓋率”嗎?Flink 作業接上源頭就自動有了監控,這算不算覆蓋了那張表?

本文順著這幾個問題往下追:先弄清覆蓋率的分子分母各是什麼,再看“被監控”這件事怎麼脫離具體模板來定義,然後落到一個工具無關的抽象層上,最後看怎樣不靠手工登記,用兩套規則和一個求解器把覆蓋率“求”出來。

一、覆蓋率的分母

現象:每個工具都接近 100%

先看 Prometheus 的 100% 是怎麼來的。Prometheus 透過服務發現得到一批抓取目標,每抓一次,就給這個目標寫一條 up:抓到了是 1,沒抓到是 0。所謂覆蓋率,就是 up=1 的目標數除以目標總數。Zabbix 的演算法類似,分母是登記在它裡面的主機,分子是 agent.ping 還通的主機1。Flink 平臺更直接:作業是平臺自己建立的,每個作業建立時就掛上了指標上報,分母和分子天然相等。

問題出在分母上。三個工具的分母,都是它自己已經知道的東西。一臺從沒裝過 Zabbix agent 的機器,不在 Zabbix 的分母裡;一個新建的叢集 prod-gz,只要沒人把它加進 Prometheus 的服務發現配置,就不會有一條 up 序列,自然也不會讓覆蓋率下降。

第一次嘗試:對缺失的指標告警

Prometheus 社群對“東西不見了”有一個常用辦法:absent(up{job="node"}),當整個 job 的 up 都消失時觸發告警2。它能抓住“曾經有、後來沒了”的一類問題,比如整個 exporter 掛了。

但它抓不住另外兩類。一類是從服務發現裡被移除的目標:目標一移除,舊的 up 序列就被標記為過期,直接從查詢結果裡消失,absent() 看到的只是“少了一個”,不會報警3。另一類更徹底,是從來沒被發現過的東西,比如那個新叢集和那張表,它們連“曾經有”都沒有。說到底,absent() 拿來比對的仍是 Prometheus 自己的歷史,沒有跳出它的分母。

三個工具各有各的分母 四個分割槽從左到右是 Prometheus、Zabbix、Flink 平臺和“沒有工具認領”。前三個分割槽各有一張自報覆蓋率卡片:Prometheus 分母 1,284 個目標、分子 up=1 的 1,284,100%;Zabbix 分母 312 臺主機、分子 agent 可用 309,99.0%;Flink 平臺分母 86 個作業、分子已接入 86,100%。各分割槽下面列著它看得見的物件,其中 Prometheus 的 10.2.3.17:9100 和 Zabbix 的 sh-db-017 用點線相連,是同一臺機器。第四個分割槽是虛線框,裡面是叢集 prod-gz、主題 order_events 和表 dwd_order_wide,不在任何一個分母裡。 Prometheus 叢集 prod-sh · prod-bj Zabbix 主機組 IDC-物理機 Flink 平臺 實時作業 沒有工具認領 清單裡有,工具裡沒有 自報覆蓋率 100% 分母 1,284 個目標 分子 up=1 · 1,284 自報覆蓋率 99.0% 分母 312 臺主機 分子 agent 可用 309 自報覆蓋率 100% 分母 86 個作業 分子 已接入 86 它看得見的 它看得見的 它看得見的 10.2.3.17:9100 namespace=order sh-db-017 sh-db-018 ods_order_etl dwd_order_join 叢集 prod-gz 主題 order_events 表 dwd_order_wide 虛線框:它們不在 任何一個工具的分母裡 點線連著的是同一臺機器:Prometheus 記成 10.2.3.17:9100,Zabbix 記成 sh-db-017,各算一次
圖 1 · 每個工具只對自己發現的目標負責,自報的覆蓋率天然接近 100%;清單上有些東西不在任何一個分母裡

圖 1 裡還藏著第二個問題:同一臺資料庫機器,Prometheus 記成 10.2.3.17:9100,Zabbix 記成 sh-db-017。兩個工具各把它算進自己的分母,各報一次“已覆蓋”。如果我們把幾個工具的數字簡單相加,這臺機器被數了兩次,而那張表一次也沒被數到。

解決:分母從工具之外來

所以第一條結論很樸素:覆蓋率的分母不能由監控工具自己提供,必須來自一份獨立的“應該被監控的東西”清單。 這份清單的來源是各類資產的權威系統:主機來自 CMDB 或雲廠商 API,叢集和工作負載來自 Kubernetes API,Flink 作業來自各 Flink 叢集的 REST 介面(GET /jobs/overview)4,Kafka 主題來自叢集後設資料,資料表來自資料目錄。

覆蓋率於是變成兩份集合的比對:清單裡有哪些東西(期望),工具裡看得到哪些東西(觀測),前者減去後者就是缺口。

可是,“工具裡看得到”就算被監控了嗎? Prometheus 抓得到 sh-db-017 的指標,不代表它的磁碟快滿時有人收到告警。接下來要回答的,是“被監控”到底指什麼。

二、模板之上的“能力”

現象:模板之間沒法直接比較

同樣是“監控一臺 Linux 主機”,兩個工具給出的東西長得完全不一樣。Zabbix 的官方模板 “Linux by Zabbix agent” 裡是一組 item 和 trigger:agent.ping 檢查存活,system.cpu.util 採 CPU,vfs.fs.dependent.size[{#FSNAME},pused] 採每個分割槽的使用率,配上 Linux: Zabbix agent is not available、Linux: High CPU utilization 這類觸發器1。Prometheus 那邊是 node_exporter 暴露的 node_filesystem_avail_bytes 之類的指標,再由 kube-prometheus 的規則集給出 TargetDown、NodeFilesystemAlmostOutOfSpace 這類告警56。

如果按“模板裡有多少條規則”來衡量,兩邊的數字毫無可比性:一個模板可能有 60 個 item,卻沒有一條磁碟告警;另一個只有 10 條規則,卻條條都有人接。數規則、數儀表盤,量的都是工具的形態,而不是我們關心的問題有沒有被盯住。

原因:我們真正想問的是“這件事有沒有人盯”

換個問法會清楚很多。對一臺主機,我們想知道的是:它掛了會不會有人知道?CPU、記憶體、磁碟快滿了會不會有人知道?至於用哪個工具、哪個指標名、哪條規則,是實現細節。

把這些“想知道的事”叫作能力(capability)。能力屬於實體型別,而不屬於工具:主機需要“存活”和“資源”兩項能力,一個對外提供介面的服務需要“存活”和“請求”(速率、錯誤、耗時)能力,一個 Flink 作業需要“執行狀態”“checkpoint”“消費積壓”,一張資料表需要“新鮮度”和“量級”。

這份清單不必從零想。幾套流傳很廣的監控方法論,本身就是在回答“某類東西要看哪幾項”:Brendan Gregg 的 USE 方法針對資源類物件,看利用率、飽和度、錯誤7;Tom Wilkie 的 RED 方法針對請求驅動的服務,看速率、錯誤、耗時8;Google SRE 書裡的四個黃金訊號是延遲、流量、錯誤、飽和度9。它們正好可以作為不同實體型別的預設能力集。

解決:把每個工具的規則對映到能力上

有了能力這一層,模板差異就被消化了:每個工具裡的每一條 item 或規則,都對映到“哪類實體的哪項能力”。比較兩個工具,比的是它們各自覆蓋了哪些能力,而不是它們有多少條規則。

同一項能力,在三個工具裡分別是什麼
實體 · 能力PrometheusZabbixFlink 平臺
主機
存活up · TargetDownagent.ping · Zabbix agent is not available不涉及
資源(磁碟)node_filesystem_avail_bytes · NodeFilesystemAlmostOutOfSpacevfs.fs.dependent.size[…,pused] · Space is critically low不涉及
Flink 作業
執行狀態flink_jobmanager_job_numRestarts不涉及numRestarts
checkpointflink_jobmanager_job_lastCheckpointDuration不涉及lastCheckpointDuration
消費積壓…_KafkaSourceReader_…不涉及pendingRecords
資料表
新鮮度沒有沒有沒有

Prometheus 一列的 Flink 指標名隨版本和 reporter 配置會有出入。

圖 2 · 模板各不相同,但能對到同一張能力表上;對完之後,三個工具都空著的那一行一眼就能看出來

表裡 Prometheus 一列的 Flink 指標,是 Flink 的 Prometheus reporter 把指標作用域拼成名字、把作業名等變數轉成 label 之後的樣子10,所以同一個 numRestarts 會在兩個工具裡各出現一次,名字不同,能力相同。

能力表還順帶讓“覆蓋”有了層級。同一項能力,可以只是有采集(指標在庫裡,出事後能查),也可以是有告警(有一條規則會觸發),還可以是告警有人接(規則路由到了這個實體的負責團隊)。三者對“出事時有沒有人知道”的意義完全不同,統計時應該分開。

圖 2 最後一行三個工具都是空的,那正是開頭那張表。可 Flink 平臺明明說那個作業“已自動接入監控”。作業被監控了,為什麼它寫的表沒有?

Flink 自帶一套指標系統。作業一提交,JobManager 和各個 TaskManager 就按固定的作用域(scope)註冊指標:作業級的 numRestarts、lastCheckpointDuration,任務級的 backPressuredTimeMsPerSecond,運算元級的 numRecordsOutPerSecond、currentOutputWatermark。運算元指標的預設名字是 <host>.taskmanager.<tm_id>.<job_name>.<operator_name>.<subtask_index>,作業名、運算元名都帶在裡面11。聯結器還會補上自己的指標,比如 KafkaSource 會報告還沒拉取的記錄數 pendingRecords、距離上次處理記錄的時間 sourceIdleTime12。

平臺只要在叢集配置裡掛一個 reporter,所有作業的這些指標就會源源不斷地送到監控系統,再配一套按作業名展開的通用告警模板,就做到了“源頭接上就有監控”。這是真實的便利:沒有人需要為每個新作業單獨配監控。

現象:作業全綠,錶停了 6 小時

回到開頭的事故。凌晨 02:10,上游的訂單服務因為一次配置變更停止往 order_events 寫訊息。對 ods_order_etl 來說,這意味著什麼?

上游停產的那 6 小時裡,三類實體各自的監控狀態 三個分割槽從左到右是 Kafka 主題 order_events、Flink 作業 ods_order_etl、資料表 dwd_order_wide,作業讀主題、寫表。主題下方的生產速率是虛線框,沒有任何工具採集。作業下方是平臺模板自帶的訊號:numRestarts 0、lastCheckpointDuration 1.2 s、pendingRecords 0 都有告警且為綠,sourceIdleTime 6 h 只有採集、沒有閾值。表下方記錄著最後寫入 02:10、現在 08:10,新鮮度告警是虛線框,沒有定義多久算舊。一條強調色連線從 sourceIdleTime 指向表的最後寫入時間。 Kafka 主題 Flink 作業 資料表 讀 寫 order_events ods_order_etl dwd_order_wide 生產速率 沒有任何工具採集 平臺模板自帶的訊號 規則 numRestarts 0 告警 lastCheckpointDuration 1.2 s 告警 pendingRecords 0 告警 sourceIdleTime 6 h 無閾值 表的狀態 最後寫入 02:10 現在 08:10 新鮮度告警 沒定義多久算舊 作業沒出錯,只是沒有資料可讀 虛線框:這一項沒有對應的監控
圖 3 · 上游停產時,作業模板裡的告警全是綠的;能說明問題的 sourceIdleTime 有采集、沒閾值,表的新鮮度沒人定義

作業沒有重啟,checkpoint 照常完成(沒有新資料,狀態很小,反而更快),pendingRecords 是 0,因為主題裡確實沒有待讀的訊息。平臺模板裡每一條告警都是綠的,而且它們沒有說錯:作業本身確實健康。

唯一變了的是 sourceIdleTime,它從 02:10 起一路漲到 6 小時。這個指標一直在採集,但平臺的通用模板不會給它設閾值。原因不難理解:有的主題每秒幾千條,空閒 5 分鐘就不正常;有的主題只在夜裡批次灌一次數,空閒 20 小時也正常。“多久沒資料算異常”取決於資料本身的業務節奏,平臺在作業這一層根本不知道這個數。

原因:自動監控盯的是載體,不是貨物

這就是 Flink 自動監控的邊界:它覆蓋的是“作業”這個實體的能力,也就是執行狀態、checkpoint、積壓。作業是搬運資料的載體,資料本身(主題裡的訊息、表裡的行)是另外的實體,它們的能力(生產速率、新鮮度、量級)需要另外的期望值,而這些期望值只有資料的負責人知道。

但作業的指標並非和資料實體毫無關係。sourceIdleTime 說的其實是主題這一側的事:上游多久沒生產了。numRecordsOutPerSecond 說的是表這一側的事:每秒寫進去多少行。也就是說,一個實體上的訊號,可以順著實體之間的關係,給相鄰的實體提供一部分覆蓋。只是這種“派生覆蓋”通常只到“有采集”這一級,要升到“有告警”,還得有人在資料實體上把閾值定下來。

這樣,問題就從“Flink 有沒有監控”變成了更一般的三件事:實體有哪些型別、它們之間有什麼關係、每類實體要求哪些能力。能不能把這三件事做成一個獨立的層,讓所有工具都往上對?

四、本體層:實體、關係與能力

它應該長什麼樣

“本體”(ontology)這個詞在這裡的意思很樸素:對我們關心的世界,約定有哪些型別的東西、它們之間有哪些關係。Palantir 的 Foundry 把這套東西叫 Ontology,用物件型別、屬性、連結型別來描述業務世界13;我們借這個詞,描述的是“需要被監控的世界”。

把前三節的結論拼起來,這個層至少要有五樣東西:

實體型別和身份鍵。 每類實體(主機、叢集、Flink 作業、Kafka 主題、資料表、服務)約定一個身份鍵:主機用主機名,叢集用叢集名,作業用“Flink 叢集 + 作業名”,表用“庫.表”。身份鍵必須穩定,並且不依賴任何一個監控工具的命名。

實體清單。 每類實體的全體例項,從第一節說的權威來源同步過來,這是覆蓋率的分母。

關係。 作業讀哪個主題、寫哪張表,Pod 跑在哪個叢集,實體歸哪個團隊負責。關係既用來做派生覆蓋,也用來回答“告警該發給誰”。

能力要求。 每類實體需要哪些能力,按重要度分級:核心表要求新鮮度告警,臨時表也許只要求有采集。

監控繫結。 每個工具裡的每一條規則或採集項,落到“哪個實體的哪項能力、到哪一級”。這一步要做身份解析:Prometheus 的 instance="10.2.3.17:9100" 和 Zabbix 的 sh-db-017,都要對映到身份鍵 host.name = sh-db-017。

本體層的結構 左邊分割槽是實體清單的權威來源:CMDB 給出 sh-db-017,K8s API 給出叢集 prod-sh 和 prod-gz,Flink REST 和 Kafka 給出作業與主題,OpenLineage 給出 reads 和 writes 關係,資料目錄和 Backstage 給出表和 owner。中間分割槽是本體層:實體型別與身份鍵表(host 用 host.name,k8s_cluster 用 k8s.cluster.name,flink_job 用叢集加 job_name,topic 用叢集加 topic,table 用庫.表,各自列出必需能力),以及關係表(flink_job reads topic、flink_job writes table、任意實體 owned_by team)。右邊分割槽是各工具的監控繫結:Prometheus 的 10.2.3.17:9100、Zabbix 的 sh-db-017、Flink 平臺的 ods_order_etl,以及一條規則繫結 TargetDown 到存活。兩個工具裡的名字都對映到 host.name = sh-db-017。底部是輸出:實體乘能力乘層級的覆蓋矩陣,以及按重要度排序的缺口清單。 實體清單的來源 本體層 各工具的監控 CMDB sh-db-017 K8s API prod-sh · prod-gz Flink REST · Kafka /jobs/overview OpenLineage reads · writes 資料目錄 · Backstage owner: team-dw 實體型別 身份鍵 必需能力 host host.name 存活 · 資源 k8s_cluster k8s.cluster.name 存活 · 資源 flink_job 叢集+job_name 執行 · 積壓 topic 叢集+topic 生產 table 庫.表 新鮮度 · 量級 關係 flink_job reads topic 派生覆蓋 flink_job writes table 派生覆蓋 * owned_by team 告警路由 Prometheus 10.2.3.17:9100 Zabbix sh-db-017 Flink 平臺 ods_order_etl 規則繫結 TargetDown→存活 兩個名字都對映到 host.name = sh-db-017 輸出:實體 × 能力 × 層級 的覆蓋矩陣 → 按重要度排序的缺口清單
圖 4 · 本體層夾在中間:左邊決定“應該有什麼”,右邊說明“實際盯著什麼”,兩邊都對齊到同一套實體型別和身份鍵上

業界已有的幾塊拼圖

這五樣東西,業界其實各有現成的做法,只是很少有人把它們拼成“覆蓋率”這一個用途。

OpenTelemetry 的實體模型解決身份。它規定一個實體由型別、身份屬性和描述屬性組成:身份屬性在實體的生命週期內不能變,描述屬性可以變14。它的語義約定還要求身份屬性的選擇能讓不同的觀察者(應用裡的 SDK、旁邊的 Collector)獨立推出同一個身份。這正是我們需要的:host.name、k8s.cluster.name、service.name 這些屬性名可以直接拿來做身份鍵。不過這個模型目前還處在開發狀態,關係的建模也寫明留待後續。

Backstage 的軟體目錄解決“宣告的清單”和負責人。它用 catalog-info.yaml 宣告 Component、API、Resource、System 等實體,每個實體有 spec.owner15。它的清單來自開發者主動宣告,而不是從監控裡推出來,所以天然適合當分母;owner 則直接決定告警路由。ServiceNow 的 CSDM 在 CMDB 裡做的是類似的事,把業務服務、應用服務、技術服務分層管理16。

Grafana 的 Asserts(現在叫 Knowledge Graph)解決關係。它從 Prometheus 指標的 label 裡抽取實體和關係,比如節點 HOSTS Pod、服務 CALLS 服務,還允許用 YAML 補充自定義的實體型別17。要注意它的圖是從觀測側長出來的:沒有被採集的東西不會出現在圖裡。所以它適合補關係和做身份解析,卻不能當分母用,這一點和第一節的結論一致。

OpenLineage 的 Flink 整合解決資料鏈路上的關係。它在作業提交時透過 Flink 的 JobListener 讀取 Source 和 Sink,生成“作業讀了哪些資料集、寫了哪些資料集”的血緣事件18。有意思的是,它掛的位置和平臺的“自動監控”是同一個時刻:作業一提交,監控和血緣邊可以一起自動產生。目前它的支援範圍有限,文件寫明只支援 Application Mode,聯結器以 Kafka 為主。

這四塊分別對應身份、清單、關係三樣東西,再加上我們自己定義的能力要求和監控繫結,本體層就齊了。剩下的問題是:有了這一層,覆蓋率具體怎麼算、怎麼呈現?

五、從列舉到求解

現象:列舉出來的覆蓋表會腐化

照第四節的寫法,最直接的落地方式是一張對照表:每個工具裡的每個目標、每條規則,手工登記成“它覆蓋了哪個實體的哪項能力”。規模小的時候這能用,可它很快就會爛掉。

設想機房新上架一臺資料庫機器 gz-db-001。它進了 CMDB,清單那一側自動就有了;對照表那一側卻沒有人想起來加一行,於是它在報表裡要麼不出現,要麼永遠顯示“沒有任何監控”,沒人分得清這是真缺口還是表沒更新。反過來,一臺下線的機器從 CMDB 刪掉了,對照表裡那幾行還在,指向一個已經不存在的東西。實體每天在變,對照表靠人維護,它最後會變成第二份 CMDB,而且是更舊的那份。

原因:工具本身就是按規則覆蓋的

如果回頭看各個工具實際是怎麼決定“監控誰”的,會發現它們幾乎都不按名單,而是按規則。

Zabbix 的自動註冊可以寫一個動作:主機後設資料裡含 Linux,就加入某個主機組、掛上 Linux 模板19。Prometheus Operator 的 ServiceMonitor 不列服務名,只寫 selector.matchLabels,選中所有帶某個 label 的 Service;Prometheus 再用 serviceMonitorSelector 選中它要用的 ServiceMonitor20。Flink 的 reporter 寫在叢集配置裡,這個叢集上提交的每個作業都會上報。這和 Kubernetes 排程的親和性(affinity)是一個思路:Pod 不寫“我要去 node-17”,只寫“我要去帶 disktype=ssd 的節點”,由排程器去匹配21。

也就是說,真實世界裡的覆蓋關係本來就是“屬性和關係上的規則”,是我們在建模時把它攤平成了名單。名單會腐化,是因為它把規則的結果抄了一遍,而規則的輸入每天都在變。

解決:兩套規則,一個求解器

那就不抄結果,直接把規則寫下來。整個系統拆成三樣東西:

事實,由同步程式從權威來源和工具配置裡讀出來,不經人手:CMDB 裡有哪些主機、各自屬於哪個組、重要度是幾級;血緣裡誰讀誰、誰寫誰;Zabbix 的模板掛在哪些主機組上;Prometheus 當前有哪些抓取目標。

規則一:哪些需要被覆蓋。 它只用型別、屬性和關係來說話,比如“主機都要有存活”“一級主機要有資源”“一級表要有新鮮度”。這裡正好用上關係:一張一級表的上游,不管隔了幾層作業和主題,都繼承它的重要度。這是一條遞迴規則,寫一次,任意深度的血緣都適用。

規則二:當前覆蓋了什麼。 它把每個工具的作用域翻譯成能力:主機組在 Linux 模板的作用域裡,組裡的主機就有存活和資源;主機的 IP 出現在 Prometheus 抓取目標裡,它就有存活(身份解析在這裡也是一條規則,而不是一張別名表);平臺託管的作業有執行;被有監控的作業讀著的主題,派生出“生產”的 L1。

求解器拿事實和兩套規則,算出“要求的”和“做到的”,兩者之差就是缺口。規則裡不出現任何一個主機名、作業名,新東西一進事實,就自動落進兩套規則裡被判定一次。

事實、兩套規則和求解器 上層是自動同步來的事實:CMDB 裡新上架的主機 gz-db-001 屬於主機組 IDC-GZ、重要度 tier 1;Zabbix 的 Linux 模板只掛在 IDC-SH 組上;Prometheus 的抓取目標是 10.2.3.17:9100 和 10.2.9.40:9100。中層是兩套手寫規則:哪些要覆蓋(主機要存活,一級主機要資源,一級表要新鮮度,一級實體的上游也算一級),以及當前覆蓋了什麼(主機組在模板作用域裡就有存活和資源,IP 在抓取目標裡就有存活,平臺託管的作業有執行,被已覆蓋作業讀取的主題有生產,只到 L1)。下層是求解器 clingo 算出 need 減 has 的缺口:gz-db-001 的存活和資源、dws_daily_job 的執行、order_events 的生產、dwd_order_wide 的新鮮度。規則裡沒有出現任何一個主機名。 事實 · 從資產和各工具配置自動同步 兩套規則 · 手寫,少而穩定 求解 CMDB · 新上架的主機 名稱 gz-db-001 主機組 IDC-GZ 重要度 tier 1 Zabbix · 模板作用域 模板 Linux by Zabbix agent 掛在 IDC-SH Prometheus · 抓取目標 目標 10.2.3.17:9100 目標 10.2.9.40:9100 need · 哪些要覆蓋 host 存活 L3 host ∧ tier=1 資源 L3 table ∧ 一級 新鮮度 L3 一級實體的上游 也算一級 has · 當前覆蓋了什麼 group ∈ 模板作用域 存活 · 資源 L3 ip ∈ 抓取目標 存活 L3 platform = managed 執行 L3 被有監控的作業讀取 生產 L1 clingo gap = need − has gap · 要求的層級 → 達到的層級 gz-db-001 · 存活 3 → 0 gz-db-001 · 資源 3 → 0 dws_daily_job · 執行 3 → 0 order_events · 生產 2 → 1 dwd_order_wide · 新鮮度 3 → 0 規則裡沒有一個主機名: 新機器一同步進來就被判定
圖 5 · 事實自動同步,規則按型別、屬性和關係來寫;新上架的 gz-db-001 沒人登記,求解一次就被認出缺了存活和資源

這類“事實加規則、推出結論”的問題,有一族專門的工具:邏輯程式設計,最常用的是 Datalog。它天然支援遞迴(血緣多少層都行),一定會算完,每個結論都能追溯到是哪幾條事實和規則推出來的。它在工程裡並不陌生:GitHub 的 CodeQL 查詢語言,語義就建立在 Datalog 上22;OPA 的策略語言 Rego 也是受 Datalog 啟發設計的23;Soufflé 則是一個可以直接拿來跑大規模 Datalog 的引擎24。

直接寫求解器的語法,表達力夠了,可讀性卻不好::-、大寫變數、#max,對大多數寫監控規則的人來說是一門新語言。好在求解是引擎的事,寫法可以另選。下面兩小節給出兩種寫法,一種借 Kubernetes 的親和性,一種用 SQL,它們表達的是同一套語義,跑出來的缺口也完全一樣。

規則用什麼寫:親緣性風格的宣告

這樣分層的先例就在 Kubernetes 生態裡。OPA Gatekeeper 把策略拆成兩層:ConstraintTemplate 裡是 Rego 寫的邏輯,Constraint 是一個普通的 YAML 資源,用 match 欄位(kinds、labelSelector、namespaceSelector)指定作用在哪些物件上,用 parameters 傳參25。寫約束的人只碰 YAML,求值交給底下的引擎。

覆蓋規則也可以這麼做。Kubernetes 使用者已經熟悉的幾種選擇語法,正好對得上求解器裡的幾種基本操作:

  • matchLabels / matchExpressions(In、NotIn、Exists、DoesNotExist)是對實體自身屬性的條件26。
  • affinity 加 topologyKey 在 Kubernetes 裡的意思是“存在另一個滿足條件的物件,並且和我在這個 label 上取值相同”21。放到覆蓋率裡,“主機所在的組掛了 Linux 模板”“主機的 IP 出現在抓取目標裡”都是這個形狀,身份解析也就成了一條親和性規則。
  • antiAffinity 是“不存在這樣的物件”,正好用來寫反方向的檢查:抓取目標找不到任何 IP 相同的主機。
  • 再補兩個 Kubernetes 裡沒有、覆蓋率需要的:relatedFrom 沿血緣關係找鄰居,LabelPropagation 把一個 label 沿血緣傳給所有上游。

於是規則寫出來是這樣的(節選,完整的 11 條在下面的摺疊塊裡):

YAML
kind: LabelPropagation
metadata: {name: tier-follows-lineage}
spec:
label: {tier: "1"}
towards: upstream # 沿血缘传给上游,深度不限
---
kind: CoverageRequirement
metadata: {name: critical-host}
spec:
selector: {kind: host, matchLabels: {tier: "1"}}
require: [{capability: resource, level: 3}]
---
kind: CoverageProvider
metadata: {name: zabbix-linux-template}
spec:
selector:
kind: host
affinity: # 存在一个挂了 Linux 模板的主机组,和它的 group 相同
- selector: {kind: hostgroup, matchLabels: {zabbix-template: linux}}
topologyKey: group
provide: [{capability: alive, level: 3}, {capability: resource, level: 3}]
---
kind: CoverageProvider
metadata: {name: source-idle-derived}
spec:
selector:
kind: topic
relatedFrom: # 被一个有“运行”覆盖的作业读着
- type: reads
selector: {kind: flink_job, covered: running}
provide: [{capability: produce, level: 1}]
---
kind: OrphanCheck
metadata: {name: prometheus-orphan}
spec:
selector:
kind: prom_target
antiAffinity: # 找不到任何一台 ip 相同的主机
- selector: {kind: host}
topologyKey: ip

規則分成三種資源:CoverageRequirement 是第一套邏輯(哪些需要覆蓋),CoverageProvider 是第二套(當前覆蓋了什麼),OrphanCheck 是反方向的檢查。每條都只說“選中哪些實體、要求或提供哪項能力到哪一級”,沒有一個主機名。事實也換成同樣的形狀:實體帶 kind、name 和 labels,關係是 from、type、to。

一個八十行左右的編譯器把 YAML 翻譯成 ASP(回答集程式設計,Datalog 的一個擴充套件),再交給求解器 clingo27。比如上面那條 zabbix-linux-template 會變成:

has(E, alive, 3) :- entity(host, E), entity(hostgroup, O0),
label(O0, "zabbix-template", "linux"),
label(E, "group", V1), label(O0, "group", V1).

affinity 變成了對另一個實體 O0 的連線,topologyKey: group 變成了兩邊 group 取同一個值 V1。LabelPropagation 則編譯成一條遞迴規則:

label(U, "tier", "1") :- upstream(U, X), label(X, "tier", "1").

python3 compile.py facts.yaml rules.yaml > coverage.lp && python3 -m clingo coverage.lp 的結果(每個結論一行):

orphan("10.2.9.40:9100")
gap("gz-db-001",alive,3,0)
gap("gz-db-001",resource,3,0)
gap("dws_daily_job",running,3,0)
gap("order_events",produce,2,1)
gap("dwd_order_wide",fresh,3,0)
完整的事實、規則和編譯器

facts.yaml:

YAML
# 事实:由同步程序从 CMDB、各工具配置和血缘生成
entities:
- {kind: host, name: sh-db-017, labels: {group: IDC-SH, tier: "1", ip: 10.2.3.17}}
- {kind: host, name: sh-db-018, labels: {group: IDC-SH, tier: "2"}}
- {kind: host, name: gz-db-001, labels: {group: IDC-GZ, tier: "1"}}
- {kind: hostgroup, name: IDC-SH, labels: {group: IDC-SH, zabbix-template: linux}}
- {kind: hostgroup, name: IDC-GZ, labels: {group: IDC-GZ}}
- {kind: prom_target, name: "10.2.3.17:9100", labels: {ip: 10.2.3.17}}
- {kind: prom_target, name: "10.2.9.40:9100", labels: {ip: 10.2.9.40}}
- {kind: flink_job, name: ods_order_etl, labels: {platform: managed}}
- {kind: flink_job, name: dws_daily_job}
- {kind: topic, name: order_events}
- {kind: table, name: dwd_order_wide, labels: {tier: "1"}}
- {kind: table, name: dws_order_daily, labels: {tier: "1", freshness-sla: 1h}}
relations:
- {from: ods_order_etl, type: reads, to: order_events}
- {from: ods_order_etl, type: writes, to: dwd_order_wide}
- {from: dws_daily_job, type: reads, to: dwd_order_wide}
- {from: dws_daily_job, type: writes, to: dws_order_daily}

rules.yaml:

YAML
# ---- 规则一:哪些需要被覆盖 ----
kind: LabelPropagation
metadata: {name: tier-follows-lineage}
spec:
label: {tier: "1"}
towards: upstream # 沿血缘传给上游,深度不限
---
kind: CoverageRequirement
metadata: {name: host-baseline}
spec:
selector: {kind: host}
require: [{capability: alive, level: 3}]
---
kind: CoverageRequirement
metadata: {name: critical-host}
spec:
selector: {kind: host, matchLabels: {tier: "1"}}
require: [{capability: resource, level: 3}]
---
kind: CoverageRequirement
metadata: {name: critical-job}
spec:
selector: {kind: flink_job, matchLabels: {tier: "1"}}
require: [{capability: running, level: 3}]
---
kind: CoverageRequirement
metadata: {name: critical-topic}
spec:
selector: {kind: topic, matchLabels: {tier: "1"}}
require: [{capability: produce, level: 2}]
---
kind: CoverageRequirement
metadata: {name: critical-table}
spec:
selector: {kind: table, matchLabels: {tier: "1"}}
require: [{capability: fresh, level: 3}]
---
# ---- 规则二:当前覆盖了什么 ----
kind: CoverageProvider
metadata: {name: zabbix-linux-template}
spec:
selector:
kind: host
affinity: # 存在一个挂了 Linux 模板的主机组,和它的 group 相同
- selector: {kind: hostgroup, matchLabels: {zabbix-template: linux}}
topologyKey: group
provide: [{capability: alive, level: 3}, {capability: resource, level: 3}]
---
kind: CoverageProvider
metadata: {name: prometheus-scrape}
spec:
selector:
kind: host
affinity: # 存在一个抓取目标,和它的 ip 相同
- selector: {kind: prom_target}
topologyKey: ip
provide: [{capability: alive, level: 3}]
---
kind: CoverageProvider
metadata: {name: flink-platform}
spec:
selector: {kind: flink_job, matchLabels: {platform: managed}}
provide: [{capability: running, level: 3}]
---
kind: CoverageProvider
metadata: {name: source-idle-derived}
spec:
selector:
kind: topic
relatedFrom: # 被一个有“运行”覆盖的作业读着
- type: reads
selector: {kind: flink_job, covered: running}
provide: [{capability: produce, level: 1}]
---
kind: CoverageProvider
metadata: {name: freshness-sla}
spec:
selector:
kind: table
matchExpressions: [{key: freshness-sla, operator: Exists}]
provide: [{capability: fresh, level: 3}]
---
# ---- 反方向:工具里有、清单里没有 ----
kind: OrphanCheck
metadata: {name: prometheus-orphan}
spec:
selector:
kind: prom_target
antiAffinity: # 找不到任何一台 ip 相同的主机
- selector: {kind: host}
topologyKey: ip

compile.py(需要 pip install clingo pyyaml):

Python
"""把亲缘性风格的 YAML 规则编译成 ASP,交给 clingo 求解。"""
import itertools, sys, yaml
def q(s): return '"%s"' % s
fresh = itertools.count()
def sel(spec, var):
"""把一个 selector 编译成一串 ASP 条件,var 是被选中的实体。"""
body = [f"entity({spec['kind']}, {var})"]
for k, v in spec.get("matchLabels", {}).items():
body.append(f"label({var}, {q(k)}, {q(v)})")
for e in spec.get("matchExpressions", []):
op, k = e["operator"], q(e["key"])
if op == "Exists": body.append(f"label({var}, {k}, _)")
elif op == "DoesNotExist": body.append(f"not label({var}, {k}, _)")
elif op == "In":
v = f"V{next(fresh)}"
body.append(f"label({var}, {k}, {v}), {v} = ({'; '.join(q(x) for x in e['values'])})")
elif op == "NotIn":
body += [f"not label({var}, {k}, {q(x)})" for x in e["values"]]
if "covered" in spec:
body.append(f"has({var}, {spec['covered']}, _)")
for a in spec.get("affinity", []):
o, v = f"O{next(fresh)}", f"V{next(fresh)}"
body += sel(a["selector"], o) + [f"label({var}, {q(a['topologyKey'])}, {v})",
f"label({o}, {q(a['topologyKey'])}, {v})"]
for a in spec.get("antiAffinity", []):
aux = f"aff_{next(fresh)}"
o, v = f"O{next(fresh)}", f"V{next(fresh)}"
aux_body = sel(a["selector"], o) + [f"label(X, {q(a['topologyKey'])}, {v})",
f"label({o}, {q(a['topologyKey'])}, {v})"]
out.append(f"{aux}(X) :- entity(_, X), {', '.join(aux_body)}.")
body.append(f"not {aux}({var})")
for r in spec.get("relatedFrom", []):
o = f"O{next(fresh)}"
body += [f"edge({r['type']}, {o}, {var})"] + sel(r["selector"], o)
return body
out = []
facts = yaml.safe_load(open(sys.argv[1]))
for e in facts["entities"]:
out.append(f"entity({e['kind']}, {q(e['name'])}).")
out += [f"label({q(e['name'])}, {q(k)}, {q(v)})." for k, v in (e.get("labels") or {}).items()]
for r in facts["relations"]:
out.append(f"edge({r['type']}, {q(r['from'])}, {q(r['to'])}).")
out.append('upstream(U, X) :- edge(reads, X, U). upstream(U, X) :- edge(writes, U, X).')
for doc in yaml.safe_load_all(open(sys.argv[2])):
s = doc["spec"]
if doc["kind"] == "LabelPropagation":
(k, v), = s["label"].items()
out.append(f"label(U, {q(k)}, {q(v)}) :- upstream(U, X), label(X, {q(k)}, {q(v)}).")
elif doc["kind"] in ("CoverageRequirement", "CoverageProvider"):
head = "need" if doc["kind"] == "CoverageRequirement" else "has"
body = ", ".join(sel(s["selector"], "E"))
for c in s.get("require", s.get("provide")):
out.append(f"{head}(E, {c['capability']}, {c['level']}) :- {body}. % {doc['metadata']['name']}")
elif doc["kind"] == "OrphanCheck":
out.append(f"orphan(E) :- {', '.join(sel(s['selector'], 'E'))}.")
out += ["has(E, C, 0) :- need(E, C, _).",
"gap(E, C, N, G) :- need(E, C, N), G = #max { L : has(E, C, L) }, G < N.",
"#show gap/4. #show orphan/1."]
print("\n".join(out))

逐條看,每一條都是規則自己推出來的,沒有人登記過:

  • gz-db-001 是新機器,它所在的 IDC-GZ 組沒掛 Linux 模板,IP 也不在抓取目標裡,於是存活和資源都是 0。
  • dws_daily_job 是一個跑在自建叢集上的作業,不歸平臺託管,沒有自動監控。它之所以被要求覆蓋,是因為它寫的 dws_order_daily 是一級表,tier: "1" 順著血緣傳了上來。
  • order_events 只有從 ods_order_etl 派生來的 L1,沒到要求的 L2。
  • dwd_order_wide 是一級表,卻沒有在資料目錄裡登記新鮮度 SLA,也就沒有新鮮度檢查。它的下游 dws_order_daily 登記了 SLA,所以不在缺口裡。
  • 第一行是反方向的檢查:10.2.9.40:9100 在 Prometheus 裡被抓著,清單裡卻沒有這臺機器,多半是下線後沒清理的“殭屍”目標,或者是清單漏登記了。

也可以用 SQL 寫

如果團隊更熟 SQL,同一套邏輯也能直接寫成檢視。Datalog 和 SQL 的距離其實不遠:Datalog 的規則就是帶連線的查詢,遞迴規則對應 SQL 的 WITH RECURSIVE28;Google 的 Logica 就是一門把 Datalog 風格的程式翻譯成 SQL、交給 SQLite、DuckDB、BigQuery 執行的語言29。

事實用普通的表來存:

SQL
-- 事实:由同步程序写入
CREATE TABLE host (name TEXT PRIMARY KEY, grp TEXT, tier INT, ip TEXT);
CREATE TABLE hostgroup (name TEXT PRIMARY KEY, zabbix_template TEXT);
CREATE TABLE prom_target (address TEXT PRIMARY KEY, ip TEXT);
CREATE TABLE flink_job (name TEXT PRIMARY KEY, platform TEXT);
CREATE TABLE topic (name TEXT PRIMARY KEY);
CREATE TABLE tbl (name TEXT PRIMARY KEY, tier INT, freshness_sla TEXT);
CREATE TABLE reads (job TEXT, topic TEXT);
CREATE TABLE writes (job TEXT, tbl TEXT);
INSERT INTO host VALUES ('sh-db-017','IDC-SH',1,'10.2.3.17'),
('sh-db-018','IDC-SH',2,NULL),
('gz-db-001','IDC-GZ',1,NULL);
INSERT INTO hostgroup VALUES ('IDC-SH','linux'), ('IDC-GZ',NULL);
INSERT INTO prom_target VALUES ('10.2.3.17:9100','10.2.3.17'), ('10.2.9.40:9100','10.2.9.40');
INSERT INTO flink_job VALUES ('ods_order_etl','managed'), ('dws_daily_job',NULL);
INSERT INTO topic VALUES ('order_events');
INSERT INTO tbl VALUES ('dwd_order_wide',1,NULL), ('dws_order_daily',1,'1h');
INSERT INTO reads VALUES ('ods_order_etl','order_events'), ('dws_daily_job','dwd_order_wide');
INSERT INTO writes VALUES ('ods_order_etl','dwd_order_wide'), ('dws_daily_job','dws_order_daily');

兩套規則是兩個檢視,一級實體的遞迴傳播是一個遞迴 CTE,求解是一次左連線:

SQL
-- 血缘:谁是谁的上游
CREATE VIEW upstream AS
SELECT job AS child, topic AS parent FROM reads
UNION SELECT tbl, job FROM writes;
-- 一级实体:自己标了 tier 1,或者是某个一级实体的上游(递归,深度不限)
CREATE VIEW critical AS
WITH RECURSIVE c(name) AS (
SELECT name FROM host WHERE tier = 1
UNION SELECT name FROM tbl WHERE tier = 1
UNION SELECT u.parent FROM upstream u JOIN c ON u.child = c.name
) SELECT name FROM c;
-- 规则一:哪些需要被覆盖
CREATE VIEW need AS
SELECT name, 'alive' AS cap, 3 AS lvl FROM host
UNION ALL SELECT name, 'resource', 3 FROM host WHERE name IN (SELECT name FROM critical)
UNION ALL SELECT name, 'running', 3 FROM flink_job WHERE name IN (SELECT name FROM critical)
UNION ALL SELECT name, 'produce', 2 FROM topic WHERE name IN (SELECT name FROM critical)
UNION ALL SELECT name, 'fresh', 3 FROM tbl WHERE name IN (SELECT name FROM critical);
-- 规则二:当前覆盖了什么
CREATE VIEW has AS
SELECT h.name, c.cap, 3 AS lvl -- 主机组挂了 Linux 模板
FROM host h JOIN hostgroup g ON g.name = h.grp AND g.zabbix_template = 'linux'
CROSS JOIN (SELECT 'alive' AS cap UNION ALL SELECT 'resource') c
UNION ALL SELECT h.name, 'alive', 3 -- ip 在抓取目标里
FROM host h JOIN prom_target p ON p.ip = h.ip
UNION ALL SELECT name, 'running', 3 FROM flink_job WHERE platform = 'managed'
UNION ALL SELECT r.topic, 'produce', 1 -- 被平台托管的作业读着
FROM reads r JOIN flink_job j ON j.name = r.job AND j.platform = 'managed'
UNION ALL SELECT name, 'fresh', 3 FROM tbl WHERE freshness_sla IS NOT NULL;
-- 求解:要求的层级减去达到的层级
SELECT n.name, n.cap, n.lvl AS need, COALESCE(MAX(h.lvl), 0) AS got
FROM need n LEFT JOIN has h ON h.name = n.name AND h.cap = n.cap
GROUP BY n.name, n.cap, n.lvl
HAVING got < n.lvl;
-- 反方向:抓取目标里有、清单里没有
SELECT address AS orphan FROM prom_target
WHERE ip NOT IN (SELECT ip FROM host WHERE ip IS NOT NULL);

cat facts.sql coverage.sql | sqlite3 -header -column 的結果和 YAML 那一版逐行一致:

name cap need got
-------------- -------- ---- ---
dwd_order_wide fresh 3 0
dws_daily_job running 3 0
gz-db-001 alive 3 0
gz-db-001 resource 3 0
order_events produce 2 1
orphan
--------------
10.2.9.40:9100

兩種寫法各有長處。親緣性風格的 YAML 每條規則是一個獨立的資源,可以有自己的名字、負責人和評審記錄,適合由各個團隊分別提交;SQL 一眼能看到全部邏輯,能直接用現有的資料庫和 BI 工具出報表,適合資料團隊維護。它們背後的思想是同一個:

同一個操作,在三種寫法裡的樣子
操作親緣性 YAMLSQL求解器(ASP)
按屬性選matchLabels · matchExpressionsWHERElabel(E, k, v)
和另一個物件對上affinity + topologyKeyJOIN … ON共享變數的連線
找不到這樣的物件antiAffinityNOT IN · NOT EXISTSnot …
沿血緣傳遞,深度不限LabelPropagationWITH RECURSIVE遞迴規則
取達到的最高層級編譯器生成MAX · GROUP BY#max
求最少的補法需要擴充套件寫不出來#minimize

前五行三種寫法等價,可以互相翻譯;最後一行是搜尋問題,只有求解器原生支援。

圖 6 · 寫法可以換,語義不變;親緣性和 SQL 管“推導”,“求最少補法”這一步還得交給求解器

求解器還能反過來問:最少補哪幾項

表格最後一行是兩種寫法都替代不了的地方。既然缺口是求出來的,就可以反過來問:從一組候選補法裡,最少挑哪幾項,能讓缺口清零?這是一個搜尋問題,SQL 只能一行行地查,表達不了“在所有組合裡找最好的那個”,求解器卻是專門幹這個的。把候選補法寫成“可選”的事實,加一條“不許有缺口”的約束和一個“補的項越少越好”的目標,接在編譯出來的程式後面:

% 候选补法:求解器从里面挑,要求补完之后没有缺口,且补的项最少
{ fix(link(G)) : entity(hostgroup, G) }.
{ fix(onboard(J)) : entity(flink_job, J) }.
{ fix(idle_alert(J)) : edge(reads, J, _) }.
{ fix(sla(T)) : entity(table, T) }.
label(G, "zabbix-template", "linux") :- fix(link(G)).
label(J, "platform", "managed") :- fix(onboard(J)).
has(T, produce, 3) :- edge(reads, J, T), fix(idle_alert(J)).
label(T, "freshness-sla", "待定") :- fix(sla(T)).
:- gap(_, _, _, _).
#minimize { 1, F : fix(F) }.
#show fix/1.

python3 -m clingo coverage.lp repair.lp 給出的最優解是 4 項:

fix(link("IDC-GZ"))
fix(onboard("dws_daily_job"))
fix(idle_alert("ods_order_etl"))
fix(sla("dwd_order_wide"))

gz-db-001 的兩個缺口被一項“給 IDC-GZ 組掛上模板”同時補上,這也是求解器選它、而不是給這臺機器單獨配監控的原因:掛在組上,這個組以後上架的機器也一併有了。sla("dwd_order_wide") 只說明這張表需要一個新鮮度期望,具體是 1 小時還是 1 天,仍然要由表的負責人來定。候選補法本身也可以照前面的辦法包成一種 YAML 資源(比如 kind: FixCandidate),由編譯器生成這幾行,這裡為了看清楚求解器在做什麼,直接寫了出來。

腐化去了哪裡

規則也會過時,但它腐化的方式和名單不一樣。名單是每來一個實體就要動一次;規則只在“我們的要求變了”或“工具的作用域變了”時才要動,而後者本身也是同步來的事實。剩下的風險集中在屬性上:一臺機器的 tier 標錯了,規則就會給出錯誤的要求。所以屬性要來自它的權威來源,而不是在覆蓋率系統裡另抄一份;規則則應當像程式碼一樣放進倉庫、走評審,改一條規則的影響,重新求解一次就能看到。

六、覆蓋率的呈現

求解出來的是 need 和 has 兩張關係,呈現時把它們展開就行。如果一定要一個數字,可以算“一級實體的必需能力裡,達到 L3 的比例”。但單個百分比會把最重要的資訊平均掉:一千臺測試機的資源監控和一張核心表的新鮮度,在百分比裡權重一樣。更有用的呈現是兩樣東西:

覆蓋矩陣(一級實體)
實體能力層級由誰提供
host:sh-db-017存活L3Prometheus · TargetDown
資源L3Zabbix · High CPU utilization
flink_job:ods_order_etl執行L3Flink 平臺模板
checkpoint
積壓
topic:order_events生產L1派生自作業的 sourceIdleTime,缺閾值
table:dwd_order_wide新鮮度L0沒有

L1 有采集,L2 有告警,L3 告警能路由到負責人。

圖 7 · 按實體和能力展開後,幾個工具各管一部分、哪一格空著,都擺在同一張表裡

矩陣用來看全貌,缺口清單用來分派工作。缺口清單的每一行都天然有負責人(實體的 owner),也天然有“補到哪一級算完”的標準,這比一句“覆蓋率要提升到 95%”好執行得多。

最後還要補一條檢查:規則判定有覆蓋,不等於訊號還活著。 告警規則寫在那裡,指標卻因為 exporter 升級改了名字、label 變了而再也沒有資料,這條規則就永遠不會觸發。所以同步事實時,還應該順便查一下每條告警規則引用的序列最近有沒有資料,把“訊號失效”也作為一條事實交給求解器,命中的降回 L0。這一步把 absent() 用回了它擅長的地方:它不適合找分母之外的東西,但很適合檢查已知的規則有沒有失效。

七、這套方法的邊界

本體層不是免費的。實體型別、能力要求、覆蓋規則都要有人維護,清單和屬性的來源一旦失真,求解的結論跟著失真,求解器只保證推導正確,不保證輸入正確。比較穩妥的起步方式是隻建兩三類最關鍵的實體(比如一級服務和核心表),每套規則先寫最少的幾條,先讓缺口清單跑起來,再逐步擴大。

派生覆蓋是一種推斷,需要謹慎。作業上的 sourceIdleTime 能說明主題“多久沒被讀到新訊息”,但如果作業本身停了,這個訊號也跟著消失,它對主題的覆蓋也就不存在了。所以派生覆蓋應當依賴“提供訊號的實體本身是健康的”這個前提,計算時要一起檢查。

能力要求是一種約定,也就可能被“刷”。給每張表掛一條永遠不會觸發的新鮮度告警(閾值設成 30 天),矩陣會全部變綠,卻沒有任何意義。這類問題本體層管不了,只能靠告警質量本身的度量(觸發次數、誤報率、處理時長)來約束,那是另一個話題。

小結

  1. 三個工具的 100% 是什麼意思? 每個工具的分母都是它自己發現的目標,所以自報覆蓋率只能說明“我看見的我都看著”。不同工具的數字既不能相加,也不能平均,同一臺機器還會被重複計算。
  2. 分母從哪來? 從工具之外的權威清單來:CMDB、Kubernetes API、Flink REST、Kafka 後設資料、資料目錄。覆蓋率是“期望”和“觀測”兩個集合的比對。
  3. 模板不同怎麼比較? 不比模板,比能力。給每類實體定義必需能力(可以借 USE、RED、黃金訊號作預設),把每個工具的規則對映到能力上,並區分有采集、有告警、告警有人接三個層級。
  4. Flink 的自動監控覆蓋了什麼? 覆蓋的是作業這個載體的能力。資料實體的新鮮度、量級需要資料負責人給出期望值;作業上的訊號可以順著讀寫關係給資料實體提供派生覆蓋,但通常只到“有采集”。
  5. 本體層長什麼樣? 實體型別與身份鍵、實體清單、關係、能力要求、監控繫結五樣東西。身份可以借 OpenTelemetry 的實體模型,清單和負責人可以借 Backstage 或 CMDB,關係可以借 Asserts 和 OpenLineage。
  6. 為什麼不能手工登記覆蓋關係? 名單是規則結果的抄本,實體一變就腐化。工具本身就按規則覆蓋,所以改成兩套規則:一套說哪些需要覆蓋,一套說當前覆蓋了什麼,事實自動同步,用 Datalog 或 ASP 一類的求解器求出兩者之差,還能反過來求最少的補法。規則不必直接用求解器的語法寫:可以寫成 Kubernetes 親和性風格的 YAML 再編譯過去,也可以直接寫成 SQL 檢視和遞迴 CTE。
  7. 結果怎麼呈現? 一張“實體 × 能力 × 層級”的矩陣,加一份按重要度排序、每行都有負責人的缺口清單,再配上對每條告警規則的訊號存活檢查。

參考資料

參考資料

  1. Linux by Zabbix agent 模板Zabbixzabbix.com ↗item agent.ping、system.cpu.util、vm.memory.util;trigger Linux: Zabbix agent is not available、Linux: High CPU utilizationhttps://www.zabbix.com/integrations/linux ↩ ↩2

  2. Absent alerting for jobsRobust Perceptionrobustperception.io ↗用 absent(up{job="..."}) 在整個 job 消失時告警https://www.robustperception.io/absent-alerting-for-jobs/ ↩

  3. Detect hosts that vanished from service discoveryOneUptime · 2026-07-31oneuptime.com ↗從服務發現裡消失的目標,舊的 up 序列被標記為 stale,absent() 抓不到,只能和獨立維護的期望列表比對https://oneuptime.com/blog/post/2026-07-31-detect-hosts-vanished-service-discovery/view ↩

  4. TargetDown runbookkube-prometheus runbooksrunbooks.prometheus-operator.dev ↗https://runbooks.prometheus-operator.dev/runbooks/general/targetdown/ ↩

  5. NodeFilesystemAlmostOutOfSpace runbookkube-prometheus runbooksrunbooks.prometheus-operator.dev ↗https://runbooks.prometheus-operator.dev/runbooks/node/nodefilesystemalmostoutofspace/ ↩

  6. The USE MethodBrendan Gregg · brendangregg.combrendangregg.com ↗https://www.brendangregg.com/usemethod.html ↩

  7. The RED Method: How to instrument your servicesTom Wilkie · Grafana Labs · 2018-08-02grafana.com ↗https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/ ↩

  8. Monitoring Distributed SystemsGoogle SRE Booksre.google ↗四個黃金訊號:延遲、流量、錯誤、飽和度https://sre.google/sre-book/monitoring-distributed-systems/ ↩

  9. Ontology core conceptsPalantir Foundry 文件palantir.com ↗object type、property、link type、action typehttps://www.palantir.com/docs/foundry/ontology/core-concepts ↩

  10. Entity Data ModelOpenTelemetry 規範opentelemetry.io ↗實體 = Type + Id(身份屬性,生命週期內不變)+ Description(可變屬性);目前是 Development 狀態https://opentelemetry.io/docs/specs/otel/entities/data-model/ ↩

  11. Descriptor Format of Catalog EntitiesBackstagebackstage.io ↗kind 有 Component、API、Resource、System、Domain、Group、User;spec.owner 指向負責團隊https://backstage.io/docs/features/software-catalog/descriptor-format ↩

  12. Application servicesServiceNow 文件docs.servicenow.com ↗CSDM 裡業務服務、應用服務、技術服務的分層https://docs.servicenow.com/bundle/washingtondc-servicenow-platform/page/product/configuration-management/concept/application-services.html ↩

  13. Map metrics to the entity graphGrafana Cloud 文件grafana.com ↗從 Prometheus 指標的 label 抽出實體和關係(HOSTS、CALLS 等),可用 YAML 自定義對映https://grafana.com/docs/grafana-cloud/monitor-applications/asserts/introduction/map-metrics-to-entity-graph/ ↩

  14. Active agent autoregistrationZabbix 文件zabbix.com ↗自動註冊動作可按主機後設資料加入主機組、掛模板https://www.zabbix.com/documentation/current/en/manual/discovery/auto_registration ↩

  15. Prometheus Operator · Getting startedPrometheus Operatorprometheus-operator.dev ↗ServiceMonitor 用 spec.selector.matchLabels 選 Service,Prometheus 用 spec.serviceMonitorSelector 選 ServiceMonitorhttps://prometheus-operator.dev/docs/developer/getting-started/ ↩

  16. Assigning Pods to NodesKubernetes 文件kubernetes.io ↗nodeSelector 與 affinity:按節點 label 而不是節點名來約束排程https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ ↩ ↩2

  17. About the QL languageCodeQL 文件codeql.github.com ↗QL 的語義基於 Dataloghttps://codeql.github.com/docs/ql-language-reference/about-the-ql-language/ ↩

  18. Policy LanguageOpen Policy Agent 文件openpolicyagent.org ↗Rego was inspired by Dataloghttps://www.openpolicyagent.org/docs/latest/policy-language/ ↩

  19. Soufflésouffle-lang.github.iosouffle-lang.github.io ↗面向大規模靜態分析的 Datalog 引擎https://souffle-lang.github.io/ ↩

  20. Gatekeeper · How to use GatekeeperOPA Gatekeeper 文件open-policy-agent.github.io ↗ConstraintTemplate 裡是 Rego 邏輯,Constraint 是 YAML,用 match(kinds、labelSelector 等)和 parameters 指定作用範圍和引數https://open-policy-agent.github.io/gatekeeper/website/docs/howto/ ↩

  21. Labels and SelectorsKubernetes 文件kubernetes.io ↗matchLabels 與 matchExpressions(In、NotIn、Exists、DoesNotExist)https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ ↩

  22. clingoPotasscopotassco.org ↗回答集程式設計(ASP)求解器;文中示例用 Python 包 clingo 5.8.2 驗證https://potassco.org/clingo/ ↩

  23. The WITH ClauseSQLite 文件sqlite.org ↗遞迴公用表表示式 WITH RECURSIVEhttps://www.sqlite.org/lang_with.html ↩

  24. Logicalogica.devlogica.dev ↗Datalog 一族的邏輯程式語言,程式翻譯成 SQL 在 DuckDB、SQLite、BigQuery、PostgreSQL 上執行https://logica.dev/ ↩