Docker + Capistrano3で簡単にWebアプリをデプロイする

 2016/03/08
このエントリーをはてなブックマークに追加

こんにちは。@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

なお、より詳しいソースは、こちらのレポジトリを見ていただくと良いと思います。

それでは。

 2016/03/08
このエントリーをはてなブックマークに追加

サイト内検索


著作

寄稿

Latest post: