Lambda@EdgeとCloudFront Functions、本番で両方使った結果が予想と違った

国際メディアサイトのリニューアルで、エッジ実行の2つのサービスを実装。想定と異なった選定基準と、実装で気づいた落とし穴を実例で紹介します。

先日のプロジェクトで痛感した、エッジ実行の落とし穴

うちのチームで国際向けのメディアサイトをリニューアルすることになって、コンテンツの地域別配信とセキュリティヘッダーの自動付与をエッジで実行する必要が出た。最初は「CloudFront Functionsで軽くやろう」と思ってたんだけど、要件が膨らむ度に「あ、これLambda@Edgeの方が向いてるな」って場面が何度も出てきた。結局、同じプロジェクト内で両方使うことになったんだけど、その過程で見えた選定基準と実装のポイントが結構面白い。

正直に言うと、2026年時点では「CloudFront Functionsが主流になる」って予想は外れてる。むしろLambda@Edgeはまだまだ現役で、両者の立ち位置が完全に棲み分けている。その理由を、実装した構成と共に話していきたい。

CloudFront FunctionsはHTTPヘッダー処理の専門家

最初にCloudFront Functionsから触った理由は、単純にシンプルさと費用だ。CloudFront FunctionsはHTTPリクエスト/レスポンスの処理に特化した軽量な実行環境なんだよね。JavaScriptで書くんだけど、実行時間が1ms未満に制限されてる代わりに、実行時間に対する課金がほぼゼロに近い。

うちのケースでは、全てのレスポンスに Strict-Transport-SecurityContent-Security-Policy を付与する処理をCloudFront Functionsで実装した。こういった定型的なヘッダー操作には最適だ。

// CloudFront Functions での例
function handler(event) {
  var response = event.response;
  var headers = response.headers;

  // セキュリティヘッダーを自動付与
  headers['strict-transport-security'] = {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubdomains; preload'
  };
  
  headers['content-security-policy'] = {
    key: 'Content-Security-Policy',
    value: "default-src 'self'; script-src 'self' 'unsafe-inline'"
  };

  return response;
}

実行時間が1ms未満という制限は、一見厳しそうに見えるけど、実際に使ってみるとそこまで困らない。ヘッダー操作、リクエストの軽いバリデーション、地域別リダイレクト程度なら十分な速度が出る。何より、デプロイも秒で終わるし、ローカルテストも簡単だ。

ただし、ここが大事なんだけど、CloudFront FunctionsはNode.jsの一部機能しか使えない。ファイルシステムアクセスもないし、外部のNPMパッケージも基本的には使えない。実装を進めるうちに、この制約が運用のボトルネックになってきたんだ。

Lambda@Edgeが必要になる瞬間

プロジェクトが進むにつれて「DynamoDBから地域別のコンテンツマッピングを取得したい」「リクエストボディをパースして条件分岐したい」「画像サイズに応じた最適な画像を返したい」みたいな要件が出てきた。CloudFront Functionsではこれらの複雑な処理ができないんだ。

そこでLambda@Edgeの出番。Lambda@EdgeはCloudFrontの背後で実行できる本物のLambda関数で、制限がはるかに緩い。実行時間は最大30秒(ビューリクエストは15秒)で、DynamoDB、S3、他のAWSサービスへのアクセスも普通にできる。つまり、エッジでもバックエンドと繋がった複雑な処理が実現できるわけだ。

# Lambda@Edge でのビューリクエスト処理例
import json
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('RegionContentMapping')

def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    headers = request['headers']
    
    # CloudFlareの国情報ヘッダーから地域を判定
    country_code = headers.get('cloudfront-viewer-country', [{}])[0].get('value', 'US')
    
    # DynamoDBから地域別のコンテンツマッピングを取得
    response = table.get_item(Key={'country': country_code})
    
    if 'Item' in response:
        content_version = response['Item'].get('version', 'default')
    else:
        content_version = 'default'
    
    # リクエストを修正してオリジンに渡す
    request['uri'] = f'/content/{content_version}{request["uri"]}'
    
    return request

こういった複雑な処理が必要になると、もうCloudFront Functionsでは無理。Lambda@Edgeじゃないと実現できない。

実装したアーキテクチャを見てみよう

うちのチームが最終的に構築した構成は、CloudFront Functionsとlambda@Edgeを組み合わせたハイブリッド方式だ。

graph TB
    subgraph "CloudFront Distribution"
        CF["CloudFront"]
        
        subgraph "Viewer Side"
            VF["CloudFront Functions<br/>(Viewer Request)"]
        end
        
        subgraph "Origin Side"
            OR["Lambda@Edge<br/>(Origin Request)"]
            OW["Lambda@Edge<br/>(Origin Response)"]
        end
    end
    
    subgraph "AWS Backend"
        CACHE["S3 Origin<br/>キャッシュレイヤー"]
        DDB["DynamoDB<br/>地域別マッピング"]
        LAMBDA["Origin Lambda<br/>ビジネスロジック"]
    end
    
    VF -->|軽量バリデーション<br/>ヘッダー追加| CF
    CF -->|キャッシュHIT| CACHE
    CF -->|キャッシュMISS| OR
    OR -->|国コード取得| DDB
    OR -->|動的コンテンツ| LAMBDA
    LAMBDA --> OW
    OW -->|レスポンス加工| CF

