A la lliçó anterior vam arribar a una conclusió incòmoda: les instàncies d'un grup gestionat són d'un sol ús, poden desaparèixer en qualsevol moment i per tant res important no pot viure al seu disc. AlpinaShop té ara mateix 60 GB d'imatges de producte al disc local d'un servidor físic llogat, sense còpia de seguretat real, servides pel mateix procés que renderitza el catàleg. Aquesta lliçó resol aquest problema.

Cloud Storage és l'emmagatzematge d'objectes de Google Cloud: un servei on deses fitxers —imatges, vídeos, còpies de seguretat, exportacions, registres— amb durabilitat extrema, sense gestionar discos ni servidors, i accessibles per HTTPS des de qualsevol lloc. És probablement el servei més transversal de tota la plataforma: apareixerà un altre cop a les còpies de Cloud SQL (02-03), com a origen de dades de BigQuery (04-01), com a magatzem d'artefactes als pipelines (06-01) i com a origen del CDN (03-03).

En aquesta lliçó entendràs el model d'objectes i per què els "directoris" són una il·lusió; triaràs ubicació i classe d'emmagatzematge amb criteri de cost; migraràs realment els 60 GB al bucket alpinashop-catalogo; asseguraràs l'accés amb IAM uniforme i URL signades; automatitzaràs l'estalvi amb regles de cicle de vida; i connectaràs l'aplicació Flask a Storage per pujar la imatge d'un producte nou.

Contingut

  1. El model d'objectes: buckets, objectes i la il·lusió de les carpetes
  2. Noms de bucket i ubicació
  3. Classes d'emmagatzematge i el seu cost real
  4. Migrar els 60 GB d'AlpinaShop
  5. Control d'accés: IAM uniforme, ACL i buckets públics
  6. URL signades per a contingut privat
  7. Versionatge d'objectes
  8. Regles de cicle de vida
  9. Retenció i bloqueig d'objectes
  10. Metadades i Cache-Control
  11. Notificacions a Pub/Sub
  12. Accés des de l'aplicació Flask amb la biblioteca de Python
  13. Cost d'egress i consells de disseny

  1. El model d'objectes: buckets, objectes i la il·lusió de les carpetes

Cloud Storage no és un sistema de fitxers. Té exactament dos nivells:

  • Bucket: el contenidor. Es crea una vegada, té un nom únic a tot Google Cloud, una ubicació fixa i una classe per defecte.
  • Objecte: el fitxer. Té un nom (que pot contenir barres), un contingut binari i unes metadades.

I res més. No existeixen els directoris. Quan veus això a la consola:

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

el que hi ha en realitat són tres objectes els noms complets dels quals són mochilas/moc-40-frontal.jpg, mochilas/moc-40-lateral.jpg i botas/bot-gtx-frontal.jpg. La barra / és un caràcter més del nom; la consola i les eines la interpreten com a separador i simulen una jerarquia utilitzant prefixos.

Conseqüències pràctiques molt reals:

  • Reanomenar una "carpeta" amb molts objectes no és una operació atòmica: implica copiar i esborrar cada objecte.
  • Una carpeta buida no existeix. Si esborres tots els objectes amb el prefix mochilas/, la carpeta desapareix de la vista.
  • Llistar és una operació per prefix. Llistar gs://bucket/mochilas/ recorre els objectes que comencen per aquesta cadena; en buckets amb milions d'objectes, l'elecció del prefix importa per al rendiment.
  • El disseny de noms és el teu esquema. Un bon conveni de prefixos substitueix l'estructura de carpetes.

Per a AlpinaShop fixem aquest conveni, que farem servir durant tot el curs:

gs://alpinashop-catalogo/
  productos/<sku>/original/<fitxer>.jpg     # imatge original pujada per l'equip
  productos/<sku>/web/<fitxer>-800.webp     # versio optimitzada per a la botiga
  productos/<sku>/thumb/<fitxer>-200.webp   # miniatura per a llistats
  estatico/css/, estatico/js/               # recursos del web
  exportaciones/<yyyy>/<mm>/<dd>/           # fitxers per a analitica (modul 4)

El prefix per data a exportaciones/ no és casual: facilita les regles de cicle de vida i les càrregues particionades a BigQuery.

Altres propietats importants del model:

  • Els objectes són immutables. No es modifica un objecte: se substitueix per un altre amb el mateix nom. No existeix "escriure al byte 500".
  • Consistència forta. Després d'una escriptura correcta, qualsevol lectura posterior retorna la versió nova; el llistat també és consistent. Això no sempre va ser així en altres núvols, i elimina tota una categoria d'errors.
  • Durabilitat anunciada d'onze nous (99,999999999 %) a l'any. No confonguis durabilitat amb disponibilitat, ni amb protecció davant d'errors humans: si esborres un objecte, s'esborra. Per això hi ha el versionatge (apartat 7).

  1. Noms de bucket i ubicació

