How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

Litestream으로 월 0.03달러에 데이터베이스 서버를 없앤 방법

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

수수께끼 하나. 제 웹 앱은 모든 데이터를 SQL 데이터베이스에 보관합니다. 앱을 언제든 내려버리고 코드를 다른 호스팅 플랫폼에 배포해도, 앱은 여전히 동일한 데이터를 그대로 제공합니다. 프로덕션에서 앱을 운영하는 비용은 월 0.03달러입니다.

어떻게 이게 가능할까요?

간단하네요. 어딘가에서 앱의 모든 상태를 저장하는 별도의 데이터베이스 서버를 돌리고 있는 거죠.

아닙니다. 제 앱은 원격 데이터베이스 서버와 통신하지 않습니다.

아, 그럼 Amazon DynamoDBGoogle Cloud Firestore 같은 독점 관리형 데이터스토어를 쓰는 거군요.

아닙니다. 제 스택 전체는 오픈소스이며 플랫폼에 종속되지 않습니다.

그럼 뭔데요?

저는 SQLite, Litestream, 그리고 Docker를 조합했습니다.

제 도구는 LogPaste라고 합니다. 텍스트 파일에 대한 공유 가능한 URL을 생성할 수 있게 해줍니다. 저는 오픈소스인 KVM over IP 장치에서 이를 사용해 사용자가 진단 로그를 저에게 쉽게 공유할 수 있도록 하고 있습니다.

텍스트 파일 공유 자체는 그다지 혁신적이지 않지만, 서버리스 데이터 복제는 그럴지도 모릅니다. LogPaste 앱 서버를 Herokufly.io라는 두 개의 전혀 다른 호스팅 플랫폼 사이에서 마이그레이션하는 데모를 보여드리겠습니다. 데이터베이스 서버도, 데이터 마이그레이션 과정도 없지만 모든 데이터는 플랫폼 간에 그대로 유지됩니다:

가장 좋은 점은 앱 코드를 전혀 수정할 필요가 없었다는 것입니다. 앱은 그냥 로컬 SQLite 데이터베이스에 쓰는 것만 하면 되고, Litestream이 백그라운드에서 마법처럼 데이터 복제를 처리합니다.

이번 글에서는 제가 Litestream을 앱에 어떻게 통합했는지, 그리고 여러분도 어떻게 같은 방법으로 비싸고 복잡한 데이터베이스 서버를 대체할 수 있는지 설명하겠습니다.

데이터베이스 서버가 싫은 사람들을 위한 데이터 영속성

부끄러운 프로그래머로서의 비밀 하나는, 제가 데이터베이스 서버를 유지보수할 수 없다는 것입니다.

지난 8년간 저는 직접 소프트웨어 제품과 서비스를 만들어 왔지만, 프로덕션에서 데이터베이스 서버를 사용해 본 적이 한 번도 없습니다. 백업이나 소프트웨어 업그레이드를 책임지고 싶지 않기 때문에, MySQL, Postgres, Redis가 필요한 것은 저에게는 선택지에서 제외됩니다.

대신 저는 Cloud Datastore, Firebase, Firestore 같은 Google 관리형 데이터스토어만 사용해 왔습니다. 하지만 Google은 몇 년마다 완전히 새로운 데이터스토어 솔루션을 만들고 기존 것을 폐기하며, 모든 마이그레이션 작업을 고객에게 떠넘깁니다. 저는 Google이 곧 종료해버릴지도 모르는 기술 스택 위에 또 다른 서비스를 만들고 싶지 않았습니다.

여러 폐기 공지가 포함된 AppEngine 라이브러리 문서 스크린샷

Google은 Python DB Client 라이브러리를 폐기하여 사용자들이 NDB로 마이그레이션하도록 강제했습니다. 이후 NDB를 폐기하고 Cloud NDB를 내놓았습니다. 이제는 불길하게도 개발자들에게 또 다른 API를 기반으로 새 앱을 만들라고 안내하고 있습니다.

Litestream: 서버리스 데이터베이스 서버

몇 달 전, 인기 있는 Bolt database의 저자인 Ben Johnson이 새로운 프로젝트를 시작했다는 것을 알게 되었습니다. 바로 Litestream입니다. Amazon S3 클라우드 스토리지에 SQLite 데이터베이스를 복제하는 단순한 오픈소스 도구입니다.

Litestream 홈페이지 스크린샷

Litestream은 SQLite 데이터베이스를 Amazon S3 클라우드 스토리지에 복제하는 오픈소스 도구입니다.

멋져 보였지만, 특별히 끌리지는 않았습니다. 저는 SQLite를 전혀 쓰지 않으니 무슨 상관인가 싶었습니다.

SQLite 자체에 반대하는 건 아니었지만, 설계가 비현실적으로 느껴졌습니다. 네트워크를 통해 외부 서버로 데이터를 보내는 다른 데이터베이스와 달리, SQLite는 모든 것을 로컬 파일에 씁니다. 저는 늘 “그 파일을 잃어버리면 어떻게 되지?” 하고 걱정했습니다.

좀 더 생각해보니, 제가 Litestream을 무시한 이유는 SQLite를 쓰지 않기 때문이라는 걸 깨달았습니다. 그런데 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-idsecret-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이 데이터베이스를 클라우드 스토리지에 복제할 수도 없기 때문입니다.

다음으로 entrypoint 스크립트는 Litestream 프로세스를 생성하는데, 이 프로세스는 LogPaste의 SQLite 데이터베이스를 감시하며 변경 사항을 클라우드 스토리지에 지속적으로 스트리밍합니다:

# Begin replication to S3 in the background.
litestream replicate "${DB_PATH}" "${DB_REPLICA_URL}" &

사소한 꼼수는 끝에 붙은 &에 있습니다. 이 기호는 Litestream 프로세스를 백그라운드에서 실행하라고 스크립트에 지시하며, 덕분에 같은 Docker 컨테이너 안에서 두 개의 장기 실행 프로세스를 실행할 수 있습니다. Ben Johnson은 더 깔끔한 솔루션을 공개했지만, 저는 설명을 쉽게 하기 위해 이 편법 버전을 사용하고 있습니다.

entrypoint 스크립트는 마지막에 단순한 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 클라이언트는 다음과 같습니다:

클라이언트 측 코드는 30줄 미만의 HTML과 JavaScript로 이루어져 있습니다:

<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은 LogPaste를 사용해 사용자가 디버그 로그에 대한 URL을 생성할 수 있게 합니다.

LogPaste는 지난 몇 달간 TinyPilot의 모든 디버그 로그를 처리해 왔으며 잘 작동하고 있습니다. 데이터 복제 비용은 정말로 월 0.03달러에 불과합니다:

S3 요금 0.03달러와 데이터 전송 요금 0.00달러를 보여주는 AWS 청구서 스크린샷

제 사용 사례는 솔직히 말해 상당히 가볍습니다. 매일 로그를 업로드하는 사용자는 소수에 불과하므로, 더 무거운 워크로드에서는 이 설정에 어려움이 있을 수도 있습니다.

또한 Litestream은 여러 데이터베이스 쓰기 간의 충돌을 해결할 수 없으므로, 각 데이터베이스는 쓰기 권한을 가진 애플리케이션 서버를 하나만 가질 수 있다는 점도 중요합니다.

그럼에도 불구하고 저는 Litestream에 매우 깊은 인상을 받았고, 더 많은 시나리오에서 활용해보고 싶습니다.

LogPaste 셀프 호스팅

제 LogPaste 앱의 자체 인스턴스를 호스팅하고 싶다면 배포가 쉽습니다. 홈페이지의 텍스트를 커스터마이즈해서 “LogPaste” 대신 여러분 제품의 이름을 표시하게 할 수도 있습니다.

예를 들어, TinyPilot 버전은 다음과 같습니다:

TinyPilot의 LogPaste 인스턴스 스크린샷

TinyPilot의 LogPaste 인스턴스는 코드 변경 없이 커스텀 브랜딩을 포함합니다

몇 가지 다른 플랫폼에 대한 배포 가이드를 작성해 두었습니다:

플랫폼비고
fly.io무료 티어에서 상시 실행 인스턴스를 최대 3개까지 사용할 수 있으며 SSL 인증서가 포함됩니다
Amazon LightSail인스턴스당 월 7달러, SSL 인증서 포함
Heroku무료 티어에서 온디맨드 인스턴스를 무제한으로 사용할 수 있으며, 커스텀 도메인의 SSL 인증서는 월 7달러

더 읽어보기

  • Litestream: Litestream 공식 문서.
  • mtlynch/logpaste: LogPaste의 MIT 라이선스 소스 코드 및 문서.
  • litestream-s6-example: Docker 컨테이너에서 앱과 함께 Litestream을 실행하는 더 발전되고 견고한 방법입니다. s6-overlay를 사용해 장애 시 Litestream 인스턴스를 재시작합니다.

아키텍처 다이어그램: Loraine Yow.

Ben Johnson의 Litestream 작업과 이 글의 초기 검토에 감사드립니다. 이 글에 피드백을 제공해준 Blogging for Devs Community 회원들에게도 감사드립니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글