Litestream 如何以每月 0.03 美元的成本淘汰我的資料庫伺服器
這裡有個謎題。我的網頁應用程式將所有資料都保存在 SQL 資料庫中。我可以隨時將它拆除,把程式碼部署到不同的代管平台,應用程式卻依然能提供完全相同的資料。在正式環境中執行我的應用程式,每月成本僅需 0.03 美元。
這怎麼可能呢?
這很簡單。你在某處執行著一台獨立的資料庫伺服器,儲存了應用程式的所有狀態。
不對,我的應用程式從不與遠端資料庫伺服器溝通。
喔,那你一定是用了像是 Amazon DynamoDB 或 Google Cloud Firestore 這種專有的代管式資料儲存服務。
才不是呢,我的整個技術堆疊都是開源且與平台無關的。
那到底是什麼?
我結合了 SQLite、Litestream 和 Docker。
我的工具叫做 LogPaste。它能讓使用者為文字檔產生可分享的網址。我在開源的 KVM over IP device 中使用它,讓使用者能輕鬆與我分享診斷日誌。
分享文字檔本身並沒有什麼革命性,但無伺服器資料複寫或許就是了。以下是我將 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 database 的作者 Ben Johnson(班·強森) 接手了一個新專案:Litestream。這是一個簡單的開源工具,能將 SQLite 資料庫複寫到 Amazon 的 S3 雲端儲存空間。

Litestream 是一個能將 SQLite 資料庫複寫到 Amazon S3 雲端儲存空間的開源工具。
它看起來挺巧妙,但我並沒有特別感到興奮。我從來不用 SQLite,所以根本不在意。
我對 SQLite 並沒有成見,只是覺得它的設計似乎不太實用。與其他透過網路將資料傳送到外部伺服器的資料庫不同,SQLite 會將所有內容寫入本機檔案。我總是擔心:「如果那個檔案遺失了怎麼辦?」
再仔細想想,我意識到自己是因為沒用 SQLite 才忽略了 Litestream。但 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 存取雲端儲存貯體所需的 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 美元:

誠實地說,我的使用情境算是相當輕量。每天只有少數使用者上傳他們的日誌,所以在負載較重的情況下,這種設定可能會出現痛點。
還有一點很重要,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(蘿倫·尤)繪製。
感謝班·強森在 Litestream 上的貢獻以及對本文的早期審閱。感謝 Blogging for Devs Community 的成員對本文提供的回饋。
隨機一篇部落格