CloudFront → ALB → Django 構成で API レスポンスの URL スキームが http:// になる問題と解決策

はじめに

AWS の CloudFront + ALB + ECS Fargate で Django REST Framework (DRF) の API サーバーを運用していたところ、API レスポンスに含まれる URL が http:// で返されるという問題に遭遇しました。本記事では原因の調査過程と、最終的な解決策を紹介します。

構成

Client (HTTPS)
  ↓
CloudFront (SSL終端, us-east-1)
  ↓ HTTP
ALB (HTTP:80のみ受付, ap-northeast-1)
  ↓ HTTP
ECS Fargate (Gunicorn + Uvicorn, port 9000)
  ↓
Django REST Framework

CloudFront がSSLを終端し、ALB へは HTTP で転送する構成です。

問題

DRF の API ルート (/api/rest/) にアクセスすると、レスポンスに含まれる URL がすべて http:// になっていました。

1
2
3
4
{
  "users": "http://api.example.com/api/rest/users/",
  "items": "http://api.example.com/api/rest/items/"
}

モバイルアプリがこの URL を使って後続のリクエストを組み立てるため、HTTPS を前提とした通信が失敗する原因になっていました。

最初の対応: SECURE_PROXY_SSL_HEADER(不十分)

Django には SECURE_PROXY_SSL_HEADER という設定があり、リバースプロキシが付与するヘッダーを見てリクエストが HTTPS かどうかを判定できます。

1
2
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

一般的なリバースプロキシ構成ではこれで解決するはずですが、今回の構成では機能しませんでした。

原因: ALB が X-Forwarded-Proto を上書きする

調査の結果、以下のことがわかりました:

  1. CloudFront はクライアントが HTTPS でアクセスしたことを知っているので、X-Forwarded-Proto: https をオリジンに転送できる
  2. しかし ALB は受信したプロトコルに基づいて X-Forwarded-Proto ヘッダーを常に上書き する
  3. CloudFront → ALB は HTTP 通信なので、ALB は X-Forwarded-Proto: http を設定する
  4. Django は X-Forwarded-Proto: http を受け取り、リクエストを HTTP と判定する

つまり、CloudFront が正しい値を送っても、ALB が途中で上書きしてしまうため Django まで届かないのです。

CloudFront: X-Forwarded-Proto: https  ← クライアントはHTTPS
    ↓
ALB: X-Forwarded-Proto: http          ← ALBが受信プロトコル(HTTP)で上書き
    ↓
Django: "リクエストはHTTPだ" → http:// でURL生成

解決策: CloudFront カスタムオリジンヘッダー

ALB は X-Forwarded-Proto 等の標準ヘッダーを上書きしますが、カスタムヘッダーには干渉しません

CloudFront のオリジン設定でカスタムヘッダーを追加し、Django 側でそのヘッダーを参照するようにしました。

インフラ側 (Terraform)

CloudFront のオリジン設定にカスタムヘッダーを追加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
resource "aws_cloudfront_distribution" "edge" {
  origin {
    domain_name = var.app_origin_domain_name
    origin_id   = local.app_origin_id

    custom_header {
      name  = "X-Forwarded-Ssl"
      value = "on"
    }

    custom_origin_config {
      http_port              = 80
      https_port             = 80
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }
  }
  # ...
}

アプリ側 (Django)

SECURE_PROXY_SSL_HEADER の参照先をカスタムヘッダーに変更:

1
2
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_SSL', 'on')

補足: Django は HTTP ヘッダーを HTTP_ プレフィックス + 大文字 + ハイフンをアンダースコアに変換した形で request.META に格納します。X-Forwarded-SslHTTP_X_FORWARDED_SSL となります。

結果

1
2
3
4
5
$ curl -s https://api.example.com/api/rest/ | python3 -m json.tool | head -3
{
    "users": "https://api.example.com/api/rest/users/",
    ...
}

API レスポンス中の URL が正しく https:// で返されるようになりました。

ヘッダーの流れ(修正後)

CloudFront:
  X-Forwarded-Proto: https    ← ALBに上書きされる(使わない)
  X-Forwarded-Ssl: on         ← カスタムヘッダー(ALBは干渉しない)
    ↓
ALB:
  X-Forwarded-Proto: http     ← ALBが上書き
  X-Forwarded-Ssl: on         ← そのまま通過 ✓
    ↓
Django:
  SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_SSL', 'on')
  → "リクエストはHTTPSだ" → https:// でURL生成 ✓

対応順序の注意点

ALB のヘルスチェックパスも同時に変更する場合、デプロイ順序に注意が必要です。

NG な順序:

  1. Terraform apply でヘルスチェックパスを変更
  2. アプリをデプロイ → 新しいヘルスチェックパスに応答できるコードがまだないため、ヘルスチェック失敗

正しい順序:

  1. アプリをビルド・デプロイ(新しいヘルスチェックエンドポイントを含む)
  2. 動作確認
  3. Terraform apply でヘルスチェックパスを変更

まとめ

方法仕組みCloudFront → ALB 構成で有効か
X-Forwarded-Proto標準ヘッダーでプロトコル判定❌ ALB が上書きする
X-Forwarded-Ssl (カスタムヘッダー)CloudFront が付与、ALB は干渉しない✅ 確実に動作

CloudFront → ALB → アプリケーション という構成では、標準の X-Forwarded-Proto は ALB に上書きされる ことを念頭に置く必要があります。カスタムヘッダーを使うことで、ALB を経由しても確実にオリジナルのプロトコル情報をアプリケーションに伝達できます。

参考