Edge と Serverless のハイブリッド: ユーザーの近くでコードを実行

1. 概要

ユーザーがサーバーから遠く離れている場合、コードがどれだけ高速に実行されても、ネットワーク遅延は高いままです。Edge と Serverless のハイブリッドモデルがこれを解決します。軽いロジックの一部はユーザーの近くのポイントで実行され、重い部分は中央の数個のリージョンの Serverless 関数で実行されます。

この記事では、このコンセプトについて説明し、なぜこのモデルが注目されているのか、そして 2 つのレイヤー間で作業を分割する方法を説明します。Spring Boot 3.x、Spring Cloud Function、および小さなエッジ関数の具体的な例を示します。記事の最後に、このモデルを使用すべきではないケースを説明します。

2. Edge、Serverless、ハイブリッドモデル

2.1. Edge とは何か

Edge は複数の場所に分散しているインフラストラクチャで、通常は CDN ネットワークに関連付けられています。以前は、Edge は主に静的ファイルをキャッシュするために使用されていました。今日、多くのプラットフォームが Edge でコードを直接実行できるようにしており、これを Edge 関数 と呼びます。

Edge runtime は通常、厳しい制限を持っています:

  • リクエストごとの実行時間は短い。
  • メモリが小さく、完全なファイルシステムがない。
  • 通常、JavaScript/TypeScript または WebAssembly をサポートしており、フル JVM を実行することはめったにない。
  • 従来型のデータベースへの長時間接続を保つのには適していない。

2.2. Serverless とは何か

Serverless(Function-as-a-Service または自動スケーリングコンテナ)は、サーバーを管理することなくコードをデプロイできます。プラットフォームはリクエスト数に応じて自動的にスケーリングし、0 までスケールダウンできます。その代わりに、新しいインスタンスが起動するときの Cold Start を受け入れる必要があります。

Edge と比べると、Serverless は通常、より少ないリージョンで実行されます。その代わりに、より多くのリソースを持ち、完全な Java サポートを提供し、データベースに近い位置にあります。

2.3. ハイブリッドモデルが必要な理由

各レイヤーには独自の強みがあります:

┌──────────┐    近い   ┌──────────────┐  さらに遠い ┌────────────────────┐    ┌──────────┐
│ ユーザー │ ────────▶ │ Edge         │ ─────────▶ │ Serverless origin  │ ─▶ │ Database │
│          │ ◀──────── │ (cache, ルート│ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │ 指定, auth)  │            │  Spring Cloud Func)│    └──────────┘
                       └──────────────┘            └────────────────────┘
  • Edge は、速度が必要で中央データに依存しない作業を処理します: キャッシュ、言語に基づくリダイレクト、トークンの事前チェック、ボットブロック、A/B テスト。
  • Serverless Origin は、一貫したデータを必要とするビジネスロジックを処理します: 価格計算、注文作成、支払い。

一般的な原則: 読み取りが多く、変更が少ない部分を Edge に移動し、書き込みと一貫性が必要な部分をオリジンに保つ。

3. なぜこのモデルが注目されているのか

いくつかの主な理由があります:

  1. レイテンシの認識. 多くのリクエストは Edge で直接応答され、Origin へ行く必要がありません。中央リージョンから遠いユーザーが最も恩恵を受けます。
  2. オリジンの負荷とコスト削減. Edge でキャッシュされるリクエストはすべて、Serverless 関数が実行される必要がない 1 回です。
  3. 場所に基づくパーソナライズ. Edge はユーザーがどこから来たかを知っているため、言語、通貨、または適切なコンテンツバージョンを選択できます。
  4. 地域ごとのデータストレージ要件. Edge は、リクエストを正しい法定地域にあるオリジンにルーティングできます。
  5. Java エコシステムの改善. GraalVM による Native Image と高速スタートアップの技術は Cold Start を削減し、Java が以前よりも Serverless に適しています。

4. Origin: Spring Boot が Edge にキャッシュ方法を「教える」

ハイブリッドモデルは Origin から始まります。Edge は Origin が明確な Cache-Control ヘッダーを返す場合のみ正しくキャッシュします。製品カタログ API の例を考えてみましょう:

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

Controller は各データ型に対して異なるキャッシュヘッダーを返します:

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

各指示の意味:

  • max-age=30: ブラウザーは 30 秒間キャッシュを使用できます。
  • public: 共有キャッシュ (Edge、CDN) がレスポンスを保存できます。
  • s-maxage=300: 共有キャッシュのみが 5 分間キャッシュを保持します。Edge はブラウザーより長く保持します。理由は、Edge でキャッシュを主動的に削除できますが、ユーザーのマシン上のキャッシュは削除できないからです。
  • stale-while-revalidate=60: 期限が切れると、Edge は最大 60 秒間古いバージョンを返し続け、バックグラウンドで更新します。ユーザーは Origin を待つ必要がありません。
  • ショッピングカートは個人データなので、no-store を使用して、どのレイヤーでも保存されないようにします。

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. Spring Cloud Function による Serverless でのビジネスロジック

中央データを必要とする計算部分は Serverless 関数で実行する必要があります。Spring Cloud Function は、純粋な Function としてロジックを記述し、同じコードをローカル HTTP または Serverless プラットフォームの adapter を通じて実行できるようにします。

依存関係を追加します (使用している Spring Boot と互換性のあるバージョンで spring-cloud-dependencies BOM を宣言します):

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

国に応じた注文見積もり関数を記述します:

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

ローカルで実行する場合、spring-cloud-function-web は quoteOrder bean を POST /quoteOrder endpoint で自動的に公開します:

