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