kinoppyd.dev

blog

products

accounts & contact

Springは何をしているのか?

posted at 2023-12-04 15:38:00 +0900 by kinoppyd

このエントリは、SmartHR Advent Calendar 2023 のシーズン2の3日目です。なんすかシーズン2って。

Spring、使っていますか? Rails4.1から追加された新しいRailsの起動を早くするやつです。べつに新しくないですね。なんならRails7からは rails new のときにデフォルトで追加されなくなっちゃいました。DHH曰く「最近はコンピュータの性能上がったから別に起動とか大したことないっしょ」らしいですが、本当にそうか? と思います。Springは大量のGemをロードしたりinitializerとかに大量のコードが書かれている大きなアプリケーションを起動する上で未だに一定の効果を発揮してくれる一方で、なんだかよくわからない謎の機能くらいのイメージしか持っていない方も多いと思います。僕もそうでした。なんとなく別プロセスでRails立ち上げて……みたいな仕組みを耳にしたことはありましたが、えぇなんか怖いとかRailsのリロードとかがうまく動かないのってこいつのせいでは……みたいな印象を持っていて、なんかバグったなと感じたらとりあえず bin/spring stop とかあまり意味のないことをしていました。

今回は、このよくわからんSpringを知るためにコードを読んで知ったSpringの姿をお伝えします。なお、この内容は Qiita Rails Night で登壇したLTの内容の詳細版です。

リポジトリ

デフォルトでは採用されなくなりましたが、Public Archiveとかでもなく今でも時々コードが取り込まれているので、まだ元気な方なんじゃないでしょうか。

このポスト内で紹介しているコードは、全て2023-12-04時点で最新の 378e0ce のコミットのものを参照しています。

Springの仕組み

まず最初に簡潔にSpringの仕組みを説明すると、Springは Spring::Server というサーバーが動いているプロセスを立ち上げ、その中で Spring::ApplicationManager というクラスが事前にRailsをロードし、サーバーに接続してきたクライアントに対してそのフォークを接続することでRailsの起動時間を短縮するという手法をとっています。また、このSpringが動いているサーバーに接続するのは console, runner, generate, destroy, test のコマンドのみで、server は対象ではありません。なので、アプリケーションがなんか様子おかしいからSpring再起動したろは実はあまり効果が望めないのです。どちらかというと、consoleでデバッグしてるときになにか変だと感じたら再起動したほうが良いかも知れません。

Spring::ApplicationManager は、Spring::Server の内部で実行環境ごとに異なるプロセスを保持していて、開発環境かテスト環境かを区別しています。Spring::ApplicationManager の内部では、 Spring::Application オブジェクトがロードしたRailsアプリケーションをデフォルトでポーリングしながら監視しており、Gemfileやアプリケーションコードの変更があるとリロード用のコールバックが実行されます。

以上がSpringの大まかな仕組みですが、それがどの様に普段のRails開発で介入してくるのかを追ってみましょう。

Springのインストール

Rails7以降はSpringのインストールは任意なので、自分でコマンドを実行する必要があります。READMEに従うと、Gemfileにspringを追加して、以下のコマンドを実行します。

bundle install
bundle exec spring binstub --all

このコマンドを実行すると、Springは bin 配下に spring の binstub を作成すると同時に、既存の rails と rake の binstub にもSpringのbinstubを読み込むようにフックが挿入されます。

diff --git a/bin/rails b/bin/rails
index efc0377..c8b5338 100755
--- a/bin/rails
+++ b/bin/rails
@@ -1,4 +1,5 @@
 #!/usr/bin/env ruby
+load File.expand_path("spring", __dir__)
 APP_PATH = File.expand_path("../config/application", __dir__)
 require_relative "../config/boot"
 require "rails/commands"
diff --git a/bin/rake b/bin/rake
index 4fbf10b..7327f47 100755
--- a/bin/rake
+++ b/bin/rake
@@ -1,4 +1,5 @@
 #!/usr/bin/env ruby
+load File.expand_path("spring", __dir__)
 require_relative "../config/boot"
 require "rake"
 Rake.application.run

このフックによって、rails と rake のコマンドはどちらも必ずspringのコードを事前に実行するようになります。なお、 bundler 経由で bundle exec rails のように呼び出しても、Railsのアプリケーションローダーが自動で binstub を探して実行してくれるので、問題ありません。

rails/railties/lib/rails/app_loader.rb at 79c3cef444bf783e15f6b3928e69d53fcf933acf · rails/rails

def exec_app
  original_cwd = Dir.pwd

  loop do
    if exe = find_executable

## 中略

def find_executable
  EXECUTABLES.find { |exe| File.file?(exe) }
end

フックと同時に作成される bin/spring の内容は、 Spring::Client::Binstub 内に書かれています。

spring/lib/spring/client/binstub.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

SPRING = <<~CODE
  #!/usr/bin/env ruby

  # This file loads Spring without loading other gems in the Gemfile in order to be fast.
  # It gets overwritten when you run the `spring binstub` command.

  if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"])
    require "bundler"

    Bundler.locked_gems.specs.find { |spec| spec.name == "spring" }&.tap do |spring|
      Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
      gem "spring", spring.version
      require "spring/binstub"
    end
  end
CODE

RAILS_ENV が development か test か nil のときに、spring/binstub を読み込むような動作をしています。

Springサーバーの起動

spring/lib/spring/binstub.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

command  = File.basename($0)
bin_path = File.expand_path("../../../bin/spring", __FILE__)

if command == "spring"
  load bin_path
else
  disable = ENV["DISABLE_SPRING"]

  if Process.respond_to?(:fork) && (disable.nil? || disable.empty? || disable == "0")
    ARGV.unshift(command)
    load bin_path
  end
end

rails や rake コマンドから起動するときはelseに入るので、 Processfork メソッドを持っていることを確認した上で ARGV にコマンド名を追加して bin/spring を起動します。余談ですが、 DISABLE_SPRING という環境変数に0以外の何かを入れると Spring の介入を避けることがきるんですね。

bin/spring の中では、 Spring::Client.run(ARGV) を呼び出しています。

spring/bin/spring at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

lib = File.expand_path("../../lib", __FILE__)
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) # enable local development
require 'spring/client'
Spring::Client.run(ARGV)

Spring::Client.run では、先程のARGVに追加したコマンドによって、 Spring::Client::Command のサブクラスが選択されます。ここでは rails で起動しているので、 Spring::Client::Rails が選択されます。ちなみに、なぜかここに rake が一覧にないため、 rake コマンドって Spring の対象じゃないの? と思うのですが、どうなってるんでしょうね。よくわからないです。少なくともCommandsにはいるので、対象だとは思うのですが……なぜでしょう。とにかく、 Spring::Client::Rails 内では、 Spring の対象になるコマンドの一覧が定義されています。最初に概要でお話した対象のコマンド一覧はここで決まっています。

spring/lib/spring/client/rails.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

module Spring
  module Client
    class Rails < Command
      COMMANDS = %w(console runner generate destroy test)

      ALIASES = {
        "c" => "console",
        "r" => "runner",
        "g" => "generate",
        "d" => "destroy",
        "t" => "test"
      }

ここで適切なコマンドを選んでいると、今度は Spring::Client::Run に渡され、ここで Spring::Server のプロセスの有無をチェックした上で接続に行きます。かなり長いクラスなので要点をかいつまんで説明していきます。

まず Spring::Client::run に処理が渡ると、 UNIXSocket を開きに行き、成功すれば接続を、失敗したらサーバーの立ち上げを行います。

spring/lib/spring/client/run.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def call
  begin
    connect
  rescue Errno::ENOENT, Errno::ECONNRESET, Errno::ECONNREFUSED
    cold_run
  else
    warm_run
  end
ensure
  server.close if server
end

この begin ... rescue ... else の制御構文を使っているコードを初めてみてまあまあびっくりした記憶があります。

begin
rescue
else
end

ってはじめて見た気がするな

— kinoppyd (@GhostBrain) November 13, 2023

cold_run の場合は、サーバーの立ち上げを行います。

spring/lib/spring/client/run.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def boot_server
  env.socket_path.unlink if env.socket_path.exist?

  pid     = Process.spawn(gem_env, env.server_command, out: File::NULL)
  timeout = Time.now + BOOT_TIMEOUT

  @server_booted = true

  until env.socket_path.exist?
    _, status = Process.waitpid2(pid, Process::WNOHANG)

    if status
      exit status.exitstatus
    elsif Time.now > timeout
      $stderr.puts "Starting Spring server with `#{env.server_command}` " \
                   "timed out after #{BOOT_TIMEOUT} seconds"
      exit 1
    end

    sleep 0.1
  end
end

ここで分かる通り、 Process.spawn を使って別のプロセスを起動しています。これがSpringサーバーの本体です。 server_command には、デフォルトで "#{File.expand_path("../../../bin/spring", __FILE__)} server --background" の値が使われます。これ以降、SpringサーバーとRailsのプロセスはUNIX Socketを使って通信しますが、socketファイルは SPRING_SOCKETSPRING_TMP_PATH, SDG_+RUNTIME_DIR などの環境変数で介入しない限りは、Dir.tmpdir"spring-#{Process.uid}" のをあわせたパスになります。これらの値は、 Spring::Env で管理されています。

Spring::Server 側は、バックグラウンドモードで起動すると、loop メソッドを使ってクライアントからの接続を待ち続けます。

spring/lib/spring/server.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def start_server
  server = UNIXServer.open(env.socket_name)
  log "started on #{env.socket_name}"
  loop { serve server.accept }
