El balanceador de AlpinaShop ya funciona: una IP anycast global, HTTPS, el MIG detrás y las imágenes servidas directamente desde el bucket. Pero queda la ineficiencia con la que cerramos la lección anterior. Cada vez que un cliente de Lisboa abre la ficha de una mochila, los mismos bytes vuelven a salir de europe-west1 y cruzan la península. Con 60 GB de catálogo, cientos de miles de peticiones de imagen al mes y clientes repartidos por Europa, eso es latencia que el cliente nota y salida de datos que la factura acumula.

Cloud CDN resuelve exactamente ese problema, y lo hace sin que tengas que cambiar una línea de la aplicación ni mover un objeto de sitio. No es un producto separado con su propia consola, su propia IP y su propio dominio: es una casilla que se activa sobre el servicio de backend o el backend de bucket que ya construiste en 03-02. Ese detalle de diseño es el que hace que esta lección sea corta en comandos y larga en criterio: activarlo cuesta un --enable-cdn; hacerlo bien consiste en entender qué se cachea, con qué clave, durante cuánto tiempo y cómo se mide.

En esta lección vas a activar la caché sobre bb-catalogo-imagenes y bs-catalogo-web, elegir el modo de caché correcto para cada uno, evitar que los parámetros utm_* de las campañas de marketing destrocen el ratio de aciertos, decidir una política de TTL coherente con los nombres inmutables que ya adoptaste en 02-02, aprender por qué la invalidación no debe ser tu herramienta habitual, y medir el resultado en milisegundos y en euros.

Contenido

  1. Qué es una CDN y qué problema resuelve exactamente
  2. La red de puntos de presencia de Google
  3. Dónde vive Cloud CDN: sobre el balanceador, no al lado
  4. Activación sobre el backend de bucket y sobre el servicio de backend
  5. Modos de caché: CACHE_ALL_STATIC, USE_ORIGIN_HEADERS, FORCE_CACHE_ALL
  6. La clave de caché y el desastre de los parámetros utm_*
  7. TTL: quién decide cuánto vive un objeto en el borde
  8. Nombres inmutables: la estrategia que sostiene todo lo anterior
  9. Invalidación de caché: el botón de emergencia
  10. Contenido privado en caché: URLs y cookies firmadas
  11. Caché negativa, contenido obsoleto y otras opciones útiles
  12. Medición: ratio de aciertos, cabeceras Age y Via, y depuración con curl
  13. El ahorro real: latencia y euros
  14. Cuándo Cloud CDN no basta: Media CDN

  1. Qué es una CDN y qué problema resuelve exactamente

Una red de distribución de contenido (CDN) es un conjunto de servidores caché repartidos geográficamente que guardan copias de tu contenido cerca de los usuarios. Cuando un cliente pide un objeto, lo sirve el nodo más cercano en lugar del servidor de origen.

Suena simple, pero conviene ser preciso sobre los tres problemas distintos que resuelve, porque no son el mismo y se miden de forma diferente:

Problema Sin CDN Con CDN
Latencia El objeto viaja de europe-west1 a Lisboa en cada petición Se sirve desde el punto de presencia de Lisboa: decenas de milisegundos menos
Coste de salida Cada byte servido se factura como salida a internet desde el bucket Los aciertos de caché se facturan a tarifa de CDN, más barata
Carga en el origen El MIG y el bucket atienden todas las peticiones El origen solo ve los fallos de caché: menos instancias, menos operaciones

Hay un cuarto efecto, menos obvio y muy valioso: absorción de picos. Cuando AlpinaShop publique la campaña de otoño y la portada reciba diez veces el tráfico habitual, si la portada y sus imágenes están en caché, el MIG casi no se entera. El autoescalado que configuraste en 02-01 seguirá existiendo como red de seguridad, pero se disparará mucho menos.

Y una limitación que hay que tener clara desde el principio: una CDN solo acelera lo que se puede cachear. La página del carrito de un cliente concreto, el resultado de un pago o el panel de Lucía no se cachean nunca. La CDN no hace más rápida tu aplicación; hace que la mayor parte del tráfico no llegue a tu aplicación.

  1. La red de puntos de presencia de Google

Google mantiene más de doscientas ubicaciones de caché repartidas por el mundo, además de decenas de regiones y puntos de presencia de red. Muchas de esas cachés están dentro de las redes de los propios operadores (lo que Google llama Google Global Cache), es decir, físicamente en el operador al que está conectado el cliente.

Lo importante para tu diseño no es el número, sino la mecánica:

flowchart LR
    C1([Cliente Lisboa])
    C2([Cliente Helsinki])
    P1["PoP Lisboa<br/>caché"]
    P2["PoP Helsinki<br/>caché"]
    LB["Balanceador global<br/>alpinashop-lb-ip"]
    O1["Bucket alpinashop-catalogo<br/>europe-west1"]
    O2["MIG alpinashop-web-mig<br/>europe-west1"]

    C1 -->|1. GET /imagenes/...| P1
    C2 -->|1. GET /imagenes/...| P2
    P1 -->|2. fallo de cache| LB
    P2 -->|2. acierto: no sale| P2
    LB --> O1
    LB --> O2
    P1 -.->|3. respuesta cacheada| C1
  1. El cliente resuelve alpinashop.example y llega a la IP anycast. Esa IP se anuncia desde todos los puntos de presencia, así que el cliente entra por el más cercano.
  2. En ese punto de presencia se consulta la caché. Si hay acierto, la respuesta sale de ahí y nunca llega a europe-west1.
  3. Si hay fallo, la petición viaja por la red privada de Google hasta el origen —no por internet pública—, la respuesta se guarda en el borde y se entrega al cliente.

