前記事: Rails: DockerでHeroku的なデプロイソリューションを構築する: 前編(翻訳)
概要
原著者の許諾を得て、CC BY-NC-SAライセンスに基づき翻訳・公開いたします。
- 英語記事: Creating a Heroku-like Deployment Solution with Docker
- 原文公開日: 2017/06/07
- 著者: Pedro Cavalheiro
- サイト: Semaphoreci.com
Rails: DockerでHeroku的なデプロイソリューションを構築する: 後編(翻訳)
Herokuライクなデプロイソリューションの構築方法を解説します。
特定のクラウドプロバイダや、Dockerに関連しないツールを必要としません。
ここまでのまとめ
これで自動デプロイの構築に必要な材料がすべて揃いました。以下は最終的なコードであり、deployer.rb
という名前でrootフォルダに保存できます。各行の動作を見てみましょう。
# deployer.rb
class Deployer
APPLICATION_HOST = '54.173.63.18'.freeze
HOST_USER = 'remoteuser'.freeze
APPLICATION_CONTAINER = 'mydockeruser/application-container'.freeze
APPLICATION_FILE = 'application.tar.gz'.freeze
ALLOWED_ACTIONS = %w(deploy).freeze
APPLICATION_PATH = 'blog'.freeze
def initialize(action)
@action = action
abort('Invalid action.') unless ALLOWED_ACTIONS.include? @action
end
def execute!
public_send(@action)
end
def deploy
check_changed_files
copy_gemfile
compress_application
build_application_container
push_container
remote_deploy
end
private
def check_changed_files
return unless `git -C #{APPLICATION_PATH} status --short | wc -l`
.to_i.positive?
abort('Files changed, please commit before deploying.')
end
def copy_gemfile
system("cp #{APPLICATION_PATH}/Gemfile* .")
end
def compress_application
system("tar -zcf #{APPLICATION_FILE} #{APPLICATION_PATH}")
end
def build_application_container
system("docker build -t #{APPLICATION_CONTAINER}:#{current_git_rev} .")
end
def push_container
system("docker push #{APPLICATION_CONTAINER}:#{current_git_rev}")
end
def remote_deploy
system("#{ssh_command} docker pull "\
"#{APPLICATION_CONTAINER}:#{current_git_rev}")
system("#{ssh_command} 'docker stop \$(docker ps -q)'")
system("#{ssh_command} docker run "\
"--name #{deploy_user} "\
"#{APPLICATION_CONTAINER}:#{current_git_rev}")
end
def current_git_rev
`git -C #{APPLICATION_PATH} rev-parse --short HEAD`.strip
end
def ssh_command
"ssh #{HOST_USER}@#{APPLICATION_HOST}"
end
def git_user
`git config user.email`.split('@').first
end
def deploy_user
user = git_user
timestamp = Time.now.utc.strftime('%d.%m.%y_%H.%M.%S')
"#{user}-#{timestamp}"
end
end
if ARGV.empty?
abort("Please inform action: \n\s- deploy")
end
application = Deployer.new(ARGV[0])
begin
application.execute!
rescue Interrupt
puts "\nDeploy aborted."
end
それでは1つずつ手順を追ってみましょう。
APPLICATION_HOST = '54.173.63.18'.freeze
HOST_USER = 'remoteuser'.freeze
APPLICATION_CONTAINER = 'mydockeruser/application-container'.freeze
APPLICATION_FILE = 'application.tar.gz'.freeze
ALLOWED_ACTIONS = %w(deploy).freeze
APPLICATION_PATH = 'blog'.freeze
ここでは値の重複を避けるためにいくつかの定数をコードで定義しています。APPLICATION_HOST
は実行するサーバーのリモートIPアドレス、HOST_USER
はリモートサーバーのユーザー名、APPLICATION_CONTAINER
はアプリをラップするコンテナの名前です。APPLICATION_FILE
は圧縮したアプリのファイル名なので名前は自由に変えられます。ALLOWED_ACTIONS
は許可する操作の配列であり、どの操作を利用可能にするかを簡単に定義できます。最後のAPPLICATION_PATH
はアプリへのパスです。今回の例ではblog
としています。
def initialize(action)
@action = action
abort('Invalid action.') unless ALLOWED_ACTIONS.include? @action
end
def execute!
public_send(@action)
end
上は、(ALLOWED_ACTIONS
で)利用できる各メソッドのバリデーションと呼び出しを行うラッパーです。これを用いることで、コードをリファクタリングする必要なしに、呼び出し可能な新しいメソッドを簡単に追加できます。
def deploy
check_changed_files
copy_gemfile
compress_application
build_application_container
push_container
remote_deploy
end
上はデプロイ手順です。これらのメソッドは先の例とほぼ同じですが、わずかな変更があります。それぞれの手順を見てみましょう。
def check_changed_files
return unless `git -C #{APPLICATION_PATH} status --short | wc -l`
.to_i.positive?
abort('Files changed, please commit before deploying.')
end
アプリのデプロイにはローカルのコードを使っているので、ファイルが変更されているかどうかをチェックして、変更がある場合はデプロイを行わないようにするのがよい方法です。この手順ではファイルの作成や変更を検出するのにgit status --short
を使っています。-C
フラグはgitでチェックする対象(この例ではblog)を定義します。不要ならこの手順を取り除くこともできますが、おすすめしません。
def copy_gemfile
system("cp #{APPLICATION_PATH}/Gemfile* .")
end
上は、デプロイのたびにblogのルートディレクトリにあるGemfileとGemfile.lockをコピーします。これによって、デプロイが完了する前にすべてのgemがインストールされるようになります。
def compress_application
system("tar -zcf #{APPLICATION_FILE} #{APPLICATION_PATH}")
end
メソッド名からわかるとおり、この手順ではアプリ全体を圧縮して1つのファイルにします。このファイルは後でコンテナに含められます。
def build_application_container
system("docker build -t #{APPLICATION_CONTAINER}:#{current_git_rev} .")
end
このメソッドは、コンテナのビルド手順を実行します。このときに依存ライブラリやgemをすべてインストールします。Gemfileが変更されるたびにDockerでそのことが検出されてインストールが行われるので、依存ライブラリの更新を気にする必要はありません。依存ライブラリが変更されるたびに多少時間がかかります。変更が何もない場合、Dockerはキャッシュを使うので手順の実行はほぼ瞬時に完了します。
def push_container
system("docker push #{APPLICATION_CONTAINER}:#{current_git_rev}")
end
このメソッドは、Docker Registryに新しいコンテナをアップロードします。最新のコミットハッシュをgitで取得しているこのcurrent_git_rev
メソッドにご注目ください。各デプロイの識別にはこのコミットハッシュを使います。アップロードしたコンテナはすべてDockerHubコンソールで確認できます。
def remote_deploy
system("#{ssh_command} docker pull "\
"#{APPLICATION_CONTAINER}:#{current_git_rev}")
system("#{ssh_command} 'docker stop \$(docker ps -q)'")
system("#{ssh_command} docker run "\
"--name #{deploy_user} "\
"#{APPLICATION_CONTAINER}:#{current_git_rev}")
end
ここでは以下の3つを行っています。
docker pull
: リモートサーバーにアップロードしたコンテナをpull
します。ssh_command
メソッド呼び出しは、リモートコマンドの送信が必要になるたびに、コードの重複を避けるための単なるラッパーです。docker stop $(docker ps -q)
: 新しいコンテナを実行するときにポート番号が衝突しないようにするため、実行中のコンテナをすべて停止します。docker run
: 正しいタグを与えて新しいコンテナを起動し、現在のgitユーザーとタイムスタンプに基づいて名前を付けます。これは、現在実行中のアプリをデプロイしたユーザーを知る必要がある場合に便利です。名前を確認するには、リモートサーバーでdocker ps
コマンドを入力します。
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
01d777ef8d9a mydockeruser/application-container:aa2da7a "/bin/sh -c 'cd /t..." 10 minutes ago Up 10 minutes 0.0.0.0:3000->3000/tcp mygituser-29.03.17_01.09.43
if ARGV.empty?
abort("Please inform action: \n\s- deploy")
end
application = Deployer.new(ARGV[0])
begin
application.execute!
rescue Interrupt
puts "\nDeploy aborted."
end
上はCLIから引数を受け取って、アプリのデプロイを実行します。Ctrl-Cでデプロイをキャンセルすると、rescue
ブロックでわかりやすいメッセージが表示されます。
アプリをデプロイする
この時点でのフォルダ構造は次のようになっているはずです。
.
├── blog
│ ├── app
│ ├── bin
... (application files and folders)
├── deployer.rb
├── Dockerfile
次は、アプリを実行してデプロイしましょう。
$ ruby deployer.rb deploy
コマンドが実行されるたびに出力が表示されます。すべての出力結果は、最初の例の手動実行とほぼ同じです。
Sending build context to Docker daemon 4.846 MB
Step 1/9 : FROM ruby:2.3.1-slim
---> e523958caea8
Step 2/9 : COPY Gemfile* /tmp/
---> Using cache
---> f103f7b71338
Step 3/9 : WORKDIR /tmp
---> Using cache
---> f268a864efbc
Step 4/9 : RUN gem install bundler && apt-get update && apt-get install -y build-essential libsqlite3-dev rsync nodejs && bundle install --path vendor/bundle
---> Using cache
---> 7e9c77e52f81
Step 5/9 : RUN mkdir -p /app/vendor/bundle
---> Using cache
---> 1387419ca6ba
Step 6/9 : WORKDIR /app
---> Using cache
---> 9741744560e2
Step 7/9 : RUN cp -R /tmp/vendor/bundle vendor
---> Using cache
---> 5467eeb53bd2
Step 8/9 : COPY application.tar.gz /tmp
---> b2d26619a73c
Removing intermediate container 9835c63b601b
Step 9/9 : CMD cd /tmp && tar -xzf application.tar.gz && rsync -a blog/ /app/ && cd /app && RAILS_ENV=production bundle exec rake db:migrate && RAILS_ENV=production bundle exec rails s -b 0.0.0.0 -p 3000
---> Running in 8fafe2f238f1
---> c0617746e751
Removing intermediate container 8fafe2f238f1
Successfully built c0617746e751
The push refers to a repository [docker.io/mydockeruser/application-container]
e529b1dc4234: Pushed
08ee50f4f8a7: Layer already exists
33e5788c35de: Layer already exists
c3d75a5c9ca1: Layer already exists
0f94183c9ed2: Layer already exists
b58339e538fb: Layer already exists
317a9fa46c5b: Layer already exists
a9bb4f79499d: Layer already exists
9c81988c760c: Layer already exists
c5ad82f84119: Layer already exists
fe4c16cbf7a4: Layer already exists
aa2da7a: digest: sha256:a9a8f9ebefcaa6d0e0c2aae257500eae5d681d7ea1496a556a32fc1a819f5623 size: 2627
aa2da7a: Pulling from mydockeruser/application-container
1fad42e8a0d9: Already exists
5eb735ae5425: Already exists
b37dcb8e3fe1: Already exists
50b76574ab33: Already exists
c87fdbefd3da: Already exists
f1fe764fd274: Already exists
6c419839fcb6: Already exists
4abc761a27e6: Already exists
267a4512fe4a: Already exists
18d5fb7b0056: Already exists
219eee0abfef: Pulling fs layer
219eee0abfef: Verifying Checksum
219eee0abfef: Download complete
219eee0abfef: Pull complete
Digest: sha256:a9a8f9ebefcaa6d0e0c2aae257500eae5d681d7ea1496a556a32fc1a819f5623
Status: Downloaded newer image for mydockeruser/application-container:aa2da7a
01d777ef8d9a
c3ecfc9a06701551f31641e4ece78156d4d90fcdaeb6141bf6367b3428a2c46f
出力結果は、ハッシュやDockerキャッシュの違いによって異なることがあります。最後に、上のように2つのハッシュが出力されます。
01d777ef8d9a
c3ecfc9a06701551f31641e4ece78156d4d90fcdaeb6141bf6367b3428a2c46f
1つ目の短いハッシュは、停止したコンテナのハッシュです。最後の長いハッシュは、新たに実行中のコンテナのハッシュです。
これで、リモートサーバーのIPアドレスにアクセスするとアプリが実行されていることを確認できます。
Semaphoreで継続的デリバリー(CD)する
本チュートリアルのスクリプトを使って、アプリをSemaphoreに自動デプロイできます。やり方を見てみましょう。
最初に、「Project Settings」でDocker support
付きのプラットフォームを指定します。
SemaphoreのProjectページで、「Set Up Deployment」をクリックします。
「Generic Deployment」を選択します。
「Automatic」を選択します。
Gitのブランチを選択します(普通はmaster
)。
ここではアプリをデプロイしたいだけなので、ローカルコンピュータで実行するときと同じ方法でデプロイスクリプトを実行します。
rbenv global 2.3.1
docker-cache restore
ruby deployer.rb deploy
docker-cache snapshot
2つのdocker-cache
コマンドにご注目ください。これらがビルドしたイメージを取り出しを行うので、ゼロからビルドする必要はありません。ローカルでの実行と同様、最初は少し時間がかかりますが、次回からは速くなります。詳しくはSemaphoreの公式ドキュメントをご覧ください。
また、rbenv global 2.3.1
コマンドをメモしておきましょう。これは、スクリプトの実行に必要な現在のRubyのバージョンを設定するためのものです。別の言語を使う場合は、必要な環境を設定する必要があります。
次の手順では、リモートサーバーへのアクセスに使うSSHキーのアップロード(必要な場合)と、新しいサーバーへの名前付けを行っています。完了すると、コードをmaster
ブランチにpush
するたびにこのスクリプトが実行され、定義済みのリモートサーバーにアプリがデプロイされます。
その他の自動化可能なコマンド
この後のセクションでは、便利な自動化コマンドをいくつかご紹介します。
現在のバージョン
現在実行中のアプリのバージョンをトラックするには、コンテナのTagに情報を記述します。
現在実行中のバージョンを取り出すには、以下のコードが必要です。
def current
remote_revision = `#{ssh_command} docker ps | grep -v CONTAINER | awk '{print $2}' | rev | cut -d: -f1 | rev`.strip
abort('No running application.') if remote_revision == ''
current_rev = `git show --ignore-missing --pretty=format:'%C(yellow)%h\
%C(blue)<<%an>> %C(green)%ad%C(yellow)%d%Creset %s %Creset'\
#{running_revision} | head -1`.strip
if current_rev.empty?
puts 'Local revision not found, please update your master branch.'
else
puts current_rev
end
deploy_by = `#{ssh_command} docker ps --format={{.Names}}`
puts "Deploy by: #{deploy_by}"
end
各行の動作について解説します。
remote_revision = `#{ssh_command} docker ps | grep -v CONTAINER | awk '{print $2}' | rev | cut -d: -f1 | rev`.strip
上のコマンドは以下を行います。
docker ps
でリモートコンテナのステータス出力を取得grep -v CONTAINER
で出力からヘッダを除去awk '{print $2}'
で2番目のカラム(image name:tag)を取得- 残りのコマンドでimage nameと
:
を削除し、残りの部分とコミットハッシュを返す - 返された文字列の最終行の改行を
.strip
で削除
abort('No running application.') if remote_revision == ''
コンテナが1つも実行されていない場合や、コミットが1つも見つからない場合はコマンド実行をやめます。
current_rev = `git show --ignore-missing --pretty=format:'%C(yellow)%h\
%C(blue)<<%an>> %C(green)%ad%C(yellow)%d%Creset %s %Creset'\
#{running_revision} | head -1`.strip
このコマンドは、git log
にマッチするコンテナハッシュを検索して書式を整えます。
if current_rev.empty?
puts 'Local revision not found, please update your master branch.'
else
puts current_rev
end
このコミットが現在のgit history
にない場合、ユーザーにリポジトリの更新を促します。これは、新しいコミットがローカルコピーからまだrebase
されていない場合に発生することがあります。コミットがある場合は、ログ情報を出力します。
deploy_by = `#{ssh_command} docker ps --format={{.Names}}`
このコマンドは、現在実行中のコンテナ名を返します。コンテナ名にはユーザー名とタイムスタンプが含まれます。
puts "Deploy by: #{deploy_by}"
上のコマンドは、デプロイを行ったユーザーとタイムスタンプを出力します。
ログ
多くのアプリはログを出力するので、場合によってはログの面倒も見なければなりません。Dockerに組み込まれているログシステムを使うと、シンプルなSSH接続でアプリのログに簡単にアクセスできるようになります。
アプリからログを出力するには、以下を入力します。
def logs
puts 'Connecting to remote host'
system("#{ssh_command} 'docker logs -f --tail 100 \$(docker ps -q)'")
end
docker logs
コマンドは、アプリで生成されたログをすべて出力します。-f
フラグは、接続を保持してすべてのログをストリームとして読み出せるようにします。--tail
フラグは、出力する古いログの最大行数を指定します。最後の$(docker ps -q)
は、リモートホストで実行中のコンテナごとにIDを返します。今はアプリを実行しているだけなので、コンテナをすべて取り出しても問題ありません。
メモ: 本記事のサンプルアプリはすべてのログをファイルに書き込むので、Dockerにはログを一切出力しません。この振る舞いは、アプリの起動時にRAILS_LOG_TO_STDOUT=true
環境変数で変更できます。
Dockerのインストールとログイン
新しいホストでは、必要なインストールや設定をsetup
コマンド一発でできるようにすると便利です。
インストールとログインの2つの手順を完了させます。
def docker_setup
puts 'Installing Docker on remote host'
system("#{ssh_command} -t 'wget -qO- https://get.docker.com/ | sh'")
puts 'Adding the remote user to Docker group'
system("#{ssh_command} 'sudo usermod -aG docker #{HOST_USER}'")
puts 'Adding the remote user to Docker group'
system("#{ssh_command} -t 'docker login}'")
end
各コマンドの動作について解説します。
system("#{ssh_command} -t 'wget -qO- https://get.docker.com/ | sh'")
このコマンドはDockerのインストールスクリプトを実行します。リモートユーザーのパスワード入力を促すには-t
フラグが必要です。パスワード入力を求められたら入力します。
system("#{ssh_command} 'sudo usermod -aG docker #{HOST_USER}'")
このコマンドは、Dockerグループにリモートユーザーを追加します。これは、sudo
せずにdocker
コマンドを実行する場合に必要です。
system("#{ssh_command} -t 'docker login'")
更新されたアプリをダウンロードするためにログインが必要なので、このコマンドが必要になります。-t
フラグは、ログイン入力できるようにするためのものです。
ロールバック
新しいアプリの実行で何か問題が起きたら、直前のバージョンにいつでもロールバックできることが重要です。Dockerコンテナのアプローチを用いたことで、デプロイされたすべてのバージョンがホスト上に保存されているので、即座にロールバックを開始できます。
次のコードスニペットをご覧ください。
def rollback
puts 'Fetching last revision from remote server.'
previous_revision = `#{ssh_command} docker images | grep -v 'none\|latest\|REPOSITORY' | awk '{print $2}' | sed -n 2p`.strip
abort('No previous revision found.') if previous_revision == ''
puts "Previous revision found: #{previous_revision}"
puts "Restarting application!"
system("#{ssh_command} 'docker stop \$(docker ps -q)'")
system("#{ssh_command} docker run --name #{deploy_user} #{APPLICATION_CONTAINER}:#{previous_revision}")
end
各手順の動作について見てみましょう。
puts 'Fetching last revision from remote server.'
previous_revision = `#{ssh_command} docker images | grep -v 'none\|latest\|REPOSITORY' | awk '{print $2}' | sed -n 2p`.strip
abort('No previous revision found.') if previous_revision == ''
このコマンドは、リモートホスト上にあるすべてのDockerイメージの中から直前のコンテナtag
をgrepします。このタグはgitコミットの短いハッシュになっていて、アプリのロールバックを参照するときに使われます。直前のDockerイメージがない場合は、ロールバックをやめます。
system("#{ssh_command} 'docker stop \$(docker ps -q)'")
このコマンドは、実行中のコンテナをすべてシャットダウンして、直前のコンテナを起動できるようにします。
system("#{ssh_command} docker run --name #{deploy_user} #{APPLICATION_CONTAINER}:#{previous_revision}")
このコマンドは、直前の手順で見つかったタグを用いてアプリを起動します。デプロイメソッド(deploy_user
)で使われているのと同じ命名ルールを利用できます。
まとめ
本チュートリアルのすべての手順を行うと、ソフトウェアをデプロイする自動ツールが完全に動くようになるはずです。このツールは、アプリを簡単にデプロイできなければならないが、Herokuなどの自動化された環境にホスティングできない場合に便利です。
このツールが有用だとお思いいただけましたら、お気軽に本チュートリアルを共有してください。疑問点などがございましたら、ぜひ元記事にコメントをどうぞ。
皆さまが楽しくリリースできますように。
追伸: Dockerを用いた継続的デリバリー(CD)にご関心がおありでしたら、SemaphoreのDocker platformをぜひチェックしてください。タグ付きのDockerイメージのレイヤキャッシュを完全にサポートしています。