Limaを使ってDockerのファイル同期を試す

動機

  • ファイル数が増えるとMacでのDockerでVolumeの同期が遅い
  • DockerDesktopのMutagenに期待していたがDockerDesktopの有料プランに取り込まれてしまった
  • 他の手段としてlimaを試したいのでDockerの起動とファイルの同期までをやってみる

Limaの実行

インストール

brew install lima

Limaではなにも指定せずにstartすればDefaultという名前で環境を作ってくれるが、今回Dockerを使いたいので用意されているTemplate元に設定を作成

Docker向けののTemplate
lima/examples/docker.yaml at master · lima-vm/lima · GitHub

Templateの修正

mountType: "virtiofs" # ファイルの共有方法virtiofsに変更
vmType: "vz" # mountTypeに合わせてvmTypeもvsを指定

virtiofsとvzは現時点でexperimental

設定ファイルを指定してLimaインスタンスの作成と起動

limactl create --name=my-docker docker.yaml
limactl start my-docker

起動している仮想マシンの一覧。vmTypeがvzであることが確認できる

$ limactl list
NAME         STATUS     SSH                VMTYPE    ARCH       CPUS    MEMORY    DISK      DIR
my-docker    Running    127.0.0.1:57337    vz        aarch64    4       4GiB      100GiB    ~/.lima/my-docker

Dockerの動作確認

limaインスタンス起動時のメッセージを参考にDockerのcontextを切り替える

$ docker context create lima-my-docker --docker "host=unix://$HOME/.lima/my-docker/sock/docker.sock"
$ docker context use lima-my-docker
$ docker context show
lima-my-docker

コンテナの起動

docker run --name nginx -p 127.0.0.1:8080:80  nginx:alpine

ブラウザからhttp://localhost:8080/を開くとnginxの起動を確認できる

ファイルの同期

開発環境として実行したいのでファイル同期の確認をしておく
limaのインスタンスのマウントはDockerのTemplateで以下のように指定されているので/tmp/lima/以下にサンプルのhtmlをおく

docker.yaml

mounts:
- location: "~"
- location: "/tmp/lima"
  writable: true

/tmp/lima/index.html

<!DOCTYPE html>
<html>
<body>
<h1>Lima test</h1>
</body>
</html>

ファイルを置いた状態でvolumeをマウントしてdockerを起動

docker run --name nginx -p 127.0.0.1:8080:80 -v /tmp/lima/index.html:/usr/share/nginx/html/index.html nginx:alpine

ブラウザからの表示が変更されているの確認したあと

Lima test

ローカルの/tmp/lima/inde.htmlを修正してからリロードすると

...
<h1>changed file</h1>
...

表示が変更される

changed file

Limaのインスタンスから変更しても同期される(ブラウザの反映にはリロード & キャッシュの削除が必要だった)

$ limactl shell my-docker bash
$ sed -i 's/changed file/changed file in lima/' /tmp/lima/index.html

感想

Lima側の設定が用意されたTemplateから出来たので試したかったことは簡単に行えた

ローカル、Lima、Dockerと3つの環境の把握が必要になるのが難点だが、そもそもそういうものなのでやむをえないしアプリケーションのファイル数が増えて同期が遅くなってくるのも辛いので、改善されるなら試す価値はあると思う

環境の管理が大変なら有料でも同じことができるDesktop環境を探す選択肢をとることになりそう

DockerComposeのfileはマージできる

docker compose コマンド実行時に -f オプションで読み込む設定ファイルを指定出来るが、複数ファイルを指定すると内容をマージして実行してくれる

docker-compose.main.yml

services:
    ruby:
        image: ruby:3.2.1

docker-compose.other.yml

services:
    python:
        image: puthon:3.9.16

configコマンドで実行される設定を確認できる

docker compose -f docker-compose.main.yml -f docker-compose.other.yml config
name: tmp
services:
  python:
    image: python:3.9.16
    networks:
      default: null
  ruby:
    image: ruby:3.2.1
    networks:
      default: null
networks:
  default:
    name: tmp_default
docker compose -f docker-compose.main.yml -f docker-compose.other.yml up -d
docker compose -f docker-compose.main.yml -f docker-compose.other.yml ps -a
NAME                IMAGE               COMMAND             SERVICE             CREATED             STATUS                      PORTS
tmp-python-1        python:3.9.16       "python3"           python              25 seconds ago      Exited (0) 24 seconds ago
tmp-ruby-1          ruby:3.2.1          "irb"               ruby                25 seconds ago      Exited (0) 24 seconds ago

