我如何用 Litestream 以每月 0.03 美元取代資料庫伺服器
原文由 Michael Lynch 于 發布,訂閱此部落格
來猜個謎語吧。我的網頁應用程式把所有資料都存在 SQL 資料庫裡。我可以隨時把它整個拆掉,把程式碼部署到另一個代管平台,應用程式卻依然能提供完全相同的資料。而我的應用程式在正式環境中運行的成本,每個月只要 0.03 美元。
這是怎麼辦到的?
這還不簡單。你一定是在某個地方跑了一台獨立的資料庫伺服器,存著應用程式的所有狀態。
不,我的應用程式從來不會連到遠端的資料庫伺服器。
喔,那你一定是用了像 Amazon DynamoDB 或 Google Cloud Firestore 這類專屬的代管式資料儲存服務。
才不是,我的整個技術堆疊都是開源的,而且跟平台無關。
那到底是怎麼回事?
我把 SQLite、Litestream 和 Docker 組合起來用了。
我的工具叫做 LogPaste。它可以讓使用者為文字檔產生可分享的網址。我在自己開源的 KVM over IP 裝置裡用了它,讓使用者能輕鬆地把診斷日誌分享給我。
分享文字檔本身沒什麼革命性,但無伺服器的資料複寫或許就是了。這是我把 LogPaste 應用程式伺服器在兩個不同的代管平台——Heroku 和 fly.io——之間遷移的示範。過程中沒有資料庫伺服器,也不需要資料遷移步驟,但所有資料在不同平台之間都完整保留:
最棒的是,我完全不需要修改應用程式的程式碼。它只是寫入本機的 SQLite 資料庫,然後 Litestream 就在背景神奇地處理好資料複寫。
在這篇文章裡,我會說明我是如何把 Litestream 整合進應用程式,以及你也可以如何用同樣的方法,取代既昂貴又複雜的資料庫伺服器。
給討厭資料庫伺服器的人的資料持久化方案
我有個難以啟齒的程式設計師祕密:我不會維護資料庫伺服器。
過去八年來,我一直在打造自己的軟體產品與服務,但我從來沒有在正式環境中使用過資料庫伺服器。我不想負責備份或軟體升級,所以任何需要 MySQL、Postgres 或 Redis 的東西,對我來說都是直接出局。
所以我一直都是用 Google 代管的資料儲存服務,像是 Cloud Datastore、Firebase 和 Firestore。但每隔幾年,Google 就會推出一個全新的資料儲存解決方案,把舊的標為棄用,然後把所有遷移工作丟給客戶去做。我可不想再用一個 Google 很快可能就會砍掉的技術堆疊來蓋另一個服務。

Google 將其 Python DB Client 函式庫標為棄用,迫使使用者遷移到 NDB。接著他們又棄用 NDB,改推 Cloud NDB。現在,他們又語帶不祥地引導開發者用另一個全新的 API 來建置新應用程式。
Litestream:無伺服器的資料庫伺服器
幾個月前,我看到熱門的 Bolt 資料庫作者 Ben Johnson 接手了一個新專案:Litestream。這是一個簡單的開源工具,能把 SQLite 資料庫複寫到 Amazon S3 雲端儲存空間。

Litestream 是一個能將 SQLite 資料庫複寫到 Amazon S3 雲端儲存空間的開源工具。
看起來挺不錯,但我當時並沒有特別興奮。我從來不用 SQLite,關我什麼事?
我對 SQLite 沒有偏見,但它的設計在我看來不太實際。不像其他透過網路把資料傳到外部伺服器的資料庫,SQLite 是把所有東西都寫到一個本機檔案裡。我總是擔心:「要是那個檔案不見了怎麼辦?」
再多想一下,我才意識到,我之所以對 Litestream 興趣缺缺,只是因為我不用 SQLite。但 Litestream 解決的,正是讓我遲遲不敢採用 SQLite 的那個障礙……或許值得一試。
更棒的是,Litestream 可能是我脫離 Google Cloud Platform 的門票。SQLite 在哪裡都能跑,所以我在選擇伺服器代管平台時就有充分的自由。而 Litestream 在儲存端也提供了廠商彈性,因為它支援任何相容 S3 的服務,包括 BackBlaze B2、Wasabi 和 Minio。
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-id 和 secret-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 美元:

老實說,我的使用情境算是相當輕量。每天只有少數使用者會上傳日誌,所以在負載更重的情況下,這個架構可能會遇到一些痛點。
還有一點很重要,Litestream 無法解決多個資料庫寫入之間的衝突,所以每個資料庫只能有一個具有寫入權限的應用程式伺服器。
儘管如此,Litestream 還是讓我印象非常深刻,我也很期待能在更多場景中使用它。
自行架設 LogPaste
如果你想自己架設 LogPaste 應用程式的實例,部署起來很容易。你甚至可以自訂首頁上的文字,讓它顯示你產品的名稱,而不是「LogPaste」。
舉例來說,這是 TinyPilot 的版本:

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 的成員們對這篇文章提供的回饋。
隨機一篇部落格
留言
登入後參與討論