【Fastly】SNI 設定漏れで503 エラー


概要

Fastly で SNI の設定漏れにより、503エラーが発生しました。
本記事では、エラーの原因と対策について記載していきます。

事象

TLD の異なる3つのドメインのうち、jpは正常にアクセス可能でしたが、tw, netは、下画像のように オリジンへのヘルスチェックが失敗しており、リクエストを転送できない状況でした。


Error 503 backend is unhealthy(公式ドキュメントより)

構成

構成図

  • Fastly
    • 3つのドメインを Fastly のエンドポイントで受け付ける(各ドメインの証明書を登録)
    • Host ヘッダの中身に応じて転送先オリジンを振り分ける
    • backendにはhealthcheckが紐付けてあり、200 の場合はリクエストを送る ← 今回これが失敗してる
  • オンプレ
    • 3つのドメインを1組の Load Balancer、 App サーバー で受け付ける
    • Fastly -> オンプレ間もHTTPSで通信したいため各ドメインの証明書を登録

Terraform

管理は Terraform を利用しており、backendhealthcheckは以下のようにしています。
var.domain_namesには、map 形式で TLD とドメイン名のペアを設定しています。

main.tf
resource "fastly_service_v1" "web" {
(中略)
  dynamic "backend" {
    for_each = var.domain_names
    content {
      name                  = "lb_${backend.key}_https"
      address               = "XX.XX.XX.XX"
      auto_loadbalance      = false
      between_bytes_timeout = 10000
      connect_timeout       = 1000
      first_byte_timeout    = 150000
      max_conn              = 1000
      port                  = 443
      use_ssl               = true
      ssl_cert_hostname     = backend.value
      healthcheck           = "${local.healthcheck_name_lb}-${backend.key}"
    }
  }
(中略)
  dynamic "healthcheck" {
    for_each = var.domain_names
    content {
      name              = "${local.healthcheck_name_lb}-${healthcheck.key}"
      host              = healthcheck.value
      path              = "/sample.txt"
      expected_response = 200
      http_version      = 1.1
      method            = "HEAD"
      check_interval    = 5000
      timeout           = 32767
      initial           = 3
      threshold         = 3
      window            = 5
    }
  }
(中略)
}
terraform.tfvars
domain_names = {
  "jp"  = "example.jp"
  "tw"  = "example.tw"
  "net" = "example.net"
}

原因と対応

Fastly で SNI の設定が漏れていた

原因は、Fastly の backendの設定にssl_sni_hostnameプロパティがないことでした。
このプロパティは、TLSハンドシェイクの中で、どの証明書を利用するか指定する際に用いられます。

SNIが何かについては、こちらの記事が簡潔に説明してくれていて分かりやすかったです。

利用されている証明書を確認

openssl コマンドから確認できます。

$ openssl s_client -showcerts -connect XX.XX.XX.XX:443

証明書の情報が2枚分表示されますが、先頭に0がある方を参照します。(1の方は中間CA証明書)
コモンネームを見ると、デフォルトではjpが利用されていることが分かります。

Certificate chain
 0 s:/C=JP/ST=XXXX/L=XXXX-SHI/OU=XXXX/O=XXXX, Inc./CN=example.jp
   i:/C=BE/O=GlobalSign nv-sa/CN=GlobalSign RSA OV SSL CA 2018

したがって、tw, netのドメインに対しても、jpの証明書が当たってしまい、TLS通信が開始できないことでヘルスチェックが失敗しているのだと分かります。

opensslコマンドで証明書情報を知る方法はこちらのサイトを参考になりました。

他のドメインの証明書もチェック

先程のコマンドに-servernameオプションを追加することで、SNIでホスト名を指定することができます。

$ openssl s_client -showcerts -connect XX.XX.XX.XX:443 -servername example.net

証明書の設定自体はできているので、SNIでホスト名を指定できれば問題なさそうです。

Certificate chain
 0 s:/CN=example.net
   i:/C=BE/O=GlobalSign nv-sa/CN=GlobalSign GCC R3 DV TLS CA 2020

※ こちらに載せている出力結果は一部マスクしており、本来の出力結果とは異なります。

プロパティを追加

backendブロックの中に、ssl_sni_hostnameを追加して、ドメイン名を記載します。
これにより、ヘルスチェックが通るようになり、エラーを解消することができました!

main.tf
(中略)
  dynamic "backend" {
    for_each = var.domain_names
    content {
      name                  = "lb_${backend.key}_https"
      address               = "XX.XX.XX.XX"
      auto_loadbalance      = false
      between_bytes_timeout = 10000
      connect_timeout       = 1000
      first_byte_timeout    = 150000
      max_conn              = 1000
      port                  = 443
      use_ssl               = true
      ssl_cert_hostname     = backend.value
      healthcheck           = "${local.healthcheck_name_lb}-${backend.key}"
      ssl_sni_hostname      = backend.value ## newly add
    }
  }
(中略)

ひとこと

今回のエラーに遭遇したことで初めてSNIを知り、勉強することができました。
Hostヘッダを付加すれば、サーバー側で認識してくれるんじゃ? と思いましたが、
TLS通信前にヘッダの内容を知ることができないのは盲点でした(言われてみればそうだけど)。

最近のブラウザはSNIに標準対応しているそうで意識することはなさそうです。
こういう機会があって知れてよかった。