AWS OpsWorksをVagrantでLocal環境に再現する方法


#なぜやるか
AWS OpsWorksは強力なクラウドアプリケーション管理ツールです。

ただ、一つ難点があるとすれば、AWS上にしか環境を作れないことです。なので、chefもAWS上でしか試せません。また、chefの魅力の一つであるローカルにも同じ環境を作ることができるという利点が失われています。なぜ、AWS上にしか環境を作れないのかというと、OpsWorkを機能させるためのOpsWorks Agentというモジュールが各インスタンスにプリインストールされていて、また、それと連携して動作するOpsoWorks独自のレシピがインスタンス作成時に起動して色々なOpsWorksの設定をする仕組みになっているからです。

AWS上でしか環境を含めた試験ができないとなると、試験をする度にいちいちインスタンスを生成して課金されることになってしまいます。課金が嫌だというのと、普段から本番と同じ環境で開発をすすめたいため、なんとかVagrantを使ってOpsWorksの環境を再現してみました。Railsプロジェクトを想定していますが、他のものでも使えると思います。


#手順

##1.OpsWorks AgentがインストールされたVagrant boxファイルを作成する。

https://github.com/wwestenbrink/vagrant-opsworks

このツールを使ってboxファイルを作成します。dependeciesに書いてあるものがちゃんとinstallされていることを確認し、usageに従って作成します。
virtualbox用のコマンドとvmware用のコマンドが両方書いているので、使っている環境に合わせて進めます。OSは自分はOpsWoks上でも使っているので、ubuntu14.04（デフォルトなので指定はしない）を使いました。

##2.再現したい環境をAWS OpsWorks上で作り、setup時のnode情報を取得する。

手順1の処理は何十分かかかります。その間にでも１度普通にOpsWorksで環境を作ります。そして、terminal等でsshログインし、以下のコマンドでsetup時のnode情報をjsonで取得し、どこかにコピペで保存しておきます。

`sudo opsworks-agent-cli get_json setup`

OpsWorksがインスタンスを操作する際、chefで利用するnodeの情報をjsonとして毎回保存しています。OpsWorksの操作はsetup、deploy、cofigureなど数種類あります。上記のコマンドは一番最近のsetup時のnodeの情報を出力させるものになります。このjsonとopsworks-agent-cliを利用し、OpsWorksがインスタンス上で行う処理を再現することができます。（後ほど説明）

##3.VagrantfileとLocal用のcookbooksを作る。

リポジトリ構成は色々と検討の余地があると思いますが、自分はRailsプロジェクトの中に、VagrantfileとLocal用のcookbooksを作りました。
このcookbooksはOpsWorks上で使用するcustom cookbooksではないことに注意して下さい。Local環境にOpsWorksを再現するための処理をするcookbooksになります。custom cookbookは公式ドキュメントにあるよう別レポジトリで管理します。

```
.
├── Gemfile
├── Gemfile.lock
├── README.rdoc
├── Rakefile
├── Vagrantfile
├── app
├── bin
├── config
├── config.ru
├── cookbooks
|   └── mimic_opsworks
|       ├── attributes
|       │   └── default.rb
|       ├── recipes
|       │   ├── default.rb
|       │   └── link_local.rb
|       └── templates
|           └── default
|               └── json.erb
├── db
├── lib
├── log
├── public
├── test
├── tmp
└── vendor
```

一応自分のVagrantfileをさらしますが、chefとVagrantの経験浅いので、コード汚いです。意図を汲み取ったら、リファクタリングして下さい。（本当は一つのレシピに全部いれたかった。）

```rb:Vagrantfile
# -*- mode: ruby -*-
# vi: set ft=ruby :

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  app_name = "yourapp"
  config.vm.box = "ubuntu1404-opsworks" #手順1で作成したboxファイル名を指定
  config.vm.network "private_network", ip: "192.168.33.10"
  config.vm.synced_folder ".", "/vagrant", type: 'nfs'
  config.vm.provision "shell", inline: "apt-get update > /dev/null"
  config.vm.provision "chef_solo" do |chef|  
    chef.json = { app_name: app_name }
    chef.run_list = ["mimic_opsworks::default"]
  end
  config.vm.provision "shell", inline: "opsworks-agent-cli run_command"
  config.vm.provision "chef_solo" do |chef|  
    chef.run_list = ["mimic_opsworks::link_local"]
  end
  config.vm.provision "shell", inline: "cd /srv/www/#{app_name}/current; bundle install"
end

```

