Edge e serverless híbrido: executar code perto do usuário

1. Visão geral

Usuários distantes do servidor sofrem alta latência de rede, mesmo que seu código seja muito rápido. O modelo edge combinado com serverless resolve isso: lógica leve executa em pontos próximos ao usuário, enquanto a lógica pesada executa em funções serverless em alguns regions centrais.

Este artigo explica o conceito, por que esse modelo é importante e como dividir o trabalho entre as duas camadas. Você verá exemplos práticos com Spring Boot 3.x, Spring Cloud Function e uma pequena edge function. No final, há casos em que não deveria usar esse modelo.

2. Edge, serverless e o modelo híbrido

2.1. O que é edge

Edge é uma infraestrutura distribuída em vários locais, geralmente acoplada a redes CDN. Anteriormente, edge era usado principalmente para cache de arquivos estáticos. Hoje em dia, muitas plataformas permitem executar código diretamente na edge, chamado de edge function.

O runtime de edge geralmente tem limitações rígidas:

  • Tempo de execução de cada requisição é curto.
  • Memória pequena, sem sistema de arquivos completo.
  • Geralmente suporta JavaScript/TypeScript ou WebAssembly, raramente JVM completo.
  • Não é adequado para manter conexões longas com bancos de dados tradicionais.

2.2. O que é serverless

Serverless (Function-as-a-Service ou containers auto-escaláveis) permite fazer deploy de código sem gerenciar servidores. A plataforma escala automaticamente conforme a quantidade de requisições, incluindo scale para 0. Em troca, você precisa aceitar cold start quando uma nova instância é iniciada.

Comparado a edge, serverless geralmente executa em menos regions. Em compensação, tem mais recursos disponíveis, suporta Java completamente e fica perto do banco de dados.

2.3. Por que precisamos do modelo híbrido

Cada camada tem seus próprios pontos fortes:

┌──────────┐    perto    ┌──────────────┐   mais longe   ┌────────────────────┐    ┌──────────┐
│ Usuário  │ ────────▶ │ Edge         │ ────────────▶ │ Serverless origin  │ ─▶ │ Banco de │
│          │ ◀──────── │ (cache, rote │ ◀──────────── │ (Spring Boot /     │ ◀─ │ dados    │
└──────────┘           │ amento, auth)│                │  Spring Cloud Func)│    └──────────┘
                       └──────────────┘                └────────────────────┘
  • Edge processa tarefas que precisam ser rápidas e não dependem de dados centralizados: cache, redirecionamento por idioma, verificação sombria de token, bloqueio de bots, A/B testing.
  • Serverless origin processa lógica de negócio que precisa de dados consistentes: cálculo de preço, criação de pedido, pagamento.

Princípio geral: coloque leitura frequente e dados que raramente mudam na edge, mantenha escrita e dados que precisam ser consistentes na origin.

3. Por que esse modelo está sendo focado agora

Há várias razões principais:

  1. Latência percebida. Muitas requisições são respondidas diretamente na edge, sem precisar chegar à origin. Usuários longe do region central se beneficiam muito.
  2. Reduz carga e custo da origin. Cada requisição servida de cache na edge é uma vez menos que a função serverless precisa rodar.
  3. Personalização por localização. Edge sabe de onde o usuário vem, então pode escolher idioma, moeda ou versão de conteúdo apropriados.
  4. Requisitos de residência de dados. Edge pode rotear requisições para origin no region correto conforme requisitos legais.
  5. Ecossistema Java se adequou melhor. Native image com GraalVM e técnicas de inicialização rápida ajudam a reduzir cold start, tornando Java mais viável com serverless do que antes.

4. Origin: deixar Spring Boot “ensinar” edge como fazer cache

O modelo híbrido começa na origin. Edge só faz cache corretamente quando origin retorna header Cache-Control bem definido. Considere uma API de catálogo de produtos:

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", "Nón lá 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));
    }
}

O controller retorna headers de cache diferentes para cada tipo de dado:

@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"));
    }
}

O significado de cada diretiva:

  • max-age=30: o navegador pode usar a versão em cache por 30 segundos.
  • public: caches compartilhados (edge, CDN) podem armazenar a resposta.
  • s-maxage=300: especificamente caches compartilhados mantêm a resposta por 5 minutos. Edge mantém mais tempo que o navegador, pois você pode limpar cache na edge manualmente mas não no navegador do usuário.
  • stale-while-revalidate=60: quando expirar, edge continua servindo a versão antiga por até 60 segundos e revalida em background. O usuário não precisa esperar pela origin.
  • Carrinho é dado pessoal, então use no-store para que nenhuma camada armazene.

Verifique com 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":"Nón lá Huế","price":150000}

5. Lógica de negócio em serverless com Spring Cloud Function

Cálculos que precisam de dados centralizados devem rodar em funções serverless. Spring Cloud Function permite escrever lógica como uma Function pura, depois executar o mesmo código localmente via HTTP ou via adapter da plataforma serverless.

Adicione a dependência (declare BOM spring-cloud-dependencies com versão compatível com seu Spring Boot):

<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>

Escreva uma função que calcula cotação de pedido por 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));
        };
    }
}

Ao rodar localmente, spring-cloud-function-web expõe automaticamente o bean quoteOrder no 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}

A lógica fica dentro de uma Function pura, não depende de HTTP ou da plataforma. Graças a isso, você pode mudar entre provedores serverless apenas trocando o adapter, sem modificar a lógica de negócio.

Nota: Com Java em serverless, cold start é um problema real. Mantenha as funções simples, limite beans desnecessários e considere native image se a plataforma suportar.

6. Edge function: roteamento, bloqueio antecipado e encaminhamento

A camada edge geralmente executa JavaScript. Muitos edge runtime seguem o modelo Fetch API padrão da web: recebem Request, retornam Response. O exemplo abaixo ilustra a ideia. Como nomear headers de localização e como fazer deploy depende da plataforma que você usa.

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. Redirecionar página inicial por idioma, sem chamar origin
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. Bloquear cedo requisições de carrinho sem token
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. Encaminhar para origin, com informações de 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,
    }));
  },
};

Essa edge function faz três coisas:

  1. Redireciona por país direto na edge. Requisições para a página inicial nunca chegam à origin.
  2. Bloqueia cedo requisições sem token. Origin não gasta uma execução de função serverless com uma requisição que certamente será rejeitada. A verificação completa de token ainda deve ser feita na origin.
  3. Encaminha as demais requisições. A resposta da origin carrega header Cache-Control da seção 4, então a camada de cache da edge sabe qual resposta guardar e por quanto tempo.

7. Testando o comportamento principal

O contrato entre origin e edge é justamente o header de cache. Por isso, escreva testes para garantir que esse header não mude por acaso:

@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");
    }
}

A função serverless é apenas uma Function, então você a testa como código Java comum:

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. Armadilhas comuns e quando NÃO usar

8.1. Armadilhas comuns

  • Fazer cache de dados pessoais por engano. Se esquecer no-store ou private em resposta que depende do usuário, a edge pode entregar o carrinho de uma pessoa para outra. Esse é o erro mais grave.
  • Cache key não combina com personalização. Se origin retorna conteúdo diferente conforme X-Edge-Country mas a cache key na edge não leva isso em conta, todos os usuários recebem a mesma versão. Adicione header Vary apropriado ou configure a cache key na edge.
  • Chamar banco de dados diretamente da edge. Cada requisição sai de vários pontos de edge em direção a um banco de dados em um único region. Latência não diminui, pode até aumentar, com risco de esgotar conexões.
  • Lógica de negócio duplicada. Regra de precificação escrita uma vez em JavaScript na edge e outra em Java na origin, depois lentamente divergem. Edge deve fazer apenas trabalho de infraestrutura: roteamento, cache, filtragem.
  • Confiar demais em verificação na edge. Verificar token na edge é apenas para filtrar cedo. Origin ainda precisa autenticar e autorizar completamente.

8.2. Quando NÃO usar

Nota: O modelo híbrido aumenta o número de camadas para debugar, fazer deploy e monitorar. Use apenas quando benefícios forem mensuráveis e claros.

  • Usuários concentrados em uma região, perto do region de origin. Edge compute traz poucos benefícios nesse caso. Um CDN de cache estático geralmente é suficiente.
  • Aplicação principalmente escreve dados ou precisa de consistência forte, como sistema de pagamento ou controle de estoque. A maioria das requisições ainda vai precisar chegar à origin.
  • Workload roda por muito tempo ou é intensivo em CPU, como processamento de vídeo ou relatórios grandes. Não se adequam aos limites da edge, e geralmente combinam melhor com serviço contínuo do que com função serverless.
  • Traffic estável e alto continuamente. Um serviço rodando 24/7 pode ser mais simples e barato que serverless.
  • Equipe pequena, observabilidade ainda ruim. Erros distribuídos entre edge, serverless e banco de dados são muito difíceis de rastrear sem tracing completo.

9. Conclusão

O modelo edge combinado com serverless não substitui arquitetura tradicional. É uma forma de dividir trabalho pela distância: trabalho leve, leitura frequente e que não precisa de dados centralizados roda na edge; lógica de negócio que precisa ser consistente roda em serverless origin perto do banco de dados.

Neste artigo, você viu origin com Spring Boot controlar cache na edge via Cache-Control com s-maxage e stale-while-revalidate. Também escreveu lógica de negócio como Function com Spring Cloud Function para facilitar testes e mudança de plataforma, depois usou edge function para redirecionar, bloquear cedo e encaminhar requisições.

Comece pelo passo mais simples: coloque headers de cache corretos em cada endpoint e meça o impacto. Só mude lógica para edge quando os dados do seu próprio sistema mostrarem que vale a pena.

edge computingserverlessspring bootspring cloud functionarquitetura