はじめに

弊社では、エンジニアには基本的にMacを支給しています。 今まで、新しく人が入るたびに毎回同じ環境構築を手作業で行っていたのですが、今回これをAnsibleを使って自動化しました。

これでいつでも新しい人を迎えられます。採用のご応募はこちらからどうぞ。笑

Ansibleとは

Ansibleは構成管理ツールの一つです。同種のツールにはChefPuppetなどがあります。

本来はリモートにあるサーバ等にsshで接続して環境を構築するツールですが、対象ホストをlocalhostにすることで自分自身の構成管理にも使えます。

※ 環境を構築することを「プロビジョニング」と言います。

自動化したこと

  • Homebrewで各種パッケージをインストール
  • Homebrew Caskで各種GUIアプリケーションをインストール
  • oh-my-zshRictyフォントなどHomebrewだけでカバーできないもののインストール
  • ApacheやPHPの設定ファイルの調整

これらをコマンド一発で実行できるようにしました。

Ansibleを使ったMacの環境構築については既に詳しい解説記事がたくさんありますが、ApacheやPHPの設定ファイルの調整の部分はWebであまり実例を見かけなかったので、PHPerの皆さんには参考になるかもしれません。

実際に作成したplaybook

今回作成したplaybook(構成を記述したymlファイル)は弊社のプライベートリポジトリにあるためお見せできませんが、私の個人用のplaybookがだいたい似たような内容になっているので、そちらを実例としてご参照ください。

Ansibleの動作環境を用意

環境構築を自動化するとは言え、最低限Ansible自体の動作環境は事前に必要になります。Homebrewで簡単にインストール可能です。(Homebrwe自体のインストールは事前に済ませておいてください)

$ brew install python
$ brew install ansible

最小限の内容で実際に動かしてみる

必要なファイルを準備

Ansibleを使ってあるホストの構成管理を行うには、

  • inventoryファイル(対象のホストを指定する)
  • playbookファイル(構成を記述する)

の2つが必要になります。

今回はinventoryファイルをhosts、playbookファイルをlocalhost.ymlというファイル名で作成します。

これらを作成した状態で

$ ansible-playbook localhost.yml -i hosts

という感じでコマンドを叩くと、プロビジョニングが始まります。

それぞれ以下のような内容で作成しましょう。

inventoryファイル

# 今回はローカルマシンが対象
$ echo 'localhost' > hosts

playbookファイル

$ vi localhost.yml
- hosts: localhost
  connection: local
  gather_facts: no
  sudo: no
  tasks:
    # update homebrew
    - name: update homebrew
      homebrew: update_homebrew=yes

この時点では、Homebrewを最新にするだけのplaybookになっています。

この状態で実行してみると、タスクが1つ実行され、ホストの環境が更新される(結果のステータスがchangedになる)ことが分かると思います。

ちなみに、一度実行した直後にもう一度実行すると、結果のステータスはchangedではなくokとなり、実際には変更が加えられません。 このように何度実行しても結果が変わらない性質のことを「冪等性(べきとうせい)」と言います。

Homebrewパッケージをインストール

さて、ここからはplaybookに必要なタスクを書き足していきます。

まずはHomebrewで各種パッケージをインストールしましょう。 localhost.ymlに以下のように追記します。

 - hosts: localhost
   connection: local
   gather_facts: no
   sudo: no
+  vars:
+    homebrew_taps:
+      - homebrew/php
+      - homebrew/apache
+
+    homebrew_packages:
+      - { name: ansible }
+      - { name: boris }
+      - { name: brew-cask }
+      - { name: colordiff }
+      - { name: composer }
+      - { name: coreutils }
+      - { name: git }
+      - { name: graphviz }
+      - { name: htop-osx }
+      - { name: httpd22 }   # php56より前に置く
+      - { name: hub }
+      - { name: jq }
+      - { name: mysql }
+      - { name: nkf }
+      - { name: node }
+      - { name: openssl }
+      - { name: php56, install_options: with-homebrew-apxs }
+      - { name: php56-intl }
+      - { name: php56-opcache }
+      - { name: php56-apcu }
+      - { name: php56-mcrypt }
+      - { name: php56-xdebug }
+      - { name: php56-xhprof }
+      - { name: phpunit }
+      - { name: python }
+      - { name: reattach-to-user-namespace }
+      - { name: rename }
+      - { name: sl }
+      - { name: sqlite }
+      - { name: tig }
+      - { name: tmux }
+      - { name: tree }
+      - { name: unrar }
+      - { name: wget }
+      - { name: zsh }
+      - { name: zsh-completions }
+
+     # brew tap
+     - name: install taps of homebrew
+       homebrew_tap: tap="{{ item }}" state=present
+       with_items: homebrew_taps
+
     # brew update
     - name: update homebrew
       homebrew: update_homebrew=yes
