Posted at

serverspec で Elastic Beanstalk をテストする

More than 5 years have passed since last update.

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 にしてみる。

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

その他の 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

初期化コマンドで生成した spec/example.comspec/base に変更して、 Rakefilespec/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 - きょうもぼへぼへちゃんがゆく

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 を自動生成できるようにしておく。

# -*- 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


参考