【AWS】JenkinsとserverspecでChefのテストを自動化する
はじめに
こんにちは植木和樹です。相変わらず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ファイル)
登場人物
- ローカルの作業用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から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://[email protected]/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を利用します。キーペアの設定やセキュリティグループの設定は以下の通りです。
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の管理」-「プラグインの管理」をクリックします。
「利用可能」タブから "Git Client Plugin" をチェックして「ダウンロードして再起動後にインストール」をクリックします。
プラグインのインストール/アップグレード画面で「インストール完了後、ジョブがなければJenkinsを再起動する」をチェックします。するとプラグインのインストールが始まり、しばらくするとJenkinsが再起動します。
これでgit-clientプラグインのインストールは完了です。次にJenkinsのジョブを作成します。
Jenkinsジョブの作成
ジョブの流れ
Jenkinsで3つのジョブを作成します。各ジョブの役割は以下の通りです。
- テスト用EC2インスタンスを起動する(CloudFormationを使用)
- gitリポジトリから最新のChef Cookbooksを取得し、テスト用EC2インスタンスに適用する。その後serverspecでテスト用EC2をテストする
- テスト用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」とし、「フリースタイル・プロジェクトのビルド」を選択します。「ビルド」で「シェルの実行」をクリックし、テキストエリアに下記内容を貼り付けたら保存します。
シェルスクリプト: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とブランチ名を入力してください。
「ビルド」で「シェルの実行」をクリックし、テキストエリアに下記内容を貼り付けたら保存します。
シェルスクリプト: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つずつジョブを実行してみましょう。エラーなくジョブが実行できればステータス欄が青いランプになります。もし赤いランプが表示された場合にはジョブのコンソール出力を手がかりにエラー原因を調べ、修正してください。
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 ディレクトリの
ディレクトリ構成
./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に保ちましょう。