syntax

2014年1月29日水曜日

appサーバ(Rails)周りの準備

アプリそのものはプログラマーさんが制作していて、私のほうでは作ったアプリをサーバ上で動かす環境とデプロイ周りを担当します。
以前にもRails環境のサーバをいくつか構築したことがあり、過去を思い出してみると、

  • Lighttpd+ fcgi(ライティーー!)
  • Apache + Mongrel
  • Apache + Passenger
  • nginx + Unicorn

こんな流れでやってました。今回も「何か違うものでやりたい!」と思ってnginx + Rubinius + Pumaあたりに目をつけたのですが、軽くアプリを動かした時点で色々と動かなかったので、すぐに撤退してNginx+Unicornに戻りました。

Rubyの実装は、rbenvでRuby2.1(立ち上げ最初は2.0)をビルドしてそれを使います。
実はappサーバの中にもnginxを建ててnginxとunicorn間はUnixドメインソケット通信するようにします。
上流にnginx、ELBがいるのにapp内にもnginx建てるのも野暮ったいのですが、app内のnginxで何か制御したい場合があるかもということで立ち上げます。

アプリが起動するまでにやった作業の流れは以下の感じです。(事前にrbenvでRuby2.1が動いてるとします)
rbenv、nginx、Unicornのセットアップは参考の記事がたくさんありますのでここでは書きません。
bundlerでのgem管理が基本ですが、bundlerとeyeはgem installでいれておきます。

ソースをgitからチェックアウトできるようにする

  • appサーバのec2-userの公開鍵をgitosisに登録する。

環境変数を設定する

  • RAILS_ENVとか

デプロイする

  • 手元のローカルマシンからbundle exec cap staging deploy

 unicorn起動する

  • 上のデプロイのタスクに含まれている

プロセスを確認する、ブラウザで見てみる

  • psしたりログ見たり、ブラウザでたたいてみたり。。

特に書くことがありませんでした^^;
デプロイ関連の記事で色々書くことになると思います。
環境変数の設定は、この案件だけでやってそうなことなので書いておきます。
環境変数セットにEC2のUserDataを使用しています。マシン起動時に下記の変数がされます
(UserData自体の設定はインスタンスを作るときに下記のパラーメータを与えてやってます)

user data

RAILS_ENV=staging
SERVER_ROLES=staging-app
STAGE=staging
  • RAILS_ENV Rails内で色々参照される環境変数
  • SERVER_ROLES このマシンがどういう役割をするのか(web,app,resque専用とか) 。このマシンではunicornを動かすのかresqueを動かすのかなどこの変数で決めます。
  • STAGE cap STAGE ~~で指定するステージと一致(大体STAGE=RAILS_ENVなのですが、もしかしたらstaging環境を2,3個作ったりするかもしれないので区別できるように変数を用意します)

Written with StackEdit.

nginxでのプロキシ設定

nginxに来たアクセスをインターナルなELBにフォワードしてappサーバ群に振り分けてもらいます。

インターネット
|
nginx
|
ELB
|
appサーバ群

nginxのプロキシ先にELBを指定している場合このような問題がおきますので、参考記事のように設定しておきます。

http {
...
    resolver 127.0.0.1 valid=10s;
...
    server {
        location / {
            proxy_pass http://staging-elb-1.example.internal$request_uri;
        }
    }
}

staging-elb-1.example.internalのIPが変更された場合、ngxinの再起動なしで変更後のほうにプロキシするようになりました。
なおupstream内にホスト名を書いた場合は上記resolverを書いてもnginx起動時にしか名前解決をしないようです。
なのでupstreamを使うのは今のところ見合わせています。(でもupstreamでkeepaliveを使いたいところでもあります)

Written with StackEdit.

アクセスログのフォーマットをltsv形式にする

nginxが出力するアクセスログをfluentdで取り込み、それをElasticSearchやMongoDBに集約する予定なので、取り込みやすいようにログフォーマットをltsv形式にします。
必要そうな項目を一通り羅列しました。