重複する設定がある場合には後から指定したファイルの内容に上書きされるので注意が必要

docker-compose.other.yml

services:
  python:
    image: python:3.9.16
  ruby:
    image: ruby:3.1.0
docker compose -f docker-compose.main.yml -f docker-compose.other.yml config
name: tmp
services:
  python:
    image: python:3.9.16
    networks:
      default: null
  ruby:
    image: ruby:3.1.0
    networks:
      default: null
networks:
  default:

毎回 -f オプションをつけるのが手間であれば環境変数に設定してしまうと楽

export COMPOSE_FILE="docker-compose.main.yml:docker-compose.other.yml"

セパレータはMacLinux(:)かWindowsか(;)で異なる

参照

MySQLは設定によってはVARCHARの比較時にアルファベットの大文字小文字を区別しない

最近MySQLのVARCHARがデフォルトの設定で大文字と小文字を区別してくれていない事を知らずにバグを仕込んでしまったので軽く押さえてみた

検証

Dockerコンテナ

$ docker run -it --name mysql -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0
$ docker exec -it mysql bash

MySQLにログインしてテーブル作成

$ mysql -uroot -p
$ CREATE DATABASE sample_db;
$ CREATE TABLE sample_db.user (id int, name varchar(10));

nameを大文字でレコードを登録

$ INSERT INTO sample_db.user VALUES(1,'JHON');

WHEREでnameを指定して絞り込み

大文字

$ SELECT id,name FROM sample_db.user WHERE name = 'JHON';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

小文字

$ SELECT id,name FROM sample_db.user WHERE name = 'jhon';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

whereでの指定が小文字のjhonにも関わらずnameが大文字のJHONであるレコードがselectされる

なぜこんなことになるかドキュメントをひいてみる

10.1 一般の文字セットおよび照合順序

文字セットとは、記号とエンコーディングのセットです。 照合順序とは、文字セット内の文字を比較するためのルールを集めたものです。 架空の文字セットを例にして、文字セットと照合順序の違いを見てみましょう。

B.3.4.1 文字列検索での大文字/小文字の区別

非バイナリ文字列の場合 (CHARVARCHARTEXT)、文字列検索では比較オペランドの照合順序が使用されます。 バイナリ文字列 (BINARYVARBINARYBLOB) の場合、比較ではオペランド内のバイトの数値が使用されます。つまり、アルファベット文字の場合、比較では大文字と小文字が区別されます。

B.3.4.1 文字列検索での大文字/小文字の区別

デフォルトの文字セットおよび照合順序は utf8mb4 および utf8mb4_0900_ai_ci であるため、非バイナリ文字列比較ではデフォルトで大文字と小文字が区別されません。 これは、_`col_name`_ LIKE 'a%' を使用して検索した場合、A または a で始まるすべてのカラム値が取得されることを意味します。

かいつまんで言うとMySQLでは文字を扱うために文字セットと文字を比較するための照合順序(collate)の設定があってクエリを実行した際の結果はこの設定に左右されるらしいが、デフォルトでは大文字と小文字が区別されない設定になっているようだ。

ただし非バイナリ文字列比較とあるのでBINARY演算子によるCASTを試してみる。

CAST付きの絞り込み

小文字

SELECT id,name FROM sample_db.user WHERE BINARY name = 'jhon';
Empty set, 1 warning (0.00 sec)

大文字

SELECT id,name FROM sample_db.user WHERE BINARY name = 'JHON';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set, 1 warning (0.00 sec)

今度は小文字ではヒットしなくなった

またクエリの実行時にcollateを指定する事も出来る

collateを使用した比較

まずは文字セットを指定したいcollateに対応したものに設定しなおす

SET character_set_connection=utf8mb4;

小文字

mysql> SELECT id,name FROM sample_db.user WHERE name = 'jhon' COLLATE utf8mb4_0900_bin;
Empty set (0.01 sec)

大文字

mysql> SELECT id,name FROM sample_db.user WHERE name = 'JHON' COLLATE utf8mb4_0900_bin;
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

テーブルが使用するcollateの設定ごと変えてやればクエリごとに指定をしなくても良くなる

テーブルの設定

現在の設定の確認

SELECT TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='sample_db' AND TABLE_NAME='user';
+--------------------+
| TABLE_COLLATION    |
+--------------------+
| utf8mb4_0900_ai_ci |
+--------------------+
1 row in set (0.00 sec)

collateの変更

ALTER TABLE sample_db.user COLLATE utf8mb4_0900_bin;

create table時にcollateを指定しても良い

CREATE TABLE sample_db.user (id int, name varchar(10)) COLLATE utf8mb4_0900_bin;

ただテーブルごとの設定もそれぞれに付与するのは手間だしそのデータベースを扱うサービス全体で大文字と小文字を区別して欲しいことも多いだろうから、その場合データベース単位で設定しまう方がラクだろう。

データベースの設定

ALTER DATABASE sample_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
CREATE DATABASE sample_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;

最後に

今まで既存のアプリケーションの改修が多かったことも原因かもしれないがが、MySQLを触りだして数年たつのにこの事を知らずに結構衝撃だった。 幸いリリース前にチーム内の人が指摘してくれてことなきを得たが、調べても最初BINARYのキャストの話が多く出てきてピンと来ずcollateという馴染みの薄い単語覚えておかないと調べるのに少し不便だったので記録を残しておく

おまけ

文字セットの確認

SHOW VARIABLES LIKE "chara%";
+--------------------------+--------------------------------+
| Variable_name            | Value                          |
+--------------------------+--------------------------------+
| character_set_client     | latin1                         |
| character_set_connection | latin1                         |
| character_set_database   | utf8mb4                        |
| character_set_filesystem | binary                         |
| character_set_results    | latin1                         |
| character_set_server     | utf8mb4                        |
| character_set_system     | utf8mb3                        |
| character_sets_dir       | /usr/share/mysql-8.0/charsets/ |
+--------------------------+--------------------------------+
8 rows in set (0.01 sec)

GoのStructをJSONに変換する場合fieldは大文字でなければならない

GoでJSONを扱いたかったのだがStructから変換する際に変換の対象にならないFieldがあった。

type User struct {
    No int
    Name string
    tel string
}

func main() {
    user := User{No: 0, Name: "Bob", tel: "000-1111-2222"}
    json, _ := json.Marshal(user)
    fmt.Println(string(json)) // {"No":0,"Name":"Bob"}
}

要はStructのFIeldが小文字で始まる場合はJSONへのコンバートの対象にならないようだ。

どうやらGoは定数なども頭文字を大文字にしないと外部のパッケージからアクセス出来ず、その関係なのかJSONに変換させる際にも大文字で始める必要があるらしい。

pkg.go.dev

users []string // not exported

JSONに変換したい時にはStructのKeyを全部大文字にしないといけないのは無茶だろうと思ったが、StructにはTagオプションがありJSONに変換する際のKeyの指定や値が空の場合に変換の候補から外すオプション(omitempty)などが指定出来るようなのでこれを使うという事のようだ。

Name string json:"name,omitempty"

Tagをつければ小文字始まりのKeyでもJSONとして出力できる。

type User struct {
    No   int
    Name string
    Tel  string `json:"tel"`
}

func main() {
    user := User{No: 0, Name: "Bob", Tel: "000-1111-2222"}
    json, _ := json.Marshal(user)
    fmt.Println(string(json)) // {"No":0,"Name":"Bob","tel":"000-1111-2222"}
}

この状態でStructに値を指定しなければJSONに変換した際に空文字で出力されるが

user := User{No: 0, Name: "Bob"}
Tel string `json:"tel"` // {"No":0,"Name":"Bob","tel":""}

TagにomitemptyのオプションをつければJSONのKey自体が省略される

Tel string `json:"tel,omitempty"` // {"No":0,"Name":"Bob"}

Goをそんなに書き慣れていないのでJSONにした際に一部だけ落ちるという状況に最初?となったが、頭文字が大文字か小文字かでエクスポートの対象にするか分けるという事が分かればルールとしてなるほどと思うで言語の仕様としてちょっと面白いと感じた。

言語を調べる際にはなるべく公式にあたりたいと思うのだがGoのpkgは日本語のドキュメントがあるがStructに関しては適当なものが見つからず途中ちょっと困った。

