16年間、誰も踏まなかった——SQLiteの奥に眠っていたバグを半年かけて掘り当てた話

スポンサーリンク

WireGuardをベースに、端末どうしを直接つないで仮想的なネットワークを作るサービス「Tailscale」が、8月12日、半年におよんだ障害の顛末をブログで公開した。原因は自社のコードではなく、その下で動いていたSQLiteの中にあった。しかもそのバグは、2010年7月に出たバージョン3.7.0以降のすべてのバージョンに存在していたとみられている。16年ぶん、誰も報告していなかったことになる。

起きていたのは、データベースファイルの破損だ。最初の1件が見つかったのが2025年8月。そこから解決までの半年ほどのあいだに、計19回。共通点が何ひとつ見つからず、再現もできない。ブログを書いたのはTailscaleのソフトウェアエンジニア、Alex Chan氏で、調査には社内の複数チームとSQLiteの中心開発者を含む数十人が関わったとある。

主データベースとしてのSQLite採用

SQLiteは、スマートフォンのアプリからWebブラウザまで、ほとんどあらゆるところに入っている小さなデータベースだ。サーバーを別に立てる必要がなく、ファイル1つで完結する。

Tailscaleがこれを主データベースに据えたのは2022年から。よく知られていて、信頼でき、広く使われているから、というのが理由だとChan氏は書いている。同社の言い方を借りれば、SQLiteは「退屈な技術」で、それは褒め言葉である。

構成もSQLiteの流儀に沿っていた。同社のコントロールプレーン——端末どうしがつながるために必要な情報を配る側——は、内部でいくつものサーバー群(シャード)に分かれている。利用者ごとの仮想ネットワークは、そのうちのどれか1つに乗る。シャードごとにSQLiteのデータベースが1つあり、それに書き込むのはGoで書かれたプロセス1つだけ。書き手を1つに絞るこのやり方は、SQLiteがまさに想定している使い方だ。

ただし1か所だけ、標準から外れているところがあった。バックアップである。同社は数分おきにデータベース全体のスナップショットを取り、SQLiteのファイルをまるごとS3に上げていた。これを速く、かつ中身の整合が取れた状態で行うために、本来はSQLiteに任せておける「チェックポイント」という処理を、自分たちの手で制御していた。2023年の初めからこの構成で走っていて、それまで何も起きていなかった。

再現できない破損の半年

2025年8月、S3のバックアップを読み込むパイプラインが、あるデータベースでエラーを出した。バックアップに対して中身の整合を確かめるコマンドを走らせると、たしかに壊れていた。SQLiteのデータベースが壊れることは起こりうるが、普通に使っていてぶつかるものではない。修復して原因を調べたが、何も出てこなかった。

そして、また起きた。また起きた。また起きた。

調査は最初から行き詰まった。直近の変更を洗っても関係しそうなものがない。SQLiteとやり取りする低レベルのコードは何年も前に書かれたきりで、誰も触っていない。それでも全部読み直したが、こんな壊れ方をする理由は見つからなかった。破損どうしの共通点を探しても、特定のシャード、顧客、機能、時間帯、負荷——どれとも結びつかなかった。

引き金が分からないので、手元で再現することもできない。仕方なく、本番環境に診断用の記録を仕込んで、次の破損が起きる瞬間を待ち構える方針に切り替えた。動いているデータベースの障害調査でいちばんやりたくないやり方だが、ほかに手がなかった。

厄介なことに、破損は規則正しく起きてくれない。数時間おきに続くこともあれば、何週間も空くこともある。10月から12月にかけては6週間まるごと何も起きず、収まったかと思ったところで、クリスマスに戻ってきた。

破損したシャードでは、修復か復元が終わるまでコントロールプレーンを止める必要がある。その間、そのシャードに乗っているネットワークは、情報を配る側がまるごと消えた状態になる。すでにつながっている端末どうしの通信は続くが、新しく参加した端末はほかの端末の一覧をもらえないのでつながれない。ネットワークの構成を変えても伝わらないし、管理画面とAPIも一時的に使えなくなる。復旧にかかる時間は当初1時間を超えていて、回を重ねるごとに1時間未満まで縮めていった。

もっとも、このデータベースが持っているのは端末やネットワークの設定情報だけで、暗号鍵や通信の中身は入っていない。初期の復旧では、直前に追加した端末やその時の設定変更がいくつか残らず、入れ直しが必要になった程度だという。また、破損に巻き込まれたシャードは全体の一部で、大半のネットワークは一度も影響を受けていない。それでも同社はステータスページに全体障害として掲載し続けた。関係のない利用者にも障害表示が見える状態が半年続いた、ということでもある。

