Pular para o conteúdo

Trilha de Go · parte 28 de 37

Upload de Arquivos para AWS S3 em Go: Streaming, Presigned URLs e Escala

Como enviar arquivos grandes para o AWS S3 em Go com streaming, presigned URLs e controle de memória, do primeiro upload à escala de produção.

Publicada em · 6 min de leitura

Tamanho da fonte:
Largura da coluna:

Upload de Arquivos para AWS S3 em Go: Streaming, Presigned URLs e Escala

O armazenamento e a transferência de arquivos pesados (fotos de perfil, comprovantes fiscais em PDF, gravações em vídeo) representam um dos principais gargalos de performance em aplicações web. Em arquiteturas conteinerizadas modernas (Kubernetes, AWS ECS, Google Cloud Run), instâncias de servidores são efêmeras: armazenar arquivos no disco local do container gera perda definitiva de dados ao reiniciar o pod e esgota o armazenamento efêmero da máquina host.

A solução padrão da indústria é persistir arquivos diretamente em Object Storage como o AWS S3 (ou equivalentes como Google Cloud Storage, Azure Blob Storage ou MinIO). Em Go, o domínio do pacote io.Reader e das primitivas de streaming permite transferir múltiplos gigabytes de mídia sem carregar os dados para a memória RAM, protegendo os microsserviços contra estouros de memória (Out Of Memory - OOM).


1. A Armadilha de Memória: Por que io.ReadAll é Proibido

Um dos erros mais graves cometidos em handlers de upload é buffering prematuro:

// ❌ CÓDIGO CRÍTICO: DETONA A MEMÓRIA DO SERVIDOR
fileBytes, err := io.ReadAll(r.Body) // Se o usuário enviar um vídeo de 2GB, 2GB de RAM são alocados!

Para processar uploads sem consumo explosivo de memória, o Go utiliza fluxos contínuos (streaming), onde bytes lidos do socket TCP da requisição HTTP são encaminhados instantaneamente para o socket TCP do AWS S3 em pequenos chunks gerenciados pelo io.Pipe ou pelo s3manager.

Streaming de Upload para AWS S3
figura 1 · Streaming de Upload para AWS S3

2. As Duas Estratégias de Upload: Proxy vs Presigned URL

Antes de escrever código, você deve escolher entre dois padrões arquiteturais dependendo do seu volume de tráfego:

Backend Proxy vs Presigned URL
figura 2 · Backend Proxy vs Presigned URL

Comparativo de Decisão:

CritérioPadrão 1: Backend Streaming ProxyPadrão 2: Presigned URL Direta
FluxoCliente $\to$ Backend Go $\to$ AWS S3Cliente $\to$ AWS S3 (Direto)
Banda do ServidorConsome a banda de entrada e saída do backendZero consumo de banda no backend
Processamento LocalPermite inspecionar antivírus, redimensionar imagens ou extrair metadadosNão permite alteração no trânsito pelo backend
ComplexidadeSimples para o cliente HTTP (apenas um POST)Exige duas requisições no cliente (pedir URL + enviar arquivo)
Cenário IdealArquivos pequenos e médios com validações estritas de negócioArquivos gigantescos (vídeos, backups, raw data) e alta escala

3. Padrão 1: Streaming Proxy com AWS SDK v2

O AWS SDK for Go v2 disponibiliza o pacote github.com/aws/aws-sdk-go-v2/feature/s3/manager (ou seu sucessor transfermanager), que implementa uploads particionados em paralelo (Concurrent Multipart Upload) dividindo arquivos grandes em partes de 5MB+ automaticamente.

3.1 Inicialização do Cliente S3

package storage

import (
	"context"
	"fmt"

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

type S3Service struct {
	client *s3.Client
	bucket string
}

func NewS3Service(ctx context.Context, region, bucket string) (*S3Service, error) {
	// Carrega credenciais do ambiente (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY ou IAM Role)
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
	if err != nil {
		return nil, fmt.Errorf("falha ao carregar credenciais AWS: %w", err)
	}

	client := s3.NewFromConfig(cfg)
	return &S3Service{client: client, bucket: bucket}, nil
}

3.2 Handler HTTP com r.MultipartReader e Detecção de Magic Bytes

Para evitar gravar o arquivo em disco temporário ou armazená-lo na memória, usamos r.MultipartReader():

package handler

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"path/filepath"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/google/uuid"
)

