この記事は、Techouse Advent Calendar 2024 21日目です。
昨日は nodematerial さんによる 「テスト技法勉強会」で、学生エンジニアが大幅にレベルアップした件 でした。
こんにちは、株式会社Techouseでバックエンドエンジニアをしている本澤(Niranabe)と申します。私は普段クラウドハウス労務の開発に携わっています。今回はクラウドハウス労務のメンテナンス中に起きた事件と、今後同じ事件が起きないようにするために講じた対策について紹介します。
事件
ある日私たちはデータベースのメンテナンスを行いました。来たる新機能のために必要な新規テーブルを追加したり、既存のテーブルに変更を加えたりするメンテナンスです。
メンテナンス当日、新規テーブルは次々と作成されていき、順調に思われました。しかし異変は急に訪れます。 マイグレーションがいつまでも終わらないのです。 どうしてここまで時間がかかるのか。私たちは急いでログを確認しにいきました。
ログを確認したところ、あるALTER TABLE文の実行にとても長い時間がかかっていました。対象テーブルは1000万レコードを超える巨大なテーブルだったため、変更に膨大な時間がかかってしまっていたのでした(終わりそうになかったので当日はプロセスを中止し、後日時間のかからない方法で完了させましたが、データベースのクローンを用意して同等のSQLを実施したところ完了まで3時間30分かかりました)。
ALTER TABLEはテーブルに対して最も強いロック(PostgreSQLにおけるAccessExclusiveLock)を取るので、実行中はそのテーブルに対して読取も書き込みできません。もしサービス稼働中にマイグレーションが実行されていたら、そのテーブルのレコードに全くアクセスできず、サービス障害につながります。このような事態を踏まえて、クラウドハウス労務ではデータベースのメンテナンスを行う際には事前に顧客に通知した上でサービスを閉塞して行っています。
とはいえ、事態の把握と再発防止策は考えないといけません。調査をしましょう。
何が起こったのか
私たちはRuby on Railsで開発しているので、データベースのメンテナンスはマイグレーションファイルで行います。例えばALTER TABLE文ならば以下のように記述します。
class RestrictBlogTitleLength < ActiveRecord::Migration[7.1] def change change_column :blogs, :title, :string, limit: 100 end end
このようにデータベースに対する変更をメソッドで簡単に記述できます。Ruby on Railsで少しでも開発したことがある開発者ならば誰でも知っているメソッドです。そのような基本的なメソッドであっても、大障害につながる可能性があると考えると肝が冷えます。
change_column
メソッドで気軽にデータベースのメンテナンスを行い、全然終わらなくて地獄を見たという経験は誰にでもあるようで、他チームでも昔同様の事件があったらしいです(そちらも幸い大きな障害にはつながらなかったようですが)。また、チームメンバーにも前職で経験したという声が聞かれました。どうやらこのようなミスはみんなやっていることで、私たちが無能であるために起こった事件ではないらしいです。良かった良かった。
どうすればいいのか
安心している場合ではありません。同じ過ちを2度と繰り返さないように対策を講じなければなりません。今回問題になったchange_column
のように、特にプロジェクトが大きくなるにつれて、当たり前のように利用されているがまさか問題を生むと思っていないコードは山ほどあります。
このような問題を発生させないためにルールを作成する必要があります。このようなルールを守ってもらうために、人はドキュメントを書きます。私のチームにも「設計・実装ガイドライン」というタイトルのドキュメントがあります。定期的にメンテナンスされており、正しいことが書いてある立派なドキュメントです。
ドキュメントを書いて安心するな
しかし、正しいドキュメントがあることは、それが正しく運用されることを必ずしも意味しません。技術的に難解な背景があるルールはなかなかチームメンバーに浸透しません。たくさんのルールは全て覚えていることが難しく、努力だけで忠実に守ることは極めて難しいです。
たとえ正しいことであっても、それがすぐに広まるとは限りません。正しいことがすぐに広まる世界ならば、古代ギリシアの時代にはすでに地球は丸かったでしょう。また一度教訓を残しても、忘れる人もいれば分かってて破る人もいます。正しい教訓が守られる世界ならば、パリ不戦条約は現在も国際関係の基本をなす条約として燦然と輝いていたことでしょう。
プログラマはしばしば集中してゾーンに入り、コードエディタとターミナルに齧り付いてコーディングに熱中します。その時にはドキュメントのことなど思考にはないでしょう。そのような状況でも、ルールが強制的に守られるような仕組みが必要です。
コードで語れ
Rubyにはコードの規約違反を検出できる仕組みがあります。ご存知 RuboCop ですね。Rubyプログラマならばかなりの人が知っていると思います。RuboCopはRubyの静的コード解析ツールで、誤解を恐れず端的に述べると「コードのエラーや読みづらい部分を指摘してくれる」gemです。Rubyプログラマのみなさんの中には、コードが読みやすくなったと感謝している人もいれば、指摘事項の修正がめんどくさいと憎んでいる人もいることでしょう。
実はRuboCopには、利用者独自でカスタマイズでルールを作成できる仕組みがあります。この仕組みを活用すれば、プロジェクト独自のルールをRuboCopで記述できそうです。RuboCopを利用すればGitHub Actionsなどで自動検出することも可能なので、禁止されているコーディングが意図せず本番環境にデプロイされることを防ぐことができます。
RuboCopでカスタムルールを記述する
早速書いてみましょう。今回は change_column
を禁止するだけの簡単なルールを作成してみます。
# lib/rubocop/cop/migration/change_column.rb module RuboCop module Cop module Style class ChangeColumn < RuboCop::Cop::Base def_node_matcher :prohibit_change_column, '(send nil? :change_column ...)' MSG = 'change_column を利用したときに実行されるALTER TABLE文は、大量のデータがある場合には時間がかかる可能性があるため、原則利用は禁止です。' \ 'もし、change_columnを利用する場合は、データ量が多い場合にも問題ないかどうかを確認し、問題がある場合は他の方法を検討してください。' \ 'disableする際は他のメンバーと相談した上で行い、必ず理由をコメントしてください。' def on_send(node) return unless prohibit_change_column(node) add_offense(node) end end end end end
有効化するための設定も追加します。
# db/.rubocop.yml # change_columnはdb/でのみ利用されるので、db/に別でrubocop.ymlを作成している inherit_from: ../.rubocop.yml require: - ../lib/rubocop/cop/migration/change_column.rb AllCops: DisabledByDefault: true Style/ChangeColumn: Enabled: true
これだけで実装自体は完了です。実行してみると、以下のようになります。
$ bundle exec rubocop Offenses: db/migrate/20240919032442_restrict_blog_title_length.rb:3:5: C: Style/ChangeColumn: change_column を利用したときに実行されるALTER TABLE文は、大量のデータがある場合には時間がかかる可能性があるため、原則利用は禁止です。もし、change_columnを利用する場合は、データ量が多い場合にも問題ないかどうかを確認し、問題がある場合は他の方法を検討してください。disableする際は他のメンバーと相談した上で行い、必ず理由をコメントしてください。 change_column :blogs, :title, :string, limit: 100 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 31 files inspected, 1 offense detected
見事検出されました。これによって、新米エンジニアが悪気なくコードを書いても、ルールがあるなんてすっかり忘れていたベテランエンジニアがコードレビューで見逃しても大丈夫です。
書いてみよう
カスタムルールを書くうえで、真っ先に躓くポイントがあります。def_node_matcher
の '(send nil? :change_column ...)'
がよくわからないのです。逆にこの記述が理解できれば問題なさそうです。
RuboCopはコードを解析する際、parserというgemを利用してRubyプログラムをAST(抽象構文木)に変換し、そのASTに対して解析を行なっています。つまり、カスタムルールを記述するためには、RubyプログラムがASTでどう変換されるかを知らないといけないのです。上のプログラムは、解析対象のRubyプログラムをASTに変換したとき、ASTが(send nil? :change_column (任意の文字列))
にマッチするかどうかをチェックしています。
新たにBlog.create!
を禁止するルールを追加するケースを考えます。この時、このコードがASTでどのような記述がされるかを知る必要があります。parser gemをインストールすれば以下のように調べることができます。
$ gem install parser $ ruby-parse -e 'Blog.create!'
以下のように、ASTが出力されます。
(send (const nil :Blog) :create!)
RuboCopのメソッドも利用可能です。出力はruby-parse
と同様になります。
require 'rubocop' code = 'Blog.create!' source = RuboCop::AST::ProcessedSource.new(code, RUBY_VERSION.to_f) ast = source.ast puts ast
パターンにマッチしているかどうかは以下のようにチェックできます。
pattern = '(send (const nil? :Blog) :create!)' RuboCop::NodePattern.new(pattern).match(ast) # => true
RuboCop::NodePattern
は実際にカスタムルールを定義するdef_node_matcher
でも利用されているクラスなので、ルールを記述する前にirbで色々試したいときに便利です。
create!
は引数を取ることがほとんどで、今回は引数に関係なくパターンマッチさせたいです。引数をとるメソッド呼び出しを解析してみると以下のようになります。
require 'rubocop' code = 'Blog.create!(title: "開発者ブログ始めました")' source = RuboCop::AST::ProcessedSource.new(code, RUBY_VERSION.to_f) ast = source.ast puts ast
(send (const nil :Blog) :create! (hash (pair (sym :title) (str "開発者ブログ始めました"))))
任意の文字列にマッチさせるため、...
を利用します。...
はmatchメソッドの中では正規表現におけるワイルドカードのように機能します。
pattern = '(send (const nil? :Blog) :create! ...)' RuboCop::NodePattern.new(pattern).match(ast) # => true
こうして、禁止したい記述がASTでどのように記述されるか、それをマッチするパターンはどのように書くかわかったので、カスタムルールが書けそうです。
最後に
今回はRuboCopのカスタムルールの導入方法を紹介しました。ASTの解析で検出可能なルールは全て記述可能なので、ルールの幅はかなり広そうです。例えば、別の記事で紹介したPostgreSQL Row Level Security (RLS) を使って顧客データ保護の安全性を高めている件の中で、RLSに依存しない実装を心がけるべきと述べられていました。その確認のために「ActiveRecordの記述でcompany_id
が指定してあるかどうかをチェックするルールの追加」などが有用そうです。
開発上のルールの背景にはチームやプロジェクトごとに特別な事情があり、ルールを守るために個人の技術のみを頼りにするのは得策ではありません。チームやプロジェクトが大きくなれば経験の浅いエンジニアも参画しますし、業務委託などで外部から参加するエンジニアはチームやプロジェクトの事情を深くは知らない場合があります。ルールが浸透していなくても、絶対検出できるRuboCopを今後も活用していきたいです。
参考資料
https://docs.rubocop.org/rubocop/development.html
明日のTechouse Advent Calendar 2024は codingalone さんによる Amazon Neptune は人類には早すぎた です。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。