Netlifyのデプロイ完了時にCloudflareのキャッシュを自動パージする
以前「Jekyll+Re:VIEW+Netlifyでblogを作ってる」で書いたように、このblogはGitHub上のリポジトリにpushしたRe:VIEWファイルをNetlifyで自動ビルドする運用です。Netlify自体に月間100GBの無料枠があるので転送量は今のところ問題になっていませんが、せっかくなのでCDN(すでに使っているCloudflare)を利用しようと思い立ちました。
CDNといえばキャッシュに始まり、キャッシュに終わるものです(Compute@Edgeみたいなやつもありますが)。適切なタイミングでキャッシュを破棄(パージ)することで、Netlifyの高速な自動デプロイ機能*1を最大限に活かせます。
Netlifyのビルド機能には完了時のwebhook設定が用意されており、CloudflareにはAPI経由のキャッシュパージ機能があるので、これらをAWS LambdaでつないでやればNetlify側の更新完了時にキャッシュをパージできそうです(図1)。
[*1] ビルド依存関係が大きいのですが、Netlify側のビルド構成キャッシュがよくできており、GitHubへのpushからおおむね1分以内にはblogが更新されます
下調べ
Netlifyのwebhook仕様を簡単に調べる
Netlifyのwebhookメニュー(Settings → Build & deploy)を見ると、わりと柔軟にhookを送信できることがわかります(図2)。
CDNのキャッシュパージにはDeploy succeeded
のタイミングが適切でしょう。
hook先のURIに加えてsecret keyの設定オプションがあります。これについて該当するドキュメントを読むと、シンプルなJWT(JSON Web Token)ベースのリクエストソース検証が可能のようです。
JWTとなかよく
都合良いことに、Webhook送信側のNetlifyも、その受け側のAWS API GatewayもJWTをサポートします*2。
が、AWS側のドキュメントでAPI GatewayがサポートするJWTの仕様を確認すると、issuerへのURI指定が必須、audience指定必須です。Netlifyが送出に対応しているJWTは単純なHMACなので、標準機能同士ではマッチしません。
[*2] 実は、API GatewayがJWTを組み込み機能としてサポートしたのは結構最近です。参考記事
このため、API Gatewayステージでの認証は諦めてLambda側の前処理に負わせることにします。
CloudflareのキャッシュパージAPI
わりと素直なAPIですが、要注意ポイントがあります。ホスト(FQDN)指定のキャッシュパージはEnterpriseプラン専用です。
無料版でも利用できるのはURL指定のパージもしくは全パージで、悩ましいところですが発生頻度を考えると全パージで良いでしょう。
Cloudflare APIをうまく叩けているかの確認にもひと工夫必要です。キャッシュのパージという処理の性質上、Web上で結果確認するのが難しいものですが、Cloudflareのアカウント詳細を見に行くとAudit Log(監査ログ)機能があり、そこでキャッシュパージリクエストの発行ログを見れるので助かりました(図4)。
AWS Lambda+Sinatraで要件を満たす
JWT署名検証のサンプル実装がRubyであることに加え、AWS LambdaのRubyランタイム(つい最近2.7もサポートした)を使ってみたかったので、今回はRubyでやっていきます。AWS LambdaのRuby(2.7)ランタイムで動作するSinatra+Rackのサイト上でNetlifyから飛んできたwebhookのJWT署名を検証、検証OKならCloudflareへキャッシュパージリクエストを発行する、という内容です。
AWS LambdaでSinatra
https://github.com/aws-samples/serverless-sinatra-sampleリポジトリのコードをベースに改造して作ります。
signed関数
出来上がりはこんな感じです。
def signed(request, body) signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"] return unless signature options = {iss: "netlify", verify_iss: true, algorithm: "HS256"} decoded = JWT.decode(signature, PRESHARED_KEY, true, options) ## decoded : ## [ ## { sha256: "..." }, # this is the data in the token ## { alg: "..." } # this is the header in the token ## ] decoded.first['sha256'] == Digest::SHA256.hexdigest(body) rescue JWT::DecodeError false end
サンプルコード自体に問題があって署名検証で順調にハマり、中でも↓の問題はRubyよく知らない人間には相応のキツさがありました。
Netlifyのwebhook JWT署名検証サンプルではJWT. decode()の戻りがシンボル指定のハッシュであるように扱われているけど実際は文字列キーで戻ってくるのでdigest検証に一生失敗するhttps://t.co/VIp05X3K2g
— Kei Nakazawa (@muo_jp) March 27, 2020
Sinatraのエンドポイント記述
post "/netlify-hook" do request.body.rewind body = request.body.read halt 403 unless signed(request, body) uri = URI.parse("https://api.cloudflare.com/client/v4/zones/#{ZONE_ID}/purge_cache") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme === "https" params = { purge_everything: true } headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{API_TOKEN}" } response = http.post(uri.path, params.to_json, headers) ret = { :Output => response.code }.to_json puts ret ret end
Sinatra側では次のような問題がありました。
- ヘッダの渡ってきかたがNetlifyのサンプルとは違うので適宜調整が必要
- body(POST内容が入っている)がパフォーマンス上の都合かStringIOのインスタンスとして渡ってくる
- これは原則的に複数回読み出し用のものではないので、都度rewindする必要がある
その他、実装上の厄介ポイント
- AWS LambdaがRubyランタイムをサポートしたのが割と最近で、さらにSinatraを使っている人となると世界でもかなり少ない
- ので、当然に挙動が怪しくても自前でデバッグするほかない。
- デバッグしづらい。SAM CLIベースのローカル環境なしでいけるやろと取り組んだのが運の尽きで、最初からちゃんと作っておくべきだった
- Lambdaのコード実行がうまくいっているかの確認が大変
- 署名検証がうまくいっているかの確認も大変
コードと導入方法
実際に自サイトで導入しているLambda用プロジェクト一式をGitHub:muojp/netlify-webhook-purge-cloudflare-cacheに置いてあります*3。
[*3] デバッグ用のリクエストダンプ(puts
呼び出し)をいくつかapp/server.rbに残しているので、CloudWatchにリクエストデータが流れると困る場合には除去してください
READMEに記載のとおり、あらかじめコード格納先のS3 bucketを作っておき、CloudFormationでSinatraAppをデプロイし、3つの環境変数を設定すれば使えます。
CLOUDFLARE_API_TOKEN | CloudflareのAPIトークン |
---|---|
ZONE_ID | サイトのゾーンID |
NETLIFY_PRESHARED_KEY | Netlifyのwebhookに指定したsecret |
CloudflareのAPIトークンは図5のような設定で作りましょう。
サイトのゾーンIDはCloudflareのダッシュボード(Overview画面)の右下にひっそり書かれているのでコピーします。
Netlify側のwebhook URIには、AWS Lambdaを呼び出すAPI GatewayのURIに/netlify-hookをくっつけたものを指定します。
例:https://g12345676543123456.execute-api.ap-northeast-1.amazonaws.com/Prod/netlify-hook
JWS secret token (optional)という項目に適当な文字列を設定し、同じものをAWS Lambda側環境変数のNETLIFY_PRESHARED_KEYに設定すれば準備完了です。試しにNetlify側でデプロイを実行し、Cloudflare側のAudit Logにキャッシュパージリクエストが記録されることを確認しましょう。