Litestream VFS에 선택적 쓰기 지원과 백그라운드 하이드레이션을 추가해, 오브젝트 스토리지에서 바로 SQLite를 읽고(그리고 제한적으로 쓰고) 더 빠르게 기동하는 방법을 소개합니다.
저자: Ben Johnson (@benbjohnson)
이미지: Annie Ruygt
저는 Ben Johnson이고, Fly.io에서 Litestream을 만들고 있습니다. Litestream은 SQLite에 빠져 있던 백업/복구 시스템입니다. 무료 오픈소스 소프트웨어로 어디서나 실행되어야 하고, 여기에서 더 자세히 읽을 수 있습니다.
Litestream에 대해 글을 쓸 때마다 “Litestream이 무엇인지” 설명을 조금씩 더 잘 다듬게 됩니다. 이렇게 말해보죠. Litestream은 SQLite 데이터베이스를 S3 스타일의 오브젝트 스토리지와 동기화해 두는 Unix-y 도구입니다. SQLite의 속도와 단순함이라는 장점을 얻으면서도, 치명적인 데이터 손실 위험에 자신을 노출시키지 않는 방법입니다. 앱이 Litestream의 존재를 알 필요도 꼭 없습니다. 그냥 백그라운드 도구로 실행해 두면 됩니다.
최근 몇 주는 정말 바빴습니다!
우리는 최근 Sprites를 공개했습니다. Sprites가 무엇인지 모른다면 한번 확인해 보세요. 우리가 지금까지 출시한 것 중에서도 가장 멋진 것 중 하나입니다. 더 이상의 영업은 하지 않겠습니다. 어쨌든 Sprites는 대단한 물건이고, Litestream이 그 안에서 하중을 받는(load-bearing) 구성 요소라는 점이 저에겐 큰 의미가 있습니다.
Sprites는 두 가지 큰 방식으로 Litestream에 직접 의존합니다.
첫째, Litestream SQLite는 전 세계에 걸친 Sprites 오케스트레이터의 핵심입니다. 중앙집중형 Postgres 클러스터에 의존하는 우리의 대표 제품 Fly Machines와 달리, Elixir로 만든 Sprites 오케스트레이터는 S3 호환 오브젝트 스토리지를 바로 사용합니다. Sprites에 등록된 각 조직은 자신의 SQLite 데이터베이스를 하나씩 받고, Litestream이 이를 동기화합니다.
이 설계는 재밌습니다. 과소평가된 “여러 개의 SQLite 데이터베이스” 패턴을 활용합니다. 확장 특성도 좋습니다. Fly.io가 성장하면서 그 Postgres 클러스터를 건강하게 유지하는 일은 큰 엔지니어링 과제였습니다.
하지만 Litestream의 관점에서 오케스트레이터는 심심합니다. 그래서 이 정도만 말하겠습니다. Sprites가 Litestream을 사용하는 두 번째 방식이 훨씬 더 흥미롭습니다.
Litestream은 각 Sprite에서 동작하는 디스크 스토리지 스택에 직접 내장되어 있습니다.
Sprites는 1초도 안 되어 실행되고, 각각 100GB의 내구성 있는(durable) 스토리지를 가진 채로 부팅합니다. 이는 까다로운 엔지니어링입니다. 우리는 Sprites의 스토리지 루트가 S3 호환 오브젝트 스토리지라는 점을 이용해 이를 가능하게 했고, 연결된 NVMe를 읽기 통과 캐시(read-through cache)로 활용하는 “사용 중인 스토리지 블록 데이터베이스”를 유지함으로써 속도를 확보합니다. 이를 수행하는 시스템은 JuiceFS이고, 그 데이터베이스—“블록 맵(block map)”이라고 부르죠—는 (맞혔겠지만) BoltDB를 기반으로 다시 작성한 메타데이터 저장소입니다.
농담입니다! 물론 Litestream SQLite입니다.
Sprite 안의 모든 것은 빠르게 올라오도록 설계되어 있습니다.
Sprite 아래의 Fly Machine이 튕기면(bounce), 오브젝트 스토리지로부터 블록 맵을 다시 구성해야 할 수도 있습니다. 블록 맵은 엄청 크진 않지만 아주 작지도 않습니다. 최악의 경우 수십 MB 초반 정도일 수 있습니다.
문제는 이 일이 Sprite가 다시 부팅하는 동안 벌어진다는 점입니다. 맥락을 더하면, 이는 들어오는 웹 요청에 대한 응답으로 발생할 수도 있습니다. 즉, 그 요청에 제때 응답을 돌려주기 위해 충분히 빨리 끝내야 합니다. 시간 예산이 작습니다.
이를 더 빠르게 하기 위해 우리는 기동 시간을 개선하려고 Litestream VFS를 통합하고 있습니다. VFS는 앱에 로드하는 동적 라이브러리입니다. 로드한 뒤에는 다음 같은 일을 할 수 있습니다:
sqlite> .open file:///my.db?vfs=litestream
sqlite> PRAGMA litestream_time = '5 minutes ago';
sqlite> SELECT * FROM sandwich_ratings ORDER BY RANDOM() LIMIT 3 ;
22|Veggie Delight|New York|4
30|Meatball|Los Angeles|5
168|Chicken Shawarma Wrap|Detroit|5
Litestream VFS를 사용하면 오브젝트 스토리지 블롭에서 바로(point-in-time) SQLite 쿼리를 수행할 수 있어, 데이터베이스를 다운로드하기 전에 쿼리에 답할 수 있습니다.
좋긴 하지만 완벽하진 않았습니다. 두 가지 문제가 있었습니다:
재밌는 문제들이죠. 이를 해결하기 위한 첫 번째 시도를 소개합니다.
우리가 한 첫 번째 작업은 VFS를 선택적으로 읽기-쓰기로 만들었다는 것입니다. 이 기능은 꽤 미묘합니다. 흥미롭지만, 겉보기만큼 범용적이진 않습니다. 어떻게 동작하는지 설명하고, 왜 이렇게 동작하는지도 설명하겠습니다.
이 글은 특히 VFS에 대한 이야기라는 점을 염두에 두세요. 당연히, 일반적인 방식으로 Litestream을 사용하는 “평범한” SQLite 데이터베이스는 쓰기 가능합니다.
VFS는 오브젝트 스토리지에 있는 데이터베이스의 각 페이지에 대해 (file, offset, size) 인덱스를 유지하는 방식으로 동작합니다. 이 인덱스를 구성하는 데이터는 LTX 파일에 저장되어, VFS가 시작할 때 빠르게 재구성할 수 있고 조회도 강하게 캐시됩니다. 앞에서 sandwich_ratings를 쿼리했을 때, VFS 라이브러리는 SQLite의 read 메서드를 가로채 요청된 페이지를 인덱스에서 찾고(fetch), 가져와 캐시했습니다.
읽기에는 아주 좋습니다. 쓰기는 더 어렵습니다.
읽기 전용 모드에서는 내부적으로 Litestream이 폴링(polling)합니다. 그래서 원격 작성자가 데이터베이스에 대해 새 LTX 파일을 만들어냈는지 감지할 수 있습니다. 이는 프로덕션에서 빠르게 동작해야 하는 데이터베이스에 대해 테스트를 돌리거나 느린 분석 쿼리를 수행하면서도 최신 상태를 따라갈 수 있게 해주는 유용한 사용 사례를 지원합니다.
쓰기 모드에서는 다중 작성자를 허용하지 않습니다. 다중 작성자 분산 SQLite 데이터베이스는 Lament Configuration이고, 우리는 고통의 광활한 풍경을 탐험하는 사람들이 아니기 때문입니다. 그래서 VFS의 쓰기 모드는 폴링을 비활성화합니다. 단일 작성자만 있고, 추가로 감시할 백업이 없다고 가정합니다.
다음은 버퍼링입니다. 쓰기는 로컬 임시 버퍼(“쓰기 버퍼”)로 들어갑니다. 대략 1초마다(또는 정상 종료 시) 쓰기 버퍼를 오브젝트 스토리지와 동기화(sync)합니다. 이 동기화가 일어나기 전까지는 VFS를 통해 쓰인 어떤 것도 진짜로 내구적이라고 할 수 없습니다.
대부분의 스토리지 블록 맵은 이것보다 훨씬 작지만, 그래도요.
이제 우리가 지원하려는 사용 사례를 다시 떠올려 봅시다. Sprite가 콜드 스타트 중이고, 스토리지 스택은 10MB짜리 블록 맵 전체를 가지고 있지 않은 상태에서도 부팅 직후 몇 밀리초 만에 쓰기를 제공해야 합니다. 이 쓰기 가능한 VFS 모드는 그걸 가능하게 해 줍니다.
핵심은, 이 사용 사례를 Sprite가 원래부터 가지고 있는 내구성 요구사항과 같은 수준까지만 지원한다는 점입니다. Sprite의 모든 스토리지는 이런 “결국 내구적(eventual durability)”인 성질을 공유하므로, VFS 쓰기의 조건이 여기서는 말이 됩니다. 하지만 여러분의 애플리케이션에는 아마 맞지 않을 겁니다. 그렇지만 어떤 이유로든 맞는다면, 마음껏 쓰세요! Litestream VFS에서 쓰기를 활성화하려면 LITESTREAM_WRITE_ENABLED 환경 변수를 "true"로 설정하기만 하면 됩니다.

Sprite 스토리지 스택은 VFS 모드로 SQLite를 사용합니다. 우리의 원래 VFS 설계에서는 대부분의 데이터가 S3에 남아 있습니다. 다시 말해: 콜드 스타트에는 괜찮지만, 정상 상태에서는 그다지 좋지 않습니다.
이 문제를 해결하기 위해 dm-clone 같은 시스템에서 트릭을 하나 “슬쩍” 가져왔습니다. 바로 백그라운드 하이드레이션(background hydration)입니다. 하이드레이션 설계에서는 원격으로 쿼리를 처리하면서 동시에 전체 데이터베이스를 끌어오는 루프를 돌립니다. LITESTREAM_HYDRATION_PATH 환경 변수를 설정한 채로 VFS를 시작하면, 우리는 그 파일로 하이드레이션을 수행합니다.
하이드레이션은 LTX 컴팩션을 활용해 각 페이지의 최신 버전만 기록합니다. 읽기는 하이드레이션에 의해 블로킹되지 않습니다. 즉시 오브젝트 스토리지에서 읽기를 제공하고, 하이드레이션 파일이 준비되면 그쪽으로 전환합니다.

하이드레이션 파일은 무엇이냐고요? 그건 단순히 데이터베이스의 완전한(full) 복사본입니다. litestream restore를 실행했을 때 얻는 것과 동일합니다.
이 기능은 Sprites처럼 자주 튕기는 환경을 위해 설계되었기 때문에, 데이터베이스는 임시 파일에 씁니다. 매번 시작할 때 데이터베이스가 최신 상태를 사용하고 있다고 신뢰할 수는 없습니다(전체 복구를 하지 않는 한). 그래서 VFS를 종료할 때 하이드레이션 파일을 그냥 버립니다. 이 동작은 현재 VFS에 내장되어 있습니다. 이 기능은 Sprites에 필요한 것들은 갖추고 있지만, 역시 여러분 앱이 원하는 것과는 다를 수 있습니다.
이 글은 오픈소스 Litestream 프로젝트에서 우리가 수행한 비교적 큰 두 가지 변화에 관한 이야기입니다. 하지만 기능 자체는 우리 스토리지 스택이 필요로 하는 문제와 비슷한 문제를 겨냥해 좁게 설계되어 있습니다. 여러분이 이 기능을 활용할 수 있다고 생각한다면 정말 기쁘고, 꼭 이야기해 주셨으면 합니다.
일반적인 읽기/쓰기 워크로드라면 이런 메커니즘은 전혀 필요 없습니다. Litestream은 VFS 없이도 잘 동작하며, 애플리케이션을 수정할 필요도 없고, 애플리케이션 옆에서 사이드카(sidecar)로 실행하기만 하면 됩니다. 그 구성의 핵심은 “쓰기”를 효율적으로 따라잡는 것이고, 쓰기가 발생할 때 작업할 전체 데이터베이스가 있다는 걸 알고 있으면 이는 쉽습니다.
하지만 이 전체 작업은, 제게는 Litestream이 비교적 복잡하고 요구사항이 빡센 문제 영역에서 어떻게 사용될 수 있는지 보여주는 가치 있는 사례 연구입니다. Sprites는 정말 멋지고, Sprite에서 일어나는 모든 디스크 쓰기가 Litestream을 통해 흐르고 있다는 사실을 알게 되는 건 만족스럽습니다.
마지막 업데이트: 2026년 1월 29일