serverspec で Elastic Beanstalk をテストする


VPC 上に構築した Elastic Beanstalk の EC2 インスタンスを踏み台サーバ経由で serverspec するまでの流れ、のメモ。

だいたい以下のような構成だと当てはまるはず。

- Elastic Beanstalk 使ってる
- VPC 上で使ってる
- 踏み台使って EC2 インタンスにアクセスしてる
- 雑多な操作には fabric 使ってる
- ステージングと本番の2環境ある

## 導入

### serverspec インストール

Gemfile 作成

```
source 'https://rubygems.org'
gem 'serverspec'
gem 'rake'
```

インストール

```
mkdir -p vendor/bundle
bundle install --path vendor/bundle
```

### 初期化

初期化コマンドを実行して雛形を作成する。
backend は SSH 接続を選択、ホスト名は適当な値を入力。

```
bundle exec serverspec-init
Select OS type:

  1) UN*X
  2) Windows

Select number: 1

Select a backend type:

  1) SSH
  2) Exec (local)

Select number: 1

Vagrant instance y/n: n
Input target host name: example.com
 + spec/
 + spec/example.com/httpd_spec.rb
 + spec/spec_helper.rb
 + Rakefile
```

## テストを書く

デフォルトの`httpd_spec.rb` をリネームして `nginx_spec.rb` にしてみる。

```ruby
require 'spec_helper'

describe package('nginx') do
  it { should be_installed }
end

describe service('nginx') do
  it { should be_running }
end

describe port(80) do
  it { should be_listening }
end

describe file('/etc/nginx/conf.d/webserver.conf') do
  it { should be_file }
end
```

やりたいことの書き方は公式のここを見るとわかりやすい。

[serverspec - Resource Types](http://serverspec.org/resource_types.html)

その他の spec を書くのに以下のリソースタイプを使ったりした。

**be_mode**

ファイルモードを確認する

ex. nginx のログディレクトリに実行権限があるか(fluentd用)

```
describe file('/var/log/nginx') do
  it { should be_mode 755 }
end
```

**match**

プロセスが指定のパラメータで起動しているか

ex. newrelic が起動しているか

```
describe process("nrsysmond") do
  its(:args) { should match /-c \/etc\/newrelic\/nrsysmond.cfg -p / }
end
```

## 踏み台設定

serverspec 的に ssh config の設定を使ったほうが楽そうなので、踏み台サーバ(*-bastion)から多段SSHで接続できるように定義しておく。
対象サーバの HostName は serverspec 側で動的に割り当てるようにするので、ここでは設定しない。
(AutoScaling 走ってるので書いてもしょうがないし)

**.ssh/config**

```
Host foo-staging-bastion
    HostName        54.xxx.yyy.zzz
    IdentityFile    ~/.ssh/foo-staging.pem
    User            foouser

Host foo-staging
    IdentityFile    ~/.ssh/foo-staging.pem
    User            ec2-user
    ProxyCommand    ssh foo_step -W %h:%p

Host foo-staging-bastion
    HostName        54.zzz.yyy.xxxx
    IdentityFile    ~/.ssh/foo-production.pem
    User            foouser

Host foo-production
    IdentityFile    ~/.ssh/foo-production.pem
    User            ec2-user
    ProxyCommand    ssh foo_step -W %h:%p
```


## sererspec 設定

デフォルトの設定ではホスト毎にディレクトリを掘って spec を書かなければいけないので、role 毎に spec を書けるように変更する。role とホストの定義は json ファイルから読み込むようにする。

基本的には公式 Tips を少しアレンジしただけ。

[serverspec - Advanced Tips](http://serverspec.org/advanced_tips.html)

初期化コマンドで生成した `spec/example.com` を `spec/base` に変更して、 `Rakefile` と `spec/spec_helper.rb` の内容を書き換える。

対象のロールを増やしたい場合は `base` ディレクトリと同列に `webapp` とか `db` とかディレクトリ追加して、その中に spec 書いていけばよい。

`spec/env/servers.json` にはホストと role を定義する。

ディレクト構成は以下な感じ。

```
.
├── Rakefile
└── spec
    ├── base
    │   ├── fluentd_spec.rb
    │   ├── newrelic_spec.rb
    │   ├── nginx_spec.rb
    │   └── uwsgi_spec.rb
    ├── env
    │   └── servers.json
    └── spec_helper.rb
```

各種ファイルを以下のように変更する。

**spec/spec_helper.rb**

```
require 'serverspec'
require 'pathname'
require 'net/ssh'

include SpecInfra::Helper::Ssh
include SpecInfra::Helper::DetectOS

RSpec.configure do |c|
  c.request_pty = true
  if ENV['ASK_SUDO_PASSWORD']
    require 'highline/import'
    c.sudo_password = ask("Enter sudo password: ") { |q| q.echo = false }
  else
    c.sudo_password = ENV['SUDO_PASSWORD']
  end
  c.before :all do
    block = self.class.metadata[:example_group_block]
    if RUBY_VERSION.start_with?('1.8')
      file = block.to_s.match(/.*@(.*):[0-9]+>/)[1]
    else
      file = block.source_location.first
    end
    c.ssh.close if c.ssh
    c.host = ENV['TARGET_HOST']
    hostname = ENV['TARGET_HOSTNAME']
    options = Net::SSH::Config.for(c.host)
    user    = options[:user] || Etc.getlogin
    c.ssh   = Net::SSH.start(hostname, user, options)
    c.os = backend.check_os
  end
end
```

公式 Tips のままだと何度やっても死ぬので以下を追記してる。

```
c.request_ppy = true
```

ref: [serverspecがsudoのところで動かなくて悩んだ件（解決） #serverspec - きょうもぼへぼへちゃんがゆく](http://blog.livedoor.jp/ashibuya0128/archives/52071211.html)


**Rakefile**

```
require 'rake'
require 'rspec/core/rake_task'
require 'json'

servers = JSON.parse(File.read('spec/env/servers.json'))

desc "Run serverspec to all servers"
task :spec => 'serverspec:all'

class ServerspecTask < RSpec::Core::RakeTask

  attr_accessor :target_host, :target_hostname

  def spec_command
    cmd = super
    "env TARGET_HOST=#{target_host} TARGET_HOSTNAME=#{target_hostname} #{cmd}"
  end

end

namespace :serverspec do
  task :all => servers.map {|s| 'serverspec:' + s['name'] }
  servers.each do |server|
    desc "Run serverspec to #{server['name']} (#{server['hostname']})"
    ServerspecTask.new(server['name'].to_sym) do |t|
      t.target_host = server['host']
      t.target_hostname = server['hostname']
      t.pattern = 'spec/{' + server['roles'].join(',') + '}/*_spec.rb'
    end
  end
end

```

**spec/env/servers.json**


```
[
  {
    "host": "foo-staging", 
    "hostname": "10.0.2.206", 
    "name": "foo-staging-10.0.2.206", 
    "roles": [
      "base"
    ]
  }, 
  {
    "host": "foo-production", 
    "hostname": "10.0.3.225", 
    "name": "foo-production-10.0.3.225", 
    "roles": [
      "base"
    ]
  }, 
  {
    "host": "foo-production", 
    "hostname": "10.0.1.205", 
    "name": "foo-production-10.0.1.205", 
    "roles": [
      "base"
    ]
  }
]

```

host に .ssh/config に設定した Host, hostname に private ip を定義する。


## テスト実行

```
bundle exec rake spec
```

trace オプションを付けるとデバッグしやすい

```
bundle exec rake spec -t
```


## 定義ファイルの自動生成

毎回変わる EC2 インスタンスをいちいち手書きするのもアレなので、fabric で servers.json を自動生成できるようにしておく。

```python
# -*- coding: utf-8 -*-
import json
import boto
import boto.beanstalk
import boto.ec2
from fabric.api import task, puts

# Elastic Beanstalk の Applicaton 名
EB_APP_NAME = 'example-app'
# Elastic Beanstalk の リージョン名
EB_REGION = 'ap-northeast-1'


def _get_eb_conn():
    """ Beanstalk の conn オブジェクトを取得する
    """
    region = [r for r in boto.beanstalk.regions() if r.name == EB_REGION][0]
    return boto.connect_beanstalk(region=region)


def _get_ec2_conn():
    """ EC2 の conn オブジェクトを取得する
    """
    region = [r for r in boto.ec2.regions() if r.name == EB_REGION][0]
    return boto.connect_ec2(region=region)


def _get_eb_environments():
    """ Application 内の Environment 一覧を返す
    """
    conn = _get_eb_conn()
    res = conn.describe_environments(application_name=EB_APP_NAME)
    return res['DescribeEnvironmentsResponse']['DescribeEnvironmentsResult']['Environments']


@task
def generate_spec_servers():
    """ Application 内の EC2 で severs.json を生成する
    """
    puts('Start generate spec/env/servers.json for serverspec')
    envs = _get_eb_environments()
    eb_conn = _get_eb_conn()
    ec2_conn = _get_ec2_conn()
    servers = []
    for e in envs:
        env_name = e['EnvironmentName']
        if 'production' in env_name:
            host = 'foo-production'
        elif 'staging' in env_name:
            host = 'foo-staging'
        res = eb_conn.describe_environment_resources(environment_id=e['EnvironmentId'])
        instances = res['DescribeEnvironmentResourcesResponse']['DescribeEnvironmentResourcesResult']['EnvironmentResources']['Instances']
        for ins in ec2_conn.get_only_instances(instance_ids=[i['Id'] for i in instances]):
            servers.append({
                'name': '{}-{}'.format(env_name, ins.private_ip_address),
                'host': host,
                'hostname': ins.private_ip_address,
                'roles': ['base']
            })
    with open('spec/env/hosts2.json', 'w') as f:
        f.write(json.dumps(servers, indent=2))
    puts('Completed')
```

以下で `spec/env/servers.json` が生成される

```
fab generate_spec_servers
```

## 参考

- [serverspec - Home](http://serverspec.org/)
- [serverspecを使ってサーバの状態をテストしてみよう - Tech-Sketch](http://tech-sketch.jp/2014/04/serverspec.html)
- [serverspec のテストをホスト間で共有する方法 - Gosuke Miyashita](http://mizzy.org/blog/2013/05/12/1/)
- [ServerSpecテストケースを細分化する | Liberal Research Laboratory](http://blog.watt-is.jp/2014/02/10/serverspec%E3%83%86%E3%82%B9%E3%83%88%E3%82%B1%E3%83%BC%E3%82%B9%E3%82%92%E7%B4%B0%E5%88%86%E5%8C%96%E3%81%99%E3%82%8B/)
- [serverspecがsudoのところで動かなくて悩んだ件（解決） #serverspec - きょうもぼへぼへちゃんがゆく](http://blog.livedoor.jp/ashibuya0128/archives/52071211.html)
- [Serverspecが超高速で書けるserverspec-snippetsを作った - Glide Note - グライドノート](http://blog.glidenote.com/blog/2014/06/17/serverspec-snippets/)