La API de S3: Un Estándar De Facto
La API de S3 se convirtió en el TCP/IP del object storage. Todo sistema de almacenamiento serio la implementa porque el ecosistema de herramientas, SDKs y modelos mentales es demasiado valioso como para ignorarlo. Pero "S3-compatible" cubre un espectro que va desde "más o menos anda" hasta "respuestas idénticas bit a bit".
Las operaciones core son engañosamente simples:
PutObject → Escribir bytes en una key
GetObject → Leer bytes de una key
DeleteObject → Borrar una key
ListObjectsV2 → Enumerar keys con filtrado por prefijo
Donde la cosa se pone picante, y donde la compatibilidad se rompe, es en las operaciones avanzadas: multipart uploads, presigned URLs, bucket policies y event notifications.
Multipart Upload por Dentro
Los PutObject simples topan en 5GB en S3 (y la mayoría de servicios compatibles). Para cualquier cosa más grande, o cuando se desea uploads resumibles, se necesita multipart uploads. Esto es lo que pasa realmente por debajo:
// Paso 1: Iniciar, el server devuelve un UploadId
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: 'mi-bucket',
Key: 'video-grande.mp4',
ContentType: 'video/mp4',
}));
// Paso 2: Subir partes, cada una devuelve un ETag
const partSize = 25 * 1024 * 1024; // chunks de 25MB
const parts = [];
for (let i = 0; i < totalParts; i++) {
const start = i * partSize;
const end = Math.min(start + partSize, fileSize);
const { ETag } = await s3.send(new UploadPartCommand({
Bucket: 'mi-bucket',
Key: 'video-grande.mp4',
UploadId,
PartNumber: i + 1,
Body: fileStream.slice(start, end),
}));
parts.push({ PartNumber: i + 1, ETag });
}
// Paso 3: Completar, el server ensambla el objeto
await s3.send(new CompleteMultipartUploadCommand({
Bucket: 'mi-bucket',
Key: 'video-grande.mp4',
UploadId,
MultipartUpload: { Parts: parts },
}));
Lo que el SDK te esconde: cada parte se almacena independientemente en el server hasta que se llama a CompleteMultipartUpload. Esto significa que los multipart uploads abandonados filtran storage. Los buckets con terabytes de partes huérfanas son comunes porque nadie configuró lifecycle rules para limpiarlas.
{
"Rules": [{
"ID": "abortar-multipart-incompleto",
"Status": "Enabled",
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 3
}
}]
}
En MinIO, esta lifecycle rule funciona idéntica. En Tigris y R2, la implementación difiere un toque, Tigris auto-limpia después de 24 horas por defecto, y R2 requiere que lo configures por dashboard o API.
Estrategia de Part Size
Acá es donde la mayoría de implementaciones la pifian. La spec de S3 permite partes entre 5MB y 5GB, con un máximo de 10.000 partes por upload. Un part size naive de 5MB significa que tu tamaño máximo de archivo es ~50GB. Subilo a 100MB por parte y es posible manejar casi un terabyte.
function calcularPartSize(fileSize) {
const MIN_PART = 5 * 1024 * 1024; // 5MB
const MAX_PARTS = 10000;
// Arrancar en 5MB, duplicar hasta que entre en 10k partes
let partSize = MIN_PART;
while (Math.ceil(fileSize / partSize) > MAX_PARTS) {
partSize *= 2;
}
// Pero también considerar condiciones de red,
// partes más grandes = menos requests pero penalidades de retry más gordas
const networkAdjusted = Math.max(partSize, 25 * 1024 * 1024);
return networkAdjusted;
}
Los benchmarks subiendo un archivo de 10GB a los tres servicios desde un datacenter en São Paulo muestran lo siguiente. MinIO (self-hosted, misma región) completó en 47 segundos con partes de 25MB. R2 tardó 63 segundos. Tigris, sorprendentemente, dio 51 segundos gracias a sus escrituras distribuidas en edge. El cuello de botella fue casi siempre la red, no el backend de storage.
Presigned URLs: La Capa de Seguridad en la que Nadie Piensa
Las presigned URLs son el mecanismo que te deja dar acceso temporario y acotado a objetos sin compartir credenciales. La firma se computa usando HMAC-SHA256 sobre un string de request canónico.
import boto3
from botocore.config import Config
s3 = boto3.client('s3',
endpoint_url='https://minio.internal:9000',
aws_access_key_id='AKIAEXAMPLE',
aws_secret_access_key='secret',
config=Config(signature_version='s3v4'),
)
# Generar presigned PUT URL para upload directo desde browser
upload_url = s3.generate_presigned_url(
'put_object',
Params={
'Bucket': 'user-uploads',
'Key': f'avatars/{user_id}/{filename}',
'ContentType': 'image/jpeg',
},
ExpiresIn=3600,
)
El detalle crítico: el ContentType en los parámetros de la presigned URL tiene que matchear exacto con lo que el cliente manda. Si se firma para image/jpeg y el browser envía image/png, el request falla con SignatureDoesNotMatch. Esto le pasa a prácticamente todo equipo al menos una vez.
Para downloads con R2, hay un truco eficiente, es posible usar Cloudflare Workers como proxy de firmado que además inyecta cache headers:
// Cloudflare Worker como generador de presigned URLs
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1);
const object = await env.R2_BUCKET.get(key);
if (!object) return new Response('No encontrado', { status: 404 });
const headers = new Headers();
headers.set('Content-Type', object.httpMetadata.contentType);
headers.set('Cache-Control', 'public, max-age=86400, immutable');
headers.set('ETag', object.httpEtag);
return new Response(object.body, { headers });
}
};
MinIO en Producción: Erasure Coding y Monitoreo
MinIO funciona bien para casos que requieren soberanía de datos (datos de salud, contratos gubernamentales). El deployment mínimo de producción usa erasure coding con setup de 4 discos:
# docker-compose.yml, MinIO producción con erasure coding
services:
minio:
image: minio/minio:RELEASE.2026-01-15T00-00-00Z
command: server /data{1...4} --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_PROMETHEUS_AUTH_TYPE: public
volumes:
- /mnt/disk1:/data1
- /mnt/disk2:/data2
- /mnt/disk3:/data3
- /mnt/disk4:/data4
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 5s
retries: 3
El erasure coding con 4 discos te da EC:2 por defecto, es posible perder 2 discos cualesquiera y seguir leyendo toda la data. La matemática detrás es codificación Reed-Solomon, el mismo algoritmo que usan los códigos QR y los CDs. MinIO parte cada objeto en shards de datos y paridad distribuidos entre los discos.
Para monitoreo, MinIO expone un endpoint de Prometheus en /minio/v2/metrics/cluster. Las métricas críticas para alertar:
# Reglas de alerta Prometheus
groups:
- name: minio
rules:
- alert: MinIODiskOffline
expr: minio_cluster_disk_offline_total > 0
for: 5m
labels:
severity: critical
- alert: MinIOHighLatency
expr: histogram_quantile(0.99, minio_s3_requests_duration_seconds_bucket) > 2
for: 10m
labels:
severity: warning
- alert: MinIOCapacity
expr: (minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes) < 0.15
for: 1h
labels:
severity: warning
Tigris: El Newcomer Distribuido Globalmente
Tigris toma un enfoque diferente, está construido sobre FoundationDB y distribuye objetos en edge locations automáticamente. Escribirs a un endpoint único y las lecturas se sirven desde la región más cercana. El modelo de precios es más simple que S3 también: pagás por storage y requests, con egress gratis.
Una feature notable es su capacidad de búsqueda en metadata:
# Tigris soporta consultas de metadata de objetos directamente
aws s3api list-objects-v2 \
--bucket mi-bucket \
--query "Contents[?Metadata.project=='website']" \
--endpoint-url https://fly.storage.tigris.dev
La trampa: Tigris todavía es joven. Aparecieron edge cases en su implementación de multipart upload a fines de 2025 donde ListParts devolvía partes en un orden diferente que S3. Ya lo arreglaron, pero es un recordatorio de que "S3-compatible" es aspiracional para servicios nuevos.
Armando una Capa CDN con Edge Caching
Esta es una arquitectura probada para una aplicación pesada en medios, object storage como origen, con una capa de cache que maneja la distribución edge:
Cliente → Cloudflare (CDN) → Origin Worker → R2/MinIO
↓
Cache (Tiered)
├── L1: Edge (cada PoP, 1h TTL)
└── L2: Regional (compartido, 24h TTL)
El origin worker hace el trabajo pesado:
async function handleRequest(request, env) {
const url = new URL(request.url);
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
// Verificar cache L1 edge
let response = await cache.match(cacheKey);
if (response) {
return new Response(response.body, {
headers: {
...Object.fromEntries(response.headers),
'X-Cache': 'HIT',
},
});
}
// Buscar en R2 origin
const objectKey = url.pathname.slice(1);
const object = await env.R2_BUCKET.get(objectKey);
if (!object) {
return new Response('Objeto no encontrado', { status: 404 });
}
// Construir response con caching agresivo para assets inmutables
const isImmutable = objectKey.includes('/immutable/');
const cacheControl = isImmutable
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600, stale-while-revalidate=86400';
response = new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Cache-Control': cacheControl,
'ETag': object.httpEtag,
'X-Cache': 'MISS',
},
});
// Guardar en edge cache (sin bloquear)
request.method === 'GET' && cacheControl !== 'no-store'
&& event.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
Invalidación de Cache
La parte difícil, como siempre. El approach recomendado: keys content-addressable para assets estáticos (hash en el nombre del archivo), y TTLs cortos + stale-while-revalidate para contenido dinámico.
// Upload content-addressable, la key ES el cache buster
import { createHash } from 'crypto';
function contentAddressableKey(buffer, originalName) {
const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12);
const ext = originalName.split('.').pop();
return `immutable/${hash}.${ext}`;
}
Para case es donde se necesita invalidación instantánea (como cuando un usuario actualiza su foto de perfil), una capa de redirección funciona bien:
/avatars/user-123 → 302 → /immutable/a1b2c3d4e5f6.jpg
Actualizás el puntero de redirección, y el objeto cacheado viejo expira naturalmente. El nuevo puntero se cachea con un TTL corto. Sin llamadas a API de purge, sin cache stampedes.
Comparación de Costos: Números Reales
Los costos trackeados para un workload haciendo 500GB storage, 2TB egress/mes, 10M requests GET:
| Servicio | Storage | Egress | Requests | Total/mes |
|---|---|---|---|---|
| AWS S3 | $11.50 | $180.00 | $4.00 | $195.50 |
| Cloudflare R2 | $7.50 | $0.00 | $3.60 | $11.10 |
| Tigris | $10.00 | $0.00 | $2.50 | $12.50 |
| MinIO (self-hosted) | ~$40* | $0.00 | $0.00 | ~$40.00 |
*El costo de MinIO es el alquiler del server, 4x 1TB NVMe en Hetzner.
El costo de egress en S3 es el asesino. Si tus objetos se acceden frecuentemente, R2 o Tigris te van a ahorrar una fortuna. Si se necesita soberanía de datos o teners requisitos de compliance, MinIO en tu propio metal es el camino, pero pagás con overhead operacional en vez de dólares.
Para Cerrar
Object storage es una de esas cosas que parece simple hasta que se está debuggeando por qué tu multipart upload falló en la parte 9.437 de un archivo de 50GB a las 3 de la mañana. Que la API de S3 sea un estándar de facto es genuinamente notable, switchear entre MinIO, R2 y Tigris es mayormente un cambio de configuración.
Un stack recomendado: R2 para assets públicos (cero egress es imbatible), MinIO para datos sensibles a compliance, y Tigris para workloads donde la distribución global importa. Presigned URLs para todas las operaciones client-facing, keys content-addressable para manejo de cache, y lifecycle rules para limpiar después de uploads incompletos.
La infraestructura es la parte aburrida. Lo que significa que debería funcionar sin que tengas que pensar en ella.