Некоторые уроки работы с wp-discourse и Cloudflare

Некоторые уроки, которые я извлек за последние несколько месяцев при настройке Discourse, wp-discourse и Cloudflare, специфичные для моей среды. Делюсь этим на случай, если кому-то это пригодится.

Окружение:

  • Discourse и WordPress размещены на отдельных экземплярах AWS EC2 в одной VPC
  • Стек WordPress: nginx с кэшированием через fastCGI + php8.3-fpm + redis + mariadb
  • Использую плагин wp-discourse для связывания WP и Discourse, а также для встраивания комментариев Discourse под постами блога
  • Cloudflare проксирует как Discourse, так и WP
  • WP использует APO

1) Немного схитрим с VPC

Когда я только развернул систему, я быстро столкнулся с проблемой: Cloudflare пытался ограничивать или блокировать API-трафик между веб-сервером и сервером Discourse. Я подумывал о том, чтобы настроить правила исключения, но поскольку оба сервера находятся в одной VPC AWS, было гораздо проще просто добавить записи в файл hosts на каждом сервере, чтобы указывать hostname каждого сервера на его адрес в VPC, а не на публичный DNS-адрес. Теперь больше не нужно беспокоиться о том, что Cloudflare будет вмешиваться в трафик между WP и Discourse. Небольшое изменение, но значительно уменьшающее головную боль.

2) Гонка между wp-discourse и Apple News

У меня уже довольно долгое время была странная периодическая проблема после настройки Discourse с WordPress: иногда — не часто, но иногда — новый пост WordPress для посетителей отображался с блоком нативных комментариев WordPress внизу вместо комментариев Discourse. Фактически пост был в порядке, с правильными комментариями Discourse, но краевой кэш Cloudflare перехватывал пост в ту секунду или две до того, как комментарии Discourse успевали загрузиться, и сохранял устаревшую страницу.

Я никак не мог понять, как и почему это происходит. Поведение казалось случайным, без видимых триггеров. Я пересмотрел логи, но ничего очевидного не нашел.

Поэтому я списал это на взаимодействие различных уровней кэширования и попросил chatGPT написать небольшой mu-плагин, который обойдет проблему, заставив WP кричать «НЕ КЭШИРУЙ ЭТО» для новых постов до того, как будет установлена связь с Discourse, а затем принудительно очистит кэш на стороне 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-плагин выполняет API-запросы к Cloudflare, поэтому в wp-config.php необходимо определить константы с вашим идентификатором зоны CF (SCW_CF_ZONE_ID) и API-ключом (SCW_CF_API_TOKEN). Возможно, вы захотите переименовать эти константы.

Это полностью устранило проблему — она исчезла сразу после развертывания discourse-cloudflare-purge.php. Но это все равно меня сильно беспокоило. Почему это происходило? Я мог вызвать проблему снова в течение нескольких дней, отключив mu-плагин, так что корень проблемы все еще оставался.

В конце концов, после тщательного анализа логов я нашел причину:

Сайт WP, который я обслуживаю, автоматически публикует контент в Apple News с помощью этого плагина, который регулярно обновляется и отлично работает уже несколько лет. Однако процесс публикации в Apple News запускается сразу после публикации поста, и иногда происходит поведение, показанное в следующем фрагменте логов nginx. Этот фрагмент начинается с события «POST», вызванного нажатием автором кнопки «Опубликовать» в WordPress:

Фрагмент логов доступа nginx
[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] — это скрытый IP-адрес автора поста в WP, а [Discourse server IP] — адрес VPC сервера Discourse.

[HIT], [MISS] или [BYPASS] — это статус попадания в локальный кэш fastcgi nginx для данного запроса, что является хорошим индикатором.

URL нового поста: /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.

Кратко: лог показывает создание поста, а затем, до того, как плагин Discourse успевает сделать свое дело, AppleNewsBot (запущенный публикацией поста) перехватывает только что опубликованную ссылку. (Вы можете видеть, как Discourse строит пост и загружает изображения прямо ниже, поскольку другие серверы Apple News также забирают копии.)

Эта гонка почти наверняка была источником появления устаревших страниц в краевом кэше CF, и несо consistency возникает потому, что иногда Discourse выигрывает гонку, и при первом попадании поста в кэш он уже содержит правильные комментарии, а иногда выигрывает Apple News, и пост попадает в кэш без них. У плагина WP для CF есть своя логика очистки кэша, но, похоже, она срабатывает раньше, чем Apple News.

В любом случае, я чувствую себя гораздо спокойнее, зная почему возникала проблема. Я рассматриваю возможность отправить запрос на исправление разработчикам плагина Apple News, но не уверен, достаточно ли эта проблема распространена, чтобы заслуживать внимания разработчиков. Кроме того, mu-плагин (в сочетании с умной настройкой кэширования в nginx) полностью устраняет проблему для меня.

3) Попытка получить и то, и другое: кэширование ответов ajax на 60 секунд

Плагин wp-discourse предлагает несколько способов работы с кэшированием комментариев на постах/страницах WP, но чтобы соответствовать требованиям владельца сайта, я выбрал метод отображения комментариев через ajax. Это практически именно то, что мне нужно, и работает отлично: новые комментарии на постах WP появляются почти сразу после их публикации на стороне Discourse.

Но всегда висит угроза события с высоким трафиком — это сайт прогноза погоды, и иногда прогнозы становятся вирусными. Что, если меня внезапно завалит пользователями, просматривающими встроенные комментарии, и всеми генерируемыми ими ajax-запросами?

Оказывается, мы можем немного злоупотребить Cloudflare и настроить кратковременное кэширование ответов ajax на краевых серверах Cloudflare, чтобы снизить потенциальную нагрузку. Для этого нужен еще один mu-плагин, чтобы сказать WP, что делать, и правило кэширования Cloudflare, чтобы сказать CF, что делать.

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 добавлять заголовок Cache-Control к запросам /wp-json/wp-discourse/v1/discourse-comments?post_id=. Параметр s-maxage=60 определяет время, в течение которого ответ должен храниться в кэше.

Обычно Cloudflare не кэширует такие запросы, поэтому нам нужно мягко подтолкнуть его к этому с помощью правила кэширования:

Скриншоты правил кэширования Cloudflare

Это правило помечает пути с /wp-json/wp-discourse/ как подходящие для кэширования, а затем говорит краевому кэшу CF хранить их столько, сколько просит источник. В нашем случае мы просим 60 секунд. (Мы делаем это через плагин, а не через поле «время жизни» на третьем скриншоте, потому что минимальное TTL, которое Cloudflare позволяет установить для бесплатных аккаунтов, составляет 2 часа, а для Pro-аккаунтов — 1 час, а мне нужно гораздо меньше.)

Я полагаю, что такое кратковременное кэширование немного поможет, если пост WP станет вирусным и 100 000 человек начнут одновременно его просматривать — CF возьмет на себя часть нагрузки, позволяя мне сохранять видимость почти живого обновления комментариев.

Помимо этих моментов — ни один из которых не является виной wp-discourse! — интеграция CF и wp-discourse прошла очень легко. Я довольный клиент. (И если быть честным, мне даже понравилось повода для экспериментов!)

2 лайка