rescue Interrupt
end

サーバーが起動すると、Spring::Client::Run はUNIXSocketを使ってサーバーに接続を試みます。

spring/lib/spring/client/run.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def run
  verify_server_version

  application, client = UNIXSocket.pair

  queue_signals
  connect_to_application(client)
  run_command(client, application)
rescue Errno::ECONNRESET
  exit 1
end

Spring::Client::Run から Spring::Server にソケット通信がつながると、接続を待ち受けていたサーバー側のコードに動作が移ります。 Spring::Server は、起動すると loop メソッドでSocketの接続を待ち続け、接続があると serve メソッドを呼び出します。

spring/lib/spring/server.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def serve(client)
  log "accepted client"
  client.puts env.version

  app_client = client.recv_io
  command    = JSON.load(client.read(client.gets.to_i))

  args, default_rails_env = command.values_at('args', 'default_rails_env')

  if Spring.command?(args.first)
    log "running command #{args.first}"
    client.puts
    client.puts @applications[rails_env_for(args, default_rails_env)].run(app_client)
  else
    log "command not found #{args.first}"
    client.close
  end
rescue SocketError => e
  raise e unless client.eof?
ensure
  redirect_output
end

Spring::Application の起動とRailsの実行

通信用のソケットのやり取りを行ったあと、起動したいコマンドのチェックなどを行い、 @applications[rails_env_for(args, default_rails_env)].run(app_client) を実行します。@applicationsSpring::ApplicationManagerrails_envs_for ごとにセットされているハッシュで、developmentとかtestごとにマネージャーが存在します。マネージャーは、mutexをつかった排他制御の管理を主に行っています。そして、内部でさらにもう一つプロセスをspawnして管理しています。

spring/lib/spring/application_manager.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def start_child(preload = false)
  @child, child_socket = UNIXSocket.pair

  Bundler.with_original_env do
    bundler_dir = File.expand_path("../..", $LOADED_FEATURES.grep(/bundler\/setup\.rb$/).first)
    @pid = Process.spawn(
      {
        "RAILS_ENV"           => app_env,
        "RACK_ENV"            => app_env,
        "SPRING_ORIGINAL_ENV" => JSON.dump(Spring::ORIGINAL_ENV),
        "SPRING_PRELOAD"      => preload ? "1" : "0"
      },
      "ruby",
      *(bundler_dir != RbConfig::CONFIG["rubylibdir"] ? ["-I", bundler_dir] : []),
      "-I", File.expand_path("../..", __FILE__),
      "-e", "require 'spring/application/boot'",
      3 => child_socket,
      4 => spring_env.log_file,
    )
  end

  start_wait_thread(pid, child) if child.gets
  child_socket.close
end

spring/application/boot を実行していますが、これは内部で Spring::Applicaiton を起動しています。この様に、Springは実行されるために最低でも2つのプロセスを必要とするので、springを使ったアプリケーションがいる環境で ps などを見てみると、いかにもゾンビになりそうなプロセスがこんな感じで存在します。

% ps aux | grep spring
kinoppyd 57044   3.3  1.1 410289872 367808   ??  Ss    9:59AM  20:15.90 spring app    | my_app | started 4 hours ago | development mode
kinoppyd 19810   0.0  0.0 409132736   4048 s001  S    金02PM   0:00.33 spring server | my_app | started 71 hours ago
kinoppyd 99569   0.0  0.0 408628368   1664 s001  S+    2:22PM   0:00.00 grep --color spring

57044のプロセスが Spring::Application で、19810のプロセスが Spring::Server ですね。

spring/application/boot は、こんな感じで Spring::Application を起動しています。

spring/lib/spring/application/boot.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

# This is necessary for the terminal to work correctly when we reopen stdin.
Process.setsid

require "spring/application"

app = Spring::Application.new(
  UNIXSocket.for_fd(3),
  Spring::JSON.load(ENV.delete("SPRING_ORIGINAL_ENV").dup),
  Spring::Env.new(log_file: IO.for_fd(4))
)

Signal.trap("TERM") { app.terminate }

Spring::ProcessTitleUpdater.run { |distance|
  "spring app    | #{app.app_name} | started #{distance} ago | #{app.app_env} mode"
}

app.eager_preload if ENV.delete("SPRING_PRELOAD") == "1"
app.run

Spring::Application もまた長いクラスなのですが、最終的にはこの中にrailsコマンドなどが接続するためのプロセスの正体があります。あまりにも長いので該当箇所だけを見てみると、こんな感じです。

spring/lib/spring/application.rb at 378e0ce8741bd9e599a435a523bbcf0633392c91 · rails/spring

