WordPressサイトのチューニング (CentOS 6)

# はじめに
お客:  「静的ページでメンテナンス大変なんだよねぇ」
業者:  「WordPressにしちゃいましょう!!」
(...)
お客:  「高負荷時にページ見れないんだけど…」
業者:  「高負荷時にだけ静的ページに手動で入れ替えましょう!! (nginxとか知らない)」
あなた: 「高負荷になり得るサーバで，何でWordPressとか重いのを入れちゃったの???」

こんなお客の同僚のあなたに朗報です．nginxの設定方法を少しこだわってみました．

# 概要
- アクセスが多い (数十〜1万リクエスト/秒) ページでWordPress
- OSもパッケージのバージョンが古いCentOS 6
- 運用面からyumで導入可能なパッケージのみで構成
- 運用面からバックエンドでWordPressはapache + mod_phpで構成
- フロントエンドにnginxを置きキャッシュ
    - WordPress Super Cache(数倍程度)やopcache(数倍程度)，MySQL(0-20%未満)のチューニングよりも効果的
- CPU 1コアの仮想マシン1台でも数千〜1万リクエスト/秒程度の処理性能を実現可能
    - 筆者の環境では：
        - チューニング前: 0.47リクエスト/秒
        - チューニング後: 8137.53リクエスト/秒 (17313.89倍!!!)

# 注意
- 著者はウェブは専門外です．

# 背景
- CMSとして圧倒的なシェアを誇る(とされている)WordPress
- WordPressを提案してくるWeb屋さんは多い
    - WordPressはPHPによる動的ページ
    - PHPは圧倒的に遅く，メモリを大量に必要とする
        - せいぜいCPU1コア当たり数リクエスト/秒
            - 静的ページであればCPU1コア当たり数千リクエスト/秒
        - 静的ページと比較すると1コア当たり数千倍単位で遅い
        - WordPress Super Cacheは焼け石に水
            - apache (prefork) の場合，httpdが大量にメモリを消費する時点で負け
        - オペコードやデータベースのキャッシュは焼け石に水
            - 実施した方が良いことは良い
- アクセスが多いページにはWordPressは不向き
    - 静的なページを生成する様なWAFやアプリケーションを利用すべき
- それでもWordPressを導入する人はいる
- apache + mod_phpはメンテナンスできるっていう人はいる
    - けどnginxはできないっていう…
    - apache+mod_phpはウェブ屋さんが，nginxはインフラ屋さんが担当する，っていうケースもアリ? (個人的にはウェブ屋さんはどちらもできないと駄目なんじゃ…?)
- Cent OS 6だとapacheは未だにpreforkがデフォルト
- nginxはnginx開発元によりyumによる管理を可能とするRPMが配布されている

# 構成と前提条件
- OS: CentOS 6
- メモリ: 2GB
- CPU: 1コア
- パッケージ管理: yum
- バックエンド: apache + mod_php (通常の基本的な設定は既に実施済み)
- フロントエンド: nginx
- アクセス数: 高負荷時に数十〜1万リクエスト/秒以上
- 利用している機能: 予約投稿
- キャッシュの有効期限(動的ページ): 1分 (WordPressの設定変更無しで予約投稿を実現するためだけ…)
- キャッシュの有効期限(静的ファイル): 1週間
- WordPress， apache， PHP，MySQLの修正は加えない，もしくは，必要最低限に留める

# apache
## 現状の把握
現状でどの程度の処理速度であるか``ab (Apache Benchmark)``で計測しておく．
特に，``Requests per second:``の値を確認しておく．
可能であれば，別の物理マシンで計測する方が良い．
おそらく，0.5〜9リクエスト/秒程度の処理速度しか得られない．
-cと-nはサーバの処理能力の限界をちょっと超える程度の値を指定した方が正確な結果を得やすい．

```
$ ab -c 1000 -n 10000 -s 3600 http://localhost/
```
- -c: 1度に並行して接続する数
    - なかなか終わらなければ，100， 10と減らしていく．多分に10でも厳しい．
- -n: 総試行回数
    - なかなか終わらなければ，1000， 100，10と減らしていく．多分に100でも厳しい．
- -s: レスポンスタイムアウト(秒) (デフォルト: 30秒)
    - タイムアウトしてしまう場合があるので長めにしておく．

## 計測結果例
後述の環境では，0.47リクエスト/秒(``Requests per second``)の性能しかない．

