ファイルアップロードは「動かす」のは簡単ですが、本番で安全に受けるのは奥が深い領域です。巨大ファイルでメモリが溢れる、悪意あるファイル名でサーバーのファイルを上書きされる、拡張子を偽装した実行ファイルを掴まされる、アップロード帯域がサーバーを詰まらせる——どれも「とりあえず受ける」コードでは防げません。
この記事は、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.File は io.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原則
io.Copyでストリーミング。io.ReadAllでメモリに載せない。BodyLimitで読む前にサイズで弾く(多層防御の最外層)。- S3 へは
manager.Uploaderでio.Readerを直結。認証情報は IAM ロール。 file.Filenameを信用しない。サーバーが UUID で命名(パストラバーサル対策)。- MIME は
http.DetectContentTypeで実体判定し許可リストで照合。 - 巨大ファイルは presigned URL でクライアント→S3 直アップロード(帯域・メモリ・コスト削減)。
- 冪等性はコンテンツハッシュをキーに、後処理はイベント駆動へ逃がす。
ファイルアップロードは、Echo の薄い multipart API と、ストレージ・セキュリティ・コストの設計が噛み合って初めて本番品質になります。リアルタイムな進捗通知が要るならWebSocket/SSE、全体像は本番運用ガイドへ。