ドメイン名を使ってEC2を運用していたら、ELBのスケールアウトで苦労した話

2014年5月14日 13時00分 修正 タイトルが誤解を招くものだったので、「なぜ URL に www を付けるのか。または、サブドメインなしでは CNAME が使えない件」から変更致しました。併せて、画像に Public IP と Private IP の明記を行いました。

皆さん、こんにちは。MUGENUP の osada です。 今回は、スケールアウト時にELB(Amazon Elastic Load Balancer) を使うときの注意点についての記事です。 といっても、インフラ・エンジニアには自明のことと思いますので、読者の対象は インフラ・エンジニアではないけど、インフラもやるというベンチャーならではのエンジニア向けです。

要旨

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

弊社 のインフラは AWS です。

まず弊社のインフラをお話すると、AWS(Amazon Web Service)を使用しており、CTO の伊藤がメイン・オペレーションを担当しています (彼は TCP/IP や WebSocket の RFP などを嬉々として語る通信系のツワモノです)。 しかし、それでは CTO が 単一障害点(Single Point of Failure, SPOF) となってしまうため、 社員全員が AWS へのアクセス権を持ち、一通りオペレーションができる状態となっています。

スケールアウトの頓挫

さて、先日、ご好評いただいている弊社のゲーム攻略サイトみなゲー(β版)が アクセス増のため、スケールアウトが必要となりました。

サーバ1台だったものを、ロードバランサーを使って、2台構成にする という、とても単純な作業です。 ……と、その時はまだ思っていたのです。

元々は、サーバ1台に対して、IP を使って DNS を貼っていました。

f:id:mgnup:20140514121236p:plain

そこで、ELBに従来のIPを設定し、ELBから、新しい2台のサーバに振り分けることを計画しました。

f:id:mgnup:20140514121239p:plain

ところが、AWSロードバランサーである、ELB (Elastic Load Balancing) には、 IP を振ることができなかったのです。

ELB が IP を持たない理由

SPOF(単一障害点) の話を上述しましたが、これはロードバランサーにおいても同じです。 もしロードバランサー1台が壊れてしまったら、どのサーバにもアクセスできなくなってしまいます。 ELBはそれを避けるため、複数台のロードバランサーを1つとして扱っているようです。 そのため、ELB 自体には、DNS が振られるのであって、IP を振ることはできません。

f:id:mgnup:20140514121241p:plain

IP を振る とは? [DNS の設定]

DNS とは、ドメイン名(mina-game.com など)を扱うサービスです。

ドメイン名の扱い方には複数ありますが、 本稿では、IPアドレスと結びつけるAレコードと、別名を付けるCNAMEレコードを取り上げます

レコード名 役割
A (Adress) レコード IPアドレスと結びつける mina-game.com => 123.456.78.90
CNAME レコード ドメイン名に別名を付ける mina-game.com => www.mina-game.com

今まで、mina-game.com にアクセスすると、123.456.78.90 のサーバにアクセスできていたのは、 Aレコード が付いていたからです。

ところが、ELB には IP を付けられない ため、Aレコードを貼ることができません。

よって、この場合、 CNAME を貼る ことが正解となります。

……そう、その時は、それで解決すると思っていたのです。

CNAME には、サブドメイン(ホストドメイン)が必須

ところが、ドメインmina-game.com に対して、CNAME を振ることは出来ない のです。

f:id:mgnup:20140514121244p:plain

少々長いですが、AWS から引用します。

ドメイン名を登録する際は、ドメイン名自体だけでなく、一連のサブドメイン名全体も予約することになります。例えば、example.com をカスタムドメイン名として登録する場合、foo.bar.example.com や foo.myLB.example.com などのサブドメイン名を作成することができます。この一連のドメインサブドメイン名は、ゾーンと呼ばれます。予約した example.com などのドメイン名は、ゾーンの階層の最上部にあるため、Zone Apex と呼ばれます。 DNS ルールでは Zone Apex(example.com など)での CNAME レコードの作成が許可されていません。例えば、example.com というドメイン名を所有している場合、CNAME レコードはサブドメイン名である foo.example.com には使用できますが、Zone Apex である example.com には使用できません。 http://docs.aws.amazon.com/ja_jp/ElasticLoadBalancing/latest/DeveloperGuide/using-domain-names-with-elb.html

mina-game.comゾーンの階層の最上部にある、Zone Apex であり、それには CNAME を使用する事ができないのです。

もし、www.mina-game.com などの サブドメイン を指定してあったなら、 cname の差し替えで期待どおりの処理が終わっていたことでしょう。 しかし、今回は zone apex で運用していたため、ELB を使ったスケールアウトをすることはできませんでした。

Route 53 を使用する

zone apex で運用を始めてしまった以上、ELB でのスケールアウトは無理なのでしょうか?

AWSのヘルプの続きに、こう書いてあります。

