Rails: DockerでHeroku的なデプロイソリューションを構築する: 後編(翻訳)

前記事: Rails: DockerでHeroku的なデプロイソリューションを構築する: 前編(翻訳)

概要

原著者の許諾を得て、CC BY-NC-SAライセンスに基づき翻訳・公開いたします。

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イメージのレイヤキャッシュを完全にサポートしています。

関連記事

Rails: DockerでHeroku的なデプロイソリューションを構築する: 前編(翻訳)

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好きで、Goで書かれたRubyライクなGoby言語のメンテナーでもある。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