def serve(client)
  log "got client"
  manager.puts

  @clients[client] = true

  _stdout, stderr, _stdin = streams = 3.times.map { client.recv_io }
  [STDOUT, STDERR, STDIN].zip(streams).each { |a, b| a.reopen(b) }

  if preloaded?
    client.puts(0) # preload success
  else
    begin
      preload
      client.puts(0) # preload success
    rescue Exception
      log "preload failed"
      client.puts(1) # preload failure
      raise
    end
  end

  args, env = JSON.load(client.read(client.gets.to_i)).values_at("args", "env")
  command   = Spring.command(args.shift)

  connect_database
  setup command

  if Rails.application.reloaders.any?(&:updated?)
    Rails.application.reloader.reload!
  end

  pid = fork {
    # Make sure to close other clients otherwise their graceful termination
    # will be impossible due to reference from this fork.
    @clients.each_key { |c| c.close if c != client }

    Process.setsid
    IGNORE_SIGNALS.each { |sig| trap(sig, "DEFAULT") }
    trap("TERM", "DEFAULT")

    unless Spring.quiet
      STDERR.puts "Running via Spring preloader in process #{Process.pid}"

      if Rails.env.production?
        STDERR.puts "WARNING: Spring is running in production. To fix "         \
                    "this make sure the spring gem is only present "            \
                    "in `development` and `test` groups in your Gemfile "       \
                    "and make sure you always use "                             \
                    "`bundle install --without development test` in production"
      end
    end

    ARGV.replace(args)
    $0 = command.exec_name

    # Delete all env vars which are unchanged from before Spring started
    original_env.each { |k, v| ENV.delete k if ENV[k] == v }

    # Load in the current env vars, except those which *were* changed when Spring started
    env.each { |k, v| ENV[k] ||= v }

    connect_database
    srand

    invoke_after_fork_callbacks
    shush_backtraces

    command.call
  }

  disconnect_database

  log "forked #{pid}"
  manager.puts pid

  wait pid, streams, client
rescue Exception => e
  log "exception: #{e}"
  manager.puts unless pid

  if streams && !e.is_a?(SystemExit)
    print_exception(stderr, e)
    streams.each(&:close)
  end

  client.puts(1) if pid
  client.close
ensure
  # Redirect STDOUT and STDERR to prevent from keeping the original FDs
  # (i.e. to prevent `spring rake -T | grep db` from hanging forever),
  # even when exception is raised before forking (i.e. preloading).
  reset_streams
end

ちょっとめちゃめちゃ長いのですが、ここにSpringの大事な動作が全部詰まっているので引用しました。serve メソッドが接続を受け付けると、まずRailsアプリのロードを試みます。すでにロードされている場合は必要なリロードだけですが、初回はすべてをロードします。この初回だけロードする仕組みが、Springが2回以降早くRailsを起動できる理由です。Railsをロードすると、今度は自身をforkします。forkは、メモリをすべてコピーするので、一度ロードしたRailsアプリを何度も使い回すことができます。

forkしたプロセスの中では、Spring.command によって特定されたクラス、今回の rails コマンドの場合であれば Spring::Commands::Rails クラスの call メソッドを呼び出し、これで初めてRailsのアプリケーションが実行されます。長かったですが、ここまでが rails コマンドを実行したあと、 spring がどの様に振る舞うかという詳細な解説でした。最終的に Spring::Commands::Rails.call は、 bin/rails の binstub を呼び出します。これは内部的に再び bin/spring を呼び出しますが、今度は Spring がすでにロードされているので、一連の処理はすべて無視され、通常通りRailsが起動します! ここでは、すでに読み込んでおいたRailsのコードをforkして使うため、SpringはRailsの起動を早くすることができるんですね。

また、ここで作成されたPIDが、このあと Spring::Applicaiton -> Spring::ApplicationManager -> Spring::Server -> Spring::Client::Run に戻っていき、Ctrl-Cなどのシグナルハンドラをセットするので、まったくの別プロセスにも関わらずCtrl-Cでコンソールなどを終了することができるのです。

まとめ

以上が、Springのコードを読んだ結果わかった、SpringがどうやってRailsアプリを早く起動させているかの全てです。

Spring::Server プロセスと、Spring::ApplicationManager を介して複数立ち上がっている Spring::Application のそれぞれ独立したプロセスが、Spring::Client::Run によってコンソールと接続され、予め温められていたRailsアプリケーションを参照することがわかりました。

最初にも書いた通り、Springは現在のところデフォルトでバンドルされなくなり、オプション扱いです。DHHも「マシン十分速いからいらんくね?」みたいなことを言っていますが、それでも数十万行のコードベースを持つ巨大なモノリスアプリを開発しているチームには、これからも良い選択肢であり続けるでしょう。Springの仕組みを解説して広くSpringへの理解が広がることで、Springがなんかよくわからん怖いやつから、なんとなく知ってる便利なやつになってくれると嬉しいです。