🦁

アプリケーションサーバーを理解する

に公開

🎄Merry Christmas🎄 WWWAVE アドベントカレンダーの12/8の記事です!

ごきげんよう
国内向け漫画配信サイトComicFestaの開発をしているほさざえもんです

今回は、簡易的な puma もどきを自作して、rails s を使わずに Rails アプリを動かしてみました。
アプリケーションサーバーの理解度を上げるのが目的です!

きっかけ

日々の開発や障害調査の中で、
「クライアント⇄アプリの通信のどこで問題が起きてるの?」
っていう切り分けがめちゃくちゃ大事だと感じています。

ただ、自分自身がアプリケーションサーバーの仕組みを理解しきれていないと気づいたので、
今回「pumaって結局何やってるの?」を自作して確かめてみました🫠

アプリケーションサーバーとは?

アプリのロジックを実行するサーバーのことです。
Rails だと puma, unicorn が代表的ですね。

クライアント⇄アプリの通信のざっくりとした流れはこんな感じです

アプリケーションサーバーの役割

より具体にするとRailsアプリを実行するために以下のことをしています

  • OS の上で TCPポートを開いて HTTP リクエストを受け取る
  • 受け取ったリクエストから Rack の env を組み立てて app.call(env) を呼ぶ
  • Rails などの Rack アプリから返ってきた
    [status, headers, body] をHTTPレスポンスとしてクライアントに返す
  • 複数の worker / thread を使って、同時にたくさんのリクエストをさばく

Rackってなに

RackはアプリケーションサーバーとRailsアプリをつなぐ共通インターフェースのことです
Rackの仕様で、以下のコードでRailsアプリを呼び出すことができます

status, headers, body = app.call(env)

実装してみよう。やってみよう〜♪ (WANIMA)

TCPサーバーを立ち上げてリクエストを受け取り、Rack互換のRailsアプリケーションに渡してレスポンスを返す仕組みを実装します。1リクエストごとにスレッドを生成して並列処理を行います。

Codex の力を借りながら実装!実装!実装!

実装したコード

require "socket"
require "rack"
require "stringio"
require_relative "./my_app/config/environment" 

app = Rails.application

STATUS_TEXT = {
  200 => "OK",
  201 => "Created",
  204 => "No Content",
  302 => "Found",
  400 => "Bad Request",
  401 => "Unauthorized",
  403 => "Forbidden",
  404 => "Not Found",
  500 => "Internal Server Error",
}.freeze

Thread.abort_on_exception = true

server = TCPServer.new("127.0.0.1", 9292)
puts "[mini-puma] Running Rails on http://127.0.0.1:9292"

loop do
  socket = server.accept

  # 1リクエスト = 1スレッド で処理(超シンプルな並列モデル)
  Thread.new(socket) do |client|
    begin
      # --- ① リクエストラインを読む ---
      request_line = client.gets
      unless request_line && !request_line.strip.empty?
        client.close
        next
      end

      method, full_path, http_version = request_line.split(" ")
      path, query = full_path.split("?", 2)

      puts "[mini-puma] #{Thread.current.object_id} #{method} #{full_path} (#{http_version})"

      # --- ② ヘッダ行を読む ---
      headers = {}
      while (line = client.gets)
        line = line.chomp
        break if line.empty?

        key, value = line.split(":", 2)
        next unless key && value

        headers[key] = value.strip
      end

      # --- ③ ボディ(POSTデータなど)を読む ---
      body = ""
      content_length = headers["Content-Length"]&.to_i
      if content_length && content_length > 0
        body = client.read(content_length) || ""
      end

      # --- ④ Rack env を組み立てる ---
      env = {
        "REQUEST_METHOD" => method,
        "PATH_INFO" => path,
        "QUERY_STRING" => query || "",
        "SERVER_NAME" => "127.0.0.1",
        "SERVER_PORT" => "9292",
        "rack.version" => Rack::VERSION,
        "rack.url_scheme" => "http",
        "rack.input" => StringIO.new(body),
        "rack.errors" => $stderr,
        "rack.multithread" => true,
        "rack.multiprocess" => false,
        "rack.run_once" => false,
      }

      headers.each do |k, v|
        env_key = "HTTP_" + k.upcase.tr("-", "_")
        env[env_key] = v
      end

      if headers["Content-Type"]
        env["CONTENT_TYPE"] = headers["Content-Type"]
      end
      if headers["Content-Length"]
        env["CONTENT_LENGTH"] = headers["Content-Length"]
      end

      # --- ⑤ Rackアプリ(= Railsアプリ)を呼ぶ ---
      status, res_headers, body_enum = app.call(env)

      # --- ⑥ ステータス行を組み立てる ---
      status_code = status.to_i
      reason = STATUS_TEXT[status_code] || "OK"
      client.print "HTTP/1.1 #{status_code} #{reason}\r\n"

      # --- ⑦ レスポンスヘッダを書き出す ---
      res_headers.each do |k, v|
        value = v.is_a?(Array) ? v.join(", ") : v
        client.print "#{k}: #{value}\r\n"
      end

      client.print "\r\n"

      # --- ⑧ ボディを書き出す ---
      body_enum.each do |chunk|
        client.print chunk
      end
      body_enum.close if body_enum.respond_to?(:close)

      puts "[mini-puma] #{Thread.current.object_id} -> status=#{status_code}"

    rescue => e
      warn "[mini-puma:error] #{e.class}: #{e.message}"
      warn e.backtrace.join("\n")
    ensure
      client.close rescue nil
    end
  end
end

上記コードを実行します。

hosazaemoooon@Mac puma-study % ruby mini_puma_step4.rb
・・・
[mini-puma] Running Rails on http://127.0.0.1:9292
[mini-puma] 17040 GET / (HTTP/1.1)
[mini-puma] 17040 -> status=200
[mini-puma] 24140 GET /icon.png (HTTP/1.1)
[mini-puma] 24140 -> status=200

こんな感じでrails sしなくてもサーバーが立ち上がりましたわ〜〜!

リクエストとレスポンスの流れも追ってみる

試しにrailsアプリのroutes.rbに以下を追加して/helloにアクセスしたときにレスポンスが帰ってくることも見てみましょう

get '/hello', to: ->(env) { [200, { "Content-Type" => "text/plain" }, ["Hello, World!"]] }

↓ログ

[mini-puma] 24160 GET /hello (HTTP/1.1)
[mini-puma] 24160 -> status=200

普段あまり意識したことはなかったのですが、pumaは頑張ってくれていることがわかりました
いつもありがとう。

おわりに

実際アプリケーションサーバーって導入した後に改修/開発する機会ってほぼないと思いますが、アプリケーションサーバーを理解することによって、相乗的にWebサーバーやCloudFrontの解像度が上がったような気がします

一筆書き終えたので、私は一足先にお正月モードに突入したいと思います

みなさん。良いお年を😘

wwwave's Techblog

Discussion