以前「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: めざすもの

[*1] ビルド依存関係が大きいのですが、Netlify側のビルド構成キャッシュがよくできており、GitHubへのpushからおおむね1分以内にはblogが更新されます

下調べ

Netlifyのwebhook仕様を簡単に調べる

Netlifyのwebhookメニュー(Settings → Build & deploy)を見ると、わりと柔軟にhookを送信できることがわかります(図2)。

webhookの種類

図2: webhookの種類

CDNのキャッシュパージにはDeploy succeededのタイミングが適切でしょう。

webhookの設定

図3: webhookの設定

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)。

Cloudflareの監査ログ

図4: Cloudflareの監査ログ

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よく知らない人間には相応のキツさがありました。

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つの環境変数を設定すれば使えます。

表1: 設定する環境変数

CLOUDFLARE_API_TOKENCloudflareのAPIトークン
ZONE_IDサイトのゾーンID
NETLIFY_PRESHARED_KEYNetlifyのwebhookに指定したsecret

CloudflareのAPIトークンは図5のような設定で作りましょう。

APIトークン生成時の設定

図5: APIトークン生成時の設定

サイトのゾーン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にキャッシュパージリクエストが記録されることを確認しましょう。