注目の記事

【AWS】JenkinsとserverspecでChefのテストを自動化する

2013.07.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは植木和樹です。相変わらずCloudFormationとChefな毎日を送っています。そのおかげで、最近は実験用サーバを設定するときにも極力手作業はなくし、CloudFormationやChefを使って自動化・省力化する習慣がつきました。以前作ったCookbookを使用して、コマンド1つで新環境が構築されたときって気分いいですよね。

さてChefのCookbookが増えてきて徐々に再利用が進んではいるのですが、Cookbookを作成してから数週間もすると「本当にこのクックブックはまだ動くのかな?」と不安になってきます。ここはやはり、Cookbookが正しく適用されることを継続して保証する仕組みがほしいところです。

本日はChef Cookbookのテスト自動化の一例として、JenkinsからEC2を起動してからchef-soloを使ってCookbooksを適用し、その後serverspecでテストを行うまでを設定してみたいと思います。なお筆者はJenkinsをちゃんと使うのが今回初めてなので、いろいろとおかしなことをしているかもしれません。そんな時はコメントなどで教えていただけると幸いです。

それでは始めましょう。

事前に用意するもの

  • AWSアカウント
  • ローカルの作業用PC(OSX 10.8)
  • ローカルの端末にknife solo環境があること(0.3.0.pre5)(Jenkinsサーバ自体の設定のためです)
  • Jenkinsからgitにアクセスするための秘密鍵
  • Jenkinsからテスト用EC2にsshアクセスするための秘密鍵キーペア(PEMファイル)

登場人物

jenkins_20130725_000

ローカルの作業用PC
Jenkins用EC2にChefで環境を作るための作業PCです
Jenkins用EC2
Jenkinsを動かします。gitリポジトリから最新のCookbooksを取得し、テスト用EC2に適用します
またテスト用EC2をserverspecを使ってテストします。
テスト用EC2
JenkinsからCloudFormationで自動的に作成されます。その後Cookbooksが適用され、serverspecでテストされます。
gitリポジトリ
最新のCookbooksを管理します

Jenkins用EC2の準備

EC2の起動

まずはJenkinsを動かすためのEC2を用意します。次の設定に従ってマネージメントコンソールからEC2を作成しました。

リージョン

Tokyo
VPC
デフォルトVPC
OS
Amazon Linux x86_64 (2013.03)
インスタンスタイプ
t1.micro
セキュリティグループ
ssh(tcp/22), web(tcp/8080)

作業用PC ~/.ssh/configの編集

Jenkins用EC2が起動したらローカルの作業用PCの ~/.ssh/config に追加しておきます。毎回PEMファイルのパスや、長いホスト名を入力する手間を省くためです。

Host jenkins
  Hostname ec2-54-250-xxx-xxx.ap-northeast-1.compute.amazonaws.com
  User ec2-user
  IdentityFile ~/.ssh/ueki_keypair.pem

Jenkins, knife-soloのインストール

次にJenkins用EC2に、JenkinsとKnife-SoloをChefでインストールします。ローカルの端末からknife soloでノードを指定してCookbooksを適用します。

$ mkdir ~/Documents/jenkins_chef
$ cd ~/Documents/jenkins_chef
$ knife cookbook create jenkins -o site-cookbooks
$ knife cookbook create knife -o site-cookbooks
$ vi site-cookbooks/jenkins/recipes/default.rb
(以下の内容を貼り付ける)
$ vi site-cookbooks/knife/recipes/default.rb
(以下の内容を貼り付ける)

ファイル:site-cookbooks/jenkins/recipes/default.rb

execute "install-jenkins-repo" do
  command <<-_EOH_
    wget -O /etc/yum.repos.d/jenkins.repo http://pkg.jenkins-ci.org/redhat/jenkins.repo
    rpm --import http://pkg.jenkins-ci.org/redhat/jenkins-ci.org.key
  _EOH_
  action :run
  not_if { ::File.exists?("/etc/yum.repos.d/jenkins.repo") }
end

package "jenkins" do
  action :install
end

service "jenkins" do
  action [:enable, :start]
end

%w{aws-cli jq}.each do |name|
  package name do
    action :install
  end
end

ファイル:site-cookbooks/knife/recipes/default.rb

serverspecを使うためにRuby 1.9をインストールしています。パスの解決や、gemライブラリの依存問題解消のため、ややこしいことしていますが、もっとシンプルな解決策がありそうな気がします。

%w{git ruby19 make gcc ruby19-devel rubygem19-rake openssl-devel}.each do |name|
  package name do
    action :install
  end
end

link "/usr/bin/ruby" do
  to "/usr/bin/ruby1.9"
end

link "/usr/bin/rake" do
  to "/usr/bin/rake1.9"
end

link "/usr/bin/gem" do
  to "/usr/bin/gem1.9"
end

%w{knife-solo serverspec}.each do |name|
  gem_package name do
    gem_binary  "gem1.9"
    options "--no-ri --no-rdoc"
    action :install
  end
end

execute "uninstall_rspec_2.14" do
  command "gem uninstall rspec-core --version 2.14.4"
  only_if { `gem list | grep '^rspec ' | grep '2.14'`.length > 0 }
end

レシピファイルを作ったらknife-soloでJenkins用EC2に適用します。

$ knife solo bootstrap jenkins -r jenkins,knife

Jenkins用EC2に Jenkins 1.524、knife solo 0.2.0がインストールされました。gemだと少々古い0.2.0がインストールされますが問題ありません。JenkinsがインストールされたらブラウザでJenkinsサーバの8080番ポートにアクセスしてみましょう。ダッシュボードが表示されればOKです。

jenkins_20130725_001

JenkinsからEC2を起動するための準備

ssh用キーペアファイル と gitリポジトリ用鍵ファイル をjenkinsユーザのホームディレクトリにアップロードする

Jenkinsからテスト用EC2へChefを適用するために、sshの鍵(jenkins_keypair.pem)が必要です。またgitリポジトリに接続するためにも鍵(id_rsa)が必要です。それぞれ事前に作成し作業用PCにダウンロードしておいたものをコピーします。

# 作業用PC → Jenkins用EC2へファイルをコピー
local$ scp -p ~/.ssh/jenkins_keypair.pem jenkins:/tmp
local$ scp -p ~/.ssh/id_rsa jenkins:/tmp 
local$ ssh jenkins

# Jenkinsのホームディレクトリへファイルを移動し、パーミッションを設定する
jenkins$ sudo su - 
jenkins# mkdir /var/lib/jenkins/.ssh/
jenkins# mv /tmp/jenkins_keypair.pem /var/lib/jenkins/.ssh/
jenkins# mv /tmp/id_rsa /var/lib/jenkins/.ssh/
jenkins# chmod 600 /var/lib/jenkins/.ssh/*
jenkins# chown -R jenkins:jenkins /var/lib/jenkins/.ssh/

# jenkinsユーザでgitリポジトリにアクセスできることを確認する
jenkins$ sudo su - 
jenkins# cd /tmp
jenkins# sudo -u jenkins -H git ls-remote -h ssh://git@git.classmethod.jp/aws/ops-cookbooks.git

aws-cliのためクレデンシャルファイルを作成する

aws-cliでEC2のインスタンス情報や、CloudFormationのスタックを作成するためのIAMユーザを作成します。この操作はマネージメントコンソールから行います。ひとまずPowerUser権限を付与しておけばいいでしょう。

※EC2インスタンス作成にIAM Roleが使えればこの手間はないのですが、残念ながらCloudFormation では Instance Profile を利用したEC2起動オペレーションがサポートされていないための回避策です。(2013年7月現在)

作成したIAMユーザのアクセスキーとシークレットアクセスキーを、jenkinsユーザのホームディレクトリに aws_config というファイル名で保存します。

$ sudo su -
# sudo -u jenkins cat <<_EOF_ > /var/lib/jenkins/aws_config
[default]
aws_access_key_id=<access_key>
aws_secret_access_key=<secret_key>
_EOF_

Jenkins用EC2 ~/.ssh/config の編集

Jenkins用EC2からテスト用EC2にログインできるよう /var/lib/jenkins/.ssh/config に編集します。Chefやserverspec 実行時にPEMファイルのパスなどの入力する手間を省くためです。下記ではデフォルトVPC内のすべてのEC2への接続についてログインユーザとPEMファイルのパスを設定しています。

Host 172.31.*
  User ec2-user
  IdentityFile ~/.ssh/jenkins_keypair.pem

EC2を起動するためのCloudFormationテンプレートを作成する

テスト用EC2の起動にはCloudFormationを利用します。キーペアの設定やセキュリティグループの設定は以下の通りです。

CloudFormationの設定
ServerImageId ami-39b23d38 最新のAmazon LinuxのAMIです
KeyName jenkins_keypair sshログインに使用するキーペアです。事前に作成しておきます。
IAMInstanceProfile ueki-chef-role テスト用EC2のIAM-Profileです。事前に作成しておきます。
テスト用EC2からS3やCloudWatchへアクセスするようなスクリプトをChefで設定することが多いので指定するようにしています。
SecurityGroup テンプレート内で作成 Jenkins用EC2からのssh(tcp/22)を許可します。
SubnetId 未指定 サブネットは未指定なのでデフォルトサブネットにテスト用EC2を起動します

Jenkins用EC2にログインしてテンプレートファイルを作成します。gitリポジトリに含めてCookbooksと一緒に管理するのも良いと思います

$ sudo su -
# sudo -u jenkins vi /var/lib/jenkins/single-ec2.template
(下記内容を貼り付けて保存します)

ファイル:/var/lib/jenkins/single-ec2.template

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "Create a Instance within a subnet",

  "Parameters" : {
    "ServerName" : {
      "Type" : "String",
      "Default" : "chef-test"
    },
    "ServerImageId" : {
      "Type" : "String",
      "Default" : "ami-39b23d38"
    },
    "ServerInstanceType" : {
      "Type" : "String",
      "Default" : "t1.micro"
    },
    "KeyName" : {
      "Type" : "String",
      "Default" : "jenkins_keypair"
    },
    "IAMInstanceProfile" : {
      "Type" : "String",
      "Description" : "Input an IAM role id for service instances",
      "Default" : "ueki-chef-role"
    }
  },
  "Resources" : {

    "EC2Instance" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {

        "ImageId" : { "Ref" : "ServerImageId" },
        "InstanceType" : { "Ref" : "ServerInstanceType" },
        "KeyName" : { "Ref" : "KeyName" },
        "IamInstanceProfile" : { "Ref" : "IAMInstanceProfile" },
        "SecurityGroupIds" : [
          { "Ref" : "SecurityGroup" }
        ],
        "Tags" : [
          { "Key" : "Name", "Value" : { "Ref" : "ServerName" } }
        ]
      }
    },

    "SecurityGroup" : {
      "Type" : "AWS::EC2::SecurityGroup",
      "Properties" : {
        "GroupDescription" : "SecurityGroup for chef-node",
        "SecurityGroupIngress" : [
          { "IpProtocol" : "tcp",  "FromPort" : "22",  "ToPort" : "22", "CidrIp" : "172.31.0.0/16" }
        ]
      }
    }
  },

  "Outputs" : {
    "InstanceId" : {
      "Description" : "The instance ID of this instance",
      "Value" : {
        "Ref" : "EC2Instance"
      }
    },
    "IPAddress" : {
      "Description" : "The ip-address of this instance",
      "Value" : {
        "Fn::GetAtt" : [  "EC2Instance", "PrivateIp" ]
      }
    }
  }
}

以上で、Jenkinsのジョブから使用する各種ファイルの準備が整いました。次にJenkinsの設定とジョブを作成していきましょう。

Jenkins gitプラグインの設定

