从 EC2 上的 Go 迁移到 Fly.io:更有趣,每月省 9 美元
最近我把两个业余项目从 Amazon EC2 实例迁移到了 Fly.io。体验非常好:Fly.io 开箱即用。这让我得以删掉约 500 行 Ansible 脚本和配置文件,每月还省下了 9 美元。
顺手我还对其中较大的那个项目做了一些简化:把静态文件的托管从 CDN 换成了带 ETag 缓存的 go:embed,把定时任务从 cron 换成了简单的后台 goroutine,把配置文件换成了环境变量。
两个应用的整体架构保持不变:都是 Go 的 net/http 服务、SQLite 数据库,再加上一些 HTML 模板和静态文件。
我花了大约一小时摸清 Fly.io 的基本用法并迁移了较简单的那个项目,又花了几个晚上迁移了较复杂的那个。Fly.io 帮我处理了那些烦人的反向代理和 SSL 事务,部署简单到只需一句 fly deploy,而且 Fly.io 上还有一个不错的仪表盘可以查看运行状态。
从旧到新
很长一段时间里,我用一台运行 Amazon Linux 的 EC2 实例(机型 t2.micro)来托管这两个应用。它们都是低流量的小网站,这样跑也没什么问题。但即使有好用的工具,所需的配置和日常维护还是比我愿意投入的要多。
这两个都是小型的 Go Web 应用,正如有人在 Hacker News 上指出的,部署 Go 应用简单到“把可执行文件 scp 过去然后运行就行。这在任何 Linux 虚拟机上都能跑,无需任何配置。”
我当时这样回复道(那还是在真正切换到 Fly.io 之前):
我现在就是这么做的。但还有一大堆别的配置:
- 安装并配置 Caddy 来处理 SSL 终止。Caddy 很棒,但还是有需要操心的事,得琢磨 20 行配置。
- 配置 systemd 来运行 Caddy 和我的 Go 服务。倒也不难,但我得第一次去搞懂 systemd,还要为每个服务写一份约 25 行的配置文件。
- 编写脚本以便在 Caddy 发布新版本时升级(我当时弄的时候它还没进 apt 仓库)。
- 用 Ansible 编写部署脚本,用来克隆仓库、为 Caddy 和 Go 服务创建用户和用户组、拷贝配置文件、添加用于备份的 cron 任务(150 行 Ansible YAML)。
看起来用 Fly.io 和 Render 的话,大部分这些工作都不需要,或者无需额外配置就能直接获得。
我在后面还提到,Fly.io 和自己用 EC2 实例折腾的区别,有点像 Dropbox 和当年 Dropbox 刚发布时那条著名的 Hacker News 评论里建议的方案之间的区别:
对于 Linux 用户来说,你已经可以相当轻松地自己搭出这样一套系统:搞个 FTP 账号,用 curlftpfs 把它挂载到本地,然后在挂载的文件系统上用 SVN 或 CVS 就行了。在 Windows 或 Mac 上,这个 FTP 账号也可以通过系统自带的软件访问。
说是“相当轻松”,但对懒惰的开发者来说还是太麻烦,更别说非技术用户了。
所以我一直在寻找一个能搞定托管、SSL 证书和部署的简单托管服务。几年前我试过 Heroku,但更贵,而且他们会引导你使用相对昂贵的托管数据库(而不是给 SQLite 用的简单磁盘卷)。看起来现在依然如此。
后来我又接触到了 Fly.io 和 Render。Render 看起来功能更全一些(比如支持 cron 定时任务),但相比 EC2 并不能帮我省钱,所以我继续找。
Fly.io 看起来更极客、更偏命令行,这很合我胃口,而且价格也低得离谱:最多 3 台小型虚拟机免费(我只需要 2 台),超出后每台小型虚拟机每月 2 美元。事实证明,1 个共享 CPU 加 256MB 内存对于 Go 应用来说已经绰绰有余,即使有一定流量也不在话下(见负载测试)。
我的两个应用都用 SQLite,而 Fly.io 全面拥抱 SQLite,所以在这方面也很契合。他们还有一个非常出色的技术博客。
Simple Lists

