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

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で複数サーバーにデプロイする場合、以下のように記載すれば複数サーバーにデプロイできます。

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

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

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

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

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

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

[capistrano-bundle_rsync](https://github.com/sonots/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

```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に定義します。
もし ` Capfile ` に ` Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } ` を記載していないとelb.rakeがロードされないので気をつけてください。

```rb
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で複数のサーバーそれぞれにデプロイをする方法](https://tackeyy.com/blog/posts/deploy-web-service-to-each-servers) に記載しています。

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

## 課題

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

<br>
_この記事は[CapistranoでELB + EC2環境にローリングデプロイ](https://tackeyy.com/blog/posts/rolling-deploy-to-ec2-in-elb-with-capistrano)をQiita用に加筆修正したものです_