Twitter API連携のアプリ作ったら予想外にアクセスが来たのでサーバーの設定をいじってみた。

Twitterアカウントと連携して犯罪係数測定という数年前の某アニメネタのn番煎じなのを作って( 犯罪係数測定 )、まあこれなら程々にしかアクセス伸びないだろうと思ってたらアクセスが殺到してサーバーがまともに動かなくなってしまったので、改善して安定化させるまでに取った手順を記録します。色々と試行錯誤してみましたがその中で効果が大きかったものをピックアップしていきます。
本番環境で動かしていますので実際のデータに基づいた検証ができているわけではなくあくまで体感でですが、TwitterをはじめとしてAPI連携を行うWebサービスを作りたい人たちの参考になれば(もしくはご指摘があれば)幸いです。

サーバーの構成について

旧サーバー(今でもDB用に使用中)

  • さくらのVPS メモリ1G HDDプラン
  • Ubuntu14.04
  • Apache2
  • Passenger
  • Ruby on rails4
  • MySQL

新サーバー

  • さくらのVPS メモリ2G SSDプラン
  • CentOS6
  • Nginx
  • Unicorn
  • Ruby on Rails4

ネックはメモリ

1GB HDD→2GB SSDのVPSに

これまでさくらのVPS(1GBメモリ、HDDプラン)を利用していたのですが、やたらとサーバーが止まっては(cronで設定したコードで)自動再起動を繰り返すよう不安定さになってしまい、鯖落ち通知メールを見るたび僕の犯罪係数が上がってしまいそう(笑)になってしまいました。
SQLの発行回数減らしたり無駄なAPIリクエスト(今後別のTwitterアプリ作成を見越して冗長になってた)を無くしたり…とやってみましたが、焼け石に水で大した効果は上がらず。
ネックはメモリの制約からPassengerのWorkerを3〜(無理して)4くらいしか設定できないところにあるかなと感じ、このまま落ち着かないまま続くのも嫌だし思いっきり2GB SSDのプランでVPSを増設しました。

またHDDを全然使い切れてなかったのと最悪スワップアウトしてもSSDならIO早いしある程度は使えるんじゃ…との目論見で容量半減を受け入れてSSDを選択しましたw
結果的にはスワップアウトよりは設定変更時の再起動の早さやコマンド操作がサクサクと進むほうの恩恵が大きかったですが。

メモリが制約になる理由としましては、APIを使う関係上APIを投げた相手のサーバーからのリクエストを待たなければならず、どうしてもリクエストを処理する時間が長くなってしまいます。
特に今回のサービスの場合はAPIを数〜十数個くらい(なるべく同時並行にしてはいるものの)投げてから処理していますので、APIリクエスト中に当該リクエストのデータ等諸々を保持するためのメモリの占有時間ががどうしても大きくなってしまう→スループットを伸ばせないからだと分析しました。

ところでなんでクラウド使わないかですって?AWSとさくらのクラウドについてざっと調べた感じ、(最初の割引を除けば)最低維持費がメモリ2GBくらいのVPSよりも高くつきそうだからですw
しかもそれでスペックは1GBのVPS以下だったりして趣味で使う範囲では割高感が否めないですね

まずはWWWサーバー機能のみを新しいVPSに移管して、順位や友達との比較機能、(今後は過去との比較機能も実装しようとしているので)データベースに記録を保管しているのですが、移行がちょっと面倒なデータベースはとりあえず後回しにしようと思い、実行しました。
その結果、移管後の元のVPSはCPU・IO・ネットワーク無事どれも余裕が生まれました。
結構サーバーでネックになりやすいのはDBだとはよく言われますけど、使い方によっては逆にそうではないケースもあるようで今後も気をつけて見極めたいです。

UnicornのWorkerを多め、kill早めに

これまでApache+Passengerを使ってきたので、他のツールを試してみたいとNginx+Unicornにしてみました。(後で1workerで並列リクエスト処理可能なPassengerのほうが良かったかもしれないことに気付くのですが…w)
またしばらく使っているとメモリリークがあるのかメモリ使用量がだんだんと増えてきたので、とりあえずUnicorn Worker Killerを使ってみました。Worker数とkillするタイミングで色々と試してみたところ、Worker数が7で256〜512リクエストでkillする設定にしました。
またメモリ使用量基準でkillする場合、APIの同時リクエスト等でマルチスレッドのコードを書くとkillされた処理のを待ってるプロセスがタイムアウト→まとめてkillになり、パフォーマンスが激落ちするので注意が必要でした。

