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

週刊Railsウォッチ: Rails公式のdevcontainerでKamalをサポート、RailsBump.orgほか(20241108)

こんにちは、hachi8833です。Rails 8.0.0が思ったより早くリリースされました🎉

【速報】Rails 8.0.0がリリースされました

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やX.comでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

🔗 config.active_job.enqueue_after_transaction_commitの従来の設定方法が非推奨化

設定方法も従来のシンボルからブーリアンに変更する。

この設定値はキューアダプタに委譲されなくなった(この設定で制御される振る舞いは、ユーザーが制御できない振る舞いの変更をそのままにしておくと危険すぎるため)。

修正 #52675
同PRより


つっつきボイス:「enqueue_after_transaction_commitそのものが非推奨になったわけではなくて、これまで:never:always:defaultのいずれかを設定可能だったのをブーリアンのみ設定可能に変更するということのようですね」「コンフィグガイドからはenqueue_after_transaction_commitの項目が削除されていました」

参考: 3.15.5 config.active_job.enqueue_after_transaction_commit -- Rails アプリケーションの設定項目 - Railsガイド

🔗 Rails公式のdevcontainer内でもKamalをサポート

devcontainerにKamalのサポートを追加

従来は、生成したdevcontainerでDockerを利用できなかったため、Kamalを利用できなかった。

Joé Dupuis
同Changelogより

Kamalを実行するにはDockerが必要。devcontainerのdocker-in-docker機能を使うと、コンテナー内で Dockerを実行可能になる。

VS CodeのDocker credentialsの問題

VS Codeのdevcontainerでは、Dockerを非rootユーザーとして実行できないという問題がある。VS Codeはコンテナー内でDocker credentialの転送を試みるが、これにより非rootユーザーとしてDockerを使えなくなる。

これを解決するには、VS Codeのdevcontainer拡張機能でDocker Credential Helperを無効にする必要がある。

関連issue:

自分がこのことをRailsガイドのdevcontainerセクションに追記してもよい。

docker-in-dockerdocker-outside-docker

あるいは、docker-in-docker (dockerの中にdockerの中にdocker...)機能の代わりにdocker-outside-docker機能を使うことも可能。これにより、ホストDockerソケットがコンテナーに公開され、パフォーマンスは向上するが、セキュリティが犠牲になる。

ここではパフォーマンスを優先するのが合理的と思われるが、この機能はDocker経験の浅い初心者が使う可能性が高いため、セキュリティを優先するのが妥当と思われる。

特に強い意見があるわけではないので、docker-outside-docker機能に切り替えても構わない。

同PRより


つっつきボイス:「docker-in-dockerとdocker-outside-of-dockerという機能がDockerにあるんですね」「devcontainerはそもそもDockerで動きますし、例のKamalはデプロイ用のコンテナを作ってデプロイするツールなので、ざっと見た感じでは、devcontainerのコンテナからKamalのコンテナを作成する必要があるということかな」

Rails: Kamalデプロイツールはゲームチェンジャーになるか?(翻訳)

「プルリクメッセージによると、docker-outside-of-dockerはパフォーマンスはいいけどセキュリティが劣る、docker-in-dockerはセキュリティはいいけどパフォーマンスが劣るので、どっちにしようか悩んでるみたいですね」「以下の記事にわかりやすい図がある↓」

参考: dind(docker-in-docker)とdood(docker-outside-of-docker)でコンテナを料理する #Docker - Qiita

(docker-outside-of-docker)

(docker-in-docker)

dind(docker-in-docker)とdood(docker-outside-of-docker)でコンテナを料理する #Docker - Qiitaより

「devcontainersのリポジトリにあるdocker-in-dockerの設定を見ると、/var/lib/dockerをマウントする方法でやっていますね↓: 上の図のように、ホストマシンとは完全に独立したDocker環境をDockerの中に構築できるらしい」「なるほど」

参考: features/src/docker-in-docker at main · devcontainers/features

