読者です 読者をやめる 読者になる 読者になる

capistrano3のコードを読んで仕組みを理解する

ruby capistrano

メモです。

はじめに

capistrano3の概要を知るには以下の記事がとても参考になる。

業務での使用も経て何となく分かったつもりだったが、デフォルトで色々やってくれるのが逆に少し気持ち悪かった。
内部を理解しないことには気持ち悪さは解消されそうにないのでコードを読んだ。
だいぶ理解したものの、すぐに忘れそうなのでメモしておく。

対象

  • capistrano3について何となく知っている。使ったことがある
  • capistranoの内部でどういう風に処理が走っているかは知らない
  • capistranoのバージョンは3.4.0

長くなったので、各項目のまとめだけ読むと手っ取り早いと思う。

1. cap install

入り口としてcap installが何故実行出来るのか、から。
bin/capの中を覗く。

require 'capistrano/all'
Capistrano::Application.new.run

capistrano/allを読み込みCapistrano::Applicationを実行しているのみ。

1.1. capistrano/all

require 'capistrano/version'
require 'capistrano/version_validator'
require 'capistrano/i18n'
require 'capistrano/dsl'
require 'capistrano/application'
require 'capistrano/configuration'
  • dsl
    • invokeとかonとかrun_locallyとかのDSL定義
  • application
    • 後述
  • configuration
    • setとかfetchとか設定系のDSL定義

ここは特筆することなさそう。

1.2. capistrano/application

Rake::Applicationを継承。bin/capにあったCapistrano::Application.new.runのクラス。
クラスがインスタンス化されるときの処理(initialize)が以下のようになっている。
自前のCapfileに加えて、lib/CapfileRakefileとして読み込まれる。

    def initialize
      super
      @rakefiles = %w{capfile Capfile capfile.rb Capfile.rb} << capfile
    end

    # allows the `cap install` task to load without a capfile
    def capfile
      File.expand_path(File.join(File.dirname(__FILE__),'..','Capfile'))
    end

なおrunはRakeに処理を委譲しているだけ。

    def run
      Rake.application = self
      super
    end

1.3. lib/Capfile

cap installした時に作られるCapfileではなくライブラリ側のCapfile

include Capistrano::DSL
require 'capistrano/install'

lib/capistrano/install.rbの定義は1行だけ。

load File.expand_path(File.join(File.dirname(__FILE__),'tasks/install.rake'))

このあと見て行くsetupdeployでもほとんど同じで、最終的に.rakeファイルを読み込むようになっている。

`lib/capistrano/tasks/install.rake`
desc 'Install Capistrano, cap install STAGES=staging,production'
task :install do
    # ...
end

:installcap install時の処理が書かれている。
つまり色々とファイルを自動生成してくれる。

1.4. cap installのまとめ

  • bin/capcapistrano/allを読み込み、Cap用のRakeを実行する
  • capistrano/allにより基本的なDSLが定義される
    • 中で読まれるcapistrano/applicationによりライブラリ側のCapfileが読まれる
    • ライブラリのCapfileにはinstallだけが定義されている
      • なのでcap installが実行出来る

cap installすると生成されるCapfileは不要なコメントアウト行を除くと以下の通り。

# Load DSL and set up stages
require 'capistrano/setup'

# Include default deployment tasks
require 'capistrano/deploy'

# Load custom tasks from `lib/capistrano/tasks` if you have any defined
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }

setupdeployについて読み進めていく。
(lib/capistrano/tasks/*.rakeについてはコメントの通りカスタム定義なので読むところはなし)

2. capistrano/setup

include Capistrano::DSL

namespace :load do
  task :defaults do
    load 'capistrano/defaults.rb'
  end
end

stages.each do |stage|
  Rake::Task.define_task(stage) do
    set(:stage, stage.to_sym)

    invoke 'load:defaults'
    load deploy_config_path
    load stage_config_path.join("#{stage}.rb")
    load "capistrano/#{fetch(:scm)}.rb"
    I18n.locale = fetch(:locale, :en)
    configure_backend
  end
end

require 'capistrano/dotfile'

処理の流れは何となく読み取れる。

  • stage(たぶんproductionとかstagingとか)ごとの設定を読み込んでいる
  • defaultsっていうのを読み込んでいる。きっとデフォルト設定だろう
  • scmの定義を読み込んでいる
  • その他なんか設定している

もう少しコードを読んでみる。

2.1. stage(たぶんproductionとかstagingとか)ごとの設定を読み込んでいる

いくつかプロパティが出てくるが、これらはどこで定義されているか。

stages

前述のcapistrano/dslにて読み込まれるlib/capistrano/dsl/stages.rbに定義されている。

      def stages
        Dir[stage_definitions].map { |f| File.basename(f, '.rb') }
      end

      def stage_definitions
        stage_config_path.join('*.rb')
      end

stage_config_pathlib/capistrano/dsl/paths.rbに定義されている。

      def stage_config_path
        Pathname.new fetch(:stage_config_path, 'config/deploy')
      end

:stage_config_pathが設定されていればそのパス、設定されていなければconfig/deployのパスを取得している。

cap installするとconfig/deploy/{production,staging}.rbを作成してくれるので、デフォルトではこれらが取得される。
File.basenameなので結果としては['production', 'staging']という値がstagesの戻り値になっているはず。

deploy_config_path

上記のstage_config_pathと同じくlib/capistrano/dsl/paths.rbに定義されている。

      def deploy_config_path
        Pathname.new fetch(:deploy_config_path, 'config/deploy.rb')
      end

こちらもcap installで作られるconfig/deploy.rbがデフォルトである。

なおsetupのコードを再び見ると、loadの順番は次のようになっている。

    load deploy_config_path
    load stage_config_path.join("#{stage}.rb")

config/deploy.rbを読み込んでからconfig/deploy/*.rbを読み込む。
なので両者で同じ定義をしているとconfig/deploy/*.rbが勝ちそうな気がする。試していない。

2.2. defaultsっていうのを読み込んでいる

その名の通り変数のデフォルト値を定義している。

set_if_empty :scm, :git
set_if_empty :branch, :master
set_if_empty :deploy_to, -> { "/var/www/#{fetch(:application)}" }
set_if_empty :tmp_dir, "/tmp"

set_if_empty :default_env, {}
set_if_empty :keep_releases, 5

set_if_empty :format, :pretty
set_if_empty :log_level, :debug

set_if_empty :pty, false

set_if_empty :local_user, -> { Etc.getlogin }

多くの変数については、自動生成されるdeploy.rbコメントアウトされた内容として書かれている。

# Default value for :scm is :git
# set :scm, :git
...

2.3. scmの定義を読み込んでいる

前述の通り:scmdeploy.rbなどで定義するがデフォルトはgitである。
lib/capistrano/git.rbが読み込まれ、このあとのdeployで必要なコマンドが定義される。
あとでまた詳しく見るとする。

2.4. その他なんか設定している

configure_backendについて。

    def configure_backend
      backend.configure do |sshkit|
        sshkit.format           = fetch(:format)
        sshkit.output_verbosity = fetch(:log_level)
        sshkit.default_env      = fetch(:default_env)
        sshkit.backend          = fetch(:sshkit_backend, SSHKit::Backend::Netssh)
        sshkit.backend.configure do |backend|
          backend.pty                = fetch(:pty)
          backend.connection_timeout = fetch(:connection_timeout)
          backend.ssh_options        = (backend.ssh_options || {}).merge(fetch(:ssh_options,{}))
        end
      end
    end

sshkitの設定ぽい。処理の全体像とは関係が低そうなのでこれ以上は追わない。

2.5. capistrano/setupのまとめ

  • SCMやデプロイ先などアプリケーション固有の変数を初期化
  • config/deploy.rbの読み込み
  • config/deploy/*.rbの読み込み
  • SCM固有のコマンド定義を読み込み
  • SSH設定を読み込み

3. capistrano/deploy

require 'capistrano/framework'

load File.expand_path("../tasks/deploy.rake", __FILE__)

capistrano/frameworkについては以下のようになっている。

load File.expand_path("../tasks/framework.rake", __FILE__)
require 'capistrano/install'

ということで、framework.rakedeploy.rakeを読み進める必要がある。

3.1. framework.rake

冒頭の参考記事(2つ目)にもある通り、このファイルはデプロイフローの雛形を定義している。

namespace :deploy do
  desc 'Start a deployment, make sure server(s) ready.'
  task :starting do
  end

  # ...

  desc 'Rollback to previous release.'
  task :rollback do
    %w{ starting started
        reverting reverted
        publishing published
        finishing_rollback finished }.each do |task|
      invoke "deploy:#{task}"
    end
  end
end

desc 'Deploy a new release.'
task :deploy do
  set(:deploying, true)
  %w{ starting started
      updating updated
      publishing published
      finishing finished }.each do |task|
    invoke "deploy:#{task}"
  end
end
task default: :deploy
  • deploy/rollbackは共に必要なコマンドを実行するだけ
    • このあとで詳しく見る
  • deploy/rollback以外のコマンドはデフォルトでは何もしない
    • 具体的な処理はdeploy.rakeで定義される
  • namespace :deployのデフォルトは:deploy
    • なのでcap deploydeployが呼ばれる

テンプレートメソッドっぽい。

3.2. deploy.rake

:startingとか:updatingとか、framework.rakeのいくつかのコマンドが再定義されている。

3.2.1. deploy.rake -- deploy

フェーズ毎に簡単に処理を見てみる。

deploy:starting

該当部分だけ抜き出す。

  task :starting do
    invoke 'deploy:check'
    invoke 'deploy:set_previous_revision'
  end

  desc 'Check required files and directories exist'
  task :check do
    invoke "#{scm}:check"
    invoke 'deploy:check:directories'
    invoke 'deploy:check:linked_dirs'
    invoke 'deploy:check:make_linked_dirs'
    invoke 'deploy:check:linked_files'
  end

  task :set_previous_revision do
    on release_roles(:all) do
      target = release_path.join('REVISION')
      if test "[ -f #{target} ]"
        set(:previous_revision, capture(:cat, target, '2>/dev/null'))
      end
    end
  end
  • check系のタスクではファイルやディレクトリのチェックや準備を行う
    • deploy:check:directoriesなどのタスクは同一ファイルに定義されている
    • execute :mkdir, '-p', shared_path, releases_pathなどをしている
  • :set_previous_revisionではリリース前の最新リビジョンを変数に保存しておく

deploy:started

デフォルトのまま。何もしない。

deploy:updating

:updatingには依存があり、先に:new_release_pathが行われる。 まずは:new_release_pathから読む。

  task :new_release_path do
    set_release_path
  end

set_release_pathlib/capistrano/dsl/paths.rbに定義されている。

      def releases_path
        deploy_path.join('releases')
      end

      def set_release_path(timestamp=now)
        set(:release_timestamp, timestamp)
        set(:release_path, releases_path.join(timestamp))
      end
  • リリース先のパスを変数に設定している
  • releases/20150080072500みたいなパス
    • デプロイの構造は公式のStructureを参照

次にupdatingの処理の中身を読む。

  task :updating => :new_release_path do
    invoke "#{scm}:create_release"
    invoke "deploy:set_current_revision"
    invoke 'deploy:symlink:shared'
  end

  namespace :symlink do
    # ...
    desc 'Symlink files and directories from shared to release'
    task :shared do
      invoke 'deploy:symlink:linked_files'
      invoke 'deploy:symlink:linked_dirs'
    end
    # ...
  end

  desc "Place a REVISION file with the current revision SHA in the current release path"
  task :set_current_revision  do
    invoke "#{scm}:set_current_revision"
    on release_roles(:all) do
      within release_path do
        execute :echo, "\"#{fetch(:current_revision)}\" >> REVISION"
      end
    end
  end
  • :set_current_revisionは前述のREVISIONにリビジョンを書き込んでいるだけ
  • :symlink:sharedはsymlinkの必要があるディレクトリやファイルがあれば作っている
    • 上記のリンク(Structure)のsharedを参照

:create_releaseについては少し詳しく読む。
コードを全部載せると長いので概要だけ。

  # lib/capistrano/tasks/git.rake
  desc 'Upload the git wrapper script, this script guarantees that we can script git without getting an interactive prompt'
  task :wrapper do
    # ...
  end

  desc 'Clone the repo to the cache'
  task clone: :'git:wrapper' do
    # ...
  end

  desc 'Update the repo mirror to reflect the origin state'
  task update: :'git:clone' do
    # ...
  end

  desc 'Copy repo to releases'
  task create_release: :'git:update' do
    # ...
  end
  • 依存関係を考慮した実行順は wrapper -> clone -> update -> create_release
  • wrapper
    • 専用のgit-ssh.shを配置する
  • clone
    • cloneされていなければcloneする
      • git :clone, '--mirror', repo_url, repo_path
    • clone済みなら何もしない
      • デプロイ先にrepo/HEADがあるかどうか
        • 上記のリンク(Structure)のrepoを参照
  • update
    • repoの中で実行される
    • git :remote, :update
  • create_release
    • repoの中で実行される
    • :repo_tree がある場合
      • git :archive, fetch(:branch), tree, "| tar -x --strip-components #{components} -f - -C", release_path
    • ない場合
      • git :archive, fetch(:branch), '| tar -x -f - -C', release_path

deploy:updated

デフォルトのまま。何もしない。

deploy:publishing

  task :publishing do
    invoke 'deploy:symlink:release'
  end

  namespace :symlink do
    desc 'Symlink release to current'
    task :release do
      on release_roles :all do
        tmp_current_path = release_path.parent.join(current_path.basename)
        execute :ln, '-s', release_path, tmp_current_path
        execute :mv, tmp_current_path, current_path.parent
      end
    end
  end

current -> /var/www/my_app_name/releases/20150120114500/ みたいなsymlinkを作っているだけ。
デプロイ先の構造については上記のリンク(Structure)を参照。

deploy:published

デフォルトのまま。何もしない。

deploy:finishing

  task :finishing do
    invoke 'deploy:cleanup'
  end

  desc 'Clean up old releases'
  task :cleanup do
    on release_roles :all do |host|
      releases = capture(:ls, '-xtr', releases_path).split
      if releases.count >= fetch(:keep_releases)
        info t(:keeping_releases, host: host.to_s, keep_releases: fetch(:keep_releases), releases: releases.count)
        directories = (releases - releases.last(fetch(:keep_releases)))
        if directories.any?
          directories_str = directories.map do |release|
            releases_path.join(release)
          end.join(" ")
          execute :rm, '-rf', directories_str
        else
          info t(:no_old_releases, host: host.to_s, keep_releases: fetch(:keep_releases))
        end
      end
    end
  end

descの通り:keep_releasesを超えた古いリリースファイルを消す作業。
capture(:ls, '-xtr', releases_path).splitは、こんなやり方があるのかとちょっと驚いた。
ls -xtr <pat>とすることで「水平(x)」に「日付(t)」の「降順(r)」で出力させて、splitすることでそのまま配列として扱っている。

あとt(:keeping_releases ...)tって何だと思ったが、i18nのようだった。

# lib/capistrano/dsl.rb
def t(key, options={})
  I18n.t(key, options.merge(scope: :capistrano))
end

deploy:finished

  task :finished do
    invoke 'deploy:log_revision'
  end

  desc 'Log details of the deploy'
  task :log_revision do
    on release_roles(:all) do
      within releases_path do
        execute %{echo "#{revision_log_message}" >> #{revision_log}}
      end
    end
  end

リリース先のrevisions.logにメッセージを書き込む。
revisions.logについても上記のリンク(Structure)を参照。

3.2.2. deploy.rake -- rollback

deploy:starting
deploy:started

deployと同じ。

deploy:reverting

  task :reverting do
    invoke 'deploy:revert_release'
  end

  desc 'Revert to previous release timestamp'
  task :revert_release => :rollback_release_path do
    on release_roles(:all) do
      set(:revision_log_message, rollback_log_message)
    end
  end

  task :rollback_release_path do
    on release_roles(:all) do
      releases = capture(:ls, '-xt', releases_path).split
      if releases.count < 2
        error t(:cannot_rollback)
        exit 1
      end
      last_release = releases[1]
      set_release_path(last_release)
      set(:rollback_timestamp, last_release)
    end
  end
  • リリースが1つしかないならロールバック不可
  • 一つ前のリリースを:release_pathに設定

deploy:reverted

デフォルトのまま。何もしない。

deploy:publishing

処理的にはdeployと同じ。
ただし前述の通り:release_pathが直前のリリースパスになっているので、currentがそれにsymlinkされる。

deploy:published

deployと同じ。(何もしない)

deploy:finishing_rollback

  task :finishing_rollback do
    invoke 'deploy:cleanup_rollback'
  end

  desc 'Remove and archive rolled-back release.'
  task :cleanup_rollback do
    on release_roles(:all) do
      last_release = capture(:ls, '-xt', releases_path).split.first
      last_release_path = releases_path.join(last_release)
      if test "[ `readlink #{current_path}` != #{last_release_path} ]"
        execute :tar, '-czf',
          deploy_path.join("rolled-back-release-#{last_release}.tar.gz"),
        last_release_path
        execute :rm, '-rf', last_release_path
      else
        debug 'Last release is the current release, skip cleanup_rollback.'
      end
    end
  end
  • ロールバックしたリリースはいらないよね、っていう作業
  • rolled-back-release-#{last_release}.tar.gzという感じのファイル名でアーカイブ
    • デプロイ先(currentとかと同じ階層)に置かれる
  • releasesにあるリリースを消す

ちなみにロールバックに関する公式のページは中身がなかった。

deploy:finished

deployと同じ。

3.3. capistrano/deploy のまとめ

  • capistrano/frameworkはフローの雛形を提供している
  • deploy
    • デプロイ先のディレクトリなどを準備する
    • 初回のみgit cloneする
    • 毎回git remote updateが行われる
    • git archive <branch>したものがreleases/<timestamp>の中に置かれる
    • 上記リリース物とcurrentをsymlinkでくっつける
    • releases:keep_releasesを超えないように調整する
  • rollback
    • 直前のリリース物をcurrentにする
    • 直前のリリース物をアーカイブする
    • 直前のリリース物をreleasesから削除する

frameworkdeployのおかげで利用者としては基本的にはフックを定義するだけで良い。ということをちゃんと理解出来た。

基本処理は分かったとして、一応before/afterのフックについても仕組みを見ておきたい。

4. before/after

Before / After Hooks

capistrano/dsl.rbで読み込まれるlib/capistrano/dsl/task_enhancements.rbにて定義されている。

    def before(task, prerequisite, *args, &block)
      prerequisite = Rake::Task.define_task(prerequisite, *args, &block) if block_given?
      Rake::Task[task].enhance [prerequisite]
    end

    def after(task, post_task, *args, &block)
      Rake::Task.define_task(post_task, *args, &block) if block_given?
      post_task = Rake::Task[post_task]
      Rake::Task[task].enhance do
        post_task.invoke
      end
    end

capistranoというよりはRakeの構文の模様。
Rake で任意のタスクの前後に別のタスクを実行する

4.1. before/after のまとめ

  • Rakeに委譲しているだけ

おわりに

整理と備忘のために書いたけど、忘れてもまたドキュメントとコードを読んだ方が分かりやすいような気がしてきた。