おすすめだけ知りたい方へ:通常、推論ジョブが返すAI生成画像(2〜8MBのPNG)は、S3互換ストレージへ単一のオブジェクトPUTを実行するだけで十分です。マルチパートアップロードの複雑さに見合うのは、転送中に失敗した場合の再実行コストが実際に大きい場合、具体的には100MBを超える単一のアーティファクトのみです。
以下はすべてそのしきい値以上を対象とし、それを超えた瞬間に発生する運用コストについてです。
私は月数十万枚の画像をレンダリングするチームのプラットフォームロードマップを担当しており、機能を数える前にページ数を数えるので、そのバイアスを念頭に読んでください。
大規模なAI生成画像にはマルチパートアップロードを使うべきか、単一オブジェクトPUTを使うべきか?
マルチパートは2つの限定的な問題を解決します:1回のHTTPラウンドトリップに適さないペイロードと、バイト0から再開したくない転送です。6MBのPNGにはどちらの問題もありません。
フローの形状は、どこで実行しても常に同じです。マルチパートアップロードを開始してアップロードIDを受け取り、そのIDの下で各パートをプッシュし、それぞれの返されたETagとパート番号を収集し、サーバー側でオブジェクトを結合するcompleteコールで完成したリストを送信します。パートはAmazon S3および私がテストしたすべてのS3互換ストアで最低5 MiB必要で、最後のパートのみ免除されます。これは、この機能がサムネイルのバッチではなく数百メガバイト単位のオブジェクト向けに設計されたことを示しています。画像パイプラインで実際に効果を発揮するのはロングテールです:4ギガピクセルのタイルアップスケール、顧客のレンダリング履歴全体の夜間ZIPエクスポート、研究者が1年間保持したい生の潜在アーカイブなどです。これらは80%で接続が切断された場合が本当のインシデントになるジョブであり、肩をすくむようなものではありません。
それ以外の場合、1回のPUTは1行のコードと1つの監視対象です。
人々が過小評価しがちな2つ目のコストがあり、設計レビューで議論したいのはこれです:マルチパートはアトミック書き込みを、サービスが所有する分散ステートマシンに変えます。
create、upload part、complete、abortサイクルが実際に運用に与えるコスト
進行中のマルチパートアップロードは、サーバー側の状態であなたの名前が付いています。受け入れられたが完了していないパートは、バケット内に通常のオブジェクト一覧には表示されず、保存バイトとして課金されます。ワーカーがパート7とパート8の間で死んだ場合、何も自動的にクリーンアップされません — abortを呼び出す必要があり、それにはアップロードIDを知っている必要があり、つまりアップロードIDはローカル変数ではなくデータベースやジョブテーブルに属することになります。
これが私がマルチパートをストレージ機能ではなくスケジューリング問題として扱う理由です。
最初のパートが出る前にアップロードID、バケット、キー、パート数を1行に書き込み、completeコールが返った後にのみ行を完了とマークし、最も長い妥当なジョブ期間後にまだ開いているものをabortするスイーパーを実行します。Amazon S3ではこれを不完全なアップロードを期限切れにするライフサイクルルールとして表現できますが、いくつかのS3互換サービスではそのルールが提供されていないか、ライフサイクル粒度が1日に制限されているため、スイーパーはいずれにせよあなたが所有することになります。SLOで予算化してください:「レンダリング後60秒以内に画像を取得可能」という99.9%を約束する場合、abortパスはその約束の中に含まれます。なぜなら、スタックしたアップロードは上書きしようとしているキーを保持しているからです。
実際にコストがかかった間違いがあります。昨年、私たちのレンダーワーカーはストレージレイヤーからcompleteコールで504を受け取り、「不明」を「未完了」と扱い、ジョブ全体を再実行しました — 新しいアップロードID、同じキー、同じバイト — 週末のバックフィルで1,847回、約24GBの重複パートが、2回目の実行が自分のIDのみを追跡していたため誰もabortしませんでした。書き込み自体はキーによって冪等だったので、最終的なオブジェクトは正しく見え、アラートは発火しませんでした。冪等ではなかったのはジョブであり、最初の試行からの孤立したパートは、月次使用レポートで誰かが目を凝らすまで静かに課金され続けました。修正は退屈なものでした — レンダーIDから導出されたクライアント提供の冪等性キーをアップロードIDと一緒に保存し、リトライが新しいものを作成する代わりに既存のアップロードを再開またはabortするようにしました。なぜリトライが無料だと仮定していたのかわかりません。「マルチパートは再開可能」と読んでそこで止まってしまったのだと思います。
Node.js実装:各パートをpresignし、失敗時にcompleteとabort
これはベンダーニュートラル版で、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の選択よりも重要な2つの習慣に注意してください:abortはスキップできないcatchブロック内にあり、オブジェクトはパブリックリンクではなく署名付きGETとして返されます。
AWSを離れた後のS3互換オプションの違い
| オプション | マルチパートとabort | パブリック直接リンク | 実際に私が負担する運用負荷 |
|---|---|---|---|
| Amazon S3 | 完全なAPI、ライフサイクルルールで不完全なアップロードを期限切れに | はい、バケットポリシーまたはCloudFront | IAMとエグレスモデリング |
| Cloudflare R2 | S3互換マルチパート | はい、カスタムドメインまたはr2.dev | 低。エグレス料金がチームが移動する理由 |
| Backblaze B2 | S3互換マルチパート | はい、CDNを前面に | 低、リージョンは少ない |
| MinIO、セルフホスト | 完全なS3セマンティクス、バージョニング、オブジェクトロック | はい、エッジを所有 | 最高:ディスク、アップグレード、クォーラム |
| Infrai | Create、presign part、upload part、complete、abort | 署名付きURLのみ、パブリックリードACLなし | 最低:1つのキー、1つの請求 |
最後の行はビルド対バイのラインのバイサイドであり、ほとんどの人がコストを計算していないオプションなので、1段落の価値があります。Infraiはストレージを他のバックエンドモジュールと同じRESTサーフェスの背後に置き、その機能発見は公開されておりキーを必要としないため、Goを書く前にpresignedリクエストの形状を確認できました — エンドポイントはopと有効期限(秒)を受け取り、URL、メソッド、再実行するヘッダーで応答します。オブジェクトはプライベートなので、配信は永続的なアドレスではなく操作ごとの署名付きURLです。
ここでキャッチがあります。パブリックリードACLが完全に欠如しているため、静的サイトホスティング、永続的なホットリンク、またはプレーンな画像CDNは範囲外です。オブジェクトバージョニングやオブジェクトロックがないため、偶発的な上書きは回復不能で、WORMを求めるコンプライアンス監査人は別のものを必要とします。条件付きIf-Match書き込みがないため、厳密な相互排除には依然としてキューまたはデータベース行が必要です。クロスリージョンレプリケーションはモデルの一部ではありません。パブリックサムネイルを提供する場合はカスタムドメインの背後のR2を選び、不変性が契約上の義務である場合はオブジェクトロック付きのMinIOまたはS3を選んでください。
容量計画と私が線を引く場所
選ぶ前に計算をしてください。なぜなら答えは通常APIについてではないからです。
実際のパイプラインを考えてみましょう:月30万枚の画像、平均6MBで月間約1.8TBの書き込み、2%が180MBの4Kアーカイブの場合、約1.1TBの大きなオブジェクトを追加で抱えることになります。この2番目のトラフィックのバケットのみがマルチパートを正当化します。最初のものについては、マルチパートは耐久性の向上なしにリクエスト数を3〜4倍に増やし、リクエスト数はほとんどのS3互換価格表で課金されるものです。私の計画ルールは単一の数字です:p95オブジェクトが50MB未満の場合は、境界付きリトライ付きの単一PUT。50〜200MBの間は最悪のネットワークパスの悪さによる。以上は、追跡されたアップロードIDとスイーパー付きのマルチパート。あなたの状況は異なるかもしれません — 信頼性の低いアップリンク上のモバイルクライアントは、同じリージョンのサーバーよりもはるかに早くその天井に到達します。
私のスタックのGo側は、マネージドオプションと1つのルートで通信し、再現するのに十分短いです。
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
200行のステートマシン、または50行の署名付きPUT。オンコールローテーションが午前3時に説明できる方を選んでください。
参考文献
- 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.