タスクの期限切れを通知するバッチが、ある日突然本番環境でだけ落ちるようになりました。エラーは ArgumentError: Invalid Timezone: America/Godthab。コードには何も手を入れていませんし、ローカルでも再現しません。
原因はRailsが持つタイムゾーン名の対応表と、OS側のtzdataパッケージのバージョンのズレでした。Dockerベースイメージを更新したタイミングで誰にでも起きうる話なので、調べたことをまとめておきます。
何が起きていたか
問題のバッチは「全タイムゾーンをループして、その地域が朝9時になっていたら通知を送る」という作りでした。
ActiveSupport::TimeZone::MAPPING.values.uniq.each do |tz|
Time.use_zone(tz) do
# その地域が朝9時なら通知を送る、といった処理
end
end
ActiveSupport::TimeZone::MAPPING は「表示用の名前 → IANAタイムゾーン名」の対応表です。"Tokyo" => "Asia/Tokyo" のようなハッシュで、タイムゾーン選択のセレクトボックスによく使われます。
このループが、たった1件の値で ArgumentError を投げて全体を止めていました。
原因はRailsが持っている「古い名前」
MAPPING の値には、IANAのtzdatabase側で既に改名済みの名前が残っていました。無効になっていたのは次の3つです。
| MAPPINGの値(旧名称) | IANAの現在の正式名 |
|---|---|
America/Godthab |
America/Nuuk |
Europe/Kiev |
Europe/Kyiv |
Asia/Rangoon |
Asia/Yangon |
America/Godthab → America/Nuuk はtzdata 2020a、Europe/Kiev → Europe/Kyiv はtzdata 2022bでの変更で、どちらもリリースノートに旧名称向けの後方互換リンクを残す旨が書かれています。
なぜ「これまでは動いていた」のか
tzdataは改名時に、旧名称から新名称へのリンク(backward ファイルに定義されるもの)を用意します。これまで動いていたのは、Railsの対応表がIANAの改名に追いつかない分をtzdata側が吸収してくれていたからでした。
しかしこの後方互換リンクは必ず提供されるとは限りません。tzdataのビルド構成やベースイメージの都合で削られていると、旧名称は単に「存在しないタイムゾーン」になります。今回のケースでは、Dockerベースイメージ更新でOS側のtzdataが新しくなったのが引き金でした。アプリのコードは1行も変わっていないのに落ち始めたのはそのためです。
本番でだけ再現した理由
Rubyのtzinfo gemは、タイムゾーン定義の読み込み元を2通り持っています。
TZInfo::DataSource.get.class
# => TZInfo::DataSources::ZoneinfoDataSource # OSのシステムtzdataを読む
# => TZInfo::DataSources::RubyDataSource # tzinfo-data gemを読む
ZoneinfoDataSource の場合、解決できる名前はOSのtzdataパッケージの内容に完全に依存します。macOSのローカル機と本番のLinuxコンテナでは中身が違い、手元には後方互換名が残っていたため何度試しても再現しませんでした。「ローカルで再現しないから大丈夫」と判断してはいけない、分かってはいても踏みがちな落とし穴です。
調査方法:本番相当の環境で総当たりする
どの値が無効かは、全定義値を総当たりすれば一発で分かります。
invalid = []
ActiveSupport::TimeZone::MAPPING.values.uniq.each do |tz|
begin
Time.current.in_time_zone(tz)
rescue => e
invalid << [tz, e.message]
end
end
invalid
ポイントは、これを必ず本番相当の環境(同じDockerイメージ、同じOS)で実行することです。手元で流しても「無効な値はゼロ件」という嘘の安心が返ってくるだけでした。
対処:DBの保存値は変えず、使う直前に読み替える
今回の事象では、ユーザーのタイムゾーン設定を次のように定義していました。
enumerize :time_zone, in: ActiveSupport::TimeZone::MAPPING.values.uniq
DBには旧名称がそのまま保存されています。「DBの値を一括更新する」のが正攻法に見えますが、この値は他テーブルとの突き合わせやSQLの比較条件にも登場していたため、影響範囲が読みづらい変更でした。
そこで、保存値は変えず、in_time_zone / Time.use_zone を呼ぶ直前にだけ読み替える方針にしました。
UNRESOLVABLE_TIME_ZONE_ALIAS = {
"America/Godthab" => "America/Nuuk",
"Europe/Kiev" => "Europe/Kyiv",
"Asia/Rangoon" => "Asia/Yangon",
}.freeze
def self.resolvable_time_zone(time_zone)
UNRESOLVABLE_TIME_ZONE_ALIAS[time_zone] || time_zone
end
呼び出し側は Time.use_zone(resolvable_time_zone(user.time_zone)) とするだけです。将来また改名が起きても、このハッシュに1行足せば済みます。
Rails本体は今どうなっているか
気になったので、Rails本体の activesupport/lib/active_support/values/time_zone.rb をバージョンごとに見てみました(2026年8月時点)。
- v7.0系〜v8.0系: 3つとも旧名称のまま
- v8.1.0:
America/Nuukに更新済み。Europe/KievとAsia/Rangoonは旧名称のまま - mainブランチ: 3つとも新名称に更新済み
少しずつ追従が進んでいるようです。Europe/Kiev については「表示名として出す以上Rails側が更新すべきでは」という趣旨のIssue(rails/rails#44273)もあり、長く議論されてきた経緯があります。
まとめ
今回いちばんの学びは、全件をループする処理は1件の無効値で全体が止まるというもろさでした。MAPPING.values.uniq を回す実装は、ユーザーが1人もいない地域まで含めて処理します。誰も使っていない値が1つ壊れただけでバッチ全体が止まるのは、影響と原因のバランスが悪すぎます。個別に rescue してスキップするか、実際に設定されている値だけを pluck して回すようにしておくと壊れにくくなります。
もう1つは、tzdataのような管理外の外部データへの暗黙的な依存は、依存元が更新された瞬間に壊れうるということです。ベースイメージの更新はアプリのコード差分として見えないので、レビューでも気づけません。ロケールや文字コード、証明書ストアあたりも同じ性質を持っていそうです。
「本番でだけ落ちる、しかもコードは変えていない」という状況は焦りますが、依存データの更新まで含めて疑えると切り分けが早くなります。同じような状況の方の参考になれば嬉しいです。