インフラ

キャッシュと集約によるSecrets Managerのコスト削減

マネージドシークレットマネージャーは、保存されたシークレットごと、およびAPIコールごとに課金されます。ナイーブなフェッチングは、その両方のコストを急増させます。ここでは、キャッシングと統合によってコストを削減する方法を紹介します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

マネージドシークレットマネージャーは、保存するシークレットの数と、それらを読み取るためのAPIコールの数という2つの軸で課金します。驚くような請求のほとんどは2番目の軸に起因し、その大部分は回避可能です。プロセスがリクエストごと、あるいは短命なワーカーのコールドスタートごとにシークレットを取得すると、コール数は一定に保たれず、トラフィックに応じて増加します。各シークレットを起動時に一度だけ取得し、値をプロセスメモリに保持し、関連するシークレットを単一のドキュメントに統合すれば、同じ保護をほんの一部のコストで運用できます。この投稿では、コストの無駄遣いにつながるアクセスパターンと、その無駄遣いを止める2つの変更点について示します。

マネージドシークレットマネージャーの課金方法

マネージドシークレットマネージャーは、環境変数とは異なり、小さな従量課金制データベースのように価格設定されています。請求額は2つの要素によって決まります。

1つ目はストレージです。保管する個別のシークレットごとに月額料金を支払います。多くの場合、その料金は請求期間中に存在した期間に応じて日割り計算されます。誰かが読み取るかどうかにかかわらず、10個のシークレットは1つのシークレットのストレージ料金の10倍のコストがかかります。

2つ目はアクセスです。シークレットを読み取る、または一覧表示するすべてのAPIコールが測定され、通常は1万コール単位でカウントされます。値が変更されたかどうか、また少し前に読み取ったかどうかに関係なく、コールはコールとして扱われます。プロバイダーは、直近の1000回の読み取りがすべて同じバイトを返したことを認識しません。プロバイダーはそれぞれに課金します。

どちらの軸も、それ自体では高価ではありません。1つのシークレットを1回読み取るだけなら、ほぼ無料です。請求額が増えるのは、単純なアクセスパターンによって、一握りのシークレットが数百万回のコールに、そして一握りの論理値が数十の保存エントリに変わってしまうためです。これらはどちらもパターンの問題であり、どちらにも直接的な解決策があります。

コスト漏洩の原因

シークレットに関する法外な請求のほとんどは3つの習慣に起因しますが、そのいずれもコードを書く際には合理的に感じられるものです。

1つ目は、リクエスト処理パス内でのフェッチです。ハンドラーがデータベースのパスワードを必要とするため、リクエストが到着したときにシークレットを読み取ります。これはクリーンでローカルに見えますが、呼び出し回数がトラフィックに直接結びつきます。1秒あたり1000リクエストの場合、リクエストごとに1回のシークレット読み取りは、1秒あたり1000回の課金対象の呼び出しとなり、これは、一度も変更されない値に対して、月あたり25億回近くの呼び出しに相当します。

2つ目は、短命なプロセスのコールドスタートごとにフェッチすることです。サーバーレス関数やジョブランナーは、起動し、1つの作業単位をこなし、終了します。各インスタンスが起動時にシークレットを読み取る場合、数千の同時実行インスタンスにスケールし、絶えず再利用されるビジーな関数は、起動時の読み取りを安定的で高い呼び出しレートに変えてしまいます。値は安定していますが、プロセスの寿命が短いため、読み取りは際限なく繰り返されます。

3つ目は、1つの論理的な設定を多くの保存されたシークレットに分割することです。あるサービスがデータベースのURL、APIトークン、署名キー、WebhookのURLを必要とするため、4つのシークレットを保存します。これをいくつかのサービスといくつかの環境で掛け合わせると、保存されるシークレットの数は数十にまで膨れ上がります。すると、その1つ1つにストレージ料金を支払うことになり、完全なセットを読み取るコードはシークレットごとに1回の呼び出しを行うことになります。

これらのいずれもセキュリティ上の欠陥ではありません。これらはアクセスパターンの欠陥であり、何も弱体化させることなく修正できます。

起動時に一度フェッチし、メモリにキャッシュする

中核となる動きは、シークレットを、めったに変更されない高コストな値を扱うのと同じように扱うことです。一度読み込み、保持し、再利用します。起動時にプロセスが必要とするすべてのシークレットをロードし、値をプロセスメモリに保存し、ホットパスにはネットワークの代わりにメモリから読み込ませます。リクエストハンドラは、シークレットマネージャーをまったく呼び出しません。フィールドを読み取ります。

