Heroku
PostgreSQL
マイグレーション
schema
rename_column
herokuDay 6

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

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が返ってしまいます。新しいバージョンのアセットを先にリリースしておけば回避できるのですが、いい方法ないかな。