こんにちは、CTOの島田です。 今回は、StudyplusのDBのmigrationで発生した問題とその解決ステップを説明したいと思います。
前提
まずは前提。
- Aurora MySQL 5.7
- Rails 5.1.6 (対応当時。今は5.2.2)
schema.rb
での運用
Studyplus本体のmigrationは、色々な経緯によって2018年5月まで、いわゆるRailsのmigration の作法とは異なる方法で運用されてました。
schema.rb
でスキーマの状態を管理してはいたのですが通常とはやや異なる管理がされていました。
以下、改変して一部抜粋。
ActiveRecord::Schema.define do execute %q( CREATE TABLE `samples` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `content_type` varchar(256) DEFAULT NULL, `last_modified_at` datetime DEFAULT NULL, `issued_at` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`id`), UNIQUE KEY `content_type ` (`content_type `) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ... end
このように execute
で直接DDLが記述されていました。
開発環境を構築する場合や自動テストを実行するにはこれで良いですが、カラムやインデックスの追加、削除などのALTER TABLE に対してはこれでは対応できません。
そういった変更を各環境に適用するためにはDDLを直接DBで実行するという属人的なオペレーションが必要になっていました。
ridgepoleの導入
そこでschema.rb
での運用による解決として、ridgepole の導入を決めました。
これで煩雑かつ属人的でリスクの高いスキーマ変更作業から解き放たれるかと思ったのですが、別の問題が勃発しました。
"0000-00-00 00:00:00" 問題
ridgepoleを導入してSchemafileを生成し、以下のようにdry-runを実行するとなぜか差分が発生してしまいました。
以下、改変して一部抜粋。
$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run Apply `./db/Schemafile` (dry-run) change_column("samples", "issued_at", :datetime, {:unsigned=>false, :comment=>nil}) ... # ALTER TABLE `samples ` CHANGE `issued_at` `issued_at` datetime NOT NULL ...
このようなカラムが、なんと60テーブルに及び90カラム以上!
問題の原因は?
差分として change_column...
となってしまうカラムには共通点がありました。
それは、全て DATETIME でDEFAULT '0000-00-00 00:00:00'
でした。
よくよくSchemafileを確認してみると、確かに
create_table "samples", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t| ... t.datetime "issued_at", null: false ... end
となっていました。
試しに、
t.datetime "issued_at", null: false
↓
t.datetime "issued_at", null: false, default: '0000-00-00 00:00:00'
とすると、その部分のchange_column...
は出力されなくなりました。
ここからは推測ですが、ridgepoleではActiveRecordを利用しているため、Schemafileをexportする際に '0000-00-00 00:00:00'
を認識することが出来ないのではないかと考えました。
例えば以下のようなデータがあった場合に、
mysql> SELECT `samples`.* FROM `samples` ORDER BY `samples`.`id` ASC LIMIT 1 \G *************************** 1. row *************************** id: 1 ... issued_at: 0000-00-00 00:00:00 1 row in set (0.00 sec)
ActiveRecord では、
[1] pry(main)> sample = Sample.first ... => #<Sample:0x000055b595171600 id: 1, ... issued_at: nil>
というように、nil
と認識をされてしまう事と関連があるのではないかと思いました。
これはridgepoleやActiveRecordの不具合でなく、0000-00-00 00:00:00
を利用している事が問題であり、0000-00-00 00:00:00
の撲滅をしてく事を決めました。
0000-00-00 00:00:00
がなぜ良くないかについては、そーだいさんの MySQLの0000-00-00 00:00:00は使ってはならない の記事を参照してもらえればと思います。
暫定対応
ただ撲滅をするといっても数も多く、すぐに全てを対応するのは難しい状態でした。
しかし、スキーマ変更でDDLの直接実行をするのを防ぐ事を優先したかったので、やむえず暫定オペレーションを考えることにしました。
それは、$ ridgepole --export -o Schemafile
で生成したSchemafileに default: '0000-00-00 00:00:00'
を追加するRakeタスクを作るという苦肉の策でした。
Rakeタスクの内容としては、
- ridgepole で Schemafile を export(ファイル名:
SchemafileImport
) - Rakeタスクにて
default: '0000-00-00 00:00:00'
を追加する対象の列に、defaultを追加して、Schemafileを別途出力(ファイル名:SchemafileExport
) という処理になります。
そして、スキーマ変更をする順番は、
$ rake database_export:with_default_datetime
- ridgepole の実行には
SchemafileExport
を利用する$ ridgepole -c ./config/ridgepole.yml -f ./db/SchemafileExport --apply
という手順となっていました。
以下、Rakeタスクを一部改変して掲載
namespace :database_export do desc "Execute ridgepole export" task :with_default_datetime do # ridgepoleで生成するSchemafile IMPORT_OUTPUT_PATH = "./db/SchemafileImport".freeze # スキーマ変更時に利用するSchemafile EXPORT_OUTPUT_PATH = "./db/SchemafileExport".freeze OVERWRITE_TARGETS = { samples: ["issued_at", "xxxx_at"], xxxs: ["xxxx_at"], ... }.freeze @updated_schema = "" puts "exporting schema" sh "ridgepole -c ./config/ridgepole.yml --export -o #{EXPORT_OUTPUT_PATH}" sh "ridgepole -c ./config/ridgepole.yml --export -o #{IMPORT_OUTPUT_PATH}" puts "adding default value to EXPORT file" File.open(EXPORT_OUTPUT_PATH, "r") do |file| file.each_line("") do |lines| line_array = lines.split("\n") create_table_line = "" create_table_line = line_array[2] if line_array[0]&.match(/^\#/) && line_array[2]&.match(/^create_table/) # 1個目のテーブル用 create_table_line = line_array[0] if line_array[0]&.match(/^create_table/) if create_table_line create_table_line_array = create_table_line.split(/\s/) table_name = create_table_line_array[1]&.match(/[a-zA-Z\_]+/).to_s if OVERWRITE_TARGETS.keys.include?(table_name.to_sym) line_array.each do |line| col_array = line.split(/\s/) col = col_array[3]&.match(/[a-zA-Z\_]+/).to_s if OVERWRITE_TARGETS[table_name.to_sym].include?(col) lines.gsub!(/#{line}/, line + ", default: '0000-00-00 00:00:00'") end end @updated_schema += lines else @updated_schema += lines end end end end puts "updating file" File.open(EXPORT_OUTPUT_PATH, "w") do |file| file.puts(@updated_schema) end puts "update finished" end end
問題の解消
しばらくは、このRakeタスクでSchemafileを生成する運用と並行して、0000-00-00 00:00:00
を廃止するという地道な作業をしていきました。
ただ、幸運にもほとんとの対象が、created_at
とupdated_at
だったので(なぜ0000-00-00 00:00:00
だったのか謎だが)
t.datetime "created_at", null: false, default: '0000-00-00 00:00:00' t.datetime "updated_at", null: false, default: '0000-00-00 00:00:00'
↓
t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.datetime "updated_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
のように CURRENT_TIMESTAMP
にしていくことで対応できました。
解消手順としては、
- 対象カラムを利用している箇所を
CURRENT_TIMESTAMP
にして問題ないか確認、必要に応じて修正 - 修正したコードをデプロイ
- Rakeタスクを変更
- ridgepoleにて
ALTER TABLE
でDEFAULT
を修正
という事をひたすら繰り返しました。
あるべき姿
そうして、少しづつ進めてどうにか以下のようにする事ができました。
$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run Apply `./db/Schemafile` (dry-run) No change
まとめ
Studyplusはリリースから、2019年で7年目を迎えます。
最初はRailsのバージョンが3.0.9でしたが、現在は5.2.2となっており、これまで様々なコントリビューターの方が関わってきました。
そのため、サービスの運用を続けていれば必然的に発生する技術的な課題が弊社にも少なからずあります。
しかし、それらを放置する事なく地道に改善を続けて、モダンな仕組みを取り入れています。
もし、そんなスタディプラスのサーバーサイドの開発に興味がある方は、是非 こちら から応募いただければと思います。
(または、私のTwitter にDMをいただくでも構いません)