我想先在给家人用的那个极简待办清单小应用上试试 Fly.io(可参看我那篇关于 Simple Lists 的文章)。它是用 Go 写的,采用的是老派做法:由服务端渲染 HTML,用最普通的 HTML 表单做 GET 和 POST,没有任何 JavaScript。
于是我安装了 flyctl(Fly.io 的命令行工具),第一印象很不错!我完全没改源代码,直接输入 flyctl launch 试试会发生什么。几分钟后,我的应用就在一个 fly.dev 子域名上跑起来了。难道真能这么简单……
但事实还真就是这么简单。这个工具自动生成了 fly.toml 配置文件,自动识别出如何构建我的 Go 应用,还发现应用会读取 PORT 环境变量,于是加上了 PORT=8080 这一项。看起来他们是用 Paketo 的“构建包”来实现的——不过我之前并不了解这个项目。
唯一的问题是,它把 SQLite 数据库放在了虚拟机的临时磁盘上,所以每次部署时 Fly.io 都会把数据库清空。
要解决这个问题,Fly.io 提供了持久化卷的概念,于是我用命令行创建了一个:
$ flyctl volumes create simplelists_data --size=1
--size=1 意味着创建一个 1GB 的卷。这对存待办事项来说绰绰有余了……但显然这是他们允许的最小规格。Fly.io 免费提供 3GB,超出后按每月每 GB 0.15 美元计费。存储很便宜!
然后我在 fly.toml 里加上了下面这三行(最终版本基本上就是 flyctl 生成的文件再加上这几行):
[mounts]
source = "simplelists_data"
destination = "/data"
然后一切就正常工作了。Fly.io 会对卷做每日快照,这对这个场景来说已经算是足够的“备份”了。不知为何快照有大约 60MB,而我实际只用了约 100KB 的磁盘空间,不过算了——那是 Fly.io 需要操心的事!
如果你也想自己部署一个 Simple Lists,可以克隆这个仓库然后输入 flyctl launch 自行运行。你需要先用 simplelists -genpass 生成密码哈希,并设置 SIMPLELISTS_PASSHASH 这个密钥。
Gifty Weddings
Gifty Weddings 是一个婚礼礼物清单网站,帮助新人创建不绑定特定商店的礼物清单。这是一个中等规模的 Web 应用,后端用 Go 和 SQLite,前端用 Elm。首页长这样:

