2016年7月16日土曜日

[AWS] Packer + Ansible + Serverspec でAMI作成を自動化する


AmazonLinux にApacheHTTPをインストールしてAMIを作成したいと思います。
今回は、以下の手順で、作業することにします。
  1. AmazonLinux のAMIからEC2インスタンスを起動
  2. EC2インスタンスにSSHログイン
  3. ApacheHTTPをインストール
  4. httpサービスの起動確認
  5. EC2インスタンスを停止
  6. AMIを作成
  7. EC2インスタンスをターミネート
手作業だと、AWSコンソールや Teraterm を使うことになりますが、
これを、Packer+Ansible+Serverspec で自動化し、Packerのコマンド1回で済むようにしてみます。

Packerで1~7までの作業を実施しますが、
3では、Ansible のプレイブックを実行し、4では、Serverspec を実行します。


1.作業用のサーバを準備する


Packer+Ansible+Serverspecを実行するサーバを用意します。
今回は、AmazonLinuxを使用します。
なお、全ての作業は、ec2-user ユーザで行い、~/workspace ディレクトリを作って、そこに、Packer、Ansible、Serverspec のファイルを置くことにします。

1.1.作業用のEC2インスタンスを作成


今回、作業用に使用するEC2インスタンスには、管理者権限を付与したIAMロールを割り当てます。
これにより、Packer でAMIを作成するときに、アクセスキーの設定が不要になります。

1.2.Packer のインストール


Packer のダウンロードサイトからバイナリのコマンドをダウンロードして解凍し、/usr/local/bin に置きます。
[ec2-user@ip-10-0-11-158 ~]$ curl -OL https://releases.hashicorp.com/packer/0.10.1/packer_0.10.1_linux_amd64.zip
[ec2-user@ip-10-0-11-158 ~]$ unzip ./packer_0.10.1_linux_amd64.zip
[ec2-user@ip-10-0-11-158 ~]$ ls
packer  packer_0.10.1_linux_amd64.zip
[ec2-user@ip-10-0-11-158 ~]$ sudo mv ./packer /usr/local/bin/.
バージョンは以下のとおり。
[ec2-user@ip-10-0-11-158 ~]$ packer --version
0.10.1

1.3.Ansible のインストール


pipコマンドで Ansible をインストールします。
[ec2-user@ip-10-0-11-158 ~]$ sudo pip install ansible
バージョンは以下のとおり。
[ec2-user@ip-10-0-11-158 ~]$ ansible --version
ansible 2.1.0.0
  config file =
  configured module search path = Default w/o overrides
設定は以下のとおり。
[ec2-user@ip-10-0-11-158 ~]$ cat ~/.ansible.cfg
[defaults]
retry_files_enabled = False
host_key_checking = False
log_path=~/workspace/logs/ansible.log
[ssh_connection]
scp_if_ssh = True

1.4.Serverspec のインストール


gem コマンドで Serverspec をインストールします。
[ec2-user@ip-10-0-11-158 ~]$ sudo gem install serverspec
[ec2-user@ip-10-0-11-158 ~]$ sudo gem install rake
バージョンは以下のとおり。
[ec2-user@ip-10-0-11-158 ~]$ gem list --local | grep serverspec
serverspec (2.36.0)


2.Ansibleのプレイブックを作成する


Ansible のプレイブックを作成します。
yum で httpd をインストールし、自動起動をONにしてサービスを開始します。なお、httpがインストールされるサーバのIPアドレスを確認するために、ipコマンドで取得した eth0 のIPアドレスを表示します。
[ec2-user@ip-10-0-11-158 workspace]$ cat playbook_dev001.yml
---
- hosts: all
  become: yes
  tasks:
    - name: IPアドレス
      shell: ip a | awk '($0~/inet .* eth0/){split($2,i,"/");print i[1]}'
      register: ip_ret
    - debug: var=ip_ret.stdout

    - name: httpd インストール
      yum: name=httpd state=present
    - name: httpd の自動起動ON
      service: name=httpd enabled=yes state=started
自分自身を対象にして、プレイブックをテストします。
[ec2-user@ip-10-0-11-158 workspace]$  ansible-playbook -i ,127.0.0.1 playbook_dev001.yml -u ec2-user --private-key=./virginia_key.pem

PLAY [all] *********************************************************************

TASK [setup] *******************************************************************
ok: [127.0.0.1]

TASK [IPアドレス] ******************************************************************
changed: [127.0.0.1]

TASK [debug] *******************************************************************
ok: [127.0.0.1] => {
    "ip_ret.stdout": "10.0.11.158"
}

TASK [httpd インストール] ************************************************************
changed: [127.0.0.1]

TASK [httpd の自動起動ON] ***********************************************************
changed: [127.0.0.1]

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=5    changed=3    unreachable=0    failed=0

3.Packer+AnsibleでAMIを作成する


Ansibleのプレイブックができたので、Packer から起動したEC2インスタンスに Ansible のプレイブックを適用してAMIを作成してみます。
Packer のテンプレートは以下のようにました。
環境変数で、サーバ名、AMI-ID、EBSボリュームのサイズ(GB)、プレイブックを渡します。
[ec2-user@ip-10-0-11-158 workspace]$ cat packer.json
{
 "variables": {
    "server_name": "{{env `BUILD_SERVER_NAME`}}",
    "ami": "{{env `BUILD_AMI`}}",
    "volume_size": "{{env `BUILD_EBS_SIZE`}}",
    "ansible_playbook": "{{env `BUILD_PLAYBOOK`}}"
  },
  "builders" : [{
    "type" : "amazon-ebs",
    "region" : "us-east-1",
    "availability_zone" : "us-east-1b",
    "instance_type" : "t2.nano",
    "source_ami" : "{{user `ami`}}",
    "launch_block_device_mappings": [
      {
        "device_name": "/dev/xvda",
        "volume_size": "{{user `volume_size`}}",
        "volume_type": "gp2",
        "delete_on_termination": "true"
      }
    ],
    "subnet_id" : "subnet-f528a2ad",
    "associate_public_ip_address" : "true",
    "security_group_id": "sg-bbf176df",
    "ssh_keypair_name" : "virginia_key",
    "ssh_username" : "ec2-user",
    "ssh_private_key_file" : "/home/ec2-user/workspace/virginia_key.pem",
    "ssh_private_ip" : "true",
    "ami_name" : "TEST-AMI-{{user `server_name`}}",
    "ami_description" : "{{user `server_name`}} created by packer",
    "tags" : {
      "Name" : "{{user `server_name`}}",
      "Environment" : "develop"
    }
  }],
  "provisioners" : [
    {
     "type" : "ansible",
     "playbook_file" : "{{user `ansible_playbook`}}"
    }
   ]
}
キーペアのvirginia_keyはAWSコンソールで事前に作成しておきます。
サブネットやセキュリティグループは、作業用サーバと同じにして、プライベートIPアドレスで接続可能な環境を前提にしています。
また、yum を使用するので、Packer が一時的に起動するEC2インスタンスには パブリックIPアドレスを自動割り当てします。

続いて、Packe 実行用のシェルを作成します。
[ec2-user@ip-10-0-11-158 workspace]$ cat packer.sh
#!/bin/bash

server_name="dev001"
base_dir="/home/ec2-user/workspace"
packer_json="${base_dir}/packer.json"
packer_log="${base_dir}/logs/packer-${server_name}.log"

# Packer
export BUILD_PLAYBOOK="${base_dir}/playbook_${server_name}.yml"
export BUILD_SERVER_NAME="${server_name}"
export BUILD_AMI="ami-6869aa05"
export BUILD_EBS_SIZE="10"

env PACKER_LOG=1 PACKER_LOG_PATH=${packer_log} packer build ${packer_json}

exit 0
環境変数にAmazonLinux のAMI-IDなどを設定して、Packerを実行するようにし、詳細なログファイルを出力するようにしています。

これを実行すると以下のようになります。
[ec2-user@ip-10-0-11-158 workspace]$ ./packer.sh
amazon-ebs output will be in this color.

==> amazon-ebs: Prevalidating AMI Name...
==> amazon-ebs: Inspecting the source AMI...
==> amazon-ebs: Launching a source AWS instance...
    amazon-ebs: Instance ID: i-1fc8af8f
==> amazon-ebs: Waiting for instance (i-1fc8af8f) to become ready...
==> amazon-ebs: Waiting for SSH to become available...
==> amazon-ebs: Connected to SSH!
==> amazon-ebs: Provisioning with Ansible...
==> amazon-ebs: Executing Ansible: ansible-playbook /home/ec2-user/workspace/playbook_dev001.yml -i /tmp/packer-provisioner-ansible182536530 --private-key /tmp/ansible-key107272703
==> amazon-ebs: SSH proxy: serving on 127.0.0.1:45242
    amazon-ebs:
    amazon-ebs: PLAY [all] *********************************************************************
    amazon-ebs:
    amazon-ebs: TASK [setup] *******************************************************************
    amazon-ebs: SSH proxy: accepted connection
==> amazon-ebs: authentication attempt from 127.0.0.1:45682 to 127.0.0.1:45242 as ec2-user using none
==> amazon-ebs: authentication attempt from 127.0.0.1:45682 to 127.0.0.1:45242 as ec2-user using publickey
    amazon-ebs: ok: [default]
    amazon-ebs:
    amazon-ebs: TASK [IPアドレス] ******************************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: TASK [debug] *******************************************************************
    amazon-ebs: ok: [default] => {
    amazon-ebs:     "ip_ret.stdout": "10.0.11.39"
    amazon-ebs: }
    amazon-ebs:
    amazon-ebs: TASK [httpd インストール] ************************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: TASK [httpd の自動起動ON] ***********************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: PLAY RECAP *********************************************************************
    amazon-ebs: default                    : ok=5    changed=3    unreachable=0    failed=0
    amazon-ebs:
==> amazon-ebs: shutting down the SSH proxy
==> amazon-ebs: Stopping the source instance...
==> amazon-ebs: Waiting for the instance to stop...
==> amazon-ebs: Creating the AMI: TEST-AMI-dev001
    amazon-ebs: AMI: ami-10be3307
==> amazon-ebs: Waiting for AMI to become ready...
==> amazon-ebs: Modifying attributes on AMI (ami-10be3307)...
    amazon-ebs: Modifying: description
==> amazon-ebs: Adding tags to AMI (ami-10be3307)...
    amazon-ebs: Adding tag: "Name": "dev001"
    amazon-ebs: Adding tag: "Environment": "develop"
==> amazon-ebs: Tagging snapshot: snap-4179b4a5
==> amazon-ebs: Terminating the source AWS instance...
==> amazon-ebs: Cleaning up any extra volumes...
==> amazon-ebs: No volumes to clean up, skipping
Build 'amazon-ebs' finished.

==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:

us-east-1: ami-10be3307
Packer実行中は、下図のように "Packer Builder" という名称のEC2インスタンスが起動します。
この "Packer Builder" に対して、プレイブックが適用されます。
なお、AMI作成後は、ターミネートされます。



AMIは下図のように作成されています。



4.Serverspecを作成する


Serverspecで http のテストケースを作成します。
まず、以下のようにして serverspec の雛形を作成します。
[ec2-user@ip-10-0-11-158 workspace]$ mkdir serverspec
[ec2-user@ip-10-0-11-158 workspace]$ cd serverspec
[ec2-user@ip-10-0-11-158 serverspec]$ 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: packer-builder
 + spec/
 + spec/packer-builder/
 + spec/packer-builder/sample_spec.rb
 + spec/spec_helper.rb
 + Rakefile
 + .rspec
次に環境変数でSSH秘密鍵を指定できるように ~/workspace/serverspec/spec/spec_helper.rb を修正します。
以下の赤字部分を spec_helper.rb に追加します。
options = Net::SSH::Config.for(host)

options[:user] ||= Etc.getlogin
options[:keys] = ENV['TARGET_SSH_KEY']

set :host,        options[:host_name] || host
set :ssh_options, options
テストケースは以下のとおりです。
httpdパッケージのインストール、自動起動ON、サービス起動中かをチェックします。
[ec2-user@ip-10-0-11-158 workspace]$ cat serverspec/spec/packer-builder/http_spec.rb
require 'spec_helper'

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

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

describe port(80) do
  it { should be_listening }
end
あとは、Packer が起動したEC2インスタンスに対して、Serverspec を実行するようにします。
いろいろ、調べたのですが、簡単な方法が見つかりませんでした。

しかたないので、Packer が起動したEC2インスタンスのIPアドレスをaws-cliで取得して、Serverspecを実行することにしました。
以下のような、Packer から Serverspec を実行するためのシェルを作成しました
[ec2-user@ip-10-0-11-158 workspace]$ cat serverspec.sh
#!/bin/bash

BASE_DIR=/home/ec2-user/workspace
SERVERSPEC_DIR=${BASE_DIR}/serverspec

ip=` \
aws ec2 describe-instances \
 --region us-east-1 \
 --filters "Name=tag-key,Values=Name" \
  "Name=tag-value,Values=Packer Builder" \
  "Name=instance-state-name,Values=running" \
 --output text \
 --query "Reservations[].Instances[].[LaunchTime,PrivateIpAddress]" \
 | sort | tail -1 | cut -f2 \
`
ln -s ./packer-builder ${SERVERSPEC_DIR}/spec/${ip}

(
  cd ${SERVERSPEC_DIR}
  env TARGET_SSH_KEY=${BASE_DIR}/virginia_key.pem rake spec:${ip} > /tmp/xxx
)

rm -f ${SERVERSPEC_DIR}/spec/${ip} > /dev/null 2>&1

n=`cat /tmp/xxx | tail -2 | grep -c '0 failures'`
cat /tmp/xxx
rm -f /tmp/xxx

ret=0
if [ $n -eq 0 ]; then
  ret=1
fi

exit $ret

aws-cli で "Nameタグ"が"Packer Builder"のインスタンスのなかから、起動時間が最新のものを選択し、プライベートIPアドレスを取得します。

取得したIPアドレスで Serverspec の packer-build にシンボリックリンクを作成して、Serverspec を実行します。

テスト結果が全部OKであれば終了ステータスで 0 を返します。

このシェルをテストしてみます。
作業用のサーバの "Nameタグ" を "Packer Builder" に変更して、シェルを実行すると以下のようになります。
httpd をインストールしてないので、エラーになりますが、ちゃんと動いているようです。
[ec2-user@ip-10-0-11-158 workspace]$ ./serverspec.sh
/usr/bin/ruby2.0 -I/usr/local/share/ruby/gems/2.0/gems/rspec-support-3.5.0/lib:/usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/lib /usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/exe/rspec --pattern spec/10.0.11.158/\*_spec.rb failed
/usr/bin/ruby2.0 -I/usr/local/share/ruby/gems/2.0/gems/rspec-support-3.5.0/lib:/usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/lib /usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/exe/rspec --pattern spec/10.0.11.158/\*_spec.rb

Package "httpd"
  should be installed (FAILED - 1)

Service "httpd"
  should be enabled (FAILED - 2)
  should be running (FAILED - 3)

Port "80"
  should be listening (FAILED - 4)

Failures:

  1) Package "httpd" should be installed
     On host `10.0.11.158'
     Failure/Error: it { should be_installed }
       expected Package "httpd" to be installed
       sudo -p 'Password: ' /bin/sh -c rpm\ -q\ httpd
       package httpd is not installed

     # ./spec/10.0.11.158/http_spec.rb:4:in `block (2 levels) in <top (required)>'

  2) Service "httpd" should be enabled
     On host `10.0.11.158'
     Failure/Error: it { should be_enabled }
       expected Service "httpd" to be enabled
       sudo -p 'Password: ' /bin/sh -c chkconfig\ --list\ httpd\ \|\ grep\ 3:on

     # ./spec/10.0.11.158/http_spec.rb:8:in `block (2 levels) in <top (required)>'

  3) Service "httpd" should be running
     On host `10.0.11.158'
     Failure/Error: it { should be_running }
       expected Service "httpd" to be running
       sudo -p 'Password: ' /bin/sh -c ps\ aux\ \|\ grep\ -w\ --\ httpd\ \|\ grep\ -qv\ grep

     # ./spec/10.0.11.158/http_spec.rb:9:in `block (2 levels) in <top (required)>'

  4) Port "80" should be listening
     On host `10.0.11.158'
     Failure/Error: it { should be_listening }
       expected Port "80" to be listening
       sudo -p 'Password: ' /bin/sh -c netstat\ -tunl\ \|\ grep\ --\ :80\\\

     # ./spec/10.0.11.158/http_spec.rb:13:in `block (2 levels) in <top (required)>'

Finished in 0.31946 seconds (files took 0.28991 seconds to load)
4 examples, 4 failures

Failed examples:

rspec ./spec/10.0.11.158/http_spec.rb:4 # Package "httpd" should be installed
rspec ./spec/10.0.11.158/http_spec.rb:8 # Service "httpd" should be enabled
rspec ./spec/10.0.11.158/http_spec.rb:9 # Service "httpd" should be running
rspec ./spec/10.0.11.158/http_spec.rb:13 # Port "80" should be listening

[ec2-user@ip-10-0-11-158 workspace]$ echo $?
1

5.Packer+Ansibe+ServerspecでAMIを作成する


前述のPacker のテンプレートを修正して、Ansibleの後に Serverspec を実行するようにします。
テンプレートは以下のとおり。
赤字部分を追加しました。
[ec2-user@ip-10-0-11-158 workspace]$ cat packer.json
{
 "variables": {
    "server_name": "{{env `BUILD_SERVER_NAME`}}",
    "ami": "{{env `BUILD_AMI`}}",
    "volume_size": "{{env `BUILD_EBS_SIZE`}}",
    "ansible_playbook": "{{env `BUILD_PLAYBOOK`}}"
  },
  "builders" : [{
    "type" : "amazon-ebs",
    "region" : "us-east-1",
    "availability_zone" : "us-east-1b",
    "instance_type" : "t2.nano",
    "source_ami" : "{{user `ami`}}",
    "launch_block_device_mappings": [
      {
        "device_name": "/dev/xvda",
        "volume_size": "{{user `volume_size`}}",
        "volume_type": "gp2",
        "delete_on_termination": "true"
      }
    ],
    "subnet_id" : "subnet-f528a2ad",
    "associate_public_ip_address" : "true",
    "security_group_id": "sg-bbf176df",
    "ssh_keypair_name" : "virginia_key",
    "ssh_username" : "ec2-user",
    "ssh_private_key_file" : "/home/ec2-user/workspace/virginia_key.pem",
    "ssh_private_ip" : "true",
    "ami_name" : "TEST-AMI-{{user `server_name`}}",
    "ami_description" : "{{user `server_name`}} created by packer",
    "tags" : {
      "Name" : "{{user `server_name`}}",
      "Environment" : "develop"
    }
  }],
  "provisioners" : [
    {
     "type" : "ansible",
     "playbook_file" : "{{user `ansible_playbook`}}"
    },
    {
     "type" : "shell-local",
     "command" : "/home/ec2-user/workspace/serverspec.sh"
    }
   ]
}
これを、前述のシェルで実行すると、以下のとおり。
[ec2-user@ip-10-0-11-158 workspace]$ ./packer.sh
amazon-ebs output will be in this color.

==> amazon-ebs: Prevalidating AMI Name...
==> amazon-ebs: Inspecting the source AMI...
==> amazon-ebs: Launching a source AWS instance...
    amazon-ebs: Instance ID: i-b12e4821
==> amazon-ebs: Waiting for instance (i-b12e4821) to become ready...
==> amazon-ebs: Waiting for SSH to become available...
==> amazon-ebs: Connected to SSH!
==> amazon-ebs: Provisioning with Ansible...
==> amazon-ebs: Executing Ansible: ansible-playbook /home/ec2-user/workspace/playbook_dev001.yml -i /tmp/packer-provisioner-ansible519331836 --private-key /tmp/ansible-key013979345
==> amazon-ebs: SSH proxy: serving on 127.0.0.1:43408
    amazon-ebs:
    amazon-ebs: PLAY [all] *********************************************************************
    amazon-ebs:
    amazon-ebs: TASK [setup] *******************************************************************
    amazon-ebs: SSH proxy: accepted connection
==> amazon-ebs: authentication attempt from 127.0.0.1:55066 to 127.0.0.1:43408 as ec2-user using none
==> amazon-ebs: authentication attempt from 127.0.0.1:55066 to 127.0.0.1:43408 as ec2-user using publickey
    amazon-ebs: ok: [default]
    amazon-ebs:
    amazon-ebs: TASK [IPアドレス] ******************************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: TASK [debug] *******************************************************************
    amazon-ebs: ok: [default] => {
    amazon-ebs:     "ip_ret.stdout": "10.0.11.66"
    amazon-ebs: }
    amazon-ebs:
    amazon-ebs: TASK [httpd インストール] ************************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: TASK [httpd の自動起動ON] ***********************************************************
    amazon-ebs: changed: [default]
    amazon-ebs:
    amazon-ebs: PLAY RECAP *********************************************************************
    amazon-ebs: default                    : ok=5    changed=3    unreachable=0    failed=0
    amazon-ebs:
==> amazon-ebs: shutting down the SSH proxy
==> amazon-ebs: Executing local command: /home/ec2-user/workspace/serverspec.sh
    amazon-ebs: /usr/bin/ruby2.0 -I/usr/local/share/ruby/gems/2.0/gems/rspec-support-3.5.0/lib:/usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/lib /usr/local/share/ruby/gems/2.0/gems/rspec-core-3.5.1/exe/rspec --pattern spec/10.0.11.66/\*_spec.rb
    amazon-ebs:
    amazon-ebs: Package "httpd"
    amazon-ebs: should be installed
    amazon-ebs:
    amazon-ebs: Service "httpd"
    amazon-ebs: should be enabled
    amazon-ebs: should be running
    amazon-ebs:
    amazon-ebs: Port "80"
    amazon-ebs: should be listening
    amazon-ebs:
    amazon-ebs: Finished in 0.31962 seconds (files took 0.29783 seconds to load)
    amazon-ebs: 4 examples, 0 failures
    amazon-ebs:
==> amazon-ebs: Stopping the source instance...
==> amazon-ebs: Waiting for the instance to stop...
==> amazon-ebs: Creating the AMI: TEST-AMI-dev001
    amazon-ebs: AMI: ami-274ec330
==> amazon-ebs: Waiting for AMI to become ready...
==> amazon-ebs: Modifying attributes on AMI (ami-274ec330)...
    amazon-ebs: Modifying: description
==> amazon-ebs: Adding tags to AMI (ami-274ec330)...
    amazon-ebs: Adding tag: "Environment": "develop"
    amazon-ebs: Adding tag: "Name": "dev001"
==> amazon-ebs: Tagging snapshot: snap-fe5ac41c
==> amazon-ebs: Terminating the source AWS instance...
==> amazon-ebs: Cleaning up any extra volumes...
==> amazon-ebs: No volumes to clean up, skipping
Build 'amazon-ebs' finished.

==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:

us-east-1: ami-274ec330

これで、Ansible で作ったEC2インスタンスを Serverspec でチェックし、OKならAMIを作成するようになりました。

なお、Packerは、Ansible や Serverspec でエラーになると、AMIは作成せずに、EC2インスタンスをターミネートして終了します。