How I Migrated From Heroku to Digital Ocean With Chef and Capistrano
UPDATE:
Removed ElasticSearch and MongoDB recipes since they were not so useful for this tutorial.
Added unicorn.rb
Added ssh authentication step
Added file paths
I’ve always loved deploying to Heroku. The simplicity of a git push let me focus on developing my applications which is what I really care about. However, both because of the scandal about the routing system and because I wanted to expand my skill set by entering the sysadmin land, at Responsa I decided to migrate to a VPS solution.
At this point I had three choices to make:
Hosting provider
Technology stack
Deploy strategy
Provider
Many hackers I follow were recommending Digital Ocean so I gave it a try. I must say I was very impressed with the simplicity and power of their dashboard, so I decided to use it.
I immediately changed my root password
1
passwd
Copied over my ssh key with
1
ssh-copy-id root@$IP
And disabled password access setting PasswordAuthentication no in /etc/ssh/sshd_config
Technology
The decision of the web server was also quick. I wanted to achieve 0 downtime deployments so Github use of Unicorn + Nginx jumped to my mind.
Deploy strategy
This is where things got a little bit complicated. Disclaimer: I’m not a Linux/Unix pro, so many system administration practices where unknown to me prior to this week. Having said that, It was clear to me that the community is very fragmented. There were so many solutions to the same problems and so many scripts! After digging, trying and failing miserably I settled on the stack that caused me the least suffering:
Chef solo and Knife for the machine provisioning
Capistrano for the deployment
Chef
Chef is a provisioning tool written in Ruby. Its DSL is very expressive and powerful. The community is full of useful cookbooks that ease the setup of common services, however it seemed to lack a way to handle community cookbooks. This is where Librarian comes in. I just had to write a Cheffile with all the dependencies and I was done.
To bootstrap the machine with Chef and Ruby many people where using custom Knife templates that were not working for me. Some installed ruby with RVM, others with rbenv. In the end I found Knife Solo that solved all my problems. With one command I could install Chef AND run all my recipes to install Ruby and every other service I needed.
1
knifesolobootstraproot@$IPnode.json
Librarian and Knife Solo forced me to use a specific project structure:
# chef/site-cookbooks/main/templates/default/sv-responsa-run.erb#!/bin/bashexec2>&1<% unicorn_command =@options[:unicorn_command]||'unicorn_rails'-%>## Since unicorn creates a new pid on restart/reload, it needs a little extra love to# manage with runit. Instead of managing unicorn directly, we simply trap signal calls# to the service and redirect them to unicorn directly.function is_unicorn_alive { set +e if [ -n $1 ] && kill -0 $1 >/dev/null2>&1;thenecho"yes"fiset-e}echo"Service PID: $$"CUR_PID_FILE=/var/www/responsa/shared/pids/unicorn.pidOLD_PID_FILE=$CUR_PID_FILE.oldbinif[-e$OLD_PID_FILE];thenOLD_PID=$(cat$OLD_PID_FILE)echo"Waiting for existing master ($OLD_PID) to exit"while[-n"$(is_unicorn_alive $OLD_PID)"];do/bin/echo-n'.'sleep2donefiif[-e$CUR_PID_FILE];thenCUR_PID=$(cat$CUR_PID_FILE)if[-n"$(is_unicorn_alive $CUR_PID)"];thenecho"Unicorn master already running. PID: $CUR_PID"RUNNING=truefifiif[!$RUNNING];thenecho"Starting unicorn"cd/var/www/responsa/currentexportPATH="/usr/local/rbenv/shims:/usr/local/rbenv/bin:$PATH"# You need to daemonize the unicorn process, http://unicorn.bogomips.org/unicorn_rails_1.htmlbundleexec<%= unicorn_command %> -c config/unicorn.rb -E <%=@options[:environment]||'staging'%>-Dsleep3CUR_PID=$(cat$CUR_PID_FILE)fifunctionrestart{echo"Initialize new master with USR2"kill-USR2$CUR_PID# Make runit restart to pick up new unicorn pidsleep2echo"Restarting service to capture new pid"exit}functiongraceful_shutdown{echo"Initializing graceful shutdown"kill-QUIT$CUR_PID}functionunicorn_interrupted{echo"Unicorn process interrupted. Possibly a runit thing?"}traprestartHUPQUITUSR2INTtrapgraceful_shutdownTERMKILLtrapunicorn_interruptedALRMecho"Waiting for current master to die. PID: ($CUR_PID)"while[-n"$(is_unicorn_alive $CUR_PID)"];do/bin/echo-n'.'sleep2doneecho"You've killed a unicorn!"
# chef/site-cookbooks/main/templates/default/nginx.erbupstreamunicorn{serverunix:/var/www/responsa/tmp/sockets/responsa.sockfail_timeout=0;}server{listen80;listen443defaultssl;server_name<%= node[:server_name] %>; root /var/www/responsa/current/public; # set far-future expiration headers on static content expires max; server_tokens off; # ssl on; ssl_certificate <%="/var/www/responsa/certificate/#{node[:environment]}.crt"%>; ssl_certificate_key <%= "/var/www/responsa/certificate/#{node[:environment]}.key" %>;ssl_session_timeout5m;ssl_protocolsSSLv2SSLv3TLSv1;ssl_ciphersHIGH:!aNULL:!MD5;ssl_prefer_server_cipherson;# set up the rails servers as a virtual location for use laterlocation@unicorn{proxy_set_headerHost$host;proxy_set_headerX-Real-IP$remote_addr;proxy_set_headerX-Forwarded-For$proxy_add_x_forwarded_for;proxy_set_headerX-Forwarded-Proto$scheme;proxy_intercept_errorson;proxy_redirectoff;proxy_passhttp://unicorn;expiresoff;}location/{try_files$uri@unicorn;}# error_page 500 502 503 504 /500.html;}
# config/unicorn.rbrails_env=ENV['RAILS_ENV']||'production'worker_processes(rails_env=='production'?6:3)preload_apptrue# Restart any workers that haven't responded in 30 secondstimeout30working_directory'/var/www/responsa/current'# Listen on a Unix data socketpid'/var/www/responsa/shared/pids/unicorn.pid'listen"/var/www/responsa/tmp/sockets/responsa.sock",:backlog=>2048stderr_path'/var/www/responsa/shared/log/unicorn.log'stdout_path'/var/www/responsa/shared/log/unicorn.log'before_execdo|server|ENV["BUNDLE_GEMFILE"]="/var/www/responsa/current/Gemfile"endbefore_forkdo|server,worker|### When sent a USR2, Unicorn will suffix its pidfile with .oldbin and# immediately start loading up a new version of itself (loaded with a new# version of our app). When this new Unicorn is completely loaded# it will begin spawning workers. The first worker spawned will check to# see if an .oldbin pidfile exists. If so, this means we've just booted up# a new Unicorn and need to tell the old one that it can now die. To do so# we send it a QUIT.## Using this method we get 0 downtime deploys.old_pid='/var/www/responsa/shared/pids/unicorn.pid.oldbin'ifFile.exists?(old_pid)&&server.pid!=old_pidbeginProcess.kill("QUIT",File.read(old_pid).to_i)rescueErrno::ENOENT,Errno::ESRCH# someone else did our job for usendendend
Capistrano
After setting up the machine I created a snapshot on Digital Ocean, in case I had to restart from scratch.
Time to deploy! Capistrano was an easy choice.
Using Capistrano multistage I set up the production script
# config/deploy.rbrequire'bundler/capistrano'require'sidekiq/capistrano'require'capistrano/ext/multistage'set:stages,%w(production staging)set:default_stage,'staging'default_run_options[:pty]=truessh_options[:forward_agent]=trueset:application,'responsa'set:repository,$PATH_TO_GITHUB_REPOset:deploy_to,"/var/www/#{application}"set:branch,'development'set:scm,:gitset:scm_verbose,trueset:deploy_via,:remote_cacheset:use_sudo,trueset:keep_releases,3set:user,'deployer'set:bundle_without,[:development,:test,:acceptance]set:rake,"#{rake} --trace"set:default_environment,{'PATH'=>'/usr/local/rbenv/shims:/usr/local/rbenv/bin:$PATH'}after'deploy:update_code',:upload_env_varsafter'deploy:setup'dosudo"chown -R #{user}#{deploy_to} && chmod -R g+s #{deploy_to}"endnamespace:deploydodesc<<-DESC Send a USR2 to the unicorn process to restart for zero downtime deploys. runit expects 2 to tell it to send the USR2 signal to the process. DESCtask:restart,:roles=>:app,:except=>{:no_release=>true}dorun"sv 2 /home/#{user}/service/#{application}"endendtask:upload_env_varsdoupload(".env.#{rails_env}","#{release_path}/.env.#{rails_env}",:via=>:scp)end
Now with two simple commands I can deploy with 0 downtime!
12
capdeploy:setupcapdeploy
I must thank czarneckid for sharing his setup on Github from which I stole some useful portions and also @bugant for his patience.