PR TIMESのCDNをCloudFrontからFastlyに移行しました

  • URLをコピーしました!

こんにちは、インフラチームテックリードの櫻井です。

今回はプレスリリース配信サービスの prtimes.jp で使用しているCDNをCloudFrontからFastlyに移行したことについて紹介します。

CDNの基本的な情報は割愛するので、もしCDNについて基本的なことを知りたいという方はググるなりChatGPTるなりしてください。

目次

なぜ移行する必要があったのか

まずCloudFrontからFastlyに移行した理由について説明します。

prtimes.jp のプレスリリース詳細ページは現在SmartyテンプレートとjQueryというレガシーな技術で構成されています。

今後このプレスリリース詳細ページをReact化することでフロントエンドの開発スピードを向上させることを予定しています。

しかしReact化を行うと、検索エンジンのクローラーがJavaScriptを実行できない場合にページの内容を取得できず、検索エンジンに正しく反映されなかったり反映が遅れたりする可能性があります。

そこでNext.jsを活用したSSR(サーバーサイドレンダリング)を行うことで、JavaScriptを実行できないクローラーでも正しくページの内容を取得できるようにすることを考えています。

しかし当時使用していたCloudFrontではSSRした結果をキャッシュするなどキャッシュ周りで柔軟な操作をしようとするとCloudFront FunctionsやLambda@Edgeなど別の仕組みが必要だったりそもそも不可能だったりしました。

そこでVCLという設定言語を記述することで柔軟に処理を実装することができるFastlyのCDNに移行することが必要になりました。

Fastlyについては以前も画像の高画質化やプレスキットのファイルアップロード機能のためにImageOptimizerを活用したことをブログ記事に書いているので、興味のある方はこちらも参照ください。

あわせて読みたい
新卒エンジニアがプレスリリース画像の画質改善に取り組んだ話 こんにちは、21新卒エンジニアの柳です。 この度、プレスリリースのサムネイル画像とプレスリリース詳細ページ内で掲載されている画像の画質改善を行いました。 今回行...
あわせて読みたい
S3 を活用して工数を削減させた、ファイルアップロード機能の設計と実装 こんにちは、開発本部・バックエンドエンジニアの江間です。 先日、 PR TIMES の新規機能としてプレスキット機能の提供が開始されました。 プレスキット機能では、画像...

移行するために対応したこと

次にCloudFrontからFastlyに移行するために対応したことを説明します。

以下ではTerraformを使って構築することを前提に記述しているので、コードがVCLなのかTerraformのtfファイルなのかにご注意ください。

パスベースのルーティング設定

PR TIMESでは prtimes.jp ドメイン配下で複数のサービスが稼働しています。

例えば prtimes.jp/story にリクエストが来たときは prtimes.jp とは異なる専用のECSサーバーへリクエストを振り分けています。

参考 : https://developers.prtimes.jp/2022/10/05/transportation_story_program/#index_id8

このようにURLのパスの内容によってルーティング先のサーバーを切り替える処理を実装します。

例としてURLのパスが /a と前方一致するときはサーバーAにルーティングし、 /b のときはサーバーBにルーティングするという処理をVCLで作成します。

このときオリジンシールドを有効化している場合、シールドPOPの選択ロジックがパスベースのルーティングロジックで上書きされないようにするために以下のことに気をつける必要があります。

sub vcl_recv {
  if (req.url.path ~ "^/a/?") {
    set req.backend = F_a_backend_server;
  } elseif (req.url.path ~ "^/b/?") {
    set req.backend = F_b_backend_server;
  }
  ...
#FASTLY recv
  ...
}
  • 各backendのrequest_conditionに常にfalseを返すコンディションを設定して、デフォルトのbackend選択ロジックを無効化する
    • request_conditionが設定されていない場合、デフォルトでは最初に登録されたbackend(今回ならa_backend_server)に常にルーティングされるようになってしまいます
resource "fastly_service_vcl" "cdn" {
  ...
  backend {
    name = "a_backend_server"
    request_condition = "never true"
    shield             = "tyo-tokyo-jp"
    ...
  }
  backend {
    name = "b_backend_server"
    request_condition = "never true"
    shield             = "tyo-tokyo-jp"
    ...
  }
  condition {
    name      = "never true"
    priority  = 10
    statement = "false"
    type      = "REQUEST"
  }
  ...
}

