技術文章

用 Rust FFI 監控 Luna HSM:當 PKCS#11 不夠用時

Luna HSM 不提供 Prometheus exporter,SNMPv3 是唯一出路。本文分享如何以 Rust 包裝 Net-SNMP C library,設計三層快取解決 SNMP 慢速收集 vs Prometheus 快速 scrape 的速度落差問題,並附 Python/Go/Rust 多項 benchmark 比較。

作者:雲杉科技
#Rust #Luna HSM #SNMP #Prometheus #FFI #監控 #效能優化
用 Rust FFI 監控 Luna HSM:當 PKCS#11 不夠用時

問題背景

在金融機構和 CA 基礎設施中,Luna HSM 是密碼學金鑰的最終守護者。PKCS#11 API 提供完整的金鑰操作介面,但它有一個根本限制:PKCS#11 只是金鑰操作介面,不是監控介面

當你需要知道以下問題時,PKCS#11 就幫不上忙了:

  • HSM 硬體序號和韌體版本為何?
  • 目前有多少 PKCS#11 Session 在使用(達到上限會無法建立新連線)?
  • 分區儲存空間剩餘多少?
  • Client 與 Partition 的分配關係是什麼?
  • NTLS 憑證還有幾天到期?

這些問題的答案,需要透過另一個協定取得:SNMP(Simple Network Management Protocol)

SNMPv3 的代價

Luna HSM 支援 SNMPv3 AuthPriv 模式(SHA 認證 + AES 加密),這是正確的安全配置。但 SNMP 收集並不便宜:

  • 一次完整的設備狀態收集需要 60–90 秒(取決於網路延遲與分區數量)
  • Prometheus 預設的 scrape 週期是 60 秒

這造成了一個結構性問題:生產者(SNMP 收集)比消費者(Prometheus scrape)慢。如果每次 scrape 都直接觸發 SNMP 收集,輕則 timeout,重則整個監控鏈斷掉。


整體架構

解決方案的核心思想:把 SNMP 收集從 scrape 路徑分離

Mermaid diagram

Exporter 啟動後,在背景持續輪詢 Luna HSM,收集的資料放入三層快取。Prometheus 每 60 秒來 scrape 時,直接從快取讀取,不觸發 SNMP 收集

9 個 Collector

Exporter 以 Rust trait 物件實作 9 個 Collector,每個負責一組 MIB OID:

Collector對應 MIB / 主要指標估計 OID 數量
HsmStatsCollectorCHRYSALIS-UTSP-MIB::citsHsm — 操作請求、錯誤計數~4
NtlsCollectorCHRYSALIS-UTSP-MIB::citsNtls — 連線客戶端數、憑證到期~6
HsmTableCollectorSAFENET-HSM-MIB hsmTable — 韌體版本、序號~20
LicenseTableCollectorSAFENET-HSM-MIB hsmLicenseTable — 授權功能狀態~15
PolicyTableCollectorSAFENET-HSM-MIB hsmPolicyTable — HSM 政策設定~500
PartitionPolicyCollectorSAFENET-HSM-MIB hsmPartitionPolicyTable — 分區政策~3000
ClientRegistrationCollectorSAFENET-HSM-MIB hsmClientRegistrationTable~50
ClientSummaryCollectorPartition Table + Client-Partition Assignment(整合)~200
ApplianceCollectorSAFENET-APPLIANCE-MIB — 設備軟體版本~5

PartitionPolicyCollector 因為每個分區都有完整的策略 OID 樹,在多分區環境下可達 3000+ OIDs,必須使用 SNMP GETBULK Walk 批次收集(每次最多回傳 50 行,大幅減少 round-trip 次數)。

ClientSummaryCollector 是設計上的一個優化點:它一次走 PartitionTable 和 ClientPartitionAssignment 兩張表,同時輸出 partition 原始 metrics 和 Client-Partition 聚合 metrics,避免重複走同一張慢表浪費網路 I/O。


三層快取設計

三層快取是這個系統的核心創新。讓我們看看它為什麼需要三層,而不是一層。

Mermaid diagram
層級TTL設計理由
Hot5 秒防止同一個 60 秒 scrape 週期內重複觸發多次收集
Warm55 秒每次 SNMP 收集完成後重設;即使下輪收集超過 60 秒,Warm 依然有效,Prometheus scrape 不會看到空快取
Cold無限SNMP 連線失敗時的最後保險,確保 scrape 永遠有資料可回傳

為什麼不用單層 TTL?

假設使用單層 60 秒 TTL:當 SNMP 收集恰好需要 62 秒,TTL 就會在收集完成前到期,下一次 scrape 看到空快取,需要等待新一輪收集完成。這個空窗期在生產環境中表現為 Grafana 的 No Data 時段。

三層設計透過 Warm 層和 Cold 層共同消除這個空窗:Warm(TTL 55 秒)先填補第 60–55 秒的缺口,之後 Cold(無 TTL)作為最後保險,確保 scrape 永遠有資料可回傳。