Zone Apex とロードバランサーDNS 名を関連付ける場合は、次のオプションを使用します。 Option 2: DNS サービス Amazon Route 53 はホストゾーンのドメインに関する情報を格納するため、Amazon Route 53 を使用してドメインを作成します。

あ、Route 53 使えば良いんですね。

まるでこのために作られたのではないかと思えるくらい、 完璧なお膳立てで、AWS 製の DNS Amazon Route 53 を案内されてしまいました。

というわけで、zone apex を使って AWS ELB でスケールアウトするには、route 53 を使おう、という結論に落ち着きました.

f:id:mgnup:20140514121247p:plain

元々は使用していなかったサービスが一つ追加で必要になってしまいましたが、無事目的を達成出来ました。

まとめ

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

というわけで、インフラ初心者が送る スケールアウトで困った経験の共有でございました。

参考

もっと詳しい解説

【Rails3.2】【バッドノウハウ】includes 使用時に where で ドットを使うと想定外になるかも知れない件、または、なぜRails4 から references が必要になったのか

皆さん。お久しぶりです。MUGENUP の osada です。 某死にすぎるゲームの1周目をクリアしたので、戻ってまいりました! 目標を達成するために大事なことは観察力である、ということを思い知らされるゲームでした。

さてそんな今回は、Rails 3.2includes に関するバッドノウハウの共有です。 弊社 okuda が、トライ・アンド・エラーの末に見つけた、バグの解説になります。 Rails 4 系 をお使いの方には不要ですので、何かの話のネタになれば幸いです。

includes は LEFT OUTER JOIN と eager loading の 2 種類がある

Railsでは、includes は 2種類の使い方ができます。 1. eager loading 2. LEFT OUTER JOIN

通常の使い方だと、(1) の eager loading となり、 where 句の中で、includes した テーブルを参照すると、 (2) の LEFT OUTER JOIN となる、という認識でした。

class User < ActiveRecord::Base
  attr_accessible :email, :name
  has_many :user_items
end

describe User do
  let!(:user) { create :user, name: "user0", email: "user0@example.com" }

  it "eager loading" do
    subject = User.where("`name` LIKE ?", "%user0%").explain
    expect(subject).to include "SELECT `users`.* FROM `users`  WHERE (`name` LIKE '%user0%')"
    # => User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE (`name` LIKE '%user0%')
    # => UserItem Load (1.3ms)  SELECT `user_items`.* FROM `user_items` WHERE `user_items`.`user_id` IN (252)
  end

  it "outer join by string" do
    subject = User.includes(:user_items).where("user_items.number IS NULL").explain
    expect(subject).to include "LEFT OUTER JOIN"
    # => SQL (2.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE `user_items`.`number` IS NULL
  end
end

一体何が問題なのか?

上でLEFT OUTER JOIN になる条件を

where 句の中で、includes した テーブルを参照する と書いたのですが、それは誤りでした。

端的に言うと、 where 句の中で、ドットが含まれていれば、LEFT OUTER JOIN とみなす 、ということだったのです。

何が問題になるのでしょうか?

皆さん。where 句に email を使ったことはありませんか?

it "like email" do
  subject = User.includes(:user_items).where("`email` LIKE ?", "%user0@example.com%").explain
  expect(subject).to include "LEFT OUTER JOIN"
  expect(subject).to include "SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (`email` LIKE '%user0@example.com%')"
end

user_itemsjoin するつもりがないのに、join されています。 email にはほとんど必ずドットが 含まれます。 email 以外では eager loading なのに、email で where をした途端に、いきなりLEFT OUTER JOIN になってしまうのです。 無論それは、email に限りません。nameでドットが含まれていれば同じことです。

it "like name with dot" do
  subject = User.includes(:user_items).where("`name` LIKE ?", "%user0.name%").explain
  expect(subject).to include "LEFT OUTER JOIN"
end

さらには、stringで構築しているから、という理由でもありません。 arel_table で構築しても、結果は同じです。

it "like name with dot on arel" do
   subject = User.includes(:user_items).where(User.arel_table[:name].matches("%user0.name%")).explain
   expect(subject).to include "LEFT OUTER JOIN"
end

なぜこんなことになってしまうのでしょうか?

それは、eager loadingLEFT OUTER JOIN を区別するものが、下記のメソッドだからです。

references_eager_loaded_tables? で、どちらなのかを決めますが、 その決定権を握るのがtables_in_stringです。

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (tables_in_string(to_sql) - joined_tables).any?
end

def tables_in_string(string)
  return [] if string.blank?
  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  # ignore raw_sql_ that is used by Oracle adapter as alias for limit/offset subqueries
  string.scan(/([a-zA-Z_][.\w]+).?\./).flatten.map{ |s| s.downcase }.uniq - ['raw_sql_']
end

string.scan の中で、sql の中に、ドットが含まれているかどうか、のみで判断していることが見て取れると思います。

末尾の行を pry した結果が下記です。