以下で共有フォルダをnfsでマウントします。なぜかというと、Vagrant上のrailsアプリからlogやasset等の書き込みを可能にするためです。あとnfsの方が反映速いらしい。
` config.vm.synced_folder ".", "/vagrant", type: 'nfs'`

なぜか以下をいれないと、エラーが発生します。
` config.vm.provision "shell", inline: "apt-get update > /dev/null"`
なお、実際のprovision時に、この処理を実行しているところで`Duplicate sources.list entry`みたいなwarningが赤字で出たりしますが、問題ないようです。


先ほど取得したjsonファイルを修正したものをtemplateとして、あるディレクトリに配置する処理。

```
  config.vm.provision "chef_solo" do |chef|  
    chef.json = { app_name: app_name }
    chef.run_list = ["mimic_opsworks::default"]
  end
```

mimic_opsworks::defaultレシピはこんな感じです。jsonファイルにはdatetimeなファイル名にしないといけません。


```rb:default.rb
directory "/var/lib/aws/opsworks/chef" do
  mode '0755'
  action :create
end
template "/var/lib/aws/opsworks/chef/2014-11-11-03-03-49-02.json" do
  source "json.erb"
end
```

修正後のjson.erbを参考までに一応載せますが、構成によって違ってくるので、手順２のjsonから自分で作成して下さい。修正のポイントを下に書きます。