アクセスログ出力設定

次にアクセスログを出力するための設定を記述します。

今回はS3にアクセスログを出力することを目指します。

まず初めにTerraformでログ出力先のS3バケットを作成します。

ログが外部から見えないようにバケットのACL設定をprivateにしているのと、一般的に古いログほど参照する頻度は下がるのでライフサイクル設定で30日後にStandard-IAに移し300日後にGlacierに移すことでログの保存コストを抑えています。

また、万が一ログを消してしまったときに復旧できるようにバージョニングも有効化しています。

resource "aws_s3_bucket" "log" {
  bucket = "fastly-log-bucket"
  tags = {
    Name = "fastly-log-bucket"
  }
}

resource "aws_s3_bucket_acl" "log" {
  bucket = aws_s3_bucket.log.id
  acl    = "private"
}

resource "aws_s3_bucket_lifecycle_configuration" "log" {
  bucket = aws_s3_bucket.log.id
  rule {
    id     = "log_rule"
    status = "Enabled"

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 300
      storage_class = "GLACIER"
    }
  }
}

resource "aws_s3_bucket_versioning" "log" {
  bucket = aws_s3_bucket.log.id
  versioning_configuration {
    status = "Enabled"
  }
}

次にFastlyからS3にログを出力するのに使うIAMロールを作成します。

resource "aws_iam_role" "logging_s3" {
  name = "fastly_s3_logging_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = {
      Condition = {
        StringEquals = {
          "sts:ExternalId" = "**********" // My Fastly Customer ID
        }
      }
      Action = "sts:AssumeRole"
      Principal = {
        AWS = "717331877981" // Fastly's AWS Account
      }
      Effect = "Allow"
      Sid    = "S3LoggingTrustPolicy"
    }
  })

  inline_policy {
    name = "PutLogsToS3Policy"
    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action = [
            "s3:PutObject",
          ]
          Effect   = "Allow"
          Resource = "${aws_s3_bucket.log.arn}/*"
        },
      ]
    })
  }
}

S3バケットにログファイルを作成するためのPutObject権限を持つPolicyに加え、Fastly側からこのRoleをAssumeRoleするためにFastlyのAWSアカウントとFastly内での自分のカスタマーIDを付与したAssumeRolePolicyを設定します。

カスタマーIDはFastlyの管理画面にログインしてCompany settingsから確認することができます。

ログ出力に必要なS3バケットとIAMロールの作成が完了したら、最後にFastly側でS3へのログ出力設定を行います。

resource "fastly_service_vcl" "cdn" {
  ...
  logging_s3 {
    acl              = "private"
    bucket_name      = aws_s3_bucket.log.bucket
    domain           = "s3.ap-northeast-1.amazonaws.com"
    format           = file("${path.module}/log_format/s3_logging.json")
    format_version   = 2
    gzip_level       = 6
    message_type     = "blank"
    name             = "s3_logging"
    path             = "/%Y/%m/%d/"
    period           = 300 // in seconds
    s3_iam_role      = aws_iam_role.logging_s3.arn
    timestamp_format = "%Y-%m-%dT%H:%M:%S.000"
  }
  ...
}
{
  "fastly_service_name": "my-fastly-service",
  "timestamp": "%{strftime(\\{"%Y-%m-%dT%H:%M:%S%z"\\}, time.start)}V",
  "client_ip": "%{req.http.Fastly-Client-IP}V",
  "geo_country": "%{client.geo.country_name}V",
  "geo_city": "%{client.geo.city}V",
  "host": "%{if(req.http.Fastly-Orig-Host, req.http.Fastly-Orig-Host, req.http.Host)}V",
  "url": "%{json.escape(req.url)}V",
  "request_method": "%{json.escape(req.method)}V",
  "request_protocol": "%{json.escape(req.proto)}V",
  "request_referer": "%{json.escape(req.http.referer)}V",
  "request_user_agent": "%{json.escape(req.http.User-Agent)}V",
  "request_cookie": "%{json.escape(substr(req.http.Cookie, 0, 4000))}V",
  "response_state": "%{json.escape(fastly_info.state)}V",
  "response_status": %{resp.status}V,
  "response_reason": %{if(resp.response, "%22"+json.escape(resp.response)+"%22", "null")}V,
  "response_body_size": %{resp.body_bytes_written}V,
  "fastly_server": "%{json.escape(server.identity)}V",
  "fastly_is_edge": %{if(fastly.ff.visits_this_service == 0, "true", "false")}V,
  "is_shield": "%{req.backend.is_shield}V",
  "x_cache": "%{resp.http.X-Cache}V"
}