[1] pry(#<ActiveRecord::Relation>)> to_sql
=> "SELECT `users`.* FROM `users`  WHERE (`users`.`name` LIKE '%user0.name%')"
[2] pry(#<ActiveRecord::Relation>)> tables_in_string(to_sql)
=> ["users", "user0"]
[3] pry(#<ActiveRecord::Relation>)> joined_tables
=> ["users"]
[4] pry(#<ActiveRecord::Relation>)> (tables_in_string(to_sql) - joined_tables).any?
=> true

確かに、ドットで判断していることが分かります。

まとめ

以上が、Rails3.2で、includesに起きる、バッドノウハウです。 知っていればどうということはありませんが、意図せず想定外の結果を産むこともありますので、十分お気をつけ下さい。

むしろ、この機会に、最近リリースされたRails4.1.0に乗り換えるのも良いのではないでしょうか。

(おまけ) Rails 4.1 では、LEFT OUTER JOIN の使用に、references が必要になった。

さて、そんなわけで、Rails 4から、LEFT OUTER JOINしたい時は明示的に、references` を書くことになりました。

User.includes(:user_items).where("user_items.number IS NULL")
# => Mysql2::Error: Unknown column 'user_items.number' in 'where clause': SELECT `users`.* FROM `users`  WHERE (user_items.number IS NULL)

User.includes(:user_items).where("user_items.number IS NULL").references(:user_items)
# => SQL (0.4ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (user_items.number IS NULL)

上述のメソッドは、下記のように書き換わりました

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (references_values - joined_tables).any?
end

変更されているのは、末尾の (references_values - joined_tables).any? です。 3.2系のときは、(tables_in_string(to_sql) - joined_tables).any? でした。

これらの問題については、こちらで検討され、修正がされたようです。 includes eager loading is realized with LEFT OUTER JOIN strategy when querying with value contains 'dot' · Issue #7177 · rails/rails

Deprecatedになった、ということでしたが、今回試したところ、エラーになりました。 確認したところ、Rails 4.1 では、使用不可になったようです。 Remove implicit join references that were deprecated in 4.0.

(おまけ 2) Arel を使おう

事例を説明するために、where の中に文字列を書きましたが、sexy ではありません。 綺麗に書きましょう。

it "like name call outer join" do
    subject = User.includes(:user_items).where(user_items: { number: nil } ).explain
    expect(subject).to include "LEFT OUTER JOIN"
end

(おまけ 3)

使用したテストを置いておきます。

備考1

SQLの確認に、to_sql ではなくexplain を使っているのは、Rails 3系のto_sqlは、eager loading を考慮しないためです。 Rails 4系では修正されています。

ActiveRecord::Relation#to_sql does not match SQL executed when using includes · Issue #6132 · rails/rails

MUGENUP勉強会を開催しました!

こんにちは、MUGENUPの伊藤です。

今回は先日2/27日に開催した勉強会をまとめてみました!

当日は外部の方を6人お招きし、全部で12人での開催となりました。

f:id:mgnup:20140227213058j:plain

開催までの流れ

MUGENUPの開発部では隔週木曜日に勉強会を開催しています。 元々は少人数でそれぞれの人が好きなことを発表するスタイルで、発表中にツッコミとかしながら和気あいあいとやっていました。

また、年末に外部の人を呼んでやったところ好評で、その後定期的に参加してくれるようになった人もいます。

そんなこんなで「本格的に外部の人を呼んでやってみたい!」と思い、元々MUGENUPと御縁のあったエンジニアの方や、知り合いづてなどで何人かに声をかけてみました。

すると皆さん快諾して頂き、晴れてこの勉強会開催にこぎつけました。

会場

今回はMUGENUPの入っているビルにある「シアタールーム」というところで開催しています。 名前の通り映画鑑賞や、カラオケなんかもできちゃう部屋なんですがソファでくつろげたり照明をいじれたりで結構雰囲気を出せるような部屋になっています。

f:id:mgnup:20140213194143j:plain

当日の内容

当日は19:30 ~ 21:30の2時間で予定していました。 普段は一人あたり5~10分の発表で、発表中にツッコミを入れたりするので一人あたりだいたい15分くらいでのんびりやっていました。

しかし、段々と参加者が増えていき、「あれ、これそもそも発表時間オーバーするかも」などと思っていたのですが、 最終的には発表者が10名程だったことからなんとかギリギリ全員が発表できました。

さて、気になる当日の発表内容ですが、

  • vagrant, chef, knife soloを使ってmunin-nodeを複数サーバーに展開し監視をするまで

  • 酔っぱらいレベルを測定できるアプリNomBay

  • Semantic Versioningについて

  • psd のファイル構造、または photoshop plugin を作る的な何か

  • 楽しくなるPerl開発環境

  • RubyのSymbolについて

  • 電子マネーに関する何か

  • binding.pryに隠れている黒魔術について

といった内容の発表がありました。

各人が好きなことを発表し合った結果、内容がかなりばらつき 普段なら接することはない技術をお互いが知ることができてかなり刺激的な勉強会になりました。

ちなみに私もperlは使ったことがなかったので、その後インストールして色々いじってみました。

社内では「また外部の人を呼んで勉強会をやろう!」という風に盛り上がったので、 また近いうちに開催予定です。

ご興味の有る方は伊藤までご連絡ください!

宣伝

MUGENUP では、知りたがりなエンジニアを募集中です。

無限流開発、ご一緒しませんか?

エントリーはこちら http://mugenup.com/recruit/information#web_application

f:id:mgnup:20131118023234p:plain

Rails 4.1 の spring で paralell_tests を使う方法

みなさん、こんにちは! 2週間ぶりのご無沙汰、MUGENUP の osada です。

ruby 2.1.0Rails 4.1 で開発した、みなゲー編集部が正式リリースとなりました! よろしくお願いします。

さて、そんなRails 4.1の新機能として、プレローダーspringが標準装備となりました。 本日はspringparalell_testsを併用する方法についてのお話です。

要旨は下記となります。

  • PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

では、よろしくお願いします。

PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない

Rails 4.1のテストで、rspecでは成功するのに、paralell_testsでは多くが失敗する、という現象に遭いました。 springが原因と考えられます。

通常、rakerspecコマンドを使うとき、コード(code)を読み込んで、メモリ上にRailsappインスタンスを生成し、使用します。処理が終わると、appは解放されます。

f:id:mgnup:20140223195200p:plain

この処理はかなり重い処理なので、毎回作るのではなく、一度作ったappを使いまわそう、というのがspringの動きです。springで作られたappは処理が終わっても維持され、再度使用されます。

f:id:mgnup:20140223195207p:plain

このspringbin_stubとして提供され、bin/というディレクトリに rails, rspec, rakeのコマンドが用意されます。例えば、

be bin/rake db:create

のように、通常のコマンドの代わりに、bin/下のコマンドを呼び出すことで、springが使用されます。

一方、paralell_testsというのは、rakeなどの処理を複数のプロセスに分けて同時に処理する方法です。

f:id:mgnup:20140223195212p:plain

例えば、parallel:specを実行すると、下記の4つのコマンドが実行されます。

bebundle execalias です

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=3;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=4;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=2;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec

しかし、これにspringを使用すると、4つのプロセスがあるのに、実際に稼働するappは1つになってしまいます。

f:id:mgnup:20140223195216p:plain

よって、テストが失敗してしまうのです。

原因はparalell_testsbin/rspecを使用するという点にあります。これをどうにかしましょう。

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bin/rspec
……

結論からいうと、PARALLEL_TESTS_EXECUTABLEを設定することで、bin/rspecの使用を回避できます。

PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bundle exec rspec
……

この理由は、parallel_testsは、実行するファイルを下記のように設定しているからです。

def executable
  ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
end

またrspecdetermine_executableは下記であり、[bin/rspec, script/spec, bundle exec rspec, spec, rspec] のどれかを起動する事がわかります。

def determine_executable
  cmd = case
  when File.exists?("bin/rspec")
    "bin/rspec"
  when File.file?("script/spec")
    "script/spec"
  when ParallelTests.bundler_enabled?
    cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
    "bundle exec #{cmd}"
  else
    %w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
  end

  cmd or raise("Can't find executables rspec or spec")
end

bin/rspecを削除しても良いのですが、それではspringが使えなくなるので、 PARALLEL_TESTS_EXECUTABLEを設定する方が適切でしょう。

以上、本項の結論は、

parallel_testsに、springを使わせないためには、PARALLEL_TESTS_EXECUTABLEを設定する となります。

bin/rspecを書き換えて、始めのプロセスだけspringを使う

しかしせっかくのspringです。 4つの内1つだけでspringを使い、 残り3つを別のプロセスで動かせば良いのではないでしょうか?

f:id:mgnup:20140223195219p:plain

相手はbin_stubですから、修正はとても簡単です。

bin/rspecのこれを、

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  ARGV.unshift "rspec"
  load Gem.bin_path("spring", "spring")
end

こうしましょう!

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  if ENV["TEST_ENV_NUMBER"].nil? || ENV["TEST_ENV_NUMBER"].empty?
    ARGV.unshift "rspec"
    load Gem.bin_path("spring", "spring")
  else
    exec "bundle", "exec", "rspec", *ARGV
  end
end

ENV["TEST_ENV_NUMBER"].nil? は通常のbin/rspec起動時です。 ENV["TEST_ENV_NUMBER"].empty?は、parallel_testsの一番始めのプロセスの時です。

つまり、

bin/rspecとして起動したとき、または、parallel_testsの一番始めのプロセスの時に、springを使用する。

というbin_stubになりました。

さて、肝心のスピードアップですが、featureテストなどが多いため、私のプロジェクトでは違いが現れませんでした。 参考までとはなりますが、環境の影響が少ないspec/modelに適用した結果を載せておきます。

[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" bundle exec rake parallel:spec[model]
……
444 examples, 0 failures

Took 216.680576 seconds

real    3m47.661s
user    3m20.585s
sys     0m25.249s
[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

……
444 examples, 0 failures

Took 212.844729 seconds

real    3m37.395s
user    0m1.378s
sys     0m0.285s

realを見ると10 secほど速くなっているように見えますが、誤差の範囲かもしれません。 もし「速くなったよ!」という方がいらっしゃいましたら、ご一報ください!

FailuresLogger を使って、失敗したテストを再実行する

featureテストは実際のブラウザの挙動を再現するため、 タイムアウトなどの理由により、 タイミングによって失敗することもあります。 parallel_testsを使うと、さらに失敗しやすくなります。

たまたま失敗したのか、本当に失敗しているのかを確認するため、 失敗したテストのみを再度実行するのですが、 その時に役立つのが、FailuresLoggerです。

.rspec_parallelparalell_testsのみのオプションを設定できます。

# .rspec_parallel

--format progress
--format ParallelTests::RSpec::FailuresLogger --out tmp/failing_specs.log

これには失敗したテストがログに残ります。標準出力と同じものです。

f:id:mgnup:20140223200202p:plain

ここから、再度テストを実行しましょう。

$ grep rspec tmp/failing_specs.log | awk '{print $2}' | xargs bundle exec rspec

/Users/osada/projects/mugenup/workstation/vendor/bundle/gems/rspec-core-2.14.7/lib/rspec/core/configuration.rb:896:in `load': cannot load such file -- /Users/osada/projects/mugenup/workstation/spec/views/projects/edit.html.erb_spec.rb:34 (LoadError)

あら、失敗してしまいました。なぜでしょうか? 実際にファイルを開いてみたところ、ascii color codeが含まれていました。

f:id:mgnup:20140223200208p:plain

たしかに、これでは、rspecが通りませんね。 といっても--colorを消してしまうのも、嬉しくありません。

そこで、正規表現を使って、抜き出すことにしました。

  • rspec から始まり、「ドット、スラッシュ、コロン、英数字とアンダースコア」で構成される文字列を抽出する
$ ruby -ne 'puts $1 if /(?<=rspec )([\.\/:\w]+)/' tmp/failing_specs.log | xargs bundle exec rspec

これで、失敗したテストのみ、再実行することができます。

rake taskにするべきか?とも思ったのですが、さらに実行を遅らせてしまいそうなので、 aliasの登録だけにしておきました。

こうゆうとき、どういう技を使えばいいか、わからないの

rubyワンライナーを書くのは、少し大変ですね。

RuntimeLoggerを使って、テストのグループを平均化する

さて、paralell_testsはデフォルトでは、ファイルサイズ順に均等化します。 ファイルサイズの大きい順に並べて、4つのグループに順番に追加していくわけです。

RuntimeLoggerを使うことで、テストにかかった時間で、均等化することができます。

--format progress
--format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log

tmp/parallel_runtime_rspec.logには、テストファイル名と、その実行時間が記録されています。

[feature/parallel stash]~/projects/mugenup/workstation: tail tmp/parallel_runtime_rspec.log
……
./spec/views/sub_projects/show.html.erb_spec.rb:2.261982
./spec/views/projects/show.html.erb_spec.rb:2.806851

テストのグループ分けのとき、with_runtime_infoメソッドが呼ばれます。 runtime_logつまりtmp/parallel_runtime_rspecがあるとき、かつ、 今回のtestsファイル群が、このログの中に含まれていると考えられるときに、 このログを使ってグループ分けします。

puts "Using recorded test runtime"が出力されれば、成功です。

def with_runtime_info(tests)
  lines = File.read(runtime_log).split("\n") rescue []

  # use recorded test runtime if we got enough data
  if lines.size * 1.5 > tests.size
    puts "Using recorded test runtime"
    times = Hash.new(1)
    lines.each do |line|
      test, time = line.split(":")
      next unless test and time
      times[File.expand_path(test)] = time.to_f
    end
    tests.sort.map{|test| [test, times[File.expand_path(test)]] }
  else # use file sizes
    tests.sort.map{|test| [test, File.stat(test).size] }
  end
end

実際に、使用したときと、使用していないときを比較してみます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

Finished in 2 minutes 19.6 seconds
78 examples, 0 failures

Finished in 3 minutes 19.1 seconds
124 examples, 0 failures

Finished in 3 minutes 10.7 seconds
116 examples, 0 failures

Finished in 3 minutes 13.1 seconds
126 examples, 0 failures

使用していないときの処理時間は2:19.6 〜 3:19.1であり、60秒以上のズレがあります。

一方、runtime_logを使用したときは下記です。 Using recorded test runtimeが表示されていることが確認できます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]
Using recorded test runtime

Finished in 3 minutes 7.8 seconds
102 examples, 0 failures

Finished in 2 minutes 58.3 seconds
108 examples, 0 failures

Finished in 3 minutes 10.2 seconds
123 examples, 0 failures

Finished in 3 minutes 11.8 seconds
111 examples, 0 failures

2:58.3 〜 3:11.8であり、13秒ほどのズレでした。

残念ながら、こちらも大きなスピードアップは感じられませんでしたが、 速くなったよ、という方はご一報いただけると嬉しいです。

まとめ

parallel_testsspringの使い方、いかがだったでしょうか?

  • PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" を設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

弊社でもテストが遅いということが、問題になっており、日々改善に勤しんでおります。

こんなやり方あるよ!という情報をお持ちの方、いらっしゃいましたら、 ご教授いただけますと幸いです。

よろしくお願いします!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【探検】Railsカラム更新のメソッド1

初めに

皆さん、初めまして。そして、明けましておめでとうございます。株式会社MUGENUP 開発部の奥田です。 今回、初めて技術ブログを書くことになりました。よろしくお願い致します。

何回かに分けてRailsのカラム更新メソッドについて書いていき、今回はupdate_attributeupdate_attributesについて記述します。2つのメソッドは私の環境だとactiverecord-3.2.16/lib/active_record/persistence.rbにありました。以下の表は簡単なまとめです。

メソッド名 validation callback 更新カラム数
update_attribute なし あり 1つだけ
update_attributes あり あり 複数

それではそれぞれのメソッドのソースコードを探検してみましょう〜!!

ActiveRecord::Persistence#update_attribute

  # Updates a single attribute and saves the record.
  # This is especially useful for boolean flags on existing records. Also note that
  #
  # * Validation is skipped.
  # * Callbacks are invoked.
  # * updated_at/updated_on column is updated if that column is available.
  # * Updates all the attributes that are dirty in this object.
  #
  def update_attribute(name, value)
    name = name.to_s
    raise ActiveRecordError, "#{name} is marked as readonly" if self.class.readonly_attributes.include?(name)
    send("#{name}=", value)
    save(:validate => false)
  end

save(:validate => false)となっているので、validationなしということがわかります。Callbackを起こすは英語でinvokeと表現するのですね〜。updated_atまたはupdated_onは更新するとわざわざ記述しているので、更新しないメソッドもあるのかもしれません。驚いたのはsend("#{name}=", value)の部分で、sendメソッドはカラム名=をメソッドと認識することです。sendメソッドを深堀りするのも面白そうだと思いました。

ActiveRecord::Persistence#update_attributes

  # Updates the attributes of the model from the passed-in hash and saves the
  # record, all wrapped in a transaction. If the object is invalid, the saving
  # will fail and false will be returned.
  #
  # When updating model attributes, mass-assignment security protection is respected.
  # If no +:as+ option is supplied then the +:default+ role will be used.
  # If you want to bypass the protection given by +attr_protected+ and
  # +attr_accessible+ then you can do so using the +:without_protection+ option.
  #
  def update_attributes(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save
    end
  end

update_attributesにはoptionsがあるんですね〜。:without_protectionoptionsに入れることでmass-assignmentのprotectionチェックなしにできることを初めて知りました。assign_attributesの中も見たい気持ちはありますが、今後のネタとして取っておきます(笑)。コメントアウトで強調したいところを+で囲っていますが、私の知らない記法でした。Markdown記法ではないようなので、要調査です。

番外編 ActiveRecord::Persistence#update_attributes!

#update_attributessaveを呼んでいるのでカラム更新の失敗時に戻り値としてfalseを返します。そして、#update_attribute!をつけた#update_attributes!の場合はカラム更新の失敗時に例外を返しますが、その中身はどうなっているのでしょう?saveが呼ばれて、!がついてるということは・・・?

  # Updates its receiver just like +update_attributes+ but calls <tt>save!</tt> instead
  # of +save+, so an exception is raised if the record is invalid.
  def update_attributes!(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save!
    end
  end

予想通りですが、save!が呼ばれていました〜。

最後に

簡単にではありましたが、Railsのカラム更新メソッドについて見てきました。この記事を読んで初めて知ったことがあれば幸いです。他にもカラム更新のメソッドはあるので、私の次回の記事でもカラム更新のメソッドを探検したいと思います。

宣伝

MUGENUP では、Rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【拡張】論理和できるenumを書いてみた【gem】

新年あけましておめでとうございます! MUGENUP の osada です。 2014 年は挑戦の年、ということで、MUGENUP でも新しい事業を初めています。

ゲームライター・編集者募集中|みなゲー

f:id:mgnup:20140113031124p:plain

みんなで作るゲーマー向けの攻略サイト「みなゲー」。 その攻略記事を書いてくださる方を募集しています。 スマホゲームなら俺に任せろ!という豪の方、攻略記事を書いてみませんか?

開発部でも、新しい挑戦としてRuby 2.1Rails 4.1 で開発を初めています!

新機能 enum

Rails 4.1 に enum という新機能が実装されました。

Ruby on Rails 4.1 Release Notes — Ruby on Rails Guides

class Conversation < ActiveRecord::Base
  enum status: [ :active, :archived ]
end
 
conversation.archived!
conversation.active? # => false
conversation.status  # => "archived"
 
Conversation.archived # => Relation for all archived Conversations

status など、特定のグループの中のどれか1つの値を持つ要素」を便利に書けるようになりました。

enum では プレイングマネージャー を表現できない

enum はグループの間でどれか一つの要素を表現します。各要素は排他関係です。:active:archived が同時に存在することはありません。

そう聞くと、グループの複数の値が共存して欲しい。 そう思うことはないでしょうか?

例えば、野球で、監督兼選手は、プレイングマネージャーと呼ばれるそうです。

class User < ActiveRecord::Base
  enum role: [ :manager, :player ]
end

しかしこのとき、enum ではプレイングマネージャーを表現できません。Userがなれるのは、manager(監督)か、player(選手)しかないからです。

論理和できる列挙型、bitwise_enum を作ってみた

bitwise_enum/lib/bitwise_enum.rb at master · osdakira/bitwise_enum

とうことで複数の要素を表現できる、bitwise_enum を作ってみました。

通常のenumは、要素を[0, 1, 2]と表現していくため、 manager0palyer には 1が入ります。

一方、bitwise_enum では、manager1playerには2が入ります。 プレイングマネージャーを表現したいときは3にすればOK、ということですね。

これはビット(2進数)に直すと、1桁目がmanager、2桁目がplayerを表現していることになります。 このようにすることで、複数の要素を同時に表現することが可能です。

f:id:mgnup:20140113171345p:plain

使い方

class User < ActiveRecord::Base
  bitwise_enum role: [ :manager, :player ]
end

基本的な使い方はenumと同様です。

user = User.new
user.manager!
user.manager? # => true
user.role  # => "['manager']"

user = User.new
user.player!
user.player? # => true
user.role    # => "['player']"

違いとしては、要素が共存できるということです。

user.manager!
user.manager? # => true
user.player!
user.player? # => true
user.role    # => "['manager', 'player']"

プレイングマネージャーが表現出来ました!

一方で、今までは要素は上書きしていましたが、 bitwise_enumでは、明示的に削除する必要があります。 そのためにnot_xx()というメソッドがあります。

user.manager!
user.manager? # => true
user.not_manager!
user.manager? # => false

また、全てをリセットするためのreset_xx()というメソッドがあります。

user.manager! # => ['manager']
user.reset_role # => nil
user.role = []

enumの実装も同じなのですが、manager!を呼んだ時点で、 update!が動いているため、即座にSQLが動いてしまいます。 このままでは error ハンドリングするときに困るので、代入するときにもビット演算を行うようになっています。

user.role = :manager
user.role # => ['manager']
user.role = :admin
user.role # => ['manager', 'admin']

scope も、通常の値ではなく、ビット演算の値でSQLを発行します。

User.manager # => SELECT `users`.* FROM `users`  WHERE (role & 1 = 1)

ただコレに関しでは実装部分が、arelを使わない未熟なものです。

klass.scope value, -> { klass.where("#{name} & #{bit} = #{bit}") }

どなたかarelの使い方について、情報を教えていただけないでしょうか!

以上が、プレイングマネージャーが実装できるbitwise_enumの説明です。

gem にしてみた

せっかくなので gem にしてみました。

osdakira/bitwise_enum

gem を作るのは簡単でしたが、テスト環境の構築に迷いました。rails に載せずに環境を作るのは難しいですね。

まとめ

  • 列挙した要素の値を、重複して持つことができる bitwise_enumを実装しました。
  • enum のコードにビット演算を追加して実現しています。

皆さんはenumをどう使われているでしょうか?是非使い方を教えて下さい!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【悲報】ActiveSupport::Concern の ClassMethod はモジュールメソッドになる件

皆さんこんにちは!「太鼓式マッサージ?面白そう!」と思ったら「タイ古式マッサージ」でした。MUGENUP の osada です。名前って難しいですよね。

ところで、ActiveSupport::ConcernClassMethod で定義したクラス変数が、どこにあるか、ご存知ですか?

# app/models/concern/item_module.rb
module ItemModule
  extend ActiveSupport::Concern
  module ClassMethods
    def my_module_method
      @@concern_class_variable ||= "concern_class_variable"
    end
  end
end
# app/models/item.rb
class Item < ActiveRecord::Base
  include ItemModule
end
# spec/models/item_spec.rb
describe Item do
  it "ClassMethods で定義すると参照できる" do
    expect(Item.my_module_method).to eq "concern_class_variable"
  end
end

ここまではOKだと思います。 では、これを自身のクラス変数として参照できないことをご存知でしょうか?

  it "ClassMethods で取り込んだクラスメソッドを実行しても自身のクラス変数としては定義されない" do
    expect{
      Item.my_module_method
    }.not_to change{Item.class_variable_defined?(:@@concern_class_variable)}.from(false)
  end

参照はできるのに、定義されていない。それは一体どこにあるの?というのが今回のお話です。

クラス変数はどこに定義されているの?

ruby の場合、クラスメソッドはクラスに特異メソッドを追加する、という文脈で定義されます。

Ruby におけるクラスメソッドとはクラスの特異メソッドのことです。 したがって、何らかの方法でクラスオブジェクトにメソッドを定義すれば、そ れがクラスメソッドとなります。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fdef.html#class_method

よって、クラスメソッド内で定義されたクラス変数は、自身のクラス変数となります。

class Item < ActiveRecord::Base
  include ItemModule
  class << self
    def my_class_methods
      @@my_class_variable = "my_class_variable"
    end
  end
end
  it "クラスメソッドを実行するとクラス変数が定義される" do
    expect{
      Item.my_class_methods
    }.to change{Item.class_variable_defined?(:@@my_class_variable)}.from(false).to(true)
  end

これは全く期待通りの動きです。先ほどと違うのは、自身のクラスメソッドとして定義しているという点ですね。 では、class定義ならOKで、module だからダメなんでしょうか?

module の included として定義してみる

ActiveSupport::Concern では includedというメソッドで、クラス定義の中でコードを評価することができます。

module ItemModule
  extend ActiveSupport::Concern
  included do
    class << self
      def my_singleton_module_method
        @@singleton_include_module_method = "singleton_include_module_method"
      end
    end
  end
end

この結果はクラス変数はクラスに紐付きました。期待通りです。

  it "included で 拡張した特異クラスのクラスメソッドはクラス変数が定義される" do
    expect{
      Item.my_singleton_module_method
    }.to change{Item.class_variable_defined?(:@@singleton_include_module_method)}.from(false).to(true)
  end

どうやら、module だからではないようですね。違いは、includedClassMethodなので、期待通りに動かないのはClassMethodのせいのようです。

ClassMethodincludedの違いはどこにあるのか?

ClassMethod はどのように動くのでしょうか?

base.extend const_get("ClassMethods") if const_defined?("ClassMethods")

ClassMethodという定義があったら、baseに対してextendするという実装になっています。

一方includesは、

base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
    def included(base = nil, &block)
      if base.nil?
        @_included_block = block
      else
        super
      end
    end

base クラスのclass_evalを呼び出しているため、自身のコンテキストで評価されます。

違いはClassMethodはモジュールのextendであり、includedはクラス定義内での評価というところです。

引数で指定したモジュールのインスタンスメソッドを self の特異 メソッドとして追加します。 instance method Object#extend

extend されたクラス変数はモジュール変数となる

extendはモジュールのメソッドを、特異クラスのメソッドとして追加するだけです。よって、extendで追加されたクラスメソッド内で定義されたクラス変数は、モジュール変数になってしまうのです!!!

  it "ClassMethods で定義したクラスメソッドは、モジュール変数" do
    expect{
      Item.my_module_method
    }.to change{ItemModule::ClassMethods.class_variable_defined?(:@@concern_class_variable)}.from(false).to(true)
  end

そしてさらに恐ろしいことに、モジュール変数はincludeした全てで共有されます。マニュアルにしっかり書かれています。

モジュールで定義されたクラス変数(モジュール変数)は、そのモジュールをイ ンクルードしたクラス間でも共有されます。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fvariables.html#class

module Foo
  @@foo = 1
end
class Bar
  include Foo
  p @@foo += 1          # => 2
end
class Baz
  include Foo
  p @@foo += 1          # => 3
end

ClassMethod はモジュールメソッドだ

名前って難しいですよね。ClassMethod だと思っていたら(そして実際にクラスメソッドなのですが)、その中で定義したクラス変数は、モジュール変数になってしまうのです。

これからはできるだけ ClassMethod は使わないようにしよう、と心に決めた日でした。

[注意] モジュール変数、クラス変数、クラスメソッドの役割について

モジュール変数を定義するべきではない、というのはよく言われていることだと思います。そもそもモジュールは数学的な意味で関数であるべきだというのが私の持論です。副作用がないと言い換えても良いです。

なので、モジュール内でクラス変数を使うべきではないというご指摘は最もだと思います。クラス変数を使わない方が良いというのも納得できます。

本記事は、クラス変数やクラスメソッドの使用を推奨したものではありません。あくまで、知っておくと罠が回避できるかも、という記事ですので、ご了承のほどよろしくお願い致します。

参考

» ActiveSupport::Concern でハッピーなモジュールライフを送る TECHSCORE BLOG

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain