Edge y serverless híbrido: ejecutar código cerca del usuario
1. Descripción general
Los usuarios lejos del servidor experimentarán alta latencia de red, incluso si tu código se ejecuta muy rápido. El modelo edge combinado con serverless resuelve esto: la lógica ligera se ejecuta en puntos cercanos a los usuarios, la lógica pesada se ejecuta en funciones serverless en algunos centros de datos centrales.
Este artículo explica el concepto, por qué este modelo está ganando atención y cómo dividir el trabajo entre los dos niveles. Verás ejemplos concretos con Spring Boot 3.x, Spring Cloud Function y una pequeña edge function. Al final, hay casos donde no deberías usar este modelo.
2. Edge, serverless y el modelo híbrido
2.1. ¿Qué es edge?
Edge es infraestructura distribuida en muchos lugares, generalmente asociada con redes CDN. Antes, edge se usaba principalmente para cachear archivos estáticos. Hoy en día, muchas plataformas permiten ejecutar código directamente en el edge, llamado edge function.
El runtime de edge generalmente tiene límites estrictos:
- El tiempo de ejecución por request es corto.
- Memoria pequeña, sin sistema de archivos completo.
- Generalmente soporta JavaScript/TypeScript o WebAssembly, raramente la JVM completa.
- No es apropiado para mantener conexiones largas con bases de datos tradicionales.
2.2. ¿Qué es serverless?
Serverless (Function-as-a-Service o contenedores que se escalan automáticamente) te permite desplegar código sin gestionar servidores. La plataforma escala automáticamente según la cantidad de requests, incluso a cero. A cambio, debes aceptar el cold start cuando una nueva instancia se inicia.
Comparado con edge, serverless generalmente se ejecuta en menos regiones. Como compensación, tiene más recursos, soporte completo para Java y está más cerca de la base de datos.
2.3. ¿Por qué necesitas un modelo híbrido?
Cada nivel tiene sus fortalezas:
┌──────────┐ cerca ┌──────────────┐ más lejos ┌────────────────────┐ ┌──────────┐
│ Usuario │ ────────▶ │ Edge │ ────────────▶ │ Serverless origin │ ─▶ │ Base de │
│ │ ◀──────── │ (cache, ruta,│ ◀────────────│ (Spring Boot / │ ◀─ │ datos │
└──────────┘ │ auth) │ │ Spring Cloud Func)│ └──────────┘
└──────────────┘ └────────────────────┘
- Edge maneja las cosas que necesitan ser rápidas y no dependen de datos centrales: caché, redirección por idioma, verificación preliminar de tokens, bloqueo de bots, pruebas A/B.
- Serverless origin maneja la lógica de negocio que necesita datos consistentes: cálculo de precios, creación de pedidos, pagos.
La regla general: mueve la lógica de lectura intensiva y cambios infrecuentes al edge, mantén la lógica de escritura y la que necesita consistencia en el origin.
3. Por qué este modelo está ganando atención
Hay varias razones principales:
- Latencia percibida. Muchas requests se responden directamente en el edge, sin necesidad de ir al origin. Los usuarios lejos del centro de datos central se benefician más.
- Reducción de carga y costos en origin. Cada request cacheada en el edge es una ejecución de función serverless que se ahorra.
- Personalización según ubicación. Edge sabe dónde viene el usuario, así que puede elegir el idioma, moneda o versión de contenido apropiada.
- Requisitos de residencia de datos. Edge puede enrutar requests al origin en la región legal correcta.
- El ecosistema Java se ha adaptado mejor. Las imágenes nativas con GraalVM y técnicas de inicio rápido ayudan a reducir el cold start, por lo que Java es más apropiado para serverless que antes.
4. Origin: dejar que Spring Boot “enseñe” al edge cómo cachear
El modelo híbrido comienza en origin. Edge solo cachea correctamente cuando origin devuelve un header Cache-Control claro. Considera una API de catálogo de productos:
public record Product(String id, String name, BigDecimal price) {}
@Component
public class ProductCatalog {
private final Map<String, Product> products = Map.of(
"p-1", new Product("p-1", "Sombrero de hoja Huế", new BigDecimal("150000")),
"p-2", new Product("p-2", "Áo bà ba", new BigDecimal("320000"))
);
public Optional<Product> findById(String id) {
return Optional.ofNullable(products.get(id));
}
}
El controller devuelve diferentes headers de caché para cada tipo de dato:
@RestController
@RequestMapping("/api")
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable String id) {
return catalog.findById(id)
.map(product -> ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
.cachePublic()
.sMaxAge(Duration.ofMinutes(5))
.staleWhileRevalidate(Duration.ofMinutes(1)))
.body(product))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/cart")
public ResponseEntity<List<String>> getCart(@RequestHeader("Authorization") String token) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(List.of("p-1"));
}
}
El significado de cada directiva:
max-age=30: el navegador puede usar la versión en caché durante 30 segundos.public: el caché compartido (edge, CDN) puede almacenar la respuesta.s-maxage=300: solo el caché compartido mantiene la versión en caché durante 5 minutos. Edge la mantiene más tiempo que el navegador, porque puedes borrar manualmente el caché en edge pero no en el dispositivo del usuario.stale-while-revalidate=60: cuando expira, edge sigue devolviendo la versión anterior durante hasta 60 segundos y la actualiza en segundo plano. El usuario no tiene que esperar al origin.- El carrito es datos personales, así que usa
no-storepara que ningún nivel lo guarde.
Verifica con curl:
curl -i http://localhost:8080/api/products/p-1
HTTP/1.1 200
Cache-Control: max-age=30, public, s-maxage=300, stale-while-revalidate=60
Content-Type: application/json
{"id":"p-1","name":"Sombrero de hoja Huế","price":150000}
5. Lógica de negocio en serverless con Spring Cloud Function
La parte que requiere datos centrales debe ejecutarse en una función serverless. Spring Cloud Function te permite escribir lógica como una Function pura, y luego ejecutar el mismo código localmente vía HTTP o a través del adaptador de tu plataforma serverless.
Agrega la dependencia (declara el BOM spring-cloud-dependencies con una versión compatible con la versión de Spring Boot que estés usando):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-function-web</artifactId>
</dependency>
</dependencies>
Escribe una función que cotice un pedido según el país:
public record OrderRequest(String country, BigDecimal subtotal) {}
public record OrderQuote(BigDecimal subtotal, BigDecimal tax, BigDecimal total) {}
@Configuration
public class PricingFunctions {
private static final Map<String, BigDecimal> TAX_RATES = Map.of(
"VN", new BigDecimal("0.10"),
"SG", new BigDecimal("0.09")
);
@Bean
public Function<OrderRequest, OrderQuote> quoteOrder() {
return request -> {
BigDecimal rate = TAX_RATES.getOrDefault(request.country(), BigDecimal.ZERO);
BigDecimal tax = request.subtotal().multiply(rate).setScale(0, RoundingMode.HALF_UP);
return new OrderQuote(request.subtotal(), tax, request.subtotal().add(tax));
};
}
}
Cuando se ejecuta localmente, spring-cloud-function-web expone automáticamente el bean quoteOrder en el endpoint POST /quoteOrder:
curl -s -X POST http://localhost:8080/quoteOrder \
-H "Content-Type: application/json" \
-d '{"country":"VN","subtotal":150000}'
{"subtotal":150000,"tax":15000,"total":165000}
La lógica está en una Function pura, independiente de HTTP o la plataforma. Por eso puedes cambiar entre proveedores serverless solo reemplazando el adaptador, sin modificar la lógica de negocio.
Nota: Con Java en serverless, el cold start es un problema real. Mantén la función simple, limita los beans innecesarios y considera imágenes nativas si tu plataforma las soporta.
6. Edge function: enrutamiento, bloqueo temprano y reenvío
La capa de edge generalmente ejecuta JavaScript. Muchos edge runtimes siguen el modelo de Fetch API estándar de web: reciben Request, devuelven Response. El ejemplo abajo ilustra la idea. La forma de nombrar headers de ubicación y cómo desplegar depende de tu plataforma.
const ORIGIN = "https://origin.nonla.example";
export default {
async fetch(request) {
const url = new URL(request.url);
const country = request.headers.get("x-edge-country") ?? "VN";
// 1. Redirige la página de inicio por idioma, sin llamar al origin
if (url.pathname === "/") {
const lang = country === "VN" ? "vi" : "en";
return Response.redirect(`${url.origin}/${lang}/`, 302);
}
// 2. Bloquea temprano requests de carrito sin token
if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
return new Response("Unauthorized", { status: 401 });
}
// 3. Reenvía al origin, incluyendo información del país
const headers = new Headers(request.headers);
headers.set("X-Edge-Country", country);
return fetch(new Request(ORIGIN + url.pathname + url.search, {
method: request.method,
headers,
body: request.body,
}));
},
};
Esta edge function hace tres cosas:
- Redirige según el país directamente en el edge. Requests a la página de inicio nunca llegan al origin.
- Bloquea temprano requests sin token. Origin no gasta una ejecución de función serverless en requests que claramente serán rechazadas. La autenticación completa del token sigue siendo necesaria en origin.
- Reenvía el resto de requests. La respuesta del origin lleva headers
Cache-Controlde la sección 4, así que la capa de caché del edge sabe qué responses almacenar y por cuánto tiempo.
7. Prueba el comportamiento principal
El contrato entre origin y edge es el header de caché. Por eso escribe pruebas para asegurar que no se modifique sin intención:
@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void productResponseIsCacheableAtEdge() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/p-1")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getHeader("Cache-Control"))
.contains("public", "s-maxage=300", "stale-while-revalidate=60");
}
@Test
void cartResponseIsNeverStored() throws Exception {
MvcResult result = mockMvc.perform(get("/api/cart")
.header("Authorization", "Bearer demo"))
.andReturn();
assertThat(result.getResponse().getHeader("Cache-Control")).isEqualTo("no-store");
}
}
Una función serverless es solo una Function, así que la pruebas como código Java normal:
class PricingFunctionsTest {
private final Function<OrderRequest, OrderQuote> quoteOrder =
new PricingFunctions().quoteOrder();
@Test
void addsVietnameseTax() {
OrderQuote quote = quoteOrder.apply(new OrderRequest("VN", new BigDecimal("150000")));
assertThat(quote.tax()).isEqualByComparingTo("15000");
assertThat(quote.total()).isEqualByComparingTo("165000");
}
@Test
void unknownCountryHasNoTax() {
OrderQuote quote = quoteOrder.apply(new OrderRequest("XX", new BigDecimal("100")));
assertThat(quote.total()).isEqualByComparingTo("100");
}
}
8. Errores comunes y cuándo NO usar esto
8.1. Errores comunes
- Cachear datos personales por error. Si olvidas
no-storeoprivatepara responses que dependen del usuario, edge podría devolver el carrito de un usuario a otro. Este es el error más grave. - La clave de caché no coincide con la personalización. Si origin devuelve contenido diferente según
X-Edge-Countrypero la clave de caché en edge no considera ese header, todos los usuarios recibirán la misma versión. Agrega el headerVaryapropiado o configura la clave de caché en edge. - Llamar la base de datos directamente desde edge. Cada request desde muchos puntos de edge va a una base de datos en una región. La latencia no disminuye sino que aumenta, con riesgo de agotar las conexiones.
- Duplicar la lógica de negocio. La regla para calcular el precio se escribe una vez en JavaScript en el edge, otra en Java en el origin, y gradualmente divergen. Edge solo debe hacer trabajo de infraestructura: enrutamiento, caché, filtrado.
- Confiar demasiado en verificación en edge. Verificar el token en edge es solo para filtrado temprano. Origin aún debe autenticar y autorizar completamente.
8.2. Cuándo NO usar esto
Nota: El modelo híbrido aumenta el número de capas que necesitas depurar, desplegar y monitorear. Solo úsalo cuando los beneficios sean claramente medibles.
- Tus usuarios se concentran en una región, cerca del origin. El edge compute proporciona poco beneficio. Un caché de CDN estático generalmente es suficiente.
- Tu aplicación es principalmente escritura o necesita consistencia fuerte, como sistemas de pago o gestión de inventario. La mayoría de requests aún deben ir al origin.
- El workload se ejecuta durante mucho tiempo o es intensivo en CPU, como procesamiento de video o reportes grandes. No se ajustan a los límites de edge, y generalmente son más adecuados para servicios que se ejecutan continuamente que para funciones serverless.
- El tráfico es estable y alto de manera consistente. Un servicio que se ejecuta continuamente puede ser más simple y barato que serverless.
- Tu equipo es pequeño y carece de buena observabilidad. Los errores esparcidos en edge, serverless y base de datos serán muy difíciles de rastrear sin trazado distribuido sólido.
9. Conclusión
El modelo de edge combinado con serverless no reemplaza la arquitectura tradicional. Es una forma de dividir el trabajo según la distancia: trabajo ligero, intensivo en lectura y que no necesita datos centrales se ejecuta en edge; la lógica de negocio que necesita consistencia se ejecuta en serverless origin cerca de la base de datos.
En este artículo, viste cómo origin con Spring Boot controla el caché en edge mediante Cache-Control con s-maxage y stale-while-revalidate. También escribiste lógica de negocio como una Function con Spring Cloud Function para facilitar las pruebas y el cambio de plataforma, y usaste edge functions para enrutar, bloquear temprano y reenviar requests.
Comienza con el paso más simple: establece los headers de caché correctamente para cada endpoint y mide la efectividad. Solo mueve lógica al edge cuando los números de tu propio sistema muestren que vale la pena.