くりにっき

フルスタックキュアエンジニアです

Railsでdb:migrateの時だけdatabase.ymlを自動で差し替える

前置き

GitLabでPostgreSQLを使う時にPgBouncer経由でDBに接続してる(database.ymlにpgbouncerの接続先を書いている)のですが、 その状態で rake db:migrate すると下記のようなエラーが出ます。

ActiveRecord::ConcurrentMigrationError: 

Failed to release advisory lock


  from active_record/migration.rb:1385:in `with_advisory_lock'
  from active_record/migration.rb:1229:in `migrate'
  from active_record/migration.rb:1061:in `up'
  from active_record/migration.rb:1036:in `migrate'
  from active_record/tasks/database_tasks.rb:238:in `migrate'
  from active_record/railties/databases.rake:86:in `block (3 levels) in <top (required)>'
  from active_record/railties/databases.rake:84:in `each'
  from active_record/railties/databases.rake:84:in `block (2 levels) in <top (required)>'
  from rake/task.rb:273:in `block in execute'
  from rake/task.rb:273:in `each'
  from rake/task.rb:273:in `execute'
  from rake/task.rb:214:in `block in invoke_with_call_chain'
  from monitor.rb:235:in `mon_synchronize'
  from rake/task.rb:194:in `invoke_with_call_chain'
  from rake/task.rb:183:in `invoke'
  from rake/application.rb:160:in `invoke_task'
  from rake/application.rb:116:in `block (2 levels) in top_level'
  from rake/application.rb:116:in `each'
  from rake/application.rb:116:in `block in top_level'
  from rake/application.rb:125:in `run_with_threads'
  from rake/application.rb:110:in `top_level'
  from rake/application.rb:83:in `block in run'
  from rake/application.rb:186:in `standard_exception_handling'
  from rake/application.rb:80:in `run'
  from bundle/ruby/2.6.0/gems/rake-12.3.3/exe/rake:27:in `<top (required)>'
  from bundle/ruby/2.6.0/bin/rake:23:in `load'
  from bundle/ruby/2.6.0/bin/rake:23:in `<main>'

https://docs.gitlab.com/omnibus/update/README.html

If you’re using PgBouncer:

You’ll need to bypass PgBouncer and connect directly to the database master before running migrations.

とあるように db:migrate する時だけPgBouncerを使わず直接PostgreSQLにつなぐのが大正解です。

しかし

  • GitLabのバージョンアップの時だけdatabase.ymlを書き換えるのは面倒
    • 忘れがちなので自動化すべき
    • 運用でカバーするのは時代遅れなので仕組み化すべき
  • いつもCIからのデプロイでGitLabのバージョンアップを行っているのだが、database.ymlの接続先をPostgreSQLにしてデプロイ&自動でdb:migrate(1回目のデプロイ)と接続先をPgBouncerに戻す作業(2回目のデプロイ)で2回デプロイするのが面倒
    • 忘れがちなので(ry
    • 仮に2回デプロイ行うとしても一時的にPostgreSQLのコネクションが少なくなるのでオンラインでバージョンアップを行うとコネクションが足りずにエラーが多発する可能性がある
      • workerの台数によるけど常時100コネクション近く張り付いてる状態なのでメンテいれずにpgbouncer外すのは厳しいと判断
    • SwarmでBlue/GreenデプロイでGitLabをノーメンテでバージョンアップするのに慣れすぎて*1 、バージョンアップ程度でメンテ入れるのが嫌

という理由で、今のデプロイフローをなるべく変えずにどうにかdb:migrateの時だけ自動で接続先を切り替える方法がないか考えました。

モンキーパッチの縛りプレイの内容

モンキーパッチを作るにあたって下記のような縛りプレイの条件がありました

  • RailsやGitLabのバージョンアップでなるべく壊れないようにする
  • Dockerイメージで配布されているGitLabを使っているので *2既存のソースは全て変更不能
    • Dockerイメージの時点でモンキーパッチなんて不可能だと思われがちですが、GitLabのアプリケーション本体はRailsなので config/initializers/lib/tasks にファイルをmountすればRailsが自動で読み込むのでモンキーパッチを適用することができます *3
  • 既存のソースが変更できないのでGitLabに入っていないgemを使うのは難しい
    • bundle installされた状態でDockerイメージとして配布されてるので、そこに新しいgemを追加するのは茨の道
  • 利用しているDockerイメージは https://github.com/sameersbn/docker-gitlab でだが、起動時の処理はDockerイメージ内のスクリプト *4 にあるため、そこに特定の処理を差し込むのはほぼ不可能

モンキーパッチ

このようなモンキーパッチを

version: '3.7'
services:
  gitlab:
    volumes:
      - "./monkey_patch/db_migrate_monkey_patch.rake:/home/git/gitlab/lib/tasks/db_migrate_monkey_patch.rake:ro"

のような感じでDockerコンテナ内にmountして使っています。

注意点

  • かなり黒魔術なので自己責任でお願いします
  • database.ymlの中身は適宜書き換えてください
  • オンラインで実行しても問題ないはずですが、 rake db:migrate 実行中にpumaやunicornをrestartすると意図しない設定が読み込まれるので注意してください

モンキーパッチのポイント

rake taskの実体は Rake::Taskインスタンスで、rake task実行時には Rake::Task#invoke が呼ばれます。

そのため Rake::Task["db:migrate"].invoke という特異メソッドに対してモンキーパッチ用のmoduleを prependすることで rake db:migrate 実行時のみに特定の処理を差し込んでいます。

ボツ案1

Rake::Task["db:migrate"].enhance(["db:migrate:before"]) do
  Rake::Task["db:migrate:after"].invoke
end

みたいな感じで db:migrate:before でdatabase.ymlを書き換えて db:migrate:after で元に戻すという方法もあったのですが、これだと db:migrate でエラーになった時に db:migrate:after が実行されずにdatabase.ymlが元に戻せなくなる不具合があるのでボツになりました。

そのため、上の方で書いているように Rake::Task["db:migrate"].invoke に対してモンキーパッチをあてるしかないと思ってます。

ボツ案2

begin
  Rake::Task["db:migrate"].enhance(["db:migrate:before"])
ensure
  # ロールバック処理
end

これだとrake taskの定義時に ensure が評価されてrake taskの実行時には ensure は評価されないので意図通りに動かないです。

Rails 5.2.2.1にしたらErrno::ENOENT: No such file or directoryのエラーになった

Railsのsecurity fixが出ました

weblog.rubyonrails.org

個人アプリ2つアップデートしたんですが、どっちもRails 5.2.2だし余裕だろうと思ったら片方だけ下記のようなエラーになってちょっとハマったのでメモ。

+ ./bin/rails db:create

(snip)

Errno::ENOENT: No such file or directory @ rb_sysopen - /home/circleci/app/tmp/development_secret.txt
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/application.rb:597:in `binwrite'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/application.rb:597:in `generate_development_secret'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/application.rb:430:in `secret_key_base'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/devise-4.6.1/lib/devise/secret_key_finder.rb:24:in `key_exists?'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/devise-4.6.1/lib/devise/secret_key_finder.rb:16:in `find'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/devise-4.6.1/lib/devise/rails.rb:37:in `block in <class:Engine>'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/initializable.rb:32:in `instance_exec'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/initializable.rb:32:in `run'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/initializable.rb:61:in `block in run_initializers'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/initializable.rb:60:in `run_initializers'
/home/circleci/app/vendor/bundle/ruby/2.6.0/gems/railties-5.2.2.1/lib/rails/application.rb:361:in `initialize!'
/home/circleci/app/config/environment.rb:5:in `<top (required)>'

原因

tmp/ がコミットされていなくて tmp/development_secret.txt の作成に失敗してた

今回CIがコケたアプリはRails 4.1系からupdateしてたので tmp/.keep の追従忘れてたんだろうなあと納得

対応内容

tmp/ を作ってコミットしただけ

mkdir -p tmp
touch tmp/.keep
git add -f tmp/.keep
git commit -am "Add tmp/"

OSS雑メンテ #railsdm

Rails Developers Meetup 2017 というイベントで「OSS雑メンテ」という発表をしたのでスライドをシェアさせていただきます

スライド

発表中の反応

他の人のスライド

他の人のスライドは下記をご覧ください

railsdm.github.io

ドリコムで使ってるgem一覧 #railsdm

先日 【増枠】Rails Developers Meetup #2 でLTした時の資料です。ご査収ください

rails-developers-meetup.connpass.com

資料

スライド版

sue445.github.io

markdown

github.com

当日の質問

覚えてる範囲で

社内gemにするかOSSするかどう切り分けてる?

  • 基本的には外に出すこと前提で作ってる
    • 理由:みんなに使ってもらえた方がフィードバックが得られやすいし、Travis CIなどのエコシステムも利用できる
  • ビジネスロジックを抽出していたり、社内コンテキスト*1に依存してるような場合は社内gemにしてる
  • OSSにするとどうしても汎用化しちゃいがちでEasyから外れてしまうので、社内利用用途に絞ることでEasyを維持*2しているケースもある

