Некоторые уроки, которые я извлек за последние несколько месяцев при настройке 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 не кэширует такие запросы, поэтому нам нужно мягко подтолкнуть его к этому с помощью правила кэширования:
Это правило помечает пути с /wp-json/wp-discourse/ как подходящие для кэширования, а затем говорит краевому кэшу CF хранить их столько, сколько просит источник. В нашем случае мы просим 60 секунд. (Мы делаем это через плагин, а не через поле «время жизни» на третьем скриншоте, потому что минимальное TTL, которое Cloudflare позволяет установить для бесплатных аккаунтов, составляет 2 часа, а для Pro-аккаунтов — 1 час, а мне нужно гораздо меньше.)
Я полагаю, что такое кратковременное кэширование немного поможет, если пост WP станет вирусным и 100 000 человек начнут одновременно его просматривать — CF возьмет на себя часть нагрузки, позволяя мне сохранять видимость почти живого обновления комментариев.
Помимо этих моментов — ни один из которых не является виной wp-discourse! — интеграция CF и wp-discourse прошла очень легко. Я довольный клиент. (И если быть честным, мне даже понравилось повода для экспериментов!)


