Edge et serverless hybrides : exécuter du code près des utilisateurs

1. Aperçu

Les utilisateurs loin du serveur subiront une latence réseau élevée, même si votre code s’exécute très rapidement. L’architecture edge hybride serverless résout ce problème : la logique légère s’exécute en points proches des utilisateurs, la logique lourde s’exécute dans des fonctions serverless sur quelques régions centrales.

Cet article explique le concept, les raisons de l’intérêt pour ce modèle et comment répartir le travail entre les deux couches. Vous verrez des exemples concrets avec Spring Boot 3.x, Spring Cloud Function et une petite edge function. La fin de l’article couvre les cas où ne pas utiliser ce modèle.

2. Edge, serverless et le modèle hybride

2.1. Qu’est-ce que c’est, l’edge

L’edge est une infrastructure distribuée sur plusieurs emplacements, généralement liée aux réseaux CDN. Autrefois, l’edge était principalement utilisé pour mettre en cache les fichiers statiques. Aujourd’hui, de nombreuses plateformes permettent d’exécuter du code directement à l’edge, appelées edge functions.

Les runtimes edge ont généralement des limitations strictes :

  • Durée d’exécution courte par requête.
  • Mémoire faible, pas de système de fichiers complet.
  • Soutien généralement de JavaScript/TypeScript ou WebAssembly, rarement une JVM complète.
  • Pas adapté pour maintenir des connexions longue durée aux bases de données traditionnelles.

2.2. Qu’est-ce que c’est, le serverless

Serverless (Function-as-a-Service ou conteneurs auto-scaling) vous permet de déployer du code sans gérer de serveurs. La plateforme scale automatiquement selon le volume de requêtes, y compris jusqu’à 0. En contrepartie, vous devez accepter un cold start au démarrage d’une nouvelle instance.

Comparé à l’edge, le serverless s’exécute généralement dans moins de régions. En compensation, il offre plus de ressources, supporte Java pleinement et se situe près des bases de données.

2.3. Pourquoi avoir besoin du modèle hybride

Chaque couche a ses forces propres :

┌──────────┐    gần    ┌──────────────┐   xa hơn   ┌────────────────────┐    ┌──────────┐
│ Người    │ ────────▶ │ Edge         │ ─────────▶ │ Serverless origin  │ ─▶ │ Database │
│ dùng     │ ◀──────── │ (cache, định │ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │ tuyến, auth) │            │  Spring Cloud Func)│    └──────────┘
                       └──────────────┘            └────────────────────┘
  • Edge gère les tâches nécessitant une rapidité et indépendantes des données centrales : cache, redirection par langue, vérification basique de token, blocage de bots, A/B testing.
  • Serverless origin gère la logique métier nécessitant des données cohérentes : calcul de prix, création de commandes, paiements.

Principe général : sortez la partie fortement lue et rarement modifiée vers l’edge, conservez la partie d’écriture et celle nécessitant la cohérence à l’origin.

3. Pourquoi ce modèle attire l’attention

Il y a plusieurs raisons principales :

  1. Latence perçue. De nombreuses requêtes sont traitées directement à l’edge, sans avoir besoin d’atteindre l’origin. Les utilisateurs éloignés de la région centrale bénéficient le plus.
  2. Réduction de charge et coût de l’origin. Chaque requête mise en cache à l’edge signifie que la fonction serverless n’a pas besoin de s’exécuter.
  3. Personnalisation par localisation. L’edge connaît la localisation de l’utilisateur, ce qui permet de choisir la langue, la devise ou la version du contenu appropriée.
  4. Exigences de résidence des données. L’edge peut acheminer les requêtes vers l’origin dans la juridiction appropriée.
  5. L’écosystème Java s’est mieux adapté. Les images natives avec GraalVM et les techniques de démarrage rapide réduisent le cold start, rendant Java plus adapté au serverless qu’avant.

4. Origin : faire en sorte que Spring Boot « apprenne » à l’edge comment cacher

Le modèle hybride commence par l’origin. L’edge met en cache correctement seulement quand l’origin renvoie un en-tête Cache-Control clair. Considérez une API de catalogue de produits :

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

Le contrôleur renvoie des en-têtes de cache différents pour chaque type de données :

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

Signification de chaque directive :

  • max-age=30 : le navigateur peut utiliser la version en cache pendant 30 secondes.
  • public : le cache partagé (edge, CDN) est autorisé à stocker la réponse.
  • s-maxage=300 : le cache partagé conserve la version en cache pendant 5 minutes. L’edge la conserve plus longtemps que le navigateur, parce que vous pouvez effacer activement le cache à l’edge mais pas sur la machine de l’utilisateur.
  • stale-while-revalidate=60 : à l’expiration, l’edge continue à servir l’ancienne version pendant 60 secondes au maximum et la rafraîchit en arrière-plan. L’utilisateur n’a pas à attendre l’origin.
  • Le panier est des données personnelles, donc on utilise no-store pour qu’aucune couche ne le stocke.

Vérification avec 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. Logique métier en serverless avec Spring Cloud Function

La partie calcul nécessitant des données centrales doit s’exécuter dans une fonction serverless. Spring Cloud Function vous permet d’écrire la logique sous forme de Function pur, puis d’exécuter le même code sur HTTP localement ou via l’adaptateur de la plateforme serverless.

Ajoutez les dépendances (déclarez le BOM spring-cloud-dependencies avec une version compatible avec votre 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>

Écrivez une fonction de devis de commande par pays :

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

Lors de l’exécution locale, spring-cloud-function-web expose automatiquement le bean quoteOrder à l’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 logique réside dans une Function pure, indépendante de HTTP ou de la plateforme. Ainsi, vous pouvez basculer entre les fournisseurs serverless en changeant simplement l’adaptateur, sans modifier la logique métier.

Remarque : Avec Java sur serverless, le cold start est un vrai problème. Gardez les fonctions compactes, limitez les beans inutiles et envisagez une image native si la plateforme la supporte.

6. Edge function : routage, filtrage précoce et forwarding

La couche edge exécute généralement JavaScript. De nombreux runtimes edge suivent le modèle Fetch API web standard : reçoivent une Request, renvoient une Response. L’exemple ci-dessous illustre l’idée. La façon de nommer les en-têtes de localisation et le déploiement dépendent de la plateforme que vous utilisez.

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. Chuyển hướng trang chủ theo ngôn ngữ, không cần gọi origin
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. Chặn sớm request giỏ hàng không có token
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. Chuyển tiếp tới origin, kèm thông tin quốc gia
    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,
    }));
  },
};

Cette edge function fait trois choses :

  1. Redirection par pays directement à l’edge. Les requêtes pour la page d’accueil ne vont jamais à l’origin.
  2. Filtrage précoce des requêtes sans token. L’origin n’a pas besoin d’exécuter une fonction serverless pour une requête qui sera certainement rejetée. La vérification complète du token doit toujours se faire à l’origin.
  3. Forwarding du reste des requêtes. La réponse de l’origin porte l’en-tête Cache-Control de la section 4, donc la couche de cache de l’edge sait quelle réponse mettre en cache et pour combien de temps.

7. Tester le comportement principal

Le contrat entre l’origin et l’edge est essentiellement l’en-tête de cache. Par conséquent, écrivez des tests pour vous assurer que cet en-tête ne change pas involontairement :

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

La fonction serverless n’est qu’une Function, donc on la teste comme du code Java classique :

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. Erreurs courantes et quand ne pas l’utiliser

8.1. Erreurs courantes

  • Mise en cache accidentelle de données personnelles. Si vous oubliez no-store ou private pour les réponses dépendantes de l’utilisateur, l’edge pourrait servir le panier de quelqu’un d’autre à quelqu’un d’autre. C’est l’erreur la plus grave.
  • Clé de cache qui ne correspond pas à la personnalisation. Si l’origin renvoie un contenu différent selon X-Edge-Country mais que la clé de cache à l’edge n’en tient pas compte, tous les utilisateurs reçoivent la même version. Ajoutez l’en-tête Vary approprié ou configurez la clé de cache à l’edge.
  • Appel direct de la base de données depuis l’edge. Chaque requête d’une multitude de points d’edge vers une base de données dans une région. La latence ne diminue pas mais augmente, avec le risque d’épuiser les connexions.
  • Logique métier dupliquée. Les règles de tarification écrites une fois en JavaScript à l’edge, une fois en Java à l’origin, puis divergent progressivement. L’edge devrait uniquement faire du travail d’infrastructure : routage, cache, filtrage.
  • Confiance aveugle au filtrage à l’edge. Vérifier les tokens à l’edge est juste pour un filtrage précoce. L’origin doit toujours authentifier et autoriser complètement.

8.2. Quand NE PAS l’utiliser

Remarque : Le modèle hybride augmente le nombre de couches à déboguer, déployer et surveiller. À n’utiliser que si le bénéfice est clairement mesurable.

  • Les utilisateurs sont concentrés dans une région, près de la région origin. Le compute edge n’apporte alors que peu de bénéfices. Un CDN de cache statique est généralement suffisant.
  • L’application est principalement en écriture ou nécessite une forte cohérence, comme les systèmes de paiement ou de gestion des stocks. La plupart des requêtes doivent toujours aller à l’origin.
  • Les charges de travail s’exécutent longtemps ou sont lourdes en CPU, comme le traitement vidéo ou les gros rapports. Elles ne correspondent pas aux limitations de l’edge et conviennent mieux aux services continus qu’aux fonctions serverless.
  • Le trafic est constant et élevé. Un service s’exécutant en continu peut être plus simple et moins cher que le serverless.
  • Petite équipe, pas de bonne observabilité. Les erreurs réparties sur l’edge, le serverless et la base de données seront très difficiles à tracer sans tracing end-to-end.

9. Conclusion

Le modèle edge hybride serverless ne remplace pas l’architecture traditionnelle. C’est une façon de diviser le travail par distance : les tâches légères, fortement lues et indépendantes des données centrales s’exécutent à l’edge ; la logique métier nécessitant la cohérence s’exécute en serverless origin près de la base de données.

Dans cet article, vous avez vu comment l’origin Spring Boot contrôle le cache à l’edge avec Cache-Control utilisant s-maxage et stale-while-revalidate. Vous avez aussi écrit la logique métier sous forme de Function avec Spring Cloud Function pour faciliter les tests et le changement de plateforme, puis utilisé edge function pour rediriger, filtrer tôt et forwards les requêtes.

Commencez par l’étape la plus simple : établir les en-têtes de cache corrects pour chaque endpoint et mesurer l’efficacité. Ne sortez la logique à l’edge que si vos propres métriques système montrent que c’en vaut la peine.

edge computingserverlessspring bootspring cloud functionarchitecture