Rust FFI 包裝 Net-SNMP

Luna HSM 官方建議的 SNMP library 是 Net-SNMP(C library)。Rust 生態系沒有成熟的純 Rust SNMPv3 實作,因此我們用 FFI 包裝 Net-SNMP C library。

三層 Crate 結構

netsnmp-sys 是 bindgen 生成的 unsafe C bindings(不手動修改)

netsnmp 是安全的 Rust wrapper crate

rust-exporter 是使用 netsnmp 的應用層(lib 名稱:luna_snmp

netsnmp-sys:bindgen 自動生成

build.rs 呼叫 bindgen 掃描 Net-SNMP 標頭檔,生成 unsafe Rust bindings:

// 由 bindgen 自動生成,不要手動修改
extern "C" {
    pub fn snmp_open(
        session: *mut snmp_session
    ) -> *mut snmp_session;

    pub fn snmp_close(
        session: *mut snmp_session
    ) -> ::std::os::raw::c_int;
}

netsnmp:安全包裝層

Session struct 包裝 C session 指標,透過 RAII Drop 確保資源釋放。每輪收集開始前,manager 會呼叫 reconnect() 重新建立 session,防止長時間運行導致 UDP socket 狀態累積問題

pub struct Session {
    ss:  *mut netsnmp_sys::netsnmp_session,
    cfg: SessionConfig,
}

// SAFETY: libnetsnmp session 在單一 OS thread 上操作是 safe 的。
// Session 不實作 Sync,只實作 Send,確保同時只有一個 thread 存取。
unsafe impl Send for Session {}

impl Session {
    pub fn open(cfg: &SessionConfig) -> Result<Self, SnmpError> { ... }

    /// 關閉並重新建立 session(修復 UDP socket 累積問題)
    pub fn reconnect(&mut self) -> Result<(), SnmpError> {
        if !self.ss.is_null() {
            unsafe { snmp_close(self.ss); }
            self.ss = ptr::null_mut();
        }
        let ss = unsafe { open_v3_session(&self.cfg)? };
        self.ss = ss;
        Ok(())
    }

    pub fn bulk_walk(&self, root_oid: &str) -> Result<Vec<OidValue>, SnmpError> { ... }
}

impl Drop for Session {
    fn drop(&mut self) {
        // SNMP session 在 Rust 物件銷毀時自動釋放
        if !self.ss.is_null() {
            unsafe { netsnmp_sys::snmp_close(self.ss); }
        }
    }
}

CollectorManager:Panic Recovery 設計

生產環境中,FFI 呼叫偶爾可能因 C library 內部狀態異常而 panic。Manager 使用 catch_unwind 包裝收集迴圈,自動重啟而不是靜默停止:

pub fn start(cfg: SessionConfig, cache: Cache, interval: Duration) -> Self {
    let handle = thread::spawn(move || {
        loop {
            // Panic recovery:若 FFI 呼叫意外 panic,等 5s 後自動重啟
            let result = std::panic::catch_unwind(
                std::panic::AssertUnwindSafe(|| {
                    collection_loop(cfg.clone(), cache.clone(), interval);
                })
            );
            if let Err(e) = result {
                eprintln!("[manager] panicked: {e:?}; restarting in 5s");
                thread::sleep(Duration::from_secs(5));
            }
        }
    });
    CollectorManager { _handle: handle }
}

Luna 特有的 OID 字串解碼

Luna 的 client 名稱使用非標準編碼:OID 值不是直接的 UTF-8 字串,而是 [length, char1, char2, ...] 格式。

// oid/decoder.rs
pub fn decode_oid_string(parts: &[&str]) -> String {
    if parts.is_empty() {
        return String::new();
    }
    let length: usize = match parts[0].parse() {
        Ok(n) => n,
        Err(_) => return String::new(),
    };
    if parts.len() < length + 1 {
        return String::new();
    }
    let mut result = String::with_capacity(length);
    for s in &parts[1..=length] {
        match s.parse::<u8>() {
            Ok(b) if b.is_ascii_graphic() || b == b' ' => result.push(b as char),
            _ => return String::new(),  // 非可印字元視為解碼失敗
        }
    }
    result
}

沒有這個解碼步驟,ClientRegistrationCollectorClientSummaryCollector 收到的客戶端名稱會是亂碼。這是 Luna HSM 文件中不太顯眼的細節,Python 版本當初也遇到過這個問題。

/health 端點:三狀態健康模型

Rust exporter 的健康端點根據快取年齡回傳三種狀態:

狀態條件HTTP code
initializing從未成功收集(age < 0)200
healthy快取資料在正常範圍內200
stale超過 2 倍 collection interval 未更新503
GET /health
→ {"status":"healthy","cache_age_seconds":42.3}

GET /health(資料過舊)
→ HTTP 503
→ {"status":"stale","cache_age_seconds":180.5}

stale 回傳 503 讓 Kubernetes liveness probe 或外部監控能自動偵測 exporter 失能,而不只是靜默地提供陳舊資料。


Python / Go / Rust 三語言比較

漸進式重寫的歷史

這個專案的演進是典型的「漸進式重寫」:

  1. Python:快速驗證概念,2 週內有 working prototype,但 GIL 限制並行能力,型別問題讓維護困難
  2. Go:改善並發模型,靜態型別讓重構更安全,但在 FFI boundary 出現 session lifetime bug
  3. Rust:borrow checker 從根本解決 session lifetime 問題,且效能顯著提升

技術特性比較

項目PythonGoRust
SNMP 函式庫pysnmp / easysnmpgosnmpnetsnmp-sys(FFI)
型別系統動態型別靜態型別靜態型別 + borrow checker
錯誤處理Exceptionerror returnResult<T, E>
並行模型asyncio(受 GIL 限制)goroutine同步 + OS thread
HTTP serverFlask/aiohttpnet/httptiny_http(無 async runtime)
二進位大小—(需 runtime)~8 MB~4 MB(strip 後)
部署複雜度高(Python 環境管理)低(單一 binary)低(單一 binary)
開發迭代速度最快較慢(編譯器嚴格)
FFI 安全性無靜態保證部分保證borrow checker 強制

Benchmark:資料處理層

以下是各項關鍵操作的實測效能數據(合成資料,不含 SNMP 網路 I/O):

Benchmark說明PythonGoRust
OID 字串解碼['3','75','77','83'] → 'KMS'290 ns23.0 ns(12.6x14.4 ns(20.2x
Client-Partition OID 解析完整 OID → (client, serial)1.66 µs311 ns(5.3x223 ns(7.4x
Partition 序號提取OID instance 提取1.07 µs245 ns(4.4x206 ns(5.2x
OID → metric 名稱查表7 個 OID HashMap 查詢1.96 µs74.5 ns(26.3x143 ns(13.7x
SNMP 值類型格式化7 種 SNMP vtype2.70 µs498 ns(5.4x115.5 ns(23.3x
Enum 映射(NTLS 狀態)6 個值查表869 ns928 ns(慢)83.3 ns(10.4x
1000 行端對端處理完整 parse pipeline603.5 µs43.6 µs(13.8x
  • Rust 平均加速比(相對 Python,資料處理層):~13.4x
  • Go 平均加速比(相對 Python,資料處理層):~9.2x
  • OID 表格查表(HashMap)Go 比 Rust 快(26.3x vs 13.7x)——HashMap 實作差異所致
  • 端對端處理 1000 行:Rust 43.6 µs vs Go 603.5 µs(13.8x

注意: 這是純資料處理層的 benchmark,端對端收集時間主要受 SNMP 網路延遲決定,三種語言在這一層差距不大(~100–130 ms/request)。

為什麼最終選 Rust,不是 Go?

效能不是決定性因素。真正的問題是 session lifetime bug

Go 版本在高並行負載下,goroutine 有機率在 session 被 snmp_close 後繼續使用已釋放的 C 指標,導致不定時的 segfault。這個 bug 難以在測試環境重現,更難以系統性地修復。

Rust 的 borrow checker 在編譯時就防止了這類問題:持有 Session 引用的程式碼,編譯器可以靜態保證其生命週期不超過 Session 物件本身。這個 bug 不是被修復,而是無法被寫出


部署:Container 化

Rust exporter 支援 Podman/Docker 容器部署,提供 podman-compose.yaml 供多 HSM 場景使用:

services:
  snmp-exporter-hsm-01:
    image: luna-rust-snmp-exporter:latest
    ports:
      - "9116:9116"   # HSM-01 exporter
    environment:
      - SNMP_TARGET=${HSM_01_IP}
      - SNMP_AUTH_PASSWORD=${SNMP_AUTH_PASSWORD}
      - SNMP_PRIV_PASSWORD=${SNMP_PRIV_PASSWORD}
      - COLLECTION_INTERVAL_SECS=60

  snmp-exporter-hsm-02:
    image: luna-rust-snmp-exporter:latest
    ports:
      - "9117:9116"   # HSM-02 exporter(不同 host port)
    environment:
      - SNMP_TARGET=${HSM_02_IP}
      ...

每個 HSM 對應一個 container 實例,各自維護獨立的 SNMP session 和三層快取。Grafana 可透過 job label 區分不同 HSM 的 metrics。


結語

三層快取是「生產者比消費者慢」這個通用問題的一個解法,適用於任何需要解耦收集與讀取週期的場景,不限於 SNMP 監控。

Rust FFI 的學習曲線確實陡峭,但在 C library boundary 的正確性保證上,borrow checker 提供了其他語言無法比擬的靜態保證。如果你的系統有 unsafe C 互動,Rust 不只是效能工具,更是正確性工具

Panic recovery、session reconnect、三狀態健康端點——這些看起來像是「防禦性過設計」的細節,在生產環境跑了幾個月後,會感謝當初把它們加上去。

如果你也在監控 Luna HSM 或其他 SNMP-only 設備,歡迎透過聯絡頁面交流。