En la lección anterior llegamos a una conclusión incómoda: las instancias de un grupo gestionado son desechables, pueden desaparecer en cualquier momento y por tanto nada importante puede vivir en su disco. AlpinaShop tiene ahora mismo 60 GB de imágenes de producto en el disco local de un servidor físico alquilado, sin copia de seguridad real, servidas por el mismo proceso que renderiza el catálogo. Esta lección resuelve ese problema.

Cloud Storage es el almacenamiento de objetos de Google Cloud: un servicio donde guardas ficheros —imágenes, vídeos, copias de seguridad, exportaciones, logs— con durabilidad extrema, sin gestionar discos ni servidores, y accesibles por HTTPS desde cualquier parte. Es probablemente el servicio más transversal de toda la plataforma: aparecerá otra vez en las copias de Cloud SQL (02-03), como origen de datos de BigQuery (04-01), como almacén de artefactos en los pipelines (06-01) y como origen del CDN (03-03).

En esta lección entenderás el modelo de objetos y por qué los "directorios" son una ilusión; elegirás ubicación y clase de almacenamiento con criterio de coste; migrarás realmente los 60 GB al bucket alpinashop-catalogo; asegurarás el acceso con IAM uniforme y URLs firmadas; automatizarás el ahorro con reglas de ciclo de vida; y conectarás la aplicación Flask a Storage para subir la imagen de un producto nuevo.

Contenido

  1. El modelo de objetos: buckets, objetos y la ilusión de las carpetas
  2. Nombres de bucket y ubicación
  3. Clases de almacenamiento y su coste real
  4. Migrar los 60 GB de AlpinaShop
  5. Control de acceso: IAM uniforme, ACL y buckets públicos
  6. URLs firmadas para contenido privado
  7. Versionado de objetos
  8. Reglas de ciclo de vida
  9. Retención y bloqueo de objetos
  10. Metadatos y Cache-Control
  11. Notificaciones a Pub/Sub
  12. Acceso desde la aplicación Flask con la biblioteca de Python
  13. Coste de egress y consejos de diseño

  1. El modelo de objetos: buckets, objetos y la ilusión de las carpetas

Cloud Storage no es un sistema de ficheros. Tiene exactamente dos niveles:

  • Bucket: el contenedor. Se crea una vez, tiene un nombre único en todo Google Cloud, una ubicación fija y una clase por defecto.
  • Objeto: el fichero. Tiene un nombre (que puede contener barras), un contenido binario y unos metadatos.

Y nada más. No existen los directorios. Cuando ves esto en la consola:

gs://alpinashop-catalogo/
  mochilas/
    moc-40-frontal.jpg
    moc-40-lateral.jpg
  botas/
    bot-gtx-frontal.jpg

lo que hay en realidad son tres objetos cuyos nombres completos son mochilas/moc-40-frontal.jpg, mochilas/moc-40-lateral.jpg y botas/bot-gtx-frontal.jpg. La barra / es un carácter más del nombre; la consola y las herramientas la interpretan como separador y simulan una jerarquía usando prefijos.

Consecuencias prácticas muy reales:

  • Renombrar una "carpeta" con muchos objetos no es una operación atómica: implica copiar y borrar cada objeto.
  • Una carpeta vacía no existe. Si borras todos los objetos con el prefijo mochilas/, la carpeta desaparece de la vista.
  • Listar es una operación por prefijo. Listar gs://bucket/mochilas/ recorre los objetos que empiezan por esa cadena; en buckets con millones de objetos, la elección del prefijo importa para el rendimiento.
  • El diseño de nombres es tu esquema. Un buen convenio de prefijos sustituye a la estructura de carpetas.

Para AlpinaShop fijamos este convenio, que usaremos durante todo el curso:

gs://alpinashop-catalogo/
  productos/<sku>/original/<fichero>.jpg     # imagen original subida por el equipo
  productos/<sku>/web/<fichero>-800.webp     # versión optimizada para la tienda
  productos/<sku>/thumb/<fichero>-200.webp   # miniatura para listados
  estatico/css/, estatico/js/                # recursos de la web
  exportaciones/<yyyy>/<mm>/<dd>/            # ficheros para analítica (módulo 4)

El prefijo por fecha en exportaciones/ no es casual: facilita las reglas de ciclo de vida y las cargas particionadas en BigQuery.

Otras propiedades importantes del modelo:

  • Los objetos son inmutables. No se modifica un objeto: se sustituye por otro con el mismo nombre. No existe "escribir en el byte 500".
  • Consistencia fuerte. Tras una escritura correcta, cualquier lectura posterior devuelve la versión nueva; el listado también es consistente. Esto no siempre fue así en otras nubes, y elimina toda una categoría de errores.
  • Durabilidad anunciada de once nueves (99,999999999 %) al año. No confundas durabilidad con disponibilidad, ni con protección frente a errores humanos: si borras un objeto, se borra. Para eso está el versionado (apartado 7).

  1. Nombres de bucket y ubicación

El nombre del bucket es único globalmente, compartido con todos los clientes de Google Cloud del mundo. Si catalogo está ocupado —lo está— tendrás que elegir otro. Reglas:

  • Entre 3 y 63 caracteres, minúsculas, números, guiones, guiones bajos y puntos.
  • No puede empezar por goog ni parecerse a google.
  • No puede tener aspecto de dirección IP.
  • El nombre es visible para quien reciba una URL: no metas información sensible en él.

Como el espacio de nombres es global, la convención habitual es prefijar con el nombre de la organización o el proyecto. AlpinaShop usará alpinashop-catalogo; si estuviera ocupado, alpinashop-catalogo-prod o similar.

La ubicación es inmutable: se decide al crear el bucket y no se puede cambiar. Para moverlo hay que crear otro y copiar. Tres tipos:

Tipo Ejemplo Réplicas Disponibilidad típica Coste de almacenamiento Cuándo usarlo
Regional europe-west1 Varias zonas de una región Alta El más bajo Datos servidos desde esa región; cómputo en la misma región (sin coste de salida entre servicios)
Birregional eur4 (Países Bajos + Finlandia), o pares personalizados Dos regiones concretas Muy alta Intermedio Necesitas resistir la caída de una región completa con control de dónde están los datos
Multirregión EU, US, ASIA Varias regiones del área geográfica Máxima El más alto Contenido servido a escala continental, orígenes de CDN

Criterio para AlpinaShop: las imágenes se sirven a clientes de España y Portugal, y el cómputo (las VM del MIG, App Engine, GKE) está en europe-west1. Un bucket regional en europe-west1 es la elección correcta: mínimo coste, latencia mínima hacia el cómputo y cero cargos de salida entre servicios de la misma región. La distribución geográfica hacia el cliente final la resolveremos con Cloud CDN (03-03), no pagando un bucket multirregión.

gcloud storage buckets create gs://alpinashop-catalogo \
  --project=alpinashop-prod \
  --location=europe-west1 \
  --default-storage-class=STANDARD \
  --uniform-bucket-level-access \
  --public-access-prevention

Los dos últimos flags merecen atención desde ya: --uniform-bucket-level-access desactiva las ACL por objeto y deja que todo el acceso se gobierne con IAM (apartado 5), y --public-access-prevention impide que nadie —ni por error— pueda hacer público el bucket. Ambos son buenas prácticas de seguridad que cuesta mucho más activar después que al principio.

  1. Clases de almacenamiento y su coste real

Todos los objetos se guardan con la misma durabilidad y la misma latencia de acceso (milisegundos, incluso en Archive). Lo que cambia entre clases es el equilibrio entre lo que pagas por guardar y lo que pagas por leer.

Clase Duración mínima Coste de almacenamiento Coste de recuperación Caso de uso
Standard Ninguna El más alto (≈0,020 $/GB/mes en Europa) Ninguno Datos accedidos con frecuencia: imágenes de la tienda, web estática
Nearline 30 días ≈50 % de Standard Sí, por GB leído Acceso mensual: copias recientes, archivos históricos consultables
Coldline 90 días ≈25 % de Standard Mayor que Nearline Acceso trimestral: copias de seguridad, archivo tibio
Archive 365 días ≈10 % de Standard El más alto Acceso anual o nunca: retención legal, archivo definitivo

Los importes son órdenes de magnitud a 2026 para razonar sobre proporciones, no tarifas. Verifica siempre en la página oficial de precios de Cloud Storage y en la calculadora de Google Cloud.

La duración mínima es la trampa más frecuente. Si guardas un objeto en Coldline y lo borras a los 10 días, se te factura igualmente como si hubiera estado 90 días. Y si lo lees varias veces, el coste de recuperación puede superar con creces el ahorro de almacenamiento.

Regla práctica: el ahorro de una clase fría solo es real si el objeto casi nunca se lee y va a permanecer mucho tiempo.

Un cálculo orientativo para AlpinaShop. Sus 60 GB de imágenes, con las miniaturas y versiones web que se generarán, rondarán los 90 GB:

Escenario Almacenamiento/mes Comentario
90 GB en Standard ≈1,80 $ Trivial frente al resto de la factura
90 GB en Nearline ≈0,90 $ Ahorro de 0,90 $ que se evapora si el CDN falla y hay que releer
Copias históricas de 500 GB en Coldline ≈2,50 $ Aquí sí compensa

Conclusión: para las imágenes vivas del catálogo, Standard. Las clases frías se reservarán para las versiones antiguas y las exportaciones, mediante reglas de ciclo de vida (apartado 8). Optimizar 90 céntimos al mes mientras se paga por instancias sobredimensionadas es un mal uso del tiempo; en 07-05 sistematizaremos dónde está el dinero de verdad.

Hay también Autoclass, que mueve automáticamente cada objeto entre clases según su patrón de acceso real, sin coste de recuperación por las transiciones. Es la opción sensata cuando no sabes cómo se accederá a los datos:

gcloud storage buckets update gs://alpinashop-catalogo --enable-autoclass

  1. Migrar los 60 GB de AlpinaShop

Marta tiene las imágenes en /var/www/imagenes del servidor físico. El objetivo es dejarlas en gs://alpinashop-catalogo/productos/ sin interrumpir la tienda actual.

Paso 1: instalar el CLI y autenticarse en el servidor de origen. El servidor físico no es una VM de Google, así que necesita credenciales. Lo correcto es una cuenta de servicio con permiso solo de escritura en ese bucket:

# En Cloud Shell: crear la cuenta de servicio para la migracion
gcloud iam service-accounts create sa-migracion-catalogo \
  --display-name="Migracion inicial de imagenes"

gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member="serviceAccount:[email protected]" \
  --role="roles/storage.objectCreator"

# Generar la clave (fichero JSON) que se copiara al servidor fisico
gcloud iam service-accounts keys create ~/sa-migracion.json \
  --iam-account="[email protected]"

Dos advertencias importantes. Primera: el rol objectCreator permite crear objetos pero no leerlos ni borrarlos; si esa clave se filtrase, el daño sería limitado. Segunda: las claves JSON de cuenta de servicio son credenciales de larga vida y el activo más peligroso de un proyecto. Bórrala en cuanto termine la migración (gcloud iam service-accounts keys delete). En 03-04 y 07-04 verás alternativas sin claves (Workload Identity Federation).

