Rails
EC2
elb

CapistranoでELB + EC2環境にローリングデプロイ

More than 1 year has passed since last update.

ELBにぶら下がっているEC2にCapistranoを利用してローリングデプロイする方法について書きました。
もしもっと良い方法があれば教えてください。

環境

  • rails (4.2.0)
  • unicorn (5.0.0)
  • capistrano (3.4.1)
  • aws-sdk (2.1.36)

ELBにEC2を2つぶら下げる構成でサービスを運用しています。

Capistranoで複数サーバーにデプロイする時の問題点

Capistranoで複数サーバーにデプロイする場合、以下のように記載すれば複数サーバーにデプロイできます。

server 'xx.xx.xx.xx', user: 'user', roles: %w[web web1]
server 'xx.xx.xx.xx', user: 'user', roles: %w[web web2]

ただ、このように記載して複数サーバーにデプロイした場合、以下のような問題がありました。

  • bundle installrake assets:precompile でCPUに負荷がかかり、CPUが100%にはりつくことがある
  • rake assets:precompile に時間がかかるのでサーバーのレスポンスが劣化してしまう

ローリングデプロイにする前に考えたこと

ビルドサーバーを用意して成果物だけを各サーバーに配置する

そもそも各サーバーで bundle installrake assets:compile する必要はなく、ビルドサーバーのようなものを用意してあげて、成果物だけ各サーバーに配信して、unicornをgraceful restartすれば良さそうです。

capistrano-bundle_rsyncを使えば、上記に書いたようなことを実現できます。
ただ、あまりメンテされてない印象だったので採用はいったん見送りました。

ブルーグリーンデプロイ

今のところプロビジョニングツールは導入できていないこともあり、複数サーバー(現状の構成だと4つ)をメンテするのもつらみがあります。
Immutable Infrastructureなどは大変興味はあるものの、今回の課題に対しては、yak shaving感があるので今回は採用を見送りました。(infrastructure as codeをしたさはすごいあるのですが、、、)

ローリングデプロイ

おもに対応コストの点から現状のデプロイフローにそってデプロイを行いつつ、デプロイの前後にELBからデプロイするEC2を切り離して、デプロイ終了後にELBにもう一度くっつけるようにしてみました。

デプロイの流れとしては、以下のようになります。

  1. web1をELBから切り離す
  2. デプロイ実行
  3. web1をELBにつけて、web1が in serviceになるまで待つ
  4. web2をELBから切り離す(以下同じ)

まず、以下のようにCapistranoのタスクに elb:remove elb:add をフックします。

config/deploy.rb

before 'deploy:starting',   'elb:remove'
before 'deploy:finishing',  'elb:add'

after 'deploy:publishing', 'deploy:restart'
after 'deploy:failed',     'elb:remove'

Capistranoで実行するrakeタスクをlib/capistrano/tasks/elb.rakeに定義します。
もし CapfileDir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } を記載していないとelb.rakeがロードされないので気をつけてください。

require 'aws-sdk'
require 'dotenv/load'
require 'active_support'
require 'active_support/core_ext'
require 'pry-byebug'

namespace :elb do
  ELB_NAME         = 'elb_name'.freeze
  EC2_INSTANCE_IDS = {
    'xx.xx.xx.xx'  => 'instance_id', # web1
    'xx.xx.xx.xx'  => 'instance_id'  # web2
  }.freeze

  def client
    @client ||= Aws::ElasticLoadBalancing::Client.new(
      access_key_id:     ENV['AWS_ELB_ACCESS_KEY'],
      secret_access_key: ENV['AWS_ELB_SECRET_KEY'],
      region:            'ap-northeast-1' # 東京リージョン
    )
  end

  def instance(hostname)
    {
      instances: [
        {
          instance_id: EC2_INSTANCE_IDS[hostname]
        }
      ],
      load_balancer_name: ELB_NAME
    }
  end

  def in_service?
    client.wait_until(:instance_in_service, { load_balancer_name: ELB_NAME }) do |w|
      w.max_attempts = 8
      w.delay = 10
    end
  rescue Aws::Waiters::Errors::WaiterFailed
    raise 'Resource did not enter the desired state in time'
  end

  desc 'Remove EC2 instance from ELB'
  task :remove do
    on roles(:app) do |server|
      # NOTE: ELBにぶら下がっていないサーバーにデプロイする場合があるため、ELBにぶら下げてないサーバーの場合は何もしない
      if EC2_INSTANCE_IDS[server.hostname].present?
        puts "Removing #{server.hostname} from ELB"
        res = client.deregister_instances_from_load_balancer(instance(server.hostname))
        raise "There is no EC2 instance on ELB!!!" if res[:instances].empty?
      end
    end
  end

  desc 'Add EC2 instance from ELB'
  task :add do
    on roles(:app) do |server|
      if EC2_INSTANCE_IDS[server.hostname].present?
        puts "Add #{server.hostname} to ELB"
        res = client.register_instances_with_load_balancer(instance(server.hostname))
        # NOTE: 2017.11.16時点でELBにぶら下がるインスタンスは2台なので1台しかぶらさがってないとaddできていない
        raise 'Failed to add EC2 instance to ELB!!!' if res[:instances].length == 1
        raise 'Some  are not in service' unless in_service?
        puts 'All instances on ELB are in service'
      end
    end
  end

  desc 'Health check of ELB'
  task :health_check do
    puts 'Health check of ELB'
    if in_service?
      puts 'In service'
    else
      puts 'Out of service'
    end
  end
end

単体のrakeタスクの動作は以下のコマンドで確認できます。

$ ROLES=web1 bundle exec cap production elb:health_check
$ ROLES=web1 bundle exec cap production elb:add
$ ROLES=web1 bundle exec cap production elb:remove

web1 というロールを指定することで web1 というロールが付与されているサーバーを対象にrakeタスクが実行されます。

ロールにの使い方は Capistranoで複数のサーバーそれぞれにデプロイをする方法 に記載しています。

ELBからインスタンスを切り離す、再びつける、の動作が確認できれば、確認環境などで $ cap production deploy してみてください。

課題

  • DB変更がともなうデプロイの時につらみがある



この記事はCapistranoでELB + EC2環境にローリングデプロイをQiita用に加筆修正したものです