ActiveRecordのlockメソッド(Postgresql)

概要

これまであまり使っていなかったActiveRecordのlock機能に触れる機会があったので少し試したことを記録しておく

Version

詳細

lock(with_lock)メソッド

AcitveRecordのロック機能のについてはRailsガイドに解説があるが、今回調べたlockメソッドはデータベースの機能を利用した悲観的ロックを扱う。transactionで囲まれたブロック内でlockを経由してからクエリメソッドを実行すると対象のレコードにロックが実行され、このトランザクションが完了するまではその他のトランザクションから更新を防ぐ事が出来る。

User.transaction do
  user = User.lock.first
  user.update(name: "change")
end

もしくは

user = User.lock.first
user.with_lock do
  user.update(name: "change")
end

動作の検証

デバッガのブレイクポイントを貼ってトランザクションの途中で停止させた状態で

User.transaction do
  user = User.lock.first
  binding.pry
  user.update(name: "change")
end

別のRails consoleなどからレコードの更新を行うとupdateの実行が待たされる。

# 別のRails console
user = User.first
user.update(name: "other name")
=> true # デバッガを進めてlockしたtransactinoを抜けると処理が実行され結果が返ってくる

注意しないといけない点としてトランザクションを貼った中でなければロックが実行されないのだが、その状態でlockメソッドを使用してもなんの警告もエラーもでない事だ。User.transactionコメントアウトした以下のコードはこの箇所のupdateだけであれば問題なく実行されるが、先ほど同様にlock直後をデバッガで停止させた状態で別のコンソールなどからupdateを実行しても即座に更新出来てしまう。

#User.transaction do
  user = User.lock.first
  binding.pry
  user.update(name: "change")
#end

updateの実行だけなら一見正常に実行出来たように見えてしまうのでバグを防ぐためにはきちんとロックを想定したテストを書くなどの対策が必要だろう。

行ロック・テーブルロック

Postgresqlには行ロック・テーブルロックがある。 SQL文の中身を見るとわかるがActiveRecordのlockメソッドで実行されているのは行ロックだ。

User.lock.to_sql
=> "SELECT \"users\".* FROM \"users\" FOR UPDATE"

また

異なる種類のロックを使いたい場合は、lockメソッドに生SQLを渡すことも可能です。たとえば、MySQLにはLOCK IN SHARE MODEという式があります(レコードのロック中にも他のクエリからの読み出しを許可します)。この式を指定するには、以下のように単にlockオプションの引数で渡します。 Railsガイド/Active Record クエリインターフェイス

という事なので引数にモードの指定を渡せばRailsガイドの例にあるMySQLと同様にPostgresでも行ロックのモードを指定する事が出来る。

User.transaction do
    # User.lock('FOR SHARE').to_sql
    # => "SELECT \"users\".* FROM \"users\" FOR SHARE"
    user = User.lock('FOR SHARE').first
    user.update(name: "change")
end

一方のテーブルロックだがPostgresqlではLOCKコマンドを実行する必要があるので引数でなにも指定しない場合でもSELECTメソッドが実行されるActiveRecordのlockメソッドでは使用できない。もしテーブルロックが必要であるならModel経由ではなく別に生のSQLを実行する必要がある。

User.transaction do
    ActiveRecord::Base.connection.execute('LOCK users IN ACCESS EXCLUSIVE MODE')
    user = User.first
    binding.pry # ここで止めると別コンソールで User.first の時点で結果が返ってこない
    user.update(name: "change")
end

lockメソッドを使用していないがtransactionを抜ければ待たされていた実行結果は返ってくるようだ。

参照

Railsガイド/Active Record クエリインターフェイス PostgreSQL 13.1文書

DockerのChromeでCupriteの動作を検証してみる

はじめに

Seleniumの調査だったり開発環境がApple Siliconだったりで考慮しないといけない事が多いのでSystemTestの設定を後回しにしていたRailsアプリがあったのだが、CupriteというSeleniumではなくCDP(Chrome DevTools Protocol)を使用したGemの存在を知ったのがきっかけでやっぱりきちんとやろうという事になり、まずは勉強がてら一旦Railsから離れてCupriteとDockerのChormeで環境を作って試してみた。

詳細

リポジトリ

Gemなど

Ferrum

