GZipヘッダのmtimeを固定値にし、ファイルmtimeを正しく設定する

rails/sprockets

Sprocketsのgzip圧縮処理において、GZipヘッダ内の mtime フィールドを固定値に変更し、圧縮ファイル自体のファイルシステム上の mtime を元ファイルと同期する修正が加えられました。これにより、コンテンツが変化していないのにファイルハッシュが変わるという問題が解消されます。

背景

GZipフォーマットは圧縮データのヘッダ内に mtime フィールドを持ちますが、このヘッダの値はHTTPキャッシュ制御には不要であり、むしろ変動することで問題を引き起こしていました。Zlib::GzipWriter#mtime に可変な値(元ファイルのmtime)を設定すると、圧縮対象のコンテンツが変わっていなくてもヘッダが変化し、圧縮ファイルのバイト列・コンテンツハッシュが変わります。Dockerイメージのレイヤー比較やGitのコンテンツ追跡(#707)において、実質的な変更がないのにファイルが「変更済み」と判定されるという問題が発生していました。

Apacheの mod_deflate やnginxの ngx_http_gzip_module、そしてZopfli自体もGZipヘッダの mtime0 に設定する実装を採用しています。同様の判断は rack/rack#2372Rack::Deflater にも適用されました。GZipヘッダの mtime はHTTPクライアントが実際に利用するものではなく、HTTPレスポンスの Last-Modified ヘッダによってキャッシュ制御は行われるためです。

一方、ファイルシステム上の mtimeFile.utime で設定されるもの)は別の意味を持ちます。これは Last-Modified HTTPヘッダの値として使われるため、未圧縮ファイルと圧縮ファイルで同一の値を持つことが重要です。しかし #233 でZopfliサポートが追加された際に、Zopfliアーカイバへのファイルmtime設定が見落とされていたことも今回の修正で合わせて対処されています。

技術的な変更

GZipヘッダ mtime の固定値化とファイルmtime設定のロジック集約という2つの変更が、encoding_utils.rbutils/gzip.rb の両ファイルにまたがって行われました。

lib/sprockets/encoding_utils.rb では、GZIP_MTIME 定数が追加され、gzip メソッド内のハードコードされた 1 を置き換えます。

GZIP_MTIME = RUBY_VERSION >= "2.7" ? 0 : 1

def gzip(str)
  io = StringIO.new
  gz = Zlib::GzipWriter.new(io, Zlib::BEST_COMPRESSION)
  gz.mtime = Sprockets::EncodingUtils::GZIP_MTIME
  gz << str
  gz.finish
  io.string
end

Ruby 2.7未満では Zlibmtime = 0 が「未設定」と等価になりコンパイル時刻が埋め込まれるバグがあるため、0 の代わりに 1 を使用します。Ruby 2.7以降ではGZip仕様通りの 0(タイムスタンプなし)が使えます。

lib/sprockets/utils/gzip.rb では、アーカイバのインターフェースと責務の分離が行われました。変更前は各アーカイバ(ZlibArchiverZopfliArchiver)が mtime 引数を受け取りGZipヘッダとファイルmtimeの両方を設定していましたが、変更後はどちらのアーカイバもファイルmtimeの設定を行わず、呼び出し元の compress メソッドが一元的に File.utime を呼び出します。

変更前(ZlibArchiver):

def self.call(file, source, mtime)
  gz = Zlib::GzipWriter.new(file, Zlib::BEST_COMPRESSION)
  gz.mtime = mtime
  gz.write(source)
  gz.close

  File.utime(mtime, mtime, file.path)
end

変更後(ZlibArchivercompress メソッド):

module ZlibArchiver
  MTIME = RUBY_VERSION >= "2.7" ? 0 : 1

  def self.call(file, source)
    gz = Zlib::GzipWriter.new(file, Zlib::BEST_COMPRESSION)
    gz.mtime = MTIME
    gz.write(source)
    gz.close

    nil
  end
end

# ...

def compress(file, target)
  mtime = Sprockets::PathUtils.stat(target).mtime
  archiver.call(file, source)
  File.utime(mtime, mtime, file.path)

  nil
end

ZopfliArchiver も同様に mtime 引数が削除され、Autoload::Zopfli.deflate への mtime: オプションも取り除かれました。File.utimecompress メソッドのみが責任を持つため、ZlibとZopfliの両方で確実にファイルmtimeが設定されるようになっています。

テストも更新され、GZipヘッダのmtimeバイトを直接比較する箇所で Sprockets::EncodingUtils::GZIP_MTIME 定数を使うよう修正されました。

assert_equal [31, 139, 8, 0, Sprockets::EncodingUtils::GZIP_MTIME, 0, 0, 0], output.bytes.take(8)

設計判断

ファイルmtimeの設定責務をアーカイバから compress メソッドへ引き上げたことが、この変更の重要な設計判断です。

変更前は各アーカイバが mtime を引数として受け取り、GZipヘッダとファイルmtimeの両方を設定していました。この設計ではアーカイバごとに File.utime を呼ぶ必要があり、Zopfliサポート追加時のように新しいアーカイバを追加した際に設定漏れが発生するリスクがありました。変更後は compress メソッドがアーカイバの呼び出し後に必ず File.utime を実行する構造になっており、将来のアーカイバ追加時にも設定漏れが起きにくい設計になっています。

GZipヘッダの固定値には 0 ではなく 1 をRuby 2.7未満向けに使用するという判断は、同様の問題に対処した Rack::Deflater と同じアプローチです。これにより、Rubyバージョン間の動作差異を定数 GZIP_MTIMEMTIME で吸収しつつ、テストコードも実装と整合した記述になっています。

まとめ

GZipヘッダの mtime を固定値にすることで圧縮ファイルのコンテンツが決定論的になり、DockerやGitなどコンテンツハッシュに依存するツールでの不要な差分が解消されます。同時にファイルmtimeの設定をアーカイバから compress メソッドに集約したことで、ZlibとZopfliで Last-Modified の同期が保証されると同時に、今後のアーカイバ追加に対するロバスト性も高まりました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
1f22cead

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文(総論)→背景・技術・設計(各論)→まとめ(結論)」という理想的な3部構成が明確に適用されており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、PR/Issue番号のリンク記法など、全てのカスタムMarkdown構文が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Sprockets、GZip、Dockerレイヤーなどに関する専門的な内容を、過度な初心者向け解説なしに記述しており、対象読者であるエンジニアに最適化されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が徹底されており、極めて高い可読性を実現しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているすべてのコードブロックは、提供されたDiffの内容と完全に一致しており、正確に引用されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`mtime`、`コンテンツハッシュ`、`mod_deflate`、`Last-Modified`ヘッダなど、PRの文脈で使われている技術用語を正確かつ適切に使用しています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

Rubyのバージョンによる`Zlib`の挙動の違いや、アーキテクチャの責務変更など、技術的な変更の背景と内容を論理的かつ正確に説明しています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべて、PRのDescriptionやDiffで裏付けられており、推測や憶測に基づいた記述は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#821)、参照Issue/PR番号(#707, #233)、Rubyバージョン(2.7)など、すべての数値・固有名詞が正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「GZipヘッダのmtimeを固定値にし、ファイルmtimeを正しく設定する」は、PRのタイトルと内容を的確に要約しており、主題と一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事の内容はPR情報に忠実であり、バージョンのサポート状況やリリース日程といった、PRに記載のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

PR Descriptionの「just merged」という表現を「適用されました」と自然に表現しており、時間表現の歪曲は見られません。