Jenkinsの初期設定ではgitリポジトリを扱うことができません。プラグインをインストールしてgit-clientを有効にしましょう。Jenkinsダッシュボード画面から「Jenkinsの管理」-「プラグインの管理」をクリックします。

jenkins_20130725_011

「利用可能」タブから "Git Client Plugin" をチェックして「ダウンロードして再起動後にインストール」をクリックします。

jenkins_20130725_013

プラグインのインストール/アップグレード画面で「インストール完了後、ジョブがなければJenkinsを再起動する」をチェックします。するとプラグインのインストールが始まり、しばらくするとJenkinsが再起動します。

jenkins_20130725_015

これでgit-clientプラグインのインストールは完了です。次にJenkinsのジョブを作成します。

Jenkinsジョブの作成

ジョブの流れ

Jenkinsで3つのジョブを作成します。各ジョブの役割は以下の通りです。

  1. テスト用EC2インスタンスを起動する(CloudFormationを使用)
  2. gitリポジトリから最新のChef Cookbooksを取得し、テスト用EC2インスタンスに適用する。その後serverspecでテスト用EC2をテストする
  3. テスト用EC2インスタンスを終了する

2つ目のジョブは「2-1. git clone」「2-2. chef prepare」「2-3. chef cook」「2-4. serverspec」と細かく分割した方が良いのでしょうが、git cloneしてきた場所やパラメータをジョブ間で受け渡す方法がわからなかったため一つにまとめてしまっています。ここは要改善ですね。

テスト用EC2インスタンスを起動するジョブの作成

最初はテスト用EC2を起動するジョブです。Jenkinsのダッシュボード画面から「新規ジョブの作成」をクリックしジョブを作成します。

ジョブ名は「01_serverspec-run-instance」とし、「フリースタイル・プロジェクトのビルド」を選択します。「ビルド」で「シェルの実行」をクリックし、テキストエリアに下記内容を貼り付けたら保存します。

jenkins_20130725_003

シェルスクリプト:01_serverspec-run-instance

※コピー&ペーストでうまく実行できない場合は行末のバックスラッシュを取り除いてください

export STACK_NAME=CHEF-NODE
export AWS_DEFAULT_REGION=ap-northeast-1
export AWS_CONFIG_FILE=~/aws_config

# CloudFormationスタックが作成されていない時はスタックを作成する
stack_exists=$(aws cloudformation describe-stacks | \
  jq -s -r '.[] | \
    map(select(.StackName == "'${STACK_NAME}'" and .StackStatus == "CREATE_COMPLETE")) | \
      .[].StackStatus' | wc -l)
if [ $stack_exists == 0 ]; then
  cat ${JENKINS_HOME}/single-ec2.template | \
  xargs -0 aws cloudformation create-stack --stack-name ${STACK_NAME} \
    --capabilities CAPABILITY_IAM --template-body
fi

# スタックが "CREATE_IN_PROGRESS" の場合は "CREATE_COMPLETE" になるまで待機する
DO_BREAK=0
RETRY=0
while [ ${DO_BREAK} != 1 ]; do
  stack_status=$(aws cloudformation describe-stacks | \
    jq -s -r '.[] | map(select(.StackName == "'${STACK_NAME}'")) | .[].StackStatus')
  if [ $stack_status == "CREATE_COMPLETE" ]; then
    DO_BREAK=1
  elif [ $stack_status != "CREATE_IN_PROGRESS" ]; then
    sleep 1
    RETRY=`expr $RETRY + 1`
    if [ $RETRY -gt 5 ]; then
      DO_BREAK=1
    fi
  else
    sleep 5
  fi
done

gitから最新のCookbooksを取得し、テスト用EC2にChef Cookbooksを適用するジョブの作成

次はテスト用EC2にChefを適用するジョブです。先ほどと同様Jenkinsのダッシュボード画面から「新規ジョブの作成」をクリックしジョブを作成します。ジョブ名は「02_serverspec-git-clone」とし、「フリースタイル・プロジェクトのビルド」を選択します。

gitリポジトリから最新のCookbooksを取得する設定をします。リポジトリのURLとブランチ名を入力してください。

jenkins_20130725_017

「ビルド」で「シェルの実行」をクリックし、テキストエリアに下記内容を貼り付けたら保存します。

シェルスクリプト:02_serverspec-git-clone

※コピー&ペーストでうまく実行できない場合は行末のバックスラッシュを取り除いてください

export STACK_NAME=CHEF-NODE
export AWS_DEFAULT_REGION=ap-northeast-1
export AWS_CONFIG_FILE=~/aws_config
export PATH=/usr/local/bin:$PATH

# EC2のインスタンスIDを取得する
INSTANCE_ID=$(aws ec2 describe-instances | \
  jq -s -r '.[].Reservations[].Instances[] | \
    select(.Tags[].Key == "Name" \
      and .Tags[].Value == "chef-test" \
      and .State.Name == "running" ) | .InstanceId')

# EC2のステータスチェックが"2/2 OK"になるまで待機する
while [ 1 ]; do
  passed=$(aws ec2 describe-instance-status | \
    jq -s -r  '.[0][] | select(.InstanceId == "'${INSTANCE_ID}'") | \
     .SystemStatus.Status == "ok" , .InstanceStatus.Status == "ok"' | wc -l)
  if [ $passed -eq 2 ]; then
    break
  fi
  sleep 5
done

# EC2のプライベートIPアドレスを取得する
IP_ADDRESS=$(aws ec2 describe-instances | \
  jq -s -r '.[].Reservations[].Instances[] | \
    select(.InstanceId == "'${INSTANCE_ID}'" ) | .PrivateIpAddress')

cd ${WORKSPACE}
if [ ! -f solo.rb ]; then
  knife solo init .
fi

# テスト用EC2に適用するCookbooksをJSONファイルで指定する
mkdir -p nodes
echo '{"run_list":["i18n","timezone"]}' > nodes/${IP_ADDRESS}.json

# JSONファイルに基いてEC2にCookbooksを適用する
knife solo bootstrap ${IP_ADDRESS}

# JSONファイルに基いてEC2にserverspecを実行する(解説は後ほど)
cd ${WORKSPACE}
rake

rm nodes/${IP_ADDRESS}.json

EC2を終了するジョブの作成

最後は作成したEC2を終了するジョブです。EC2はCloudFormationで作成していますので、スタックを削除するだけです。Jenkinsのダッシュボード画面から「新規ジョブの作成」をクリックしジョブを作成します。ジョブ名は「03_serverspec-destroy-instance」とし、「フリースタイル・プロジェクトのビルド」を選択します。「ビルド」で「シェルの実行」をクリックし、テキストエリアに下記内容を貼り付けたら保存します。

シェルスクリプト:03_serverspec-destroy-instance

export STACK_NAME=CHEF-NODE
export AWS_DEFAULT_REGION=ap-northeast-1
export AWS_CONFIG_FILE=~/aws_config
aws cloudformation delete-stack --stack-name ${STACK_NAME}

Jenkinsでジョブの実行

Jenkinsのダッシュボードから1つずつジョブを実行してみましょう。エラーなくジョブが実行できればステータス欄が青いランプになります。もし赤いランプが表示された場合にはジョブのコンソール出力を手がかりにエラー原因を調べ、修正してください。

jenkins_20130725_016

run_listに応じてテストするserverspecを変える方法

Cookbookを適用したサーバが「あるべき姿」になっているかをserverspecを用いて検査します。テスト内容が書かれたSPECファイルは各Cookbook毎に作成するのが管理しやすいと思います。ただテスト自体は複数のCookbooksが適用されたサーバに対して行うのでテストの実行はCookbooksをまたぎます。また「どのCookbookのSPECファイルを使用するか」は「どのCookbookが適用されたか」つまりrun_listに応じて実行時に判断してもらいたいです。

