こしごぇ(B)

旧:http://d.hatena.ne.jp/koshigoeb/

Heroku で運用するプレビュー環境に自動デプロイする試み(0)

中途半端だけど、せっかくやってみたので晒してみる。一番の収穫は、シェルスクリプトの trap を覚えた事だったりするのは内緒。

やっていること

  • CI で CircleCI を使用している
  • Git を使用している
  • GitHub を使用している
  • PR をレビューして master ブランチにマージする運用
  • 開発時のプレビュー環境として Heroku を使用している

やりたいこと

  • master ブランチの最新版を、労力少なくプレビュー環境に反映させたい

手作業

  • 適宜 Heroku にデプロイする運用
  • めんどくさい

Heroku の Automatic deploys

  • Heroku の GitHub 連携機能のひとつ
  • ブランチを指定しておくと、自動的にデプロイしてくれる機能
  • Rails 固有の rake db:migrate みたいな後処理は実行させられない
    • ビルドパック作ればいいのかもしれないけど、試してない
  • 残念

余談: Heroku の Pull request apps

  • 存在する PR から Heroku アプリの複製を作れる機能
  • PR が作られたら自動で複製を作ってくれる機能もある
  • app.json を使う
    • 環境変数の継承が使えて便利
    • PostgreSQL 9.4 を使う場合、今は --version=9.4 を付ける必要があり、それを app.json の addons で記述する方法が分からない
  • 便利 and 残念

CircleCI Heroku Deployment

  • Continuous Deployment with Heroku - CircleCI
  • CircleCI のアカウント設定で Heroku の API キーを登録する
  • CircleCI のプロジェクト設定の Heroku Deployment で、使用する SSH キーのユーザをセットする
    • ボタンを押すと Heroku と GitHub に公開鍵が登録される
  • circle.yml の deployment でデプロイ設定書いたら ok
  • 簡単 and 便利

雑感

  • koshigoe/rails-example-circleci-heroku-deployment
  • 既存環境の上書きは余計な心配を抱える事になって上手くないかもしれない
  • Blue-Gree Deployment 的な考えで、毎回アプリを作成する方向で考えた方が健全かもしれない
    • URL を変えずに運用する工夫の手間次第
  • master ブランチの自動デプロイはやりすぎかもしれない
    • 同一 Heroku アプリに対する並行デプロイが発生するはず
  • 動作確認用の初期データを db:seed で冪等対策せず入れる事を考えているため、毎回 db:setup してしまっている
    • プレビュー環境で確認中にデプロイされてデータが消えるとか、余計にストレスたるはず
  • データベースのロールバックってどうするんだっけ
    • 今回のリリースで変更したスキーマとデータを元に戻す、という作業になるんだっけ
    • 今回は無理矢理 db:setup できるから気にしなくてもいいんだけど
  • CircleCI の出力無しタイムアウトの上限はどれだけなのか
    • コマンド全体のタイムアウトは別にあるのか、あるとして上限はどれだけなのか
    • timeout: 3 して while で sleep 1 をすると延々続けられるみたいだけど…
machine:
  ruby:
    version: 2.2.1
dependencies:
  cache_directories:
    - "vendor/bundle"
deployment:
  staging:
    branch: master
    commands:
      - ./deploy.sh:
          timeout: 600 # default
#!/bin/sh -ex
# -e を使って途中でエラーが発生したら即終了へ

# HEROKU_APP_NAME が未定義なら -u で拾って即終了
# HEROKU_APP_NAME が空なら if -z で拾って即終了
if [ -z "$HEROKU_APP_NAME" ]
then
    echo "\$HEROKU_APP_NAME required."
    exit 1
fi

# 1) まずはメンテナンスモードへ、失敗したら即終了
heroku maintenance:on -a $HEROKU_APP_NAME

# よくあるシグナルを受け取ったら異常終了
trap "echo trap SIGHUP;  exit 1" HUP
trap "echo trap SIGINT;  exit 1" INT
trap "echo trap SIGQUIT; exit 1" QUIT
trap "echo trap SIGTERM; exit 1" TERM
# CircleCI の timeout は拾えない様なので諦める

# ここ以降での終了を拾って後処理する
#
# 正常終了時)
#   1) メンテナンスモード終了
#
# 異常終了時)
#   1) ロールバック
#   2) DB セットアップ(リセット)
#   3) メンテナンスモード終了
trap "
case \$? in
  0 )
    echo Auto deploy successful.
    ;;
  * )
    heroku rollback -a $HEROKU_APP_NAME
    heroku run rake db:setup -a $HEROKU_APP_NAME
    echo Auto deploy failed.
    ;;
esac

heroku maintenance:off -a \$HEROKU_APP_NAME
" EXIT

# 2) デプロイ
git push -f git@heroku.com:$HEROKU_APP_NAME.git $CIRCLE_SHA1:refs/heads/master

# 3) DB セットアップ(毎回リセット)
heroku run rake db:setup -a $HEROKU_APP_NAME

シェルスクリプトの trap でシグナルを拾う場合の実行中コマンド

例えば、以下の様にして TERM を拾う様にしてみる。

#!/bin/bash -x

trap "echo trap TERM; exit 1" TERM
trap "echo EXIT" EXIT

sleep 5

echo ok

以下は sleep 5 で待っている間に pkill -f trap-signal.sh で TERM シグナルを送った時の結果。

$ ./trap-signal.sh
+ trap 'echo trap TERM; exit 1' TERM
+ trap 'echo EXIT' EXIT
+ sleep 5
++ echo trap TERM
trap TERM
++ exit 1
+ echo EXIT
EXIT

この時、 sleep 5 が完了するまでは待たされる。

シグナルを trap した時に、そのシェルスクリプト内で実行中のプロセスに対してシグナルを伝播させる簡単な方法は何だろう?

Heroku で Reverse Proxy

ろくに調べもせず、明後日の方向に進んでいる気がするけれど。

require 'rack/reverse_proxy'

if ENV['BASIC_AUTH_USERNAME'] && ENV['BASIC_AUTH_PASSWORD'] && !ENV['BASIC_AUTH_USERNAME'].empty? && !ENV['BASIC_AUTH_PASSWORD'].empty?
  use Rack::Auth::Basic do |username, password|
    username == ENV['BASIC_AUTH_USERNAME'] && password == ENV['BASIC_AUTH_PASSWORD']
  end
end

use Rack::ReverseProxy do
  reverse_proxy_options preserve_host: true, replace_response_host: true
  if ENV['BASIC_AUTH_USERNAME'] && ENV['BASIC_AUTH_PASSWORD'] && !ENV['BASIC_AUTH_USERNAME'].empty? && !ENV['BASIC_AUTH_PASSWORD'].empty?
    reverse_proxy '/', ENV['REVERSE_URL'], username: ENV['BASIC_AUTH_USERNAME'], password: ENV['BASIC_AUTH_PASSWORD']
  else
    reverse_proxy '/', ENV['REVERSE_URL']
  end
end

app = proc do |env|
  [ 200, { 'Content-Type' => 'text/plain' }, 'b' ]
end

run app

jaswope/rack-reverse-proxy を使って実現。gem だとリダイレクト対応がされていないので、GitHub の最新ソースを使用。

igrigorik/em-proxy も試してみたけれど、リダイレクト対応がよく分からず断念した。

最新 master ブランチからアプリを作って、デプロイが成功したら Reverse Proxy アプリの環境変数を更新(heroku config:set)してスイッチする、様な感じで使えれば良いな、と。

  • Heroku の dyno 1 個で Reverse Proxy を動かすのはどうなんだろうか
    • EventMachine 系であれば良いのかな
    • Ruby 実装調べちゃったけど、node.js で Reverse Proxy したら良かったのかな
  • リダイレクト対応が怪しい
    • ログイン後にリダイレクトする様なアプリで試して上手くいったけど…
    • google.com のリダイレクトはループした…

まあ、おもちゃ、かな。

FactoryGirl を使って冪等な Database seeding をやってみた

development, test 以外で FactoryGirl を使うのどうなんだ、という話は全力で脇に置く。こういう誤魔化し方もあるのかな、という試み。

んー、seeds.rb で find_or_create_by を書かなくて良い、程度のおもちゃかな…。

構造

db/ 以下に色々置く感じの構造。

$ tree db/seeds
db/seeds
├── development.rb
├── factories
│   ├── cities.rb
│   ├── countries.rb
│   └── prefectures.rb
├── factories.rb
├── production.rb
└── test.rb
  • rake db:seed すると db/seeds/#{env}.rb が require されてコードが実行される
    • 共通処理書きたければ、各ファイルから共通ファイル require する、とか?
  • db/seeds/factories.rb には共通処理を書いておく
  • db/seeds/factories/**/*.rb にファクトリを置く
    • ファクトリは initialize_with { find_or_initialize_by } して冪等保証してるだけ

db/seeds.rb

これで spec/factories/ の方との干渉を防げるか自信なし。

表出力は本題とは関係なくて適当に。 Thread.current も特に意味はなく適当に。

require 'factory_girl'

include FactoryGirl::Syntax::Methods

begin
  original_definition_file_path = FactoryGirl.definition_file_paths
  FactoryGirl.definition_file_paths = %W(db/seeds/factories)
  FactoryGirl.reload

  begin
    Thread.current[:seeding_results] = Hash.new {|h, k| h[k] = [] }

    ActiveRecord::Base.transaction do
      require_relative "seeds/#{Rails.env}"
    end

    Thread.current[:seeding_results].each do |klass, ids|
      puts Hirb::Helpers::AutoTable.render(klass.where(id: ids))
    end
  end
ensure
  FactoryGirl.definition_file_paths = original_definition_file_path
end

使い方

ファクトリを書く

冪等保証のために initialize_with { find_or_initialize_by } を使っているので、モデルごとにファクトリを作る必要がある。

ファクトリは db/seeds/factories/ 以下に配置する想定。

FactoryGirl.define do
  factory :prefecture do
    initialize_with { Prefecture.find_or_initialize_by(country: country, name: name) }
  end
end

seed を書く

実際の seed 処理は db/seeds/#{Rails.env}.rb に書く想定なので、それぞれ書く必要がある。

create(:country, name: '日本') do |japan|
  create(:prefecture, country: japan, name: '東京都') do |tokyo|
    create(:city, prefecture: tokyo, name: '千代田区')
    create(:city, prefecture: tokyo, name: '港区')
  end
  create(:prefecture, country: japan, name: '神奈川県') do |kanagawa|
    create(:city, prefecture: kanagawa, name: '横浜市')
    create(:city, prefecture: kanagawa, name: '川崎市')
  end
end