vatt'ghern jaskier's ballads

LINE 的 Kafka broker 一直有嚴格的存取控制,但存取控制管的是誰能碰到 broker,管不到碰到之後看不看得懂裡面的內容——一旦真的有人碰到,payload 是明文。LINE 把加密往前推一層,讓訊息從 producer 寫進去到 consumer 讀出來的整條路上,broker 自己看到的永遠是密文。

Kafka 的 payload,在 broker 上是密文

LINE 的聊天訊息早就有端到端加密,叫 Letter Sealing。這篇談的是另一層:訊息佇列本身。LINE 每天用多個系統處理數量龐大的訊息,其中不少帶著使用者的個資,而這些訊息在寫進 Kafka 之後,會以明文形式存放在 broker 上。團隊自己的講法很直接:存取控制規範的是誰能碰到 broker,不是碰到之後看不看得懂儲存在 broker 上的明文。這句話點出了問題的形狀——存取控制跟資料保護是兩件事,前者擋人,後者擋內容。解法是把加密往前推一層,讓 payload 從 producer 寫出去到 consumer 讀進來,broker 上存的、複製的、備份的,全部都是密文。

LINE 描述自己每天要處理的規模是「billions of messages」——訊息量體大到只要其中一小部分內容敏感,broker 上長期堆積的明文就已經構成風險。這也是為什麼解法沒有停在存取控制上加碼:存取控制能做的是把碰到 broker 的人數壓低,但只要還有人能碰到,風險就沒有真正消除;真正把風險關掉的做法,是讓碰到的人看到的東西本身就沒有意義。

DEK 加密 payload,KEK 只加密 DEK

核心設計是兩層信封。第一層是 DEK(Data Encryption Key),對稱金鑰,演算法用 AES-GCM,直接拿來加密訊息本體。第二層是 KEK(Key Encryption Key),非對稱金鑰,用 secp521r1 曲線上的 ECIES,但它不碰 payload,只負責加密那把短短的 DEK。為什麼要拆成兩層而不是直接用非對稱金鑰加密整段內容——團隊給的理由很乾脆:「Encrypting large payloads with asymmetric keys alone is very expensive」。非對稱運算天生比對稱運算貴,如果每一筆訊息的完整 payload 都要過一次非對稱加密,成本會直接壓垮吞吐量。於是把貴的運算限定在一個很小的目標上:DEK 是一把很短的金鑰,昂貴的非對稱運算只用來包它,真正吃量的 payload 交給便宜的對稱加密處理。沒有這套機制的話,broker 上長期躺著的就是完整明文——不管是誰,只要能讀到那份儲存,就等於讀到訊息本身。DEK、KEK 這套信封機制把「讀到儲存」跟「讀懂內容」徹底切開,這也是端到端加密裡「端」真正的意思:加密跟解密都發生在使用者自己的行程裡,broker、複本、備份,中間任何一站經手的都只有密文。

曲線選擇本身文章沒有多解釋為什麼是 secp521r1 而不是更常見的 secp256r1,合理的推測是,KEK 只用在一個相對低頻的動作上——加密、解密的對象是 DEK,不是逐筆 payload,運算次數被兩端的快取壓得很低,所以團隊有本錢選一條金鑰長度更大、安全邊際更高的曲線,不用擔心這個選擇拖慢整體吞吐量。

兩層金鑰分工之外,還有一個容易被忽略的選擇:加密的粒度是逐筆訊息(record-level),而不是整批(batch-level)。Kafka producer 內部本來就會把多筆訊息打包成一個 batch 送出,對 batch 整體壓縮效率最好;但 LINE 選擇犧牲這塊壓縮效率,換一個更根本的東西——「record-level encryption allows using Kafka's standard extension points such as interceptors」,逐筆加密能掛在 Kafka 既有的 interceptor、serializer 這些標準擴充點上,不用去改 Kafka 的內部批次格式。這個選擇也連帶保住了「compatibility with Kafka upgrades」——跟著 Kafka 版本升級走,不必為了加密機制去維護一份分叉過的 client 或 broker 邏輯。壓縮效率變差是實打實的代價,只是團隊判斷它比「跟標準擴充點脫鉤」的代價小。

