Deploy Project to Staging Using Capistrano on Ubuntu

(For capistrano 2.x)
本文介绍如何配置 Capistrano 部署 Rails 到 staging 的服务器(服务器上跑 Passenger 和 Nginx)。
前提条件
- VPS:创建用于部署的 user,并且安装好了 Git, RVM, Ruby, Passenger, Nginx, MySQL 等所需软件
- SSH:配置本机可以无密码 SSH 到 VPS
- 域名:登录域名网站,修改 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 .
这会生成两个文件: Capfile
和 config/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.rb
和 confing/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_root
和passenger_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_name
、 root
、 rails_env
、access_log
和 error_log
为你环境的值。需要注意的一点是 root
路径最后一定是 public 目录的。
运行以下两条命令使 Nginx 的新配置生效:
$ sudo /opt/nginx/sbin/nginx -s reload
$ sudo service nginx restart
如果 DNS 已经生效,那访问网址应该就能打开网站页面了。
参考资料
- 2.x Getting Started
- 2.x from the beginning
-
[Ruby on Rails 實戰聖經 網站佈署 by ihower](https://ihower.tw/rails4/deployment.html)