Dos consecuencias prácticas:

  • La caché no es una, son muchas. Un objeto puede estar caliente en Madrid y frío en Varsovia. Por eso el ratio de aciertos nunca es del 100 % ni debe serlo.
  • Google agrupa las peticiones concurrentes al mismo objeto (request coalescing, activado por defecto): si mil clientes piden a la vez una imagen que no está en caché, el origen recibe una petición, no mil. Esto evita el efecto estampida en los lanzamientos.

  1. Dónde vive Cloud CDN: sobre el balanceador, no al lado

Esta es la idea que más confusión genera al llegar desde otras plataformas. En muchos proveedores, la CDN es un servicio aparte: creas una "distribución", te dan un nombre de host propio y apuntas tu DNS ahí. En Google Cloud no.

Cloud CDN es una propiedad de un backend del balanceador de aplicaciones externo global. Se activa así:

  • Sobre un backend de bucket (bb-catalogo-imagenes) → cachea los objetos de Cloud Storage.
  • Sobre un servicio de backend (bs-catalogo-web) → cachea lo que devuelvan las instancias del MIG, o mañana el NEG sin servidor de Cloud Run (DA-001).

De ahí se derivan cosas muy cómodas:

  • No cambias el DNS ni la IP. Sigues con alpinashop-lb-ip y el mismo certificado.
  • El mismo mapa de URL decide qué se cachea y qué no, porque cada ruta va a un backend distinto y cada backend tiene su propia configuración de caché.
  • Cloud Armor (03-05) se evalúa en el borde, de modo que las reglas de seguridad se aplican también a las peticiones que acaban sirviéndose desde caché. La caché no es un agujero por el que se cuela el tráfico sin filtrar.
  • Los logs del balanceador que activaste en 03-02 ya traen los campos de caché. No hay un sistema de observabilidad aparte.

Recuerda cómo quedó el mapa alpinashop-url-map:

