Litestream으로 월 0.03달러에 데이터베이스 서버를 없앤 방법
수수께끼 하나 내겠습니다. 제 웹 앱은 모든 데이터를 SQL 데이터베이스에 저장합니다. 저는 언제든 앱을 중단하고 다른 호스팅 플랫폼에 코드를 배포할 수 있는데, 그러고도 앱은 똑같은 데이터를 계속 제공합니다. 운영 환경에서 앱을 실행하는 비용은 한 달에 0.03달러입니다.
어떻게 가능할까요?
간단하네요. 어딘가에서 별도의 데이터베이스 서버가 실행 중이고, 앱의 모든 상태를 저장하고 있는 거죠.
아닙니다. 제 앱은 원격 데이터베이스 서버와 전혀 통신하지 않습니다.
아, 그렇다면 Amazon DynamoDB나 Google Cloud Firestore 같은 독점 관리형 데이터 저장소를 사용하고 있군요.
그것도 아닙니다. 전체 스택이 오픈 소스이고 특정 플랫폼에 종속되지 않습니다.
그럼 대체 어떻게 한 건가요?
SQLite, Litestream, Docker를 조합했습니다.
제가 만든 도구의 이름은 LogPaste입니다. 사용자가 텍스트 파일을 공유할 수 있는 URL을 만들도록 해줍니다. 저는 오픈 소스 KVM over IP 장치인 TinyPilot에서 이 도구를 사용합니다. 사용자가 진단 로그를 저와 쉽게 공유할 수 있게 하기 위해서입니다.
텍스트 파일 공유 자체는 혁신적이라고 할 수 없지만, 서버리스 데이터 복제는 그럴지도 모릅니다. 다음은 LogPaste 앱 서버를 서로 다른 두 호스팅 플랫폼인 Heroku와 fly.io 사이에서 마이그레이션하는 모습을 보여 주는 데모입니다. 데이터베이스 서버도, 데이터 마이그레이션 단계도 없지만 두 플랫폼 사이에서 모든 데이터가 그대로 유지됩니다.
가장 좋은 점은 앱 코드를 전혀 수정할 필요가 없었다는 것입니다. 앱은 로컬 SQLite 데이터베이스에 데이터를 쓰기만 하고, Litestream이 백그라운드에서 데이터 복제를 알아서 처리합니다.
이 글에서는 Litestream을 앱에 통합한 방법과, 여러분도 같은 방식으로 비싸고 복잡한 데이터베이스 서버를 대체하는 방법을 설명하겠습니다.
데이터베이스 서버를 싫어하는 사람을 위한 데이터 지속성
부끄러운 프로그래머의 비밀이 하나 있습니다. 저는 데이터베이스 서버를 관리하지 못합니다.
지난 8년 동안 직접 소프트웨어 제품과 서비스를 만들어 왔지만, 운영 환경에서 데이터베이스 서버를 사용한 적은 한 번도 없습니다. 백업이나 소프트웨어 업그레이드를 책임지고 싶지 않기 때문에 MySQL, Postgres, Redis가 필요한 것이라면 제게는 고려 대상이 아닙니다.
대신 저는 늘 Cloud Datastore, Firebase, Firestore처럼 Google이 관리하는 데이터 저장소를 사용했습니다. 하지만 Google은 몇 년마다 완전히 새로운 데이터 저장소 솔루션을 만들고, 기존 솔루션을 폐기한 다음, 마이그레이션 작업을 전부 고객에게 떠넘깁니다. Google이 곧 없애 버릴 가능성이 높은 기술 스택 위에 또 다른 서비스를 만들고 싶지는 않았습니다.

Google은 Python DB Client 라이브러리를 지원 중단하고 사용자에게 NDB로 마이그레이션하도록 했습니다. 그런 다음 NDB도 지원 중단하고 Cloud NDB를 사용하도록 했습니다. 이제는 개발자에게 또 다른 API를 기준으로 새 앱을 만들라고 불길하게 안내하고 있습니다.
Litestream: 서버리스 데이터베이스 서버
몇 달 전, 인기 있는 Bolt 데이터베이스의 개발자인 Ben Johnson이 새로운 프로젝트인 Litestream을 시작했다는 소식을 봤습니다. Litestream은 SQLite 데이터베이스를 Amazon의 S3 클라우드 스토리지에 복제하는 간단한 오픈 소스 도구입니다.