upstream_statusはappsサーバからのステータスコード、upstream_addrはappsサーバのIP、opensocial_viewer_idはユーザのopensocial_viewer_id(取れる場合)も取得しています。

http {
    ....
    log_format ltsv 'time:$time_local\t'
                    'msec:$msec\t'
                    'remote:$remote_addr\t'
                    'forwardedfor:$http_x_forwarded_for\t'
                    'method:$request_method\t'
                    'uri:$request_uri\t'
                    'status:$status\t'
                    'size:$body_bytes_sent\t'
                    'referer:$http_referer\t'
                    'ua:$http_user_agent\t'
                    'reqtime:$request_time\t'
                    'cache:$upstream_http_x_cache\t'
                    'runtime:$upstream_http_x_runtime\t'
                    'host:$host\t'
                    'upstream_addr:$upstream_http_x_server_addr\t'
                    'upstream_status:$upstream_status\t'
                    'session:$session\t'
                    'set_cookie:$sent_http_set_cookie\t'
                    'opensocial_viewer_id:$opensocial_viewer_id\t';
    ...
    server {
    ...
        access_log  /var/log/nginx/staging.example.web.access.log ltsv;
    ...
    }
}

Written with StackEdit.

2014年1月28日火曜日

nginxでソーリーページを返す(メンテ以外の場合)

nginxでソーリーページを返す(メンテ以外の場合)

以下の状況の場合はnginxでエラーハンドリングを行います。

  • リバースプロキシしてappsからの応答が一定以上ない場合のタイムアウト
  • アプリからのステータスコードが40x,50xの場合

nginxが40X,50Xのステータスコードでレスポンスを返すと、プラットフォーム側で用意しているエラー画面が表示される場合があります。
これを見た時プラットフォーム側のエラーなのか、こちらのサーバ側のエラーなのか判断つきません。
40x,50xのエラー時でもプラットフォームには200を伝えてこちらの用意したソーリーページを表示するようにします。

ステータスコードを書き換えたうえで事前に用意した専用のコンテンツを返すには下記のように設定します。
appsからのレスポンス待ちを5000msに設定しています。超えた場合は504となります。

server {
...
  proxy_read_timeout 5000ms;
  proxy_connect_timeout 5000ms;
  proxy_intercept_errors on;
  error_page 400 =200 /system/400.html;
  error_page 403 =200 /system/403.html;
  error_page 404 =200 /system/404.html;
  error_page 500 =200 /system/500.html;
  error_page 502 =200 /system/502.html;
  error_page 503 =200 /system/503.html;
  error_page 504 =200 /system/504.html;
...
}

書き換わったステータスコードがnginxのログにも出力されるので、アクセスログからエラー状況の調査ができなくなります。
対策としてログフォーマットに$upstream_statusを追加して、こちらのステータスコードでエラー状況を判断するようにします。

メンテ中の場合、nginxでメンテ画面を返す。

メンテ中のメンテ画面

Capistranoでのメンテナンス切り替えはmaintenance.htmlファイルを配置するかどうかというものなので、nginx側で下記のように設定してメンテ画面を表示するのが一般的かと思います。

こういう感じ

if (-f $document_root/system/maintenance.html) {
  return 503;
}
error_page 503 @503;
location @503 {
  rewrite ^(.*)$ /system/maintenance.html break;
}

ただ、メンテ中であっても動作確認のためにテスターなど特定のユーザがアクセスした場合は、メンテ画面にしないでアプリに素通ししたい場合もあります。
nginxのifの制御に癖があったりするので(ifの中にifを書いたら怒られた)、結局以下の様な設定になってしまいました。

set $maintenance  "";
#メンテファイルがあった場合
if (-f $document_root/system/maintenance.html) {
  set $maintenance  "maintenance";
}
#gadget.xmlへのアクセスの場合は$maintenanceを上書きする(メンテ画面を返さないようにする)
if ($request_uri ~ ^\/gadget\.xml) {
    set $maintenance  "xml_${maint}";
}
#特定のIP(会社とか)からのアクセスだったら変数maintenanceを上書きする。(メンテ画面を返さないようにする)
if ($http_x_forwarded_for ~ ^1\.1\.1\.1) {
    set $maintenance  "office_${maintenance}";
}
#特定のopensocial_viewer_idだったら変数maintenanceを上書きする。(メンテ画面を返さないようにする)
if ($opensocial_viewer_id = "12345678" ) {
    set $maintenance  "office_${maintenance}";
}
#$maitenanceがmaintenanceのままだったらメンテナンス画面を返す。
if ($maintenance = "maintenance"){
  rewrite ^(.*)$  /system/maintenance.html break;
  break;
}

Written with StackEdit.

2014年1月27日月曜日

nginxで同一ユーザからの過度なアクセスを制限する

連打対策してるのをユーザにわかってもらえる程度の制限

今回運用するWEBサイトではユーザがほぼ同タイミングでPOSTのボタンを数回連打したり、もしくはツールなどを使って一定期間同じリクエストを延々と飛ばしてきたりする可能性が普通にあるらしく、ある程度の規制はいれておきたいと思います。

nginxで使えそうなものがないか色々と調べてみるとここで紹介されているlimit_req_zoneが使えそうです。

下記のように、httpディレクティブで何をキーにしてアクセス数をどの程度で制限するかを定義して、serverのlocation内でどの制限を適用するかを指定します。
制限を超えてアクセスしてきた場合、burst内であればhttp status 499,burstを超えた場合は503として扱い、専用のソーリーページを返すようにします。

