가상 사설망(VPN) 서비스 기업인 테일스케일(Tailscale)은 작년 말부터 지속된 서비스 불안정성의 원인을 조사한 결과, SQLite 데이터베이스 엔진 내부에 있는 16년 된 WAL(Write-Ahead Logging) 재설정 버그를 발견했습니다. 이 버그로 인해 6개월 동안 총 19회의 데이터베이스 손상이 발생하여 제어 평면(Control plane)이 일시적으로 중단되었으나, 다행히 사용자의 개인 암호키나 실제 네트워크 트래픽 데이터는 유출되지 않았습니다. 업체는 장기간에 걸카 포렌식 분석 끝에 근본적인 원인을 파악하고 이를 완전히 해결하여 서비스 안정성을 복원했다고 밝혔습니다.
번역된 본문
블로그 | 인사이트 2026년 8월 12일
우리가 16년 된 SQLite 버그를 추적해 낸 방법
작년 말, 우리 서비스의 가동률(uptime)은 꽤 불안정했습니다. 이러한 추세는 당사의 상태 페이지(status page)에서 확인할 수 있었으며, 이러한 불안정성은 새해에도 계속되었습니다. 이러한 서비스 중단의 상당수는 SQLite 깊은 곳에 숨어있던 단일 버그로 인해 발생했습니다. 이를 추적하고 찾아내는 데는 수개월에 걸친 강도 높은 포렌식 분석이 필요했습니다. 이제 여름이 되었고, 우리는 버그를 찾았다는 것과 이를 완전히 이해했으며, 무엇보다도 이를 수정했다는 확신을 가지고 있습니다. 고객 여러분께서는 Tailscale이 신뢰할 수 있는 서비스이기를 기대하셨겠지만, 몇 달 동안 우리는 그 기대에 부응하지 못했습니다. 서비스에 혼란을 드려 대단히 죄송합니다. 우리는 무엇이 잘못되었는지, 어떻게 대응했는지, 그리고 궁극적으로 SQLite 데이터베이스의 핵심에 있는 오래된 버그를 어떻게 밝혀냈는지 설명하기 위해 이 블로그 포스트를 게재합니다.
Tailscale의 데이터베이스 아키텍처
클라이언트가 단일 공개 엔드포인트(controlplane.tailscale.com)로 당사의 제어 평면(control plane)과 상호작용하는 반면, 내부적으로 제어 평면은 일련의 조정 서버(또는 "샤드")로 나뉩니다. 각 tailnet은 한 번에 하나의 내부 샤드에 존재하지만, 다른 샤드로 원활하게 마이그레이션될 수 있습니다. 이러한 샤드는 내부 구현 세부 사항이므로, 고객은 자신의 tailnet이 어느 샤드에 있는지 알 필요가 없으며 알 수도 없습니다. 각 샤드에는 해당 샤드의 tailnet에 대한 모든 정보를 보유하는 SQLite 데이터베이스가 있습니다. 단일 Go 프로세스가 이 데이터베이스에 배타적으로 액세스하며, 해당 tailnet에 대한 제어 평면을 서비스합니다. 이러한 단일 작성자(single-writer) 설계는 SQLite가 의도된 정확한 사용 방식입니다. 우리는 2022년부터 SQLite를 기본 데이터베이스로 사용해 왔으며, 잘 알려져 있고 신뢰할 수 있으며 널리 사용되기 때문에 이를 선택했습니다. SQLite는 좋은 의미에서 '지루한 기술(boring technology)'입니다. 많은 기업이 훨씬 더 큰 규모의 배포에서 아무런 문제 없이 SQLite를 사용하고 있으며, 우리 역시 스트레스 없이 사용할 수 있을 것이라 기대했습니다.
현재 백업 파이프라인에서는 몇 분마다 데이터베이스의 전체 스냅샷을 찍은 다음, 전체 SQLite 파일을 S3 버킷에 업로드합니다. 우리는 2023년 초부터 이 설정을 아무런 사고 없이 운영해 왔습니다. 작년 8월로 넘어가면, 해당 S3 백업을 읽는 데이터 파이프라인이 우리 데이터베이스 중 하나에서 오류를 보고했습니다. 백업에 대해 SQLite의 PRAGMA integrity_check 명령을 실행해 본 결과, 실제로 손상된 것을 확인했습니다. SQLite 손상은 발생할 수 있지만 극히 드문 일이며 정상적인 작동 중에는 절대 발생해서는 안 되는 일입니다. 우리는 영향을 받은 데이터베이스를 복구하고 원인을 조사했지만 헛수고였습니다. 대규모로 운영할 때는 희귀한 이벤트도 어느 정도 빈번하게 발생할 수 있으므로, 이런 일이 반복해서 발생했을 때 놀라지 않았어야 했습니다. 실제로 근본적인 버그를 해결하기 전까지 6개월 동안 총 19번의 개별적인 데이터베이스 손상 사례를 겪었습니다.
"데이터베이스 손상"이라는 말을 들으면 데이터 유실을 걱정하는 것이 당연합니다. 다행히 당사의 제어 평면은 구성 데이터만 처리하므로, 이러한 데이터베이스에는 tailnet 및 기기에 대한 메타데이터만 포함될 뿐, 고객의 개인 암호화 키나 네트워크 트래픽은 절대 포함되지 않습니다. 초기 사고 당시 복구 과정에서 소수의 새로 추가된 기기나 구성 변경 사항이 저장되지 않았으며, 소량의 메타데이터를 다시 입력해야 했습니다. 손상이 발생할 때마다 데이터베이스를 복구하거나 복원하는 동안 해당 샤드에서 제어 평면 프로세스를 중지해야 했습니다. 이는 복구 기간 동안 전체 제어 평면이 사라졌기 때문에 해당 샤드의 tailnet에게 매우 고통스러운 일이었습니다. 초기 사고에서는 해당 다운타임이 1시간 이상이었지만, 이후 사고에서는 복구 프로세스를 점차 개선하여 속도를 높였습니다. 각 tailnet은 기기들이 서로 피어 투 피어(P2P) WireGuard® 연결을 맺는 메시 네트워크입니다. 기기가 tailnet에 참여할 때 새로운 연결을 설정하려면 제어 평면에서 다른 기기 목록을 가져와야 하므로, SQLite 가동 중단 시간 동안 기기가 온라인 상태가 되면 연결할 수 없었습니다. 데이터베이스가 복구되는 동안 이미 온라인 상태였던 기기들은 서로 연결을 유지했지만, (후략)
Blog | insights August 12, 2026 How we tracked down a 16-year-old SQLite bug At the end of last year, our uptime was pretty shaky . You can see this trend on our status page , and that instability continued into the new year. Many of these outages were caused by a single bug, deep in SQLite . It took months of intense forensics to track it down. Now we’re in summer, we’re confident that we’ve found the bug, that we understand it—and more importantly, that we’ve fixed it. We know our customers expect Tailscale to be a reliable service, and for several months we didn’t live up to that promise. That’s disruptive, and we’re sorry. We’re publishing this blog post to explain what went wrong, how we responded, and how we ultimately helped to uncover a long-standing bug in the heart of the SQLite database. Tailscale’s database architecture While our clients interact with our control plane as a single public endpoint ( controlplane.tailscale.com ), internally, our control plane is split into a series of coordination servers (or “shards”). Each tailnet lives on one internal shard at a time, but can migrate seamlessly from one to another. These shards are an internal implementation detail: you don’t know what shard your tailnet is on, and you never need to. Each shard has an SQLite database that holds all the information about the tailnets on that shard. A single Go process exclusively accesses that database, and serves the control plane for those tailnets. This single-writer design is exactly how SQLite is meant to be used. We’ve used SQLite as our primary database since 2022 , and we chose it because it's well-known, reliable, and widely used. SQLite is “boring technology” —in a good way. Many companies use SQLite in much larger deployments without issue, and we expected the same stress-free usage. In our current backup pipeline, we take a complete snapshot of the database every few minutes, then upload the entire SQLite file to an S3 bucket. We’d been running this setup without incident since early 2023. Fast forward to August last year, when a data pipeline that reads those S3 backups reported an error in one of our databases. We ran SQLite’s PRAGMA integrity_check command against the backup, and found it was indeed corrupted. SQLite corruption is possible , but it’s highly unusual and not something you should encounter in normal operation. We repaired the affected database, and investigated the cause, but to no avail. When operating at scale, even rare events can occur with some frequency, so we should have been unsurprised when it happened again—and again, and again, and again. In total, we faced 19 separate instances of database corruption over six months before we finally resolved the underlying bug. When you hear the phrase “database corruption”, it’s natural to worry about data loss. Because our control plane only handles configuration data, these databases contain metadata about your tailnet and devices, but never your private encryption keys or network traffic. In the earliest incidents, the recovery process meant a handful of newly added devices or configuration changes didn’t persist, and a small amount of metadata had to be re-entered. Whenever corruption occurred, we had to stop the control plane process on the shard while we repaired or restored the database. This was painful for tailnets on that shard, because their entire control plane disappeared during that recovery window. In the early incidents, that downtime was over an hour, but we gradually sped up the recovery process over subsequent incidents. Each tailnet is a mesh network, where devices make peer-to-peer WireGuard® connections to each other. When a device joins the tailnet, it has to get a list of other devices from the control plane before it can establish new connections—so if a device came online during the SQLite downtime, it couldn’t connect. While the database was being repaired, devices already online remained connected to each other, but they couldn’t learn about changes to the network. Those tailnets also temporarily lost access to the web-based admin console and the Tailscale API. There’s also a broader impact on trust. We post a global incident on our status page even when only a small number of tailnets are affected. Many people saw a status page event for an incident that didn’t affect them. Indeed, the majority of shards and tailnets were never involved in a database corruption incident! Nonetheless, repeated downtime erodes trust, whether or not you’re directly affected. From the very first instance of corruption, we knew this was a serious threat to our reliability, and we threw a lot of engineering time at the problem—but the fix wasn’t easy. Trying to find the fault This bug resisted all our initial attempts to find it. We looked at recent changes, but there weren’t any that seemed relevant. Nobody had been working on our low-level code that interacts with SQLite, because it had all been written years ago and presented no issues up until that point. We re-reviewed all of that code with a fine-toothed comb to look for previously missed bugs, but we didn’t find anything that would cause the corruption we were seeing. We looked for common factors between corruption incidents, but we couldn’t find any. It wasn’t tied to a single shard, or customer, or tailnet feature, or time of day, or load level. We were at a loss for what might be triggering the behaviour. This lack of reliable trigger conditions meant we couldn’t reproduce the bug synthetically. Instead, we had to rely on deploying passive, forensic telemetry in our live environment to catch the corruption red-handed. Gathering live diagnostics for a database issue is the last thing we wanted to do, but we had no choice. As an additional complication, the corruption didn’t occur on a regular schedule. Sometimes incidents would be hours apart, other times weeks. This made it difficult to predict progress or plan further work, because we were never sure when we’d get our next diagnostic dump. We had a six-week period between October and December when there were no corruption incidents, before they returned as an unwelcome Christmas present. Because this wouldn’t be a quick or easy fix, we reached out to the SQLite developers for a professional support contract . This was a great decision. It gave us direct access to their deep expertise and experience, and we had many detailed technical conversations about our architecture and our incidents. Between Tailscale engineering and the SQLite core developers, we mapped out several theories for what might be causing the corruption—including broken POSIX locks on close() , mismanaging memory owned by SQLite, or accidentally using SQLite from multiple threads while disabling thread safety . After every incident, we gathered more data, added more diagnostics, and systematically ruled out these theories. We were gradually converging on the true bug. The transactions that didn’t bark While we were investigating the root cause, we still had a live platform to run. We took aggressive steps to automate recovery and minimize downtime: Configuring our control plane shards to hard-stop immediately upon encountering corruption Deploying an automated backup monitor that continuously ran PRAGMA integrity_check over our backups Improving our runbooks and on-call training These efforts cut our response time to under an hour—and then we discovered an unexpected clue. We wanted a way to restore service that didn’t involve rolling back to the last known-good backup (which would lose a lot of data) or repairing the known-corrupted database (which was potentially risky). To do this, we built a transaction logging pipeline. We streamed every SQL statement that modified the database to a separate log file. Because SQLite is a single-writer database with serialisable transactions , our transaction history was completely linear and deterministic. (This wouldn’t be true in a multi-writer data