RubyでCDPを使うためのGem。Ferrumとはラテン語で鉄の意味らしい。
名前がラテン語である理由やGItHubのトップにあるイラストがMUCのエンドゲームでサノスが使っていたインフェニティティガントレットっぽく見えるのが気になる。

Cuprite

CapybaraでFerrumを使うためのGem。

CDP(Chrome DevTools Protocol)

Cupriteを知るまでその存在を知らなかったのだがプログラムから呼び出せるChromeに実装されたAPI。E2ETestなどではSeleniumを代替出来るのでCuprite & Ferrumはそのアプローチを取っている。

やったこと

疎通のテストなので単純なリクエストを返してくれるRackアプリをテスト用に作成。 最終的にCapybaraを通してJavaScriptの実行を試したいので検証用のボタンも追加しておく。

config.ru

class App
  def call(env)
    html = "<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>"
    [200,{ "Content-Type" => "text/html" },[html]]
  end
end
run App.new

DockerはWebアプリを起動するためのRubyChromeを別コンテナで立ち上げている。

version: "3.9"
services:
  web:
    build:
      context: .
    command: ["bundle", "exec", "rackup", "-p", "80", "-o", "0.0.0.0"]
    ports:
      - "80:80"
    volumes:
      - ./app:/app
    depends_on:
      - chrome
  chrome:
    image: browserless/chrome
    ports:
      - "3333:3333"
    volumes:
      - .:/app:cached
    environment:
      PORT: 3333
      CONNECTION_TIMEOUT: 600000

Rubyのコンテナでは必要なGemをbuild時にインストールしておりRuby3からWEBRickが標準ライブラリでなくなったのでPumaを追加している。

# frozen_string_literal: true

source "https://rubygems.org"

gem "rack"
gem "puma"
gem "cuprite"
gem "test-unit"
gem "debug"
FROM ruby:3.1.1
RUN mkdir /app
WORKDIR /app
ADD ./app /app
RUN gem install bundler
RUN bundle install
$ docker compose build
$ docker compose up -d

Dockerのビルドとコンテナの起動が済んだら動作を一つずつ確認していく。

Webアプリコンテナ <-> Chromeコンテナ

Rackアプリの起動の確認とWebコンテナからChormeのコンテナにアクセス出来るかを確認しておく

$ docker compose exec web curl 'http://localhost:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%
$ docker compose exec web curl 'http://chrome:3333'
<!doctype html><html><head><meta charset="utf-8"/><title>browserless debugger</title>...

Chrome経由でWebアプリに接続するのでChromeのコンテナからホストネームでアクセス出来るかも確認しておく

$ docker compose exec chrome curl 'http://web:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%

Ferrum -> Chromeコンテナ

WebコンテナからFerrum経由でChromeにアクセス出来るか確認

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil }
}
browser = Ferrum::Browser.new(options)
browser.go_to("https://google.com")
puts browser.current_title
browser.quit
$ docker compose exec web ruby ferrum.rb
Google

Capybra -> Chromeコンテナ -> Webアプリ

Capybara経由でChromeを通してRackアプリに接続出来るか確認

require "capybara/cuprite"
require 'capybara/dsl'

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil },
    base_url: "http://web"
}

Capybara.register_driver(:cuprite) do |app|
    Capybara::Cuprite::Driver.new(app,options)
end

Capybara.javascript_driver = :cuprite
Capybara.server_host = "0.0.0.0"
Capybara.app_host = "http://web:80"

session = Capybara::Session.new(:cuprite)
driver = session.driver
browser = driver.browser
page = browser.page
browser.visit "/"
puts page.body

今回ChromeもDockerにした上に似た名前の項目が多いので設定が紛らわしいが Capybara::Cuprite::Driver.newのurlにはChromeの宛先をCapybara.app_hostにはWebアプリケーションの宛先を設定する。
その他はCapybaraからvisit "/"のようにホスト名を省略してページを選択したければ Capybara::Cuprite::Driver.newbasu_url を渡す。
browser_options: { 'no-sandbox': nil }Docker経由の場合必要とのこと。 Capybara.server_hostもDocker経由の場合 "0.0.0.0" を指定して外部からのアクセスを許可してやる必要がある。
この内容を保存して実行してやるとCapybara経由でRackアプリに接続出来る事が確認できる。

$ docker compose exec web bundle exec ruby capybara.rb
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id="text">Hello World!</h1><button onclick="changeText()">Click</button></body></html>