```
$ ab -k -c 100 -n 100 -s 3600 http://hogehoge/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking hogehoge (be patient).....done


Server Software:        Apache
Server Hostname:        hogehoge
Server Port:            80

Document Path:          /
Document Length:        31451 bytes

Concurrency Level:      100
Time taken for tests:   214.958 seconds
Complete requests:      100
Failed requests:        0
Write errors:           0
Keep-Alive requests:    0
Total transferred:      3166800 bytes
HTML transferred:       3145100 bytes
Requests per second:    0.47 [#/sec] (mean)
Time per request:       214958.318 [ms] (mean)
Time per request:       2149.583 [ms] (mean, across all concurrent requests)
Transfer rate:          14.39 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    4   1.3      4       7
Processing:  6344 182384 46966.2 205816  209095
Waiting:     5854 83952 18922.2  91713   98939
Total:       6345 182388 46965.4 205819  209098

Percentage of the requests served within a certain time (ms)
  50%  205819
  66%  207392
  75%  207631
  80%  207911
  90%  208271
  95%  208737
  98%  209024
  99%  209098
 100%  209098 (longest request)
```

##Listenポートの80から8080への変更
```
-Listen 12.34.56.78:80
+Listen 127.0.0.1:8080
```
##prefork.cの設定変更
```
<IfModule prefork.c>
-        StartServers       8
-        MinSpareServers    5
-        MaxSpareServers   20
-        ServerLimit      256
-        MaxClients       256
-        MaxRequestsPerChild  4000
+        StartServers        20
+        MinSpareServers     20
+        MaxSpareServers     20
+        ServerLimit         20
+        MaxClients          20
+        MaxRequestsPerChild 500
+        MaxMemFree          1024
 </IfModule>

```
- StartServers，MinSpareServers，MaxSpareServers，ServerLimit，MaxClients
    - クライアントのリクエストを処理するhttpdの子プロセスの数を指定
    - メモリ容量やコア数，フロントエンド(nginx)の数，アクセス状況に応じて調整
        - スワップアウトしない値に調整
        - WordPressでは1つのhttpdが50MB〜100MB以上のメモリを消費する
            - topコマンドで表示されるRESの値を確認
        - 数千リクエスト/秒のアクセスであってもhttpdにアクセスする数は少ない
        - メモリがあるからといって大きくしすぎてもコンテキストスイッチやTLBミスによる遅延が大きくなるだけ
    - 高負荷時のforkを防止し負荷を削減するために全て同じにする
        - ここの設定では常に20個のhttpdが常駐することとなる
- MaxRequestsPerChild
    - メモリ消費を抑えるための仕組み
    - 指定された数のリクエストを受信した子プロセスを終了させ，新しい子プロセスを生成
        - 終了したプロセスが消費していたメモリがOSに返却される
- MaxMemFree
    - メモリ消費を抑えるための仕組み
    - 単位はKB
    - 指定した(ここでは1MB)以上の空きメモリができたら可能であればOSへメモリを返却する
    - デフォルト(つまり制限無し)では，OSへ空きメモリが返却されない

##VirtualHostの設定を以下に変更
```
-NameVirtualHost *:80
+NameVirtualHost *:8080
(snip)
-<VirtualHost *:80>
+<VirtualHost *:8080>
        ServerName   hogehoge.com
        ServerAdmin  root@hogehoge.com
        DocumentRoot "/var/www/html/hogehoge/"
 </VirtualHost>
```

# nginx
## nginxのyumリポジトリの追加
CentOS 6や7ではnginxがyumの標準リポジトリには存在しない．
そこで，yumで管理して運用性を向上するため，nginxの開発元のyumリポジトリを追加する．
以下の作業でnginxのyumリポジトリが有効となる．
ただし，nginxの最新バージョンではないので，設定項目に若干の違いがあり得ることに注意する．

```
$ sudo rpm -ivh http://nginx.org/packages/centos/6/noarch/RPMS/nginx-release-centos-6-0.el6.ngx.noarch.rpm
```

## nginxのインストール
```
$ sudo yum -y install nginx
```
## OS起動時のnginxの起動の有効化
```
$ sudo /sbin/chkconfig nginx on
```

## /etc/nginx/nginx.confの設定
インストールによって最低限の設定がされているが，以下の様に修正する．

```
user  nginx;
# rely on nginx auto detection, and its detection works well.
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

# note that multiple file descriptors are used per connection, and this
# value must be more than 2-4 times of ``worker_connections.''
# also note that this value is related to fs.file-max (/proc/sys/fs/file-max).
worker_rlimit_nofile 16384;

events {
    # note that the maximum number of file descriptors is limited to
    # net.core.somaxconn by default, and nginx automatically changes
    # this limitation to ``worker_connections'' if configured.
    worker_connections  4096;

    # enable multi_accept but take care because we have only one core and
    # multi_accept may be weak agains DoS attack.
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    server_tokens off;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" '
                      '"$request_time" "$upstream_response_time"';

    access_log  /var/log/nginx/access.log  main;

    include /etc/nginx/conf.d/*.conf;
}
```
## /etc/nginx/admin.confの作成
管理ページ用の共通設定を作成する．

```
allow 10.0.0.0/8;       # allow accesses from administrators' segment.
deny all;

proxy_no_cache 1;
proxy_cache_bypass 1;

# do not buffer client requests administrators' pages.
proxy_request_buffering off;

proxy_pass http://backend;
```

## /etc/nginx/conf.d/defaultの設定
一部，nginx.confに記述した方が良いものもあるが，見易さのためここでは，conf.d/defaultで記述する．

```
# allow large file uploads.
client_max_body_size  32m;
# extend buffer in order to avoid warnings on posts.
client_body_buffer_size 64k;
# use tmpfs for client request body.
client_body_temp_path /dev/shm/nginx_client_temp 1 2;

# path for cache.
proxy_cache_path /var/www/nginx_cache levels=1:2 keys_zone=czone:8m
                 max_size=256m inactive=7d use_temp_path=off;
# path for temporary files.
proxy_temp_path  /dev/shm/nginx_tmp;
# allow large file downloads.
proxy_max_temp_file_size 1024M;
# set an identifier key for URL.
proxy_cache_key "$scheme://$host$request_uri$is_args$args";

upstream backend {
    # currently, we have only one backend, this is then meaningless.
    #ip_hash;

    # multiple requests per connection using HTTP KeepAlive improves
    # performance.
    keepalive 360;

    server 127.0.0.1:8080;
}

server {
    listen       80;
    server_name  www.hogehoge.com;

    #charset koi8-r;
    #access_log  /var/log/nginx/log/host.access.log  main;

    #error_page  404              /404.html;

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}

    sendfile              on;
    sendfile_max_chunk    256k;
    tcp_nopush            on;
    # this is not real-time application, and performance first.
    tcp_nodelay           off;
    # use TCP RST, not FIN, in case of timeout.  this avoids a socket
    # to linger FIN_WAIT1 state for long time and immediately free buffers
    # for a socket.
    reset_timedout_connection on;

    # HTTP KeepAlive timeout
    keepalive_timeout     10;
    # wait for client to send header, body, request.
    client_header_timeout 10;
    client_body_timeout   10;
    send_timeout          10;

    # enable gzip compression.
    gzip                  on;
    # reuse a compressed file if it is already compressed once.
    gzip_static           on;
    # gunzip for a client that does not support gzip when a gzip cache exists.
    gunzip                on;
    # use defacto standard version.
    gzip_http_version     1.0;
    # add Vary header.
    gzip_vary             on;
    # compression level from 1 to 9 (9 is the maximum).
    gzip_comp_level       9;
    # do not gzip when file size is less than this value.
    gzip_min_length       1024;
    # files that will be gzip-ed.
    gzip_types            text/plain text/xml text/css text/javascript
                          application/xhtml+xml application/xml
                          application/rss+xml application/atom_xml
                          application/javascript application/x-javascript
                          application/x-httpd-php;
    # old MS IE does not support gzip.
    gzip_disable          "MSIE [1-6]\.";
    # apply zip on proxy as well.
    gzip_proxied          any;

    # these configurations avoid many logs of:
    #    ``an upstream response is buffered to a temporary file.''
    # prepare 16 buffers whose size is 64k per connection to backend.
    proxy_buffers 16 64k;
    # buffer to store the first part of response from backend.
    proxy_buffer_size 64k;
    # buffers for busy sending to a client and for reading from backend.
    proxy_busy_buffers_size 128k;

    # use HTTP 1.1 for backend HTTP KeepAlive.
    proxy_http_version 1.1;
    # pass ``Host'' to backend for a virtual host.
    proxy_set_header Host $host;
    # pass more information to backend.
    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-For $proxy_add_x_forwarded_for;
    # do not allow end user to send ``Connection,'' in HTTP header.
    proxy_set_header Connection "";
    # do not compress communications with backend.
    proxy_set_header Accept-Encoding "";
    # do not expose headers to end users because these headers are for backend.
    proxy_hide_header X-Pingback;
    proxy_hide_header X-Powered-By;
    proxy_hide_header Etag;
    proxy_hide_header Vary;

    # specify cache zone by name.
    proxy_cache czone;
    # specify cache key.  this might be changed for a dynamic page.
    proxy_cache_key "$scheme://$host$request_uri$is_args$args";
    # hold caches specified time for HTTP response codes.
    proxy_cache_valid 200 301 302 1m;
    # hold negative caches as well but relatively shorter.
    proxy_cache_valid 404 1m;
    # hold caches for other HTTP response.
    proxy_cache_valid any 1m;
    # send only one request for the same page, i.e., do not forward all
    # requests, to backend when the requested page is first accessed and
    # not cached yet.
    proxy_cache_lock on;
    proxy_cache_lock_age 5s;
    proxy_cache_lock_timeout 5s;
    # use stale cache on errors and cache updates.
    proxy_cache_use_stale error timeout invalid_header updating
                          http_500 http_502 http_503 http_504;
    # enable background cache update.
    # XXX: supported from nginx 1.11.10.
    #proxy_cache_background_update on;

    location ^~ /wp-admin       { include admin.conf; }
    location ^~ /wp-login.php   { include admin.conf; }
    # do not cache a form page that uses token.
    location ^~ /form-example {
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        proxy_pass http://backend;
    }
    location / {
        # do not cache when an administrator is logged in.
        set $nocache 0;
        set $cache_bypass 0;
        if ($http_cookie ~ ^.*(wordpress_logged_in).*$) {
            set $nocache 1;
            set $cache_bypass 1;
        }
        proxy_no_cache $nocache;
        proxy_cache_bypass $cache_bypass;
        proxy_pass http://backend;
        # ignore cache control header.
        proxy_ignore_headers Cache-Control;
        proxy_ignore_headers Set-Cookie;
        proxy_ignore_headers X-Accel-Expires;
        proxy_ignore_headers Expires;
    }
    # cache static files longer.
    location ~* .*\.(?:jpg|jpeg|gif|png|svg|ico|css|js|htm|html|pdf|ttf|eot)(\?[^/]+)?$ {
        proxy_pass http://backend;
        # ignore cache control header.
        proxy_ignore_headers Cache-Control;
        proxy_ignore_headers Set-Cookie;
        proxy_ignore_headers X-Accel-Expires;
        proxy_ignore_headers Expires;
        # caches longer than dynamic pages.
        proxy_cache_valid 200 301 302 7d;
    }
}
```

## nginxの設定ファイルの確認
設定ファイルの記述が正しいか確認する．
誤りがある場合には``/var/log/nginx/error.log``に詳細が出力される．

```
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```

## nginxの起動

```
$ sudo /sbin/service nginx start
```

## 処理性能の確認
``ab``で性能を確認しておく．

```
$ ab -c 1000 -n 10000 -s 3600 http://localhost/
```

性能の良いPCであれば1万リクエスト/秒以上 (``Requests per second:``参照) の処理速度を得られる (はず)．
それ程性能が良くない仮想マシンでも5000リクエスト/秒以上の処理速度を得られる．

なお，ソケットを開けない旨のエラーメッセージが出力されたら，プロセスが開けるファイル数の限界を``ulimit -S``の出力で確認する．
小さい様であれば，``ulimit -H``で表示されるhard limitを確認する．
そして，``/etc/security/limits.d/``以下にファイルを追加してnofileをhard limit限界まで増やす．

## 計測結果例
1秒間に処理できるリクエストの数(``Requests per second``)が0.47から8137.53の実に17313.89倍に!!

```
$ ab -k -c 4000 -n 100000 -s 3600 http://hogehoge/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking hogehoge (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:        nginx
Server Hostname:        hogehoge
Server Port:            80

Document Path:          /
Document Length:        30251 bytes

Concurrency Level:      4000
Time taken for tests:   12.289 seconds
Complete requests:      100000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    0
Total transferred:      3068500000 bytes
HTML transferred:       3025100000 bytes
Requests per second:    8137.53 [#/sec] (mean)
Time per request:       491.550 [ms] (mean)
Time per request:       0.123 [ms] (mean, across all concurrent requests)
Transfer rate:          243847.63 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0  301 737.6      2    7032
Processing:    29   85 138.4     49    3244
Waiting:        1   52 112.4     28    3229
Total:         31  386 759.5     53    7871

Percentage of the requests served within a certain time (ms)
  50%     53
  66%     58
  75%    251
  80%   1044
  90%   1057
  95%   1261
  98%   3058
  99%   3066
 100%   7871 (longest request)
```

# 今後の課題
- ``X-Accel-Expires``を用いたキャッシュのクリア
    - WordPressにnginxとの連携のためのプラグインがある．
- WordPress cronの停止とOSのcronへの移行
- WordPress，PHP，MySQLのチューニング
    - 通常のチューニングによってより高性能化が可能
    - 効果はnginxのキャッシュに比較すると小さい
    - nginxによるキャッシュを利用する場合には，WP Super Cacheは実施しない方が良い
- 動的ページ (コメントや独自のフォームなど) でのキャッシュのコントロール

# おわりに
nginxでキャッシュすることにより，動的なページで注意が必要なものの，大幅な処理速度を改善できます．
これ以上の性能を得るのであれば，フロントエンドのnginxの数を増やしたり，データベースサーバをクラスタ化することを検討するのが良いでしょう．