BLOG

Ansibleに入門してRubyをビルドさせてみるの巻 後編

kuroda

ご挨拶

新年あけましておめで…え、もうすぐ8月!?

今回のお話

前回は、Rubyのビルドを行うAnsibleのプレイブックとしてyamlファイルを1つ作りました。内容が単純な場合はファイル1つで問題ありませんが、複雑な構築手順をプレイブックとして記述するときには、いくつかの段階ごとにファイルを分割した方が見通しが良くなりそうです。

Ansibleの公式ドキュメントでは、そのような場合におすすめの Best Practices を提示しています。

今回はこのBest Practicesにしたがって前回作ったRubyビルドのプレイブックを複数のファイルに分割してみます。

ターゲットとするホストは前回同様の2つですが、今回はそれに入れるRubyのバージョンを分けてみます。

  • trusty.l.syngram.co.jp
    • OS : Ubuntu 14.04
    • 入れるもの : Ruby 2.2.5
  • xenial.l.syngram.co.jp
    • OS : Ubuntu 16.04
    • 入れるもの :Ruby 2.3.1

大まかなディレクトリ構成

作業場所のディレクトリ構成は、次のようになります。

.
├── book.yml
├── host_vars/
├── hosts
└── roles/

book.ymlが今回のプレイブック、hostsがインベントリです。
インベントリはホスト名を並べてグループ名をつけただけのものになります。

[remote]
trusty.l.syngram.co.jp
wily.l.syngram.co.jp

プレイブックは前回と大きく変わります。

---
- hosts: remote
  roles:
    - ruby-source
  serial: 1
- hosts: remote
  roles:
    - build-dep-ruby
    - build-ruby

ホストのグループに対して、適用するべきロールを列挙しています。
「ロール」というのは分割したタスクの集まりに名前をつけたもので、ここでは以下の3つのロールを用意しています。

  • ruby-source : rubyのソースファイルをダウンロードする
  • build-dep-ruby : rubyのビルドに必要なパッケージをaptでインストールする
  • build-ruby : rubyをビルドする

今回は全体のタスクがRubyをビルドする準備をしてビルドするという単純なものなので、ちょっと細かめに手順をロールに分割してしまいましたが、本来は、ホストに持たせたい役割を実現するタスクの集まりにするのが良いようです(だからroleという名前なのでしょうか。演劇の比喩で用語を揃えていると言うのもあるようですが)。

例えば、WordPressを稼働させるサーバーを作る時のロールとして

  • apache-server : apacheのインストールと設定
  • mysql-server : MySQLのインストールと設定
  • wordpress-host : WordPressのインストールと設定

とか作ったりすると良いようです。

同じ手順を条件(パラメータ)を変えて実行する方法

今回はバージョンの異なる2つのRubyをそれぞれ別のホストにインストールします。ダウンロードするRubyのソースファイルのURLやホストのアドレスは異なりますが、手順は共通しています。こういう場合は異なる部分を変数として記述し、手順は同じものを使うようにしたいものです。

Ansibleでは以下のような場所に変数を書いて使うことができます。

  • roles/XXX/defaults/main.yml に書く(“XXX”というロールでの変数のデフォルト値)
  • inventoryの中に VAR=VALUE の形で書く(ただし、推奨されないようです)
  • group_vars/GROUPNAME/main.yml に書く(inventoryの”XXX”groupに属するホストに適用される)
  • host_vars/HOSTNAME/main.yml に書く(“XXX”というホストに適用される)
  • roles/XXX/vars/main.yml に書く(“XXX”というロールで適用される変数)

よく使うと思われるものを挙げましたが、他にもどんな場所に書けるかはこちらで確認できます。

このように変数はいくつかの場所で書く事ができますが、同じ名前の変数は優先度の高い方の値で上書きされます。上に挙げたリストでは、下の方ほど優先度が高く、例えばroleのデフォルト値はgroup_varsやhost_varsで上書きできますが、roles/XXX/vars/main.yml に書いた変数は上書きできません。

