Techouse Developers Blog

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

RubyKaigi 2026 — Falcon への移行で発生した問題を調査してみた

ogp

はじめに

こんにちは、株式会社 Techouse でクラウドハウスのエンジニアをしている Higashiji です。

本記事では、RubyKaigi 2026 2日目の発表 Surviving Black Friday: 329 billion requests with Falcon! の内容を深掘りします。 発表者は、 Samuel Williams さんをはじめとする Shopify のエンジニアの皆さんです。

登壇者の Samuel さんがすでにスライドをアップロードしてくださっているため、元のスライドを参照しつつ読んでいただければ幸いです。

内容は Shopify のストアを表示する Storefront を Unicorn から Falcon へ移行する過程で発生したトラブルや成果を共有するものです。

本講演は Sub Arena で行われましたが、この規模のシステムの実情に触れる機会はなかなかないこともあってか、現地では立ち見が出るほどに注目のセッションでした。

筆者も現地で聴講し、Shopify の扱うトラフィックの大きさに驚愕するとともに、大規模なアーキテクチャ移行における実践的な知見を得ることができました。

一方で、自分自身 Falcon のアーキテクチャについてあまり知見がなかったために、現地での発表を聴いた時点では個々のトピックについての表面的な理解に留まってしまいました。

そこで本記事では、アーキテクチャ移行において発生した問題をより具体的に理解することを目的として、発表で触れられた問題を深掘りしていきます。

なお、本記事で取り上げる具体的な issue は、講演内で直接言及されたものではなく、講演の内容をきっかけに筆者が独自に調査したものです。そのため、Shopify において実際に直面した事象と本記事の解説内容が必ずしも一致するとは限らない点は、あらかじめご了承ください。


Falcon とその土台

本論に先立ち、本講演のテーマである Falcon およびその土台となっている Fiber / Fiber Scheduler / Async について軽くおさらいします。

Fiber

Fiber は Ruby が標準で提供している、ノンプリエンプティブなユーザーレベルスレッドです。いわゆる Coroutine などと他の言語で言われている機構に相当します。

Ruby で並行処理を行う場合、代表的なのは Thread です。
Thread がネイティブスレッドを用いて実装されているのに対して、Fiber は単一のネイティブスレッドの中で VM の実行コンテキストをコード側から明示的に切り替えるモデルです。

fiber = Fiber.new do
  puts "Hello"
  Fiber.yield
  puts "World"
end

fiber.resume # => "Hello" を出力して yield で停止
fiber.resume # => "World" を出力して終了

Thread に比べて生成コストやコンテキストスイッチのコストが小さく、メモリ使用量も少ないというメリットがあります。一方で、自分から yield しない限り他の Fiber へ切り替わりません。

そのため、CPU を最大限使って効率よく処理するためには、ブロッキング操作のような待ち時間を検知して Fiber の切り替えを行う仕組みが必要になります。これを担うのが、次に紹介する Fiber Scheduler です。

Fiber Scheduler

Fiber Scheduler は Ruby 3.0 で導入された、ブロッキング操作を Fiber の切り替えに結び付けるためのインターフェースです。

Fiber.set_scheduler(scheduler) で Scheduler オブジェクトを登録すると、Ruby 組み込みのブロッキング操作の挙動が変わります。具体的には IO#read / IO#writeKernel#sleepMutex#lock などが呼ばれた際、代わりに Scheduler の対応するメソッドが実行されます。

Scheduler は IO#read のような呼び出しを内部的にノンブロッキング I/O とイベント待ちに置き換えます。当該 Fiber は I/O が読み書き可能になるまで待機状態となり、その間 Scheduler は別の Fiber を実行します。I/O が完了したら、元の Fiber が再開されデータを返します。

これによって、Net::HTTPFile.read のような既存のブロッキング前提のコードをそのまま協調的に動かすことが Fiber Scheduler の狙いです。

ただし、Ruby 本体が提供しているのはあくまで「インターフェース」であり、実際に Fiber を管理する Scheduler の実装は、ユーザー側に委ねられています。多くの場合ライブラリを使うことになりますが、その代表格が、次に紹介する Async gem です。

Async

Async は、本講演の発表者でもある Samuel さんが開発している、Fiber Scheduler の代表的な実装の 1 つです。

Async { ... } ブロックの中では、sleepNet::HTTP.get のような通常のブロッキング操作をそのまま書けます。Async が裏で Fiber Scheduler を立ち上げ、その上で並行に動かしてくれます。

require "async"

Async do
  3.times do |i|
    Async do
      sleep 1
      puts "Task #{i} done"
    end
  end
end
# 3 つの sleep が並行に走るため、全体で約 1 秒で完了する

Falcon

falcon のロゴ

ここまで紹介してきた Fiber、Fiber Scheduler、Async を土台としたアプリケーションサーバーが Falcon です。

Async と同じく発表者の Samuel さんが主体となって開発しており、RubyKaigi 2024 では Falcon をテーマに Keynote 発表もされていました。

Falcon は Rack 互換のアプリケーションサーバーであり、PumaUnicorn の代替として、既存の Rack アプリケーションに手を入れずに動作させることができます。

最大の特徴は、リクエストごとに Fiber を 1 本ずつ割り当てる点です。I/O 待ちの間に他の Fiber へ切り替わることで、ネイティブスレッドを割り当てる場合に比べて CPU リソースをより効率的に活用できます。

Falcon の Worker がリクエストごとに Fiber を 1 本ずつ生成し、Async 管理下の Fiber Scheduler 上で並行処理する様子を示した図

Falcon では、Worker が基本的な実行単位になっており、スレッドもしくはプロセスごとに 1 つの Worker を割り当てる形になります。

Worker はリクエストごとに 1 つの Fiber を生やします。

各 Fiber は先述の Async gem が管理する Fiber Scheduler の管理下でリクエストを処理します。これにより、ブロッキング操作を並行実行し、サーバー全体で CPU リソースを効率的に活用できます。

Falcon 全体の構成図。Async::Container が複数の Worker を管理し、別 service として Supervisor が各 Worker のメモリ使用量を監視する構造

それぞれの Worker は Async::Container により管理されます。障害発生時に Worker を立ち上げ直すのは Container の仕事です。

また、アプリケーション本体のサービスとは別に、独立した service として Supervisor を立てることも可能です。Supervisor は各 Worker のメモリ使用量を監視して、OOM Killer が発生する前に Worker を停止させるなど、安定運用に欠かせない監視機能を提供します。


Falcon 移行に際して発生した問題

発表では、スケールテストの最中に Falcon でのみ発生していた 3 つの問題が紹介されました。

  1. rdkafka Segfaults
  2. Spanner Deadlocks
  3. The Silent Death

このうち、Fiber 特有の興味深い問題である 1 と 2 について詳しく見ていきます。


rdkafka Segfaults

Apache Kafka は、大規模なストリーミングデータを扱うための分散メッセージングシステムです。ログ収集やイベント駆動アーキテクチャの基盤として広く採用されています。

Ruby から Kafka を扱う際には、C 言語で実装された librdkafka をベースとした rdkafka-ruby gem が広く利用されています。

今回取り上げられたのは、 高負荷時に rdkafka が Segfault を起こし、それが連鎖的に OOM Killer の発動に繋がっていく という不具合です。

なぜ Segfault が発生するのか

章のタイトルとしては rdkafka Segfaults ですが、Segfault が起こる根本的な原因は rdkafka 自体ではなく Ruby 本体の実装に起因していたものと思われます。