+
+    # brew instal
+    - name: install homebrew packages
+      homebrew: name="{{ item.name }}" state="{{ item.state|default('latest') }}" install_options="{{ item.install_options|default() }}"
+      with_items: homebrew_packages

やっていること

  • vars:にHomebrewに追加したいtapとHomebrewでインストールしたいパッケージをリスト化
  • homebrew_tapモジュールを使って各tapを追加
  • homebrewモジュールを使って各パッケージの最新バージョンをインストール

Homebrew Caskパッケージ(GUIアプリケーション)をインストール

次に、Homebrew Caskで各種GUIアプリケーションをインストールします。

   :
   :
   vars:
     homebrew_taps:
+      - caskroom/cask
       - homebrew/php
       - homebrew/apache
     :
     :
+    homebrew_cask_packages:
+      - avast
+      - caffeine
+      - clipmenu
+      - cooviewer
+      - dropbox
+      - eclipse-java
+      - evernote
+      - filezilla
+      - firefox
+      - flash-player
+      - google-chrome
+      - google-drive
+      - google-japanese-ime
+      - gyazo
+      - heroku-toolbelt
+      - java
+      - karabiner
+      - kobito
+      - libreoffice
+      - macdown
+      - phpstorm
+      - reaper
+      - slack
+      - skitch
+      - skype
+      - spectacle
+      - sublime-text
+      - vagrant
+      - virtualbox
+      - vlc
+      - xmind
+      - xtrafinder
+
   tasks:
     :
     :
+    # brew cask install
+    - name: update brew-cask
+      homebrew: name=brew-cask state=latest
+    - name: install homebrew cask packages
+      homebrew_cask: name="{{ item }}" state=present
+      with_items: homebrew_cask_packages

やっていること

  • インストール対象のtapにcaskroom/caskを追加
  • Homebrew Caskでインストールするアプリのリストをvars:に追加
  • 事前にbrew-caskパッケージを最新化
  • homebrew_caskモジュールを使って各種アプリをインストール

oh-my-zshをインストール

私はシェルの設定に明るくないのでoh-my-zshで楽をしています。 oh-my-zshのインストールも自動化しましょう。

+    # install oh-my-zsh
+    - name: install oh-my-zsh
+      shell: "sh -c $(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
+      args:
+        creates: ~/.oh-my-zsh

やっていること

  • shellモジュールを使ってoh-my-zshのインストールコマンドを実行
  • createsオプションでファイルの存在チェックを行うことで冪等性を確保(ファイルが存在していればコマンドは実行されない)

Rictyフォントをインストール

Rictyフォントが好きなのでこれも入れます。

   vars:
     :
     :
     homebrew_taps:
       - caskroom/cask
       - homebrew/php
       - homebrew/apache
+      - sanemat/font
   :
   :
+  handlers:
+    - name: run fc-cache
+      shell: fc-cache -vf
  :
  :
   tasks:
     :
     :
+    # install Ricty font
+    - name: install xquartz
+      homebrew_cask: name=xquartz
+    - name: install ricty
+      homebrew: name=ricty
+    - name: copy generated font file
+      shell: "cp -f $(brew --cellar ricty)/*/share/fonts/Ricty*.ttf ~/Library/Fonts/"
+      args:
+        creates: ~/Library/Fonts/Ricty-Bold.ttf
+      notify: run fc-cache

やっていること

  • 必要なtapを追加
  • handlers:fc-cacheコマンドを実行するNotifyハンドラを定義
  • 事前にxquartzをインストール
  • rictyをインストール
  • フォントファイルをコピー
  • createsオプションを使って冪等性を確保
  • notify:を使って、実行後にrun fc-cacheハンドラに通知

Apacheを設定

httpd.confの設定変更も自動化しましょう。

ファイルの編集は冪等性を保ちながら正確に行うのがなかなか難しく、試行錯誤の末に以下のような設定をしたのですが、割と複雑な感じになってしまっています。 もっと美しく書けるという方はぜひアドバイスいただければ幸いです。

   vars:
     :
     :
+    apache_user: Apacheの実行ユーザ名
   :
   :
   tasks:
     :
     :
+    # configure apache
+    - name: configure httpd.conf (replace)
+      replace: dest=/usr/local/etc/apache2/2.2/httpd.conf regexp="{{ item.regexp }}" replace="{{ item.replace }}"
+      with_items:
+        - { regexp: '^( *Listen) .*$',                                  replace: '\1 80' }
+        - { regexp: '^( *User) .*$',                                    replace: '\1 {{ apache_user }}' }
+        - { regexp: '^( *DirectoryIndex) .*$',                          replace: '\1 index.php index.html' }
+        - { regexp: '^( *)# *(AddHandler cgi-script .cgi).*$',          replace: '\1\2' }   # just comment in
+        - { regexp: '^LoadModule +php5_module +/usr/local/Cellar/.+\n', replace: '' }   # remove the line inserted via `brew install php56 --homebrew-apxs`
+    - name: configure httd.conf (insert)
+      lineinfile: dest=/usr/local/etc/apache2/2.2/httpd.conf insertafter="{{ item.insertafter }}" line="{{ item.line }}"
+      with_items:
+        - { insertafter: "LoadModule rewrite_module",         line: "LoadModule php5_module /usr/local/opt/php56/libexec/apache2/libphp5.so" }
+        - { insertafter: '^#ServerName ',                     line: "ServerName localhost:80" }
+        - { insertafter: '^ *AddType ',                       line: "    AddType application/x-httpd-php .php" }
+        - { insertafter: '^ *# *Include .+httpd-vhosts.conf', line: "Include /usr/local/etc/apache2/2.2/extra/httpd-vhosts.my.conf" }
+    - name: create httpd-vhosts.conf
+      copy: src=templates/httpd-vhosts.my.conf dest=/usr/local/etc/apache2/2.2/extra/httpd-vhosts.my.conf
+      args:
+        force: no

やっていること

  • Apacheの実行ユーザ名を変数化(他の人とplaybookを共有しやすいように)
  • replaceモジュールを使って、正規表現でファイルの中身を無理やり置換
    • Listen 8080Listen 80
    • User daemonUser Apacheの実行ユーザ名
    • DirectoryIndex index.htmlDirectoryIndex index.php index.html
    • # AddHandler cgi-script .cgiAddHandler cgi-script .cgi(コメントインするだけ)
    • LoadModule php5_module /usr/local/Cellar/php56/5.6.x/libexec/apache2/libphp5.so を削除(php56インストール時に--with-homebrew-apxsオプションによって自動で挿入された行)
  • lineinfileモジュールを使って、「正規表現にマッチした最後の行」の直後に行を挿入(もちろん冪等的に)
    • LoadModule rewrite_module の次の行に LoadModule php5_module /usr/local/opt/php56/libexec/apache2/libphp5.so
    • #ServerName の次の行に ServerName localhost:80
    • AddType の次の行に AddType application/x-httpd-php .php
    • # Include /usr/local/etc/apache2/2.2/extra/httpd-vhosts.conf の次の行に Include /usr/local/etc/apache2/2.2/extra/httpd-vhosts.my.confhttpd-vhosts.confファイルを冪等的に編集するのは大変なので自分用のvhosts設定ファイルを追加)
  • copyモジュールを使って自分用のvhosts設定ファイルをコピー
    • 初回のプロビジョニング後は自由に変更していいように、force: noでファイルが既存かつテンプレートと内容が異なっていても上書きしないように設定

当然ながらhttpd-vhosts.my.confを用意しておく必要があります。

$ mkdir templates
$ vi templates/httpd-vhosts.my.conf
<Directory "/usr/local/var/www/htdocs">
    AllowOverride All