func (s *S3Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
	// 1. Limita o tamanho máximo permitido no stream para evitar DoS (ex: 50MB)
	const maxUploadSize = 50 << 20 // 50 Megabytes
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)

	// 2. Obtém o leitor de partes multipart sem carregar tudo em RAM
	reader, err := r.MultipartReader()
	if err != nil {
		http.Error(w, "Requisição deve ser multipart/form-data válida", http.StatusBadRequest)
		return
	}

	for {
		part, err := reader.NextPart()
		if err == io.EOF {
			break
		}
		if err != nil {
			http.Error(w, "Erro ao processar stream do arquivo", http.StatusBadRequest)
			return
		}

		// Localiza o campo do formulário desejado
		if part.FormName() == "document" {
			defer part.Close()

			// 3. Inspeção Segura de MIME Type via Magic Bytes:
			// Lê apenas os primeiros 512 bytes para inspecionar a assinatura real do arquivo
			headerBuffer := make([]byte, 512)
			n, err := part.Read(headerBuffer)
			if err != nil && err != io.EOF {
				http.Error(w, "Falha na leitura inicial", http.StatusInternalServerError)
				return
			}

			detectedMIME := http.DetectContentType(headerBuffer[:n])
			if detectedMIME != "application/pdf" && detectedMIME != "image/jpeg" && detectedMIME != "image/png" {
				http.Error(w, "Tipo de arquivo não permitido (aceito apenas PDF, JPEG ou PNG)", http.StatusUnsupportedMediaType)
				return
			}

			// 4. Reconstitui o stream completo usando io.MultiReader (junta os 512 bytes lidos com o resto)
			fileStream := io.MultiReader(bytes.NewReader(headerBuffer[:n]), part)

			// 5. Gera nome seguro e imutável para a chave no S3
			ext := filepath.Ext(part.FileName())
			s3Key := fmt.Sprintf("uploads/%s/%s%s", time.Now().Format("2006/01"), uuid.NewString(), ext)

			// 6. Envia diretamente para o S3 via Concurrent Uploader em chunks de 5MB
			uploader := manager.NewUploader(s.client, func(u *manager.Uploader) {
				u.PartSize = 5 * 1024 * 1024 // 5MB por parte
				u.Concurrency = 3            // 3 uploads concorrentes por arquivo
			})

			result, err := uploader.Upload(r.Context(), &s3.PutObjectInput{
				Bucket:      aws.String(s.bucket),
				Key:         aws.String(s3Key),
				Body:        fileStream, // io.Reader direto sem alocação em buffer!
				ContentType: aws.String(detectedMIME),
			})
			if err != nil {
				http.Error(w, fmt.Sprintf("Erro ao persistir no S3: %v", err), http.StatusInternalServerError)
				return
			}

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusCreated)
			fmt.Fprintf(w, `{"file_url":"%s","key":"%s"}`, result.Location, s3Key)
			return
		}
	}

	http.Error(w, "Campo 'document' ausente no formulário", http.StatusBadRequest)
}

4. Padrão 2: Presigned URLs para Altíssima Escala

Para aplicações que lidam com milhares de uploads simultâneos ou arquivos gigantes (ex: vídeos de 1GB), o backend Go deve apenas emitir uma URL pré-assinada temporária. O frontend (React, Flutter, Vue) faz a requisição PUT diretamente contra os servidores da AWS.

Gerando a Presigned URL no Go

package storage

import (
	"context"
	"fmt"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/google/uuid"
)

type PresignManager struct {
	presignClient *s3.PresignClient
	bucket        string
}

func NewPresignManager(s3Client *s3.Client, bucket string) *PresignManager {
	return &PresignManager{
		presignClient: s3.NewPresignClient(s3Client),
		bucket:        bucket,
	}
}

// GenerateUploadURL cria um link assinado válido por 15 minutos
func (pm *PresignManager) GenerateUploadURL(ctx context.Context, fileExtension string) (uploadURL string, key string, err error) {
	key = fmt.Sprintf("raw/%s%s", uuid.NewString(), fileExtension)

	req, err := pm.presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String(pm.bucket),
		Key:    aws.String(key),
	}, func(opts *s3.PresignOptions) {
		opts.Expires = 15 * time.Minute // Expira em 15 minutos
	})

	if err != nil {
		return "", "", fmt.Errorf("falha ao presignar URL S3: %w", err)
	}

	return req.URL, key, nil
}

Como o Cliente consome a URL:

  1. O frontend chama a API Go: POST /api/v1/uploads/presign informando a extensão .mp4.
  2. O Go retorna { "upload_url": "https://meu-bucket.s3.amazonaws.com/raw/uuid.mp4?X-Amz-Signature=...", "key": "raw/uuid.mp4" }.
  3. O frontend executa:
    curl -X PUT -T "video.mp4" "https://meu-bucket.s3.amazonaws.com/raw/uuid.mp4?X-Amz-Signature=..."
  4. Resultado: Zero megabytes de mídia transitaram pelo servidor Go, economizando largura de banda e capacidade de CPU.

5. Práticas Essenciais de Segurança

  1. Nunca Confie na Extensão de Arquivo: Um invasor pode renomear um executável malicioso .exe ou script .sh para foto.png. Sempre valide os primeiros 512 bytes com http.DetectContentType ou uma biblioteca de Magic Numbers.
  2. Proteção Contra Path Traversal: Nunca use o nome de arquivo enviado pelo cliente diretamente no caminho do S3 (../../etc/passwd). Limpe o nome com filepath.Base() ou, preferencialmente, substitua o nome por um UUID aleatório (uuid.NewString()).
  3. Limite o Tamanho com http.MaxBytesReader: Sem esse limite, uma chamada maliciosa pode enviar um stream infinito de gigabytes pelo socket HTTP, consumindo tempo de I/O do servidor até atingir timeouts operacionais.

6. Resumo Conceitual

  • Nunca armazene uploads de usuários no disco local do container; use Object Storage gerenciado como o AWS S3 ou MinIO.
  • Elimine o risco de Out Of Memory (OOM) processando requisições com r.MultipartReader() e manager.NewUploader sem intermediar buffers de bytes na memória.
  • Utilize io.MultiReader para recombinar os 512 bytes inspecionados para detecção de Magic Bytes com o restante do fluxo contínuo.
  • Para altíssima escala e mídias pesadas, prefira Presigned URLs (PresignPutObject), permitindo que clientes transmitam diretamente ao S3 sem onerar a banda do backend Go.
  • Integre os endpoints de upload com Middlewares em Go para aplicar limites de timeout e com Autenticação e JWT para validar permissões de envio.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?