El nom del bucket és únic globalment, compartit amb tots els clients de Google Cloud del món. Si catalogo està ocupat —ho està— hauràs de triar-ne un altre. Regles:

  • Entre 3 i 63 caràcters, minúscules, números, guions, guions baixos i punts.
  • No pot començar per goog ni assemblar-se a google.
  • No pot tenir aspecte d'adreça IP.
  • El nom és visible per a qui rebi una URL: no hi posis informació sensible.

Com que l'espai de noms és global, la convenció habitual és prefixar amb el nom de l'organització o el projecte. AlpinaShop farà servir alpinashop-catalogo; si estigués ocupat, alpinashop-catalogo-prod o similar.

La ubicació és immutable: es decideix en crear el bucket i no es pot canviar. Per moure'l cal crear-ne un altre i copiar. Tres tipus:

Tipus Exemple Rèpliques Disponibilitat típica Cost d'emmagatzematge Quan utilitzar-lo
Regional europe-west1 Diverses zones d'una regió Alta El més baix Dades servides des d'aquesta regió; còmput a la mateixa regió (sense cost de sortida entre serveis)
Biregional eur4 (Països Baixos + Finlàndia), o parells personalitzats Dues regions concretes Molt alta Intermedi Necessites resistir la caiguda d'una regió completa amb control d'on són les dades
Multiregió EU, US, ASIA Diverses regions de l'àrea geogràfica Màxima El més alt Contingut servit a escala continental, orígens de CDN

Criteri per a AlpinaShop: les imatges es serveixen a clients d'Espanya i Portugal, i el còmput (les VM del MIG, App Engine, GKE) és a europe-west1. Un bucket regional a europe-west1 és l'elecció correcta: mínim cost, latència mínima cap al còmput i zero càrrecs de sortida entre serveis de la mateixa regió. La distribució geogràfica cap al client final la resoldrem amb Cloud CDN (03-03), no pagant un bucket multiregió.

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

Els dos últims flags mereixen atenció des d'ara: --uniform-bucket-level-access desactiva les ACL per objecte i deixa que tot l'accés es governi amb IAM (apartat 5), i --public-access-prevention impedeix que ningú —ni per error— pugui fer públic el bucket. Tots dos són bones pràctiques de seguretat que costa molt més activar després que al principi.

  1. Classes d'emmagatzematge i el seu cost real

Tots els objectes es desen amb la mateixa durabilitat i la mateixa latència d'accés (mil·lisegons, fins i tot a Archive). El que canvia entre classes és l'equilibri entre el que pagues per desar i el que pagues per llegir.

Classe Durada mínima Cost d'emmagatzematge Cost de recuperació Cas d'ús
Standard Cap El més alt (≈0,020 $/GB/mes a Europa) Cap Dades accedides amb freqüència: imatges de la botiga, web estàtic
Nearline 30 dies ≈50 % de Standard Sí, per GB llegit Accés mensual: còpies recents, arxius històrics consultables
Coldline 90 dies ≈25 % de Standard Més gran que Nearline Accés trimestral: còpies de seguretat, arxiu tebi
Archive 365 dies ≈10 % de Standard El més alt Accés anual o mai: retenció legal, arxiu definitiu

Els imports són ordres de magnitud a 2026 per raonar sobre proporcions, no tarifes. Verifica sempre a la pàgina oficial de preus de Cloud Storage i a la calculadora de Google Cloud.

La durada mínima és el parany més freqüent. Si deses un objecte a Coldline i l'esborres als 10 dies, se't factura igualment com si hi hagués estat 90 dies. I si el llegeixes diverses vegades, el cost de recuperació pot superar de bon tros l'estalvi d'emmagatzematge.

Regla pràctica: l'estalvi d'una classe freda només és real si l'objecte gairebé mai no es llegeix i hi romandrà molt de temps.

Un càlcul orientatiu per a AlpinaShop. Els seus 60 GB d'imatges, amb les miniatures i versions web que es generaran, voltaran els 90 GB:

Escenari Emmagatzematge/mes Comentari
90 GB a Standard ≈1,80 $ Trivial davant de la resta de la factura
90 GB a Nearline ≈0,90 $ Estalvi de 0,90 $ que s'evapora si el CDN falla i cal rellegir
Còpies històriques de 500 GB a Coldline ≈2,50 $ Aquí sí que compensa

Conclusió: per a les imatges vives del catàleg, Standard. Les classes fredes es reservaran per a les versions antigues i les exportacions, mitjançant regles de cicle de vida (apartat 8). Optimitzar 90 cèntims al mes mentre es paga per instàncies sobredimensionades és un mal ús del temps; a 07-05 sistematitzarem on són els diners de debò.

Hi ha també Autoclass, que mou automàticament cada objecte entre classes segons el seu patró d'accés real, sense cost de recuperació per les transicions. És l'opció sensata quan no saps com s'accedirà a les dades:

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

  1. Migrar els 60 GB d'AlpinaShop

La Marta té les imatges a /var/www/imagenes del servidor físic. L'objectiu és deixar-les a gs://alpinashop-catalogo/productos/ sense interrompre la botiga actual.

Pas 1: instal·lar el CLI i autenticar-se al servidor d'origen. El servidor físic no és una VM de Google, així que necessita credencials. El correcte és un compte de servei amb permís només d'escriptura en aquell bucket:

# A Cloud Shell: crear el compte de servei per a la migracio
gcloud iam service-accounts create sa-migracion-catalogo \
  --display-name="Migracio inicial d'imatges"

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

# Generar la clau (fitxer JSON) que es copiara al servidor fisic
gcloud iam service-accounts keys create ~/sa-migracion.json \
  --iam-account="[email protected]"

Dos advertiments importants. Primer: el rol objectCreator permet crear objectes però no llegir-los ni esborrar-los; si aquella clau es filtrés, el dany seria limitat. Segon: les claus JSON de compte de servei són credencials de llarga vida i l'actiu més perillós d'un projecte. Esborra-la tan bon punt acabi la migració (gcloud iam service-accounts keys delete). A 03-04 i 07-04 veuràs alternatives sense claus (Workload Identity Federation).

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

Pas 2: primera còpia massiva. gcloud storage cp amb --recursive puja l'arbre complet. La versió moderna del CLI paral·lelitza automàticament:

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