// https://github.com/devcontainers/features/blob/main/src/docker-in-docker/devcontainer-feature.json#L77
    "mounts": [
        {
            "source": "dind-var-lib-docker-${devcontainerId}",
            "target": "/var/lib/docker",
            "type": "volume"
        }
    ],

「同じくdocker-outside-of-dockerの設定ではsocketファイルをホストと共有するようになっている↓: たぶんこうすると、このDockerコンテナ内でdockerコマンドを使えば"ホスト側の"Dockerを参照できる」「なるほど」「その気になれば自分で自分のコンテナを終了させることも可能でしょうね😆」

参考: features/src/docker-outside-of-docker at main · devcontainers/features

// https://github.com/devcontainers/features/blob/main/src/docker-outside-of-docker/devcontainer-feature.json#L57-L63
    "mounts": [
        {
            "source": "/var/run/docker.sock",
            "target": "/var/run/docker-host.sock",
            "type": "bind"
        }
    ],

「docker-in-dockerは昔からあるわかりやすいセットアップかな」「rafaelのコメントを見ると、development環境でしか使わない機能なのでセキュリティよりパフォーマンスが重要、ならばdocker-outside-of-dockerにしようという結論になってますね」

「このあたりはもう少し詳しく見てみないとわからないけど、Kamalはローカルからデプロイするツールなのでdocker-outside-of-dockerにしたのもわかりますけど、デプロイツールとしての権限が結構強いので、docker-in-dockerの方がいいのではという気もしますね: たとえばCIとかデプロイ専用サーバーとかでdocker-outside-of-dockerにしたとすると、アクセスしてはいけない隣のDockerコンテナにアクセスしてしまうでしょうし」「う〜む」「docker-in-docker周りはそのうち調べてみようかな」

🔗 enumのラベルをキーワード引数としても渡せるようになった

ラベルをキーワード引数としてenumに渡せることについては、#41328でも意図されていたことなので、それをドキュメント化してテストでカバーした。

# rails/activerecord/lib/active_record/enum.rb Lines 60 to 65 in 0618d2d
 # Finally, it's also possible to explicitly map the relation between attribute and 
 # database integer with a hash: 
 # 
 #   class Conversation < ActiveRecord::Base 
 #     enum :status, active: 0, archived: 1 
 #   en
# rails/activerecord/test/cases/enum_test.rb Lines 702 to 713 in 0618d2d
 test "option names can be used as label" do 
   klass = Class.new(ActiveRecord::Base) do 
     self.table_name = "books" 
     enum :status, default: 0, scopes: 1, prefix: 2, suffix: 3 
   end 

   book = klass.new 
   assert_predicate book, :default? 
   assert_not_predicate book, :scopes? 
   assert_not_predicate book, :prefix? 
   assert_not_predicate book, :suffix? 
 end

コードベースを簡素化するためにサポートを削除することについては反対するつもりはないが、少なくとも削除する前に非推奨化する必要がある。

修正: #53407

同PRより


つっつきボイス:「これと次のプルリクはkamipoさんによるものです」「元々ラベルをキーワード引数としてenumに渡せるようになっていたのを、インターフェイスを整えてテストも足したんですね👍」

# activerecord/lib/active_record/enum.rb#L216
-   def enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods: true, validate: false, **options)
-     assert_valid_enum_definition_values(values)
-     assert_valid_enum_options(options)
+   def enum(name, values = nil, **options)
+     values, options = options, {} unless values
+     _enum(name, values, **options)
+   end

属性とデータベースのintegerの関係を明示的にハッシュでマッピングできる。

class Conversation < ActiveRecord::Base
  enum :status, active: 0, archived: 1
end

ActiveRecord::Enum - Ruby on Rails edge APIより

🔗 rename_enumにリネーム前/リネーム後の2つの名前を渡せるよう修正

rename_enumにはリネーム前と後の2つの名前を渡す必要があるのに、リネーム後の名前がオプショナルなのは変だ。

