先看三行數字。它們來自三個監控工具各自的概覽頁(這是拼出來的一個典型場景,數字是示意):
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 自己的歷史,沒有跳出它的分母。
圖 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 或規則,都對映到“哪類實體的哪項能力”。比較兩個工具,比的是它們各自覆蓋了哪些能力,而不是它們有多少條規則。
| 實體 · 能力 | Prometheus | Zabbix | Flink 平臺 |
|---|---|---|---|
| 主機 | |||
| 存活 | up · TargetDown | agent.ping · Zabbix agent is not available | 不涉及 |
| 資源(磁碟) | node_filesystem_avail_bytes · NodeFilesystemAlmostOutOfSpace | vfs.fs.dependent.size[…,pused] · Space is critically low | 不涉及 |
| Flink 作業 | |||
| 執行狀態 | flink_jobmanager_job_numRestarts | 不涉及 | numRestarts |
| checkpoint | flink_jobmanager_job_lastCheckpointDuration | 不涉及 | lastCheckpointDuration |
| 消費積壓 | …_KafkaSourceReader_… | 不涉及 | pendingRecords |
| 資料表 | |||
| 新鮮度 | 沒有 | 沒有 | 沒有 |
Prometheus 一列的 Flink 指標名隨版本和 reporter 配置會有出入。
表裡 Prometheus 一列的 Flink 指標,是 Flink 的 Prometheus reporter 把指標作用域拼成名字、把作業名等變數轉成 label 之後的樣子10,所以同一個 numRestarts 會在兩個工具裡各出現一次,名字不同,能力相同。
能力表還順帶讓“覆蓋”有了層級。同一項能力,可以只是有采集(指標在庫裡,出事後能查),也可以是有告警(有一條規則會觸發),還可以是告警有人接(規則路由到了這個實體的負責團隊)。三者對“出事時有沒有人知道”的意義完全不同,統計時應該分開。
圖 2 最後一行三個工具都是空的,那正是開頭那張表。可 Flink 平臺明明說那個作業“已自動接入監控”。作業被監控了,為什麼它寫的表沒有?
三、Flink 自動監控覆蓋了什麼
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 來說,這意味著什麼?
作業沒有重啟,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。
業界已有的幾塊拼圖
這五樣東西,業界其實各有現成的做法,只是很少有人把它們拼成“覆蓋率”這一個用途。
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。
求解器拿事實和兩套規則,算出“要求的”和“做到的”,兩者之差就是缺口。規則裡不出現任何一個主機名、作業名,新東西一進事實,就自動落進兩套規則裡被判定一次。
這類“事實加規則、推出結論”的問題,有一族專門的工具:邏輯程式設計,最常用的是 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 條在下面的摺疊塊裡):
kind: LabelPropagationmetadata: {name: tier-follows-lineage}spec: label: {tier: "1"} towards: upstream # 沿血缘传给上游,深度不限---kind: CoverageRequirementmetadata: {name: critical-host}spec: selector: {kind: host, matchLabels: {tier: "1"}} require: [{capability: resource, level: 3}]---kind: CoverageProvidermetadata: {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: CoverageProvidermetadata: {name: source-idle-derived}spec: selector: kind: topic relatedFrom: # 被一个有“运行”覆盖的作业读着 - type: reads selector: {kind: flink_job, covered: running} provide: [{capability: produce, level: 1}]---kind: OrphanCheckmetadata: {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:
# 事实:由同步程序从 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:
# ---- 规则一:哪些需要被覆盖 ----kind: LabelPropagationmetadata: {name: tier-follows-lineage}spec: label: {tier: "1"} towards: upstream # 沿血缘传给上游,深度不限---kind: CoverageRequirementmetadata: {name: host-baseline}spec: selector: {kind: host} require: [{capability: alive, level: 3}]---kind: CoverageRequirementmetadata: {name: critical-host}spec: selector: {kind: host, matchLabels: {tier: "1"}} require: [{capability: resource, level: 3}]---kind: CoverageRequirementmetadata: {name: critical-job}spec: selector: {kind: flink_job, matchLabels: {tier: "1"}} require: [{capability: running, level: 3}]---kind: CoverageRequirementmetadata: {name: critical-topic}spec: selector: {kind: topic, matchLabels: {tier: "1"}} require: [{capability: produce, level: 2}]---kind: CoverageRequirementmetadata: {name: critical-table}spec: selector: {kind: table, matchLabels: {tier: "1"}} require: [{capability: fresh, level: 3}]---# ---- 规则二:当前覆盖了什么 ----kind: CoverageProvidermetadata: {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: CoverageProvidermetadata: {name: prometheus-scrape}spec: selector: kind: host affinity: # 存在一个抓取目标,和它的 ip 相同 - selector: {kind: prom_target} topologyKey: ip provide: [{capability: alive, level: 3}]---kind: CoverageProvidermetadata: {name: flink-platform}spec: selector: {kind: flink_job, matchLabels: {platform: managed}} provide: [{capability: running, level: 3}]---kind: CoverageProvidermetadata: {name: source-idle-derived}spec: selector: kind: topic relatedFrom: # 被一个有“运行”覆盖的作业读着 - type: reads selector: {kind: flink_job, covered: running} provide: [{capability: produce, level: 1}]---kind: CoverageProvidermetadata: {name: freshness-sla}spec: selector: kind: table matchExpressions: [{key: freshness-sla, operator: Exists}] provide: [{capability: fresh, level: 3}]---# ---- 反方向:工具里有、清单里没有 ----kind: OrphanCheckmetadata: {name: prometheus-orphan}spec: selector: kind: prom_target antiAffinity: # 找不到任何一台 ip 相同的主机 - selector: {kind: host} topologyKey: ipcompile.py(需要 pip install clingo pyyaml):
"""把亲缘性风格的 YAML 规则编译成 ASP,交给 clingo 求解。"""import itertools, sys, yaml
def q(s): return '"%s"' % sfresh = 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。
事實用普通的表來存:
-- 事实:由同步程序写入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,求解是一次左連線:
-- 血缘:谁是谁的上游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.lvlHAVING 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 0dws_daily_job running 3 0gz-db-001 alive 3 0gz-db-001 resource 3 0order_events produce 2 1orphan--------------10.2.9.40:9100兩種寫法各有長處。親緣性風格的 YAML 每條規則是一個獨立的資源,可以有自己的名字、負責人和評審記錄,適合由各個團隊分別提交;SQL 一眼能看到全部邏輯,能直接用現有的資料庫和 BI 工具出報表,適合資料團隊維護。它們背後的思想是同一個:
| 操作 | 親緣性 YAML | SQL | 求解器(ASP) |
|---|---|---|---|
| 按屬性選 | matchLabels · matchExpressions | WHERE | label(E, k, v) |
| 和另一個物件對上 | affinity + topologyKey | JOIN … ON | 共享變數的連線 |
| 找不到這樣的物件 | antiAffinity | NOT IN · NOT EXISTS | not … |
| 沿血緣傳遞,深度不限 | LabelPropagation | WITH RECURSIVE | 遞迴規則 |
| 取達到的最高層級 | 編譯器生成 | MAX · GROUP BY | #max |
| 求最少的補法 | 需要擴充套件 | 寫不出來 | #minimize |
前五行三種寫法等價,可以互相翻譯;最後一行是搜尋問題,只有求解器原生支援。
求解器還能反過來問:最少補哪幾項
表格最後一行是兩種寫法都替代不了的地方。既然缺口是求出來的,就可以反過來問:從一組候選補法裡,最少挑哪幾項,能讓缺口清零?這是一個搜尋問題,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 | 存活 | L3 | Prometheus · TargetDown |
| 資源 | L3 | Zabbix · High CPU utilization | |
| flink_job:ods_order_etl | 執行 | L3 | Flink 平臺模板 |
| checkpoint | |||
| 積壓 | |||
| topic:order_events | 生產 | L1 | 派生自作業的 sourceIdleTime,缺閾值 |
| table:dwd_order_wide | 新鮮度 | L0 | 沒有 |
L1 有采集,L2 有告警,L3 告警能路由到負責人。
矩陣用來看全貌,缺口清單用來分派工作。缺口清單的每一行都天然有負責人(實體的 owner),也天然有“補到哪一級算完”的標準,這比一句“覆蓋率要提升到 95%”好執行得多。
最後還要補一條檢查:規則判定有覆蓋,不等於訊號還活著。 告警規則寫在那裡,指標卻因為 exporter 升級改了名字、label 變了而再也沒有資料,這條規則就永遠不會觸發。所以同步事實時,還應該順便查一下每條告警規則引用的序列最近有沒有資料,把“訊號失效”也作為一條事實交給求解器,命中的降回 L0。這一步把 absent() 用回了它擅長的地方:它不適合找分母之外的東西,但很適合檢查已知的規則有沒有失效。
七、這套方法的邊界
本體層不是免費的。實體型別、能力要求、覆蓋規則都要有人維護,清單和屬性的來源一旦失真,求解的結論跟著失真,求解器只保證推導正確,不保證輸入正確。比較穩妥的起步方式是隻建兩三類最關鍵的實體(比如一級服務和核心表),每套規則先寫最少的幾條,先讓缺口清單跑起來,再逐步擴大。
派生覆蓋是一種推斷,需要謹慎。作業上的 sourceIdleTime 能說明主題“多久沒被讀到新訊息”,但如果作業本身停了,這個訊號也跟著消失,它對主題的覆蓋也就不存在了。所以派生覆蓋應當依賴“提供訊號的實體本身是健康的”這個前提,計算時要一起檢查。
能力要求是一種約定,也就可能被“刷”。給每張表掛一條永遠不會觸發的新鮮度告警(閾值設成 30 天),矩陣會全部變綠,卻沒有任何意義。這類問題本體層管不了,只能靠告警質量本身的度量(觸發次數、誤報率、處理時長)來約束,那是另一個話題。
小結
- 三個工具的 100% 是什麼意思? 每個工具的分母都是它自己發現的目標,所以自報覆蓋率只能說明“我看見的我都看著”。不同工具的數字既不能相加,也不能平均,同一臺機器還會被重複計算。
- 分母從哪來? 從工具之外的權威清單來:CMDB、Kubernetes API、Flink REST、Kafka 後設資料、資料目錄。覆蓋率是“期望”和“觀測”兩個集合的比對。
- 模板不同怎麼比較? 不比模板,比能力。給每類實體定義必需能力(可以借 USE、RED、黃金訊號作預設),把每個工具的規則對映到能力上,並區分有采集、有告警、告警有人接三個層級。
- Flink 的自動監控覆蓋了什麼? 覆蓋的是作業這個載體的能力。資料實體的新鮮度、量級需要資料負責人給出期望值;作業上的訊號可以順著讀寫關係給資料實體提供派生覆蓋,但通常只到“有采集”。
- 本體層長什麼樣? 實體型別與身份鍵、實體清單、關係、能力要求、監控繫結五樣東西。身份可以借 OpenTelemetry 的實體模型,清單和負責人可以借 Backstage 或 CMDB,關係可以借 Asserts 和 OpenLineage。
- 為什麼不能手工登記覆蓋關係? 名單是規則結果的抄本,實體一變就腐化。工具本身就按規則覆蓋,所以改成兩套規則:一套說哪些需要覆蓋,一套說當前覆蓋了什麼,事實自動同步,用 Datalog 或 ASP 一類的求解器求出兩者之差,還能反過來求最少的補法。規則不必直接用求解器的語法寫:可以寫成 Kubernetes 親和性風格的 YAML 再編譯過去,也可以直接寫成 SQL 檢視和遞迴 CTE。
- 結果怎麼呈現? 一張“實體 × 能力 × 層級”的矩陣,加一份按重要度排序、每行都有負責人的缺口清單,再配上對每條告警規則的訊號存活檢查。
參考資料
參考資料
-
Linux by Zabbix agent 模板zabbix.com ↗item
agent.ping、system.cpu.util、vm.memory.util;triggerLinux: Zabbix agent is not available、Linux: High CPU utilizationhttps://www.zabbix.com/integrations/linux ↩ ↩2 -
Absent alerting for jobsrobustperception.io ↗用
absent(up{job="..."})在整個 job 消失時告警https://www.robustperception.io/absent-alerting-for-jobs/ ↩ -
Detect hosts that vanished from service discoveryoneuptime.com ↗從服務發現裡消失的目標,舊的
up序列被標記為 stale,absent()抓不到,只能和獨立維護的期望列表比對https://oneuptime.com/blog/post/2026-07-31-detect-hosts-vanished-service-discovery/view ↩ -
Flink REST APInightlies.apache.org ↗
GET /jobs/overview列出叢集上的作業https://nightlies.apache.org/flink/flink-docs-release-1.20/docs/ops/rest_api/ ↩ -
TargetDown runbookrunbooks.prometheus-operator.dev ↗https://runbooks.prometheus-operator.dev/runbooks/general/targetdown/ ↩
-
NodeFilesystemAlmostOutOfSpace runbookrunbooks.prometheus-operator.dev ↗https://runbooks.prometheus-operator.dev/runbooks/node/nodefilesystemalmostoutofspace/ ↩
-
The USE Methodbrendangregg.com ↗https://www.brendangregg.com/usemethod.html ↩
-
The RED Method: How to instrument your servicesgrafana.com ↗https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/ ↩
-
Monitoring Distributed Systemssre.google ↗四個黃金訊號:延遲、流量、錯誤、飽和度https://sre.google/sre-book/monitoring-distributed-systems/ ↩
-
Metric Reporters · Prometheusnightlies.apache.org ↗所有 scope 變數作為 Prometheus label 匯出,預設埠 9249https://nightlies.apache.org/flink/flink-docs-release-1.20/docs/deployment/metric_reporters/ ↩
-
Flink Metricsnightlies.apache.org ↗預設 operator scope
<host>.taskmanager.<tm_id>.<job_name>.<operator_name>.<subtask_index>;numRestarts、lastCheckpointDuration在 Job 級,backPressuredTimeMsPerSecond在 Task 級https://nightlies.apache.org/flink/flink-docs-release-1.20/docs/ops/metrics/ ↩ -
Apache Kafka Connector · Monitoringnightlies.apache.org ↗KafkaSource 的
pendingRecords、sourceIdleTime、currentEmitEventTimeLag、watermarkLaghttps://nightlies.apache.org/flink/flink-docs-release-1.20/docs/connectors/datastream/kafka/ ↩ -
Ontology core conceptspalantir.com ↗object type、property、link type、action typehttps://www.palantir.com/docs/foundry/ontology/core-concepts ↩
-
Entity Data Modelopentelemetry.io ↗實體 = Type + Id(身份屬性,生命週期內不變)+ Description(可變屬性);目前是 Development 狀態https://opentelemetry.io/docs/specs/otel/entities/data-model/ ↩
-
Descriptor Format of Catalog Entitiesbackstage.io ↗kind 有 Component、API、Resource、System、Domain、Group、User;
spec.owner指向負責團隊https://backstage.io/docs/features/software-catalog/descriptor-format ↩ -
Application servicesdocs.servicenow.com ↗CSDM 裡業務服務、應用服務、技術服務的分層https://docs.servicenow.com/bundle/washingtondc-servicenow-platform/page/product/configuration-management/concept/application-services.html ↩
-
Map metrics to the entity graphgrafana.com ↗從 Prometheus 指標的 label 抽出實體和關係(HOSTS、CALLS 等),可用 YAML 自定義對映https://grafana.com/docs/grafana-cloud/monitor-applications/asserts/introduction/map-metrics-to-entity-graph/ ↩
-
OpenLineage · Apache Flink integrationopenlineage.io ↗在作業提交時透過
JobListener讀取 Source / Sink,產生 reads / writes 血緣;文件寫明僅支援 Application Modehttps://openlineage.io/docs/integrations/flink ↩ -
Active agent autoregistrationzabbix.com ↗自動註冊動作可按主機後設資料加入主機組、掛模板https://www.zabbix.com/documentation/current/en/manual/discovery/auto_registration ↩
-
Prometheus Operator · Getting startedprometheus-operator.dev ↗ServiceMonitor 用
spec.selector.matchLabels選 Service,Prometheus 用spec.serviceMonitorSelector選 ServiceMonitorhttps://prometheus-operator.dev/docs/developer/getting-started/ ↩ -
Assigning Pods to Nodeskubernetes.io ↗nodeSelector 與 affinity:按節點 label 而不是節點名來約束排程https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ ↩ ↩2
-
About the QL languagecodeql.github.com ↗QL 的語義基於 Dataloghttps://codeql.github.com/docs/ql-language-reference/about-the-ql-language/ ↩
-
Policy Languageopenpolicyagent.org ↗Rego was inspired by Dataloghttps://www.openpolicyagent.org/docs/latest/policy-language/ ↩
-
Soufflésouffle-lang.github.io ↗面向大規模靜態分析的 Datalog 引擎https://souffle-lang.github.io/ ↩
-
Gatekeeper · How to use Gatekeeperopen-policy-agent.github.io ↗ConstraintTemplate 裡是 Rego 邏輯,Constraint 是 YAML,用
match(kinds、labelSelector 等)和parameters指定作用範圍和引數https://open-policy-agent.github.io/gatekeeper/website/docs/howto/ ↩ -
Labels and Selectorskubernetes.io ↗
matchLabels與matchExpressions(In、NotIn、Exists、DoesNotExist)https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ ↩ -
clingopotassco.org ↗回答集程式設計(ASP)求解器;文中示例用 Python 包 clingo 5.8.2 驗證https://potassco.org/clingo/ ↩
-
The WITH Clausesqlite.org ↗遞迴公用表表示式
WITH RECURSIVEhttps://www.sqlite.org/lang_with.html ↩ -
Logicalogica.dev ↗Datalog 一族的邏輯程式語言,程式翻譯成 SQL 在 DuckDB、SQLite、BigQuery、PostgreSQL 上執行https://logica.dev/ ↩