{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris","title":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","description":"Un recorrido técnico por las entrañas del object storage S3-compatible, desde la mecánica de multipart uploads y la seguridad de presigned URLs hasta armar tu propia capa CDN con edge caching sobre MinIO, Tigris y Cloudflare R2.","language":"es","datePublished":"2025-11-05","dateModified":null,"contentFormat":"text/markdown","content":"Infrastructure\n\n# Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload\n\n2025-11-059 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris>)\n\n## La API de S3: Un Estándar De Facto\n\nLa 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\".\n\nLas operaciones core son engañosamente simples:\n\n```\nPutObject → Escribir bytes en una key\nGetObject → Leer bytes de una key\nDeleteObject → Borrar una key\nListObjectsV2 → Enumerar keys con filtrado por prefijo\n```\n\nDonde 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.\n\n## Multipart Upload por Dentro\n\nLos ` 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:\n\n```javascript\n// Paso 1: Iniciar, el server devuelve un UploadId\nconst { UploadId } = await s3.send(new CreateMultipartUploadCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n ContentType: 'video/mp4',\n}));\n\n// Paso 2: Subir partes, cada una devuelve un ETag\nconst partSize = 25 * 1024 * 1024; // chunks de 25MB\nconst parts = [];\n\nfor (let i = 0; i < totalParts; i++) {\n const start = i * partSize;\n const end = Math.min(start + partSize, fileSize);\n\n const { ETag } = await s3.send(new UploadPartCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n UploadId,\n PartNumber: i + 1,\n Body: fileStream.slice(start, end),\n }));\n\n parts.push({ PartNumber: i + 1, ETag });\n}\n\n// Paso 3: Completar, el server ensambla el objeto\nawait s3.send(new CompleteMultipartUploadCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n UploadId,\n MultipartUpload: { Parts: parts },\n}));\n```\n\nLo 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.\n\n```json\n{\n \"Rules\": [{\n \"ID\": \"abortar-multipart-incompleto\",\n \"Status\": \"Enabled\",\n \"AbortIncompleteMultipartUpload\": {\n \"DaysAfterInitiation\": 3\n }\n }]\n}\n```\n\nEn 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.\n\n### Estrategia de Part Size\n\nAcá 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.\n\n```javascript\nfunction calcularPartSize(fileSize) {\n const MIN_PART = 5 * 1024 * 1024; // 5MB\n const MAX_PARTS = 10000;\n\n // Arrancar en 5MB, duplicar hasta que entre en 10k partes\n let partSize = MIN_PART;\n while (Math.ceil(fileSize / partSize) > MAX_PARTS) {\n partSize *= 2;\n }\n\n // Pero también considerar condiciones de red,\n // partes más grandes = menos requests pero penalidades de retry más gordas\n const networkAdjusted = Math.max(partSize, 25 * 1024 * 1024);\n\n return networkAdjusted;\n}\n```\n\nLos 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.\n\n## Presigned URLs: La Capa de Seguridad en la que Nadie Piensa\n\nLas 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.\n\n```python\nimport boto3\nfrom botocore.config import Config\n\ns3 = boto3.client('s3',\n endpoint_url='https://minio.internal:9000',\n aws_access_key_id='AKIAEXAMPLE',\n aws_secret_access_key='secret',\n config=Config(signature_version='s3v4'),\n)\n\n# Generar presigned PUT URL para upload directo desde browser\nupload_url = s3.generate_presigned_url(\n 'put_object',\n Params={\n 'Bucket': 'user-uploads',\n 'Key': f'avatars/{user_id}/{filename}',\n 'ContentType': 'image/jpeg',\n },\n ExpiresIn=3600,\n)\n```\n\nEl 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.\n\nPara 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:\n\n```javascript\n// Cloudflare Worker como generador de presigned URLs\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n const key = url.pathname.slice(1);\n\n const object = await env.R2_BUCKET.get(key);\n if (!object) return new Response('No encontrado', { status: 404 });\n\n const headers = new Headers();\n headers.set('Content-Type', object.httpMetadata.contentType);\n headers.set('Cache-Control', 'public, max-age=86400, immutable');\n headers.set('ETag', object.httpEtag);\n\n return new Response(object.body, { headers });\n }\n};\n```\n\n## MinIO en Producción: Erasure Coding y Monitoreo\n\n[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:\n\n```bash\n# docker-compose.yml, MinIO producción con erasure coding\nservices:\n minio:\n image: minio/minio:RELEASE.2026-01-15T00-00-00Z\n command: server /data{1...4} --console-address \":9001\"\n environment:\n MINIO_ROOT_USER: ${MINIO_ROOT_USER}\n MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}\n MINIO_PROMETHEUS_AUTH_TYPE: public\n volumes:\n - /mnt/disk1:/data1\n - /mnt/disk2:/data2\n - /mnt/disk3:/data3\n - /mnt/disk4:/data4\n healthcheck:\n test: [\"CMD\", \"mc\", \"ready\", \"local\"]\n interval: 30s\n timeout: 5s\n retries: 3\n```\n\nEl 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.\n\nPara monitoreo, MinIO expone un endpoint de Prometheus en ` /minio/v2/metrics/cluster `. Las métricas críticas para alertar:\n\n```yaml\n# Reglas de alerta Prometheus\ngroups:\n - name: minio\n rules:\n - alert: MinIODiskOffline\n expr: minio_cluster_disk_offline_total > 0\n for: 5m\n labels:\n severity: critical\n - alert: MinIOHighLatency\n expr: histogram_quantile(0.99, minio_s3_requests_duration_seconds_bucket) > 2\n for: 10m\n labels:\n severity: warning\n - alert: MinIOCapacity\n expr: (minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes) < 0.15\n for: 1h\n labels:\n severity: warning\n```\n\n## Tigris: El Newcomer Distribuido Globalmente\n\nTigris 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.\n\nUna feature notable es su capacidad de búsqueda en metadata:\n\n```bash\n# Tigris soporta consultas de metadata de objetos directamente\naws s3api list-objects-v2 \\\n --bucket mi-bucket \\\n --query \"Contents[?Metadata.project=='website']\" \\\n --endpoint-url https://fly.storage.tigris.dev\n```\n\nLa 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.\n\n## Armando una Capa CDN con Edge Caching\n\nEsta 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:\n\n```\nCliente → Cloudflare (CDN) → Origin Worker → R2/MinIO\n ↓\n Cache (Tiered)\n ├── L1: Edge (cada PoP, 1h TTL)\n └── L2: Regional (compartido, 24h TTL)\n```\n\nEl origin worker hace el trabajo pesado:\n\n```javascript\nasync function handleRequest(request, env) {\n const url = new URL(request.url);\n const cacheKey = new Request(url.toString(), request);\n const cache = caches.default;\n\n // Verificar cache L1 edge\n let response = await cache.match(cacheKey);\n if (response) {\n return new Response(response.body, {\n headers: {\n ...Object.fromEntries(response.headers),\n 'X-Cache': 'HIT',\n },\n });\n }\n\n // Buscar en R2 origin\n const objectKey = url.pathname.slice(1);\n const object = await env.R2_BUCKET.get(objectKey);\n\n if (!object) {\n return new Response('Objeto no encontrado', { status: 404 });\n }\n\n // Construir response con caching agresivo para assets inmutables\n const isImmutable = objectKey.includes('/immutable/');\n const cacheControl = isImmutable\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, stale-while-revalidate=86400';\n\n response = new Response(object.body, {\n headers: {\n 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',\n 'Cache-Control': cacheControl,\n 'ETag': object.httpEtag,\n 'X-Cache': 'MISS',\n },\n });\n\n // Guardar en edge cache (sin bloquear)\n request.method === 'GET' && cacheControl !== 'no-store'\n && event.waitUntil(cache.put(cacheKey, response.clone()));\n\n return response;\n}\n```\n\n### Invalidación de Cache\n\nLa 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.\n\n```javascript\n// Upload content-addressable, la key ES el cache buster\nimport { createHash } from 'crypto';\n\nfunction contentAddressableKey(buffer, originalName) {\n const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12);\n const ext = originalName.split('.').pop();\n return `immutable/${hash}.${ext}`;\n}\n```\n\nPara 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:\n\n```\n/avatars/user-123 → 302 → /immutable/a1b2c3d4e5f6.jpg\n```\n\nActualizá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.\n\n## Comparación de Costos: Números Reales\n\nLos costos trackeados para un workload haciendo 500GB storage, 2TB egress/mes, 10M requests GET:\n\n| Servicio | Storage | Egress | Requests | Total/mes |\n| --- | --- | --- | --- | --- |\n| AWS S3 | $11.50 | $180.00 | $4.00 | $195.50 |\n| Cloudflare R2 | $7.50 | $0.00 | $3.60 | $11.10 |\n| Tigris | $10.00 | $0.00 | $2.50 | $12.50 |\n| MinIO (self-hosted) | ~$40\\* | $0.00 | $0.00 | ~$40.00 |\n\n\\*El costo de MinIO es el alquiler del server, 4x 1TB NVMe en Hetzner.\n\nEl 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.\n\n## Para Cerrar\n\nObject 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.\n\nUn 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.\n\nLa infraestructura es la parte aburrida. Lo que significa que debería funcionar sin que tengas que pensar en ella.\n\n## Preguntas frecuentes\n\n### ¿MinIO está realmente listo para producción a gran escala?\n\nSí, 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.\n\n### ¿Cómo difieren las presigned URLs del acceso basado en IAM?\n\nLas 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.\n\n### ¿Puede Cloudflare R2 reemplazar a S3 completamente?\n\nPara 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n\n- s3\n- minio\n- tigris\n- cloudflare-r2\n- object-storage\n- multipart-upload\n- presigned-urls\n- cdn\n- edge-caching\n- infraestructura\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#title"},{"level":2,"text":"La API de S3: Un Estándar De Facto","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#la-api-de-s3-un-estndar-de-facto"},{"level":2,"text":"Multipart Upload por Dentro","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#multipart-upload-por-dentro"},{"level":3,"text":"Estrategia de Part Size","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#estrategia-de-part-size"},{"level":2,"text":"Presigned URLs: La Capa de Seguridad en la que Nadie Piensa","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#presigned-urls-la-capa-de-seguridad-en-la-que-nadie-piensa"},{"level":2,"text":"MinIO en Producción: Erasure Coding y Monitoreo","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#minio-en-produccin-erasure-coding-y-monitoreo"},{"level":2,"text":"Tigris: El Newcomer Distribuido Globalmente","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#tigris-el-newcomer-distribuido-globalmente"},{"level":2,"text":"Armando una Capa CDN con Edge Caching","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#armando-una-capa-cdn-con-edge-caching"},{"level":3,"text":"Invalidación de Cache","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#invalidacin-de-cache"},{"level":2,"text":"Comparación de Costos: Números Reales","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#comparacin-de-costos-nmeros-reales"},{"level":2,"text":"Para Cerrar","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#para-cerrar"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#questions"},{"level":3,"text":"¿MinIO está realmente listo para producción a gran escala?","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-1"},{"level":3,"text":"¿Cómo difieren las presigned URLs del acceso basado en IAM?","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-2"},{"level":3,"text":"¿Puede Cloudflare R2 reemplazar a S3 completamente?","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#article-tips"}],"sections":[{"heading":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","level":1,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris","content":"Infrastructure"},{"heading":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","level":1,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#title","content":"2025-11-059 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris>)"},{"heading":"La API de S3: Un Estándar De Facto","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#la-api-de-s3-un-estndar-de-facto","content":"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\".\n\nLas operaciones core son engañosamente simples:\n\n```\nPutObject → Escribir bytes en una key\nGetObject → Leer bytes de una key\nDeleteObject → Borrar una key\nListObjectsV2 → Enumerar keys con filtrado por prefijo\n```\n\nDonde 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."},{"heading":"Multipart Upload por Dentro","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#multipart-upload-por-dentro","content":"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:\n\n```javascript\n// Paso 1: Iniciar, el server devuelve un UploadId\nconst { UploadId } = await s3.send(new CreateMultipartUploadCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n ContentType: 'video/mp4',\n}));\n\n// Paso 2: Subir partes, cada una devuelve un ETag\nconst partSize = 25 * 1024 * 1024; // chunks de 25MB\nconst parts = [];\n\nfor (let i = 0; i < totalParts; i++) {\n const start = i * partSize;\n const end = Math.min(start + partSize, fileSize);\n\n const { ETag } = await s3.send(new UploadPartCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n UploadId,\n PartNumber: i + 1,\n Body: fileStream.slice(start, end),\n }));\n\n parts.push({ PartNumber: i + 1, ETag });\n}\n\n// Paso 3: Completar, el server ensambla el objeto\nawait s3.send(new CompleteMultipartUploadCommand({\n Bucket: 'mi-bucket',\n Key: 'video-grande.mp4',\n UploadId,\n MultipartUpload: { Parts: parts },\n}));\n```\n\nLo 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.\n\n```json\n{\n \"Rules\": [{\n \"ID\": \"abortar-multipart-incompleto\",\n \"Status\": \"Enabled\",\n \"AbortIncompleteMultipartUpload\": {\n \"DaysAfterInitiation\": 3\n }\n }]\n}\n```\n\nEn 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."},{"heading":"Estrategia de Part Size","level":3,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#estrategia-de-part-size","content":"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.\n\n```javascript\nfunction calcularPartSize(fileSize) {\n const MIN_PART = 5 * 1024 * 1024; // 5MB\n const MAX_PARTS = 10000;\n\n // Arrancar en 5MB, duplicar hasta que entre en 10k partes\n let partSize = MIN_PART;\n while (Math.ceil(fileSize / partSize) > MAX_PARTS) {\n partSize *= 2;\n }\n\n // Pero también considerar condiciones de red,\n // partes más grandes = menos requests pero penalidades de retry más gordas\n const networkAdjusted = Math.max(partSize, 25 * 1024 * 1024);\n\n return networkAdjusted;\n}\n```\n\nLos 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."},{"heading":"Presigned URLs: La Capa de Seguridad en la que Nadie Piensa","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#presigned-urls-la-capa-de-seguridad-en-la-que-nadie-piensa","content":"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.\n\n```python\nimport boto3\nfrom botocore.config import Config\n\ns3 = boto3.client('s3',\n endpoint_url='https://minio.internal:9000',\n aws_access_key_id='AKIAEXAMPLE',\n aws_secret_access_key='secret',\n config=Config(signature_version='s3v4'),\n)\n\n# Generar presigned PUT URL para upload directo desde browser\nupload_url = s3.generate_presigned_url(\n 'put_object',\n Params={\n 'Bucket': 'user-uploads',\n 'Key': f'avatars/{user_id}/{filename}',\n 'ContentType': 'image/jpeg',\n },\n ExpiresIn=3600,\n)\n```\n\nEl 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.\n\nPara 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:\n\n```javascript\n// Cloudflare Worker como generador de presigned URLs\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n const key = url.pathname.slice(1);\n\n const object = await env.R2_BUCKET.get(key);\n if (!object) return new Response('No encontrado', { status: 404 });\n\n const headers = new Headers();\n headers.set('Content-Type', object.httpMetadata.contentType);\n headers.set('Cache-Control', 'public, max-age=86400, immutable');\n headers.set('ETag', object.httpEtag);\n\n return new Response(object.body, { headers });\n }\n};\n```"},{"heading":"MinIO en Producción: Erasure Coding y Monitoreo","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#minio-en-produccin-erasure-coding-y-monitoreo","content":"[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:\n\n```bash\n# docker-compose.yml, MinIO producción con erasure coding\nservices:\n minio:\n image: minio/minio:RELEASE.2026-01-15T00-00-00Z\n command: server /data{1...4} --console-address \":9001\"\n environment:\n MINIO_ROOT_USER: ${MINIO_ROOT_USER}\n MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}\n MINIO_PROMETHEUS_AUTH_TYPE: public\n volumes:\n - /mnt/disk1:/data1\n - /mnt/disk2:/data2\n - /mnt/disk3:/data3\n - /mnt/disk4:/data4\n healthcheck:\n test: [\"CMD\", \"mc\", \"ready\", \"local\"]\n interval: 30s\n timeout: 5s\n retries: 3\n```\n\nEl 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.\n\nPara monitoreo, MinIO expone un endpoint de Prometheus en ` /minio/v2/metrics/cluster `. Las métricas críticas para alertar:\n\n```yaml\n# Reglas de alerta Prometheus\ngroups:\n - name: minio\n rules:\n - alert: MinIODiskOffline\n expr: minio_cluster_disk_offline_total > 0\n for: 5m\n labels:\n severity: critical\n - alert: MinIOHighLatency\n expr: histogram_quantile(0.99, minio_s3_requests_duration_seconds_bucket) > 2\n for: 10m\n labels:\n severity: warning\n - alert: MinIOCapacity\n expr: (minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes) < 0.15\n for: 1h\n labels:\n severity: warning\n```"},{"heading":"Tigris: El Newcomer Distribuido Globalmente","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#tigris-el-newcomer-distribuido-globalmente","content":"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.\n\nUna feature notable es su capacidad de búsqueda en metadata:\n\n```bash\n# Tigris soporta consultas de metadata de objetos directamente\naws s3api list-objects-v2 \\\n --bucket mi-bucket \\\n --query \"Contents[?Metadata.project=='website']\" \\\n --endpoint-url https://fly.storage.tigris.dev\n```\n\nLa 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."},{"heading":"Armando una Capa CDN con Edge Caching","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#armando-una-capa-cdn-con-edge-caching","content":"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:\n\n```\nCliente → Cloudflare (CDN) → Origin Worker → R2/MinIO\n ↓\n Cache (Tiered)\n ├── L1: Edge (cada PoP, 1h TTL)\n └── L2: Regional (compartido, 24h TTL)\n```\n\nEl origin worker hace el trabajo pesado:\n\n```javascript\nasync function handleRequest(request, env) {\n const url = new URL(request.url);\n const cacheKey = new Request(url.toString(), request);\n const cache = caches.default;\n\n // Verificar cache L1 edge\n let response = await cache.match(cacheKey);\n if (response) {\n return new Response(response.body, {\n headers: {\n ...Object.fromEntries(response.headers),\n 'X-Cache': 'HIT',\n },\n });\n }\n\n // Buscar en R2 origin\n const objectKey = url.pathname.slice(1);\n const object = await env.R2_BUCKET.get(objectKey);\n\n if (!object) {\n return new Response('Objeto no encontrado', { status: 404 });\n }\n\n // Construir response con caching agresivo para assets inmutables\n const isImmutable = objectKey.includes('/immutable/');\n const cacheControl = isImmutable\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, stale-while-revalidate=86400';\n\n response = new Response(object.body, {\n headers: {\n 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',\n 'Cache-Control': cacheControl,\n 'ETag': object.httpEtag,\n 'X-Cache': 'MISS',\n },\n });\n\n // Guardar en edge cache (sin bloquear)\n request.method === 'GET' && cacheControl !== 'no-store'\n && event.waitUntil(cache.put(cacheKey, response.clone()));\n\n return response;\n}\n```"},{"heading":"Invalidación de Cache","level":3,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#invalidacin-de-cache","content":"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.\n\n```javascript\n// Upload content-addressable, la key ES el cache buster\nimport { createHash } from 'crypto';\n\nfunction contentAddressableKey(buffer, originalName) {\n const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12);\n const ext = originalName.split('.').pop();\n return `immutable/${hash}.${ext}`;\n}\n```\n\nPara 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:\n\n```\n/avatars/user-123 → 302 → /immutable/a1b2c3d4e5f6.jpg\n```\n\nActualizá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."},{"heading":"Comparación de Costos: Números Reales","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#comparacin-de-costos-nmeros-reales","content":"Los costos trackeados para un workload haciendo 500GB storage, 2TB egress/mes, 10M requests GET:\n\n| Servicio | Storage | Egress | Requests | Total/mes |\n| --- | --- | --- | --- | --- |\n| AWS S3 | $11.50 | $180.00 | $4.00 | $195.50 |\n| Cloudflare R2 | $7.50 | $0.00 | $3.60 | $11.10 |\n| Tigris | $10.00 | $0.00 | $2.50 | $12.50 |\n| MinIO (self-hosted) | ~$40\\* | $0.00 | $0.00 | ~$40.00 |\n\n\\*El costo de MinIO es el alquiler del server, 4x 1TB NVMe en Hetzner.\n\nEl 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."},{"heading":"Para Cerrar","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#para-cerrar","content":"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.\n\nUn 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.\n\nLa infraestructura es la parte aburrida. Lo que significa que debería funcionar sin que tengas que pensar en ella."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#questions","content":""},{"heading":"¿MinIO está realmente listo para producción a gran escala?","level":3,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-1","content":"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."},{"heading":"¿Cómo difieren las presigned URLs del acceso basado en IAM?","level":3,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-2","content":"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."},{"heading":"¿Puede Cloudflare R2 reemplazar a S3 completamente?","level":3,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#article-tips","content":"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.\n2. **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.\n3. **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.\n\n- s3\n- minio\n- tigris\n- cloudflare-r2\n- object-storage\n- multipart-upload\n- presigned-urls\n- cdn\n- edge-caching\n- infraestructura\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris"},{"text":"Cloudflare","url":"https://cloudflare.com/"},{"text":"MinIO","url":"https://min.io/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris"},"headline":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","description":"Un recorrido técnico por las entrañas del object storage S3-compatible, desde la mecánica de multipart uploads y la seguridad de presigned URLs hasta armar tu propia capa CDN con edge caching sobre MinIO, Tigris y Cloudflare R2.","datePublished":"2025-11-05","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Infrastructure","keywords":"s3, minio, tigris, cloudflare-r2, object-storage, multipart-upload, presigned-urls, cdn, edge-caching, infraestructura","wordCount":1672,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Object Storage S3-Compatible: MinIO, Tigris, R2 e Internals del Multipart Upload","item":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿MinIO está realmente listo para producción a gran escala?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cómo difieren las presigned URLs del acceso basado en IAM?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Puede Cloudflare R2 reemplazar a S3 completamente?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"f10bb286e80dd4f0ff6e82020483917322f24f1dbcafe3191ec3d5a843c3cd69","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":72911,"contentBytes":15135}}
