透過快取與整合降低 Secrets Manager 成本
託管的機密管理器按儲存的機密數量與 API 呼叫次數計費。單純的擷取方式會讓這兩項費用暴增。本文說明快取與整合如何降低成本。
一個受管理的密碼管理器會根據兩個方面向您收費:您儲存的密碼數量,以及您為了讀取它們而進行的 API 呼叫次數。驚人帳單的大部分來自第二個方面,而其中大部分是可以避免的。如果一個行程在每次請求時,或在短生命週期的 worker 每次冷啟動時都去擷取其密碼,那麼呼叫次數就會隨著流量增長,而不是保持平穩。在啟動時擷取一次每個密碼,將其值保存在行程記憶體中,並將相關的密碼合併到單一文件中,這樣同樣的保護措施,其運行成本僅為一小部分。這篇文章展示了會導致金錢浪費的存取模式,以及能阻止這種浪費的兩種改變。
受控密鑰管理器如何計費
受控密鑰管理器的定價方式類似一個小型的計量資料庫,而非環境變數。有兩項因素會影響帳單費用。
第一項是儲存空間。您需要為您所保存的每一個獨立密鑰支付月費,費用通常會根據其在計費週期內的存在時間按比例計算。無論是否有人讀取,十個密鑰的儲存費用就是一個密鑰的十倍。
第二項是存取。每次讀取或列出密鑰的 API 呼叫都會被計量,通常以一萬次呼叫為一個區塊來計算。無論其值是否變更,也無論您前一刻是否才讀取過,只要有呼叫就會計費。服務供應商無從得知您過去一千次的讀取都回傳了相同的位元組。它會對每一次呼叫收費。
單獨來看,這兩個收費面向都不昂貴。讀取一次單一密鑰的費用近乎免費。帳單費用會增加,是因為天真的存取模式將少數幾個密鑰變成了數百萬次的呼叫,並將少數幾個邏輯值變成了數十個儲存的項目。這兩者都是模式問題,也都有直接的解決方法。
成本洩漏之處
三種習慣造成了大部分超額的密鑰帳單,而且在撰寫時,這三種習慣看起來都很合理。
第一種是在請求路徑中擷取。一個處理程式需要資料庫密碼,因此它在請求到達時讀取密鑰。這看起來乾淨且具局部性,但它將您的呼叫次數直接與您的流量綁定。每秒一千次請求時,每次請求讀取一次密鑰,就相當於每秒一千次計費呼叫,對於一個從未改變的值,這相當於一個月近二十五億次呼叫。
第二種是在每個短生命週期的程序冷啟動時擷取。無伺服器函式和作業執行器會啟動、執行一個工作單元,然後退出。如果每個實例在啟動時都讀取其密鑰,一個擴展到數千個並行實例並不斷回收它們的繁忙函式,會將啟動時的讀取轉變為穩定且高頻率的呼叫率。這個值是穩定的,但程序的生命週期很短,所以讀取會無休止地重複。
第三種是將一個邏輯設定分割成許多儲存的密鑰。一個服務需要資料庫 URL、API 權杖、簽署金鑰和 webhook URL,所以您儲存了四個密鑰。將其乘以幾個服務和幾個環境,儲存的密鑰數量就會攀升至數十個。現在您需要為每一個密鑰支付儲存費用,而任何讀取完整集合的程式碼都會為每個密鑰進行一次呼叫。
這些都不是安全性漏洞。它們是存取模式的缺陷,而您可以在不削弱任何安全性的情況下修復它們。
在啟動時擷取一次,然後在記憶體中快取
核心作法是將密鑰視為任何不常變動的昂貴值來處理:讀取一次、保留它,並重複使用。在啟動期間載入程序所需的所有密鑰,將值儲存在程序記憶體中,並讓熱路徑從記憶體而非網路讀取。請求處理器完全不會呼叫密鑰管理器。它讀取一個欄位。
這將存取軸線從「每個請求一次呼叫」縮減為「每個程序啟動時每個密鑰一次呼叫」。一個啟動一次並運行數天的伺服器,在開機時進行幾次讀取,然後在其餘的生命週期中,無論處理多少流量,讀取次數都為零。
以下是 Go 中的模式。一個小型的設定結構體持有已解析的值,一個載入器會將每個密鑰擷取一次,而程式的其餘部分則透過參考取得該結構體並讀取其欄位。
// Secrets holds resolved values in process memory. Fetched once at startup,
// read from memory on every request after that.
type Secrets struct {
DatabaseURL string
APIToken string
SigningKey []byte
}
// Load fetches every secret the process needs, one call each, at startup.
// If any required secret is missing, the process fails fast instead of
// discovering the gap on the first request.
func Load(ctx context.Context, sm SecretClient) (*Secrets, error) {
dbURL, err := sm.Get(ctx, "database-url")
if err != nil {
return nil, fmt.Errorf("load database-url: %w", err)
}
token, err := sm.Get(ctx, "api-token")
if err != nil {
return nil, fmt.Errorf("load api-token: %w", err)
}
key, err := sm.Get(ctx, "signing-key")
if err != nil {
return nil, fmt.Errorf("load signing-key: %w", err)
}
return &Secrets{
DatabaseURL: dbURL,
APIToken: token,
SigningKey: []byte(key),
}, nil
}
func main() {
ctx := context.Background()
secrets, err := Load(ctx, newSecretClient())
if err != nil {
log.Fatalf("startup: %v", err)
}
// Handlers close over secrets and read fields. No network call per request.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
use(secrets.APIToken)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
有兩個特性使其既安全又低成本。啟動時載入意味著遺失或格式錯誤的密鑰會在部署時立即讓程序崩潰,而不是在一小時後才讓第一個使用者請求失敗。而且因為該值只存在於記憶體中,它絕不接觸磁碟,並在程序結束時消失。
對於短生命週期的 worker,同樣的概念適用,但需做一項調整。一個每個實例處理多次調用的函式,應該在處理函式執行前的初始化程式碼中,每個實例載入一次密鑰,而不是在處理函式主體內。大多數函式執行環境會在多次調用之間保持一個暖實例的存活,因此每個實例的載入成本會分攤到該實例服務的每一次調用中。讀取次數從每次調用一次,降至每個實例生命週期一次。
將相關的密鑰整合到一份文件中
當您停止將一份設定分散到多個密鑰時,儲存軸與部分的存取軸會一起縮減。密鑰管理器儲存的是不透明的位元組 (opaque bytes)。沒有什麼能阻止這些位元組成為一個一次持有數個相關值的 JSON 物件。
與其使用四個分別名為 database-url、api-token、signing-key 和 webhook-url 的密鑰,不如儲存一個名為 service-config 的密鑰,其值為一份小型的 JSON 文件。
{
"database_url": "postgres://user:pass@host:5432/app",
"api_token": "tok_live_abc123",
"signing_key": "base64keymaterial",
"webhook_url": "https://hooks.example.com/abc"
}
載入器現在進行一次呼叫,解析 JSON,並填入相同的結構。
func LoadBundle(ctx context.Context, sm SecretClient) (*Secrets, error) {
raw, err := sm.Get(ctx, "service-config")
if err != nil {
return nil, fmt.Errorf("load service-config: %w", err)
}
var b struct {
DatabaseURL string `json:"database_url"`
APIToken string `json:"api_token"`
SigningKey string `json:"signing_key"`
}
if err := json.Unmarshal([]byte(raw), &b); err != nil {
return nil, fmt.Errorf("parse service-config: %w", err)
}
return &Secrets{
DatabaseURL: b.DatabaseURL,
APIToken: b.APIToken,
SigningKey: []byte(b.SigningKey),
}, nil
}
這將儲存的密鑰數量從四個減少到一個,因此該服務的儲存項目大約減少了四分之三。它也將啟動時的讀取次數從四次呼叫減少到一次,這對於每次實例化都會讀取的短生命週期 worker 來說最為重要。這兩項變更可以疊加。整合降低了每次啟動的成本,而快取則讓每次啟動變得不那麼頻繁。
按照信任邊界和輪換頻率進行分組,而不是按照恰好存在多少個金鑰來分組。屬於同一服務、可由同一組主體讀取、且傾向於一起變更的值,應歸於同一份文件中。具有真正不同存取規則或獨立輪換排程的值則保持分開,因為合併它們會迫使採用更粗略的授權或更笨拙的輪換。整合的重點在於那些自然而然會一起傳遞的值。
計費維度,並列比較
下表具體說明了成本漏洞與修復方法。它比較了一個持有四個邏輯值且每秒處理一千個請求的服務,其天真模式(四個獨立密鑰,每次請求讀取一次)與修復後模式(一個整合密鑰,啟動時擷取一次)的差異。
| 維度 | 天真模式 | 快取與整合 |
|---|---|---|
| 儲存的密鑰 | 4 | 1 |
| 每次請求的讀取次數 | 4 | 0 |
| 每次程序啟動的讀取次數 | 4 | 1 |
| 每秒 1000 次請求下的計量呼叫 | 每月約 100 億次 | 每次部署僅數次 |
| 儲存項目 | 4 個單位 | 1 個單位 |
| 洩漏值的影響範圍 | 一個密鑰 | 一份文件 |
存取次數那列是費用的關鍵。將讀取操作移出請求路徑並移至啟動階段,會將呼叫次數從流量的函數變為部署頻率的函數。流量可以達到每小時數百萬次請求。部署每天只有幾次。這就是巨額帳單與四捨五入誤差之間的全部區別。
當值被快取時處理輪替
將密鑰快取在記憶體中會引發一個合理的反對意見。如果你輪替一個密鑰,快取了舊值的行程會繼續使用它,直到有東西使其重新載入。快取是用新鮮度換取成本,而輪替正是這種權衡體現之處。有三種方法可以讓輪替正常運作,而且你可以結合使用它們。
最簡單的方法是重新啟動。大多數的輪替工作流程已經會重新部署或回收受影響的服務,而根據定義,一個新的行程在啟動時會載入新的值。如果你的輪替運行手冊以滾動式重新啟動結束,那麼快取的密鑰就能免費刷新,你也不需要做任何其他事情。
第二種方法是有界限的快取生命週期。不要永久持有一個值,而是附加一個存活時間,並在它到期時重新載入。一小時的生命週期意味著一個被輪替的密鑰會在一小時內被取用,無需任何重新啟動,其代價是每個行程每小時對每個密鑰多一次額外的讀取。與每次請求的讀取相比,這仍然是極小的呼叫次數,並且它為值的過時程度設定了上限。應根據輪替必須傳播的速度來選擇生命週期,而不是出於習慣。
第三種方法是明確的信號。讓輪替步驟透過訊息通道、一個設定重新載入的端點,或是一個行程會攔截以重新執行其載入器的 POSIX 信號,來通知正在運行的行程重新載入。這提供了近乎即時的傳播且無需輪詢,代價是需要建構通知路徑。當輪替必須在幾秒鐘而不是幾分鐘內生效時,使用此方法。
對大多數服務來說,前兩種方法就足夠了。輪替不頻繁且重新啟動是例行公事,因此由重新啟動驅動的刷新加上適中的存活時間,就能涵蓋常見情況,而無需建立通知系統。
密鑰管理器中不該存放什麼
部分費用來自於儲存非密鑰的內容。密鑰管理器是用於存放一旦洩露便會造成危害的值:密碼、私鑰、權杖、帶有嵌入式憑證的連線字串。它並非通用的設定儲存庫,將其當作設定儲存庫使用會增加儲存的密鑰數量和讀取次數。
非敏感的設定應存放在純環境變數或在建置時寫入的設定檔中。功能旗標、區域名稱、頁面大小限制、公開的基礎 URL、日誌層級:這些都不需要保護,也不應佔用計費的密鑰欄位。將它們移至環境變數,密鑰管理器就只存放真正需要保護的內容,這樣可以縮減儲存空間並移除您原本需要付費的讀取次數。
一個有用的測試方法是:如果某個值出現在建置日誌或堆疊追蹤中會構成安全事件,那它就是密鑰。如果只是顯得不整潔,那它就是設定。將每種內容存放在為其定價的地方。
坦率說明權衡取捨
這些技術改變的是成本,而非安全性,但在採用它們之前,確實有一些值得一提的權衡取捨。
記憶體內快取意味著每個程序在其生命週期內都會持有自己的一份密鑰副本,而輪替後的值在快取過期、程序重啟或收到重新載入信號之前,都是不可見的。對於具有長生命週期程序和不頻繁輪替的服務來說,這種延遲是無害的。在一個每隔幾分鐘就輪替密鑰並期望立即傳播到各處的系統上,應審慎規劃重新載入的路徑,而不是依賴重啟。
整合到一個 JSON 文件中意味著您會失去按值輪替的粒度。輪替任何一個欄位都意味著要寫入整個文件的新版本,而每個讀取者都會同時取得所有欄位。如果兩個值確實需要獨立的輪替排程或不同的存取授權,請將它們分開。整合適用於共享信任邊界和輪替節奏的值,這涵蓋了大部分的值,但並非全部。
其背後的想法很簡單。受控管的密鑰管理器是存放敏感位元組的安全儲存空間,而不是一個讓您在每次請求時都查詢的低延遲儲存。一旦您這樣對待它,即:不常讀取、將相關的東西分組在一起、並將非密鑰的內容排除在外,安全性會維持在原來的水平,而帳單則幾乎消失。