Litestream은 SQLite 데이터베이스를 Amazon의 S3 클라우드 스토리지에 복제하는 오픈 소스 도구입니다.
괜찮아 보이기는 했지만, 특별히 흥미가 생기지는 않았습니다. 저는 SQLite를 전혀 사용하지 않는데, 그게 저와 무슨 상관이 있을까요?
SQLite에 반감이 있었던 것은 아닙니다. 다만 그 설계가 비현실적으로 보였습니다. 네트워크를 통해 외부 서버로 데이터를 보내는 다른 데이터베이스와 달리 SQLite는 모든 것을 로컬 파일에 씁니다. 저는 늘 이렇게 걱정했습니다. “그 파일을 잃어버리면 어떻게 하지?”
좀 더 생각해 보니, 제가 SQLite를 사용하지 않는다는 이유로 Litestream을 처음부터 배제했다는 사실을 깨달았습니다. 그런데 Litestream은 제가 SQLite를 도입하지 못하게 막고 있던 바로 그 문제를 해결해 주었습니다. 어쩌면 한번 시도해 볼 만한지도 모르겠습니다.
더 좋은 점은 Litestream이 Google Cloud Platform에서 벗어날 수 있는 출구가 되어 준다는 것입니다. SQLite는 어디서나 실행되므로 서버 호스팅 플랫폼을 자유롭게 선택할 수 있습니다. 스토리지 측면에서도 Litestream은 여러 공급업체를 선택할 수 있게 해줍니다. BackBlaze B2, Wasabi, Minio를 비롯해 S3 호환 서비스를 모두 지원하기 때문입니다.
이론적으로는 Litestream이 장밋빛으로 들렸지만, 운영 환경에서 시험해 보기 전에는 기술을 평가할 수 없습니다. 로그 업로드 서비스가 필요했고, Litestream을 시험하기에 딱 좋은 프로젝트처럼 보였습니다.
기본 기능 만들기
LogPaste는 명령줄에서 HTTP PUT 요청을 받아야 했기 때문에 Go로 이 간단한 HTTP 핸들러를 작성했습니다.
func (s defaultServer) pastePut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Read the full HTTP PUT request body as a string.
bodyRaw, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "can't read request body", http.StatusBadRequest)
return
}
body := string(bodyRaw)
// Generate a random entry ID.
id := generateEntryId()
// Store the PUT body in the SQLite database.
err = s.store.InsertEntry(id, body)
if err != nil {
http.Error(w, "can't save entry", http.StatusInternalServerError)
return
}
// Send a JSON response with the ID we generated.
w.Header().Set("Content-Type", "application/json")
resp := PastePutResponse{
Id: id,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
panic(err)
}
}
}InsertEntry 구현은 예상할 수 있는 모습입니다. SQLite에 행 하나를 삽입하는 기본적인 코드입니다.
func (d db) InsertEntry(id string, contents string) error {
_, err := d.ctx.Exec(`
INSERT INTO entries(
id,
creation_time,
contents)
values(?,?,?)`, id, time.Now().Format(time.RFC3339), contents)
return err
}이제 LogPaste는 다음과 같이 명령줄에서 HTTP 요청을 받을 수 있습니다.
$ curl -X PUT -d "Hello, world!" http://localhost:3001
{"id":"fFnL9cU6"}
$ curl http://localhost:3001/fFnL9cU6
Hello, world!이 코드는 정상적으로 동작하지만 SQLite 데이터베이스를 로컬 파일 시스템에 기록합니다. 클라우드 스토리지를 사용하려면 Litestream을 통합해야 했습니다.
클라우드 데이터 동기화를 위한 Litestream 추가
Litestream의 가장 큰 장점 중 하나는 자신이 서비스하는 애플리케이션과 완전히 독립적이라는 것입니다. LogPaste 코드는 Litestream API를 호출하지 않으며, 동기화를 위해 특별한 설정을 요구하지도 않습니다. Litestream은 백그라운드에서 조용히 맡은 일을 합니다.
Litestream과 LogPaste를 하나로 묶기 위해 커스텀 Docker 이미지를 만들었습니다. 일반적으로 Docker 이미지에는 서비스 하나만 담아야 하지만, 배포를 쉽게 하려고 이 원칙을 가끔은 조금 어깁니다. 서로 조율해야 하는 컨테이너 두 개를 배포하는 것보다 독립적인 Docker 컨테이너 하나를 배포하는 편이 훨씬 쉽기 때문입니다.
LogPaste의 Dockerfile은 먼저 소스에서 LogPaste 바이너리를 빌드한 다음 Litestream의 Linux 실행 파일을 내려받습니다.
# Build LogPaste from source
RUN go build \
-mod=readonly \
-v \
-o /app/server \
./main.go
# Download Litestream executable
RUN wget "https://github.com/benbjohnson/litestream/releases/download/v${litestream_version}/${litestream_deb_filename}"그다음 Docker가 커스텀 litestream.yml 파일을 이미지에 복사합니다. 이 파일은 Litestream의 설정 파일입니다.
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
dbs:
- path: ${DB_PATH}
replicas:
- url: ${DB_REPLICA_URL}replicas.url 필드에는 데이터베이스를 저장할 클라우드 스토리지 위치가 들어갑니다. access-key-id와 secret-access-key는 Litestream이 클라우드 스토리지 버킷에 접근할 때 필요한 IAM 방식의 자격 증명입니다.
이 값을 설정 파일에 하드코딩할 수도 있지만, Litestream은 환경 변수를 지원하며 실행 시점에 값을 보간합니다. 민감한 자격 증명을 저장하지 않고도 litestream.yml 파일을 소스 코드 저장소에서 관리할 수 있으므로 편리한 기능입니다. Docker 이미지의 이식성도 높아집니다. 누구나 제 이미지를 재사용하고 자신의 클라우드 스토리지 버킷에 해당하는 환경 변수를 설정해 LogPaste 서버를 만들 수 있습니다.
Litestream 로직의 다음 부분은 Docker 컨테이너가 시작될 때 실행되는 LogPaste의 docker_entrypoint 스크립트에 들어 있습니다. 먼저 클라우드 스토리지에서 앱의 최신 데이터베이스 스냅샷을 내려받습니다.
# Restore database from S3.
litestream restore -if-replica-exists -v "${DB_PATH}"-if-replica-exists 플래그는 클라우드 스토리지에 아직 스냅샷이 없어도 괜찮다고 Litestream에 알려 줍니다. 그렇지 않으면 닭과 달걀의 문제가 생깁니다. 복원할 클라우드 데이터베이스가 없어서 앱을 시작할 수 없는데, 앱이 실행된 적이 없으므로 Litestream도 데이터베이스를 클라우드 스토리지에 복제할 수 없게 됩니다.
그다음 엔트리포인트 스크립트가 Litestream 프로세스를 생성합니다. 이 프로세스는 LogPaste의 SQLite 데이터베이스를 감시하면서 변경 사항이 생길 때마다 클라우드 스토리지로 계속 전송합니다.
# Begin replication to S3 in the background.
litestream replicate "${DB_PATH}" "${DB_REPLICA_URL}" &작은 꼼수는 끝에 붙은 &입니다. 이 기호를 사용하면 스크립트가 Litestream 프로세스를 백그라운드에서 실행합니다. 덕분에 하나의 Docker 컨테이너 안에서 장시간 실행되는 프로세스 두 개를 실행할 수 있습니다. Ben Johnson은 더 깔끔한 해결책을 공개했지만, 여기서는 시연을 쉽게 하려고 약간 투박한 방식을 사용하겠습니다.
엔트리포인트 스크립트의 마지막 작업은 간단한 HTTP 서버인 LogPaste 앱을 시작하는 것입니다.
# Start LogPaste server.
/app/server환경 변수를 모두 채운 상태로 Docker 컨테이너를 실행하려면 다음 명령을 사용합니다.
LITESTREAM_ACCESS_KEY_ID=MY-ACCESS-ID
LITESTREAM_SECRET_ACCESS_KEY=MY-SECRET-ACCESS-KEY
DB_REPLICA_URL=s3://my-bucket-name/db
docker run \
-e "PORT=3001" \
-e "LITESTREAM_ACCESS_KEY_ID=${LITESTREAM_ACCESS_KEY_ID}" \
-e "LITESTREAM_SECRET_ACCESS_KEY=${LITESTREAM_SECRET_ACCESS_KEY}" \
-e "DB_REPLICA_URL=${DB_REPLICA_URL}" \
-p 3001:3001/tcp \
--name logpaste \
mtlynch/logpaste운영 환경에서는 다음과 같은 구조로 모두 연결됩니다.