上に挙げた以外の変数も含めた完全な優先順位はこちらで確認できます(Ansibleのバージョン1と2とで少し異なるようです)。

今回はホストごとに別のバージョンのRubyをインストールするので、 host_vars/ にRubyのバージョンに依存した情報を書いてみます。他には例えば同じバージョンを複数のホストに入れる場合だと、inventoryでバージョンごとのホストをグループ分けして group_vars/ に書いたりする事になるはずです。

Rubyのバージョンに依存する情報として、今回は以下のように書いてみました。

  • 2.2.5を入れる trusty.l.syngram.co.jp
    ruby_ver: 2.2.5
    ruby_src_url: https://cache.ruby-lang.org/pub/ruby/2.2/ruby-2.2.5.tar.gz
    ruby_src_sha256: 30c4b31697a4ca4ea0c8db8ad30cf45e6690a0f09687e5d483c933c03ca335e3
    
  • 2.3.1を入れる xenial.l.syngram.co.jp
    ruby_ver: 2.3.1
    ruby_src_url: https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz
    ruby_src_sha256: b87c738cb2032bf4920fef8e3864dc5cf8eae9d89d8d523ce0236945c5797dcd
    

秘密情報をAnsibleで扱う

操作対象のホスト上でパッケージのインストールなどのroot権限が必要な操作を実行するためには、 sudo コマンドを使うためのパスワードをAnsibleに教える必要があります。操作対象のホスト全てでパスワードが共通なら、 ansible-playbook を起動するときに --ask-become-pass を使ってパスワードを入力できます。

$ ansible-playbook --ask-become-pass -i hosts playbook.yml 
SUDO password: 

しかし、この方法では複数の操作対象に対してパスワードは共通のもの1つしか入力できません。ホスト単位またはグループ単位でパスワードが異なる場合は、専用の変数で ansible_become_pass というものがあって、 host_vars/group_vars/ などで記述できます。

ただし、そもそもパスワードをそのままファイルに書くわけにはいきません。多分、そのプレイブックはgitか何かのリポジトリで管理して共有したりするでしょう?

こういう場合のために、Ansibleには暗号化したyamlファイルを扱うためのvaultという機能があります。

早速、試してみましょう。適当なディレクトリで以下のようにすることで、暗号化したYAMLファイルを作ることができます

kuroda@charlie:~/tmp$ ansible-vault create foo.yml
New Vault password: (パスワードを入れる)
Confirm New Vault password: (確認用にもう一度入れる)

こうするとエディタが起動して、コマンドの引数に指定した foo.yml の内容を好きに書くことができます(実際はYAML以外のファイルでも構いません)。

これで foo.yml ができたので、まずはそのまま表示してみます

kuroda@charlie:~/tmp$ cat foo.yml 
$ANSIBLE_VAULT;1.1;AES256
32303935373465653132633166633765393762626337646261383937343966383533306434636432
6664613966353938376261393339376239636531336339610a376565623830326634333564643638
38313830373736626637326238336439343566393731613661356130656431376432623836346135
3038366535656663340a383339383032306664386436343032653361613030393231613038616133
3431

ばっちり暗号化されています。
復号して表示するには、 ansible-vault view を使います

kuroda@charlie:~/tmp$ ansible-vault view foo.yml 
Vault password: (さっき決めたパスワードを入れる)
foo: bar

(実際はページャーが起動して表示されます。環境にも依るかもしれません。)
hostsgroup_vars 等の下の、通常の変数を書いたYAMLファイルと同じ場所に置いておくと、 ansible-playbook が必要に応じて復号して使ってくれます。

例えば、今回は hosts_vars/trusty.l.syngram.co.jp/vault.ymlansible_become_pass を書いて暗号化したものを置いてみます。

kuroda@charlie:~/work/ansible/ruby-build$ ansible-vault view host_vars/trusty.l.syngram.co.jp/vault.yml 
Vault password: 
ansible_become_pass: XXXXX

