Chef(knife-solo)+Serverspec+GitHub+Wercker+AWS(EC2)でインフラCIする

ChefとServerspecやったらこれはインフラCI試してみるしかないということでやってみた。

CIするためのツールとかサービスとかいろいろあって、CIツールとしてはJenkinsが有名なんだけど、例えばJenkins用にEC2のインスタンス立て維持するのは個人としてはコスト的にちょっとなーというかんじではある。個人ならCIサービスのがよいかなーと調べてみたところ、だいたい機能にはあんまり大差なさそうな感じでTravisとか有名そうだったんだけど、Werckerというのがプライベートリポジトリも無料っぽくて、後々何かの役に立ちそうな気もするのでWerckerにした。ただベータ期間中だけ無料ということであって、将来的にもプライベートリポジトリが無料かどうかはわかんないです。

Oracle Buys Wercker

やってみたツールの組み合わせはChef(knife-solo)+Serverspec+GitHub+Wercker+AWS(EC2)というかんじで、ChefとServerspecで書いたインフラコードをGitHubにプッシュするとWerckerでフックして、EC2上にインスタンス立てて、Chefのレシピを適用し、Serverspecでテストするという流れ。

大まかな流れについては、サーバ/インフラ徹底攻略 (WEB+DB PRESS plus) 「特集2 テスト駆動インフラ&CI最前線」で、Puppet+Serverspec+GitHub+Wercker+Vagrant+DigitalOceanの例が載ってて、基本的な構想はこれを参考にしたのだけど、サーバのキッティングがPuppetじゃなくてChefなのと、テストのインスタンス立てるのがDigitalOceanじゃなくてAWS。あとクラウド上にインスタンス立てるのに作りがVagarantに依存するのが個人的にイマイチな気がして、AWSの制御はaws-sdkからAPIを自前で叩くかんじにしたので、それぞれの部品はいろいろ違うかんじにはなった。

まず事前準備として、AWSコンソールからwercker用のIAMユーザを作って、EC2のインスタンス制御するためのAPI用のアクセスキーを生成する。

  • IAM
    • Groups
      • Create New Group
        • Group Name: ec2-admins
        • Attach Policy: AmazonEC2FullAccess
    • Users
      • Create New User
        • Enter User Names: wercker
        • Generate an access key for each user: on
        • Show User Security Credentialsで以下のAPI用のアクセスキーの値をメモる。
      • werckerユーザにグループを割り当てる
        • Add User to Groups
        • Group Name: ec2-admins

あと、EC2インスタンスSSHログインするためのSSH鍵を作っておく。

  • EC2
    • Key Pairs
      • Create Key Pair
        • Key pair name: aws-wercker

秘密鍵aws-wercker.pemとしてダウンロードしておく。SSH鍵についてはWercker側でキーペア作成することもできるのだけど、その場合は、秘密鍵がWerckerのサービスの外に持ち出せないので、ビルドがコケた時に手元のPCからビルド中のインスタンスSSHできなくてイマイチなので、AWS側で作ったキーをWerckerに登録した方が柔軟性があると思う。

次に、Werckerにアカウント登録する。
http://wercker.com/
登録時にREGISTER USING GITHUBGitHubアカウント認証もしておく。

ログインしたらダッシュボードから「Add An Application」を選んで、リポジトリを登録する。

  • Add An Application
    • Choose a Git provider
    • Select a repository
      • 対象のリポジトリここではminamijoyo/commit-infraを指定して、Use selected repoを押す。
    • Configure access
      • 今回はpublicリポジトリなので「wercker will checkout the code without using an SSH key」を選択。
    • Setup your wercker.yml
      • リポジトリ内に先にビルド定義のwercker.ymlを作成しておくと自動検出される。後で作成してもよい。
    • Finish

リポジトリ登録したらSettingsタブで環境変数とか設定する。

  • Settings
    • PIPELINE
      • Add new variableで環境変数を登録する。
        • AWS_ACCESS_KEY_ID
          • Text: xxxxx (さっき作ったAWSAPI用のアクセスキーID)
          • Protected: on
        • AWS_SECRET_ACCESS_KEY
          • Text: xxxx (さっき作ったAWSAPI用のアクセスキー)
          • Protected: on
        • AWS_DEFAULT_REGION
          • Text: ap-northeast-1 (AWSのリージョンは各自環境に合わせて読み替え)
          • Protected: off
        • AWS_SSH_KEY_PRIVATE
          • Text: xxxx(さっき作ったSSH秘密鍵aws-werker.pemの内容を貼り付ける)
          • Protected: on
        • TARGET_SSH_KEYPATH
          • Text: ~/.ssh/aws-wercker.pem (SSH鍵を配置するパスは後でserverspecから参照するために環境変数にしてる)
          • Protected: off

ビルド定義のwercker.ymlはこんなかんじ。GitHubリポジトリの直下に配置しておく。

box: wercker/ruby
build:
  steps:
    - bundle-install
    - script:
        name: make ssh dir
        code: mkdir -m 700 -p $HOME/.ssh
    - create-file:
        name: put ssh private key
        filename: $HOME/.ssh/aws-wercker.pem
        overwrite: true
        hide-from-log: true
        content: $AWS_SSH_KEY_PRIVATE
    - script:
        name: chmod ssh private key
        code: chmod 400 $HOME/.ssh/aws-wercker.pem
    - script:
        name: delete ec2.env file if exists
        code: rm -f ec2.env
    - script:
        name: aws ec2 run-instances
        code: bundle exec ruby ec2-run-instances.rb
    - script:
        name: print ec2.env for debug
        code: cat ec2.env
    - script:
        name: export environments
        code: source ec2.env
    - script:
        name: ssh connect test
        code: ssh ec2-user@$TARGET_IP -i $HOME/.ssh/aws-wercker.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null hostname
    - script:
        name: berks vendor cookbooks
        code: bundle exec berks vendor cookbooks
    - script:
        name: knife solo bootstrap
        code: bundle exec knife solo bootstrap ec2-user@$TARGET_IP -i $HOME/.ssh/aws-wercker.pem -N commitm-ap
    - script:
        name: serverspec
        code: bundle exec rake serverspec:commitm-ap
    - script:
        name: aws ec2 terminate-instances
        code: bundle exec ruby ec2-terminate-instances.rb

順番に簡単に説明していくと、

box: wercker/ruby

boxというのはwercker上でビルドする環境のことで、予めいくつか用意されてるし、自分で作って登録することもできるようです。

build:
  steps:
    - bundle-install

次にビルドのステップを順番に書いていきます。
bundle-installは組み込みタスクでGemfileをbundle installするやつです。
ちなみに今回Gemfileはこんなかんじです。

source 'https://rubygems.org'

gem "chef", '~> 12.0.3'
gem "knife-solo", '~> 0.4.2'
gem "berkshelf", '~> 3.2.3'
gem "serverspec", '~> 2.8.2'
gem "aws-sdk", '~> 2.0.28'

各自、必要な物を適宜書いてください。

    - script:
        name: make ssh dir
        code: mkdir -m 700 -p $HOME/.ssh
    - create-file:
        name: put ssh private key
        filename: $HOME/.ssh/aws-wercker.pem
        overwrite: true
        hide-from-log: true
        content: $AWS_SSH_KEY_PRIVATE
    - script:
        name: chmod ssh private key
        code: chmod 400 $HOME/.ssh/aws-wercker.pem

SSH秘密鍵を配置するのは環境変数を参照してファイルに落とす。scriptタスクのcodeで任意のコマンドが書けるので基本なんでもできる。nameの欄はただのコメントなのでなんでもいいけど、werckerの管理画面でビルド中の進捗状況にnameの文字列が出るので処理内容がわかる程度のコメントの方がよいと思う。

    - script:
        name: delete ec2.env file if exists
        code: rm -f ec2.env
    - script:
        name: aws ec2 run-instances
        code: bundle exec ruby ec2-run-instances.rb
    - script:
        name: print ec2.env for debug
        code: cat ec2.env
    - script:
        name: export environments
        code: source ec2.env