LogPaste, Litestream, Docker, S3가 운영 환경에서 연결되는 방식
LogPaste 데모
사용자는 명령줄에서 LogPaste에 업로드할 수 있지만, 다른 웹 앱과 통합하는 것도 쉽습니다. 다음은 제 데모 인스턴스를 대상으로 실행되는 간단한 LogPaste HTML 클라이언트입니다.
클라이언트 측 코드는 HTML과 JavaScript를 합쳐 30줄도 되지 않습니다.
<div class="upload-form">
<textarea id="upload-textarea" placeholder="Enter some text"></textarea>
<button class="button" id="upload">Upload</button>
</div>
<a id="result"></a>
<div id="error"></div>
<script src="https://logpaste.com/static/js/logpaste.js"></script>
<script>
const baseUrl = "https://logpaste.com";
document.getElementById("upload").addEventListener("click", (evt) => {
const resultElement = document.getElementById("result");
const errorElement = document.getElementById("error");
resultElement.innerText = "";
errorElement.innerText = "";
const textToUpload = document.getElementById("upload-textarea").value;
logpaste
.uploadText(textToUpload, baseUrl)
.then((id) => {
const url = `${baseUrl}/${id}`;
resultElement.innerText = url;
resultElement.href = url;
})
.catch((error) => {
errorElement.innerText = error;
});
});
</script>운영 환경에서 LogPaste 사용하기
저는 오픈 소스 KVM over IP 장치인 TinyPilot에서 LogPaste를 운영 환경에 사용하고 있습니다. 사용자가 자신이 소유한 장치에서 제 소프트웨어를 실행하기 때문에, 사용자가 문제를 신고해도 저는 어떤 진단 정보도 확인할 수 없습니다. LogPaste를 사용하면 사용자가 로그를 저와 편리하게 공유할 수 있습니다.
TinyPilot은 사용자가 디버그 로그의 URL을 만들 수 있도록 LogPaste를 사용합니다.
지난 몇 달 동안 LogPaste는 TinyPilot의 모든 디버그 로그를 처리해 왔고, 아주 잘 작동했습니다. 데이터 복제 비용은 정말 한 달에 0.03달러뿐입니다.

물론 제 사용 사례는 상당히 부담이 적습니다. 매일 로그를 업로드하는 사용자가 몇 명뿐이므로, 더 많은 부하가 걸리면 이 구성에서 문제가 생길 수도 있습니다.
또한 Litestream은 여러 데이터베이스 쓰기 작업 사이의 충돌을 해결할 수 없다는 점도 중요합니다. 따라서 각 데이터베이스에 쓰기 권한을 가진 애플리케이션 서버는 하나만 둘 수 있습니다.
그럼에도 Litestream에 깊은 인상을 받았고, 앞으로 더 많은 상황에서 사용해 보고 싶습니다.
LogPaste 자체 호스팅
제 LogPaste 앱 인스턴스를 직접 호스팅하고 싶다면 쉽게 배포할 수 있습니다. 홈페이지의 텍스트를 수정해 “LogPaste” 대신 자신의 제품 이름을 표시할 수도 있습니다.
예를 들어 다음은 TinyPilot 버전입니다.

TinyPilot의 LogPaste 인스턴스는 코드를 수정하지 않고도 맞춤형 브랜딩을 적용합니다.
몇 가지 플랫폼에 대한 배포 지침을 작성해 두었습니다.
| 플랫폼 | 참고 사항 |
|---|---|
| fly.io | 무료 티어에서 항상 실행되는 인스턴스를 최대 3개까지 사용할 수 있으며 SSL 인증서가 포함됩니다. |
| Amazon LightSail | 인스턴스당 월 7달러이며 SSL 인증서가 포함됩니다. |
| Heroku | 무료 티어에서 필요할 때 실행하는 인스턴스를 무제한으로 사용할 수 있으며, 커스텀 도메인에 SSL 인증서를 사용하려면 월 7달러가 듭니다. |
더 읽어 보기
- Litestream: Litestream 공식 문서
- mtlynch/logpaste: MIT 라이선스로 공개된 LogPaste 소스 코드와 문서
- litestream-s6-example: Docker 컨테이너에서 Litestream을 앱과 함께 실행하는 더 고급스럽고 견고한 방법입니다. s6-overlay를 사용해 장애가 발생하면 Litestream 인스턴스를 다시 시작합니다.
아키텍처 다이어그램: Loraine Yow
Litestream을 개발하고 이 글을 초기에 검토해 준 Ben Johnson에게 감사드립니다. 이 글에 의견을 보내 준 Blogging for Devs Community 회원들에게도 감사드립니다.
글을 무작위로 읽기