curl -s -X POST http://localhost:8080/quoteOrder \
  -H "Content-Type: application/json" \
  -d '{"country":"VN","subtotal":150000}'
{"subtotal":150000,"tax":15000,"total":165000}

ロジックは純粋な Function に含まれており、HTTP やプラットフォームに依存しません。これにより、adapter を変更するだけで Serverless プロバイダー間を切り替えることができ、ビジネスロジックを修正する必要がありません。

注意: Serverless 上の Java では、Cold Start は実際の問題です。関数を簡潔に保ち、不要な bean を制限し、プラットフォームがサポートしている場合は Native Image を検討してください。

6. Edge 関数: ルーティング、事前ブロック、フォワード

Edge レイヤーは通常 JavaScript で実行されます。多くの Edge Runtime は標準的な Web Fetch API パターンに従っています: Request を受け取り、Response を返します。以下の例はこの考え方を示しています。場所ヘッダーの命名方法とデプロイ方法は、使用しているプラットフォームに依存します。

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. 言語に基づいてホームページをリダイレクト、origin の呼び出しは不要
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. トークンなしのショッピングカートリクエストを事前にブロック
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. 国情報を含めて origin にフォワード
    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,
    }));
  },
};

この Edge 関数は 3 つのことを行います:

  1. 国に基づいてリダイレクト Edge で直接。ホームページへのリクエストは Origin に到達することはありません。
  2. トークンがないリクエストを事前にブロック します。Origin は確実に拒否されるリクエストのために Serverless 関数の実行回数を節約できます。完全なトークン認証は依然として Origin で行う必要があります。
  3. 残りのリクエストをフォワード します。Origin からのレスポンスはセクション 4 の Cache-Control ヘッダーを含んでいるため、Edge のキャッシュレイヤーは、どのレスポンスをキャッシュして、どのくらいの期間キャッシュするかを知ります。

7. 主な動作のテスト

Origin と Edge の間の契約は、キャッシュヘッダーです。したがって、このヘッダーが意図せずに変更されないようにテストを記述します:

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

Serverless 関数は単なる Function なので、通常の Java コードと同じようにテストできます:

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. よくあるエラーと使用すべきでない場合

8.1. よくあるエラー

  • 個人データを誤ってキャッシュする. ユーザーに依存するレスポンスで no-store または private を忘れた場合、Edge はあるユーザーのショッピングカートを別のユーザーに返す可能性があります。これは最も深刻なエラーです。
  • キャッシュキーがパーソナライズ方法と一致しない. Origin が X-Edge-Country に基づいて異なるコンテンツを返すが、Edge のキャッシュキーがそのヘッダーを考慮しない場合、すべてのユーザーは同じバージョンを受け取ります。適切な Vary ヘッダーを追加するか、Edge でキャッシュキーを構成します。
  • Edge からデータベースを直接呼び出す. 複数の Edge ポイントから 1 つのリージョンのデータベースへのリクエストがあります。レイテンシは減少せず、増加し、接続が枯渇するリスクがあります。
  • ビジネスロジックの重複. 価格計算ルールを Edge で JavaScript で 1 回、Origin で Java で 1 回記述してから、徐々に乖離します。Edge はインフラストラクチャ作業のみを行うべきです: ルーティング、キャッシュ、フィルタリング。
  • Edge のチェックを完全に信頼する. Edge のトークンチェックは早期フィルタリング用です。Origin は依然として完全な認証と認可を実行する必要があります。

8.2. 使用すべきでない場合

注意: ハイブリッドモデルは、デバッグ、デプロイ、監視する必要があるレイヤー数を増やします。測定可能な利益が明確な場合にのみ使用します。

  • ユーザーが 1 つの地域に集中している、Origin リージョンの近く。Edge compute はこの場合、ほとんど利益をもたらしません。通常、静的 CDN キャッシュで十分です。
  • アプリケーションが主に書き込み用途、または強い一貫性が必要な場合、例えば支払いシステムまたは在庫管理。ほとんどのリクエストは依然として Origin に移動する必要があります。
  • 長時間実行または CPU 集約的なワークロード、ビデオ処理や大規模レポートなど。これらは Edge の制限に適合しておらず、通常は Serverless 関数よりも継続的に実行されるサービスに適しています。
  • トラフィックが安定し、継続的に高い. 継続的に実行されるサービスは Serverless よりも単純で安価な場合があります。
  • 小さなチーム、observability が不十分. Error は Edge、Serverless、Database に広がっており、エンドツーエンドのトレーシングがない場合、追跡が非常に困難になります。

9. 結論

Edge と Serverless のハイブリッドモデルは、従来型のアーキテクチャに取って代わるものではありません。それは距離に基づいた作業分割の方法です: 軽い、読み取りが多く、中央データを必要としない作業は Edge で実行され、一貫性が必要なビジネスロジックはデータベースの近くの Serverless Origin で実行されます。

この記事では、Origin の Spring Boot が s-maxage と stale-while-revalidate を含む Cache-Control で Edge のキャッシュを制御する方法を見ました。Spring Cloud Function を使って Function としてビジネスロジックを記述して、テストとプラットフォーム移行を容易にし、その後 Edge 関数を使用してリダイレクト、事前ブロック、リクエスト転送を実行しました。

最もシンプルなステップから始めてください: 各エンドポイントに対して正しくキャッシュヘッダーを設定し、有効性を測定します。システムのデータが価値があることを示す場合にのみ、ロジックを Edge に移動します。

edge computingserverlessspring bootspring cloud functionアーキテクチャ