インフラブログ

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

AWS CloudFront経由で静的コンテンツを配信する

画像、音声、css、jsなど静的コンテンツをAWS CloudFront経由で配信してwebサーバの負荷を軽減するようにします。 CloudFrontはリバースプロキシ型のキャッシュサーバのように動作します。

CloudFrontを試していたところ一つ問題を確認しています。クライアント端末がgoogleのパブリックDNSを使っている場合だと、CloudFrontからのリソース読み込みが遅くなる可能性があります。(参考になる記事) 今のところサーバサイドで解決する方法はわかっていません。2014-4-14:AWSのほうで対応したようです

構成

動的コンテンツと静的コンテンツのURLのドメインをあらかじめ分けておいて、静的コンテンツのリクエストはCloudFrontのほうに届くようにしておきます。静的コンテンツ配信のドメインをimage.staging.example.comとします。

端末
|  リクエスト http://image.staging.example.com/assets/xxxx 
|
CloudFront [image.staging.example.com]
|  キャッシュがCloudFrontになければ、下のoriginからリソースを取得してきて端末に配信する
| (キャッシュできるものであればキャッシュする)
|
web server(origin) [origin.staging.example.com]
|  CloudFrontからのリクエストに応じてリソースを配信する
|
app server

アプリがRails製なのでasset_hostで指定します。

  • config/environments/staging.rb
config.action_controller.asset_host = "image.staging.example.com"

CloudFrontディストリビューションの作成

スクリプトで作成できるようにしておきます。

stage = ARGV.shift
stage ||= "staging"

asset_host = "image.#{stage}.example.com"
origin_host = "origin.#{stage}.example.com"

cf = AWS::CloudFront.new

cf.client.create_distribution({
  :distribution_config => {
    :caller_reference => Time.now.to_i.to_s,
    :aliases => {:items => [asset_host], :quantity => 1},
    :default_root_object => "index",
    :origins => {
      :items => [{
        :id=>"Custom-#{origin_host}",
        :domain_name => origin_host,
        :custom_origin_config => {
          :http_port => 80,
          :https_port => 443,
          :origin_protocol_policy => "http-only"
        }
      }],
      :quantity => 1
    },
    :default_cache_behavior => {
      :target_origin_id => "Custom-#{origin_host}",
      :forwarded_values => {
        :query_string => false,
        :cookies=>{:forward => "none"}
      },
      :trusted_signers => {
        :items => [], :enabled => false, :quantity => 0
      },
      :viewer_protocol_policy => "allow-all",
      :min_ttl => 0,
      :allowed_methods => {:items => ["GET", "HEAD"], :quantity => 2}
    },
    :cache_behaviors => {:items => [], :quantity => 0},
    :comment => "#{stage}",
    :logging => {
      :enabled => false,
      :include_cookies => false,
      :bucket => "",
      :prefix => "",
    },
    :price_class => "PriceClass_All",
    :enabled => true,
    :viewer_certificate => {:cloud_front_default_certificate => true},
    :restrictions => {
      :geo_restriction => {:items => [], :restriction_type => "none", :quantity => 0}
    },
  }
})

resp = cf.client.list_distributions
resp[:items].each do |distribution|
  pp distribution
end

DNSの設定

CloudFrontでディストリビューションを作成した後、image.staging.example.comDNS AレコードをCloudFrontで作成したディストリビューションエイリアスに設定します。

AWSのScheduled Scalingを使って日時に合わせてインスタンス数を調整する

時間帯やイベント開催中かどうかによってアクセス数が変動すると思われるので、appsサーバのインスタンス増減をスケジューリングできるようにします。以下の想定でスケジュールを設定してみます。

スケジュールの例

イベント非開催時の通常時

  • 深夜過疎(1:00-6:00)
    • ベースn=1台とする(下記ソースのSIZE_SMALL)
  • 通常時
    • n+1台(下記ソースのSIZE_MEDIUM)
  • 夜間ピーク時(18:00-25:00)
    • n+2台(下記ソースのSIZE_LARGE)

イベント開催時

  • 一週間継続して行われるイベント(例: 1月14日〜20日の全日)
    • ベースのnを+3する。(下記ソースのEXTRA1)
  • 短時間型イベント(例: 毎月29日の12:00-13:00)
    • n+5台(下記ソースのEXTRA2)

スケジュールの設定方法

一旦wheneverを使ってスケジュール定義をする

AWS Scheduled Scalingではcronフォーマットで日時指定できます。 Wheneverを使ってスケジュールの定義とcron形式への出力を行い、改めてas.scheduled_actions.createを叩いてスケジュールを登録するようにします。 wheneverでそのままcrontabを作成して、cronでのオートスケーリングのスケジュール管理をすることも考えたのですが、cronは指定の時間にコマンド叩くだけでちゃんとスケジュールされているかどうかまで事前に確認できないので、AWSのスケジューラを使うようにします。

台数、発動時間の指定

  • config/auto_scaling_schedule_staging.rb

運用スケジュールやサーバ負荷に応じて随時このファイルを編集します。

# -*- coding: utf-8 -*-
# 日付、時間帯によるインスタンス数の調整を行うタイミングをcronで管理する
# (指定の時間になったらaws autoscaling put-scheduled-update-group-actionを実行するだけ)
# 日時はUTC指定

EXTRA1 = 3 #イベント1時のインスタンス追加分
EXTRA2 = 5 #イベント2の時のインスタンス追加分
SIZE_SMALL = 1 # 過疎時間帯のインスタンス数
SIZE_MEDIUM = 2 # 通常時のインスタンス数
SIZE_LARGE = 3 # 夜間のピークタイムのインスタンス数

# イベント1の開催日(1週間程度開催されるキャンペーンなど)
event1_days = [17,18,19,20,21,22,23]
# イベント2の開催日(12:00-13:00に開催するタイムセールスのような短時間のイベント)
event2_days = [29]

# event1
(1..31).each{|day|
  if event1_days.include?(day) 
    extra = EXTRA1
    event = "_event"
  else
    extra = 0
    event = "" 
  end
  # 6:00-18:00
  every "00 21 #{day} * *" do
    command "daytime#{event} #{SIZE_MEDIUM + extra}"
  end
  # 18:00-25:00
  every "00 09 #{day} * *" do
    command "night#{event} #{SIZE_LARGE + extra}"
  end
  # 25:00-6:00
  every "00 16 #{day} * *" do
    command "midnight#{event} #{SIZE_SMALL + extra}"
  end
}

# event2
event2_days.each{|day|
  event1_days.include?(day)?extra = EXTRA1 : extra = 0
  # 10:30-13:20
  every "30 01 #{day} * *" do
    command "event2_scale_out #{SIZE_MEDIUM + EXTRA2}"
  end
  every "20 04 #{day} * *" do
    command "event2_scale_in #{SIZE_MEDIUM + extra}"
  end
}

AWS Auto Scaling scheduled actionsの更新

  • auto_scaling_schedule_update.rb

wheneverでcronフォーマットを出力してそれを強引に整形してscheduled_actions.createに渡しています。 cap staging update_auto_scaling_scheduleでこのスクリプトが実行されるようにします。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
$VERBOSE=nil
require "pp"
require "aws-sdk"
require "whenever"
require "active_support/core_ext/kernel/reporting"

stage = ARGV.shift
stage ||= "staging"
group = "#{stage}-app-scaling-group"

AWS.config(YAML.load(File.read('/etc/scripts/aws/config.yml')))

as = AWS::AutoScaling.new

as.scheduled_actions.filter(:group => group).each{|schedule_action|
  schedule_action.delete
}

cron = Whenever.cron({:file => "auto_scaling_schedule_#{stage}.rb"}).split("\n")
cron.each{|line|
  if md = line.match(/^(.+)\/bin\/bash -l -c \'(.+) (.+)\'$/)
    pp line
    as.scheduled_actions.create(md[2],
      :group => group,
      :desired_capacity => md[3].to_i,
      :recurrence => md[1],
      :min_size => md[3].to_i,
      :max_size => md[3].to_i
    )
  end
}

#確認
system("aws autoscaling describe-scheduled-actions --auto-scaling-group-name #{group} --output json | jq '.ScheduledUpdateGroupActions []| {ScheduledActionName, Time, StartTime, MinSize, MaxSize, DesiredCapacity, Recurrence }'")

exit(0)
  • 実行結果
"00 21 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * * /bin/bash -l -c 'daytime 2'"
"00 09 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * * /bin/bash -l -c 'night 3'"
"00 16 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * * /bin/bash -l -c 'midnight 1'"
"00 21 17,18,19,20,21,22,23 * * /bin/bash -l -c 'daytime_event 5'"
"00 09 17,18,19,20,21,22,23 * * /bin/bash -l -c 'night_event 6'"
"00 16 17,18,19,20,21,22,23 * * /bin/bash -l -c 'midnight_event 4'"
"30 01 29 * * /bin/bash -l -c 'event2_scale_out 7'"
"20 04 29 * * /bin/bash -l -c 'event2_scale_in 2'"
{
  "Recurrence": "00 16 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * *",
  "DesiredCapacity": 1,
  "MaxSize": 1,
  "MinSize": 1,
  "StartTime": "2014-02-12T16:00:00Z",
  "Time": "2014-02-12T16:00:00Z",
  "ScheduledActionName": "midnight"
}
{
  "Recurrence": "00 21 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * *",
  "DesiredCapacity": 2,
  "MaxSize": 2,
  "MinSize": 2,
  "StartTime": "2014-02-12T21:00:00Z",
  "Time": "2014-02-12T21:00:00Z",
  "ScheduledActionName": "daytime"
}
{
  "Recurrence": "00 09 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,24,25,26,27,28,29,30,31 * *",
  "DesiredCapacity": 3,
  "MaxSize": 3,
  "MinSize": 3,
  "StartTime": "2014-02-13T09:00:00Z",
  "Time": "2014-02-13T09:00:00Z",
  "ScheduledActionName": "night"
}
{
  "Recurrence": "00 09 17,18,19,20,21,22,23 * *",
  "DesiredCapacity": 6,
  "MaxSize": 6,
  "MinSize": 6,
  "StartTime": "2014-02-17T09:00:00Z",
  "Time": "2014-02-17T09:00:00Z",
  "ScheduledActionName": "night_event"
}
{
  "Recurrence": "00 16 17,18,19,20,21,22,23 * *",
  "DesiredCapacity": 4,
  "MaxSize": 4,
  "MinSize": 4,
  "StartTime": "2014-02-17T16:00:00Z",
  "Time": "2014-02-17T16:00:00Z",
  "ScheduledActionName": "midnight_event"
}
{
  "Recurrence": "00 21 17,18,19,20,21,22,23 * *",
  "DesiredCapacity": 5,
  "MaxSize": 5,
  "MinSize": 5,
  "StartTime": "2014-02-17T21:00:00Z",
  "Time": "2014-02-17T21:00:00Z",
  "ScheduledActionName": "daytime_event"
}
{
  "Recurrence": "30 01 29 * *",
  "DesiredCapacity": 7,
  "MaxSize": 7,
  "MinSize": 7,
  "StartTime": "2014-03-29T01:30:00Z",
  "Time": "2014-03-29T01:30:00Z",
  "ScheduledActionName": "event2_scale_out"
}
{
  "Recurrence": "20 04 29 * *",
  "DesiredCapacity": 2,
  "MaxSize": 2,
  "MinSize": 2,
  "StartTime": "2014-03-29T04:20:00Z",
  "Time": "2014-03-29T04:20:00Z",
  "ScheduledActionName": "event2_scale_in"
}

AWS Auto Scalingでunicornサーバとresqueサーバの台数を調節する 

ピークタイムやイベント開催中かどうかによってWEBサイトの負荷が大きく変動するのが予想されるので、オートスケーリングを使用してインスタンスを必要な時に必要な台数だけ確保するようにしてサーバ費用を抑えるようにします。

どのサーバにオートスケーリングを利用するか

以下2つのサーバ群に対してオートスケーリングを利用します。

費用けちってサービスレベルがどのくらい下がるか

オンデマンドのEC2を1台だけ固定で用意して、残り必要な台数はオートスケーリングで起動することにします。

サーバ費用をけちるためにスポットインスタンスでオートスケーリングすることにしたので、一時的にスポットインスタンスの料金が急騰した場合にスポットインスタンスがターミネートされて固定分の1台しか稼働していないという状況になることが想定されます。

1台では当然捌けずメンテナンス行きとなるので、メンテ中にスポットの料金上限を引き上げたり、別のインスタンスタイプに変更するなどしてインスタンスを確保する必要があります。

スポットインスタンスの料金が頻繁に急騰しやすい、オートスケール発動時にインスタンスリソースを確保できないなど運用が安定しないようであれば、固定サーバ分を増やして、オートスケールを使わないようにしようと思います(費用も考慮しますが)

スクリプトで新規作成する

AWS Management Consoleから設定可能ですが、いつものようにスクリプトで新規作成できるようにしておきます。 appサーバ(unicorn)のオートスケーリングとbackground(resque-work)の2つそれぞれ作成します。

以下の設定を一気に作成します。

  • launch config
  • auto scaling group
    • どのlaunchconfigを使うか・最大、最小インスタンス数、どのELBに所属するかなど
  • policy(インスタンス増設時、縮小時)
  • cloudwatch metric alarm(CPU高負荷、低負荷)
    • Cloudwatchでの観測値をAutoScalingの発動条件にする設定

インスタンス起動後にELBにつなぐかどうか

appサーバはELBにつなぐため、as.create_auto_scaling_group内で:load_balancer_names => [elb],としてますが、resque-workサーバはELBにつなぐ必要がないため、この記述はしません。

スポットインスタンスにするかオンデマンドにするか

as.create_launch_configuration内に:spot_price => 料金を記述した場合はスポットインスタンス、記述していない場合はオンデマンドインスタンスで立ち上がります。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-

require 'aws-sdk'
require 'base64'

stage = ARGV.shift
stage ||= "staging"

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

as = AWS::AutoScaling.new.client
cw = AWS::CloudWatch.new.client

template_name="#{stage}-app"
launch_config = "#{stage}-launch-config"
auto_scaling_group = "#{stage}-app-scaling-group"
policy_add = "#{stage}-scaling-policy-add"
policy_remove = "#{stage}-scaling-policy-remove"
ssh_key = "sshkey"
security_group = stage
instance_type = stage == "production" ? "c1.xlarge" : "m1.small"
spot_price = stage == "production" ? "0.76" : "0.05"
min_size = stage == "production" ? 1 : 0
max_size = stage == "production" ? 1 : 1
elb = "#{stage}-lb-1"
cooldown_time = 300
cooldown_time_add = 300
cooldown_time_remove = 3000
adjustment_add = stage == "production" ? 5 : 1
adjustment_remove = stage == "production" ? 5 : 1
az = ["ap-northeast-1a", "ap-northeast-1c"] 
user_data = Base64.encode64("RAILS_ENV=#{stage}\nSERVER_ROLES=#{stage}-app\nSTAGE=#{stage}\nEC2_NAME=#{stage}-app-autoscale")
interval = 1200

scaleout_policy = "#{stage}-scaleout-policy"
scalein_policy = "#{stage}-scalein-policy"
scaleout_alarm = "#{stage}-scaling-cpu-high"
scalein_alarm = "#{stage}-scaling-cpu-low"

#テンプレートとなるインスタンスののAMI-IDを取得 (ec2についているタグ「template=staging-app」というものをテンプレートとみなす)
ami_id = AWS::EC2.new.images.tagged("template").tagged_values(template_name).map(&:id)[0]

#launch-configの作成
  as.create_launch_configuration(
    :launch_configuration_name => launch_config,
    :image_id => ami_id, #どのAMIを使うか
    :key_name => ssh_key, # ec2-userに登録するssh公開鍵の設定
    :security_groups => [security_group], #どのセキュリティグループに属するか
    :instance_type => instance_type, # インスタンスタイプ
    :spot_price => spot_price, # スポットプライスの許容料金
    :user_data => user_data, #インスタンス起動時に設定するuserdata
  )

#auto scaling groupの作成
  as.create_auto_scaling_group(
    :auto_scaling_group_name => auto_scaling_group,
    :launch_configuration_name => launch_config, #どのlaunchconfigを用いてオートスケーリングするか
    :availability_zones => [az[0]], # どのアベイラビリティゾーンに配置するか
    :desired_capacity => min_size, # 維持したい台数
    :min_size => min_size, #縮小できる最少台数
    :max_size => max_size, #増設できる最大台数
    :default_cooldown => cooldown_time, # 次のオートスケーリングが発動可能になるまでの時間間隔
    :load_balancer_names => [elb], # そのelbに所属するか
    :tags => [ {:key => "#{stage}:role:app" , :value => "1"}, {:key => "#{stage}:role:web" , :value => "1"} ],
  )

#policy(インスタンス増設時)作成
  as.put_scaling_policy(
    :policy_name => policy_add,
    :auto_scaling_group_name => auto_scaling_group,
    :adjustment_type => "ChangeInCapacity", # 指定した値だけ台数を増減させる
    :scaling_adjustment => adjustment_add,  # 1回のオートスケーリングアクティビティで減らす台数
    :cooldown => cooldown_time_add, # 次のオートスケーリングが発動可能になるまでの時間間隔
  )
#policy(インスタンス縮小時)作成
  as.put_scaling_policy(
    :policy_name => policy_remove,
    :auto_scaling_group_name => auto_scaling_group,
    :adjustment_type => "ChangeInCapacity", # 指定した値だけ台数を増減させる
    :scaling_adjustment => adjustment_remove, # 1回のオートスケーリングアクティビティで減らす台数
    :cooldown => cooldown_time_remove, # 次のオートスケーリングが発動可能になるまでの時間
  )

scaleout_arn = as.describe_policies(:policy_names => [policy_add])[:scaling_policies][0][:policy_arn]
scalein_arn = as.describe_policies(:policy_names => [policy_remove])[:scaling_policies][0][:policy_arn]

# Cloudwatchの観測値をAutoScalingの発動条件にする設定(CPU高負荷)
cw.put_metric_alarm(
  :alarm_name => scaleout_alarm,
  :alarm_description => "",
  :actions_enabled => true,
  :alarm_actions => [scaleout_arn],  #しきい値超えと判定した際に行うアクション
  :metric_name => 'CPUUtilization', #測定項目にCPU使用率
  :namespace => 'AWS/EC2', 
  :statistic => 'Average', 'Average', #平均を測定値とする
  :dimensions => [{:name => 'AutoScalingGroupName', :value => auto_scaling_group}], # 測定対象
  :period => 60,
  :evaluation_periods => 15, # 60秒x15回=15分間観測値が超えていたら、しきい値超えと判定
  :threshold => 95, # 95%
  :comparison_operator => 'GreaterThanThreshold',# 以上だったら
)

# Cloudwatchの観測値をAutoScalingの発動条件にする設定(CPU低負荷)
cw.put_metric_alarm(
  :alarm_name => scalein_alarm,
  :alarm_description => "",
  :actions_enabled => true, 
  :alarm_actions => [scalein_arn], #しきい値超えと判定した際に行うアクション
  :metric_name => 'CPUUtilization', #測定項目にCPU使用率
  :namespace => 'AWS/EC2', 
  :statistic => 'Average', #平均を測定値とする
  :dimensions => [{:name => 'AutoScalingGroupName', :value => auto_scaling_group}], # 測定対象
  :period => 60, # 60秒に1回測定
  :evaluation_periods => 15, # 60秒x15回=15分間観測値が超えていたら、しきい値超えと判定
  :threshold => 10, # しきい値=10%
  :comparison_operator => 'LessThanThreshold',# 以下だったら
)

#launch configの表示
p as.describe_launch_configurations(:launch_configuration_names => [launch_config])[:launch_configurations]
# auto scaling groupの表示
p as.describe_auto_scaling_groups(:auto_scaling_group_names => [auto_scaling_group])[:auto_scaling_groups]
# インスタンス増設時のpolicy表示
p as.describe_policies(:policy_names => [policy_add])
# インスタンス縮小時のpolicy表示
p as.describe_policies(:policy_names => [policy_remove]) 
# cluodwatchのしきい値超え時の設定表示(CPU高負荷)
p cw.describe_alarms(:alarm_names => [scaleout_alarm])[:metric_alarms]
# cluodwatchのしきい値超え時の設定表示(CPU低負荷)
p cw.describe_alarms(:alarm_names => [scalein_alarm])[:metric_alarms]

今回は新規作成の手順でした

オートスケールを機能させるにはいくつかのconfig設定が必要で、今回まとめて新規作成を行いました。 アプリがデプロイされたら、LaunchConfigを新しいAMIで作りなおしてAutoScalingGroupも更新してと日々アップデート作業が必要になります。

また、今回はオートスケール発動条件に・cpu90%以上で増設・cpu10%以下縮小という設定を行いましたが、他にも深夜1:00-5:00はインスタンスを縮小するなどスケージュール設定を行うこともできます。実はこのスケジュール機能が使えそうと思っていて、このためにオートスケーリングを設定していたりもします。 スケジュール設定は次の記事に書き残しておきます。

AWS SNSでメール通知を行う

AWS Cloudwatchで監視を行っていてしきい値超えを検知した場合に、AWS SNSで設定されている通知を行う事ができます。 SNSでの通知としては・メールを送信・指定のURLにアクセスする・APNsにPush通知を行うなどがありますが、今回は指定のメールアドレスにメールを送信します。

下記のように3つのトピックを用意して、アラートの重要度に合わせて通知先を振り分けるようにします。

  • systemalert
    • ただちにサービスレベルに影響しない(ディスク残量低下、オートスケール発動の通知)
  • systemcritical
    • サービスレベルに影響がある(ELBのレスポンスタイムが2秒以上、RDSのレプリケーション遅延を10秒以上検知)
  • systemservicedown
    • サービスダウンしている状態(サイトヘルスチェックでNG)

トピックアクションとして、以下のように指定のメールアドレスにメール送信を行うようにします。

  • systemalert-{stage|production}@watch.example.com
  • systemcritical-{stage|production}@watch.example.com
  • systemservicedown-{stage|production}@watch.example.com
  • 上記メールアドレスにメールが送られてきたらskepeのアラート部屋にも発信したいので、watchサーバ上の/etc/aliasesで設定をしておきます。 設定はこんなかんじ

上記トピックをスクリプトで新規作成できるようにしておきます。 なおトピックのサブスクリプション作成時に、指定したメールアドレスに確認用のメールが送信されるので、受信してメールの中の確認用URLにアクセスする必要があります。

stage = ARGV.shift
stage ||= "staging"
az = ARGV.shift
az ||="main"
domain = "@watch.example.com"
topics = ["systemalert-#{stage}", "systemcritical-#{stage}", "systemservicedown-#{stage}"]

AWS.config(YAML.load(File.read('/etc/scripts/aws/config.yml')))

client = AWS::SNS::Client.new

topics.each{|topic|
  # create topic
  response = client.create_topic(
    :name => topic
  )
  # subscript
  client.subscribe(
    :topic_arn => response[:topic_arn],
    :protocol => "email",
    :endpoint => topic + domain
  )
}

AWS RDSでMySQLサーバを立ち上げる

RDSを使ってmysqlをmaster,slaveのレプリケーション構成で立ち上げます。 redisと同じようにスクリプトでセットアップできるようにします。パラメータは初期パラメータとしてとりあえずの設定をしています。 MultiAZでマスタを立ち上げるとactiveがap-northeast-1cになる場合があるので、その時はfailover付きでリブートし直します。

AWS RDSMySQLサーバを立ち上げる


RDSを使ってmysqlをmaster,slaveのレプリケーション構成で立ち上げます。
redisと同じようにスクリプトでセットアップできるようにします。パラメータは初期パラメータとしてとりあえずの設定をしています。
MultiAZでマスタを立ち上げるとactiveがap-northeast-1cになる場合があるので、その時はfailover付きでリブートし直します。

stage = "staging" master = "#{stage}-master-1" slave = "#{stage}-slave-1" subnet_group = "#{stage}-rds-subnet" param_group = "#{stage}-mysql56" param_group_family = "mysql5.6" option_group = "#{stage}-mysql56" subnets = ["subnet-xxx", "subnet-xxx"] availability_zones = ["ap-northeast-1a", "ap-northeast-1c"] instance_class = "db.m1.small" storage = 10 db_name = "dbname_#{stage}" username = "xxx" password = "xxx" engine_version = "5.6.13" stage == "production" ? sg = "sg-xxx" : sg="sg-xxx" stage == "production" ? arn = "xxx" : arn = "xxx" event_categories = ["availability", "configuration change", "creation", "deletion", "failover", "failure", "low storage", "maintenance", "notification", "recovery", "restoration"]

client = AWS::RDS::Client.new

オプショングループ作成

client.create_db_parameter_group( :option_group_name => option_group_name, :engine_name => :mysql, :major_engine_version => "5.6", :option_group_description => "#{stage} param group" )

パラメータグループ作成

client.create_db_parameter_group( :db_parameter_group_name => param_group, :db_parameter_group_family => param_group_family, :description => "#{stage} param group" )

パラメータ変更

client.modify_db_parameter_group( :db_parameter_group_name => param_group, :parameters => [ {:parameter_name => "character_set_client", :parameter_value => "utf8mb4", :apply_method => "immediate"}, {:parameter_name => "character_set_connection", :parameter_value => "utf8mb4", :apply_method => "immediate"}, {:parameter_name => "character_set_database", :parameter_value => "utf8mb4", :apply_method => "immediate"}, {:parameter_name => "character_set_server", :parameter_value => "utf8mb4", :apply_method => "immediate"}, {:parameter_name => "character_set_results", :parameter_value => "utf8mb4", :apply_method => "immediate"}, {:parameter_name => "collation_connection", :parameter_value => "utf8mb4_general_ci", :apply_method => "immediate"}, {:parameter_name => "init_connect", :parameter_value => "SET NAMES utf8mb4;", :apply_method => "immediate"}, {:parameter_name => "max_connect_errors", :parameter_value => "999999999", :apply_method => "immediate"}, {:parameter_name => "max_allowed_packet", :parameter_value => "67108864", :apply_method => "immediate"}, {:parameter_name => "slow_query_log", :parameter_value => "ON", :apply_method => "immediate"}, {:parameter_name => "long_query_time", :parameter_value => "0.1", :apply_method => "immediate"}, {:parameter_name => "min_examined_row_limit", :parameter_value => "1000", :apply_method => "immediate"}, {:parameter_name => "query_cache_type", :parameter_value => "1", :apply_method => "pending-reboot"}, {:parameter_name => "general_log", :parameter_value => "OFF", :apply_method => "immediate"}, {:parameter_name => "wait_timeout", :parameter_value => "60", :apply_method => "immediate"}, {:parameter_name => "lock_wait_timeout", :parameter_value => "3", :apply_method => "immediate"}, {:parameter_name => "skip_name_resolve", :parameter_value => "ON", :apply_method => "pending-reboot"}, {:parameter_name => "log_output", :parameter_value => "FILE", :apply_method => "immediate"},

] )

サブネットグループ作成

client.create_db_subnet_group( :db_subnet_group_name => subnet_group, :db_subnet_group_description => "#{stage} subnet group" , :subnet_ids => subnets )

マスタDB作成

client.create_db_instance( :db_name => db_name, :db_instance_identifier => master, :allocated_storage => storage, :db_instance_class => instance_class, :engine => "mysql", :master_username => username, :master_user_password => password, :vpc_security_group_ids => [sg], :db_subnet_group_name => subnet_group, #:availability_zone => availability_zones[0], :db_parameter_group_name => param_group, :option_group_name => option_group, :preferred_maintenance_window => "mon:05:30-mon:06:30", :backup_retention_period => 30, :preferred_backup_window => "19:00-20:00", :port => 3306, :multi_az => true, :engine_version => engine_version, :auto_minor_version_upgrade => false, )

完成を待つ

while "available" != client.describe_db_instances(:db_instance_identifier => master)[:db_instances][0][:db_instance_status] puts "waiting for creating master" sleep 30 end

スレーブDB作成

client.create_db_instance_read_replica( :db_instance_identifier => slave, :source_db_instance_identifier => master, :db_instance_class => instance_class, :availability_zone => availability_zones[0], :port => 3306, :auto_minor_version_upgrade => false, )

完成を待つ

while "available" != client.describe_db_instances(:db_instance_identifier => slave)[:db_instances][0][:db_instance_status] puts "waiting for creating slave" sleep 30 end

イベント通知の設定を行う

client.create_event_subscription( :subscription_name => "#{stage}-event", :sns_topic_arn => arn, :source_type => "db-instance", :event_categories => event_categories, :source_ids => [master, slave], :enabled => true )

AWS ElasticacheでRedisサーバを立ち上げる

Redisを使用するのでelasticacheのredisでmaster-slaveのレプリケーション構成を作ります。 手順は以下のようになります。

  • パラメータグループを新規作成、一部のパラメータを変更する(aof,timeout)
  • このパラメータグループを使うノードを作成する(マスターノードとなる)
  • このノードをマスターとするレプリケーショングループを作成する
  • このレプリケーショングループに所属するノードをつくる(スレーブノードとなる)

AWS Mamagement Consoleからでもさくさくと作れますが、複製を作るかもしれないのでスクリプトにしておきます。

なお、アプリからredisに接続する時のエンドポイントはレプリケーショングループID.xxx.0001.apne1.cache.amazonaws.com:6379といた具合になります。

master = "#{stage}-master-1"
slave = "#{stage}-slave-1"
repl_group = "#{stage}-redis-1"
subnet_group = "#{stage}-redis-subnet"
param_group = "#{stage}-redis26"
family_param_group = "redis2.6"
availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
node_type = "cache.m1.small"
arn = "xxx"
security_group = "xxx"
subnets = ["subnet-xxx", "subnet-xxx"]
redis_version = "2.6.13"

client = AWS::ElastiCache::Client.new

#パラメータグループの作成
client.create_cache_parameter_group(
  :cache_parameter_group_name => param_group,
  :cache_parameter_group_family => family_param_group,
  :description => "#{param_group}",
)

#パラメータ変更
client.modify_cache_parameter_group(
  :cache_parameter_group_name => param_group,
  :parameter_name_values => [ {:parameter_name => "timeout", :parameter_value => "300"}, {:parameter_name => "appendonly", :parameter_value => "yes"} ]
)

#サブネットグループの作成
client.create_cache_subnet_group(
  :cache_subnet_group_name => subnet_group,
  :cache_subnet_group_description => "#{stage} redis subnet group",
  :subnet_ids => [subnets[0]]
)

#マスターノードの作成
client.create_cache_cluster(
  :cache_cluster_id => master,
  :num_cache_nodes => 1,
  :cache_node_type => node_type,
  :engine => "redis",
  :engine_version => redis_version,
  :cache_parameter_group_name => param_group,
  :cache_subnet_group_name => subnet_group,
  :security_group_ids => [security_group],
  :preferred_availability_zone => availability_zones[0],
  :preferred_maintenance_window => "mon:19:00-mon:20:00",
  :notification_topic_arn => arn,
  :auto_minor_version_upgrade => false
)

#マスターの作成を待つ
while "available" != client.describe_cache_clusters(:cache_cluster_id => master)[:cache_clusters][0][:cache_cluster_status]
  sleep 30
end

#レプリケーショングループの作成
client.create_replication_group(
  :replication_group_id => repl_group,
  :primary_cluster_id => master,
  :replication_group_description => "#{stage} replication group"
)

#レプリケーショングループの作成を待つ
while "available" != client.describe_replication_groups(:replication_group_id => repl_group)[:replication_groups][0][:status]
  sleep 30
end

#スレーブノードの作成
client.create_cache_cluster(
  :cache_cluster_id => slave,
  :replication_group_id => repl_group,
)

AWS ELB(Elastic Load Balancing)作成

インターネットからのHTTPリクエストを受けるproxyサーバRailsが動くアプリケーションサーバの間にロードバランサであるELBを挟んで複数のアプリケーションサーバにリクエストを振るようにします。ちなみにELBはZeus製と聞いたことがあります。

AWS Mamagement Consle上のブラウザ操作で簡単作成できるのですが、環境の複製を作るといった場合に同じ操作をするのはしんどいので、スクリプトで作成できるようにします。

こんな感じ

stage = "staging"
subnet = "xxxx"
sg = "xxx"
elb_name = "#{stage}-lb-1"

#ロードバランサの作成
# subnets指定することでそのsubnetsにいるインスタンスだけにフォワードする
# scheme : internalでsubnet内に配置
# http(tcp:80)をlistenしてhttp(tcp:80)にフォワードする

lb = AWS::ELB::LoadBalancerCollection.new.create(elb_name,
  :subnets =>  [subnet],
  :security_groups => [sg],
  :scheme => "internal",  
  :listeners => [{
    :port => 80,
    :protocol => :http,
    :instance_port => 80,
    :instance_protocol => :http,
  }]
)

#ヘルスチェックの設定
# unhealthy_threshold 
#   ヘルスチェックの結果、連続okでない回数がこの指定値以上になれば
#   インスタンスにリクエストを送らない(out of service)
# healthy_threshold
#   out of serviceのインスタンスにヘルスチェックを行った際、この指定値以上に連続okとなれば
#   このインスタンへのリクエスト配信を再開する(inservice)
# interval - 6秒に1回ELBが配下のインスタンスにヘルスチェック用リクエストを飛ばす
# timeout - 配下のインスタンスからの応答待ち時間(超えたらunhealth+1)
# target - ヘルスチェックのリクエストにtcp:80でGET/healthを使用する
lb.configure_health_check(
  :healthy_threshold => 2,
  :unhealthy_threshold => 10,
  :interval => 6,
  :timeout => 5,
  :target => "HTTP:80/health"
)

#インスタンスをELB配下に登録する(web=stagingのタグを持つインスタンスが対象)
lb.instances.register(AWS::EC2.new.instances.tagged("web").tagged_values(stage).select{|ins| ins })

# クッキーでのスティッキーポリシーの作成
# cookie_name - リクエスト振り分けをスティッキーにする際にキーとして使用する名前
AWS::ELB::Client.new.create_app_cookie_stickiness_policy(
  :load_balancer_name => elb_name,
  :policy_name => "lb-cookie-session",
  :cookie_name => "_session_id"
)

# ELBリスナーにポリシー適用
# tcp80ポートリスナーに上記のcookie sticknessポリシーを適用する
AWS::ELB::Client.new.set_load_balancer_policies_of_listener(
  :load_balancer_name => elb_name,
  :load_balancer_port => 80,
  :policy_names => ["lb-cookie-session"]
)

redis-commanderでredisサーバへの操作を行えるようにする

node.jsで作られたredisマネジメントツールredis-commanderをインストールします。 GUIでredisの操作できます。

手順

$sudo yum install nodejs
$sudo yum install npm
$sudo npm install -g redis-commander

haproxy

redis-commanderからはローカルのhaproxyを経由してredisサーバに接続するようにします。

listen redis
    bind :6379
    mode tcp
    balance source
    server redis1 staging-redis-1.example.internal:6379 inter 5s fall 2 rise 2 check port 6379

monitでプロセス監視を行う

プロセスの起動はmonit経由で行います。

  • /etc/monit.d/staging
check process redis-commander  matching "redis-commander"
  start program = "/usr/bin/redis-commander" as uid ec2-user and gid ec2-user
  stop program = "/usr/bin/killall -9 redis-commander"
  if 5 restarts within 5 cycles then timeout

動作確認

AWSセキュリティグループのポリシー追加

会社のIP、プロキシIPからセキュリティグループappへのtcp:8081接続を許可します。

画面を開く

http://redis-commanderをインストールしたサーバ:8081で画面が開きます。 今回はredidのindex1〜4まで使う予定なのでmore->Add Serverより指定のindex分、接続を行っておきます。

unicorn/resqueのプロセス管理にeyeを使う

unicorn,resqueのプロセス管理に今回はeyeを使うことにします。 はじめBluepillで設定していたのですが、Ruby2.0環境だとBluepillのプロセスがゾンビで溜まりまくるという問題があり、代替をさがしていたところeyeというのがありました。God、Bluepillを参考に作られているということで設定も似た感じかな?と思い採用しています。今のところRuby2.x環境でも問題は起きていません。

EyeとMonitで監視対象のプロセスをわける

デプロイの際に再起動するプロセスをeyeで、それ以外をmonitで監視するようにします。

インストール

bundleでは無くgemで入れてます。 $gem install eye eyeの起動方法はeye load 設定ファイルとなります。 設定ファイルはサーバの役割毎に用意しておきます。 各サーバに環境変数SERVER_ROLEというのを設定していて、この環境変数から読み込む設定ファイルが決まるようします。 これでappsサーバはunicornを起動する設定を読み込み、resqueサーバはresque関連を起動する設定を読み込むことになります。

eyeの設定ファイル

Railsプロジェクトのconfig/eye/以下に設定ファイルをそれぞれ配置しておきました。 以下はunicornとresque-workerプロセスを管理する設定例になります。 各プロセスの起動停止コマンドや、監視内容を設定しています(プロセスのメモリが300m超えたら再起動するとか)

  • config/eye/staging-app.eye
# -*- coding: utf-8 -*-

ENV['RAILS_ROOT'] ||= File.expand_path(File.join(File.dirname(__FILE__), "../.."))
rails_root = ENV['RAILS_ROOT']
rails_env = ENV['RAILS_ENV']
stage = ENV['STAGE']

current_path = "/home/ec2-user/example/#{stage}/current"
shared_path = "/home/ec2-user/example/#{stage}/shared/"

# load submodules, here just for example
Eye.load("./eye/*.rb")

# Eye self-configuration section
Eye.config do
  logger "/var/log/eye/eye.log"
end

Eye.application "#{stage}-app" do

  working_dir current_path

  process("unicorn") do
    pid_file "tmp/pids/unicorn.pid"
    start_command "bundle exec unicorn -Dc config/unicorn/#{stage}.rb -E #{rails_env}"
    stdall "log/unicorn.log"

    # stop signals:
    # http://unicorn.bogomips.org/SIGNALS.html
    stop_signals [:TERM, 10.seconds]

    # soft restart
    restart_command "kill -USR2 {PID}"

    check :cpu, :every => 30, :below => 99, :times => 3
    check :memory, :every => 30, :below => 300.megabytes, :times => [3,5]

    start_timeout 120.seconds
    restart_grace 120.seconds

    monitor_children do
      stop_command "kill -QUIT {PID}"
      check :cpu, :every => 30, :below => 99, :times => 3
      check :memory, :every => 30, :below => 300.megabytes, :times => [3,5]
    end
  end

  process ("resque_worker") do
    pid_file "tmp/pids/resque_worker.pid"
    start_command "bundle exec rake -t -f Rakefile environment resque:work"
    stop_signals [:QUIT, 5.seconds,:TERM, 5.seconds,:KILL]

    start_timeout 120.seconds
    restart_grace 120.seconds

    daemonize false
    env "RAILS_ENV" => rails_env,
        "QUEUE" => "*",
        "INTERVAL" => 1,
        "BACKGROUND" => "yes",
        "PIDFILE" => "tmp/pids/resque_worker.pid"

    stdall "log/resque_worker.log"

    monitor_children do
      stop_command "kill -QUIT {PID}"
      check :cpu, :every => 30, :below => 99, :times => 3
      check :memory, :every => 30, :below => 300.megabytes, :times => [3,5]
    end
  end

end

デプロイタスクも用意する

デプロイ時にunicorn,resqueの再起動が必要になるので、eyeを通して再起動を行えるようにタスクを書いておきます。 なお、unicornの再起動にはUSR2でのリスタートとプロセスを落として起動するの2つの方法で再起動できるようにそれぞれタスクを用意しました。 (ファイルアップロードのデプロイ時はUSR2リロードで良く、currentシンボリックリンクが切り替わる場合は停止、起動を行います)

  • cap staging deploy:reload - eye restartが実行される(unicornのUSR2リロード)
  • cap staging deploy:restart - eye stop,startが実行される)

lib/capistrano/tasks/eye.cap

namespace :eye do

  desc "eye start"
  task :start do
    on roles(:app), in: :parallel do
      execute "sudo /etc/init.d/eye start || true"
    end
  end

  desc "eye stop"
  task :stop do
    on roles(:app), in: :parallel do
      execute "sudo /etc/init.d/eye stop || true"
    end
  end

  desc "eye restart"
  task :restart do
    on roles(:app), in: :sequence, wait: 10 do
      execute "sudo /etc/init.d/eye restart || true"
    end
  end

  desc "eye stop and start"
  task :stop_start do
    on roles(:app), in: :sequence, wait: 20 do |host|
      puts host
      if host.roles.include?(:web)
        execute "ssh gateway elb_remove.rb #{fetch(:stage)} #{host} || true"
      end
      execute "sudo /etc/init.d/eye stop || true"
      execute "sudo /etc/init.d/eye start || true"
      puts "sleep 30"
      sleep 30
      if host.roles.include?(:web)
        execute "ssh gateway elb_add.rb #{fetch(:stage)} #{host} || true"
      end
    end
  end

  desc "eye info"
  task :info do
    on roles(:app), in: :parallel do
      count = 0
      while capture("sudo /etc/init.d/eye info || true").match(/starting/) && count < 18
        puts "loop"
        count = count + 1
        sleep 10
      end
    end
  end

  after :restart, "eye:info"
  after :stop_start, "eye:info"
end

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

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

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を追加して、こちらのステータスコードでエラー状況を判断するようにします。