一筆加密後的 Kafka 訊息分三層 · Key / Header / Body

Bodypayload,被 DEK 加密
HeaderKEK ID + 被 KEK 加密的 DEK
Key原本的 partition key,不變
一筆加密後的訊息維持 Kafka 原本的三段結構:Key 不動;Header 多放兩樣東西——用來識別金鑰版本的 KEK ID,跟被那把 KEK 加密過的 DEK;Body 是被 DEK 加密後的 payload。

這個三段式結構也解釋了為什麼下游系統可以在不知情的狀況下繼續運作:Key 完全沒變,分區路由邏輯不用改;只有 Header 多了一段以前沒有的 metadata,Body 變成密文。對於任何不需要解密、只需要按 key 路由或做基礎設施層面處理的元件——比如做 mirror、備份的工具——這段設計幾乎是無感的,只有真的要讀 payload 內容的 consumer,才需要知道 KMS、KEK、DEK 這幾個詞。

Producer 端:interceptor 跟 serializer 怎麼分工

加密邏輯沒有塞進使用者的業務程式碼,而是掛在 Kafka client 既有的兩個擴充點上,各自管一件事。Interceptor 管 header:「Immediately before a message is sent, intercept it, generate the DEK, encrypt the DEK with the KEK, and insert this information into the header」——在訊息真正送出前攔截它,產生一把新的 DEK、用 KEK 加密這把 DEK、把結果寫進 header。Serializer 管 body,它是「Wrapper around the existing data serializer」,包在原本的資料序列化邏輯外面,做「a second encryption using the DEK prepared by the interceptor」——用 interceptor 準備好的那把 DEK,對序列化完的 payload 再加密一次。兩個元件各管一半,中間靠 ThreadLocal 傳遞 DEK,處理結束時再把資源清掉。interceptor 跟 serializer 各自處理一半,這個分工也讓兩件事可以獨立測試、獨立除錯:header 有沒有正確帶上 KEK ID 是 interceptor 的責任,payload 解不解得開是 serializer 的責任,兩邊都出問題時,不用先去猜是哪一段程式碼壞的。

如果每一筆訊息都重新產生一把 DEK、重新做一次非對稱加密去包它,高流量下這筆開銷會疊得很快。團隊的做法是「We therefore cache DEKs for a fixed time and reuse the same DEK for messages sent during that period」——把 DEK 快取一段固定時間,這段時間內送出的訊息共用同一把 DEK,只有 payload 各自加密,DEK 本身的產生跟 KEK 包裝動作不用每筆都重來。這個快取策略是後面效能數字能壓在低個位數的關鍵前提之一:真正貴的非對稱運算次數,跟訊息筆數脫鉤,跟快取週期掛鉤。

Consumer 端:deserializer 逆向操作與快取

Consumer 端做的是鏡射的兩步。第一步,「Find the encrypted DEK in the received message header and decrypt it with the consumer's KEK private key」——從收到的 header 裡找出加密過的 DEK,用自己手上那把 KEK 私鑰解開它,還原成明文 DEK。第二步,「Use the decrypted DEK to decrypt the payload contained in the message body」——拿這把明文 DEK 去解開訊息本體的 payload,再交回原本的反序列化邏輯繼續走。整個 deserializer 同樣是「implemented as a wrapper around the existing deserialization logic」,包在原本的邏輯外面,不需要動到下游程式碼認得的資料格式。

