WordPress で Apache の mod_cache を設定する

# やりたいこと

- WordPress を動かしている Apache HTTP Server (Version 2.4) で mod_cache (mod_cache_disk) を設定したい。
- WordPress は mod_rewrite で [Pretty Permalinks](https://codex.wordpress.org/Introduction_to_Blogging#Pretty_Permalinks) を設定している。
- リクエスト先（一覧 or 記事など）によってキャッシュの可否・有効期限を変更したい。

# 分かったこと

- **すべてのリクエスト（レスポンス）に対するキャッシュは rewrite 後のURL (`/index.php`) のキャッシュとして扱われる。**
- よって、特定のURLやディレクトリをキャッシュさせたい場合でも `CacheEnable` の対象に `/index.php` が含まれている必要がある。
- rewrite 後のURL (`/index.php`) をリクエストされたURL毎にユニークになるようにしなければならない（そうしないと**キャッシュされる内容がすべて同一になる**）。
- `Location` ディレクティブなどを使ってキャッシュの設定やレスポンスヘッダを振り分けることができないので、キャッシュの可否・有効期限の制御は WordPress 側でやる。

Apache のデバッグログ: `http://localhost:80/141412` へのリクエストに対して、最終的に `Headers and body for URL http://localhost:80/index.php? cached.` となっている。

```
[cache:debug] [pid 16] cache_storage.c(664): [client 172.20.0.8:59718] AH00698: cache: Key for entity /141412?(null) is http://localhost:80/141412?, referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(210): [client 172.20.0.8:59718] AH00750: Adding CACHE_SAVE filter for /141412, referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(220): [client 172.20.0.8:59718] AH00751: Adding CACHE_REMOVE_URL filter for /141412, referer: https://localhost/
[cache:debug] [pid 16] cache_storage.c(664): [client 172.20.0.8:59718] AH00698: cache: Key for entity /index.php?(null) is http://localhost:80/index.php?, referer: https://localhost/
[cache_disk:debug] [pid 16] mod_cache_disk.c(572): [client 172.20.0.8:59718] AH00709: Recalled cached URL info header http://localhost:80/index.php?, referer: https://localhost/
[cache_disk:debug] [pid 16] mod_cache_disk.c(885): [client 172.20.0.8:59718] AH00720: Recalled headers for URL http://localhost:80/index.php?, referer: https://localhost/
[cache:debug] [pid 16] cache_util.c(735): [client 172.20.0.8:59718] AH00782: Cache lock obtained for stale cached URL, revalidating entry: /index.php, referer: https://localhost/
[cache:debug] [pid 16] cache_storage.c(359): [client 172.20.0.8:59718] AH00695: Cached response for /index.php isn't fresh. Adding conditional request headers., referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(210): [client 172.20.0.8:59718] AH00750: Adding CACHE_SAVE filter for /index.php, referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(220): [client 172.20.0.8:59718] AH00751: Adding CACHE_REMOVE_URL filter for /index.php, referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(1332): [client 172.20.0.8:59718] AH00769: cache: Caching url: /index.php, referer: https://localhost/
[cache:debug] [pid 16] mod_cache.c(1338): [client 172.20.0.8:59718] AH00770: cache: Removing CACHE_REMOVE_URL filter., referer: https://localhost/
[cache_disk:debug] [pid 16] mod_cache_disk.c(1350): [client 172.20.0.8:59718] AH00737: commit_entity: Headers and body for URL http://localhost:80/index.php? cached., referer: https://localhost/
```

# 設定例

## mod_cache

```apacheconf
# CacheRoot は `a2enmod cache_disk` で mod_cache_disk を有効にすると勝手に設定される模様
# see: /etc/apache2/mods-available/cache_disk.conf

CacheEnable disk /
CacheDisable /wp-admin/
CacheDisable /wp-content/
CacheDisable /wp-includes/

# Set-Cookie がある場合はキャッシュしない
CacheIgnoreHeaders Set-Cookie

# リクエストに no-cache があってもキャッシュを返す
CacheIgnoreCacheControl On
```

## mod_rewrite

`/index.php` に `?cache_key=%{REQUEST_URI}` のようにリクエストされたURLをクエリとして付加することで、別のURLとして扱ってくれる。

```apacheconf
<Directory "/var/www/html">
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.php$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.php?cache_key=%{REQUEST_URI} [QSA,L]
</Directory>
```

**修正 (2018-01-22)**

`/index.php?cache_key=%{REQUEST_URI}` への rewrite は、 `?cache_key=` によってクエリが上書かれてしまうので、 `QSA` フラグを付ける必要がある。[^1]

[^1]: この場合、クエリの違い毎にキャッシュが生成されるので、キャッシュ内容に関係ないクエリについては `CacheIgnoreURLSessionIdentifiers` 等で工夫する。

ref: https://httpd.apache.org/docs/2.4/rewrite/flags.html#flag_qsa


## WordPress

テーマの functions.php かプラグインで、以下のように設定する。（上記の設定の場合、`Expires` や `Cache-Control` ヘッダーを付加しない限りキャッシュされない）

```php
// 'send_headers' フックだと is_single() などが使えない。
add_action('template_redirect', function () {
    if (is_user_logged_in()) {
        return;
    }
    
    if (is_single()) {
        header('Cache-Control: s-maxage=60');
    }
});
```

クエリ付きのURLは `CacheDefaultExpire` や `CacheMinExpire` の値は反映されない模様。

> If the URL included a query string (e.g. from a HTML form GET method) it will not be cached unless the response specifies an explicit expiration by including an "Expires:" header or the max-age or s-maxage directive of the "Cache-Control:" header, as per RFC2616 sections 13.9 and 13.2.1.
> (https://httpd.apache.org/docs/2.4/en/caching.html)

## 結果

`CacheDetailHeader On` とすることで、`X-Cache-Detail` ヘッダーで詳細を確認できる。

### Cache-Control ヘッダーを設定したレスポンス

1回目 (cache miss)

<img width="448" alt="
" src="https://qiita-image-store.s3.amazonaws.com/0/16100/2f195b6c-3781-bb12-7718-971e819096bb.png">

2回目 (cache hit)

<img width="284" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/16100/8b34e994-0fb1-6b0c-7d45-6abefa658252.png">

Apache のデバッグログ: `http://localhost:80/141412` へのリクエストに対して、最終的に `Headers and body for URL http://localhost:80/index.php?cache_key=/141412 cached.` となっている。

```
[cache:debug] [pid 17] cache_storage.c(664): [client 172.20.0.8:51730] AH00698: cache: Key for entity /141412?(null) is http://localhost:80/141412?, referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(210): [client 172.20.0.8:51730] AH00750: Adding CACHE_SAVE filter for /141412, referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(220): [client 172.20.0.8:51730] AH00751: Adding CACHE_REMOVE_URL filter for /141412, referer: https://localhost/
[cache:debug] [pid 17] cache_storage.c(664): [client 172.20.0.8:51730] AH00698: cache: Key for entity /index.php?cache_key=/141412 is http://localhost:80/index.php?cache_key=/141412, referer: https://localhost/
[cache_disk:debug] [pid 17] mod_cache_disk.c(572): [client 172.20.0.8:51730] AH00709: Recalled cached URL info header http://localhost:80/index.php?cache_key=/141412, referer: https://localhost/
[cache_disk:debug] [pid 17] mod_cache_disk.c(885): [client 172.20.0.8:51730] AH00720: Recalled headers for URL http://localhost:80/index.php?cache_key=/141412, referer: https://localhost/
[cache:debug] [pid 17] cache_util.c(735): [client 172.20.0.8:51730] AH00782: Cache lock obtained for stale cached URL, revalidating entry: /index.php?cache_key=/141412, referer: https://localhost/
[cache:debug] [pid 17] cache_storage.c(359): [client 172.20.0.8:51730] AH00695: Cached response for /index.php isn't fresh. Adding conditional request headers., referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(210): [client 172.20.0.8:51730] AH00750: Adding CACHE_SAVE filter for /index.php, referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(220): [client 172.20.0.8:51730] AH00751: Adding CACHE_REMOVE_URL filter for /index.php, referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(1332): [client 172.20.0.8:51730] AH00769: cache: Caching url: /index.php?cache_key=/141412, referer: https://localhost/
[cache:debug] [pid 17] mod_cache.c(1338): [client 172.20.0.8:51730] AH00770: cache: Removing CACHE_REMOVE_URL filter., referer: https://localhost/
[cache_disk:debug] [pid 17] mod_cache_disk.c(1350): [client 172.20.0.8:51730] AH00737: commit_entity: Headers and body for URL http://localhost:80/index.php?cache_key=/141412 cached., referer: https://localhost/
```

### Cache-Control ヘッダーを設定していないレスポンス

キャッシュされない。

<img width="805" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/16100/ab036ff4-8652-9d2d-3807-25c819808278.png">

# User-Agent によって表示を分けている場合

WordPress 側で User-Agent によって表示を出し分けている場合は、Apache にキャッシュさせる内容も分ける必要がある。その場合、`/index.php` に渡すクエリを User-Agent （表示内容）によって変わるようにすることで対応できる。

（mod_cache は `Vary` ヘッダーを見ているので、`Vary` ヘッダーに `User-Agent` を追加することでキャッシュを分けることもできるが、無数にある User-Agent 毎にキャッシュが生成されてしまうため、キャッシュの効果が薄れる）

```apache
# デバイスの判定・出し分けのロジックはWordPress側と合わせる（以下は簡略版）

BrowserMatchNoCase ".*"              layout=desktop
BrowserMatchNoCase "(ipod|iphone)"   layout=mobile
BrowserMatchNoCase "android.*mobile" layout=mobile

<Directory "/var/www/html">
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.php$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  # この場合 %{ENV:layout} には 'desktop' or 'mobile' が渡される
  RewriteRule . /index.php?cache_key=%{REQUEST_URI}:%{ENV:layout} [QSA,L]
</Directory>
```

このとき、WordPress側でクライアントキャッシュも有効にすると（`max-age` など）、同一ブラウザにおいて User-Agent を変更した場合に、同じクライアントキャッシュが使い回されてしまう。これを防ぐためには `Vary` ヘッダーに `User-Agent` を追加する必要があるが、前述の通りキャッシュの意味がなくなる。

（途中で User-Agent を変更するのは開発時くらいだから無視してもいいかもしれないが、モバイルブラウザの「PC版サイトをリクエスト」とかも影響あるかも）

# 備考

- Apache のその他の設定や、WordPress の挙動、テーマやプラグインによっては、独自にレスポンスヘッダーを追加してることもあるので気をつける。
- mod_cache は細かい制御が難しいので、リアルタイム性が求められるコンテンツやユーザーによって変化するコンテンツが含まれている場合はしんどいかも。

# 参考

- https://httpd.apache.org/docs/2.4/en/caching.html
- https://httpd.apache.org/docs/2.4/en/mod/mod_cache.html
- [Apacheのキャッシュ(mod_cache、mod_disk_cache)＋WordPressの注意点](https://server-setting.info/centos/apache-mod_cache-wordpress.html)