Chuyển đến nội dung chính
友田 陽大
Go & Echo in production
Go
Echo
セキュリティ
AWS
アーキテクチャ設計
コスト最適化

Echo ファイルアップロード本番設計:multipart・S3 ストリーミング・presigned URL・検証で安全に受ける

Go Echo(v5)でファイルアップロードを本番品質に実装するガイド。c.FormFile / c.MultipartForm の使い方、メモリに載せない S3 ストリーミングアップロード(AWS SDK Go v2)、巨大ファイルを API に通さない presigned URL 方式、サイズ上限・MIME 検証・ファイル名のパストラバーサル対策・コンテンツ重複排除までを実コードで解説します。

Published
Reading time
9 min read
Author
友田 陽大
Chia sẻ

ファイルアップロードは「動かす」のは簡単ですが、本番で安全に受けるのは奥が深い領域です。巨大ファイルでメモリが溢れる、悪意あるファイル名でサーバーのファイルを上書きされる、拡張子を偽装した実行ファイルを掴まされる、アップロード帯域がサーバーを詰まらせる——どれも「とりあえず受ける」コードでは防げません。

この記事は、Go Echo 本番運用ガイドのファイルアップロード編です。Echo v5 の multipart API を起点に、メモリに載せないストリーミング巨大ファイルを API に通さない presigned URL 方式、そしてセキュリティ検証までを、本番で使える形で実装します。

この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。AWS SDK for Go v2 は更新されるため公式で最新を確認してください。認証情報は IAM ロール/環境変数前提で、アクセスキーをコードに書きません。


1. 基本:c.FormFile で1ファイルを受ける(ストリーミング)

Echo v5 のファイル受け取りは c.FormFile("field")*multipart.FileHeader です。ポイントは、ファイル全体をメモリに読み込まず、io.Copy でストリーミングすることです。

func (h *UploadHandler) Upload(c *echo.Context) error {
	file, err := c.FormFile("file") // *multipart.FileHeader
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}

	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	dst, err := os.Create(safeName(file.Filename)) // ← ファイル名は必ずサニタイズ(第4章)
	if err != nil {
		return err
	}
	defer dst.Close()

	if _, err = io.Copy(dst, src); err != nil { // ストリーミング:メモリに全部載せない
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"status": "uploaded"})
}

複数ファイルは c.MultipartForm() から取り出します。

form, err := c.MultipartForm()
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "invalid multipart form")
}
for _, file := range form.File["files"] { // <input name="files" multiple>
	src, _ := file.Open()
	// ... 同様に io.Copy でストリーミング保存
	src.Close()
}

なぜストリーミングが重要かio.ReadAll で全バイトをメモリに載せると、100MB のファイル × 同時 50 リクエストで 5GB を消費し、OOM でプロセスが落ちます。io.Copy は固定サイズのバッファで少しずつ転送するので、ファイルサイズに関係なくメモリ使用量が一定です。


2. サイズ上限:BodyLimit で「読む前」に弾く

巨大リクエストは、ボディを読み始める前にサイズで拒否します。ミドルウェアガイドBodyLimit をアップロード経路に効かせます。

// アップロード用グループにだけ大きめの上限を設定(v5 は整数バイト)
uploads := e.Group("/uploads")
uploads.Use(middleware.BodyLimit(25_000_000)) // 25MB を超えたら 413

BodyLimit多層防御の最外層です。これに加えて、ストレージ側(S3 のオブジェクトサイズ)やビジネスルール(プラン別上限)でも検証します。「1箇所で守る」ではなく「各層で守る」のが本番の作法です。


3. S3 へストリーミング保存:AWS SDK Go v2

本番では、アップロードをサーバーのローカルディスクではなくオブジェクトストレージ(S3 等)に保存します。AWS SDK for Go v2 の manager.Uploader は、マルチパートで並列アップロードしつつ io.Reader を受けるので、file.Open() の結果をそのままストリーミングできます。

import (
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Storage struct {
	uploader *manager.Uploader
	bucket   string
}

func NewS3Storage(client *s3.Client, bucket string) *S3Storage {
	return &S3Storage{uploader: manager.NewUploader(client), bucket: bucket}
}

func (s *S3Storage) Save(ctx context.Context, key, contentType string, body io.Reader) error {
	_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket:      &s.bucket,
		Key:         &key,
		Body:        body,         // io.Reader をそのまま渡す=メモリに載せない
		ContentType: &contentType,
	})
	return err
}

ハンドラからは、c.Request().Context() を渡してキャンセル・タイムアウトを伝播させます。

func (h *UploadHandler) UploadToS3(c *echo.Context) error {
	file, err := c.FormFile("file")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}
	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	contentType, err := sniffContentType(src) // 第4章:実体から MIME を判定
	if err != nil || !allowedMIME[contentType] {
		return echo.NewHTTPError(http.StatusUnsupportedMediaType, "unsupported file type")
	}

	key := "uploads/" + uuid.NewString() + extByMIME(contentType) // ファイル名は自前で決める
	if err := h.storage.Save(c.Request().Context(), key, contentType, src); err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"key": key})
}

認証情報を書かないs3.Client は SDK の既定資格情報チェーン(ECS タスクロール/EC2 インスタンスロール/環境変数)から構成します。アクセスキーをコードや環境変数に直書きせず、IAM ロールで最小権限(対象バケットの PutObject だけ)を付与するのが正解です(デプロイのシークレット管理)。


4. セキュリティ:3つの必須検証

アップロードは外部入力の塊です。最低限この3点を守ります。

4.1 ファイル名を信用しない(パストラバーサル対策)