ここまでの設定に問題がなければアクセス後5~10分ほど待てばアクセスログが出力されるはずですが、ミスがあるとファイルが出力されません。

その場合はトラブルシューティング用のエンドポイントにリクエストを送信することでエラーの内容を確認することができます。

$ curl -sg -H "Fastly-Key:$token" "<https://api.fastly.com/service/:SERVICE_ID/logging_status>"

https://docs.fastly.com/ja/guides/setting-up-remote-log-streaming#一般的なログエラーのトラブルシューティング

Cache-Controlヘッダー

FastlyではCache-Controlヘッダーの値を元にCDNでキャッシュするかどうかを判定します。

ここで注意する必要があるのがデフォルト設定だと

  • Cache-Controlヘッダーがない場合はキャッシュされる
  • Cache-Control: no-cache はキャッシュされる

ということです。

特に当社の場合はCloudFrontの設定でCache-Controlヘッダーを無視してURLのパスパターンによってキャッシュするかどうかを判定していたため、Fastly移行の際に挙動が変わる箇所がありました。

デフォルトのVCLだと以下のように Cache-Control: private または Cache-Control:no-store のときにキャッシュを保存しないように定義されています。

sub vcl_fetch {
  ...
  if (beresp.http.Cache-Control ~ "(private|no-store)") {
    return(pass);
  }
  ...
}

しかしレスポンスヘッダーにCache-Controlヘッダーが含まれない場合、デフォルトTTLで設定した期間キャッシュが保存されてしまいます。

もしユーザーの個人情報などキャッシュしてはいけないレスポンスにCache-Controlヘッダーがついていないと、CDNのキャッシュを通じてユーザーの個人情報が漏洩してしまうインシデントにつながってしまいます。

この対策として以下のような設定をVCLに追加して、レスポンスにCache-Controlヘッダーがない場合にキャッシュを保存しないようにしました。

sub vcl_fetch {
  ...
  if (!beresp.http.Cache-Control) {
    return(pass);
  }
  ...
}

次に Cache-Control: no-cache はよく勘違いされがちですが、”キャッシュを保存しない”という意味ではなく”キャッシュを保存するが再利用時に検証を行う”という意味です。

実際にMDNのドキュメントには以下のように書かれています。

レスポンスディレクティブの no-cache は、キャッシュに保存できることを示しますが、キャッシュがオリジンサーバーから切断された場合でも、再利用の前にオリジンサーバーで検証しなければなりません。

Cache-Control: no-cache

キャッシュに、保存されているコンテンツを再利用する際に、必ず更新がないかどうかをチェックさせたい場合は、 no-cache を使用する必要があります。これは、キャッシュがオリジンサーバーに各リクエストを再検証することを要求することで実現されます。

no-cache はキャッシュにレスポンスを保存することを許可しますが、再利用する前に再検証することを要求します。もし、「キャッシュさせない」の意味が実際には「保存させない」であるなら、no-store が使用すべきディレクティブです。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control

Cache-Control: no-cache を指定したときの挙動をFastlyのサポートの方に確認したところ、特に再利用時の検証はしていないとのことだったので、 Cache-Control: no-cache を指定してもキャッシュされてしまいます。

CDNにキャッシュを保存したくない場合は上に書いたように Cache-Control: no-store または Cache-Control: private を設定するか、VCLの設定を変更してno-cacheの場合にキャッシュしないようにする必要があります。

Fastly-Client-IPヘッダー