**Viewer Request層(CloudFront Functions)**で、低遅延が必須な軽い処理をやる。例えば、HTTPメソッドの検証、不正なリクエストの早期破棄、リクエストヘッダーの正規化といった具合だ。

**Origin Request層(Lambda@Edge)**では、少し重い処理に対応する。DynamoDBから動的データを取得したり、オリジンの選択ロジックを実装したり、リクエストボディを変換するといった処理が入る。

**Origin Response層(Lambda@Edge)**では、レスポンスの加工が主な仕事になる。セキュリティヘッダーの動的追加、キャッシュヘッダーの制御、画像の最適化メタデータ追加みたいなことをやってる。

正直、最初は「Viewer ResponseのCloudFront Functionsだけで十分」と思ってたんだけど、実装してみると「ここはやっぱりLambda@Edgeが必要だな」という場面が連続で出てくるんだ。

コスト・パフォーマンス・複雑度の実測データ

実装から3ヶ月運用した結果をまとめると、こんな感じになった。

項目CloudFront FunctionsLambda@Edge
実行時間制限1ms未満30秒(15秒)
実行時間計課金ほぼなし(M回実行で$0.6)実行時間で課金(100msごと)
外部API呼び出し不可可能
DynamoDB/S3アクセス不可可能
デプロイ時間数秒数分(全CloudFrontエッジ同期)
ローカルテスト簡単複雑
ダウンタイムなしローリング(最大15分)

コスト的には、CloudFront Functionsの方が圧倒的に安い。うちの場合、月100万リクエスト規模では、CloudFront Functionsはほぼ無視できるレベル。Lambda@Edgeは月5000円くらい費用がかかってる。

ただし、複雑度を考えるとLambda@Edgeが必要な処理も多い。そこを無理やりCloudFront Functionsでやろうとするのは、後々の運用負荷が半端ない。

デプロイメントの現実的な話

これ、公式ドキュメントには書いてないけど、本番運用で一番気になるポイントだ。

CloudFront Functionsは数秒でデプロイ完了する。修正してから本番反映までが本当に速い。

Lambda@Edgeは違う。デプロイするとCloudFrontの全エッジロケーション(200以上)に同期されるんだけど、これに数分〜15分かかるんだ。その間はローリング更新で部分的に新旧が混在する。不具合があると、全世界に一気に展開されるリスクがある。

だから、Lambda@Edgeは本当に必要な処理だけに限定した方がいい。容易に変更が必要な処理はCloudFront Functionsか、Origin Lambda(本体のLambda)に移すべき。

うちのチームでハマったのは、A/Bテストの割合を動的に変更したい要件だ。最初、これをLambda@Edgeでやろうとしたんだけど、デプロイメントの時間がネックになった。結果、割合の判定ロジックだけをLambda@Edgeで残して、割合の値そのものはDynamoDBから読み込むようにした。

# Lambda@Edge(簡潔版)
def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    
    # DynamoDBから割合を取得
    response = table.get_item(Key={'config': 'ab-test-ratio'})
    ratio = int(response['Item']['value'])
    
    # ユーザーのCookieベースの割合判定
    cookie_hash = hash_function(request['headers']['cookie'])
    if cookie_hash % 100 < ratio:
        request['uri'] = '/variant-b' + request['uri']
    
    return request

こうすることで、割合の変更が即座に反映される。Lambda@Edgeのコードは変わらないから、デプロイ待ちが必要ない。これ、本番運用で気づいた工夫だ。

CloudFront Functionsで実装できる実際の処理リスト

3ヶ月運用してわかった「これならいける」という処理をいくつか挙げてみた。

function handler(event) {
    var request = event.request;
    var response = event.response;
    var headers = request.headers;

    // 1. URLの正規化
    if (request.uri.endsWith('/')) {
        request.uri = request.uri.slice(0, -1);
    }

    // 2. ボットの検出と遮断
    var userAgent = headers['user-agent']?.[0]?.value || '';
    if (userAgent.includes('bot') || userAgent.includes('crawler')) {
        return {
            statusCode: 403,
            statusDescription: 'Forbidden'
        };
    }

    // 3. デバイス別クエリ追加
    var deviceType = headers['cloudfront-is-mobile-viewer']?.[0]?.value === 'true' ? 'mobile' : 'desktop';
    request.querystring = 'device=' + deviceType;

    // 4. セキュリティヘッダー追加
    response.headers['x-content-type-options'] = {
        key: 'X-Content-Type-Options',
        value: 'nosniff'
    };

    return response;
}

これらは全部CloudFront Functionsで十分。正直、ここまでできれば、エッジレイヤーの8割の要件が満たせる。

Lambda@Edgeが輝く場面

反対に「これはLambda@Edgeじゃないと無理」という処理を見てみよう。

# 1. DynamoDBからのテーブルルックアップ
def origin_request_handler(event, context):
    request = event['Records'][0]['cf']['request']
    
    user_id = extract_user_id(request)
    response = user_table.get_item(Key={'user_id': user_id})
    
    if response.get('Item', {}).get('premium'):
        request.uri = '/premium' + request.uri
    
    return request

# 2. 画像の動的最適化(WebP対応判定など)
def origin_response_handler(event, context):
    response = event['Records'][0]['cf']['response']
    request = event['Records'][0]['cf']['request']
    
    if 'image' in request.uri and 'webp' in request['headers'].get('accept', [''])[0]['value']:
        response['headers']['content-type'] = [{'value': 'image/webp'}]
    
    return response

# 3. マルチアカウント・マルチリージョンの複雑なルーティング
def origin_request_handler(event, context):
    request = event['Records'][0]['cf']['request']
    
    # 複数のS3バケットを地域別で使い分け
    region = get_user_region(request)
    bucket_map = {
        'JP': 'content-jp.s3.amazonaws.com',
        'EU': 'content-eu.s3.eu-west-1.amazonaws.com',
        'US': 'content-us.s3.us-east-1.amazonaws.com'
    }
    
    request['origin']['custom']['domainName'] = bucket_map.get(region, bucket_map['US'])
    
    return request

ここまで複雑な処理になると、CloudFront Functionsでは絶対に実現できない。

選定フローチャート

正直に言うと、この判断基準が2026年時点でも実務的な解答だ。

graph TD
    A["実装したい処理"] --> B{"実行時間<br/>1ms以下か?"}
    B -->|Yes| C{"外部API<br/>DynamoDB<br/>アクセス不要か?"}
    B -->|No| D["Lambda@Edge<br/>を選択"]
    C -->|Yes| E{"ローカル<br/>テストが<br/>重要か?"}
    C -->|No| D
    E -->|Yes| F["CloudFront Functions<br/>を選択"]
    E -->|No| G{"デプロイ頻度が<br/>高いか?"}
    G -->|Yes| F
    G -->|No| D

このフローに従うと、大体の判断がつく。

運用で気づいた細かいポイント

ドキュメントには書いてない、実装してから気づいたことをいくつか紹介しよう。

1. CloudFront Functionsのエラーログは難しい

Lambda@EdgeはCloudWatch Logsに直接ログが流れるけど、CloudFront Functionsはそうじゃない。エラーの詳細を確認するには、CloudFrontのディストリビューションをログ有効化して、S3に出力させる必要がある。これが意外と手間なんだ。デバッグの時間がかかるし、エラーが起きてから原因追跡までが長くなる。

2. Lambda@Edgeはリージョン固定

Lambda@Edgeを使う場合、関数をus-east-1リージョンにデプロイする必要がある。これ、めちゃくちゃ不便だ。他のリージョンで作った関数はCloudFrontと関連付けられない。グローバルに展開したい気持ちはわかるんだけど、管理が複雑になる。

3. キャッシュキーの設計がめちゃ大事

CloudFront Functionsで複数の条件に基づいてリクエストを修正すると、キャッシュキーが複雑になる。正規化されていないと、同じコンテンツなのに複数回キャッシュミスが発生するんだ。これ、パフォーマンスに直結する。

// 注意:こういう処理を入れるとキャッシュが効きにくくなる
function handler(event) {
    var request = event.request;
    
    // クエリパラメータの順序を正規化しないと
    // ?a=1&b=2 と ?b=2&a=1 が別物として扱われる
    var params = new URLSearchParams(request.querystring);
    var sorted = new URLSearchParams([...params].sort());
    request.querystring = sorted.toString();
    
    return request;
}

この辺りの工夫が、実装後の本番運用で想像以上に重要だった。

まとめ

2026年時点で、Lambda@EdgeとCloudFront Functionsの関係性は「完全な併存」だ。CloudFront Functionsが完全に置き換えた訳ではなく、むしろ両者が最適な領域で使い分けられている。

実装して分かったポイントは3つ:

  1. CloudFront Functions は軽量処理の専門家。ヘッダー操作、URLの正規化、簡単なバリデーション程度なら十分で、デプロイも高速。コスト的にも最高だ。

  2. Lambda@Edge は複雑処理が必要な時の選択肢。外部API呼び出しやDynamoDBアクセスが必要なら、これ一択。デプロイ時間が長いのは覚悟の上。

  3. 設計の工夫で運用負荷が変わる。デプロイ頻度が高い処理は、無理やりLambda@Edgeに詰め込まずに、動的データはDynamoDBから読み込むようにすると、後々楽になる。

次のアクション:

  • まずCloudFront Functionsで実装してみて、本当に足りなくなったらLambda@Edgeに移す方が、リスクが低い
  • Lambda@EdgeはOrigin Request/Responseを慎重に選択して、ローリング更新のリスクを最小限に抑える
  • キャッシュキー設計をプロジェクト初期に詰めておく。後から変更すると運用が地獄になる

両方使ってみると、エッジコンピューティングの可能性と制限がよく見えてくる。皆さんもプロジェクトで試してみたら、きっと見えてくる工夫があると思いますよ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事