aws-sdkでEC2インスタンス作ってるec2-run-instances.rbの中身は昨日のエントリを参照。
EC2インスタンス起動後にsshできるまで待つ(若干手抜き) - 城陽人の本棚
立てたインスタンスのIDとパブリックIPを他のステップに環境変数で引き回すために、ec2.envという中間ファイル作ってます。なんかもうちょっとカッコいい方法ないものか。

    - script:
        name: ssh connect test
        code: ssh ec2-user@$TARGET_IP -i $HOME/.ssh/aws-wercker.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null hostname

後続のchefの適用などがコケた場合の切り分けのために、念のためSSH接続のテストしておく。

    - script:
        name: berks vendor cookbooks
        code: bundle exec berks vendor cookbooks
    - script:
        name: knife solo bootstrap
        code: bundle exec knife solo bootstrap ec2-user@$TARGET_IP -i $HOME/.ssh/aws-wercker.pem -N commitm-ap

berksでクックブック取得してknife soloで適用する。-Nがレシピを適用するノード定義。ファイル名がnodes/commitm-ap.jsonとかであらかじめ定義してあります。

{
  "environment": "production",
  "run_list": [
    "role[base]",
    "role[ap]"
  ]
}

ロールの定義の詳細は本題からそれるので割愛。Chefの使い方は適宜ググってください。

    - script:
        name: serverspec
        code: bundle exec rake serverspec:commitm-ap

serverspecでテストする。serverspecのテスト対象ごとに変わるパラメータの指定はhosts.jsonみたいな設定を外出ししたファイルを予め準備しておいて、rakeタスク生成したタイミングで環境変数埋め込むようなかんじにしてます。

[
(略)
  {
    "name": "commitm-ap",
    "host_name": "<%= ENV['TARGET_IP'] %>",
    "user": "ec2-user",
    "port": 22,
    "keys":  "<%= ENV['TARGET_SSH_KEYPATH'] %>",
    "roles":["base", "ap"]
  }
]

Rakefileの書き方とかは以下を参照。
Serverspecでテスト対象のIPとロールを指定する - 城陽人の本棚
knife-soloとの対称性を考えると環境変数よりrakeタスクの引数にした方が綺麗かもしんない。

    - script:
        name: aws ec2 terminate-instances
        code: bundle exec ruby ec2-terminate-instances.rb

最後にEC2のインスタンスを廃棄する。インスタンスの廃棄は普通のstepsの中に書かずにafter-stepsというのでfinallyっぽい書き方もできるのだけど、ビルドこけたら現状保存した状態でsshログインして調査したくなるので、普通のstepsに直列に並べてます。ec2-terminate-instances.rbの中身はこんなかんじで、aws-sdkからterminate_instances叩いてるだけです。消す前にAMIとか作成したかったらAWSAPI好きに叩けばよいと思う。

require 'aws-sdk'

# set environments
puts "initialize client"
ec2 = Aws::EC2::Client.new(
  access_key_id: ENV['AWS_ACCESS_KEY_ID'],
  secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
  region: ENV['AWS_DEFAULT_REGION']
)

instance_id = ENV["INSTANCE_ID"]
puts "INSTANCE_ID=#{instance_id}"

puts "terminate instance"
terminate_response = ec2.terminate_instances(instance_ids: [instance_id])
puts "#{terminate_response}"

ビルド定義をwercker.ymlという名前でリポジトリの直下に置いておいたら、あとは何かリポジトリを更新してgit pushするたびに自動でビルドが走る。うん、いいかんじ。

werckerならではっぽい注意点としては、ビルドは25分以内に終わる必要がある。ただbundle-installはキャッシュされるので、初回だけ時間かかっても2回目以降はキャッシュが聞くのでだいぶ速い。あと、ビルド中標準出力に5分以上出力がないとハングしたと見なされて強制停止される。これが原因でberkshelfが依存してるdep-selector-libgecodeのインストールが一回コケたのだけど、再ランしたらキャッシュが効いたからか通った。本質的に解決してないのであんまりよくはないと思うので、もし頻発して前処理にいろいろ時間がかかる場合は専用のBOXを作った方がよいかもしんない。あとEC2インスタンスの起動待ちとか時間のかかりそうなタスクは適宜ループ処理内で標準出力を出したりして若干の工夫が必要。

まあ何にせよこれでとりあえずミニマムなインフラCI環境できたのでよいかんじじゃないでしょうか。

参考