これにより、呼び出し回数が「リクエストごとに1回」から「プロセスの起動ごとにシークレットあたり1回」に削減されます。一度起動して何日間も稼働するサーバーは、起動時に数回の読み取りを行った後は、どれだけ多くのトラフィックを処理するかにかかわらず、そのライフタイムが尽きるまで読み取りはゼロになります。

これがGoでのパターンです。小さなconfig構造体が解決済みの値を保持し、ローダーが各シークレットを一度だけフェッチし、プログラムの残りの部分は構造体を参照で受け取り、フィールドを読み取ります。

// 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))
}

2つの特性が、これを安価であると同時に安全なものにしています。起動時に読み込むことで、1時間後に最初のユーザーリクエストが失敗するのではなく、デプロイ時にシークレットが欠落していたり不正な形式だったりした場合に、プロセスが即座にクラッシュします。そして、値はメモリ内にのみ存在するため、ディスクに書き込まれることはなく、プロセスが終了すると消滅します。

短命なワーカーについては、1つの調整を加えることで同じ考え方が適用されます。インスタンスごとに多くの呼び出しを処理する関数は、ハンドラーの本体内ではなく、ハンドラーの前に実行される初期化コード内で、インスタンスごとに1回シークレットを読み込むべきです。ほとんどの関数ランタイムは複数の呼び出しにわたってウォームインスタンスを維持するため、インスタンスごとの読み込みは、そのインスタンスが処理するすべての呼び出しにわたって償却されます。読み取り回数は、呼び出しごとに1回からインスタンスのライフタイムごとに1回に減少します。

関連するシークレットを1つのドキュメントに統合する

1つの設定を多数のシークレットに分割するのをやめると、ストレージ軸とアクセス軸の一部が共に縮小します。シークレットマネージャーは、不透明なバイトを保存します。それらのバイトが、一度に複数の関連値を保持するJSONオブジェクトになることを妨げるものは何もありません。

database-urlapi-tokensigning-keywebhook-url という名前の4つのシークレットの代わりに、値が小さなJSONドキュメントである service-config という名前の1つのシークレットを保存します。

{
  "database_url": "postgres://user:pass@host:5432/app",
  "api_token": "tok_live_abc123",
  "signing_key": "base64keymaterial",
  "webhook_url": "https://hooks.example.com/abc"
}

ローダーは現在、1回の呼び出しを行い、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
}

これにより、保存されるシークレットの数が4から1に削減されるため、そのサービスのストレージ行はおよそ4分の3減少します。また、起動時の読み取りも4回の呼び出しから1回に削減されますが、これはインスタンスごとに読み取りを行う短命なワーカーにとって最も重要です。この2つの変更は積み重なります。統合によって起動ごとのコストが低下し、キャッシュによって各起動が稀になります。

信頼境界とローテーションの周期によってグループ化し、たまたま存在するキーの数でグループ化しないでください。同じサービスに属し、同じプリンシパルのセットが読み取ることができ、一緒に変更される傾向がある値は、1つのドキュメントに属します。真に異なるアクセスルールや独立したローテーションスケジュールを持つ値は、分離したままにします。なぜなら、それらをマージすると、より粗い権限付与や、より扱いにくいローテーションを強制することになるからです。統合とは、自然にまとまる値に関するものです。

課金軸の比較

この表は、リークとその修正を具体的に示します。4つの論理値を保持し、毎秒1000リクエストを処理するサービスについて、単純なパターン(4つの個別のシークレット、リクエストごとに1回読み取り)と修正されたパターン(1つの統合されたシークレット、起動時に1回フェッチ)を比較しています。

単純なパターン キャッシュおよび統合
保存されるシークレット 4 1
リクエストごとの読み取り 4 0
プロセス開始ごとの読み取り 4 1
1000 req/sでの計測対象呼び出し 月に約100億回 デプロイごとに数回
ストレージ項目 4ユニット 1ユニット
値が漏洩した場合の影響範囲 1つのシークレット 1つのドキュメント

アクセスの行が、金銭的な影響が最も大きい部分です。リクエストパスから起動時に読み取りを移動させることで、呼び出し回数はトラフィックの関数からデプロイ頻度の関数に変わります。トラフィックは1時間に数百万リクエストになることもありますが、デプロイは1日に数回です。これが、高額な請求と誤差の範囲との間のすべての違いです。

値がキャッシュされている場合のローテーションの取り扱い

メモリにシークレットをキャッシュすることには、もっともな反対意見が1つあります。シークレットをローテーションすると、古い値をキャッシュしたプロセスは、何かがリロードさせるまでその値を使い続けます。キャッシュはコストと引き換えに鮮度を犠牲にし、ローテーションはそのトレードオフが現れる場面です。ローテーションを機能させ続けるには3つの方法があり、それらを組み合わせることもできます。

最も簡単なのは再起動です。ほとんどのローテーションワークフローでは、影響を受けるサービスをすでに再デプロイまたは再起動しており、新しいプロセスは定義上、起動時に新しい値を読み込みます。ローテーションのランブックがローリングリスタートで終わる場合、キャッシュされたシークレットは追加コストなしで更新され、他には何も必要ありません。

2つ目は、キャッシュの有効期間を制限することです。値を永続的に保持するのではなく、生存期間 (time to live) を設定し、期限が切れたらリロードします。1時間の有効期間は、ローテーションされたシークレットが再起動なしで1時間以内に取得されることを意味しますが、その代償としてプロセスごとに1時間あたりシークレットごとに1回の追加読み取りコストがかかります。それでも、リクエストごとの読み取りと比較すれば呼び出し回数はごくわずかであり、値がどれだけ古くなるかの上限を設けることができます。有効期間は、習慣からではなく、ローテーションがどれだけ速く伝播する必要があるかに基づいて選択してください。

3つ目は、明示的なシグナルです。ローテーションのステップで、メッセージチャネル、設定リロード用のエンドポイント、またはプロセスがトラップしてローダーを再実行するPOSIXシグナルを通じて、実行中のプロセスにリロードを通知させます。これにより、通知パスを構築するコストはかかりますが、ポーリングなしでほぼ即時の伝播が実現します。ローテーションが分単位ではなく秒単位で反映される必要がある場合に使用します。

ほとんどのサービスでは、最初の2つで十分です。ローテーションは頻繁ではなく、再起動は日常的なので、再起動による更新に加えて適度な生存期間を設定すれば、通知システムを構築することなく一般的なケースに対応できます。

シークレットマネージャーに含めるべきでないもの

料金の一部は、シークレットではないものを保存することから発生します。シークレットマネージャーは、公開されると害を及ぼす値、つまりパスワード、プライベートキー、トークン、認証情報が埋め込まれた接続文字列のためのものです。それは汎用的な設定ストアではなく、そのように使用すると、保存されるシークレット数と読み取り回数の両方が増加します。

機密性のない設定は、通常の環境変数やビルド時に組み込まれる設定ファイルに置くべきです。機能フラグ、リージョン名、ページサイズ制限、公開ベースURL、ログレベルといったものは、いずれも保護を必要とせず、従量課金制のシークレットスロットを占有すべきではありません。それらを環境変数に移動させれば、シークレットマネージャーは実際に保護が必要なものだけを保持するようになり、それによってストレージが削減され、料金を支払っていた読み取りがなくなります。

役立つテストがあります。ビルドログやスタックトレースに値が表示されることがセキュリティインシデントになる場合、それはシークレットです。単に見苦しいだけであれば、それは設定です。それぞれを、それに適した価格の場所に保存しましょう。

トレードオフを平たく言えば

これらのテクニックはセキュリティではなくコストを変えるものですが、採用する前に言及しておくべきトレードオフがあります。

インメモリキャッシュは、各プロセスがそのプロセスの生存期間中、シークレットの独自のコピーを保持することを意味し、ローテーションされた値は、キャッシュが期限切れになるか、プロセスが再起動するか、リロードシグナルが到着するまで反映されません。長寿命のプロセスと頻繁でないローテーションを持つサービスでは、その遅延は無害です。数分ごとにシークレットをローテーションし、どこにでも即座に伝播することを期待するシステムでは、再起動に頼るのではなく、リロードパスを意図的に計画してください。

1つのJSONドキュメントに統合することは、値ごとのローテーションの粒度を失うことを意味します。いずれかのフィールドをローテーションすることは、ドキュメント全体の新しいバージョンを書き込むことを意味し、すべてのリーダーがすべてのフィールドを一緒に取得します。もし2つの値が本当に独立したローテーションスケジュールや異なるアクセス権を必要とするなら、それらを分けておいてください。統合は、信頼境界とローテーションのリズムを共有する値のためのものであり、それはほとんどの値に当てはまりますが、すべてではありません。

根底にある考え方はシンプルです。マネージドシークレットマネージャーは機密性の高いバイトのための安全なストレージであり、リクエストごとにクエリする低レイテンシのストアではありません。そのように扱うようになれば、つまり、読み取りを稀にし、関連するものをグループ化し、シークレットでないものを除外することで、セキュリティは全く変わらず、請求額はほとんどなくなります。