基础设施

通过缓存和整合降低 Secrets Manager 成本

托管的密钥管理器按存储的密钥和 API 调用计费。简单的获取方式会使这两项成本激增。下面介绍缓存和整合如何降低成本。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

托管的密钥管理器从两个维度向您收费:您存储的密钥数量,以及您为读取密钥而发出的 API 调用次数。意外账单中的大部分费用来自第二个维度,而这其中大部分是可以避免的。如果一个进程在每次请求时都获取其密钥,或者在每个短生命周期工作进程的每次冷启动时都获取,那么调用次数就会随着流量增长,而不是保持不变。在启动时获取一次每个密钥,将值保存在进程内存中,并将相关的密钥合并到单个文档中,这样,同样的保护措施,其运行成本仅为原来的一小部分。这篇文章展示了导致金钱泄露的访问模式,以及阻止泄露的两种改变。

托管的密钥管理器如何向你收费

托管的密钥管理器的定价方式类似于一个小型计量数据库,而不是环境变量。有两项因素决定了账单费用。

第一项是存储。你需要为你保存的每个独立密钥支付月费,该费用通常根据其在计费周期内的存在时长按比例计算。无论是否有人读取它们,十个密钥的存储费用是一个密钥的十倍。

第二项是访问。每个读取或列出密钥的 API 调用都会被计量,通常以一万次调用为单位进行计数。无论值是否已更改,也无论你刚才是否读取过它,一次调用就是一次调用。服务提供商不知道你最近的一千次读取都返回了相同的字节。它会对每一次调用收费。

单独来看,这两个维度本身都不昂贵。读取一次单个密钥的成本近乎于零。账单费用之所以会增长,是因为幼稚的访问模式将少数几个密钥变成了数百万次调用,并将少数几个逻辑值变成了数十个存储条目。这两种情况都是模式问题,也都有直接的解决方法。

成本泄露在何处

三种习惯导致了大多数超额的密钥账单,并且在编写它们时,这三种习惯都感觉很合理。

第一种是在请求路径内获取。处理程序需要数据库密码,因此它在请求到达时读取密钥。这看起来干净且局部,但它将你的调用次数直接与流量挂钩。在每秒一千次请求的情况下,每次请求读取一次密钥就相当于每秒一千次计费调用,对于一个从未改变的值来说,这相当于每月近 25 亿次调用。

第二种是在短暂进程的每次冷启动时获取。无服务器函数和作业运行器启动,完成一个工作单元,然后退出。如果每个实例在启动时都读取其密钥,一个扩展到数千个并发实例并不断回收它们的繁忙函数,会将启动时的读取操作转变为稳定且高频的调用率。值是稳定的,但进程的生命周期很短,因此读取操作会无休止地重复。

第三种是将一个逻辑配置拆分成许多存储的密钥。一个服务需要数据库 URL、API 令牌、签名密钥和 webhook URL,因此你存储了四个密钥。将其乘以几个服务和几个环境,存储的密钥数量就会攀升至几十个。现在,你需要为它们中的每一个支付存储费用,并且任何读取完整集合的代码都会为每个密钥进行一次调用。

这些都不是安全漏洞。它们是访问模式上的缺陷,你可以在不削弱任何安全性的情况下修复它们。

启动时获取一次,然后缓存在内存中

核心做法是像对待任何不常变化的昂贵值一样对待密钥:读取一次,保存下来,然后复用。在启动期间加载进程需要的所有密钥,将这些值存储在进程内存中,并让热路径从内存而不是网络中读取。请求处理程序根本不调用密钥管理器。它读取一个字段。

这将访问轴从“每个请求调用一次”缩减为“每个进程启动时每个密钥调用一次”。一个启动一次并运行数天的服务器,在启动时进行几次读取,然后在其剩余生命周期内读取次数为零,无论它处理多少流量。

以下是 Go 中的模式。一个小的 config 结构体持有解析后的值,一个 loader 获取每个密钥一次,程序的其余部分通过引用获取该结构体并读取字段。

// 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,同样的想法也适用,但需要做一项调整。每个实例处理多次调用的函数,应该在处理程序(handler)之前运行的初始化代码中每个实例加载一次密钥,而不是在处理程序主体(handler body)内部。大多数函数运行时会在多次调用之间保持一个“热”实例的活动状态,因此,每次实例的加载成本会被分摊到该实例所服务的每一次调用中。读取次数从每次调用一次下降到每个实例生命周期一次。

将相关密钥整合到一个文档中

