Litestream 如何让我以每月 0.03 美元的成本省掉了数据库服务器
原文由 Michael Lynch 于 发布,订阅该博客
先来猜个谜。我的 Web 应用把所有数据都存在 SQL 数据库里。我可以随时把它整个拆掉,把代码部署到另一个托管平台,应用依然能提供完全相同的数据。而我在生产环境中运行这个应用的成本是每月 0.03 美元。
这是怎么实现的?
这还不简单,你肯定在别处跑了一个独立的数据库服务器,用来保存应用的所有状态。
不是,我的应用从不和远程数据库服务器通信。
哦,那你一定是用了像 Amazon DynamoDB 或 Google Cloud Firestore 这样的专有托管数据存储。
也不是,我的整个技术栈都是开源的,而且不绑定任何平台。
那到底是怎么做的?
我把 SQLite、Litestream 和 Docker 组合在了一起。
我的工具叫 LogPaste。它可以让用户为文本文件生成可分享的链接。我在开源的 KVM over IP 设备中使用了它,这样用户就能轻松地把诊断日志分享给我。
分享文本文件本身没什么了不起,但无服务器的数据复制或许算得上。这里有一个演示,展示我如何在两个完全不同的托管平台 Heroku 和 fly.io 之间迁移 LogPaste 应用服务器。期间没有任何数据库服务器,也不需要做数据迁移,但所有数据在不同平台之间都完好保留:
最棒的是,我完全不需要修改应用代码。它只是写入本地的 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 访问云存储桶所需的 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 也无法把数据库复制到云端,因为应用还没跑起来过。
接下来,入口脚本会启动一个 Litestream 进程,它会监视 LogPaste 的 SQLite 数据库,并持续将任何变更同步到云存储:
# Begin replication to S3 in the background.
litestream replicate "${DB_PATH}" "${DB_REPLICA_URL}" &那个末尾的 & 算是个小技巧。它让脚本在后台运行 Litestream 进程,这样我就能在同一个 Docker 容器里同时运行两个常驻进程。Ben Johnson 发布过一个更优雅的方案,但为了演示方便,我这里用的是这个取巧的版本。
入口脚本的最后一步是启动 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,它也很容易与其他 Web 应用集成。这里有一个针对我的演示实例的简易 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
我在开源的 KVM over IP 设备 TinyPilot 中正式使用了 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 成员对本文提供的反馈。
随机一篇博客
评论
登录后参与讨论