読者です 読者をやめる 読者になる 読者になる

tkak's tech blog

This is my technological memo.

InSpecではじめるテスト駆動インフラ

最近、新しくChefのCookbookを書く機会があったので、前から気になっていたInSpecを少し触ってみました。

github.com

InSpecとは何か?

InSpecは、Chef社が開発しているオープンソースのサーバーテストフレームワークです。サーバーのテストフレームワークといえば、Serverspecが有名ですが、InSpecはインフラ管理向けというよりコンプライアンスの担保だったりセキュリティ要件を満たしているかどうかのテスト用作られたツールになっています。InSpecのGithubには、

InSpec is inspired by the wonderful Serverspec project. Kudos to mizzy and all contributors!

と記載があるように、Serverspecにインスパイアされているようです。なので、基本的にServerspecで提供されている代表的なリソースはInSpecでも大体用意されていて、今の所(バージョン1.3.0)以下のようなリソースをサポートしています。

apache_conf apt audit_policy auditd_conf auditd_rules bash bond bridge bsd_service command csv directory etc_group etc_passwd etc_shadow file gem group grub_conf host iis_site inetd_conf ini interface iptables json kernel_module kernel_parameter launchd_service limits_conf login_def mount mysql_conf mysql_session npm ntp_conf oneget os os_env package parse_config parse_config_file pip port postgres_conf postgres_session powershell process registry_key runit_service security_policy service ssh_config sshd_config ssl sys_info systemd_service sysv_service upstart_service user users vbscript windows_feature wmi xinetd_conf yaml yum

例えば、指定したパッケージが適切にインストールされているかチェックしたい場合、packageリソースを使って以下のように書きます。InSpecでもServerspecでも大体似たような感じで書くことができ、バージョンチェックのところ以外は全く同じです。

## Serverspec
describe package('nginx') do
  it { should be_installed.with_version('1.9.5') }
end

## InSpec
describe package('nginx') do
  it { should be_installed }
  its('version') { should eq 1.9.5 }
end

ディレクトリとファイルのテストもほとんど一緒で、modeの箇所以外はそのままInSpecでも動かせます。

## Serverspec
describe file('/usr/local/foo') do
  it { should be_directory }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
  it { should be_mode '755' }
end

describe file('/usr/local/foo/bar') do
  it { should be_file }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
  it { should be_mode '644' }
  its('content') { should match('Hello') }
end

## InSpec
describe file('/usr/local/foo') do
  it { should be_directory }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
  its('mode') { should cmp '0755' }
end

describe file('/usr/local/foo/bar') do
  it { should be_file }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
  its('mode') { should cmp '0644' }
  its('content') { should match('Hello') }
end

ちなみに、よりInSpec風に書くのであれば、以下のようにテストを書くこともできます。

describe directory('/usr/local/foo') do
  its('owner') { should cmp 'root' }
  its('group') { should cmp 'root' }
  its('mode') { should cmp '755' }
end

describe file('/usr/local/foo/bar') do
  its('owner') { should cmp 'root' }
  its('group') { should cmp 'root' }
  its('mode') { should cmp '644' }
  its('content') { should match('Hello') }
end

機能的にServerspecもInSpecもほとんど大差ない感じなんですが、強いて違う点を挙げるとすれば、InSpecは、Serverspec に比べ、よりRubyRSpec、Rakeを意識する必要がない点かなと思います。というのも、InSpecは、spec_helper.rbやRakefileを準備する必要がありません。以下のようなCLIが用意されていて、とりあえずテストケースだけ書いてコマンドを実行すれば、すぐ使うことができます。

# run test locally
inspec exec test.rb

# run test on remote host on SSH
inspec exec test.rb -t ssh://user@hostname -i /path/to/key

# run test on remote windows host on WinRM
inspec exec test.rb -t winrm://Administrator@windowshost --password 'your-password'

# run test on docker container
inspec exec test.rb -t docker://container_id

test-kitchenでInSpecを使ってみる

では、実際にtest-kitchenと一緒に使ってみます。

準備

現時点での最新版のChefDK(バージョン0.19.6)には、test-kichenもInSpec (kitchen-inspec)もすでに含まれてるので、ChefDKをインストールするだけで使えるようになります。Macであれば以下のコマンドだけで環境の準備ができます。(virtualboxVagrantは別途必要)

$ brew cask install chefdk

chef generate cookbookコマンドで適当にcookbookを作って、recipeとテストを用意します。今回触ってみた環境をgithubにあげてますので、よかったら参考にしてください。

https://github.com/tkak/hello-cookbook

git cloneしてきて、kitchen listを実行すると以下のテスト対象を確認できます。

$ chef exec kitchen list
Instance                     Driver   Provisioner  Verifier  Transport  Last Action
with-serverspec-ubuntu-1604  Vagrant  ChefZero     Busser    Ssh        <Not Created>
with-serverspec-centos-72    Vagrant  ChefZero     Busser    Ssh        <Not Created>
with-inspec-ubuntu-1604      Vagrant  ChefZero     Inspec    Ssh        <Not Created>
with-inspec-centos-72        Vagrant  ChefZero     Inspec    Ssh        <Not Created>
with-shell-ubuntu-1604       Vagrant  ChefZero     Shell     Ssh        <Not Created>
with-shell-centos-72         Vagrant  ChefZero     Shell     Ssh        <Not Created>

Serverspecでテスト

まずは、通常の使い方であるServerspec (busser-serverspec)でテストを実行してみます。

$ chef exec kitchen test serverspec
...
...
...
-----> Running serverspec test suite
-----> Installing Serverspec..
Fetching: diff-lcs-1.2.5.gem (100%)
Fetching: rspec-expectations-3.5.0.gem (100%)
Fetching: rspec-mocks-3.5.0.gem (100%)
Fetching: rspec-3.5.0.gem (100%)
Fetching: rspec-its-1.2.0.gem (100%)
Fetching: multi_json-1.12.1.gem (100%)
Fetching: net-ssh-3.2.0.gem (100%)
Fetching: net-scp-1.2.1.gem (100%)
Fetching: net-telnet-0.1.1.gem (100%)
Fetching: sfl-2.3.gem (100%)
Fetching: specinfra-2.64.0.gem (100%)
Fetching: serverspec-2.37.2.gem (100%)
-----> serverspec installed (version 2.37.2)
       /opt/chef/embedded/bin/ruby -I/tmp/verifier/suites/serverspec -I/tmp/verifier/gems/gems/rspec-support-3.5.0/lib:/tmp/verifier/gems/gems/rspec-core-3.5.4/lib /opt/chef/embedded/bin/rspec --pattern /tmp/verifier/suites/serverspec/\*\*/\*_spec.rb --color --format documentation --default-path /tmp/verifier/suites/serverspec

       Package "zsh"
         should be installed

       File "/usr/local/foo"
         should be directory
         should be owned by "root"
         should be grouped into "root"
         should be mode "755"

       File "/usr/local/foo/bar"
         should be file
         should be owned by "root"
         should be grouped into "root"
         should be mode "644"
         content
           should match "Hello"

       Finished in 0.17573 seconds (files took 0.3651 seconds to load)
       10 examples, 0 failures

       Finished verifying <with-serverspec-centos-72> (0m5.60s).
...
...
...

kitchen testコマンドで、以下の処理が一気に動きます。

ちなみに、上記のログにあるように、busser-serverspec 経由でtest-kitchenを使うと、テスト初回時にインスタンス内でgemのインストールが走り、少し待たされます。ちょっとした待ち時間ですが、何度もインスタンスを作って壊してをやってると、この待ち時間がバカにできないので、そんな時はshell-verifierを使うのがオススメです。shell-verifierを使えば、事前にインストールされたホスト側のServerspecを使ってテストを実行するので、gemインストールの待ち時間なくテストができます。shell-verifierでServerspecを使う方法は以下のとおりです。

まず、ホストにServerspec をインストールしておきます。

$ chef gem install serverspec 

そして、.kitchen.ymlファイルに以下のような設定を追記し、

verifier:
  name: shell
  command: chef exec rspec -c -f documentation -I test/integration/${KITCHEN_SUITE}/serverspec --pattern test/integration/${KITCHEN_SUITE}/serverspec/**/*_spec.rb --pattern test/integration/helpers/serverspec/spec_helper.rb

spec_helper.rbを用意します。

require 'serverspec'

set :backend, :ssh

options = Net::SSH::Config.for(host)
options[:host_name] = ENV['KITCHEN_HOSTNAME']
options[:port] = ENV['KITCHEN_PORT']
options[:user] = ENV['KITCHEN_USERNAME']
options[:keys] = ENV['KITCHEN_SSH_KEY']

set :host,        options[:host_name]
set :ssh_options, options
set :env, :LANG => 'C', :LC_ALL => 'C'

これでbusser-serverspecを使うより短時間でテストが実行できるようになります。ただし、.kitchen.ymlにコマンドの記述とspec_helper.rbの用意をする必要があり、若干面倒です。例えば、複数のCookbookを管理する時とか。

InSpecでテスト

次にInSpecでテストを動かしてみます。使い方は簡単で、InSpecを使うには.kitchen.ymlに以下のようなveriferを追記するだけで使えるようになります。

verifier:
  name: inspec

デフォルトだとtest/integration/<test suites>/以下のディレクトリにテストを配置します。また、以下のようにテストの場所を変更したり、他のリポジトリにあるテストを使うことも可能です。

## local
verifier:
  name: inspec
  inspec_tests:
    - test/recipes

## github
verifier:
  name: inspec
  inspec_tests:
    - https://github.com/dev-sec/tests-ssh-hardening

kitchen testのログは以下のようになります。

$ chef exec kitchen test inspec
...
...
...
-----> Verifying <with-inspec-centos-72>...
       Detected alternative framework tests for `inspec`
       Use `/Users/takaaki.furukawa/src/github.com/tkak/hello-cookbook/test/integration/with-inspec/inspec` for testing

Target:  ssh://vagrant@127.0.0.1:2201


  System Package
     ✔  zsh should be installed
  File /usr/local/foo
     ✔  should be directory
     ✔  should be owned by "root"
     ✔  should be grouped into "root"
     ✔  mode should cmp == "0755"
  File /usr/local/foo/bar
     ✔  should be file
     ✔  should be owned by "root"
     ✔  should be grouped into "root"
     ✔  mode should cmp == "0644"
     ✔  content should match "Hello"

Test Summary: 10 successful, 0 failures, 0 skipped
       Finished verifying <with-inspec-centos-72> (0m1.13s).
...
...
...

特に問題なく動きます。しかも、busser-serverspec のようにgemインストールが走って待たされるということもなく、shell-verifierを使うときのようにspec_helper.rbを用意する必要もないので、なかなかいい感じです。

まとめ

test-kitchenでInSpecを試してみました。後継のツールだけあって思ってたよりシンプルで使いやすかったです。すでにServerspec で書いてあるテストコードは、Serverspec で十分テストをまわせているのですぐにInSpecに置き換えようとは思いませんが、新規で書くならInSpecも選択肢としてありだなと感じました。また、InSpec本来の使い方であるコンプライアンスチェックとしてChef Automateと組み合わせるといったことができるらしいのですが、今回はインフラのテストとしてしか試していないので、機会があればその辺も触ってみたいです。

参考

DevOps導入指南 Infrastructure as Codeでチーム開発・サービス運用を効率化する (DEV Engineer’s Books)

DevOps導入指南 Infrastructure as Codeでチーム開発・サービス運用を効率化する (DEV Engineer’s Books)

Infrastructure As Code: Managing Servers in the Cloud

Infrastructure As Code: Managing Servers in the Cloud

Serverspec

Serverspec

今更ですが、CoreOSでDocker Swarmクラスタを組んでマルチホストネットワーク機能を試しました(Proxy環境下)

Dockerを使って開発していると、本番環境でもDockerを使ってみたいなぁという気持ちになります。ただ、本番環境でDockerを使うのは、個人的にまだまだハードルが高そうな印象です。マルチホスト間のネットワークはどうするか?分散システムのクラスタリングはどうするか?コンテナのスケジューリングは?監視は?ログは?デプロイの仕組みは?負荷が高い時の対応はどうするのか?セキュリティは?など、運用面で色々と考えなければなりません。さらにDocker界隈のツールは様々なツールが存在していてどれから手をつけていいのか選択に困ります。最初はなるべく小さく始めたいところです。なので、今回、本番環境を意識したDocker環境をより少ないツールで作れるかどうか試してみました。ひとまず、クラスタ組んで、マルチホストネットワーキング機能が試せるところまで。使ったツールは以下になります。

今回試した環境のterraformファイルは、githubにあげてますので、よかったら参考にどうぞ。

なお、プライベートな環境下で構築を試したのでProxyの設定をもろもろしていますが、参考にする場合は適当に読み替えていただければと思います。

TerraformによるCoreOSのデプロイとetcdの設定

Dockerを本番環境で使う場合、複数ホストのコンテナ間通信をどうするかというのは気になる問題ですが、いくつか方法が存在します。

Dockerのバージョン1.9からマルチホストネットワーク機能が正式に導入されたので、今回はそれを使ってみます。マルチホストネットワーク機能を使うためには、以下のような条件が必要です。今回、極力カーネルコンパイルとかはしたくなかったので、条件を満たすためにCoreOSを選択しました。

  • DockerホストOSのカーネルが3.16かそれ以降
  • Dockerがサポートしている分散KVS(Consul, Etcd, ZooKeeper)が利用できること

まず、CoreOSサーバ3台を作りetcdクラスタを組みます。Dockerのドキュメントではdocker-machineを使った手順なんですが、普段からTerraformでインフラ管理してるので今回Terraformを使います。Terraformの設定ファイルは以下になります。個々のパラメータは適当に読み替えてください。

## main.tf
provider "openstack" {
    domain_name = "default"
    tenant_name = "project-1"
    auth_url = "<keystone endpoint>"
}

variable "servers" {
    default = "test-1,test-2,test-3"
}

resource "null_resource" "discovery_url_template" {
    provisioner "local-exec" {
        command = "curl -s 'https://discovery.etcd.io/new?size=${length(split(",", var.servers))}' > discovery_url"
    }
}

resource "template_file" "discovery_url" {
    template = "discovery_url"
    depends_on = [
        "null_resource.discovery_url_template"
    ]
}

resource "template_file" "cloud-init" {
    count = "${length(split(",", var.servers))}"
    template = "${file("cloud-config.yml.tpl")}"

    vars {
        hostname = "${element(split(",", var.servers), count.index)}"
        discovery_url = "${template_file.discovery_url.rendered}"
    }
}

resource "openstack_compute_instance_v2" "coreos" {
    count = "${length(split(",", var.servers))}"
    name = "${element(split(",", var.servers), count.index)}"
    image_name = "coreos-stable-899-3-0"
    flavor_name = "flavor-1"
    region = "region-1"
    network {
        name = "network-1"
    }
    key_pair = "core"
    security_groups = ["default"]
    user_data = "${element(template_file.cloud-init.*.rendered, count.index)}"
}

CoreOSは、OS初期設定をcloud-config.ymlで行うのが標準仕様になってます。OpenStack上でCoreOSを立てる時は、user_dataconfig_driveにcloud-configの設定を渡すことで、インスタンス起動時に初期設定が行われます。cloud-config.ymlを動的に設定したいので、Terraformのtemplate_fileリソースを使います。cloud-config.yml.tplファイルは以下のようになります。

#cloud-config

hostname: ${hostname}

