Herokuのappを二つ使って交互起動で常時運用(Sinatra版)

この記事は2016/5/31までの内容です。



Twitter bot の運用に heroku を使っている [twitter:@riocampos] です。Web app はあまり使っていません。

でも記載しましたが、 Free dyno だと18時間起動/6時間強制睡眠を強要されるようになります(現状ではまだ24時間起動出来ていますが、そろそろ強要されそう)。
ということで対策を講じました。2つの app を使って交互に起動させる、という手段です*1
process type として web を使います。それ以外では通信が行えないのでダメです。が、web だと30分の間にアクセスが生じないとプロセスが止められるのでその対策も行っています。
また、希望の時間帯に app を止めるために Process Scheduler を使っています(もちろん実行時間を一日18時間までに設定)。

heroku で複数の app を登録する方法

はこちらを見てください。
複数の app を登録する場合 - Herokuのapp作成手順(buildpack-multiを使う場合も含む) - Qiita

alternateapps.rb

こちらで相手側との通信を行います。
簡単に言えば「自分が起きたら相手に知らせる」「相手から知らせがあったら定期実行している作業を止める(グローバル変数を使う)」ということで交互起動を実現しています。
具体的には http://anotherappurl/update へのベーシック認証アクセスを行うことで「起動したよ」通知としています。
(現状だと意味はないのですが)相手からの通知があった場合に REST 的に JSON でレスポンスを返しています。
なお、環境変数にベーシック認証の ID/PW や自分/相手のURLを登録しています(ベーシック認証の ID/PW を共用していますが、もちろん別個にした方が安全ですね)。ローカルで実行確認するときに dotenv gem を使いました。
環境変数を heroku に登録するには Setting up config vars for a deployed application のように heroku config:set コマンドを使います。

#!/usr/bin/env ruby
# coding: utf-8

require 'sinatra/base'
require 'sinatra/json'
require 'dotenv'

require 'open-uri'
require 'json'

$stdout.sync = true
$update = nil
Dotenv.load

certs =  [ENV['BASIC_AUTH_USERNAME'], ENV['BASIC_AUTH_PASSWORD']]
begin
  JSON.parse(open(ENV['ANOTHER_SITE_URL'], {:http_basic_authentication => certs}).read)
rescue Errno::ECONNREFUSED, OpenURI::HTTPError
  puts "Another app does not wake."
end

require './schedule' #ここで通常のアプリ(ただしjoinしてないもの)を読み込む

class App < Sinatra::Base
  use Rack::Auth::Basic do |username, password|
    username == ENV['BASIC_AUTH_USERNAME'] && password == ENV['BASIC_AUTH_PASSWORD']
  end
  
  configure do
#    register Sinatra::Reloader
#    enable :logging
    set :port, ENV['PORT'].to_i
  end
  
  get '/update' do
    $update = true
    puts "Recieve access from another app. Stop the cron processes."
    JSON.dump({status: 'Update'})
  end
  
  get '/*' do
    404
  end
  
  run! if app_file == $0
end

ちなみに Heroku のポート番号は環境変数 PORT に入っています。

schedule.rb

こちらが定期実行している作業のスクリプト例です。
私は cron 作業を行わせるのに rufus-scheduler gem を常用しているので、ここでもそれを使って書いています。ここでは定期作業の例として毎時0/10/20/30/40/50分に時刻をログ出力させています。相手の app から知らせがあるとグローバル変数 $update が true になるので、定期作業ブロックの冒頭に next if $update を置くことで定期作業を止めています。
また、scheduler.every '15m' のブロックで自分に15分おきにアクセスしており、それにより「Heroku ルータに30分アクセスがないとプロセスを落とす」という web process type の弊害を回避しています。もちろん Thread.start してsleep 15*60 したloopブロックでアクセスしてもいい(はず)です。

require 'rufus-scheduler'
require 'open-uri'

scheduler = Rufus::Scheduler.new

scheduler.every '15m' do
  begin
    open(ENV['MY_SITE_WAKE_URL']).read
  rescue OpenURI::HTTPError => ex
    puts 'wake up access.'
#    puts ex.backtrace.first + ": #{ex.message} (#{ex.class})"
  end
end

scheduler.cron '0 */10 * * * *' do
  next if $update
  puts Time.now
end

なお、こちらのスクリプトを参考にして Thread を使った場合、末尾に thread.join させたくなる(また rufus-scheduler でも scheduler.join させたくなる)のですが、そうするとドツボに嵌まりますのでご注意ください。参考:Sinatra内でloopなど定期作業を行うには - 別館 子子子子子子(ねこのここねこ)

補足

この app ではごく単純に「相手が起動したらこちらは定期作業をそれ以降実行しない」ようにしていますが、もちろん

  get '/down' do
    $update = nil
    JSON.dump({status: 'Down'})
  end

のような map を追加しておけば、相手から http://myappurl/down へアクセスがあれば定期作業を再開出来ます。適当に機能追加してもいいでしょう。

補足2

web プロセスを使っているので、スクリプトの起動から60秒以内に Sinatra が動作している状況をつくる必要があります。もし起動にそれ以上掛かっている場合には

Error R10 (Boot timeout) -> Web process failed to bind to $PORT within 60 seconds of launch 
Stopping process with SIGKILL 
Process exited with status 137

のように、エラーメッセージが出た後に終了してしまいます。ご注意下さい。

参考

  • open-uri でのベーシック認証の方法
singleton method OpenURI.open_uri

open_uri(name, mode = 'r', perm = nil, options = {}) -> StringIO

  • :http_basic_authentication

HTTP の Basic 認証のためのユーザ名とパスワードを、文字列の配列 ["user", "password"] として与えます。

ひとりごと

とうとうグローバル変数を使ってしまった…。

*1:本当はお金を払うべきなんだけどな(汗