GZipヘッダのmtimeを固定値にし、ファイルmtimeを正しく設定する
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ヘッダの mtime を 0 に設定する実装を採用しています。同様の判断は rack/rack#2372 で Rack::Deflater にも適用されました。GZipヘッダの mtime はHTTPクライアントが実際に利用するものではなく、HTTPレスポンスの Last-Modified ヘッダによってキャッシュ制御は行われるためです。
一方、ファイルシステム上の mtime(File.utime で設定されるもの)は別の意味を持ちます。これは Last-Modified HTTPヘッダの値として使われるため、未圧縮ファイルと圧縮ファイルで同一の値を持つことが重要です。しかし #233 でZopfliサポートが追加された際に、Zopfliアーカイバへのファイルmtime設定が見落とされていたことも今回の修正で合わせて対処されています。
技術的な変更
GZipヘッダ mtime の固定値化とファイルmtime設定のロジック集約という2つの変更が、encoding_utils.rb と utils/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未満では Zlib に mtime = 0 が「未設定」と等価になりコンパイル時刻が埋め込まれるバグがあるため、0 の代わりに 1 を使用します。Ruby 2.7以降ではGZip仕様通りの 0(タイムスタンプなし)が使えます。
lib/sprockets/utils/gzip.rb では、アーカイバのインターフェースと責務の分離が行われました。変更前は各アーカイバ(ZlibArchiver・ZopfliArchiver)が 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
変更後(ZlibArchiver と compress メソッド):
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.utime は compress メソッドのみが責任を持つため、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_MTIME と MTIME で吸収しつつ、テストコードも実装と整合した記述になっています。
まとめ
GZipヘッダの mtime を固定値にすることで圧縮ファイルのコンテンツが決定論的になり、DockerやGitなどコンテンツハッシュに依存するツールでの不要な差分が解消されます。同時にファイルmtimeの設定をアーカイバから compress メソッドに集約したことで、ZlibとZopfliで Last-Modified の同期が保証されると同時に、今後のアーカイバ追加に対するロバスト性も高まりました。