STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Rails開発でやっておくと良かったCI設定集

STORES 予約 でwebアプリケーションエンジニアをやっております。ykpythemindです。

Rails開発で、どのようなアプリケーションでも抑えておくとチーム開発が少し楽になるポイントがあります。今回はいくつか実例を載せながら紹介します。

アプリケーションの設計的な部分や実装には踏み込まず、すぐに導入できます。 あくまでRailsアプリケーションについての記事ですが、他言語やフレームワークを用いていても同様のことができます。

1. シードデータが壊れないようにCIで担保する

新しいメンバーが入って環境構築をしてもらう度にシードデータが壊れており、 db/seeds.rb *1 を直すという作業を何回か経験しています。db/seeds.rbで実行する内容をテスト中に実行しておくとメンテされるようになります。

# db/seeds.rb

# 定数データが必要であればここで呼ぶ
require_relative 'seeds/constants/xxxx'

# 開発用の初期データとして登録しておきたいもの。たとえば初期ユーザーとか。
Dir.glob('db/seeds/development/*.rb').sort.each { |file| load(file) } if Rails.env.development?
# db/seeds/development/01_end_user.rb

User.create!(email: 'user@example.com')
# ...

上記のようなseedファイル群がある場合は以下のようなイメージです。

# spec/etc/seed_spec.rb

require 'rails_helper'

describe 'development seed' do
  it 'success' do
    expect {
      Dir.glob('db/seeds/development/*.rb').sort.each { |file| load(file) }
    }.not_to raise_error # seedがなにかおかしかったらActiveRecord::RecordInvalidが出るだろう
  end
end

なお、seed-fu gemを使っている場合はActiveRecordのvalidationが効かない為この方法では担保できないことになります。扱いが難しいので、STORES 予約ではdb/seeds以下にActiveRecordを触るスクリプトをベタ書きをしています。

2. migrationファイルの先頭からmigrationを実行するテストを入れておく

Railsのdatabase migrationの仕組みだと、db/schema.rbが重要なファイルになります。 schema.rbは、

  • db:migrateタスクなどの実行後に現状のスキーマが吐き出される *2
  • db:prepareなどのタスクを実行する場合にはmigrationファイルは実行されず schema.rbからデータベースのスキーマが構成される *3

というように参照・更新されます。ここで要注意な点としては、開発環境やテスト環境において db:prepare (db:setup) タスクでデータベースを初期化する場合、migrationファイル (db/migrate 以下のファイル群) が実行されないというところにあります。たとえば、開発環境で db:migrateを実行しschema.rbを更新した後にmigrationファイルを修正した場合を考えます。この場合には、実際に本番環境でmigrationが実施された結果のスキーマと db/schema.rbが異なる、という現象が起きがちです。 *4

したがって、まっさらな状態から db:migrateを実施し、コミットされている db/schema.rbファイルとdiffがでないことを確認できれば問題はないはずです。

以下はGitHub Actionでの例です。

name: test

on: [push]

env:
  RAILS_ENV: test

jobs:
  backend-test-migration:
    name: 'database migration test'
    runs-on: ubuntu-latest
    timeout-minutes: 10

    env:
      DB_HOST: '127.0.0.1'
      DB_USERNAME: 'root'
      DB_PASSWORD: 'stores' # このあたりの環境変数が必要かどうかはconfig/database.ymlの設定によります
      TEST_DB_NAME: 'test_db_migration'

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ROOT_PASSWORD: stores
        ports:
          - 3306:3306
        options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 10

    steps:
      # RDSのMySQL-5.7と同じオプションにする
      - name: set MySQL sql_mode
        run: |
          mysql --ssl-mode=DISABLE --protocol=tcp --host 127.0.0.1 --user=root --password=${DB_PASSWORD} mysql <<SQL
          SET GLOBAL sql_mode = 'NO_ENGINE_SUBSTITUTION';
          SET GLOBAL character_set_server = 'utf8mb4';
          SET GLOBAL collation_server = 'utf8mb4_general_ci';
          SET GLOBAL character_set_database = 'utf8mb4';
          SQL

      - uses: actions/checkout@v2

      - name: setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ env.RUBY_VERSION }}
          bundler-cache: true

      # character_set_databaseを変更してから再度databaseを作成する必要がある。
      # db:setupを使うとschema:load (db/schema.rb経由)でデータベースが作成されてしまうので適さない。
      - name: Create Database
        env:
          DB_PORT: ${{ job.services.mysql.ports[3306] }}
        run: |
          bundle exec rails db:create --trace

      - name: Migration test
        env:
          DB_PORT: ${{ job.services.mysql.ports[3306] }}
        run: |
          bundle exec rails db:migrate --trace

      - name: Diff test
        run: |
          DIFF="$(git diff HEAD)"
          if [[ $DIFF != '' ]]; then
            echo 'db:migrateを実行するとschema.rbにdiffが出るようです。正しくschema.rbをコミットしているか確認してください'
            git diff HEAD
            exit 1
          fi

