Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: new_framework_defaultsの設定が反映されるタイミングを無邪気に信じてはいけない(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: Rails アップグレードガイド - Railsガイド

以下の記事にも同様のトピックが含まれていますので、参考までにどうぞ。

Rails: configuration あるいは load_defaults の話

Rails: new_framework_defaultsの設定が反映されるタイミングを無邪気に信じてはいけない(翻訳)

忙しい人向けのまとめ

Railsをアップグレードするときに生成される、new_framework_defaults_*.rb(=アップグレード後のRails用に新しいデフォルト設定が集約されたファイル: *にはRailsバージョンが入る)の設定がいつどんなタイミングでフレームワーク内部に反映されるのかを、根拠もなしに信じ込まないこと。

必ず設定を自分でテストするか、さもなければ、new_framework_defaults_*.rbでコメントアウトを解除(つまり有効にする)した設定は、そのファイルに置いたままにせず、application.rbファイル内にあるconfig.load_defaultsメソッド行の直後の行に移し替えておくこと。

Railsアプリケーションを新バージョンにアップグレードするときは、new_framework_defaults_*.rbイニシャライザに記載されているコメントアウト済みの設定を精査して、設定を1つずつコメント解除する作業がつきものです。変更が1つずつ段階的に反映されるので、この方法なら安全だと思えるものです。

しかし現実には、こうやってるから安全なはずだと思い込むのは誤解の元であり、危険ですらあります。

本記事では、私たちがRails7.1にアップグレードしたときに見事に当てが外れた、設定上の微妙な落とし穴についてお話ししたいと思います

🔗 どのタイミングで当てが外れるか

プロジェクトをRails 7.1にアップグレードしたときに、config/initializers/new_framework_defaults_7_1.rbファイルで以下の設定をコメント解除して有効にしました。

# config.active_record.default_column_serializer = nil

これ以外は何も変えておらず無効なままにしてありました。この変更によってエラーが発生すると予想していたにもかかわらず、やってみると、驚いたことに何一つ振る舞いが変わらなかったのです。

そこでRailsコンソールを開いて以下をチェックしてみました。

irb(main):001> Rails.application.config.active_record.default_column_serializer
#=> nil
irb(main):002> ActiveRecord::Base.default_column_serializer
#=> ActiveRecord::Coders::YAMLColumn

当初この設定は時代遅れか、はたまたドキュメントが間違っているのかと思いましたが、深掘りしてみたところ、驚くべき発見があったのです。

🔗 背後で何が起きていたか

Railsの起動時には、さまざまなイニシャライザが実行されますが、実行されるイニシャライザは、すべてRails::Railtieinitializerメソッドで定義されています。これらはフレームワークとそのコンポーネントのセットアップを行うために、特定の順序で実行されます。

▶読み込み順のリスト(クリックで展開)
active_support.deprecator
action_dispatch.deprecator
active_model.deprecator
active_job.deprecator
action_controller.deprecator
active_record.deprecator
action_mailer.deprecator
action_view.deprecator
active_storage.deprecator
action_mailbox.deprecator
action_text.deprecator
action_cable.deprecator
load_environment_config
load_environment_hook
load_active_support
set_eager_load
initialize_logger
initialize_cache
action_mailer.logger
action_mailer.set_configs
action_mailer.set_autoload_paths
set_load_path
set_autoload_paths
set_eager_load_paths
setup_once_autoloader
bootstrap_hook
set_secrets_root
active_support.isolation_level
active_support.raise_on_invalid_cache_expiration_time
active_support.set_authenticated_message_encryption
active_support.reset_execution_context
active_support.reset_all_current_attributes_instances
active_support.deprecation_behavior
active_support.initialize_time_zone
active_support.initialize_beginning_of_week
active_support.require_master_key
active_support.set_configs
active_support.set_hash_digest_class
active_support.set_key_generator_hash_digest_class
active_support.set_default_message_serializer
active_support.set_use_message_serializer_for_metadata
action_dispatch.configure
active_model.secure_password
active_model.i18n_customize_full_message
global_id
web_console.deprecator
active_job.logger
active_job.custom_serializers
active_job.set_configs
active_job.set_reloader_hook
active_job.query_log_tags
active_job.backtrace_cleaner
action_controller.assets_config
action_controller.set_helpers_path
action_controller.parameters_config
action_controller.set_configs
action_controller.compile_config_methods
action_controller.request_forgery_protection
action_controller.query_log_tags
action_controller.test_case
active_record.initialize_timezone
active_record.postgresql_time_zone_aware_types
active_record.logger
active_record.backtrace_cleaner
active_record.migration_error
active_record.cache_versioning_support
active_record.use_schema_cache_dump
active_record.check_schema_cache_dump
active_record.define_attribute_methods
active_record.warn_on_records_fetched_greater_than
active_record.sqlite3_production_warning
active_record.sqlite3_adapter_strict_strings_by_default
active_record.set_configs
active_record.initialize_database
active_record.log_runtime
active_record.set_reloader_hooks
active_record.set_executor_hooks
active_record.add_watchable_files
active_record.clear_active_connections
active_record.set_filter_attributes
active_record.set_signed_id_verifier_secret
active_record.generated_token_verifier
active_record_encryption.configuration
active_record.query_log_tags_config
active_record.unregister_current_scopes_on_unload
active_record.message_pack
action_mailer.compile_config_methods
test_unit.line_filtering
set_default_precompile
quiet_assets
asset_url_processor
asset_sourcemap_url_processor
sprockets-rails.deprecator
add_routing_paths
add_locales
add_view_paths
add_mailer_preview_paths
add_fixture_paths
prepend_helpers_path
load_config_initializers
wrap_executor_around_load_seed
engines_blank_point
append_assets_path
action_view.logger
action_view.caching
action_view.setup_action_pack
action_view.collection_caching
active_storage.configs
active_storage.attached
active_storage.verifier
active_storage.services
active_storage.queues
active_storage.reflection
action_view.configuration
active_storage.asset
active_storage.fixture_set
action_mailbox.config
action_text.attribute
action_text.asset
action_text.attachable
action_text.helper
action_text.renderer
action_text.system_test_helper
action_text.configure
action_cable.helpers
action_cable.logger
action_cable.health_check_application
action_cable.asset
action_cable.set_configs
action_cable.routes
action_cable.set_work_hooks
add_generator_templates
setup_main_autoloader
setup_default_session_store
build_middleware_stack
define_main_app_helper
add_to_prepare_blocks
run_prepare_callbacks
eager_load!
finisher_hook
configure_executor_for_concurrency
add_internal_routes
set_routes_reloader_hook
set_clear_dependencies_hook

イニシャライザのリストにあるactive_record.set_configsは、Rails.application.config.active_record設定1を用いて、メソッド名をセッターとしてActiveRecord::Basesendで送信し、値を渡すことで Active Record をセットアップしています。

より正確に言うと、ActiveSupport.on_load(:active_record)コールバックは「ここで」登録されていたのです。私は、このコールバックが(予想に反して)登録直後のタイミングで実行されていたことを、コールバックブロック内にブレークポイントを挿入する形で実際に確かめました。つまり、ActiveRecord::Baseクラスは既に読み込み済みだったのです(なお、ActiveSupport.run_load_hooks(:active_record, Base)呼び出しは、ActiveRecord::Baseクラス定義の最下部 にあります)。

これらはどれも、load_config_initializersイニシャライザが実行される前のタイミングで発生しました。そしてこのイニシャライザこそが、config/initializers/ディレクトリにあるnew_framework_defaults_*.rbなどのイニシャライザを読み込んでいるのです。

バックトレースを調べると、rails_event_store_active_record gem、つまりclass Event <::ActiveRecord::Base定義で起きていたことが示されていました。

RailsEventStore/rails_event_store - GitHub

rails_event_store gemの問題は、弊社のPawełによって既に修正済みであり(#1906: 彼のおかげでこの変更はまもなくRails Event Store 2.17.0でリリースされる予定です)、そればかりか、この問題はこのgemだけの問題ではないことも彼が発見してくれました。他の有名なgemでどんな問題が起きているかをチェックしてみてください。

  • friendly_id gem(#823
  • globalize gem(#786
  • pg_hero(#232
  • delayed_job_active_record(#128
  • Railsのissue #31285#52740でも議論されています

つまり、これらのgemのどれかが、Railsの起動プロセス中にActiveRecord::Base などのコンフィグ可能なRailsクラスを読み込むと、ActiveSupport.on_loadコールバックが予想よりも早いタイミングで実行されてしまい、new_framework_defaults_*.rbにある一部の設定が反映されなくなる可能性があるのです。

new_framework_defaults_*.rbにある設定項目のうち、少なくとも以下のリストにあるものは、この問題の影響を受ける可能性があります(すべてを網羅しているわけではありません)。

Rails.application.config.active_record.belongs_to_required_by_default = true  # Rails 5.0 default
Rails.application.config.active_record.cache_versioning = true                # Rails 5.2 default
Rails.application.config.active_record.collection_cache_versioning = true     # Rails 6.0 default
Rails.application.config.active_record.has_many_inversing = true              # Rails 6.1 default
Rails.application.config.active_job.retry_jitter = 0.15                       # Rails 6.1 default
Rails.application.config.action_mailer.deliver_later_queue_name = nil         # Rails 6.1 default
Rails.application.config.active_record.partial_inserts = false                # Rails 7.0 default
Rails.application.config.active_record.automatic_scope_inversing = true       # Rails 7.0 default
Rails.application.config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction = false # Rails 7.1 default
Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256               # Rails 7.1 default
Rails.application.config.action_controller.allow_deprecated_parameters_hash_equality = false                # Rails 7.1 default
Rails.application.config.active_record.default_column_serializer = nil          # Rails 7.1 default
Rails.application.config.active_record.raise_on_assign_to_attr_readonly = true  # Rails 7.1 default (when eager_load is enabled)

🔗 問題を検出する

このシナリオを踏んでいるかどうかを検出するために、以下の簡単なスクリプトを作成しました。このスクリプトは、コンフィグ可能なRailsクラスやレコードが、Gemfileに記載されているgemによってRailsアプリの起動中に早すぎる段階で読み込まれたときに、それらのクラスやレコードを記録するActiveSupport.on_load にフックします。

# premature_load_check.rb
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("Gemfile", __dir__)

require 'active_support'
require "bundler/setup"

early_load = false

[:action_mailer, :active_job, :active_record, :action_controller].each do |rails_module|
  ActiveSupport.on_load(rails_module) do
    early_load = true
    warn <<~MSG
      ⚠️  #{rails_module} is already loaded at boot.
      This can prevent Rails.application.config.#{rails_module} settings in `new_framework_defaults.rb` from working.
      Trace:
      #{caller.join("\\n")}
    MSG
  end
end

Bundler.require

unless early_load
  puts "✅  Rails modules were not loaded prematurely."
end

このスクリプトをRailsアプリのディレクトリにコピーして、bundle exec ruby premature_load_check.rbを実行することで、gemによるRailsコンポーネントの早期読み込みを検出できるようになります。

追加情報(原文): Shinichi Maeshima氏による以下のgemもどうぞ。これも上のスクリプトと基本的に同じ機能を果たします。

willnet/a-nti_manner_kick_course - GitHub

🔗 より安全なアップグレード方法

Railsのモジュールがgemによって読み込まれるタイミングが通常よりも早すぎることに気づいたときは、new_framework_defaults_*.rbイニシャライザファイル内にある設定に十分すぎるぐらい注意を払う必要があります。

アップグレードをより安全に行う方法は、「設定をコメント解除して有効にするときは、その設定をconfig/application.rbファイル内の、config.load_defaultsメソッドが書かれている行の直下の行に移動する」というものです。こうすることで、有効にした設定が、他のgemが読み込まれるタイミングやRailsの内部設定が行われるタイミングよりも前に、確実に反映されるようになります。

# config/application.rb
module MyApp
  class Application < Rails::Application
    config.load_defaults "7.0" # この下に置く
    config.active_record.default_column_serializer = nil
  end
end

ちなみにですが、この振る舞いは、特定のRailsバージョンのデフォルト設定が読み込まれるときにconfig.load_defaultsメソッドが内部的に行っていることそのものです(参考)。

🔗 まとめ

Railsでは、new_framework_defaults_*.rbイニシャライザファイルによって、フレームワークのデフォルト設定を強力に制御できますが、扱いには十分注意する必要があります。

このファイルに関する問題がいくつも存在していることをお忘れなく。特にサードパーティgemに強く依存しているアプリでは、初期化のタイミングに関する仮定をテストで裏付けること。

Railsバージョンのデフォルト設定を新しいものに切り替えるときの安全を確保するために、上で紹介したようなツールを活用しましょう。

new_framework_defaults_*.rbファイルの設定を有効にするときは、必ずapplication.rbに移し替える形で行うこと。

複数の設定を一度に変更したりせず、必ず1つずつ設定を変更しましょう。変更したら、必ずアプリを徹底的にテストしましょう。

gem作者の方へ: 自分のgemのコードがRailsコンポーネントをどんなタイミングで読み込むかについて、どうかご配慮をお願いします。ActiveSupport.on_loadメソッドを使って、本当に必要になるまで読み込みを遅延するようお願いします。

関連記事

Rails: configuration あるいは load_defaults の話

Rails 6.0 -> 6.1 -> 7.0アップグレードの備忘録

Railsのアップグレードを成功させるための知見リスト(翻訳)


  1. 訳注: これはRails 7.2で削除されました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。