file.Filenameクライアントが自由に決めた文字列です。../../etc/passwd../app.go のような値をそのままパスに使うと、サーバーの任意ファイルを上書きできてしまいます。ファイル名はサーバーが決めます

// ❌ 危険:クライアント由来の名前をそのままパスに
dst, _ := os.Create("/data/" + file.Filename) // ../ でディレクトリを抜けられる

// ✅ 安全:自前で命名(UUID)。元の名前はメタデータとして「値」で保持
key := uuid.NewString() + filepath.Ext(file.Filename) // 拡張子だけは検証後に流用
// 表示用の元ファイル名は DB のカラムに保存し、パスには使わない

4.2 MIME は拡張子ではなく「実体」で見る

.png という拡張子は嘘をつけます。ファイルの先頭512バイトhttp.DetectContentType に渡し、実体の Content-Type を判定して許可リストで照合します。

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

func sniffContentType(rs io.ReadSeeker) (string, error) {
	buf := make([]byte, 512)
	n, err := rs.Read(buf)
	if err != nil && err != io.EOF {
		return "", err
	}
	if _, err := rs.Seek(0, io.SeekStart); err != nil { // 読んだぶんを巻き戻す
		return "", err
	}
	return http.DetectContentType(buf[:n]), nil
}

multipart.Fileio.ReadSeeker を満たすので、先頭を読んで判定 → Seek(0) で巻き戻して保存できます。

4.3 その他の防御

リスク対策
実行可能ファイルの混入MIME 許可リスト + 拡張子の再付与(クライアント拡張子を使わない)
マルウェアアップロード後に非同期スキャン(S3 のマルウェアスキャン等)し、検疫が済むまで非公開
画像爆弾(decompression bomb)画像はサイズ・寸法を検証してから再エンコード
過剰アップロードRateLimiter をアップロード経路に

5. presigned URL:巨大ファイルは API を経由させない

ここが本番の最重要パターンです。動画や大きな画像をAPI サーバー経由でアップロードすると、サーバーの帯域・メモリ・実行時間(と課金)を消費します。代わりに、サーバーは「アップロード許可証(presigned URL)」を発行するだけにし、クライアントが直接 S3 へアップロードします。

import "github.com/aws/aws-sdk-go-v2/service/s3"

// サーバー:アップロード先と短命の署名付き URL を発行する(ファイル本体は通さない)
func (h *UploadHandler) CreateUploadURL(c *echo.Context) error {
	claims, _ := CurrentUser(c) // 認証必須(誰のアップロードか)
	key := "uploads/" + claims.UserID + "/" + uuid.NewString()

	ps := s3.NewPresignClient(h.s3)
	req, err := ps.PresignPutObject(c.Request().Context(), &s3.PutObjectInput{
		Bucket: &h.bucket,
		Key:    &key,
	}, s3.WithPresignExpires(5*time.Minute)) // 短命:5分で失効
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, map[string]string{"url": req.URL, "key": key})
}

クライアントは返ってきた url に対して PUT で直接 S3 にアップロードします。アップロード完了後、key をサーバーに通知して DB に紐付けます。

方式サーバー経由presigned URL 直アップロード
サーバー帯域全データが通る通らない(許可証だけ)
メモリ/実行時間ファイルサイズに比例一定(極小)
大きいファイルAPI タイムアウトの懸念S3 が直接受ける
コストサーバーの転送・実行課金大幅減
向く場面小さいファイル・即時加工動画・大画像・大量アップロード

判断の目安:数 MB 以下で即座に検証・加工が要るならサーバー経由、それ以上や大量ならpresigned URL。presigned URL でも、アップロード完了を S3 イベント(S3 → Lambda の非同期処理)で受けて検証・スキャン・サムネ生成につなげます。サーバーレスでの重い後処理の逃がし方は、私が決済・帳票基盤でも採った「API は薄く、重い処理はイベント駆動へ」の定石です。


6. 冪等性:同じファイルを二重に保存しない

リトライや二重送信で同じファイルが複数回保存されるのを防ぐには、コンテンツのハッシュをキーにします。「内容が同じなら同じキー」にすれば、再アップロードは同じオブジェクトを上書きするだけになり、重複が生まれません。

// 内容ベースのキー(content-addressed storage)
func contentKey(prefix string, sum [32]byte, ext string) string {
	return prefix + "/" + hex.EncodeToString(sum[:]) + ext // 同一内容 → 同一キー
}

ハッシュは保存ストリームと並行して計算できます(io.TeeReader で「保存しながらハッシュ」)。この「冪等性を構造で担保する」発想は、決済の二重課金対策と同じ原則です。


まとめ:ファイルアップロードを本番品質にする7原則

  1. io.Copy でストリーミングio.ReadAll でメモリに載せない。
  2. BodyLimit で読む前にサイズで弾く(多層防御の最外層)。
  3. S3 へは manager.Uploaderio.Reader を直結。認証情報は IAM ロール。
  4. file.Filename を信用しない。サーバーが UUID で命名(パストラバーサル対策)。
  5. MIME は http.DetectContentType で実体判定し許可リストで照合。
  6. 巨大ファイルは presigned URL でクライアント→S3 直アップロード(帯域・メモリ・コスト削減)。
  7. 冪等性はコンテンツハッシュをキーに、後処理はイベント駆動へ逃がす。

ファイルアップロードは、Echo の薄い multipart API と、ストレージ・セキュリティ・コストの設計が噛み合って初めて本番品質になります。リアルタイムな進捗通知が要るならWebSocket/SSE、全体像は本番運用ガイドへ。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc