hidemium's blog

日々学んだことをアウトプットする。

Docker + Chef + serverspec + Jenkins でインフラCIの環境を構築してみた

Dockerが使えるようになったため、Jenkinsにより仮想サーバの起動から、サーバ構築、テスト、仮想サーバの廃棄までを自動化してみました。

やりたいこと

以下のように、Chefのリポジトリの更新をトリガーに、仮想サーバの起動から、サーバ構築、テスト、仮想サーバの廃棄までをJenkinsにて自動化します。

  1. Chefのレシピをリモートリポジトリへgit pushすると、Jenkinsが通知を検知
  2. JenkinsからDockerの仮想サーバ(コンテナ)を起動
  3. 起動が成功すれば、Chefを実行し、サーバを構築
  4. サーバ構築が成功すれば、serverspecを実行し、サーバの状態をテスト
  5. テストが成功すれば、Dockerの仮想サーバ(コンテナ)を廃棄

また、Dockerの起動停止、サーバ構築、テストは全てSSH接続により行います。

構成

CentOS 6.5 : Chef、serverspec、Jenkins、Gitをインストール
Ubuntu 12.04 : Dockerをインストール、サーバ構築、テスト対象
※上記2台のサーバはVMware ESXi 5.1上で動作しています。

インストール

以下のソフトウェアをインストールします。
※過去の記事のリンクになります。

設定

各ソフトウェアの設定について説明していきます。

Docker

まず、DockerでSSH接続が可能なイメージを作成します。
今回、Dockerのイメージを作成するために、コンテナでの処理を記述することができるDockerfileを使用しました。
このDockerfileで、CentOSからSSH接続を行うために、CentOS側の公開鍵の設定も行っています。

$ cd docker #作業ディレクトリに移動します。
$ cp ~/id_rsa.pub . #CentOS側の公開鍵を作業ディレクトリにコピーします。
$ vi Dockfile
FROM ubuntu:12.04
MAINTAINER hidemium

RUN apt-get -y update

# install
RUN apt-get -y install openssh-server

RUN mkdir /var/run/sshd
RUN echo 'root:root' | chpasswd

# sshd config
ADD id_rsa.pub /root/id_rsa.pub #公開鍵をコンテナに追加します。
RUN mkdir /root/.ssh/
RUN mv /root/id_rsa.pub /root/.ssh/authorized_keys
RUN chmod 700 /root/.ssh
RUN chmod 600 /root/.ssh/authorized_keys
RUN sed -i -e '/^UsePAM\s\+yes/d' /etc/ssh/sshd_config

EXPOSE 22
CMD /usr/sbin/sshd -D

docker buildコマンドにより、Dockerfileに記述された処理を実行後、commitをしてイメージの作成までを自動で行います。

  • tオプションにより、イメージ名を指定します。

実行後、ubuntu-sshdというイメージが作成されていることを確認します。

$ sudo docker build -t ubuntu-sshd .
$ sudo docker images

CentOS側からSSH接続が可能になったか確認するため、テストします。
先ほど作成したイメージから、22番ポートのポートフォワードを指定し、コンテナを起動します。
コンテナ起動後に、PORTSの列に「0.0.0.0:[ポート番号]」から、22番ポートにマッピングされたローカルのポートを確認します。

$ sudo docker run -d -p 22 ubuntu-sshd
$ sudo docker ps -a
CONTAINER ID        IMAGE                COMMAND                CREATED             STATUS                         PORTS                                  NAMES
6e61ccbe71df        ubuntu-sshd:latest   /bin/sh -c '/usr/sbi   2 days ago          Up 25 hours                    0.0.0.0:[ポート番号]->22/tcp                         loving_almeida

CentOS側から、以下のコマンドを実行しログインできることを確認します。

$ ssh root@<Ubuntu側のIPアドレス> -p [ポート番号]

Chef

Chefのリポジトリを作成します。

$ knife solo init chef-repo

サーバ構築用のcookbookを作成します。
今回は、apache2をインストールするレシピを用意します。

$ cd chef-repo/
$ knife cookbook create apache2 -o site-cookbooks
$ vi site-cookbooks/apache2/recipes/default.rb
# Apacheをインストール
# sudo apt-get -y install apache2
package "apache2" do
    action :install
end

JSONファイルが作成されるため、実行するapache2のcookbookを指定します。

$ vi nodes/<サーバのIPアドレス>.json
{
  "run_list":[
    "recipe[apache2]"
  ]
}

serverspec

Chefのrootディレクトリに移動し、serverspecの初期設定を行います。

$ cd chef-repo/
$ serverspec-init
Select OS type:

  1) UN*X
  2) Windows

Select number: 1  #UN*Xを選択

Select a backend type:

  1) SSH
  2) Exec (local)

Select number: 1 #SSHを選択

Vagrant instance y/n: n #nを選択
Input target host name: 192.168.xxx.xxx #テスト対象サーバのIPアドレスを指定
 + spec/
 + spec/192.168.xxx.xxx/
 + spec/192.168.xxx.xxx/httpd_spec.rb
 + spec/spec_helper.rb
 + Rakefile

apache2がインストールされているか確認するテストコードを書きます。

$ mv spec/192.168.xxx.xxx/httpd_spec.rb spec/192.168.xxx.xxx/apache2_spec.rb
$ vi spec/192.168.xxx.xxx/apache2_spec.rb
require 'spec_helper'

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

serverspecは、SSH接続でテスト対象にアクセスしますが、テスト対象にログインするための情報は~/.ssh/configから取得しています。
serverspecが利用できるように、~/.ssh/configを修正します。

$ vi ~/.ssh/config
Host 192.168.xxx.xxx
  HostName 192.168.xxx.xxx #テスト対象サーバのIPアドレスを指定
  User root #SSH接続でログインするユーザを指定
  Port 22
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile "/root/.ssh/id_rsa" #秘密鍵のパスを指定
  IdentitiesOnly yes
  LogLevel FATAL

Dockerは、新たなコンテナを起動するたびに、22番ポートにフォワードされるポートが毎回異なって生成される問題があります。
Chefの場合は、-pオプションを使用すれば、ポート番号を指定できますが、serverspecは~/.ssh/configからポート番号を取得しており、ポート番号を引数で与えることができません。
今回、~/.ssh/configからSSH接続の情報を取得している、spec/spec_helper.rbを修正し、外部ファイルからポート番号を設定できるようにしました。

$ vi spec/spec_helper.rb
require 'serverspec'
require 'pathname'
require 'net/ssh'

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

RSpec.configure do |c|
  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
    host  = File.basename(Pathname.new(file).dirname)

+   SSH_CONFIG_FILE = 'spec/.ssh-config'
+   config = File.open(SSH_CONFIG_FILE).read
+   port = ""
+   if config != ''
+     config.each_line do |line|
+       if match = /Port (.*)/.match(line)
+         port = match[1]
+       end
+     end
+   end

    if c.host != host
      c.ssh.close if c.ssh
      c.host  = host
      options = Net::SSH::Config.for(c.host)
      user    = options[:user] || Etc.getlogin
+     options[:port] = port
      c.ssh   = Net::SSH.start(host, user, options)
    end
  end
end

ファイルは、spec/.ssh-configから取得しており、ポート番号が12345だった場合、以下のような内容になります。

$ echo Prot 12345 > spec/.ssh-config
$ cat spec/.ssh-config
Prot 12345

Git

Chef・serverspec用のリモートリポジトリを作成します。

$ mkdir /var/lib/git
$ mkdir /var/lib/git/chef-cookbooks.git
$ cd /var/lib/git/chef-cookbooks.git
$ git --bare init

Chef・serverspec用のローカルリポジトリを作成します。
chef-repoのディレクトリに移動し、リポジトリの初期化を行い、ローカルリポジトリを作成します。
リモートリポジトリには何も入っていないため、ローカルリポジトリの内容を追加します

$ cd chef-repo/
$ git init
$ git add .
$ git commit -m "first commit"
$ git remote add origin /var/lib/git/chef-cookbooks.git
$ git push origin master

Jenkins

Jenkinsでのジョブは、jenkinsユーザにより実行されるため、パスワードなしでsudoを実行する設定を入れておきます。

$ visudo
jenkins ALL=(ALL) NOPASSWD: ALL # ←最終行に追加

Jenkinsにログイン後、[新規ジョブの作成]をクリックし、ジョブ名とビルドを設定します。

以下のように設定を入れ、[OK]をクリックします。

  • ジョブ名: infrastructure-ci
  • フリースタイル・プロジェクトのビルド オン

上記で作成したリポジトリと連携するため、[ソースコード管理]を以下のように設定します。

  • ソースコード管理
    • Git オン
    • Repositories
      • Repository URL: /var/lib/git/chef-cookbooks.git
      • Credentials: なし
    • Branches to build
      • Branch Specifier (blank for 'any'): */master

リポジトリのgit pushを検知するために、[ビルド・トリガ]を以下のように設定します。

  • ビルド・トリガ
    • SCMをポーリング チェック
      • スケジュール 下記に記載
      • post-commitフックを無視 チェックオフ

スケジュールはcronのように設定できます。
今回は、15分毎にチェックが走るように、以下のように設定します。

H/15 * * * *

Chefとserverspecのコマンドを実行するために、[ビルド手順を追加]>[シェルの実行]をクリックし、[シェルの実行]を以下のように設定します。

container_id=`sudo ssh chef@192.168.xxx.xxx sudo docker run -d -p 22 ubuntu-sshd`
container_port=`sudo ssh chef@192.168.xxx.xxx sudo docker inspect --format="'{{(index (index .NetworkSettings.Ports \"22/tcp\") 0).HostPort}}'" $container_id`
sudo /root/.rbenv/shims/knife solo bootstrap root@192.168.xxx.xxx -p $container_port
echo Port $container_port > spec/.ssh-config
sudo /root/.rbenv/shims/rake ci:setup:rspec spec
sudo ssh chef@192.168.xxx.xxx sudo docker stop $container_id
sudo ssh chef@192.168.xxx.xxx sudo docker rm $container_id
  • ビルド後の処理
    • JUnitテスト結果の集計
      • テスト結果XML
spec/reports/*.xml

上記の設定ができたら、[保存]をクリックします。

シェルスクリプトの説明

上記のシェルスクリプトの内容は、今回の環境構築でメインとなるところです。
各コマンドについて説明していきます。

1行目は、docker runで起動すると、コンテナIDが出力されるため、$container_idに格納しています。

すでにserverspecのところで説明しましたが、Dockerを使った構成で問題となるのは、22番ポートをフォワードしているポート番号の取得にあります。
Dockerのコンテナから情報を取得するには、Remote APIを使った方法やdocker-clientを使う方法がありましたが、シンプルにしたかったので、 docker inspectコマンドを使いました。

以下のコマンドで、22番ポートをフォワードしているポート番号を取得することができます。

docker inspect --format='{{(index (index .NetworkSettings.Ports "22/tcp") 0).HostPort}}' <コンテナID>

2行目は、コンテナIDからポート番号を取得し、$container_portに格納しています。
3行目は、knife solo bootstrapコマンドにより、prepareとcookを同時に実行しています。また、-pオプションによりコンテナのポート番号を指定しています。
4行目は、コンテナのポート番号をspec/.ssh-configに出力しています。
5行目は、serverspecを実行していますが、内部でspec/spec_helper.rbが呼ばれており、spec/.ssh-configの値を取得しています。このポート番号利用してSSH接続が行われます。
また、テスト結果をレポートするため、ci_reporterを使用しています。
ci_reporterの使用には、事前に以下の準備をしておきます。

$ gem install ci_reporter
$ vi Rakefile
require 'ci/reporter/rake/rspec'

6行目は、docker stopコマンドにより、コンテナの停止を行っています。docker rmコマンドでいきなり削除してもよかったのですが、時々削除に失敗して異常終了することがあったためです。他サイトの情報を確認したところ、一度停止するほうがいいようです。
7行目は、docker rmコマンドにより、コンテナの削除を行っています。

実行

以下のように、Chefのファイルを修正後、commitし、git pushを実行します。

$ cd chef-repo/
$ vi site-cookbooks/apache2/recipes/default.rb
$ git add .
$ git commit -m "first commit"
$ git push origin master

スケジュールのタイミングによりgit pushの通知が検知されます。
通知を検知すると、JenkinsのワークスペースにChefのリポジトリがコピーされます。

ジョブが実行されたら、[ジョブ名]>[ビルド履歴]>[ビルド番号]へ移動し、[コンソール出力]をクリックします。ジョブが正常終了すれば、コンソール出力の末尾に「Finished: SUCCESS」と表示されます。

以下は、Dockerの起動、Chefとserverspecの実行、Dockerの削除が正常終了していることを示しています。
ちなみに、Chefのレシピはapache2のインストールしか書いていませんが、仮想サーバの起動から廃棄まで2分程度で終了していました。

SCMのポーリングが実行
ビルドします。 ワークスペース: /var/lib/jenkins/workspace/infrastructure-ci
Fetching changes from the remote Git repository
Fetching upstream changes from /var/lib/git/chef-cookbooks.git
Checking out Revision 187f8b995de5ddc053a18a3d114f1aa5018f1979 (origin/master)
[infrastructure-ci] $ /bin/sh -xe /tmp/hudson7478251361203469042.sh
++ sudo ssh chef@192.168.xxx.xxx sudo docker run -d -p 22 ubuntu-sshd
+ container_id=e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
++ sudo ssh chef@192.168.xxx.xxx sudo docker inspect '--format='\''{{(index (index .NetworkSettings.Ports "22/tcp") 0).HostPort}}'\''' e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
+ container_port=49164
+ sudo /root/.rbenv/shims/knife solo bootstrap root@192.168.xxx.xxx -p 49164
Bootstrapping Chef...
:
hef Client finished, 1/1 resources updated in 44.868323127 secono Port 49164
+ sudo /root/.rbenv/shims/rake ci:setup:rspec spec
rm -rf spec/reports
/root/.rbenv/versions/2.0.0-p451/bin/ruby -S rspec spec/192.168.xxx.xxxx/apache2_spec.rb
.

Finished in 0.10092 seconds
1 example, 0 failures
+ sudo ssh chef@192.168.xxx.xxx sudo docker stop e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
+ sudo ssh chef@192.168.xxx.xxx sudo docker rm e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
e0a57b71d3e7352f7b405c5f9f74f746f3b3611107ea5766d894d319884e1998
Recording test results
Finished: SUCCESS

実行が失敗した場合は、以下のようにエラーが表示され、そこでJenkinsのジョブは停止します。

Build step 'シェルの実行' marked build as failure
Finished: FAILURE

ジョブの途中で失敗した場合は、後続の処理が実行されないため、正常終了すれば仮想サーバの起動から、サーバ構築、テスト、仮想サーバの廃棄まですべて終了したことになります。

おわりに

今回、インフラのCIを実現するうえで必要な仮想サーバの起動から、サーバ構築、テスト、仮想サーバの廃棄までの処理を全て自動化することができました。また、Dockerを導入したことで、全ての処理が完了するまで2分程度で終了したため、テスト回数が増加しても対応できるレベルとなりました。この処理時間の短さは、インフラのICを実現するのに欠かせないものであると考えています。この仕組みが実際に使われれば、常に動くChefのレシピがある状態を保てるかと思います。
また、仮想サーバにAWSを使ったり、GitをGitHubなどのホスティングサービスを使うなど、細かな点で変更は可能ですが、全体の流れとしてある程度完成した形になったと思います。後は、実際に運用を行ってみて、ルール決めが必要となったり、欠点などが見えてくるのではと思っています。*1
今回の記事がみなさんのお役に立てられればと思います。

*1:一応見えている問題としては、ジョブに失敗すると、Dockerのコンテナが削除されずに残ってしまうため、どういうタイミングで消すかというものがあります。