環境変数などはよしなに調整が必要です。

3. productionモードでRails serverを起動するテストを入れておく

実際に本番環境でサーバを上げる際にはRails.envでの挙動の違いに注意する必要があります。 テスト環境では問題なかったが、本番デプロイを行うと新しいunicorn workerが上がって来ないなどで問題になることがありました。 *5

  • テスト環境では Rails.env == testの状態でしか検証できていない
  • 本番環境ではunicornサーバを立ち上げている

以上の差異を解消するため、CI上で実際にproductionモードでrails serverを起動するテストを導入しました。

name: test

on: [push]

jobs:
  backend-test-prod:
    name: 'launch prod server test'
    runs-on: ubuntu-latest

    services:
      # 必要なコンテナを適宜上げること

    steps:
      - uses: actions/checkout@v2

      - name: setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ env.RUBY_VERSION }}
          bundler-cache: true

      - name: Run Rails server (production mode)
        env:
          SENDGRID_API_KEY: 'dummy' # production環境では必須のものを適宜設定すること。向き先は各sandbox環境などに向けてください。
          # .... 略 .....
          RAILS_ENV: 'production'
        run: bundle exec ruby spec/production_mode_test.rb

スクリプトは以下のようなものを実施します。unicornサーバを上げているので、puma等の場合調整が必要です。

# productionモードでrails serverを立ち上げるだけのテスト. RSpecでは実行しない

require 'fileutils'
require 'net/http'
require 'retryable'

logfile = './log/unicorn.log'

FileUtils.mkdir_p('tmp/pids')
File.delete(logfile) if File.exist?(logfile)

pid = spawn('bundle exec unicorn -c config/unicorn/production.rb')

begin
  Retryable.retryable(sleep: 1, tries: 30) do |retries, exception|
    puts "try (#{retries})"
    puts exception

    conn = Net::HTTP.new('localhost', 3000)
    conn.open_timeout = 1
    conn.read_timeout = 1
    resp = conn.get('/dummy') # テスト用のエンドポイントを指定する。実際のデータベースアクセスなどを確認したかったらステージング環境やE2Eテストで確認することにして、あくまで疎通確認のみ

    if resp.code != '200'
      # 意図していないレスポンスであれば強制終了する
      raise "response error: #{resp.code}"
    end
  end

  puts 'request success'
rescue StandardError => e
  puts 'error'
  puts e
  puts e.backtrace
  exit 1
ensure
  # unicornサーバを終了させにいく. 異常終了しているときはそもそもこのときにプロセスが上がっていなくてエラーする
  Process.kill('QUIT', pid)
  Process.waitpid(pid)

  if File.exist?(logfile)
    puts 'log ---'
    puts File.read(logfile)
  end
end

4. カスタムcopを作成する

おそらく一般的なRailsプロジェクトの場合、 rubocopを導入されているのではないでしょうか。 頻繁にあるレビュー指摘事項やプロジェクト固有のルールは独自カスタムcop化してしまうのが良いと気づきました。

# .rubocop.yml

require:
  - './config/rubocop/custom_cops/use_save_bang_on_test.rb'

CustomCops/UseSaveBangOnTest:
  Include:
    - 'spec/**/*'
require 'rubocop'

