How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

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

原文は Michael Lynch により に公開されました。 このブログを購読する

なぞなぞを出します。私のWebアプリはすべてのデータをSQLデータベースに保存しています。思い立ったときにアプリを停止し、別のホスティングプラットフォームにコードをデプロイしても、アプリは同じデータをそのまま提供し続けます。そして本番環境での運用コストは月額わずか0.03ドルです。

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

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

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

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

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

じゃあ何なんだ?

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

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

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

一番良いのは、アプリのコードをまったく修正する必要がなかったことです。アプリはローカルのSQLiteデータベースに書き込むだけで、Litestreamが裏側で魔法のようにデータレプリケーションを処理してくれます。

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

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

恥ずかしながら、私はデータベースサーバーを維持管理できないのです。

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

その代わり、Cloud DatastoreやFirebase、FirestoreといったGoogleが管理するデータストアをずっと使ってきました。しかしGoogleは数年ごとにまったく新しいデータストアソリューションを作り、古いものを非推奨にし、移行作業をすべて顧客に押し付けます。dumps all the migration work onto its customers。いずれ廃止されそうな技術スタックの上に、また別のサービスを作りたくはありませんでした。

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

GoogleはPython DB Clientライブラリを非推奨にし、ユーザーにNDBへの移行を強いました。その後NDBを非推奨にしてCloud NDBに置き換えました。そして今、不穏なことに、また別のAPIを使って新しいアプリを構築するよう開発者に指示しています。

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

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

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

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

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

SQLiteに反対というわけではありませんでしたが、その設計は実用的ではないように思えました。他のデータベースがネットワーク経由で外部サーバーにデータを送信するのとは異なり、SQLiteはすべてをローカルファイルに書き込みます。私は常々「もしそのファイルを失ったらどうなるんだろう?」と心配していました。

よく考えてみると、SQLiteを使っていないからという理由でLitestreamを軽視していたことに気づきました。しかしLitestreamは、私がSQLiteを採用できなかったまさにその障害を解決してくれるのです……試してみる価値はあるかもしれません。

さらに良いことに、LitestreamはGoogle Cloud Platformから脱出するための切符になるかもしれません。SQLiteはどこでも動作するので、サーバーのホスティングプラットフォームを自由に選べます。Litestreamはストレージ側でもベンダーの柔軟性を提供してくれます。BackBlaze B2WasabiMinioなど、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イメージは単一のサービスだけを持つべきとされていますが、デプロイを容易にするために私は時々このルールを曲げます。相互に連携が必要な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の運用

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

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

LogPasteはこの数ヶ月間、TinyPilotのすべてのデバッグログを処理してきましたが、問題なく動作しています。データレプリケーションのコストは、本当に月額わずか0.03ドルです。

S3の料金が0.03ドル、データ転送料金が0.00ドルと表示されたAWS請求書のスクリーンショット

私のユースケースは、正直なところかなりライトなものです。1日にログをアップロードするユーザーはほんの一握りなので、より高負荷なワークロードではこの構成で問題が生じる可能性もあります。

また、Litestreamは複数のデータベース書き込み間の競合を解決できないため、各データベースに対して書き込みアクセスを持つアプリケーションサーバーは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作。

Ben JohnsonのLitestreamにおける功績と本記事の初期レビューに感謝します。Blogging for Devs Communityのメンバーの皆さんにも、本投稿へのフィードバックに感謝します。

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

コメント