この仕掛けを @kenjiskywalker さんがこちらのブログで紹介しています(Testing #chef Cookbook by #serverspec #devops)。今回はこのブログを参考にさせていただきました。ポイントはリポジトリディレクトリ以下に作成した Rakefile と spec/spec_helper.rb です。

run_list を node ディレクトリの .json に書き出しておき、serverspecではこのJSONファイルから実行するSPECファイルを判断しています。 あとは各Cookbook毎にspec/serverspec/xxxxx_spec.rb という名前でSPECファイルを作成し、Jenkinsのジョブを実行すれば run_list に応じたテストが実行されます。

ディレクトリ構成

./Rakefile
│ 
├ spec/spec_helper.rb
│ 
├ nodes/
│
└ site-cookbooks/i18n ┬ recipes/default.rb
                      │
                      └ spec ─ serverspec ─ default_spec.rb

ファイル:Rakefile

require 'rubygems'
require 'rake'
require 'rspec/core/rake_task'
require 'yaml'
require 'json'
require 'chef/run_list'

json_files = Dir::glob("./nodes/*.json")

Chef::Config[:cookbook_path] = './site-cookbooks/'
Chef::Config[:role_path]     = './nodes/'

desc "Run serverspec to all hosts"
task :default => 'serverspec:all'

host_run_list = {}

json_files.each do |json_file|
  host_config    = JSON.parse(File.read(json_file))
  host_name      = File.basename(json_file, ".json")
  run_list       = host_config["run_list"] || []
  chef_type      = host_config["chef_type"]

  ### ignore "role" and "base.json"
  next if chef_type == "role" || host_name == "base"

  ### to Hash "hostname" => "run_list"
  host_run_list[host_name]= run_list
end

namespace :serverspec do

  ### start task
  task :all => host_run_list.keys
  host_run_list.keys.each do |target_host|
    desc "Run serverspec to #{target_host}"
    RSpec::Core::RakeTask.new(target_host.to_sym) do |t|
      ENV['TARGET_HOST'] = target_host

      target_run_list = host_run_list[target_host]

      ### merge {host}.json + base.json . and extract run_list
      recipes = Chef::RunList.new(*target_run_list).expand("_default", "disk").recipes

      t.pattern = ['site-cookbooks/{' + recipes.join(',') + '}/spec/serverspec/*_spec.rb', "spec/#{target_host}/*_spec.rb"]
    end
  end
end

ファイル:spec/spec_helper.rb

require 'serverspec'
require 'pathname'
require 'net/ssh'
require 'highline/import'

include Serverspec::Helper::Ssh
include Serverspec::Helper::DetectOS

RSpec.configure do |c|
  c.host  = ENV['TARGET_HOST']
  options = Net::SSH::Config.for(c.host)
  user    = options[:user] || Etc.getlogin
  c.ssh   = Net::SSH.start(c.host, user, options)
  c.os    = backend.check_os
end

まとめ

今回はchef-soloを対象にserverspecでサーバ環境をテストする仕組みを構築しました。新しくCookbookを追加する場合はserverspecのSPECファイルを作成し、「02_serverspec-git-clone」のシェルスクリプトで指定しているrun_listを修正するだけでCookbookの適用とテストが実行されます。

なおchef-serverを利用する場合は、chef-clientのキック方法や run_list をChefサーバに問い合わせて適用するCookbooksを判断するなど、もう少し検討しなければならない点があります。またroleベースで運用している場合には、roleからrun_listへ展開する方法も考えなければならないでしょう。

まだまだ改善しなければならない点はありますが、ひとまずこれでCookbookのテスト環境は整いました。"Infrastructure as Code"という言葉が示すとおり、CloudFormationやChefを使えばインフラをコードとして表現できるようになりつつあります。しかしアプリケーションプログラムと同様、テストのないコードはいずれメンテナンスが難しくなります。serverspecは使い方も分かりやすくテストも書きやすいです。ぜひ導入し、何ヶ月たっても安心して使えるCookbookに保ちましょう。