ngx_mruby

ngx_mrubyで動的コンテンツキャッシュを実現する

ngx_mruby

はじめに

冬が近づき、寒くなると僕を思い出しますね?どうも、ホスティング事業部の@pyama86です。

これまで開発者(@matsumotory)が身近にいながら、なかなか触る機会がなかったngx_mrubyを触ってみました。題材として我々が提供しているサーバホスティングにおいて、HTTPリクエストに対して、いかに早くレスポンスを返せるかということは、サービスの指標として非常に大事な要素の一つです。今回はその点において劇的な効果が見込めるコンテンツキャッシュについて、動的にキャッシュの利用URLを選択できる仕組みを実装しました。この記事ではその実装及びベンチマークについて扱いたいと思います。

ngx_mrubyとは

ngx_mrubyとは弊社ペパボ研究所に所属する松本 亮介(@matsumotory)によって開発されているOSSです。nginxに組み込むことで、nginxの設定ファイルや、nginxの各イベントにフックしてmrubyを実行し、nginxの動作をプラガブルに扱うことができます。

同じようにApache httpdをmrubyでプラガブルに扱うことができるmod_mrubyも氏のプロダクトであり、同一のmrubyの資産を利用し、nginx、Apache httpdで同様の動作を実現できるのも強みの一つです。

ngx_mrubyを利用した動的コンテンツキャッシュの概要

nginxではモジュールを追加することにより、機能を拡張できます。デフォルトでngx_http_proxy_moduleが組み込まれているので、設定を記述するだけでコンテンツキャッシュを利用できます。

02.svg

コンテンツキャッシュを利用すると上記の図のように、1回目のリクエストに対するオリジンサーバのレスポンスをnginxがキャッシュし、2回目のリクエストはオリジンサーバにリクエストすることなくnginxがキャッシュから直接応答することで、オリジンサーバの処理時間を削減して、高速にレスポンスを返すことができます。

この仕組みをそのままサーバホスティングで利用しようとした場合、数百万件のVirtualHostと、それに紐づくファイルをnginxの設定ファイルに全て定義すると、設定ファイルが膨大なサイズとなり、さらにキャッシュの対象URLを変更するたびにnginxの設定ファイルの変更、再読込が必要になるなど、システムとして非常に扱いづらい状態になることが想定されます。

そういった問題に対してngx_mrubyを用いて、下記のようなアーキテクチャで解決できます。

03.svg

満たすべき要件

前項のアーキテクチャを利用してサービスとしてお客様に提供することを考慮すると、次の要件を満たす必要があります。

  1. お客様の指定した任意のURLをキャッシュできる
  2. お客様の指定した任意のURLのキャッシュを削除する事ができる

コンテンツキャッシュは画像や、htmlファイルなどの静的なコンテンツをキャッシュすることが主な用途と想定されます。対して動的なコンテンツはアクセスごとに内容が変化することから、キャッシュを必要としないケースが多いと考えられ、そういった運用の幅を広げるために任意のURLを指定できる仕組みが必要です。また、サイトのトップ画面となるindex.htmlなど更新した内容を即時反映したいケースもあるので、キャッシュの削除も機能として必要であると考えました。加えて、画像やcss、jsファイルなどはワイルドカードのように指定できたほうが用途に即しているので正規表現で対応しています。

こういった前提を踏まえて、作成した設定ファイルを見てみましょう。

ngx_mrubyを利用してnginxの設定ファイルを定義する

  • nginx.conf
