LoginSignup
3
2

More than 5 years have passed since last update.

Herokuアプリのデータベースのカラム名を変えたくなったら

Last updated at Posted at 2018-12-05

Webアプリが成長していくと、あー、このカラムの名前やっぱり別のにしておけばよかった。Railsのマイグレーションファイルで書くとrename_column(:articles, :text, :body)しなくちゃ、と思うこと、ありますよね。あ、そういえばカラム名が変わるとコードから参照してるところも編集しなくちゃ。article.textだったところをarticle.bodyに。

さて、カラム名はdb:migrateすれば変わるんだけど、コードの方は?そうそう、デプロイしてdynoが再起動したら変わる。その間にアプリにアクセスがあった場合はどうなるんだろう?

試してみました。例えば、こうなります:

ActionView::Template::Error (undefined method `body' for #<Article>)

コードはbodyカラムを参照してるんだけどデータベースにはtextカラムしかない。

この記事では、カラムの名前は変えなきゃいけないとして、このエラーをどうやれば避けられるか考えます。

この記事は、Heroku Advent Calendar 2018の12月6日の記事としてお送りします。昨日は tsukakei さんが TypeScriptをHerokuにデプロイした時にハマっていました。そうそう。アプリのビルドはコードのpush時に。トップディレクトリにpackage.jsonがあればgit push heroku masterされたコードをみてプラットフォームがよしなにビルドしてくれるはず!

Dynoのコードの更新とデータベースのマイグレーションは同期してくれない

Herokuでは、デプロイの時のコードの更新とマイグレーションは別の時に起こります。その間にもリクエストは届き、コードはデータベースへのアクセスを続けます。アプリケーションをメンテナンスモードにしてもいいんだけど、できれば避けたいですよね。

デプロイをしてからOne-off dynoでマイグレーションを走らせる場合には、しばらくの間、新しいバージョンのコードが古いスキーマのデータベースとやりとりをします。コードがpushされ、ビルドが終わるとdynoが再起動され新しいコードが走り始めます。One-off dynoからデータベースのマイグレーションが起きるのはそのあとです。

いっぽう、Release Phaseコマンドを利用する場合は、逆に、しばらくの間古いバージョンのコードが新しいスキーマのデータベースとやりとりをします。コードがpushされ、ビルドが終わると、Release Phaseコマンドが走りデータベースのマイグレーションが起き、そのあとdynoが再起動され新しいコードが走り始めます。

デプロイ前後のコードとデータベースは互換に保つ

このように、コードのバージョンとデータベースのスキーマの更新は同期せず前後します。このような状況でも、データベースのスキーマを、変更前後どちらのコードとも互換なようにして進めることで、アプリケーションを稼働させつつデプロイを済ませることができます。

例えばarticlesテーブルのtextカラムをbodyカラムに変更する場合は、下記のように、まずbodyカラムを追加し、その後コードがbodyカラムを参照するようにしてから、最後にtextカラムを消去します。3段階のデプロイをすることになります。

(1) 新しいカラムをつくり既存のカラムと同期する

例えば下記のようなマイグレーションを作成してコードの変更なしにHerokuにデプロイし、マイグレーションのみ走らせます。bodyカラムを追加し、textカラムの内容をコピーします。textカラムを更新する旧版のコードはまだ走っている可能性があるので、textカラムに更新があった場合にはそれをbodyカラムにも反映するようにトリガを設定します。

このマイグレーションを追加しデプロイし、マイグレーションを走らせると、textカラムを参照しているコードからも、bodyカラムを参照しているコードからも、このデータベースとやりとりをできるようになります。

class AddBodyToArticles < ActiveRecord::Migration[5.2]
  def up
    add_column :articles, :body, :text
    execute <<_ADD_TRIGGER
UPDATE articles SET body = text;
CREATE OR REPLACE FUNCTION sync_to_body()
  RETURNS TRIGGER AS $$
  BEGIN
    IF ( TG_OP = 'UPDATE' AND NEW.text != OLD.text OR NEW.body IS NULL ) THEN
      NEW.body := NEW.text;
    END IF;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;

CREATE TRIGGER sync_to_body_trigger
  BEFORE INSERT OR UPDATE OF text ON articles
  FOR EACH ROW EXECUTE PROCEDURE sync_to_body();
_ADD_TRIGGER
  end

  def down
    execute <<_REMOVE_TRIGGER
DROP TRIGGER sync_to_body_trigger ON articles;
DROP FUNCTION sync_to_body();
UPDATE articles SET text = body;
_REMOVE_TRIGGER
    remove_column :articles, :body
  end
end

(2) コードが新しいカラムを参照するようにする

コード中でtextカラムを参照している部分を編集して、bodyカラムを参照するようにします。この変更をデプロイしdynoが再起動すると、textカラムへの参照はなくなります。

(3) 既存のカラムを削除する

下記のようなマイグレーションを作成してコードの変更なしにHerokuにデプロイし、マイグレーションのみ走らせます。これでtextカラムが削除されます。念のためrollbackもできるようにしてあります。

class DropTextFromArticles < ActiveRecord::Migration[5.2]
  def up
    execute <<_REMOVE_TRIGGER
DROP TRIGGER sync_to_body_trigger ON articles;
DROP FUNCTION sync_to_body();
_REMOVE_TRIGGER
    remove_column :articles, :text
  end

  def down
    add_column :articles, :text, :text
    execute <<_ADD_TRIGGER
CREATE OR REPLACE FUNCTION sync_to_body()
  RETURNS TRIGGER AS $$
  BEGIN
    IF ( TG_OP = 'UPDATE' AND NEW.text != OLD.text OR NEW.body IS NULL ) THEN
      NEW.body := NEW.text;
    END IF;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;

CREATE TRIGGER sync_to_body_trigger
  BEFORE INSERT OR UPDATE OF text ON articles
  FOR EACH ROW EXECUTE PROCEDURE sync_to_body();
_ADD_TRIGGER
  end
end

これでダウンタイムなしでカラム名を変更できました。

もっと詳しく!

Thoughts on Javaの記事「Update your Database Schema Without Downtime」などに、カラム名の変更以外の場合についても、詳しい解説があります。

この記事の内容の確認はzunda/rails-dbmigrate-testでおこないました。

落ち穂拾い

  • 筆者はRuby on RailsやPostgreSQLについては詳しくないです。ごめんなさい。上記のマイグレーションのコードなど、より良い書き方があればお知らせいただけるとうれしいです。
  • Gitでの多段リリースの表現方法を考えてたい。上記のようにコミットが複数になる場合、Tagを書きつつブランチでマイグレーションのテストを終えて、プロダクション用のブランチにtagをmergeしていくのが良いだろうか?
  • Private Spacesではコードの変更時に複数のweb dynoが新旧両方のバージョンで稼働する可能性があり、新しいバージョンのweb dynoからレスポンスをもらったクライアントが古いバージョンのweb dynoにアセットをもらいに行くと、404 Not Foundが返ってしまいます。新しいバージョンのアセットを先にリリースしておけば回避できるのですが、いい方法ないかな。
3
2
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2