wp-discourse + Cloudflare の教訓

Discourse、wp-discourse、Cloudflare を使った過去数ヶ月の個人環境設定に関する教訓をいくつか共有します。何かの役に立てば幸いです。

環境:

  • Discourse と WordPress は、同じ VPC 内の別々の AWS EC2 インスタンスでホストされています
  • WordPress ス택は nginx(fastCGI キャッシュ付き)+ php8.3-fpm + redis + mariadb
  • wp-discourse プラグインを使用して、WP と Discourse を連携させ、ブログ記事の下に Discourse コメントを埋め込んでいます
  • Cloudflare が Discourse と WP の両方をプロキシしています
  • WP は APO を使用しています

1) VPC を少し「ごまかす"

初期デプロイ時、すぐに Cloudflare が Web サーバーと Discourse サーバー間の API トラフィックに対してレート制限をかけたりブロックしたりする問題に直面しました。除外ルールを設定する方法を考えましたが、両方のサーバーが同じ AWS VPC 内にあるため、各サーバーのホストファイルにエントリを追加して、各サーバーのホスト名をパブリック DNS アドレスではなく VPC アドレスに直接指し示す方がはるかに簡単でした。これで、Cloudflare が WP と Discourse の間に割り込んでくることを心配する必要はありません。小さな変更ですが、大きな頭痛の種を解消しました。

2) wp-discourse と Apple News の競合への対応

Discourse と WordPress をセットアップした後、長い間奇妙な断続的な問題に直面していました。新しい WordPress 投稿が公開された際、訪問者には Discourse の代わりに、古い WordPress ネイティブのコメントブロックが下部に表示されることが時々(頻繁ではありませんが)ありました。実際の投稿自体は問題なく、Discourse コメントが正しく表示されていましたが、Cloudflare のエッジキャッシュが Discourse コメントが反映される直前のページをキャッチし、古いページを保持してしまっていたのです。

この動作の「どのように」や「なぜ」をどうしても特定できませんでした。トリガーもなさそうに、ランダムに発生しているように見えました。ログをくまなく探しましたが、明らかな原因は見つかりませんでした。

そこで、これはさまざまなキャッシュレイヤー間の相互作用によるものだと割り切り、ChatGPT に小さな mu-plugin を作成してもらいました。このプラグインは、Discourse のリンクが確立される前に WP に「このページをキャッシュしないように」と叫ばせ、その後にコメントスレッドが追加された時点で CF 側でキャッシュクリアをもう一度実行して、CF のキャッシュに古いデータが残らないように確実にするものです。

discourse-cloudflare-purge.php
<?php
/**
 * Plugin Name: Discourse → Cloudflare Safe Cache for Comments
 * Description: Prevents caching of single-post HTML until Discourse linkage exists; also purges Cloudflare when linkage lands.
 * Author: Lee Hutchinson + ChatGPT
 * Version: 1.6.1
 *
 * Changelog:
 * 1.6.1 - Added timeout failsafe so that we're not emitting no-cache headers forever
 * 1.6.0 — ADD pre-linkage no-cache gate (template_redirect) so edge/origin never cache the WP-native-comments HTML.
 * 1.4.0 — Purge on added/updated postmeta + status transition; purge slash/no-slash variants.
 * 1.3.0 — Credentials via wp-config.php constants only.
 */

if (!defined('ABSPATH')) exit;

/** Resolve Cloudflare credentials from wp-config.php */
function scw_dcfp_get_creds(): array {
    $zone  = defined('SCW_CF_ZONE_ID')   ? trim((string) SCW_CF_ZONE_ID)   : '';
    $token = defined('SCW_CF_API_TOKEN') ? trim((string) SCW_CF_API_TOKEN) : '';
    return ['zone_id' => $zone, 'api_token' => $token];
}