module CustomCops
  class UseSaveBangOnTest < ::RuboCop::Cop::Cop
    MSG =
      'テスト中ではsave/updateメソッドではなくsave!/update!メソッドでレコードを作成してください。テストの前提条件になるレコードは作成できなかった場合例外で停止するほうが好ましいです'

    def_node_matcher :use_save_without_bang?, <<~PATTERN
      (send _ {:save :update} ...)
    PATTERN

    def on_send(node)
      add_offense(node) if use_save_without_bang?(node)
    end
  end
end

f:id:ykpythemind:20210813115527p:plain
実行結果

この程度の追加で既存のlintの仕組みに組み込めてしまうのでおすすめです。*6 *7 なお筆者はrubocop astに詳しいわけではないのでGitLabのコードにあるcustom cop などを大いに参考にしています。

なお、STORES 予約チームでは reviewdog を用いており、Pull Requestに自動コメントをつける仕組みがあります。 導入が簡単なのでこちらもおすすめです。

f:id:ykpythemind:20210816172652p:plain

おまけ: bin/setup スクリプトで環境構築が終了するようにする

すこし脱線しますが、1に付属して、 bin/setup を流すだけで開発が可能な状態になっていると新しいメンバーにやさしいです。

rails newすると以下のスクリプトが bin/setupに作成されます。コメントに Add necessary setup steps to this file. とある通り、このスクリプトをメンテするのが推奨されているようです。 *8

#!/usr/bin/env ruby
require "fileutils"

# path to your application root.
APP_ROOT = File.expand_path('..', __dir__)

def system!(*args)
  system(*args) || abort("\n== Command #{args} failed ==")
end

FileUtils.chdir APP_ROOT do
  # This script is a way to set up or update your development environment automatically.
  # This script is idempotent, so that you can run it at any time and get an expectable outcome.
  # Add necessary setup steps to this file.

  puts '== Installing dependencies =='
  system! 'gem install bundler --conservative'
  system('bundle check') || system!('bundle install')

  # Install JavaScript dependencies
  system! 'bin/yarn'

  # puts "\n== Copying sample files =="
  # unless File.exist?('config/database.yml')
  #   FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
  # end

  puts "\n== Preparing database =="
  system! 'bin/rails db:prepare'

  puts "\n== Removing old logs and tempfiles =="
  system! 'bin/rails log:clear tmp:clear'

  puts "\n== Restarting application server =="
  system! 'bin/rails restart'
end

開発用の環境変数などはリポジトリにコミットされていない場合が多いでしょう。たとえば dotenv を用いていて .env.development ファイルを開発者に適宜共有している場合、 .env.development が存在しない場合はabortして止めるような処理を書いておくのがおすすめです(環境構築においてもfail fastしたほうがトラブルシューティングしやすいので)

ちなみに、デフォルトで記述してあるdb:prepare タスクは db:create, db:schema:load, db:seed を実行しますので、シードデータはそのタイミングで投入されることになります。

まとめ

以上のような細かい自動化を行っていますが、忘れた頃に助けられることがあり非常に役に立っております。

STORES 予約チームでは開発での困りごとがあったらすぐ解決して自動化できる楽しいチームです。 絶賛採用中です。よろしくおねがいします。

hello.hey.jp

*1: rails db:setupやrails db:seedを呼ぶと呼ばれます

*2: db:schema:dump

*3: db:schema:load

*4: 本番サーバでも db:schema:dumpタスクを実行することでdb/schema.rbを吐き出すことができるので、コミットされているファイルとdiffが出たら本番環境のschema.rbを持ってきてコミットするしかなさそうです。筆者は何度か経験しています...

*5: 一例では、Rails.env == productionの場合、Rails.application.config.eager_loadが真であるためにファイル探索で問題が起きるケースのようでした。

*6: このままだと不完全でまれに誤検出があるのですが、適宜インラインで rubocop:disable CustomCops/UseSaveBangOnTest とすることで無効化してもらうことにしています

*7: 検証が不安な場合は https://gist.github.com/ykpythemind/72b5c1e5284e1c0c0cee42a202fb207e のようにテストを書くことができます

*8:なお、コメントには This script is idempotent, so that you can run it at any time and get an expectable outcome. とあるのですが、私はbin/setupの冪等性はあまり考えていません