この修正により、rename_tableと同様にrename_enumにもリネーム前と後の名前をオプショナルでない形で渡すようになる。

同PRより


つっつきボイス:「リネームなのにリネーム後の名前が引数で省略できちゃうのはたしかに変😆」「PostgreSQLのアダプタで修正されていますね」「そういえばPostgreSQLのenumはたしかCREATE TYPEみたいなDDLで宣言するんでしたね」

# activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L577
      # Rename an existing enum type to something else.
-     def rename_enum(name, **options)
-       new_name = options.fetch(:to) { raise ArgumentError, ":to is required" }
+     def rename_enum(name, new_name = nil, **options)
+       new_name ||= options.fetch(:to) do
+         raise ArgumentError, "rename_enum requires two from/to name positional arguments."
+       end

参考: PostgreSQL 16ドキュメント: 8.7. 列挙型

Rails: PostgreSQLアダプタでenumのリネーム、enum値の追加とリネームが可能になった(翻訳)

🔗JSON.encodeでCIDR形式のIPAddrをサポート

ActiveSupport::JSON.encodeでCIDR記法をサポート

改修前:

ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0\""

改修後:

ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0/24\""

Taketo Takashima
同Changelogより


つっつきボイス:「IPAddrでは元々"172.16.0.0/24"のようなプレフィックス長付きの書式がサポートされていたのが、JSON.encodeすると/24の部分がJSONの文字列から落ちていたのを修正した: 今までこの機能を使っていたらbreaking changeになりそうですね」

参考: Classless Inter-Domain Routing - Wikipedia

# activesupport/lib/active_support/core_ext/object/json.rb#L242
-class IPAddr # :nodoc:
- def as_json(options = nil)
-   to_s
+unless IPAddr.method_defined?(:as_json, false)
+ # Use `IPAddr#as_json` from the IPAddr gem if the version is 1.2.7 or higher.
+ class IPAddr # :nodoc:
+   def as_json(options = nil)
+     if ipv4? && prefix == 32
+       to_s
+     elsif ipv6? && prefix == 128
+       to_s
+     else
+       "#{self}/#{prefix}"
+     end
+   end
  end
end

🔗 内部のquery()をリトライ可能にした

動機/背景

Closes: #53411

permanent_connection_checkoutが導入された(リンク切れ)とき、Active Recordは常にwith_connectionを使い、lease_connectionを決して使わない(リンク切れ)ように変更された。これにより、リクエスト中に独自に(またはgem 内で)lease_connectionを呼び出さないアプリでは、コネクションが現在のスレッド/ファイバーに固定されなくなる。必要に応じてコネクションをチェックイン/チェックアウトすることで、1回のリクエスト中にコネクションを検証する必要が生じる回数が大幅に増加していた(pingが増えるとレイテンシも増加する)。

良いニュースとしては、Active Recordがコネクションの検証をdefer(先延ばし)(リンク切れ)するようになり、明示的なpingの代わりに再試行可能なクエリを検証として利用可能になったというのがある。

詳細

このコミットにより、内部のすべてのSELECT query()がリトライ可能になり、コネクション検証で利用可能になる。これは、スキーマキャッシュダンプを使うアプリケーションの運用ではあまり有用ではない可能性もあるが、開発中のアプリケーションやproductionでダンプを使わないアプリケーションのレイテンシは確実に短縮される。

シーケンスを更新するためにquery_valueが使われていた場所がいくつかあったが、これらはinternal_executeを使う形に変更された。理由は、自動的にリトライされるのは冪等なSELECTクエリだけであり、Active Recordがこれらのクエリの戻り値を実際には気にしないため(つまりinternal_exec_queryを使う理由がない)。

追加情報

このプルリクのフォローアップもさらに行うつもりだが、既に現状のプルリクが(概念上は😅)だいぶ大きくなっている。
同PRより


つっつきボイス:「lease_connectionは以前全廃されたメソッドでしたね(ウォッチ20240402)」「マルチスレッド/マルチFiberが関連してくる内部改修は大変そう」

