インフラブログ

とあるWEBサイトのインフラを構築運用するメモ

メール以外の方法でもアラートの通知を行う(Twilio)

アラート通知の続きです。

サービスダウン検知

MSPに運用を手伝ってもらっていればサービスダウンが発生した場合でも電話で障害連絡してくれたり一次対応をやってくれたりするのですが、今回はスモールスタートでの運用ということもありMSPを使うことを勘定にいれなかったので、なんとか自力で対応できるようにします。

監視検知としてはMuninを使ってWEBサイトのヘルスチェックを行うのですが、サイトダウン検出時にアラートメール送ってもすぐ誰かに見てもらえると限らず、社内で警告音鳴らしたとしても社内に誰もいなければ気づいてもらえません。

いざという時はやはり電話で担当者に連絡したいと思っていたのですが、Twilio参考になる記事という電話周りのAPIが提供されているようなので、試しに使ってみてます。 なお、携帯に電話をかけると1分20円ほど課金が発生します。(電話に出なければ発生しないのかな?)

どんなものかというと、Twilioが監視サーバと電話の中間に入ってやりとりを代行してくれます。Twilioと監視サーバの間でhttpでのやりとりが発生するので、監視サーバ上にWEBサーバ等が必要です。今回は監視サーバ上にSinatraで簡単なサイトを作って、架電開始のリクエストや、Twilioからのコールバックの対応を行います。

f:id:ls-la:20140130150509j:plain

サンプルを見ながら作ってみたのですが、ざっくり流れは以下の感じです。

アラート発生時にTwilioのAPIを使って担当者に電話する。

  • アラート検知のスクリプトなどから、架電スタートのリクエスト/startを叩く。wget http://watch.example.com:4567/start?secret=xxx&message=アラートです
  • /startからstart_callが呼び出され、Twilioが担当の携帯に架電する。

担当者が電話にでない、出ても自動留守電メッセージだったという場合は、次の担当者に架電する。

  • Twilioの架電結果が/callbackにコールバックリクエストとして来るのでパラメータの中のCallStatus:no-answerで電話に出てないと判断して 再度start_callを呼んで次の電話番号に架電する。次の番号がない場合は架電を停止する。

担当者が電話に出たら、文章を読み上げ始める。

  • 担当が電話を取ると、/answerで設定した文面をTwilioがゆっくりさんみたいな音声で読み上げる。

担当者が内容を聞いたら確認のボタンを押す。

  • 読み上げの他にキー入力の制御も設定しているので、担当のキー入力待ち状態となる。

確認のボタンを押したのが検知できたらそこで架電をやめる。

  • 担当が1桁のキーを押すか、10秒何もしていない時点でTwilioは/completeにアクセスくる。
  • キーが押されていれば架電停止のフラグをたて、「確認しました」の応答を返す。
  • 押されていなければ「確認できませんでした」を返す。
  • この後もTwilioから/callbackにコールバックが返ってくるので、架電停止フラグをみて停止でなければ次の電話番号に電話をかける。

sinatraのコード

rubyはこれから勉強します。。

require 'sinatra/base'
require 'twilio-ruby' 
require 'uri'
require 'redis-objects'
require 'json'

FROM = '+81xxx' #電話する際の電話番号
ACCOUNT_SID = 'xxx' #twilioにサインアップしたときにもらうID
AUTH_TOKEN = 'xxx' #twilioにサインアップしたときにもらうToken
NUMBERS = ["+81xxx","+81xxx","+81xxx"] #電話番号リスト
SECRET = "xxx" #だれでも実行できないようにするための簡単な合言葉
HOST = "http://watch.example.com:4567" #このアプリが動くURL

CALLBACK_URL = "#{HOST}/callback?secret=#{SECRET}" #架電後に結果をtwilioが送ってくる際の受取先              
ANSWER_URL = "#{HOST}/answer" #電話がつながった後にtwilioにやってもらいたいことをxmlで教える
COMPLETE_URL = "#{HOST}/complete" #電話中の担当者が番号入力を行ったあとにtwilioにやってもらいたいことをxmlで教える


#アラートメッセージの文面、今何番目に電話してるとか各種状態をredisに保存する。
class State
  include Redis::Objects
  value :processing
  list :numbers
  counter :number_index
  value :message

  def id
    1
  end

  def number_shuffle
    numbers.clear
    NUMBERS.shuffle.each{|n| numbers << n }
  end

  def processing?
    processing == "0" ? false : true 
  end

  def new_number
    if number_index.to_i < NUMBERS.size
      number = numbers.at(number_index)
      number_index.increment
      number
    else
      nil
    end
  end

  def stop
    processing.value = "0"
  end

  def start(text)
    processing.value = "1"
    number_shuffle
    message.value = text
    number_index.reset
  end

  def dump 
    "processing: #{processing.value},numbers: #{numbers.each{|n| n} },number_index: #{number_index},message: #{message.value}"
  end

end

State.redis = Redis.new(:host => '127.0.0.1')

def log text
  `echo \`date\` >> /var/tmp/twilio`
  `echo #{State.new.dump} >> /var/tmp/twilio`
  `echo #{text} >> /var/tmp/twilio`
end

#電話をかける
def start_call
  callback = "#{HOST}/callback?secret=#{SECRET}"
  url = "#{HOST}/answer"
  @state = State.new
  if number = @state.new_number
    @client = Twilio::REST::Client.new ACCOUNT_SID, AUTH_TOKEN
    @client.account.calls.create({
      :to => number,
      :from => FROM,
      :url => URI.encode(url),
      :method => 'GET',
      :status_callback => URI.encode(callback),
      :status_callback_method => 'GET',
      :timeout => '30',
      :record => 'false',
      'IfMachine' => 'hangup' #電話に出た相手が留守電メッセージのようなマシンだった場合電話を切る(US向けの機能らしいです)
    }) 
  else
    @state.stop
  end
end

class Server < Sinatra::Base

  get '/'do
    ""
  end

  get '/stop' do
    State.new.stop
    log "stop"
    "stop"
  end

  #
  get '/start' do
    if params[:secret] == SECRET
      State.new.start(params[:message])
      log "start #{params.to_json}"
      start_call
    end
    "start"
  end

  get '/callback' do
    if params[:secret]
      log "callback #{params.to_json}"
      if State.new.processing?
        start_call
      else
        content_type "text/xml"
        "<Response><Say language='ja-jp'>completed</Say></Response>"
      end
    end
  end
  
  get '/answer' do
    log "answer #{params.to_json}"
    content_type "text/xml" 
    "<Response><Gather timeout='10' numDigits='1' action=\"#{COMPLETE_URL}\" method='GET'><Say language='ja-jp'>#{State.new.message.value}。確認のため、何か数字を押して下さい。</Say></Gather><Say>確認できませんでした。次に電話します。</Say></Response>"
  end

  get '/complete' do
    log "complete #{params.to_json}"
    if params[:Digits]
      State.new.stop
      content_type "text/xml"
      "<Response><Say language='ja-jp'>確認しました。アラートを停止します</Say></Response>"
    else
      content_type "text/xml"
      "<Response><Say language='ja-jp'>確認できませんでした。次に電話します。</Say></Response>"
    end
  end
end

メール以外の方法でもアラートの通知を行う(1)

前に書いた記事がデプロイの話で、WEBサイト環境構築というよりかは運用的な記事でした。今回はさらに脱線してアラートの通知について書きます。

デプロイの設定作業の時に色々参考記事を漁ってたのですが、デプロイ開始、終了を音声で通知したりJenkinsのビルドの結果をライトで通知するという記事もよく目にしました。「メール飛ばすだけではつまらないから自分も真似してみよう」といくつか設定をしています。その時その時のジャストアイデアを盛り込んでいるので、かなり野暮ったくなっていてすみません。

どうやって通知する?

通知手段としては以下の方法を使うことにしています。

  • アラートメール送信する(いつもやってる定番の子)
  • 社内で音、音楽を鳴らす
  • ミラーボールを回す
  • skypeのアラート部屋に通知する
  • 電話する

社内で音を鳴らすというのは、アンプ・スピーカーがつながっているUbuntuマシンが社内にあって、そのマシン上でmp3を再生すれば社内に大音量で鳴り響くという仕組みです。単純にUbuntuにミュージックサーバ?MPDを導入して、曲の再生、停止をコントロールできるようにするだけです。アラート音など単発音を鳴らす場合はmpg321を使います。

なにを通知する?

このようなものを通知しようと考えています。

  • デプロイの開始/終了
  • WEBサイトの50xエラーの多発検知
  • 運営にとっていいことが発生した場合(新規会員登録してくれた時とか)
  • WEBサイトのサービスダウン

デプロイの開始/終了

Capistranoのタスクの中に開始・終了通知タスクを用意しておいて、それぞれのタイミングでフックするようにします。 タスクは、監視サーバに作られたアカウントにメールを送信するというものです。 そして/etc/aliasesを参照してメールボックスにメールが入るのと同時に下記のスクリプトを実行するようにしてます。

skypeに通知する

  • /etc/aliases
systemalert-staging: :include:/home/ec2-user/scripts/alert/alert, alert
  • /home/ec2-user/scripts/alert/alert
"|/home/ec2-user/scripts/alert/alert.sh"
  • /home/ec2-user/scripts/alert/alert.sh
#!/bin/sh
source /home/ec2-user/.bash_profile
ruby /home/ec2-user/scripts/alert/skype.rb
  • /home/ec2-user/scripts/alert/skype.rb(社内のsevabotにPOSTをする)
require "net/http"
require "mail"
require "uri"
require "digest/md5"
mail = Mail.new(STDIN.read)

sevabot_url = "http://社内のUbuntuで動いているsevabotのサーバ/msg/"
chat = "xxx" #Skype alert部屋のhash
shared_secret = "xxxx" # sevabot設定時につけたシークレット
msg = mail.subject.to_s + "\n" + mail.body.to_s 
md5 = Digest::MD5.new.update(chat + msg + shared_secret).to_s #sevabotが認証に使う

uri = URI.parse(sevabot_url)

request = Net::HTTP::Post.new(uri.request_uri)
request.body = URI.encode_www_form("chat" => chat, "msg" => msg , "md5" => md5)

http = Net::HTTP.new(uri.host, uri.port)
http.start do |h|
  response = h.request(request)
end

puts request.body

あとはsevebotさんがPOSTした内容を指定のskype部屋によろしく発言してくれます。

  • sevebot

f:id:ls-la:20140129225345j:plain

開始と同時に社内に音楽を流す

また、社内のUbuntuthunderbirdがメールボックスを常時チェックしていて、メールフィルタ適用時に任意のスクリプトを実行できるプラグインからMPDを再生する(mpc next;mpc playする)スクリプトが叩かれるようにします。 これでMPDが延々とプレイリストの曲を流し続けてくれます。デプロイ終了時も同様に処理するように設定しますが、最後はmpc pauseで曲を止めるようにします。

  • mpd

f:id:ls-la:20140129225343j:plain

WEBサイトの499、50xエラーの多発検知/

監視サーバ上にMongoDBを置き、アクセスログの499,50xのログを収集します。一方で 社内のUbuntuではmongo-tailを起動して監視サーバのMongoDBをtailしてファイルに出力するようにします。また、logmon参考になる記事でtailしたファイルを監視して指定のキーワードを検出した時はアラートの効果音を鳴らします。(mpg321コマンド叩くだけ) 効果音は「ピッ」と短る鳴るようにしてます。ひとつの50xエラーで1回警告音が鳴るので、大量に50xが発生している場合は「ピピピピピピ・・・」と 社内にやかましく鳴りだして、「すぐに復旧しなければ!」とみんなの意識が向くかもしれません。

  • mongo-tailの起動
$/usr/lib64/fluent/ruby/bin/mongo-tail -h 監視サーバのホスト -d DB名 -c コレクション名 
  • logmonの設定 /etc/logmon/logmon.conf
:mongo_tail_log
upstream_staus:504
mpg321 alert_504.mp3

:mongo_tail_log
upstream_staus:500
mpg321 alert_500.mp3

f:id:ls-la:20140129225340j:plain

サービスダウン検知は次の記事にかきます。

Capistrano(3.x)でデプロイする

AWS内のステージング環境をセットアップしているとアプリが動くところを早く確認したくなり、過去の案件で使われてたCapistranoの設定を流用してさっさとデプロイしようとしたところ、最近はバージョンが3らしく、書き方も大きく変わっているようです。 Capistrano2.xのまま使おうかとも考えたのですが、プログラム担当で神主の息子でもある御方に「ちゃんと勉強して」と怒られたので3にさせていただきます。 このような記事を参考にしてCapfile、deploy.rbを設定しました。

いつものように設定について解説は網羅しませんが、補足しておきたいことだけ書いておきます。

インターネット越しのデプロイは踏み台サーバを経由して各サーバにSSHする

手元の端末からAWS内の各サーバ一にデプロイする時は、一度踏み台サーバにsshしてそこから各サーバにさらにSSHする方針にします。 専用にVPNはるのも大げさなような気がするし、かといってインターネットから全てのインスタンスに直でSSHできるのもあれなので。

お手元のPC(cap叩く)
|
|
踏み台サーバ
|
|
各サーバ

Capistrano2.xのときは踏み台経由でのデプロイがサポートされていたと思うのですが、3では実装されていないようです。 作業する方には各自クライント端末の.ssh/configに以下のようなproxy設定をしてもらうことにします。

Host fumidai.example.com
    HostName fumidai.example.com
    User ec2-user
    Port 12345

Host 172.31.*(VPC内のネットワークアドレスがこれに収まる)
  User ec2-user
  ProxyCommand nohup ssh fumidai.example.com exec nc %h %p

こうすると手元の端末から$ssh 172.31.1.1と実行した場合、fumidai.example.comsshしてからさらに172.31.1.1にsshすることになります。 もし手元の端末が172.31.なネットワーク環境下にいた場合は正しく動作しないかもしれません。ネットワークがかぶらない前提の設定です。

デプロイ実行時に動的にroleの設定をする

負荷によってec2インスタンスが増減するのでデプロイ時にインスタンスのタグを参照して、条件に該当するipをroleに設定するようにします。 stagingデプロイの際、インスタンスにweb=stagingのタグがあればこのインスタンスのipがrole:webとしてセットされます。 app=stagingがあればrole:appにもセットされます。 インスタンスのipを返すスクリプト自体は:gateway_serverのサーバ上に置いて、デプロイ時にssh越しでそれを叩いています。

  • lib/capistrano/tasks/aws.cap
# -*- coding: utf-8 -*-
#
namespace :aws do
  desc "role設定する"
  task :set_roles do
    on fetch(:gateway_server) do
      ["proxy", "web", "app", "db"].each do |role|
        ips = capture("ec2_roles.rb #{fetch(:stage)} #{role}).to_s.split(/\s+/)
        unless ips.empty?
          role role.to_sym, ips
          puts role
          puts ips
        end
      end
    end
  end
end
  • ec2_roles.rb
require 'aws-sdk'
roles = ARGV.shift
roles ||= "app"
stage = ARGV.shift
stage ||= "staging"

AWS.config(YAML.load(File.read('config.yml')))

ips = []
AWS::EC2.new.instances.tagged("#{stage}:role:#{role}").tagged_values("1").each{|ec2|
  ips << ec2.private_ip_address.to_s if ec2.status.to_s == "running"
}

puts ips

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

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

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

こんな流れでやってました。今回も「何か違うものでやりたい!」と思って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個作ったりするかもしれないので区別できるように変数を用意します)

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を使いたいところでもあります)

アクセスログのフォーマットを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;
    ...
    }
}

メンテ中の場合、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;
}

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($binary_remote_addr)をキーにしてアクセスをカウントする例が多いのですが、オープンソーシャルなWEBサイトということもあり、プラットフォームのサーバのIPがクライアントIPになっていたり、携帯端末からアクセスしてきた場合は各キャリアが持つプロキシサーバのIPだったりするので、$binary_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アプリではセッション管理にRedisを使用することになっているのですが、このセッションの中に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に設定する
    set $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[\x00-\x1F]([^\;]+)\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を参照できません(空扱い) 。最初に来た人達に対して「キーが空」としてアクセス制限のテーブルを参照してしまうとすぐにアクセス超過となってしまいます。 空の場合の処理を調べてみたところ、キーが空の場合はアクセス制限処理がスルーされるようなので、とりあえず問題は起きなさそうです。

内部用の名前解決DNSを用意する

物理サーバでインフラ環境を作っていた時は、サーバに固定IPをつけたりネットワークアドレスも「10.1.1.0/24がAPPSセグメントで10.1.2.0/24がDBセグメントで・・・」と設計することがほとんどでした。 EC2を試した時はデフォルトでDHCPな環境だったので、AWSで構築するときはネットワークアドレス、IPアドレスをなるべく意識しなくても良いようにしたいと考えています。

$ssh staging-app-1のようにhost名でどのサーバかある程度識別できるようにはしたいので、自前で名前解決を用意することにします。 自分にとってはBINDでの構築が手っ取り早いので、即決でBINDを採用します。

このDNSサーバはVPC内のクライアントからDNS問い合わせを受け付けて、この案件用の内部ドメイン例えばexample.internalの名前解決を行います。 yahoo.co.jpのような他のドメインの問い合わせの場合はAWSで用意しているDNSにフォワードします。

/etc/named.conf一部

options {
  ....
  forwarders{
    172.31.0.2; 
  };
  ...
};

zone "example.internal" {
        type master;
        file "example.internal.db";
        allow-query { any; } ;
        //allow-transfer{ x.x.x.x; };
        //notify yes;
        //also-notify { x.x.x.x; };
};

いつものDNS運用ならゾーンファイルのレコード情報をたまに更新する程度なのですが、EC2環境の場合だと「インスタンスを新規に起動したからそのインスタンスの名前とIPを調べて、ゾーンファイルに書いて、namedリロードして、、、」なんてことを頻繁にやりそうです。そんな細かい作業を手で毎回やるのもしんどいのでスクリプト化します。

内容としてはVPC内にいるインスタンスのタグNameとプライベートIPを組み合わせたAレコードをゾーンファイルに書き出し、/usr/sbin/named-checkzoneでゾーンファイルに異常がなければnamedをリロードします。

AWS::EC2.new.instances.each {|ins|
  if ins.tags['Name'] && ins.private_ip_address.match(/\d+\.\d+\.\d+\.\d+/)
    p "#{ins.tags['Name']}   IN   A   #{ins.private_ip_address}"
  end
}

リバースプロキシ用のnginxをインストールする

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

  • インターネット側からのHTTPリクエストをappsサーバにプロキシする
    • appsサーバの数が負荷に応じて動的に変わるので、nginxはappsサーバを束ねるELBにプロキシするようにします。
  • appsサーバから5秒たっても応答が無い場合、nginx側で強制的にソーリーページを返す
    • 5秒ルールというものがあるらしいです。
  • メンテ中はどんなリクエストにも一律同じソーリーページを返す
  • 同一ユーザによる短時間の大量アクセス、同時アクセスを制御する(連打対策)
    • システムの負荷も考慮して、appsサーバまで無駄なリクエストを通したくないため。
  • 過去のアクセスログを検索できるようにする
    • ユーザからの問い合わせがあったときに行動調査が後日でも行えるようにするためです(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