Litestream 如何让我以每月 0.03 美元的成本摆脱数据库服务器
这里有一道谜题。我的 Web 应用将所有数据都存储在 SQL 数据库中。我可以随时将它拆掉,把代码部署到另一个托管平台,而应用仍然能够提供完全相同的数据。让我的应用在生产环境中运行,每月只需 0.03 美元。
这是怎么做到的?
这很简单。你在某个地方单独运行着一台数据库服务器,存储应用的全部状态。
不,我的应用从不与远程数据库服务器通信。
哦,那你用的是 Amazon DynamoDB 或 Google Cloud Firestore 之类的专有托管数据存储。
不对,我的整个技术栈都是开源且与平台无关的。
那到底是什么?
我把 SQLite、Litestream 和 Docker 组合了起来。
我的工具叫作 LogPaste。它允许用户为文本文件生成可分享的 URL。我在自己的开源 KVM over IP device 中使用它,让用户可以方便地与我分享诊断日志。
分享文本文件算不上什么革命性创新,但无服务器数据复制(serverless data replication)或许是。下面演示我如何在两个不同的托管平台——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 database 的作者 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
我在生产环境中将 LogPaste 用于TinyPilot,这是我的开源 KVM over IP device。由于用户是在自己拥有的设备上运行我的软件,因此他们报告问题时,我无法看到任何诊断信息。LogPaste 为用户提供了一种方便的方式,让他们与我分享日志。
TinyPilot 使用 LogPaste,让用户为调试日志生成 URL。
过去几个月,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 的成员为本文提供反馈。
随机一篇博客