從 EC2 上的 Go 搬到 Fly.io:更好玩,每月省 9 美元
最近我把兩個 side project 從 Amazon EC2 執行個體搬到 Fly.io 上。體驗非常好:Fly.io 就是能直接用。它讓我刪掉了大約 500 行 Ansible 腳本和設定檔,每個月還省了 9 美元。
順手也把其中較大的那個專案做了一些簡化:把原本用 CDN 託管靜態檔案的方式,改成用 go:embed 加上 ETag 快取;把 cron 排程改成簡單的背景 goroutine;把設定檔改成環境變數。
兩個應用的架構則維持不變:都是用 Go 的 net/http 伺服器、SQLite 資料庫,加上一些 HTML 樣板與靜態檔案。
我花了大約一小時摸熟 Fly.io 的基本操作並搬移比較簡單的那個專案,較複雜的那個則花了幾個晚上。Fly.io 幫我處理了惱人的反向代理和 SSL 那些瑣事,部署只要 fly deploy 一行指令就能搞定,Fly.io 上還有個不錯的儀表板可以查看運行狀況。
從舊到新
很長一段時間,我都是用一台跑 Amazon Linux 的 EC2 執行個體來同時跑這兩個應用(機型是 t2.micro)。它們都是低流量的網站,這樣跑其實沒什麼問題。但就算有不錯的工具,還是得花不少心力去設定和維護,超出我想投入的程度。
這些都是小型的 Go 網頁應用,就像有人在 Hacker News 上說的,部署 Go 應用簡單到「把執行檔 scp 過去跑起來就行。這在任何 Linux 虛擬機上都不用額外設定就能跑。」
就像我當時回覆的(那時還沒真的搬到 Fly.io):
我現在就是這樣做的。但還有不少額外的設定:
- 安裝並設定 Caddy 來處理 SSL 終止。Caddy 很棒,但還是有些東西要操心,還得搞定 20 行設定檔。
- 設定 systemd 來跑 Caddy 和我的 Go 伺服器。不是什麼高深技術,但我得第一次去搞懂 systemd,還得為每個服務寫一份將近 25 行的設定檔。
- 還要寫腳本在 Caddy 出新版時升級(我當時用的時候它還沒進 apt 套件庫)。
- Ansible 的部署腳本,要 clone 專案、為 Caddy 和 Go 伺服器建立使用者與群組、複製設定檔、加上備份用的 cron 排程(150 行 Ansible YAML)。
看起來用 Fly.io 和 Render 的話,大多數這些東西都不需要,或是不用額外設定就能直接有。
就像我後來提到的,用 Fly.io 跟自己在 EC2 上土炮的差別,有點像 Dropbox 剛推出時,那則有名的 Hacker News 留言所建議的做法跟 Dropbox 本身的差別:
對於 Linux 使用者來說,你已經可以很輕易地自己打造這樣的系統,只要去申請一個 FTP 帳號,用 curlftpfs 把它掛載到本地,然後在掛載的檔案系統上用 SVN 或 CVS 就好。從 Windows 或 Mac 也可以透過內建軟體存取這個 FTP 帳號。
說起來或許「相當簡單」,但對懶惰的開發者來說還是太麻煩,更別說一般非技術使用者了。
所以我一直在找一個能幫我搞定託管、SSL 憑證和部署的簡單託管服務。幾年前我玩過 Heroku,但他們比較貴,而且會把你推向使用他們相對昂貴的託管資料庫(而不是給 SQLite 用的簡單磁碟空間)。看起來現在還是這樣。
後來我又接觸到 Fly.io 和 Render。Render 看起來功能更完整一些(例如有支援 cron 排程),但跟 EC2 比起來省不了什麼錢,所以我就繼續觀望。
Fly.io 看起來更 geek、更偏向命令列操作,很合我的胃口,而且價格低得不可思議:最多三台小型虛擬機免費(我只需要兩台),超過之後一台小的每月只要 2 美元。事實證明,1 顆共享 CPU 加上 256MB 記憶體對 Go 應用來說就很夠用了,就算有一點流量也沒問題(見負載測試)。
我兩個應用都用 SQLite,而 Fly.io 又是全面擁抱 SQLite,所以在這方面也很合拍。他們的技術部落格也寫得非常好。
Simple Lists