現在はCloudFrontでもクライントのIPアドレスを取得できるCloudFront-Viewer-Addressヘッダーが利用可能ですが、CloudFrontの導入当時は利用できなかったためX-Forwarded-ForヘッダーからクライアントのIPアドレスを取得していました。

しかしX-Forwarded-Forヘッダーは中間にあるCDNのノードやロードバランサーなどのIPアドレスも含まれる上にクライアントが直接指定することも可能なため、常に正しいクライアントのIPアドレスを取得し続けるのは難しいです。

Fastlyを利用している場合、Fastly-Client-IPヘッダーを参照することで正確にクライアントのIPアドレスを取得することができます。

ただしFastly-Client-IPヘッダーはクライアントが直接指定することも可能なので、VCLのvcl_recvに以下のように設定することでクライアントのIPスプーフィング(なりすまし)を防ぐ必要があります。

sub vcl_recv {
  ...
  if (fastly.ff.visits_this_service == 0) {
    set req.http.Fastly-Client-IP = client.ip;
  }
  ...
}

オブジェクトサイズの上限引き上げ(セグメントキャッシュ)

Fastlyで配信することができるレスポンスは通常20MBが最大となっており、この制限を超えるレスポンスを返そうとすると 503 Response object too large エラーが発生します。

この対応としてFastlyにはセグメントキャッシュという仕組みが用意されています。

セグメントキャッシュはキャッシュをより小さなセグメントに分割して取得してから再結合することで、20MBを超えるサイズのオブジェクトをキャッシュしたりオブジェクトの一部のみをキャッシュしたりすることができます。

ただしセグメントキャッシュを有効化するためにはオリジンサーバーがHTTP範囲リクエストに対応している必要があり、セグメントキャッシュは動的に生成されたコンテンツに使用できないなどの制約もあるため、セグメントキャッシュが利用できないという場合はサポートに問い合わせてオブジェクトサイズの上限を引き上げてもらうことをおすすめします。

セッションIDのハッシュ化

先ほどアクセスログをS3に出力する方法を紹介しましたが、Cookieをそのまま出力するとセッションIDなどの秘匿情報も一緒に出力されてしまうというリスクがあります。

この対策としてCookieに含まれるセッションIDをハッシュ化する対応を行いました。

こちらも #FASTLY log よりも上に記述する必要があります。

sub vcl_log {
  declare local var.hashed_session_id STRING;
  set var.hashed_session_id = "SESSION_ID=" + digest.hash_sha256(req.http.Cookie:SESSION_ID) + ";";
  declare local var.hashed_cookie STRING;
  set var.hashed_cookie = regsub(req.http.Cookie, "SESSION_ID=[a-z0-9]*;?", var.hashed_session_id);
#FASTLY log
}

セッションIDをハッシュ化することで万が一アクセスログが外部に漏洩してしまったとしてもセッションが乗っ取られることを防ぐことができ、なおかつ調査や分析のためにユーザーのセッションを追跡することも可能になっています。

メンテナンス画面の設定

データベースのバージョンアップなど、サービス全体でメンテナンス画面を返したいこともあると思います。

以下のように設定することで簡単にメンテナンス画面への切り替えを行うことができるようになります。

resource "fastly_service_vcl" "cdn" {
  ...
  vcl {
    content = templatefile("${path.module}/vcl/main.vcl", {
      maintenance_html = file("${path.module}/html/maintenance.html")
    })
    main = true
    name = "main"
  }
  ...
}
sub vcl_recv {
  ...
  if (table.lookup(service_config, "maintenance", "false") == "true") {
    error 600;
  }
  ...
}
...
sub vcl_error {
  ...
  if (obj.status == 600) {
    set obj.status = 503;
    set obj.response = "Service Unavailable";
    synthetic {"${maintenance_html}"};
    return (deliver);
  }
  ...
}
<div>これはメンテナンスページです</div>

上の例ではFastlyのDictionaryに保存しているmaintenanceというキーの値をtrueに変更することでメンテナンス画面を表示することができます。