このまま ansible-playbook すると

kuroda@charlie:~/work/ansible/ruby-build$ ansible-playbook -i hosts book.yml 
ERROR! Decryption failed

おっと。今度は --ask-vault-pass で解除用のパスワードを教える必要があります。

kuroda@charlie:~/work/ansible/ruby-build$ ansible-playbook --ask-vault-pass -i hosts book.yml  
Vault password: 

PLAY ***************************************************************************
(以下略)

さて、YAMLファイルを暗号化できるようになったので、パスワードをそのままファイルに書いて保存する事への不安はひとまずなくなりました。とはいえ、暗号化したファイルをそのままリポジトリに入れて管理するのは、自分専用ならともかく共有する事を考えると適切ではないでしょう。また、YAMLファイル全体を暗号化してしまうため、「パスワードはどこに書いたっけ」と忘れたときにgrepなどで検索して探し出すのも難しくなります。

こういう場合のオススメが公式のドキュメントで紹介されていますが、以下のような方法になります。

まず、暗号化するYAMLにパスワードを変数として記述しますが、この時、変数名の頭に vault_ を付けます。例えばsudoのパスワードなら vault_ansible_become_pass という名前にします。

次に、暗号化しないYAMLで実際の変数を定義しますが、変数の値には先ほどの vault_ 付きの変数が展開されるように記述します。

そして、暗号化しないYAMLファイルはリポジトリに追加するなどして管理し、暗号化する方のYAMLファイルは誰にも見られないようにパーミッションや .gitignore 等をうまく設定しておきます。

具体的な例をあげると、暗号化しないYAMLファイルは host_vars/trusty.l.syngram.co.jp/main.yml として以下のように書いて、

ansible_become_pass: "{{ vault_ansible_become_passs }}"

暗号化するYAMLファイルは host_vars/trusty.l.syngram.co.jp/vault.yml として以下のように書きます。

vault_ansible_become_passs: XXXXX

そしてこの vault.yml だけを ansible_vault で暗号化するわけです。

こうすることで、

  • ansible_become_pass という文字列で検索すると、どのファイルに書いているかが分かる
  • 値に vault_ansible_become_pass という変数を展開しているので、同じディレクトリのvault.yml に暗号化して書いてあると見当がつけられる

と言うわけです。

実際のプレイブック

下準備が長くなりましたが、実際のファイルの中身を見ていきましょう。
今回のプレイブック一式はgithubに置いてあるので、必要に応じてお手元にcloneしてください。

host_vars

まずは各ホスト用の変数ファイルです。先ほどほとんどの部分をお見せしましたが、もう一度全体を見返してみます。今回は2つ用意して使いますが、似たような内容なので片方だけ。

ansible_become_pass: "{{ vault_ansible_become_pass }}"
ruby_ver: 2.2.5
ruby_src_url: https://cache.ruby-lang.org/pub/ruby/2.2/ruby-2.2.5.tar.gz
ruby_src_sha256: 30c4b31697a4ca4ea0c8db8ad30cf45e6690a0f09687e5d483c933c03ca335e3

1行目はsudoのパスワードで、実際の内容は ansible-vault で暗号化したファイルに vault_ansible_become_pass という名前の変数で記述しています。

2行目以降はホスト毎にインストールするRubyのバージョン番号、ソースコードのtar-ballのURL、そのハッシュ値ですが、この辺は構造化して次のように書くこともできます。

ruby:
  ver: 2.2.5
  src:
    url: https://cache.ruby-lang.org/pub/ruby/2.2/ruby-2.2.5.tar.gz
    sha256: 30c4b31697a4ca4ea0c8db8ad30cf45e6690a0f09687e5d483c933c03ca335e3