Disk IOが問題にならない程度にスワップを使う

スワップアウトは極力発生させてはいけないとはよく言いますが、メモリが不足している状況下ではそんな悠長なことは言ってられないもの。とはいえスワップを使い過ぎるとディスクのIO速度がネックになってWorker単体だけでなく全体で見てもスループットが落ちました。またCPU使用率を見ても10%ないしはそれ以上をスワップ関係に割かれることになり…DISK IOと合わせてもし過負荷認定されて制限食らうと怖いなと思うところ。
そこでVPSのコントロールパネルなどを見てDisk IOが極端に多くなっていないか確認しました。Disk IO の確認に関してはこの記事なども参考になりました。
その結果、自分の環境ではWorker数を9まで増やしてたところ7に減らしたほうが捌けるリクエスト数も体感で変わらずか増えて、レスポンスの早さも良くなってで改善されました。(これでもまだスワップは500MB程度ありますが、avgqu-szが5〜30→0.1になって%utilも30〜90%→一桁%に収まりました)

Nginxでリクエスト制限を設定

アクセスが集中している時に無尽蔵に受け入れると通常1秒以内に済むレスポンスで10秒くらい待たされたりしてよろしくないので潔く503を出すことにしました。

keep-aliveを見なおしてTCPコネクションの同時接続数を減らす

keep-aliveは1回のTCP接続で複数のHTTPリクエストを処理する機能です。結構次のリクエストまで待つべく60〜120秒まで持たせようとしている記事が多いですが、connection数が溜まることによるメモリの増加を防ぎたいので、今回はページ読み込みに付随する画像やCSS、JSを一度の接続で読み込むためだけに使いたいと考え5秒としました。また後述するlimit_connを負荷に即して設定しやすくなる副効果もあります。

Limit_req、Limit_connを設定

APIを大量リクエストするため時間のかかる解析処理と逆に軽量な画像やCSS/JSなどの静的ファイル処理、それ以外の処理(普通のページ表示)の主に3つに分けました。
リクエスト数の1秒あたりのrateで制限するとレスポンスまでの時間がかかる処理が偶々集中した時に中々繋がらなくなるので、connection数で制限をかけたほうが負荷に即していい感じでした。(ただしkeep-aliveをオフにするか数秒の場合に限ります。)
特にTwitterアカウントの解析処理ではTwitterサーバーの調子によっての変動や、各ユーザーのフォローid等を取得する場合は解析中のユーザーのフォロー数に応じてレスポンスまでの時間の変動が大きいためconnection数での制限をメインにしてrateは補助的に使用したほうがいいと考えました。最終的にはこんな感じにしました。(9割型connection数で引っかかります。)ちなみに静的ファイル以外にもTwitterログイン時の前処理や後処理、検索エンジンのクローラなどのbotからのアクセス…などなど、アクセス解析のPVには現れないリクエストはたくさんあります。ですので実際のPV数は170くらいでもrateは最低300くらいは必要だったりするので注意して考えて下さい。

静的ファイル 解析 その他
connection 制限なし 25 40
rate 2000req/m 40req/m 500req/m

静的ファイルをNginx完結で渡せるよう設定

1回のページのロードで大抵の場合は最低でもjs、cssファイルを1つずつ、また画像をページによっては数ファイルか(それ以上)読み込ませることになるはずです。静的ファイルは負荷は軽いとはいえページを返すより何倍も回数は多いもの。また静的ファイルのパスは{Railsのルート}/public/以下と一緒であるためRailsアプリケーションで処理させる必要はありません。Unicornも通さずnginxで直接返すよう設定しましょう。

設定結果

/etc/nginx/nginx.conf

