Docker
itamae

Itamaeを使って開発環境&Staging環境&本番環境を構築する


はじめに

Railsアプリを例にItamaeを使って開発環境&Staging環境&本番環境を構築します。

Itamaeを使う理由としては、Chefに比べて起動が早い上に、sshやdockerに対応しているので使い勝手がいいためです。

開発環境、staging環境はDocker上に構築するため、前提としてDockerがinstallされていることとします。


itamaeの設定について


ディレクトリ構成


  • Gemfile

  • entrypoint.rb

  • nodes


    • development.json

    • staging.json

    • production.json



  • environments


    • development.rb

    • staging.rb

    • production.rb



  • roles


    • base.rb

    • development.rb

    • rails.rb



  • staging


    • keys


      • default



    • values


      • RAILS_MASTER_KEY

      • DB_PASSWORD





  • production


    • keys


      • default



    • values


      • RAILS_MASTER_KEY

      • DB_PASSWORD





  • cookbooks


    • user


      • default.rb

      • files


        • development_rsa.pub

        • staging_rsa.pub

        • production_rsa.pub





    • nginx


      • default.rb

      • templates


        • rails.conf.erb





    • env


      • default.rb

      • templates


        • conf.erb





    • start_sh


      • default.rb

      • files


        • development.erb

        • staging.erb







Chefを参考に役割ごとにDirectoryをきっています。


Gemfile


Gemfile

source 'https://rubygems.org'

gem "itamae"
gem "docker-api"
gem "itamae-secrets"
gem "itamae-plugin-recipe-rbenv"


docker-apiは、itamaeでdockerイメージを作成するために必要です。

itamae-secretsは、暗号化するために使います。

暗号化、復号に必要なkeyファイルだけをgitの管理外にすることで、秘密にしておきたい情報もcommitできるようになります。

itamae-plugin-recipe-rbenvはrbenvをinstallするための外部レシピです。


entrypoint.rb

itamaeの実行時に常に指定する実行ファイル。

-jで指定された設定ファイル(json)を元にレシピを実行するようにします。


entrypoint.rb

node["recipes"].each do |recipe|

include_recipe recipe
end


nodes

itamae実行時に指定する設定ファイルを置きます。

各Hostごとにどの設定ファイルを使うかと、どの役割のレシピを使うのかを指定します。

recipeのパスはentrypoint.rbからの相対パスで表現します。

本番はRailsのHostが2台あり、プロビジョニングに違いがあれば、production1.json,production2.jsonのように分けて、差分をここで設定します。


development.json

{

"recipes": [
"./environments/development.rb",
"./roles/base.rb",
"./roles/develop.rb",
"./roles/docker.rb"
]
}


staging.json

{

"recipes": [
"./environments/staging.rb",
"./roles/base.rb",
"./roles/web.rb",
"./roles/docker.rb"
]
}


production.json

{

"recipes": [
"./environments/production.rb",
"./roles/base.rb",
"./roles/web.rb",
]
}


environments

各環境ごとの設定を指定するファイルを置きます。

rubyなので動的に値をセットすることができます。

ファイルの指定はcookbookにおけるそのパッケージのdefault.rbからの相対パスを指定しています。


environments/development.rb

user_name = "takeshy"

node.reverse_merge!(
environment: "development",
user: {
name: user_name,
authorized_keys: "./files/development_rsa.pub",
sudo: true
},
rbenv: {
user: user_name,
version: "2.5.3",
versions: []
},
env: {
file: "./templates/conf.erb"
},
start_sh: {
file: "./templates/development.erb"
}
)


environments/staging.rb

user_name = "takeshy"

node.reverse_merge!(
environment: "staging",
user: {
name: user_name,
authorized_keys: "./files/staging_rsa.pub",
sudo: true
},
rbenv: {
user: user_name,
version: "2.5.3",
versions: []
},
env: {
file: "./templates/conf.erb"
},
nginx: {
url: "https://staging.example.com",
file: "./templates/rails.conf.erb"
},
start_sh: {
file: "./templates/staging.erb"
}
)


environments/production.rb

user_name = "takeshy"

node.reverse_merge!(
environment: "staging",
user: {
name: user_name,
authorized_keys: "./files/production_rsa.pub",
sudo: false
},
rbenv: {
user: user_name,
global: "2.5.3",
versions: ["2.5.3"],
"rbenv-default-gems": {
"default-gems": ["bundler"]
},
install: true
},
env: {
file: "./templates/conf.erb"
},
nginx: {
url: "https://example.com",
file: "./templates/rails.conf.erb"
}
)

