Jake Goldsborough
Apr 9, 2026 • 4 min read
バックアップの問題から始まりました。数百ギガバイトのアップロードを抱えるサイトが、バックアップ生成中にディスク容量不足に陥っていました。あるサイトでは600GB以上のアップロードがあり、バックアッププロセスが何度も途中で落ちていました。
信頼性の高い大容量バックアップを調査していたところ、そのサイトの一つで驚くべき事実が発覚しました。実際のユニークなコンテンツは、報告されているサイズのほんの一部しかなかったのです。同じファイルが、それぞれ異なるファイル名で何度も何度も保存されていました。重複の量は異常なレベルでした。
そこで最適化を実装しました。コンテンツハッシュによって重複ファイルを検出し、各コピーをダウンロードする代わりにハードリンクを使用するというものです。新しいテストをいくつか書き、すべてパスし、承認されてマージされました。しかし残念ながら、このような修正を実際に完全にテストするのは非常に難しいものです。
その後、誰かが実際の本番バックアップで実行したところ、私が存在を知らなかったファイルシステムの制限に引っかかりました。原因は何だったのか?たった一つのリアクションGIFが、246,173回も重複して保存されていたのです……
問題の背景
Discourseにはセキュアアップロードという機能があります。ファイルがセキュリティコンテキスト間を移動する際(例えば、プライベートメッセージからパブリックな投稿へ)、システムはランダム化されたSHA1を持つ新しいコピーを作成します。元のコンテンツは同一ですが、Discourseはそれを新しいファイルとして扱います。
これはリアクションGIFや人気の画像で常に発生します。ユーザーが投稿間でシェアしたり、PMに埋め込んだり、異なるカテゴリに再投稿したりします。コンテキストが変わるたびに別のコピーが作成されます。
通常の操作においてはほぼ問題ありません。しかし、バックアップにとっては大惨事です。
あるお客様は432GBのアップロードを持っていました。ユニークなコンテンツはどのくらいか?26GBです。残りはすべて重複でした。16倍の膨張率で、そのすべてがバックアップアーカイブに入っていたのです。
修正方法
修正方法は単純明快に思えました。Discourseはoriginal_sha1にオリジナルのコンテンツハッシュを記録しています。バックアップ中に以下を行います。
original_sha1でアップロードをグループ化する- 各グループの最初のファイルをダウンロードする
- 重複ファイルにハードリンクを作成する
ハードリンクは複数のファイル名をディスク上の同じデータに向けるものです。GNU tarはそれらを保持するため、アーカイブにはデータが一度だけ格納されます。26GBをダウンロードし、26GBをアーカイブするだけで、全員がハッピーになれます。
def process_upload_group(upload_group)
primary = upload_group.first
primary_filename = upload_path_in_archive(primary)
return if !download_upload_to_file(primary, primary_filename)
# Create hardlinks for all duplicates in this group
upload_group.drop(1).each do |duplicate|
duplicate_filename = upload_path_in_archive(duplicate)
hardlink_or_download(primary_filename, duplicate, duplicate_filename)
end
end
hardlink_or_downloadメソッドは、ハードリンクが失敗した場合にダウンロードにフォールバックします。
def hardlink_or_download(source_filename, upload_data, target_filename)
FileUtils.mkdir_p(File.dirname(target_filename))
FileUtils.ln(source_filename, target_filename) # Create hardlink
increment_and_log_progress(:hardlinked)
rescue StandardError => ex
# Fallback: download if hardlink fails
log "Failed to create hardlink, downloading instead", ex
download_upload_to_file(upload_data, target_filename)
end
リリースしたところ、好意的なフィードバックが得られました……
制限との遭遇
その後、同僚が新バージョンを使って大規模なサイトのバックアップを実行しました。ログは好調に見えていました。
53000 files processed (25 downloaded, 52975 hardlinked). Still processing...
54000 files processed (25 downloaded, 53975 hardlinked). Still processing...
...
64000 files processed (25 downloaded, 63975 hardlinked). Still processing...
65000 files processed (25 downloaded, 64975 hardlinked). Still processing...
Failed to create hardlink for upload ID 482897, downloading instead
Failed to create hardlink for upload ID 457497, downloading instead
Failed to create hardlink for upload ID 867574, downloading instead
65,000個のハードリンクを作成したところで、失敗が始まりました。実はext4にはiNodeあたりおよそ65,000ハードリンクという制限があります。一つのファイルに向けられるファイル名は65,000個までなのです。
フォールバックが機能したため、完全な失敗にはなりませんでした。バックアップは完了しました。しかし、246,173個すべての重複に対して1回のダウンロードで済むはずが、制限に達した後に約181,000回のフォールバックダウンロードが発生してしまいました。
それでも246,173回のダウンロードよりはマシです。しかし、期待していた結果ではありませんでした。
問題のGIF
では、246,173個ものコピーが存在したファイルとは何だったのでしょうか?
Upload.where(original_sha1: '27b7a62e34...').count
=> 246173
Upload.where(original_sha1: '27b7a62e34...').first.filesize
=> 1643869
1.6MB。それが25万回近く重複していました。たった一枚の画像から377GBものバックアップの肥大化が生じていたのです。
そして、それが何であるかを確認してみると……