```rb:json.erb
{
  "ssh_users": {
  },
  "opsworks": {
    "agent_version": "328",
    "layers": {
      "db-master": {
        "instances": {
        }
      },
      "rails-app": {
        "name": "Rails App Server",
        "id": "99999999-9999-9999-9999-999999999999",
        "elb-load-balancers": [

        ],
        "instances": {
          "rails-app1": {
            "public_dns_name": "ec2-99-99-99-99.ap-northeast-1.compute.amazonaws.com",
            "private_dns_name": "ip-99-99-99-99.ap-northeast-1.compute.internal",
            "backends": 8,
            "ip": "99.99.99.99",
            "private_ip": "99.99.99.99",
            "instance_type": "c3.large",
            "status": "online",
            "id": "99999999-9999-9999-9999-999999999999",
            "aws_instance_id": "i-99999999",
            "elastic_ip": null,
            "created_at": "2014-11-08T10:16:10+00:00",
            "booted_at": "2014-11-10T00:56:37+00:00",
            "region": "ap-northeast-1",
            "availability_zone": "ap-northeast-1a",
            "infrastructure_class": "ec2"
          }
        }
      },
      "postgres": {
        "name": "postgres",
        "id": "99999999-9999-9999-9999-999999999999",
        "elb-load-balancers": [

        ],
        "instances": {
          "rails-app1": {
            "public_dns_name": "ec2-99-99-99-99.ap-northeast-1.compute.amazonaws.com",
            "private_dns_name": "ip-99-99-99-99.ap-northeast-1.compute.internal",
            "backends": 8,
            "ip": "99.99.99.99",
            "private_ip": "99.99.99.99",
            "instance_type": "c3.large",
            "status": "online",
            "id": "99999999-9999-9999-9999-999999999999",
            "aws_instance_id": "i-99999999",
            "elastic_ip": null,
            "created_at": "2014-11-08T10:16:10+00:00",
            "booted_at": "2014-11-10T00:56:37+00:00",
            "region": "ap-northeast-1",
            "availability_zone": "ap-northeast-1a",
            "infrastructure_class": "ec2"
          }
        }
      }
    },
    "activity": "setup",
    "valid_client_activities": [
      "reboot",
      "stop",
      "deploy",
      "setup",
      "configure",
      "update_dependencies",
      "install_dependencies",
      "update_custom_cookbooks",
      "execute_recipes"
    ],
    "sent_at": 9999999999,
    "deployment": null,
    "applications": [
      {
      "name": "<%= node[:app_name] %>",
      "slug_name": "<%= node[:app_name] %>",
        "application_type": "rails"
      }
    ],
    "stack": {
      "name": "Railspostgres",
      "id": "99999999-9999-9999-9999-999999999999",
      "vpc_id": "vpc-99999999",
      "elb-load-balancers": [

      ],
      "rds_instances": [

      ]
    },
    "instance": {
      "id": "99999999-9999-9999-9999-999999999999",
      "hostname": "rails-app1",
      "instance_type": "c3.large",
      "public_dns_name": "ec2-99-99-99-99.ap-northeast-1.compute.amazonaws.com",
      "private_dns_name": "ip-99-99-99-99.ap-northeast-1.compute.internal",
      "ip": "99.99.99.99",
      "private_ip": "99.99.99.99",
      "architecture": "x86_64",
      "layers": [
        "postgres",
        "rails-app"
      ],
      "backends": 8,
      "aws_instance_id": "i-99999999",
      "region": "ap-northeast-1",
      "availability_zone": "ap-northeast-1a",
      "subnet_id": "subnet-99999999",
      "infrastructure_class": "ec2"
    },
    "ruby_version": "2.1",
    "ruby_stack": "ruby",
    "rails_stack": {
      "name": "nginx_unicorn"
    }
  },
  "deploy": {
  "<%= node[:app_name] %>": {
  "application": "<%= node[:app_name] %>",
      "application_type": "rails",
      "environment": {
      "RAILS_SECRET_KEY": "<%= node["RAILS_SECRET_KEY"] %>"
      },
      "environment_variables": {
      "RAILS_SECRET_KEY": "<%= node["RAILS_SECRET_KEY"] %>"
      },
      "auto_bundle_on_deploy": true,
      "deploy_to": "/srv/www/<%= node[:app_name] %>",
      "deploying_user": null,
      "document_root": "public",
      "domains": [
      "<%= node[:app_name] %>"
      ],
      "migrate": true,
      "mounted_at": null,
      "rails_env": "development",
      "restart_command": null,
      "sleep_before_restart": 0,
      "ssl_support": false,
      "ssl_certificate": null,
      "ssl_certificate_key": null,
      "ssl_certificate_ca": null,
      "scm": {
        "scm_type": "git",
        "repository": "/vagrant",
        "revision": null,
        "ssh_key": null,
        "user": null,
        "password": null
      },
      "symlink_before_migrate": {
        "config/database.yml": "config/database.yml",
        "config/memcached.yml": "config/memcached.yml"
      },
      "symlinks": {
        "system": "public/system",
        "pids": "tmp/pids",
        "log": "log"
      },
      "database": {
        "adapter": "postgresql",
        "username": "<%= node[:postgresql][:username] %>",
        "password": "<%= node[:postgresql][:password] %>",
        "host": "localhost"
      },
      "memcached": {
        "host": null,
        "port": 11211
      }
    }
  },
  "languages": {
    "ruby": {
      "ruby_bin": "/usr/bin/ruby"
    }
  },
  "rails": {
    "max_pool_size": 8
  },
  "unicorn": {
  },
  "opsworks_custom_cookbooks": {
    "enabled": true,
    "scm": {
      "type": "git",
      "repository": "https://github.com/tarky/ops_berks.git",
      "user": null,
      "password": null,
      "revision": null,
      "ssh_key": null
    },
    "manage_berkshelf": true,
    "berkshelf_version": "3.1.3",
    "recipes": [
      "opsworks_initial_setup",
      "dependencies",
      "opsworks_ganglia::client",
      "unicorn::rails",
      "postgresql::client",
      "postgresql::server",
      "deploy::default",
      "deploy::rails"
    ]
  },
  "chef_environment": "_default",
  "recipes": [
    "opsworks_custom_cookbooks::load",
    "opsworks_custom_cookbooks::execute"
  ],
  "opsworks_commons": {
    "assets_url": "https://opsworks-instance-assets-us-east-1.s3.amazonaws.com"
  },
  "opsworks_berkshelf": {
    "version": "3.1.3",
    "prebuilt_versions": [ "3.1.3"]
  },
  "opsworks_bundler": {
    "version": "1.5.3",
    "manage_package": true
  },
  "opsworks_rubygems": {
    "version": "2.2.2"
  },
  "postgresql": {
    "password": {
      "<%= node[:postgresql][:username] %>": "<%= node[:postgresql][:md5_password] %>"
    }
  }
}

```


###修正のポイント

####1.ソースリポジトリをLocalのものにする
手順２で取得した時点のjsonだと、ここの"repository"はgithubなどのrailsアプリのソースのurlが入っていますが、共有フォルダの`/vagrant`もgitリポジトリなので、わざわざgithubからとってくるのではなく、ここからとってくるようにしています。これによって、githubでprivateリポジトリを使っている場合は、sshキーをOpsWorks上で管理する必要があるのですが、ローカルではそれが不要になります。（必須ではない）

```
      "scm": {
        "scm_type": "git",
        "repository": "/vagrant",
```

####2.rails_envをdevelopmentにする。

`      "rails_env": "development",`

asset:precompile無しで自動的にsassやCoffeeScriptをコンパイルしてもらうため。

####3.実行したいレシピを列挙する

    "berkshelf_version": "3.1.3",
    "recipes": [
      "opsworks_initial_setup",
      "dependencies",
      "opsworks_ganglia::client",
      "unicorn::rails",
      "postgresql::client",
      "postgresql::server",
      "deploy::default",
      "deploy::rails"
    ]


