Railsアプリ with Mina and OpenRC

目的とか

KONMAIが送る大人気リズムアクションゲーム"REFLEC BEAT groovin’!!“に向けてRefixativeの新バージョンを書いていた。今までは毎回毎回サーバーにsshしてはgit pullしてうんぬんかんぬん、とやっていたので、現代的な環境を構築したかった。

ついでに、サーバーを再起動する度にunicornのプロセス周りがなぜだかややこしいことになっていたので、どうせそもそもデーモンなんだからinitスクリプトを書けばいいんじゃね、ということでそっちもついでに対応した。

OpenRCの実装を読み始めた辺りから変な沼にハマって、そこから3日くらいかかった。

環境その他

開発環境はMac OS X Mavericks。Homebrewでいろんなものを突っ込んである状態。

サーバーはさくらのVPS(2G)でOSとしてGentoo Linuxがインストールされている。

Rubyのバージョンは2.1.2、Railsのバージョンは4.1.1。

DBにPostgreSQL 9.3を使用。現時点ではまだ用意していないが、memcachedも使用する予定。

Rails

rails newして適当にGemfileを弄ってbundle install --path vendor/bundle

rbenv on Gentoo

まず第一関門がRubyだった。Gentoo上でrbenvやRVMを使ってRubyをインストールすると、auto_gemなるライブラリがないと言われてRubyが動かないという問題に遭遇した。ググってみると同じ問題に遭遇している人が多数いた。RUBYOPTを空にしてrubygemsをemergeしなおせばいいよ、とかまぁいろいろ見つかったのだけど、そもそもauto_gemというのが気になり、Twitterでいろいろ叫びながら調べていたところ、Gentooのこわいひとに教えてもらうことができた。

(ちなみにその後Gentooのこわいひとが次のように仰っていたので、きっとこの問題は起こらなくなるだろう)

さて、それはともかくとして、少なくともこれをやってた時点では直ってなかったので対策をしなければならない。

そもそもauto_gem.rbとやらが何をしているのかというと、

  • require 'rubygems'する
  • そのときにLoadErrorが起きたら握り潰す

以上である。Gentooのこわいひとの言う通り、見事に現代には不要なブツである。不要なのだから、現代的なRuby環境では「何もしなくてもよい」ということができる。というわけで、適当にsite_ruby辺りに空のauto_gem.rbを突っ込んでしまえばよい。例えばRuby 2.1.2であれば、

touch $HOME/.rbenv/versions/2.1.2/lib/ruby/site_ruby/2.1.0/auto_gem.rb

とかやると動くようになる。

ちなみにruby-buildを使っている場合はインストール後にそれを自動でやってくれるpluginがある。 -> Cofyc/rbenv-auto_gem

これを$HOME/.rbenv/pluginsの下にcloneしてrbenv install ...とかやると適切な場所に空のauto_gem.rbを作ってくれる。

これでRubyが動くようになった。

therubyracer

rake assets:precompileをしたとき、ローカルでは上手くいくのに、サーバー側ではSegmentation FaultでRubyが落ちるという事態に遭遇した。吐かれたエラーを見るとtherubyracerがどうの、といったところで落ちていた。サーバー側にはnode.jsが入っているし、特に問題ないだろうと判断してGemfileからtherubyracerを消したところ上手く動作した。なんだったんだろう。

Mina

Minaはデプロイ自動化ツールで、同じようなツールで最も有名なのは恐らくCapistranoだろう。

今までRefixativeのデプロイというのはHerokuのようにgit pushしたら行われるとかなんかのbotにコマンド投げると行われるとかでは全くなく、開発者自身(つまり私)がデプロイ先サーバーにsshして、アプリケーションが置いてあるフォルダに移動して、git pullして、unicornをreloadして……とやっていた。正直アホらしい。

そういうわけでその辺自動化するためにCapistranoでも触るか……と思っていたところで某筑波のておくれからこんなreplyが来た。

ナウいらしい。ならば使うしかあるまい。というわけでMina採用となった。

その前に

サーバー側でRailsアプリを動かすユーザー等々を準備しておく。

まずユーザー(今回はrefxgroovin)を予め作成しておく。デプロイ先はこのユーザーのホームディレクトリ以下とし、今回は/home/refxgroovin/refixativeとしている。ステージング環境も同じユーザーで動かし、そちらのデプロイ先はrefixative-stagingとした。

開発環境からサーバーに対してrefxgroovinとしてsshでログインできるように設定する。開発環境で公開鍵・秘密鍵のペアを作成し、サーバー側の/home/refxgroovin/.ssh/authorized_keysに公開鍵を追記する。

ソースコードを取得する元になるリポジトリだが、今回github上にあるpublicなリポジトリなので実際どっちでもよさそうだし、SSH Agentを使うこともできるが、今回はさらにサーバー上のrefxgroovinユーザーでSSHの鍵を生成してリポジトリにアクセスできるように設定する。

Minaの準備

まずはGemfileにgem 'mina'の一行を追加してbundleした後、

$ bundle exec mina init

とすると、config/deploy.rbというファイルが出来るのでこれを編集する。以下に示すdeploy.rbはデフォルトを元にいろいろ試行錯誤した結果である。

require 'mina/bundler'
require 'mina/rails'
require 'mina/git'
require 'mina/rbenv'  # for rbenv support. (http://rbenv.org)
# require 'mina/rvm'    # for rvm support. (http://rvm.io)

# Optional settings:
#   set :user, 'foobar'    # Username in the server to SSH to.
#   set :port, '30000'     # SSH port number.

set :user, 'refxgroovin'

# Basic settings:
#   domain       - The hostname to SSH to.
#   deploy_to    - Path to deploy into.
#   repository   - Git repo to clone from. (needed by mina/git)
#   branch       - Branch name to deploy. (needed by mina/git)

set :domain, 'refxgroovin'
set :repository, 'git@github.com:mayth/refixative.git'
set :branch, 'next'

# Manually create these paths in shared/ (eg: shared/config/database.yml) in your server.
# They will be linked in the 'deploy:link_shared_paths' step.
set :shared_paths, [
  'config/database.yml',
  'config/unicorn.rb',
  'config/application.yml',
  'log'
]

# This task is the environment that is loaded for most commands, such as
# `mina deploy` or `mina rake`.
task :environment do
  case ENV['to']
  when 'staging'
    set :deploy_to, "/home/#{user}/refixative-staging"
  when 'production'
    set :deploy_to, "/home/#{user}/refixative"
  when nil
    fail 'specify `to`, deployment target'
  else
    fail 'unknown deployment target'
  end

  # If you're using rbenv, use this to load the rbenv environment.
  # Be sure to commit your .rbenv-version to your repository.
  invoke :'rbenv:load'
end

# Put any custom mkdir's in here for when `mina setup` is ran.
# For Rails apps, we'll make some of the shared paths that are shared between
# all releases.
task :setup => :environment do
  queue! %[mkdir -p "#{deploy_to}/shared/log"]
  queue! %[chmod g+rx,u+rwx "#{deploy_to}/shared/log"]

  queue! %[mkdir -p "#{deploy_to}/shared/config"]
  queue! %[chmod g+rx,u+rwx "#{deploy_to}/shared/config"]

  queue! %[mkdir -p "#{deploy_to}/shared/pids"]
  queue! %[chmod g+rx,u+rwx "#{deploy_to}/shared/pids"]

  shared_configs = %w(database.yml unicorn.rb application.yml)
  shared_configs.each do |conf|
    queue! %[touch "#{deploy_to}/shared/config/#{conf}"]
  end
  queue  %[echo "-----> Be sure to edit the following files in 'shared/config':"]
  shared_configs.each do |conf|
    queue  %[echo "----->   * #{conf}"]
  end
end

desc "Deploys the current version to the server."
task :deploy => :environment do
  deploy do
    # Put things that will set up an empty directory into a fully set-up
    # instance of your project.
    invoke :'git:clone'
    invoke :'deploy:link_shared_paths'
    invoke :'bundle:install'
    invoke :'rails:db_migrate'
    invoke :'rails:assets_precompile'

    to :launch do
      env = ENV['to'] == 'production' ? '' : ".#{ENV['to']}"
      cmd = ENV['reload'] || 'reload'
      queue %[sudo /etc/init.d/refxgroovin#{env} #{cmd}]
    end
  end
end

# For help in making your deploy script, see the Mina documentation:
#
#  - http://nadarei.co/mina
#  - http://nadarei.co/mina/tasks
#  - http://nadarei.co/mina/settings
#  - http://nadarei.co/mina/helpers

shared_paths

shared_pathsに指定されたファイル・ディレクトリは、デプロイしたときに”#{deploy_to}/shared"にシンボリックリンクが張られるようになる。例えばconfig/database.ymlを指定すると、デプロイしたときに/home/refxgroovin/current/config/database.ymlから/home/refxgroovin/shared/config/database.ymlにリンクが張られる。これによってサーバーに依存するファイルをいちいち書き換える必要がなくなる(んだろう、たぶん)。

(※Minaはデプロイした最新版を"#{deploy_to}/current"とする。これもシンボリックリンクで、実体はreleasesフォルダ以下にある)

例えばRails 4.1のconfig/secrets.yml。これはrails newしたときに生成される.gitignoreに含まれていてリポジトリにはないはずである。ここで、shared_pathsconfig/secrets.ymlを設定して実体をshared/config/secrets.ymlに置いておけば、デプロイ時にシンボリックリンクが作成されるので捗る、と思う。(ちなみにRefixativeの場合はFigaroで管理しているのでconfig/secrets.ymlはリポジトリに存在している。その内容は単に環境変数を見るだけのものである。この場合は代わりにconfig/application.ymlが存在しないので、これをshared_pathsに設定してある)

注意点としては、あくまで自動でリンクを張るだけであって実体に関しては何も関知しないので、それは自分で作成する必要がある。

セットアップ

その辺りの設定ができたら開発環境でセットアップのコマンドを発行する。

$ bundle exec mina setup to=production

to=productionに関しては、今回はproduction/stagingを切り替えられるようにしたので必須である(デフォルトのままなら必要ない)。これでデプロイ先にはMinaのディレクトリ構造が生成され、sharedディレクトリ以下にいくつかファイルやディレクトリが生成される。

メッセージにあるとおり、shared以下のファイルは自分で編集しなければならないので、サーバー側で編集する。

デプロイ

ここまで終われば、開発環境に戻ってきて

$ bundle exec mina deploy to=production

とする。このコマンドによってsshしてgit cloneして云々……が実行され、最後にto :launchで書いた内容が実行され、デプロイは終了する。上述の例だとここで独自のinitスクリプトを用いているのでそれを用意しておく必要があるが、デフォルトだとrestart.txtをtouchして終わりなので、特に何も起きずに終了するはずである。

サーバー側環境構築

rbenvとかrubyのインストールは省略。そこでハマったのは前述の点くらいである。

initスクリプトを書こう

Gentoo Linuxでは標準でOpenRCというinitシステムを採用している。それを踏まえた上で、unicornのデーモンを管理するためのinitスクリプトを書く。書き方はGentoo Linux Documentation – Initscripts辺りを見たり、他のinitスクリプトを参考にする。今回は特にapache2とpostgresql-9.3を参考にして書いてみた。

#!/sbin/runscript
# Distributed under the terms of the MIT License
# $Header: $

extra_started_commands="reload"

description_graceful="Reexecutes the running binary and stops the old master after the old workers finish their current request."
description_gracefulstop="Stops the server after the workers finish their current request."
description_reload="Reexecutes the running binary and stops the old master immediately."

CONF="${RC_SVCNAME#*.}"

[ "${CONF}" == "refxgroovin" ] && CONF='production'

if [ "${CONF}" == "production" ]; then
	REFXGROOVIN_BASE="/home/refxgroovin/refixative"
else
	REFXGROOVIN_BASE="/home/refxgroovin/refixative-${CONF}"
fi
PIDFILE="${REFXGROOVIN_BASE}/shared/pids/unicorn.pid"

RBENV_DIR="/home/${REFXGROOVIN_USER}/.rbenv"
PATH="${RBENV_DIR}/bin:$PATH"

depend() {
	need net postgresql
}

start() {
	ebegin "Starting Refixative groovin'!! ${CONF} server"
	start-stop-daemon --start \
		--name unicorn_rails \
		--chdir ${REFXGROOVIN_BASE}/current \
		--user ${REFXGROOVIN_USER} \
		--pidfile ${PIDFILE} \
		--exec rbenv -- exec bundle exec unicorn_rails -c "${REFXGROOVIN_BASE}/current/config/unicorn.rb" -E production -D
	eend $?
}

stop() {
	local seconds=$(( ${GRACEFUL_TIMEOUT} + ${FORCE_TIMEOUT} ))
	ebegin "Stopping Refixative groovin'!! ${CONF} server gracefully (this can take up to ${seconds} seconds)"
	local retries="SIGQUIT/${GRACEFUL_TIMEOUT}"

	if [ "${FORCE_QUIT}" = "YES" ] ; then
		einfo "FORCE_QUIT enabled."
		retries="${retries}/SIGTERM/${FORCE_TIMEOUT}"
	fi

	start-stop-daemon --stop \
		--pidfile ${PIDFILE} \
		--retry "${retries}"
	eend $?
}

restart() {
	stop

	local timeout=${RESTART_TIMEOUT:-10}
	local i=0 retval=0
	while [ -e ${PIDFILE} ] && [ $i -lt ${timeout} ] ; do
		sleep 1 && i=$(expr $i + 1)
	done
	[ -e ${PIDFILE} ] && retval=1
	eend ${retval} "Unable to confirm whether the server stopped or not."
	[ ${retval} -ne 0 ] && return ${retval}

	sleep 2 # waiting a little for COMPLETELY stopped the old master

	start
}

reload() {
	local retval=0

	ebegin "Reloading Refixative groovin'!! ${CONF} server"
	kill -USR2 `cat ${PIDFILE}`
	retval=$?
	eend ${retval} "Unable to reload the server."
	[ ${retval} -ne 0 ] && return ${retval}

	local timeout=${RELOAD_TIMEOUT:-10}
	local oldbin="${PIDFILE}.oldbin"
	ebegin "Waiting for restarting (this can take up to ${timeout} seconds)"
	local i=0
	retval=0
	while [ ! -e ${oldbin} ] && [ $i -lt ${timeout} ] ; do
		sleep 1 && i=$(expr $i + 1)
	done
	[ ! -e ${oldbin} ] && retval=1
	eend ${retval} "Unable to found the old pidfile at ${oldbin}"
	[ ${retval} -ne 0 ] && return ${retval}

	ebegin "Stopping old Refixative groovin'!! ${CONF} server"
	kill -QUIT `cat ${oldbin}`
	eend $?
}

# vim: ts=4 filetype=gentoo-init-d

reloadはUSR2+QUITの組み合わせでダウンタイムなしでの更新を行うものである。あとrestartはタイミングの問題か何かで、前のmasterがリッスンしているポートを手放す前に新しいmasterが動き始めて起動に失敗することがあったので、とりあえず間にsleep 2を入れてある。ひとまずこれで安定して再起動できる。適当。

最初は強制的なstop(TERMシグナル)とgracefulなstop(QUITシグナル)を別のコマンドに分けていたのだが、stop()のときに呼ばれる処理がgracefulstop()とかにしてしまうと呼ばれないために、gracefulstopしてもサービスが停止していないと判断されてしまった。そんなわけで、postgresqlのスクリプトを参考にしてstopを書き換えた。start-stop-daemon--retryオプションに所定の形式のリストを渡すと、最初の要素から順に指定されたタイムアウト分だけ待ちながらシグナルを送ってくれる。これを用いて、最初はQUITを送り、それから一定時間経っても終了出来なければTERMを送るような処理になっている。

こんな感じのスクリプトを/etc/init.d/refxgroovinとして作成して、あといくつか変数を定義したファイルを/etc/conf.d/refxgroovinとして作成する。conf.dの方ではREFXGROOVIN_USERの設定が必須である。これ以外にいくつかのタイムアウトの設定を行う。そうしたら

$ /etc/init.d/refxgroovin start

としてサービスを起動させる。これでログを確認してmasterがreadyになっていて、かつcurlか何かでリクエストを投げて返事が返ってくれば成功である。

stagingの話

ところで今回はstaging環境にも対応させている。initスクリプトはrefxgroovin.(env)という名前でrefxgroovinへのシンボリックリンクを張ればおしまいである。つまり、refxgroovin.stagingという名前でシンボリックリンクを作成すると、それがstaging環境向けのinitスクリプトになる。

conf.dの方はシンボリックリンクではなくコピーで対応する。

sudoers

デプロイが完了したとき、refxgroovinユーザーでサービスの再起動またはreloadが出来ればよい。しかしながらサービスの操作というのは当然スーパーユーザーでないとできない。そこでsudoである。でもrefxgroovinユーザーはunicornを動かすので、仮にunicornやRails、アプリケーション自身に任意コマンド実行の脆弱性があった場合、sudoから何かされる可能性がある。だからといってrefxgroovinユーザーにパスワードを設定するとデプロイする度にパスワードを入力しなきゃいけなくて、それは面倒(ここは運用方針に依存する気がするけど)。

そういうわけで、refxgroovinユーザーに対しては「任意のユーザーとして、パスワードなしで、/etc/init.d/refxgroovinと/etc/init.d/refxgroovin.stagingの実行のみを許可する」ように設定する。

refxgroovin ALL = (ALL) NOPASSWD: /etc/init.d/refxgroovin, (ALL) NOPASSWD: /etc/init.d/refxgroovin.staging

visudoで追記する。(ALL) NOPASSWD:は2回書かなくてよかったような気もするのだが、何か動かなかったので2回書いてある(他に原因があったかもしれない)。

この状態で一度試しにサーバー上で

$ sudo /etc/init.d/refxgroovin reload

とかやって動くかどうかを確認する。動かなかったらググってみるとかmanを読むとかしてほしい。

ひとまずこれでrefxgroovinユーザーがroot(とか他のユーザー権限)で出来ることはinitスクリプトを使ってrefxgroovinデーモンを操作することだけになった、はずである。たぶん。

ちゃんと動いたならば、deploy.rbto :launchを書き換える。上記の例のようにqueueを使ってsudoを呼べばよい。編集したらデプロイしてみて、ちゃんとデプロイが成功するかどうかを確認する。

私は当初unicornのpidfileをデフォルトの位置に設定していたが、この設定ではreloadが失敗した。

pidfileのデフォルトの位置は#{APPROOT}/tmp/pids/unicorn.rbだが(今回であればAPPROOT/home/refxgroovin/current)、Minaがlaunchを試みるのはcurrentが最新版のコードベースに置き換わった後である。つまり、例えば元々releases/4がcurrentだったとして、その状態でデプロイしてto :launchが動くのはcurrentがreleases/5に置き換わった後になる。要はpidfileはreleases/4/tmp/pidsにあるのに、releases/5/tmp/pidsを見に行ってしまう。当然そこには何もないのでpidfileがないといって失敗する。

そんなわけで明示的に、かつデプロイ時にシンボリックリンクとかで実体の位置が変わらない場所にpidfileの場所を設定しておく必要がある。今回の場合はshared/pids/unicorn.pidにした。shared以下に配置しているが、shared_pathsには設定していない(こういう使い方がMina的にOKなのかは知らない)。またディレクトリ自体がないと起動に失敗するので、setup時にshared/pidsディレクトリを作成するようにした。