coreos:
  etcd2:
    discovery: ${discovery_url}
    discovery-proxy: http://proxy.example.com:1234 <- Discovery URLにアクセスするためのproxyの設定
    advertise-client-urls: http://$private_ipv4:2379,http://$private_ipv4:4001
    initial-advertise-peer-urls: http://$private_ipv4:2380
    listen-client-urls: http://0.0.0.0:2379,http://0.0.0.0:4001
    listen-peer-urls: http://$private_ipv4:2380
  units :
    - name: etcd2.service
      command: start
    - name: docker-tcp.socket
      command: start
      enable: true
      content: |
        [Unit]
        Description=Docker Socket for the API

        [Socket]
        ListenStream=2375
        BindIPv6Only=both
        Service=docker.service

        [Install]
        WantedBy=sockets.target
    - name: docker.service
      drop-ins:
        - name: 20-http-proxy.conf
          content: |
            [Service]
            Environment="HTTP_PROXY=http://proxy.example.com:1234"
      command: restart

etcdクラスタを組む際、各サーバで動かすetcd同士が同じクラスタに所属することを識別させるための情報(discovery token)が必要になります。手動でやる場合、以下のURLにアクセスしdiscovery tokenを取得してetcdの設定に使用するんですが、今回はTerraformのnull_resourceを使ってtokenを取得し、cloud-config.ymlを動的に生成するようにしています。

$ curl -s 'https://discovery.etcd.io/new?size=3'

以上のファイルの準備が整ったらterraform applyを実行します。インスタンスが立ち、etcdクラスタが作られます。etcdクラスタが組めているか、以下のコマンドで確認します。

$ etcdctl cluster-health
member 39da9b2c59694a93 is healthy: got healthy result from http://xxx.xxx.xxx.xxx:2379
member 53d999311a07d120 is healthy: got healthy result from http://yyy.yyy.yyy.yyy:2379
member d4822f0cf45608fd is healthy: got healthy result from http://zzz.zzz.zzz.zzz:2379
cluster is healthy

Docker Swarmクラスタを組む

次にDocker Swarmクラスタを組みます。Docker Swarmは、Dockerホストをクラスタリングし、Dockerコンテナを起動する時にどのホストで起動させるのかをスケジュールするツールです。Docker社が提供してます。

Docker Swarmは、AgentとManagerの2つの構成になっていて、それぞれDockerコンテナとして動かします。また、Swarm Managerは1台あればSwarmクラスタが組めますが、Swarm Managerが落ちるとDocker Swarm自体が使えなくなってしまうので、Swarm Managerを複数(プライマリとレプリカの構成で)動かします。その構成の場合、サービスディスカバリーとしてConsul, Etcd, ZooKeeperのどれかを使う必要があります。今回は、CoreOSに標準で組み込まれているEtcdを使います。

f:id:keepkeptkept:20160123163205p:plain

以下のコマンドで、Docker Swarm Agentを起動します。

$ docker run -d --name=swarm-agent swarm join --addr=$(cut -d'=' -f2 /etc/environment):2375 etcd://$(cut -d'=' -f2 /etc/environment):2379/nodes

次に、Docker Swarm Managerを起動します。4000番ポートをDocker Swarm Managerのポートにしています。

$ docker run -d -p $(cut -d'=' -f2 /etc/environment):4000:4000 --name=swarm-manager swarm manage -H :4000 --replication --advertise $(cut -d'=' -f2 /etc/environment):4000 etcd://$(cut -d'=' -f2 /etc/environment):2379/nodes

4000番ポートでdocker infoを実行するとクラスタの情報がわかります。

## Host A
$ docker -H $(cut -d'=' -f2 /etc/environment):4000 info
Containers: 6
Images: 3
Role: primary
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 3
 xxx: xxx.xxx.xxx.xxx:2375
  └ Status: Healthy
  └ Containers: 2
  └ Reserved CPUs: 0 / 4
  └ Reserved Memory: 0 B / 16.46 GiB
  └ Labels: executiondriver=native-0.2, kernelversion=4.3.3-coreos-r1, operatingsystem=CoreOS 899.3.0, storagedriver=overlay
 yyy: yyy.yyy.yyy.yyy:2375
  └ Status: Healthy
  └ Containers: 2
  └ Reserved CPUs: 0 / 4
  └ Reserved Memory: 0 B / 16.46 GiB
  └ Labels: executiondriver=native-0.2, kernelversion=4.3.3-coreos-r1, operatingsystem=CoreOS 899.3.0, storagedriver=overlay
 zzz: zzz.zzz.zzz.zzz:2375
  └ Status: Healthy
  └ Containers: 2
  └ Reserved CPUs: 0 / 4
  └ Reserved Memory: 0 B / 16.46 GiB
  └ Labels: executiondriver=native-0.2, kernelversion=4.3.3-coreos-r1, operatingsystem=CoreOS 899.3.0, storagedriver=overlay
CPUs: 12
Total Memory: 49.38 GiB
Name: be07ab661423


## Host B
$ docker -H $(cut -d'=' -f2 /etc/environment):4000 info
Containers: 6
Images: 3
Role: replica
Primary: xxx.xxx.xxx.xxx:4000
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 3
...
...

無事にDocker Swarmが組めているのがわかります。試しにSwarm Managerを落としてみると、primaryが別なホストに移るのがわかります。

## Host A
$ docker stop swarm-manager

## Host B
$ docker -H $(cut -d'=' -f2 /etc/environment):4000 info
Containers: 6
Images: 3
Role: primary
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 3
...
...

ちなみに、環境変数DOCKER_HOSTを設定しておくとオプションを省略できて、シングルホストの時のように使えます。

$ export DOCKER_HOST=$(cut -d'=' -f2 /etc/environment):4000
$ docker info
Containers: 6
Images: 3
Role: primary
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 3
...
...

また、Dockerコンテナをsystemdから起動できるようにする場合は、以下のような記述をcloud-config.yml.tplに追記します。

    - name: swarm-agent.service
      command: start
      content: |
        [Unit]
        Description=Docker Swarm Agent Container
        After=docker.service
        Requires=docker.service

        [Service]
        TimeoutStartSec=0
        Restart=always
        ExecStartPre=-/usr/bin/docker stop swarm-agent
        ExecStartPre=-/usr/bin/docker rm -f swarm-agent
        ExecStartPre=-/usr/bin/docker pull swarm
        ExecStart=/usr/bin/docker run --name=swarm-agent swarm join --addr=$private_ipv4:2375 etcd://$private_ipv4:2379/nodes

        [Install]
        WantedBy=multi-user.target
    - name: swarm-manager.service
      command: start
      content: |
        [Unit]
        Description=Docker Swarm Manager Container
        After=docker.service
        Requires=docker.service

        [Service]
        TimeoutStartSec=0
        Restart=always
        ExecStartPre=-/usr/bin/docker stop swarm-manager
        ExecStartPre=-/usr/bin/docker rm -f swarm-manager
        ExecStartPre=-/usr/bin/docker pull swarm
        ExecStart=/usr/bin/docker run -p $private_ipv4:4000:4000 --name=swarm-manager swarm manage -H :4000 --replication --advertise $private_ipv4:4000 etcd://$private_ipv4:2379/nodes

        [Install]
        WantedBy=multi-user.target 

こうすることでterraform applyだけで、Docker Swarmクラスタまで組めるようになります。

マルチホストネットワーク機能を試す

Docker Swarmクラスタが組めたので(ようやく)、次にマルチホストネットワーク機能を試します。

マルチホストネットワーク機能は、overlayドライバを使うことで仮想ネットワークを作ることができます。以下のコマンドでoverlayネットワークを作ります。Docker Swarmを使ってる場合、overlayドライバがデフォルトなので--driverオプションは省略可能です。

$ docker network create --driver overlay backend