/** Discourse linkage meta keys (filterable) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** Build URLs to purge for a post (slash + no-slash, plus home + RSS) */
function scw_dcfp_build_urls(int $post_id): array {
    $urls = [];
    $permalink = get_permalink($post_id);
    if ($permalink) {
        $urls[] = $permalink;
        $urls[] = (substr($permalink, -1) === '/') ? rtrim($permalink, '/') : trailingslashit($permalink);
    }
    $urls[] = home_url('/');
    $urls[] = get_bloginfo('rss2_url');
    $urls = array_unique(array_filter($urls));
    return apply_filters('scw_dcfp_urls', $urls, $post_id);
}

/** Minutes since publish (UTC) or null if unknown */
function scw_dcfp_minutes_since_publish(int $post_id): ?int {
    $post_time = get_post_time('U', true, $post_id);
    if (!$post_time) return null;
    return (int) floor((time() - (int)$post_time) / 60);
}

/** Low-level Cloudflare purge-by-URL */
function scw_dcfp_purge_urls(array $urls): void {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '' || empty($urls)) return;

    $endpoint = sprintf('https://api.cloudflare.com/client/v4/zones/%s/purge_cache', $creds['zone_id']);
    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . $creds['api_token'],
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['files' => array_values($urls)]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log('[SCW CF Purge] WP_Error: ' . $response->get_error_message());
        return;
    }
    $code = (int) wp_remote_retrieve_response_code($response);
    if ($code < 200 || $code >= 300) {
        $body = wp_remote_retrieve_body($response);
        error_log(sprintf('[SCW CF Purge] HTTP %d. Response: %s', $code, $body));
    }
}

/** Common gate: only act when linkage meta key written & post is public */
function scw_dcfp_should_trigger($post_id, $meta_key, $meta_value): bool {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') return false;
    if (empty($meta_value)) return false;
    if (!in_array($meta_key, scw_dcfp_meta_keys(), true)) return false;
    return (get_post_status($post_id) === 'publish');
}

/** Purge on added/updated Discourse linkage meta */
function scw_dcfp_on_post_meta_change($meta_id, $post_id, $meta_key, $meta_value): void {
    if (!scw_dcfp_should_trigger($post_id, $meta_key, $meta_value)) return;
    $urls = scw_dcfp_build_urls((int) $post_id);
    scw_dcfp_purge_urls($urls);
}
add_action('added_post_meta',   'scw_dcfp_on_post_meta_change', 10, 4);
add_action('updated_post_meta', 'scw_dcfp_on_post_meta_change', 10, 4);

/** Purge on publish transition, if linkage meta already present */
function scw_dcfp_on_transition_post_status($new_status, $old_status, $post): void {
    if ($new_status !== 'publish' || !($post instanceof WP_Post)) return;
    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) {
            $urls = scw_dcfp_build_urls((int) $post->ID);
            scw_dcfp_purge_urls($urls);
            break;
        }
    }
}
add_action('transition_post_status', 'scw_dcfp_on_transition_post_status', 10, 3);

/**
 * NEW: Pre-linkage no-cache gate.
 * Until any Discourse linkage meta exists on a published single post,
 * emit strong no-cache headers so neither origin (FastCGI) nor Cloudflare
 * can store the pre-linkage HTML that shows WP's native comment form.
 */
function scw_dcfp_prelinkage_nocache(): void {
    if (!is_singular('post')) return;

    $post = get_queried_object();
    if (!($post instanceof WP_Post) || get_post_status($post) !== 'publish') return;

    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) return; // linkage exists; cache normally
    }

    // In wp-config.php you can set: define('SCW_CF_LINKAGE_GRACE_MIN', 10);
    $grace = defined('SCW_CF_LINKAGE_GRACE_MIN') ? (int) SCW_CF_LINKAGE_GRACE_MIN : 0;
    if ($grace > 0) {
        $mins = scw_dcfp_minutes_since_publish($post->ID);
        if ($mins !== null && $mins >= $grace) {
            // We’ve waited long enough; stop gating and let caching resume.
            // (Optionally, trigger one purge to refresh edge with whatever is current.)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // Block caching for this response (origin + edge + browsers)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate, etc.
        header('cf-edge-cache: no-cache');    // Extra hint for Cloudflare/APO
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // Optional debug: uncomment for a few publishes
    // error_log('[SCW CF Gate] Pre-linkage no-cache for post ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** Optional: admin notice if not configured */
add_action('admin_notices', function () {
    if (!current_user_can('manage_options')) return;
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') {
        echo '<div class="notice notice-error"><p><strong>SCW Discourse → Cloudflare:</strong> Missing credentials. Define <code>SCW_CF_ZONE_ID</code> and <code>SCW_CF_API_TOKEN</code> in <code>wp-config.php</code>.</p></div>';
    }
});

この mu-plugin は Cloudflare と API 通信を行うため、wp-config.php で CF のゾーン ID(SCW_CF_ZONE_ID)と API キー(SCW_CF_API_TOKEN)を定数として定義する必要があります。これらの定数名を変更することも検討してください。

これにより、問題の動作は完全に解消されました。discourse-cloudflare-purge.php をデプロイした瞬間に問題は消えました。しかし、それでも私はなぜそうなっていたのか気になって仕方がありませんでした。mu-plugin を無効にすると数日以内に問題が再発するため、根本原因は依然として存在していました。

最終的に、ログを精査したところ、問題の原因がわかりました。

私が運用している WP サイトは、このプラグイン を使用してコンテンツを Apple News にクロスパブリッシングしています。このプラグインは定期的に更新されており、何年も問題なく動作していました。しかし、Apple News への公開プロセスは投稿公開時に即座にトリガーされ、以下の nginx ログ抜粋に示されるような動作が時折発生します。このログは、著者が WordPress で「公開」ボタンを押してトリガーされた「POST」イベントから始まります。

Messy nginx access log excerpt
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [Poster IP] | POST /wp-admin/admin-ajax.php HTTP/2.0 200 Ref: "https://spacecityweather.com/wp-admin/post.php?post=53600&action=edit" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:40 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET / HTTP/2.0 200 Ref: "-" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-json/jetpack/v4/scan HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.197 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.183 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [-] [Discourse server IP] | POST /wp-json/wp-discourse/v1/update-topic-content HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.175 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/image-29.png HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/change.jpg HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] 2a01:b747:3003:206::32 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.182 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.181 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[Poster IP] は WP 投稿者の IP アドレス(伏字)、[Discourse server IP] は Discourse サーバーの VPC アドレスです。

[HIT][MISS][BYPASS] は、そのリクエストに対するローカル nginx fastcgi キャッシュのヒット状態を示しており、良い指標となります。

新しい投稿の URL は /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 です。

要約すると、ログが示しているのは、投稿が作成された直後に、Discourse プラグインが処理を行う前に、AppleNewsBot が(投稿公開をトリガーとして)飛び込み、新しく公開されたパーマリンクにアクセスしているという点です(Discourse が投稿を構築して画像を取得している様子や、他の Apple News サーバーがコピーを取得している様子もその下に確認できます)。

この競合状態が、CF のエッジキャッシュに古いページが表示される原因であったことはほぼ間違いありません。不整合が生じるのは、Discourse が競合に勝ち、キャッシュに初めて格納された際に正しいコメントが含まれている場合と、Apple News が勝ち、コメントが含まれていない場合があるためです。WP の CF プラグインには独自のキャッシュクリアロジックがありますが、Apple News よりも先に発火しているようです。

とにかく、問題の「なぜ」がわかったことで、ずいぶん安心しました。Apple News プラグインのメンテナに問題報告を出すことも検討していますが、この問題が広範に発生しており開発者の注意を引くほどかどうかはわかりません。それに、この mu-plugin(nginx のキャッシュ設定を適切に行うことと併せて)は、私にとっては問題が完全に解消されるため、実用的です。

3) Ajax 応答を 60 秒間キャッシュして「両方手に入れる」試み