Ruta Backend ¿Cachear?
/imagenes/* bb-catalogo-imagenes (bucket) Sí, agresivamente: son ficheros inmutables
/estatico/* bb-catalogo-imagenes (bucket) Sí: CSS y JS versionados
/ y fichas de producto bs-catalogo-web (MIG) Sí, con cuidado: HTML que cambia
/carrito, /cuenta, /pago bs-catalogo-web (MIG) Nunca: contenido por usuario

Ese cuadro es la decisión de diseño de la lección. Todo lo demás son parámetros.

  1. Activación sobre el backend de bucket y sobre el servicio de backend

Empezamos por lo fácil y de mayor impacto: las imágenes.

gcloud config set project alpinashop-prod

# CDN sobre el backend de bucket que sirve /imagenes/* y /estatico/*
gcloud compute backend-buckets update bb-catalogo-imagenes \
  --enable-cdn \
  --cache-mode=CACHE_ALL_STATIC \
  --default-ttl=3600 \
  --max-ttl=31536000 \
  --client-ttl=3600 \
  --negative-caching

Qué hace cada opción:

  • --enable-cdn: activa la caché. Es lo único obligatorio; el resto son ajustes.
  • --cache-mode: la política general (apartado 5).
  • --default-ttl: cuánto se guarda una respuesta si el origen no dice nada.
  • --max-ttl: el techo. Aunque el origen pida un año, no se superará este valor. Aquí lo dejamos en un año porque confiamos en nuestros propios Cache-Control.
  • --client-ttl: el max-age máximo que se le comunica al navegador. Permite cachear mucho en el borde y poco en el cliente, algo muy útil (apartado 7).
  • --negative-caching: cachea también los errores (apartado 11).

Ahora el servicio de backend del MIG. Aquí somos más conservadores, porque detrás hay una aplicación que genera HTML:

gcloud compute backend-services update bs-catalogo-web \
  --global \
  --enable-cdn \
  --cache-mode=CACHE_ALL_STATIC \
  --default-ttl=300 \
  --max-ttl=3600 \
  --client-ttl=60 \
  --serve-while-stale=86400 \
  --compression-mode=AUTOMATIC
  • --default-ttl=300: cinco minutos para lo estático que sirva la aplicación y no traiga cabeceras propias.
  • --serve-while-stale=86400: si el origen no responde, el borde puede seguir sirviendo la copia caducada hasta 24 horas mientras revalida. Esta opción convierte una caída del MIG en una degradación en lugar de un error 502 para el contenido cacheado. Es una de las mejores relaciones beneficio/esfuerzo de todo el módulo.
  • --compression-mode=AUTOMATIC: el borde comprime con gzip o Brotli las respuestas de texto que el origen envíe sin comprimir, ahorrando ancho de banda sin tocar gunicorn.

Comprueba el estado con:

gcloud compute backend-buckets describe bb-catalogo-imagenes \
  --format="yaml(cdnPolicy, enableCdn)"

gcloud compute backend-services describe bs-catalogo-web --global \
  --format="yaml(enableCDN, cdnPolicy)"

Los cambios de configuración de CDN tardan unos minutos en propagarse a todos los puntos de presencia. Y ojo: cambiar la configuración no vacía la caché. Los objetos ya guardados siguen ahí con su TTL original.

  1. Modos de caché: CACHE_ALL_STATIC, USE_ORIGIN_HEADERS, FORCE_CACHE_ALL

El modo de caché responde a una pregunta: ¿quién decide qué se cachea, el origen o el CDN?

Modo Qué cachea Quién manda Riesgo Cuándo usarlo
USE_ORIGIN_HEADERS Solo lo que el origen marca explícitamente como cacheable El origen, siempre Ninguno; si te equivocas, simplemente no se cachea Aplicaciones que ya emiten Cache-Control correctos y quieren control total
CACHE_ALL_STATIC Contenido estático por tipo de contenido y extensión, aunque el origen calle; respeta el origen para el resto Compartido Bajo: se respetan private y no-store La opción por defecto sensata. La que usa AlpinaShop
FORCE_CACHE_ALL Todas las respuestas correctas, ignorando private y no-store El CDN, ignorando al origen Alto: puede servir la página de un usuario a otro Solo en backends que sirven exclusivamente contenido público inmutable

Detalles que importan:

  • CACHE_ALL_STATIC considera "estático" el contenido por su tipo MIME y su extensión: imágenes, vídeo, CSS, JavaScript, fuentes, PDF. El HTML no entra en esa categoría, así que las fichas de producto de AlpinaShop no se cachearán salvo que la aplicación lo pida con su propio Cache-Control. Es justo lo que queremos: control explícito sobre el HTML.
  • En cualquier modo, una respuesta con Cache-Control: private, no-store o Set-Cookie no se cachea (salvo FORCE_CACHE_ALL). Esa es la red de seguridad que impide el desastre clásico.
  • FORCE_CACHE_ALL sobre un backend que sirve páginas de sesión es probablemente el peor incidente de seguridad que se puede provocar con una casilla. Si algún día lo necesitas, aplícalo a un servicio de backend dedicado al que solo lleguen rutas públicas, nunca al que sirve el sitio entero.

Para AlpinaShop la decisión es:

  • bb-catalogo-imagenes → CACHE_ALL_STATIC. Todo lo que hay ahí es estático por definición y ya viene con Cache-Control de un año desde 02-02.
  • bs-catalogo-web → CACHE_ALL_STATIC, y que sea la aplicación Flask la que decida ruta a ruta.

Así es como Dani lo declara en la aplicación:

# app.py — control explícito de cacheabilidad por ruta

@app.route("/producto/<sku>")
def ficha_producto(sku):
    producto = repositorio.obtener(sku)
    respuesta = make_response(render_template("producto.html", producto=producto))
    # Publico y cacheable 5 minutos en el borde, 1 minuto en el navegador.
    # 's-maxage' lo lee el CDN; 'max-age' lo lee el navegador.
    respuesta.headers["Cache-Control"] = "public, max-age=60, s-maxage=300"
    return respuesta


@app.route("/carrito")
def carrito():
    respuesta = make_response(render_template("carrito.html"))
    # Nunca, en ningun sitio, bajo ninguna circunstancia.
    respuesta.headers["Cache-Control"] = "private, no-store"
    return respuesta

Regla que conviene grabar: si una respuesta depende de quién la pide, lleva private, no-store. Ponlo tú explícitamente y no confíes en que el modo de caché te salve.

  1. La clave de caché y el desastre de los parámetros utm_*

La clave de caché es la cadena con la que el CDN identifica un objeto guardado. Si dos peticiones generan la misma clave, la segunda es un acierto. Si generan claves distintas, la segunda es un fallo aunque el contenido sea idéntico.

Por defecto la clave incluye:

  • El protocolo (http o https).
  • El host (alpinashop.example).
  • La ruta (/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp).
  • La cadena de consulta completa (?utm_source=newsletter&utm_campaign=otono26).

Y ahí está el problema. El equipo de marketing de AlpinaShop lanza una campaña y las URLs de las imágenes empiezan a llegar así:

/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp?utm_source=newsletter&utm_medium=email&utm_campaign=otono26
/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp?utm_source=instagram&utm_medium=social&utm_campaign=otono26
/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp?fbclid=IwAR3x9...

Son el mismo objeto con tres claves distintas. Cada variante provoca un fallo de caché, una lectura del bucket y un cargo de relleno de caché. Con una campaña grande, el ratio de aciertos se hunde de un 92 % a un 40 % y nadie entiende por qué la factura ha subido justo cuando llegaron las visitas.

La solución es excluir esos parámetros de la clave:

# Backend de bucket: las imagenes no dependen de NINGUN parametro
gcloud compute backend-buckets update bb-catalogo-imagenes \
  --cache-key-query-string-blacklist=utm_source,utm_medium,utm_campaign,utm_term,utm_content,gclid,fbclid,msclkid,ref

# Servicio de backend: enfoque inverso, lista blanca.
# Solo estos parametros forman parte de la clave; el resto se ignoran.
gcloud compute backend-services update bs-catalogo-web --global \
  --cache-key-query-string-whitelist=pagina,orden,talla,color

Dos estrategias, y conviene entender por qué se usa una en cada sitio:

Estrategia Comando Ventaja Riesgo
Lista negra (excluir) --cache-key-query-string-blacklist Fácil de empezar; no rompe nada existente Cada parámetro de seguimiento nuevo hay que añadirlo a mano
Lista blanca (incluir solo) --cache-key-query-string-whitelist Inmune a parámetros nuevos; ratio máximo Si olvidas un parámetro que sí cambia el contenido, sirves la página equivocada

Para el bucket, la lista negra basta y sobra; en realidad ningún parámetro afecta a una imagen. Para el servicio de backend, la lista blanca es más potente pero exige que Dani revise la lista cada vez que añada un filtro nuevo al catálogo. Es una decisión que se documenta, no que se improvisa.

Si las imágenes de verdad no dependen de ningún parámetro, la opción más limpia es directamente:

gcloud compute backend-buckets update bb-catalogo-imagenes \
  --cache-key-query-string-blacklist=""   # equivale a ignorar toda la query

Otros componentes que se pueden añadir a la clave:

  • --cache-key-include-http-header=X-Alpina-Region: útil si sirves variantes por cabecera. Cada valor distinto multiplica las entradas de caché, así que úsalo solo con cabeceras de cardinalidad muy baja.
  • --cache-key-include-named-cookie=idioma: variantes por cookie concreta. Mismo aviso.
  • --no-cache-key-include-protocol: unifica HTTP y HTTPS en la misma entrada. Como en AlpinaShop todo el HTTP se redirige a HTTPS (03-02), da igual.

Y una advertencia que va en el otro sentido: si el origen devuelve una cabecera Vary con valores que el CDN no admite, la respuesta directamente no se cachea. Vary: Accept-Encoding es seguro; Vary: User-Agent mata la caché por completo, porque hay decenas de miles de user agents distintos.

  1. TTL: quién decide cuánto vive un objeto en el borde

Hay cinco valores implicados y se confunden constantemente. Este es el orden de mando:

Valor Dónde se define A quién afecta Qué hace
Cache-Control: max-age Cabecera del origen Navegador y CDN TTL general
Cache-Control: s-maxage Cabecera del origen Solo cachés compartidas (el CDN) Tiene prioridad sobre max-age en el borde
--default-ttl Configuración del backend CDN TTL cuando el origen no dice nada
--max-ttl Configuración del backend CDN Techo: recorta lo que pida el origen
--client-ttl Configuración del backend Navegador Techo del max-age que se envía al cliente

La secuencia real, para una respuesta que llega desde el origen con Cache-Control: public, max-age=31536000, immutable y un backend configurado con --max-ttl=86400 --client-ttl=3600:

  1. El CDN lee max-age = 31 536 000 s.
  2. Aplica --max-ttl: guarda el objeto 24 horas, no un año.
  3. Aplica --client-ttl: reescribe la cabecera hacia el cliente a max-age=3600, una hora.

La combinación s-maxage alto + max-age bajo es la más útil y la menos usada:

Cache-Control: public, max-age=60, s-maxage=86400

Significa: "CDN, guárdalo un día; navegador, guárdalo un minuto". Ventaja: si necesitas cambiar el contenido, invalidas la caché del CDN (que sí controlas) y en 60 segundos todos los navegadores del mundo ven lo nuevo. Nunca puedes invalidar la caché de un navegador ajeno; por eso conviene que el cliente cachee poco y el borde cachee mucho — salvo cuando el nombre del fichero es inmutable, que es el caso siguiente.

  1. Nombres inmutables: la estrategia que sostiene todo lo anterior

En 02-02 tomaste una decisión que ahora se cobra sus intereses: las imágenes de AlpinaShop se guardan con un nombre que nunca se reutiliza, con un hash o versión incrustada:

productos/MOCH-2210/web/frontal-800.a3f9c1.webp
productos/MOCH-2210/web/frontal-800.b7e204.webp   ← la foto nueva es otro objeto

Y se suben con Cache-Control: public, max-age=31536000, immutable.

La consecuencia es enorme y merece enunciarse explícitamente:

Si el nombre es inmutable, el problema de la invalidación desaparece. Publicar una foto nueva no consiste en actualizar un objeto cacheado, sino en publicar un objeto que nadie ha pedido nunca. La ficha HTML apunta al nombre nuevo y la caché sirve el objeto nuevo desde el primer momento, mientras el viejo caduca solo.

Con ese esquema, la política correcta para el bucket es cachear un año de verdad, también en el cliente:

gcloud compute backend-buckets update bb-catalogo-imagenes \
  --cache-mode=CACHE_ALL_STATIC \
  --default-ttl=3600 \
  --max-ttl=31536000 \
  --client-ttl=31536000

Aquí sí queremos client-ttl largo: el navegador de un cliente recurrente no volverá a pedir la imagen jamás. Fíjate en el contraste con el apartado anterior: el TTL del cliente es largo cuando el nombre es inmutable y corto cuando no lo es. Esa es toda la regla.

Y el corolario para el HTML: la ficha de producto no puede tener TTL largo, porque su URL sí es estable (/producto/MOCH-2210). Por eso lleva s-maxage=300. Contenido con URL estable → TTL corto. Contenido con URL versionada → TTL de un año.

  1. Invalidación de caché: el botón de emergencia

A veces hay que borrar algo del borde antes de que caduque: se publicó un precio equivocado, una imagen con el logotipo mal, un texto legal incorrecto.

# Invalidar un objeto concreto
gcloud compute url-maps invalidate-cdn-cache alpinashop-url-map \
  --path="/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp"

# Invalidar un prefijo completo (el comodin solo vale al final)
gcloud compute url-maps invalidate-cdn-cache alpinashop-url-map \
  --path="/imagenes/productos/MOCH-2210/*"

# Invalidar todo el sitio: el martillo. Evitalo.
gcloud compute url-maps invalidate-cdn-cache alpinashop-url-map \
  --path="/*" --async

# Seguir la operacion
gcloud compute operations list --global --filter="operationType~invalidate" --limit=5

Observa que la invalidación se ejecuta sobre el mapa de URL, no sobre el backend: se propaga a todos los backends que ese mapa enruta.

Por qué no debe ser tu mecanismo habitual:

  • Es lenta en términos de incidente. Hay que propagar la orden a todos los puntos de presencia del mundo; el orden de magnitud son minutos, no segundos. Durante una crisis de contenido, esos minutos se hacen largos.
  • Está sujeta a cuota. Hay un límite de invalidaciones por proyecto y unidad de tiempo. Un despliegue que invalida /* en cada publicación choca con la cuota antes de lo que crees.
  • Un /* destruye el ratio de aciertos. Después de una invalidación total, todo el tráfico del mundo golpea el origen a la vez. Has convertido una publicación rutinaria en una prueba de carga involuntaria.
  • Requiere permisos elevados, típicamente roles/compute.loadBalancerAdmin, que no querrás repartir alegremente (03-04).

La alternativa correcta es la del apartado 8: nombres versionados para los recursos, TTL corto para el HTML. La invalidación queda reservada a los errores, que es para lo que está.

  1. Contenido privado en caché: URLs y cookies firmadas

El bucket alpinashop-catalogo es privado (lo creaste con --public-access-prevention en 02-02) y el backend de bucket lo lee con permisos propios, así que las imágenes de catálogo son públicas a través del balanceador pero no directamente. Perfecto para el catálogo.

Ahora imagina el siguiente caso de AlpinaShop: los vídeos de las guías técnicas de escalada solo deben verlos los clientes que han comprado el curso presencial. Son ficheros grandes, se benefician mucho de la caché, pero no pueden ser públicos.

Para eso existen las URLs firmadas de Cloud CDN y las cookies firmadas. La diferencia con las URLs firmadas de Cloud Storage (02-02) es importante: aquellas las valida Cloud Storage y rompen la caché (cada firma es una URL distinta); estas las valida el borde del CDN y permiten cachear.

# 1) Generar una clave de firma de 16 bytes en base64url
head -c 16 /dev/urandom | base64 | tr +/ -_ > clave-cdn.txt

# 2) Registrarla en el backend
gcloud compute backend-buckets add-signed-url-key bb-catalogo-guias \
  --key-name=clave-guias-2026-01 \
  --key-file=clave-cdn.txt

# 3) Indicar cuanto se cachea la respuesta firmada
gcloud compute backend-buckets update bb-catalogo-guias \
  --signed-url-cache-max-age=3600

# 4) Guardar la clave en Secret Manager (03-06) y borrar el fichero local
gcloud secrets create cdn-clave-guias --data-file=clave-cdn.txt
shred -u clave-cdn.txt

Cookie firmada frente a URL firmada:

URL firmada Cookie firmada
Alcance Un objeto concreto Un prefijo de rutas entero
Cómo llega Dentro del enlace Cabecera Set-Cookie con el nombre Cloud-CDN-Cookie
Buena para Una descarga puntual Una galería o un reproductor con muchos ficheros
Efecto en la caché Cachea bien: la clave ignora los parámetros de firma Cachea bien, y una sola cookie cubre toda la sesión

Para los vídeos de las guías, la cookie es la opción correcta: la aplicación Flask valida la compra, emite una cookie firmada válida para /guias/ durante dos horas y el reproductor pide los fragmentos sin más autenticación. El borde valida la firma y sirve desde caché.

Aviso. Las claves de firma son material criptográfico: dan acceso a contenido de pago. Deben vivir en Secret Manager (03-06), rotarse periódicamente (se pueden registrar varias claves a la vez en el mismo backend precisamente para rotar sin cortes) y no aparecer nunca en el repositorio. Si el contenido protegido tiene implicaciones contractuales o de derechos, que un profesional de seguridad revise el diseño antes de producción.

  1. Caché negativa, contenido obsoleto y otras opciones útiles

Caché negativa. Sin ella, si un rastreador pide diez mil URLs inexistentes, tu origen genera diez mil 404. Con ella, el borde recuerda el error:

gcloud compute backend-services update bs-catalogo-web --global \
  --negative-caching \
  --negative-caching-policy='404=120,410=300,500=0,502=0,503=0'

Lo interesante es el matiz de los códigos 5xx puestos a 0: no queremos cachear nuestros propios errores de servidor. Si el MIG devuelve un 502 durante un despliegue y lo cacheamos dos minutos, hemos multiplicado el impacto del despliegue. Los 404 sí: un producto que no existe seguirá sin existir dentro de dos minutos.

Servir contenido obsoleto (--serve-while-stale). Ya lo activamos: durante ese margen, si el origen no responde o devuelve error, el borde sirve la copia caducada y revalida en segundo plano. Es la diferencia entre "la web va un poco desactualizada" y "la web está caída".

Saltarse la caché bajo demanda. Muy útil para que Dani depure en producción sin tocar la configuración:

gcloud compute backend-services update bs-catalogo-web --global \
  --bypass-cache-on-request-headers=x-alpina-debug

A partir de ahí, cualquier petición con esa cabecera va directa al origen. Como puede saltarse la caché de todo el sitio, la cabecera debe ser poco adivinable y no debe documentarse fuera del equipo.

  1. Medición: ratio de aciertos, cabeceras Age y Via, y depuración con curl

Sin medir, todo lo anterior es fe. Hay tres niveles de observación.

Nivel 1: la respuesta HTTP. La forma más rápida de saber si algo se está cacheando:

curl -sI https://alpinashop.example/imagenes/productos/MOCH-2210/web/frontal-800.a3f9c1.webp
HTTP/2 200
content-type: image/webp
cache-control: public, max-age=31536000, immutable
age: 4127
via: 1.1 google

Cómo se lee:

Cabecera Qué significa
Age: 4127 La copia lleva 4127 segundos en el borde. Age presente y mayor que cero = acierto de caché.
Sin Age Fallo de caché: la respuesta viene del origen
Via: 1.1 google Ha pasado por la infraestructura de proxy de Google. Aparece haya acierto o no
Cache-Control Lo que el borde le dice al navegador, ya recortado por --client-ttl

Prueba clásica: pide dos veces seguidas. La primera no trae Age; la segunda, sí. Si la segunda tampoco lo trae, hay un problema de cacheabilidad y toca el nivel 2.

Nivel 2: los logs del balanceador. Los campos de caché ya están ahí:

gcloud logging read '
  resource.type="http_load_balancer"
  AND httpRequest.requestUrl:"/imagenes/"
' --project=alpinashop-prod --limit=20 --freshness=1h \
  --format="table(
    httpRequest.requestUrl,
    httpRequest.cacheLookup,
    httpRequest.cacheHit,
    httpRequest.cacheFillBytes,
    jsonPayload.statusDetails
  )"
  • cacheLookup: false → ni siquiera se buscó en caché. Casi siempre significa que el CDN no está activado en ese backend, o que la petición no es cacheable (método distinto de GET, cabecera Authorization…).
  • cacheLookup: true, cacheHit: false → se buscó y no estaba: fallo legítimo o clave de caché fragmentada (apartado 6).
  • cacheFillBytes → los bytes que se trajeron del origen para rellenar. Si esto es alto de forma sostenida, tu ratio es malo.
  • statusDetails: response_from_cache → confirmación de acierto.

Nivel 3: métricas. El ratio de aciertos se obtiene de loadbalancing.googleapis.com/https/request_count agrupando por la etiqueta cache_result, cuyos valores son HIT, MISS, DISABLED y variantes de revalidación. El panel de Marta (06-04) debe llevar:

Métrica / vista Objetivo para AlpinaShop
Ratio de aciertos en /imagenes/* > 90 %
Ratio de aciertos global > 60 %
cacheFillBytes mensual Estable o a la baja pese a crecer el tráfico
p95 de total_latencies de imágenes Muy por debajo del p95 del HTML

Guía de depuración de un fallo persistente. Si un objeto nunca se cachea, comprueba en este orden:

  1. --enable-cdn está realmente en el backend correcto (describe, no memoria).
  2. La respuesta no lleva Cache-Control: private, no-store ni no-cache.
  3. La respuesta no lleva Set-Cookie. Es la causa más frecuente en aplicaciones Flask: basta con que se toque session en la vista para que se emita una cookie y la respuesta deje de ser cacheable.
  4. No hay un Vary con cabeceras de alta cardinalidad (User-Agent, Cookie).
  5. La petición es GET y no lleva Authorization.
  6. La clave de caché no está fragmentada por parámetros de consulta.
  7. La respuesta trae Content-Length o una longitud determinable.

El punto 3 se arregla en dos líneas y suele explicar la mitad de los casos:

@app.after_request
def limpiar_cookie_en_estaticos(respuesta):
    """Evita que una cookie de sesion accidental haga incacheable el catalogo."""
    if request.path.startswith(("/estatico/", "/imagenes/")):
        respuesta.headers.pop("Set-Cookie", None)
    return respuesta

  1. El ahorro real: latencia y euros

Vamos con un cálculo orientativo para AlpinaShop. Supongamos 2 TB al mes de imágenes servidas a clientes europeos y un ratio de aciertos del 90 %.

Concepto Sin CDN Con CDN al 90 %
Salida a internet desde el bucket 2 000 GB × ~0,11 $/GB ≈ 220 $ 200 GB (los fallos) → relleno de caché ≈ 2–20 $
Salida desde caché del CDN — 1 800 GB × ~0,08 $/GB ≈ 144 $
Búsquedas en caché — ~2 M peticiones × 0,0075 $/10 000 ≈ 1,5 $
Operaciones de clase B en el bucket Millones de lecturas Solo las de relleno: dos órdenes de magnitud menos
Total aproximado ≈ 220 $ ≈ 150–170 $

Precios de orden de magnitud a modo ilustrativo. Las tarifas de salida, de relleno de caché y de búsqueda varían por continente y por tramo de volumen, y cambian con el tiempo: verifícalas siempre en la calculadora y en la documentación oficial de precios antes de presentar una cifra a nadie.

Conclusiones honestas de ese cuadro, que es más interesante que un "ahorras un 90 %" de folleto:

  • El ahorro en factura es real pero moderado: en torno a un 25-30 %. La salida desde caché también se paga.
  • El ahorro crece con el tráfico, porque el origen deja de escalar con las visitas: menos instancias en el MIG, menos operaciones en el bucket, menos IOPS.
  • La ganancia grande es la latencia. Un cliente de Lisboa pasa de un tiempo hasta el primer byte del orden de 40-60 ms a menos de 15 ms; uno de Helsinki, de más de 60 ms a cifras similares. En una ficha de producto con doce imágenes, eso se traduce en cientos de milisegundos de página completa, que es exactamente lo que mide Google en las señales de experiencia de página y lo que se nota en la conversión.
  • Y hay un ahorro que no sale en ninguna factura: los picos de campaña dejan de ser un problema de capacidad.

  1. Cuándo Cloud CDN no basta: Media CDN

Google Cloud tiene un segundo producto de distribución, Media CDN, construido sobre la misma infraestructura que sirve YouTube. No es "Cloud CDN mejorado": es otro producto, con otro modelo de configuración (EdgeCacheService, EdgeCacheOrigin, EdgeCacheKeyset) y otro público.

Cloud CDN Media CDN
Se configura sobre El balanceador global existente Recursos propios, independientes del balanceador
Pensado para Webs, APIs, imágenes, estáticos Vídeo bajo demanda y en directo, descargas masivas
Volumen típico Cualquiera Decenas de TB al mes en adelante
Extras Los vistos en esta lección Reglas de enrutamiento avanzadas, mejor economía a gran escala

Para AlpinaShop, Cloud CDN es la elección correcta y lo seguirá siendo. Si algún día la sección de guías técnicas se convierte en una plataforma de vídeo con miles de horas y decenas de TB mensuales, entonces sí tocaría evaluar Media CDN.

Errores Comunes y Consejos

  • Creer que Cloud CDN es un producto aparte y buscar dónde crear la "distribución". Es una casilla del backend del balanceador que ya tienes.
  • Activar FORCE_CACHE_ALL en el backend que sirve el sitio entero. Es la vía más rápida para servirle a un cliente el carrito de otro. Si lo necesitas, hazlo en un backend dedicado a rutas públicas.
  • Ignorar los parámetros de campaña. Los utm_* y fbclid fragmentan la clave de caché y hunden el ratio justo el día que llega el tráfico. Configura la lista negra antes de la campaña, no después.
  • Confundir max-age con s-maxage. El segundo solo lo leen las cachés compartidas y tiene prioridad en el borde. La pareja max-age bajo + s-maxage alto es la que quieres para el HTML.
  • Poner --client-ttl largo en contenido con URL estable. No puedes invalidar el navegador de nadie. TTL de cliente largo solo con nombres inmutables.
  • Usar la invalidación como parte del despliegue. Es lenta, tiene cuota y un /* deja el origen al descubierto. Versiona los nombres.
  • Olvidarse de Set-Cookie. Una sesión de Flask tocada por accidente en una vista de catálogo hace incacheable toda la ruta y nadie entiende el ratio bajo.
  • Vary: User-Agent. Anula la caché por completo. Revisa qué Vary emite tu aplicación y tu CMS.
  • Cachear los 5xx. Con caché negativa mal configurada, un despliegue con un fallo de treinta segundos se convierte en cinco minutos de error para todo el mundo. Pon los 5xx a 0.
  • No activar --serve-while-stale. Es prácticamente gratis y convierte caídas en degradaciones.
  • Medir "a ojo" desde el navegador. El navegador tiene su propia caché y te engañará. Depura con curl -sI y con los campos cacheHit/cacheLookup del log.
  • Consejo: prueba desde varias ubicaciones. Un acierto en Madrid no dice nada de Varsovia. Cada punto de presencia tiene su propia caché y su propio calentamiento.
  • Consejo: separa lo cacheable de lo que no lo es en el mapa de URL. Es más fácil razonar sobre /estatico/* con caché de un año y /cuenta/* sin caché, que sobre un único backend con reglas sutiles.

Ejercicios

Ejercicio 1 — Diagnosticar un ratio de aciertos del 38 %

Marta abre el panel una semana después de activar el CDN y ve un ratio del 38 % en /imagenes/*, cuando esperaba más del 90 %. Recopila estos datos:

  • curl -sI sobre una imagen concreta, dos veces seguidas: la segunda respuesta sí trae Age: 61.
  • El log muestra muchas peticiones con cacheLookup: true, cacheHit: false y URLs terminadas en ?utm_source=....
  • Otras peticiones muestran cacheLookup: false sobre rutas /imagenes/promo/*.
  • El Cache-Control de las imágenes de /imagenes/promo/ es public, max-age=300.

Explica cada síntoma por separado y escribe los comandos que corrigen la situación.

Ejercicio 2 — Política de caché para tres rutas nuevas

AlpinaShop añade tres rutas. Define para cada una: modo de caché, Cache-Control que debe emitir la aplicación, TTL de cliente y de borde, y qué componentes debe llevar la clave de caché.

  1. /api/stock/<sku> — devuelve JSON con las unidades disponibles. Cambia cada pocos minutos y lo consultan miles de visitantes.
  2. /estatico/app.4f2b9c.js — el bundle de JavaScript, con hash en el nombre.
  3. /cuenta/pedidos — el histórico de pedidos del cliente autenticado.

Ejercicio 3 — Cálculo de impacto de una campaña

La campaña de otoño va a multiplicar por cinco el tráfico de imágenes durante dos semanas: de 2 TB/mes se pasará a un ritmo equivalente a 10 TB/mes. Estima el coste mensual de salida con y sin CDN suponiendo un ratio del 90 %, y responde: ¿qué otro efecto, más importante que el coste, tiene el CDN en esa campaña? ¿Qué configurarías antes de que empiece?


Soluciones

Solución 1

Son tres problemas distintos disfrazados de uno:

a) La imagen concreta sí se cachea. El Age: 61 de la segunda petición lo demuestra. Por tanto la configuración base del backend de bucket es correcta y no hay que tocarla.

b) Los utm_* fragmentan la clave. Cada variante de campaña genera una entrada nueva: cacheLookup: true, cacheHit: false es exactamente la firma de este problema. Se corrige excluyendo los parámetros de la clave:

gcloud compute backend-buckets update bb-catalogo-imagenes \
  --cache-key-query-string-blacklist=utm_source,utm_medium,utm_campaign,utm_term,utm_content,gclid,fbclid,msclkid

c) /imagenes/promo/* ni se busca en caché. cacheLookup: false significa que la petición no llegó a consultarse. Si esas rutas están enrutadas en el mapa de URL hacia un backend distinto (por ejemplo un bs-promociones que se creó para la campaña), ese backend no tiene --enable-cdn. Se comprueba y se corrige así:

gcloud compute url-maps describe alpinashop-url-map \
  --format="yaml(pathMatchers)"

gcloud compute backend-services update bs-promociones --global \
  --enable-cdn --cache-mode=CACHE_ALL_STATIC \
  --default-ttl=3600 --max-ttl=86400

d) El max-age=300 de las promos no es un error, pero es incoherente: si las imágenes de promoción también tienen nombre inmutable, deberían llevar un año. Si no lo tienen, la corrección de fondo es adoptar el mismo convenio de nombres del resto del catálogo, no subir el TTL a ciegas.

Solución 2

Ruta Modo Cache-Control de la app Borde Cliente Clave de caché
/api/stock/<sku> CACHE_ALL_STATIC (con cabecera explícita) public, max-age=0, s-maxage=60 60 s 0 s Protocolo + host + ruta. Sin parámetros de consulta
/estatico/app.4f2b9c.js CACHE_ALL_STATIC public, max-age=31536000, immutable 1 año 1 año Solo ruta; ignorar toda la consulta
/cuenta/pedidos Irrelevante private, no-store Nunca Nunca No aplica

Razonamiento:

  1. Stock. Es JSON, no lo cachearía CACHE_ALL_STATIC por sí solo, así que la cabecera explícita es obligatoria. s-maxage=60 con max-age=0 es la combinación clave: el borde absorbe miles de peticiones por minuto y el navegador siempre pregunta, de modo que un cambio de stock se ve como mucho un minuto tarde. Sesenta segundos de desfase en un contador de unidades es aceptable; un minuto de datos obsoletos en el paso de pago no lo sería, y por eso la comprobación definitiva de stock se hace en el momento de confirmar el pedido, no leyendo esta API.
  2. Bundle JS. Nombre con hash → caso libro: un año en todas partes. Publicar una versión nueva es publicar otro nombre.
  3. Histórico de pedidos. private, no-store explícito. Y muy importante: nunca en un backend con FORCE_CACHE_ALL. Además llevará Set-Cookie de sesión, lo que refuerza la incacheabilidad, pero no hay que depender de ese efecto colateral: la cabecera se pone a propósito.

Solución 3

Coste orientativo con 10 TB/mes:

  • Sin CDN: 10 000 GB × ~0,11 $/GB ≈ 1 100 $/mes.
  • Con CDN al 90 %: 9 000 GB desde caché × ~0,08 $/GB ≈ 720 $, más 1 000 GB de relleno (≈10-40 $) y las búsquedas (unos pocos dólares) ≈ 740-770 $/mes. Ahorro aproximado del 30 %.

El efecto más importante no es el coste, es la carga en el origen. Sin CDN, ese tráfico multiplicado por cinco lo absorbe el MIG y el bucket: el autoescalado subiría hacia el límite de 10 instancias, el coste de cómputo se dispararía y la latencia empeoraría justo el día de más ventas. Con un 90 % de aciertos, el origen solo ve el 10 % del tráfico de imágenes; el MIG se mantiene cerca de su tamaño habitual y la experiencia del cliente es mejor precisamente cuando más importa.

Qué configurar antes de que empiece la campaña:

  1. La lista negra de parámetros utm_*, gclid y fbclid. Sin esto, la campaña se sabotea a sí misma.
  2. --serve-while-stale=86400 en bs-catalogo-web, para que un problema del origen bajo carga degrade en lugar de tumbar la tienda.
  3. Caché negativa con los 5xx a 0 y los 404 a un par de minutos.
  4. Verificar con curl -sI que las imágenes de la campaña devuelven Age en la segunda petición, antes del lanzamiento.
  5. Un panel con el ratio de aciertos y cacheFillBytes visible durante la campaña, y una alerta si el ratio cae por debajo del 80 %.
  6. No invalidar la caché durante la campaña salvo emergencia real: sería regalar el pico de tráfico al origen.

Conclusión

AlpinaShop ya no envía los mismos bytes a Lisboa una y otra vez. Has entendido que Cloud CDN no es un producto aparte sino una propiedad del balanceador que construiste en 03-02: se activa con --enable-cdn sobre bb-catalogo-imagenes y sobre bs-catalogo-web, sin tocar el DNS, la IP, el certificado ni la aplicación, y aprovechando los mismos logs y las mismas métricas.

Sabes elegir el modo de caché con criterio —CACHE_ALL_STATIC como opción sensata por defecto, USE_ORIGIN_HEADERS cuando la aplicación ya manda de verdad, y FORCE_CACHE_ALL solo en backends dedicados a contenido público—, y por qué una respuesta con private, no-store o Set-Cookie nunca debe cachearse. Dominas la clave de caché, que es donde se gana o se pierde el ratio: has excluido utm_*, gclid y fbclid para que la campaña de otoño no destruya el trabajo, y conoces el equilibrio entre lista negra y lista blanca. Has ordenado los cinco valores de TTL —max-age, s-maxage, --default-ttl, --max-ttl, --client-ttl— y has visto por qué los nombres inmutables adoptados en 02-02 son la pieza que hace innecesaria la invalidación, esa operación lenta, con cuota y capaz de dejar el origen a la intemperie. Sabes servir contenido de pago desde caché con cookies firmadas, cachear los 404 pero nunca los 500, seguir sirviendo durante una caída con --serve-while-stale, y depurar un fallo de caché leyendo Age, Via y los campos cacheHit/cacheLookup del log. Y tienes un cálculo honesto del ahorro: en torno a un 30 % de la factura de salida, mucho más en carga del origen, y una mejora de latencia que se nota en cada ficha de producto.

Con esto, el borde de AlpinaShop está completo en lo que a rendimiento se refiere: red, balanceo y caché. Pero hay un problema que hemos ido aplazando lección tras lección. En 03-01 dijimos que el firewall no distingue personas y que "solo Lucía y Marta pueden entrar por SSH" se resuelve en otro sitio. En 02-02 limitamos a Lucía a un prefijo del bucket con una condición que no explicamos. En esta misma lección hemos dicho que invalidar la caché requiere un rol elevado que no conviene repartir. Todo eso apunta al mismo lugar: quién puede hacer qué, sobre qué recurso. En la siguiente lección, 03-04, Gestión de identidad y acceso (IAM), construimos el modelo de permisos real de AlpinaShop: grupos en lugar de personas, roles predefinidos en lugar de Editor, un rol personalizado a medida para Lucía, cuentas de servicio sin claves descargadas, impersonación, condiciones, y las herramientas para responder a la pregunta más frecuente de cualquier administrador de la nube: "¿por qué este usuario no puede hacer esto?".

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