Si vols controlar el paral·lelisme (per exemple, per no saturar la connexió de l'oficina en horari de treball):

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

# I per a fitxers grans, pujada composta en paral.lel
gcloud config set storage/parallel_composite_upload_threshold 150M

La pujada composta en paral·lel divideix un fitxer gran en trossos que es pugen simultàniament i després es recomponen al servidor. Accelera molt els fitxers de centenars de MB, però genera objectes amb un tipus de suma de verificació diferent (CRC32C composta), cosa que pot confondre eines antigues. Per a imatges de pocs MB, com les d'AlpinaShop, no aplica.

Pas 3: sincronització incremental. El dia del tall no vols tornar a pujar 60 GB, només el que hagi canviat. Per això hi ha rsync:

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

Compte amb --delete-unmatched-destination-objects: esborra al destí tot allò que no sigui a l'origen. És el que vols per a un mirall exacte, i una catàstrofe si apuntes malament l'origen. Abans d'executar-lo de debò, fes servir la simulació:

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

Pas 4: verificar. Comprova el nombre d'objectes i la mida total:

# Nombre d'objectes
gcloud storage ls --recursive "gs://alpinashop-catalogo/productos/**" | wc -l

# Mida total
gcloud storage du --summarize --readable-sizes gs://alpinashop-catalogo/productos

Quan no utilitzar el CLI. Si tinguessis desenes de TB o les dades fossin en un altre núvol, l'eina adequada seria Storage Transfer Service, que gestiona reintents, verificació i programació sense ocupar el teu servidor; i per a petabytes sense amplada de banda, Transfer Appliance, un dispositiu físic que Google t'envia. Per a 60 GB per una connexió decent, gcloud storage és més que suficient: a 100 Mbps són uns 90 minuts.

  1. Control d'accés: IAM uniforme, ACL i buckets públics

Existeixen dos mecanismes històrics de control d'accés, i convé tenir claríssim quin utilitzar:

ACL (control d'accés per objecte) IAM uniforme a escala de bucket
Granularitat Per objecte individual Per bucket (i per objecte via condicions IAM)
On es defineix A les metadades de cada objecte A la política IAM del bucket o del projecte
Auditabilitat Difícil: cal inspeccionar objecte a objecte Senzilla: una sola política per llegir
Recomanació actual Evitar Utilitzar sempre

Amb uniform bucket-level access activat, les ACL deixen de tenir efecte i tot es decideix amb IAM. És el que vam fer en crear el bucket, i és la recomanació ferma de Google des de fa anys: el motiu pel qual històricament es filtraven dades de buckets no era la manca de controls, sinó que ningú no era capaç d'auditar milions d'ACL individuals.

Rols predefinits més utilitzats:

Rol Permet
roles/storage.objectViewer Llegir i llistar objectes
roles/storage.objectCreator Crear objectes (no llegir ni esborrar)
roles/storage.objectUser Llegir, crear, esborrar objectes (no configurar el bucket)
roles/storage.objectAdmin Control total sobre els objectes
roles/storage.admin Control total sobre el bucket i els seus objectes
# L'aplicacio web nomes necessita llegir imatges
gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member="serviceAccount:[email protected]" \
  --role="roles/storage.objectViewer"

# La Lucia necessita llegir les exportacions, pero nomes aquestes: condicio IAM per prefix
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/')"

Aquesta condició IAM és un bon exemple de mínim privilegi aplicat amb precisió: la Lucía pot llegir exportaciones/ i res més, sense necessitat d'un bucket a part. El mecanisme complet de condicions IAM s'estudia a 03-04.

Buckets públics. Fer un bucket llegible per tot internet és tan fàcil com concedir objectViewer a allUsers:

# NO fer aixo al bucket del cataleg
gcloud storage buckets add-iam-policy-binding gs://mi-bucket \
  --member=allUsers --role=roles/storage.objectViewer

Riscos reals:

  • El que és públic és públic per sempre. Una URL indexada per un cercador o desada a la memòria cau per un tercer no es pot retirar.
  • Pagues l'egress de tothom. Si algú enllaça les teves imatges des d'un fòrum amb molt trànsit, la factura és teva.
  • Un error de prefix exposa tot el bucket, no només el que pretenies.

Quan sí que és raonable: recursos estàtics veritablement públics (CSS, JS, el logotip) en un bucket separat creat explícitament per a això. Les imatges de producte d'AlpinaShop, encara que no siguin secretes, es serviran a través del CDN amb el bucket com a origen privat (03-03), no obrint-lo a internet. Per això vam crear el bucket amb --public-access-prevention.

  1. URL signades per a contingut privat

Com permet, doncs, AlpinaShop que un client descarregui la seva factura en PDF, o que el Dani comparteixi un arxiu amb un proveïdor, sense fer res públic i sense donar-li un compte de Google?

Amb una URL signada: un enllaç temporal que incorpora una signatura criptogràfica i funciona durant un temps limitat. Qui la tingui hi pot accedir; quan caduca, deixa de funcionar.

# Enllac valid durant 15 minuts per a un objecte privat
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

I des de l'aplicació, que és on es fa servir de debò:

from datetime import timedelta
from google.cloud import storage

client = storage.Client()
bucket = client.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)

Detalls que importen:

  • version="v4" és l'algorisme de signatura vigent; utilitza sempre aquest.
  • response_disposition força la descàrrega amb un nom amable, en lloc d'obrir el PDF al navegador amb el nom intern de l'objecte.
  • La caducitat ha de ser curta. Una URL signada vàlida durant 7 dies és pràcticament un enllaç públic: es reenvia per correu i sobreviu.
  • També serveix per pujar (method="PUT"): el navegador de l'usuari puja directament a Cloud Storage sense passar pel teu servidor, cosa que estalvia amplada de banda i temps de procés. És el patró recomanat per a pujades grans.
  • Signar requereix una clau. Si l'aplicació corre en una VM o a Cloud Run amb compte de servei i sense fitxer de clau, necessita el permís iam.serviceAccounts.signBlob (rol Creador de tokens de compte de servei) per signar mitjançant l'API d'IAM.

  1. Versionatge d'objectes

Els objectes són immutables, però els seus noms no: si puges un altre objecte amb el mateix nom, l'anterior es perd. I si algú executa un rsync mal apuntat, es perd molt més.

El versionatge fa que cada sobreescriptura o esborrat conservi la versió anterior com a versió no actual, identificada per un número de generació:

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

# Veure totes les versions, incloses les no actuals
gcloud storage ls --all-versions gs://alpinashop-catalogo/productos/MOC-40/

# Restaurar una versio concreta pel seu numero de generacio
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 sufix #<generació> identifica una versió específica. Restaurar consisteix simplement a copiar-la sobre l'actual.

El versionatge és l'única protecció real davant de l'error humà —l'esborrat accidental, el script equivocat, el ransomware— però té un cost: pagues per totes les versions. Sense una regla de cicle de vida que netegi les antigues, el bucket creix indefinidament. Van junts, sempre.

  1. Regles de cicle de vida

Una regla de cicle de vida és una política que Cloud Storage aplica automàticament sobre els objectes que compleixen certes condicions. Es defineix 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 per regla:

  1. Als 30 dies, les exportacions passen a Nearline. Els informes de la Lucía es consulten molt la primera setmana i gairebé mai després. matchesStorageClass evita que la regla reprocessi objectes ja moguts.
  2. Als 180 dies passen a Coldline. Es conserven per si cal reconstruir un històric, a la quarta part de cost.
  3. Les versions no actuals s'esborren als 30 dies de deixar de ser actuals, sempre que existeixin com a mínim 3 versions més noves. La combinació de les dues condicions és deliberada: garanteix una finestra temporal de recuperació i un nombre mínim de versions conservades.
  4. Les pujades multipart incompletes s'avorten als 7 dies. Una pujada gran interrompuda deixa fragments que ocupen i es facturen sense aparèixer en llistar el bucket. És una despesa fantasma clàssica; aquesta regla hauria de ser a tots els teus buckets.

Aplicació i verificació:

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

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

Advertiments sobre el comportament: les regles s'avaluen una vegada al dia de manera asíncrona, així que no esperis efecte immediat; i les transicions a classes fredes reinicien la durada mínima, de manera que moure a Coldline alguna cosa que esborraràs d'aquí a un mes és contraproduent.

Fixa't que a les imatges vives del catàleg no apliquem cap transició: es llegeixen constantment i s'han de quedar a Standard.

  1. Retenció i bloqueig d'objectes

Quan la llei o un contracte exigeixen conservar dades durant un termini, el versionatge no n'hi ha prou: algú amb permisos suficients pot esborrar-ho tot. Per a això existeixen dos mecanismes més forts.

Política de retenció del bucket: cap objecte no pot esborrar-se ni sobreescriure's fins a complir l'edat indicada.

# Retenir les factures 6 anys (obligacio mercantil habitual a Espanya)
gcloud storage buckets update gs://alpinashop-facturas \
  --retention-period=6y

Bloqueig de la política (lock): fa la retenció irreversible. Ni el propietari del projecte, ni l'administrador de l'organització, ni el suport de Google poden reduir-la o treure-la. Només es pot augmentar el termini.

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

És una operació de les que no admeten penediment: si bloqueges 6 anys, aquell bucket no es podrà buidar durant 6 anys, i tampoc no es podrà esborrar mentre contingui objectes. És exactament el seu propòsit (compliment normatiu, protecció davant d'un atacant amb credencials d'administrador), però exigeix estar completament segur.

Complementàriament, la retenció d'objectes individuals permet marcar objectes concrets amb una data de retenció, i els event-based holds i temporary holds congelen un objecte indefinidament fins que s'allibera la retenció (útil, per exemple, davant d'un litigi).

  1. Metadades i Cache-Control

Cada objecte té metadades, algunes de les quals es converteixen directament en capçaleres HTTP quan se serveix:

Metadada Capçalera HTTP Per a què
Content-Type Content-Type Que el navegador sàpiga què és. Si està malament, el navegador descarrega la imatge en lloc de mostrar-la
Cache-Control Cache-Control Quant de temps pot desar-se a la memòria cau, al navegador i al CDN
Content-Encoding Content-Encoding Contingut comprimit (gzip)
Content-Disposition Content-Disposition Forçar descàrrega i nom de fitxer
Metadades personalitzades x-goog-meta-* Les teves pròpies etiquetes: SKU, fotògraf, data de sessió
# Imatges de producte: cachejar un any, son immutables per disseny
gcloud storage objects update "gs://alpinashop-catalogo/productos/**/web/*.webp" \
  --cache-control="public, max-age=31536000, immutable"

# El cataleg JSON canvia cada dia: cachejar poc i revalidar
gcloud storage objects update gs://alpinashop-catalogo/estatico/catalogo.json \
  --cache-control="public, max-age=300, must-revalidate"

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

Per què importa tant Cache-Control. Quan posem Cloud CDN davant del bucket (03-03), aquesta capçalera és la que decideix quant de temps guarda el CDN cada objecte als punts de presència. Un max-age alt significa que la majoria de les peticions se serveixen des de la vora de la xarxa: més ràpid per al client i molt més barat, perquè no surten del bucket.

El problema és què passa quan canvia una imatge. Per això la tècnica correcta és anomenar els objectes de manera immutable: si el fitxer inclou un hash o una versió (frontal-800.a3f9c1.webp), mai no se sobreescriu, i publicar una imatge nova és publicar un nom nou. Així pots cachejar un any sense por. És la mateixa idea que ja vas utilitzar sense saber-ho en versionar les plantilles d'instància a la lliçó anterior.

Per defecte, els objectes de Cloud Storage se serveixen amb Cache-Control: public, max-age=3600, cosa que poques vegades és el que vols. Estableix-lo explícitament en pujar.

  1. Notificacions a Pub/Sub

Cloud Storage pot publicar un missatge en un topic de Pub/Sub cada vegada que un objecte es crea, s'esborra, s'arxiva o canvien les seves metadades. És la base de les arquitectures dirigides per esdeveniments:

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

El cas d'ús natural per a AlpinaShop: quan l'equip puja una imatge original a productos/<sku>/original/, es publica un esdeveniment, i un consumidor genera automàticament la versió web i la miniatura. L'esdeveniment OBJECT_FINALIZE es dispara quan l'escriptura s'ha completat, no quan comença.

No ho desenvolupem aquí: Pub/Sub té la seva pròpia lliçó (04-04) i el consumidor natural seria una Cloud Function (06-03). Queda't amb la idea que el bucket no és un magatzem passiu, sinó una font d'esdeveniments.

  1. Accés des de l'aplicació Flask amb la biblioteca de Python

Arriba el moment de connectar el catàleg d'AlpinaShop amb el bucket. Instal·lació:

pip install google-cloud-storage

Sobre l'autenticació: el codi no porta contrasenyes ni rutes a fitxers de clau. La biblioteca fa servir les Application Default Credentials que vam veure a 01-06, i les resol per aquest ordre: la variable GOOGLE_APPLICATION_CREDENTIALS si existeix, després les credencials de gcloud auth application-default login al teu portàtil, i finalment —quan el codi corre en una VM, a GKE o a Cloud Run— el compte de servei adjunt, obtingut del servidor de metadades. El mateix codi funciona en local i en producció sense canvis. Aquest és un dels grans encerts del model 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")
EXTENSIONS_OK = {".jpg", ".jpeg", ".png", ".webp"}

# El client es crea UNA vegada en arrencar, no en cada peticio:
# obre connexions reutilitzables i resol credencials, cosa que es costosa.
client = storage.Client()
bucket = client.bucket(BUCKET)


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

    nom = secure_filename(fitxer.filename)
    extensio = os.path.splitext(nom)[1].lower()
    if extensio not in EXTENSIONS_OK:
        return f"Extensio no permesa: {extensio}", 400

    # Nom immutable: mai se sobreescriu, es pot cachejar un any
    desti = f"productos/{sku}/original/{uuid.uuid4().hex}{extensio}"
    blob = bucket.blob(desti)

    blob.cache_control = "public, max-age=31536000, immutable"
    blob.metadata = {"sku": sku, "pujat-per": "panel-intern"}

    # upload_from_file rep el flux directament: no s'escriu al disc local,
    # cosa essencial en instancies efimeres com les del MIG de la llico 02-01.
    blob.upload_from_file(fitxer.stream, content_type=fitxer.mimetype)

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


@app.route("/producto/<sku>/imagenes")
def llistar_imatges(sku):
    # list_blobs amb prefix: la forma correcta de "llistar una carpeta"
    blobs = client.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}")

Punts didàctics del codi:

  • storage.Client() fora de la vista. Crear-lo en cada petició és un error de rendiment habitual: implica resoldre credencials i obrir connexions noves cada vegada.
  • Validació d'extensió i secure_filename. No construeixis mai el nom de l'objecte directament amb el que envia l'usuari: podria contenir ../ o caràcters inesperats.
  • upload_from_file amb el flux. Es puja en streaming sense tocar el disc de la instància, que pot desaparèixer en qualsevol moment.
  • Nom amb UUID. Evita col·lisions i fa l'objecte immutable, habilitant el cacheig agressiu de l'apartat 10.
  • URL signades en llistar. El bucket és privat; les imatges es mostren amb enllaços temporals. En producció, això ho reemplaçarà el CDN amb origen privat (03-03).

Descarregar un objecte és igual de directe:

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

contingut = blob.download_as_text()            # a memoria, com a text
blob.download_to_filename("/tmp/pedidos.csv")  # a disc

if blob.exists():
    blob.reload()  # refresca les metadades des del servidor
    print(blob.size, blob.updated, blob.content_type, blob.storage_class)

  1. Cost d'egress i consells de disseny

L'emmagatzematge a Cloud Storage és barat. El que sorprèn a la factura és gairebé sempre l'egress: el trànsit que surt de Google Cloud cap a internet.

Tipus de trànsit Cost orientatiu
Ingress (pujar dades a GCP) Gratuït
Bucket → servei GCP a la mateixa regió Gratuït
Bucket → servei GCP a una altra regió del mateix continent Baix, per GB
Bucket → internet (Europa/Nord-amèrica) ≈0,08–0,12 $/GB, amb descomptes per volum
Bucket → internet a través de Cloud CDN Menor que l'egress directe, i moltes peticions ni tan sols arriben al bucket
Operacions (classe A: escriptures i llistats; classe B: lectures) Cèntims per cada 10.000, però rellevant amb milions d'objectes petits

Un exemple orientatiu per a AlpinaShop: si la botiga serveix 200 GB d'imatges al mes directament des del bucket, l'egress volta els 16–24 $/mes, davant de menys de 2 $ d'emmagatzematge. És a dir, moure les dades costa deu vegades més que desar-les. Per això el CDN no és un luxe de rendiment, sinó també una mesura de cost.

Consells de disseny que es deriven de tot l'anterior:

  • Col·loca el còmput a la mateixa regió que el bucket. europe-west1 en tots dos casos: zero cost de transferència entre serveis.
  • Serveix les imatges a través del CDN amb Cache-Control llarg i noms immutables.
  • Optimitza el pes abans de pujar. Convertir a WebP i redimensionar redueix l'egress proporcionalment; és l'optimització de cost més rendible d'aquesta lliçó.
  • Vigila els objectes molt petits. Milions de fitxers diminuts paguen més en operacions que en emmagatzematge; de vegades convé agrupar-los.
  • Activa sempre la regla que avorta pujades multipart incompletes.
  • Etiqueta els buckets amb entorno, equipo, centro-coste i aplicacion per poder repartir la despesa (01-04).

Errors habituals i consells

  • Pensar en carpetes. No existeixen. Reanomenar un prefix amb 50.000 objectes és copiar i esborrar 50.000 objectes.
  • Triar malament la ubicació. És immutable. Un bucket multiregió EU per servir clients espanyols costa més sense aportar res davant d'europe-west1 + CDN.
  • Posar-ho tot a Coldline "per estalviar". Si els objectes es llegeixen, el cost de recuperació i les durades mínimes et surten més cars que Standard.
  • Activar versionatge sense regla de cicle de vida. El bucket creix per sempre i ningú no se n'adona fins a la factura.
  • Fer públic un bucket per "provar ràpid". El que és públic s'indexa, i desactivar l'accés no esborra el que ja es va copiar.
  • Utilitzar ACL per objecte. Impossible d'auditar. Activa accés uniforme i treballa amb IAM.
  • URL signades amb caducitat de dies. Equivalen a enllaços públics: es reenvien.
  • Oblidar Cache-Control. El valor per defecte d'1 hora arruïna l'eficàcia del CDN.
  • Crear el client de Storage en cada petició de Flask. Penalització de latència innecessària en totes les peticions.
  • Consell: utilitza --dry-run abans de qualsevol rsync amb esborrat.
  • Consell: anomena els objectes de manera immutable (hash o UUID). Simplifica el cacheig, el versionatge i les publicacions.
  • Consell: esborra les claus JSON de compte de servei tan bon punt acabi la tasca puntual que les va justificar.
  • Consell: revisa la despesa per bucket amb l'exportació de facturació a BigQuery que vas configurar a 01-04.

Exercicis

Exercici 1: crear i organitzar el bucket del catàleg

  1. Crea un bucket amb nom únic (alpinashop-catalogo-<lesteveinicials>) a europe-west1, classe Standard, accés uniforme i prevenció d'accés públic.
  2. Crea localment una estructura que simuli tres productes amb una imatge cadascun i puja-la respectant el conveni productos/<sku>/original/.
  3. Comprova que no existeixen "carpetes" reals: llista els objectes amb el seu nom complet.
  4. Calcula la mida total i el nombre d'objectes.
  5. Aplica Cache-Control d'un any a totes les imatges sota productos/.

Exercici 2: versionatge, cicle de vida i recuperació

  1. Activa el versionatge al teu bucket.
  2. Sobreescriu una de les imatges amb contingut diferent i llista totes les versions.
  3. Restaura la versió original pel seu número de generació.
  4. Escriu i aplica un fitxer de cicle de vida que: mogui a Nearline el que hi hagi sota exportaciones/ amb més de 30 dies, esborri versions no actuals amb més de 15 dies conservant com a mínim 2 de més noves, i avorti pujades multipart incompletes als 7 dies.
  5. Verifica la configuració aplicada.

Exercici 3: accés privat i aplicació Flask

  1. Crea un compte de servei sa-tienda-lectura amb només permís de lectura d'objectes sobre el teu bucket.
  2. Genera una URL signada vàlida 10 minuts per a una de les imatges i comprova-la amb curl.
  3. Comprova que la URL directa (sense signatura) retorna 403.
  4. Escriu un endpoint Flask que rebi un fitxer per POST, validi l'extensió, el pugi amb nom immutable sota productos/<sku>/original/ i retorni una URL signada de 5 minuts.
  5. Explica per què el codi no conté cap credencial.

Solucions

Solució 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 i pujada
mkdir -p ~/catalogo/{MOC-40,BOT-GTX,TDA-2P}
for sku in MOC-40 BOT-GTX TDA-2P; do
  echo "imatge simulada de $sku" > ~/catalogo/$sku/frontal.jpg
  gcloud storage cp ~/catalogo/$sku/frontal.jpg \
    "gs://$BUCKET/productos/$sku/original/frontal.jpg"
done

# 3. Noms complets: no hi ha carpetes
gcloud storage ls --recursive "gs://$BUCKET/**"

# 4. Mida i nombre d'objectes
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"

Al punt 3 veuràs sortides del tipus gs://<bucket>/productos/MOC-40/original/frontal.jpg: un únic objecte el nom del qual conté barres. No hi ha cap entitat "carpeta" que puguis descriure o a la qual assignar permisos.

Solució 2

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

# 2. Sobreescriure i llistar versions
echo "versio 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 (substitueix GEN pel numero de generacio de la versio antiga)
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 important: la restauració funciona perquè el versionatge estava actiu abans de sobreescriure. Activar-lo després no recupera res. És la raó d'activar-lo el dia que es crea el bucket, no el dia que passa l'accident.

Solució 3

# 1. Compte de servei amb lectura
gcloud iam service-accounts create sa-tienda-lectura \
  --display-name="Lectura del cataleg"

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 signada de 10 minuts
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 sense signatura
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://storage.googleapis.com/$BUCKET/productos/MOC-40/original/frontal.jpg"  # 403
# 4. Endpoint de pujada amb URL signada de resposta
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__)
client = storage.Client()
bucket = client.bucket(os.environ["BUCKET_CATALOGO"])
PERMESES = {".jpg", ".jpeg", ".png", ".webp"}


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

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

    desti = f"productos/{sku}/original/{uuid.uuid4().hex}{ext}"
    blob = bucket.blob(desti)
    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": desti, "url": url}, 201
  1. El codi no conté credencials perquè la biblioteca client utilitza les Application Default Credentials. Al portàtil del Dani són les de gcloud auth application-default login; a la VM del MIG, a GKE o a Cloud Run són les del compte de servei adjunt, que s'obtenen del servidor de metadades i es roten soles. El mateix codi, sense canvis ni fitxers de clau, funciona als dos llocs. Aquest és el model d'identitat que aprofundirem a 03-04.