http {
        # キャッシュヒットの有無をログに出力する
        log_format rt_cache '$remote_addr - $upstream_cache_status [$time_local]  '
        '"$host" "$request" $status $body_bytes_sent '
        '"$http_referer" "$http_user_agent"';

        access_log /var/log/cache.log rt_cache;

        mruby_init_code '
            Userdata.new("conn").redis = Redis.new("127.0.0.1", 6379)
            Userdata.new("conn").mysql = MySQL::Database.new("127.0.0.1", "root", "", "cache")
        ';

        upstream backend {
                server 127.0.0.1:8080;
        }

        proxy_cache_path /var/cache/nginx/cache levels=1 keys_zone=zone1:100m inactive=1d max_size=10m;

        server {
                listen       80;
                server_name  localhost;
                location / {
                        proxy_cache zone1;
                        mruby_set_code $do_not_cache '
                                r = nginx::Request.new
                                conn = Userdata.new("conn")
                                cache_status = conn.redis.get(r.var.host + r.var.uri)

                                unless cache_status
                                        uris = conn.mysql.execute("SELECT uri FROM cache WHERE host = ?",r.var.host)
                                        cache_status = "1"
                                        while u = uris.next
                                                if u =~ r.var.uri
                                                        cache_status = "0"
                                                        break
                                                end
                                        end
                                        uris.close
                                        conn.redis.set(r.var.host + r.var.uri, cache_status)
                                end
                                cache_status
                        ';

                        proxy_cache_bypass $do_not_cache;
                        proxy_no_cache $do_not_cache;
                        proxy_cache_revalidate on;
                        proxy_pass http://backend;
                        proxy_cache_key "$scheme://$host$request_uri";
                        proxy_cache_valid 200 301 302 1d;
                        proxy_cache_valid 404 1m;
                        proxy_cache_valid 500 5s;
                }

                location ~ /purge(/.*) {
                        allow 127.0.0.1;
                        deny all;
                        proxy_cache_purge zone1 "$scheme://$host$1$is_args$args";
                }
        }
}

いくつかのブロックに分けて、ポイントを解説していきたいと思います。

Redis、MySQLのコネクションをUserdataに保持する

mruby_init_code内で、Redis、MySQLへ接続を行い、mruby-userdataへ保存しておきます。user-dataに保存することで、nginx起動後、リクエストごとにRedis、MySQLに接続することなくコネクションを使いまわすことができるようになるので、接続処理の重複を省く事ができ、処理時間を短縮できます。

mruby_init_code '
    Userdata.new("conn").redis = Redis.new("127.0.0.1", 6379)
    Userdata.new("conn").mysql = MySQL::Database.new("127.0.0.1", "root", "", "cache")
';

引用:人間とウェブの未来 [mrb_stateを共有しているRubyコード間でuserdataを読み書きできるmruby-userdata作った] mruby_init_codeディレクティブはnginx.confのhttp{}セクションに設定を書くことができ、nginx.conf読込時にRubyスクリプトを実行できる機能です。これは以前から実装済みだったのですが、masterプロセス初期化時に実行されるので、そのタイミングでの実行を代替することができます。

MySQLからキャッシュ対象のURLを取得する

mruby_set_code $do_not_cache '
        r = nginx::Request.new
        conn = Userdata.new("conn")
        cache_status = conn.redis.get(r.var.host + r.var.uri)

        unless cache_status
                uris = conn.mysql.execute("SELECT uri FROM cache WHERE host = ?",r.var.host)
                cache_status = "1"
                while u = uris.next
                        if u =~ r.var.uri
                                cache_status = "0"
                                break
                        end
                end
                uris.close
                conn.redis.set(r.var.host + r.var.uri, cache_status)
        end
        cache_status
';
proxy_cache_bypass $do_not_cache;
proxy_no_cache $do_not_cache;

nginxのキャッシュの利用を制御するproxy_cache_bypass、キャッシュの保存を制御するproxy_no_cache$do_not_cacheという変数で制御しています。

$do_not_cacheの内容については、mruby_set_codeを利用すると、第2引数に記述したRubyコードの戻り値を、第1引数の変数にセットできるので、MySQLからHTTPヘッダのHostをキーにcacheテーブルを検索した結果とリクエストURIを正規表現を用いて比較し、合致した場合にコンテンツキャッシュを保存、レスポンスに利用するよう実装しています。MySQLに登録されるレコードは以下のようなレコードになるでしょう。

  1. .+(jpg|png|gif)$
  2. .+\.html$
  3. .+intdex.php$

なお、MySQLにリクエストのたびにクエリを実行すると、応答待ちの時間分レスポンスが遅くなるので、Redisにキャッシュ可否を保存し、次回からはレスポンス時間を短縮できるようにしています。

キャッシュの削除を可能にする

nginxではbuild時にngx_cache_purgeモジュールを追加することで、proxy_cache_purgeディレクティブを利用してキャッシュの削除に関する定義を行えます。

location ~ /purge(/.*) {
        allow 127.0.0.1;
        deny all;
        proxy_cache_purge zone1 "$scheme://$host$1$is_args$args";
}

上記の定義を行い、localhostから以下のようにURLアクセスすることで、キャッシュを削除できます。

# curl -i http://localhost/purge/index.html
HTTP/1.1 200 OK
Server: nginx/1.11.6
Date: Tue, 29 Nov 2016 09:50:24
Content-Type: text/html
Content-Length: 285
Connection: keep-alive

01.png

今回は検証の便宜上、localhostからのアクセスとしましたが、実際にサービスに実装する場合は、APIサーバ経由などでキャッシュを削除できるようにすると、悪意を持った削除を防ぎつつ、任意のキャッシュを削除できます。

ベンチマーク

気になる性能評価について、今回は4パターンのベンチマークをApache Benchを利用して取得しました。

$ ab -c100 -n10000  http://localhost/

ハードウェアについてはMacBook Pro (Retina, 15-inch, Mid 2015)上で起動したVirtualBoxのコア1、メモリ512MBのVMを使用しました。

  ngx_mruby有 ngx_mruby無
proxy_cache有 10042rps 13573rps
proxy_cache無 4004rps 5343rps

ngx_mrubyを利用した場合、利用しない場合と比較して、30%未満ほどの性能劣化が見られる反面、キャッシュの有無の比較においては、キャッシュヒット率100%の環境で、4004rpsから10042rpsと倍以上のパフォーマンスが得られました。

プロキシサーバの性能劣化については、オリジンサーバが動的コンテンツを扱う場合、プロキシサーバの処理時間と比べて、動的コンテンツの処理時間が長時間となるケースが多いことから、全体の処理時間から見ると30%程度のプロキシサーバの性能劣化であれば誤差といえます。さらには動的キャッシュの利用でオリジンサーバのリソースを節約できるメリットを踏まえると十分有益であると結論付けられます。

まとめ

ngx_mrubyを利用することによって、今回紹介したようにオリジンサーバの前段のプロキシサーバで、L7の処理が可能になり、これまでのアーキテクチャで実現できなかったことがシンプルにできるようになりました。特に動的な処理をプロキシサーバで扱えるようになるとアーキテクチャの選択の幅が広がり、エンジニアとして非常に好奇心を掻き立てられます。例えばApache httpdの.htaccessの機能を利用して、複雑な定義を行っている場合なども、mrubyを利用してシンプルな定義に置き換えるなど手軽に始められると思います。

GMOペパボではロリポップ!レンタルサーバにおける次世代ホスティングサービスを始め、既に多くのサービスでngx_mrubyを採用しており、弊社のエンジニアである@buty4649による100行あったmod_rewriteをngx_mrubyで書き換えた話@hfmによる動的証明証読み込み ngx_mruby編を見ていただくと、ホスティングに限らず、様々な用途で活用できることがご確認できるかと思います。

最後に、今後も利用実績を随時アウトプットしていくので、イベントなどぜひ足を運んでください。直近では東京、大阪にて「GMOペパボ ホスティング技術カンファレンス ~破壊的イノベーションをおこす革新的技術~」を計画しております。下記のURLからお申込みいただき、ぜひ会場でお会いしましょう!