第6章:AWS S3 署名付き URL を使ったファイルアップロード設計

Go
Golang
Backend
AWS
S3
FileUpload

はじめに

ファイルアップロードをバックエンド経由でやるとメモリを食う。S3の署名付きURLを使えばクライアントが直接S3に投げられる。AWS認証情報もフロントに渡さなくていい、いいとこ取りの設計。


1. 基本方針

なぜ署名付き URL を使うのか

❌ 直接バックエンド経由でアップロード(非推奨)
  クライアント → バックエンド → S3
  └ 問題: 大容量ファイルがバックエンドのメモリを圧迫する

✅ 署名付き URL を使った直接アップロード(推奨)
  クライアント ──[1. URL要求]──▶ バックエンド
  クライアント ◀──[2. 署名付きURL]── バックエンド
  クライアント ──[3. ファイルを直接PUT]──▶ S3

メリット:

  • バックエンドのメモリ負荷を削減
  • AWS 認証情報をフロントに公開しない
  • ファイル転送の高速化

署名付きURL(Presigned URL)ってなんぞや?↓

  • 署名付きURLは、一定時間だけ有効なAWS S3へのアクセスURLで、URLを持っていれば認証なしでPUTやGETができる。有効期限が過ぎると無効になるので、短い期間(このシステムは10分)で発行するのが安全。

2. S3 バケット構成

環境変数でバケット名を管理する。

バケット用途環境変数
temp署名付き URL 経由の一時ファイルAWS_S3_TEMP_BUCKET
private非公開ファイルAWS_S3_PRIVATE_BUCKET
public公開アクセス可能なファイルAWS_S3_PUBLIC_BUCKET
# 環境変数設定例
AWS_S3_TEMP_BUCKET=your-app-temp
AWS_S3_PRIVATE_BUCKET=your-app-private
AWS_S3_PUBLIC_BUCKET=your-app-public
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=ap-northeast-1

3. API 設計

エンドポイント

POST /upload/presigned_url

サポートファイル形式

var supportedMimeTypes = map[string]bool{
    "image/jpeg":      true,
    "image/png":       true,
    "image/gif":       true,
    "image/webp":      true,
    "application/pdf": true,
}

ファイルサイズ制限

制限
最小1 KB (1,024 bytes)
最大100 MB (104,857,600 bytes)
1 リクエスト最大ファイル数60 ファイル

リクエスト例

POST /upload/presigned_url

[
  {
    "fileName": "product_image.jpg",
    "mimeType": "image/jpeg",
    "fileSize": 2097152
  },
  {
    "fileName": "manual.pdf",
    "mimeType": "application/pdf",
    "fileSize": 5242880
  }
]

レスポンス例

{
  "data": [
    {
      "fileName": "product_image.jpg",
      "url": "https://your-bucket.s3.ap-northeast-1.amazonaws.com/uploads/shop-uuid/product_image.jpg?X-Amz-Signature=..."
    },
    {
      "fileName": "manual.pdf",
      "url": "https://your-bucket.s3.ap-northeast-1.amazonaws.com/uploads/shop-uuid/manual.pdf?X-Amz-Signature=..."
    }
  ]
}

署名付き URL の仕様

項目
有効期限10 分間
アップロード先temp バケット
HTTP メソッドPUT
オブジェクトキー形式uploads/{shop-uuid}/{filename}

4. アーキテクチャ

Handler(Upload)
  │ リクエストのバリデーション、エラーハンドリング

UseCase(CreatePresignedURL)
  │ ファイルバリデーション、並行処理で複数URL生成

Infrastructure(S3Client)
  │ AWS SDK v2 を使って署名付き URL を生成

AWS S3(temp バケット)

5. S3 クライアントインターフェース

インターフェースを定義することでテスト時にモックに差し替えできる。

// internal/infrastructure/aws/s3.go
package aws

import "context"

