インフラ

メディアのコンテンツハッシュキー、不変性と重複排除

画像を、ファイル名ではなくそのバイトデータのsha256ハッシュ値で保存します。無償の不変性、自動的な重複排除、そして1つの環境変数で移動できるストレージが手に入ります。

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

オブジェクトストレージに画像をアップロードするとき、明白なキーはそのファイル名、例えば img/hero.png です。それは読みやすく、推論も容易ですが、その裏ではキャッシュの無効化、オブジェクトの重複、データベースに固定されたURLといった問題を引き起こします。これら3つすべてを回避する、別のキーがあります。オブジェクトにファイル名から名前を付けるのではなく、そのバイト列から名前を付けます。ファイルコンテンツのsha256を計算し、img/{sha256}.{ext} というキーで保存します。名前ではなく、コンテンツがキーを決定するのです。この記事では、その選択を支持するすべての論拠、それを実装するGoのコード、そしてファイル名ベースのキーが依然として正しい唯一のケースについて詳しく説明します。

コンテンツハッシュキーの仕組み

ルールは一行です。ストレージキーはファイルコンテンツのハッシュです。バイトを読み込み、それらに対して sha256 を実行し、ダイジェストを16進数エンコードして、オブジェクトを img/{digest}.{ext} に配置します。コンテンツのハッシュが 9f86d0... となるファイルは img/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08.png に配置されます。

このことから、すぐに2つの特性が導き出され、この記事の他のすべてはそれらの帰結です。

  • 同じバイトは常に同じキーを生成します。2つの異なるソースから、2つの異なる元の名前で同一のファイルを2回アップロードしても、両方とも全く同じオブジェクトに配置されます。
  • 異なるバイトは異なるキーを生成します。1ピクセルを変更するとダイジェストは完全に変わるため、編集されたファイルは新しいキーを持つ新しいオブジェクトとなり、古いキーは依然として古いバイトを指し示します。

これはコンテンツアドレッシングです。データのアドレスはデータ自体から導出されます。Git は blob で同じことを、同じ理由から行っています。

不変性:永久にキャッシュし、無料で無効化する

キーは常に1つの正確なバイトシーケンスのみを参照できるため、そのキーにあるオブジェクトは決して変更されません。この1つの事実が、ウェブが持つ最も強力なキャッシュヘッダーを解放します:

Cache-Control: public, immutable, max-age=31536000

max-age=31536000は1年です。immutableは、ユーザーがリロードしたときにIf-None-Matchで再検証する必要さえないことをブラウザに伝えます。これらがエンコードする約束は実際に真実であるため、ここでは両方とも安全です:このURLの背後にあるバイトは、オブジェクトが存在する限り変更されません。R2やS3の前にあるCDNは、一度キャッシュすると、追い出されるまでエッジからそれを提供し、オリジンはそのファイルに対するリピートトラフィックをほとんど見ません。

次に、無効化の話を比較してみましょう。ファイル名キーでは、hero.pngを更新することはオブジェクトを上書きすることを意味し、古いバージョンをすでに保持しているすべてのキャッシュは今や間違っています。CDNをパージする必要があり、ブラウザが同じURLで古いコピーを保持していないことを願うしかありません。コンテンツハッシュキーでは、何も上書きしません。編集された画像は新しいキーであり、したがって新しいURLです。そして、その新しいURLは少し前まで存在しなかったため、キャッシュされていません。古いURLは、それをまだ参照している誰にでも古いバイトを提供し続けます。キャッシュの無効化は、あなたが行う操作ではなくなります。URLの意味が決して変わらないため、それは起こり得ないことになります。

重複排除: HeadObject、そして場合によっては PutObject

同一のバイトが常に同じキーにマッピングされる場合、同じファイルを2回保存することは、それを1回保存することと同じです。アップロードパスは、オブジェクトを書き込む前にチェックします。

  1. コンテンツハッシュキーを計算します。
  2. そのキーに対して HeadObject を実行します。
  3. 存在する場合、何もしません。バイトはすでにそこにあります。
  4. 存在しない場合、PutObject を実行します。