🔗 CurrentAttributes#attributesのバグを修正

ActiveSupport::CurrentAttributes#attributesが呼び出しのたびに新しいハッシュオブジェクトを返すよう修正された

従来は、メソッドが呼び出されるたびに同じハッシュオブジェクトが返されていた。

fatkodima
同Changelogより

CurrentAttributesのバグと思われるものを踏んだ。

自分たちは信頼性の高いプッシュ機能を備えたSidekiqを使っている。基本的にSidekiqはジョブをRedisにプッシュするが、Redisが一時的に利用できなくなった場合は、ジョブのペイロードをメモリ内キューに保存して、再起動時にプッシュする。ジョブがキューに入れられたときのCurrent.attributesが、ジョブのペイロードの一部として保存される。ただし、現在Current.attributesは一種のグローバルオブジェクト(メモリ内で常に同じオブジェクト)であるため、新しいオブジェクトが別のCurrent.set値でプッシュされると、(前述のメモリ内キュー内にある)古い参照が変更され、誤った値が含まれるようになった😱

もちろん、これは呼び出し側でCurrent.attributes.dupすれば修正可能だが、この振る舞いが驚くべきものであるため、(Sidekiqコードベースで行われるような)対応が忘れられてしまう。これはRails側で修正する必要があると思われるため、このプルリクで修正する。

CurrentAttributesの意外な振る舞いをもうひとつ発見した。

Current.attributes # => {}
Current.set(foo: 1) { }
Current.attributes # => { foo: nil } <-----

おそらくバグではなさそうだが、キーの有無に依存したり、キーが存在しないのではなくnil値になるだけで予期しない振る舞いが発生する可能性があるため、少なくとも期待される結果ではない。了承いただければ、これも合わせて修正可能。
cc @byroot
同PRより


つっつきボイス:「ActiveSupport::CurrentAttributesはグローバル的に属性を受け渡しできるしくみでしたね」「CurrentAttributesは競合状態のようなものに対してとても敏感なんですが、attributesが同じオブジェクトを返していたのはたしかにバグで、破壊的メソッドで書き換えると壊れるやつ: 呼び出し側でdupすれば回避できるけど、おっしゃる通り"驚くべき振る舞い"ですね」

Rails API: ActiveSupport::CurrentAttributes(翻訳)

🔗 ActiveSupport::Durationsinceagoのメモリアロケーションを削減

現在のCRubyバージョンでは、HashでEnumerable#injectを使うと、 Hash#eachが呼び出されるたびに配列が強制的にアロケーションされてしまう。Hash#eachを直接使うべき。

あまりエレガントな書き方ではないが、injectreduceの仕組みを理解する必要もなくなる。

簡単なベンチマーク:

require 'active_support/all'
require 'benchmark'

p a = ActiveSupport::Duration.build(2716147)
p a.parts

time = Time.current

pre_alloc = GC.stat(:total_allocated_objects)
puts(Benchmark.measure do
  300_000.times do
    a.since(time)
  end
end)
post_alloc = GC.stat(:total_allocated_objects)
puts "alloced=#{post_alloc - pre_alloc}"
diff --git a/before b/after
--- a/before
+++ b/after
@@ -1,4 +1,4 @@
 1 month, 1 day, and 1 second
 {:months=>1, :days=>1, :seconds=>1}
-  2.311823   0.032300   2.344123 (  2.439997)
-alloced=14101243
+  2.223739   0.038590   2.262329 (  2.316780)
+alloced=12601105

スピードは最大5%改善し、オブジェクト数も最大10%削減された。

これは、yjit-benchのlobsters benchmarkでアロケーションをプロファイリングしているときに発見した。
同PRより


つっつきボイス:「Enumerable#injectだとアロケーションが発生してしまうので、スマートじゃないけどHash#eachを使う書き方に変えてメモリアロケーションを削減したのか↓」「現在のCRubyで発生するとあるので、Rubyの実装に依存しているらしい: 本当ならRubyのEnumerable#injectで修正したいところでしょうね」「たしかに」