詳しくは以前エントリに書きました

sue445.hatenablog.com

どうでもいい情報

*1:capistrano系の社内gemは社内インフラ構成前提になってる

*2:イージーをいじ

【今月のgem】activerecord-compatible_legacy_migration というgemを作った

github.com

どんなgemか

同一のmigrationファイルでRails 4.2系とRails 5.0系の両方でいい感じに動くようにするためのgemです

これだけだと分かりづらいので具体例を出します

Rails 4.2系から5.0系へのmigrationファイルの移行について

Rails 4.2系から5.0系に移行する時に、Rails 4.2で作られた既存のmigrationのスーパークラスを下記のように ActiveRecord::Migration から ActiveRecord::Migration[4.2] にします

Rails 4時代のmigration

class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.timestamps null: false
    end
  end
end

Rails 5移行後のmigration

class CreateUsers < ActiveRecord::Migration[4.2]
  def change
    create_table :users do |t|
      t.timestamps null: false
    end
  end
end

ActiveRecord::Migration のままでもエラーにはなりませんがログファイルに下記のようなDEPRECATION WARNINGが出ます *1

DEPRECATION WARNING: Directly inheriting from ActiveRecord::Migration is deprecated. Please specify the Rails release the migration was written for:

class CreateUsers < ActiveRecord::Migration[4.2]

ユースケース

アプリであればmigrationファイルを上記のように修正すればいいのですが、moutable engineのようにgemの中に内包されてるmigrationファイルをアプリの db/migrate/ にコピーするgemだとそうはいきません。

  • gem内のmigrationファイルが ActiveRecord::Migration の場合
    • アプリでRails 4.2系を使ってる時は問題なし
    • アプリでRails 5.0系を使ってる時にDEPRECATION WARNINGが出る
      • 動きはするけど、5.1辺りで使えなくなる可能性もあるのでできれば放置はしたくない
      • とはいえgem開発者的にはなるべく多くのバージョンに対応しておきたい
      • アプリでmigrationファイルを修正後にgemのmigration installコマンドをたたいた時にgem側のmigratonファイルとアプリ側のmigratonファイルで差分があると「変更されてるけど上書きしますか?」的な確認が大量にでそう(自信ない)
  • gem内のmigrationファイルが ActiveRecord::Migration[4.2] の場合
    • アプリでRails 5.0系を使ってる時は問題なし
    • アプリでRails 4.2系を使ってる時にはエラーになる

migrationファイルを内包したgemをいくつかメンテしているのですが、Rails 5対応をするために既存のRails 4系のアプリともmigrationファイルの互換性を保つのが割とつらかったのでそれを解決するために作りました

使い方

migrationクラスのスーパークラスActiveRecord::CompatibleLegacyMigration.migration_class にするだけです

class CreateUsers < ActiveRecord::CompatibleLegacyMigration.migration_class
  def change
    create_table :users do |t|
      t.timestamps null: false
    end
  end
end

ActiveRecord::CompatibleLegacyMigration.migration_classRails 5.0以降だと ActiveRecord::Migration[4.2] を、Rails 5未満だと ActiveRecord::Migration を返します

https://github.com/sue445/activerecord-compatible_legacy_migration/blob/v0.1.0/lib/active_record/compatible_legacy_migration.rb

Rails 5.0以降でしか使えないmigrationファイルはgemとして配布する時に ActiveRecord::Migration[5.0] つければいいと思います

おまけ

他のgemはどうやってRails 5対応やってるんだろうなぁと思って試しに devise を見たら [5.0] の部分をerbで埋め込んでた

gemごとに個別対応するの、つらみある。。。

最後に

gemでmigrationファイルを配布していてRails 4.2系とRails 5.0系を両方メンテする必要が出てきたら是非ご利用ください

*1:コンソールには出ないので注意

Railsでmysql-clientとmysql-serverのバージョンが食い違ってハマった

GitLab CIでRailsアプリをお手軽CI開発する - Tech Inside Drecom で書ききれなかったおまけです。

tl;dr

あらすじ

GitLab CIでRailsアプリをお手軽CI開発する - Tech Inside Drecom を書いてる時に .gitlab-ci.yml

services:
  - mysql:5.5

って書いていたのになぜか activerecordmysql 5.7以降でしか使えないDDLを発行してmigrationが失敗するという事象が発生。

エラーの状況

migrationファイル

こんな感じのmigrationを実行したかった

class TableRenaemStatusToProfile < ActiveRecord::Migration
  def change
    rename_table :user_statuses, :user_profiles
  end
end

ローカル (MySQL 5.5)

問題なし

Migrating to TableRenaemStatusToProfile (20140126044757)
   (25.6ms)  RENAME TABLE `user_statuses` TO `user_profiles`
   (13.4ms)  CREATE UNIQUE INDEX `index_user_profiles_on_user_id`  ON `user_profiles` (`user_id`)
   (8.7ms)  DROP INDEX `index_user_statuses_on_user_id` ON `user_profiles`

リネーム対象のテーブルにあったindexも新テーブル名に即したindex名に変更された。(activerecord賢い)

GitLab CI