ここで以下のようなメッセージが出る場合は、Dockerのクラスタ設定--cluster-advertise--cluster-storeが抜けているので、設定を追加します。

$ docker network create backend
Error response from daemon: 500 Internal Server Error: failed to parse pool request for address space "GlobalDefault" pool "" subpool "": cannot find address space GlobalDefault (most likely the backing datastore is not configured)

以下のファイルから、Dockerオプションを設定することができます。ファイルを作ったらdocker.serviceを再起動します。

$ sudo vi /etc/systemd/system/docker.service.d/30-custom.conf
[Service]
Environment="DOCKER_OPTS=--cluster-advertise eth0:2375 --cluster-store etcd://xxx.xxx.xxx.xxx:2379"

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker

が、再度試したところ、また違うエラーが・・・。

$ docker network create backend
Error response from daemon: 500 Internal Server Error: error getting pools config from store during init: could not get pools config from store: client: response is invalid json. The endpoint is probably not valid etcd cluster endpoint.

今回、だいぶここでハマったんですが、Docker APIの通信がProxyを介してしまってうまくいかなかったようです。NO_PROXYにDockerホストのIPを全部並べてDockerホスト間はProxyさせないようにします。

$ cat /etc/systemd/system/docker.service.d/20-http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://proxy.example.com:1234"
Environment="NO_PROXY=localhost,127.0.0.1,xxx.xxx.xxx.xxx,yyy.yyy.yyy.yyy,zzz.zzz.zzz.zzz"

再度docker.serviceを再起動させて、無事にネットワークができました。

$ docker network create backend
856e8f470224627390abaaa31db868bd01ba7423e0bf7ea5b020f7e8e0336b34
$ docker network ls
NETWORK ID          NAME                DRIVER
856e8f470224        backend             overlay
f52c2ae81a2a        xxx/bridge     bridge
f9c89af96fad        yyy/none       null
...
...

では、コンテナを2つ立ち上げて疎通が確認できるか試してみます。

$ docker run -itd --net=backend --name test1 --hostname test1 ubuntu /bin/bash
$ docker run -itd --net=backend --name test2 --hostname test2 ubuntu /bin/bash

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
c1b2cd585175        ubuntu              "/bin/bash"         5 minutes ago       Up 49 seconds                           xxx/test2
01f8c3067398        ubuntu              "/bin/bash"         5 minutes ago       Up 5 minutes                            zzz/test1

docker psコマンドから、異なるホストでコンテナ2つが起動してるのがわかります。

$ docker exec -it zzz/test1 /bin/bash
root@test1:/# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
27: eth0@if28: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default
    link/ether 02:42:0a:00:00:02 brd ff:ff:ff:ff:ff:ff
    inet 10.0.0.2/24 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:aff:fe00:2/64 scope link
       valid_lft forever preferred_lft forever
29: eth1@if30: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.2/16 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe12:2/64 scope link
       valid_lft forever preferred_lft forever

eth0についている10.0.0.0/24のネットワークがoverlayネットワークです。

root@test1:/# ping test2
PING test2 (10.0.0.3) 56(84) bytes of data.
64 bytes from test2 (10.0.0.3): icmp_seq=1 ttl=64 time=0.950 ms
64 bytes from test2 (10.0.0.3): icmp_seq=2 ttl=64 time=0.544 ms
^C
--- test2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.544/0.747/0.950/0.203 ms
root@test1:/#

おぉ、疎通も無事確認できました!

ところで、コンテナ同士の名前解決はどうやってるんでしょうか。/etc/resolv.confなどをみてみましたが、どうやら/etc/hostsでやっているようです。

root@test1:/# cat /etc/hosts
10.0.0.2        test1
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
10.0.0.3        test2
10.0.0.3        test2.backend
root@test1:/# 

試しに、新しくtest3を作ってみると/etc/hostsに新しい設定が追加されてました。

root@test1:/# cat /etc/hosts
10.0.0.2        test1
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
10.0.0.3        test2
10.0.0.3        test2.backend
10.0.0.4        test3
10.0.0.4        test3.backend
root@test1:/# 

まとめ

Dockerを本番環境で使うために、CoreOSでDocker Swarmクラスタを組んでマルチホストネットワーク機能を試してみました。DockerをProxy環境下で動かす場合は、いろんな場所にproxy設定を記述しないといけないのがアレですね。ここまで動かすのにProxyのおかげでだいぶ消耗しました・・・。あと、コンテナを立ち上げるのにdocker runコマンド経由なのが少し煩わしく感じました。その辺はスクリプトにするか、もう少し宣言的に出来ないかと思うのでdocker-composeやnomadなどを検討する必要がありそうです。でも、まぁ、ひとまず、環境ができてよかったです。

プログラマのためのDocker教科書 インフラの基礎知識&コードによる環境構築の自動化

プログラマのためのDocker教科書 インフラの基礎知識&コードによる環境構築の自動化

Docker 実践ガイド (impress top gear)

Docker 実践ガイド (impress top gear)

sinatraとsidekiqを組み合わせて、簡単なWeb APIを作ってみた

sidekiqは、複数のjobを非同期実行させることができるrubyライブラリです。割と簡単にjobの並列処理がrubyで書けて、便利そうです。今回、年末から年始にかけて、ゆる〜くsinatraとsidekiqを組み合わせて、非同期型のjob実行Web APIを作ってみたので、まとめておきます。

背景

GUIしか存在しない、いにしえのツールをWeb API化(poltergeistを使ってブラウザ操作部分を自動化し、sinatraとsidekiqを組み合わせてAPIを作成)したかったからです。

なお、今回作ったものはGitHubにあげました。

github.com

開発環境

今回Dockerも触ってみたかったので、ローカル開発環境をDockerで作りました。

  • OS X 10.11.1 (15B42)
  • Docker version 1.8.2, build 0a8c2e3
  • docker-machine version 0.4.1 (e2c88d6)
  • docker-compose version: 1.4.2

docker-machine

docker-machineでDocker環境(VirtualBox上にDocker用VM)を作ります。

$ docker-machine start dev

docker-machine envの結果を環境変数に設定します。

$ eval "$(docker-machine env dev)"

application用のDockerfile

sinatraとsidekiqを動かすapplication用コンテナのDockerfileを、以下のように作ります。一度/tmpにGemfileとGemfile.lockをコピーしてbundle installすることで、docker runするたびにgem installが動くことを回避し、うまくDockerのキャッシュを使えるようにしています。また、sinatraで使うポート5000をEXPOSEし、外からアクセスできるようにします。

FROM ruby:2.2.4
RUN gem install bundler

WORKDIR /tmp
ADD Gemfile Gemfile
ADD Gemfile.lock Gemfile.lock
RUN bundle install

WORKDIR /opt/sinatra-sidekiq-example

EXPOSE 5000

docker-compose

sidekiqは、jobのキューをためるのにredisを使っているので、applicationコンテナとは別にredisコンテナを立ち上げます。ただ、applicationコンテナとredisコンテナ、複数のコンテナを立ち上げようとすると、linkオプションを使わないといけなかったり、コンテナの起動順序を意識したりと、dockerコマンドだけで管理しようとすると極端に辛くなってきます。そこでdocker-composeを使います。docker-compose.ymlファイルにコンテナの定義を書くことで、コンテナを管理しやすくします。コンテナが多くなってくると、コマンドラインで短縮化されたオプションを書きまくるより、設定ファイルでより直感的に管理できる方がいいですからね。

docker-composeのヘルプはこんな感じ。

$ docker-compose -h
Define and run multi-container applications with Docker.

Usage:
  docker-compose [options] [COMMAND] [ARGS...]
  docker-compose -h|--help

Options:
  -f, --file FILE           Specify an alternate compose file (default: docker-compose.yml)
  -p, --project-name NAME   Specify an alternate project name (default: directory name)
  --verbose                 Show more output
  -v, --version             Print version and exit

Commands:
  build              Build or rebuild services
  help               Get help on a command
  kill               Kill containers
  logs               View output from containers
  port               Print the public port for a port binding
  ps                 List containers
  pull               Pulls service images
  restart            Restart services
  rm                 Remove stopped containers
  run                Run a one-off command
  scale              Set number of containers for a service
  start              Start services
  stop               Stop services
  up                 Create and start containers
  migrate-to-labels  Recreate containers to add labels
  version            Show the Docker-Compose version information

今回のdocker-compose.ymlファイルはこんな感じにしました。

web:
  build: .
  command: foreman start
  ports:
    - "5000:5000"
  volumes:
    - .:/opt/sinatra-sidekiq-example
  links:
    - redis
  environment:
    - REDIS_HOST=redis
    - REDIS_PORT=6379
redis:
  image: redis

buildでカレントディレクトリを指定してますが、そうすることでカレントディレクトリからDockerfileを探してきて勝手に読み込んでくれます。commandは、コンテナを起動するときに実行するコマンドです。portsはポートフォワーディングの設定です。volumesは、コンテナの内部とカレントディレクトリを共有するために設定します。linksは、コンテナ同士の関連付けです。environmentはコンテナ内部の環境変数設定です。redisコンテナは特にカスタマイズせずに公式のredisイメージを立ち上げるだけにしてます。

Dockerfileからコンテナをビルドします。

$ docker-compose build

コンテナを起動させます。

$ docker-compose up -d

コンテナの情報を確認します。

$ docker-compose ps
            Name                          Command             State           Ports
--------------------------------------------------------------------------------------------
sinatrasidekiqexample_redis_1   /entrypoint.sh redis-server   Up      6379/tcp
sinatrasidekiqexample_web_1     foreman start                 Up      0.0.0.0:5000->5000/tcp

コンテナの中に入ります。

## application用 (web)
$ docker-compose run web /bin/bash

## redis用
$ docker-compose run redis /bin/bash

参考

sidekiq

開発環境が整ったら、ようやくsidekiqです。Gemfileにsidekiqを指定し、bundle installします。

gem 'sidekiq'

sidekiqを使った実装は、jobをキューに入れるところと、jobを実行するところの2つです。

redisの接続設定

sidekiqはバックエンドにredisを使うので、redisに接続するための設定を記述します。configure_clientとconfigure_server、両方必要です。

## jobをキューにためるのに必要
Sidekiq.configure_client do |config|
  config.redis = { url: "redis://#{ENV['REDIS_HOST']}:#{ENV['REDIS_PORT']}" }
end

## jobをキューから取得するのに必要
Sidekiq.configure_server do |config|
  config.redis = { url: "redis://#{ENV['REDIS_HOST']}:#{ENV['REDIS_PORT']}" }
end

workerの実装

jobを実行するworkerの実装は、以下のような感じです。Sidekiq::Workerモジュールをincludeしたclassを作り、performメソッド内にjobを記述します。ドキュメント読む限り、performメソッドの引数は、redisの容量を少なくするためになるべく少なくした方がいいようです。

require 'sidekiq'

class SomeWorker
  include Sidekiq::Worker

  def perform(id)
    thing = $redis.hget('things', id)
    sleep 10
    p "The job is done: #{thing}"
  end
end

キューにためたjobを実行するためには、sidekiqコマンドを使います。

$ bundle exec sidekiq -h
2016-01-02T15:21:45.078Z 7 TID-origl9uvo INFO: sidekiq [options]
    -c, --concurrency INT            processor threads to use
    -d, --daemon                     Daemonize process
    -e, --environment ENV            Application environment
    -g, --tag TAG                    Process tag for procline
    -i, --index INT                  unique process index on this machine
    -q, --queue QUEUE[,WEIGHT]       Queues to process with optional weights
    -r, --require [PATH|DIR]         Location of Rails application with workers or file to require
    -t, --timeout NUM                Shutdown timeout
    -v, --verbose                    Print more verbose output
    -C, --config PATH                path to YAML config file
    -L, --logfile PATH               path to writable logfile
    -P, --pidfile PATH               path to pidfile
    -V, --version                    Print version and exit
    -h, --help                       Show help

## 実行例
$ bundle exec sidekiq -r ./app.rb -C ./sidekiq.yml

オプションを色々と指定することもできますが、yml形式の設定ファイルを使うこともできます。sidekiq.ymlはこんな感じ。

:verbose: true
:pidfile: /var/run/sidekiq.pid
:logfile: /var/log/sidekiq.log
:concurrency: 10
:queues:
    - default

sidekiqのプロセス数は、concurrencyで指定します。デフォルトは、25です。また、-dオプションでプロセスをデーモン化できますが、本番環境ではsystemdなどを使った方がいいとドキュメントに書いてあります。

jobをキューに登録

非同期で実行したいjobをキューに登録するのには、perform_asyncクラスメソッドを呼びます。

    SomeWorker.perform_async(req['id'])

5分後に実行したいjobは以下のように、perform_inを使います。

    SomeWorker.perform_in(5.minutes, req['id'])

dashboard

f:id:keepkeptkept:20160103102740p:plain

sidekiqは、jobのステータスをブラウザから確認するためのdashboardを提供してます。ドキュメントには、railsと一緒に使う方法しか書いてないんですが、sinatraと一緒に使う場合は、以下のようにすれば使えました。

require './app'
require 'sidekiq/web'

run Rack::URLMap.new('/' => SomeApp, '/sidekiq' => Sidekiq::Web)

参考

foreman

foremanは複数のプロセスを管理するツールです。今回sinatraとsidekiqのプロセス管理をforemanでやってみました。以下のようにProcfileにプロセスの定義を書きます。

web: bundle exec rackup -p $PORT --host 0.0.0.0
worker: bundle exec sidekiq -r ./app.rb -C sidekiq.yml

起動は以下のコマンド。

$ foreman start

参考

まとめ

sinatraとsidekiqを組み合わせることで、簡単に非同期型のjob実行Web APIが書けました。半分くらいDockerで遊んで得た知見ですが、これでひとまずやりたいことが実現できそうなことが分かってよかったです。

プログラマのためのDocker教科書 インフラの基礎知識&コードによる環境構築の自動化

プログラマのためのDocker教科書 インフラの基礎知識&コードによる環境構築の自動化

Docker実践ガイド

Docker実践ガイド

Terraform moduleあるある

このエントリは HashiCorp Advent Calendar 2015 - Qiita 10日目の記事です。今回はTerraformのmodule機能に関する知見をご紹介します。

moduleとは?

module "consul" {
    source = "github.com/hashicorp/consul/terraform/aws"
    servers = 3
}

moduleは、Terraform resourceを抽象化するためのものです。よく使うパラメータをmodule内に隠蔽して入力項目を減らしたり、複数のresourceをまとめたり、tfファイルの見通しを良くするために使います。

生のresourceを使ってTerraformの設定ファイルを書くのには、ある程度インフラの知識が必要です。module機能を使って、可能な限り入力項目を簡潔にすれば、インフラの知識がない人でもTerraformを使ってインフラの構築作業ができるようになります。例えば、インフラインフラエンジニアがmoduleの作成と管理を行い、アプリケーションエンジニアがmoduleを利用する、というような具合です。moduleを制するものはTerraformを制す、と言っても過言ではないくらい、重要な機能だと思います。

moduleの作り方

moduleの作り方ですが、ドキュメントを読めばなんとなく概要はつかめると思います。ただ、正直色んな書き方ができてしまうので、最初は困ると思います(自分がそうでした…)。実はHashicorpがterraform-community-modulesというのを作っているので、最初はそれを参考にするのがいいかと思います。自分が作ってるterraform moduleもこれを参考にして作っていて、大体このようなファイル構成にしてます。

.
├── CHANGELOG.md
├── README.md
├── init.sh
├── main.tf
├── outputs.tf
├── templates
│   └── node.json.tpl
└── variables.tf

基本的にmoduleはgitリポジトリごとに分けて管理していて、main.tfにメイン処理、variables.tfに変数、outputs.tfにモジュール外で呼び出したいパラメータを記述するようにしています。

では実際に試行錯誤して得られた知見をみていきましょう。

Try 1: moduleの変数に配列を使いたい

module "example" {
  source = "..."
  servers = ["foo", "bar", "buz"]
  ...
  ...

ある時、上の設定のようにmoduleの変数に配列を渡したい時がありました。

試しにこの書き方で使ってみると、エラーが返ってきます。現在の最新バージョンv0.6.8でも、まだサポートされていないようです。moduleの変数に配列は渡せません。

module "example" {
  source = "..."
  servers = "foo,bar,buz"
  ...
  ...

ではどうしたかというと、色々とgithubのissueを探した結果、上の設定のようにカンマを使って文字列として変数を渡し、module内で配列に変換するという方法を見つけました。

module内部の記述は以下のようになります。

resource "openstack_compute_instance_v2" "default" {
    count = "${length(split(",", var.servers))}"
    name = "${element(split(",", var.servers), count.index)}"
    ...
    ...

countパラメータでは、splitで文字列を配列に変換し、lengthで配列のサイズを求めています。nameパラメータではsplitで文字列を配列に変換し、elementで配列の要素を取得しています。正直余り綺麗ではないですが、なんとかやりたいことは実現できました…。

Try 2: クラスタを組む時にAZを分けたい

ある時、サービスの可用性を高めるために異なるAZに分けてインスタンスを構築したい、そんな時がありました。

これは割と知られている方法かもしれませんが、count.indexと余りを計算する%を組み合わせて実現できました。

variables.tfファイルの中で、AZの定義を書き、

variable "availability_zones" {
  default = {
    "0" = "az-foo"
    "1" = "az-bar"
    "2" = "az-buz"
  }
}

main.tfの中で、lookupを使って、availability_zones変数から対応するAZを取得してます。こうすることで、1台目はaz-fooに、2台目はaz-barに、3台目はaz-buzに、4台目はaz-fooに、といった具合になります。

resource "openstack_compute_instance_v2" "default" {
  ...
  availability_zone = "${lookup(var.availability_zones, count.index % 3)}"
  ...
  ...

Try 3: Serverspecと連携させたい

ある時、Terraformで作ったインスタンスをServerspecで確認したい、Serverspecで使うyamlファイルをTerraformで生成したい、という時がありました。

そんな時は、template_file resourceを使って対象インスタンスごとにyamlファイルを生成し、Terraform実行後にServerspecを実行するというようなことで実現できました。以下は、設定例です。

resource "template_file" "example" {
    template = "${file("./templates/os_base.yml.tpl)}"

    vars {
        target = "${openstack_compute_instance_v2.example.access_ip_v4}"
    }

    provisioner "local-exec" {
        command = "echo '${template_file.example.rendered}' > /var/tmp/os_base.yml"
    }
}

local-execでechoする、…なかなか渋い方法になりました。

Try 4: module内の変数をmoduleの外で使いたい

ある時、moduleの中で生成される変数を他のresourceの変数として使いたい、というような時がありました。

普通にmodule内部のresourceパスを書けばできるだろうと思って、色んな書き方を試したんですが、うまくいきません。色々試した結果、moduleの外から呼び出したい変数はmoduleの中でoutput設定を使って、明示的に定義してやらないといけないことに気がつきました。moduleを作る際は、意外とoutput設定が重要です。

output "ipv4_address" {
    value = "${openstack_compute_instance_v2.default.access_ip_v4}"
}

Try 5: moduleはきちんとtagを打つ

moduleを使う際は、バージョンロックさせるためにgitのtagでバージョンを指定するようにしましょう。tagを指定しないとmoduleの入力パラメータが変わってしまった時にエラーになってしまうので、利用者にきちんと説明をした方がいいです。

まとめ

以上、Terraformのmoduleに関する知見を紹介しました。Terraformがリリースされて1年以上になりますが、まだまだノウハウが足りません。かゆい所にもうちょっとで手が届くか届かないかっていう感じなので、tfファイルのグッドプラクティス情報、ぜひ知りたいです。よろしくお願いします。

HashiConf 2015に参加してきました

9月28, 29にポートランドで開催されたHashiConf 2015に参加してきたのでレポートします。

セッションを聞いて

カンファレンス全体を通して、一日目のMitchell Hashimoto氏のKeynoteがやはり一番エキサイティングでした。事前に新しいプロダクトがリリースされる噂はあったんですけど、まさか二つ発表されるとは…。しかも、発表の流れがめちゃくちゃ良くて、みんなワクワクしながら新プロダクトのリリースを待ってたところにNomadが出てきて、おー、ってなったところに、さらにその後『One more thing』でOttoが発表されて、会場はだいぶ盛り上がってました。え、それどこの、スティーブジョブズですか?ってなってました。カリスマ性半端なかった。

個人的に新プロダクトの中でもOttoはとても興味深くて、開発者と運用者の間の責任領域をうまく抽象化し、開発、インフラの構築、デプロイまでを一つのツールで管理できるプロダクトだと思います。そこそこ人数がいて開発と運用で組織が分かれてるような会社だと、各組織間で責任領域が分かれていて越えられない壁みたいなのがあって、ワークフローや調整などで結構コストがかかるっていう悲しい問題があるんですけど、Ottoはその辺りをいい感じで解決出来そうです。運用者側はOttoのインフラモジュールをPackerやTerraormなどで定義しておいて、開発者側はあまりインフラを意識せずにそのモジュールを使って、開発からアプリケーションのデプロイまでできると。まさにプライベートなPaaSを実現できそうなツール。まだまだリリースされたばかりで足りてないと思うけど、HashiCorpの今までやってきたこととこれからのビジョンを具現化したようなツールで、今後の展開が本当に楽しみです。

午後のセッションでは、『VC's on line 1』っていうタイトルでTerraformとChefを使ったインフラ自動化の話が面白かったですね。chef provisioningとTerraformの性能比較とか、Terraformのモジュールの書き方とか、個人的にめちゃめちゃ参考になりました。みんながみんなCodeを理解できるわけではないから、そういう人に対してインフラ自動化を可視化できるようにGUIツールを作ったっていうようなこと言ってて、そこに至る背景とか苦労とか考えると、共感してしまって他人ごとでは聞けない発表でした。

2日目は、朝からまたHashimoto氏によるKeynote。The Tao of Hashicorpの話を一つ一つ説明しくれてました。話自体は知ってる内容でしたが、改めて本人から説明を聞くと、なんだかもう本当ありがたくて、Hashimoto氏から後光さしてましたね。もうここまで来ると、まさにHashiCorp道の教祖、いや、DevOps界の神のお言葉。ありがたや、ありがたやと思いながら聞いてました。あと、AtlasとOttoの関係をGitHubとGitに例えて説明をしてたのも面白かったです。一年でエンジニアが30人くらいまで増えたらしく、今後Atlasを使ってどうマネタイズしてくのかなぁなんて考えたりしてました。

2日目の午後のセッションはAmazonのMicroserviceの話が特に面白かったです。Microserviceをどこにどう導入していったかっいう話で、それぞれのフェーズの課題とそれに対する解決法みたいなことを説明してました。前半は、これからはMicroserviceの時代だぜ的なノリで面白かったけど、後半は自社製品の説明で少し退屈な内容でした。

参加者との交流

参加者との交流は、わざわざ現地まで行ってカンファレンスに参加することの醍醐味で、HashiCorpプロダクトに携わるエンジニアと実際に会って話せて楽しかったです。

Hashimoto氏と日本から来てるメンバーで写真撮りました。

@yoshidashingoさんは、会場で配ってたHashiCorpのロゴ入りノートにサインもらってました。

1日目夜のパーティーでTerraformのプロダクトマネジメントしてる人と話す機会があって、次のメジャーバージョンいつ出そうとしてるのかとか、今作ってるTerraformのVMwareプラグインを本家に取り込んでもらえるか的な相談とか、個人的にかなり有意義な話ができたので良かったです。

また、日本のWebインフラ界隈で有名なエンジニアの方々とも知り合いになることもできました。特に2日目夜は一緒にレバノン料理を食べながら、NomadやOttoがどうだったとか、面白いセッションあったかとか、HashiConfの話から始まりいろんな情報交換ができて楽しい時間を過ごせました。一応名刺持ってったけど、結局一枚も使わずにTwitter のアカウント教えてもらっただけでした。その辺はなんかエンタープライズ系のエンジニアは少ないのかなぁと思いました。

ポートランドの街

ポートランドクラフトビールが有名な街らしく、ふらっと街を散策してたらブリュワリーがやってるレストランを見つけて入ってみました。9ドルで6種類のクラフトビールの飲み比べができるメニューがあってだいぶお得で、味はかなり癖のあるものから飲みやすいものまで様々で、ビール好きにはたまらない感じでした。

日本でポートランドっていうと全然知名度低いけど(アメリカでは住みたい街ナンバーワンらしい)、意外にもいい街でした。

と、まぁ、そんな感じでHashiConf 2015はむちゃくちゃ楽しかったです!ありがとうHashiCorp!ありがとうHashiConf!

Chefを学ぶのが面倒な人のInfrastructure as Code with JSON

devops chef

最近、いかにChefの学習コストを小さくしつつ、組織にインフラのコード化(Infrastructure as Code)を導入するか、について悶々と考えていたので、まとめてみた。

Chefの辛み

インフラのコード化をするにあたって、Chefは最初に覚えることが多くてなかなか辛い、という話をよく耳にする。Chefの動作概念、Chefの各種リソースの使い方、Chefの周辺ツール群の使い方、Cookbookのコーディング規約など、Chefを使い始めるために覚えるべき内容は結構多い。組織が大きくなればなるほど学習コストはバカにならないし、インフラエンジニアに日常的にコードを書いてもらうことはなかなか難しい。そんな環境でInfrastructure as Codeを広めようとしても、現実的になかなかうまくいかない。インフラをコード化して業務改善がしたいのに、いつの間にかみんなにChefを広めることが目的になり、疲弊したりする。

Infrastructure as Code with JSON

そんな中、DevOpsの流れとは逆行するが、みんなみんながみんなChefのことわかってCookbookが書ける必要はないんじゃないかとふと思った。大規模な組織では業務内容が細分化されているし、Cookbookの作成は得意な人に任せて、最低限必要な変数(attribute)をJSON ファイルで定義してchefを実行する方法だけ学べばいいのではないかと。まさにInfrastructure as Code with ChefならぬInfrastructure as Code with JSONJSON を書いてインフラを管理する。Cookbookに比べてある程度柔軟性は無くなるが、最低限の学習コストでChefを導入することができ、業務の自動化ができそう。そうすれば、少しでも手作業による運用、筋肉運用を減らすことができるんじゃないだろうか。

インフラのコード化を進めていく段階を分類するとだいたいこんな感じになる。

  1. Chefなんて使わずにマンパワーにまかせた筋肉運用をする
  2. Chefはそんなに知らないけどJSONファイルが書けてcookbookが使える
  3. Chefのことをよく知ってて自由にcookbookが書ける

理想は3の人が当たり前にいることだが、いきなり1から3にいける人はそこまで多くないと思うし、まずは1から2に持っていくのが改善効果が期待できるし大事だと思う。

Cookbookを書かずにCookbookを使う

Cookbookを使うだけであれば、Berkshelfの使い方と、attributeとrun_listを学ぶだけだ。作業としてはBerkshelf を使ってcookbookをRubyのBundlerのようにgemを手元に取ってきて、Cookbookの実行に必要なattributeとrun_listをNodeのJSON ファイルに定義してChefを実行する。Chefを実行するだけならChefのCookbookを書く必要はない。以下、使い方を紹介したい。なお、紹介のなかで使うNodeのJSONファイルなどはGithubに置いておいた。

1. BerkshelfでCookbooksを取得する

まず、使いたいCookbookを選ぶ。ChefコミュニティのCookbookを使ってもいいし、社内で使ってるCookbookでもいい。Cookbookの取得元は以下の通り。

  • コミュニティCookbook置き場 - Supermarket
  • 社内のCookbook置き場 - Private supermarketかBerkshelf APIのエンドポイント

timezone-ii Cookbookを使う場合のBerksfileはこんな感じ。

source "https://supermarket.chef.io"

cookbook "timezone-ii"

社内のCookbookを使う場合は、sourceに社内のエンドポイントを指定する。

source "https://shanai.example.com/"

cookbook "hogehoge"
cookbook "fugafuga"
cookbook "piyopiyo"

Berksfileを作ったらberksコマンドでcookbookをローカルに取ってくる。まるでRubyのBundlerのように。以下の例では取得先をcookbooksディレクトリにしている。

$ berks vendor cookbooks
Resolving cookbook dependencies...
Fetching cookbook index from https://supermarket.chef.io...
Using timezone-ii (0.2.0)
Vendoring timezone-ii (0.2.0) to cookbooks/timezone-ii

$ tree -L 2
.
├── Berksfile
├── Berksfile.lock
└── cookbooks
    └── timezone-ii

2. NodeのJSONファイルを定義する

次にChefを実行するために必要なNodeのJSONファイルを作成する。Nodeの設定に必要な可変値(attribute)や、どのrecipeを実行するか(run_list)などを記載する。その際、きちんと使いたいCookbookのドキュメントを読んで、使えるAttributeやrecipeを確認する。

supermarket.chef.io

timezone-iiのcookbookを実行する例だと以下のようになる。

{
    "name": "my_node",
    "tz": "Asia/Tokyo",
    "run_list": [
        "recipe[timezone-ii]"
    ]
}

nodesディレクトリを掘ってそこにJSONファイルをおく。

$ tree -L 2
.
├── Berksfile
├── Berksfile.lock
├── cookbooks
│   └── timezone-ii
└── nodes
    └── my_node.json

3. Chefを実行する

あとはChefを実行するだけ。Chefの実行はchef-solo、chef-client、knife-soloなど、いろんな方法があるが、ここではVagrantで作ったVM上で、chef-clientのlocalモードを実行する例を示す。

まず、以下のようなchefを実行するのに必要な設定ファイルclient.rbを用意する。

cookbook_path '/vagrant/cookbooks'

ちなみに、社内環境とかProxy配下の環境で試す場合は以下のようなファイルになる。chef-zeroでproxyを使う場合はno_proxylocalhostを入れることがポイント。

http_proxy 'http://proxy.example.coml:1234'
no_proxy 'localhost'
cookbook_path '/vagrant/cookbooks'

最後にvagrantVMを立ててchefを実行する。

$ vagrant box add tkak/centos-6.6-x86_64-chef-dk
$ vagrant init tkak/centos-6.6-x86_64-chef-dk
$ vagrant up
$ vagrant ssh

[vagrant@localhost ~]$ date
Sun Apr  5 04:19:24 UTC 2015
[vagrant@localhost ~]$ sudo chef-client -z -j /vagrant/nodes/my_node.json -c /vagrant/conf/client.rb
Starting Chef Client, version 12.0.3
resolving cookbooks for run list: ["timezone-ii"]
Synchronizing Cookbooks:
  - timezone-ii
Compiling Cookbooks...
Converging 4 resources
Recipe: timezone-ii::default
  * yum_package[tzdata] action install (up to date)
  * log[Linux platform 'centos' is unknown to this recipe; using generic Linux method] action write (skipped due to not_if)
Recipe: timezone-ii::linux-generic
  * ruby_block[confirm timezone] action run
    - execute the ruby block confirm timezone
  * file[/etc/localtime] action create
    - update content in file /etc/localtime from ab1ddb to 0bc4b3
    (current file is binary, diff output suppressed)
    - restore selinux security context

Running handlers:
Running handlers complete
Chef Client finished, 2/3 resources updated in 10.584156048 seconds
[vagrant@localhost ~]$ date
Sun Apr  5 13:21:12 JST 2015
[vagrant@localhost ~]$ 

まとめ

ChefのCookbookを書かずにJSONとBerkshelf だけでChefを実行する方法について書いた。もちろんChefをきちんと理解してCookbookが書ける方が望ましいが、Infrastructure as Codeに馴染みがない人やこれから学びたいという人は、とりあえずChefのcookbook使ってみるところからはじめるのがいいと思う。大事なのはChefを使うことではなく業務を改善することなので、まずはCookbookを使ってみて少しでも手作業をなくすことが大切だと思う。

TerraformのProviderを作った

terraform

Terraform v2.0から、オリジナルProviderを作るためのフレームワーク機能が追加されている。今回この機能を使ってオレオレProviderを作ったので、やったことをまとめておく。

Go API library

まず、TerraformのProviderを作る前に、自分が使いたいクラウドサービスのGo API libraryを探す。なければ自分で作る。

今回はGMO ConoHaクラウド用のAPI libraryを作った。ConoHaクラウドは今のところオブジェクトストレージ(OpenStack Swift)APIしか提供していなかったので、オブジェクトストレージを操作する機能だけのものになっている。

また、他のクラウドサービス向けのプラグインを作る時の参考用に、簡単なAPI client libraryの型みたいな物も作ってみた。

Terraform Provider

Client libraryが準備できたら、次に、Terraform Providerを作る。terraform-provider-xxxっていう名前のリポジトリを作って、main.goprovider.goconfig.goresource_xxx.goのファイルを用意する。Providerは基本的にこの4種類のファイルで構成されている。(詳細はGitHubを参照)

以下、ちょっとした解説。

main.go

package main
import (
    "github.com/hashicorp/terraform/plugin"
    "github.com/tkak/terraform-provider-conoha/conoha"
)
func main() {
    plugin.Serve(&plugin.ServeOpts{
        ProviderFunc: conoha.Provider,
    })
}

main.goファイルには、plugin.Serve関数を呼ぶだけの処理を書く。オリジナルProviderをプラグインとしてTerraformに組み込むためのもの。

provider.go

// Provider returns a terraform.ResourceProvider.
func Provider() terraform.ResourceProvider {
    return &schema.Provider{
        Schema: map[string]*schema.Schema{
            "tenant": &schema.Schema{
                Type:     schema.TypeString,
                Required: true,
            },
            "user": &schema.Schema{
                Type:     schema.TypeString,
                Required: true,
            },
            "password": &schema.Schema{
                Type:     schema.TypeString,
                Required: true,
            },
        },
        ResourcesMap: map[string]*schema.Resource{
            "conoha_container": resourceConohaContainer(),
        },
        ConfigureFunc: providerConfigure,
    }
}

provider.goファイルには、helper/schemaライブラリを使って、Provider関数を定義する。ここでは、.tfファイル内で使う、provider用の変数を定義したり、conoha_containerなど、独自のresourceの宣言を行う。例えば、上のProvider関数の定義だとこんな.tfファイルになる。

provider "conoha" {
    user = "hoge"
    password = "hoge123"
    tenant = "hoge"
}

簡単なバリデーションであればhelper/schemaのTypeやRequiredなどを使ってシンプルに書くことができる。

config.go

type Config struct {
    Tenant   string `mapstructure:"tenant"`
    User     string `mapstructure:"user"`
    Password string `mapstructure:"password"`
    Token    string `mapstructure:"token"`
    Endpoint string `mapstructure:"endpoint"`
}

func (c *Config) Client() (*conoha.Client, error) {
...
}

config.goファイルでは、client libraryを使うためのConfig structを定義したり、Client関数を定義したりする。変数の詳細なバリデーションも必要あればここに書いたりする。

resource_conoha_container.go

func resourceConohaContainer() *schema.Resource {
    return &schema.Resource{
        Create: resourceConohaContainerCreate,
        Read:   resourceConohaContainerRead,
        Delete: resourceConohaContainerDelete,

        Schema: map[string]*schema.Schema{
            "name": &schema.Schema{
                Type:     schema.TypeString,
                Required: true,
                ForceNew: true,
            },
        },
    }
}

func resourceConohaContainerCreate(d *schema.ResourceData, meta interface{}) error {

    client := meta.(*conoha.Client)

    c := &conoha.Container{
        Name: d.Get("name").(string),
    }

    err := client.CreateContainer(c)
    if err != nil {
        return fmt.Errorf("Error creating container: %s", err)
    }

    d.Set("name", c.Name)
    d.SetId(c.Name)

    return nil
}

...

resource_xxx.goファイルには、resourceで扱う変数の定義やAPI周りのメイン処理を書く。上のresourceConohaContainer()だと、.tfファイルのresource定義はこのようになる。

resource "conoha_container" "example" {
    name = "fuga"
}

また、*schema.ResourceDataSet関数やSetId関数を使うことで、Terraform内で扱う任意の変数やリソースIDなどを設定することができる。

Usage

オリジナルProviderの動かし方の説明も書いておく。

Install

TerraformのProviderは、terraformのバイナリファイルに組み込まれているのではなく、Providerごと別なバイナリファイルになっている。インストールはgo buildしたあとterraform-provider-<provider名>という名前でTerraformの実行ファイルと同じディレクトリ配下($GOPATH/bin配下)に配置する。要は直接go installを叩くのと一緒。そうすると、Terraformコマンドを実行した時にProviderのバイナリファイルが読み込まれる。インストール手順はこんな感じ。

$ go get github.com/tkak/conoha
$ go get github.com/tkak/terraform-provider-conoha
$ go install github.com/tkak/terraform-provider-conoha

terraform apply

あとは、.tfを作って、実行するだけ。今回作ったProviderだと以下のような.tfファイルを作成し、terraform applyコマンドを実行する。

$ vi conoha.tf
---
variable "conoha_user" {}
variable "conoha_password" {}
variable "conoha_tenant" {}

provider "conoha" {
    user = "${var.conoha_user}"
    password = "${var.conoha_password}"
    tenant = "${var.conoha_tenant}"
}

resource "conoha_container" "example" {
    name = "hoge"
}
---
$ terraform plan \
-var "conoha_user=${CONOHA_USER}" \
-var "conoha_password=${CONOHA_PASSWORD}" \
-var "conoha_tenant=${CONOHA_TENANT}"

パスワードなどのパラメータはファイルとしてバージョン管理したくないのでCONOHA_*環境変数で設定して実行する。

ちなみに、環境変数TF_LOG=1を設定すると実行時にデバッグログをはくようにすることもできる。

まとめ

Goは最近勉強しはじめたばかりで、今回作ったライブラリやProviderはまだまだ改良する点があるが、ひとまず動く物を作ることができた。実際作るのにかかった時間はそんなにはなくて、APIの調査やTerraform のコードを読んだりした時間の方がかかった。Mitchell Hashimoto先生曰くGoogle CloudのProviderは8時間しかかからなかったらしい。慣れたらかなり簡単にオリジナルProviderが作れると思う。

プラグインごとの細かい作り込みは必要だけど、いろんなクラウドサービスのProviderを用意すれば、それぞれを共通のファイル形式で一元管理できる。 そんなツールは今までありそうでなかったし、Infrastructure as Codeの痒いところに手が届くツールとして、Terraformは中々優れているものなんじゃないかなと思う。

References