Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2025 - Making TCPSocket.new Happy!

ogp

こんにちは、2025年に新卒で入社した(ReLU)と申します。
本記事では、RubyKaigi 2025の2日目に行われたMisaki Shioi(@coe401_)さんによるセッション、「Making TCPSocket.new "Happy"!」について紹介させていただきます。

が、その前に前提知識として Happy Eyeballs について軽く紹介します。

Happy Eyeballs とは

1981年から長く使われてきた IPv4 ではアドレス数の枯渇が問題となっており、その解決策として1990年代から IPv6 の普及が進められています。
日本ではユーザー側の回線で IPv6 の普及が進んでおり、報道によれば2024年2月に IPv6 普及率が50%に達したところです。

IPv6 で通信をするにはサービスプロバイダ側でも IPv6 に対応する必要があり、すでに Google(google.com)・Netflix(netflix.com)・Facebook(facebook.com) など、多くのコンテンツプロバイダが IPv4・IPv6 両対応となっています。
なので、ひょっとしたら IPv4・IPv6 の区別を意識することなく日常的に IPv6 を使って通信している方も多いかもしれません。
ご自身の使われているネットワークが IPv6 に対応しているか否かは test-ipv6.com を使うことで確認できますので、ぜひ見てみてください。

IPv4・IPv6 両対応環境のことを「IPv4/IPv6 デュアルスタック環境 (以下、デュアルスタック)」といいます。
デュアルスタックだと「IPv4 で通信障害が起きても IPv6 で通信できる」あるいは「IPv6 で通信障害が起きたら IPv4 で通信できる」ことを意味し、ネットワーク全体としての可用性は高くなります。
実際、Techouse でも IPv4/IPv6 のいずれかに限定された通信のトラブルに直面したことがあります(例:全社合宿で訪れた先のネットワークが IPv6 のみに対応していてインターネット接続に支障が出たり、オフィスで利用している ISP の影響で一時的に IPv6 経由の接続ができなくなったり……)。

こうした問題が発生したときでも通信ができるようにするためには、ネットワーク側が単純にデュアルスタックに対応するだけではダメで、プログラム側もデュアルスタックを意識する必要があります。

たとえば Ruby では次のように「IPv6で接続し、ダメだったらIPv4で接続する」プログラムが考えられます。

require 'socket'
require 'timeout'

# 接続先ホスト
HOST = ARGV[0] || 'google.com'
# タイムアウト時間を設定
CONNECTION_TIMEOUT = 2

# 指定されたアドレスファミリーでホストに接続するメソッド
def try_connect(host, port, family)
  # 指定されたアドレスファミリーで名前解決
  addr_infos = Socket.getaddrinfo(host, port, family, :STREAM)
  addr_infos.each do |info|
    _, _, _, ip_address, _, socktype, protocol = info

    # ソケットを作成
    socket = Socket.new(family, socktype, protocol)
    # ソケット接続に使用するアドレス構造体を作成
    addr = Socket.sockaddr_in(port, ip_address)

    # 接続を試みて、タイムアウトになれば次のアドレスへ
    Timeout.timeout(CONNECTION_TIMEOUT) do
      socket.connect(addr)
    end

    puts "Connected to #{ip_address} (#{family == Socket::AF_INET6 ? 'IPv6' : 'IPv4'})"
    return socket
  end
rescue => e
  puts "Connection failed via #{family == Socket::AF_INET6 ? 'IPv6' : 'IPv4'}: #{e.message}"
end

# IPv6を優先してホストに接続するメソッド
def connect_prefer_ipv6(host, port)
  # IPv6を優先して接続を試みる
  socket = try_connect(host, port, Socket::AF_INET6)
  return socket if socket

  # IPv6接続が失敗した場合、IPv4で接続を試みる
  socket = try_connect(host, port, Socket::AF_INET)
  return socket if socket

  raise 'Both IPv6 and IPv4 connection attempts failed.'
end

socket = connect_prefer_ipv6(HOST, 80)
socket.close