TestCase

最後にテストケースを作成してSystemTestでの実行を試す。

# 必要な設定は上のCapybaraのテストとほぼ同じだが、そのままだとRackサーバーを立ち上げようとするので以下の設定を追記
Capybara.default_driver = :cuprite

require 'test/unit'
require 'capybara/dsl'

class SystemTestCase < Test::Unit::TestCase
  include Capybara::DSL
  def test_foo
    visit '/'
    assert_equal(page.find('h1').text, 'Hello World!')
    page.click_on 'Click'
    assert_equal(page.find('h1').text, 'Change Text')
  end
end
$ docker compose exec web ruby test_case.rb
Loaded suite test_case
Started
.
Finished in 0.319190042 seconds.
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
3.13 tests/s, 6.27 assertions/s

JavaScriptの実行も確認出来た

つまずいたところ

途中上記の設定で実行しても以下のような空のHTMLで返ってきて困った。

<html><head></head><body></body></html>

原因は意外なところでDockerComposeで指定していたサービス名が理由だった。 こちらを参考にしてapp -> webに変えたらうまく動いた。エラー内容も違うし詳細な理由はわからないのが、とりあえずはDockerComposeのService名はappにしないほうが無難なのだろうか

まとめ

SystemTestはブラウザが必要だったりUnitTestよりも複雑でそこにDockerや新しく使うGemが絡んでくると確信のないまま設定をあれこれいじくる事になりがちなので一旦小さな構成で試してみた。 とりあえず迷った時に立ちかえられる構成が出来たので次はRailsアプリに組み込んでいきたい。

参照

RailsでリクエストをWebサーバーから受け取った直後にBreakpointを貼ってデバッグする

RailsでPumaの挙動を確認したかったのだがデバッグするのにRackのMiddlewareが邪魔だったので回避策を検討してみた。

環境

詳細

Railsから見て先頭付近でリクエストを受け付けるところとして最初にApplicationControllerにデバッガを貼ってみたがBacktraceを見ると60行ほどのスタックでPumaからBreakpointまで結構な量のMiddlewareを通過していた。

class ApplicationController < ActionController::Base
    binding.break
end

rails middleware を打つとnewしたてのRailsアプリでもこれだけのMIddlewareが表示される。これをいちいち遡ってPumaまでたどりくのは面倒だ。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActionDispatch::ServerTiming
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run MyApp::Application.routes

アプリケーションとしてのRailsにリクエストが届くのがMiddlewareを経過した後なら、Middlewareの中にBreakpointを貼って然るべき場所に登録してやれば良いだろうという事で↓のようなただリクエストを通過させるMiddlewareを作成してBreakpointを設定。

class MyMiddleware
    def initialize(app)
        @app = app
    end
    
    def call(env)
        status, headers, body = @app.call(env)
        binding.break
        [status, headers, body]
    end
end

このMiddlewareをconfig/application.rbから登録すれば良いのだがconfig.middleware.useを使うとMiddlewareの最後、アプリケーションの直前に挿入されてしまうのでconfig.middleware.insert_beforeを使って一番上のMiddlewareの前に登録してやる。

config.middleware.insert_before ActionDispatch::HostAuthorization, MyMiddleware
$ rails middleware
use MyApp::Application::MyMiddleware
use ActionDispatch::HostAuthorization
use Rack::Sendfile
...
run MyApp::Application.routes

この状態でRailsのseverを起動して適当なリクエストを送ると先頭に追加したMiddlewareの中でデバッガが実行されてPumaの直後ぐらいにBreakpointが止まってくれる。あとは少しスタックを昇っていけばさほど労せずに目的のPumaまでたどり着ける。

$ backtrace
#0 MyApp::Application::MyMiddleware#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)
#1 Rails::Engine#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)
#2 Puma::Configuration::ConfigMiddleware#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)

感想

軽い気持ちでPumaの挙動を確認しようと思ったらMiddlewareが結構邪魔で、当初回避方法を思いつかずInitializerとして登録したりして右往左往したので同じ轍を踏まないように記録しておく。 最終的にはシンプルな形で解決出来たのでPumaに限らずinsert_beforeの対象を適当なMiddlewareにすれば実際に開発しているアプリでMiddlewareのデバッグもしやすくなったので結果オーライか。