# activesupport/lib/active_support/duration.rb#L486
      def sum(sign, time = ::Time.current)
        unless time.acts_like?(:time) || time.acts_like?(:date)
          raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
        end
        if @parts.empty?
          time.since(sign * value)
        else
-         @parts.inject(time) do |t, (type, number)|
-           if type == :seconds
-             t.since(sign * number)
-           elsif type == :minutes
-             t.since(sign * number * 60)
-           elsif type == :hours
-             t.since(sign * number * 3600)
-           else
-             t.advance(type => sign * number)
-           end
+         @parts.each do |type, number|
+           t = time
+           time =
+             if type == :seconds
+               t.since(sign * number)
+             elsif type == :minutes
+               t.since(sign * number * 60)
+             elsif type == :hours
+               t.since(sign * number * 3600)
+             else
+               t.advance(type => sign * number)
+             end
          end
+
+         time
        end
      end

🔗 テストコードでContent-Typeがnilの場合の処理を修正

#47598を終了してクローズする。

動機/背景

このプルリクを作成した理由は、@rubendinhoがプルリク#47598を引き継ぐ人が求められていたため。

元の問題は、ActionController::TestCaseテストでリクエストのContent-Typeを明示的にnilに設定してPOSTリクエストを実行したときにRailsが発するundefined method 'to_sym' for nil NoMethodErrorエラーが少しわかりにくいというもの。

詳細

このプルリクは、ActionController::TestCaseのContent-Typeチェックを変更して、nil Content-Typeに配慮する。これにより、ややわかりにくいNoMethodErrorではなく、より具体的なUnknown Content-Typeエラーをraiseするようになる。

元のプルリクになかったテストを追加しておいた。
同PRより


つっつきボイス:「これは改善というよりテストコードのバグ修正でしょうね: 元のコードだとmime_typenilのときにシンボルに変換できなくてNoMethodErrorになるので、そこから先の分岐に進んでいなかった↓」「ぼっち演算子&.で修正したんですね」

# actionpack/lib/action_controller/test_case.rb#L111
-         case content_mime_type.to_sym
+         case content_mime_type&.to_sym
          when nil
            raise "Unknown Content-Type: #{content_type}"
          when :json
            data = ActiveSupport::JSON.encode(non_path_parameters)
          when :xml
            data = non_path_parameters.to_xml
          when :url_encoded_form
            data = non_path_parameters.to_query
          else
            @custom_param_parsers[content_mime_type.symbol] = ->(_) { non_path_parameters }
            data = non_path_parameters.to_query
          end

RailsのObject#tryがダメな理由と効果的な代替手段(翻訳)

🔗Rails

🔗 RailsBump.org(Ruby Weeklyより)


つっつきボイス:「記事は、RailsBump.orgというサイトのメンテナンスとサポートをFastRuby.ioが引き受けたという話ですが、このRailsBump.orgってちょっと便利そうですね↓」


RailsBumpより

「お、Railsバージョンごとのgemの互換性情報に加えて、Gemfile.lockファイルを貼り付ければ、そこで使われているgemの互換性を個別にチェックしてくれるんですね、これはよさそう👍」「RailsバージョンごとのdiffをチェックできるRailsDiff↓と合わせて使うと、Railsのアップグレードで便利そうですね」

「RailsBumpの動作を見る限りでは、gemを動的にチェックしにいっているっぽいですね」「gemspecのdependencyを取りに行っているだけならあまり意味がないだろうから、他にもチェックしているのかも: ざっと調べた限りでは、以下のcheckerというツール↓を使って互換性をチェックしているらしい」

railsbump/checker - GitHub

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

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


今週は以上です。

バックナンバー(2024年度第3四半期)

週刊Railsウォッチ: Rails 8.0 Beta 1リリース、Railsのメンテナンスポリシー更新ほか(20241024)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

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