developmentとstagingはrbenvをinstallするものの、rubyをinstallしません。

理由はrubyのビルドに時間がかかりすぎるため、itamaeの対象がDockerだった場合にread timeout reached (Docker::Error::TimeoutError)でエラーになってしまうためです。

そのため、dockerの起動時にrubyをinstallするようにしています。


roles

役割ごとにどのcookbookを使うかを指定するファイルを置きます。

簡単なレシピであれば直接書いてしまっています。


roles/base.rb

execute 'apt-get update'

%w{ git sudo openssh-server rsyslog wget gnupg curl cron build-essential libmysqlclient-dev vim language-pack-ja-base language-pack-ja
screen rsync net-tools nodejs npm libxml2-dev libxslt1-dev patch}
.each do |pkg|
package pkg
end
directory "/run/sshd"
execute 'update-locale LANG=ja_JP.UTF-8 LANGUAGE="ja_JP:ja"'

include_recipe "../cookbooks/user/default.rb"
include_recipe "../cookbooks/env/default.rb"
include_recipe "rbenv::user"



roles/web.rb

include_recipe "../cookbooks/nginx/default.rb"



roles/docker.rb

include_recipe "../cookbooks/start_sh/default.rb"

file "/home/#{node[:user][:name]}/rbenv.sh" do
owner "#{node[:user][:name]}"
group "#{node[:user][:name]}"
mode '777'
content <<EOF
#!/bin/bash
export RBENV_ROOT=/home/
#{node[:rbenv][:user]}/.rbenv
export PATH=$RBENV_ROOT/bin:$PATH
eval "$(rbenv init --no-rehash -)"
$RBENV_ROOT/bin/rbenv install -s
#{node[:rbenv][:version]}
$RBENV_ROOT/bin/rbenv global
#{node[:rbenv][:version]}
$RBENV_ROOT/shims/gem install bundler --no-ri --no-rdoc
EOF
end

dockerだった場合はdockerの起動時にrbenvを使ってrubyをinstallするようのスクリプトを生成しています。installに-sを渡すことですでにinstall済みの時はskipされます


roles/development.rb

%w{ mysql-server redis-server }.each do |pkg|

package pkg
end


stagingおよびproduction

itamae-secretsのコマンドにより自動で生成されます。

共通鍵がstaging/keys/defaultおよびproduction/keys/defaultにできるので.gitignoreに指定してcommitしないようにする必要があります。それらのkeyをitamaeを実行する環境にgitを使わずに安全に置く必要があります。

values以下は暗号化されているため、commitして問題ありません。

#staging環境用にRAILS_MASTER_KEYのデータを暗号化して保存する場合

itamae-secrets set --base=./staging RAILS_MASTER_KEY xxxxx

#production環境用にRAILS_MASTER_KEYをセットする場合

itamae-secrets set --base=./production RAILS_MASTER_KEY xxxxx

暗号化したデータはitamaeから下記のように取得できます。


itamae-secret-sample.rb

if node[:environment] == "production"

node[:secrets] = Itamae::Secrets(File.expand_path('../../production', __dir__))
else
node[:secrets] = Itamae::Secrets(File.expand_path('../../staging', __dir__))
end
puts node[:secrets]["RAILS_MASTER_KEY"]
#=> "xxxxx"


cookbooks

レシピの書き方の詳細については下記を参照ください。

https://github.com/itamae-kitchen/itamae/wiki

たとえ見なくても下記の設定を見れば、なんとなくやっていることはわかると思います。


user

Userを作成するレシピです。


cookbooks/user/default.rb

user "create user" do

username node[:user][:name]
home "/home/#{node[:user][:name]}"
shell "/bin/bash"
end

directory "/home/#{node[:user][:name]}/.ssh" do
mode '700'
owner node[:user][:name]
group node[:user][:name]
end

remote_file "/home/#{node[:user][:name]}/.ssh/authorized_keys" do
mode '700'
owner node[:user][:name]
group node[:user][:name]
source node[:user][:authorized_keys]
end

if node[:user][:sudo]
directory "/etc/sudoers.d" do
mode '750'
owner 'root'
group 'root'
end

file "/etc/sudoers.d/#{node[:user][:name]}" do
mode '440'
owner 'root'
group 'root'
content "#{node[:user][:name]} ALL=NOPASSWD: ALL"
end
end