# En el servidor fisico
gcloud auth activate-service-account --key-file=/root/sa-migracion.json
gcloud config set project alpinashop-prod

Paso 2: primera copia masiva. gcloud storage cp con --recursive sube el árbol completo. La versión moderna del CLI paraleliza automáticamente:

gcloud storage cp --recursive /var/www/imagenes/* \
  gs://alpinashop-catalogo/productos/

Si quieres controlar el paralelismo (por ejemplo, para no saturar la conexión de la oficina en horario de trabajo):

gcloud config set storage/process_count 8
gcloud config set storage/thread_count 4

# Y para ficheros grandes, subida compuesta en paralelo
gcloud config set storage/parallel_composite_upload_threshold 150M

La subida compuesta en paralelo divide un fichero grande en trozos que se suben simultáneamente y luego se recomponen en el servidor. Acelera mucho los ficheros de cientos de MB, pero genera objetos con un tipo de suma de verificación distinta (CRC32C compuesta), lo que puede confundir a herramientas antiguas. Para imágenes de pocos MB, como las de AlpinaShop, no aplica.

Paso 3: sincronización incremental. El día del corte no quieres volver a subir 60 GB, solo lo que haya cambiado. Para eso está rsync:

gcloud storage rsync --recursive --delete-unmatched-destination-objects \
  /var/www/imagenes gs://alpinashop-catalogo/productos

Cuidado con --delete-unmatched-destination-objects: borra en el destino todo lo que no esté en el origen. Es lo que quieres para un espejo exacto, y una catástrofe si apuntas mal el origen. Antes de ejecutarlo de verdad, usa la simulación:

gcloud storage rsync --recursive --dry-run \
  /var/www/imagenes gs://alpinashop-catalogo/productos

Paso 4: verificar. Comprueba el número de objetos y el tamaño total:

# Numero de objetos
gcloud storage ls --recursive "gs://alpinashop-catalogo/productos/**" | wc -l

# Tamaño total
gcloud storage du --summarize --readable-sizes gs://alpinashop-catalogo/productos

Cuándo no usar el CLI. Si tuvieras decenas de TB o los datos estuvieran en otra nube, la herramienta adecuada sería Storage Transfer Service, que gestiona reintentos, verificación y programación sin ocupar tu servidor; y para petabytes sin ancho de banda, Transfer Appliance, un dispositivo físico que Google te envía. Para 60 GB por una conexión decente, gcloud storage es más que suficiente: a 100 Mbps son unas 90 minutos.

  1. Control de acceso: IAM uniforme, ACL y buckets públicos

Existen dos mecanismos históricos de control de acceso, y conviene tener clarísimo cuál usar:

ACL (control de acceso por objeto) IAM uniforme a nivel de bucket
Granularidad Por objeto individual Por bucket (y por objeto vía condiciones IAM)
Dónde se define En los metadatos de cada objeto En la política IAM del bucket o del proyecto
Auditabilidad Difícil: hay que inspeccionar objeto a objeto Sencilla: una sola política que leer
Recomendación actual Evitar Usar siempre

Con uniform bucket-level access activado, las ACL dejan de tener efecto y todo se decide con IAM. Es lo que hicimos al crear el bucket, y es la recomendación firme de Google desde hace años: el motivo por el que históricamente se filtraban datos de buckets no era la falta de controles, sino que nadie era capaz de auditar millones de ACL individuales.

Roles predefinidos más usados:

Rol Permite
roles/storage.objectViewer Leer y listar objetos
roles/storage.objectCreator Crear objetos (no leer ni borrar)
roles/storage.objectUser Leer, crear, borrar objetos (no configurar el bucket)
roles/storage.objectAdmin Control total sobre los objetos
roles/storage.admin Control total sobre el bucket y sus objetos
# La aplicacion web solo necesita leer imagenes
gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member="serviceAccount:[email protected]" \
  --role="roles/storage.objectViewer"

# Lucia necesita leer las exportaciones, pero solo esas: condicion IAM por prefijo
gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member="user:[email protected]" \
  --role="roles/storage.objectViewer" \
  --condition="title=solo-exportaciones,expression=resource.name.startsWith('projects/_/buckets/alpinashop-catalogo/objects/exportaciones/')"

Esa condición IAM es un buen ejemplo de mínimo privilegio aplicado con precisión: Lucía puede leer exportaciones/ y nada más, sin necesidad de un bucket aparte. El mecanismo completo de condiciones IAM se estudia en 03-04.

Buckets públicos. Hacer un bucket legible por todo internet es tan fácil como conceder objectViewer a allUsers:

# NO hacer esto en el bucket del catalogo
gcloud storage buckets add-iam-policy-binding gs://mi-bucket \
  --member=allUsers --role=roles/storage.objectViewer

Riesgos reales:

  • Lo público es público para siempre. Una URL indexada por un buscador o guardada en caché por un tercero no se puede retirar.
  • Pagas el egress de todo el mundo. Si alguien enlaza tus imágenes desde un foro con mucho tráfico, la factura es tuya.
  • Un error de prefijo expone todo el bucket, no solo lo que pretendías.

Cuándo sí es razonable: recursos estáticos verdaderamente públicos (CSS, JS, el logotipo) en un bucket separado creado explícitamente para ello. Las imágenes de producto de AlpinaShop, aunque no sean secretas, se servirán a través del CDN con el bucket como origen privado (03-03), no abriéndolo a internet. Por eso creamos el bucket con --public-access-prevention.

  1. URLs firmadas para contenido privado

¿Cómo permite entonces AlpinaShop que un cliente descargue su factura en PDF, o que Dani comparta un archivo con un proveedor, sin hacer nada público y sin darle una cuenta de Google?

Con una URL firmada: un enlace temporal que incorpora una firma criptográfica y funciona durante un tiempo limitado. Quien la tenga puede acceder; cuando caduca, deja de funcionar.

# Enlace valido durante 15 minutos para un objeto privado
gcloud storage sign-url gs://alpinashop-catalogo/facturas/2026/F-10234.pdf \
  --duration=15m \
  --impersonate-service-account=sa-catalogo-web@alpinashop-prod.iam.gserviceaccount.com

Y desde la aplicación, que es donde se usa de verdad:

from datetime import timedelta
from google.cloud import storage

cliente = storage.Client()
bucket = cliente.bucket("alpinashop-catalogo")
blob = bucket.blob("facturas/2026/F-10234.pdf")

url = blob.generate_signed_url(
    version="v4",
    expiration=timedelta(minutes=15),
    method="GET",
    response_disposition='attachment; filename="factura-F-10234.pdf"',
)
print(url)

Detalles que importan:

  • version="v4" es el algoritmo de firma vigente; usa siempre este.
  • response_disposition fuerza la descarga con un nombre amable, en lugar de abrir el PDF en el navegador con el nombre interno del objeto.
  • La caducidad debe ser corta. Una URL firmada válida durante 7 días es prácticamente un enlace público: se reenvía por correo y sobrevive.
  • También sirve para subir (method="PUT"): el navegador del usuario sube directamente a Cloud Storage sin pasar por tu servidor, lo que ahorra ancho de banda y tiempo de proceso. Es el patrón recomendado para subidas grandes.
  • Firmar requiere una clave. Si la aplicación corre en una VM o en Cloud Run con cuenta de servicio y sin fichero de clave, necesita el permiso iam.serviceAccounts.signBlob (rol Creador de tokens de cuenta de servicio) para firmar mediante la API de IAM.

  1. Versionado de objetos

Los objetos son inmutables, pero sus nombres no: si subes otro objeto con el mismo nombre, el anterior se pierde. Y si alguien ejecuta un rsync mal apuntado, se pierde mucho más.

El versionado hace que cada sobrescritura o borrado conserve la versión anterior como versión no actual, identificada por un número de generación:

gcloud storage buckets update gs://alpinashop-catalogo --versioning

# Ver todas las versiones, incluidas las no actuales
gcloud storage ls --all-versions gs://alpinashop-catalogo/productos/MOC-40/

# Restaurar una version concreta por su numero de generacion
gcloud storage cp \
  gs://alpinashop-catalogo/productos/MOC-40/web/frontal-800.webp#1754400000000000 \
  gs://alpinashop-catalogo/productos/MOC-40/web/frontal-800.webp

El sufijo #<generación> identifica una versión específica. Restaurar consiste simplemente en copiarla encima de la actual.

El versionado es la única protección real frente al error humano —el borrado accidental, el script equivocado, el ransomware— pero tiene un coste: pagas por todas las versiones. Sin una regla de ciclo de vida que limpie las antiguas, el bucket crece indefinidamente. Van juntos, siempre.

  1. Reglas de ciclo de vida

Una regla de ciclo de vida es una política que Cloud Storage aplica automáticamente sobre los objetos que cumplen ciertas condiciones. Se define en JSON:

{
  "lifecycle": {
    "rule": [
      {
        "action": { "type": "SetStorageClass", "storageClass": "NEARLINE" },
        "condition": {
          "age": 30,
          "matchesPrefix": ["exportaciones/"],
          "matchesStorageClass": ["STANDARD"]
        }
      },
      {
        "action": { "type": "SetStorageClass", "storageClass": "COLDLINE" },
        "condition": {
          "age": 180,
          "matchesPrefix": ["exportaciones/"],
          "matchesStorageClass": ["NEARLINE"]
        }
      },
      {
        "action": { "type": "Delete" },
        "condition": {
          "daysSinceNoncurrentTime": 30,
          "numNewerVersions": 3
        }
      },
      {
        "action": { "type": "AbortIncompleteMultipartUpload" },
        "condition": { "age": 7 }
      }
    ]
  }
}

Regla por regla:

  1. A los 30 días, las exportaciones pasan a Nearline. Los informes de Lucía se consultan mucho la primera semana y casi nunca después. matchesStorageClass evita que la regla reprocese objetos ya movidos.
  2. A los 180 días pasan a Coldline. Se conservan por si hace falta reconstruir un histórico, a la cuarta parte de coste.
  3. Las versiones no actuales se borran a los 30 días de dejar de ser actuales, siempre que existan al menos 3 versiones más nuevas. La combinación de las dos condiciones es deliberada: garantiza una ventana temporal de recuperación y un número mínimo de versiones conservadas.
  4. Las subidas multiparte incompletas se abortan a los 7 días. Una subida grande interrumpida deja fragmentos que ocupan y se facturan sin aparecer al listar el bucket. Es un gasto fantasma clásico; esta regla debería estar en todos tus buckets.

Aplicación y verificación:

gcloud storage buckets update gs://alpinashop-catalogo \
  --lifecycle-file=ciclo-vida.json

gcloud storage buckets describe gs://alpinashop-catalogo \
  --format="json(lifecycle)"

Advertencias sobre el comportamiento: las reglas se evalúan una vez al día de forma asíncrona, así que no esperes efecto inmediato; y las transiciones a clases frías reinician la duración mínima, con lo que mover a Coldline algo que vas a borrar en un mes es contraproducente.

Fíjate en que en las imágenes vivas del catálogo no aplicamos ninguna transición: se leen constantemente y deben quedarse en Standard.

  1. Retención y bloqueo de objetos

Cuando la ley o un contrato exigen conservar datos durante un plazo, el versionado no basta: alguien con permisos suficientes puede borrarlo todo. Para eso existen dos mecanismos más fuertes.

Política de retención del bucket: ningún objeto puede borrarse ni sobrescribirse hasta cumplir la edad indicada.

# Retener las facturas 6 años (obligacion mercantil habitual en España)
gcloud storage buckets update gs://alpinashop-facturas \
  --retention-period=6y

Bloqueo de la política (lock): hace la retención irreversible. Ni el propietario del proyecto, ni el administrador de la organización, ni el soporte de Google pueden reducirla o quitarla. Solo se puede aumentar el plazo.

gcloud storage buckets update gs://alpinashop-facturas --lock-retention-period

Es una operación de las que no admiten arrepentimiento: si bloqueas 6 años, ese bucket no se podrá vaciar durante 6 años, y tampoco se podrá borrar mientras contenga objetos. Es exactamente su propósito (cumplimiento normativo, protección frente a un atacante con credenciales de administrador), pero exige estar completamente seguro.

Complementariamente, la retención de objetos individuales permite marcar objetos concretos con una fecha de retención, y los event-based holds y temporary holds congelan un objeto indefinidamente hasta que se libera la retención (útil, por ejemplo, ante un litigio).

  1. Metadatos y Cache-Control

Cada objeto tiene metadatos, algunos de los cuales se convierten directamente en cabeceras HTTP cuando se sirve:

Metadato Cabecera HTTP Para qué
Content-Type Content-Type Que el navegador sepa qué es. Si está mal, el navegador descarga la imagen en lugar de mostrarla
Cache-Control Cache-Control Cuánto tiempo puede cachearse, en el navegador y en el CDN
Content-Encoding Content-Encoding Contenido comprimido (gzip)
Content-Disposition Content-Disposition Forzar descarga y nombre de fichero
Metadatos personalizados x-goog-meta-* Tus propias etiquetas: SKU, fotógrafo, fecha de sesión
# Imagenes de producto: cachear un año, son inmutables por diseño
gcloud storage objects update "gs://alpinashop-catalogo/productos/**/web/*.webp" \
  --cache-control="public, max-age=31536000, immutable"

# El catalogo JSON cambia a diario: cachear poco y revalidar
gcloud storage objects update gs://alpinashop-catalogo/estatico/catalogo.json \
  --cache-control="public, max-age=300, must-revalidate"

# Metadato propio con el SKU
gcloud storage objects update gs://alpinashop-catalogo/productos/MOC-40/web/frontal-800.webp \
  --custom-metadata=sku=MOC-40,fotografo=estudio-norte

Por qué importa tanto Cache-Control. Cuando pongamos Cloud CDN delante del bucket (03-03), esta cabecera es la que decide cuánto tiempo guarda el CDN cada objeto en los puntos de presencia. Un max-age alto significa que la mayoría de las peticiones se sirven desde el borde de la red: más rápido para el cliente y mucho más barato, porque no salen del bucket.

El problema es qué ocurre cuando cambia una imagen. Por eso la técnica correcta es nombrar los objetos de forma inmutable: si el fichero incluye un hash o una versión (frontal-800.a3f9c1.webp), nunca se sobrescribe, y publicar una imagen nueva es publicar un nombre nuevo. Así puedes cachear un año sin miedo. Es la misma idea que ya usaste sin saberlo al versionar las plantillas de instancia en la lección anterior.

Por defecto, los objetos de Cloud Storage se sirven con Cache-Control: public, max-age=3600, lo que rara vez es lo que quieres. Establécelo explícitamente al subir.

  1. Notificaciones a Pub/Sub

Cloud Storage puede publicar un mensaje en un topic de Pub/Sub cada vez que un objeto se crea, se borra, se archiva o cambian sus metadatos. Es la base de las arquitecturas dirigidas por eventos:

gcloud storage buckets notifications create gs://alpinashop-catalogo \
  --topic=imagenes-subidas \
  --event-types=OBJECT_FINALIZE \
  --object-prefix=productos/ \
  --payload-format=json

El caso de uso natural para AlpinaShop: cuando el equipo sube una imagen original a productos/<sku>/original/, se publica un evento, y un consumidor genera automáticamente la versión web y la miniatura. El evento OBJECT_FINALIZE se dispara cuando la escritura se ha completado, no cuando empieza.

No lo desarrollamos aquí: Pub/Sub tiene su propia lección (04-04) y el consumidor natural sería una Cloud Function (06-03). Quédate con la idea de que el bucket no es un almacén pasivo, sino una fuente de eventos.

  1. Acceso desde la aplicación Flask con la biblioteca de Python

Llega el momento de conectar el catálogo de AlpinaShop con el bucket. Instalación:

pip install google-cloud-storage

Sobre la autenticación: el código no lleva contraseñas ni rutas a ficheros de clave. La biblioteca usa las Application Default Credentials que vimos en 01-06, y las resuelve por este orden: la variable GOOGLE_APPLICATION_CREDENTIALS si existe, luego las credenciales de gcloud auth application-default login en tu portátil, y finalmente —cuando el código corre en una VM, en GKE o en Cloud Run— la cuenta de servicio adjunta, obtenida del servidor de metadatos. El mismo código funciona en local y en producción sin cambios. Este es uno de los grandes aciertos del modelo de Google Cloud.

import os
import uuid
from flask import Flask, request, redirect, render_template_string
from google.cloud import storage
from werkzeug.utils import secure_filename

app = Flask(__name__)

BUCKET = os.environ.get("BUCKET_CATALOGO", "alpinashop-catalogo")
EXTENSIONES_OK = {".jpg", ".jpeg", ".png", ".webp"}

# El cliente se crea UNA vez al arrancar, no en cada peticion:
# abre conexiones reutilizables y resuelve credenciales, lo que es costoso.
cliente = storage.Client()
bucket = cliente.bucket(BUCKET)


@app.route("/producto/<sku>/imagen", methods=["POST"])
def subir_imagen(sku):
    fichero = request.files.get("imagen")
    if fichero is None or fichero.filename == "":
        return "Falta el fichero", 400

    nombre = secure_filename(fichero.filename)
    extension = os.path.splitext(nombre)[1].lower()
    if extension not in EXTENSIONES_OK:
        return f"Extension no permitida: {extension}", 400

    # Nombre inmutable: nunca se sobrescribe, se puede cachear un año
    destino = f"productos/{sku}/original/{uuid.uuid4().hex}{extension}"
    blob = bucket.blob(destino)

    blob.cache_control = "public, max-age=31536000, immutable"
    blob.metadata = {"sku": sku, "subido-por": "panel-interno"}

    # upload_from_file recibe el flujo directamente: no se escribe en disco local,
    # algo esencial en instancias efimeras como las del MIG de la leccion 02-01.
    blob.upload_from_file(fichero.stream, content_type=fichero.mimetype)

    return {"objeto": destino, "uri": f"gs://{BUCKET}/{destino}"}, 201


@app.route("/producto/<sku>/imagenes")
def listar_imagenes(sku):
    # list_blobs con prefijo: la forma correcta de "listar una carpeta"
    blobs = cliente.list_blobs(BUCKET, prefix=f"productos/{sku}/web/")
    urls = [
        b.generate_signed_url(version="v4", expiration=900, method="GET")
        for b in blobs
    ]
    html = "".join(f'<img src="{u}" width="200">' for u in urls)
    return render_template_string(f"<h2>{sku}</h2>{html}")

Puntos didácticos del código:

  • storage.Client() fuera de la vista. Crearlo en cada petición es un error de rendimiento habitual: implica resolver credenciales y abrir conexiones nuevas cada vez.
  • Validación de extensión y secure_filename. Nunca construyas el nombre del objeto directamente con lo que envía el usuario: podría contener ../ o caracteres inesperados.
  • upload_from_file con el flujo. Se sube en streaming sin tocar el disco de la instancia, que puede desaparecer en cualquier momento.
  • Nombre con UUID. Evita colisiones y hace el objeto inmutable, habilitando el cacheo agresivo del apartado 10.
  • URLs firmadas al listar. El bucket es privado; las imágenes se muestran con enlaces temporales. En producción, esto lo reemplazará el CDN con origen privado (03-03).

Descargar un objeto es igual de directo:

blob = bucket.blob("exportaciones/2026/08/pedidos.csv")

contenido = blob.download_as_text()          # a memoria, como texto
blob.download_to_filename("/tmp/pedidos.csv")  # a disco

if blob.exists():
    blob.reload()  # refresca los metadatos desde el servidor
    print(blob.size, blob.updated, blob.content_type, blob.storage_class)

  1. Coste de egress y consejos de diseño

El almacenamiento en Cloud Storage es barato. Lo que sorprende en la factura es casi siempre el egress: el tráfico que sale de Google Cloud hacia internet.

Tipo de tráfico Coste orientativo
Ingress (subir datos a GCP) Gratuito
Bucket → servicio GCP en la misma región Gratuito
Bucket → servicio GCP en otra región del mismo continente Bajo, por GB
Bucket → internet (Europa/Norteamérica) ≈0,08–0,12 $/GB, con descuentos por volumen
Bucket → internet a través de Cloud CDN Menor que el egress directo, y muchas peticiones ni siquiera llegan al bucket
Operaciones (clase A: escrituras y listados; clase B: lecturas) Céntimos por cada 10.000, pero relevante con millones de objetos pequeños

Un ejemplo orientativo para AlpinaShop: si la tienda sirve 200 GB de imágenes al mes directamente desde el bucket, el egress ronda los 16–24 $/mes, frente a menos de 2 $ de almacenamiento. Es decir, mover los datos cuesta diez veces más que guardarlos. Por eso el CDN no es un lujo de rendimiento, sino también una medida de coste.

Consejos de diseño que se derivan de todo lo anterior:

  • Coloca el cómputo en la misma región que el bucket. europe-west1 en ambos casos: cero coste de transferencia entre servicios.
  • Sirve las imágenes a través del CDN con Cache-Control largo y nombres inmutables.
  • Optimiza el peso antes de subir. Convertir a WebP y redimensionar reduce el egress proporcionalmente; es la optimización de coste más rentable de esta lección.
  • Vigila los objetos muy pequeños. Millones de ficheros diminutos pagan más en operaciones que en almacenamiento; a veces conviene agruparlos.
  • Activa siempre la regla que aborta subidas multiparte incompletas.
  • Etiqueta los buckets con entorno, equipo, centro-coste y aplicacion para poder repartir el gasto (01-04).

Errores Comunes y Consejos

  • Pensar en carpetas. No existen. Renombrar un prefijo con 50.000 objetos es copiar y borrar 50.000 objetos.
  • Elegir mal la ubicación. Es inmutable. Un bucket multirregión EU para servir a clientes españoles cuesta más sin aportar nada frente a europe-west1 + CDN.
  • Poner todo en Coldline "para ahorrar". Si los objetos se leen, el coste de recuperación y las duraciones mínimas te salen más caros que Standard.
  • Activar versionado sin regla de ciclo de vida. El bucket crece para siempre y nadie se da cuenta hasta la factura.
  • Hacer público un bucket para "probar rápido". Lo público se indexa, y desactivar el acceso no borra lo que ya se copió.
  • Usar ACL por objeto. Imposible de auditar. Activa acceso uniforme y trabaja con IAM.
  • URLs firmadas con caducidad de días. Equivalen a enlaces públicos: se reenvían.
  • Olvidar Cache-Control. El valor por defecto de 1 hora arruina la eficacia del CDN.
  • Crear el cliente de Storage en cada petición de Flask. Penalización de latencia innecesaria en todas las peticiones.
  • Consejo: usa --dry-run antes de cualquier rsync con borrado.
  • Consejo: nombra los objetos de forma inmutable (hash o UUID). Simplifica el cacheo, el versionado y las publicaciones.
  • Consejo: borra las claves JSON de cuenta de servicio en cuanto termine la tarea puntual que las justificó.
  • Consejo: revisa el gasto por bucket con la exportación de facturación a BigQuery que configuraste en 01-04.

Ejercicios

Ejercicio 1: crear y organizar el bucket del catálogo

  1. Crea un bucket con nombre único (alpinashop-catalogo-<tuiniciales>) en europe-west1, clase Standard, acceso uniforme y prevención de acceso público.
  2. Crea localmente una estructura que simule tres productos con una imagen cada uno y súbela respetando el convenio productos/<sku>/original/.
  3. Comprueba que no existen "carpetas" reales: lista los objetos con su nombre completo.
  4. Calcula el tamaño total y el número de objetos.
  5. Aplica Cache-Control de un año a todas las imágenes bajo productos/.

Ejercicio 2: versionado, ciclo de vida y recuperación

  1. Activa el versionado en tu bucket.
  2. Sobrescribe una de las imágenes con contenido distinto y lista todas las versiones.
  3. Restaura la versión original por su número de generación.
  4. Escribe y aplica un fichero de ciclo de vida que: mueva a Nearline lo que esté bajo exportaciones/ con más de 30 días, borre versiones no actuales con más de 15 días conservando al menos 2 más nuevas, y aborte subidas multiparte incompletas a los 7 días.
  5. Verifica la configuración aplicada.

Ejercicio 3: acceso privado y aplicación Flask

  1. Crea una cuenta de servicio sa-tienda-lectura con solo permiso de lectura de objetos sobre tu bucket.
  2. Genera una URL firmada válida 10 minutos para una de las imágenes y compruébala con curl.
  3. Comprueba que la URL directa (sin firma) devuelve 403.
  4. Escribe un endpoint Flask que reciba un fichero por POST, valide la extensión, lo suba con nombre inmutable bajo productos/<sku>/original/ y devuelva una URL firmada de 5 minutos.
  5. Explica por qué el código no contiene ninguna credencial.

Soluciones

Solución 1

BUCKET="alpinashop-catalogo-jcm"

gcloud storage buckets create "gs://$BUCKET" \
  --location=europe-west1 \
  --default-storage-class=STANDARD \
  --uniform-bucket-level-access \
  --public-access-prevention

# 2. Estructura local y subida
mkdir -p ~/catalogo/{MOC-40,BOT-GTX,TDA-2P}
for sku in MOC-40 BOT-GTX TDA-2P; do
  echo "imagen simulada de $sku" > ~/catalogo/$sku/frontal.jpg
  gcloud storage cp ~/catalogo/$sku/frontal.jpg \
    "gs://$BUCKET/productos/$sku/original/frontal.jpg"
done

# 3. Nombres completos: no hay carpetas
gcloud storage ls --recursive "gs://$BUCKET/**"

# 4. Tamaño y numero de objetos
gcloud storage du --summarize --readable-sizes "gs://$BUCKET"
gcloud storage ls --recursive "gs://$BUCKET/**" | wc -l

# 5. Cache-Control
gcloud storage objects update "gs://$BUCKET/productos/**" \
  --cache-control="public, max-age=31536000, immutable"

En el punto 3 verás salidas del tipo gs://<bucket>/productos/MOC-40/original/frontal.jpg: un único objeto cuyo nombre contiene barras. No hay ninguna entidad "carpeta" que puedas describir o a la que asignar permisos.

Solución 2

# 1. Versionado
gcloud storage buckets update "gs://$BUCKET" --versioning

# 2. Sobrescribir y listar versiones
echo "version modificada" > /tmp/frontal.jpg
gcloud storage cp /tmp/frontal.jpg "gs://$BUCKET/productos/MOC-40/original/frontal.jpg"
gcloud storage ls --all-versions "gs://$BUCKET/productos/MOC-40/original/"

# 3. Restaurar (sustituye GEN por el numero de generacion de la version antigua)
gcloud storage cp \
  "gs://$BUCKET/productos/MOC-40/original/frontal.jpg#GEN" \
  "gs://$BUCKET/productos/MOC-40/original/frontal.jpg"
{
  "lifecycle": {
    "rule": [
      {
        "action": { "type": "SetStorageClass", "storageClass": "NEARLINE" },
        "condition": {
          "age": 30,
          "matchesPrefix": ["exportaciones/"],
          "matchesStorageClass": ["STANDARD"]
        }
      },
      {
        "action": { "type": "Delete" },
        "condition": { "daysSinceNoncurrentTime": 15, "numNewerVersions": 2 }
      },
      {
        "action": { "type": "AbortIncompleteMultipartUpload" },
        "condition": { "age": 7 }
      }
    ]
  }
}
gcloud storage buckets update "gs://$BUCKET" --lifecycle-file=ciclo-vida.json
gcloud storage buckets describe "gs://$BUCKET" --format="json(lifecycle,versioning)"

Nota importante: la restauración funciona porque el versionado estaba activo antes de sobrescribir. Activarlo después no recupera nada. Es la razón de activarlo el día que se crea el bucket, no el día que ocurre el accidente.

Solución 3

# 1. Cuenta de servicio con lectura
gcloud iam service-accounts create sa-tienda-lectura \
  --display-name="Lectura del catalogo"

gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
  --member="serviceAccount:sa-tienda-lectura@$(gcloud config get-value project).iam.gserviceaccount.com" \
  --role="roles/storage.objectViewer"

# 2. URL firmada de 10 minutos
URL=$(gcloud storage sign-url \
  "gs://$BUCKET/productos/MOC-40/original/frontal.jpg" \
  --duration=10m --format="value(signed_url)")
curl -s -o /dev/null -w "%{http_code}\n" "$URL"   # 200

# 3. URL directa sin firma
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://storage.googleapis.com/$BUCKET/productos/MOC-40/original/frontal.jpg"  # 403
# 4. Endpoint de subida con URL firmada de respuesta
import os, uuid
from datetime import timedelta
from flask import Flask, request
from google.cloud import storage
from werkzeug.utils import secure_filename

app = Flask(__name__)
cliente = storage.Client()
bucket = cliente.bucket(os.environ["BUCKET_CATALOGO"])
PERMITIDAS = {".jpg", ".jpeg", ".png", ".webp"}


@app.route("/producto/<sku>/imagen", methods=["POST"])
def subir(sku):
    f = request.files.get("imagen")
    if not f or not f.filename:
        return {"error": "falta el fichero"}, 400

    ext = os.path.splitext(secure_filename(f.filename))[1].lower()
    if ext not in PERMITIDAS:
        return {"error": f"extension no permitida: {ext}"}, 400

    destino = f"productos/{sku}/original/{uuid.uuid4().hex}{ext}"
    blob = bucket.blob(destino)
    blob.cache_control = "public, max-age=31536000, immutable"
    blob.upload_from_file(f.stream, content_type=f.mimetype)

    url = blob.generate_signed_url(
        version="v4", expiration=timedelta(minutes=5), method="GET"
    )
    return {"objeto": destino, "url": url}, 201
  1. El código no contiene credenciales porque la biblioteca cliente usa las Application Default Credentials. En el portátil de Dani son las de gcloud auth application-default login; en la VM del MIG, en GKE o en Cloud Run son las de la cuenta de servicio adjunta, que se obtienen del servidor de metadatos y se rotan solas. El mismo código, sin cambios ni ficheros de clave, funciona en los dos sitios. Ese es el modelo de identidad que profundizaremos en 03-04.

Limpieza al terminar:

gcloud storage rm --recursive "gs://$BUCKET"

Conclusión

Los 60 GB de imágenes de AlpinaShop ya no dependen del disco de un servidor físico. Has entendido que Cloud Storage no es un sistema de ficheros sino un almacén de objetos plano, donde las carpetas son una ilusión construida sobre prefijos, y has fijado un convenio de nombres (productos/<sku>/original|web|thumb/, exportaciones/<fecha>/) que sostendrá el resto del curso. Sabes que el nombre del bucket es global y que la ubicación es irreversible, y has razonado por qué un bucket regional en europe-west1 es mejor elección que un multirregión para una tienda que servirá sus imágenes a través de CDN. Conoces las cuatro clases de almacenamiento, sus duraciones mínimas y la trampa del coste de recuperación, y has llegado a una conclusión honesta: las imágenes vivas se quedan en Standard, y las clases frías se reservan para exportaciones antiguas.

Has hecho la migración real con gcloud storage cp y rsync, con paralelismo controlado, simulación previa y verificación posterior, usando una cuenta de servicio con permiso únicamente de creación. Has asegurado el bucket con acceso uniforme, prevención de acceso público, roles mínimos y una condición IAM que limita a Lucía a exportaciones/; y has aprendido a compartir contenido privado con URLs firmadas v4 de vida corta, tanto de lectura como de subida directa. Has activado el versionado —la única red frente al error humano— acompañado siempre de reglas de ciclo de vida que impiden que el bucket crezca sin control, incluida la regla que aborta subidas incompletas. Has visto la retención bloqueada para obligaciones legales, has ajustado Cache-Control y has comprendido por qué los nombres inmutables son la clave del cacheo. Y has conectado el catálogo Flask al bucket con la biblioteca de Python, sin una sola credencial en el código.

Queda una pieza de AlpinaShop todavía anclada al servidor físico, y es la más delicada de todas: la base de datos tienda, ese único PostgreSQL que Marta administra a mano, sin réplica, sin failover y con copias de seguridad que nadie ha probado a restaurar. En 02-03, Cloud SQL, la migraremos a la instancia gestionada alpinashop-pedidos: compararemos qué tareas deja de hacer Marta, crearemos la instancia con alta disponibilidad regional y réplica de lectura para los informes de Lucía, configuraremos copias automáticas y recuperación a un punto en el tiempo, conectaremos la aplicación Flask de forma segura con el conector de Python y haremos la migración real con pg_dump e importación desde el bucket que acabamos de crear.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados