nginx 移行を通じて学んだ Fastly のはじめかた

今年もはじまりました Retty Advent Calendar 2021 !
初日を担当いたします技術部の西村です。

※ 2021/12/03 更新
共通関数の設定 にて戻り値が設定できない旨記載してましたが、実はできるようになったとのご連絡をいただきましたので修正しております。

Subroutines | Fastly Developer Hub

今年はパート 2 までありますので、みなさまぜひご覧くださいませ。

今年の内容は「nginx 移行を通じて学んだ Fastly のはじめかた」ということで記事を書かせていただきます。
掲題の通り Fastly に関してはこれからはじめようと思っている方向けに記載しております。

弊社は昔から画像変換サービスやアセットの配信で Fastly を利用しております。 今年は大きく 2 つで Fastly と関わることになりました。

  1. Retty 内部に存在するリバースプロキシ/静的ファイルキャッシュサーバ(以下 nginx サーバ)を Fastly へ移行
  2. WAF として Signal Sciences のサービス利用

本記事は 1 に関するもので現在鋭意検証中となっております。

また 2 に関してですが Fastly 様が開催されております Yamagoya 2021 にて幸田より 「Retty における Signal Sciences の導入事例」にて発表いたしますので、そちらもぜひご覧くださいませ。

移行経緯

まず retty.me のシステム概略図を載せます。

f:id:rettydev:20211129110504p:plain

画像が見づらくて恐縮ですが、最下流の ALB 配下に Request Router という自作ルーティング環境が存在しております。
この環境は現在の PHP アプリケーション環境(概略図 ※1)と徐々に移行を進めておりますマイクロサービスの環境(概略図 ※2)を振り分けております。
PHP アプリケーション環境の前段には nginx サーバがリバースプロキシ/キャッシュサーバとして存在しております。

今回は nginx サーバが話の中心になるのですが、長年以下のような課題を抱えておりました。

  1. キャッシュ効率が悪い
  2. システムの複雑性
  3. セキュリティ対策が取りづらい

※ そもそもキャッシュに依存しないアプリケーションにつきましては今回は範囲外

1. キャッシュ効率が悪い

nginx サーバは ECS on EC2 の環境で複数台で動作しております。
キャッシュに関しては EC2 上の EBS ボリュームに各々持っており、そこの同期は行っておらず単純に複数台にキャッシュが分散することになります。
また負荷に応じてスケーリングを行ったとしてもキャッシュがない状態で起動しても意味がなく、キャッシュをコピーしたとしてもかなりの時間がかかるため上手くスケーリングできないのが現状です。

2. システムの複雑性

上記のキャッシュ効率を補うために一部のリクエストにて、

  1. EC2 上にキャッシュがあるか
    1. => あれば EC2 上のキャッシュを返す
  2. EC2 上にキャッシュがない場合には、 S3 にキャッシュがあるか
    1. => あれば S3 のキャッシュを返す
    2. => そして特定の EC2 上のリクエストであれば SQS にキャッシュ生成のリクエストを追加する
  3. キャッシュが存在しない場合にはアプリケーションサーバへリクエストを投げる

の設定になっています。
キャッシュ依存の環境では S3 にもキャッシュを持たせるようになっており、それを実現するために下記のようなシステムを別組で行ってます。

f:id:rettydev:20211129110659p:plain

① nginx サーバでは Lua を利用してリクエストを整形して td-agent が SQS にリクエストを送れるようにログ出力。
この動作は nginx サーバのすべてで動作させているわけではなく、システムの関係上 1 台のみ起動している。
AWS Elastic Beanstalk のワーカー環境にて SQS からのリクエストを受信
③ Retty 本体のサービスとは別にこのリクエストを処理するだけのワーカー専用のアプリケーションサーバを設置しており、その環境にてリクエストを送る
④ リクエストを返す
⑤ リクエストの内容をキャッシュ用途として S3 上に保管

という形になっておりキャッシュの効率性を上げるために複雑な環境と専用のシステムが存在しているのが現状です。

3.セキュリティ対策が取りづらい

昨年の記事でも少し書いたのですが、AWS WAF を取り入れない以上セキュリティ対策を行うには Request Router または nginx サーバになるのですが、効率性や運用性を考えると難しそうでした。

なぜ Fastly なのか

上記課題を解決するための構成はいくつかあるのですが、今回は Fastly へ全面的に移行することになりました。
すでに弊社での利用実績があったのもありますが、長期運用された nginx の設定ファイルがかなり育っており、そこを上手く移行するには VCL で書く必要があったのでほぼ一択です。
ただ VCL を運用することで Fastly に依存することになる、また VCL で割と何でもできるので、本来 Fastly でやるべきこと・やらないことを考えることも悩んだのも事実です。

最終的な構成は下記のようになります。

f:id:rettydev:20211129110100p:plain

移行対象としては下記の赤枠が置き換わる形になります。

f:id:rettydev:20211129110916p:plain

最終的に CloudFront 部分も Fastly へ移行する想定ですが、かなりスッキリした形になるかと思います。
先の S3 にキャッシュを生成するシステム群も不要になります。

移行取り掛かり

移行取り掛かりとして以下 3 を見ていきました。

  1. Fastly のドキュメント読む
  2. VCL 書いて動かしてみる
  3. nginx の設定を読み解く

1. Fastly のドキュメント読む

下記 2 つがメインのドキュメントになると思いますので、可能な限り目を通しておきましょう。

以下は私が読んでおいたほうが良いと思うドキュメントをピックアップしたものとなります。

コストに関する情報です。
最初にサポートに相談するのも良いと思います。
私もかなり親身にサポートいただきました。

リソースの制限事項が記載されてます。
サポート経由で制限の引き上げが可能なもの、そうでないものがありますのでぜひご一読を。

Fastly がどういうキャッシュの仕組みになっているか記載されております。
後ほどカスタム VCL の話が出てくるのですが、それを使用した場合と使用しない場合に以下のような違いがあるので注意が必要です。

  • カスタム VCL を使用しない
    • => Cache-Control ヘッダーにて private のみがキャッシュしない対象
  • カスタム VCL を使用する ※ テンプレートの利用あり : Adding VCL to your service configuration
    • => Cache-Control ヘッダーにて privateno-store がキャッシュしない対象

との差異がありますので、サービス毎に使い方が異なる場合にはご注意くださいませ。

キャッシュキーに関するドキュメントとなります。
デフォルトのキャッシュキーはのホストと URL(パラメータ含む) になります。

CNAME レコード追加に関するドキュメントです。
弊社のように APEX ドメインで利用する場合には、A レコードを並べる形になりますので、詳細はドキュメント内のリンクをご参照ください。

キャッシュ設定のベストプラクティスとなってます。
オリジンが機能不全の場合に失効済コンテンツを配信する設定などは有用ですのでご一読くださいませ。

KVS の機能です。
弊社では一部店舗 ID を別の環境に向けるなどの処理に利用してます。
Edge Dictionaries に関しては Fastly の設定を更新することなく値を変更することが可能な箇所になっております。

POP の一つをシールドとして設定することでキャッシュヒット率の向上やオリジンへの負荷減少に繋がりますので設定しておくと良いと思います。
POP 間トラフィックのコストが増えますが、その分オリジンのデータ転送コストが削減されるためそんなに気にしなくても良さそうでした。

VCL 利用する場合には必ず読んで組み込み関数がどのような動作を行うか確認しておきましょう。
なおコントロールパネルからキャッシュ設定がある程度可能ですので VCL 書かなくても良いです。
ただコントロールパネルとカスタム VCL の混在はおすすめしません。
キャッシュ設定の順番を制御しづらくなるのと、重複設定などが発生する可能性があるためです。
最終的な VCL はコントロールパネル上で確認できるのですがかなりの行数になるため見ることは少ないかと思います。

そのため少しでもカスタム VCL で書くのならそっちに寄せたほうが良いと思います。

ログに関する情報です。
標準のものから独自に出力したい内容を組み込むことが可能です。

2. VCL 書いて動かしてみる

VCL 書いた経験がほぼ無いのでまずはどんなことができるのかやってみました。
下記は Fastly のアカウントを作らなくても動作確認できるので活用しました。

Fastly Fiddle

以下は Fastly の方が書いている記事ですので一読すると良いと思います。

3. nginx の設定を読み解く

長期熟成された nginx の設定を紐解いていきます。
できるだけ現在の利用状況に応じて取捨選択しながら移行したいのですが、かなりの工数を使うことが分かったので今回はできるだけ正確に移行することを主眼に見ていってます。

a. location の評価順が間違ってないか

読み込まれた順番で評価されていくのではなく評価順があるので下記を参考にきちんと整理して並べ替えを行い VCL 化していきました。

b. キャッシュの確認

弊社ではオリジンの Cache-Control ヘッダーは無視して nginx サーバ側で制御してましたので Fastly 側もそれに合わせました。
基本的に nginx はオリジンの Cache-Control ヘッダーを見てキャッシュ判断を行い、private、no-store、no-cache ディレクティブが含まれる場合や、 Set-Cookie ヘッダーをもつレスポンスはキャッシュしないので Fastly 側もそれに合わせました。

VCL 紹介

では実際に作成した VCL をいくつか紹介して本記事を締めくくろうと思います。
ちなみに詳細は記載しませんが、環境構築には Terraform 利用しました。

またキャッシュパージには下記を利用しました。

カスタム VCL にはテンプレートファイルがありますので、必ずそちらを利用してください。

キャッシュパージ

キャッシュパージに関しては URL ベース、サロゲートキーベース、全コンテンツのパージがあります。
URL ベースのバージに関しては認証設定が無いと誰でもキャッシュ削除可能ですので API 経由でバージを行う場合には認証設定を行ってください。

VCL で書くと下記の様になります。

if (req.request == "FASTLYPURGE") {
    set req.http.Fastly-Purge-Requires-Auth = "1";
}
  • 動作確認
    • 認証無し

        $ curl -XPURGE  https://example.com/
        {"msg":"Provided credentials are missing or invalid"}
      
    • 認証あり

        $ curl -XPURGE -H Fastly-Key:$FASTLY_API_KEY https://example.com/
        { "status": "ok", "id": "1234-5678-90" }
      

また URL ベースのキャッシュパージとは別に、弊社のように各店舗ごとにメニューページや写真ページなど複数を管理しており、店舗 ID に紐づくキャッシュを一括で削除したい場合があります。
その場合には店舗 ID をキーにしてサロゲートキーを利用してます。

if (req.url ~ "/([0-9]{12})/?") {
  set beresp.http.Surrogate-Key = re.group.1;
}

サロゲートキーをレスポンスヘッダー情報として確認したい場合にはリクエストヘッダーに Fastly-Debug に 1 を付与してあげると良いです。
下記のようなヘッダーが返ってきます。

< surrogate-key: 12345

共通関数の設定

ユーザ独自の関数を定義できます。
名前は vcl_recv や vcl_hash など組み込み関数以外の名前にする必要があります。

vcl_miss や vcl_pass はその後バックエンドにリクエストを行う処理に移るため、共通化する関数を作ることが多いです。
ちなみに値を渡したり、戻り値を設定することはできないです。
※ 実は戻り値を設定できるようになったとのご連絡をいただいたので訂正します。

Subroutines | Fastly Developer Hub

以下はバックエンドへのリクエストヘッダー X-Real-IP にサービス接続してきた接続元 IP アドレスを付与する処理になります。
vcl_miss、vcl_pass の call 関数にて呼び出してます。

sub set_request {
  set bereq.http.X-Real-IP = req.http.Fastly-Client-IP;
}

sub vcl_miss {
#FASTLY miss

 call set_request;

 return(fetch);


sub vcl_pass {
#FASTLY pass

 call set_request;

 return(pass);
}

Vary ヘッダーの活用

User-Agent によるキャッシュの出し分けは Vary ヘッダーを利用してます。
nginx サーバでは iPhone ならキャッシュキーに @ip な形でキャッシュキーの追加を行ってましたが、それをそのまま Fastly に移行すると追加したキャッシュキーの分だけ削除する手間が増えます。
そこで Vary ヘッダーを利用することでキャッシュの出し分けは Vary ヘッダーを利用し、またキャシュの削除は通常のホスト名 + URLでこのキャッシュキーに紐づくキャッシュはすべて削除することが可能になります。

sub vcl_recv {
#FASTLY recv

...

  # User Agent 設定
  # iPhone
  if (req.http.user-agent ~ "(?i)iPhone") {
    set req.http.X-ua-type = "@ip";
  }
  # Android
  if (req.http.user-agent ~ "(?i)Android") {
    set req.http.X-ua-type = "@sa";
  }

...

}

...

sub vcl_fetch {
#FASTLY fetch

...

  if (beresp.http.Vary) {
    if (beresp.http.Vary:User-Agent) {
      unset beresp.http.Vary:User-Agent;
    }
    set beresp.http.Vary = beresp.http.Vary ", X-ua-type";
  } else {
    set beresp.http.Vary = "X-ua-type";
  }

...

}

synthetic

Fastly が生成した synthetic を返すことができます。
検証期間中に本当に Fastly を経由しているかどうかを確認するために利用していたりします。
またメンテナンスの文も Fastly で管理することも可能です。

sub vcl_recv {
#FASTLY recv

  if (req.url == "/check_fastly") {
    error 905;
  }
}

...

sub vcl_error {
#FASTLY error

  if (obj.status == 905) {
    set obj.status = 200;
    set obj.response = "OK";
    set obj.http.Content-Type = "application/json";
    synthetic {"{
      "Env": "Fastly-Production"
}"};
    return(deliver);
  }
}

闇実装

この部分は参考になる変数などはあると思いますが実装に関しては闇を作っているのでこのような形は取らないほうが良いと思います。
最初の方で 2. システムの複雑性 に関して説明したと思いますが、その部分を無理くり Fastly で実現した形がこうなっております。

sub vcl_miss {
#FASTLY miss

  declare local var.worker_request_frequency INTEGER;
  set var.worker_request_frequency = std.strtol(table.lookup(worker_request, "frequency"), 10);
  
  if(req.is_background_fetch && req.url ~ "^(/hoge/|/fuga/)") {
    if (randomint(0, 99) < var.worker_request_frequency){
      call retty_production_worker;
    } else {
      if (stale.exists) {
        return(deliver_stale);
      }
    }
  } 
}

弊社特有なので詳細は割愛しますが、バックグラウンドフェッチまでこちらの都合で制御しており、一定の割合にてワーカー環境へリクエストを投げるのか、それとも既存のキャッシュを返すのかの設定になります。
これを実現するにはデフォルトの失効済配信コンテンツの TTL を長めにとってます。

# 例 1 日
set beresp.stale_while_revalidate = 86400s;

if (stale.exists) これがないと 503 になるので注意しましょう。

キャッシュ設定

キャッシュの設定に関しては下記のように設定してます。
vcl_fetch 内で Cache-Control の設定を変更すると配信に影響を与えます。

sub vcl_fetch {
#FASTLY fetch

  if (req.url ~ "^/$") {
    set beresp.http.Cache-Control = "max-age=600";

    if (beresp.status == 200) {
      set beresp.ttl = 1h;
    }

    if (http_status_matches(beresp.status, "301,302,304,404")) {
      set beresp.ttl = 1m;
    }

    return(deliver);
  }
}

デバッグ

設定した値が正常に反映されているか気になるときがあります。
ログに出すことも可能ですが、さっと確認したいときにはレスポンスヘッダーに付与してあげるのが良いです。

sub vcl_recv {
#FASTLY recv

  if (req.http.user-agent ~ "(?i)iPhone") {
    set req.http.X-ua-type = "@ip";
  }
}


sub vcl_deliver {
#FASTLY deliver

  # for debug
  set resp.http.X-ua-type = req.http.X-ua-type;

  return(deliver);
}

レスポンスヘッダーにて X-ua-type を確認すると適切な値が入っているかの確認ができます。

Terraform 使って店舗 ID の自動登録やキャッシュクリアなど運用部分の話も色々したいのですが、本日はここまでといたします。

ここまで読んでいただいてありがとうございます!

明日以降の Retty Advent Calendar 2021 もどうぞよろしくお願いします。