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
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.
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:
Comparativo de Decisão:
| Critério | Padrão 1: Backend Streaming Proxy | Padrão 2: Presigned URL Direta |
|---|---|---|
| Fluxo | Cliente $\to$ Backend Go $\to$ AWS S3 | Cliente $\to$ AWS S3 (Direto) |
| Banda do Servidor | Consome a banda de entrada e saída do backend | Zero consumo de banda no backend |
| Processamento Local | Permite inspecionar antivírus, redimensionar imagens ou extrair metadados | Não permite alteração no trânsito pelo backend |
| Complexidade | Simples para o cliente HTTP (apenas um POST) | Exige duas requisições no cliente (pedir URL + enviar arquivo) |
| Cenário Ideal | Arquivos pequenos e médios com validações estritas de negócio | Arquivos 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:
- O frontend chama a API Go:
POST /api/v1/uploads/presigninformando a extensão.mp4. - O Go retorna
{ "upload_url": "https://meu-bucket.s3.amazonaws.com/raw/uuid.mp4?X-Amz-Signature=...", "key": "raw/uuid.mp4" }. - O frontend executa:
curl -X PUT -T "video.mp4" "https://meu-bucket.s3.amazonaws.com/raw/uuid.mp4?X-Amz-Signature=..." - 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
- Nunca Confie na Extensão de Arquivo:
Um invasor pode renomear um executável malicioso
.exeou script.shparafoto.png. Sempre valide os primeiros 512 bytes comhttp.DetectContentTypeou uma biblioteca de Magic Numbers. - Proteção Contra Path Traversal:
Nunca use o nome de arquivo enviado pelo cliente diretamente no caminho do S3 (
../../etc/passwd). Limpe o nome comfilepath.Base()ou, preferencialmente, substitua o nome por um UUID aleatório (uuid.NewString()). - 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()emanager.NewUploadersem intermediar buffers de bytes na memória. - Utilize
io.MultiReaderpara 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.