くりにっき

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

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 は評価されないので意図通りに動かないです。