我們推出一個以 Astro 建置的雙語(法語/阿拉伯語)市集網站 — 靜態輸出、數百個地點與服務頁面,以及數千個專業人士檔案頁面,每個頁面都從 Firebase Storage 拉取一張照片。沒有什麼特別的。直到有一天,生產環境建置突然... 壞掉了。在本地開發時沒有任何警告、沒有失敗的測試、也沒有 lint 錯誤。npm run build 以非零退出碼結束,堆疊追蹤指向 astro:assets。
深入調查後發現原因:其中一張個人檔案照片的 URL 已經失效。Firebase Storage 的下載權杖已輪替,因此原本能回傳 JPEG 的請求現在回傳 403。就是這樣。一個失效的 URL,在數千個之中,卻讓整個建置全部停擺。
這讓我感到意外,因為我原本以為 Astro 的圖片管線遇到壞掉的遠端圖片時,會以「軟性失敗」的方式處理 — 記錄警告、跳過它,然後繼續。但事實並非如此。以下說明原因,以及我們採取的對策。
Astro 的 元件以及底層的 getImage() 函式,都會交由 Image Service 處理,其工作是對圖片進行 transform() — 依據你的設定進行調整大小、轉換格式等。這是你可以掛鉤並自訂的部分。
問題在於 transform() 收到的圖片已經被抓取完畢,以原始緩衝區的形式存在。實際對遠端 src 發出網路請求的步驟,發生在更早的 Astro 內部 loadRemoteImage 階段 — 而這並非公開、可插拔的 Image Service API 的一部分。因此,你無法在「這裡有一個遠端 URL」與「這裡有已抓取的緩衝區」之間,插入受支援的鉤子來攔截失敗的請求並決定如何處理。
所以從框架的角度來看,遠端圖片出現 403 或逾時,並不是「優雅處理」的狀況 — 而是在靜態生成期間的致命錯誤。即使你的資料集中只有一個圖片 URL 失效,整個建置也會停止。
兩分鐘快速解法(以及為什麼不夠)
最快的解決方式很明顯:把 換成普通的 標籤。這樣可以完全跳過 Astro 在建置時的抓取與轉換步驟,因為普通的 只會原封不動地輸出 URL,讓瀏覽器在執行階段處理。
這立即讓我們的部署恢復正常,但這只是權宜之計,並非真正的修復 — 你會失去自動調整大小、格式轉換(WebP/AVIF),以及回應式 srcset 處理,而這些正是使用 的主要原因。作為臨時措施尚可,但不適合長期使用。
真正的修復:建置前檢查,而非建置中檢查
真正的問題不在於 Astro 處理失敗的方式 — 而是我們把尚未驗證過的 URL 交給 Astro。因此,我們把驗證提前到建置前步驟,在 Astro 接觸圖片之前執行。
這個腳本很簡單:收集所有頁面會參照的遠端圖片 URL,對每個 URL 發出 HEAD 請求(含逾時設定),然後將失敗的 URL 寫入 JSON 清單。
js
// scripts/validate-image-urls.mjs
import fs from "node:fs";
const TIMEOUT_MS = 8000;
const CONCURRENCY = 10;
async function checkUrl(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
return res.ok;
} catch {
return false; // 逾時、DNS 失敗、連線重設 — 全部視為「壞掉」
} finally {
clearTimeout(timer);
}
}
比程式碼本身更重要的兩個設計決策:
單一 URL 採取「關閉優先」,整體採取「開啟優先」。如果單一 URL 逾時或出錯,就視為壞掉 — 這是單一圖片的安全預設值。但如果一次執行中有 70% 的 URL 都失敗,那很可能不是「大部分圖片在一夜之間失效」,而是檢查器本身出了問題 — CI 執行器離線、DNS 異常、或速率限制。在這種情況下,腳本會保留現有的清單,而不是把網站上的所有照片都清空。驗證步驟中的部分網路故障,不應該影響到你的生產內容。
透過 prebuild 掛鉤,而非 Astro 整合。我們透過 npm 的生命週期(package.json 中的「prebuild」: 「node scripts/validate-image-urls.mjs」)來串接,這會在 build 之前自動執行。我們刻意沒有把它寫成 Astro 整合鉤子 — 這樣更簡單、完全與 Astro 內部建置順序解耦,而且在未來框架遷移時也不會受到影響。
在元件端,只需要一行檢查:
astro
import { isBadImageUrl } from "../utils/badImageUrls";
const showFallback = isBadImageUrl(profile.thumbnail);
{showFallback
? /* 改為渲染首字母頭像 */ null
: /* 正常渲染使用 profile.thumbnail 作為來源的優化 Image 元件 */ null}
已知失效的 URL 會顯示優雅的備援方案(首字母頭像),而不會傳到 。其他所有圖片則正常走完優化管線。
這在大規模環境下真的有效嗎?
第一次實際的生產執行中,靠著並行限制,在幾秒鐘內檢查了數千個獨特的圖片 URL,而且完全達到預期效果:標記出少數真正失效的 URL,其餘則保持原狀,讓建置不再依賴網路上每一個圖片主機的可用性。
在推出這個方案時,有一個小習慣帶來了幫助:當我把緊急的 備援方案還原回 後,我使用了 git 歷史比對(git show HEAD:path/to/file),而不是依賴我對原始屬性的記憶。這是低成本的保險 — 任何時候在時間壓力下撤銷緊急修復時,都值得這樣做。
結論
如果你使用 Astro 的 或 getImage() 搭配你無法完全控制的遠端來源 — 使用者上傳、CMS、雲端儲存 — 不要假設壞掉的 URL 會優雅失敗。它不會,設計上就是如此,因為抓取發生在你無法掛鉤的管線階段。請在建置開始前驗證,而不是在建置中驗證。
我們在 bricoleapp.com 上執行這個方案,這是一個服務摩洛哥與埃及的雙語家政服務市集 — 如果有任何部分對你有幫助,我們很樂意深入討論。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.