</Directory>

NameVirtualHost *:80

<VirtualHost *:80>
    DocumentRoot "/usr/local/var/www/htdocs"
    ServerName localhost
    ErrorLog "/usr/local/var/log/apache2/error_log"
    CustomLog "/usr/local/var/log/apache2/access_log" common
</VirtualHost>

PHPを設定

同じ要領でphp.iniを設定します。

+    # configure php
+    - name: configure php.ini
+      replace: dest=/usr/local/etc/php/5.6/php.ini regexp="{{ item.regexp }}" replace="{{ item.replace }}"
+      with_items:
+        - { regexp: '^(max_execution_time) *=.*$',       replace: '\1 = 0' }
+        - { regexp: '^(memory_limit) *=.*$',             replace: '\1 = 512M' }
+        - { regexp: '^(;date.timezone) *=.*$',           replace: '\1 = Asia/Tokyo' }
+        - { regexp: '^(;mbstring.language) *=.*$',       replace: '\1 = Japanese' }
+        - { regexp: '^(mysql.default_socket) *=.*$',     replace: '\1 = /private/tmp/mysql.sock' }
+        - { regexp: '^(pdo_mysql.default_socket) *=.*$', replace: '\1 = /private/tmp/mysql.sock' }
+        - { regexp: '^(zend_extension *= *opcache.so)',  replace: ';\1' }   # just comment out