跟 producer 端的 DEK 快取對稱,consumer 端也有一層快取:「Consumers cache pairs of encrypted DEK and decrypted DEK so that when the same encrypted DEK is received again, the consumer can skip the decryption step.」——把「加密 DEK → 解密後 DEK」的配對存起來,同一把加密 DEK 再出現時,直接查表拿到明文 DEK,跳過用 KEK 私鑰解密這一步。這步快取解決的是同一個問題的另一半:producer 端快取降低的是「產生+包裝 DEK」的頻率,consumer 端快取降低的是「解開 DEK」的頻率,兩邊都在把非對稱運算壓在跟金鑰輪替週期同一個量級,而不是跟訊息流量同一個量級。快取解密後的 DEK,省下的是重複做非對稱運算的成本,但代價是明文 DEK 會在 consumer 行程的記憶體裡多留一段時間,不是用完即丟。這是效能與金鑰生命週期之間常見的取捨——強調輪替與最小暴露時間的金鑰系統,往往得在「解密快取」跟「明文金鑰在記憶體裡留存的時間」之間找一個平衡點,LINE 這裡選擇讓效能贏。

點按或觸碰底線詞看角色定義 · 4 個名詞

整條路徑上四個角色各管各的: interceptorproducer 端,訊息送出前攔截、產生 DEK、用 KEK 加密 DEK、寫進 header。serializerproducer 端,包在既有序列化邏輯外,用 interceptor 準備好的 DEK 加密 payload 本體。KMS金鑰生命週期的管理者:topic owner 在這裡註冊金鑰對,producer 拿公鑰,取得授權的 consumer 拿私鑰,新 consumer 加入時要向它申請存取。deserializerconsumer 端,包在既有反序列化邏輯外,先用 KEK 私鑰解出 DEK,再用 DEK 解出 payload。 四者合起來,才是「E2EE」裡那個「end-to-end」的意思——加密發生在 producer 行程裡,解密發生在 consumer 行程裡,broker 兩頭都碰不到。

共用 KEK 的取捨:header 大小 vs 金鑰隔離

金鑰怎麼分發,靠一個獨立的 KMS(Key Management Service)管理。Topic owner 「register an asymmetric key pair in the KMS」,producer「fetch the public key」,通過授權的 consumer「fetch the private key」;「When a new consumer is added, it requests access to the private key from the KMS」——新加入的 consumer 要主動申請授權,不是憑空就能解密。這套機制天然會撞上一個規模問題:「Kafka topics can have many consumers that continue to grow. If each consumer is given a unique key, metadata accumulates in the message header proportional to the number of consumers, and message size grows accordingly.」如果每個 consumer 都配一把自己專屬的 KEK,header 裡就得放跟 consumer 數量等量的加密 DEK 項目,訊息也跟著變大,而且膨脹幅度沒有上限——topic 加了幾個新 consumer,所有訊息的 header 就跟著長胖一點。

LINE 的解法是反過來:「Multiple consumers share a single KEK from the start. Using a shared KEK keeps the message header to a single metadata entry regardless of the number of consumers.」讓多個 consumer 從一開始就共用同一把 KEK,header 裡的 metadata 筆數固定是一筆,不管 topic 底下掛了幾個 consumer。下面這張圖把兩種做法的差別畫出來——這是設計文件描述的關係示意,不是官方公布的具體實測數字:拖動 consumer 數量,看 per-consumer 金鑰的線怎麼跟著往上爬,共用 KEK 的線怎麼一直貼在底部不動。

拖動 consumer 數量 · 比較兩種金鑰策略下 header 要放幾筆 DEK

12
consumer 數量 header 裡的 DEK 項目數 per-consumer 金鑰 共用 KEK
兩條線都是示意圖,畫的是設計文件描述的定性關係,不是實測 benchmark 數字:per-consumer 金鑰下 header 項目數等於 consumer 數量,一路線性往上;共用 KEK 下永遠是一筆。共用 KEK 犧牲的是金鑰隔離的細緻程度——多個 consumer 拿同一把私鑰——用 KMS 授權管控跟定期輪替補回來。

共用 KEK 不是沒有代價,它犧牲的是金鑰隔離的細緻程度:理論上任何拿到這把共用私鑰的 consumer 都能解開所有用它加密的訊息,跟 per-consumer 金鑰天生就把權限切開的做法不一樣。LINE 補這個缺口的方式有兩層:一層是「KMS」本身的授權管控,新 consumer 要主動申請才拿得到私鑰,不是預設放行;另一層是「Periodic key rotation」——被描述成一項安全需求,不是可有可無的例行公事。定期輪替把「一把金鑰用多久」壓在一個有限的時間窗內,就算私鑰外洩,暴露的範圍也被輪替週期天然限制住。這個取捨背後其實是一個更通用的模式:當金鑰要跟著一個會持續成長的維度掛勾——這裡是 consumer 數量——讓金鑰粒度跟著那個維度走,遲早會撞上規模問題;把粒度收斂到一個固定值,再用授權與輪替補回細緻度,是一個能套用在其他場景的思路。多租戶系統裡「每個租戶一把金鑰」要不要繼續往「每個使用者一把金鑰」細分,多半也會撞上同樣的取捨。

零停機輪替:新舊 KEK 並存

金鑰要輪替,但線上系統不能因為換金鑰而停機或短暫解不開訊息。做法是讓新舊兩把 KEK 有一段共存期:「the header contains the DEK encrypted with the old KEK and the DEK encrypted with the new KEK side by side」——輪替期間,header 裡同時放著被舊 KEK 加密的 DEK、跟被新 KEK 加密的 DEK。Consumer 端不需要知道現在正在輪替,它只要「examine the KEK ID included in each header entry and select only the entry that matches its key to decrypt」——看每個 header 項目上標的 KEK ID,挑跟自己手上金鑰對得上的那一項去解密,其餘忽略。舊 consumer 用舊金鑰照樣解得開,已經拿到新金鑰的 consumer 直接用新的,兩者互不干擾。

整個切換節奏是靠 metrics 一步步確認,不是憑感覺猜時間。先在 KMS 註冊新 KEK;「About one minute later, confirm via metrics that producers are reflecting the new KEK」,約一分鐘後看 metrics 確認 producer 端真的開始用新 KEK 了;再「About one minute later, confirm via metrics that consumers are decrypting with the new KEK」,又約一分鐘後確認 consumer 端也真的在用新 KEK 解密。這兩步確認完,才輪到停用舊 KEK;最後再確認 producer 全面只用新 KEK,一次輪替才算走完。整個流程沒有任何一步是「先假設沒問題就往下走」,每一步都靠可觀測的訊號卡關。刻意不用固定等待時間去卡關,理由不難理解:如果流量分布不均勻,或者某個 consumer 因為自己的問題慢了半拍,用固定計時器判斷「應該切完了」很容易在還沒真的切完的時候就去停用舊 KEK,那一刻還沒切過去的 consumer 就可能解不開訊息。用可觀測的訊號代替猜測的時間,是這套零停機設計真正站得住腳的地方。

拖動時間軸 · 5 個步驟

register producer consumer deactivate complete t=0:00
在 KMS 註冊新 KEK,這時候還沒有任何 producer 或 consumer 使用它。

效能實測與漸進式上線

設計上乾淨不代表跑起來便宜,這件事得量出來才算數。測試環境用的是「Same server hardware as production」,跑在「test-only topics」上,負載則是照「maximum requests per second (RPS) and maximum message sizes measured in production」去重現——換句話說,測的不是隨手設定的壓測數字,而是正式環境真的量到過的尖峰負載。每組設定跑十分鐘,量每個 instance 的 CPU 使用率。結果是:「CPU increase per instance was below 1%」;就算是「for the highest RPS (up to 5,000 requests per second) and message sizes of tens of kilobytes」——每秒最多約 5,000 筆請求、訊息大小落在數十 KB——producer 跟 consumer 的 CPU 增幅都還是「about 1%」。要注意這裡的「最多約 5,000 RPS」是這次測試覆蓋的負載上限,不是文章標題講的「1M messages per second」——標題那個數字指的是被拿來做實驗的那個 Kafka topic 本身「can handle up to 1,000,000 messages per second」的承載規模,是 topic 的容量上限,不是這次實測真正打過的流量。兩個數字都真實存在,但問的是不同問題:一個是「這個 topic 撐得住多少」,一個是「加密之後多吃多少 CPU」。對維運團隊來說,「CPU 增幅低於 1%」這個數字的意義不只是一次性測試過關,更直接影響容量規劃:如果加密要多吃 10%、20% 的 CPU,上線前就得重新盤點每個 topic 的 producer、consumer 資源配置,甚至可能得先擴容再上線;壓在 1% 以內,代表這件事幾乎不用動到既有的容量規劃,這也是團隊敢用兩週漸進式節奏推到 100% 的底氣。

低於 1% 的 CPU 增幅,讓團隊得出一個實際的結論:「Encryption would not require adding more servers」——加密不需要為此多加機器。這個結論之所以成立,跟前面兩處快取設計直接相關:producer、consumer 兩端都把貴的非對稱運算壓在金鑰快取週期上,而不是隨每一筆訊息線性增加;真正逐筆做的,只有便宜的 AES-GCM 對稱加解密。如果沒有這兩層快取,同樣的設計換上一個「每筆訊息都重新走一次非對稱運算」的版本,1% 這個數字多半守不住。

上線方式也是刻意求穩,不是一次全開。migration 期間先靠相容判斷過渡:「Consumers' deserializers detect whether header metadata is present and choose how to operate accordingly」,deserializer 自己判斷這筆訊息有沒有加密 metadata,決定要不要走解密路徑,讓加密流量跟未加密流量在同一個 topic 上並存而不出錯。正式推進的節奏是「Initially set the encryption rate to a minimum (for example, 1%) so that encryption applies to only a tiny portion of traffic」,先讓極少量流量走加密,確認沒問題再靠 metrics 決策「increase the rate stepwise to 10%, 50%, and 100%」,一階一階往上推。整個過程「rolled out encryption gradually and raised the encryption rate to 100% over about two weeks」,大約兩週跑完,期間「zero encryption/decryption-related incidents」,正式環境量到的即時 CPU 增幅也「stayed within the predicted 1% or less」——事前測試預測的數字跟正式環境實際發生的數字對得上,這比單一次的測試結果更有說服力。從遷移的角度看,這裡真正值得抄的不是加密演算法本身,而是「相容判斷、灰度、metrics 卡關」這一套節奏:先讓新舊格式在同一個 topic 上並存,再用極小比例的流量驗證新路徑沒有副作用,每一階段的推進都綁在可觀測的訊號上,不是綁在時間表上。這個節奏跟演算法無關,換成任何一次改變訊息格式的遷移——不管是加密、換序列化框架,還是升級 schema——都適用同一套邏輯:相容先行、流量分階段、訊號驅動決策。

文章沒有寫的細節也值得留意:DEK 快取多久算「固定時間」、共用 KEK 到底掛在多少個 consumer 底下、金鑰輪替的預設週期抓多長,這些具體參數都沒有公開。這不影響設計本身的可信度——公開的是機制與實測結論,不是內部的 SRE 手冊——但如果要照這個架構搬到自己的系統,這幾個參數還是得按自己的流量特性重新抓,不能直接照抄 LINE 的數字。如果你的團隊也在用 Kafka 存放帶個資的訊息,這篇文章給的具體檢查清單是:先確認 broker 層的存取控制是不是被誤當成資料保護在用;再看 payload 加密要做在 record 層還是 batch 層,決定要犧牲壓縮效率還是相容性;最後想清楚金鑰要跟哪個維度掛勾——是每個 consumer,還是整個 topic——避免重蹈 header 隨規模膨脹的覆轍。

這套設計解決什麼:DEK/KEK 兩層信封把「加密要快」跟「金鑰要能分權管理」拆開處理,共用 KEK 把「header 不能因為 consumer 變多而膨脹」跟「金鑰隔離要夠細」的衝突,交給 KMS 授權與定期輪替去補;record-level 加密則是拿壓縮效率換 Kafka 標準擴充點的相容性。三個取捨疊起來,換到的是一個 broker 全程只看得到密文、CPU 增幅壓在 1% 以內、可以用兩週漸進式上線推到全流量的方案。