WordPressを爆速にするnginxのproxyキャッシュを冗長化しても共通で利用できるようにするアーキテクチャ(OpenResty+Redis)のご紹介 | SEEDS Creators' Blog | 株式会社シーズ

WordPressを爆速にするnginxのproxyキャッシュを冗長化しても共通で利用できるようにするアーキテクチャ(OpenResty+Redis)のご紹介

クラウド事業部の原口です。

nginxは主にリバースプロキシとして使う事が多いと思いますが、バックエンドのプログラム側でのキャッシュ実装が難しい場合などはnginx側でキャッシュしてしまうと、お手軽にインフラのみでパフォーマンスを上げる事ができます。
よく聞くのは「wordpressをプロキシキャッシュで爆速にする!」といった内容ですが、これは本当に早くなります。

キャッシュを使う事で高速になるばかりか、wordpressへアクセスが行かない事で当然データベースなどの検索も走らなくなりサーバー負荷も軽減されます。
もちろん記事を更新してもページが更新されない!などの問題へのハンドリングが必要ですが、wordpress + nginxの実装は広く行われている為、少し検索するとnginxプロキシキャッシュのベストプラクティスが見つかります。そういった情報量の多さからも負荷が高いwordpressサイトへの実装としてはかなりよい手段となります。

冗長化したい

さて、そんな最高のnginxプロキシキャッシュですが、nginxのプロキシキャッシュは「ファイル」でしか保存できない為、サーバー自体の冗長化を行った際に共通で利用できません。これは2台に冗長化すると単純計算でキャッシュヒット率が下がるという事で、WEBサーバーが3台…4台…と増えていくと、よりキャッシュヒット率が減少する事となります。またpurge(キャッシュの破棄)のタイミングも同期的に行う必要もあり、そもそも難易度の高いwordpressの冗長化にさらにものすごく面倒な問題が発生してしまう事となります。
以下のようにAWSであればElastic Load Barancingを利用して冗長化した場合に問題となります。

nginxプロキシキャッシュをredisに保存

nginxはプロキシキャッシュをファイルにしか出力できませんが、これをなんとかredisに保存するようにする事でWEBサーバー郡から共通で利用できるキャッシュストアとする事ができます。
キャッシュが一箇所となる事で、もし問題がおきてもflashする事でキャッシュを全削除できたり、redisの強力なキャッシュ操作でキャッシュのハンドリングも容易になります。
AWSであればAmazon ElastiCache (Redis)を利用する事でマネージドなキャッシュストアを利用できます

OpenRestyとは?

OpenRestyは、nginxにLuaJITとその他いろいろなサードパーティ製モジュールを予め拡張された状態で提供されているOSSです。
https://openresty.org/en/

結局の所はnginxなのですが、例えば今回のようにRedisに接続するなどのモジュールはOpenRestyでは予め使える状態である為こちらを利用していきます。

OpenRestyでnginxプロキシキャッシュをredisに保存する実装例

インストール

yum-config-manager --add-repo https://openresty.org/package/amazon/openresty.repo
yum install -y openresty

設定(全体)

worker_processes 8;
user nginx nginx;

events {
    worker_connections  10000;
}

http {
    sendfile      on;
    include       mime.types;
    default_type  application/octet-stream;
    resolver [DNSサーバーのIP];

    upstream app {
        server [バックエンドサーバの設定];
        keepalive 1000000;
    }

    upstream redis_backend {
        server [redisサーバのエンドポイント]:6379;
        keepalive 1000000;
    }

    server {
        listen 80;

        set $do_not_cache 0;
        set $do_purge 0;

        if ($request_method = POST) {
            set $do_not_cache 1;
            set $do_purge 1;
        }

        if ($request_uri = "/wp-admin/admin-ajax.php") {
            set $do_not_cache 1;
            set $do_purge 0;
        }

        if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
             set $do_not_cache 1;
        }

        if ($request_uri ~* "(/wp-admin/|/xmlrpc.php|/wp-(app|cron|login|register|mail).php|wp-.*.php|/feed/|index.php|wp-comments-popup.php|wp-links-opml.php|wp-locations.php|sitemap(_index)?.xml|[a-z0-9_-]+-sitemap([0-9]+)?.xml)") {
            set $do_not_cache 1;
        }

        if ($request_uri !~* "(\.php.*|\.css.*|\.js.*|\/[^\.]*)$") {
            set $do_not_cache 1;
        }

        if ($http_cookie ~ ^.*(wordpress_logged_in).*$) {
            set $do_not_cache 1;
        }

        #などなどキャッシュしない設定条件やキャッシュをpurgeする条件をここに書いていく

        proxy_redirect   off;

        proxy_set_header Host                   $host;
        proxy_set_header X-Real-IP              $remote_addr;
        proxy_set_header X-Forwarded-Host       $host;
        proxy_set_header X-Forwarded-Server     $host;
        proxy_set_header X-Forwarded-Proto      $scheme;
        proxy_set_header Accept-Encoding        "";

        proxy_ignore_headers Cache-Control;
        proxy_ignore_headers Set-Cookie;
        proxy_ignore_headers X-Accel-Expires;
        proxy_ignore_headers Expires;

        location = /redis_fetch {
            internal;
            set_md5 $key $args;
            set $redis_key $host:$key;
            redis_pass redis_backend;
        }

        location = /redis_store {
            internal;
            set_unescape_uri $exptime $arg_exptime;
            set_unescape_uri $key $arg_key;
            set_md5 $key;
            redis2_query set $host:$key $echo_request_body;
            redis2_query expire $host:$key $exptime;
            redis2_pass redis_backend;
        }

        location / {
            access_by_lua_block {
                ngx.log(ngx.INFO, "access_by_lua_block start.")
                if (ngx.var.do_purge == "0") then
                    return
                end

                local redis = require "resty.redis"
                local red = redis:new()
                local ok, err = red:connect("[redisサーバのエンドポイント]", 6379)
                if not ok then
                    ngx.log(ngx.ALERT, "failed to connect: ", err)
                    return
                end

                res, err = red:keys(ngx.var.host..":*")
                if not res then
                    ngx.log(ngx.ALERT, "failed to keys: ", err)
                    return
                end

                for i, r in pairs(res) do
                    ngx.log(ngx.INFO, r)
                    red:del(r)
                end

                red:close()
            }

            set $key $uri$is_args$args;
            set_escape_uri $escaped_key $key;

            srcache_store_skip $do_not_cache;
            srcache_fetch_skip $do_not_cache;

            srcache_fetch GET /redis_fetch $key;
            srcache_store PUT /redis_store key=$escaped_key&exptime=604800;

            add_header X-Openresty-Cache $srcache_fetch_status;
            add_header X-Openresty-Cache-STORE $srcache_store_status;

            if ($do_not_cache = 0) {
                more_set_headers 'Cache-Control: public, no-transform, max-age=604800';
            }

            proxy_pass http://app;
        }

        location /cache_purge {
            default_type  text/plain;
            content_by_lua_block {
                local redis = require "resty.redis"
                local red = redis:new()
                local ok, err = red:connect("[redisサーバのエンドポイント]", 6379)
                if not ok then
                    ngx.log(ngx.ALERT, "failed to connect: ", err)
                    return
                end

                res, err = red:keys(ngx.var.host..":*")
                if not res then
                    ngx.log(ngx.ALERT, "failed to keys: ", err)
                    return
                end

                for i, r in pairs(res) do
                    ngx.log(ngx.INFO, r)
                    red:del(r)
                end

                red:close()

                ngx.say("cache purged. host: ", ngx.var.host)
            }
        }
   }
}

簡単な解説やハマったポイント

基本的には公式のreadmeの
https://github.com/openresty/srcache-nginx-module#caching-with-redis
を確認する事で動作が可能でした。
これに加えて上記の実装では
「特定の条件の時はキャッシュしない(BYPASS)」
「POSTメソッドがきたらキャッシュをすべてpurgeする」
「/cache_purge に接続すると接続先hostに対するキャッシュを削除する」
などの実装を行っています。(カワカツ先生に実装いただきました!thx!!)

開発中は

add_header X-Openresty-Cache $srcache_fetch_status;
add_header X-Openresty-Cache-STORE $srcache_store_status;

といったヘッダをつける事でgoogle chromeのDevToolsなどでヘッダにてキャッシュから返したか、キャッシュされたか、などのステータスを確認しながら実装できます。

その中で、いくつかハマったポイントがあるのでご紹介します。

1.no resolver defined to resolveエラーが発生する
nginxへの接続はAmazon ElastiCacheを利用していた為、エンドポイントがホスト名だったのですがタイトルのエラーが発生しました。nginxのproxy_passなどの接続先をホスト名とする場合は
resolver [DNSサーバーのIP];
といった形でDNSサーバーを記述する必要があります。
AWSでVPCを利用している場合はDNSサーバは必ず xxx.xxx.xxx.2 となりますのでこれを指定します。
(参考) https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/VPC_DHCP_Options.html#AmazonDNS

2.gzipしてるとどうやらキャッシュされない
キャッシュをする上でgzip圧縮されているコンテンツだった場合はキャッシュがうまくいかないようでした。

proxy_set_header Accept-Encoding        "";

と設定する事でキャッシュされました

3. バックエンドからのヘッダにexpireやmax-ageなどがあるとキャッシュされない

proxy_ignore_headers Cache-Control;
proxy_ignore_headers Set-Cookie;
proxy_ignore_headers X-Accel-Expires;
proxy_ignore_headers Expires;

nginx側でヘッダーを無視する事で回避できました

さいごに

そもそもこういった要件であればamazon cloudfrontでいいのでは、とか、wordpressでもない限り、普通にアプリでredisキャッシュ実装するわ…といった事がほとんどではないかと思うので、なかなかnginxで大味にプロキシキャッシュ!という事はないのかもしれませんが、この構成はかなりお気に入りですので今後もこのようなニーズが来た際はOpenRestyをフル活用したいと思います。
キャッシュだけじゃなくluaでnginxの挙動を変えれるのは本当に最高です。

それでは。