ここに書くのは、OpsWorksのコンソールの対象のLayerの画面でsetupに表示されるものです。このインスタンスに適用するLayer全てについて書きます。もちろんCustom cookbooksの分も入れます。自分の場合は、RailsのLayerとPostgreSQLのLayerのsetupを両方入れてます。ただ、Localでは必要のないものは削除してしまったほうがいいかもしれません。自分はebsやMySQLのclientは削除しています。

![OpsWorksのコンソールのLayerの画面](https://qiita-image-store.s3.amazonaws.com/0/27156/226f187c-63cc-08e0-aed3-747f24309cd0.png)

recipes項目はもう一カ所あるので、注意して下さい。修正するのは、この位置のものです。もう一カ所はそのままにしておきます。

####4.以下の項目を追加する。

```
  "opsworks_berkshelf": {
    "version": "3.1.3",
    "prebuilt_versions": [ "3.1.3"]
  },
```
これはAWS側でprebuildしてくれているberkshelfをinstallするために必要です。これがないと、berkshelfをgemで落としてこようとしてなぜかコンパイルがエラーになります。

####5.センシティブな情報はattributeに移す。

`"RAILS_SECRET_KEY": "<%= node["RAILS_SECRET_KEY"] %>"`

こんな感じです。

####6.あとIPなどの情報は念のため適当な数字にしてつぶしておく。
` "private_dns_name": "ip-99-99-99-99.ap-northeast-1.compute.internal",`

以上でjson.erbについては完了です。自分は項目を削っていませんが、必要のない項目はけずって小さくしてもいいかもしれません。attributeのファイルについては適宜作成して下さい。センシティブな情報なのでattributeのファイルはgitignoreにしましょう。


Vagrantfileに戻ります。
上記で配置したjsonのnode情報でsetup処理を実行しています。

` config.vm.provision "shell", inline: "opsworks-agent-cli run_command"`


ここまでのprovisionでOpsWorks上のsetup処理の再現は実は完了です。仮にここまでのprovisionを実行してブラウザで画面にアクセスしたら動いているはずです。

これ以降のプロビジョンは開発をしやすくするために、共有フォルダ（/Vagrant）を、Railsのコードが配置されるディレクトリにリンクさせます。なぜかというと、通常のOpsWorksのデプロイの処理は、リポジトリからソースを取得しているからです。ローカルのエディタで修正したら、すぐにVagrant上のアプリに変更が反映させて開発をスムーズにするためにこの処理を入れています。

```
  config.vm.provision "chef_solo" do |chef|  
    chef.run_list = ["mimic_opsworks::link_local"]
  end
```

```rb:link_local.rb
link "/srv/www/#{node['app_name']}/current" do
  action :delete
end
link "/srv/www/#{node['app_name']}/current" do
  to "/vagrant"
end
```


リンク後にbundle installしています。

`config.vm.provision "shell", inline: "cd /srv/www/#{app_name}/current; bundle install"`


以上でVagrantfileの作成は完了です。


##4.database.ymlの作成

development用でアクセスできるものを作成しておきます。

```rb:database.yml
development:
  adapter: "postgresql"
  database: ""
  encoding: "utf8"
  host: "localhost"
  username: "xxxxx"
  password: "xxxxx"
  reconnect: false
```

##5.vagrant up

これでvagrant upをするだけで、OpsWorksと同じ環境がローカルにできるはずです。date で挟んでいるのは、かかった時間を後で調べるためです。（なくてもOK）自分の場合はだいたい20分位かかります。(Macbook Airにて）

`date ; vagrant up ; date`

この処理はネット接続が安定している環境で行って下さい。様々なものをネットから落としてくる処理が走りますので、途中で接続が切れてしまうとエラーが発生してしまうことがあります。

#まとめ

これで、OpsWorksと同じ環境がVagrantにできました！recipeも手軽に試すことができますし、何より環境差異によりLocalでは動くけどproductionで動かないみたいなことは減らせるでしょう。

もしかしたら構成によっては、すんなりうまくいかないかもしれません。エッセンスだけ参考にして、色々と試して頂ければと思います。また、もっとスマートなやり方が有るという方は、教えて頂ければ幸いです。Vagrantfileのprovisionの項目が多くなってしまったので、少なくできたらなと思います。

##参考URL

[wwestenbrink/vagrant-opsworks](https://github.com/wwestenbrink/vagrant-opsworks)
[Virtualizing AWS OpsWorks with Vagrant](http://pixelcog.com/blog/2014/virtualizing-aws-opsworks-with-vagrant/)