type S3Client interface {
    // 基本操作
    PutObject(ctx context.Context, input PutObjectInput) error
    GetObject(ctx context.Context, input GetObjectInput) ([]byte, error)
    DeleteObject(ctx context.Context, key string) error
    GetObjectMetadata(ctx context.Context, key string) (*ObjectMetadata, error)

    // 署名付き URL 操作
    GeneratePresignedURL(ctx context.Context, input PresignedURLInput) (string, error)
    GeneratePresignedURLs(ctx context.Context, inputs []PresignedURLInput) ([]string, error)

    // バッチ操作
    PutObjects(ctx context.Context, inputs []PutObjectInput) error
    CopyToBucket(ctx context.Context, srcKey, dstKey, dstBucket string) error
}

type PresignedURLInput struct {
    Key           string // オブジェクトキー(例: "uploads/shop-uuid/file.jpg")
    ContentType   string // MIME タイプ
    ContentLength int64  // ファイルサイズ
    ExpiresIn     int    // 有効期限(秒)
}

6. UseCase の実装例

// internal/usecases/upload/create_presigned_url_usecase.go
package usecase

import (
    "context"
    "fmt"
    "sync"

    "github.com/cockroachdb/errors"
    "myapp/internal/infrastructure/aws"
)

type CreatePresignedURLInput struct {
    Files []FileInput
}

type FileInput struct {
    FileName  string
    MimeType  string
    FileSize  int64
    ShopUUID  string
}

type CreatePresignedURLOutput struct {
    Results []PresignedURLResult
}

type PresignedURLResult struct {
    FileName string
    URL      string
}

type CreatePresignedURLUseCase struct {
    s3Client aws.S3Client
}

func NewCreatePresignedURLUseCase(s3Client aws.S3Client) *CreatePresignedURLUseCase {
    return &CreatePresignedURLUseCase{s3Client: s3Client}
}

var supportedMimeTypes = map[string]bool{
    "image/jpeg":      true,
    "image/png":       true,
    "image/gif":       true,
    "image/webp":      true,
    "application/pdf": true,
}

const (
    minFileSize = 1024             // 1 KB
    maxFileSize = 104_857_600      // 100 MB
    maxFiles    = 60
)

func (u *CreatePresignedURLUseCase) Execute(
    ctx context.Context,
    input CreatePresignedURLInput,
) (*CreatePresignedURLOutput, error) {
    // ファイル数チェック
    if len(input.Files) > maxFiles {
        return nil, errors.Newf("一度にアップロードできるファイルは %d 件までです", maxFiles)
    }

    // 各ファイルのバリデーション
    for _, f := range input.Files {
        if !supportedMimeTypes[f.MimeType] {
            return nil, errors.Newf("サポートされていないファイル形式です: %s", f.MimeType)
        }
        if f.FileSize < minFileSize || f.FileSize > maxFileSize {
            return nil, errors.Newf("ファイルサイズが制限範囲外です: %d bytes", f.FileSize)
        }
    }

    // 並行処理で署名付き URL を生成
    results := make([]PresignedURLResult, len(input.Files))
    errs := make([]error, len(input.Files))
    var wg sync.WaitGroup

    for i, f := range input.Files {
        wg.Add(1)
        go func(idx int, file FileInput) {
            defer wg.Done()

            objectKey := fmt.Sprintf("uploads/%s/%s", file.ShopUUID, file.FileName)
            url, err := u.s3Client.GeneratePresignedURL(ctx, aws.PresignedURLInput{
                Key:           objectKey,
                ContentType:   file.MimeType,
                ContentLength: file.FileSize,
                ExpiresIn:     600, // 10 分
            })
            if err != nil {
                errs[idx] = errors.Wrap(err, "署名付き URL の生成に失敗しました")
                return
            }

            results[idx] = PresignedURLResult{
                FileName: file.FileName,
                URL:      url,
            }
        }(i, f)
    }

    wg.Wait()

    // エラーチェック
    for _, err := range errs {
        if err != nil {
            return nil, err
        }
    }

    return &CreatePresignedURLOutput{Results: results}, nil
}

