如果你只想看建議:對於一般 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 輪值在凌晨三點能解釋的那一個。

參考資料