ここでTailscaleは、SQLiteの開発陣と有償のサポート契約を結んでいる。両者で仮説を立て、破損のたびにデータを集めては潰していった。close()によってPOSIXのロックが壊されている説、SQLiteが持っているメモリの扱いを誤っている説、スレッド安全を切ったまま複数スレッドから触っている説。どれも違った。

WAL-Resetバグの正体

手がかりになったのは、復旧を速くするために作った仕組みのほうだった。

同社は、データベースを変更するSQL文をすべて別ファイルに流して記録するパイプラインを組んだ。書き手が1つしかいないので、記録は完全に一本道になる。最後に無事だったバックアップにこの記録を順番に流し込めば、壊れた部分を迂回して最新の状態まで戻せる、という狙いだった。

これがうまく動いた——だけでなく、妙なものを見せた。19回のうち2回、記録を流し直しても最後まで通らなかったのだ。調べると、あるトランザクションが書き込んで確定したはずのデータが、あとから来たトランザクションからはなぜか見えていない。エラーは何も出ていない。起きてはいけないことである。

疑いはチェックポイントに絞られつつあった。ここで、その中身に触れておきたい。

SQLiteのデータベースファイルは、「ページ」と呼ばれる小さなブロックの集まりでできている。中身を書き換えるとき、該当するページが新しいものに差し替わる。ただしTailscaleが使っている設定では、新しいページをデータベース本体に直接書かない。いったん「WAL」という別ファイルに書き足していく。読み手を待たせずに書けるので速い。そして、たまったぶんを本体に書き戻す作業が「チェックポイント」だ。ふだんはSQLiteが勝手にやってくれて、使う側は気にしなくていい。

破損のたびに記録されていた数字にも、おかしなところがあった。SQLiteが「WALから本体へ何ページ運んだか」を報告してくるのだが、その数がWALに入っていたページ数を上回っていた。10ページしかないところから20ページ運んだ、という話になっている。

そこでSQLite側が、チェックポイント中に何が起きているかを覗くための道具を新しく書いた。SQLiteは、SQLを解釈する層、ページに切り分ける層、実際にディスクに書く層に分かれている。開発陣が作ったのは、いちばん下のディスク層をくるむ形で、やり取りを細かく記録に残す薄い皮のような仕組みだ。開発費用はTailscaleが出し、成果はSQLiteの公開リポジトリに入っている。

これを本番環境に入れて、次の破損を待った。さほど待たずに来た。

出てきた答えが「WAL-Resetバグ」である。チェックポイント処理と、書き込みトランザクションのあいだに起きるデータ競合——2つの処理が特定のタイミングでぶつかったときにだけ起きる、すれ違いの不具合だった。チェックポイント中の絶妙な瞬間に書き込みが入ると、チェックポイント側が状況を取り違える。WALから本体へコピー済みだと思い込んだページが、実際にはコピーされていない。そのページは永久に失われる。ところが、そのページを指し示している索引などのほうは、そのまま本体に書き込まれてしまう。行き先のない矢印だけが残り、データベースは壊れる。

踏み固められた道の外側

SQLite公式のドキュメントを読むと、このバグがどれだけ稀か分かる。

発現には条件が要る。WALモードで動いていること。同じファイルに2つ以上の接続が開いていて、それぞれ別のスレッドか別のプロセスにいること。そのうえで、書き込みとチェックポイントが同じ瞬間に重なること。しかも重なり方には厳しいタイミングの制約がある。

どれくらい厳しいかというと、SQLiteの開発陣は自然な形では一度もこのバグを再現できなかった。検証のために、狙った瞬間に問題の書き込みを差し込む仕掛けをSQLite本体に足して、わざと発火させるしかなかった。その細工なしでは、開発中もテスト中も、一度も観測されていない。

ならばなぜ、Tailscaleは半年で19回も踏んだのか。

答えは、同社が標準から外していた、あの1か所にあった。チェックポイントを自分たちの手で制御し、しかも自分たちのペースで、かなり頻繁に回していた。ほかの利用者が一生に一度も出会わない条件の組み合わせを、同社は毎日何度も作り出していたことになる。稀な条件で発火する不具合でも、そこまで数を撃てば、いずれ当たる。

Chan氏はブログで、SQLiteが悪いとは書いていない。大半の人は標準的な構成でSQLiteを使い、この種の問題には出会わない、と前置きしたうえで、チェックポイントを手動で制御して攻めたペースで走らせたことにより自分たちは「踏み固められた道から外れた」のだ、と書いている。

つまり、このバグは16年間放置されていたわけではない。16年間、誰もそこを踏まなかった。それだけのことだった。

撤回された修正版

修正はSQLite側で入った。バグは3.7.0(2010年7月)から3.51.2(2026年1月)までのバージョンに存在し、修正が入ったのは3.51.3(2026年3月13日)以降。古い系列を使っている場合のために、3.44.6と3.50.7へのバックポートも用意されている。チェックポイント処理に、別のスレッドがWALをリセットしたかどうかを確かめる手順が1つ足された形だ。

ただ、ここまでたどり着くのにもうひと騒動あった。

当初この修正は3.52.0として出た。Tailscaleはまず一部のシャードで様子を見て、問題なさそうだと判断してから全体に展開した。そのとたん、バックアップの監視が真っ赤になる。13個のデータベースで破損の報告が上がった。

結果からいうと、これは誤検知だった。原因は、同じ3.52.0に入っていた別の変更にある。文字列と浮動小数点数のあいだの変換が作り直され、丸め方がわずかに変わっていた。Tailscaleは細かい時刻を文字列で保存し、それを計算で数値に直した値に索引を張っていたため、索引の中身と計算し直した値が食い違い、整合チェックが「壊れている」と報告したのだった。先行して試したシャードには、たまたま丸め方の変化にかかる時刻が入っていなかった。

SQLiteはこれを受けて3.52.0を撤回し、WAL-Resetの修正だけを取り出して3.51.3として出し直した。公式の変更履歴にも、3.52.0の欄には「撤回」とだけ書かれ、予定していた機能は3.53.0に送られたと記されている。その3.53.0では、索引の中身が古くなったときに自動で直す仕組みが追加された。Tailscale側も、時刻の精度を秒単位の整数まで落として、変換のあいまいさが入らないようにしている。

警報を待った2か月

修正を全体に入れ終えて、それでも同社は勝利宣言をしなかった。破損が起きないことは、直った証拠にならない。10月から12月の、あの6週間の凪を見ているからだ。

そこで、バグの条件が揃ったこと自体を検知する仕掛けを入れた。SQLiteのドライバにパッチを当て、書き込みトランザクションとWALのリセットが重なったら警告を出すようにする。もしこれが鳴ったのにデータベースが無事なら、修正が実際に踏みとどまらせたことになる。

入れて、待った。鳴らない。数週間が過ぎても鳴らない。警報のほうが壊れているのか、そもそも仮説が間違っていたのか——2か月後、ようやく鳴った。

アラートの名前は「SQLitePartyMode」。壊れかけたが、システムが止めた、という内容である。バグの発生条件が本番環境で実際に起きていること、そして修正が効いていることが、これで裏付けられた。同社によれば、その警報が鳴ってから記事公開までのさらに4か月、データベース関連の障害は1件も起きていない。

標準構成の利用者への影響

SQLite公式は、このバグについて「稀ではあるが深刻な結果をもたらす」として、修正済みのバージョンへの更新を勧めている。同時に、そこまで急ぐ話ではない、とも書いている。手元にある観測データから見積もると、実際の環境でこの問題が起きる頻度は、SSDが故障する確率や宇宙線がメモリを叩く確率と同じくらいか、それ以下。よほど変わったことをしていないかぎり、未修正のバージョンを使っていても一生出会わないだろう、という書き方だ。公式ドキュメントは、はっきり「緊急事態ではない」と断っている。

手元のスマートフォンにもパソコンにも、SQLiteはたぶん何十個と入っている。そのどれもが、この16年間、踏まずに通り過ぎてきた。

Tailscaleがこの一件から引き出した教訓は、退屈で信頼できる技術でも、標準から外れた使い方をすればリスクになる、というものだった。よく通られている構成は、それだけよくテストされている。念のために書いておくと、同社がやっていたことは何ひとつ裏技ではない。チェックポイントの手動制御は公開され、文書化され、サポートされている使い方だ。それでも、通る人の少ない道ではあった。

気になるのは、この16年のあいだに同じところを踏んだ人がほかにいたのかどうかである。データベースが壊れる理由はいくらでもあるし、原因不明のまま復元して終わりにした事例が、どこかに埋もれていてもおかしくない。SQLite側の見積もりも、手元に届いた範囲のデータに基づくものだ。今回それが表に出たのは、Tailscaleがこのバグを人より何倍も速いペースで踏み続け、しかも半年かけて追いかける気力を持っていたからだった。

出典

Tailscale公式ブログ(Alex Chan氏、2026年8月12日):How we tracked down a 16-year-old SQLite bug

SQLite公式ドキュメント(WAL-Resetバグの詳細と発生条件):Write-Ahead Logging – The WAL-Reset Bug

SQLite公式(バージョンごとの変更履歴。3.52.0の撤回もここに記載):Release History Of SQLite

報道(Brandon Vigliarolo氏、The Register、2026年8月12日):Deeply buried 16-year-old SQLite bug caused last year’s Tailscale outages

コメント

タイトルとURLをコピーしました