<!-- Canonical: https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris
Language: es
Content-SHA256: f10bb286e80dd4f0ff6e82020483917322f24f1dbcafe3191ec3d5a843c3cd69
-->

Infrastructure

# Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload

2025-11-059 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris>)

## 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:

```javascript
// 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.

```json
{
 "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.

```javascript
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.

```python
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](<https://cloudflare.com/>) Workers como proxy de firmado que además inyecta cache headers:

```javascript
// 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](<https://min.io/>) 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:

```bash
# 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:

```yaml
# 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:

```bash
# 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:

```javascript
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.

```javascript
// 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.

## Preguntas frecuentes

### ¿MinIO está realmente listo para producción a gran escala?

Sí, pero con salvedades. MinIO maneja almacenamiento a escala de petabytes bien cuando se despliega con erasure coding y monitoreo apropiado. La clave es entender que no es un servicio gestionado, el overhead operacional es tuyo. Para la mayoría de los equipos, arrancar con Tigris o R2 y migrar a MinIO self-hosted solo cuando se necesita soberanía de datos o control de costos a escala es el camino pragmático.

### ¿Cómo difieren las presigned URLs del acceso basado en IAM?

Las presigned URLs codifican la autorización en la URL misma usando firmas HMAC. Son limitadas en tiempo, alcance a una operación específica (GET, PUT), y no requieren que el cliente tenga credenciales AWS. El acceso IAM requiere que el cliente tenga y gestione credenciales. Utilizar presigned URLs para uploads desde el browser y compartir temporario; utilizar IAM para comunicación server-to-server.

### ¿Puede Cloudflare R2 reemplazar a S3 completamente?

Para muchos workloads, sí. R2 tiene cero costos de egress lo cual cambia la economía drásticamente. Las brechas principales son lifecycle policies (todavía madurando), event notifications (limitadas comparadas con S3 EventBridge), y el ecosistema de herramientas que asumen features específicas de S3 como S3 Select o transiciones a Glacier.

## Tips

1. **Tip:** Siempre configurar el ` partSize ` dinámicamente según el tamaño del archivo, 5MB para archivos menores a 100MB, 25MB+ para objetos más grandes. Los part sizes fijos son la manera más rápida de quemar ancho de banda en reintentos.
2. **Tip:** Las presigned URLs deberían tener el TTL más corto que puedas soportar. Un default recomendado es ` 3600 ` segundos para uploads, ` 900 ` para downloads. Si el flujo necesita más, el flujo tiene un problema de diseño.
3. **Tip:** Al ejecutar MinIO en producción, siempre desplegarlo con erasure coding (` minio server /data{1...4} `). El modo single-disk es solo para dev, un fallo de disco consumiendo un archivo de facturas demuestra la importancia de esto.

- s3
- minio
- tigris
- cloudflare-r2
- object-storage
- multipart-upload
- presigned-urls
- cdn
- edge-caching
- infraestructura

[Volver al Blog](<https://lucioduran.com/es/blog>)