メンテナンス画面の中身は直接vcl内には書かずに外部ファイルとして切り出すことでhtmlファイルのシンタックスハイライトなどエディタの機能を活かしながら編集できるようになっています。

また if (table.lookup(service_config, "maintenance", "false") == "true") の部分に条件を加えて、 if (table.lookup(service_config, "maintenance", "false") == "true" && req.http.Fastly-Client-IP !~ allow_list) としてallow_listで指定したIPのみメンテナンス画面を表示しないようにしたり、 if (table.lookup(service_config, "maintenance", "false") == "true" && req.url.path !~ "^/exception/?) として特定のパスのみメンテナンス画面を表示しないようにするなど要件に合わせて柔軟に対応することができます。

具体的な移行手順

次にCloudFrontからFastlyに移行するために行った具体的な手順について紹介します。

CDNサービスを作成

まず初めにFastlyのCDNサービスを作成します。

管理画面から作成しても良いですし、Terraformのファイルがある場合はそれを使って作成しても良いです。

今回は以前プレスリリースの画質改善のためにFastlyを導入したときに作成したTerraformのファイルがあったため、それを参考にTerraformから作成しました。

このとき重要なのが最初から全ての機能を実装しようとしないことです。

まず最初は公式ドキュメントにあるカスタムVCLのboilerplateをそのまま使用して、動くかどうかを確かめてから少しずつ機能を実装していくのがおすすめです。

Fastly TLSを使ってTLS証明書を取得

次に独自ドメインでHTTPS通信を有効化するためにFastly TLSを使ってTLS証明書を取得します。

Fastly TLSで使用できる証明書はLet’s EncryptとGlobalSignの二種類に加えて、β版ですがFastly自身のTLS認証局であるCertainlyも利用できます(Certainlyが選べない場合はサポートに問い合わせると選べるようになると思います)。

今回はステージング環境では無料で使えるLet’s Encryptを採用し、本番環境ではGlobalSignを採用しました。

以下にTerraformでGlobalSignの証明書を取得するコードを載せておくので参考にしてください。

resource "fastly_tls_subscription" "main" {
  domains               = ["prtimes.jp"]
  certificate_authority = "globalsign"
}

data "aws_route53_zone" "main" {
  name = "prtimes.jp"
}

# Set up DNS record for managed DNS domain validation method
resource "aws_route53_record" "domain_validation" {
  name            = fastly_tls_subscription.main.managed_dns_challenge.record_name
  type            = fastly_tls_subscription.main.managed_dns_challenge.record_type
  zone_id         = data.aws_route53_zone.main.id
  allow_overwrite = true
  records         = [fastly_tls_subscription.main.managed_dns_challenge.record_value]
  ttl             = 60
}

# Resource that other resources can depend on if they require the certificate to be issued
resource "fastly_tls_subscription_validation" "main" {
  subscription_id = fastly_tls_subscription.main.id
  depends_on      = [aws_route53_record.domain_validation]
}

/etc/hostsを書き換えて動作確認

VCLの設定やTLS証明書の作成などが完了したら prtimes.jp にアクセスしたときにFastlyの方にアクセスするようにAnycastIPを /etc/hosts に追記して動作チェックを行います。

AnycastIPの値は Fastlyに管理画面にログイン > 上部の”Secure”をクリック > “TLS management”の”Manage certificates”をクリック > 使用するドメインの”View details”をクリック とすると”A records”の部分にAnycast IPの一覧が表示されています。

ここに表示されているIPアドレスのどれか一つをコピーしてローカルマシンの /etc/hosts に以下のように追記することで、 prtimes.jp にアクセスしたときにFastlyの方にアクセスするように変更することができます。

192.0.2.0 prtimes.jp

加重ルーティングを使って段階的にDNS切り替え

/etc/hosts を使った動作確認が完了したら、実際にDNSレコードの向き先をCloudFrontからFastlyに切り替えていきます。

このとき一度に全てのリクエストをFastlyに向けてしまうと、最初はキャッシュが存在しないためオリジンサーバーに負荷をかけてしまったり、バグがあった場合に全てのユーザーに影響が及んでしまいます。

そのため今回はRoute53の加重ルーティングを使って緩やかにCloudFrontからFastlyへの切り替えを行いました。

具体的には以下のような手順で切り替えることができます。

  1. CloudFrontに向いている既存のAレコードをシンプルルーティングから加重ルーティングに変更する(重み99)
  2. FastlyのAnycastIPに向いているAレコードを加重ルーティングで新規作成する(重み1)
  3. CloudFrontに向いている方のAレコードの重みを少しずつ下げて0にする
  4. 重みが0になってCloudFrontにアクセスが来なくなったらCloudFrontに向いているAレコードを削除する
  5. Fastlyに向いているAレコードを加重ルーティングからシンプルルーティングに変更する

上記の手順で全てのリクエストがFastlyの方に向くようになればFastlyへの移行は完了です🎉

Fastlyに移行して気づいたメリットとデメリット

最後にCloudFrontとFastlyを比較して思ったことなどを紹介したいと思います。

柔軟性

まず柔軟性については圧倒的にFastlyの方が優れていると思います。

CloudFrontのときはやろうと思っても機能が提供されていなかったり複雑になりそうなことでも、FastlyならVCLを少し記述することで簡単に実現できると感じました。

またFastlyは公式ドキュメントにサンプルコードが多く、VCL初学者でもあまり苦労せず使い始めることができました(ただし日本語版ドキュメントは時々よく分からない訳をしていることがあるので英語版が推奨です笑)。

金額面

金額面についてはCDN単体の料金で見るとCloudFrontもFastlyもほとんど変わらないかFastlyの方が少し安いくらいの料金でした。

ただしCloudFrontの場合はALBやS3などのAWSサービスからのアウトバウンド通信料金が無料になることを考えると、オリジンにALBやS3を使用している場合トータルではCloudFrontの方が安くなるかもしれません。

キャッシュパージ

キャッシュをパージする場合、CloudFrontでは主に管理画面から実行する方法とAPIから実行する方法があり、キャッシュパージの権限を持った認証情報がないと実行できません。

Fastlyの場合は管理画面から実行する方法に加えてPURGEリクエストでキャッシュパージする方法があり、PURGEリクエストはデフォルトだと認証情報は任意ですが必須にすることもできます。

また複数のURLをまとめてキャッシュパージする場合、CloudFrontでは /some/* のように前方一致で指定することができますが、Fastlyではそのように指定することはできません。

その代わりにFastlyではSurrogate-Keyヘッダーを使ったキャッシュパージが可能になっているため、Fastlyでまとめてキャッシュパージをしたいという場合はSurrogate-Keyヘッダーを設定することをおすすめします。

Surrogate-Keyヘッダーの具体的な使い方については別の記事で紹介しているので、こちらも参照ください。

あわせて読みたい
S3 を活用して工数を削減させた、ファイルアップロード機能の設計と実装 こんにちは、開発本部・バックエンドエンジニアの江間です。 先日、 PR TIMES の新規機能としてプレスキット機能の提供が開始されました。 プレスキット機能では、画像...
あわせて読みたい
AVIF・WebPでサムネイル画像を配信して、ブラウザでのパフォーマンスを大幅に改善した話 こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。 今回はSTORYのサムネイル画像の配信フォーマットを変更することによるパ...

サポートの手厚さ

今回Fastlyに移行するにあたりエンタープライズサポートを契約し、VCLやTerraformの設定について詳しくレビューや改善提案をいただきながら移行を進めることができました。

AWSについてもエンタープライズサポートを契約していますが、CDN単体について詳しくレビューしていただくことは難しいのでこのようなサポートの手厚さについてもFastlyに移行して良かったところだと思いました。

まとめ

今回はprtimes.jpのCDNをFastlyに移行したことについて紹介しました。

今回の記事がFastlyの導入を検討している方の参考になれば幸いです。

弊社では開発メンバーを募集中なので気になった方は以下のページから申し込みをお願いします!

https://prtimes.co.jp/careers/engineer/

  • URLをコピーしました!

この記事を書いた人

2018年に京都大学を卒業後、PR TIMESに新卒入社。
現在は開発チームのサーバーサイドエンジニア兼インフラチームのテックリードを担当しています。

目次