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