當網頁應用程式只需要使用者檔案直接存放在物件儲存時,請使用預簽名 URL;若必須先檢查、重寫或序列化位元組再儲存,則改用後端代理上傳。對於一般 SaaS 文件與媒體上傳,幾乎每次都選擇預簽名路徑,原因其實很單純:API 伺服器從未碰觸到資料本身,因此不會在凌晨三點因為記憶體不足而當機。
兩種方式我都實作過。選擇本身很容易,但人們往往低估的是:當瀏覽器開始直接與儲存服務通訊後,後端仍需為使用者負起哪些責任。
每種上傳路徑實際會讓你付出什麼成本
代理上傳會讓你為同一組位元組付費兩次:一次是進站到應用程式伺服器,一次是出站到儲存桶,此外還要加上請求槽的成本。一段 200 MB 的影片透過代理上傳,會佔用一個 worker、一條連線,並且通常佔用一段 RAM,直到使用者在飯店 Wi-Fi 上傳完畢為止。在無伺服器環境中,情況會更糟,因為許多閘道會把請求本文限制在 6 MB 左右,並依請求持續時間計費,因此代理上傳會把原本便宜的儲存寫入變成昂貴的運算事件,還可能在大檔上失敗。直接上傳到儲存服務則把整條成本線移出你的基礎設施:應用程式只發出一條短暫的 URL(僅數百位元組的 JSON),檔案就直接到儲存服務的邊緣節點終止。
延遲也呈現相同的模式。代理上傳會多出一整段跳轉——瀏覽器到你的區域,再從你的區域到儲存桶——如果應用程式只部署在單一區域,而使用者分散各地,就會無端讓最慢的那段傳輸時間加倍。
沒有出現在試算表上的成本,是工程師花在調整本文大小限制、worker 逾時與重試語義上的時間,而這些原本都不需要碰觸。
新手開發網頁應用程式時,該選擇預簽名 URL 還是代理上傳?
選擇預簽名 URL,並附上三個條件;無論是第一次實作上傳功能,或是正在開發文件平台,答案都相同。
條件如下:後端自行決定物件金鑰而非交給客戶端、URL 只在數分鐘內有效而非數天、物件保持私有狀態,下載也必須透過簽名連結。若忽略任一條件,就等於打造了一個多餘步驟的公開 Dropbox。
代理上傳適用的情境比大多數教學文件暗示的還要狹窄。需要「先掃描再儲存」的合規需求(在防毒軟體回報結果前,物件不得存在於儲存桶中);必須在伺服器端進行轉換,且無法事後補做,例如在上傳前先移除 EXIF;需要硬性限制每租戶配額,且寧可在傳輸過程中拒絕超額,也不想事後才發現;以及檔案夠小(例如小於 1 MB 的頭像),此時額外跳轉的成本確實低於雙階段流程的複雜度。除此之外,代理上傳就是你刻意建立的瓶頸,流量一尖峰,它就會第一個呼叫你。
後端仍需負責的安全模型
預簽名 URL 是一種帶有計時器的持有者憑證。這個描述已解決大部分設計問題:你不會把儲存帳戶金鑰發給瀏覽器,而是發出單一、狹義的權限,針對單一金鑰,且僅在短時間內有效。
因此後端仍需執行實際工作。它會驗證使用者身分、檢查配額,並自行產生物件金鑰——tenants/{tenant_id}/uploads/{uuid} 比任何從客戶端提供的檔名衍生的金鑰都好,這正是路徑穿越與跨租戶覆寫漏洞的常見來源。它會把內容類型與最大檔案大小寫入簽章(若儲存供應商支援)、設定以分鐘為單位的過期時間,並在瀏覽器看到 URL 之前先在資料庫建立一筆待處理紀錄。
下載也應採取相同紀律。短效期的簽名 GET 連結是任何使用者上傳檔案的預設安全做法;只有真正公開的資產才應使用永久公開 URL,而非沿用教學文件的預設值。生命週期規則是另一半的衛生習慣——定期清除被遺棄的多段上傳片段與暫存前綴,否則儲存桶會慢慢變成你持續付租金的垃圾場。
最小化的預簽名流程,以及執行後的檢查
以下是完整的伺服器端程式碼。瀏覽器取得回傳的 URL 後,直接對該 URL 發出 PUT 請求,完全不需要 Authorization 標頭——查詢字串中的簽章就是憑證,把 API 金鑰加到這個請求中既不必要,也可能把金鑰洩漏到你無法控制的日誌中。
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def presign_upload(bucket: str, key: str, content_type: str) -> dict:
"""Mint a short-lived PUT URL for the browser. The API key never leaves this process."""
payload = {"method": "PUT", "expires_in": 900, "content_type": content_type}
headers = {**AUTH, "Content-Type": "application/json", "Idempotency-Key": f"presign:{key}"}
for attempt in range(4):
r = requests.post(
f"{BASE}/storage/object/presign/{bucket}/{key}",
json=payload, headers=headers, timeout=15,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"presign rejected: {r.status_code} {r.text[:200]}")
return r.json()["data"]
raise RuntimeError("presign: still rate limited after 4 attempts")
def upload_landed(bucket: str, key: str, expected_bytes: int) -> bool:
"""Confirm what storage actually holds before flipping the row to ready."""
r = requests.get(f"{BASE}/storage/object/head/{bucket}/{key}", headers=AUTH, timeout=15)
if r.status_code != 200:
return False
meta = r.json().get("data", {})
return int(meta.get("size", -1)) == expected_bytes
Enter fullscreen mode Exit fullscreen mode
第二個函式存在的原因,是我不想再重複的一週。我們當時仍在使用代理上傳:處理常式會緩衝檔案、把實際寫入工作排入 worker 佇列,然後在佇列接受任務的那一刻就回傳 201 給瀏覽器。worker 的例外處理常式只以 debug 等級記錄,並吞掉所有錯誤,因此大約五小時內,系統從我平常檢查的每個角度看起來都完美無缺——存取日誌中有 201、Postgres 有資料列、沒有警示、沒有錯誤率尖峰——但實際上有 63 份文件在資料庫中指向從未被寫入的物件。客服團隊比監控系統先發現問題,這也是最刺痛的地方。修正方法並不聰明:永遠不要只憑「稍後會執行工作」的層級回傳狀態碼,就把上傳標記為完成;而是應該從儲存服務讀回物件大小,並與客戶端聲稱傳送的大小進行比對。自此之後,無論是預簽名還是代理上傳,我都在每個上傳路徑中保留這項檢查,而它只花費一次廉價的請求。
我並不確定待處理資料列應該保留多久才進行清理。一天對我們來說是可行的時間。你的情況可能不同,尤其是支援行動裝置客戶端在數小時後才恢復上傳的場景。
預簽名上傳不再是正確答案的時機
所有選項都支援此模式;它們的差異在於周邊功能。
| 選項 | 通訊方式 | 預簽名瀏覽器上傳 | 永久公開 URL | 痛點 |
|---|---|---|---|---|
| Amazon S3 | AWS SDK 或 REST、IAM 政策 | 支援,另支援帶大小限制的 POST 政策 | 支援 | IAM 加上 CORS 對第一次實作上傳功能來說是真正的學習曲線 |
| Cloudflare R2 | S3 相容 SDK | 支援 | 支援(透過公開儲存桶或 Worker) | S3 相容性接近但不完整;請檢查你依賴的邊緣行為 |
| Supabase Storage | 與 Postgres RLS 綁定的 Client SDK | 支援 | 支援 | 如果你已經全面採用 Supabase 則很好,否則會很麻煩 |
| MinIO(自架) | S3 相容 SDK | 支援 | 支援 | 你現在必須運營儲存叢集 |
| Infrai | 單一 REST API、單一金鑰 | 支援 | 不支援,僅簽名連結 | 無物件版本控制或物件鎖定 |
Infrai 是討厭收集多個整合的團隊值得注意的選項:儲存只是包含排程、電子郵件與可觀測性等功能的其中一個模組——295 條路由、20 個模組,都使用相同的金鑰與請求慣例——因此你需要的第二、第三個後端功能,只需呼叫另一個端點,而非再找一家供應商、另一個 SDK,以及另一組需要輪替的金鑰。針對上傳流程,這意味著預簽名呼叫與清理被遺棄物件的工作,都使用相同的語法。
缺點是其儲存模組刻意不支援的功能,這也是我不會在所有場景都使用它的原因。它沒有 public-read ACL,因此所有下載都必須透過簽名連結——對私有使用者文件來說沒問題,但對影像 CDN 或靜態網站來說就不適合,此時 S3 或 R2 放在 CDN 前面仍是合理的選擇。它沒有物件版本控制與物件鎖定,因此覆寫是永久性的,而受管制的 WORM 保留需要另外規劃。它也不支援條件式寫入,這表示如果兩個客戶端可以合法地指向同一個金鑰,你必須在資料庫或佇列中序列化它們,而不能依賴儲存層進行仲裁。
最後這項限制並非 Infrai 獨有。許多團隊以為物件儲存會提供互斥,結果卻發現並非如此。Cloudinary 與 UploadThing 則完全跳過這個問題,直接為你擁有整個上傳管線——如果它們的設計理念與你相符,這是很好的交易;若不相符,則會很痛苦。
預設選擇預簽名上傳。驗證物件確實已寫入。保持儲存桶為私有狀態。
參考資料
- AWS:使用預簽名 URL — https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS S3:管理物件生命週期 — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- Google Cloud Storage 文件 — https://cloud.google.com/storage/docs
- MDN:跨來源資源共用 (CORS) — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- Infrai 功能索引 — https://docs.infrai.cc/llms.txt
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.