要让 Gifty 在 Fly.io 上跑起来,我做了几处改动:
- 让服务端通过环境变量而非配置文件来读取配置项。
- 使用
go:embed和fs.FS将 HTML 模板和静态文件嵌入 Go 二进制文件中。 - 用 goroutine 来运行两个后台任务,以替代 cron 定时任务。
配置改用环境变量
第一处改动非常小。我原本有一个用 json.Decoder 加载的 Config 结构体,现在改成了用 os.Getenv。这个项目相对较小,不需要 Viper 这类花哨的库——Go 标准库就足够了。
大致是这样的:
func main() {
cfg := Config{
AWSKey: os.Getenv("GIFTY_AWS_KEY"),
AWSSecret: os.Getenv("GIFTY_AWS_SECRET"),
ListenAddress: getEnvOrDefault("GIFTY_LISTEN_ADDRESS", ":8080"),
DatabasePath: getEnvOrDefault("GIFTY_DATABASE_PATH", "/data/gifty.sqlite"),
...
}
// ... use cfg ...
}
func getEnvOrDefault(name, defaultValue string) string {
value, ok := os.LookupEnv(name)
if !ok {
value = defaultValue
}
return value
}
静态文件托管
到这里我面临一个抉择。我之前一直主张用 Amazon CloudFront(后端为 S3)这类 CDN 来托管静态文件。我甚至写过一个叫 cdnupload 的 Python 工具,会把网站的静态文件以带内容哈希的文件名上传到 S3,既能实现良好的缓存,又能避免版本问题。就在几周前,Gifty 也还是这么做的。
那套方案对于更大、分布式的应用来说依然不错——而且我也挺喜欢 Fly.io 在分布式应用方面的探索——但对这个小网站来说就有点小题大做了。Go 的 Web 服务器处理静态文件完全够用,而且我知道可以用 Last-Modified 或 ETag 头来解决缓存问题。
于是我告别了 cdnupload,全面转向 go:embed。这个特性在 Go 1.16 中加入:它是一种内置方式,可以让 Go 编译器把文件嵌入到二进制文件中,并在运行时通过 fs.FS 文件系统接口来访问。
大致是这样的:
// This "go:embed" directive tells Go to embed static/* (recursively),
// and make it accessible as the staticFS variable.
//go:embed static/*
var staticFS embed.FS
func main() {
// Tell the HTTP server to serve staticFS at /static/*
hashFS := hashfs.NewFS(staticFS)
http.Handle("/static/", hashfs.FileServer(hashFS))
// This function is passed to the HTML templating engine,
// allowing templates to generate paths to static files.
// In templates, it's used like this:
//
// <link rel="stylesheet" href="{{static "styles/main.css"}}">
funcMap := template.FuncMap{
"static": func(path string) string {
return "/" + hashFS.HashName("static/"+path)
},
}
// ...
}
虽然 Go 的 http.FileServer 支持 Last-Modified 头,但遗憾的是 go:embed 并不提供文件的修改时间。也不支持 ETag。
这有点烦人,我正准备自己写一个 ETag 包装器,结果发现了 Ben Johnson(就在 Fly.io 工作!)写的一个只有 200 行的库 hashfs。这个库封装了 fs.FS 文件系统,并提供一个会生成 ETag 头的 http.Handler,让浏览器能够有效缓存。
后台任务
在切换到 Fly.io 之前,我有两个 cron 定时任务:
- 一个任务是在客户婚期过去几天后发送“婚后”邮件。
- 另一个任务是每天使用 SQLite 客户端的
.backup命令备份数据库,并将结果上传到 S3 存储桶。
Fly.io 并未将 cron 定时任务作为内置功能提供,所以我有几个选项可选:
- 使用 Fly.io 运行一个服务管理器,同时启动 Gifty 和 cron 任务。[更新:正如 Ben Johnson 指出的,如果我使用 Dockerfile,就可以
apt install cron然后像平常一样使用 cron。] - 在 Fly.io 上单独启动一个 cron 应用,或使用 Fly Machines。
- 在 Go 服务中用简单的 goroutine 来执行后台任务。
选项 1 会抵消使用 Fly.io 本来带来的部分简洁性:我得创建一个 Dockerfile 并配置各种东西,而这正是我想避免的。
选项 2 更干净一些,但要让 cron 应用连接主应用以访问数据库可能会很麻烦(卷能在不同应用间共享吗?我不确定)。而且 Fly Machines 又是需要学习的新东西(又该由什么来按时间间隔启动它们呢?)。
选项 3 初看有点脏,但我喜欢它的简单!我无需学习任何新东西,而且我知道自己不会运行多个应用实例,所以最终选了这个方案。
在 Go 中,你可以用 goroutine 和 time.Ticker 用几行代码启动一个定时后台任务。我大致是这样做的(在 main 中):
// Start goroutine to send post-wedding emails every so often.
go func() {
ticker := time.NewTicker(time.Hour)
for {
<-ticker.C
err := sendPostWeddingEmails(config, emailRenderer, dbModel)
if err != nil {
emailAdmin("error sending post-wedding email: %v", err)
}
}
}()
// Start goroutine to check if database needs backing up every so often.
// Ticks every 6 hours, but backUpDatabase skips if there's already one today.
go func() {
ticker := time.NewTicker(6 * time.Hour)
for {
<-ticker.C
err := backUpDatabase(config, s3Client)
if err != nil {
emailAdmin("error backing up database: %v", err)
}
}
}()
这很简单,但效果很好。重试没有做特殊处理——我就利用了下一次 tick 时会再尝试一次的特性(反正也很少出错!)。
是的,我知道——这些 goroutine 在服务器停止时不会优雅地关闭。但即便在任务执行期间服务器退出的小概率情况下,也不会造成什么影响。我在实际代码中多做的一件事是捕获 panic 并也通过邮件发给我。
backUpDatabase 函数(同样,坚持极简原则)使用 os/exec 来运行 sqlite3 客户端,执行 .backup <filename> 脚本,然后将结果上传到私有的 S3 存储桶。它还会删除超过最新 10 份之外的旧备份。
负载测试
我已经把旧的 EC2 服务器下线了,所以遗憾的是无法做前后对比。不过,我主要想测试新服务器是否足够快。
从我这里(新西兰)访问,Fly.io 上的站点感觉更快了,但我觉得这主要是因为我把它托管在了 Fly.io 的 syd 区域(悉尼,就在对岸),而之前是托管在 AWS 的 us-west-2 区域(俄勒冈),离我和我的大多数客户都要远得多。
此外,我的静态文件现在托管在同一个域名和同一台服务器上,这意味着它们也来自悉尼而非美国,而且浏览器或许可以复用已建立的连接,也无需为另一个主机再做 TLS 握手。
下面是初始 HTML 及后续静态文件(未缓存)的网络时间线截图。这来自首页,它是包含多张图片的最重页面,但我很自豪的是它总计不到 900KB,且在一秒内就能完全加载。