Neteja en acabar:

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

Conclusió

Els 60 GB d'imatges d'AlpinaShop ja no depenen del disc d'un servidor físic. Has entès que Cloud Storage no és un sistema de fitxers sinó un magatzem d'objectes pla, on les carpetes són una il·lusió construïda sobre prefixos, i has fixat un conveni de noms (productos/<sku>/original|web|thumb/, exportaciones/<data>/) que sostindrà la resta del curs. Saps que el nom del bucket és global i que la ubicació és irreversible, i has raonat per què un bucket regional a europe-west1 és millor elecció que un multiregió per a una botiga que servirà les seves imatges a través de CDN. Coneixes les quatre classes d'emmagatzematge, les seves durades mínimes i el parany del cost de recuperació, i has arribat a una conclusió honesta: les imatges vives es queden a Standard, i les classes fredes es reserven per a exportacions antigues.

Has fet la migració real amb gcloud storage cp i rsync, amb paral·lelisme controlat, simulació prèvia i verificació posterior, utilitzant un compte de servei amb permís únicament de creació. Has assegurat el bucket amb accés uniforme, prevenció d'accés públic, rols mínims i una condició IAM que limita la Lucía a exportaciones/; i has après a compartir contingut privat amb URL signades v4 de vida curta, tant de lectura com de pujada directa. Has activat el versionatge —l'única xarxa davant de l'error humà— acompanyat sempre de regles de cicle de vida que impedeixen que el bucket creixi sense control, inclosa la regla que avorta pujades incompletes. Has vist la retenció bloquejada per a obligacions legals, has ajustat Cache-Control i has comprès per què els noms immutables són la clau del cacheig. I has connectat el catàleg Flask al bucket amb la biblioteca de Python, sense ni una sola credencial al codi.

Queda una peça d'AlpinaShop encara ancorada al servidor físic, i és la més delicada de totes: la base de dades tienda, aquell únic PostgreSQL que la Marta administra a mà, sense rèplica, sense failover i amb còpies de seguretat que ningú no ha provat de restaurar. A 02-03, Cloud SQL, la migrarem a la instància gestionada alpinashop-pedidos: compararem quines tasques deixa de fer la Marta, crearem la instància amb alta disponibilitat regional i rèplica de lectura per als informes de la Lucía, configurarem còpies automàtiques i recuperació a un punt en el temps, connectarem l'aplicació Flask de manera segura amb el connector de Python i farem la migració real amb pg_dump i importació des del bucket que acabem de crear.

Curs de Google Cloud Platform (GCP)

Mòdul 1: Introducció a Google Cloud Platform

Mòdul 2: Serveis principals de GCP

Mòdul 3: Xarxes i seguretat

Mòdul 4: Dades i anàlisi

Mòdul 5: Aprenentatge automàtic i IA

Mòdul 6: DevOps i monitoratge

Mòdul 7: Temes avançats de GCP

Mòdul 8: Projecte final

© Copyright 2026. Tots els drets reservats