この記事に出てくる HITSC 関連の用語、構成名、設計上の呼び方は、執筆時点の検証用基盤に関するものです。実装や設計の進行に伴い、現在は変更されている場合があります。
HITSC / hitto Troubleshooting Contest のMVPでは、状態管理にSQLiteを使っている。
/var/lib/hitsc-firecracker/metadata/hitsc.sqlite3
SQLiteには以下を保存している。
problems
tickets
instances
score_attempts
ticket_events
published_ports
snapshots
HITSCは当初、個人用の常設練習基盤として作っていた。 そのため、単一workerで完結し、外部DBサーバを立てずに使えるSQLiteはかなり相性がよい。
ただし、負荷テストを始めるとSQLiteの同時書き込み問題が見えた。
この記事では、HITSCで実際に起きた SQLITE_BUSY、原因、修正、まだ残している判断をまとめる。
観測した現象
負荷テスト中に、1つのticketだけ ERROR になった。
tkt-20260530-144455-c59826 ERROR smoke-001 hitto 2026-05-30T23:44:55+09:00
同じタイミングで他のticketは普通に起動していた。
monitorでは、Firecracker processやnetwork namespaceは増えており、worker自体が落ちているわけではなかった。
firecracker_processes: 9
network_namespaces: 9
root_hitsc_links: 0
namespace_hitsc_links: 9
この時点で、KVM、Firecracker、guest kernel、TAP、network namespaceが全部壊れているような症状ではなかった。 特定ticketの状態更新だけが失敗しているように見えた。
ticket_events を見ると、原因はこれだった。
ERROR database is locked (5) (SQLITE_BUSY)
つまり、Firecrackerの起動失敗ではなく、SQLiteへの並列書き込み競合だった。
負荷テストの形
負荷テストでは、複数の hitsc CLIプロセスを同時に動かす。
たとえば、tools/load-test-tickets.sh はticketをまとめて発行し、xargs -P で並列に ticket start を実行する。
sudo env DESTROY=1 tools/load-test-tickets.sh smoke-001 10 4
script内では、ざっくりこういうことをしている。
xargs -r -P "${PARALLEL}" -I{} bash -c '
ticket="$1"
command="$2"
hitsc ticket "${command}" "${ticket}"
' _ "{}" "${command}" <"${TICKETS_FILE}"
つまり、1つのGo process内でgoroutineを並べているだけではない。
別々の hitsc プロセスが、同じSQLite DBを開いて書き込む。
この構成では、SQLiteのwrite lock競合が起きうる。
ticket startは何度もDBを書く
ticket start はFirecrackerを起動するだけではない。
HITSC側では、起動中にいくつもの状態を保存する。
problem情報のupsert
ticket status更新
instance state upsert
ticket event insert
published port予約
published port状態更新
起動失敗時のERROR記録
たとえば internal/store/sqlite.go には、ticketやinstanceを書き込む処理がある。
func (s *Store) InsertTicket(ticket *model.Ticket) error {
_, err := s.db.Exec(`
INSERT INTO tickets (
id, problem_id, problem_version, assigned_user_id, status, purpose,
created_at, assigned_at, started_at, solved_at, closed_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, ticket.ID, ticket.ProblemID, nullableText(ticket.ProblemVersion), nullableText(ticket.AssignedUserID), string(ticket.Status), nullableText(ticket.Purpose),
ticket.CreatedAt, ticket.AssignedAt, ticket.StartedAt, ticket.SolvedAt, ticket.ClosedAt, ticket.ExpiresAt)
return err
}
instance側も同様にupsertする。
func (s *Store) UpsertInstance(i *model.InstanceState) error {
_, err := s.db.Exec(`
INSERT INTO instances (
id, ticket_id, problem_id, status, worker, generation,
rootfs_path, kernel_path, socket_path, config_path, stdout_log, stderr_log, metrics_fifo,
namespace_name, bridge_name, tap_name, guest_mac, guest_ip, host_ip, prefix, ssh_user, ssh_port, ssh_password, pid,
created_at, started_at, stopped_at, destroyed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ticket_id=excluded.ticket_id,
problem_id=excluded.problem_id,
status=excluded.status,
...
`, ...)
return err
}
また、操作履歴として ticket_events も保存する。
func (s *Store) InsertTicketEvent(event model.TicketEvent) error {
_, err := s.db.Exec(`
INSERT INTO ticket_events (id, ticket_id, event_type, message, created_at)
VALUES (?, ?, ?, ?, ?)`, event.ID, event.TicketID, event.EventType, event.Message, event.CreatedAt)
return err
}
1つ1つの書き込みは短い。 しかし、負荷テストで複数ticketを同時に起動すると、これらが別プロセスから同時に走る。
SQLiteが悪いのか
SQLiteが悪いというより、MVP初期の接続設定が並列CLI実行に足りていなかった。
SQLiteは「ファイル1つで完結するDB」としてHITSCに合っている。
単一workerで完結する
外部DB serviceが不要
バックアップしやすい
問題環境のmetadataを1ファイルで扱える
個人用・小規模常設基盤に向いている
一方で、SQLiteは同時書き込みを大量に捌くためのDBではない。 writerは基本的に直列化される。
HITSCの負荷テストは、複数CLI processが同じDBへ同時に書くため、write lockが取れない瞬間がある。
ここで待機設定がなければ、SQLiteは SQLITE_BUSY を返す。
修正前の接続
修正前は、SQLiteを素直に開いていた。
db, err := sql.Open("sqlite", dbPath)
この状態だと、lock競合時に十分待たずに失敗することがある。
並列 ticket start で一部だけ ERROR になるのは、このためだった。
修正後の接続
現在の internal/store/sqlite.go では、DSNにSQLiteのpragmaを入れている。
func sqliteDSN(dbPath string) string {
q := url.Values{}
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "synchronous(NORMAL)")
q.Add("_pragma", "foreign_keys(ON)")
return dbPath + "?" + q.Encode()
}
Open ではこのDSNを使う。
db, err := sql.Open("sqlite", sqliteDSN(dbPath))
入れた設定は以下。
busy_timeout(5000)
journal_mode(WAL)
synchronous(NORMAL)
foreign_keys(ON)
busy_timeout
busy_timeout(5000) は、lockが取れない時に即失敗せず、最大5秒待つための設定である。
HITSCの書き込みは短いものが多い。 そのため、多くの場合は「少し待てば前のwriterが終わる」。
この場合、すぐ SQLITE_BUSY で落とすより、短時間待つ方が運用に合っている。
今回の問題では、これが一番直接的な対策だった。
WAL
journal_mode(WAL) は、write-ahead log modeを使う設定である。
WALにすると、readerとwriterの競合を減らせる。
HITSCではWeb UIやmonitor、ticket list がDBを読む一方で、ticket start や ticket score がDBへ書く。
そのため、reader/writerが混ざる運用ではWALが合っている。
ただし、WALにしてもwriterが無制限に並列化されるわけではない。
SQLITE_BUSY が絶対に起きなくなるわけでもない。
HITSCでは、WALは「reader/writer競合を減らす」ため、busy_timeout は「短いwrite lock競合を待つ」ため、という役割分担で入れている。
synchronous=NORMAL
synchronous(NORMAL) は、WALと組み合わせた性能寄りの設定である。
HITSCのmetadataは重要だが、MVPでは以下のような補助情報もある。
instances/<ticket-id>/state.json
tickets/<ticket-id>/ticket.json
Firecracker log
problem.yaml
そのため、過剰にfsyncを重くして並列起動を遅くするより、WAL + NORMALで現実的な速度を取る判断にした。
もちろん、本番コンテスト基盤としてより厳密な永続性を求める段階では、ここは再評価する。
foreign_keys=ON
これは同時書き込み対策そのものではない。
ただし、SQLiteではforeign key制約は接続ごとに有効化する必要がある。 HITSCは複数CLI processからDBを開くため、各接続で有効にしておきたい。
そのため、DSN側にも入れている。
foreign_keys(ON)
Open 後にも明示的に PRAGMA foreign_keys = ON を実行している。
これは重要な接続設定なので、保守的に両方で有効化している。
testで守る
この設定は地味だが、外れると負荷テストでまた壊れる。
そのため、internal/store/sqlite_test.go にtestを追加している。
func TestOpenConfiguresSQLiteForConcurrentCLIUse(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
st, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer st.Close()
var journalMode string
if err := st.db.QueryRow(`PRAGMA journal_mode`).Scan(&journalMode); err != nil {
t.Fatal(err)
}
if strings.ToLower(journalMode) != "wal" {
t.Fatalf("journal_mode = %q, want wal", journalMode)
}
var busyTimeout int
if err := st.db.QueryRow(`PRAGMA busy_timeout`).Scan(&busyTimeout); err != nil {
t.Fatal(err)
}
if busyTimeout != 5000 {
t.Fatalf("busy_timeout = %d, want 5000", busyTimeout)
}
var synchronous int
if err := st.db.QueryRow(`PRAGMA synchronous`).Scan(&synchronous); err != nil {
t.Fatal(err)
}
if synchronous != 1 {
t.Fatalf("synchronous = %d, want 1 (NORMAL)", synchronous)
}
var foreignKeys int
if err := st.db.QueryRow(`PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 {
t.Fatalf("foreign_keys = %d, want 1", foreignKeys)
}
}
このtestは「SQLiteが開けるか」だけでなく、HITSCが並列CLI運用に必要な接続設定でDBを開いているかを確認する。
修正後の負荷テスト
修正後は、以下のような負荷テストで SQLITE_BUSY が再発しないことを確認した。
sudo env DESTROY=1 tools/load-test-tickets.sh smoke-001 5 2
sudo env DESTROY=1 tools/load-test-tickets.sh smoke-001 10 4
この時点で見たかったのは、VM起動速度そのものよりも、並列 ticket start で状態管理が破綻しないかだった。
観測対象は以下。
ticket statusにERRORが増えないか
instanceがRUNNINGへ収束するか
ticket_eventsにSQLITE_BUSYが出ないか
Firecracker process数とnamespace数が期待値になるか
destroy後にresourceが戻るか
そのため、tools/monitor-worker.sh も追加して、負荷テスト中のworker状態を見やすくした。
tickets_total: 15
tickets_by_status: ERROR=1 ACTIVE=14
tickets_by_problem: smoke-001=15
このように、ERRORが1つだけ混ざる症状が見えると、どのticketを掘ればよいか分かる。
out of memory (14) とは別問題
SQLite関連で紛らわしいエラーがもう1つある。
unable to open database file: out of memory (14)
これは今回の同時書き込み問題とは別である。
典型的には、非rootユーザーでroot-ownedな /var/lib/hitsc-firecracker/metadata/hitsc.sqlite3 を開こうとした時に出た。
つまり分類としてはこう。
database is locked (5) (SQLITE_BUSY):
同時書き込み・lock競合
unable to open database file: out of memory (14):
DB open失敗
多くの場合は権限や不要なstore open
後者については、DB不要のcommandでstoreを開かないようにする、必要なcommandはrootで実行する、という方向で整理した。
なぜPostgreSQLにしなかったか
この時点で、PostgreSQLへ移行する案も考えられる。
ただ、HITSCのMVPではSQLiteを継続した。
理由は単純で、まだ単一workerのCLI基盤だからである。
SQLite:
依存が少ない
worker単体で完結する
file backupで済む
個人常設練習基盤に向いている
PostgreSQL:
multi-worker
Web API server
同時利用者が多い公開基盤
複数operator
HITSCがmulti-workerや本格Web API serverに進むなら、PostgreSQLはかなり有力になる。 しかし、今のMVPで外部DBを入れると、Firecrackerやrootfs作問よりもDB運用の重さが先に来る。
そのため、まずはSQLiteを正しく使う。
なぜdb.SetMaxOpenConns(1)を入れていないか
Goの database/sql では、db.SetMaxOpenConns(1) によって1プロセス内のDB接続数を絞れる。
これは安全側の選択肢である。
ただし、今回の負荷テストでは別々の hitsc CLIプロセスが同じDBへ書いている。
そのため、1プロセス内の接続数を絞っても、プロセス間のwrite競合そのものは消えない。
さらに、将来Web UI serverのように1プロセスで読み書きする場合、接続数を強く絞ると表示や操作の待ちが増える可能性もある。
だからMVPの最初の修正では、以下に留めた。
busy_timeout
WAL
synchronous=NORMAL
foreign_keys=ON
これで改善しない場合の第2段階として、SetMaxOpenConns(1) やtransaction整理を検討する。
今後の方針
SQLiteで問題が残る場合は、次の順で進める。
1. SQLite接続設定を確認する
2. start / score / destroy のDB更新を短いtransactionに整理する
3. DB write箇所を減らす
4. db.SetMaxOpenConns(1) を検討する
5. worker daemonに操作を集約し、CLIから直接DBを書かない
6. multi-worker化の段階でPostgreSQLを検討する
HITSCの現在地では、SQLiteはまだ適切である。
ただし、「SQLiteだから何もしなくても並列起動に耐える」というわけではない。
HITSCのように複数CLI processが同じDBへ書く場合、busy_timeout とWALはMVPでもかなり重要な設定だった。
現在仕様
現在の仕様メモは次の内部ドキュメントにまとめている。
- SQLite concurrency notes
- Storage and state
- Load testing
関連する実装:
internal/store/sqlite.gointernal/store/sqlite_test.gotools/load-test-tickets.shtools/monitor-worker.sh
参考
- SQLite Write-Ahead Logging: https://www.sqlite.org/wal.html
- SQLite busy timeout: https://sqlite.org/c3ref/busy_timeout.html
- SQLite PRAGMA statements: https://www.sqlite.org/pragma.html