【実録】TTFB 600ms→12msへ。Apache×mod_cache_diskでWordPressを“静的HTML直配信”にした手順まとめ

パソコン

WordPress サイト(AlmaLinux + Apache 2.4 + PHP-FPM + MariaDB、WPは /wpm 配下)で、ページキャッシュはあるのに TTFB が ~0.6s のままという状態を、mod_cache_disk による静的HTML直配信~12ms まで短縮した実録メモです。
そのままコピペできる設定・確認コマンド・運用小ネタまで一気にどうぞ。


スポンサーリンク

まず結論(成果)

  • 変更前(ホーム): TTFB ≈ 0.60–0.63s
  • 変更後(mod_cache_disk HIT): TTFB ≈ 0.012s(12ms)
  • レスポンスヘッダ(例)
x-cache: HIT from hd0.biz
age: 185
cache-control: public, max-age=1800, s-maxage=1800, stale-while-revalidate=120, stale-if-error=86400

方針

  1. HTMLに十分なTTLを付けるmax-age を数十秒〜数十分に)
  2. WordPressやテーマが勝手に付ける短命ヘッダを上書き/限定
  3. Apacheの mod_cache_disk で匿名HTMLをディスクキャッシュし、次回からはPHPを通さず配信
  4. 確認と運用(ウォームアップ・掃除・デバッグ)

ステップ1:HTMLのTTLを“確実に”上書き

vhost(*:443)に入れたブロック(抜粋)

ポイント:append ではなく always set、先に unset
ログイン/管理/API だけは no-store にします。

# ================= キャッシュ制御(HTMLを強キャッシュ) ================= # 管理/ログイン/API はキャッシュさせない判定
SetEnvIfNoCase Request_URI "^/(wp-admin|wp-login\.php|xmlrpc\.php|wp-json)" nocache=1
SetEnvIfNoCase Cookie "(wordpress_logged_in|comment_author|woocommerce_)=" loggedin=1 <IfModule mod_headers.c> # 既存の短命ヘッダを打ち消す Header always unset Cache-Control Header always unset Expires # 匿名HTMLは30分キャッシュ+stale配信 Header always set Cache-Control "public, max-age=1800, s-maxage=1800, stale-while-revalidate=120, stale-if-error=86400" env=!nocache # ログイン/管理/API は非キャッシュ Header always set Cache-Control "private, no-store" env=loggedin Header always set Cache-Control "private, no-store" env=nocache # 圧縮分岐の明示 Header append Vary "Accept-Encoding"
</IfModule>

(補足).htaccess 側は静的拡張子だけに限定

HTMLに触らないよう FilesMatch で静的ファイルに限定します。

/var/www/html/.htaccess 例:

<IfModule mod_headers.c> <FilesMatch "\.(?:css|js|mjs|svg|json|txt|woff2?)$"> Header set Cache-Control "max-age=31536000, public" </FilesMatch> Header append Vary Accept-Encoding env=!dont-vary
</IfModule>

/wpm/wp-content/.htaccess(画像の private を撤去→public に)

<IfModule mod_headers.c> <FilesMatch "(?i)\.(jpe?g|png|gif|svg|webp|avif)$"> Header always set Cache-Control "max-age=31536000, public" Header append Vary "Accept" # WebP/AVIF のネゴシ用 </FilesMatch>
</IfModule>

ステップ2:WordPress側が出す“1秒ヘッダ”を抑える(MUプラグイン)

WPやプラグインが HTML に短命ヘッダを付けても、最後に上書きします。

/wpm/wp-content/mu-plugins/cache-headers.php

<?php
/*
Plugin Name: Cache Headers (HTML)
Description: Anonymous HTML に長めの Cache-Control を付与
*/ add_action('send_headers', function () { if ( is_user_logged_in() || is_preview() || is_admin() ) { header_remove('Cache-Control'); header('Cache-Control: private, no-store'); return; } header_remove('Cache-Control'); header('Cache-Control: public, max-age=1800, s-maxage=1800, stale-while-revalidate=120, stale-if-error=86400');
}, 9999);

ステップ3:mod_cache_disk で“静的HTML直配信”

Vary付きドキュメントも保存するのがコツ(CacheNegotiatedDocs On)。
さらに CacheIgnoreHeaders Set-Cookie で“ゆるいCookie”があっても匿名ページを弾かないようにします。

vhost(*:443)に追記:

# ==== mod_cache_disk で匿名HTMLをディスクキャッシュ ====
CacheRoot /var/cache/httpd/mod_cache_disk # 交渉ドキュメントもキャッシュ対象に
CacheNegotiatedDocs On CacheQuickHandler On
CacheLock On
CacheIgnoreQueryString Off
CacheStorePrivate Off
CacheStoreNoStore Off
CacheMaxFileSize 10485760
CacheMaxExpire 1800
CacheDefaultExpire 1800 # Set-Cookie はキャッシュ判断から除外(匿名ページを弾かない)
CacheIgnoreHeaders Set-Cookie # デバッグ(任意):X-Cache / X-Cache-Detail が付く
CacheHeader On # 管理/ログイン/API はキャッシュ無効
CacheDisable /wp-admin
CacheDisable /wp-login.php
CacheDisable /xmlrpc.php
CacheDisable /wp-json # UTM等は無視してHIT率UP(任意)
CacheIgnoreURLSessionIdentifiers sid PHPSESSID ASPSESSIONID utm_source utm_medium utm_campaign utm_term utm_content gclid fbclid # Cookieがあるときは回避フラグ
SetEnvIfNoCase Cookie "(wordpress_logged_in|comment_author|woocommerce_)" cache_bypass=1 # 匿名時は Set-Cookie を出力から除去してキャッシュ可能化
Header always unset Set-Cookie env=!cache_bypass # ルート配下をキャッシュ対象に
CacheEnable disk / # バイパス時は非キャッシュ(保険)
Header always set Cache-Control "private, no-store" env=cache_bypass

初回はMISS → 2回目以降HIT
保存後は PHPを通らずディスクからHTMLを直配信するため、TTFB が劇的に短縮します。


ステップ4:確認コマンド(そのままコピペ)

4.1 HTMLヘッダ(TTL)

curl -sI https://example.com/ | egrep -i 'cache-control|age|x-cache|strict-transport'

期待:

cache-control: public, max-age=1800, s-maxage=1800, stale-while-revalidate=120, stale-if-error=86400
x-cache: HIT from ...
age: 10(数秒待って再取得)

4.2 実測(TTFB)

for i in 1 2 3; do curl -o /dev/null -s -w 'ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com/;
done

期待:HIT時 0.30〜0.45s(環境次第)。今回の事例では ~0.012s まで短縮。

4.3 画像ヘッダ(1年キャッシュ)

curl -sI https://example.com/wpm/wp-content/uploads/2022/09/DSC_0255-100x100.jpg | egrep -i 'cache-control|vary|expires'
# => Cache-Control: max-age=31536000, public

ステップ5:運用Tips(任意)

5.1 キャッシュ掃除(htcacheclean)

  • 手動一回:
sudo htcacheclean -p /var/cache/httpd/mod_cache_disk -l 2G -t -v

15分ごとに自動掃除(systemd timer):

# /etc/systemd/system/mod_cache_disk-clean.service
[Unit]
Description=Clean Apache mod_cache_disk
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/htcacheclean -p /var/cache/httpd/mod_cache_disk -l 2G -n -t # /etc/systemd/system/mod_cache_disk-clean.timer
[Unit]
Description=Clean Apache mod_cache_disk every 15 minutes
[Timer]
OnUnitActiveSec=15m
Unit=mod_cache_disk-clean.service
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now mod_cache_disk-clean.timer

5.2 ウォームアップ(ホームを10分ごとに温める)

# /etc/systemd/system/cache-warm.service
[Unit]
Description=Warm Apache mod_cache_disk (homepage)
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -sS https://example.com/ >/dev/null # /etc/systemd/system/cache-warm.timer
[Unit]
Description=Warm Apache mod_cache_disk every 10 minutes
[Timer]
OnUnitActiveSec=10m
Unit=cache-warm.service
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now cache-warm.timer

5.3 デバッグ時だけ有効にするログ(原因特定が一撃)

# vhostに一時的に
LogLevel warn cache:debug cache_disk:debug headers:trace1

hd0_ssl_error.log に「not cacheable because …」が出て、弾かれる理由(Cookie/ヘッダ/認証など)が一発で分かります。調査後は元に戻しましょう。


よくあるハマりどころ

  • Vary: Accept-Encoding が付くと保存されない
    CacheNegotiatedDocs On を必ず入れる。
  • 匿名でも Set-Cookie が先に出る
    CacheIgnoreHeaders Set-Cookie + 出力時に Header always unset Set-Cookie env=!cache_bypass
  • どこかで Header append Cache-Control "public"
    → ルートやテーマの .htaccess を静的拡張子限定に。HTMLを触らない。
  • Age が 0 のまま
    → 直後は普通。2秒ほど待って再取得。
  • AH01071: Primary script unknown
    → ないPHPを叩くボットのノイズ。実害なし。

さらに速く・堅くしたい場合

  • PHP-FPM のワーカー調整pm.max_children ほか)+ OPcache強化
  • Redis Object Cache でDB往復を削減(WPプラグイン+wp-config.php だけでOK)
  • Cloudflare APO / Cache Everything を併用してエッジ配信(海外アクセスやTLS終端の短縮に有効)

まとめ

  • HTMLに適切なTTL(30分+stale)を付け、
  • .htaccessを静的限定に整理し、
  • mod_cache_disk による匿名HTMLのディスクHITを作れば、
    ページキャッシュは効いているのに TTFB が遅い問題は、劇的に改善できます。

実測では ~0.6s → ~0.012s
仕組みが合えば、まだまだ“配信側”で伸びしろはあります。