定数 HOST を変更して IPv6 に対応していないサービスプロバイダに接続しようとすると、IPv4 にフォールバックしている様子を確認できます。

# IPv6 に対応していない場合の実行結果
$ ruby connect.rb ipv6-only-site.example.com
Connection failed via IPv6: getaddrinfo: nodename nor servname provided, or not known
Connected to 172.168.0.0 (IPv4)

# IPv6 に対応している場合の実行結果
$ ruby connect.rb
Connected to 2404:6800:4004:825::200e (IPv6)

しかし、このような実装では次のような問題が考えられます。

  • 名前解決の失敗による遅延
    • IPv6 アドレスの取得で大きく遅延してしまう場合、IPv4 アドレスの取得を試行するまでにタイムロスが発生してしまう
  • IPv4 フォールバックまでの遅延
    • 名前解決後 IPv6 で通信できなかった場合、IPv4 にフォールバックするまでのタイムアウトが待ち時間になる

これらの問題を解決するには、理想的にはどのような方法が考えられるでしょうか……

「IPv4 と IPv6 を両方ほぼ同時にトライして、接続が先に成功した側を使いたい!」ですよね。

Happy Eyeballs とは、まさに「IPv4 と IPv6 を両方ほぼ同時にトライして、接続が先に成功した側を使う」というものです。
これにより、クライアントとサーバー間の接続確立にかかる時間を短縮することができます。

すでに RFC 6555 と、その運用実績を積んで課題が解消された RFC 8305 として標準化がされています。
Mozilla Firefox や Google Chrome など主要ブラウザでも RFC 8305 準拠の実装がすでになされています。

Happy Eyeballs v2 in Ruby

Ruby で TCP を使って通信する場合は Pure Ruby 版の Socket.tcp と、C 拡張版の TCPSocket.new の2種類の方法があります。
前者のほうは Ruby 3.4.0 にてShioiさんの手によって実装され、その内容はShioiさんが昨年の RubyKaigi 2024 で発表された「An adventure of Happy Eyeballs」・RubyKaigi 2024 follow up で発表された「Some more adventure of Happy Eyeballs」で紹介されています。

今回のセッションでは、より広く使われている TCPSocket.new にも Happy Eyeballs v2 を導入したことについて解説されました。

Ruby の Socket.tcp に実装された Happy Eyeballs v2 (以下、HEv2) は以下のような状態遷移図を元に実装されています。

Happy Eyeballs Version 2 の状態遷移図

図に表現されている通り、IPv4 のみ対応のサービス・IPv6 が利用できるサービスでは接続時間にほとんど差がないはずです。
つまり、HEv2 の効果が発揮されるのは「IPv6 ネットワーク障害時に IPv4 にフォールバックして接続する」ようなケースです。

HEv2 では、IPv4 と IPv6 の名前解決を同時に開始し、IPv4 の名前解決が先に完了すると、Resolution Delay(50ミリ秒)の間 IPv6 の名前解決完了を待ちます。
この待機時間内に IPv6 の名前解決が完了しなければ、IPv4 での接続試行を開始します。
IPv4 と IPv6 の名前解決が完了した場合は、両方のプロトコルでの接続を試み、最初に成功した方を使用します。
これにより、IPv6 に問題がある環境でも、接続の遅延を最小限に抑えながら IPv4 への素早い切り替えが可能になります。

これによって障害時の接続性の向上が実現されるのはとてもありがたいことです。
なにより、Ruby の標準ライブラリのレベルで HEv2 に対応してもらうことによって、IPv4・IPv6 の区別を意識しなくても両方が実用可能となるのが我々プログラマにとって非常にありがたいポイントです!!

内部実装の変更

Pure Ruby 実装版の HEv2 では、名前解決に Ruby 標準のインターフェースである Addrinfo.getaddrinfo を使用しています。これは内部的には C 言語で実装されていますが、Ruby のレイヤーから利用できます。
一方、CRuby の内部実装である TCPSocket.new では、Ruby のインターフェースを経由せずに直接 C レベルの名前解決関数を利用する必要があります。

一般的な C 言語プログラミングでは、このような名前解決には libc が提供する getaddrinfo() 関数を利用するのが標準的です。
しかし、getaddrinfo() 関数だけで名前解決を行うと、GVL の影響により名前解決中は他のスレッドの処理を中断しないといけなくなります。

そこで、CRuby 内部では、getaddrinfo() 関数をラップした rb_getaddrinfo() 関数が用意されています。
この関数を名前解決で使用することにより、GVL を開放してスレッド並行性を確保し、他スレッドでの処理を継続できるようにしています。

一方で、従来の rb_getaddrinfo() 関数にも制約はあり、rb_getaddrinfo() は1つの子スレッドしか生成していなかったり、名前解決完了の検知に条件変数を使用していたりと、この関数を使用して HEv2 に対応することは困難でした。
この課題を解決するため、TCPSocket.new の HEv2 実装に向けて以下の新しい関数が作成されました。

これらの新しい関数の導入により、HEv2 においては名前解決処理に専用の C 実装が用いられるようになりました。

ファイルディスクリプタのサイズ制限問題

複数の Socket を使った名前解決と接続を待ち合わせるため、Socket.tcp 内で使われている IO.select と同じ役割を持つ rb_thread_fd_select 関数が利用されました。
この内部では select(2) システムコールが利用されています。

しかし select(2) には「監視できるファイルディスクリプタ(fd)の数に上限(FD_SETSIZE)がある」という重要な制約が存在します。

具体的なシナリオを見てみましょう。

  1. サーバーが多数のIPアドレス(IPv4 と IPv6 の両方)を提供している
  2. HEv2 の仕様により、これらのアドレスに対して並行して接続を試行する
  3. 接続ごとに新しいソケットが作成され、それぞれがファイルディスクリプタを消費する
  4. さらに、名前解決の完了を検知するためのパイプも追加でファイルディスクリプタを使用する

このように、ソケットやパイプによってファイルディスクリプタが次々と消費されていくと、最終的に select(2) が監視できる上限(FD_SETSIZE)を超えてしまう可能性があります。
select(2) システムコールは、通常 glibc(GNU C Library)を通じて呼び出されますが、glibc における FD_SETSIZE のデフォルト値は、Linux 6.14 の時点でも 1024 に過ぎません。意外と小さな上限です。

上限を超えてしまうと、一部のイベントを正しく検知できなくなり、最悪の場合、SEGV(Segmentation Fault)が発生する問題につながります。
実際に、これに関連するIssueも報告されています。

この問題は、Linux の man page にも明確に警告されています。

WARNING: select() can monitor only file descriptors numbers that are less than FD_SETSIZE (1024)—an unreasonably low limit for many modern applications—and this limitation will not change.
-- select(2) -- Linux manual page

ちなみに FreeBSD では FD_SETSIZE は引き上げ可能と man page に書いてあります。(select(2) FreeBSD Manual Pages
……カーネル内のコードを軽く読んだ感じ、一応動くとは思うのですが……強引な手なのでやりたくないですよね。

Socket.tcp では問題が顕著化しなかった理由

Pure Ruby 版のほう (Socket.tcp) では、FD_SETSIZE の上限問題は発生していませんでした。これはなぜでしょうか?

その理由は、Socket.tcp の内部で select(2) システムコールへのラッパーとして使っている IO.select メソッドが FD_SETSIZE の上限を突破できるように実装されているからです。
ソースコード 中のコメントにはっきり書いてあります。

/**
 * The data  structure which wraps the  fd_set bitmap used by  select(2).  This
 * allows Ruby to use FD sets  larger than that allowed by historic limitations
 * on modern platforms.
 * (訳: この構造体は select(2) システムコールで使う fd_set ビットマップをラップするもの。
 *  歴史的な上限を超えて現代のプラットフォームで可能な数の fd を Ruby で扱えるようにする。)
 */
typedef struct {
    int maxfd;                  /**< Maximum allowed number of FDs. */
    fd_set *fdset;              /**< File descriptors buffer */
} rb_fdset_t;

このおかげで、Socket.tcp の実装では FD_SETSIZE の制限による問題が発生しなかったのです。

TCPSocket.new でもこの問題を解決するために Socket.tcp と同様のアプローチが採用されました。
具体的には、select(2) を直接操作するのをやめて、上記の rb_fdset_t 構造体を用いた rb_thread_fd_select() 関数による実装へと置き換えられました。

Shioiさんの講演では、上記で述べた技術的課題の他にも様々な紆余曲折があったそうですが、最終的に TCPSocket.new への HEv2 の導入は無事にマージされ、Ruby 3.4.0 でリリースされました!
これにより、Ruby の標準ライブラリとして多くのユーザーが HEv2 の恩恵を受けられるようになりました。

HEv2のリリースノート

Ruby 3.4.0 から TCPSocket.new をした際に Happy Eyeballs v2 はデフォルトで有効となっています。
しかし、接続先サーバーが同一 IP アドレスからの複数同時接続を制限していたり、クライアントで使用できるファイルディスクリプタの数に制限をかけているような特定の環境では、HEv2 の挙動に適さない場合があります。
なので、強制的に無効化にしたい場合は、以下のオプションを指定することで無効化できます。

  1. メソッド呼び出し時の引数で指定
    • TCPSocket.new(host, port, fast_fallback: false)
  2. グローバル設定
    • Socket.tcp_fast_fallback = false
  3. 環境変数による設定
    • TCP_NO_FAST_FALLBACK = 1

注意: C 拡張版の HEv2 は pthreads が利用可能な環境でのみ有効となります。pthreads が利用できない場合には fast_fallback オプションは無視され、従来の動作となります。

まとめ

Socket.tcpTCPSocket.new に Happy Eyeballs v2 を対応させることで、デュアルスタック環境で IPv6 を優先しながらも、接続確立時間を短縮します。
また、一方のプロトコルに障害が発生した場合の可用性も向上します。
今回の講演では、単なる機能追加ではなく、C 言語で実装された拡張ライブラリへの HEv2 導入における様々な技術的課題とその解決策が詳細に解説されました。

感想

RubyKaigi 2025 に3日間参加してきましたが、参加者のRuby愛を肌をもって感じました。

私は休憩することなく聞きたいセッションをすべて聴講しましたが、半分以上理解できませんでした。とても悔しかったとともに、どのセッションを聴いても完璧に理解できるようになりたい、という思いがヒシヒシと湧いて出てきました。

初日終了後のオフィシャルパーティでは実際にShioiさんとお話しして、感謝の意を伝えさせていただく機会にも恵まれました。
そこで Happy Eyeballs の今後の展望についてもお聞きしました。

現在、IETF の Happy WG にて、HEv2 に続く Happy Eyeballs Version3(以下 HEv3)が提案されているとのことです。
HEv3 の大きな特徴は、主に以下の2点です。

  • QUIC を優先して利用する
  • HTTPSレコードを利用する

今回の講演では言及されていませんでしたが、直接お伺いしたところによりますと現時点では、HEv3 の導入はすぐには見込めない状況だそうです。
考えてみれば、HEv3 は TLS や QUIC といった上位のレイヤまで対象とするものなので、Socket ではなく HTTP のクライアントレベルで実装するものになるはず。
これはまったくの別物と言えるでしょう。

現在 HEv3 は Internet-Draft の段階で、まだ正式なものではありません。
IETF のメーリングリストを見たところでは今のところ賛成意見が多いようで、今後 RFC として正式化されることが待ち望まれます。

緊張で顔がひきつってますが、オフィシャルパーティで一緒に撮っていただいたツーショット写真も載せて、今回のブログを締めさせていただきます。
本当にありがとうございました!

Shioiさんツーショット

参考文献


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com