リアクションGIFでした。投稿、PM、あらゆる場所で常用されていたものです。異なるセキュリティコンテキストで使用されるたびに新しいコピーが作成されます。「フレンズ」のレイチェルが嬉しそうに踊っているGIFが、246,173個も存在していたのです。
たった一つのGIFがハードリンクの制限を突き破ったのです。
計算結果
重複排除なし:246,173回のダウンロード、377GB転送。
重複排除あり(制限に到達した場合):約4回のダウンロード、約6.4MB転送。
ファイルシステムの制限により、「1回だけダウンロード」が「4回ダウンロード」になってしまいました。それでも十分許容できる結果です。
修正の修正
最初の直感は、ハードリンク数を追跡して制限に達する前に積極的にローテーションするというものでした。しかし同僚が欠点を指摘してくれました。使用されているファイルシステムが何であるかを知る方法がないということです。ext4には一つの制限、XFSには別の制限、ZFSにはまた別の制限があります。魔法の数字を決め打ちするのは脆弱です。
より良いアプローチは、制限に達したときにファイルシステム自身が教えてくれるようにすることです。
def create_hardlink(source_filename, upload_data, target_filename)
FileUtils.mkdir_p(File.dirname(target_filename))
FileUtils.ln(source_filename, target_filename)
source_filename
rescue Errno::EMLINK
# Filesystem hardlink limit reached - copy and use as new primary
FileUtils.cp(source_filename, target_filename)
target_filename
rescue StandardError => ex
download_upload_to_file(upload_data, target_filename)
source_filename
end
Errno::EMLINKが発生したとき、ファイルはすでにローカルに存在しています。再ダウンロードする必要はありません。コピーするだけで、そのコピーを後続のハードリンクの新しいプライマリとして使用します。どのファイルシステムでも動作し、設定も不要です。
学んだこと
ファイルシステムには自身のルールがあります。ext4のハードリンク制限は、特定の種類のバグや攻撃を防ぐために存在しています。恣意的なものではありません。
フォールバックがこの機能を救いました。グレースフルデグラデーションがなければ、そのバックアップは完全に失敗していたでしょう。代わりに、最適な速度よりは遅いものの、バックアップは完了しました。
本番環境は必ずエッジケースを見つけます。一つのファイルが246,000個もコピーされているのは異常です。しかし、大規模なスケールでは異常なことが起こるものです。
いくつかの具体的な教訓をまとめます。
- 成功パスだけでなく、失敗モードもテストすること。ハードリンクのフォールバックは最初から組み込まれていましたが、実際に必要になるとは思っていませんでした。
- 処理量を16分の1に削減する最適化であっても、エッジケースへの対処は必要です。グレースフルデグラデーションを伴う99.998%の改善は、クラッシュする100%の改善よりも優れています。
- ファイルシステムレベルの制約を早期に把握すること。ハードリンクの制限、iNode数、パスの長さ——これらは理論上の懸念事項ではなく、実際の運用上の境界線です。
そして今、ジェニファー・アニストンがインフラのストレステストをできることを知りました。
リンク
- Discourse PR #37261 - バックアップ重複排除の修正
- Discourse PR #37293 - ハードリンク制限の修正
原文はこちら:
Good Loopでは、Discourseのセルフホスティングを安価で提供しています。開発元であるCDCK社の協力のもと、公式ブログ記事の翻訳・公開など、日本での普及にも努めています。