sync.WaitGroupってなんぞや?↓

  • sync.WaitGroupは複数のgoroutineが全部終わるのを待つための仕組み。wg.Add(1)で待つgoroutineの数を増やして、goroutine内の処理が終わったらdefer wg.Done()で減らす。全部終わったらwg.Wait()がブロックを解除する。
  • goroutineはGoが持つ軽量なスレッドのようなもの。go func()で非同期に実行できる。複数ファイルのURLを並行で生成することで、逐次処理より速く結果が返せる。

7. Handler の実装例

// internal/handler/upload_handler.go
package handler

import (
    "net/http"

    api "myapp/generated/openapi"
    "myapp/internal/usecases/upload/usecase"

    "github.com/gin-gonic/gin"
)

type UploadHandler struct {
    createPresignedURLUseCase *usecase.CreatePresignedURLUseCase
}

func (h *UploadHandler) CreatePresignedURL(c *gin.Context) {
    var request api.PresignedURLRequest
    if err := c.ShouldBindJSON(&request); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"message": "リクエストの形式が正しくありません"})
        return
    }

    // コンテキストから店舗 UUID を取得
    shopInfo, ok := c.Get(ctxkeys.LoginShopKey)
    if !ok {
        c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal server error"})
        return
    }
    shop := shopInfo.(types.LoginShop)

    response, err := h.createPresignedURLUseCase.Execute(c.Request.Context(), request, shop.ShopUUID)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
        return
    }

    c.JSON(http.StatusOK, api.PresignedURLResponse{Data: response})
}

8. クライアント側のアップロード手順

// 1. 署名付き URL を取得
const { data } = await fetch('/upload/presigned_url', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify([
    { fileName: 'image.png', mimeType: 'image/png', fileSize: file.size }
  ]),
}).then(r => r.json());

// 2. 取得した URL に直接 PUT する(認証不要)
await fetch(data[0].url, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/png' },
  body: file,
});

// 3. アップロード完了後、オブジェクトキーをバックエンドに送信して保存
await fetch('/products', {
  method: 'POST',
  body: JSON.stringify({ imageKey: 'uploads/shop-uuid/image.png', ... }),
});

9. multipart/form-data の場合

画像のファイルデータは S3 に直送するけど、フォームに含まれるテキストフィールド(商品名とか説明文)の前後空白は自動で消してほしい。TrimWhiteSpaceMiddleware がそれを身代わりにやってくれる。

// ミドルウェアの登録
r.Use(middleware.TrimWhiteSpaceMiddleware())

処理される条件:

  • HTTP メソッド: PUT, POST, PATCH
  • Content-Type: application/json または multipart/form-data

処理されない条件:

  • GET, DELETE などその他のメソッド
  • ファイルフィールド(テキストフィールドのみ対象)

10. テストでの S3 モック

テスト時は S3Client インターフェースをモックに差し替える。

// テストでのモック使用例
shared.TestS3Client.On(
    "GeneratePresignedURL",
    mock.Anything,
    mock.MatchedBy(func(input aws.PresignedURLInput) bool {
        return input.ContentType == "image/png" && input.ContentLength == 1024000
    }),
).Return("https://example.com/presigned-url", nil).Once()

まとめ

ポイント内容
アップロード方式署名付き URL 経由でクライアントが直接 S3 へ PUT
有効期限署名付き URL は 10 分間有効
バケット設計temp(一時) / private(非公開) / public(公開)の 3 バケット
セキュリティAWS 認証情報はバックエンドのみで管理
バリデーション形式・サイズ・ファイル数を UseCase でチェック
パフォーマンス複数ファイルは goroutine で並行処理
← Return to blog
↑ Back to top