やっていること

  • max_execution_time = 0
  • memory_limit = 512M
  • date.timezone = Asia/Tokyo
  • mbstring.language = Japanese
  • mysql.default_socket = /private/tmp/mysql.sock
  • pdo_mysql.default_socket = /private/tmp/mysql.sock
  • ;zend_extension = opcache.so(コメントアウト)(参考

xdebugを設定

xdebugの設定ファイルも、vhostsと同様に別ファイル化する戦略をとります。

+    # configure xdebug
+    - name: configure ext-xdebug.ini
+      copy: src=templates/ext-xdebug.my.ini dest=/usr/local/etc/php/5.6/conf.d/ext-xdebug.my.ini
$ vi templates/ext-xdebug.my.ini
xdebug.remote_enable = 1
xdebug.remote_port = 9001
xdebug.profiler_enable = 1
xdebug.profiler_output_dir = /tmp
xdebug.idekey = PHPSTORM
xdebug.max_nesting_level = 500
xdebug.var_display_max_children = -1
xdebug.var_display_max_data = -1
xdebug.var_display_max_depth = -1

やっていること

  • copyモジュールを使ってext-xdebug.my.iniの内容をテンプレートと同一に保つ

xhprofを設定

xhprofを使いたいので、これもインストールします。

Homebrewでインストールしたxhprofでは、プロファイル結果のファイルがCellerの下に保存されてブラウザで結果を確認するのが面倒なので、ドキュメントルート直下にxhprofというディレクトリを作ってその下にシンボリックリンクを置くようにします。

また、xhprofを使うときに書くコードスニペットを毎回忘れてググッているので、スニペットを書いたファイルもこのxhprofディレクトリの下に置くようにします。

+    # configure xhprof
+    - name: make xhprod dir in DocumentRoot
+      file: dest=/usr/local/var/www/htdocs/xhprof state=directory
+    - name: symlink xhprof into DocumentRoot
+      file: src="{{ item.src }}" dest="{{ item.dest }}" state=link
+      with_items:
+        - { src: /usr/local/opt/php56-xhprof/xhprof_html, dest: /usr/local/var/www/htdocs/xhprof/xhprof_html }
+        - { src: /usr/local/opt/php56-xhprof/xhprof_lib, dest: /usr/local/var/www/htdocs/xhprof/xhprof_lib }
+    - name: put a snippet file into xhprof dir
+      copy: src=templates/xhprof.snippet dest=/usr/local/var/www/htdocs/xhprof/xhprof.snippet
$ vi templates/xhprof.snippet
<?php
// paset this into bootstrap of your application.
xhprof_enable();
register_shutdown_function(function () {
    $runs = new XHProfRuns_Default(sys_get_temp_dir());
    $runs->save_run(xhprof_disable(), 'APPLICATION_NAME');
});

やっていること

  • fileモジュールを使って、ドキュメントルート直下にxhprofディレクトリを作成
  • 同じくfileモジュールを使ってxhprof_html, xhprof_libディレクトリのシンボリックリンクを作成
  • copyモジュールを使ってスニペットファイルを配置

dotfilesを展開

さて、ここまでで一通り必要な環境は出来てきました。あとは 秘伝のdotfiles を展開したら開発環境の完成です。

ここは結構やり方を迷ったのですが、試行錯誤の末に以下のような方法をとることにしました。

  • dotfilesの取得元(src)、保存先(dest)、保存したdotfilesをホームディレクトリ配下へ展開するためのコマンド(symlink_command)をそれぞれ変数として定義
  • 取得元として、Gitリポジトリとローカルのディレクトリパスのいずれかを選択可能(dotfilesをGitHubのリポジトリ等で管理している人とそうでない人で共通のplaybookを使えるようにしたかったので)
  • 多くの人は、dotfilesの現物と一緒に、それらをホームディレクトリ配下へシンボリックリンクとして展開するためのシェルスクリプトなんかを管理しているはずだろうと想定
   vars:
     :
     :
+    dotfiles:
+      src:
+        repository: git@github.com:ttskch/dotfiles.git
+        directory: ~
+      dest: ~/dotfiles
+      symlink_command: "cd ~/dotfiles ; sh symlink.sh"
   :
   :
   tasks:
     :
     :
+    # install dotfiles
+    - name: git clone dotfiles
+      git: repo="{{ dotfiles.src.repository }}" dest="{{ dotfiles.dest }}"
+      register: ret1
+      when: dotfiles.src.repository
+    - name: copy dotfiles
+      copy: src="{{ dotfiles.src.directory }}" dest="{{ dotfiles.dest }}"
+      register: ret2
+      when: dotfiles.src.directory
+    - name: symlink dotfiles
+      shell: "{{ dotfiles.symlink_command }}"
+      when: ret1|changed or ret2|changed

やっていること

  • when:を使って、変数の定義内容に応じて取得タスクを選択
  • (1)gitモジュールを使ってGitリポジトリをclone
  • (2)copyモジュールを使ってディレクトリをコピー
  • 1,2それぞれの実行結果をregister:を使って記憶しておき、どちらか一方でも結果がchangedだったら、シンボリックリンクを展開するコマンドを実行

ここでは私の個人用のdotfilesを使用しています。

おまけ(Homebrew Caskのパッケージが最新に保てない?)

homebrew_caskモジュールのstateオプションにはpresent, absentの2値しかなく、homebrewモジュールのようにlatestを指定することができません。

なので、このplaybookを実行しても、既存のHomebrew Caskパッケージが最新バージョンに更新されることはありません。

直接brew caskコマンドを叩けば更新可能なので、その手順をplaybookに書き起こしてもよかったのですが、これ以上playbookを複雑怪奇にしたくなかったのでひとまず現状はGUIアプリの更新は手作業でやることにしています。

具体的な手順は以下のとおりです。

$ brew cask cleanup --outdated
$ brew cask install --force {token}

brew cask install --force {tokne}でパッケージを再インストールします。({token}には対象のパッケージ名を入力)

その前にbrew cask cleanup --outdatedを実行していますが、これはダウンロードしたファイル(インストーラやzip)のキャッシュを削除するコマンドで、これをやらないと古いキャッシュから再インストールが実行されて結局バージョンが更新されない、ということが起こります。(--outdatedは削除対象を10日以上前のキャッシュだけに絞るオプション)

おわりに

思いのほか長文になってしまいましたが、Macの開発環境構築をAnsibleで自動化した際に行ったことを簡単な解説を交えながらご紹介しました。

今までは最低限esaに環境構築の手順をメモっておくぐらいしかしていなかったので、コマンド一発で環境が再現できるようになったのはかなり幸福度UPです。

会社などの組織でなくとも、個人の方にも開発環境構築の自動化はおすすめです。「Macbookを買い換える度にいつも同じような環境構築をググりながらやっている」というような人は、この機会にAnsibleで自動化してみてはいかがでしょうか :wink: