Skip to content
Published on

決定論的シミュレーションテストが見つけたバグ — KAFKA-19880、そして「バグ0件」の読み方

シェア
Authors

はじめに — 再現しないバグを前にして

プロダクションで一度、奇妙なことが起きました。ログには痕跡が残っているのに、同じ状況をどれだけ再現しようとしても二度と起きません。テストを100回回しても緑です。結局「一過性の現象」として片付け、チケットを閉じます。半年後、同じことがもっと悪い形で戻ってきます。

Aiven の Ivan Yurchenko はこうしたバグをheisenbugと呼びます — IO、実行スケジューリング、その他の無作為性から生まれる非決定性が作り出すバグです。ネットワークはパケットを遅延させ、失い、順序を入れ替えて届けます。OSはスレッドを好き勝手にスケジューリングします。ディスクは故障します。彼の言葉を借りれば、こうしたバグがプロダクションで発火したとき、テレメトリだけで理解するのは事実上不可能です。

ここで「もっとテストを書け」というアドバイスはあまり効きません。想像していなかったものはテストできないからです。本稿はこの問題を別の角度から攻める技法 — 決定論的シミュレーションテスト(DST) — と、その技法が2025年11月に実際に見つけたApache Kafkaのバグを一つ追いかけます。そして、その事例がなぜ宣伝資料よりもずっと興味深いのかを話します。結果がきれいではないからです。

決定論的シミュレーションテストとは何か

AntithesisのドキュメントはDSTをこう定義します — テスト対象のソフトウェアをシミュレートされた決定論的環境に置くこと。核心は二つの単語です。

シミュレート。 システムの一部または全体を、実際のハードウェア・ネットワーク・OSの上ではなくシミュレータの上で動かします。すると、テストハーネスがシミュレートされた層で起きること — いつどんな障害が発生するか — を制御できます。

決定論。 クロック、スレッドのインターリーブ、システムが提供する乱数のように非決定性が入り込む出所を決定論的にします。だからシードを固定すれば、同じ実行が同じように再現されます。これがなぜ重要かは前節のシナリオが物語っています — バグを見つけるより、呼び戻すほうが難しかったからです。

ドキュメントによれば、このアプローチの実用的な採用は2010年ごろFoundationDBとAWSでそれぞれ起き、ほぼ同時発明に近いものでした(アイデア自体はそれより前からあります)。AWSではAl Vermeulenが内部ロックサービスの初期実装をテストするために導入し、最も早く記録に残る議論の一つがWill Wilsonの2014年Strange Loop講演です。FoundationDBが作ったそのフレームワークが、のちにAntithesisという会社の技術的基盤になります。

実装方法は大きく二つに分かれます。

  1. FoundationDB方式。 システムを設計する段階で、非決定論的なコンポーネントを全て差し替え可能(pluggable)にしておく。強力ですが、システムとその依存関係全体を最初からDSTを前提に作らなければなりません。Antithesisドキュメントの表現では、すでにプロダクションにあるシステムには一般的に非現実的です。
  2. 決定論的ハイパーバイザー方式。 ふつうの非決定論的ソフトウェアを、そのまま決定論的ハイパーバイザーの中で動かす。Antithesisが売っているのはこちらで、だからコードを直す必要がありません。

そしてドキュメントが指摘する、見落とされがちな点があります。人は「完全に決定論的なシステムを作ること」を主な技術的難題と見なしますが、実は状態空間を十分かつ効率的に探索することも同じくらい複雑な課題だということです。ほとんどのソフトウェアで状態空間は極端に大きいからです。DSTがプロパティベーステスト/ファジングやフォルト注入と一緒に語られるのはこのためです。(プロパティベーステストそのものは別稿で扱いました。)

もう一つだけ概念を。シミュレータがクロックを所有していれば、コードがtime.Sleep()に相当することをするときも実際に待つ必要がありません。だから壁時計時間(wall clock)よりはるかに多くの論理時間(logical/application time)を圧縮して動かせます。以下の数字は全てこの単位です。

Aivenの実験 — Diskless Kafkaをシミュレータに入れる

2026年3月5日、Aivenはその過程を公開しました。対象はInkless — KIP-1150: Diskless Topicsを実装するための暫定的なApache Kafkaフォークです。ブローカーのローカルディスクの代わりにオブジェクトストレージを主ストレージとして使う機能で、KIPウィキの現在のステータスはAcceptedです。

動機は明確です。既存のKafkaコードに新しいコードパスを大量に追加している最中で、それら自体が正しいかも確認しなければなりませんが、より重要なのは既存のKafkaのパスを壊していないかの確認です。

セットアップはこうです。新しいハーネスを書く代わりに、既にあるKafkaのDucktapeシステムテストスイートを拡張しました。

  • Docker Compose内にDucktapeワーカー14個(Inklessのコードでブローカーとクライアント両方を動かす)、Inklessコントロールプレーン用のPostgreSQL、MinIOストレージ
  • Disklessトピックを作って継続的にproduceし、全て消費されるか確認するシステムテスト(inkless_produce_consume_test.py)。ここにAntithesis SDKをインポートして追加のassertを行う
  • イメージをレジストリにpushし、HTTP POST一発で実行

ここで学ぶべきなのはツールではなく不変条件です。テストがずっと確認していたのは三つだけでした。

  • テストが絶対に停止しない(Antithesisのreachable演算子)
  • 書かれたメッセージは全て読まれる(always_or_unreachable)
  • 悪いことが起きない — メッセージが順序を乱して読まれる、重複オフセットが出る、パーティション集合が変わる、といったこと(unreachable)

DSTは魔法ではありません。何が「間違い」かをあなたが教えない限り、何も捕まえられません。シミュレータは奇妙な状態を作るだけで、それを奇妙だと判定するのはこの三行です。

結果1 — 論理時間2,200時間、バグ0件

テストはAntithesisスーパーバイザーの中で、合計約2,200論理時間、Kafkaクラスタへのproduceとconsumeを続けました。そして何の問題も見つかりませんでした。

Aivenがこの結果に添えた一文が、本稿で最も価値ある箇所だと思います。

通ったテストは最も疑わしい種類のテストかもしれない。それは単に、あなたがまだ問題を見つけていないだけかもしれない。

参考までに、Aivenは全体として壁時計基準で約200時間動かし、それが約9,700論理時間(1.1年)に相当したと述べています。先の2,200はそのうちこのInklessシステムテストに割かれた分です。

「バグ0件」が正確に何を証明するのか、ここで押さえておきましょう。それはInklessが正しいことの証明ではありません。このワークロードで、この三つの不変条件で、これらのシードでは違反が見つからなかったという意味です。ワークロードが触れないコードパス、不変条件が表現しない性質は、依然として死角です。

結果2 — 対照群から出てきたKAFKA-19880

そこでAivenは対照群を動かしました。自分たちの前提を再確認するために、InklessフォークではなくアップストリームのApache Kafka本家コードに、Kafkaの元のシステムテストの一部をAntithesisで動かしたのです。ハーネスが実際にバグを捕まえられる状態かどうかを見る、一種のサニティチェックです。

そしてそこからKAFKA-19880が出てきました。タイトルはこうです。

First batch in producer epoch can be appended out-of-order even for idempotent producer

Aivenの表現どおり「驚いたことに」でした。自分たちの新しいコードを検証するために作った装置が、対照群として立てた側からバグを持ち帰ってきたのです。Aivenが記事の結論で述べた表現をそのまま移せば、Apache Kafkaはコミュニティがテストと設計に常に誠実だった、非常に成熟し実戦で鍛えられたソフトウェアです。

メカニズムはこうです。プロデューサーはenable.idempotence=truemax.in.flight.requests.per.connection=5で設定されており、作りたてのからのトピックに複数バッチ分のレコードを送ります。ところが、クラスタと対象ブローカーの間にメタデータ伝播に影響する接続の問題があります。

プロデューサー: enable.idempotence=true, max.in.flight.requests.per.connection=5
対象: 作りたての空トピック input-topic-0

correlationId=21  base sequence 0     -> NotLeaderOrFollowerException  (ブローカーがまだ自分がリーダーだと知らない)
correlationId=22  base sequence 1458  -> NotLeaderOrFollowerException
correlationId=23  base sequence 2823  -> NotEnoughReplicasException    (ISRサイズ1 < min.isr 2)
correlationId=24  base sequence 4188  -> 成功 (!)
correlationId=25  base sequence 5553  -> ...

結果: このパーティションに最初に書き込まれたバッチのbase sequenceが
      0ではなく4188。
      冪等性を有効にしていても、最初のバッチが論理的に順序を乱したまま受理される。

issue本文の表現では、correlationId 24に至ってリクエストが成功し、そのため最初に書き込まれたバッチが論理的に順序を外れたまま受理されます。先に失敗したバッチは後で新しいプロデューサーエポックとして再試行されることがあり — 報告者の事例では実際にそうなりました — が、それは重要ではありません。意図したレコード順序はすでに壊れているからです。

条件も明確です。パーティションが空である必要はなく、プロデューサー状態が空で、そのエポックの最初のリクエストが上記のように失敗すれば十分です。逆にエポックの最初のリクエストが成功すれば、この状況は起こり得ません。r1が成功しr2がNotLeaderOrFollowerExceptionで失敗すれば、r3はOutOfOrderSequenceExceptionで拒否されるからです。報告者はこの食い違いを、ブローカー側のProducerAppendInfo.javaの検証ロジックに帰しています。

この事例から本当に学べること — 再現可能性

ここでDSTの価値が現れます。issue本文で報告者自身が書いた一文です。

Antithesisの下では、これをかなり安定してライブ再現できる。おそらく、これをそこそこの頻度で捕まえられるローカル環境を作るのは簡単ではないだろう。

しかし彼はそこで止まらず、これをReplicaManagerTest内のユニットテスト一つに縮約しました。

// KAFKA-19880のissue本文にある再現コードから抜粋・要約
val r0 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 0, new SimpleRecord("record 0".getBytes()))
val r1 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 1, new SimpleRecord("record 1".getBytes()))

// val order = List(r0, r1)  // 正常な順序 -- 順序違反なし
val order = List(r1, r0)     // r1 が順序を乱したまま受理される
                             // r0 は後でより高いepochとして再試行され受理される

この流れがDSTの本当のセールスポイントです。シミュレータが希少なインターリーブを安定して再現し → 人間がそれを覗き込み → ツールなしでも動く決定論的なユニットテストへと圧縮する。最終的な成果物は、Antithesisの購読がなくても誰でも動かせる20行のScalaです。ツールは梯子であって、目的地ではありませんでした。

そして結末はきれいではない

ここからが宣伝資料には出てこない部分です。

まず、報告者本人が確信を持てていません。issue本文の最後の問いはこうです — 自分がドキュメントを読んだ限りではこれはバグだ、enable.idempotenceがこれを防いでくれると期待していた、自分が正しいのか、それともこれは許容される動作でドキュメントを改善すべきなのか? Aivenのブログ記事も同じトーンを保っています — これがドキュメント不足なのか厳密なバグなのかはまだ分からず、コミュニティの答えを待っているところだと。

では今、このissueはどんな状態でしょうか。2026年7月現在JIRAを見るとこうです。

KAFKA-19880
  Summary:     First batch in producer epoch can be appended out-of-order
               even for idempotent producer
  Status:      Open
  Resolution:  Unresolved
  Priority:    Minor
  Reporter:    Ivan Yurchenko
  Created:     2025-11-12
  Affects:     4.0.1, 4.1.0
  Fix Version: (なし)
  Components:  core, producer
  コメント:      1件

報告から8か月、コメントはたった一つです。2026年3月10日、Travis Bischel(Go用Kafkaクライアントfranz-goの作者)が残した一行 — 「Likely related: KAFKA-14312」。

そのKAFKA-14312を開くと、タイトルはこうです。「Kraft + ProducerStateManager: produce requests to new partitions with a non-zero sequence number should be rejected.」新しいパーティションに0でないシーケンス番号で来るproduceリクエストは拒否すべきだ。報告者は同じTravis Bischel、報告日は2022年10月18日、優先度はMajor。そしてステータスはResolved / Won't Fixです。クローズされた日付は2024年11月12日です。

もちろんTravisは「likely related」とだけ言っており、同じissueだとは言っていません。その判断はまだ誰も下していません。しかし絵はこう整理されます。最先端のシミュレータが数百時間動いて異常動作を持ち帰り、再現もされ、ユニットテストへの縮約もされたのに — それが直すべきバグなのかドキュメントが不十分な仕様なのかは、隣接した形ですでに一度提起されて「直さない」で閉じられたことがある問いであり、今もまだ開いています。優先度はMinorです。

DSTが自動化してくれるのは発見です。それがバグかどうかを決め、優先順位をつけ、直す人を見つける仕事は、依然として人と組織の仕事です。ツールを買えば前半が解決され、後半はそのまま残ります。

数字がもっと良く見える事例 — そしてその読み方

Aivenの結果が「0件」で物足りないなら、よく引用される反対側の事例があります。WarpStreamが2024年3月に書いた記事です。読む前に出所をはっきりさせておきましょう — WarpStreamはオブジェクトストレージの上に載せたKafka代替品を売る会社であり(記事自体にその開示が付いています)、この記事はAntithesisの顧客がAntithesisについて書いたものです。以下の数字は全て自社報告値です。

  • Antithesisは、WarpStreamのワークロードを壁時計6時間動かす間にアプリケーション時間280時間をシミュレートしました。
  • そのうち、新しい「振る舞い」をこれ以上発見できず停滞(stall)するまでに約アプリケーション時間160時間かかりました。WarpStreamがここから引き出した結論は正直です — 160時間を超えて動かし続けるのは収益逓減で、代わりにテスト自体をもっと精緻にすることに投資すべきだ、というものです。
  • 初日に捕まったのは、メトリクス計測ライブラリのデータレースでした。プロジェクト最初の月からあったバグで、それまでCIでGoのレースディテクタを有効にしたまま文字どおり数万時間動かしていましたが一度も捕まりませんでした。Antithesisは実行233秒で捕まえました。
  • もっと興味深いのはデータ損失バグです。AgentはS3のPUTコストを減らすため、複数クライアントのProduceリクエストをメモリ上に約250ミリ秒集め、一つのファイルにまとめてオブジェクトストレージにflushします。flushにspeculative retryを追加するリファクタリングの最中に、エラー処理を微妙に壊してしまい、ごく短い瞬間、flushに失敗したファイルが成功したものとして扱われました。これをメタデータとしてコミットするバックグラウンドのgoroutineは5ミリ秒ごとにポーリングしており、問題の二つの状態遷移の間隔は通常1マイクロ秒未満です。つまりネットワーク障害と特定のスレッドインターリーブという二つの希少事象が重ならなければなりません。ステージングでは一度も見られず、Antithesisの中では壁時計時間当たりおおよそ一度発生しました。今は直したと述べています。

この事例から持ち帰るべきは「233秒 対 数万時間」という劇的な対比ではなく、その下にある構造です。レースディテクタを有効にして数万時間動かしたCIは、同じスケジューリングを数万回繰り返したことに近いものです。繰り返しは探索ではありません。DSTが売っているのは時間ではなく、インターリーブの多様性です。

同じ記事でWarpStreamは、AntithesisがJepsenより優れていると何段落にもわたって主張していますが、これは検証済みの事実ではなく明らかに一方の当事者の意見なので、そのまま受け取らないほうがよさそうです。(カオスエンジニアリング系のアプローチ全般はカオスエンジニアリング編で別途扱いました。)

正直なトレードオフ

Antithesis自社のドキュメントですら限界をこう書いています。ベンダー自身が認める制約であるという点で、むしろ信頼できます。

セットアップが重い。 決定論的シミュレーション環境を立てることは複雑で資源集約的な作業だとドキュメントは述べています。

全てのシステムが対象になるわけではない。 ドキュメントの表現では、全てのシステムがその周りにDSTを立てられるように設計できるわけではありません。

外部依存は結局モックしなければならない。 AntithesisのようなプラットフォームはほとんどのソフトウェアをDSTでテストできるようにしてくれますが、決定論を保証するには外部依存は依然としてモックするか別の方法で差し替える必要があります。これは思ったより大きな制約です。Aivenのセットアップをもう一度見てください — S3ではなくMinIO、マネージドDBではなくPostgresコンテナです。つまりあなたがシミュレートしているのはプロダクションではなくプロダクションの模型であり、S3の実際の一貫性動作や、マネージドサービスの実際の障害パターンは、模型の忠実度の分だけしか反映されません。

状態空間探索が本当のボトルネックです。 WarpStreamの160時間の停滞点がこれを示しています。ある時点を過ぎるとシミュレータは新しい振る舞いを見つけられなくなり、そこから必要なのはより多くの時間ではなく、より良いワークロードとより鋭い不変条件です。これは人間が行う設計作業であり、自動化されません。

タダではありません。 Antithesisは商用製品です。公開された価格表は確認できなかったので、ここで数字を作り出すことはしませんが、24時間365日のシミュレーションに計算資源がかかるという事実そのものは明らかです。参考までに、AivenもAntithesisを使う前にすでに縮小版のDSTを動かしていました — プロデューサーリクエスト数、メッセージ到着時刻、ペイロードの形といった入力をランダム化するプロパティベースのwriterテストで、それが役に立ったと述べています。ただしそうしたテストのためにかなりの量のコードを自分で書き維持する必要があり、あちこちぎこちなかったとも付け加えています。梯子には複数の段があります。

そして結果が人間の問題に帰着することがあります。 KAFKA-19880の節で見たとおりです。

では、あなたは使うべきか

値打ちがありそうな場合

  • 並行性・状態・調整が本質であるシステムです。Antithesisドキュメントが挙げる例は、分散データベース、金融トランザクションエンジン、分散インフラ、ブロックチェーン・合意プロトコル、非同期ワークフローです。(複式簿記の台帳のように不変条件が明確なドメインは特によく合います。)
  • 再現しないプロダクションの異常が既にあなたを悩ませています。
  • 不変条件を文で書けます。「書いたものは読まれる」「オフセットは単調増加する」といったことです。これができなければ、DSTを有効にしても捕まえるものがありません。
  • 例示ベースのテストと決定論的CIを既にうまくやっていて、その先へ進む必要があります。

過剰な場合

  • 普通のCRUDアプリケーションと大半のWebサービスです。ここで出るバグは大抵インターリーブバグではなくロジックバグで、ロジックバグはずっと安いツールで捕まります。
  • まだ単体・統合テストの基本ができていません。DSTはその上に載せる層であって、代替物ではありません。
  • システムの中核的な価値が外部SaaS呼び出しにあります。決定論のためにそれを全部モックしてしまうと、そもそもテストしたかった対象が消えます。
  • 時間がなく、不変条件をとりあえず二行だけ書いて済ませようとしています。それだと「バグ0件」が出るでしょうが、それはまさにAivenが警告した最も疑わしい結果です。

梯子を低い段から登ることをお勧めします。シードを固定したプロパティベーステスト → 自作の縮小版シミュレータや決定論的フォルト注入 → それでも捕まらないものが残っていて、そのバグの期待コストが十分大きいときに決定論的ハイパーバイザー。Aivenが実際に踏んだ順序がこれでした。

おわりに

まとめるとこうです。DSTは非決定性の出所をシミュレータの中に閉じ込め、希少なインターリーブを意図的に探索し、見つけたものをシードで再現します。価値は「バグをより多く見つける」ことより「見つけたバグを再び呼び戻せる」ことにあります。KAFKA-19880が決定論的ユニットテスト20行に縮約されたことがその証拠です。

同時にこの事例は、この技術の現在地をかなり正直に示しています。2,200論理時間動かした自分たちのコードからは何も出ず、対照群として動かした成熟したアップストリームから一つ出て、それは8か月間Minor/Openのままであり、隣接する系統のissueは2022年に報告され2024年にWon't Fixで閉じられました。これは失敗談だという意味ではありません。ツールが実際に働く姿とはもともとこういうものだという意味です。

だから、ツールを先に選ばないでください。あなたのシステムで絶対に起きてはならないことを一文で書くところから始めてください。その文が出てこなければ、どんなシミュレータもあなたを救ってはくれません。そしてその文が出てくるなら — 驚くべきことに、既に半分は来ています。

参考資料