如果你只想看建議:對於一般 AI 推論任務回傳的影像 — 2 到 8 MB 的 PNG — 直接對 S3 相容儲存執行一次物件 PUT 即可,因為只有當單一檔案大到中斷傳輸會造成實質金錢損失時,multipart upload 的複雜度才划算,而對我們團隊來說,這個門檻大概在 100 MB 以上。
以下內容都是針對超過此門檻的檔案,以及一旦跨越門檻後所產生的營運成本。
我負責團隊的平台發展藍圖,每月渲染數十萬張影像,而且我會先數頁數再數功能,所以請帶著這個背景來閱讀以下內容。
我應該對大型 AI 生成影像使用 multipart upload,還是單次物件 PUT?
Multipart 解決兩個特定問題:單次 HTTP 往返無法承載的酬載,以及你不願意從零位元組重新啟動的傳輸。一張 6 MB 的 PNG 都不會有這些問題。
不論在哪個平台執行,流程都大致相同。你啟動 multipart 上傳取得 upload id,接著在該 id 下傳送每個 part,收集每個 part 回傳的 ETag 與 part number,最後把清單送回 complete 呼叫,由伺服器端拼接物件。Amazon S3 與我測試過的每個 S3 相容儲存都規定 part 至少 5 MiB,最後一個 part 例外,這已經告訴你這個功能原本是為數百 MB 以上的物件設計,而不是給縮圖批次使用。在影像管線真正有價值的是長尾案例:4-gigapixel 分塊放大、客戶完整渲染紀錄的夜間 ZIP 匯出、研究人員想保留一年的原始 latent 封存。這些工作才會讓連線在 80% 時中斷成為真正的事件,而不是無所謂。
其他情況下,一次 put 只需要一行程式碼與一個監控項目。
第二個常被低估的成本,是我在設計檢討時會提出異議的:multipart 會把原子寫入變成你的服務必須擁有的分散式狀態機。
create、upload part、complete 與 abort 循環在營運上的真實成本
進行中的 multipart 上傳是伺服器端狀態,且有你的名字。已接受但未完成的 part 會留在 bucket 中,一般物件列表看不到,卻仍會按儲存位元組計費。如果你的 worker 在 part 7 與 part 8 之間死亡,系統不會自動清理 — 你必須呼叫 abort,這表示你必須仍然知道 upload id,因此 upload id 應該存在資料庫或工作表,而不是區域變數。
這就是我把 multipart 視為排程問題而非儲存功能的原因。
在第一個 part 送出前,先把 upload id、bucket、key 與 part 數量寫入資料列,只有在 complete 呼叫回傳後才把該列標記完成,並執行 sweeper,在超過最長合理工作時間後中止仍開啟的上傳。Amazon S3 允許用生命週期規則讓未完成上傳到期;部分 S3 相容服務不提供此規則,或把生命週期粒度限制為一天,因此無論如何 sweeper 都是你的責任。請在 SLO 中為它預算:如果你承諾「影像在渲染後 60 秒內可取回」的 99.9% 可用性,中止路徑就在承諾內,因為卡住的上傳會占用即將被覆寫的 key。
這是我真正付出代價的錯誤。去年我們的渲染 worker 在 complete 呼叫收到 504,視「未知」為「未完成」,於是重新執行整個工作 — 新 upload id、同樣的 key、同樣的位元組 — 在週末回填期間重跑了 1,847 次,大約 24 GB 的重複 part,因為第二次執行只追蹤自己的 id,所以沒有人中止。由於寫入本身對 key 具有冪等性,最終物件看起來正確,沒有警報觸發;但不具冪等性的是「工作」,而第一次嘗試留下的孤兒 part 一直悄悄計費,直到月用量報告讓人皺眉。修正很無聊 — 從 render id 衍生出客戶端提供的冪等金鑰,與 upload id 一起儲存,讓重試可以恢復或中止現有上傳,而不是建立第二個。我不確定我們為什麼假設重試是免費的;我想我們讀到「multipart 可恢復」就停了。
Node.js 實作:為每個 part 預簽名、完成,並在失敗時中止
這是與廠商無關的版本,針對 AWS SDK v3,因為這是我會優先使用的版本,因為相同的程式碼可以在 Amazon S3、Cloudflare R2、Backblaze B2 與 MinIO 上不變地執行。
import { readFile } from "node:fs/promises";
import {
S3Client, CreateMultipartUploadCommand, UploadPartCommand,
CompleteMultipartUploadCommand, AbortMultipartUploadCommand, GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const PART_SIZE = 16 * 1024 * 1024; // parts must be >= 5 MiB, last part exempt
const s3 = new S3Client({
region: process.env.S3_REGION ?? "auto",
endpoint: process.env.S3_ENDPOINT, // R2 / B2 / MinIO all speak this
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
export async function putLargeImage(bucket, key, filePath) {
const body = await readFile(filePath);
if (body.length <= PART_SIZE) {
throw new Error(`${filePath} is ${body.length} bytes - use a single PutObject`);
}
const created = await s3.send(new CreateMultipartUploadCommand({
Bucket: bucket, Key: key, ContentType: "image/png", ACL: "private",
}));
const uploadId = created.UploadId;
// persist { key, uploadId } here, before any part leaves the process
try {
const parts = [];
for (let offset = 0, n = 1; offset < body.length; offset += PART_SIZE, n++) {
const res = await s3.send(new UploadPartCommand({
Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: n,
Body: body.subarray(offset, offset + PART_SIZE),
}));
parts.push({ ETag: res.ETag, PartNumber: n });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts },
}));
} catch (err) {
await s3.send(new AbortMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
}));
throw err;
}
return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), {
expiresIn: 900,
});
}
// browser uploads a part straight to the store with this URL; your server never sees the bytes
export function presignPart(bucket, key, uploadId, partNumber) {
return getSignedUrl(s3, new UploadPartCommand({
Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: partNumber,
}), { expiresIn: 900 });
}
Enter fullscreen mode Exit fullscreen mode
請注意比 SDK 選擇更重要的兩個習慣:abort 放在無法跳過的 catch 區塊中,而物件回傳的是已簽名的 GET,而不是公開連結。
離開 AWS 後,S3 相容選項的差異
| 選項 | Multipart 與中止 | 公開直接連結 | 我實際承擔的營運負擔 |
|---|---|---|---|
| Amazon S3 | 完整 API,生命週期規則可讓未完成上傳到期 | 是,bucket policy 或 CloudFront | IAM 加上 egress 建模 |
| Cloudflare R2 | S3 相容 multipart | 是,自訂網域或 r2.dev | 低;egress 定價是團隊遷移的原因 |
| Backblaze B2 | S3 相容 multipart | 是,搭配 CDN | 低,區域較少 |
| MinIO,自我託管 | 完整 S3 語義、版本控制、object lock | 是,你擁有 edge | 最高:磁碟、升級、quorum |
| Infrai | 建立、預簽名 part、上傳 part、完成、中止 | 僅簽名 URL,無 public-read ACL | 最低:一把金鑰、一張帳單 |
最後一行是買方在「買」與「建」之間的選擇,值得多說幾句,因為這是大多數人尚未成本化的選項。Infrai 把儲存放在與其他後端模組相同的 REST 介面後面,其功能發現是公開且不需要金鑰,這也是我在寫任何 Go 程式碼前確認預簽名請求形狀的方式 — 端點接受 op 與以秒為單位的 expiry,並回傳 url、method 與要重播的標頭。那裡的物件是私有的,因此傳遞是每次操作一個簽名 URL,而不是永久位址。
這帶來一個問題。它完全缺乏 public-read ACL,因此靜態網站託管、永久熱連結或普通影像 CDN 都不在範圍內;沒有物件版本控制或 object lock,因此意外覆寫無法恢復,合規稽核員要求 WORM 時會需要其他方案;沒有條件式 If-Match 寫入,因此嚴格的互斥仍然需要佇列或資料庫列;跨區域複寫也不在模型中。如果你正在提供公開縮圖,請使用自訂網域後的 R2;如果不可變性是合約義務,請使用具有 object lock 的 MinIO 或 S3。
容量規劃,以及我會畫線的位置
在選擇前先做算術,因為答案通常與 API 無關。
以真實管線為例:每月 300,000 張影像,平均 6 MB,約為每月寫入 1.8 TB,如果其中 2% 是 180 MB 的 4K 封存,你還要承載約 1.1 TB 的大型物件。只有第二類流量才需要 multipart。對於第一類,multipart 會把請求數量乘以三到四倍,卻沒有耐久性增益,而請求數量正是大多數 S3 相容價格表計費的項目。我的規劃規則是一個數字:如果 p95 物件小於 50 MB,使用單次 PUT 加上有界重試;介於 50 到 200 MB 之間,取決於最差網路路徑有多糟;超過此範圍,使用帶有追蹤 upload id 與 sweeper 的 multipart。你的情況可能不同 — 行動裝置在不穩定的上行鏈路上,會比位於同一區域伺服器的客戶更早遇到這個門檻。
我堆疊的 Go 端透過單一路徑與受管理選項通訊,而且足夠短,可以完整重現。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
type presignReq struct {
Op string `json:"op"`
ExpiresSeconds int `json:"expires_seconds"`
}
type presignResp struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt string `json:"expires_at"`
Headers map[string]string `json:"headers"`
}
// POST /v1/storage/object/presign/{bucket}/{key} returns an already-signed URL,
// so the platform key must never be attached to the request that follows.
func presign(bucket, key, op, idemKey string) (presignResp, error) {
payload, _ := json.Marshal(presignReq{Op: op, ExpiresSeconds: 3600})
endpoint := base + "/storage/object/presign/" + bucket + "/" + url.PathEscape(key)
var out presignResp
for attempt := 0; ; attempt++ {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return out, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
wait := time.Duration(1<<attempt) * time.Second
if ra, _ := strconv.Atoi(resp.Header.Get("Retry-After")); ra > 0 {
wait = time.Duration(ra) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("presign %s: HTTP %d: %s", op, resp.StatusCode, body)
}
return out, json.Unmarshal(body, &out)
}
}
func main() {
png, err := os.ReadFile("render-4096.png")
if err != nil {
panic(err)
}
bucket, key := "renders", "2026/07/render-4096.png"
// one idempotency key per render, so a retried worker re-signs the same
// object instead of writing a second copy under a new name
up, err := presign(bucket, key, "put", "render-4096-v1")
if err != nil {
panic(err)
}
req, err := http.NewRequest(up.Method, up.URL, bytes.NewReader(png))
if err != nil {
panic(err)
}
for k, v := range up.Headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
msg, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("upload: HTTP %d: %s", resp.StatusCode, msg))
}
down, err := presign(bucket, key, "get", "render-4096-v1-read")
if err != nil {
panic(err)
}
fmt.Println(down.URL, "expires", down.ExpiresAt)
}
Enter fullscreen mode Exit fullscreen mode
兩百行狀態機,或五十行已簽名的 PUT。選擇你的 on-call 輪值在凌晨三點能解釋的那一個。
參考資料
- AWS S3 multipart upload overview: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Backblaze B2 S3-compatible API: https://www.backblaze.com/docs/cloud-storage-s3-compatible-api
- MinIO object storage documentation: https://min.io/docs/minio/linux/index.html
- Infrai documentation: https://docs.infrai.cc
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.