Rails in 1.44MB Challenge #kaigionrails_fd0 に参加した

osyoyu さんが企画したイベント ですね。

Rails in 1.44MB Challenge とは

Rails一式をフロッピーディスク(1.44MB)に収めてください。会場でフロッピーディスクに書き込んで動かしてプレゼントします!

Railsは結構でっかいフレームワークです。依存しているGemを全部足しあげると50MBぐらいになります。機能削減・圧縮・ズル、あらゆるテクを駆使して1/30のサイズにしてください!

ということで、50MB を何とかして 1.44MB に削るコンテストです。Kaigi on Rails 2025 のブース企画の中?いやほとんど個人企画だな?で開催されていました。

達成できたのか

$ du -sh .
1.3M    .
$ ./bin/rails s
=> Booting WEBrick
=> Rails 8.0.3 application starting in development http://localhost:3000
=> Run `bin/rails server --help` for more startup options
[2025-09-29 14:04:17] INFO  WEBrick 1.9.1
[2025-09-29 14:04:17] INFO  ruby 3.4.5 (2025-07-16) [arm64-darwin24]
[2025-09-29 14:04:17] INFO  WEBrick::HTTPServer#start: pid=27522 port=3000
Started GET "/?name=onk" for 127.0.0.1 at 2025-09-29 14:04:25 +0900
Processing by HomeController#index as HTML
  Parameters: {"name" => "onk"}
Completed 200 OK in 0ms (Views: 0.0ms | GC: 0.0ms)


127.0.0.1 - - [29/Sep/2025:14:04:24 JST] "GET /?name=onk HTTP/1.1" 304 0
- -> /?name=onk

で、達成としてもヨサソウ。

改めてレギュ読んでたら「Ubuntu で動く」とあって、やっべー忘れてた、となっているのが今です。試しにビルドしたらダメっぽかったので重い C 拡張 gem を殺さなきゃ……。

Rails が動く」の私の定義

普通の controller が動く、をゴールにしたい。

# config/routes.rb
Rails.application.routes.draw do
  root "home#index"
end
# app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    render plain: "Hello, #{params[:name] || "World"}!"
  end
end
$ rails s

で、http://localhost:3000/ にアクセスして render されること。

Started GET "/" for 127.0.0.1 at 2025-09-29 13:47:39 +0900
Processing by HomeController#index as HTML
Completed 200 OK in 0ms (Views: 0.1ms | GC: 0.0ms)

理想的には scaffold の generator が動いて、その機能を全部網羅する、がやりたいけど、いつも通りの場所にアプリケーションコードを置けて、routing と render :plain があれば許せる。

rails s で起動させることを考えると railties も要るし、app/* を読み込むことを考えると zeitwerk も要るだろう。

状況確認

rails gem は依存多すぎるので入れない、actionpackrailties は必要として、これだけに削って、vendor/bundle を tgz で固めると達成できるのか。

source "https://rubygems.org"
gem "actionpack"
gem "railties"
$ bundle config set path "vendor/bundle" && bundle install
$ du -sh vendor/bundle/ruby/3.4.0/*
 40K    vendor/bundle/ruby/3.4.0/bin
  0B    vendor/bundle/ruby/3.4.0/build_info
7.4M    vendor/bundle/ruby/3.4.0/cache
  0B    vendor/bundle/ruby/3.4.0/doc
3.7M    vendor/bundle/ruby/3.4.0/extensions
 24M    vendor/bundle/ruby/3.4.0/gems
4.0K    vendor/bundle/ruby/3.4.0/plugins
184K    vendor/bundle/ruby/3.4.0/specifications

vendor/bundle 以下には cache/ に gem ファイルがそのまま置いてあったりするので固めるのが面倒だな。とりあえず extensions が 3.7M、gems が 24M あることが分かる。

$ rm -rf vendor/bundle/ruby/3.4.0/cache
$ tar czf vendor_bundle.tgz vendor/bundle
$ ls -hl vendor_bundle.tgz
-rw-r--r--  1 onaka  staff   6.1M  9 29 13:28 vendor_bundle.tgz

と最小の Gemfile で入ったものを tgz で固めても 6.1MB あるので、依存をもりもり削らないと達成できないことが分かる。なお試しに zstd や brotli にしても大差なかった。

この時点で C 拡張の重いヤツ:

$ du -sh vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/*
172K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/bigdecimal-3.2.3
292K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/date-3.4.1
 60K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/erb-5.0.2
 88K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/io-console-0.8.1
2.9M    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/nokogiri-1.18.10
 80K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/psych-5.2.6
 60K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/racc-1.8.1
 88K    vendor/bundle/ruby/3.4.0/extensions/arm64-darwin-24/3.4.0/stringio-3.1.7

bigdecimal, date が重いのは、いやー外して動くのか?

ruby コードとして重い gem トップ 10 はこう。

$ du -s vendor/bundle/ruby/3.4.0/gems/* | sort -rn | head
14296   vendor/bundle/ruby/3.4.0/gems/nokogiri-1.18.10
5168    vendor/bundle/ruby/3.4.0/gems/rdoc-6.14.2
3440    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3
3368    vendor/bundle/ruby/3.4.0/gems/activesupport-8.0.3
2840    vendor/bundle/ruby/3.4.0/gems/concurrent-ruby-1.3.5
2792    vendor/bundle/ruby/3.4.0/gems/actionpack-8.0.3
2272    vendor/bundle/ruby/3.4.0/gems/actionview-8.0.3
1392    vendor/bundle/ruby/3.4.0/gems/date-3.4.1
1040    vendor/bundle/ruby/3.4.0/gems/irb-1.15.2
1024    vendor/bundle/ruby/3.4.0/gems/rack-3.2.1

nokogiri や rdoc はまぁ削れるだろう (勘) けど、railtiesactivesupport も削る必要がありそうな予感がありますね。

依存 gem を削減したい

Bundler には以下のように fork を指定する機能がある。

# GitHub 上の fork を指定したいとき
gem "foo", github: "user/name", ref: "commit"
# ローカルの任意の path を指定したいとき
gem "bar", path: "path/to/gem"

ので、各 gem の gemspec を真面目に書き換えながらやるかなーと考えたけど、publish されている gem ってテストコード削っていたり、割と丁寧目に gem 自体が軽くなるような処理がされているンですよね。

# actionpack.gemspec
Gem::Specification.new do |s|
  ...
  s.files        = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"]
  ...
end

lib だけにするとかを再現するのが面倒そうなので、

  • bundle install したあとのコードを直接編集していく
  • bundle を介さずに gem を読み込む

という作戦にしました。

bundle を介さずに gem を読み込む

$LOAD_PATH を弄って require するのは知っていたので、claude code にに丸投げした。(Kaigi on Rails の発表も聞きたいからね……)

コードとしては Bundler 以前の世界というか、普段 rubygems 作るときも exe とか spec_helper に同じコードは書くよねーって感じですね。

# config/boot.rb
gem_path = File.expand_path("../vendor/bundle/ruby/3.4.0/gems", __dir__)
Dir.glob("#{gem_path}/*").sort.each do |gem_dir|
  lib_path = "#{gem_dir}/lib"
  $LOAD_PATH.unshift(lib_path) if File.directory?(lib_path)
end

実際の gem 削減

vendor/bundle/ruby/3.4.0/gems/foo-x.y.z/ を消して動けばヨシ、動かなかったら require を見つけては殺す、みたいな仕事です。これも claude code にどんどん投げてる。

例えば actionview を外したかったら、https://github.com/rails/rails/blob/v8.0.3/actionpack/lib/abstract_controller/rendering.rb#L6-L7 とか https://github.com/rails/rails/blob/v8.0.3/actionpack/lib/abstract_controller/rendering.rb#L20 とかを消していく感じです。

diff --git a/vendor/bundle/ruby/3.4.0/gems/actionpack-8.0.3/lib/abstract_controller/rendering.rb b/vendor/bundle/ruby/3.4.0/gems/actionpack-8.0.3/lib/abstract_controller/rendering.rb
index 346bb4e..e9ebcc7 100644
--- a/vendor/bundle/ruby/3.4.0/gems/actionpack-8.0.3/lib/abstract_controller/rendering.rb
+++ b/vendor/bundle/ruby/3.4.0/gems/actionpack-8.0.3/lib/abstract_controller/rendering.rb
@@ -3,8 +3,8 @@
 # :markup: markdown
 
 require "abstract_controller/error"
-require "action_view"
-require "action_view/view_paths"
+# require "action_view"
+# require "action_view/view_paths"
 
 module AbstractController
   class DoubleRenderError < Error
@@ -17,7 +17,7 @@ module AbstractController
 
   module Rendering
     extend ActiveSupport::Concern
-    include ActionView::ViewPaths
+    # include ActionView::ViewPaths
 
     # Normalizes arguments and options, and then delegates to render_to_body and
     # sticks the result in `self.response_body`.

railties がデフォルトで入れてくる Rack middleware が邪魔だなーって消したのもある。

https://github.com/rails/rails/blob/v8.0.3/railties/lib/rails/application/default_middleware_stack.rb#L14-L110

あと、このコード見たら分かるけど、結構 if config.respond_to?(:active_record) 等がある。下手に config.acitve_record = ActiveSupport::OrderedOptions.new 等で起動時のエラーを回避しようとすると面倒なことになるので気をつけて。なので config/environments/*.rb の各行が何をやっているのかは知っておいた方が便利と思う。

concurrent-ruby 外し

nokogiri や rdoc、actionview 辺りはどこで使っているのか明確だし外せる自信があったのでサクッと作業したけど、concurrent-ruby はちょっと悩んだ。300KB 以上あるので外すのがいいに決まっているが、外せるのか。rails 全体の中で 34 件しか require されてないならまぁ行けるのかなーと思って取りかかった。

$ rg 'require "concurrent/' -l | wc -l
      34

だいたい

-Concurrent::Map.new
+Hash.new

の書き換えでいけるやろと思って作業させました。概ね行けたけど、僕まだ Concurrent::Map の実装読んだこと無いンだよな。そもそも git clone すらしてなかったと思って今回は反省した。

minifyrb

koic/minifyrb の存在を知っていたので、コレで適当にガーッと。

$ fd --type f "\.rb$" | xargs -I{} sh -c "minifyrb {} -o {}" 

koic.hatenablog.com

gzip する前にコメント行を削除するのが効くといいなと思ってやりました。コメントを削るだけなら他にやりようはあったと思うけど、目の前に落ちてたのでお手軽だった。

とりあえず ActiveSupportActionPack 全部に掛けたけど、何ファイルかは壊れたので戻してます。(フィードバックチャンス)

railties から generator 削除

railties でっけーなー、減らせるかな? と思って眺めたら、generators ディレクトリが 1MB って出たので。

$ du -hd 3 vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/
8.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/minitest
 32K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/tasks
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/plugin
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/rackup
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/testing
 28K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/test_unit
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/autoloaders
8.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/rack
 12K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/api
 44K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/templates
164K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/commands
 24K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/command
1.0M    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/generators
 56K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/application
8.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/railtie
 24K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/engine
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails/console
1.6M    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib/rails
1.6M    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/lib
4.0K    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/exe
1.7M    vendor/bundle/ruby/3.4.0/gems/railties-8.0.3/

もう 60% ぐらい generator じゃん。いやー、generator が rails の肝なんだなーって実感しますね。

tgz から起動

ソラで書ける自信無かったのでまるっと claude code に任せた。

tar czf vendor_bundle.tgz vendor/bundle した vendor_bundle.tgz があります。起動時に伸張するように config/boot.rb を書き換えてください

一発目は system("tar zxf ...") するコードが出てきたので、「pure ruby でできる?」って聞いたら zlib は予想通りとして、Gem::Package::TarReader が出てきた。これは初知りライブラリでした。

github.com

感想

数時間で遊べる、ちょうどいい難易度のクイズだった。osyoyu さんありがとうございます。

Gemfile.lock は普段から眺めているのでまぁまぁ何に依存しているか知っている、という前提を置けるのが良かったね。レギュB の Ruby 自体を最小化する方は普段から (ruby-build に任せずに) ビルドしまくってないと勘所が掴めなそう。

Bundler 使わずに gem 読むの 10 年ぶりに手書きしてるなーとか、actionview とこう密結合してるのかーとか、concurrent-ruby こんなでっかいの!とか、イマドキの gem は test 系の不要ファイルを本当に含んでないなとか、date や bigdecimal が gemify される前ならこんな苦労はとか、トークを聞きながら AI Agent に任せる粒度としてちょうどいいとか、面白かった点がいくつもあった。

皆さんもやってみてください :)

しかし、継続的に頭を使う必要があるとトークを聞けなくなるので、裏企画はあんまり増えないで欲しい!(>_<) これは途中で「やばっ、話している内容が頭に残ってねぇ」と気づいて焦った顔で発言しています。 アルコールを多少控えてホテルに帰ってから手を動かせば良いのはそうなんだが……。