シンタックスエラー(;´Д`)

Mysql2::Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'INDEX `index_user_statuses_on_user_id` TO `index_user_profiles_on_user_id`' at line 1: ALTER TABLE `user_profiles` RENAME INDEX `index_user_statuses_on_user_id` TO `index_user_profiles_on_user_id`

user_statusesuser_id というカラムにunique indexが貼られていたため rename_table 内でindex名変更のDDLが発行されていたのですが、MySQL 5.7以降でしか使えない RENAME INDEX が発行されてエラーになってる模様。いやお前5.5だろ。。。

activerecordどの〜!

原因

原因はactiverecordmysql serverのバージョンではなく、Dockerイメージで使ってるlibmysqlclientのバージョンが5.7で、activerecordはそこからMySQLのバージョンを取得していたためでした。*1

ためしにビルドスクリプトデバッグログを出したところ、activerecord上は確かに5.7でした

+ bundle exec rails r 'puts ActiveRecord::Base.connection.send(:full_version)'
5.7.12

今回使ってたDockerのイメージの中を調べたらlibmysqlclientとかのバージョンとも完全一致

$ docker run -i -t drecom/ubuntu-ruby bash
root@8c92dcb22de8:/# dpkg -l | grep mysql
ii  libmysqlclient-dev                   5.7.12-0ubuntu1                     amd64        MySQL database development files
ii  libmysqlclient20:amd64               5.7.12-0ubuntu1                     amd64        MySQL database client library
ii  libqt4-sql-mysql:amd64               4:4.8.7+dfsg-5ubuntu2               amd64        Qt 4 MySQL database driver
ii  mysql-client                         5.7.12-0ubuntu1                     all          MySQL database client (metapackage depending on the latest version)
ii  mysql-client-5.7                     5.7.12-0ubuntu1                     amd64        MySQL database client binaries
ii  mysql-client-core-5.7                5.7.12-0ubuntu1                     amd64        MySQL database core client binaries
ii  mysql-common                         5.7.12-0ubuntu1                     all          MySQL database common files, e.g. /etc/mysql/my.cnf

MySQLのバージョンによるmigrationの挙動の違い

activerecordのソースを読んだところ、MySQL 5.7以降だと RENAME INDEX を使って、MySQL 5.6以下だと CREATE INDEX してから DROP INDEX しているようでした

対処法

冒頭に書いた通りRails 5系では治ってます。Rails 4系でこの問題に直面したら config/initializers/activerecord_mysql_version_patch.rb のようなモンキーパッチを入れれば治ります

# activerecordでクライアントではなくサーバのMySQLのバージョンを使用するためのモンキーパッチ
# via. https://github.com/rails/rails/commit/977ffe880624bbd05f5ee1cc6e4fa51a999884ab

current_version = ActiveRecord.version
if Gem::Version.new("5.0.0.beta1") <= current_version
  raise "This monkeypatch's lifetime is over. Check ActiveRecord version."
end

module ActiveRecord
  module ConnectionAdapters
    class Mysql2Adapter < AbstractMysqlAdapter
      def full_version
        @full_version ||= @connection.server_info[:version]
      end
    end
  end
end

Rails 5系では治っているので、Rails 5系に上げた後にモンキーパッチが残り続けないようにわざとエラーを投げるようにしています。(いわゆる「モンキーパッチの賞味期限」というやつ)

モンキーパッチの賞味期限は下記のスライドが詳しいです

https://speakerdeck.com/eagletmt/activerecord-3-dot-2-4-dot-1?slide=26

まとめ

mysql clientとmysql serverのバージョンが食い違うことは早々ないと思いますが上記のようなやり方で回避は可能です

これはGitLab CI以外にも、Dockerコンテナ採用しているWerckerでも同じ現象が起こりそうな予感。

kamipoさんには足を向けて眠れません m(_ _)m https://github.com/rails/rails/commit/977ffe880624bbd05f5ee1cc6e4fa51a999884ab

*1:activerecord 5系だとmysql serverからバージョンを取得している

ActiveRecord::Relationを返さないクエリに対して雑にexplainする方法

最初にまとめ

tap 便利だった

やりたかったこと

SomeHistory.
  # 5〜6行くらいのなんか複雑な ActiveRecord::Relation のメソッドチェーン
  pluck(:user_id)

こういうクエリに対して雑に実行計画をとりたかった

pluck を呼んだ時点で戻り値がArrayになるので pluck(:user_id).explain ってのはできない。

かといってexplainのためだけに5〜6行くらいあるメソッドチェーンコピペしたくなかったのでどうすっかなーと思ったら tap を思いついた

やり方

pluckを呼ぶ直前にtapを仕込んでexplain

SomeHistory.
  # 5〜6行くらいのなんか複雑な ActiveRecord::Relation のメソッドチェーン

  # NOTE: 実行計画の結果が変わるため、selectがない場合はつけておく。tap { |ar| puts ar.select(:user_id).explain } とかでも可
  select(:user_id).

  tap { |ar| puts ar.explain }.
  pluck(:user_id)

余談

どうでもいいですが「雑(ざつ)」と「tap(たっぷ)」って語感似てますよね?