How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

Litestreamでデータベースサーバーを月0.03ドルでなくした方法

ここでなぞなぞです。私のWebアプリはすべてのデータをSQLデータベースに保存しています。アプリをその場で停止し、コードを別のホスティングプラットフォームにデプロイしても、同じデータをそのまま配信し続けられます。しかも本番環境での運用コストは月0.03ドルです。

どうしてこんなことが可能なのでしょうか。

簡単だよ。どこかで別のデータベースサーバーを動かしていて、そこにアプリの状態を全部保存しているんでしょう。

いいえ、私のアプリがリモートのデータベースサーバーと通信することはありません。

なるほど、じゃあAmazon DynamoDBGoogle Cloud Firestoreのようなプロプライエタリなマネージドデータストアを使っているんだね。

違います。私のスタックはすべてオープンソースで、プラットフォームにも依存していません。

じゃあ一体何なんだ?

SQLiteLitestream、そしてDockerを組み合わせたんです。

私が作ったツールはLogPasteといいます。テキストファイルに共有用のURLを発行できるサービスです。オープンソースのKVM over IPデバイスで使っており、ユーザーが診断ログを私に簡単に共有できるようにしています。

テキストファイルの共有自体は目新しいものではありませんが、サーバーレスなデータレプリケーションは革新的かもしれません。LogPasteのアプリサーバーを2つの異なるホスティングプラットフォーム、Herokufly.io間で移行するデモをご覧ください。データベースサーバーもデータ移行の手順もありませんが、データはプラットフォームをまたいで保持されます。

一番のポイントは、アプリのコードを一切変更する必要がなかったことです。アプリはただローカルのSQLiteデータベースに書き込むだけで、Litestreamがバックグラウンドでデータのレプリケーションを魔法のように処理してくれます。

この記事では、私がどのようにLitestreamをアプリに組み込んだのか、そして高価で複雑なデータベースサーバーを置き換えるために皆さんが同じことを実現する方法を解説します。

データベースサーバーが嫌いな人のためのデータ永続化

恥ずかしながら白状すると、私はデータベースサーバーをまともに運用できません。

この8年間、個人でソフトウェア製品やサービスを作り続けてきましたが、本番環境でデータベースサーバーを使ったことは一度もありません。バックアップやソフトウェアのアップグレードに責任を持ちたくないので、MySQLやPostgres、Redisが必要なものは私にとって選択肢になりません。

その代わり、Cloud DatastoreやFirebase、FirestoreといったGoogleのマネージドデータストアを使ってきました。しかしGoogleは数年ごとにまったく新しいデータストアを作っては古いものを非推奨にし、移行作業のすべてを顧客に押し付けてきます。いずれ廃止されそうな技術スタックの上に、また新しいサービスを作る気にはなれませんでした。

いくつもの非推奨通知が表示されたApp Engineライブラリのドキュメントのスクリーンショット

GoogleはPython DB Clientライブラリを非推奨にし、ユーザーにNDBへの移行を強制しました。その後NDBを非推奨にしてCloud NDBを推奨し、今ではまた別のAPIで新規アプリを構築するよう開発者に促しています。

Litestream:サーバーレスなデータベースサーバー

数ヶ月前、人気のBolt databaseの作者であるBen Johnson氏が新しいプロジェクトを始めたのを知りました。Litestreamです。SQLiteデータベースをAmazon S3のクラウドストレージにレプリケーションする、シンプルなオープンソースのツールです。

Litestreamホームページのスクリーンショット

LitestreamはSQLiteデータベースをAmazon S3のクラウドストレージにレプリケーションするオープンソースのツールです。

面白そうだとは思いましたが、特に心は動きませんでした。私はSQLiteを使っていなかったので、自分には関係ないと思ったのです。

SQLiteに何か不満があったわけではありません。ただ設計が非現実的に思えたのです。他のデータベースがネットワーク経由で外部サーバーにデータを送るのに対し、SQLiteはすべてをローカルファイルに書き込みます。「もしそのファイルを失ったらどうなるんだ?」といつも不安でした。

もう少し考えてみて気づきました。Litestreamに興味を持たなかったのは自分がSQLiteを使っていなかったからですが、Litestreamこそが私がSQLiteを採用できなかった理由を解決してくれるのではないかと。試してみる価値はありそうでした。

さらに良いことに、LitestreamはGoogle Cloud Platformから脱出するきっかけにもなり得ました。SQLiteはどこでも動くので、サーバーのホスティング先を自由に選べます。Litestreamはストレージ側でもベンダーロックインがなく、BackBlaze B2WasabiMinioなどS3互換のサービスならどれでもサポートしています。

理論上は素晴らしく聞こえましたが、技術の良し悪しは本番環境で試してみないとわかりません。ちょうどログアップロードサービスが必要だったので、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はバックグラウンドで静かに仕事をこなしてくれます。

カスタムDockerイメージを作成してLitestreamとLogPasteを組み合わせました。本来Dockerイメージは単一のサービスだけを保持すべきですが、デプロイを簡単にするためにあえてこの原則を曲げています。連携が必要な2つのコンテナをデプロイするより、独立した単一の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関連の次のロジックは、LogPasteのdocker_entrypointスクリプトにあります。このスクリプトはDockerコンテナの起動時に実行されます。まず、アプリの最新のデータベーススナップショットをクラウドストレージから取得します。

# 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コンテナ内で2つの常駐プロセスを実行できます。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にアップロードできますが、他のWebアプリと連携するのも簡単です。デモインスタンスで動作する、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の運用

私は本番環境でTinyPilotのためにLogPasteを使っています。TinyPilotは私のオープンソースのKVM over IPデバイスです。ソフトウェアはユーザーが所有するデバイス上で動作するため、問題が報告されても診断情報を見ることができません。LogPasteがあることで、ユーザーはログを簡単に共有できます。

TinyPilotではLogPasteを使ってユーザーがデバッグログのURLを生成できるようにしています。

LogPasteは数ヶ月間、TinyPilotのすべてのデバッグログを処理してきましたが、順調に動作しています。データレプリケーションにかかるコストは、本当に月0.03ドルだけです。

S3の料金が0.03ドル、データ転送料金が0.00ドルとなっているAWS請求書のスクリーンショット

もちろん、私のユースケースはかなりライトなものです。ログをアップロードするユーザーは1日に数人程度なので、より負荷の高いワークロードでは問題が出てくるかもしれません。

また、Litestreamは複数のデータベース書き込み間の競合を解決できないため、1つのデータベースに対して書き込み権限を持つアプリケーションサーバーは1台だけにする必要がある点にも注意が必要です。

それでも、Litestreamには非常に感銘を受けており、今後さらに多くの場面で活用していきたいと思っています。

LogPasteのセルフホスティング

もし私のLogPasteアプリをセルフホストしたいなら、簡単にデプロイできます。ホームページのテキストを「LogPaste」ではなく自分のプロダクト名にカスタマイズすることも可能です。

例えば、TinyPilot版はこのようになっています。

TinyPilotのLogPasteインスタンスのスクリーンショット

TinyPilotのLogPasteインスタンスでは、コードを変更せずにカスタムブランディングを適用しています

いくつかのプラットフォーム向けにデプロイ手順を用意しました。

プラットフォーム備考
fly.io無料枠で常時起動インスタンスを3つまで利用でき、SSL証明書も含まれます
Amazon LightSail1インスタンスあたり月7ドル、SSL証明書込み
Heroku無料枠でオンデマンドインスタンスを利用回数無制限で利用可能、カスタムドメインのSSL証明書は月7ドル

関連リンク

  • Litestream: Litestream公式ドキュメント
  • mtlynch/logpaste: LogPasteのMITライセンスのソースコードとドキュメント
  • litestream-s6-example: Dockerコンテナ内でLitestreamをアプリと並走させるための、より高度で堅牢な方法です。s6-overlayを使って障害時にLitestreamインスタンスを自動で再起動します。

アーキテクチャ図:Loraine Yow氏作成

Litestreamの開発と本記事の初期レビューをいただいたBen Johnson氏、そして本記事にフィードバックをくれたBlogging for Devs Communityのメンバーの皆さんに感謝します。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。