Logo

Deploy Project to Staging Using Capistrano on Ubuntu

avatar hugo 20 Apr 2015

(For capistrano 2.x)

本文介绍如何配置 Capistrano 部署 Rails 到 staging 的服务器(服务器上跑 Passenger 和 Nginx)。

前提条件

  1. VPS:创建用于部署的 user,并且安装好了 Git, RVM, Ruby, Passenger, Nginx, MySQL 等所需软件
  2. SSH:配置本机可以无密码 SSH 到 VPS
  3. 域名:登录域名网站,修改 DNS 记录,添加 A 记录,并指向 VPS 的 IP

配置 Capistrano

Gemfile

在 Gemfile 的 development 和 test group 添加 capistrano 和 rvm-capistrano。

group :development, :test do
  # Use Capistrano for deployment
  gem 'capistrano', "~> 2.15.5"
  gem 'rvm-capistrano', '~> 1.4.1', :require => false
end

Run bundle to install new gems.

Generate cap files

Run capify .

这会生成两个文件: Capfileconfig/deploy.rb

Capfile:

load 'deploy'
# Uncomment if you are using Rails' asset pipeline
# load 'deploy/assets'
load 'config/deploy' # remove this line to skip loading any of the default tasks

Capfile 基本不用修改。

config/deploy.rb 示例:

require 'rvm/capistrano'

set :rvm_type, :user
set :rvm_ruby_string, 'ruby-2.0.0-p598'

# repo details
set :scm, :git
# need to clean shared/cached-copy if changed repository
set :repository, "git@domain.com:username/project-name.git"
set :branch, "master"
set :git_enable_submodules, 1

# bundler bootstrap
require 'bundler/capistrano'
set :bundle_without, [:darwin, :development, :test]

# Multi stage
# https://github.com/capistrano/capistrano/wiki/2.x-Multistage-Extension
# https://github.com/VinceMD/Scem/wiki/Deploying-on-production
set :stages, %w(production staging)
set :default_stage, "staging" # require config/deploy/staging.rb
# set :default_stage, "production" # require config/deploy/production.rb
require 'capistrano/ext/multistage'

# server details
default_run_options[:pty] = true # apparently helps with passphrase prompting
ssh_options[:forward_agent] = true # tells cap to use my local private key
set :deploy_via, :remote_cache
set :user, "username"
set :use_sudo, false

# integrate whenever
# when using bundler
# set :whenever_command, "bundle exec whenever"
# when using different environments
# set :whenever_environment, defer { stage }
# set :whenever_identifier, defer { "#{fetch(:application)}-#{fetch(:rails_env)}" }
# require "whenever/capistrano"
# https://github.com/javan/whenever/blob/master/lib/whenever/capistrano.rb

# tasks
namespace :deploy do
  task :start, :roles => :app do
    run "touch #{current_path}/tmp/restart.txt"
  end

  task :stop, :roles => :app do
    # Do nothing.
  end

  desc "Restart Application"
  task :restart, :roles => :app do
    run "touch #{current_path}/tmp/restart.txt"
  end

  desc "Symlink shared resources on each release"
  task :symlink_shared, :roles => :app do
    %w{database settings.local}.each do |file|
      run "ln -nfs #{shared_path}/config/#{file}.yml #{release_path}/config/#{file}.yml"
    end

    # link dirs in public/
    %w{uploads}.each do |dir|
      run "mkdir -p #{shared_path}/public/#{dir}"
      run "ln -nfs #{shared_path}/public/#{dir} #{release_path}/public/#{dir}"
    end
  end

  desc "Initialize configuration using example files provided in the distribution"
  task :upload_config do
    %w{config}.each do |dir|
      run "mkdir -p #{shared_path}/#{dir}"
    end

    Dir["config/*.yml.example"].each do |file|
      top.upload(File.expand_path(file), "#{shared_path}/config/#{File.basename(file, '.example')}")
    end
  end

  desc 'Visit the app'
  task :visit_web do
    system "open #{app_url}"
  end
end

after 'deploy:setup', 'deploy:upload_config'
after 'deploy:update_code', 'deploy:symlink_shared'
after 'deploy:restart', 'deploy:visit_web'
after 'deploy:migrations', 'deploy:cleanup'

set :keep_releases, 7 # number for keeping old releases
after 'deploy', 'deploy:cleanup'

namespace :db do
  desc "Create db for current env"
  task :create do
    run "cd #{current_path}; bundle exec rake db:create RAILS_ENV=#{rails_env}"
    puts 'could be able to run `cap deploy:migrate` now'
  end

  desc "Populates the Production Database"
  task :seed do
    puts "\n\n=== Populating the Database! ===\n\n"
    run "cd #{current_path}; bundle exec rake db:seed RAILS_ENV=#{rails_env}"
  end
end

# http://guides.rubyonrails.org/asset_pipeline.html#precompiling-assets
# https://github.com/capistrano/capistrano/blob/master/lib/capistrano/recipes/deploy/assets.rb
load 'deploy/assets' unless (ARGV.join == "deploy:update" || ARGV.last == 'deploy:update')
# then we got these tasks:
# cap deploy:assets:clean      # Run the asset clean rake task.
# cap deploy:assets:precompile # Run the asset precompilation rake task.

namespace :remote do
  desc "Open the rails console on one of the remote servers"
  task :console, :roles => :app do
    hostname = find_servers_for_task(current_task).first
    command = "cd #{current_path} && bundle exec rails console #{fetch(:rails_env)}"
    if fetch(:rvm_ruby_string)
      # set rvm shell and get ride of "'"
      # https://github.com/wayneeseguin/rvm/blob/master/lib/rvm/capistrano.rb
      rvm_shell = %{rvm_path=$HOME/.rvm $HOME/.rvm/bin/rvm-shell "#{fetch(:rvm_ruby_string)}"}
      command = %{#{rvm_shell} -c "#{command}"}
    else
      command = %{source ~/.profile && "#{command}"}
    end
    exec %{ssh -l #{user} #{hostname} -t '#{command}'}
  end

  desc "run rake task. e.g.: `cap remote:rake db:version`"
  task :rake do
    ARGV.values_at(Range.new(ARGV.index('remote:rake')+1, -1)).each do |rake_task|
      top.run "cd #{current_path} && RAILS_ENV=#{rails_env} bundle exec rake #{rake_task}"
    end
    exit(0)
  end

  desc "run remote command. e.g.: `cap remote:run 'tail -n 10 log/production.log'`"
  task :run do
    command = ARGV.values_at(Range.new(ARGV.index('remote:run')+1, -1))
    top.run "cd #{current_path}; RAILS_ENV=#{rails_env} #{command*' '}"
    exit(0)
  end

  desc 'run specified rails code on server. e.g.: `cap remote:runner p User.all` or `cap remote:runner "User.all.each{ |u| p u }"`'
  task :runner do
    command=ARGV.values_at(Range.new(ARGV.index('remote:runner')+1,-1))
    top.run "cd #{current_path}; RAILS_ENV=#{rails_env} bundle exec rails runner '#{command*' '}'"
    exit(0)
  end

  desc "tail log on remote server"
  task :tail_log do
    top.run "tail -f #{current_path}/log/#{rails_env}.log" do |channel, stream, data|
      puts "#{data}"
      break if stream == :err
    end
    exit(0)
  end
end

namespace :update do
  desc "Dump remote database into tmp/, download file to local machine, import into local database"
  task :database do
    # config
    remote_db_yml_path          = "#{shared_path}/config/database.yml"
    remote_db_yml_on_local_path = "tmp/database_#{rails_env}.yml"

    # First lets get the remote database config file so that we can read in the database settings
    get remote_db_yml_path, remote_db_yml_on_local_path

    # load the remote settings within the database file
    remote_settings = YAML::load_file(remote_db_yml_on_local_path)[rails_env]

    remote_sql_file_path        = "#{current_path}/tmp/#{rails_env}-#{remote_settings["database"]}-dump.sql"
    remote_sql_gz_file_path     = "#{remote_sql_file_path}.gz"
    local_sql_file_path         = "tmp/#{rails_env}-#{remote_settings["database"]}-#{Time.now.strftime("%Y-%m-%d_%H:%M:%S")}.sql"
    local_sql_gz_file_path      = "#{local_sql_file_path}.gz"

    # we also need the local settings so that we can import the fresh database properly
    local_settings = YAML::load_file("config/database.yml")[rails_env]

    # dump the remote database and store it in the current path's tmp directory.
    run "mysqldump -u'#{remote_settings["username"]}' -p'#{remote_settings["password"]}' #{"-h '#{remote_settings["host"]}'" if remote_settings["host"]} '#{remote_settings["database"]}' > #{remote_sql_file_path}"

    # gzip db
    run "gzip -f #{remote_sql_file_path}"

    # download gz file to local
    get remote_sql_gz_file_path, local_sql_gz_file_path

    # unzip sql
    run_locally "gunzip #{local_sql_gz_file_path}"

    # import db to local db
    # may need to run `RAILS_ENV=production rake db:create` on local first
    run_locally("mysql -u#{local_settings["username"]} #{"-p#{local_settings["password"]}" if local_settings["password"]} #{local_settings["database"]} < #{local_sql_file_path}")

    # now that we have the upated production dump file we should use the local settings to import this db.
  end

  desc "Mirrors the remote shared public directory with your local copy, doesn't download symlinks"
  task :shared_assets do
    run_locally "if [ -e public/uploads ]; then mv public/uploads public/uploads_back; fi"
    # using rsync so that it only copies what it needs
    run_locally("rsync --recursive --times --rsh=ssh --compress --human-readable --progress #{user}@#{app_server}:#{shared_path}/system/ public/system/")

    run_locally("rsync --recursive --times --rsh=ssh --compress --human-readable --progress #{user}@#{app_server}:#{shared_path}/public/uploads/ public/uploads/")
  end

  namespace :remote do
    desc "update the remote database with the local database"
    task :database do
      input = ''
      # STDOUT.puts "Are you SURE to update the databse of remote?(YES)"
      # confirmation = STDIN.gets.chomp
      confirmation = Capistrano::CLI.ui.ask("Are you SURE to update the databse of remote?(YES)")
      abort "Interrupt.." unless confirmation == "YES"
      # config database.yml on both sides
      remote_db_yml_path          = "#{shared_path}/config/database.yml"
      remote_db_yml_on_local_path = "tmp/database_#{rails_env}.yml"

      # First get the local database config to remote
      get remote_db_yml_path, remote_db_yml_on_local_path

      # load the local settings within the database file
      local_settings = YAML::load_file("config/database.yml")[rails_env]

      # set the sql path on both sides
      local_sql_file_path = "tmp/#{rails_env}-#{local_settings['database']}-dump.sql"
      local_sql_gz_file_path = "#{local_sql_file_path}.gz"
      remote_sql_file_path = "#{current_path}/tmp/#{rails_env}-#{local_settings['database']}-#{Time.now.strftime("%Y-%m-%d_%H:%M:%S")}.sql"
      remote_sql_gz_file_path = "#{remote_sql_file_path}.gz"

      # we also need the remote settings so that we can import the fresh dataabse properly
      remote_settings = YAML::load_file(remote_db_yml_on_local_path)[rails_env]

      # dump the local database and store it in the tmp dir
      if local_settings['adapter'] == 'postgresql'
        run_locally "PGPASSWORD='#{local_settings['password']}' pg_dump  -U #{local_settings["username"]} #{"-h '#{local_settings["host"]}'" if local_settings["host"]} -c -O '#{local_settings["database"]}' > #{local_sql_file_path}"
      elsif local_settings['adapter'] == 'mysql2'
        run_locally "mysqldump -u'#{local_settings["username"]}' #{"-p#{local_settings["password"]}" if local_settings["password"]} #{"-h '#{local_settings["host"]}'" if local_settings["host"]} '#{local_settings["database"]}' > #{local_sql_file_path}"
      else
        raise "not supports #{local_settings['adapter']}"
      end

      # gzip db
      run_locally "gzip -f #{local_sql_file_path}"

      # send the gz file to remote
      upload local_sql_gz_file_path, remote_sql_gz_file_path

      # unzip sql
      run "gunzip #{remote_sql_gz_file_path}"

      # import db to remote db
      # may need to run `RAILS_ENV=production rake db:create` on remote first
      if local_settings['adapter'] == 'postgresql'
        run "PGPASSWORD='#{remote_settings['password']}' psql -U #{remote_settings['username']} -d #{remote_settings["database"]} -f #{remote_sql_file_path}"
      elsif local_settings['adapter'] == 'mysql2'
        run "mysql -u#{remote_settings["username"]} #{"-p#{remote_settings["password"]}" if remote_settings["password"]} #{remote_settings["database"]} < #{remote_sql_file_path}"
      else
        raise "not supports #{local_settings['adapter']}"
      end

      # now that we have the updated production dump file we should use the remote settings to import this db
    end

    desc "Mirrors the local shared public directory with the remote copy, doesn't download symlinks"
    task :shared_assets do
      run "cp -R #{shared_path}/system #{shared_path}/system_back"
      run "cp -R #{shared_path}/public/uploads/ #{shared_path}/public/uploads_back"
      run_locally("rsync --recursive --times --rsh=ssh --compress --human-readable --progress public/system #{user}@#{app_server}:#{shared_path}/")
      run_locally("rsync --recursive --times --rsh=ssh --compress --human-readable --progress public/uploads/ #{user}@#{app_server}:#{shared_path}/public/uploads")
    end
  end
end

# load recipes in deploy/recipes/
Dir['config/deploy/recipes/*.rb'].each { |recipe| require File.expand_path(recipe) }

配置项就不逐一介绍了,可以阅读 Capistrano 的文档 2.x Getting Started。主要是修改 :rvm_ruby_string:repository 两个,其它再按需修改。

配置 config/deploy/staging.rb 和 confing/deploy/production.rb

公用的配置都写在 config/deploy.rb,而针对 staging 和 production 的配置就分别写在 config/deploy/staging.rbconfing/deploy/production.rb 里:

# config/deploy/staging.rb
set :app_server, "yourdomain.com"
set :app_url, "http://staging.yourdomain.com"
set :application, app_server
role :web, app_server
role :app, app_server
role :db,  app_server, :primary => true
set :deploy_to, "/path/to/project"
set :user, "deploy"
set :rvm_ruby_string, "ruby-2.0.0-p598"
set :branch, "develop"
set :rails_env, "staging"

# confing/deploy/production.rb
set :app_server, 'yourdomain.com'
set :app_url, 'http://yourdomain.com'
set :application, app_server
role :web, app_server
role :app, app_server
role :db,  app_server, :primary => true
set :deploy_to, "/path/to/project"
set :user, "deploy"
set :rvm_ruby_string, 'ruby-2.0.0-p598'
set :branch, "master"
set :rails_env, "production"

添加 database.yml.example

config/database.yml 文件是不应该被提交到代码库的,我们需要用 config/database.yml.example 来做样本,在部署的时候它会被上传到服务器的 /path/to/project/shared/config/ 目录里,然后 SSH 到服务器上修改 username, password, socket 等值(上传的动作是在部署里说明)。尤其注意 MySQL socket 的路径在 Mac OS 和 Ubuntu 系统上是不同的。

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5
  username: username
  password:
  socket: /tmp/mysql.sock

development:
  <<: *default
  database: app_name_development

test:
  <<: *default
  database: app_name_test

production:
  <<: *default
  database: app_name_production
  username: username
  password: <%= ENV['APPNAME_DATABASE_PASSWORD'] %>

staging:
  <<: *default
  database: app_name_staging
  pool: 20
  username: username
  password: <%= ENV['APPNAME_DATABASE_PASSWORD'] %>
  socket: /var/run/mysqld/mysqld.sock

添加 config/environments/staging.rb

config/environments 目录下,run cp production.rb staging.rb,添加一份用于 staging 环境的文件。

为 staging 添加 secret_key_base

编辑 config/secrets.yml 文件,添加:

staging:
  secret_key_base: <%= ENV["SECRET_KEY_BASE"] || "bb1a251195b32ae2174d64943b6bf1c9acee1961ce5841dabf0a86715ce8c3dedf182ce86151760e0cd086b540848e3a7acad6bc87e63e7e28a5b3929445a535" %>
  # devise_secret_key: '2a61c5f719cc2e5d89615d74d330fd4c339820a6d83f9482b1332bt2z027ddd02da59o0061cdb180651f29b720da0b93c83ba5293b467e88800c216a6ce934d6'

128位的随机字符串可用 rake secret 命令生成。

如果使用了 Devise,把 devise_secret_key 那行取消注释,并且将 config/initiializers/devise.rb 文件的 config.secret_key 改为:

  config.secret_key = Rails.application.secrets.devise_secret_key

部署

把以上的改动都提交到一个分支里,例如叫 “staging-deployment”。然后把本地 config/deploy/staging.rb 文件的 set :branch, "develop" 的 develop 改为你的分支。Capistrano 在部署时会读取本地的 staging.rb 里配置来进行部署,修改 branch 可方便地进行测试。

Run cap deploy:setup 来在 VPS 上创建 app 的目录结构,这时 database.yml.example 就会被上传到 /path/to/project/shared/config/ 目录里,并被改名为 database.yml。根据服务器上的配置,修改 staging 里的 username, password, socket 等值,password 也可以直接写上。

Run cap deploy:check to check all dependencies. If success, you should “You appear to have all necessary dependencies installed”

Run cap deploy:update to push the code.

Run cap db:create to create the db if necessary.

Run cap deploy:migrate for first migration.

Then for usual deploy, just run:

run `cap deploy:migrations` # Deploy and run pending migrations.

or

run `cap deploy` # Deploy without running migrations.

部署成功的话,在服务器上执行 ps -ef | grep -i ruby,应该可以看到类似 Passenger RubyApp: /path/to/project/current/public (staging) 的进程。

配置 Nginx

Nginx 的配置文件一般在 /opt/nginx/conf/nginx.conf

nginx.conf 示例:

#user  nobody;
worker_processes  4;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    passenger_root /home/username/.rvm/gems/ruby-2.0.0-p598/gems/passenger-5.0.4;
    passenger_ruby /home/username/.rvm/gems/ruby-2.0.0-p598/wrappers/ruby;

    include       mime.types;
    default_type  application/octet-stream;

    include       /opt/nginx/conf/sites-enabled/*;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    # HTTP server
    #
    #server {
    #    listen       80;
    #    server_name  localhost;

    #    #charset koi8-r;

    #    #access_log  logs/host.access.log  main;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }

    #    #error_page  404              /404.html;

    #    # redirect server error pages to the static page /50x.html
    #    #
    #    error_page   500 502 503 504  /50x.html;
    #    location = /50x.html {
    #        root   html;
    #    }
    #}
}

关于 nginx.conf 文件的几点说明:

  • passenger_rootpassenger_ruby 两个配置项需要修改为你的服务器上的 passenger 和 ruby 的路径
  • HTTP Server 配置模块被注释,是因为一般 staging 的环境上都会跑多个 Rails app,所以我们把 server 配置分拆开,移到 /opt/nginx/conf/sites-enabled/ 目录里,即 include 配置项的路径里

分拆 nginx.conf 的 server 配置,我们 BeanSmile 的做法是在 /opt/nginx/conf/ 目录下建 sites-enabled 和 sites-available 目录。sites-available 目录放置 your-domain.conf 文件,然后在 sites-enabled 目录建一个软链接到 sites-available 目录里对应的配置。

配置示例:

server {
    listen       80;
    server_name  yourdomain.com;

    root /path/to/project/current/public/;
    passenger_enabled on;
    #passenger_start_timeout 300;

    rails_env staging;
    add_header X-Frame-Options ALLOWALL;
    ignore_invalid_headers off;

    #charset koi8-r;

    access_log  /path/to/project/shared/log/nginx_access.log;
    error_log  /path/to/project/shared/log/nginx_error.log  error;


    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

修改 server_namerootrails_envaccess_logerror_log 为你环境的值。需要注意的一点是 root 路径最后一定是 public 目录的。

运行以下两条命令使 Nginx 的新配置生效:

$ sudo /opt/nginx/sbin/nginx -s reload
$ sudo service nginx restart

如果 DNS 已经生效,那访问网址应该就能打开网站页面了。

参考资料

  1. 2.x Getting Started
  2. 2.x from the beginning
  3. [Ruby on Rails 實戰聖經 網站佈署 by ihower](https://ihower.tw/rails4/deployment.html)
Tags
deployment capistrano