我使用 HTTP 负载测试工具 Vegeta 做了一个小测试,压测了四个 URL:三个渲染 HTML 模板的页面(首页、一个会执行两次 SQL 查询的清单页,以及联系页面)和一张中等大小的图片。
我让“攻击”持续 10 秒。默认速率为每秒 50 个请求,但我也试了 500 和 1000。结果如下:
$ cat urls.txt | vegeta attack -duration=10s | vegeta report
Requests [total, rate, throughput] 500, 50.10, 49.84
Duration [total, attack, wait] 10.032s, 9.98s, 51.434ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.805ms, 50.004ms, 45.411ms, 53.643ms, 58.801ms, 146.6ms, 216.559ms
Bytes In [total, mean] 10757125, 21514.25
Bytes Out [total, mean] 0, 0.00
Success [ratio] 100.00%
Status Codes [code:count] 200:500
Error Set:
$ cat urls.txt | vegeta attack -duration=10s -rate=500/s | vegeta report
Requests [total, rate, throughput] 5000, 500.08, 497.69
Duration [total, attack, wait] 10.046s, 9.998s, 47.869ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.615ms, 61.354ms, 49.472ms, 72.032ms, 117.76ms, 304.653ms, 1.177s
Bytes In [total, mean] 107571250, 21514.25
Bytes Out [total, mean] 0, 0.00
Success [ratio] 100.00%
Status Codes [code:count] 200:5000
Error Set:
$ cat urls.txt | vegeta attack -duration=10s -rate=1000/s | vegeta report
Requests [total, rate, throughput] 10000, 1000.11, 994.86
Duration [total, attack, wait] 10.05s, 9.999s, 50.801ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.876ms, 126.907ms, 60.591ms, 254.24ms, 419.47ms, 1.294s, 3.508s
Bytes In [total, mean] 215062995, 21506.30
Bytes Out [total, mean] 0, 0.00
Success [ratio] 99.98%
Status Codes [code:count] 0:2 200:9998
Error Set:
Get "https://giftyweddings.com/": ... connection reset by peer
Get "https://giftyweddings.com/static/images/gifts-b80...38f.jpg": ... connection reset by peer
即使把速率提高到每秒 500 个请求,它也处理得很好,浏览网站依然很快,尽管平均延迟从 50ms 上升到 61ms,99 分位从 147ms 上升到 305ms。
只有当我把速率调到每秒 1000 个请求时,Fly.io 的虚拟机才开始吃力:平均延迟升至 127ms,p99 升至 1.3 秒,10000 个请求中有 2 个出错,测试期间浏览网站感觉有些卡顿。
所以最小的 1 核共享型 Fly.io 虚拟机可以毫无问题地处理每秒 500 个请求。我对此很满意!我知道这算不上严格的科学测试,但对我在这里的目的来说已经足够了。
结论
我用 Fly.io 托管这些业余项目才几周,但目前对他们的产品非常满意。能删掉那 500 行 Ansible 脚本、systemd 单元文件和 Caddy 配置文件,让我很开心。
终于关掉 EC2 实例、把 AWS 账单从每月 9 美元降到约 10 美分(我仍用 S3 存储用户上传的图片和备份),也让我会心一笑。我对 EC2 并无偏见,某些场景下我还会再用它,但对于小型 Web 应用来说,Fly.io 似乎非常合适。
听起来也许像 Fly.io 付费让我这么吹捧,但相信我,并没有。我只是一个热情的极客,喜欢他们的产品、喜欢他们对超小型虚拟机的支持,也喜欢他们对 SQLite 的热爱。更不用说他们的定价了!
相关讨论请见 Hacker News 和 r/golang。
随机一篇博客
评论
登录后参与讨论