안정적인 인프라를 표방하던 Tailscale이 지난해 하반기부터 올해 초까지 눈에 띄는 가동률 저하를 겪었다. 상당수 장애의 배후에는 단 하나의 버그가 있었다. 데이터베이스 엔진으로 널리 쓰이는 SQLite 내부 깊은 곳에 자리 잡은 결함이었고, 원인을 규명하는 데만 수개월에 걸친 정밀 포렌식이 필요했다. Tailscale은 SQLite 코어 개발진과 함께 이 문제를 파고든 끝에 16년 이상 존재해 온 데이터 경쟁(data race) 버그를 찾아내 수정했다고 밝혔다.
단일 작성자 구조와 반복된 데이터 손상
Tailscale의 컨트롤 플레인은 외부에는 controlplane.tailscale.com이라는 하나의 엔드포인트로 보이지만, 내부적으로는 여러 조정 서버, 즉 '샤드'로 나뉜다. 각 테일넷은 한 번에 하나의 샤드에 존재하며 필요 시 다른 샤드로 매끄럽게 이전된다. 각 샤드는 자체 SQLite 데이터베이스를 갖고, 단일 Go 프로세스가 이 데이터베이스에 배타적으로 접근한다. 이는 SQLite가 권장하는 전형적인 단일 작성자(single-writer) 설계다. 회사는 2022년부터 SQLite를 주 데이터베이스로 써 왔고, 검증되고 지루할 만큼 안정적인 기술이라는 이유로 이를 택했다.
문제는 지난해 8월 시작됐다. S3에 저장한 백업을 읽던 데이터 파이프라인이 오류를 보고했고, PRAGMA integrity_check로 확인하니 실제로 데이터베이스가 손상돼 있었다. SQLite 손상은 정상 운영에서 마주칠 일이 거의 없는 이례적 사건이다. 그런데 이 손상은 한 번으로 끝나지 않고 반복됐다. 결국 근본 원인을 잡기 전까지 6개월에 걸쳐 총 19건의 손상 사고가 발생했다. 다행히 이 데이터베이스에는 사용자의 개인 암호화 키나 네트워크 트래픽이 아니라 테일넷과 기기에 관한 메타데이터만 담겨 있어, 초기 사고에서도 손실은 최근 추가된 일부 기기나 설정 변경이 반영되지 않는 수준에 그쳤다.
재현 불가능한 버그와의 싸움
영향은 작지 않았다. 손상이 발생할 때마다 해당 샤드의 컨트롤 플레인 프로세스를 멈추고 데이터베이스를 복구해야 했는데, 그 사이 해당 샤드의 테일넷은 컨트롤 플레인 전체가 사라졌다. 이미 연결된 기기 간 통신은 유지됐지만, 복구 중 새로 접속하려는 기기는 다른 기기 목록을 받지 못해 연결할 수 없었고 관리 콘솔과 API도 일시적으로 끊겼다. 초기에는 다운타임이 한 시간을 넘겼다. 또한 소수 테일넷만 영향을 받아도 상태 페이지에는 전역 사고로 게시됐기에, 실제로는 무관한 다수 사용자도 장애 알림을 접했다. 대다수 샤드와 테일넷은 한 번도 손상을 겪지 않았지만 반복되는 다운타임은 신뢰를 갉아먹었다.
버그는 초기 조사를 모두 튕겨냈다. 최근 변경 사항을 뒤졌지만 관련 있는 것이 없었고, 수년 전 작성돼 문제없이 돌던 저수준 코드를 다시 정밀 검토해도 원인을 찾지 못했다. 특정 샤드, 고객, 기능, 시간대, 부하 어디에도 공통점이 없어 인위적 재현이 불가능했다. 손상 간격도 몇 시간에서 몇 주까지 불규칙했고, 10월부터 12월 사이 6주간은 사고가 멈췄다가 크리스마스 무렵 다시 나타났다. 재현이 안 되니 라이브 환경에 수동적인 포렌식 텔레메트리를 심어 현행범을 잡는 수밖에 없었다.
트랜잭션 로그가 던진 결정적 단서
Tailscale은 SQLite 개발진과 유료 지원 계약을 맺고 close()의 POSIX 락 손상, SQLite 소유 메모리 오용, 스레드 안전성을 끈 채 여러 스레드에서 접근하는 문제 등 여러 가설을 세워 하나씩 배제해 나갔다. 동시에 복구 자동화에도 힘써 대응 시간을 한 시간 이내로 단축했다. 이 과정에서 마지막 정상 백업으로 되돌리지 않고도 복구할 방법으로 트랜잭션 로그 파이프라인을 구축했다. SQLite는 단일 작성자에 직렬화 가능 트랜잭션을 쓰므로 이력이 완전히 선형이고 결정론적이라는 점을 활용해, 데이터베이스를 수정하는 모든 SQL 문을 별도 로그로 흘려보내 정상 백업 위에 재생하는 방식이었다.
이 파이프라인이 뜻밖의 단서를 줬다. 두 건의 사고에서 로그가 깨끗이 재생되지 않았고, 한 트랜잭션이 기록하고 커밋한 데이터가 이후 트랜잭션에서 오류 없이 사라져 보이지 않는 현상이 확인됐다. 있을 수 없는 일이었다. 여기에 손상 시점의 메트릭에서 WAL 파일에 실제 존재하는 것보다 더 많은 페이지가 데이터베이스로 복사됐다고 보고되는 이상 징후까지 겹치면서, 의심은 체크포인트 과정으로 좁혀졌다.
16년 묵은 WAL-Reset 버그
SQLite는 성능과 동시성을 위해 새 페이지를 본 데이터베이스 파일에 바로 쓰지 않고 WAL(Write-Ahead Log) 파일에 먼저 기록한 뒤, 체크포인트 과정에서 본 파일로 옮긴다. Tailscale은 빠르고 일관된 백업을 위해 이 체크포인트를 수동으로 제어하는 비표준 방식을 썼는데, 이 점이 원인 후보를 좁히는 과정에서 의심을 샀다. SQLite 개발진은 가상 파일시스템 계층을 감싸 추가 추적 정보를 남기는 tmstmpvfs라는 진단 래퍼를 만들어 공개 저장소에 공개했고, Tailscale은 이를 라이브 환경에 배포한 뒤 다음 손상을 기다렸다.
오래 기다리지 않아 잡힌 로그가 결정적이었다. 개발진이 찾아낸 것은 체크포인트와 쓰기 트랜잭션 사이의 드문 데이터 경쟁이었다. 체크포인트 중 특정 시점에 쓰기가 일어나면 체크포인트 과정이 혼란에 빠져, WAL의 일부 페이지가 본 파일로 복사됐다고 착각하지만 실제로는 쓰이지 않는다. 그 페이지를 참조하는 인덱스 같은 다른 페이지가 파일에 기록되면서 데이터베이스가 손상되는 것이다. 개발진이 'WAL-Reset 버그'로 명명한 이 결함은 최소 16년간 SQLite에 존재했고, 너무 드물어 테스트 환경에서 일부러 유발하는 코드를 추가해야 할 정도였다. 수정은 다른 스레드가 WAL을 리셋했는지 체크포인트 함수에서 감지하는 검사를 더하는 방식으로 이뤄졌으며, 이 버그가 손상과 재생 불가 로그, 일관성 없는 체크포인트 통계를 모두 설명한다는 점이 확인됐다.
이 사례는 '지루하지만 안정적인' 기술을 대규모로 운영할 때의 교훈을 보여준다. 아무리 검증된 엔진이라도 규모가 커지면 극히 드문 결함이 유의미한 빈도로 드러날 수 있고, 표준에서 벗어난 사용 방식은 그 확률을 높인다. 재현 불가능한 버그 앞에서 Tailscale은 코드 리뷰와 가설 배제에 더해 결정론적 트랜잭션 로그와 코어 개발진의 전용 진단 도구라는 관측 수단을 확보해 원인을 좁혔다. 실무자 입장에서 곱씹을 대목은 벤더 지원 계층으로의 직접 접근, 그리고 손상 순간을 라이브에서 포착할 수 있는 텔레메트리와 재생 가능한 로그 파이프라인의 가치다.