WordPress記事更新時にCloudFrontのキャッシュを自動的にクリアする方法

こんにちは、SCSKの木澤です。

本サイトを含め、私は複数のWebサイトの構成や維持管理を行っておりますが、ほとんどのサイトで前段にCDNとしてAmazon CloudFrontを設置し高速化するようにしています。廉価に(従量課金で)CDNを使えるようになって本当に良い時代になりました。

但しCloudFrontでキャッシュすることで以下のような弊害も発生してしまいます。

  • 記事を更新しても、すぐに反映されない
  • 記事を投稿しても、すぐに記事一覧に表示されない
  • 記事を削除しても、しばらく閲覧できてしまう
  • (同一ファイル名で)画像を差し替えても、すぐに反映されない

そこで、WordPressの記事投稿・更新・削除(ゴミ箱へ移動)や、画像の差し替え時にCloudFrontのキャッシュをクリアする仕組みを開発してみましたのでご紹介します。

構成の紹介

今回のポイントは、WordPressのプラグイン「WP Webhooks」となります

WP Webhooks – Automate repetitive tasks by creating powerful automation workflows directly within WordPress
Automate everything & connect your website, plugins and services together with no-code automations. Browse 100+ integrat...

本プラグインを用いることで、WordPressでのイベント発生時に紐づけて外部APIを呼び出すことができます
つまり、以下のような流れでキャッシュクリアが可能となります。

キャッシュクリア対象イベント・URLの検討

キャッシュクリアの対象は全てとするしまうこともできますが、ヒット率が低下してしまう恐れもあるため、可能な限り少なくすることが望ましいと考えます。そこで、WP Webhooksから送信されるデータを解析し選定を行いました。

クリア対象WordPressイベントの選定

WordPressのサイト運用での知見を反映し、以下のイベント時にCloudFrontのキャッシュクリアを行うこととします。
これらのイベント時にキャッシュクリアすることにより、殆どのケースでキャッシュの弊害は出なくなるものと思います。

  • 記事/固定ページを公開したとき
  • 記事/固定ページを変更したとき(下書き状態での変更は除く)
  • 記事/固定ページを下書き状態に戻したとき
  • 記事/固定ページをゴミ箱に入れたとき
  • メディアファイルの画像を削除したとき

WP Webhooksから送信されてくるデータの解析

WP WebhooksからはJSON形式で各種情報が送信されてきます。不足する情報を改めてWordPressサーバ側に問い合わせることは大掛かりな仕組みが必要であるため、この内容から判定できる最大限の情報をもとにキャッシュクリアの対象を選定することにします。

なお、記事/固定ページに関するイベントと、メディアファイルに関するイベント時のデータ構造に差異がありますので、以下にサンプルを掲載しておきます。

本対象は環境により異なる可能性がありますので、適宜調整お願いします。
記事/固定ページを作成・更新・削除した際のデータ形式(例)

以下のような形式で送信されており、対象が記事/固定ページ/メディアファイルかどうかは、post_typeキーで判別できます。

  • post:記事
  • page:固定ページ
  • attachment:メディアファイル

なお、post_statusには以下の値が入るので、記事の状態によって動作を変更することも可能です。

  • draft:下書き
  • pending:レビュー待ち
  • publish:公開済
  • trash:ゴミ箱に入っている

また記事本文・タイトルの他、付与したカテゴリやタグ、パーマリンクを含めて概ねの情報が取得できることがわかります。編集前の状態も取得できるため、変更内容も概ね推定できます。

