Docker + Capistrano3で簡単にWebアプリをデプロイする
こんにちは。@ryuzeeです。
アプリケーションのデプロイを楽にするためにDockerを使いたいけど、別にクラスタは必要ない規模だったりクラスタの管理もしたくないという人は多いのではないかと思います。 そこで、今回は、DockerとCapistrano3を組み合わせて単にデプロイを楽にする方法を紹介します。
構成図
まず今回の構成図はこんな感じです。AWS上での構成例になっていますが別にどの環境でもあまり関係ない普通のWebアプリケーションを想定してください。
実現したい要件
次に実現する要件です。特に変わったことはありません。
- いつも同じ方式でデプロイする
- ダウンタイムなしでデプロイする
- デプロイに失敗したら簡単にロールバックできるようにする
- サーバが増えてもデプロイの方式は変えなくて済むようにする
- サーバを再起動してもサービスは自動で復旧する
方式
では方式を見ていきましょう。
WebアプリケーションのDockerイメージ化
今回は、Dockerを使うので、WebアプリケーションをDockerイメージ化します。今回の構成では、複数コンテナ間の連携はないので簡単です。 なお、デプロイにおけるベストプラクティスとして、開発環境やステージング環境でテストした成果物(Artifacts)を本番に反映するときに何らかの変更をするべきではない、というものがあります。 したがってデータベースへの接続情報などをはじめとする環境固有の情報は全てコンテナ起動時に引数(環境変数)を使って渡せるようにしてください(言わずもがなですが)。
また、イメージを作成する際は、必ずイメージに何らかのバージョン情報をタグとして付与するようにします。latestタグしかないとデプロイするイメージを指定したりロールバックするのが困難になります。 僕の場合は、Railsアプリケーションにバージョン情報を持たせ、イメージをビルドする際にそのバージョンを反映するようにしました。以下は、それを行なうRakefileの例です(Build Onceを実現しつつ、タグを複数つけようとしているので若干複雑にはなっています)。
# -*- coding: utf-8 -*-
require "#{File.dirname(__FILE__)}/lib/oss/version"
require 'open3'
task :default => :build
task :build do
cmd = "docker build -q -t ryuzee/slidehub:latest . 2>/dev/null | awk '/Successfully built/{print $NF}'"
o, e, _s = Open3.capture3(cmd)
if o.chomp! == '' || e != ''
raise 'Failed to build Docker image...'
end
cmd = "docker tag -f #{o} ryuzee/slidehub:#{SlideHub::VERSION}"
o, e, _s = Open3.capture3(cmd)
if o.chomp! == '' || e != ''
raise 'Failed to add version tag to Docker image...'
end
end
task :push do
cmd = 'docker push ryuzee/slidehub:latest'
sh cmd
cmd = "docker push ryuzee/slidehub:#{SlideHub::VERSION}"
sh cmd
end
Capistrano3を使って外部からコンテナを起動し切り替える
さて、単にサーバでコンテナを起動するだけであれば、Capistrano3で、単にdocker run
コマンドを実行すればOKです。 たとえば、Capistrano3のタスクは以下のような感じになります。おおよその処理の流れとしては以下になります(ただしこのやり方には問題があるので後述)。
- 必要なイメージをpullしてくる
- –envオプションを使ってコンテナを起動する
- コンテナが起動しても、コンテナの中のプロセス(Rails)がすぐに起動してくるとは限らない(特に遅いサーバ)し、エラーになる可能性もあるので、curlを使って指定回数HTTPのステータスをチェックする
- 一定回数の間にステータスコード200が返ってくればOKそうなので、Nginxのproxy_passの設定を書き換えて、Nginxをリロードする
- 最後にコンテナが一杯動いていると問題なので、今起動したものとその前に起動していたものを残して強制的に削除する
# 前略
desc 'deploy'
task :deploy do
on roles(:container) do
execute 'sudo docker pull ryuzee/slidehub:latest'
prefix = DateTime.now.strftime('%Y%m%d%H%M%s')
cmd = <<"EOS"
docker run -d \
--env OSS_DB_NAME=#{fetch(:oss_db_name)} \
--env OSS_DB_USERNAME=#{fetch(:oss_db_username)} \
--env OSS_DB_PASSWORD=#{fetch(:oss_db_password)} \
--env OSS_DB_URL=#{fetch(:oss_db_url)} \
-P --name slidehub#{prefix} ryuzee/slidehub
EOS
container_id = capture("sudo #{cmd}")
port = capture("sudo docker port #{container_id} 3000").to_s.split(':')[1]
puts port
# confirm running
cmd = "curl -LI http://127.0.0.1:#{port} -o /dev/null -w '%{http_code}\\n' -s"
cnt = 0
loop do
execute "echo 'sleep 20 sec...'"
sleep 20
cnt += 1
if cnt == 10
error = Exception.new('An error that should abort and rollback deployment')
raise error
end
begin
container_status = capture(cmd).to_i
if container_status == 200
break
end
rescue Exception => e
puts e.inspect
end
end
data = { port: port }
template 'nginx_default.erb', '/tmp/default', data, true
execute 'sudo mv /tmp/default /etc/nginx/sites-available/default && sudo service nginx reload'
containers = capture('sudo docker ps -q').to_s.split("\n")
containers.shift
containers.shift
puts containers.inspect
containers.each do |c|
execute "sudo docker rm -f #{c}"
end
end
end
end
なお、この方式には問題があって、サーバが再起動してしまった時の考慮や新しくサーバを増やしたときの考慮ができていません。次はそれを検討しましょう。
再起動などにも対応するために起動処理をホスト側に移す
上の例には問題があるのは説明したとおりで、再起動した場合や新たにサーバを追加した場合を考慮しましょう。 そうすると、以下のような方式に変わります。
- そのコンテナを動かすサーバにはあらかじめ環境変数を設定しておく。Ubuntuであれば/etc/environmentあたりに設定する。もしくはetcdとかを使っても良い(が小規模環境だと面倒)。
- AWSを使っているならAmazon EC2の起動時にUser-data経由で環境変数を設定してもOK
- コンテナを動かすためのスクリプトをサーバ側にインストールしておく。これをサーバの起動時に動かしたり、デプロイ時にCapistrano3経由でキックする。
これを踏まえて以下のような実装になります。
コンテナを動かすためのスクリプト
- 今回はbashで作っているが別にPerlでもPythonでもなんでもホスト側に入っている言語ならOK
- コンテナを動かすホスト側に配置する。場所はどこでも良い(たとえば、/usr/local/bin/run_app.sh)
- このスクリプト自体の配置をCapistrano3のセットアップタスクで配置してもOK。
- スクリプトでは引数にコンテナのタグを指定できるようにしておくとロールバックしやすい
#!/bin/bash
. /etc/environment
TAG='latest'
if [ -z $1 ]; then
/usr/bin/docker pull ryuzee/slidehub:$TAG
else
TAG=$1
/usr/bin/docker pull ryuzee/slidehub:$TAG
if [ $? -ne 0 ]; then
TAG='latest'
/usr/bin/docker pull ryuzee/slidehub:$TAG
fi
fi
PREFIX=`date +%Y%m%d%H%M%s`
CONTAINER_ID=`/usr/bin/docker run -d \
--env OSS_DB_NAME=$OSS_DB_NAME \
--env OSS_DB_USERNAME=$OSS_DB_USERNAME \
--env OSS_DB_PASSWORD=$OSS_DB_PASSWORD \
--env OSS_DB_URL=$OSS_DB_URL \
-P --name slidehub$PREFIX ryuzee/slidehub:$TAG`
echo "Container ID is $CONTAINER_ID"
PORT_STR=`docker port $CONTAINER_ID 3000`
IFS=':'
set -- $PORT_STR
PORT=$2
echo "Listening port is $PORT"
CNT=0
while :
do
echo 'Confirming the container is listening...'
CNT=`expr $CNT + 1`
if [ $CNT -gt 20 ]
then
echo 'Failed to launch container...'
exit 1
fi
STATUS_CODE=`curl -LI http://127.0.0.1:${PORT} -o /dev/null -w '%{http_code}\\n' -s`
echo $STATUS_CODE
if [ $STATUS_CODE == 200 ]
then
break
fi
sleep 20
done
sed -i -e "s/proxy_pass http:\/\/127\.0\.0\.1:[0-9]*;/proxy_pass http:\/\/127.0.0.1:${PORT};/g" /etc/nginx/sites-enabled/default
service nginx reload
IFS=$'\n'
CONTAINERS=(`sudo docker ps -qa --filter "name=slidehub[0-9]{22,}"`)
n=0
for line in "${CONTAINERS[@]}"; do
n=`expr $n + 1`
if test $n -gt 2 ; then
echo "Deleting old container $line..."
docker rm -f "$line"
fi
done
exit 0
起動スクリプトの設定
上記のスクリプトがサーバの起動時にも実行されるようにしておきます。 以下の例は、UbuntuのUpstartを使った例です。 /etc/initの直下に適当な名前(拡張子が.confである必要はあります)で以下のようなファイルを配置します。 なお、respawnが設定されていると、何度でも実行してしまうので、respawnは指定しないでください。
description "SlideHub container"
author "Ryuzee"
start on filesystem and started docker
stop on runlevel [!2345]
script
bash /usr/local/bin/run_app.sh
end script
Capistranoからデプロイの実行
ここまでやれば、Capistrano3からのデプロイが簡単になります。タスクは以下のようになるでしょう。
set :image_version, ENV['VERSION'] || "latest"
# (中略)
desc 'deploy'
task :deploy do
on roles(:container) do
execute "sudo bash /usr/local/bin/run_app.sh #{fetch(:image_version)}"
end
end
バージョン(というかタグ)を指定してデプロイしたければ、以下のような形で指定すればOKです。
VERSION=1.0.0 bundle exec cap production container:deploy
なお、より詳しいソースは、こちらのレポジトリを見ていただくと良いと思います。
それでは。