wp-discourse プラグインには、WP の投稿/ページにおけるコメントキャッシュを処理するいくつかの方法がありますが、サイトオーナーの要件に合わせて、コメント表示には ajax 方式を選択しました。これはほぼ望み通りの機能で、Discourse 側で投稿されたほぼ同時に WP の投稿に新しいコメントが表示されるため、非常にうまく機能しています。

しかし、常に高トラフィックイベントの影が迫っています。これは天気予報サイトなので、予報がバズることもあります。もし突然、埋め込まれたコメントを表示しようとするユーザーが殺到し、それによって生成されるすべての ajax リクエストでサーバーが圧迫されたらどうでしょうか?

実は、Cloudflare を少し「悪用」して、エッジで ajax 応答を短時間キャッシュすることで、潜在的な負荷を軽減できます。これには、WP に指示を出す別の mu-plugin と、CF に指示を出す Cloudflare キャッシュルールが必要です。

discourse-rest-edge-cache.php
<?php
/**
 * Plugin Name: Discourse REST Edge Cache
 * Description: Sets cache headers on WP-Discourse REST responses so Cloudflare can cache them (~60s at edge) while browsers revalidate.
 * Version: 1.0.3
 * Author: Lee Hutchinson + ChatGPT
 */

if (!defined('ABSPATH')) exit;

add_filter('rest_post_dispatch', function($result, $server, $request){
    if (!($request instanceof WP_REST_Request)) return $result;

    // Match the WP-Discourse namespace at the start of the route
    $route = $request->get_route(); // e.g., /wp-discourse/v1/...
    $attrs = method_exists($request, 'get_attributes') ? (array) $request->get_attributes() : [];
    $ns    = $attrs['namespace'] ?? '';

    $is_discourse = ($ns === 'wp-discourse') || (is_string($route) && preg_match('#^/wp-discourse(?:/|$)#', $route));
    if (!$is_discourse) return $result;

    // Safe methods only
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // Don’t cache personalized/authenticated requests
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // Cache only successful responses
    if (is_wp_error($result)) return $result;
    $status = ($result instanceof WP_HTTP_Response) ? (int) $result->get_status() : 200;
    if ($status < 200 || $status >= 400) return $result;

    // Edge TTL ~60s, browsers revalidate; SWR smooths refresh
    $server->send_header('Cache-Control', 'public, s-maxage=60, max-age=0, stale-while-revalidate=30');
    $server->send_header('Vary', 'Accept-Encoding');

    return $result;
}, 10, 3);

これにより、WP は /wp-json/wp-discourse/v1/discourse-comments?post_id= などの呼び出しに対して Cache-Control ヘッダーを付与します。s-maxage=60 が、レスポンスをキャッシュに保持する時間を設定する部分です。

Cloudflare は通常、このようなリクエストをキャッシュしないため、キャッシュルールで優しく促す必要があります。

Some Cloudflare cache rule screenshots

このルールは、/wp-json/wp-discourse/ で始まるパスをキャッシュ対象とし、CF のエッジキャッシュに対してオリジンが要求する時間だけ保持するよう指示します。今回の場合、60 秒を要求しています(3 枚目のスクリーンショットにある「input time-to-live」ボックスを使用せず、プラグインで設定しなければならないのは、無料アカウントで Cloudflare が設定を許可する最小 TTL が 2 時間、Pro アカウントでも 1 時間であり、それよりもはるかに短い時間を設定したいためです)。

この短時間のキャッシュは、WP の投稿がバズって 10 万人が同時に閲覧し始めた場合に、CF が負荷の一部を肩代わりし、私がほぼリアルタイムで更新されるコメントという印象を維持するのに役立つと考えています。

上記の項目以外(これらはすべて wp-discourse のせいではありません!)については、CF と wp-discourse の統合は非常に簡単でした。私は満足な顧客です(正直なところ、いじくる口実があって楽しかったとも言えます)!

「いいね!」 2