背景
Rails 8 アプリで bundle exec rspec を実行したら、下記のエラーが出た
an error occurred while loading ./spec/jobs/sample_import_job_spec.rb.
Failure/Error: require_relative '../config/environment'
FrozenError:
can't modify frozen Array: ["/Users/user/.local/share/ .....
エラー文に turbo-rails や actiontext が並ぶので一見これらの gem が悪そうに見えるが、実は犯人ではない
本質は Rails の自動読み込みを支える 3 つの概念 — autoload_paths / zeitwerk / bootsnap — の連携と「凍結」のタイミングにある
結論から言うと、tmp/cache/bootsnap のキャッシュを削除したら直った。
本記事ではなぜそれで直るのかまで踏み込む
エラーの正体: FrozenError とは
FrozenError:
can't modify frozen Array: [".../turbo-rails/app/channels", ...]
FrozenError は Ruby の組み込みエラーで、freeze メソッドで凍結された(変更不可になった)オブジェクトを書き換えようとしたときに発生する
今回凍結されてた配列の正体は ActiveSupport::Dependencies.autoload_paths で、自動読み込み対象フォルダの一覧データだ。エラーメッセージに表示される turbo-rails や actiontext のパスは、その時点で配列の中身に入ってたフォルダを表示してるだけで、これらの gem が原因というわけではない
登場人物 3つの関係
Rails の自動読み込みには 3 つの主要概念が登場する。役割を表で整理する
| 概念 | 役割 | 比喩 |
|---|---|---|
autoload_paths |
自動読み込み対象フォルダのリスト(データ) | 住所録 |
zeitwerk |
リストを使って実際にファイルを読み込む仕組み(プログラム) | 司書 |
bootsnap |
起動を高速化するためのキャッシュ層 | メモ帳係 |
3 つが揃って初めて Rails の自動読み込みが機能する
autoload_paths(住所録)
「Rails が自動でクラスを探しに行くフォルダ群を並べた配列」。Rails の住所録だと捉えると分かりやすい
autoload_paths = [
"app/models",
"app/controllers",
"app/jobs",
...(gem たちが追加したフォルダも含む)
]
各 gem(Engine)は自分の app/ 配下フォルダを「うちも読み込み対象に入れて」と autoload_paths に unshift で追加する
Ruby では本来、他のファイルのコードを使うには require で明示的に読み込む必要があるが、Rails では User と書くだけで自動的に app/models/user.rb を探して読み込んでくれる。これを支えてるのがこの仕組み
zeitwerk(司書)
autoload_paths は「どこを巡回するか」のリストでしかない。実際にフォルダをスキャンしてファイルを読み込み、User という定数が呼ばれたら app/models/user.rb を読み込んで応答する役割を担うのが zeitwerk
[autoload_paths] [zeitwerk]
"巡回すべき本棚一覧" "司書(実際に動く人)"
┌──────────┐
│ A 棚 │
│ B 棚 │ ──── 渡す ───→ 「了解、巡回します」
│ C 棚 │
└──────────┘
歴史的経緯として、Rails 6 までは classic という古いオートローダーが使われていたが、Rails 7 以降は zeitwerk がデフォルトになった。Rails 8 + zeitwerk 2.7 系では autoload_paths を凍らせるタイミングが早くなり、後から .concat する gem が弾かれるようになった点が今回のエラーの遠因にもなっている
bootsnap(メモ帳係)
Rails の起動を高速化するための gem。素の Rails 起動は
- 何百もの Ruby ファイルを読み込み・パース
- 何十もの YAML 設定ファイルを解釈
- 「どのフォルダに何のファイルがあるか」を全フォルダ走査
を毎回ゼロからやるため 5〜30 秒かかる。bootsnap はこれらの結果を tmp/cache/bootsnap/ にキャッシュして再利用する
| キャッシュ | 内容 | 効果 |
|---|---|---|
load-path-cache |
どのフォルダにどの Ruby ファイルがあるかの場所メモ | フォルダ総当たり捜索を回避 |
compile-cache-iseq |
Ruby コードを機械が分かる形(バイトコード)に翻訳した結果 | 翻訳作業を回避 |
compile-cache-yaml |
YAML を Ruby が扱える形に解釈した結果 | 解釈作業を回避 |
キャッシュは速いが「実物が変わったらキャッシュも更新する必要がある」というジレンマを抱える。bootsnap は基本ファイル更新日時で判定して自動更新するが、Rails のメジャーアップデート時、gem 入れ替え時、Ruby のバージョン変更時、bundle install を中断した時など、稀にミスる
Rails 起動シーケンスと freeze のタイミング
Rails アプリの起動は「準備フェーズ」と「稼働フェーズ」に分かれており、autoload_paths は準備フェーズの最後に freeze される
起動 ─→ [準備] gem が住所録に追加 ─→ freeze ─→ [稼働] アプリ運転
↑
「住所録は完成、もう触るな」
freeze する理由は 3 つある
- 安全: 稼働中に住所録が書き換わると「さっきまで見えてたフォルダが消えた」事故が起きる
- 高速化: もう変わらないと確定すれば、Rails が先回り最適化できる
- バグ防止: 開発者がうっかり実行中に書き換えるのを禁じる
freeze している実コードは railties の finisher.rb にある
# railties-8.0.2.1/lib/rails/application/finisher.rb:24
ActiveSupport::Dependencies.autoload_paths.freeze
Finisher(仕上げ係)という名前のとおり、起動準備の最後の方で凍らせている
正常な起動の流れは以下のとおり
- 集計: 各 gem が autoload_paths に追加
- 凍結: Rails が autoload_paths を freeze
- 登録: Rails が autoload_paths を zeitwerk に push_dir で渡す
-
スキャン: zeitwerk がフォルダ内をスキャンし、「
app/models/user.rbはUser定数」の対応表を作成 -
稼働: プログラム中で
Userが呼ばれたら、zeitwerk が user.rb を読み込んで定数を提供
なぜ FrozenError が起きるか
.concat は Ruby の機能で「配列の後ろに別の配列をくっつける」操作
list = ["りんご", "みかん"]
list.concat(["バナナ"])
# => ["りんご", "みかん", "バナナ"]
今回のエラーは、誰かが住所録に対してこの .concat をしようとしたが、住所録はすでに freeze 済みだったため Ruby が「書き込み禁止」と弾いた、という構図
誰か:「住所録にフォルダ足すで」.concat
Ruby:「アカン、もう凍ってる!」
結果:FrozenError 💥
正常な順番と壊れた順番を比較するとこうなる
[正常な順番]
1. gem たちが住所録に .concat で追加
2. Rails が freeze
3. アプリ稼働
[壊れている順番]
1. gem たちが追加
2. Rails が freeze
3. 誰かが「あ、追加忘れてた」.concat ← 💥
そしてこの順番ズレを引き起こす一番ありがちな原因が、bootsnap の古いキャッシュだ
[古いキャッシュを信じた起動]
1. bootsnap「昔のメモやと A → B → C の順」
2. でも今の Rails は B → A → C の順で動くべき
3. 結果:「freeze」と「.concat」のタイミングが入れ替わる
4. → FrozenError 💥
キャッシュを消せば、最新の状態でメモを作り直す → 順番ズレ解消、という流れ
解決アプローチ 2案
| 案 | やること | なぜ効くか | おすすめ度 |
|---|---|---|---|
| 1. bootsnap キャッシュクリア | rm -rf tmp/cache/bootsnap |
古いメモを捨てて最新状態で作り直させる。順番ズレを解消 | ★★★ まずこれ |
| 2. フルバックトレース取得 | bundle exec rspec --backtrace ... |
RSpec の config.filter_rails_from_backtrace! で隠れていた gem 内部のフレームを表示 → 真犯人を特定する診断手段 |
★★ 案1で直らない時 |
bootsnap キャッシュ削除は安全
rm -rf tmp/cache/bootsnap
- 本体コードへの影響なし
- 次回起動時に bootsnap が自動でキャッシュを作り直す
- 副作用は「次の 1 回だけ起動が 10〜30 秒遅い」だけ
- 2 回目以降は元の速度に戻る
「困ったらまずキャッシュ消し」は Rails 開発の鉄板で、原因不明の起動エラーの 3〜4 割はこれで直る
RSpec のバックトレース隠蔽の落とし穴
spec/rails_helper.rb 内の以下の設定により、Rails / gem 内部のフレームが標準では非表示になる
config.filter_rails_from_backtrace!
普段はノイズ削減で便利だが、犯人が gem 内部にいる時は真因が隠れる。対処は次のいずれか
-
--backtraceオプションを付けて実行 - もしくは一時的に上記設定をコメントアウト
よくある誤解
- ❌「
turbo-railsやactiontextが悪さしてる」 → 正: エラーメッセージの配列の中身に表示されるだけ。犯人は別 - ❌「
view_componentが原因」 → 正:test/components/previews/を持たないプロジェクトではview_component-4.8.0/lib/view_component/engine.rb:41の.concatは早期 return でスキップされる - ❌「メモ(bootsnap キャッシュ)を消すと壊れる」 → 正: 本体コードに影響なし、次回起動で自動再生成
まとめ
FrozenError: can't modify frozen Array を見たら
- まず
rm -rf tmp/cache/bootsnapを試す(9 割これで直る) - 直らなければ
--backtraceで原因 gem を特定
3 つの概念の関係
- autoload_paths: 自動読み込み対象フォルダのリスト(データ・住所録)
- zeitwerk: リストを使って実際にファイルを読む仕組み(プログラム・司書)
- bootsnap: 起動を高速化するためのキャッシュ層(メモ帳係)
Rails 起動シーケンス
各 gem が autoload_paths に .concat で追加
↓
Rails が autoload_paths を freeze(finisher.rb:24)
↓
zeitwerk に push_dir で登録(finisher.rb:30 周辺)
↓
アプリ稼働開始
感想
-
bundle exec rspecで出たエラーは表面だけ見るとturbo-railsかactiontextを疑いたくなるが、実態は bootsnap が抱えてた古いキャッシュ -
「キャッシュ削除で直る」だけじゃなくて原因まで知れたのが収穫だった