(前略・だいたいデフォルト通り)
http {
(前略・だいたいデフォルト通り)

keepalive_timeout 5;
keepalive_requests 60;
reset_timedout_connection off;

client_header_timeout 6;
client_body_timeout 6;

proxy_connect_timeout 6;

limit_req_zone $binary_remote_addr zone=staticperip:13m rate=300r/m;
limit_req_zone $server_name zone=staticperserver:13m rate=2000r/m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=40r/m;
limit_req_zone $server_name zone=perserver:10m rate=500r/m;
limit_req_zone $binary_remote_addr zone=analyzeperip:5m rate=30r/m;
limit_req_zone $server_name zone=analyzeperserver:5m rate=40r/m;
limit_conn_zone $server_name zone=connection:20m;

#gzip  on;

include /etc/nginx/conf.d/*.conf;
}

/etc/nginx/conf.d/default.conf

server {
(前略)

#rssやapple-touch-iconはよく参照されるので無ければRailsを通さずに404を返す
location ~ /(\.?rss|apple-touch-icon(.*).png)$ {
return 404;
}

#解析は一番重い処理であるためlimitを別に設定
location ~ /analyze$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_pass http://unicorn;
limit_req zone=analyzeperserver burst=6 nodelay;
limit_req zone=analyzeperip burst=1 nodelay;
limit_conn connection 25;
}

#静的ファイル用設定
location ~ ^/(images|assets|uploads)/(.*) {
alias /home/nk16/www/twi_analytics/public/$1/$2;
limit_req zone=staticperip burst=20 nodelay;
limit_req zone=staticperserver burst=100 nodelay;
}

#その他の設定
location @unicorn {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_pass http://unicorn;
limit_req zone=perserver burst=30 nodelay;
limit_req zone=perip burst=5 nodelay;
limit_conn connection 40;
}
}

で、結局どれくらいまでのアクセスに耐えられたのか

結果としては1分間あたりで
〜75PV 余裕
100PV前後 時々表示が遅くなりはじめる
100PV〜 アクセス制限で503が出始める
150PV 限界。でも1秒でレスポンスが返ることもあるし、遅くとも(解析以外は)5秒以内にはほぼ返る。

という感じになりました。多少の503を許容するのであれば1日でおおよそ5〜10万PVに対応できました。
サーバー移転前と比べると約3〜4倍にまで改善できました(移転前の設定がポンコツすぎただけかも…)
追々DBも移行して同居させればSQLのレスポンスが早くなる分1Worker当たりのスループットが上がるかもしれないけども、同時にネックのメモリを圧迫する考えると…どっちに転んでしまうかw

…ただこれだけ設定しても2年半前の自分は素のPHPで書いた仕組みとしては類似のアプリ、Twitter試験を作った時のほうが時間あたりのPV数はもっと捌けてましたw
Google Analyticsによると一時間で最大約8500PV(1分あたりで140PV以上)にまで達してたようで…メモリ1GのVPSを使ってた上に当時の自分は無知だったのでフレームワークも使わずApacheで適当に公開に必要な設定だけ書いてただけだったのですが…皮肉なもので。

フォロー、ツイート権限を要求する診断Twitterアプリでも強制ツイート・フォローするとは限らない理由。

「あながた2015年によく使った言葉は?」が流行ってるようですね。
http://textmining.userlocal.jp/sp

何か診断系アプリなどが流行って大量にツイートが流れる度に毎回と言っていいほど自動投稿スパム疑惑が湧くのですが…必ずしもそうではありません

こんな感じで認証時に「フォローする、プロフィールを更新する、ツイートする」などの権限を要求されると、勝手にツイートする、スパムをフォローする、プロフィールを更新する…などと思われがちですが…
スクリーンショット 2015-12-18 19.56.31

「あながた2015年によく使った言葉は?」はこのように投稿が任意の良心的な(?)アプリでした。
スクリーンショット 2015-12-18 19.54.01

では、何故勝手にツイートやフォローする権限を要求しながら実際は勝手には何もしないのか、Twitterアプリを作っている側からして解説しようと思います。

理由1.ツイートボタンでは画像を投稿させられない

勝手にツイートしないアプリならスクリーンショット 2015-12-18 20.13.17
↑おなじみのツイートボタンで投稿させることで、ツイートする権限を要求しなくても任意で投稿することが出来ます。

ただし、大きな問題点として「画像を一緒に投稿できない」というのがあります。
そこで、画像と一緒に投稿させるアプリの場合は独自にツイートさせる機能を作る必要があるのですが、その独自に作った機能でツイートさせる時に、認証時に「ツイートする」権限が必要になるのです。
(※Twitter Cardなどの機能を使って画像付きツイートを出来るじゃないかといった指摘があるかもしれませんが、予めこちらでURLを画像を紐付ける必要が有るため、自動診断の結果に応じて自動で変えることが出来ないのです…)

理由2.ツイートさせる権限だけ要求したくても、フォローやプロフィール更新の権限まで一緒に付いてきてしまう

Twitterアプリを作成する時には、それを利用するユーザーに対してアプリが自動で行える(ユーザーの投稿を見る、ツイートする、フォローする、DMを見るetc)範囲を設定することは出来ます。
しかし選択肢は下記のようにたったの3種類しかありません
スクリーンショット 2015-12-18 20.07.14
上から、

  • 読み取り専用(自分の投稿を見る、届いたリプライを見る…などの「見る」行為)
  • 読み書き=DM関係以外全部
  • DM操作も含めて全部

となっていますが、このとき一番上の権限でツイートさせることは出来ません(ツイートの投稿は「見る」行為ではないので)。
というわけで、泣く泣く上から2番目の権限(=DM関係以外全部)を要求するしかありません。
したがって、認証画面では実際にはツイートだけしかしないアプリでも「・フォローする・プロフィールを更新する・ツイートする」などの権限を要求しているのです。

ちなみに拙作の「Twitter試験」などもツイート(任意)と同時に画像投稿を行うため、同じような事情でこのような権限設定とならざるを得ない状況となっております。

今後勝手に何かスパムをツイートしたりフォローするアプリが出て来る(あるいはこのようなアプリでしばらくしてから勝手にツイートすることがある)かもしれませんが、結構スパムが問題視されて何度も炎上したこの御時世のことですし、きっと企業や作者側ももしスパムな行為をやったら信用を大幅に失うと分かってると信じたいです。

もちろん対策として、診断等を利用後は『アプリ連携を解除』しておくことを推奨します。(ただフォロワーの変化とかを取るアプリは連携を切っちゃうとデータを更新してくれなくなる可能性があるので注意w)

スパムに注意することも重要ですが、必要以上に警戒すること無く楽しくTwitterアプリを使えるようになって頂けたらと思います。
(私は不安な場合には自分専用のアカウントで挙動をチェックしてからいつも使うアカウントでTwitterアプリを利用するようにしています)

そしてTwitter社さん、ツイートボタンから任意の画像付きツイートを投稿出来るように改良して欲しいです。

【CakePHP2】思いがけない場所でコントローラーの関数が動作してないかチェック

気づくことの発端

$this->Session->write(‘data’, $data)で変数を入れたはずなのに、
再び$data=$this->Session->read(‘data’)で読み込もうとすると何故かセッションデータが消えたり、書き換わってたりしてしまう。
セッションが全部消えたわけでも無いのに何でなのかとどっぷりハマって調べてみると、ある可能性に行き着いた。

それは…
1回のページのアクセスでコントローラーの関数(メソッド)が複数回実行されたり意図してない場所でも実行されている可能性である。
なんとも厄介な…と思って試しに

$this->log('Access To Article/add', 'debug');

を気になる場所に埋め込んでみる。

【例】
fooController.php

//...
 function bar(){
  $this->layout="type_a";
  //...
 $this->log('Access To foo/bar', 'debug');
  $data=$this->Bar->find(...);
  $this->Session->Write($data);
}

hogeController.php

//…
function huga(){
  $this->layout="type_a";
  //...
 $this->log('Access To hoge/huga', 'debug');
  $this->Session->delete($data);
}

と入れて対象の/foo/var/のURLを叩いて
/app/tmp/logs/debug.log
を見ると、

…
2014-xx-yy 11:22:33 Debug: Access To foo/bar
2014-xx-yy 11:22:33 Debug: Access To foo/bar
2014-xx-yy 11:22:33 Debug: Access To hoge/huga

という具合に1回のアクセスでfoo/barメソッドが2回ずつ実行されてたり挙句の果てにはhoge/fugaメソッドまで何で実行されてるんだ?なんてゆかいな事態になってましたorz。
ここでhoge/fugaメソッドを勝手に実行されてたらせっかくセッションに入れたデータが…\(^o^)/

【解決法】
もうこれ以上悪夢を話すのは嫌なので早速解決法に移ろうと思いますw
問題はビューにあるのではないかと試しにビューを全部コメントアウトすると直りました。
そこでビューのどこが問題なのか探すべく、2分探索法のノリで
半分コメントアウト→直ったらコメントアウトを半分に→直らなかったらコメントアウトしてなかった部分に着目して半分コメントアウト
を繰り返して原因を特定…

結果としては今回の場合はテンプレートのタグのurlの部分をサイトルートでなく相対パスで書いてたからのようでした。
今回勝手に呼び出されたメソッドは、アクセスしたページと同じテンプレートを利用していたという共通点があったのでもしかするとその範囲で勝手に実行されているのかもしれませんが、それにしても意図せず勝手に実行されてセッションが変わってしまってたとは‥困ったものです。