【Rails】レプリケーション対応によりDB負荷を半分に減らした

  • 63
    いいね
  • 0
    コメント

こんちは

freee K.K. という会計・給与などの
クラウドサービスを提供している会社にて会社員をやっとります @futoase です。
今日は弊社サービスで利用しているDB(RDS)の負荷を半分に減らしてみた、
ということで軽く書いていきたいと思います。お付き合いください。

今期ずっと見続けられてるアニメは、
ゆるゆりおそ松さんです。

ここで話すRuby/Ruby on Railsの世界とは

  • Ruby 2.1系
  • Rails 4.2系

以上の世界観となってます。
Ruby 2.1系なのは今月中に2.2にします...(2.3が出てしまう前に...)

ベンチマーク方法

について予め言っておきますと、
nginxで収集したaccess.log(staging環境なので開発者がアクセスしたログしかないですよ)を元に
特定のController#Actionへの負荷計測をsiegeに食わしてシミュレートしました。
以前弊社AdventCalendarにてちょっと出てきたビルからアプリまで出来てしまうインフラエンジニアの方がさっくりと用意してださって助かりました。

今回はSiegeを利用して、レイテンシ及び負荷についてチェックしています
(結果についてはこの記事では割愛してますm(_ _)m)

DBが1つしかない世界が続いていた

今年6月に発表したAWS Summit Tokyoにて、masterしか見ていない、という話をしました。

AWS Summit Tokyo 2015 freee

korekara.png

この記事の内容は「master/slave構成にする」という平凡な内容なんですが、
今月になって対応を行うことができたので、どうやったのかを書いてみたいと思います。

ActiveRecordの世界ではDBは一つ

というのが基本の世界。で、巷ではRails 複数DB Casual Talksというイベントが開かれたりしていました。

SequelというORMを使えば、master / slaveの構成を取ることは難しくはありません。
Sequel Railsを使えば簡単に導入できそうだけど、
すでに組み上がっているアプリでは意味のない話。ヽ(=´▽`=)ノ

大抵のRuby on RailsのアプリケーションではデフォルトのORMに
ActiveRecordを利用しているはずです。どの程度ヒットするか、どの程度負荷が増えるか、より
何を実装するのか、サービスをどう提供するのかが必要な段階で複数DBを考えるのは後回しでよいはずです。

そう、サービスが育ったために必要になるのがレプリケーションやシャーディングなどの
負荷分散の対応なのです... ... ... (`・ω・´)ゞ

組み上がっているアプリをmaster / slave対応させようとすると大変

とある内部向けAPIのController#Actionが叩かれていて、
そこだけピンポイントにslaveに向けたい、という需要がもともとからありました。

その需要を満たすために、octopusswitch_pointなどの
既存のReplication対応gemを入れるには要望に対して大きすぎるかなという面がありました。

また、ActiveModelSerializersを使ってModelをシリアライズ化した上でレスポンスを返している性格上、
belongs_toな関係を持つModelの場合、既存のgemを使ってレプリケーションできるようにするか、
新しいModelを作ってscopeを作るべきか、など悩む要素が出てきてしまい、
既存の振る舞いを変更する必要に迫られてしまいました。(´∀`)

AcitveModelSerializersについて知りたい方は以下の記事が参考になります\(^o^)/
ActiveModel::Serializersを使ってサクサクjsonを出力しよう

では、自分たちの使い方に合うものを作ろう

と思い、アプリケーションに組み込むためのgemを作ってみることにしました。
大丈夫かな。(´・ω・`)

安直にestablish_connectionしたところ

ぐぐると出てくるActiveRecord::Base.establish_connectionによるDBの接続先の切り替え。
試してみたけどパフォーマンス結果がだめでした。
master/slave切り替えできたんだけどせっかく作ったコネクションをいちいち切って再接続を行うため
アプリのレイテンシが下がる...

ActiveRecord::Base.establish_connectionのコードを参考のこと...

def establish_connection(spec = nil)
  spec     ||= DEFAULT_ENV.call.to_sym
  resolver =   ConnectionAdapters::ConnectionSpecification::Resolver.new configurations
  spec     =   resolver.spec(spec)

  unless respond_to?(spec.adapter_method)
    raise AdapterNotFound, "database configuration specifies nonexistent #{spec.config[:adapter]} adapter"
  end

  remove_connection
  connection_handler.establish_connection self, spec
end

そう、なぜremove_connectionしてしまうのか...

def remove_connection(klass = self)
  connection_handler.remove_connection(klass)
end

次はwith_connectionするだけのものを...

[Ruby] 例えば、ActiveRecord の connection_pool を止めるを参考に
with_connectionにて接続先を切り替えるgem(社内のみ)を作ってみました。
切り替えには成功しました、が、毎度接続(ConnectionPool)を作ってしまうため、
接続のオーバーヘッド分、レイテンシが低下してしまう問題に悩まされました。

こことか。

# If a connection already exists yield it to the block. If no connection
# exists checkout a connection, yield it to the block, and checkin the
# connection when finished.
def with_connection
  connection_id = current_connection_id
  fresh_connection = true unless active_connection?
  yield connection
ensure
  release_connection(connection_id) if fresh_connection
end

release_connection...

# Signal that the thread is finished with the current connection.
# #release_connection releases the connection-thread association
# and returns the connection to the pool.
def release_connection(with_id = current_connection_id)
  synchronize do
    conn = @reserved_connections.delete(with_id)
    checkin conn if conn
  end
end

swith_pointさんがやってるような都度別の接続先を持つModelを作る

switch_pointを参考にgem(これも社内のみ)を作ってみました。こことか見ながら
ActiveModelではestablish_connectionヘルパを使うことでModel別に接続先の切り替え(spec=database.yml)を切り替えることができるので、こいつを使えばいいかな、と思って実装を進めていきました。
ただ、Model(slave向け)にestablish_connectionを貼った場合、やっぱりbelongs_toな関係のModelに
手を加えるかscope定義するなどをしなければいけなくて、
アプリ側での余分な対応が必要になるなということでアプリ側の変更コストを考えてやめました。

最終的に、ConnectionPoolを自分で管理するgemを作った

あーでもない、こーでもないと試行錯誤という名の失敗を重ね、
以下の要件が揃わなければ弊社には合ってないな〜、という結論になりました。

  • 一度作成したconnectionは維持し続ける
    • やっぱ再接続コストが...
  • master/slaveの切り替えは利用者が明示的に、かつ簡単に切り替えられるようにする
  • belongs_toの関係、またincludeをしなければいけないというModel間の関係を考えず、ただBlockを囲ってれば接続先を切り替えられるそんなinterfaceを提供すればよい
  • コネクション確立のコストを下げるためにcacheを使う...より前に基本的なことをやりたい。
    • cacheはプロセスの近くに置けるから近いんだけど(物理的な意味で)、安易なcache/memo化は地獄だ...(経験談)

です。

アプリ側の準備/実装はどんな感じになるのか?

さっくりと書いていきます。

database.yml

こんな感じでConnectionPoolをmaster/slave別に作れるようにdatabase.ymlに定義します。

development: &development
  adapter:  mysql2
  charset:  utf8mb4
  encoding: utf8mb4
  database: yuruyuri
  username: akari
  password: ***********
  host:     master.rds
  port:     3306
  reconnect: true

development_slave:
  <<: *development
  host:
    -
      name:     slave.rds

developmentがmasterで、development_slaveがslaveとなります。そのまんまです。

config/initializersでmaster/slave初期化

#{Rails.root}/config/initializers以下に、master/slave初期化用のファイルを書きますm(_ _)m

require 'benri_master_slave'

BenriMasterSlave.init(
  master: "#{Rails.env}",
  slave:  "#{Rails.env}_slave"
)

実際にslaveにとあるリクエストをなげたい

以下のようにかけばok。

@gorakubu = BenriMasterSlave.exec do
  Gorakubu.find_by(name: gorakubu_name_params)
end

レプリケーション遅延を考慮までしたくない場合

レコードがない場合masterに向けたい、という場合は以下のようにかけばok。

@goraubu = BenriMasterSlave.try_master.exec do
  Gorakubu.find_by(name: gorakubu_name_params)
end

ConnectionPool(ConnectionHandler)の管理

ConnectionHandlerの生成、gem自身でのConnectionPoolの管理

ActiveRecord::Base.connection_handlerにて
現在管理しているConnectionHandlerのオブジェクトが代入されているので、
そのオブジェクトをgem側でうまい具合に管理してあげている感じです。

def set(key, handler=nil)
  if (handler.nil? ||
     !handler.kind_of?(ActiveRecord::ConnectionAdapters::ConnectionHandler))
    @connections[generate_connection_key(key)] = generate_new_handler(key)
  else
    @connections[generate_connection_key(key)] = handler
  end
end

private

def generate_new_handler(key)
  handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
  handler.establish_connection(ActiveRecord::Base, @config.spec(key))
  handler
end

DBの接続情報取得

database.ymlに記述しているDB接続情報且つ現状の環境(RAILS_ENV)の内容については、
ActiveRecord::Base.configurationsを見れば良いので楽に実装できます

def get_enable_database_configuration
  configurations = ActiveRecord::Base.configurations.deep_dup
  configurations.delete_if do |k, v|
    !BenriMasterSlave.connection.server.values.include?(k)
  end
end

そして、ActiveRecord::Base.configurationsで取得した接続情報をパースするには
ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolverを使えばok。

def spec_resolver(configurations)
  ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver.new(
    configurations
  )
end

内部APIの特定Controllerに適用した結果

db.png

DBの負荷が半分になりました。よかった。
これでまだ生きていける。

正しかった、のだろうか...?

もし、HA Proxyを導入する、としても
RDSでの運用からEC2 + MySQLな運用に切り替えないといけないので、
アプリ側はミドル層より深いところを意識せず対応するには
今はそこまでコストをかけられないためアプリ側でなんとかできないかな、と対応してみた感じですm(_ _)m
Rackに手をいれるのも...だし。

今後ConnectionHandlerの持ち方が変わったら
社内で利用しているgemが陳腐化するのでどうしようかなと考えています。
(捨てる前提で書いてたりしてます)

Github Repositoryへのpublicとしての公開は、今月(2015/12)からgemの運用を開始した、という感じなので
もう少しこなれて品質に問題がなくなってからにしようかなということで調整中ですヽ(=´▽`=)ノ

もっともっと扱うデータ量、IOPSを求めるようなリクエストなどが増えてきたら、オンプレ移行の前に
MySQL Routerを採用したり、他のもの(AWS Auroraとか)にするのかもしれません。
未来のことは予測不能です\(^o^)/

都度その時最適なものを最適なサイズで最適な期間で実装・準備し、
サービスに反映するお仕事がエンジニア・・・いや会社員だったりするのかもしれません。

興味のある方は

freee K.K.に興味のある方はこちらをどうぞ。

https://jobs.freee.co.jp/

さて、次は...

"freeeの情熱の燃えたぎらせている漢、闘争心を燃やし続ける漢、わが会社のヒーロー"
@toshi0607 です。ご期待ください(`・ω・´)ゞ