ただしこの書き方には注意があって、優先度の高い場所で変数を上書きする場合に、構造化した変数はその全体がまるごと置き換えられてしまいます。
例えば、上記のRubyソースに関する情報を仮にroles/XXX/defaults/ に書いていたとして、ローカルに置いたミラーからダウンロードする為にURLだけgroup_varsで書き換えたいとします。
この時、変数の ruby.src.url だけを書き換えるつもりで以下のように書くと、

ruby:
  src:
    url: http://mirror.intra.example.net/ruby/2.2/2.2.5.tar.gz

変数 ruby 全体がこの内容で置き換えられてしまい、 ruby.verruby.src.sha256 は未定義になってしまいます。

この点に付いてはこちらのnoteに書いてありますが、Ansibleの設定ファイル等で hash_behavior=replace というデフォルト値を hash_behavior=merge に変更することで期待した動作にする事はできます。

ただ、デフォルト動作の変更は混乱の原因になりがちなので、変数は構造化しない書き方をする方が良さそうです。

roles/ruby-source

次はロールを見ていきます。1つ目はRubyのソースコードをローカルにダウンロードするロールです。

ruby-source ロールには、タスクを書いたymlを1つだけ置いています。

---
- name: create tmp directory
  local_action:
    module: file
    path: tmp/
    state: directory
- name: fetch ruby source
  local_action:
    module: get_url
    url: "{{ ruby_src_url }}"
    sha256sum: "{{ ruby_src_sha256 }}"
    dest: tmp/ruby-{{ ruby_ver }}.tar.gz

内容としては、Ansibleを実行するローカルホストにRubyのソースをダウンロードする処理を書いています。
今回は1つのバージョンを1つのホストにインストールするだけなので、操作対象のホストに直接ダウンロードさせた方が早いのですが、同じバージョンのRubyを複数のホストにインストールする場合はこんな感じになるだろうかというのを試しています。
ここでポイントになるのは localaction: 項で、通常の場合、タスクは操作対象のホストで実行されますが、これをつけたタスクは操作対象のホストではなくAnsibleを実行するローカルホストで実行されます。

ここではダウンロードしたファイルを置くための tmp ディレクトリの作成と、実際のRubyソースのダウンロード処理を記述していますが、どちらも目的のディレクトリやファイルが既に存在する場合は実行をスキップします

また、操作対象のホスト毎にタスクを平行に実行するので、今回の場合は2つのバージョンのRubyソースが同時にダウンロードされます。

ところで、例えば仮に100台の操作対象ホストに同じバージョンのRubyをインストールする場合を考えてみると、このプレイブックの初回実行時にはまだ目的のソースファイルは存在しないため、100台分のダウンロード処理が同時に動き始めて、同じファイルを同時に100回ダウンロードしようとしてしまいます

この問題を回避する為に、Ansibleでは同時に実行するタスクの数を制限することができます。
その方法は、serial値を指定する事で、今回のようなroleを使うプレイブックの場合は大元のYAMLファイル book.yml に以下のように記述します。

---
- hosts: remote
  roles:
    - ruby-source
  serial: 1

これで、ruby-sourceロールは同時に1つずつしか実行されないため、100台に同じRubyをインストールする場合でも、ローカルホストでのダウンロード処理は以下のように1回しか実行されません。

  • 1台目に対するタスクとして、ダウンロードを実行する。tar.gzファイルがtmp/に保存される
  • 2台目以降に対しては、既にtmp/にダウンロードしたファイルが存在するため、実行がスキップされる

roles/build-dep-ruby

2つ目のロールは、Rubyのビルドに必要なパッケージを apt-get コマンドでインストールするロールです。

---
- name: apt-get update
  apt:
    update_cache: yes
    upgrade: dist
    cache_valid_time: 3600
  become: true
- name: build-dep ruby-full
  apt:
    name: ruby-full
    state: build-dep
  become: true
- name: install libXX-dev
  apt:
    name: "{{ item }}"
    state: installed
  with_items:
    - zlib1g
    - libreadline-dev
    - libssl-dev
  become: true

最初にパッケージ情報とインストール済みパッケージを最新版に更新しています(1行目から7行目)。cache_valid_time: 3600apt-get update を前回実行してから3600秒の間は省略するようにしています。

