これは「フィヨルドブートキャンプ Advent Calendar 2025」の4日目の記事です。 https://fjord-calendar.jp/calendars/2025
昨日の記事はこちらになります。
きたろうさんは輪読会などで知識面でたくさんサポートしてくださりいつも感謝しています! 今回の記事でも学習の内容を実際にお仕事に活かしている姿が本当に素晴らしく、自分も見習いたいと思いました!
目次
はじめに
私はFBCに2024年1月に入会し、現在チーム開発のプラクティスを進めています。
チーム開発プラクティスでは、自分たちが学習に利用しているブートキャンプアプリを開発します。
プラクティスを進める中で、自分の環境だけテストが通らないという問題に遭遇しました。
この記事では、その原因と背景にあったRuby標準ライブラリResolvのDNS解決について調べた内容をまとめます。
起きたこと
自分の環境で、以下Minitestのモデルテストが毎回失敗するという事象に遭遇しました。
このテストに関係する部分を特に触ったわけでもなかったためとても困惑していました。
分報で呟いてみたところ、他の生徒の環境では正常に通っているらしく、どうやら自分だけがこのテストに落ち続けているということが分かりました。
# テスト module LinkChecker class CheckerTest < ActiveSupport::TestCase test '.valid_domain? returns false with invalid domain' do assert_not Checker.valid_domain?('invalid.invalid') # RFC 2606で予約済みの無効なドメイン名(こちらはテストが通る) assert_not Checker.valid_domain?('') # テストが通らない!! end end end
# テスト結果 Failure: LinkChecker::CheckerTest#test_.valid_domain?_returns_false_with_invalid_domain Expected true to be nil or false
なぜだ…。
テスト対象のモジュールについて
原因を探るために、まずはテスト対象のモジュールについて確認を行いました。
LinkChecker::Checkerは、ブートキャンプアプリ内に貼られているURLが有効かどうかを確認するためのモジュールです。
その中のChecker.valid_domain?(domain)は、引数として渡された文字列が有効なドメインかどうかを判定します。
空文字はドメインとしておかしいはずなのでfalseが返ってほしいのですが、私の環境ではなぜかtrueが返っていたため上記のテストが通らなくなっていました。
モジュールのコードを見てみると、中身を確認するとRubyの標準ライブラリであるResolvのgetaddressというメソッドに引数としてドメイン名を渡して呼び出しています。
require 'net/http' module LinkChecker module Checker # 省略 def valid_domain?(domain) Resolv.getaddress(domain) true rescue Resolv::ResolvError false end # 省略 end end
CIでは問題なく通っていたため、これがいわゆるおま環か…と一瞬あきらめそうになりましたが、原因がわからないまま放置するのも気持ち悪かったのでResolvの挙動を調べてみることにしました。
自分の環境
- macOS: Sequoia 15.5(当時) ※ Tahoe 26.1 でも再現 - Ruby: ruby 3.1.6p260 (2024-05-29 revision a777087be6) [arm64-darwin24](当時) ※ Ruby 3.4.3(2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin24] でも再現 - Rails: Rails 6.1.7.10(当時) ※ Rails 7.2.2.2 でも再現 - ネットワーク: 回線事業者:NTT東日本 フレッツ光回線(IPv4/IPv6)
※ ここでの事象は環境依存なので、別のmacOS / 別のネットワークでは再現しない可能性があります。
調査
Rubyの標準ライブラリResolvを調べる
まずはResolvがどのような役割なのか"るりま(Rubyリファレンスマニュアル)"を見てみることにしました。
library resolv
resolvはDNSを使ってドメイン名からIPアドレスを調べるなど、名前解決を行うための Ruby標準ライブラリです。
Ruby製なので、Rubyのスレッドと呼ばれる同時進行の仕組みと相性が良い作りになっているようです。
class Resolv
Resolvクラスは、リゾルバ(=名前解決を行うクライアント側)をまとめて扱うためのクラスです。
このクラス自身が名前解決を行うわけではなく、Resolv.newに渡された複数のリゾルバへ順番に問い合わせる役割を持っています。
Resolv.getaddress(name)
Resolvのクラスメソッドで、ホスト名の文字列nameからIPアドレスをルックアップ(=ドメイン名から必要なIPアドレスを探しにいく処理)を行い、結果の最初のアドレスを返します。
ルックアップは/etc/hosts、DNSの順で行います。ルックアップに失敗したときにResolv::ResolvErrorが発生します。
※ /etc/hostsはホスト名とIPアドレスを対応させるためのファイルです。
※ 2026-05-22追記: ここでの/etc/hosts→ DNSという順番は、RubyのResolvのデフォルト設定における話です。
OS全体の名前解決では/etc/nsswitch.conf(Linuxなどで名前解決の参照順を決める設定)やmDNS(ローカルネットワーク内で名前解決する仕組み) なども関係するため、Resolvと完全に同じ挙動になるとは限らないようです。
Resolv.getaddressを試す
実際にResolv.getaddressの挙動を確認するために、Resolv.getaddress("")とResolv.getaddress("invalid.invalid")をrails cで試してみました。
なんと、空文字を渡したらIPv6アドレスが返ってきていることがわかりました。
Resolv.getaddress("") # => "2404:1a8:ff43:1912::b"
一方で無効なドメインはちゃんと例外が返ってきています。
Resolv.getaddress("invalid.invalid") # => Resolv::ResolvError
whoisコマンドを試す
このIPv6アドレスが何なのか調べてみるためにwhoisコマンドを実行しました。
whoisコマンドは、ドメイン名やIPアドレスなどの公開済みの登録情報を検索するためのコマンドです。
$ whois "2404:1a8:ff43:1912::b" inet6num: 2404:1a8::/32 netname: NTTEAST-NGN descr: NTT EAST, Inc.
whoisコマンドを実行した結果、このIPv6アドレスはNTT東日本(自分の契約している回線事業者)が管理しているアドレスだということが分かりました。
/etc/hostsを見てみる
いくら契約しているとはいえ、空文字がなぜNTT東日本のアドレスに繋がるかこの時点では見当がつきませんでした。
さらに調査をするためにResolv.getaddress(name)の内容を見直すと/etc/hosts→DNSの順でルックアップすると書いてあったため、まずは/etc/hostsを見てみます。
$ cat /etc/hosts ## # Host Database # # localhost is used to configure the loopback interface # when the system is booting. Do not change this entry. ## 127.0.0.1 localhost 255.255.255.255 broadcasthost ::1 localhost
/etc/hostsの内容を1つ1つ確認してみます。
127.0.0.1 localhost
自分自身を指すIPv4アドレス(ローカルループバックアドレス)。localhostは必ずこのPC自身を示します。
255.255.255.255 broadcasthost
IPv4のブロードキャスト用アドレス。ネットワーク全体にパケットを送るための特殊アドレスです。
::1 localhost
自分自身を指すIPv6アドレス(ローカルループバックアドレス)。
上記の情報の中には空文字("")に紐づいているIPアドレスはないため、DNSに問い合わせるときに何かが起きていそうです。
DNS解決の流れを見る
DNSに問い合わせる際にResolvがどのファイルを参照しているのか確認する必要があるため、Resolv.getaddressの中身を確認します
Resolv.getaddress
Resolv.getaddress(name)のコードを確認すると、DefaultResolverに処理を渡していることがわかります。
# https://github.com/ruby/ruby/blob/fcf3939780972d587b18afc26c4abd2da2c0b7ec/lib/resolv.rb#L43 def self.getaddress(name) DefaultResolver.getaddress(name) end
DefaultResolver
このDefaultResolverはResolv.newのデフォルト設定がそのまま使われます。
# https://github.com/ruby/ruby/blob/fcf3939780972d587b18afc26c4abd2da2c0b7ec/lib/resolv.rb#L3484 DefaultResolver = self.new
Resolv.new のデフォルト設定
Resolv.newを引数なし(=nil)で呼び出した場合、内部では次の 2 つのリゾルバが自動的に設定されます。
Hosts.new-
/etc/hostsを参照し、端末に設定されたドメイン名→ IPアドレスの対応から名前解決
-
DNS.new(nil)-
/etc/resolv.confをもとにDNSへ問い合わせて名前解決
-
※ ここの引数のあたりのコードが複雑なのですが、基本的にはresolversにはデフォルト(nil)が渡されるようです。
# https://github.com/ruby/ruby/blob/fcf3939780972d587b18afc26c4abd2da2c0b7ec/lib/resolv.rb#L89 def initialize(resolvers=(arg_not_set = true; nil), use_ipv6: (keyword_not_set = true; nil)) # resolversが渡されなかったらarg_not_set = true、use_ipv6が渡されなかったらkeyword_not_set = true if !keyword_not_set && !arg_not_set # 引数がどっちも渡ってきてたら警告を表示する warn "Support for separate use_ipv6 keyword is deprecated, as it is ignored if an argument is provided. Do not provide a positional argument if using the use_ipv6 keyword argument.", uplevel: 1 end @resolvers = case resolvers when Hash, nil [Hosts.new, DNS.new(DNS::Config.default_config_hash.merge(resolvers || {}))] else resolvers end end
ドキュメントにも引数がnilのときは/etc/resolv.confを読み込むと書いてあります。
https://github.com/ruby/ruby/blob/fcf3939780972d587b18afc26c4abd2da2c0b7ec/lib/resolv.rb#L312
# Creates a new DNS resolver.
#
# +config_info+ can be:
#
# nil:: Uses /etc/resolv.conf.
/etc/resolv.confを確認する
自分の端末の/etc/resolv.confの中身を見ていきます。
$ cat /etc/resolv.conf search flets-east.jp iptvf.jp nameserver 240b:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx<契約している回線事業者のIPv6 DNS> nameserver 192.xxx.xxx.xxx<契約している回線事業者のIPv4 DNS>
nameserverとは
nameserverは名前解決を行うDNSサーバーのIPアドレスを指定する項目です。
複数指定可能で、書いてある順番で問い合わせを行います。
nameserver 240b:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx<契約している回線事業者のIPv6 DNS> nameserver 192.xxx.xxx.xxx<契約している回線事業者のIPv4 DNS>
searchとは
searchは名前解決においてホスト名の部分だけ入力した時に、自動でドメインの部分を補完をしてくれる項目です。
search flets-east.jp iptvf.jp
例えばfooというドメイン名を解決しようとした場合、searchで上記を設定すると以下の順でDNSに問い合わせを行ってくれます。
- foo.flets-east.jp
- foo.iptvf.jp
今回起きていたのは、このsearchにより空文字("")が以下のように補完されてしまい "flets-east.jp"のIPアドレスを取得しに行ってしまうことが原因のようでした。
"" + flets-east.jp = "flets-east.jp"
実際にResolv.getaddress("flets-east.jp")を確認してみると同じIPv6アドレスが返ってくることがわかりました!
valid_domain?("")がtrueになり、テストが落ちていたことも納得できます。
bootcamp(dev)> Resolv.getaddress("")
=> "2404:1a8:ff43:1912::b"
bootcamp(dev)> Resolv.getaddress("flets-east.jp")
=> "2404:1a8:ff43:1912::b"
まとめ
Resolv.getaddress("")を呼ぶ/etc/hostsにないのでDNSを見に行く- DNSの設定は
/etc/resolv.confを参照する search flets-east.jpが入っていたため、空文字が補完されflets-east.jpで名前解決しに行く- 契約している回線事業者(NTT東日本)のDNSがIPv6アドレスを返す
Resolv.getaddress("")からIPv6アドレスが返ってきたので、LinkChecker::Checker#valid_domain?("")がtrueになる- テストが落ちる
つまり、自宅のネットワークが返すDNSの挙動によってテストが落ちていた。
DNS、お前だったのか。
〜完〜
他のネットワークを試す
とはいえ、他のネットワークでどうなるのか見てみないと分からないと思い、試しにiPhoneのテザリング(au回線)に切り替えてみました。
$ cat /etc/resolv.conf # # macOS Notice # # 省略 nameserver <iPhoneテザリングのDNS(IPv4) > nameserver <iPhoneテザリングのDNS(IPv6) >
/etc/resolv.confの内容が変わりsearchが消えていました。
この場合、空文字が渡ってきてもドメインが補完されなくなるはずです。
結果として元々想定していたエラーが返ってくるようになりました!もちろんテストも落ちません。
Resolv.getaddress("") # => Resolv::ResolvError
テストとしてどう扱うべきか?
接続するネットワークによって結果が変わる可能性があるテストを行う場合、どのような対応ができるかを考えてみました。
案1. ネットワークに依存するケース(今回でいう空文字の場合)はテストから切り離す
空文字とは別にinvalid.invalidという無効なアドレスでチェックできているため、特定のネットワークによって落ちる可能性のある不安定なテストは削ってしまっても良さそうに思います。
しかし、空文字自体がそもそもドメインとして成立しないため簡単に削ってよいのか判断が難しいところです。
案2. 空文字の場合はアプリ側で早期リターンする
DNS に問い合わせる前に空文字を弾けばネットワーク差分は発生しないのでアプリ側で制御する方法です。
def valid_domain?(domain) return false if domain.blank? #Rails を使っている場合 Resolv.getaddress(domain) true rescue Resolv::ResolvError false end
案3. Resolvをテストでstub 化する
これは自分では思いつかなかったのですが、chatGPTに他に解決方法を尋ねてみたところ、実DNSへの問い合わせを避けてスタブを使用する方法があるようです。
以下はMinitestのstubを使うことで、特定のオブジェクトのメソッドを他の処理で置き換える方法です。
getaddressを直接置き換えてしまうので、テストとして意味があるかは少し判断が難しいところではあります。
Resolv.stub(:getaddress, ->(*) { raise Resolv::ResolvError }) do assert_not Checker.valid_domain?('') end
※ この他にもネットワーク設定側で制御できる方法もあるかもですが、そこまでは調べられず…
この問題はそもそも起きているのが自分だけというのもあり一旦検討となり、Issueだけ軽く立てていたのですが、後日案2.のアプリ側で早期リターンで対応され自分の環境でもエラーが起きなくなりました🎉
参考資料
library resolv (Ruby 4.0 リファレンスマニュアル)
ruby/lib/resolv.rb at fcf3939780972d587b18afc26c4abd2da2c0b7ec · ruby/ruby · GitHub
class Thread (Ruby 4.0 リファレンスマニュアル)
「/etc/resolv.conf」ファイル - Linux技術者認定 LinuC | LPI-Japan
scutil(8) osx man page | unix.com
ASCII.jp:知ってトクするOS Xのコマンド(1) (1/2)
GitHub - apple-oss-distributions/mDNSResponder · GitHub
mdnsresponder(8) osx man page | unix.com
/etc/resolv.conf について #Linux - Qiita
resolv.confのsearchとdomain - Lism.in * blog - nekoya (id:studio-m)
/etc/hosts と /etc/resolv.conf #dns - Qiita
https://docs.seattlerb.org/minitest/Object.html#method-i-stub
hosts, resolv.conf, nsswitch.conf - ご先祖様はきっと農民。
https://toshiocp.com/entry/2022/10/10/183002
【Ruby】Minitestでスタブを使う(stub/stub_any_instance) #Rails - Qiita
https://www.rfc-editor.org/rfc/rfc2606.html
感想
今回遭遇した問題は特定のネットワーク環境でしか起きない落とし穴っぽい内容(Discordを遡って検索してみたところ、過去に2名くらい同じような人がいたっぽい)のため、かなりニッチであまり役にたつ記事ではないかもしれませんが、個人的には調べていてとても面白かったです。
Rubyの標準ライブラリを通して「クライアント側でDNS解決がどう行われているのか」の理解を深める良い機会にもなりました。
また、ネットワークが絡むテストは不安定になりやすい(環境に依存する)というのも実感できたので、自分が実装する機会があれば気をつけたいと思いました。
そして、ブログへのアウトプットがだいぶ久々になってしまった(昨年のアドベントカレンダーぶり……)のですが、書きたいことがたくさんあり、3本ほどストック(そのうち2本は5割くらいの完成度ですが)ができました🙌
最近はアウトプットをおろそかにしていたのですが、アドベントカレンダーのおかげでアウトプットの楽しさを思い出したので、改めて頑張ろうと思いました!
明日のアドカレ
読んでくださりありがとうございました!
明日のフィヨルドブートキャンプのアドベントカレンダー2025は、mousu-aさんの予定です!
フィヨルドブートキャンプのアドベントカレンダー2025はこの後も素敵な記事がたくさんアップされて来ますのでお楽しみに!