当你停止将一个配置拆分到多个密钥中时,存储轴和部分访问轴会一起收缩。密钥管理器存储不透明的字节。没有什么能阻止这些字节成为一个可一次性持有多个相关值的 JSON 对象。

与其创建名为 database-urlapi-tokensigning-keywebhook-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 req/s 下的计量调用次数 每月约 100 亿次 每次部署几次
存储项 4 个单元 1 个单元
值泄漏的爆炸半径 一个密钥 一个文档

访问次数这一行是关键所在。将读取操作从请求路径移至启动过程,会使调用次数从与流量相关的函数变为与部署频率相关的函数。流量可能达到每小时数百万次请求。而部署每天只有几次。这就是巨额账单和四舍五入误差之间的全部区别。

当值被缓存时如何处理轮换

在内存中缓存密钥会引发一个合理的反对意见。如果你轮换了一个密钥,缓存了旧值的进程会一直使用它,直到有东西使其重新加载。缓存用新鲜度换取成本,而轮换正是这种权衡体现之处。有三种方法可以保持轮换正常工作,并且你可以组合使用它们。

最简单的方法是重启。大多数轮换工作流已经会重新部署或回收受影响的服务,而根据定义,一个新进程在启动时会加载新值。如果你的轮换操作手册以滚动重启结束,那么缓存的密钥就能免费刷新,你不需要做任何其他事情。

第二种方法是有限的缓存生命周期。不要永久持有某个值,而是为其附加一个生存时间,并在其过期时重新加载。一小时的生命周期意味着轮换后的密钥将在无需任何重启的情况下于一小时内被获取,其代价是每个进程每小时对每个密钥多进行一次读取。与每次请求都读取相比,这仍然是极小的调用次数,并且它为值的陈旧程度设定了上限。应根据轮换必须多快传播来选择生命周期,而不是凭习惯。

第三种方法是显式信号。让轮换步骤通过消息通道、配置重载端点或进程捕获以重新运行其加载器的 POSIX 信号,来通知正在运行的进程进行重载。这可以在不轮询的情况下实现近乎即时的传播,代价是需要搭建通知路径。当轮换必须在几秒钟而不是几分钟内生效时,使用此方法。

对大多数服务来说,前两种方法就足够了。轮换不频繁,而重启是常规操作,因此由重启驱动的刷新加上一个适度的生存时间,就可以在不构建通知系统的情况下覆盖常见情况。

不应存放在密钥管理器中的内容

部分费用来自于存储了非密钥的内容。密钥管理器用于存放一旦泄露便会造成危害的值:密码、私钥、令牌、包含嵌入式凭据的连接字符串。它不是一个通用的配置存储,将其用作配置存储会增加存储的密钥数量和读取次数。

非敏感配置应存放在普通的环境变量中,或是在构建时固化的配置文件里。功能标志、区域名称、页面大小限制、公共基础 URL、日志级别:这些都不需要保护,也都不应占用按量计费的密钥槽位。将它们移至环境变量,密钥管理器就只存放真正需要保护的内容,这会减少存储空间并移除你一直在付费的读取操作。

一个有用的测试方法是:如果一个值出现在构建日志或堆栈跟踪中会构成安全事件,那么它就是密钥。如果仅仅是显得不整洁,那么它就是配置。将每种内容存放在为其定价的地方。

权衡取舍,直言不讳

这些技术改变的是成本,而非安全性,但在采用它们之前,确实有一些值得一提的权衡取舍。

内存缓存意味着每个进程在其生命周期内都持有自己的密钥副本,并且在缓存过期、进程重启或收到重载信号之前,轮换后的值是不可见的。对于具有长生命周期进程和不频繁轮换的服务来说,这种延迟是无害的。在每隔几分钟就轮换密钥并期望即时传播到各处的系统上,应仔细规划重载路径,而不是依赖于重启。

整合到一个 JSON 文档中意味着你失去了按值轮换的粒度。轮换任何字段都意味着要写入整个文档的新版本,并且每个读取者都会同时获取所有字段。如果两个值确实需要独立的轮换计划或不同的访问授权,请将它们分开。整合适用于共享信任边界和轮换节奏的值,大多数值都属于这种情况,但并非全部。

其基本思想很简单。托管的密钥管理器是用于存储敏感字节的安全存储,而不是你在每次请求时都要查询的低延迟存储。一旦你这样对待它,即减少读取次数、将属于同一组的内容组合在一起、并将非密钥内容排除在外,安全性将保持原样,而账单则基本上会消失。