How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

Litestream 如何以每月 0.03 美元的成本淘汰我的資料庫伺服器

這裡有個謎題。我的網頁應用程式將所有資料都保存在 SQL 資料庫中。我可以隨時將它拆除,把程式碼部署到不同的代管平台,應用程式卻依然能提供完全相同的資料。在正式環境中執行我的應用程式,每月成本僅需 0.03 美元。

這怎麼可能呢?

這很簡單。你在某處執行著一台獨立的資料庫伺服器,儲存了應用程式的所有狀態。

不對,我的應用程式從不與遠端資料庫伺服器溝通。

喔,那你一定是用了像是 Amazon DynamoDBGoogle Cloud Firestore 這種專有的代管式資料儲存服務。

才不是呢,我的整個技術堆疊都是開源且與平台無關的。

那到底是什麼?

我結合了 SQLiteLitestreamDocker

我的工具叫做 LogPaste。它能讓使用者為文字檔產生可分享的網址。我在開源的 KVM over IP device 中使用它,讓使用者能輕鬆與我分享診斷日誌。

分享文字檔本身並沒有什麼革命性,但無伺服器資料複寫或許就是了。以下是我將 LogPaste 應用程式伺服器在兩個不同的代管平台:Herokufly.io 之間遷移的示範。過程中沒有資料庫伺服器,也不需要資料遷移步驟,但所有資料在平台之間都完整保留:

最棒的是,我完全不需要修改應用程式的程式碼。它只是寫入本機的 SQLite 資料庫,而 Litestream 會在背景神奇地處理資料複寫。

在這篇文章中,我將說明我是如何將 Litestream 整合到應用程式中,以及你如何也能這麼做,來取代昂貴又複雜的資料庫伺服器。

寫給討厭資料庫伺服器的人的資料持久化

我身為程式設計師有個難為情的祕密:我不會維護資料庫伺服器。

過去八年來,我一直在打造自己的軟體產品與服務,但從未在正式環境中使用過資料庫伺服器。我不想負責備份或軟體升級,所以任何需要 MySQL、Postgres 或 Redis 的方案對我來說都是無法接受的。

因此,我一直使用 Google 代管的資料儲存服務,像是 Cloud Datastore、Firebase 和 Firestore。但每隔幾年,Google 就會推出全新的資料儲存解決方案,棄用舊的方案,並把所有的遷移工作丟給客戶。我不想在一個 Google 可能很快就會淘汰的技術堆疊上再建立另一個服務。

AppEngine 函式庫文件的螢幕截圖,顯示多則棄用通知

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 會將所有內容寫入本機檔案。我總是擔心:「如果那個檔案遺失了怎麼辦?」

再仔細想想,我意識到自己是因為沒用 SQLite 才忽略了 Litestream。但 Litestream 解決的,正是讓我遲遲不願採用 SQLite 的障礙……或許值得一試。

更棒的是,Litestream 可能是我脫離 Google Cloud Platform 的門票。SQLite 在哪裡都能執行,所以我在選擇伺服器代管平台時就有充分的自由。而 Litestream 在儲存方面也提供了廠商彈性,因為它支援任何相容於 S3 的服務,包括 BackBlaze B2WasabiMinio

理論上 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 會在背景中安靜地完成它的工作。

我建立了一個自訂 Docker 映像檔來結合 Litestream 與 LogPaste。一般來說,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 邏輯位於 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 容器中執行兩個長時間運行處理程序的方法。班·強森已經發布了一個更乾淨的解決方案,但為了方便示範,我使用的是這個取巧的版本。

entrypoint 指令碼最後會啟動 LogPaste 應用程式,這是一個簡單的 HTTP 伺服器:

# 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

我在正式環境中將 LogPaste 用於TinyPilot,也就是我的開源 KVM over IP 裝置。由於使用者是在他們自己的裝置上執行我的軟體,當他們回報問題時,我無法看到任何診斷資訊。LogPaste 提供了一種方便的方式,讓使用者能與我分享他們的日誌。

TinyPilot 使用 LogPaste 讓使用者為除錯日誌產生網址。

過去幾個月來,LogPaste 已經處理了 TinyPilot 所有的除錯日誌,而且運作良好。資料複寫的成本真的就只有每月 0.03 美元:

顯示 S3 費用為 0.03 美元且資料傳輸費用為 0.00 美元的 AWS 帳單螢幕截圖

誠實地說,我的使用情境算是相當輕量。每天只有少數使用者上傳他們的日誌,所以在負載較重的情況下,這種設定可能會出現痛點。

還有一點很重要,Litestream 無法解決多個資料庫寫入之間的衝突,因此每個資料庫只能有一個具有寫入權限的應用程式伺服器。

儘管如此,我對 Litestream 依然印象非常深刻,也很期待在更多情境中使用它。

自行託管 LogPaste

如果你想自行託管我的 LogPaste 應用程式的執行個體,部署起來很容易。你甚至可以自訂首頁上的文字,讓它顯示你的產品名稱,而不是「LogPaste」。

舉例來說,這是 TinyPilot 的版本:

TinyPilot 的 LogPaste 執行個體螢幕截圖

TinyPilot 的 LogPaste 執行個體包含了客製化品牌標識,且無需任何程式碼變更

我已為幾個不同的平台撰寫了部署說明:

平台備註
fly.io免費方案允許最多三個常駐執行個體,並包含 SSL 憑證
Amazon LightSail每個執行個體每月 7 美元,包含 SSL 憑證
Heroku免費方案允許無限個隨需執行個體,自訂網域的 SSL 憑證每月 7 美元

延伸閱讀

  • Litestream:Litestream 官方文件。
  • mtlynch/logpaste:LogPaste 採用 MIT 授權的原始碼與文件。
  • litestream-s6-example:在 Docker 容器中與應用程式並行執行 Litestream 的更進階、更穩健的方法。它使用 s6-overlay 在 Litestream 執行個體失敗時自動重新啟動。

架構圖由 Loraine Yow(蘿倫·尤)繪製。

感謝班·強森在 Litestream 上的貢獻以及對本文的早期審閱。感謝 Blogging for Devs Community 的成員對本文提供的回饋。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