我想先在一個幫家人架的小型待辦清單應用上試試 Fly.io(可以參考我寫的關於 Simple Lists 的文章)。它是用 Go 寫的,走的是老派做法:由伺服器端渲染 HTML,用最單純的 GET 和 POST 搭配 HTML 表單,完全沒有 JavaScript。
所以我裝了 flyctl(Fly.io 的 CLI),第一印象很好!我完全沒改程式碼,就試著打了 flyctl launch 看看會怎樣。幾分鐘後,我的應用就已經在 fly.dev 的子網域上跑起來了。竟然這麼簡單……
但還真的就是這麼簡單。這個工具自動產生了 fly.toml 設定檔,自動判斷該怎麼建置我的 Go 應用,還發現我的應用會去讀 PORT 環境變數,就幫我加上了 PORT=8080 的設定。看起來他們是用 Paketo 的「buildpacks」來做這件事——不過我之前沒聽過這個專案。
唯一的 Pursk 是,它預設把 SQLite 資料庫放在虛擬機的暫時磁碟上,所以每次部署,Fly.io 就會把資料庫清掉。
要解決這個問題,Fly.io 有持續性儲存空間(persistent volumes)的概念,所以我用 CLI 建了一個:
$ 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,可以 clone 這個 repo 然後打 flyctl launch 就能自己跑起來。你需要先用 simplelists -genpass 產生密碼雜湊,並設定 SIMPLELISTS_PASSHASH 這個 secret。
Gifty Weddings
Gifty Weddings 是一個婚禮禮物清單網站,幫助新人建立不受特定店家限制的禮物清單。這是一個中型的網頁應用,後端用 Go 和 SQLite,前端用 Elm。首頁長這樣:

要讓 Gifty 在 Fly.io 上跑起來,我做了幾個調整:
- 讓伺服器改從環境變數讀取設定,而不是從設定檔。
- 用
go:embed和fs.FS把 HTML 樣板和靜態檔案打包進 Go 執行檔裡。 - 把原本用 cron 排程的兩個背景任務,改用 goroutine 來跑。
用環境變數管理設定
第一個改動非常小。我原本有個 Config 結構是用 json.Decoder 載入的,現在改成用 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 的網頁伺服器本身就很適合提供靜態檔案,而且我知道可以用 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 bucket。
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 並也用 Email 通知我。
backUpDatabase 函式(一樣,保持極度簡單)是用 os/exec 來執行 sqlite3 客戶端,跑一段 .backup <檔名> 的指令,然後把結果上傳到私有的 S3 bucket。它還會刪掉超過最新 10 份以外的舊備份。
負載測試
我已經把舊的 EC2 伺服器關掉了,所以很可惜沒辦法做搬家前後的對比。不過我主要只是想確認新伺服器夠快。
從我這裡(紐西蘭)感覺 Fly.io 上的網站變快了,但我想主要是因為我把它架在 Fly.io 的 syd 區域(雪梨,就在對岸),而以前是架在 AWS 的 us-west-2 區域(奧勒岡),離我和大多數客戶都遠得多。
此外,我的靜態檔案現在跟主站在同一個網域和伺服器上,也就是說它們也是從雪梨而不是美國傳來,而且瀏覽器可能可以重用已建立的連線,也不用再為另一個主機做 TLS 握手。
下圖是初始 HTML 和後續靜態檔案(未快取)的網路時間軸截圖。這是首頁,也是最重的頁面,因為包含不少圖片,但我很自豪它總共不到 900KB,而且在一秒內就完全載入。

我用 HTTP 負載測試工具 Vegeta 跑了一個小測試,打四個網址:三個會渲染 HTML 樣板的頁面(首頁、一個會做兩次 SQL 查詢的禮物清單頁面,以及聯絡頁面),還有一張中等大小的圖片。
我讓「attack」跑 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 的 VM 才開始吃力:平均延遲升到 127ms,p99 升到 1.3 秒,10,000 個請求中有 2 個錯誤,測試期間瀏覽網站也感覺變慢了。
所以最小的共享 1 核心 Fly.io VM 可以毫無問題地處理每秒 500 個請求。我對這結果很滿意!我知道這不算是什麼嚴謹的科學測試,但對我的需求來說已經夠好了。
結論
我用 Fly.io 來託管 side project 才幾個星期,但到目前為止對他們的產品非常滿意。能把那 500 行 Ansible 腳本、systemd 單元檔和 Caddy 設定檔刪掉,真的很痛快。
終於能把 EC2 執行個體關掉,把 AWS 帳單從每月 9 美元降到每月大約 10 美分(我還是會用 S3 來放使用者上傳的圖片和備份),也讓我會心一笑。我對 EC2 沒有意見,某些用途我還是會用它,但對小型網頁應用來說,Fly.io 似乎非常適合。
聽起來大概會以為 Fly.io 有付錢叫我這樣吹捧,但相信我,完全沒有。我只是個熱情的 geek,喜歡他們的產品、他們對超小型 VM 的支援,以及他們對 SQLite 的熱愛。更別說他們的價格了!
相關討論請見 Hacker News 和 r/golang。
隨機一篇部落格
留言
登入後參與討論