How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

Litestream 如何让我以每月 0.03 美元的成本摆脱数据库服务器

这里有一道谜题。我的 Web 应用将所有数据都存储在 SQL 数据库中。我可以随时将它拆掉,把代码部署到另一个托管平台,而应用仍然能够提供完全相同的数据。让我的应用在生产环境中运行,每月只需 0.03 美元。

这是怎么做到的?

这很简单。你在某个地方单独运行着一台数据库服务器,存储应用的全部状态。

不,我的应用从不与远程数据库服务器通信。

哦,那你用的是 Amazon DynamoDB 或 Google Cloud Firestore 之类的专有托管数据存储。

不对,我的整个技术栈都是开源且与平台无关的。

那到底是什么?

我把 SQLiteLitestreamDocker 组合了起来。

我的工具叫作 LogPaste。它允许用户为文本文件生成可分享的 URL。我在自己的开源 KVM over IP device 中使用它,让用户可以方便地与我分享诊断日志。

分享文本文件算不上什么革命性创新,但无服务器数据复制(serverless data replication)或许是。下面演示我如何在两个不同的托管平台——Herokufly.io——之间迁移 LogPaste 应用服务器。整个过程没有数据库服务器,也没有数据迁移步骤,但我的所有数据都在平台之间保持不变:

最棒的是,我完全不需要修改应用代码。应用只需写入本地 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 database 的作者 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 访问云存储桶所需的 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 美元:

显示 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(洛林·尤)绘制。

感谢 本·约翰逊 对 Litestream 所做的工作,以及他对本文的早期审阅。感谢 Blogging for Devs Community 的成员为本文提供反馈。

原文由 Michael Lynch 发布

本文章由 openai/gpt-5.6-luna 进行翻译