http {
    limit_req_zone  $binary_remote_addr  zone=one:10m   rate=1r/s;
   # クライアント側IPをキーにして1秒間に1回までのアクセス上限とする。メモリ10Mを使用してoneという名前でセッション管理する。
    ...

    server {

        ...

        location /search/ {
            limit_req   zone=one  burst=5;
            # /searchには1秒に1リクエストまでの接続が許可される。
            # 1以上のリクエストが来た場合でも5リクエストまでは503エラーとせずに1r/sの割合でリクエストを処理していく。
        }

opensocial_viewer_idをキーにする

クライアント側のIP(binaryremoteaddr)WEBIPIPIPbinary_remote_addrでユーザを特定できません。

今回の案件ではそのまんまopensocial_viewer_idでユーザを区別することにします。このopensocial_viewer_idが必ずリクエストURLの中に含まれているのであればURLの中からopensocial_viewer_id=〜をすっぱ抜いてlimit_req_zone $opensocial_viewer_id zone=one:10m rate=1r/s;という感じで指定できるのですが、アプリの作りのほうで「そんなパラメータを毎回URLに付けません」ということになっているので、また一工夫必要です。

このRailsアプリではsession管理にRedisを使用することになっているのですが、このsessionの中にopensocial_viewer_idが保存されていましたので、nginxからこのopensocial_viewer_idを参照することにします。参照する際にセッションキーが必要ですが、これはcookie内の_session_idから取得可能です。

設定抜粋すると下記のようになります。

#参照するredisサーバ
upstream staging_redis {
  server staging-redis-1.example.interal:6379 fail_timeout=0;
  keepalive 1024;
}

server {
...
location / {
    ...
    #cookie内から_session_id抽出
    if ($http_cookie ~ _session_id=([0-9a-f]+)){ 
        set $session $1;
    }
    # _session_idをキーにしてredisサーバからセッション内容を参照して$redisに設定する
    eval $redis {
      #アプリの仕様でredisのindex=2にセッションを格納している
      redis2_query select 2;  
      redis2_query get example:staging:session:$session;
      redis2_pass staging_redis;
    }
    # セッション内容からopensocial_viewer_id部分を抽出して$opensocial_viewer_idに設定する。
    if ($redis ~ opensocial_viewer_id\x06:\x06EFI\x22\x0C([^\;]+)\x06){
      set $opensocial_viewer_id $1;
    }

    # oneというアクセス制限を適用する
    limit_req zone=one burst=2;
    ...
}
...
}

http {
    ...
    # $opensocial_viewer_idをキーとしたアクセス制限を定義する
    limit_req_zone $opensocial_viewer_id zone=one:10m rate=1r/s;
    ...
}

キーが空の場合は

設定した後で気づいたのですが、はじめてサイトに来た場合はsessionが作られてないのでsessionからopensocial_viewer_idを参照できません(空扱い)
。最初に来た人達に対して「キーが空」としてアクセス制限のテーブルを参照してしまうとすぐにアクセス超過となってしまいます。
空の場合の処理を調べてみたところ、キーが空の場合はアクセス制限処理がスルーされるようなので、とりあえず問題は起きなさそうです。

nginxでリバースプロキシする

リバースプロキシ上でやりたいこと

WEBサーバ(web)とアプリケーションサーバ(apps)をリバースプロキシ構成にします。WEBサーバは今回nginxを採用します。
WEBサイトの構築運用にあたってWEBサーバ側で行いたい要件が以下のようにあり、nginxに設定していきます。
nginxがどれくらい負荷に強いかわかっていませんが、リリース当初は1インスタンスで運用する予定です。頻繁にダウンするようであれば複数台構成に変更します。

  • インターネット側からのHTTPリクエストをappsにプロキシする
    • appsサーバの数が負荷に応じて動的に変わるので、nginxはappsサーバを束ねるELBにプロキシするようにします。
  • appsからの応答が5秒たっても無い場合、nginx側で強制的にソーリーページを返す
    • 5秒ルールというものがあるらしいです。
  • メンテ中はどんなリクエストにも一律同じソーリーページを返す

    • ただし社内の人など特定のユーザのアクセスの場合はアプリケーションサーバに通すようにします。
  • 同一ユーザによる短時間の大量アクセス、同時アクセスを制御する(連打対策)

    • システムの負荷も考慮して、アプリまで無駄なリクエストを通したくないという思いがあるため。
  • 過去のアクセスログを検索できるようにする
    • ユーザからの問い合わせがあったときに行動調査が後日でも行えるようにするためです(nginxだけ実現する要件ではありませんが、他の検索エンジン等を使って検索できるようにするために、nginx側でログの出力を調整します)

インストール

ngx_openresty1.5を使う

上記要件の設定を全部書くと内容が長くなるので記事を幾つかにわけます。この記事ではnginxインストールについて書きます。
$sudo yum install nginxでnginx 1.4が入るのですが、要件的に追加のモジュールをいれないといけないこともあり、モジュールてんこ盛りのngx_openresyを導入します。

バージョンについては、upstream内に記述したホスト名の名前解決がnginx起動時にしか行われない問題(これとか)があるのですが1.4でも問題が発生したので1.5系にします。

上記に書いた要件に同一ユーザによる短時間の大量アクセス、同時アクセスを制御する(連打対策)がありますが、今回のプロジェクトにおいてnginx上でこれを実現するためには、nginxがRedisサーバから指定の値を取得して比較、演算、分岐処理を行う必要があります。

redisに接続するモジュールはngx_openrestyに含まれているのですが、比較、分岐処理をするほうではnginx-eval-moduleのモジュールを使うので、モジュールを追加してからnginxのビルドが必要です。

手順

ngx_openrestyをダウンロードして解凍するとbundle/があるので、このディレクトリの中で$git clone https://github.com/vkholodkov/nginx-eval-module.gitしてから解凍ディレクトリに戻り、configure、make、make installします。ソフトウェア管理としてPaco使ってます。

configure時に他にも–add-moduleでnginx-eval-moduleを追加します。他にもオプション付与してますが下記参考にして下さい。

$./configure --prefix=/opt --with-http_stub_status_module  --with-http_realip_module  --add-module=./bundle/nginx-eval-module
$make
$sudo paco -D make install

一連のconfファイルは/etc/以下に移動しておきます。

$sudo mv /opt/nginx/conf /etc/nginx
$sudo ln -s /etc/nginx/conf /opt/nginx/conf

Written with StackEdit.