How Litestream Eliminated My Database Server for $0.03/month

Michael Lynch

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

原文由 Michael Lynch 发布,订阅该博客

先来猜个谜。我的 Web 应用把所有数据都存在 SQL 数据库里。我可以随时把它整个拆掉,把代码部署到另一个托管平台,应用依然能提供完全相同的数据。而我在生产环境中运行这个应用的成本是每月 0.03 美元。

这是怎么实现的?

这还不简单,你肯定在别处跑了一个独立的数据库服务器,用来保存应用的所有状态。

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

哦,那你一定是用了像 Amazon DynamoDBGoogle Cloud Firestore 这样的专有托管数据存储。

也不是,我的整个技术栈都是开源的,而且不绑定任何平台。

那到底是怎么做的?

我把 SQLiteLitestreamDocker 组合在了一起。

我的工具叫 LogPaste。它可以让用户为文本文件生成可分享的链接。我在开源的 KVM over IP 设备中使用了它,这样用户就能轻松地把诊断日志分享给我。

分享文本文件本身没什么了不起,但无服务器的数据复制或许算得上。这里有一个演示,展示我如何在两个完全不同的托管平台 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 数据库的作者 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

我在开源的 KVM over IP 设备 TinyPilot 中正式使用了 LogPaste。由于用户是在自己拥有的设备上运行我的软件,当他们反馈问题时,我无法看到任何诊断信息。LogPaste 为用户提供了一种便捷的方式,让他们可以把日志分享给我。

TinyPilot 使用 LogPaste 让用户为调试日志生成可分享的链接。

过去几个月里,LogPaste 承载了 TinyPilot 所有的调试日志,运行一直很稳定。数据复制的成本也确实只有每月 0.03 美元:

AWS 账单截图,显示 S3 费用为 0.03 美元,数据传输费用为 0.00 美元

当然,我这个用例算是比较轻量的。每天只有少数用户会上传日志,所以在更重的负载下,这套方案可能会遇到一些痛点。

另外需要注意的是,Litestream 无法解决多个数据库写入之间的冲突,因此每个数据库只能有一个拥有写入权限的应用服务器。

尽管如此,我对 Litestream 的表现依然非常满意,也很期待在更多场景中使用它。

自行托管 LogPaste

如果你想自行托管我的 LogPaste 应用,部署起来很容易。你甚至可以自定义首页上的文字,让它显示你自己产品的名称,而不是“LogPaste”。

例如,这是 TinyPilot 的版本:

TinyPilot 的 LogPaste 实例截图

TinyPilot 的 LogPaste 实例包含了自定义品牌,且无需任何代码改动

我为几个不同的平台编写了部署说明:

平台说明
fly.io免费套餐最多可运行三个常驻实例,并包含 SSL 证书
Amazon LightSail每个实例每月 7 美元,包含 SSL 证书
Heroku免费套餐可运行无限个按需实例,自定义域名的 SSL 证书每月 7 美元

延伸阅读


架构图由 Loraine Yow 绘制。

感谢 Ben Johnson 在 Litestream 上的工作以及对本文早期版本的审阅。感谢 Blogging for Devs Community 成员对本文提供的反馈。

本文章由 muse-spark-1.2-contributor 进行翻译

评论