enveronments配下で指定されたユーザ名(ここではtakeshy)のユーザを作成し、sudoの設定がtrueだった場合はsudoが使えるようにします。

filesのdevelopment_rsa.pub、staging_rsa.pub、production_rsa.pubは予めdevelopment用、staging用、production用に鍵を作成してその公開鍵を置いておきます。


nginx

nginxをinstallし、指定されたconfを設置して起動させるレシピです。


cookbooks/nginx/default.rb

package "nginx"

template "/etc/nginx/nginx.conf" do
source node[:nginx][:file]
owner "root"
group "root"
mode '644'
notifies :run, 'execute[restart nginx]'
end

directory "/var/log/nginx" do
owner node[:user][:name]
group node[:user][:name]
mode "755"
end

execute "restart nginx" do
command '/etc/init.d/nginx restart'
end


nginxをinstallし、設定ファイルをenvironments以下で指定されたファイルに書き換えてrestartさせています。restartは本当はservice 'nginx' do を使いたいのですが、dockerはsystemdが動いていないため、エラーになってしまうため、ubuntuのsystemd環境でも動作するinit.dの呼び出しにしています。


cookbooks/nginx/templates/rails.conf.erb

user <%= node[:user][:name] %>;

worker_processes 1;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;

keepalive_timeout 65;

gzip on;
gzip_http_version 1.0;
gzip_comp_level 2;
gzip_proxied any;
gzip_vary off;
gzip_types text/plain text/css application/x-javascript text/xml application/xml application/rss+xml application/atom+xml text/javascript applicati on/javascript application/json text/mathml;
gzip_min_length 1000;
gzip_disable "MSIE [1-6]\.";

client_max_body_size 4m;

server_names_hash_bucket_size 64;
types_hash_max_size 2048;
types_hash_bucket_size 64;

upstream unicorn{
server unix:/home/<%= node[:user][:name] %>/example/shared/tmp/sockets/unicorn.sock;
}

server {
listen 80;
server_name <%= node[:nginx][:url] %>;
root /home/<%=
node[:user][:name] %>/example/shared/public;
proxy_hide_header X-App-UID;

location / {
proxy_set_header Host $http_host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Client-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
if (!-f $request_filename) {
proxy_pass http://unicorn;
break;
}
}
}
}


exampleというアプリがユーザディレクトリの直下にdeployされている想定です。


env

環境変数を設定するレシピです。


cookbooks/env/default.rb

require 'itamae/secrets'

if node[:environment] == "production"
node[:secrets] = Itamae::Secrets(File.expand_path('../../production', __dir__))
elsif node[:environment] == "staging"
node[:secrets] = Itamae::Secrets(File.expand_path('../../staging', __dir__))
else
node[:secrets] = {}
end

execute "echo 'source /home/#{node[:user][:name]}/.env' > /home/#{node[:user][:name]}/.bashrc && chown #{node[:user][:name]}:#{node[:user][:name]} /home/#{node[:user][:name]}/.bashrc" do
not_if "test -e /home/#{node[:user][:name]}/.bashrc"
end

template "/home/#{node[:user][:name]}/.env" do
source node[:env][:file]
owner node[:user][:name]
group node[:user][:name]
mode '600'
end


環境変数が定義された.envというファイルを作成し、~/.bashrcから読み込まれるようにしています。

暗号化された情報をnode[:secrets]に代入することで、templateから参照できるようにしています。


cookbooks/env/templates/conf.erb

export RAILS_ENV=<%= node[:environment] %>

export RAILS_MASTER_KEY=
<%= node[:secrets]['RAILS_MASTER_KEY'] %>
export DB_PASSWORD=
<%= node[:secrets]['DB_PASSWORD'] %>
export RBENV_ROOT=
/home/<%= node[:rbenv][:user] %>/.rbenv
export PATH=
"${RBENV_ROOT}/bin:${PATH}"
eval "$(rbenv init -)"

itame-secretsを使うことで、環境変数も安全にプロビジョニングできています。


start_sh

Dockerの起動時に実行するスクリプトを設置するレシピです(development,stagingのみ)

終了するとコンテナも終了してしまうので、foregroundで起動しつづける必要があります。


cookbooks/start_sh/default.rb

template "/start.sh" do

source node[:start_sh][:file]
mode "755"
end

start.shというスクリプトファイルを実行可能なモードで/に設置しているだけです。


cookbooks/start_sh/templates/development.erb

#!/bin/sh

cron
/etc/init.d/rsyslog start
find /var/lib/mysql -type f -exec touch {} \; && service mysql start
mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `example_development` DEFAULT CHARACTER SET utf8;'
mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `circle_test` DEFAULT CHARACTER SET utf8;'
mysql -u root -e "CREATE USER IF NOT EXISTS 'admin'@'localhost';"
mysql -u root -e "GRANT ALL ON *.* TO 'admin'@'localhost' WITH GRANT OPTION;"
mysql -u admin -e "DROP USER IF EXISTS 'root'@'localhost';"
mysql -u admin -e "CREATE USER 'root'@'localhost' IDENTIFIED BY '';"
mysql -u admin -e "GRANT ALL ON *.* TO 'root'@'localhost' WITH GRANT OPTION;"
sudo -u <%= node[:rbenv][:user] %> /home/<%=[:rbenv][:user] %>/rbenv.sh
/us
r/sbin/sshd
redis-server > /dev/null 2>&1

必要なサービスを起動して、アプリで必要なDBを作成しています。

find /var/lib/mysql -type f -exec touch {} \の箇所はdockerのoverlayまわりで問題がありその対応です。MySQL does not start with overlay2 and overlay but starts with aufs #72

途中でmysqlにadminというユーザを作って、rootユーザを削除し、再度rootユーザを作成しているのは、ubuntu 18.04だとmysqlのrootアカウントはrootでしかログインできないようになっているためです。

rootユーザを再作成することで一般ユーザでもmysqlのrootアカウントが使えるようになります。

Circle CIのMySQLがDB名がcircle_testでアカウント名rootパスワードなしになっているため、それに揃えることでローカルでもCircleCIでもテストできるようにしています。

dockerイメージ作成時にできなかったrubyのinstallをrbenv.shを呼びだすことでここでしています。


cookbooks/start_sh/templates/staging.erb

#!/bin/sh

cron
/etc/init.d/rsyslog start
sudo -u <%= node[:rbenv][:user] %> /home/<%=[:rbenv][:user] %>/rbenv.sh
/e
tc/init.d/nginx start
/usr/sbin/sshd -D

developmentと同様dockerイメージ作成時にできなかったrubyのinstallをここでしています。

-Dによりsshdをフォアグラウンドで動作することで、start.shを終了させずにコンテナが起動し続けるようにしています。runコマンド自体はすぐに終了しますが、rubyがinstallされるまでは、sshがつながりません。


実行について

コンテナ上に作るユーザ名はtakeshy、本番サイトはexample.com、stagingサイトはstaging-example.comとします。


develop環境の場合(localで実行)

# itamaeでubuntu:18.04のdockerのimageを元にdevelopment:1.0のタグのついたイメージをプロビジョニングして作成

bundle exec itamae docker --image=ubuntu:18.04 --tag=development:1.0 -j nodes/development.json entrypoint.rb

# development:1.0のタグのついたdockerイメージをlocalhostのport4000をコンテナの3000(rails)に、port2022をコンテナの22(sshd)に
# 接続し、~/work以下をコンテナ上のユーザhogeのホーム直下のworkと同期させるコンテナを実行
docker run -p 4000:3000 -p 2022:22 -v ~/work:/home/takeshy/work:cached -d development:1.0 /start.sh

公式のubuntu:18.04のDockerイメージをpullしてきて、その上にitamaeでプロビジョニングを実行して、development:1.0というタグのついたDockerイメージを作成します。

Docker実行の際、Macの場合VirtualMachine経由でdockerを実行しているので、-pを使ったport forwardingを行わないとコンテナに通信ができません。

※最初のイメージを作成する時は、素のubuntuに対してsudoができるユーザ作成とopenssh-serverをinstallするだけのレシピを適用したimageを作成し、そのimageを使ってコンテナを起動し、コンテナに対してitamae sshでプロビジョニングを実行するのがおすすめです。itamaeでdockerを指定すると、失敗時にまた最初のインストールからなので試行錯誤に時間がかかってしまいます。ssh経由だとすでに完了したところは、サクっとすすめてくれるので速いです。ひと通り成功した後で、再度itamae dockerをすれば効率よくレシピを作成できます。

上記設定により、ブラウザからhttp://localhost:4000でコンテナ上のrailsサーバにアクセスでき、ssh -i ~/.ssh/development.rsa -p 2022 takeshy@localhostでsshができるようになります。

※注意 port forwardingによるアクセスのため、コンテナのIPではなく、localhostを指定する必要があります。

-vを使ってlocalのディレクトリと同期を取っているので、local上の~/work以下のファイルを編集することで、コンテナ上にも反映されるので、普段通りにコーディングすることができます。

:cachedを指定することでDocker上のファイルの読み込みを速くしていますが、特にHostとの同期にラグを感じることはないです。

mysqlサーバやredisはrailsアプリと同一のコンテナにinstallされます。


staging環境の場合(staging対象のサーバ上で実行)

外部からコンテナにアクセスできるよう、NginxでリバースProxyを用意します。

最初にnginxというディレクトリを作成し、下記ファイルを用意します


nginx/Dockerfile

FROM nginx

COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./fullchain.pem /etc/nginx/fullchain.pem
COPY ./privkey.pem /etc/nginx/privkey.pem


nginx/default.conf

server {

listen 443 ssl;
server_name staging.example.com;
ssl_certificate /etc/nginx/fullchain.pem;
ssl_certificate_key /etc/nginx/privkey.pem;
location / {
proxy_pass http://rails;
proxy_redirect off;
proxy_set_header HOST $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

※ALB(ロードバランサ)を使う場合は、上記のnginxのSSL関連の項目を消すことができます。

fullchiain.pem(SSLサーバ証明書),privkey.pem(SSLサーバ証明書とペアになる秘密鍵)もnginxデイレクトリの配下に置きます。

docker-composeの設定ファイルを用意します。


docker-compose.yml

version: '3'

services:
rails:
image: staging:1.0
command: /start.sh
nginx:
build: nginx
ports:
- "443:443"
mysql:
image: mysql:5.7
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
environment:
MYSQL_DATABASE: example_staging
MYSQL_ROOT_PASSWORD:
redis:
image: redis

environmentがキーだけの場合はitamae実行時の環境変数がセットされます。(↑の例ではMYSQL_ROOT_PASSWORD)

※mysql,redisはRDSやElastiCacheを使う場合は必要ありません。

# itamaeでubuntu:18.04のdockerのimageを元にstaging:1.0のタグのついたイメージをプロビジョニングして作成

bundle exec itamae docker --image=ubuntu:18.04 --tag=staging:1.0 -j nodes/staging.json entrypoint.rb

# railsのコンテナ、mysqldのコンテナ、redisのコンテナをそれぞれ立ち上げ、通信し合えるようにする
docker-compose up -d

/etc/hostsに下記を追記

"docker inspect コンテナID"で表示されるIP  rails-staging

ssh -i ~/.ssh/staging_rsa takeshy@rails-stagingでrailsコンテナにsshで接続できるので、capistrano等でrailsアプリをdeployします。

railsのコンテナからは、host名mysqlでmysqlサーバにhost名redisによりredisサーバにアクセスできます。

DNSでstaging.example.comにHOSTのIPが割り当てられてれば、ブラウザでhttps://staign.example.comでアクセスできるようになります。


production環境の場合(productionのマシンと接続可能なサーバ上で実行)

# itamaeで本番サーバ(rails1がサーバのアドレス)に対してプロビジョニングするとどうなるかの表示(dry-run)

bundle exec itamae ssh -h rails1 -j nodes/production.json --dry-run entrypoint.rb
# itamaeで本番サーバ(rails1がサーバのアドレス)に対して実際にプロビジョニングを実行
bundle exec itamae ssh -h rails1 -j nodes/production.json entrypoint.rb

本番は直接サーバに対して、ssh経由でプロビジョニングを実施します。

staging同様アプリのdeployはcapistrano等で実行することを想定しています。


まとめ

かつては色々なOSの環境でもプロビジョニングができることが求められていたため、プロビジョニングツールも複雑になりがちでした。

Berkshelfのような外部レシピに依存していると、ちょっと違うことをするのに複雑なコードを読む必要があり困ったりしました。

現在はDockerの流行により、どの環境でも同じOSを想定することが可能となり、外部レシピを使わないで自前でレシピを書くことが簡単にできます。

また、development環境は誰かがDockerイメージを構築すれば、他の開発者にはDockerイメージを配布するだけですぐに開発が可能となります。

全部Dockerfileにして(そのほうがイメージ作成時にキャッシュが使えて早い)、本番もDockerにできればいいのですが、まだ今の私にとっては難しい印象です。

既存のシステム構成を残しつつ、Dockerの恩恵を享受することに関して、itamaeはかなり便利だと思います。

参考:

ようやく itamae の使い方が固まってきたのでメモ - えいのうにっき