
SQLiteの16年越しのバグに学ぶ、並行処理の難しさとAI時代の形式手法
みなさんこんにちは。
「あの人も読んでる」、第22回目の投稿です。maguro(X @yusuktan)がお届けします。
今回は、TailscaleがSQLiteに16年間潜んでいたバグを突き止めた記録、「How we tracked down a 16-year-old SQLite bug」を紹介します。
この記事を起点に、AIが書くコードが増えるなかで、形式仕様によるモデル検査やDSTなど、機械的に不具合を探す手法の重要性についても考えます。
僕は以前の「も読」で、分散システムのバグを再現可能な形で探す決定論的シミュレーションテスト(Deterministic Simulation Testing、DST)について書きました。
今回は、実装を動かして状態空間を探索するDSTとは少し違う方法で、膨大な組み合わせから不変条件を破る短い処理順序を取り出す過程についても触れていきます。
AI時代、ソフトウェアが期待通りに正しく動くことを機械的に検証できる方法の価値が上がっていると思います。
分散システムそのものの開発に携わる機会のある方はそこまで多くないかもしれませんが、「そんな手法もあるんだー」というくらいの気持ちで気軽に読んでいただければと思います。
「枯れた技術」で起きた19回のデータベース破損
Tailscaleは2022年から、端末やネットワークの設定を管理・配布するコントロールプレーンの主要なデータベースとしてSQLiteを使っています。
Tailscaleのコントロールプレーンは、ユーザーごとのプライベートネットワークであるtailnetを単位として、複数のシャードに分かれています。1つのシャードが複数のtailnetを担当し、その情報を1つのSQLiteデータベースへ保存します。各シャードでは、1つのGoプロセスだけがそのSQLiteデータベースへアクセスするsingle-writer構成になっています。
Tailscale自身も記事の中で、SQLiteを良い意味での「boring technology」と表現しています。
広く使われ、挙動がよく知られた技術を選んだので、データベースそのものに長期間悩まされることはないはずでした。
Tailscaleは数分おきにSQLiteデータベース全体のスナップショットを作り、そのファイルをS3へ保存していました。あるとき、そのバックアップを読み取るデータパイプラインが、とあるエラーを報告します。
データやインデックスなど、SQLiteファイル内部の整合性を検査するPRAGMA integrity_checkを実行すると、実際にデータベースが壊れていました。
その後も発生は止まらず、Tailscaleは約6カ月のあいだに19回のデータベース破損に見舞われました。
Tailscaleのデータベースには、ユーザーの秘密鍵やネットワークトラフィックは保存されていません。それでも、復旧中は対象シャードのコントロールプレーンを停止する必要があり、初期のインシデントでは停止が1時間を超えました。すでに接続している端末同士の通信は続きますが、新しく起動した端末は接続先の情報を取得できず、管理画面やAPIも利用できなくなります。
発生条件には、なかなか共通点が見つかりませんでした。特定のシャード、ユーザー、機能、時刻、負荷の高さとは結びつかず、発生間隔も数時間から数週間までばらばらでした。人工的に再現できないため、Tailscaleは次の発生を待ちながら、本番環境へ少しずつ観測点を追加していきます。
ここが一番厄介なところです。
データベース破損は確かに発生しているのに、そこに至るための手順が分からない。観測点を追加しながら、本番で次に起きる1回を待つしかありませんでした。めったに起きない並行処理のバグを、本番環境で追いかける難しさがよく表れています。
WALのリセットとチェックポイント処理の競合
Tailscaleは調査を進めるため、SQLiteの開発者と有償のサポート契約を結びました。
調査の中で、SQLiteの開発者は、ファイル操作を担当するVirtual File System(VFS)層へ詳細な追跡情報を追加するtmstmpvfsという診断用shimを作りました。このshimを本番環境へ導入し、次の破損時に得られたログをSQLiteの開発者が解析したことで、原因がようやく特定されました。
その原因を理解するには、SQLiteのWrite-Ahead Log(WAL)について少しだけ知る必要があります。
WALモードでは、更新されたページをすぐにデータベース本体へ書かず、まずWALファイルへ追記します。WALへ追加されたページは、あとからデータベース本体へコピーされます。この処理をチェックポイント処理と呼びます。すべてのページをコピーし終えると、WALをリセットし、先頭から再利用していきます。
今回のバグは、WALのリセットとチェックポイント処理が特殊なタイミングで重なったことで発生しました。
チェックポイント処理が、まだデータベース本体へコピーしていないページをコピー済みだと誤認し、本当はまだコピーされていないページをコピー対象から外してしまったのです。その結果、データとそれを参照するインデックスなどの整合性が崩れ、データベースが破損しました。