但し、以下の情報は取得できないことがわかります。

  • カテゴリのフルパス(記事に付与したカテゴリの親カテゴリのIDは解るが名称が取得できないため)
  • 著者名(著者IDは解るが著者名は明記されていない)
{
    "post_id": XXXX, 
    "post": {
        "ID": XXXX, 
        "post_author": "X", 
        "post_date": "YYYY-MM-DD hh:mm:ss", 
        "post_date_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content": "記事本文", 
        "post_title": "記事タイトル", 
        "post_excerpt": "", 
        "post_status": "publish", 
        "comment_status": "open", 
        "ping_status": "open", 
        "post_password": "", 
        "post_name": "パーマリンクに設定したパス", 
        "to_ping": "", 
        "pinged": "", 
        "post_modified": "YYYY-MM-DD hh:mm:ss", 
        "post_modified_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content_filtered": "", 
        "post_parent": 0, 
        "guid": "https://example.com/?p=XXXX", 
        "menu_order": 0, 
        "post_type": "post", 
        "post_mime_type": "", "comment_count": "0", "filter": "raw"
    }, 
    "post_meta": {
        "_edit_lock": ["XXXXXXXXXX:1"], 
        "_edit_last": ["1"], 
        "last_modified": ["YYYY-MM-DD hh:mm:ss"], 
        "_thumbnail_id": ["XXXX"], "the_page_seo_title": [""], "seo_title": [""], 
        "the_page_meta_description": ["メタディスクリプションで設定した文書"], 
        "meta_description": ["メタディスクリプションで設定した文書"], 
    }, 
    "post_before": {
        "ID": XXXX, 
        "post_author": "1", 
        "post_date": "YYYY-MM-DD hh:mm:ss", "post_date_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content": "記事本文(編集前)", 
        "post_title": "記事タイトル(編集前)", 
        "post_excerpt": "", 
        "post_status": "publish", 
        "comment_status": "open", 
        "ping_status": "open", 
        "post_password": "", 
        "post_name": "パーマリンクに設定したパス(編集前)", 
        "to_ping": "", 
        "pinged": "", 
        "post_modified": "YYYY-MM-DD hh:mm:ss", 
        "post_modified_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content_filtered": "", "post_parent": 0, 
        "guid": "https://example.com/?p=XXXX", 
        "menu_order": 0, 
        "post_type": "post", 
        "post_mime_type": "", "comment_count": "0", "filter": "raw"
    }, 
    "post_thumbnail": "サムネイルアイコンの画像URL", 
    "post_permalink": "パーマリンク(フルパス)", 
    "taxonomies": {
        "post_tag": {
            "タグ名": {"term_id": XX, "name": "タグ名", "slug": "タグの省略名", "term_group": 0, "term_taxonomy_id": 29, "taxonomy": "post_tag", "description": "", "parent": 0, "count": 3, "filter": "raw"}
        }, 
        "category": {
            "カテゴリ1": {"term_id": XX, "name": "カテゴリ1", "slug": "カテゴリの省略名1", "term_group": 0, "term_taxonomy_id": XX, "taxonomy": "category", "description": "", "parent": XX, "count": XX, "filter": "raw"}, 
            "カテゴリ2": {"term_id": XX, "name": "カテゴリ2", "slug": "カテゴリの省略名2", "term_group": 0, "term_taxonomy_id": XX, "taxonomy": "category", "description": "", "parent": XX, "count": XX, "filter": "raw"}
        }
    }
}
画像を削除した際のデータ形式(例)

こちらは以下のような形式でした。
削除された画像のパスはguidから取得できることがわかります。

{
    "post_id": XXXX, 
    "post": {
        "ID": XXXX, "post_author": "1", 
        "post_date": "YYYY-MM-DD hh:mm:ss", "post_date_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content": "", "post_title": "3", "post_excerpt": "", 
        "post_status": "inherit", "comment_status": "open", "ping_status": "closed", 
        "post_password": "", "post_name": "3", "to_ping": "", "pinged": "", 
        "post_modified": "YYYY-MM-DD hh:mm:ss", "post_modified_gmt": "YYYY-MM-DD hh:mm:ss", 
        "post_content_filtered": "", "post_parent": 0, 
        "guid": "画像URLのフルパス", 
        "menu_order": 0, 
        "post_type": "attachment", 
        "post_mime_type": "image/png", "comment_count": "0", "filter": "raw"
    }, 
    "post_meta": {
    }, 
    "post_thumbnail": false, 
    "post_permalink": "https://example.com/3", 
    "taxonomies": []
}

キャッシュクリア対象の選定

上記JSONデータの解析結果を踏まえ、キャッシュクリア対象を以下の通り選定しました。
これらのキャッシュをクリアできれば、CloudFrontでのキャッシュ時間を延ばしヒット率を上げることも可能です。

発生タイミング 削除対象 パス
記事を投稿・更新・削除(ゴミ箱へ移動)したタイミング
  • トップページ(記事一覧)
  • 記事URL
  • 変更前の記事URL(※パーマリンクを変更した場合)
  • 記事に付与したタグの記事一覧
  • カテゴリ記事一覧すべて
  • 著者別記事一覧すべて
  • サイトマップ
  • /
  • /(記事パーマリンク)
  • /(変更前のパーマリンク)
  • /tag/(タグ名)
  • /category/*
  • /author/*
  • /sitemap*
メディアファイルを削除したタイミング 当該画像のURL /wp-content/uploads/2022/03/xxx.jpg(例)
本対象は環境により異なる可能性がありますので、適宜調整お願いします。

設定方法

以下の構成図のようにWordPressの環境からAPI Gatewayを呼び出す方式を想定し解説します。

本来であればVPCエンドポイント経由でAPI Gatewayを接続してプライベートAPI化することが望ましいと考えますが、当方環境での検証ではWP Webhooksからの呼び出しが動作しなかったため、Internet Gateway経由の設定としています。下記手順ではAPIキーでの認証としていますが、API GatewayにAWS WAFを組み合わせソースIP等でアクセスを絞ることが望ましいです。

IAMロールの作成

まず、Lambda動作に必要なロールを作成します。

IAMポリシー

今回はIAMポリシーとして下記2つを作成し、ロールに割り当てます。

  • CloudFrontキャッシュクリアに必要なポリシー(cloudfront:CreateInvalidation)
  • CloudWatch Logsにログ出力するポリシー

CloudFrontキャッシュクリア IAMポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "cloudfront:CreateInvalidation",
            "Resource": "arn:aws:cloudfront::AWSアカウントID:distribution/*"
        }
    ]
}

CloudWatch Logs ログ出力 IAMポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}
IAMロールの作成

作成した2つのポリシーをアタッチしたLambda用のロールを作成します。

IAMのコンソールからロール⇒ロールの作成を選択し、ロールの作成画面からAWSのサービス、ユースケースとしてはLambdaを選択します。

続けて、アタッチするポリシーとして事前に作成した2つのポリシーを選択します。

確認画面にて、ポリシー名を入力しポリシーを作成します。

Lambda関数の設置

WP Webhooksから送られてきたJSONデータを解析し、CloudFrontのキャッシュクリアを行うLambda関数を作成します。
AWSマネジメントコンソールからLambdaを開き、「関数の作成」を選択します。

関数名、言語(今回はPython)、前項で作成したIAMロールを選択して、関数の作成をクリックします。

関数のひな形が作成され、編集画面が開きます。
今回は上記の章での設計内容に準じて、ソースコードとして以下のようにしてみました。

import os
import boto3
from datetime import datetime

cf = boto3.client('cloudfront')

def lambda_handler(event, context):
    # 記事投稿/更新かメディアファイル処理かの判定
    if event['post']['post_type'] == "post" or event['post']['post_type'] == "page":
        result = invfor_postupdate(event)
    elif event['post']['post_type'] == "attachment":
        result = invfor_imgdelete(event)
    else:
        result = {"message":"Unknown Types"}

    print(result)
    
    return {
        'statusCode': 200,
        'body': result
    }

def invfor_postupdate(event):
    # 新規投稿・更新・ゴミ箱移動時のキャッシュ削除処理
    invalidate_list = []
    
    # 変更前・後ともpublished(公開済)でない場合は処理を行わない
    if 'post_before' in event:
        if event['post']['post_status'] != "publish" and event['post_before']['post_status'] != "publish":
            return {"message":"No Invalidation Target"}
    else:
        if event['post']['post_status'] != "publish":
            return {"message":"No Invalidation Target"}
    
    # 固定ページの場合はパーマリンクを採用
    if event['post']['post_type'] == "page":
        invalidate_list.append(getpass(event['post_permalink']))
    else:
        # 記事の場合
        # トップページ
        invalidate_list.append("/")

        # 新規投稿・更新時:記事URLとURL変更時は元URL
        # ゴミ箱移動時は元URLのみ
        if event['post']['post_status'] == "trash":
            invalidate_list.append("/" + event['post_before']['post_name'])
        else:
            invalidate_list.append("/" + event['post']['post_name'])
            if 'post_before' in event:
                if event['post']['post_name'] != event['post_before']['post_name']:
                    invalidate_list.append("/" + event['post_before']['post_name'])

        # 記事に付与したタグの記事一覧
        if 'post_tag' in event['taxonomies']:
            for tag in event['taxonomies']['post_tag'].keys():
                invalidate_list.append("/tag/" + tag)

        # カテゴリ記事一覧
        if 'category' in event['taxonomies']:
            invalidate_list.append("/category/*")

        # 著者別記事一覧
        invalidate_list.append("/author/*")

    # サイトマップ
    invalidate_list.append("/sitemap*")
    
    # キャッシュ削除処理の実行
    response = invalidate_cf(invalidate_list)
    
    return response
    
def invfor_imgdelete(event):
    # メディアファイル削除時のキャッシュ削除処理
    invalidate_list = []
    invalidate_list.append(getpass(event['post']['guid']))
    
    # キャッシュ削除処理の実行
    response = invalidate_cf(invalidate_list)
    
    return response

def invalidate_cf(lists):
    # cloudfront Invalidation処理
    dest_id = os.environ['dest_id']
    numlists = len(lists)
    nowstr = datetime.now().strftime('%Y%m%d%H%M%S%f')

    print(lists)

    if numlists == 0:
        response = {"message":"No Invalidation Target"}
    else:
        response = cf.create_invalidation(
            DistributionId = dest_id,
            InvalidationBatch = {
                'Paths': {
                    'Quantity': numlists,
                    'Items': lists
                },
                'CallerReference': nowstr
            }
        )
    
    return response['ResponseMetadata']

def getpass(url):
    passname = "/" + url.split("/",3)[3]
    return passname

本ソースコードでは、CloudFrontのディストリビューションIDを環境編集(dest_id)から取得するように作りましたので、Lambda編集画面の設定⇒環境変数で環境変数を設定します。

API Gatewayの設定

作成したLambda関数をREST API化するAPI Gatewayを作成します。

初期設定

AWSマネジメントコンソールからAPI Gatewayを開き、「APIを作成」を選択します

最初にAPIタイプとしてREST APIを選択します

続いて各種情報を入力します。

  • プロトコル:REST
  • 新しいAPIを作成
  • API名と、エンドポイントタイプとしてリージョンを選択

「APIの作成」ボタンを押すと、APIのひな型が作成できます。

メソッドの作成

ここではAPIが呼ばれた際の処理を紐づけるため、先に作成したLambda関数を割り当てます。

左メニュー「リソース」から、「メソッドの作成」を選択します。

次にメソッドを選びます。今回は「POST」を選択し、右側のチェックボタンを選択し確定します。

今回はLambda関数を呼び出しますので、統合タイプはLambda関数とし、関数を作成したリージョンと関数名を指定します。

初回設定時はLambda関数に、API Gatewayからの呼び出し権限を追加する承諾が出ますので、OKをクリックします。

メソッドのひな型が作成され、Lambda呼び出しのフローが表示されます。
今回はリクエストに関する設定変更のため、「メソッドリクエスト」をクリックします。

今回は後の工程でAPIキーによる簡易認証を有効化するため、「APIキーの必要性」を「true」に変更し、チェックマークをクリックし確定させます。

APIのデプロイ

作成したAPIをデプロイし利用できるようにします。

アクションから「APIのデプロイ」を選択します。

デプロイされるステージとして「新しいステージ」とし、任意のステージ名を入力します。

「デプロイ」をクリックすると、APIがデプロイされます。

呼び出し先URLが発行されますので、こちらがWP Webhooksからの呼び出し先になります。
後で利用しますので、メモしておきます。

APIキーの作成

WP Webhooksからの呼び出しの際に、簡易的な認証に用いるAPIキーを作成します。

APIKeyでの認証は推奨されておりませんが(参考記事)、本記事ではWP Webhooksの制約のため APIKeyでの認証としています。本構成はWordPressサーバからのAPI連携のみである前提で問題ないと判断しましたが、本番環境で利用する際はAWS WAFとの併用も検討お願いします。
左側メニューの「APIキー」を選択し、アクションから「APIキーの作成」を選択します。

次のAPIキーの名前を選択し、「保存」を押します。

APIキーが作成されます。
「表示」をクリックするとAPIキーが表示されますので、この内容をメモしておきます。

使用量プランの作成

最後に、使用量プランを作成し、APIキーをAPIに紐づけます。

左側メニュー「使用量プラン」を選択し、「作成」をクリックします。

使用量プランの名前とスロットリング、クォータの設定値を入力し、「次へ」を押します。

本入力例ではスロットリング・クォータ値を入れておりますが、本記事の使用例では大量に実行される可能性は低いため、無効設定でも問題ないものと考えます。

関連付けるAPIとステージを指定します。
「APIステージの追加」をクリックし、API名とステージを入力します。

最後に、APIキーの割り当てを行います。
「APIキーを使用量プランに追加」をクリックし、事前に作成したAPIキーを割り当てます。

これでAWS側のAPIの設定は完了です。

本設定画面では複数APIに1つのAPIキーを割り当てることもできますが、上述のセキュリティの観点からAPIキーを複数APIで使いまわすことはせず、1対1で紐づけるようにします。

WP Webhooksの設定

さて、いよいよ作成したAPIをWP Webhooksから呼び出す設定となります。
前述したように以下のイベントが発生した際にAPIを呼び出すように設定します。

  • 記事/固定ページを公開したとき
  • 記事/固定ページを変更したとき(下書き状態での変更は除く)
  • 記事/固定ページを下書き状態に戻したとき
  • 記事/固定ページをゴミ箱に入れたとき
  • メディアファイルの画像を削除したとき

APIキーの設定

API Gatewayにて発行したAPIキーを登録します。

WP Webhooksプラグインの設定画面を開き、Authenticationメニューから「Create Template」を選択します。

テンプレート名を入力し、Auth Typeには「API Key」を選択します。

テンプレートが作成されました。Actionメニューから「Settings」を選択します。

Key名に「x-api-key」を入力し、ValueにはAPI Gatewayにて発行したAPIキーを入力します。
Add toは「Header」を選択し、Save Templateをクリックします。

WordPressイベントとの紐づけ

本設定はWP Webhooks設定の「Send Data」にて行います。
今回設定を行うのは、以下3項目となります。

  • Post updated : 記事/固定ページの公開・変更時
  • Post trashed : 記事/固定ページをゴミ箱へ移動した時
  • Post deleted : 画像ファイルを削除した時

以下設定方法を解説します。

Post updated (記事/固定ページの公開・変更)

Post updatedを選択し、「Add Webhook URL」をクリックします。

Webhookの名前、WebhookのURLとしてはAPI GatewayのURL(ステージ込み)を入力します。

Webhookが作成されました。Actionから「Settings」を選び、設定画面に入ります。

設定画面では以下のように設定します。

  • Trigger on selected post types : 投稿・固定ページ(対象となる投稿タイプを選択)
  • Trigger on post status / Trigger on post status change : 入力なし
    (※ 公開済(publish)・下書き(draft)などステータスを指定できるが、Lambda内で判定しているため不要)
  • Change the data request type : JSON(デフォルト)
  • Change the data request method : POST(デフォルト)
  • Add authentication template : 上記で作成したAPIキーのテンプレート名を指定

Post trashed(記事/固定ページのゴミ箱への移動)

こちらもPost updatedと同様の設定を行います。

Post deleted(画像の削除)

画像削除時の挙動を設定します。
「Trigger on selected post types」をメディアファイルのみとします。

ここまでで設定は完了です。

まとめ

若干長くなりましたがいかがでしたでしょうか。
本設定を行うことで、記事や固定ページの更新時にCloudFrontのキャッシュクリアを自動で行うことができます。

CloudFrontの管理画面を覗いていると、自動的にキャッシュクリア(invalidation)が動いている様子を見ることができて、面白いです。
例えば本記事であれば以下のように動きます。

なお、高頻度(あまり間を置かず)にキャッシュクリア(invalidation)を実行した場合は、正常にクリアできない場合があります。
その点は注意してご利用下さい。

また本設定を行うことでキャッシュ時間を全体的に延ばすことができるので、併せて調整を検討することが望ましいと思います。
そうすることでキャッシュヒット率を向上させ、ユーザへのレスポンス向上とサーバ負荷の軽減を図ることができます。

本記事はWordPress前提で記載しましたが、他のCMSでも作りこみをおこなうことで同様の設定ができると思います。
サイト管理者の皆様、ぜひご検討ください。

タイトルとURLをコピーしました