La lección anterior dejó fuera de HDFS, deliberadamente, las fotos de los productos: miles de ficheros pequeños que la web tiene que servir por HTTP, con metadatos, versiones y una durabilidad que no dependa de un NameNode. El almacenamiento de objetos nació para exactamente eso. Renuncia a los directorios, a las escrituras parciales y al rename, y a cambio ofrece un modelo plano de buckets y claves, una API HTTP que Amazon S3 convirtió en estándar de facto, durabilidad de "once nueves" mediante replicación y codificación de borrado, y un coste por gigabyte que ningún disco de red iguala. Hoy es el almacén por defecto de fotos, vídeos, copias de seguridad, documentos y, cada vez más, del propio lago de datos. En esta lección entenderemos su modelo y su API, veremos cómo se consigue la durabilidad (con un ejemplo numérico de Reed-Solomon), aprenderemos a reconocer cuándo no es la herramienta adecuada, y lo pondremos a funcionar en Kilómetro Cero con MinIO en docker-compose.yml y un módulo boto3 con el que la Quesería Montblanc subirá la foto de su queso-curado y la web la mostrará mediante URLs prefirmadas.
Contenido
- Objeto, fichero y bloque
- El modelo de objetos: buckets, claves, metadatos y versiones
- La API S3 como estándar de facto
- Durabilidad: replicación y codificación de borrado (Reed-Solomon)
- Consistencia en los almacenes de objetos
- Cuándo usar objetos y cuándo no
- Coste: almacenamiento, operaciones y egress
- Objetos en Kilómetro Cero: fotos, facturas y backups
- Práctica: MinIO,
mcyservicios/catalogo/fotos.pyconboto3 - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Objeto, fichero y bloque
Hay tres formas de presentar el almacenamiento a un programa, y conviene distinguirlas antes de nada:
| Bloque | Fichero | Objeto | |
|---|---|---|---|
| Unidad | Bloques de tamaño fijo (512 B–4 KB) numerados | Ficheros en un árbol de directorios | Objetos identificados por una clave en un bucket plano |
| Interfaz | Lectura/escritura de bloques (SCSI, NVMe, iSCSI, RBD de Ceph) | POSIX: open, read, write, seek, rename |
HTTP: PUT, GET, DELETE, LIST |
| Modificación | Cualquier bloque, en cualquier momento | Cualquier byte, append, truncado |
Ninguna: el objeto se reemplaza entero |
| Metadatos | Ninguno (los pone el sistema de ficheros de encima) | Fijos: permisos, fechas, tamaño | Fijos + definidos por el usuario (x-amz-meta-*) |
| Quién lo usa | Un sistema operativo (disco de una VM, base de datos) | Aplicaciones que necesitan semántica de ficheros | Aplicaciones web, backups, lagos de datos, contenido estático |
| Escala típica | Un disco lógico por consumidor | Terabytes a petabytes (04-02) | Petabytes a exabytes, billones de objetos |
| Latencia | Microsegundos a milisegundos | Milisegundos | Decenas de milisegundos por operación |
| Ejemplos | EBS, Ceph RBD, iSCSI | NFS, HDFS, CephFS | S3, MinIO, Ceph RGW, Azure Blob, GCS |
La columna de "modificación" es la que lo cambia todo: un objeto es inmutable. Para cambiar un byte se sube un objeto nuevo con la misma clave. Esa inmutabilidad simplifica la replicación (dos copias de un objeto son idénticas o una de ellas es de otra versión, no hay estados intermedios) y es la razón de que los almacenes de objetos escalen tanto y cuesten tan poco. También es la razón de casi todas sus limitaciones (apartado 6).
- El modelo de objetos: buckets, claves, metadatos y versiones
- Bucket: contenedor de nivel superior, con nombre único (en S3, único a nivel global; en MinIO, por despliegue). Un bucket tiene región, política de acceso, configuración de versionado y reglas de ciclo de vida. Kilómetro Cero usará
km0-fotos,km0-facturasykm0-backups. - Clave (key): el nombre del objeto dentro del bucket, una cadena de hasta 1024 bytes. No hay directorios:
fotos/queso-curado/original.jpges una única cadena, y la barra no significa nada para el sistema. Las consolas y herramientas muestran "carpetas" agrupando por prefijo, pero es una ilusión: listar "la carpetafotos/queso-curado/" es una operaciónLISTconprefix=fotos/queso-curado/. - Datos: de 0 bytes a 5 TB (S3). Opacos: el sistema no interpreta el contenido.
- Metadatos: del sistema (
Content-Type,Content-Length,Last-Modified,ETag) y del usuario (x-amz-meta-productor: queseria-montblanc, hasta 2 KB en total). Los metadatos viajan con el objeto y se recuperan conHEADsin descargar los datos. No se pueden modificar sin reescribir el objeto (se hace con una copia sobre sí mismo). - Versionado: si el bucket lo activa, cada
PUTsobre una clave existente no la sobrescribe sino que crea una nueva versión con suVersionId, yDELETEno borra sino que coloca un marcador de borrado encima. Las versiones anteriores siguen ahí y se pueden recuperar: es la protección más sencilla contra el borrado accidental y contra un proceso que sube ficheros corruptos. - Ciclo de vida (lifecycle): reglas declarativas por prefijo o etiqueta: "los objetos con la etiqueta
tipo=miniaturay más de 90 días pasan a clase de almacenamiento fría", "las versiones no actuales se borran a los 30 días", "las subidas multipart incompletas se abortan a los 7 días". El sistema las aplica solo, sin código. - Clases de almacenamiento: el mismo objeto puede vivir en niveles con distinto coste y tiempo de acceso: estándar (milisegundos), acceso infrecuente (más barato por GB, más caro por lectura), archivo (S3 Glacier: horas para recuperar, centavos por TB). MinIO implementa tiering hacia otro almacén remoto.
- Etiquetas (tags): pares clave-valor para clasificar objetos (
campaña=semana-del-queso-artesano) y dirigir las reglas de ciclo de vida y las políticas de acceso.
flowchart LR
subgraph B["bucket km0-fotos (versionado activado)"]
K1["fotos/queso-curado/original.jpg<br/>v3 (actual) · v2 · v1<br/>meta: productor=queseria-montblanc"]
K2["fotos/queso-curado/miniatura-300.jpg<br/>v1"]
K3["fotos/tomate-rosa/original.jpg<br/>v1<br/>meta: productor=huerta-la-vega"]
K4["fotos/vino-crianza/original.jpg<br/>marcador de borrado · v2 · v1"]
end
LC["Regla de ciclo de vida:<br/>miniaturas, tag tipo=miniatura, > 90 días → clase fría<br/>versiones no actuales > 30 días → borrar"] -.-> B
- La API S3 como estándar de facto
Amazon lanzó S3 en 2006 con una API REST sencilla, y hoy prácticamente todos los almacenes de objetos la implementan: MinIO, Ceph RGW, Google Cloud Storage (modo interoperable), Backblaze B2, Cloudflare R2, Wasabi… Aprender S3 es aprender todos. Las operaciones básicas:
| Operación | HTTP | Qué hace |
|---|---|---|
PutObject |
PUT /bucket/clave |
Sube un objeto completo (hasta 5 GB en una sola llamada); acepta metadatos en cabeceras |
GetObject |
GET /bucket/clave |
Descarga; admite Range para leer un trozo y condiciones (If-None-Match) |
HeadObject |
HEAD /bucket/clave |
Solo metadatos y ETag: existe, cuánto mide, de qué tipo es |
DeleteObject(s) |
DELETE |
Borra (o pone un marcador si hay versionado); hasta 1000 claves por llamada |
ListObjectsV2 |
GET /bucket?list-type=2&prefix=… |
Lista claves por prefijo, paginado de 1000 en 1000, con delimiter=/ para simular carpetas |
CopyObject |
PUT con x-amz-copy-source |
Copia dentro del servidor sin descargar; es como se "renombra" y como se cambian metadatos |
| Multipart upload | CreateMultipartUpload, UploadPart, CompleteMultipartUpload |
Subida por partes (5 MB–5 GB cada una, hasta 10 000) en paralelo y reanudable; obligatoria por encima de 5 GB |
| URL prefirmada | Ninguna: es una firma | Una URL con la firma del servidor y caducidad que permite a un tercero hacer una operación concreta sin credenciales |
Dos conceptos que merecen detalle:
ETag. Cada objeto tiene una etiqueta de entidad que cambia si cambia el contenido. Para subidas simples es el MD5 del contenido ("9e107d9d372bb6826bd81d3542a419d6"), lo que permite verificar la integridad de una subida comparando el MD5 local con el ETag devuelto. Para subidas multipart es el MD5 de los MD5 de las partes seguido de -N (número de partes), así que no sirve como suma del fichero entero. Se usa también en lecturas condicionales: la web pide GET con If-None-Match: "<etag>" y recibe 304 Not Modified si la foto no ha cambiado.
URLs prefirmadas. El almacén de objetos no debería ser accesible públicamente, y la web de Kilómetro Cero no quiere hacer de proxy de cada foto (pagaría el ancho de banda dos veces y añadiría latencia). La solución es que el servicio catalogo, que sí tiene credenciales, firme una URL de descarga con una caducidad (por ejemplo 15 minutos) y se la dé al navegador; el navegador descarga directamente del almacén, que verifica la firma y la fecha. El mismo mecanismo funciona para subidas: la Quesería Montblanc recibe una URL prefirmada de PUT y sube su foto directamente al bucket sin pasar por catalogo. La firma (Signature V4) es un HMAC-SHA256 de la petición canónica con la clave secreta; el servidor la recalcula y compara. Es un ejemplo perfecto de capacidad (capability): un permiso portátil y limitado, sin cuentas ni sesiones, tema que reaparecerá en 06-01.
- Durabilidad: replicación y codificación de borrado (Reed-Solomon)
S3 anuncia una durabilidad del 99,999999999 % anual ("once nueves"): si guardas 10 millones de objetos, la expectativa es perder uno cada 10 000 años. No es una cifra de disponibilidad (esa es del 99,9 %–99,99 %); es la probabilidad de que los bytes sigan existiendo. Se consigue con dos técnicas.
Replicación. N copias completas en N dominios de fallo distintos (discos, servidores, racks, centros de datos). Con 3 copias se tolera la pérdida de 2 y el sobrecoste es del 200 % (3 bytes guardados por cada byte útil). Es lo que hace HDFS (04-02) y es simple y rápida de leer (cualquier copia sirve).
Codificación de borrado (erasure coding). En lugar de copiar, se divide el objeto en k fragmentos de datos y se calculan m fragmentos de paridad, de modo que cualesquiera k de los k+m fragmentos bastan para reconstruir el objeto. Se toleran m pérdidas con un sobrecoste de solo m/k. El algoritmo habitual es Reed-Solomon, la misma familia de códigos que usan los CD, los QR y las comunicaciones espaciales.
La intuición, sin álgebra de cuerpos finitos: piensa en k = 2 datos, d1 y d2, y m = 2 paridades:
Guardamos [d1, d2, p1, p2] en cuatro discos. Si perdemos d1 y d2 (los dos de datos), tenemos dos ecuaciones y dos incógnitas: d2 = p2 − p1, d1 = p1 − d2. Si perdemos d1 y p2: d1 = p1 − d2. Cualquier pareja de supervivientes basta. Reed-Solomon generaliza esto a cualquier k y m con coeficientes elegidos para que todo subconjunto de k fragmentos sea resoluble, operando en un cuerpo finito (GF(2⁸)) para que las sumas y multiplicaciones se hagan sobre bytes sin desbordar. Un ejemplo numérico con enteros para ver que funciona:
# Reed-Solomon "de juguete" con k=2, m=2 y aritmética entera (en la realidad: GF(2^8))
d1, d2 = 7, 12
p1 = d1 + d2 # 19
p2 = d1 + 2 * d2 # 31
# Se pierden d1 y d2; sobreviven p1 y p2:
d2_rec = p2 - p1 # 12
d1_rec = p1 - d2_rec # 7
assert (d1_rec, d2_rec) == (d1, d2)Comparemos configuraciones reales para 1 TB de datos útiles:
| Esquema | Fragmentos | Pérdidas toleradas | Bytes guardados | Sobrecoste | Quién lo usa |
|---|---|---|---|---|---|
| 3 réplicas | 3 | 2 | 3 TB | 200 % | HDFS por defecto, Cassandra |
| RS(4,2) | 4+2 | 2 | 1,5 TB | 50 % | MinIO con 6 discos (por defecto la mitad de paridad: RS(3,3) en 6 discos) |
| RS(8,4) | 8+4 | 4 | 1,5 TB | 50 % | MinIO con 12 discos, Ceph |
| RS(10,4) | 10+4 | 4 | 1,4 TB | 40 % | Facebook f4, HDFS 3 (RS-10-4) |
| RS(12,4) | 12+4 | 4 | 1,33 TB | 33 % | Backblaze (17+3), Azure LRC variantes |
El precio de la codificación de borrado es la reconstrucción: para leer un objeto hay que reunir k fragmentos de k nodos (más latencia que leer una copia), y para reparar un disco perdido hay que leer k fragmentos de cada objeto afectado (mucho tráfico de red). Por eso los sistemas la usan para datos grandes y fríos, y mantienen replicación (o cachés, 04-05) para lo pequeño y caliente; muchos hacen ambas cosas: replicación para objetos pequeños y erasure coding a partir de cierto tamaño.
Los once nueves se calculan combinando la probabilidad de fallo anual de un disco (1–3 %), el tiempo de reconstrucción tras un fallo (horas) y el número de fallos simultáneos que hacen falta para perder datos (m+1 en el mismo grupo dentro de esa ventana). Con RS(8,4) y reparación en horas, esa coincidencia es astronómicamente improbable. A ello se suman las sumas de comprobación por fragmento y el scrubbing: un proceso de fondo relee todo periódicamente para detectar corrupción silenciosa (bit rot) y repararla antes de que se acumulen fallos.
- Consistencia en los almacenes de objetos
Durante 14 años, S3 fue eventualmente consistente para sobrescrituras y borrados: tras un PUT sobre una clave existente, un GET podía devolver la versión anterior durante unos segundos; tras un DELETE, un LIST podía seguir mostrando la clave. En diciembre de 2020 Amazon anunció consistencia fuerte read-after-write para todas las operaciones: cualquier GET, HEAD o LIST posterior a un PUT o DELETE confirmado ve el nuevo estado. En el vocabulario de 03-01, cada objeto es linealizable, y en el de 03-02, S3 es CP (una escritura no se confirma hasta que el quórum de réplicas la tiene). MinIO ofrece la misma garantía dentro de un despliegue, y Ceph RGW también.
No todos los proveedores lo hacen: algunos almacenes compatibles con S3 y las configuraciones de replicación entre regiones son eventuales (la copia en la otra región tarda segundos o minutos). Y hay un matiz importante incluso con consistencia fuerte: no hay transacciones entre objetos. Subir original.jpg y luego miniatura-300.jpg son dos operaciones independientes; un lector puede ver la primera y no la segunda. Si la aplicación necesita que aparezcan juntas, sube primero las miniaturas y al final el objeto "índice" que las referencia, o guarda la referencia en la base de datos solo cuando todas las subidas han terminado.
Por último, las cachés delante del almacén (una CDN, tema de 08-04, o un navegador con Cache-Control) reintroducen la consistencia eventual por su cuenta: reemplazar original.jpg no vacía la CDN. Por eso la práctica común es no sobrescribir claves de contenido: se sube original-v3.jpg (o se usa un hash del contenido como parte de la clave) y se actualiza la referencia. La inmutabilidad del objeto se extiende así a la clave.
- Cuándo usar objetos y cuándo no
Un almacén de objetos encaja cuando los datos son:
- Inmutables o de sustitución completa: fotos, vídeos, PDF, backups, ficheros de eventos cerrados, artefactos de compilación, modelos entrenados.
- Grandes en volumen total, con acceso por clave conocida o por prefijo.
- Servidos por HTTP a navegadores, apps o a otros servicios, a menudo con CDN delante.
- Tolerantes a decenas de milisegundos por operación.
Y no encaja cuando hace falta:
- Semántica de ficheros:
seeky escritura parcial,append, bloqueos, directorios reales. Los sistemas que montan S3 como disco (s3fs, Mountpoint) funcionan para lecturas, pero cada escritura reescribe el objeto entero. - Renombrado atómico.
mv fotos/tmp/x.jpg fotos/queso-curado/original.jpgen un sistema de ficheros es una operación atómica de metadatos; en S3 esCopyObject+DeleteObject, dos operaciones, sin atomicidad y con coste proporcional al tamaño. Mover un "directorio" de un millón de objetos es un millón de copias. Muchos motores de procesamiento (Módulo 5) tuvieron problemas por esto al pasar de HDFS a S3, y por eso existen formatos de tabla (Iceberg, Delta) que evitan renombrar. - Latencia baja y muchas operaciones pequeñas: leer 10 000 objetos de 1 KB son 10 000 peticiones HTTP; una base de datos (04-04) o una caché (04-05) lo hace mil veces más rápido.
- Listados frecuentes y grandes:
LISTestá paginado a 1000 y es la operación más lenta; la aplicación debe guardar en su base de datos qué objetos existen, no descubrirlos listando. - Datos que cambian a menudo: cada cambio es una versión nueva del objeto completo.
- Coste: almacenamiento, operaciones y egress
Los almacenes de objetos en la nube cobran por tres conceptos, y el tercero sorprende a muchos equipos:
| Concepto | Orden de magnitud (S3 estándar, 2026) | Comentario |
|---|---|---|
| Almacenamiento | 0,02 €/GB·mes | 1 TB de fotos ≈ 20 €/mes; las clases frías bajan a 0,004 €/GB·mes |
| Operaciones | 5 €/millón de PUT/LIST; 0,4 €/millón de GET |
Un millón de fotos subidas ≈ 5 €; los LIST son caros y lentos |
| Salida de datos (egress) | 0,05–0,09 €/GB hacia internet | Servir 1 TB de fotos al mes desde el bucket ≈ 50–90 €, más que guardarlo |
El egress es la razón principal de poner una CDN delante (08-04): la CDN cachea las fotos en sus bordes y el bucket solo paga el primer envío a cada borde. También es la razón de que mover datos entre nubes sea caro y de que el lago de datos (04-02) y el cómputo (Módulo 5) deban estar en la misma región. Algunos proveedores (R2, B2) no cobran egress, y un MinIO propio cambia el modelo: no hay coste por GB servido, pero hay discos, servidores y personal que operarlos.
- Objetos en Kilómetro Cero: fotos, facturas y backups
Tres tipos de datos de la plataforma se van al almacén de objetos:
| Dato | Bucket | Clave | Quién escribe | Quién lee | Versionado | Ciclo de vida |
|---|---|---|---|---|---|---|
| Fotos de productos (original + miniaturas 300 y 800 px) | km0-fotos |
fotos/<slug>/original.jpg, fotos/<slug>/miniatura-300.jpg |
Productores (vía URL prefirmada de PUT), un proceso de miniaturas |
La web (vía URL prefirmada de GET, luego CDN) |
Sí | Versiones no actuales > 30 días → borrar; miniaturas antiguas → clase fría |
| Facturas en PDF | km0-facturas |
facturas/2026/09/P-2026-000123.pdf |
pedidos, al confirmar |
El cliente desde "mis pedidos" (URL prefirmada) | No (inmutables por ley) | Retención legal: bloqueo de objetos (object lock) 5 años |
| Backups de PostgreSQL | km0-backups |
postgres/km0_inventario/2026-09-14T02:00.dump |
Trabajo nocturno (07-03 verá cómo) | Restauraciones | No | > 30 días → clase fría; > 1 año → borrar |
Las fotos son el caso principal. El flujo completo, desde que la Quesería Montblanc elige una foto en su panel hasta que Ana la ve en la web:
sequenceDiagram
participant Q as Panel de la<br/>Quesería Montblanc
participant C as catalogo
participant M as MinIO<br/>(km0-fotos)
participant T as Generador de<br/>miniaturas
participant W as Web (navegador de Ana)
Q->>C: quiero subir foto de queso-curado (jpeg, 1,8 MB)
C->>C: valida producto y productor; genera clave fotos/queso-curado/original.jpg
C-->>Q: URL prefirmada de PUT (10 min)
Q->>M: PUT directo con la foto y metadatos
M-->>Q: 200 + ETag + VersionId
Q->>C: subida completada (ETag)
C->>C: guarda en km0_catalogo la clave y el ETag; publica foto.subida
T->>M: GET original.jpg
T->>M: PUT miniatura-300.jpg, miniatura-800.jpg
W->>C: GET /producto/queso-curado
C->>C: firma URL de GET de miniatura-800.jpg (15 min)
C-->>W: HTML con la URL prefirmada
W->>M: GET miniatura-800.jpg (firma válida)
M-->>W: 200, imagen (Cache-Control)
catalogo nunca toca los bytes de la foto: firma, valida y guarda referencias. Es el patrón habitual y el que permite que el almacén, no el servicio, absorba el ancho de banda.
- Práctica: MinIO,
mc y servicios/catalogo/fotos.py con boto3
mc y servicios/catalogo/fotos.py con boto3MinIO es un almacén de objetos de código abierto compatible con S3, escrito en Go, que se despliega en un solo binario. En producción se ejecuta en modo distribuido (varios nodos, varios discos por nodo, codificación de borrado automática); para km0/ basta un nodo con un volumen. Añadimos al docker-compose.yml:
# km0/docker-compose.yml (fragmento)
services:
minio:
image: minio/minio:RELEASE.2026-06-01T00-00-00Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: km0admin
MINIO_ROOT_PASSWORD: km0secreto123 # en 06-04 lo moveremos a un gestor de secretos
ports:
- "9000:9000" # API S3
- "9001:9001" # consola web
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
minio-init: # crea el bucket y las reglas una sola vez
image: minio/mc:latest
depends_on:
minio: { condition: service_healthy }
entrypoint: >
/bin/sh -c "
mc alias set km0 http://minio:9000 km0admin km0secreto123 &&
mc mb --ignore-existing km0/km0-fotos km0/km0-facturas km0/km0-backups &&
mc version enable km0/km0-fotos &&
mc ilm rule add km0/km0-fotos --noncurrent-expire-days 30 &&
mc anonymous set none km0/km0-fotos &&
echo listo"
volumes:
minio-data:mc (MinIO Client) es la CLI, que funciona con cualquier S3. Los comandos útiles desde tu máquina:
mc alias set km0 http://localhost:9000 km0admin km0secreto123
mc ls km0 # buckets
mc cp queso-curado.jpg km0/km0-fotos/fotos/queso-curado/original.jpg \
--attr "productor=queseria-montblanc;producto=queso-curado"
mc ls --versions km0/km0-fotos/fotos/queso-curado/ # versiones de cada clave
mc stat km0/km0-fotos/fotos/queso-curado/original.jpg # metadatos, ETag, VersionId
mc ilm rule ls km0/km0-fotos # reglas de ciclo de vida
mc share download --expire 15m km0/km0-fotos/fotos/queso-curado/original.jpg # URL prefirmada
mc admin info km0 # discos, uso, estado del clústerboto3 es el SDK oficial de AWS para Python y, gracias a la compatibilidad de MinIO, funciona con solo cambiar endpoint_url. El módulo servicios/catalogo/fotos.py concentra todo lo que catalogo necesita:
# km0/servicios/catalogo/fotos.py
"""Gestión de fotos de productos en el almacén de objetos (MinIO / S3)."""
import hashlib
import os
import boto3
from botocore.config import Config
BUCKET = "km0-fotos"
s3 = boto3.client(
"s3",
endpoint_url=os.getenv("S3_ENDPOINT", "http://localhost:9000"),
aws_access_key_id=os.getenv("S3_ACCESS_KEY", "km0admin"),
aws_secret_access_key=os.getenv("S3_SECRET_KEY", "km0secreto123"),
region_name="eu-west-1", # MinIO lo ignora; boto3 lo exige para firmar
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def clave_foto(slug: str, variante: str = "original", ext: str = "jpg") -> str:
return f"fotos/{slug}/{variante}.{ext}"
def subir_foto(slug: str, ruta_local: str, productor: str, variante: str = "original") -> dict:
"""Sube una foto con metadatos y verifica la integridad comparando el ETag con el MD5."""
with open(ruta_local, "rb") as f:
datos = f.read()
md5 = hashlib.md5(datos).hexdigest()
clave = clave_foto(slug, variante)
resp = s3.put_object(
Bucket=BUCKET, Key=clave, Body=datos,
ContentType="image/jpeg",
CacheControl="public, max-age=31536000, immutable", # la CDN y el navegador podrán cachearla un año
Metadata={"productor": productor, "producto": slug, "variante": variante},
Tagging="tipo=original" if variante == "original" else "tipo=miniatura", # la regla de ciclo de vida filtra por este tag
)
etag = resp["ETag"].strip('"')
if etag != md5: # subida simple: ETag == MD5 del contenido
raise RuntimeError(f"integridad: etag {etag} != md5 {md5}")
return {"clave": clave, "etag": etag, "version_id": resp.get("VersionId")}
def url_descarga(slug: str, variante: str = "miniatura-800", minutos: int = 15) -> str:
"""URL prefirmada de GET que la web incrusta en el HTML; caduca en `minutos`."""
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": clave_foto(slug, variante)},
ExpiresIn=minutos * 60,
)
def url_subida(slug: str, productor: str, minutos: int = 10) -> str:
"""URL prefirmada de PUT para que el productor suba directamente, sin pasar por catalogo."""
return s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": clave_foto(slug), "ContentType": "image/jpeg",
"Metadata": {"productor": productor, "producto": slug}},
ExpiresIn=minutos * 60,
)
def listar_fotos(slug: str) -> list[dict]:
"""Lista las variantes de un producto (LIST por prefijo, paginado)."""
paginador = s3.get_paginator("list_objects_v2")
resultado = []
for pagina in paginador.paginate(Bucket=BUCKET, Prefix=f"fotos/{slug}/"):
for obj in pagina.get("Contents", []):
resultado.append({"clave": obj["Key"], "bytes": obj["Size"], "etag": obj["ETag"].strip('"')})
return resultado
def versiones(slug: str, variante: str = "original") -> list[dict]:
resp = s3.list_object_versions(Bucket=BUCKET, Prefix=clave_foto(slug, variante))
return [{"version_id": v["VersionId"], "actual": v["IsLatest"], "fecha": v["LastModified"], "bytes": v["Size"]}
for v in resp.get("Versions", [])]
def restaurar_version(slug: str, version_id: str, variante: str = "original") -> str:
"""Recupera una versión anterior copiándola sobre sí misma: se convierte en la actual."""
clave = clave_foto(slug, variante)
resp = s3.copy_object(Bucket=BUCKET, Key=clave,
CopySource={"Bucket": BUCKET, "Key": clave, "VersionId": version_id},
MetadataDirective="COPY")
return resp["VersionId"]
def configurar_ciclo_vida() -> None:
"""Miniaturas de más de 90 días a clase fría; versiones no actuales borradas a los 30 días;
subidas multipart abandonadas abortadas a los 7 días."""
s3.put_bucket_lifecycle_configuration(
Bucket=BUCKET,
LifecycleConfiguration={"Rules": [
{"ID": "miniaturas-frias", "Status": "Enabled",
"Filter": {"Tag": {"Key": "tipo", "Value": "miniatura"}}, # solo miniaturas: los originales no se mueven
"Transitions": [{"Days": 90, "StorageClass": "FRIO"}]},
{"ID": "versiones-antiguas", "Status": "Enabled", "Filter": {"Prefix": ""},
"NoncurrentVersionExpiration": {"NoncurrentDays": 30}},
{"ID": "multipart-abandonadas", "Status": "Enabled", "Filter": {"Prefix": ""},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}},
]},
)
if __name__ == "__main__":
r = subir_foto("queso-curado", "muestras/queso-curado.jpg", productor="queseria-montblanc")
print("subida:", r)
print("descarga (15 min):", url_descarga("queso-curado", "original"))
print("fotos de queso-curado:", listar_fotos("queso-curado"))
r2 = subir_foto("queso-curado", "muestras/queso-curado-v2.jpg", productor="queseria-montblanc")
for v in versiones("queso-curado"):
print(" versión", v)
print("restaurada como:", restaurar_version("queso-curado", r["version_id"]))Comentarios sobre las partes menos evidentes:
Config(signature_version="s3v4", s3={"addressing_style": "path"}): MinIO local no tiene DNS con comodines, así que el bucket va en el path (/km0-fotos/…) y no en el host (km0-fotos.localhost). Signature V4 es la que usan las URLs prefirmadas modernas.subir_fotocomprueba elETagcontra el MD5 local: si la red corrompió algo, se detecta al momento. Conput_object(subida simple) esta igualdad se cumple; con multipart no, como vimos en el apartado 3. Para fotos de más de 100 MB, usas3.upload_file, que hace multipart automáticamente con reintentos por parte.CacheControl: immutable: como prometimos no sobrescribir contenido que la CDN vaya a cachear, podemos dejar cachear un año. Cuando la quesería cambie la foto, la nueva versión tendrá otroVersionIdy, en la práctica de Kilómetro Cero, la web referenciaráoriginal.jpg?v=<etag>para que la URL cambie.url_descargano habla con el servidor:generate_presigned_urlcalcula la firma localmente con la clave secreta. Es instantáneo y no consume una operación del bucket. Nota que elExpiresInno puede superar 7 días con Signature V4.url_subidaincluyeContentTypeyMetadataen los parámetros firmados: el productor debe enviar exactamente esas cabeceras, o la firma no cuadra. Es la manera de forzar que la subida lleve los metadatos quecatalogodecidió.restaurar_versionmuestra el truco de "modificar" un objeto inmutable: copiarlo sobre sí mismo desde la versión deseada, lo que crea una versión nueva con el contenido antiguo.configurar_ciclo_vida:StorageClassen MinIO se refiere a un nivel de tiering definido conmc ilm tier add(por ejemplo, otro MinIO con discos lentos o un S3 Glacier); en S3 se usaríaSTANDARD_IAoGLACIER. La regla de multipart abandonadas evita pagar por partes huérfanas que nadie ve en los listados.
Prueba rápida en dos líneas:
pip install boto3
python -m servicios.catalogo.fotos # sube, lista, versiona, restaura
curl -sI "$(python -c 'from servicios.catalogo.fotos import url_descarga; print(url_descarga("queso-curado","original"))')" | head -5El curl -I devuelve 200 OK con Content-Type: image/jpeg, ETag, x-amz-meta-productor: queseria-montblanc y Cache-Control. Espera 15 minutos y repite: 403 Forbidden con Request has expired. Esa es la URL prefirmada funcionando.
Errores Comunes y Consejos
- Pensar en carpetas. No existen.
LISTpor prefijo es lento y paginado; guarda en la base de datos qué claves tiene cada producto en lugar de listar el bucket en cada página vista. - Sobrescribir claves que una CDN o un navegador cachean. El bucket será consistente; las cachés no. Cambia la clave (hash o versión en el nombre) y actualiza la referencia.
- Exponer credenciales al navegador o al productor. Nunca. Firma URLs desde el servicio, con caducidad corta y los parámetros que quieres forzar incluidos en la firma.
- Buckets públicos "para que funcione". Un bucket público con
LISThabilitado es la fuga de datos más común de la nube.mc anonymous set noney URLs prefirmadas o CDN con firma. - Usar
renameo mover prefijos grandes. No hay renombrado: es copia + borrado por objeto. Diseña las claves para no tener que moverlas. - Confiar en el ETag como MD5 de subidas multipart. No lo es. Si necesitas un hash del fichero completo, calcúlalo y guárdalo como metadato (
x-amz-meta-sha256) o usa las sumas de comprobación de objetos completos (ChecksumAlgorithm). - Olvidar el egress y las operaciones en el presupuesto. Servir fotos directamente desde el bucket a miles de usuarios cuesta más que almacenarlas; la CDN (08-04) es una decisión económica antes que de latencia.
- Sin versionado ni ciclo de vida. El versionado es un seguro barato contra borrados accidentales; el ciclo de vida evita que las versiones y las multipart abandonadas crezcan sin límite. Actívalos el primer día.
Ejercicios
Ejercicio 1. Kilómetro Cero tiene 4 200 productos con 3 variantes de foto (original 1,8 MB, miniatura-800 180 KB, miniatura-300 35 KB) y 2 000 productores. Durante la Semana del Queso Artesano la web sirve 6 millones de vistas de producto al día, cada una con una miniatura-800. (a) Calcula el almacenamiento total en el bucket y su coste mensual a 0,02 €/GB. (b) Calcula el egress diario y mensual a 0,08 €/GB si se sirve directamente desde el bucket. (c) ¿Qué porcentaje del tráfico habría que servir desde una CDN para que el egress del bucket baje al 5 % del cálculo anterior, y qué cambio en las claves lo hace posible sin riesgo de servir fotos obsoletas?
Ejercicio 2. Implementa generar_miniaturas(slug) en fotos.py: descarga original.jpg en memoria con get_object, genera las variantes de 800 y 300 px con Pillow y las sube con subir_foto reutilizando los metadatos del original (léelos con head_object). Luego describe qué puede ver la web si un usuario carga la página de queso-curado justo entre la subida del original y la de las miniaturas, y cómo evitarías mostrar una imagen rota.
Ejercicio 3. Con RS(k=4, m=2) sobre 6 discos, un objeto de 24 MB se divide en 4 fragmentos de datos de 6 MB y 2 de paridad. (a) ¿Cuántos bytes ocupa en total y cuál es el sobrecoste frente a 3 réplicas? (b) Si fallan dos discos, ¿cuántos MB hay que leer para reconstruir el objeto en una lectura, y cuántos para reparar un solo fragmento perdido de ese objeto? (c) Explica por qué MinIO, con 6 discos, usa por defecto RS(3,3) (paridad = mitad) en lugar de RS(4,2) y qué se gana y se pierde.
Soluciones
Solución 1:
(a) Por producto: 1,8 MB + 0,18 MB + 0,035 MB ≈ 2,0 MB. Total: 4 200 × 2,0 MB ≈ 8,5 GB (más versiones no actuales durante 30 días; supongamos un 20 % extra: ~10 GB). Coste: 10 GB × 0,02 € ≈ 0,20 €/mes. El almacenamiento es irrelevante.
(b) Egress diario: 6 000 000 × 180 KB ≈ 1,08 TB/día; mensual ≈ 32 TB. A 0,08 €/GB: ≈ 86 €/día, 2 600 €/mes. Trece mil veces el coste de almacenar. Las operaciones GET (6 M/día × 0,4 €/M ≈ 2,4 €/día) también superan al almacenamiento.
(c) Para que el bucket sirva solo el 5 %, la CDN debe atender el 95 % de las peticiones desde caché (hit ratio ≥ 95 %), lo que es realista con 4 200 objetos pequeños y Cache-Control: max-age=31536000, immutable. La condición para que ese max-age de un año sea seguro es que la clave (o la URL) cambie cuando cambie el contenido: fotos/queso-curado/miniatura-800.jpg?v=<etag> o fotos/queso-curado/<etag>/miniatura-800.jpg. Con claves inmutables no hay que invalidar la CDN nunca; se detallará en 08-04.
Solución 2:
from io import BytesIO
from PIL import Image
def generar_miniaturas(slug: str, anchos=(800, 300)) -> list[dict]:
clave = clave_foto(slug, "original")
cabecera = s3.head_object(Bucket=BUCKET, Key=clave)
meta = cabecera["Metadata"] # productor, producto, variante
original = s3.get_object(Bucket=BUCKET, Key=clave)["Body"].read()
imagen = Image.open(BytesIO(original)).convert("RGB")
resultados = []
for ancho in anchos:
copia = imagen.copy()
copia.thumbnail((ancho, ancho * 10)) # conserva la proporción, limita el ancho
buf = BytesIO(); copia.save(buf, "JPEG", quality=85, optimize=True)
ruta_tmp = f"/tmp/{slug}-{ancho}.jpg"
with open(ruta_tmp, "wb") as f: f.write(buf.getvalue())
resultados.append(subir_foto(slug, ruta_tmp, productor=meta["productor"], variante=f"miniatura-{ancho}"))
return resultados(Se reutiliza subir_foto, que ya fija variante en los metadatos.) Entre la subida del original y la de las miniaturas, el bucket contiene original.jpg pero no miniatura-800.jpg: la web, que pide una URL prefirmada de la miniatura, obtendría una URL válida pero el GET devolvería 404 NoSuchKey y el navegador mostraría la imagen rota. El almacén es consistente por objeto, pero no hay transacción entre objetos (apartado 5). Solución: catalogo no marca la foto como "disponible" en km0_catalogo (ni publica foto.subida para la web) hasta que el generador confirma las miniaturas; mientras tanto la web muestra la foto anterior o una imagen de relleno. Alternativa más robusta: el generador sube las miniaturas antes de que se actualice la referencia, y la referencia (en la base de datos) es el único "commit" visible.
Solución 3:
(a) 4 × 6 MB + 2 × 6 MB = 36 MB para 24 MB útiles: sobrecoste del 50 %. Con 3 réplicas serían 72 MB (200 %): la codificación de borrado ahorra la mitad del disco tolerando las mismas 2 pérdidas.
(b) Para leer el objeto hacen falta 4 fragmentos cualesquiera: 24 MB leídos de 4 discos (con los dos fallos, exactamente los 4 supervivientes). Para reparar un solo fragmento perdido de 6 MB hay que leer 4 fragmentos (24 MB) y recalcular: el coste de reparación es k veces el tamaño del fragmento, que es la penalización característica del erasure coding frente a la réplica (donde reparar 6 MB cuesta leer 6 MB).
(c) Con RS(3,3), cada objeto tolera la pérdida de 3 discos de 6 (la mitad), con sobrecoste del 100 %; con RS(4,2) tolera 2, con sobrecoste del 50 %. MinIO elige por defecto la configuración más segura (y con 6 discos, dos fallos simultáneos no son tan raros durante una ventana de reparación larga), y deja que el operador baje la paridad (MINIO_STORAGE_CLASS_STANDARD=EC:2) cuando prefiere capacidad. Se gana tolerancia a fallos y lecturas que necesitan solo 3 fragmentos; se pierde el 50 % adicional de disco.
Conclusión
El almacenamiento de objetos cambia el contrato del almacenamiento: buckets y claves planas en lugar de directorios, objetos inmutables que se reemplazan enteros, metadatos que viajan con los datos, versionado y reglas de ciclo de vida que el sistema aplica solo. La API S3 (PUT/GET/HEAD/LIST, multipart, ETag, URLs prefirmadas) es el estándar que MinIO, Ceph y el resto implementan. La durabilidad de once nueves se consigue combinando replicación con codificación de borrado, donde Reed-Solomon reconstruye un objeto a partir de cualesquiera k de sus k+m fragmentos con un sobrecoste del 33–50 % en lugar del 200 % de tres copias, a cambio de lecturas y reparaciones más costosas. S3 y MinIO son hoy fuertemente consistentes por objeto, pero no hay transacciones entre objetos ni renombrado atómico, y las cachés delante del bucket devuelven la consistencia eventual, por lo que la buena práctica es no sobrescribir claves. El egress, no el almacenamiento, domina la factura, y esa es la razón económica de la CDN que veremos en 08-04. Kilómetro Cero ha destinado a objetos sus fotos (km0-fotos, con fotos.py, subida directa de la Quesería Montblanc por URL prefirmada y servicio a la web con URLs de 15 minutos), sus facturas y sus backups, y lo ha montado con MinIO y mc en docker-compose.yml.
Ya tenemos dónde guardar ficheros masivos (HDFS) y ficheros que se sirven (objetos). Falta el almacenamiento que mueve la plataforma cada segundo: registros pequeños, consultados y actualizados con baja latencia, con transacciones y con las garantías de consistencia que elegimos en 03-02. Ese es el terreno de las bases de datos distribuidas, donde pedidos migrará a Cassandra, inventario se quedará en PostgreSQL, y el anillo de hashing consistente de 04-01 reaparecerá con réplicas y niveles de consistencia por operación.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