2025 年 5 月、rdkafka-ruby で Issue (#590) が報告されました。 そして同日中に Ruby Issue Tracking System へエスカレーションされています (#21342)。

根本にある不具合は Fiber が Mutex に対してロックを解放しないまま GC されるケース において Segfault が発生する、というものです。

rdkafka-ruby では、C で実装された Kafka クライアントへアクセスするスレッドのカウンタなどで Mutex を使っています。そのため、Fiber と組み合わせた際にこの問題が顕在化しました。

Mutex 関連のデータ構造

Ruby の Thread オブジェクト の内部状態を保持する rb_thread_tkeeping_mutexes から、自身が保持している Mutex の一覧を辿ることができます。

typedef struct rb_thread_struct {
    // ...
    struct rb_mutex_struct *keeping_mutexes;
    // ...
} rb_thread_t;

ruby/ruby vm_core.h#L1178 (修正前)

これは、Thread が終了する際に、自身の keeping_mutexes を辿って、ロックしたままの Mutex を全て解放するためです。

void
rb_threadptr_unlock_all_locking_mutexes(rb_thread_t *th)
{
    while (th->keeping_mutexes) {
        // ロック中の Mutex の連結リストを辿る
        rb_mutex_t *mutex = th->keeping_mutexes;
        th->keeping_mutexes = mutex->next_mutex;

        // Mutex を解放する
        rb_mutex_unlock_th(mutex, th, mutex->fiber);
    }
}

ruby/ruby thread.c#L437-L449 (修正前)

一方の Mutex (rb_mutex_t) も、ロックを保持している Fiber へのポインタを持っており、Thread へ到達できる構造になっています。

typedef struct rb_mutex_struct {
    rb_fiber_t *fiber;
    // ...
} rb_mutex_t;

ruby/ruby thread_sync.c#L9-L13 (修正前)

これは、 Mutex が GC で回収される際に Mutex → Fiber → Thread と参照を辿り、keeping_mutexes リストから自身を除外するためです。

以下は、Mutex オブジェクトが回収される際に呼ばれる mutex_free 関数です。

static void
mutex_free(void *ptr)
{
    rb_mutex_t *mutex = ptr;
    // Mutex のロックを保持している Fiber を取り出す
    if (mutex->fiber) {
        // Mutex を解放する
        rb_mutex_unlock_th(
          mutex,
          // Fiber 経由で Thread のポインタを取り出す
          rb_fiber_threadptr(mutex->fiber),
          mutex->fiber
        );
    }
    ruby_xfree(ptr);
}

ruby/ruby thread_sync.c#L126-L136 (修正前)

Mutex が正常に GC される場合

Mutex が GC に回収される正常系を図解してみましょう。
Fiber が Mutex のロックを持っている場合、以下のようなデータ構造になります。

正常系の初期状態。Thread の keeping_mutexes が Mutex を指し、Mutex の fiber が Fiber を、Fiber が Thread を指す相互参照

Mutex が GC される際には上掲の mutex_free が呼ばれます。
まずはポインタを辿ってロックを握っている Thread を特定します。

mutex_free が Mutex の fiber ポインタから Fiber を辿り、さらに Fiber の thread フィールドから Thread を特定する様子

Thread の keeping_mutexes リストから自身を取り除きます。

rb_mutex_unlock_th によって Thread の keeping_mutexes リストから Mutex への参照を外している様子

keeping_mutexes から Mutex が 除外 され、Thread から Mutex への参照が消えた状態

その後、Mutex 自身も回収されます。

Mutex 自身も解放され、関連オブジェクトの参照関係が解消された最終状態

Mutex より先に Fiber が GC される場合

基本的には上述のようにうまく動作しますが、Fiber が Mutex のロックを保持したままの状態で、Mutex より先に GC されるケースにおいて問題が発生します。

異常系の発端。Fiber が Mutex のロックを保持したまま、Mutex より先に GC で回収されてしまうケース

Fiber が Mutex のロックを保持した状態で GC されると、Mutex の fiber は解放済みのメモリ領域を指したままになります。

Fiber が解放されたあとも Mutex の fiber ポインタが残り、解放済みメモリ領域を指すダングリングポインタになっている状態

その後 Mutex の解放処理が走ると、keeping_mutexes から自身を削除するために Fiber 経由で Thread に到達しようとします。
もともと Fiber が保持していた Thread への参照の位置を読み出すことになりますが、その位置が別のオブジェクトに再利用されていると不正な値が返ってきます。
続いてその値をポインタとして参照しようとした瞬間、マップされていない、もしくは保護されたメモリ領域へのアクセスが発生し Segfault に至ります。

mutex_free がダングリングポインタ経由で不正な Thread アドレスを読み出し、参照した瞬間に Segfault が発生する様子


この不具合は、#14262 にて修正されています。

修正内容について簡単に解説すると、GC のマークフェーズにおいて、Mutex から、自身のロックを保持している Fiber と Thread をマークするようになりました。これにより、Mutex が生きている限りこれらが GC に回収されることがなくなります。

加えて、Mutex から Thread への直接参照を持たせています。これにより、Mutex 自身も含めて同一 GC サイクルで回収される際、Fiber が先に解放されていても keeping_mutexes から自らを除外できます。

修正後の Mutex 構造体は以下の通りで、VALUE thread; フィールドが追加されています。

typedef struct rb_mutex_struct {
    /// ...
    VALUE thread;
    // ...
} rb_mutex_t;

ruby/ruby thread_sync.c#L9-L14 (修正後)

なお、Mutex から自身の所有者である Fiber / Thread を特定するための構造は、この後にも変更が入っています。興味のある方は以下の Pull Request を参照してください。


Spanner Deadlocks

Spanner は Google が提供する分散リレーショナルデータベースで、Shopify ではセッションデータの保存に使われています。

Falcon 移行後、Spanner で Deadlock が発生したとき、 Falcon の Worker に対して SIGTERM を送っても応答が返ってこなくなる という不具合が発生しました。

この事象は、Falcon の内部で使われている Async と、Spanner へのリクエストで利用されている grpc gem を併用した際に発生します。原因は、Async 内部のデータ構造を保護するための仕組みが、grpc gem における割り込みの制御と干渉することでした。

なお、ここでの「割り込み (interrupt)」は CPU や OS レイヤーで語られる一般的なものとは異なり、Thread#raise などをトリガーとして、Ruby VM がスレッドに対して例外を送出する仕組み を指します。 (以降、簡略化のため Ruby VM レベルでの割り込みを本文中では「割り込み」と記載します)

以下、Async 側と grpc gem 側の設計を順に整理してから、両者の間にどのような問題があるのかを見ていきます。

Async 内では割り込みを遅延させている

Falcon の Worker は通常のプロセスと違い、SIGTERM を受けても即座に例外で中断するわけではありません。

これは、並行処理の基盤となるAsync が内部実装の都合上、割り込みの発生を遅延させているためです。

以下は、Async のコード内で実際に割り込みを遅延させている部分です。

private def run_loop(&block)
  interrupt = nil

  begin
    Thread.handle_interrupt(::SignalException => :never) do
      # 割り込みが発生するまでループを回す
      until self.interrupted?
        # Async do ... end に渡されたブロックはこの中で実行される
        break unless yield
      end
    end
  # ...
end

async/lib/async/scheduler.rb#L528-L557

Thread.handle_interrupt は割り込みの処理されるタイミングを制御するための Ruby 標準の API です。

Thread.handle_interrupt(SignalException => :never) のブロック内では、割り込みが発生しても SignalException は即座に発生しません。 ブロックを抜けたタイミングで初めて例外が発生します。

なぜ割り込みを遅延させるか

割り込みを遅延させる理由は、Task のデータ構造を操作する途中に割り込みが発生することで Async 内部のデータ構造が壊れることを防ぐため です。

Async は、処理の実行単位である Task 同士の親子関係を内部で管理しています1
例えば以下のケースでは、Task A に対して、3 つの子 Task が存在する形になります。

# Task A
Async do
  Async do
    # Task A-1
  end

  Async do
    # Task A-2
  end

  Async do
    # Task A-3
  end
end

親 Task から見た子 Task(@children)はこのような双方向リストで管理されています。

Task A-1 から A-3 までが head/tail で双方向リンクされ、末尾の A-3 が @children に戻る循環リストの図

データ構造を操作する具体例

ここで Task A-2 が完了した場合、どうなるでしょうか。 Task が完了すると、その Task は自らをリストから外します。

Task A-2 が点線で示された図。A-1 の head と A-3 の tail が互いを直接指し、A-2 がリストから除外される

実際にこの処理を行う Async::List#remove! の実装は以下のようになっています。 仮引数の node には、完了した Task が入ります。

private def remove!(node)
    node.head.tail = node.tail # ①
    node.tail.head = node.head # ②
    
    node.head = nil
    
    return removed(node)
end

lib/async/list.rb:128-137

ここで、データ構造を操作する処理が複数のメソッドに分かれている点に注意が必要です。 仮に ① と ② の間で割り込みが発生し、処理が中断した場合はデータ構造上の不整合が発生します。

Thread.handle_interrupt によってデータ構造の操作が確実に行われていないタイミングで割り込みのハンドリングを行っているのは、このような不整合を防ぐためというわけです。

備考: データ構造を守る理由

そもそもなぜデータ構造を守る必要があるのでしょうか。

一般的な Ruby プログラムでは、割り込みが発生した後のデータ構造は気にする必要がありません。なぜなら、割り込みが発生したらプログラムは即座に終了することが前提となっているためです。

しかし Async では、割り込みの後も処理を継続することが必要です。

これは、実行中の Task それぞれで割り込みのハンドリングを書きたい場合があるためです。

最小の具体例としては、以下のようなケースです。

Async do
  Async do
    begin
      # ...
    rescue Async::Cancel
      puts "割り込みが発生しました"
    end
  end

  Async do
    begin
      # ...
    rescue Async::Cancel
      puts "割り込みが発生しました"
    end
  end
end

これを実現するために、Async::Scheduler は割り込みを検知した際、
実行中の全ての Task で Async::Cancel を raise させた上で各 Task の処理を再開します。

private def run_loop(&block)
  interrupt = nil
  begin
    Thread.handle_interrupt(::SignalException => :never) do
      # 割り込みが発生したらループを抜ける
      until self.interrupted?
        break unless yield
      end
    end
  # Thread.handle_interrupt を抜けたことで発生する例外を捕捉
  rescue Interrupt => interrupt
    Thread.handle_interrupt(::SignalException => :never) do
      # ...
      # 全ての Task で `Async::Cancel` を raise させる
      self.stop
    end
    # ループを再開する
    retry
  end
  # ...
end

データ構造が壊れている可能性がある場合、割り込みの後に安全に処理を再開できません。

そのため、 Thread.handle_interrupt でデータ構造の不整合が発生しない箇所まで割り込みのハンドリングを遅延させているのでした。


grpc gem が結果を取得する仕組み

次に grpc gem を見ていきます。

grpc gem は、C++ で実装された Core ライブラリと、それをラップして Ruby の API として提供する gem 側コードの 2 層構成になっています。 grpc gem を使ってリクエストを送信する際、実際のリクエストの送信処理を担うのは Core ライブラリです。

gem 側のコードにおける、Core ライブラリからリクエストの結果を受け取る rb_completion_queue_pluck 関数を見てみましょう。

grpc_event rb_completion_queue_pluck(...) {
  // ...
  do {
    next_call.interrupted = 0;

    rb_thread_call_without_gvl(
      // GVL を解放した状態で実行する関数
      grpc_rb_completion_queue_pluck_no_gil,
      // grpc_rb_completion_queue_pluck_no_gil に渡す引数
      (void*)&next_call,
      // 割り込み発生時に呼ばれる関数(後述)
      unblock_func,
      // unblock_func に渡す引数
      (void*)&next_call
    );

    if (next_call.event.type != GRPC_QUEUE_TIMEOUT) break;
  } while (next_call.interrupted);
}

grpc/grpc src/ruby/ext/grpc/rb_completion_queue.c#L73-L95

rb_thread_call_without_gvl がポイントです。
これは、CRuby が C 拡張向けに提供する、GVL2 を明示的に解放する関数です。

Ruby はこの仕組みにより、VM の実装をシンプルに保ちつつ、複数のスレッドが同時に同じデータ構造を操作することによる不整合から VM を保護しています。
今回の rb_completion_queue_pluck 関数は単に処理が完了するまで待つ処理なので、GVL を解放することでプロセス全体として CPU リソースを効率的に使うことができます。

rb_thread_call_without_gvl には、2 つの関数が渡されています。
grpc_rb_completion_queue_pluck_no_gil は GVL を解放した状態で呼ばれるメインの関数。
unblock_func は実行中に割り込みが発生した際に呼ばれる関数です。

grpc_rb_completion_queue_pluck_no_gil

まずは、メインの関数である grpc_rb_completion_queue_pluck_no_gil の処理を見ていきましょう。 関数名に含まれる Completion Queue3 とは、Core ライブラリに依頼した処理の結果が格納されるキューを指します。

処理の概要は、 Completion Queue に結果が格納されるまで待つ というものです。
ただし、単純に待つだけではなく 200ms に一度処理を中断し、interrupted というフラグを確認する実装となっています。

static void* grpc_rb_completion_queue_pluck_no_gil(void* param) {
  // ...
  gpr_timespec increment = gpr_time_from_millis(200, GPR_TIMESPAN);
  // ...
  for (;;) {
    // deadline を現在から 200ms 後に設定
    deadline = gpr_time_add(
        gpr_now(GPR_CLOCK_REALTIME),
        increment
    );

    // 結果が Completion Queue に格納されるか、deadline が過ぎるまで待つ
    next_call->event = grpc_completion_queue_pluck(
        next_call->cq,
        next_call->tag,
        deadline,
        NULL
    );

    // deadline を迎える前に処理が完了していたらループを抜ける
    if (next_call->event.type != GRPC_QUEUE_TIMEOUT) break;
    // deadline を迎えた際に、interrupted フラグが立っていたらループを抜ける
    if (next_call->interrupted) break;
    // deadline を迎えた際に、interrupted フラグが立っていなければ次のループへ
  }
  return NULL;
}

わざわざ定期的にフラグをチェックする処理になっているのは、Thread#raise などの割り込みをハンドリングするためです。

この関数は GVL を解放した状態で実行されますが、CRuby において 割り込みを受けて例外を発生させるなどの処理を行うためには GVL の取得が必要 です。
GVL を解放したままずっと待つだけの単純な処理にしてしまうと、処理が完了するまで Thread#raise などに反応しないスレッドになってしまいます。

grpc_rb_completion_queue_pluck_no_gil ではこれを避けるため、200ms ごとに割り込みが発生していないかをチェックしています。

割り込みの発生有無は interrupted フラグを見て判断します。このフラグが立っていれば break で処理を完了させ、 GVL を再取得します。

unblock_func

interrupted フラグを立てるのは、rb_thread_call_without_gvl に渡されたもう 1 つの関数である unblock_func の役割です。

static void unblock_func(void* param) {
  next_call_stack* const next_call = (next_call_stack*)param;
  next_call->interrupted = 1;
}

grpc/grpc src/ruby/ext/grpc/rb_completion_queue.c#L66-L69

この関数は割り込みを受けた際に発火し、interrupted フラグを立てます。


rb_thread_call_without_gvl に渡される2つの関数の処理を確認しました。 改めて、呼び出し元である rb_completion_queue_pluck 関数を見てみましょう。

grpc_event rb_completion_queue_pluck(...) {
  // ...
  do {
    next_call.interrupted = 0;

    rb_thread_call_without_gvl(
      grpc_rb_completion_queue_pluck_no_gil,
      (void*)&next_call,
      unblock_func,
      (void*)&next_call
    );

    // 処理が完了していれば、ループを抜ける
    if (next_call.event.type != GRPC_QUEUE_TIMEOUT) break;

    // interrupted フラグが立っていても、例外が発生しなかった場合は処理を継続する
  } while (next_call.interrupted);
}

grpc/grpc src/ruby/ext/grpc/rb_completion_queue.c#L73-L95

grpc_rb_completion_queue_pluck_no_gil が終了すると、rb_thread_call_without_gvl を抜け、GVL を再取得します。Ruby はこのタイミングで割り込みをチェック します。

このコードで一見不可解に見えるのが、処理を do ... while で囲み、interrupted フラグが立っている場合に処理を再開している点です。

なぜこのような実装になっているかというと、全ての割り込みが例外を発生させるわけではない4ためです。例外が発生しないケースでは Completion Queue の結果を待つ処理を再開することが期待されます。

Signal.trap("USR1") { ... }
この状態で SIGUSR1 を受信すると、Ruby は SignalException を送出せず、登録された Proc を実行します。

そこで、「interrupted フラグが立っているのに例外が発生しなかった」という条件で、grpc_rb_completion_queue_pluck_no_gil を再度実行するような処理になっています。

つまり、この関数は 例外が発生しない限り、延々と処理の完了を待ち続ける5 ことになります。

grpc/grpc src/ruby/ext/grpc/rb_completion_queue.c#L50
しかし、今回のケースでは無期限を意味する構造体である gpr_inf_future が渡されています。そのため、実際にはこの関数のレイヤーでタイムアウトが発生することはありません。
grpc/grpc src/ruby/ext/grpc/rb_call.c#L832-L833


Falcon で起こっていたこと

両者の挙動を組み合わせると、Spanner で Deadlock が発生した際、Falcon 側で何が起きていたのかが見えてきます。

Falcon の Worker をそれぞれ独立したプロセスで動かす Forked モードでは、 Worker は SIGINT / SIGTERM を受け取ると、Signal.trap でハンドリングします。 そして Thread#raise により Interrupt を発生させます。

def self.fork(**options)
  self.new(**options) do |process|
    ::Process.fork do
      Signal.trap(:INT){::Thread.current.raise(Interrupt)}
      Signal.trap(:TERM){::Thread.current.raise(Interrupt)}
      Signal.trap(:HUP){::Thread.current.raise(Restart)}
      # ...
    end
  end
end

async-container/lib/async/container/forked.rb#L98-L122

これを踏まえると、Worker が Spanner からの応答を待つ間に Worker に対し SIGTERM が送信された場合の挙動を以下のようにまとめることができます。

  1. Worker に SIGTERM が届く
  2. Signal.trap でキャッチし、Thread#raise により、対象スレッドへ割り込みを登録する
  3. grpc gem は保留中の割り込みを検知して、Completion Queue から結果を取り出す処理を中断し、GVL を取得する
  4. 本来 Thread#raise の結果として例外の発生が期待されるが、Async の handle_interrupt により例外は発生しない
  5. 例外が発生しなかったため、grpc gem は Completion Queue の結果を待つ処理を継続する

結果として、Worker が SIGTERM を受け取っても反応しない、という状況に陥りました。


この問題を受け Falcon の Container から Worker への、SIGTERM の反応がない場合に SIGKILL を送るように改良が行われました。

SIGKILLSIGTERM と異なりプロセス側でハンドリングできず、カーネルが強制的にプロセスを終了させるため、この問題を回避できます。

def stop(graceful = GRACEFUL_TIMEOUT)
  if graceful
    # まずは Worker プロセスに対して SIGINT を送る
    self.interrupt
    # 処理が終了するまでしばらく待つ
    self.wait_for_exit(clock, graceful)
  end
ensure
  if any?
    # 一定時間が過ぎても終わらない場合は SIGKILL を送る
    self.kill
    self.wait
  end
end

async-container/lib/async/container/group.rb:166-193

まとめ

軽い気持ちで調べ始めましたが、Mutex の GC 処理や grpc gem の内部実装など、幅広い部分にまたがる知識が必要となり、とても大変でした。
前例のないアーキテクチャを本番投入する際の苦労は発表でも語られていましたが、図らずも、それを身にしみて実感することとなりました。

また、今回問題が顕在化した grpc / rdkafka-ruby の両 gem は、いずれも C ライブラリを Ruby でラップした構造のライブラリでした。言語の特性に起因するインピーダンスミスマッチを吸収するレイヤーで問題が起こりやすい、という点が理解できたのも、今回の調査の収穫です。

このような知見をコミュニティに共有してくださる開発者に感謝しつつ、私自身も新しい構造を持ち込み、その成果や苦労をコミュニティに共有できるよう精進していきます。


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

jp.techouse.com


  1. 親 Task をキャンセルした際に子 Task も自動でキャンセルされるため、複雑な処理が書きやすくなるという利点があります。
  2. GVL(Giant VM Lock) とは、プロセス内で同時に Ruby コードを実行できるネイティブスレッドを 1 つに制限する仕組みです。
  3. https://grpc.io/docs/languages/cpp/async/ で詳しく解説されています。
  4. 例えば、Signal.trap でシグナルにハンドラを登録しているケースです。
  5. grpc_rb_completion_queue_pluck_no_gil 関数の実装としては、タイムアウトを外部から指定した場合に処理を切り上げるようになっています。