大家好,我是 Ben。
如果你一直有在關注 DEV 與我們的開源專案 Forem 的發展,你就會知道我們一直對網站效能近乎執著。很久以前,我在 Codeland 講過一場關於如何讓你的網站快到在日本爆紅的演講,深入探討了邊緣快取的運作原理,以及我們如何把頁面載入時間維持在幾乎瞬間完成。
https://www.youtube.com/watch?app=desktop&v=lGQtUDMStnI
我們的核心理念一直很簡單:盡可能讓架構保持精簡、在邊緣積極快取,然後讓 Rails 單體應用程式(Forem)專注於它最擅長的事情。多年來,Fastly 一直非常出色地替我們處理 HTML 邊緣快取——你大多數的頁面請求甚至不需要碰到我們的 Puma 伺服器,這讓我們的記憶體用量維持在低檔,回應時間也保持在毫秒級。DEV 上所有文件內容的提供,也一直都是靠 Fastly 完成。
不過,邊緣快取靜態 HTML 是個大家都很熟悉的問題,但使用者上傳的媒體則完全是另一回事。
隨著 DEV 成長,我們發現自己被圖片淹沒了。每篇文章的封面、使用者頭像、留言截圖、挑戰活動橫幅,全都是社群成員上傳的高解析度素材。要在全球服務數十億張這類圖片,同時又維持頁面大小精簡,最後把我們帶進了一個無聲的擴充陷阱:糾結的多 CDN 媒體管線、龐大的雲端外傳費用,以及令人咋舌的每月帳單。
以下就是我們如何終結多 CDN 混亂、簡化媒體架構、省下一大筆錢,並透過邊緣腳本搭配 bunny.net 打造出更聰明、更快速圖片傳送管線的故事。
要理解我們為什麼要切換,得先看看我們過去的圖片管線長什麼樣子。
有很長一段時間,我們的媒體堆疊都有點像拼裝車。我們讓不同的 CDN 負責平台的不同部分,另外還有一個負責動態調整尺寸的圖片代理服務,而原始素材則放在雲端儲存空間裡(像是 AWS S3)。
當使用者上傳一張 10MB 的 JPEG 作為文章封面時,我們的 Rails 應用程式不會預先把它加工成數十種不同尺寸;相反地,我們依賴即時圖片轉換。理論上這很棒:瀏覽器請求 image.jpg?width=800,動態圖片最佳化器就會把它縮放、轉成 WebP 或 AVIF,然後直接回傳。
但在實務上,這套架構在大規模運作時的經濟成本與機制都很殘酷,尤其當你把現代網路流量的現實也算進來時:
我們的媒體帳單越來越膨脹,成本高得嚇人,而且我們花太多時間在除錯,想搞清楚為什麼管線不夠聰明,無法平順應對不穩定的流量尖峰。我們需要的是一個速度快、可靠、高度可設定,而且最重要的是,經濟上能長期維持的解決方案。
其實,我自己多年來就一直在各種個人專案中使用 bunny.net。不管是快速架一個 side project,還是測試新概念,我總是會回頭用它,因為它是一個設計得非常好的平台,產品既合理又直覺。它沒有傳統雲端供應商那種厚重、企業導向的臃腫感;相反地,它提供的是乾淨、開發者友善,而且真的能正常運作的體驗。基於這些親身經驗,我知道它是我們可以放心交給 DEV 來擴展的平台。
真正讓我們決定採用的,不只是原始頻寬成本的節省(雖然把頻寬帳單砍到高級企業級 CDN 的一小部分,已經是非常大的勝利)。真正打動我們的是,它們的產品生態系如何透過 Bunny Optimizer 與 Edge Scripting,優雅地解決了我們特定的架構痛點。
Bunny Optimizer 是一個全代管的動態圖片轉換 API。我們可以很容易地整合進來,只要附加簡單的網址查詢參數(例如 ?width=600&height=300&crop=1:1),就能讓 Optimizer 即時處理縮放、裁切與自動壓縮。它會依照瀏覽器的 Accept 標頭,自動協商 WebP 或 AVIF 這類新世代格式,最多可將檔案大小減少 80%,而且不會有肉眼可見的品質損失。
但真正的關鍵、也是我們對抗爬蟲流量的終極武器,是 Perma-Cache。
一般來說,當 CDN 邊緣伺服器把一個不常被存取的圖片變體清掉快取時,下一次請求就必須一路回到來源端(我們的雲端儲存空間)重新抓取並最佳化,進而產生更多外傳費用。Perma-Cache 透過把最佳化後的圖片變體永久複製到 Bunny Storage 來解決這個問題。
一張圖片只要處理過一次,就會永久儲存在邊緣。它再也不需要回打到我們的 AWS 原始端,既能保護後端不受不規則流量影響,也幾乎在一夜之間就消除了我們的雲端儲存外傳費用。
雖然 Bunny Optimizer 給了我們圖片縮放的原始能力,但我們仍需要對圖片如何傳送有更細緻的控制。我們不想把複雜的網址組裝邏輯塞進 Rails 的 view,也不想讓使用者(或機器人)直接下載巨大未處理的原始圖片。
這就是 Edge Scripting 登場的地方。
Edge Scripting 建構在 Deno 與 V8 之上,可以直接在邊緣執行 JavaScript 與 TypeScript,讓我們能寫出輕量、具型別安全的 middleware,並在毫秒內完成執行。它完全取代了自訂圖片代理或複雜 Rails controller 路由的需求。
Images::Optimizer 服務如果你看一下 Forem 的程式碼庫,就會發現我們一直把圖片管線設計成可插拔的。我們不想把 view 硬綁到某一家 CDN 的查詢參數格式。如果某個樣板要顯示文章封面圖片,它會呼叫一個統一的 helper,再委派給我們的 Images::Optimizer 服務:
# app/views/layouts/application.html.erb
<%= Images::Optimizer.call(Settings::General.favicon_url, width: 32) %>
在 app/services/images/optimizer.rb 裡,我們使用簡單的策略模式。Optimizer 類別扮演路由器角色,把標準化參數(像是 width、height、fit、gravity)對應到目前啟用的 CDN 供應商所需要的特定網址格式。
歷史上,Forem 支援像 Fastly、Cloudflare 和 Cloudinary 這類供應商,而新增 bunny.net 非常直覺。以下是我們的 Rails 服務如何處理這種多 CDN 路由:
# app/services/images/optimizer.rb
module Images
class Optimizer
def self.call(url, options = {})
return url if url.blank?
# 根據環境設定選擇供應商策略
case provider
when :bunny
BunnyProvider.call(url, options)
when :cloudflare
CloudflareProvider.call(url, options)
when :fastly
FastlyProvider.call(url, options)
else
url
end
end
def self.provider
ENV.fetch("IMAGE_OPTIMIZATION_PROVIDER", "bunny").to_sym
end
end
end
每個供應商都有自己的網址重寫策略。舉例來說,我們的 bunny.net 供應商只是建立標準查詢字串,讓 Bunny Optimizer 來解析:
# app/services/images/bunny_provider.rb
module Images
class BunnyProvider
def self.call(url, options = {})
uri = URI.parse(url)
query_params = []
query_params << "width=#{options[:width]}" if options[:width]
query_params << "height=#{options[:height]}" if options[:height]
query_params << "crop=#{options[:crop]}" if options[:crop]
query_params << "auto=format"
uri.query = [uri.query, query_params.join("&")].compact.join("&")
uri.to_s
end
end
end
這種解耦的架構對像 Forem 這樣的開源專案非常棒。不同的自架社群只要改變 IMAGE_OPTIMIZATION_PROVIDER 環境變數,就能配置自己想要的 CDN。
不過,雖然 Rails 應用程式會產生這些最佳化後的網址,我們還是遇到了一個有趣的營運挑戰:如果某個樣板忘了傳入 width 參數,或者舊貼文裡有原始的外部網址,該怎麼辦?
如果我們只依賴 Rails 端產生網址,任何 fallback 或未解析的網址都還是會觸發一次沉重、未最佳化的圖片載入。這就是我們的 Edge Scripting 策略派上用場的地方——它在網路邊界上直接充當全域安全網。
有了 Edge Scripting,我們可以在 CDN 層直接攔截圖片請求,並在請求甚至還沒到達最佳化器或儲存空間之前,就套用自訂商業邏輯。
舉例來說,我們希望確保使用者個人檔案頭像和資訊流縮圖永遠不會以超過實際需求的尺寸傳送,不管原始上傳檔案是什麼,也不管請求了什麼查詢參數。如果用戶端請求的是未最佳化的原始頭像網址,我們的邊緣腳本就會自動攔截、檢查情境,並重寫請求,強制套用嚴格的最大寬度與 WebP 壓縮。
以下是一個簡化範例
// 在 bunny.net 邊緣執行的輕量 middleware 腳本
export default async function handleRequest(request: Request) {
const url = new URL(request.url);
// 攔截對我們使用者上傳路徑的請求
if (url.pathname.startsWith('/uploads/')) {
const isAvatar = url.pathname.includes('/avatars/');
const isThumbnail = url.pathname.includes('/thumbnails/');
// 檢查請求是否已經帶有最佳化參數
const hasWidth = url.searchParams.has('width');
if (isAvatar && !hasWidth) {
// 專門將所有頭像請求縮小到最大 150px
url.searchParams.set('width', '150');
url.searchParams.set('height', '150');
url.searchParams.set('crop', '1:1');
} else if (isThumbnail && !hasWidth) {
// 對縮圖強制套用嚴格的手機友善限制
url.searchParams.set('width', '400');
}
// 確保自動新世代格式協商(WebP/AVIF)啟用
url.searchParams.set('auto', 'format');
// 從 bunny.net 的 CDN 管線抓取最佳化後的素材
return fetch(url.toString(), request);
}
return fetch(request);
}
這非常強大,因為它把圖片傳送中「需要思考的運算」完全卸載到邊緣。Rails 應用程式不需要再追蹤響應式圖片斷點,也不需要生成又重又複雜的 markup。我們只要請求邏輯上的素材網址,邊緣腳本就會根據用戶端標頭與情境動態處理剩下的事情。
更棒的是,我們也把這整套流程整合到標準開發工作流程中。我們使用 GitHub Actions,搭配細粒度的 personal access token 來自動管理與部署這些邊緣腳本。當我們想調整最佳化規則,或加入對新版面配置的支援——像是最佳化 billboard 圖片,或調整挑戰頁面的解析度——我們只要 push 一個 commit,CI 跑完之後,全世界的新版邊緣邏輯就會在幾秒內上線。
除了路由與尺寸控制之外,在邊緣執行程式碼還帶來了一個巨大的使用者體驗勝利:能夠優雅地處理遺失或損壞的素材。
在龐大的社群生態系中,例外情況一定會發生。使用者可能刪掉了他們連結的外部圖片、舊的上傳路徑在遷移過程中可能失效,或是某個格式錯誤的請求可能混進來。傳統上,當圖片載入失敗或回傳 404/500 時,瀏覽器會顯示一個突兀又難看的「壞掉圖片」圖示,破壞版面,也讓整個網站看起來像壞掉一樣。
有了 Edge Scripting,我們可以在傳輸途中攔截這些失敗。如果來源端或儲存空間回傳錯誤狀態碼,邊緣腳本就能截住回應,無縫改寫成一張漂亮的自訂預留圖片,上面寫著 「Image not available」,或是符合我們 UI 主題的樣式。
我們不需要在 Rails 應用程式裡的每一個 <img> 標籤都塞進複雜又笨重的 JavaScript 事件監聽器(onError),而是直接在網路層原生處理。應用層完全不必思考 fallback 邏輯,而我們的使用者無論背後發生什麼事,都能看到一致且不破版的視覺體驗。
穩定並最佳化我們的圖片管線只是第一階段。展望 DEV 與 Forem 未來的演進,影片是下一個自然的方向。
影片傳送出了名的複雜——需要自適應位元率串流(HLS/DASH)、多解析度轉碼、專用儲存空間,以及最佳化過的影片播放器。在舊有架構下,這通常代表得再引入另一套分散、昂貴的第三方影片處理器與複雜整合。
考量到我們在其媒體基礎設施上的成功,bunny.net 會是我們思考未來影片策略時的第一選擇。他們的整合式平台理念也直接延伸到影片串流,相關產品同樣符合那種合理、以開發者為先的哲學,和他們的圖片最佳化器如出一轍。因為我們信任他們的基礎設施能穩定擴展,又不會有掠奪性的資料傳輸成本,把我們的邊緣架構延伸到下一代多媒體支援,感覺起來更像是自然演進,而不是一場令人頭痛的基礎設施大改造。
作為開發者,我們常常把注意力放在最佳化資料庫查詢、重構 Ruby 程式碼,或微調伺服器設定上。但有時候,最大的成效其實就在你的網路分頁裡。
外傳費用與肥大的媒體傳送,是成長中平台的一種隱形稅。透過轉向像 bunny.net 這樣原生於邊緣、又對開發者友善的平台,我們得以簡化架構、加快頁面載入,並且在過程中省下一大筆錢。
如果你正在經營一個媒體量很大的平台,或是像 Forem 這樣打造開源社群軟體,請務必好好檢查你的 CDN 帳單、查看雲端儲存外傳量,看看能不能把部分負擔卸載到一個能跟著你一起成長的平台上。你的預算(還有你的使用者)都會感謝你。
祝寫程式愉快 ❤️