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
- El modelo de objetos: buckets, objetos y la ilusión de las carpetas
- Nombres de bucket y ubicación
- Clases de almacenamiento y su coste real
- Migrar los 60 GB de AlpinaShop
- Control de acceso: IAM uniforme, ACL y buckets públicos
- URLs firmadas para contenido privado
- Versionado de objetos
- Reglas de ciclo de vida
- Retención y bloqueo de objetos
- Metadatos y
Cache-Control - Notificaciones a Pub/Sub
- Acceso desde la aplicación Flask con la biblioteca de Python
- Coste de egress y consejos de diseño
- 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.jpglo 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).
- 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
googni parecerse agoogle. - 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-preventionLos 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.
- 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:
- 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-prodPaso 2: primera copia masiva. gcloud storage cp con --recursive sube el árbol completo. La versión moderna del CLI paraleliza automáticamente:
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 150MLa 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/productosCuidado 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:
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/productosCuá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.
- 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.objectViewerRiesgos 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.
- 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.comY 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_dispositionfuerza 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.
- 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.webpEl 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.
- 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:
- 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.
matchesStorageClassevita que la regla reprocese objetos ya movidos. - A los 180 días pasan a Coldline. Se conservan por si hace falta reconstruir un histórico, a la cuarta parte de coste.
- 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.
- 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.
- 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=6yBloqueo 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.
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).
- Metadatos y
Cache-Control
Cache-ControlCada 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-nortePor 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.
- 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=jsonEl 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.
- 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:
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_filecon 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)
- 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-west1en ambos casos: cero coste de transferencia entre servicios. - Sirve las imágenes a través del CDN con
Cache-Controllargo 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-costeyaplicacionpara 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
EUpara servir a clientes españoles cuesta más sin aportar nada frente aeurope-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-runantes de cualquierrsynccon 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
- Crea un bucket con nombre único (
alpinashop-catalogo-<tuiniciales>) eneurope-west1, clase Standard, acceso uniforme y prevención de acceso público. - Crea localmente una estructura que simule tres productos con una imagen cada uno y súbela respetando el convenio
productos/<sku>/original/. - Comprueba que no existen "carpetas" reales: lista los objetos con su nombre completo.
- Calcula el tamaño total y el número de objetos.
- Aplica
Cache-Controlde un año a todas las imágenes bajoproductos/.
Ejercicio 2: versionado, ciclo de vida y recuperación
- Activa el versionado en tu bucket.
- Sobrescribe una de las imágenes con contenido distinto y lista todas las versiones.
- Restaura la versión original por su número de generación.
- 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. - Verifica la configuración aplicada.
Ejercicio 3: acceso privado y aplicación Flask
- Crea una cuenta de servicio
sa-tienda-lecturacon solo permiso de lectura de objetos sobre tu bucket. - Genera una URL firmada válida 10 minutos para una de las imágenes y compruébala con
curl. - Comprueba que la URL directa (sin firma) devuelve 403.
- 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. - 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- 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:
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
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
