How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

我如何用 Litestream 以每月 0.03 美元取代資料庫伺服器

原文由 Michael Lynch 發布,訂閱此部落格

來猜個謎語吧。我的網頁應用程式把所有資料都存在 SQL 資料庫裡。我可以隨時把它整個拆掉,把程式碼部署到另一個代管平台,應用程式卻依然能提供完全相同的資料。而我的應用程式在正式環境中運行的成本,每個月只要 0.03 美元。

這是怎麼辦到的?

這還不簡單。你一定是在某個地方跑了一台獨立的資料庫伺服器,存著應用程式的所有狀態。

不,我的應用程式從來不會連到遠端的資料庫伺服器。

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

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

那到底是怎麼回事?

我把 SQLiteLitestreamDocker 組合起來用了。

我的工具叫做 LogPaste。它可以讓使用者為文字檔產生可分享的網址。我在自己開源的 KVM over IP 裝置裡用了它,讓使用者能輕鬆地把診斷日誌分享給我。

分享文字檔本身沒什麼革命性,但無伺服器的資料複寫或許就是了。這是我把 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 資料庫作者 Ben Johnson 接手了一個新專案:Litestream。這是一個簡單的開源工具,能把 SQLite 資料庫複寫到 Amazon S3 雲端儲存空間。

Litestream 首頁截圖

Litestream 是一個能將 SQLite 資料庫複寫到 Amazon S3 雲端儲存空間的開源工具。

看起來挺不錯,但我當時並沒有特別興奮。我從來不用 SQLite,關我什麼事?

我對 SQLite 沒有偏見,但它的設計在我看來不太實際。不像其他透過網路把資料傳到外部伺服器的資料庫,SQLite 是把所有東西都寫到一個本機檔案裡。我總是擔心:「要是那個檔案不見了怎麼辦?」

再多想一下,我才意識到,我之所以對 Litestream 興趣缺缺,只是因為我不用 SQLite。但 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 存取雲端儲存空間 bucket 所需的 IAM 形式憑證。

你可以把這些值寫死在設定檔裡,但 Litestream 支援環境變數,並會在執行時進行替換。這是個很方便的功能,因為它讓你可以把 litestream.yml 檔納入版本控制,而不必存放敏感憑證。這也讓 Docker 映像檔更具可攜性——任何人只要重用我的映像檔,並為自己的雲端儲存空間 bucket 設定環境變數,就能建立自己的 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 容器中執行兩個長時間運行的行程的方法。Ben Johnson 已經發表了一個更乾淨的解法,但為了方便示範,我用的是這個取巧的版本。

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

我在 TinyPilot——我的開源 KVM over IP 裝置——的正式環境中使用 LogPaste。因為使用者是在他們自己的裝置上執行我的軟體,當他們回報問題時,我無法看到任何診斷資訊。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 繪製。

感謝 Ben Johnson 在 Litestream 上的貢獻,以及他對本文初稿的審閱。感謝 Blogging for Devs Community 的成員們對這篇文章提供的回饋。

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

留言