これにより、アップロードはべき等になります。取り込みジョブがリトライする場合や、同じアセットが3つの異なる投稿に表示される場合に重要なことですが、再度実行することは安価で安全です。40の記事で参照される共有ロゴは、40個ではなく1個のオブジェクトです。HeadObject の料金を支払いますが、これはメタデータ呼び出しであり、完全なボディの冗長なアップロードよりもはるかに安価です。

// putImmutable stores b under a content-hash key, skipping the upload
// if an object with that key already exists.
func putImmutable(ctx context.Context, cli *s3.Client, bucket string, b []byte, ext string) (string, error) {
	sum := sha256.Sum256(b)
	key := fmt.Sprintf("img/%s.%s", hex.EncodeToString(sum[:]), ext)

	_, err := cli.HeadObject(ctx, &s3.HeadObjectInput{
		Bucket: &bucket,
		Key:    &key,
	})
	if err == nil {
		return key, nil // already present, dedup hit
	}
	var nf *types.NotFound
	if !errors.As(err, &nf) {
		return "", fmt.Errorf("head %s: %w", key, err)
	}

	_, err = cli.PutObject(ctx, &s3.PutObjectInput{
		Bucket:       &bucket,
		Key:          &key,
		Body:         bytes.NewReader(b),
		ContentType:  aws.String(contentType(ext)),
		CacheControl: aws.String("public, immutable, max-age=31536000"),
	})
	if err != nil {
		return "", fmt.Errorf("put %s: %w", key, err)
	}
	return key, nil
}

HeadObject は、存在しないキーに対して型付けされた NotFound エラーを返し、それ以外はすべて、無視すべきではない実際のエラーであることに注意してください。任意のエラーを不在として扱うと、一時的なネットワーク障害が重複アップロードに変わり、本末転倒になります。

ストレージをデータベースから分離する

3つ目の利点は、キャッシュとは関係ありません。URLがどこに存在するかということです。

よくある間違いは、メディアの完全なURLをデータベースに保存することです。例えば、投稿の隣の列にhttps://media.example.com/img/9f86d0....pngを置くようなことです。これは、ホストを変更したり、バケット名を変更したり、ドメインを移動したりする日までは機能します。しかし、そうなると、これらの絶対URLはすべて間違ったものになり、修正するにはコンテンツの移行が必要になります。

コンテンツハッシュキーは、クリーンな分割を可能にします。データベースにはキーであるimg/9f86d0....pngのみを保存し、ホストは保存しません。絶対URLは、レンダリング時に環境変数から組み立てられます。

func mediaURL(base, key string) string {
	return strings.TrimRight(base, "/") + "/" + key
}

// base comes from MEDIA_BASE_URL, e.g. https://media.example.com
url := mediaURL(cfg.MediaBaseURL, post.HeaderKey)

R2から別のプロバイダーに移行したり、新しいCDNを前面に配置したりすることは、今や行や記事本文に一切触れない2段階の操作になります。オブジェクトを新しい場所にコピーし、次にMEDIA_BASE_URLを変更します。キーはコンテンツから派生しているため両側で同一であり、したがって同じキーはどこでホストされていても同じバイトに解決されます。データベースは、今日どのバケットがファイルを配信しているかを知ることも気にする必要もありません。

アップロードパイプラインのエンドツーエンド

Markdownの投稿を処理するインジェストステップで、各要素がどのように連携するかを以下に示します。著者は ![diagram](images/flow.svg) のような相対参照を記述し、パイプラインはアップロード時にそれらをコンテンツハッシュキーに書き換えます。

  1. 投稿を解析し、すべてのローカル画像参照を収集します。
  2. 参照されている各ファイルについて、バイトを読み取り、コンテンツハッシュキーを計算します。
  3. HeadObject を実行し、ミスした場合にのみ、不変キャッシュヘッダーを付けて PutObject を実行します。
  4. 本文中の相対参照を、保存されたキーに置き換えます。
  5. 投稿を永続化します。本文とデータベースは、絶対URLではなくキーを保持するようになります。

この書き換えステップが、著者のエクスペリエンスをシンプルに保つ鍵です。ライターはリポジトリ内で意味をなす相対パスを扱い、パイプラインがハッシュ化とバケットについて知っている唯一の場所です。

func processBody(ctx context.Context, up *Uploader, body string, refs []ImageRef) (string, error) {
	for _, ref := range refs {
		b, err := os.ReadFile(ref.LocalPath)
		if err != nil {
			return "", fmt.Errorf("read %s: %w", ref.LocalPath, err)
		}
		key, err := up.PutImmutable(ctx, b, ref.Ext)
		if err != nil {
			return "", err
		}
		body = strings.ReplaceAll(body, ref.Original, key)
	}
	return body, nil
}

トレードオフ: オーファンとインプレース更新の不可

コンテンツアドレッシングは無料ではありません。そして、それを優れたものにしているのと同じ特性が、ある特定の点でそれを扱いにくくもしています。バイトに少しでも変更が加わると新しいキーが生成されるため、ファイルをインプレース (その場で) 更新することはできません。上書きという概念は存在しません。画像を編集すると、新しいキーを持つ新しいオブジェクトが生成されます。古いオブジェクトは、元あった場所にそのまま残ります。

もし古いキーを何も参照しなくなれば、そのオブジェクトはオーファン (孤児) となります。それはストレージを消費しますが、誰にも読み取られません。時間が経つにつれて、編集されたり置き換えられたりしたアセットがデッドウェイト (死重) として蓄積していきます。自動的なクリーンアップは行われません。なぜなら、ストレージ側では、他の投稿や古いキャッシュされたページがまだそのキーを指しているかどうかを判断できないからです。ガベージコレクタが必要です。定期的にバケット内のオブジェクトをリストアップし、ライブコンテンツ全体で実際に参照されているキーをリストアップし、最初のセットには現れるが2番目のセットには現れないオブジェクトを削除します。これをスケジュールに従って実行し、処理中の呼び出し元が404エラーを受け取らないように、オーファンには削除前の猶予期間を与えます。

下の表は、2つのモデルを並べて示しています。

懸念事項 ファイル名キー (img/hero.png) コンテンツハッシュキー (img/{sha256}.png)
Cache-Control 再検証が必要、または短いTTL immutable, max-age=31536000
画像の更新 同じキーを上書き、CDNをパージ 新しいキー、新しいURL、パージ不要
重複アップロード 別々のオブジェクト 1つのオブジェクト、重複排除済み
古いキャッシュのリスク 現実的、無効化が必要 なし、URLの意味は決して変わらない
DBへの保存 多くの場合、絶対URL キーのみ、ホストはenvから取得
クリーンアップの負担 上書きでスペースを再利用 オーファンが蓄積、GCが必要
インプレース更新 自然 設計上不可能

最後の2行がコストです。それより上のすべてがメリットです。このトレードオフに価値があるかどうかは、ファイルが実際にどれくらいの頻度で変更されるか、そしてその見返りとして得られるキャッシュと重複排除をどれだけ重視するかにかかっています。

ファイル名キーが依然として優れた選択肢である場合

コンテンツアドレッシングは、一度書き込まれ、何度も読み取られるメディアに有効です。これは、コンテンツサイト上のほとんどの画像、動画、フォント、静的ダウンロードに当てはまります。バイトの同一性よりもスロットの同一性の方が重要な場合、それは不適切なツールです。

安定したファイル名キーを使用する場合:

  • 常に最新バージョンを提供する単一の正規URLが必要で、新しいURLを発行するよりもCDNをパージする方が望ましい場合。マーケティング部門がインプレースで上書きし、呼び出し元が参照を更新してはならないlogo/current.svgは、ファイル名で処理すべき案件です。
  • パス自体が、システムが依存する意味を持つ場合。例えばusers/{id}/avatar.pngのように、その場所がルックアップキーであり、スロットごとに正確に1つのライブオブジェクトが存在する場合です。コンテンツアドレッシングでは、アバターの編集ごとに新しいキーに分散されてしまい、結局どれが最新であるかを追跡する必要が出てきます。
  • オブジェクトが大きく、変更可能で、常に編集されるため、孤立したオブジェクトの山が、妥当なGC(ガベージコレクション)で正当化できる範囲を超えて急速に増大する場合。

これらのケースでは、無効化の作業を受け入れ、名前を維持してください。事実上ライトワンス(一度きりの書き込み)であるすべてのものについては、コンテンツにキーを選ばせましょう。めったに必要としなかったインプレース更新と引き換えに、不変性、重複排除、そしてストレージが移動しても一行も変更されないデータベースを手に入れることができます。