Kubernetes 的 CRD 裡經常能看到這樣一段:
x-kubernetes-validations: - rule: "self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas" message: "replicas 必须在 minReplicas 和 maxReplicas 之间"rule 後面那行字串不是 Go,也不是 JavaScript,apiserver 卻能在每次建立、更新物件時把它算出來。同樣長相的表示式還出現在 Google Cloud 的 IAM 條件(request.time < timestamp("2026-12-31T00:00:00Z"))、Envoy 的 RBAC 過濾器、buf 的 protovalidate 裡1。它叫 CEL,Common Expression Language。
第一次見到它,自然會有幾個疑問:為了一行條件判斷,專門造一門語言值得嗎?直接嵌一個 Lua 或 JavaScript 不行嗎?Go 裡已經有 expr、govaluate 這些表示式庫,策略領域還有 OPA 的 Rego,CEL 和它們比到底差在哪、強在哪?真要在自己的服務裡用,又該怎麼用?
我們從一個很普通的需求出發,看看一路會碰到什麼。
一、把規則交給使用者寫
現象:規則寫死在程式碼裡
假設我們在做一個運維平臺,釋出前要做一次放行判斷。最早的版本直接寫在 Go 裡:
func allow(u User, r Resource, hour int) bool { return u.Role == "admin" || (r.Owner == u.ID && hour >= 9 && hour < 18) || slices.Contains(u.Groups, r.Team)}管理員直接放行;服務負責人只能在工作時間釋出;服務所屬團隊的成員也可以釋出。很快各個業務線都來提要求:有的要加“週五下午不釋出”,有的要“灰度環境不限時間”。每改一次都要發一次平臺,規則和程式碼綁在一起,顯然走不遠。
第一次嘗試:把規則存成配置
最直接的想法是把規則變成資料。如果用 JSON 描述,社群裡有現成的格式 JsonLogic2:
{"or": [ {"==": [{"var": "user.role"}, "admin"]}, {"and": [{"==": [{"var": "resource.owner"}, {"var": "user.id"}]}, {">=": [{"var": "hour"}, 9]}, {"<": [{"var": "hour"}, 18]}]}, {"in": [{"var": "resource.team"}, {"var": "user.groups"}]}]}它能工作,前端也好做成拖拽編輯器,但人很難直接讀寫。規則一複雜,大家還是希望寫成一行程式碼那樣的東西。
第二次嘗試:嵌入一門指令碼語言
於是很多平臺走向了下一步:嵌一門現成的指令碼語言。Java 世界裡最順手的是 Spring 自帶的 SpEL,Go 世界裡有 gopher-lua、goja(JavaScript)、Starlark。規則立刻好寫了,但新的問題接著出現。我們在同一臺機器上試了三條“使用者寫的規則”:
第一條最危險。規則本來只該看到 user、resource、hour 三個變數,SpEL 卻允許用 T(...) 引用任意 Java 類,讀系統屬性只是演示,換成 Runtime.exec 就是遠端執行命令。這不是假想:2017 年 Equifax 的資料洩露,入口是 Struts 把 HTTP 頭裡的一段 OGNL 表示式拿去求值3;2022 年 Spring Cloud Function 的 CVE-2022-22963,是把請求頭裡的 SpEL 拿去求值4。表示式語言能力太強,本身就是攻擊面。
第二條說明,只要語言裡有迴圈,使用者就可能寫出停不下來的程式。第三條說明,指令碼語言通常到執行時才去找欄位,找不到就給一個 nil,nil == "admin" 是 false,規則安靜地給出了錯誤答案。
還有一個問題圖裡沒畫:放行判斷在釋出路徑上,閘道器、准入控制這類場景更是每個請求都要跑,求值本身必須足夠便宜。
問題歸納
把這幾條放在一起,我們需要的其實是這樣一種東西:
- 只能讀宿主給它的資料、呼叫宿主給它的函式,別的什麼都碰不到;
- 一定會停,而且最好能事先知道最壞要算多久;
- 寫錯欄位、型別對不上,在儲存規則時就報錯;
- 每次求值在微秒以內。
這幾條單獨看都不難,難的是同時做到。CEL 就是為這個組合設計的。
二、CEL 的出現
來歷
CEL 的規範倉庫 cel-spec 第一個提交是 2017 年 8 月 14 日,提交說明是 “Initial import from go/api-expr”,也就是從 Google 內部的一個表示式專案匯出來的5。README 裡寫得很直白:最初的規範基於開發 Firebase Rules 的經驗,又做了很多輪可用性測試。Firebase 的安全規則正是“讓使用者寫規則、由服務端在每個請求上求值”的典型場景,上面那幾個問題 Google 早就踩過。
此後 CEL 成了 Google 內外很多系統共用的條件語言。2024 年 6 月,Google 在開源部落格上專門介紹了它,概括為快、可移植、可擴充套件、安全四點6;官方站點 cel.dev 的說法是“非圖靈完備,只能訪問宿主程式提供的資料”7。
Kubernetes 是最典型的例子。CRD 校驗規則(KEP-2876)在 1.23 進入 alpha,1.29 GA8;用 CEL 寫准入策略的 ValidatingAdmissionPolicy 在 1.30 GA9,能改物件的 MutatingAdmissionPolicy 在 1.36 GA10。在這之前,想在准入階段做點校驗,就得自己寫一個 webhook 服務、部署、配證書,再讓 apiserver 每個請求都遠端呼叫它。CEL 讓這類規則變成 API 物件上的一個欄位,由 apiserver 在程序內直接求值11。
還有兩個跟 Go 使用者直接相關的變化:2026 年 6 月倉庫從 google 遷到了 cel-expr 組織;Go 模組路徑改成了 cel.dev/cel-go,v0.32.0 起再寫 go get github.com/google/cel-go@latest 會報模組路徑不匹配12。
設計上舍棄了什麼
CEL 的規範在開頭就給自己下了定義:記憶體安全、無副作用、一定會終止、強型別,型別檢查可選但推薦13。落實到語法上,就是一系列“沒有”:
- 沒有迴圈,沒有賦值,沒有函式定義。 一個 CEL 程式只是一個表示式。想遍歷列表,只能用幾個內建宏:
all、exists、exists_one、map、filter,它們遍歷的物件一定是已經存在的、有限的列表或 map。 - 沒有 I/O,也沒有“引用任意型別”的語法。 表示式能用的變數和函式,全部由宿主程式在環境(
Env)裡宣告。 - 沒有異常處理。 執行時錯誤是一個值,只能被邏輯運算子吸收,不能被捕獲(這一點後面會專門講)。
保留下來的,是一套和 C、Go、Java、JavaScript 幾乎一樣的語法,以及對 protobuf 和 JSON 型別的一等支援。所以我們開頭那條規則,用 CEL 寫出來就是:
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups捨棄了這些,換來了什麼?下面兩節分別看“寫錯了何時知道”和“會不會停”。
三、解析、檢查、求值
三個階段
在 Go 裡用 CEL,固定是三步:宣告環境、編譯(解析加型別檢查)、生成 Program 後求值。
env, _ := cel.NewEnv( ext.NativeTypes(reflect.TypeOf(User{}), reflect.TypeOf(Resource{}), ext.ParseStructTags(true)), cel.Variable("user", cel.ObjectType("main.User")), cel.Variable("resource", cel.ObjectType("main.Resource")), cel.Variable("hour", cel.IntType),)ast, iss := env.Compile(src) // 解析 + 类型检查prg, _ := env.Program(ast) // 生成可复用、并发安全的 Programout, _, _ := prg.Eval(map[string]any{"user": u, "resource": r, "hour": 23})這個拆分有兩層意思。第一層是效能:我們測得每次都重新編譯約 100 µs,快取 Program 後只求值約 0.47 µs,差了 200 多倍。第二層是檢查:因為環境裡宣告瞭 user 是 main.User,檢查器知道它有哪些欄位,user.rol 在編譯時就報錯,hour >= "9" 也會報 found no matching overload for '_>=_' applied to '(int, string)'。
第一次嘗試:變數宣告成 map
很多人第一次用 CEL,會把輸入宣告成 map(string, dyn),省得定義型別。這樣做也能跑,但檢查器就不知道 map 裡有哪些鍵了。user.rol 能透過編譯,到執行時才報 no such key: rol。
而且這個錯誤不一定能看到。我們給一個 9 點到 18 點之間、自己名下的資源發請求:
user.rol == "admin" || hour < 18 => truehour < 18 || user.rol == "admin" => trueuser.rol == "admin" || hour > 18 => error: no such key: roluser.rol == "admin" ? true : hour < 18 => error: no such key: rolhas(user.rol) && user.rol == "admin" || hour < 18 => true原因:邏輯運算子會吸收錯誤
這是 CEL 和大多數語言不一樣的地方。在 C、Go 裡,a || b 從左往右求值,a 出錯就整個出錯。CEL 規範規定 && 和 || 是可交換的:只要有一邊已經能決定結果(|| 的一邊是 true,&& 的一邊是 false),另一邊的錯誤就被忽略,跟左右順序無關13。規範給的理由是,這樣邏輯表示式可以對映到索引查詢,和 SQL 的語義一致。想要傳統的短路,就改寫成三元表示式 a ? true : b。
所以 map 宣告加上錯誤吸收,欄位寫錯的規則可能在大部分請求上“看起來沒問題”,只在某些分支上報錯。解決辦法是把變數宣告成具體型別:用 ext.NativeTypes 註冊 Go 結構體,或者直接用 protobuf 訊息型別。確實需要處理“可能不存在的欄位”時,用 has(user.nickname) 或可選值語法 user.?nickname.orValue("") 明確寫出來,不要指望錯誤吸收替你兜底。
型別檢查解決了“寫錯了何時知道”。那“會不會停”呢?沒有迴圈,是不是就萬事大吉?
四、會停,但不一定停得快
現象:一條“合法”的規則算了 12 秒
CEL 沒有迴圈,但宏可以巢狀。一個 10 個元素的列表,巢狀 8 層 all:
[0,1,2,3,4,5,6,7,8,9].all(a, [0,1,2,3,4,5,6,7,8,9].all(b, … .all(h, true)))這條表示式只有兩百多個字元,卻要迭代 次。我們不加任何限制直接跑,cel-go 用了 12.6 秒才返回 true。它確實會停,規範沒有騙人,但對一個每個請求都要算的規則來說,12 秒和不停沒有區別。規範自己也承認:除了 has() 以外的宏,是 CEL 裡唯一可能出現指數代價的地方13。
原因與解決:把“多久”變成一個數
非圖靈完備的真正價值,不是“一定會停”,而是代價可以事先算出來。因為沒有迴圈和遞迴,每個宏的迭代次數都受輸入列表大小約束,檢查器可以沿著語法樹估算出最壞的代價。CEL 用一個抽象的“成本單位”來計數,比較運算算 1,列表字面量算 40,正則匹配按正則長度乘輸入長度估算11。
於是有了兩道閘門:
執行時計數很直觀:求值器每執行一步就累加成本,超過 cel.CostLimit 就中止。上面那條 的表示式,設了 100 萬的上限後 97 毫秒就停了,錯誤是 operation cancelled: actual cost limit exceeded。再配合 cel.InterruptCheckFrequency 和 ContextEval,宏每迭代一定次數就檢查一次 context,超時也能及時退出。
靜態估算更有意思。env.EstimateCost 不需要任何輸入資料,只看語法樹和型別宣告。問題在於,對一個長度未知的列表,最壞情況就是無窮大:同一條檢查映象地址的規則,不告訴估算器列表多長、字串多長,估出來是 ;宣告最多 20 個容器、映象名最長 256 個字元,估算立刻降到 4783。
Kubernetes 把這兩道閘門都用上了。apiserver 的常量裡寫著:單條表示式 PerCallLimit = 1000000,註釋說大約 0.1 秒;每個自定義資源或每個 ValidatingAdmissionPolicy binding 的總預算 RuntimeCELCostBudget = 10000000,大約 1 秒14。寫 CRD 時,如果某條規則的估算成本超過上限,apiserver 直接拒絕這個 CRD,報錯會提示你給相關欄位加上 maxItems、maxLength。這就是為什麼寫 CRD 校驗規則時,schema 裡那些長度限制不是可有可無的。
到這裡,CEL 對開頭四個問題都給出了答案。但 Go 生態裡做表示式和規則的庫遠不止 CEL 一個,它們各自是怎麼回答這幾個問題的?
五、和同類語言對比
參加對比的語言
我們把能嵌程序序裡寫“判斷”的東西儘量列全,按作者主要用的 Go 生態排主次,Java 生態單獨一組。凡是有 Go 實現的都裝進同一個程式裡,用同一條放行規則、同一組輸入實測;Java 的幾個用 JDK 21 單獨測。
| 語言 | 出處 · 實測版本 | 定位 | 能不能寫迴圈 |
|---|---|---|---|
| 表示式語言(一個表示式,算出一個值) | |||
| CEL | Google,2017;cel-go v0.32.0,另有 C++、Java | 通用條件語言 | 只有 all / exists / map / filter 宏 |
| expr | expr-lang;v1.17.8 | Go 表示式語言 | 內建 all / map / filter,有區間 1..n |
| govaluate | Knetic;v3.0.0,已多年不更新 | Go 表示式 | 不能 |
| gval | PaesslerAG;v1.2.4 | 可擴充套件的 Go 表示式 | 不能 |
| JsonLogic | Jeremy Wadhams;Go 移植 v3.10.1 | 規則即 JSON | all / map / reduce |
| JMESPath | AWS CLI 的查詢語言;go-jmespath v0.4.0 | JSON 查詢 | 只能投影輸入 |
| jq | Stephen Dolan,2012;gojq v0.12.19 | JSON 處理 | 能(遞迴) |
| 策略語言 | |||
| Rego | OPA,CNCF 畢業專案;v1.21.3 | Datalog 風格的策略語言 | 推導式,禁止遞迴 |
| Cedar | AWS,2023 年開源;cedar-go v1.8.0 | 授權策略 | 不能 |
| 配置語言 | |||
| CUE | Marcel van Lohuizen;v0.17.1 | 配置 + 校驗 | 推導式 |
| Jsonnet | Google,2014;go-jsonnet | 配置生成 | 能(遞迴) |
| 嵌入式指令碼 | |||
| Starlark | Bazel 的 Python 方言;starlark-go | 確定性指令碼 | for 能;while、遞迴預設禁止 |
| Lua | gopher-lua v1.1.2 | 通用指令碼 | 能 |
| JavaScript | goja | 通用指令碼 | 能 |
| Tengo | d5/tengo v2.17.0 | Go 原生指令碼 | 能 |
| Java 生態(單獨實測) | |||
| SpEL | Spring 7.0.9 | Spring 的表示式語言 | 不能,但能呼叫任意方法 |
| MVEL | 2.6.0.Final | 表示式 + 指令碼 | 能 |
| Aviator | 5.4.4 | 表示式 + 指令碼 | 能 |
| JEXL | Apache Commons 3.7.1 | 表示式 + 指令碼 | 能 |
| CEL-Java | dev.cel 0.14.0 | CEL 的 Java 實現 | 同 CEL |
expr 和 cel-go 各測了兩種輸入:map[string]any 和 Go 結構體,所以 Go 側一共 17 個配置。沒有實測的:HashiCorp Sentinel(商業產品)、Apple 的 Pkl、Dhall、Go 直譯器 Yaegi、OGNL(它的問題見第一節的 Struts 漏洞)。
場景一:同一條規則怎麼寫
17 個 Go 配置都寫出了開頭那條規則,6 個測試用例(管理員、工作時間內的負責人、下班後的負責人、團隊成員、無關的人、沒有任何組的人)全部透過。寫法上的差別,比想象中能說明問題:
// CEL、expr、gval:三者完全一样user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups# Rego:每条 allow 是一条独立规则,命中任意一条即为真package authzdefault allow := falseallow if input.user.role == "admin"allow if { input.resource.owner == input.user.id input.hour >= 9 input.hour < 18}allow if input.resource.team in input.user.groups// Cedar:先建实体模型,团队关系用 in 表达成实体层级permit (principal, action == Action::"deploy", resource)when { principal.role == "admin" || (resource.owner == principal && context.hour >= 9 && context.hour < 18) || principal in resource.team};CEL 和 expr 一模一樣,因為它們都選了 C 系語法;Rego 把“或”拆成多條規則,讀起來像邏輯程式;Cedar 要求先把使用者、團隊、服務建模成實體,規則裡的 principal in resource.team 是沿著實體層級判斷的。Lua、Tengo 沒有 in,要自己寫一個 contains 迴圈;JsonLogic 和 JMESPath 只認 JSON 的 float64,傳 Go 的 int 會報錯或比較失敗。
場景二:寫錯欄位、寫錯型別
接下來把每個引擎裡的 user.role 改成 user.rol,再把 hour >= 9 改成 hour >= "9",看誰在什麼時候發現:
| 引擎 | user.rol(欄位寫錯) | hour >= "9"(型別寫錯) |
|---|---|---|
| CEL(Go 結構體) | 編譯期:undefined field 'rol' | 編譯期:no matching overload for '_>=_' applied to '(int, string)' |
| CEL(map / dyn) | 執行時:no such key;另一邊為 true 時被吸收 | |
| expr(Go 結構體) | 編譯期:type main.User has no field Rol | 編譯期:mismatched types int and string |
| expr(map) | 靜默返回 false | |
| Rego + JSON Schema | 編譯期:undefined ref: input.user.rol | 靜默返回 false |
| Rego(無 schema) | 靜默返回 false | 靜默返回 false |
| Cedar | 執行時:策略報錯,不生效 | 執行時:type error |
| govaluate、Starlark、CUE、Jsonnet | 執行時報錯 | 執行時報錯,只在走到這一支時 |
| Lua、Tengo | 靜默返回 false | 執行時報錯,只在走到這一支時 |
| gval、JsonLogic、JavaScript | 靜默返回 false | 靜默轉換,10 >= "9" 為 true |
| jq、JMESPath | 靜默返回 false | 靜默返回 false |
“執行時報錯,只在走到這一支時”:管理員的請求在第一個 || 就決定了結果,這些引擎對它返回 true,不報錯;只有走到 hour 比較的請求才報錯。
這張表裡最該注意的是紅色那一片:一半的引擎在欄位寫錯時靜默返回 false。對放行規則來說,這意味著管理員突然發不了版,而日誌裡什麼都沒有。能在編譯期攔住兩類錯誤的,只有宣告瞭具體型別的 CEL 和 expr。Rego 加上 JSON Schema 能發現欄位錯誤,但它的比較運算對不同型別有一個全序(數字總是小於字串),所以 10 >= "9" 是合法的、結果是 false。
場景三:寫一個停不下來的表示式
再給每個引擎寫一個“它允許寫出來的最壞表示式”:有迴圈的寫死迴圈,沒有迴圈的寫巢狀迭代。先不加限制跑,外面用 timeout 8s 兜底;再開啟該引擎自帶的限制手段跑一次,上限都設為約 1 秒或 100 萬步。
| 引擎 | 最壞寫法 | 不設限 | 自帶的限制 |
|---|---|---|---|
| CEL | 8 層巢狀 all,10⁸ 次 | 12.6 s 後返回 | 事先估算 4.6 億;CostLimit 97 ms 中止 |
| expr | 同上,用區間 0..9 | 108 ms 中止:預設記憶體預算 memory budget exceeded | |
| expr | 同上,用字面量列表 | 31.7 s | 沒有 CPU 或步數限制 |
| JsonLogic | 8 層巢狀 all | 15.4 s | 沒有 |
| Starlark | for i in range(10**9) | 40.4 s | SetMaxExecutionSteps 45 ms 中止 |
| Rego | 兩層 numbers.range(1, 10000) 推導式 | 8 s 未返回 | context 超時,1 s 中止 |
| Lua | while true do end | 8 s 未返回 | SetContext 超時,1 s 中止 |
| JavaScript | for (;;) {} | 8 s 未返回 | Interrupt,1 s 中止 |
| Tengo | for {} | 8 s 未返回 | RunContext,1 s 中止 |
| jq | def f: f; f | 8 s 未返回 | RunWithContext,1 s 中止 |
| CUE | 兩層 list.Range(0, 10000, 1) 推導式 | 8 s 未返回 | 沒有 |
| Jsonnet | std.foldl 遍歷 10⁸ 個元素 | 8 s 未返回 | 沒有,只有棧深度限制 |
| govaluate、gval、JMESPath、Cedar | 寫不出迴圈或巢狀迭代,代價只和表示式長度、輸入大小成正比 | ||
能停住的引擎其實不少:Lua、JavaScript、Tengo、jq、Rego 都支援 context 或中斷,Starlark 有步數上限15。差別在於它們都是“跑起來再看”,一條規則在儲存時無法判斷它會不會超時,只能上線後靠超時兜底。CEL 是這裡唯一能在寫入時就給出最壞代價的;expr 有記憶體預算,但用字面量列表就能繞開,31.7 秒沒有任何東西攔它。
場景四:Java 裡的沙箱
Java 生態的表示式語言普遍能直接訪問 Java 類,這正是第一節那幾個漏洞的根源。我們讓每個引擎讀一次系統屬性 user.name,再寫一個死迴圈或阻塞呼叫:
| 引擎 | 讀 System.getProperty("user.name") | 死迴圈或阻塞 |
|---|---|---|
| SpEL(StandardEvaluationContext) | "root" | Thread.sleep:3 s 後仍在跑 |
| SpEL(SimpleEvaluationContext) | 攔截:Type cannot be found | — |
| MVEL | "root" | while (true):3 s 後仍在跑 |
| Aviator | "root" | while true:3 s 後仍在跑 |
| JEXL(預設許可權) | 攔截:undefined property 'class' | while (true):3 s 後仍在跑 |
| JEXL(UNRESTRICTED) | "root" | — |
| CEL-Java | 編譯期:undeclared reference to 'java' | 寫不出來 |
SpEL 的 SimpleEvaluationContext 是 Spring 官方給不可信表示式準備的受限模式,代價是方法呼叫也受限:同一條規則裡的 List.contains 就因為找不到方法而報錯。
SpEL 和 JEXL 都提供了受限模式16,問題是它們預設不開,或者一開就要犧牲常用功能。CEL 的安全性不來自配置,而來自語法:語言里根本沒有“引用一個宿主型別”的寫法,java.lang.System 只是一個沒宣告過的識別符號。
場景五:每次求值的耗時
最後是效能。所有引擎都先編譯好、輸入也先轉換成各自需要的格式,只測求值本身(用團隊成員這個用例,前兩個分支都不成立,要一直算到第三個):
表示式類引擎都在 1 µs 以內。expr 用 Go 結構體時最快,163 ns,因為它把欄位訪問編譯成了直接的結構體取值;CEL 在 0.5 µs 左右,map 和結構體兩種輸入差別不大。Rego 約 11 µs,CUE、Jsonnet 每次都要走一遍完整的求值或合一,在 50–100 µs,適合配置生成,不適合放在每個請求上。要注意的是,這裡沒有算上把業務資料轉換成引擎輸入的時間:Cedar 每次要建實體,Jsonnet 每次要序列化成 JSON,加上轉換以後 Cedar 約 3 µs。
場景六:只知道一半輸入的時候
還有一種場景很少有人提,但很實用。釋出列表頁要展示“當前使用者能釋出哪些服務”,如果對每個服務都跑一遍規則,服務一多就慢。更好的辦法是:使用者的資訊已知,服務的資訊未知,讓規則自己“化簡”成一個只和服務有關的條件,再把它變成資料庫查詢。
CEL 的部分求值(partial evaluation)就是幹這個的:
prg, _ := env.Program(ast, cel.EvalOptions(cel.OptTrackState, cel.OptPartialEval))vars, _ := cel.PartialVars(map[string]any{"user": u, "hour": 10}, cel.AttributePattern("resource"))_, det, _ := prg.Eval(vars)residual, _ := env.ResidualAst(ast, det)s, _ := cel.AstToString(residual)// u1、dev、组 [web]、10 点:resource.owner == "u1" || resource.team in ["web"]// 管理员:true剩下的殘差表示式 resource.owner == "u1" || resource.team in ["web"] 只涉及服務欄位,可以直接翻譯成 WHERE owner = 'u1' OR team IN ('web')。這正是第三節提到的、規範讓 && 和 || 可交換的理由:邏輯表示式要能對映到索引查詢。在所有參加對比的引擎裡,只有 CEL 和 Rego 有這個能力;Rego 的部分求值給出的是 "u1" = input.resource.owner 和 internal.member_2(input.resource.team, ["web"]) 兩條查詢17,OPA 社群也一直用它把策略編譯成 SQL 過濾條件。
對照
把六個場景放在一起:
| 場景 | CEL | expr | Rego | Cedar | Starlark / Lua / JS |
|---|---|---|---|---|---|
| 能碰到什麼 | 只有宣告的變數和函式 | 只有 Env | 有 http.send 等內建函式,可關 | 只有實體和上下文 | 取決於注入了哪些庫 |
| 寫錯欄位 | 編譯期(需宣告型別) | 編譯期(需傳結構體) | 需配 JSON Schema | 執行時;Rust 版可用 schema 校驗 | 執行時或靜默 |
| 寫錯型別 | 編譯期 | 編譯期 | 靜默 | 執行時 | 執行時或靜默 |
| 失控 | 事先估算 + 執行時計數 | 記憶體預算,可繞開 | context 超時 | 寫不出迴圈 | 步數或超時 |
| 每次求值 | 約 0.5 µs | 0.16–0.44 µs | 約 11 µs | 約 0.6 µs(不含建實體) | 0.3 / 6.3 / 1.2 µs |
| 部分求值 | 有 | 無 | 有 | 無 | 無 |
| 跨語言 | Go、C++、Java 等,同一規範 | 只有 Go | Go,另有 Wasm | Rust、Go | 各有多種實現 |
結論可以這樣理解:
- CEL 和 expr18 是最接近的一對。 語法相同,都有型別檢查,都快。expr 更快、函式更多、寫起來更像 Go;CEL 多了成本估算、部分求值和跨語言的同一套規範。規則只在 Go 服務裡、由自己團隊寫,expr 很合適;規則來自使用者、要在寫入時就判斷安全,或者同一條規則要在 Go 後端、Java 服務、前端校驗裡都能跑,選 CEL。
- Rego 解決的是更大的問題。 它是一個策略引擎:多條規則組合、引用外部資料、統一的策略中心、決策日誌。把它當成“一行條件”的求值器來用,既慢又繞。
- Cedar19 專注授權。 實體模型和
permit / forbid結構讓它天然適合回答“誰能對什麼做什麼”,也更容易做形式化分析;但它不是通用的條件語言。 - Starlark、Lua、JavaScript 是指令碼。 真需要迴圈、區域性變數、多步計算時才用它們,用的時候必須自己加超時、步數限制,並小心控制注入的庫。
- CUE、Jsonnet 是配置語言,jq、JMESPath 是 JSON 查詢語言。它們能寫判斷,但不是為每個請求求值設計的。
選定了 CEL,在 Go 服務裡怎麼用才能把上面這些好處都拿到?
六、在 Go 裡用好 CEL
前面幾節已經暗示了大部分做法。把它們按規則的生命週期排一下:
下面逐條說明每個做法解決的是什麼問題。
1. 環境宣告具體型別
問題:宣告成 map(string, dyn) 時,欄位寫錯要到執行時才發現,還可能被 || 吸收(第三節)。
做法:輸入用 Go 結構體的話,ext.NativeTypes 加 ext.ParseStructTags(true),欄位名用 cel:"id" 這樣的標籤指定;輸入本來就是 protobuf 的話,cel.Types(&pb.Request{}) 加 cel.Variable("request", cel.ObjectType("pkg.Request")),這也是 CEL 原生的方式。環境在程序啟動時建一次,所有規則共用。
2. 儲存時編譯,並檢查輸出型別
問題:user.role 也是一條合法的 CEL 表示式,結果是字串。放行規則返回了字串,求值時才發現就晚了。
做法:儲存規則時呼叫 env.Compile,把 iss.Err() 原樣返回給使用者(它帶行列號和一個指向出錯位置的 ^);再檢查 ast.OutputType().IsExactType(cel.BoolType)。透過以後把規則原文存起來,程序裡快取 Program。每次請求重新編譯要 100 µs,快取後只剩 0.5 µs。
3. 用尺寸提示估算成本,並設定執行時上限
問題:規則會停,但可能很慢(第四節);而列表長度未知時,估算出的上限沒有意義。
做法:實現一個 checker.CostEstimator,在 EstimateSize 裡按欄位路徑返回業務上的最大長度(比如一個使用者最多 64 個組),儲存時用 env.EstimateCost 拒絕超過上限的規則。生成 Program 時加 cel.CostLimit 和 cel.InterruptCheckFrequency(100),請求時用 ContextEval 帶上超時。我們實測 CostLimit 會讓這條規則的求值從 0.47 µs 變成 1.47 µs,因為每一步都要計數;如果規則全部由內部人員編寫、並且儲存時已經估算過,可以只靠估算和超時,省掉這部分開銷。
4. 出錯一律按拒絕處理
問題:求值出錯時(欄位缺失、超出成本、超時),返回值不是 bool。
做法:放行類規則 fail closed,任何錯誤都當作“不允許”,同時把錯誤記日誌。Kubernetes 的 ValidatingAdmissionPolicy 用 failurePolicy 讓管理員顯式選擇出錯時是放行還是拒絕,也是同一個思路。
5. 自定義函式要是純函式
問題:CEL 的安全性來自“只能呼叫宿主宣告的函式”。如果宿主宣告瞭一個 httpGet(url),前面所有保證都失效了:它有副作用,成本也無法估算。
做法:用 cel.Function 註冊的函式只做純計算(解析版本號、判斷 CIDR、查一個預先載入好的記憶體表),不做 I/O。需要外部資料時,在求值前由宿主準備好放進輸入。函式的代價不是常數的,在 EstimateCallCost 和執行時成本估算裡報上去,否則估算器會按 1 計算。
6. 環境要版本化
問題:規則存進資料庫後,平臺升級 cel-go、開啟新的擴充套件庫,老規則的行為可能變化;反過來,回滾到舊版本時,新語法寫的規則可能無法編譯。
做法:擴充套件庫都帶版本號,例如 ext.Strings(ext.StringsVersion(2)),顯式寫死版本,升級時再統一評估。Kubernetes 就是這樣做的:每個 CEL 庫從哪個版本開始可用寫在文件裡,新功能先只允許已存在的規則繼續求值、不允許新寫入,等到可以安全回滾了再放開11。
7. 需要“列出所有能訪問的物件”時用部分求值
問題:對每個候選物件都跑一遍規則,物件一多就慢。
做法:已知的部分(使用者)放進 PartialVars,未知的部分(物件)用 AttributePattern 標出來,拿到殘差表示式後翻譯成資料庫查詢,像第五節場景六那樣。
在 Kubernetes 裡
在 Kubernetes 裡寫 CEL,上面幾條大多已經由 apiserver 做好了:型別來自 OpenAPI schema,成本估算和執行時預算是內建的。我們要做的主要是兩件事:給 schema 裡的列表和字串加 maxItems、maxLength,讓估算有意義;把重複的子表示式提到 variables 裡,給失敗寫一條有用的 messageExpression。比如要求 Deployment 的映象都來自公司倉庫:
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: image-from-corp-registryspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: ["apps"] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["deployments"] variables: - name: badImages expression: >- object.spec.template.spec.containers .filter(c, !c.image.startsWith('registry.corp/')) .map(c, c.image) validations: - expression: "size(variables.badImages) == 0" messageExpression: "'镜像必须来自 registry.corp:' + variables.badImages.join(', ')"七、回到開頭的放行規則
現在可以把第一節的需求完整地做出來了。下面這段程式就是第六節幾條做法的組合:環境用具體型別,儲存時檢查輸出型別和成本,請求時帶超時和成本上限,出錯就拒絕。
package main
import ( "context" "errors" "fmt" "reflect" "time"
"cel.dev/cel-go/cel" "cel.dev/cel-go/checker" "cel.dev/cel-go/ext")
type User struct { ID string `cel:"id"` Role string `cel:"role"` Groups []string `cel:"groups"`}
type Resource struct { Owner string `cel:"owner"` Team string `cel:"team"`}
const costLimit = 1_000_000
// 一个进程只建一次:声明变量的具体类型,写错字段在保存时就报错。var env = must(cel.NewEnv( ext.NativeTypes(reflect.TypeOf(User{}), reflect.TypeOf(Resource{}), ext.ParseStructTags(true)), cel.Variable("user", cel.ObjectType("main.User")), cel.Variable("resource", cel.ObjectType("main.Resource")), cel.Variable("hour", cel.IntType),))
// 保存规则时调用:检查、限定输出类型、估算成本,返回可缓存的 Program。func compileRule(src string) (cel.Program, error) { ast, iss := env.Compile(src) if iss.Err() != nil { return nil, iss.Err() } if !ast.OutputType().IsExactType(cel.BoolType) { return nil, fmt.Errorf("规则必须返回 bool,实际是 %v", ast.OutputType()) } est, err := env.EstimateCost(ast, sizeHints{}) if err != nil { return nil, err } if est.Max > costLimit { return nil, fmt.Errorf("规则最坏成本 %d 超过上限 %d", est.Max, costLimit) } return env.Program(ast, cel.CostLimit(costLimit), cel.InterruptCheckFrequency(100))}
// 每次请求调用:带超时,任何错误都按拒绝处理。func allow(ctx context.Context, prg cel.Program, u User, r Resource, hour int) (bool, error) { ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() out, _, err := prg.ContextEval(ctx, map[string]any{"user": u, "resource": r, "hour": hour}) if err != nil { return false, err } b, ok := out.Value().(bool) if !ok { return false, errors.New("non-bool result") } return b, nil}
// 告诉估算器:groups 最多 64 个,字符串最长 128。type sizeHints struct{}
func (sizeHints) EstimateSize(n checker.AstNode) *checker.SizeEstimate { if p := n.Path(); len(p) > 0 { switch p[len(p)-1] { case "groups": return &checker.SizeEstimate{Min: 0, Max: 64} case "id", "role", "owner", "team", "@items": return &checker.SizeEstimate{Min: 0, Max: 128} } } return nil}
func (sizeHints) EstimateCallCost(fn, overload string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { return nil}
func must[T any](v T, err error) T { if err != nil { panic(err) } return v}拿開頭那條規則和幾條有問題的規則試一下:
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups 保存成功,求值:trueuser.rol == "admin" 拒绝保存:ERROR: <input>:1:5: undefined field 'rol'hour >= "9" 拒绝保存:ERROR: <input>:1:6: found no matching overload for '_>=_' applied to '(int, string)'user.role 拒绝保存:规则必须返回 bool,实际是 stringuser.groups.all(a, user.groups.all(b, user.groups.all(c, user.groups.all(d, a + b + c + d != "")))) 拒绝保存:规则最坏成本 2081972611 超过上限 1000000第一節圖 1 裡的三個問題,在這裡都在儲存時被攔下了:沒有辦法引用 java.lang.System 這類宿主之外的東西;巢狀四層遍歷最多 64 個元素的組,估算出 20 億的成本,直接拒絕;欄位寫錯報出了具體的行列號。
小結
- 為什麼需要一門專門的表示式語言? 把規則交給使用者寫,嵌入完整的指令碼語言會帶來四個問題:能碰到宿主、可能停不下來、寫錯了要到執行時才知道(甚至靜默給出錯誤答案)、求值不夠便宜。SpEL、OGNL 的幾次遠端程式碼執行漏洞都出在這裡。
- CEL 從哪來? 2017 年從 Google 內部的 go/api-expr 匯出,基於 Firebase 安全規則的經驗設計,後來用在 IAM 條件、Envoy、Kubernetes 的 CRD 校驗和准入策略裡。
- 它捨棄了什麼、換來了什麼? 沒有迴圈、賦值、I/O 和引用宿主型別的語法,換來了只能訪問宿主給的資料、型別可以在編譯期檢查、最壞代價可以在寫入前估算。
- 非圖靈完備就夠了嗎? 不夠。巢狀宏照樣能寫出 12 秒的表示式,要靠靜態估算(配合列表、字串的最大長度)和執行時成本上限兩道閘門。
- 和同類比怎麼樣? 22 個實現在同一組場景裡實測:CEL 和 expr 最接近,expr 更快,CEL 多了成本估算、部分求值和跨語言規範;Rego 是策略引擎,Cedar 專注授權,Starlark、Lua、JavaScript 是指令碼,CUE、Jsonnet 是配置語言。CEL 不是每項最好,但每項都不差。
- 在 Go 裡怎麼用? 環境宣告具體型別;儲存時編譯、檢查輸出型別、估算成本,快取 Program;請求時帶超時和成本上限,出錯就拒絕;自定義函式保持純函式;擴充套件庫寫死版本;列表查詢用部分求值。
參考資料
參考資料
-
Advanced CEL topicsbuf.build ↗“If you've written expressions for conditional IAM, its Secure Web Proxy, or Firebase security rules, you've used CEL.” protovalidate 用 CEL 寫欄位約束。https://buf.build/docs/protovalidate/cel ↩
-
JsonLogicjsonlogic.com ↗規則本身是 JSON;文中用 Go 移植 diegoholiveira/jsonlogic v3.10.1。https://jsonlogic.com/ ↩
-
CVE-2017-5638:Apache Struts Jakarta Multipart 解析器遠端程式碼執行nvd.nist.gov ↗攻擊者在 Content-Type 頭裡放一段 OGNL 表示式即可執行任意命令,Equifax 2017 年的資料洩露就是從這裡進去的。https://nvd.nist.gov/vuln/detail/CVE-2017-5638 ↩
-
CVE-2022-22963:Spring Cloud Function 路由表示式 SpEL 注入nvd.nist.gov ↗
spring.cloud.function.routing-expression請求頭裡的 SpEL 被直接求值,導致遠端程式碼執行。https://nvd.nist.gov/vuln/detail/CVE-2022-22963 ↩ -
cel-expr/cel-spec:Common Expression Language 規範github.com ↗README 的設計目標:線性時間、無副作用、非圖靈完備;“The initial spec was based on the experience of developing Firebase Rules”。第一個提交是 2017-08-14 的 “Initial import from go/api-expr”。https://github.com/cel-expr/cel-spec ↩
-
Google、オープンソースの高速で安全な式言語「Common Expression Language」を発表atmarkit.itmedia.co.jp ↗2024 年 6 月 Google 開源部落格專文介紹 CEL 的報道:速度、可移植、可擴充套件、安全四個特點,以及 Kubernetes、IAM 中的用途。https://atmarkit.itmedia.co.jp/ait/articles/2406/24/news056.html ↩
-
cel.devcel.dev ↗官方站點:Speed、Portability、Safety(Non-Turing complete, only accesses data provided by the host application)、Verifiability。https://cel.dev/ ↩
-
KEP-2876:CRD Validation Expression Languagegithub.com ↗alpha 1.23,beta 1.25,stable 1.29。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/2876-crd-validation-expression-language ↩
-
KEP-3488:CEL for Admission Control(ValidatingAdmissionPolicy)github.com ↗alpha 1.26,beta 1.28,stable 1.30。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control ↩
-
KEP-3962:Mutating Admission Policiesgithub.com ↗alpha 1.32,beta 1.34,stable 1.36。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3962-mutating-admission-policies ↩
-
Common Expression Language in Kuberneteskubernetes.io ↗Kubernetes 裡 CEL 的用途、各擴充套件庫開始支援的版本、執行時成本預算和寫入時的估算成本檢查。https://kubernetes.io/docs/reference/using-api/cel/ ↩ ↩2 ↩3
-
cel-expr/cel-gogithub.com ↗Go 實現,文中用 v0.32.0。2026-06-16 倉庫遷到 cel-expr 組織,模組路徑為
cel.dev/cel-go。https://github.com/cel-expr/cel-go ↩ -
CEL Language Definitiongithub.com ↗memory-safe、side-effect-free、terminating、gradually-typed;
&&/||可交換並吸收錯誤;Performance Limits 一節:除has()外的宏是唯一可能出現指數代價的地方。https://github.com/cel-expr/cel-spec/blob/master/doc/langdef.md ↩ ↩2 ↩3 -
kubernetes:staging/src/k8s.io/apiserver/pkg/apis/cel/config.gogithub.com ↗
PerCallLimit = 1000000(約 0.1 秒)、RuntimeCELCostBudget = 10000000(每個 CR 或 VAP binding,約 1 秒)、CheckFrequency = 100。https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go ↩ -
google/starlark-gogithub.com ↗Bazel 配置語言的 Go 實現;預設不允許 while 和遞迴,
Thread.SetMaxExecutionSteps限制執行步數。https://github.com/google/starlark-go ↩ -
Spring Framework:SpEL Evaluation Contextdocs.spring.io ↗
SimpleEvaluationContext只支援 SpEL 的一個子集,排除了型別引用、構造器和 bean 引用,用於不可信表示式。https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html ↩ -
Open Policy Agent:Policy Language(Rego)openpolicyagent.org ↗文中用 OPA v1.21.3 的 Go SDK。部分求值(Partial Evaluation)和 JSON Schema 型別檢查分別見 docs 的 Partial Evaluation、Schemas 兩節。https://www.openpolicyagent.org/docs/policy-language ↩
-
expr-lang/exprgithub.com ↗Go 表示式語言,文中用 v1.17.8。
expr.Env傳結構體時做靜態型別檢查;預設記憶體預算conf.DefaultMemoryBudget = 1e6。https://github.com/expr-lang/expr ↩ -
Cedar Policy Languagecedarpolicy.com ↗AWS 2023 年開源的授權策略語言,Amazon Verified Permissions 使用;文中用 cedar-go v1.8.0。https://www.cedarpolicy.com/ ↩
延伸閱讀
- 其餘參加實測的 Go 實現pkg.go.dev ↗Knetic/govaluate v3.0.0、PaesslerAG/gval v1.2.4、yuin/gopher-lua v1.1.2、dop251/goja、d5/tengo v2.17.0、itchyny/gojq v0.12.19、jmespath/go-jmespath v0.4.0、cuelang.org/go v0.17.1、google/go-jsonnet。https://pkg.go.dev/
- 參加實測的 Java 實現central.sonatype.com ↗spring-expression 7.0.9、mvel2 2.6.0.Final、aviator 5.4.4、commons-jexl3 3.7.1、dev.cel:cel 0.14.0,JDK 21。https://central.sonatype.com/