次に apt-get build-dep ruby-full でRubyのビルドに必要なパッケージをインストールします(8行目から12行目)。

最後に、build-depではなぜか入らないライブラリ3つをインストールして、このロールは終わりです(13行目以降)。

ここで使うaptモジュールの詳細は apt – Manages apt-packages — Ansible Documentationで解説されています。

roles/build-ruby

最後のロールはRubyのビルドして、 /opt/ruby/X-Y-Z にインストールします。

まずは、ロールの中で使う変数定義から。

---
ruby_prefix: /opt/ruby/{{ruby_ver}}
ruby_basename: ruby-{{ruby_ver}}
ruby_file: "{{ruby_basename}}.tar.gz"

実際のタスクは次のようになっています。

---
- stat: path={{ ruby_prefix }}/bin/ruby
  register: stat_result
- name: build and install ruby
  include: build.yml
  when: not stat_result.stat.exists

このタスクでは、最初にインストール先にRubyが既に入っているか確認しています(2行目から3行目)。
確認した結果は変数 stat_result に格納するよう指定しています。

ビルド処理自体は main.yml とは別に build.yml で書いていて、4行目からのタスクでは、

stat_result.stat.exists

が偽の場合は build.yml を実行するように書いています。

ビルド処理はtar.gzの展開、configure、make、make installといった複数のステップが必要なのですが、それぞれのステップで別々にrubyのインストール済みを判定する処理を書くのは手間がかかるので、別のYAMLに分離した上で判定を1回で済ませているわけです。

そのbuild.ymlは以下のようになります。

---
- command: mktemp -d
  register: temp_dir
- unarchive:
    src: tmp/{{ruby_file}}
    dest: "{{ temp_dir.stdout }}/"
- command: ./configure --prefix={{ruby_prefix}}
  args:
    chdir: "{{ temp_dir.stdout }}/{{ ruby_basename }}/"
- command: make
  args:
    chdir: "{{ temp_dir.stdout }}/{{ ruby_basename }}/"
- command: make install
  become: true
  args:
    chdir: "{{ temp_dir.stdout }}/{{ ruby_basename }}/"
- name: remove temp directory
  file:
    path: "{{ temp_dir.stdout }}"
    state: absent

ここに書いたタスクはすべて操作対象のホストで実行されます。
まずビルド用の一時ディレクトリを作り、そのディレクトリ名を変数 temp_dir に格納します(2行目から3行目)。

次にunarchive モジュールでソースをそのディレクトリに展開します。 src 項で展開する tar.gz ファイルを指定しますが、デフォルトではこの tar.gz ファイルは操作対象ホストではなく、Ansibleを実行するローカルホストから探して自動的に転送してくれるので、ローカルから操作対象にtar.gzを転送する処理をプレイブックに書く必要はありません。
それに対して dest で指定するのは操作対象ホストのディレクトリになります。プレイブックに自前で tar.gz を転送するように処理を書いた場合は、 copy: no を指定することで、 src で指定したファイルを操作対象のホストで探すようになります。
また、新しいバージョン(2.0以降)のAnsbileでは src にURLを指定することでダウンロードもここで同時に行うことができます。

後は見たままですがcommand モジュールを使って configure から make install までを実行します(7行目から16行目)。

最後に作業用の一時ディレクトリを削除して終わりです(17行目)。

まとめ

今回はAnsibleのプレイブックを複数のファイルに分割するBest Practicesと呼ばれる方法に関連して、次のような点を紹介しました

  • 変数を使ってタスクのパラメータを変える方法
  • 変数を記述するYAMLを暗号化して、パスワード等の秘密情報を扱う方法
  • 変数を構造化して書くときの注意点
  • 複数の操作対象に対するタスクを平行せずに実行する方法

次回はRubyのビルドでは使わなかった、設定ファイルの書き換えやサービスの再起動について紹介します。