patorashのブログ

方向性はまだない

GETメソッドのFormでの画面遷移をTurbolinksに委ねる

今作ってるアプリケーションではTurbolinksを効かせてて、かなりいい感じだな〜と思ってるのですが、一覧で検索した結果や表示件数を変更した場合に、ページの更新が発生してサクサク移動してたのに、ここでもたつくなぁ…と課題に感じていました。

なんとなくググってみたところ、TurbolinksのTipsとしてissueに上げられていたのでこりゃいい!と思ったので、やり方を書いておきます。

元ネタはここです。

github.com

元ネタのほうだと、jQueryを使っていたりしたので依存度を下げたい。また、data-remote=trueを対象にしていたりして、なんかよくわかりません・・・。data-remote=trueはもともとAjaxで値を取得しにいくから、ダメじゃないの?って思うんですが…。画面の一部を書き換えるときとかならわかるのですが、全体を書き換えたいのを対象にするのであれば、上記の一番最初のサンプルコードのように、data-remote=falseを対象とするべきでしょう。しかし、form_withヘルパーメソッドでlocal=trueにした場合、data-remote=falseという属性は出力されていないので、data-remote=trueを除外する、というアプローチをとります。

コードを紹介

gistを作っておいたのでそちらを見てみましょう。

Support GET from with Turbolinks.

解説

やっていることは以下の通り。

  1. turbolinks:loadイベントで、Turbolinksに委ねてもいいフォームを抽出する
  2. フォームのsubmitイベントを拾うよう設定する
  3. 本来のsubmitイベントをキャンセルする
  4. 現在のページと遷移先にページが同じ場合、perパラメータを引き継ぐ(kaminariの表示件数)
  5. フォームに入力されたものをクエリ文字列に変換する
  6. Turbolinks.visitで移動する

これの肝はクエリセレクタでうまいこと対象のフォームだけを抽出することです。

document.querySelectorAll('form[method=get]:not([data-remote=true])')で、GETメソッドだけどdata-remote=trueのものは除外しています。

また、ページングのgemであるkaminariを使っているので、表示件数をコントロールできるようにするため、pathnameに変更がない場合はperパラメータは引き継ぐようにしました。

まとめ

これで、検索フォームも超速くなりました🚀

WSL2 + DockerでRailsの変更を検知しないのを修正した。

表題の通りなのですが、ファイルの変更を行ってからブラウザをリロードしても変更が反映されませんでした。反映しなおすには、Railsサーバを再起動しなければならず、非常に面倒です。

調べたところ、Vagrantでの開発とかでも同様のことが起きたりしているぽかったのですが、Railsのissueに直し方が書いてありました。

github.com

config/environments/development.rbで、file_watcherを修正すればよいとのこと。

# config.file_watcher = ActiveSupport::EventedFileUpdateChecker
config.file_watcher = ActiveSupport::FileUpdateChecker

ホストOSと共有しているファイルだからか、ファイルアップデートイベントを検知できないようです。FileUpdateCheckerにすれば、変更をポーリングでチェックしているから、検知できる模様。 また、EventedFileUpdateCheckerを使わないのであれば、listen gemを削除できるらしいです。ならば、消してもいいかも…。

deviseで論理削除を実装するときの手順をまとめておく

新しいRailsアプリを作るたびにやっている気がするので、一旦まとめておこうと思います。

今回公開する手順は、削除済みのメールアドレスで再登録可能な論理削除の実装方法です。

Deviseとは?

Deviseはアカウント認証管理のgemです。Webアプリケーションにほぼ必須である認証機能をほぼ網羅しています。

  • アカウントの新規作成
  • メールアドレスの確認
  • パスワード忘れの問い合わせ
  • パスワード間違えすぎたらアカウントロック
  • その他諸々

github.com

論理削除とは?

論理削除とは、レコードは物理的に削除しないけれど、削除済ということにする、ということです。 物理的に削除してしまうと、そのアカウントに紐づいたデータが全て削除されてしまったり、データに紐づいていたユーザが誰だったかわからなくなり、困ったことになります。 そこで、論理削除することで、ログインはできないけれど関連データを表示するときには使いたい、などがあります。

Deviseで論理削除するには?

Wikiに書いてある方法だと再登録不可能

Deviseは非常に便利なのですが、デフォルトでは論理削除に対応していないため、カスタマイズする必要があります。

一応、GitHubリポジトリWikiには、論理削除のやり方が書いてあります。

How to: Soft delete a user when user deletes account · heartcombo/devise Wiki · GitHub

それを翻訳してくださっている記事がQiitaにあります。

【翻訳】deviseで論理削除を実装する方法 - Qiita

ただし、この方法だと削除済みのメールアドレスで再登録しようとしたらできません。

そこで、今回は削除済みのメールアドレスで再登録可能な論理削除の実装方法をまとめておきます。

実装手順

deviseとkakurenbo-putiをインストールする。

Gemfileにdeviseとkakurenbo-putiを追加します。kakurenbo-putiは、論理削除機能を追加するためのgemです。論理削除機能を追加するgemは多々あるのですが、default scopeを上書きする等して論理削除済アカウントを除外するような動作をするものや、Railsのアップデートに伴って動作しなくなるものがあり、非常に辛いことになるのですが、kakurenbo-putiはdefault scopeを上書きせずにあくまでも機能を追加するだけなので副作用の心配がありません。

github.com

では、追加しましょう。

gem 'devise'
gem 'devise-i18n' # <-手軽に日本語化したければ…
gem 'kakurenbo-puti'

bundle install します。

deviseをセットアップする

deviseをセットアップします。今回はUserモデルとします。論理削除のみに集中するので、他の設定については公式を参照してください。

$ bin/rails generate devise:install
$ bin/rails generate devise User

これで、deviseの各種ファイルとUserモデルのファイルとマイグレーションファイルが追加されました。まだマイグレーションしないでください。

マイグレーションファイルを修正する

deviseのデフォルトのマイグレーションだと、論理削除を考慮した構成になっていませんので、論理削除用のカラムの追加と、インデックスの設定の修正を行います。

具体的には、以下を追加します。

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      # 略
      # kakurenbo-puti
      t.datetime :soft_destroyed_at
      # 略
    end

    # 元々あるemailをユニークにするindexはコメントアウトor削除する
    # add_index :users, :email, unique: true

    # 代わりに、soft_destoryed_atがNULLであることを条件にした部分indexを追加する
    add_index :users, :email, unique: true, where: '(soft_destroyed_at IS NULL)'
    add_index :users, :soft_destroyed_at
  end
end

修正が終わりましたら、マイグレーションを実行します。

$ bin/rails db:migrate

kakurenbo-putiをセットアップする

Userモデルでkakurenbo-putiの機能が使えるようにするため、修正します。

メールアドレスで再登録可能な実装をする場合、deviseのvalidatableはemailのみでユニークであることを検証しようとするため、コメントアウトして、代わりに自分でvalidationを追加する必要があります。

class User < ApplicationRecord

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable,
         :registerable,
         :recoverable,
         :rememberable,
         # :validatable, # <- emailのみでユニーク制約を検証してしまうのでコメントアウトする
         :confirmable,
         :lockable,
         :timeoutable

  soft_deletable # <- kakurenbo-putiを使えるようにする

  # 論理削除に対応するため、validationをカスタマイズする
  validates :email, presence: true, length: { maximum: 255 }
  validates_uniqueness_of :email, scope: :soft_destroyed_at
  validates_format_of :email, with: Devise.email_regexp, if: :will_save_change_to_email?
  validates :password, presence: true, confirmation: true, length: { in: Devise.password_length }, on: :create
  validates :password, confirmation: true, length: { in: Devise.password_length }, allow_blank: true, on: :update

  # 略
end

これで、Userモデルに論理削除用の機能が追加されました。

kakurenbo-putiで追加されるメソッド

論理削除機能が追加されることで、以下のようなメソッドが使えます。

ActiveRecord::Relation
User.all # 論理削除済ユーザも含んだ全てのユーザを取得
User.without_soft_destroyed # 論理削除済ユーザを除外して取得
User.only_soft_destroyed # 論理削除済ユーザのみ取得
Userモデル
user = User.without_soft_destroyed.first
user.soft_destroyed? # => false
user.soft_destroy # => 論理削除を実行
user.soft_destroyed? # => true
user.restore # => 復元を実行
user.soft_destroyed? # => false

他にも、callbackや破壊的メソッドなどもありますので詳しくはkakurenbo-putiのページをご参照ください。

ログイン時の処理で論理削除済ユーザを除外する

あとは、実際にログイン処理を行うときに利用されるメソッドであるfind_for_database_authenticationを上書きします。

参考になる公式のWikiはこちら。こちらはユーザ名でログインの実装なのですが、やっていることはfind_for_database_authenticationの上書きです。

How To: Allow users to sign in using their username or email address · heartcombo/devise Wiki · GitHub

再び、Userモデルを修正します。やることは、without_soft_destroyedを挟むことで、論理削除済ユーザを除外することです。

class User < ApplicationRecord
  # 略

  # データベース認証時に使われるメソッドを上書きして、
  # without_soft_destroyedを追加する
  def self.find_for_database_authentication(warden_conditions)
    conditions = warden_conditions.dup
    self.without_soft_destroyed.where(conditions.to_h).first
  end

  # 略
end

アカウント削除のactionを上書きする

あとは、アカウント削除処理を上書きしてdestroyする箇所をsoft_destroyにする必要があります。

まず、上書き用のコントローラーを作成しておきます。

$ rails generate devise:controllers users

これで、app/controllers/users配下にコントローラーができました。まだこれは使われる状態ではありません。config/routes.rbに設定する必要があります。

では、config/routes.rbでdeviseのregistrationsのコントローラーに先ほど生成されたコントローラーを指定します。

Rails.application.routes.draw do
  # 略
  devise_for :users, controllers: {
      registrations:      'users/registrations',
  }
  # 略
end

最後に、app/controllers/users/registrations_controller.rbを編集します。

class Users::RegistrationsController < Devise::RegistrationsController
  # 略
  
  # DELETE /resource
  def destroy
    resource.soft_destroy # <- 論理削除を実行
    Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
    set_flash_message :notice, :destroyed
    yield resource if block_given?
    respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) }
  end

  # 略
end

まとめ

これで、deviseに論理削除を実装することができました。 deviseとkakurenbo-putiを合わせて使うと、論理削除の実装の手間がかなり軽減されます。 それでは、よい論理削除ライフを。

gem buoysを使ってパンくずリストを作る

今ちょっと新しいRailsアプリを作っていて、そこでパンくずリストが欲しいなぁ〜と思ったので社内のチャットで「パンくずリストを作るためのgemのデファクトスタンダートってやっぱりgretelですか?」と聞いたところ、I18n対応できるbuoysというgemがあるのでそちらのほうがいいんじゃない?と言われたのでそちらを使ってみることにしました。

buoysはgretelにインスパイアされてるみたいなので、ほぼ同じに見えました。

buoyはググってみると、ブイ(海とかに浮いてる目印になるやつ)のようです。なるほど〜。

github.com

こっちはgretel。

github.com

セットアップ

インストール

Gemfileに追加します。

gem 'buoys'

そして、bundle installを実行します。

ファイル生成

generateコマンドがあるのでそれを使います。--template slimとすることで、slimに対応したテンプレートが作られます。hamlもあるとか。

$ bin/rails g buoys:install --template slim
  create  config/locale/buoys.en.yml
  create  config/buoys/breadcrumbs.rb
  create  app/views/breadcrumbs/_buoys.html.slim

設定する

app/views/layout/appication.html.slimに追加

出力するパンくずリストをrenderしときます。

= render partial: 'breadcrumbs/buoys'

Bootstrap4に対応する

デフォルトだとBootstrap4に対応していないので、classを追加するなどしておきます。 liタグに.breadcrumb-itemを追加しただけだったような気がする…。

- if buoys.any?
  ol.breadcrumb itemscope=true itemtype='http://schema.org/BreadcrumbList'
    - buoys.each.with_index(1) do |link, i|
      li.breadcrumb-item itemprop='itemListElement' itemscope=true itemtype='http://schema.org/ListItem'
        - # if `link.current?` is true, link.options includes {class: 'current'}.
        - if link.current?
          span itemprop='name'
            = link.text
          meta itemprop='position' content=i
        - else
          = link_to link.url, link.options.merge(itemprop: :item) do
            span itemprop='name'
              =link.text
          meta itemprop='position' content=i

パンくずリストを定義する

config/buoys/breadcrumbs.rbを修正します。

直接文字をハードコードする場合は、以下のようにします。

buoy :stories do
  link 'Stories', stories_path
end

I18n対応する場合は、symbolを渡します。

buoy :stories do
  link :stories, stories_path
  # same as `link I18n.t('stories', scope: 'buoys.breadcrumbs', default: 'stories'), story_path`
end

モデルのインスタンスを渡しておくこともできます。また、pre_buoyを指定することで、上位のパンくずを指定できます。

buoy :story do |story|
  link story.title, story_path(story)
  pre_buoy :stories
end

Localeファイルを定義する

config/locale/buoys.ja.ymlを作ります。

ja:
  buoys:
    breadcrumbs:
      stories: 物語一覧

パンくずリストを表示させる

app/views/stories/index.html.slimに、以下を設定します。

ruby:
  buoy :stories

これで、http://localhost:3000/storiesにアクセスすると、パンくずリスト物語一覧と表示されます。

次に、app/views/stories/show.html.slimに、以下を設定します。

ruby:
  buoy :story, @story # @story.idは1, @story.titleは指輪物語とする

これで、http://localhost:3000/stories/1にアクセスすると、パンくずリスト物語一覧 / 指輪物語と表示されます。

新規作成、編集にもパンくずリストをつける

さらに対応していこうと思います。

config/locale/buoys.ja.ymlを編集します。

ja:
  buoys:
    breadcrumbs:
      stories: 物語一覧
      new: 新規作成
      edit: 編集

config/buoys/breadcrumbs.rbを修正します。

buoy :stories do
  link :stories, stories_path
end

buoy :story do |story|
  link story.title, story_path(story)
  pre_buoy :stories
end

buoy :new_story do
  link :new, new_story_path
  pre_buoy :stories
end

buoy :edit_story do |story|
  link :edit, edit_story_path(story)
  pre_buoy :story, story
end

app/views/stories/new.html.slimに、以下を設定します。

ruby:
  buoy :new_story

そして、app/views/stories/edit.html.slimに、以下を設定します。

ruby:
  buoy :edit_story, @story

これで、新規作成のときは物語一覧 / 新規作成と表示され、編集のときは、物語一覧 / 指輪物語 / 編集と表示されるようになりました。やったね👍

パンくずリストの定義を自動生成する

そうはいってもモデルの数だけパンくずリストを定義していくの面倒過ぎます😩 そこで、メタプログラミングすることにしました。

config/buoys/breadcrumbs.rbに関数define_buoyを定義しました。ガンガンeval使ってます!(evalは自己責任で…) 一応、nested_resourcesやnamespaceやデフォルトのbuoyにも対応しています。 ただし、nested_resourcesには1階層のみ。複数階層も対応できるとは思いますが、考慮しないといけないことが増えすぎるのでここまでにしてます。

def define_buoy(single_name, parent: nil, title_method:, namespace: nil, default_buoy: nil)
  single_name = "#{parent}_#{single_name}" unless parent.nil?
  single_path = "#{single_name}_path"
  single_path = "#{namespace}_#{single_path}" unless namespace.nil?

  plural_name = single_name.pluralize
  plural_path = "#{plural_name}_path"
  plural_path = "#{namespace}_#{plural_path}" unless namespace.nil?

  if parent.nil?
    buoy plural_name.to_sym do
      link plural_name.to_sym, eval(plural_path)
      pre_buoy default_buoy unless default_buoy.nil?
    end

    buoy single_name.to_sym do |record|
      link eval("record.#{title_method}"), eval("#{single_path}(record)")
      pre_buoy plural_name.to_sym
    end

    buoy "new_#{single_name}".to_sym do
      link :new, eval("new_#{single_path}")
      pre_buoy plural_name.to_sym
    end

    buoy "edit_#{single_name}".to_sym do |record|
      link :edit, eval("edit_#{single_path}(record)")
      pre_buoy single_name.to_sym, record
    end
  else
    buoy plural_name.to_sym do |parent_record|
      link plural_name.to_sym, eval("#{plural_path}(parent_record)")
      pre_buoy parent.to_sym, parent_record
    end

    buoy single_name.to_sym do |parent_record, record|
      link eval("record.#{title_method}"), eval("#{single_path}(parent_record, record)")
      pre_buoy plural_name.to_sym, parent_record
    end

    buoy "new_#{single_name}".to_sym do |parent_record|
      link :new, eval("new_#{single_path}(parent_record)")
      pre_buoy plural_name.to_sym, parent_record
    end

    buoy "edit_#{single_name}".to_sym do |parent_record, record|
      link :edit, eval("edit_#{single_path}(parent_record, record)")
      pre_buoy single_name.to_sym, parent_record, record
    end
  end
end

この関数を使ってみましょう。

define_buoy 'story', title_method: :title

上記の関数は、以下の定義と同じです。

buoy :stories do
  link :stories, stories_path
end

buoy :story do |story|
  link story.title, story_path(story)
  pre_buoy :stories
end

buoy :new_story do
  link :new, new_story_path
  pre_buoy :stories
end

buoy :edit_story do |story|
  link :edit, edit_story_path(story)
  pre_buoy :story, story
end

素晴らしい🎉🎉🎉

ネストしたリソースの場合

例えば、StoryにTagがネストしているとします。(/stories/1/tags, /stories/1/tags/1のように…)

こうします。

define_buoy 'tag',parent: 'story', title_method: :name

上記の関数は、以下の定義と同じです。

buoy :story_tags do |story|
  link :story_tags, story_tags_path(story)
  pre_buoy :story, story
end

buoy :story_tag do |story, tag|
  link tag.name, story_tag_path(story, tag)
  pre_buoy :story_tags, story
end

buoy :new_story_tag do |story|
  link :new, new_story_tag_path(story)
  pre_buoy :story_tags, story
end

buoy :edit_story_tag do |story, tag|
  link :edit, edit_story_tag_path(story, tag)
  pre_buoy :story_tag, story, tag
end

そして、これに対応するLocaleの設定は、こう。

config/locale/buoys.ja.ymlを編集します。

ja:
  buoys:
    breadcrumbs:
      stories: 物語一覧
      story_tags: タグ一覧
      new: 新規作成
      edit: 編集

これで、

  • index…物語一覧 / 指輪物語 / タグ一覧
  • show…物語一覧 / 指輪物語 / タグ一覧 / ファンタジー
  • new…物語一覧 / 指輪物語 / タグ一覧 / 新規作成
  • edit…物語一覧 / 指輪物語 / タグ一覧 / ファンタジー / 編集

のようにパンくずリストが作られます。

めちゃくちゃ捗る🚀🚀🚀

まとめ

buoy自体も非常に便利ですが、この関数によってパンくずリストの定義がすこぶる簡単になったのでよかったです👍

RailsでModelとDBの制約の検証をするときの方針について

弊社の若者のPRでコメントしたんだけれど、これは普通に記事にできるかなと思ったので転載する。

事の経緯

保存できないことの検証はわざわざupdateせずにvalid?で取れるからそうしてほしいと伝えたら、保存できることの検証までvalid?で済ませようとしたため、コメントしました。

以下、コメント。


保存できること、更新できることのテストをする場合はちゃんとsave, create, updateが成功することを確認してください。 valid?が通ったからといって、保存が成功するとは限りません。DB側の制約に引っかかる可能性があるからです。

覚えておいてほしいのですが、ソフトウェアに限らず、物事には様々はレイヤーがあります。 そこを意識してください。

Modelでのバリデーション

DBでのバリデーション

保存

Modelでのバリデーション

メリット

  • ソースコードで管理できる
  • テストが容易
  • 複雑なロジックで検証できる
  • 検証していることに名前をつけられる

デメリット

  • psql等で直接データを登録されることには無力😥
  • タイミングによっては検証をすり抜けてくる😵
    • DBにユニーク制約なし、Model側だけでユニーク制約をつけている場合、同時にバリデーションを確認すると通ってしまう

データベースでのバリデーション(Unique制約, Not Null制約, Check制約等)

メリット

  • 確実にデータの整合性を担保できる👍
    • Model側でNot Null制約(presence: true)つけていてもpsqlならNullで登録できるが、DBでNot Null制約をつけておけばエラーを検知できる
  • 簡単な制約なら導入が楽😄

デメリット

  • ソースコードで管理できない😩(極論をいうとできるけど難しい)
  • 複雑なロジックの制約を作るのが難しい🤯
    • 別テーブルのデータを条件にするとか…。

テストの方法

プログラム側からのテストなら、Modelのバリデーションのテストも、DBの制約のテストもできます。

Modelのバリデーションの検証

  • record.valid?で可能
    • DBへの保存がないぶん速い🚀
    • save, create, updateをするとDBへの保存が発生するのでテストが遅くなる👎
      • Modelの検証で保存できないことを確認するだけならvalid?で十分
  • 本当に保存できることを確認したいときはsave, create, updateの結果を検証すること🙏
    • DBの制約の検証も通過して保存ができる、ということ

DBの制約の検証

  • record.update_column(s)等、あえてModelでの検証をしないメソッドを使う
    • save(validate: false)でも可だが、update_column(s)のほうが対象のカラム名がわかりやすい
    • DBの制約に引っかかったら例外が発生するのでそれを捕捉する
expect {
  record.update_column(:name, nil) # Not Null制約の検証
}.to raise_error(ActiveRecord::NotNullViolation)

以上です。

WSL2 + Docker Desktop for WindowsでDockerが動かない件を直した

Docker Desktop for WindowsならWSL IntegrationがあるのでWSL2でDockerが動くと聞いたのでやってみようとしたのですが、うんともすんとも言わないので、一旦WSL2のUbuntuをアンインストールして入れ直しました。

ということで、入れ直したら動いたので、まぁなんか調子悪かったんやなと思ってたのですが、後日WSL2上のUbuntuからdocker-composeを実行したらまた動かない…。

全く原因が分からないので色々ググってはDockerを再起動したり、WSL Integrationを無効・有効を繰り返したりとかしてみたんですが、全くダメ。

そして、GitHubのIssueを見てみたら全く同じような症状の人たちが結構いるみたいだったので、ずーっと読んでいってたら、解決策がありました!

github.com

WSL Integrationを有効にすると、docker-desktop-proxyが上手いこと繋げてくれてるらしい…。

qiita.com

上記のQiitaの記事を引用すると、

$ ps -ef | grep docker
root        41    40  0 13:04 pts/0    00:00:00 /mnt/wsl/docker-desktop/docker-desktop-proxy --distro-name Ubuntu --docker-desktop-root /mnt/wsl/docker-desktop

Technical Preview版では WSL 2の Ubuntuで dockerデーモンが動作していましたが、backend版では docker-desktop-proxyという Docker機能のプロキシーのようなプロセスが確認できます。 また Windows Hyper-Vマネージャーの表示では、Docker DesktopVMは動作していない状態ですが、WSL 2の UbuntuWindowsコマンドプロンプトからの両方から Docker機能を利用できそうです。

とのことなのだが、私の環境でやってみると、どうなるか…。

$ ps -ef | grep docker
patorash     14873 10929  0 03:02 pts/2    00:00:00 grep --color=auto docker

そんなプロセスはいない!ということで、GitHubのissueを参考に、このdocker-desktop-proxyを直接動かしてみます。

$ sudo /mnt/wsl/docker-desktop/docker-desktop-proxy --distro-name Ubuntu --docker-desktop-root /mnt/wsl/docker-desktop

パスワードを聞かれるので、打ちます。その後はうんともすんとも言わないけれど、プロセスが動いてます。

そこで、別タブでターミナルを開いて、docker-compose psと打ってみたところ、結果が表示されました!

そして、docker-desktop-proxyを実行しているタブではログがどんどん流れていきました。

$ sudo /mnt/wsl/docker-desktop/docker-desktop-proxy --distro-name Ubuntu --docker-desktop-root /mnt/wsl/docker-desktop
[sudo] password for patorash:
INFO[0010] proxy >> GET /v1.30/containers/json?all=1&limit=-1&filters=%7B%22label%22%3A+%5B%22com.docker.compose.project%3Dsampleproject%22%2C+%22com.docker.compose.on
eoff%3DFalse%22%5D%7D&trunc_cmd=0&size=0

INFO[0010] proxy << GET /v1.30/containers/json?all=1&limit=-1&filters=%7B%22label%22%3A+%5B%22com.docker.compose.project%3Dsampleproject%22%2C+%22com.docker.compose.on
eoff%3DFalse%22%5D%7D&trunc_cmd=0&size=0 (3.5251ms)

INFO[0010] proxy >> GET /v1.30/containers/ec0324ce627586321377c9b651240975e448314423a29fed2213f30124d0c19e/json

動いてるっぽい。大丈夫そうなので、docker-compose up -dを実行したら、ちゃんとWSL2上のUbuntuでdockerのコンテナが立ち上がりました👍

ようやくまともにWindowsRailsの開発ができそうです!✌✌✌

CircleCIでdocker-composeを起動してテストを実行

ようやく、CircleCIにてdocker-composeを使ってテストを流して問題なく終了するようになったので、そのためにやったことを書いておきます。 これまでの経緯は過去の記事を参照のこと。

patorash.hatenablog.com

patorash.hatenablog.com

patorash.hatenablog.com

目次

不具合の修正について

できたと思って記事を書いたのですが、実際に運用してみるとちょこちょこ不具合が出てきたので、この記事は随時更新していきます。すみません…。

  • 👍2020-03-13修正済:testジョブでbundle installしろと言われて落ちる
  • 👍2020-03-22修正済:artifactsの保存ができていない

CircleCIでdocker-composeを動かすためにやったこと

開発で使っているdocker-composeと同じものを丸々使ってテストが完走できることを目標としました。こうすることで、開発環境・テスト環境での差異がなくなるので、より安定した開発ができるはずです。

大きな変更点

実行環境をdockerからLinux VMに変更

executorをdockerからmachineに変更しました。machineは、LinuxVMが立ち上がる設定です。

circleci.com

machineを使うことで、Linuxでできることは大概できるようになります。これで、VM内でdocker-composeが起動できます。 executorがdockerの頃は、Dockerイメージの設定をCircleCIの設定ファイルに持たせなければなりませんでしたが、machineにすると全てdocker-compose.ymlで完結するので、そういう点では楽です。

テストの実行をdocker-compose execにて行う

executorがdockerの頃は、.circleci/config.ymlで指定したrubyのコンテナでテストを実行していましたが、machineの場合はdocker-composeのrubyコンテナで行うことになるので、コマンドの先頭にdocker-compose execが付くようになります。

executorをmachineにしてテストすることのメリット・デメリット

先にメリットとデメリットを書いておきます。

メリット

docker-compose.ymlにコンテナ設定が書ける

先ほども書きましたが、CircleCI用にdocker imageを選定しなくてもよくなるのがメリットです。docker-compose.ymlに定義されているimageが丸々使えるからです。

デメリット

デメリットも結構あります。

起動が遅い

executorがdockerの場合は起動が速くて、直ぐに動き始めます(インスタントオン)。が、machineの場合は、若干もたつきます(30~60秒)。CircleCIの公式ドキュメントにそのように書いてあります。

実行時間が長くなった

テストを実行するための準備に時間がかかります。どういう点かというと、docker imageのダウンロードと、docker imageのビルドです。 ビルドに関しては、Docker Layer Cachingが使えるとありますが、1回につき200クレジットと書いてあり、結構高いです…。resource sizeがmedium 1分10クレジットなので20分に相当します。そのため、現在はまだ使っていません。

毎回docker imageをダウンロード・ビルドするのはいかがなものか?と思うので、Docker Layer Cachingを利用せずに、docker saveコマンドでイメージを出力し、それをsave_cacheとrestore_cacheを利用して使いまわしています(これはいいのか?)

これにより速度改善にはなるのですが、docker loadにかかる時間が1分半くらいかかってます。job毎にこれが発生するので、今のところ1.5分*2=3分くらいはこれで遅くなってるかなと思いますが、まぁ仕方ありません。

また、Macでのdockerの開発環境と同じにするため、docker volumesを使っています。volumeにgemやnode_modulesなどのライブラリを入れているため、それらをキャッシュするために一旦docker volumeからエクスポートしてからsave_cacheをしているので、そのあたりも遅くなる原因になります。(これは回避できるので後で解説)

クレジット消費量が増える

実行時間が長くなるということは、クレジット消費量が増えるということです。以前は20分以内に収まっていたテストが30分くらいかかるようになったので、遅くなったうえに1.5倍のクレジットがかかるとなると、結構なデメリットです…。

【2020-03-22 追記】 高速化を頑張った結果、最速で25分くらいで終わるようにはなりました。

環境変数をコンテナに渡すのが大変

executorがdockerの場合はCircleCIの環境変数を意識せずに使えるのですが、machineの場合はCircleCIの環境変数が設定されるのはLinux VMのため、docker-composeで起動するコンテナにdocker-compose execのときにそれらの環境変数を明示的に渡さなければなりません。これがいちいち面倒でした。

CircleCIのワークフロー

config.ymlの説明をする前に、使用しているワークフローを説明します。 以下が、CircleCIのワークフローです。

f:id:patorash:20200306101602p:plain
CircleCIのワークフロー図

job: generate_cache

まず、先にキャッシュを作成します。 やっていることを羅列していくと、以下の通りです。

  1. checkout
  2. docker-composeのインストール
  3. docker imageの更新・キャッシュ
  4. 必要であれば、docker-composeでtest用コンテナ起動
  5. gemの更新・キャッシュ
  6. node_modulesの更新・キャッシュ
  7. データベース定義の更新・キャッシュ
  8. docker-compose down
  9. 次のジョブにワークスペースを渡す

これを並列化せず、1コンテナで行います。たくさんのコンテナで実行しても無駄ですからね。

job: reviewdog

reviewdogはコードチェッカーで、rubocopのルールに違反しているものがあればPull Requestにコメントをしてくれるやつです。 今回は説明はこれくらいで。公開するconfig.ymlからはこの設定を削除しています(余計なので)。 詳しく知りたい人は過去の記事をどうぞ。

patorash.hatenablog.com

job: test

これがテストの本丸ですが、こっちは簡潔で、ただdocker-compose経由でテストを実行するだけです。 こちらは並列化します。(現在はparallelism: 4に設定) やっていることを羅列していくと、以下の通りです。

  1. job generate_cache から、ワークスペースを引き継ぐ
    • generate_cacheから引き継ぐため、checkout不要
    • docker images, gem, node_modulesのキャッシュも引き継ぐのでrestore_cacheが不要
  2. docker-composeのインストール
  3. docker imageのロード
  4. ダウンロードファイル置き場のディレクトリを作成
    • docker-composeを起動する前に作っておかないとエラーになる
  5. docker-composeでtest用コンテナ起動
  6. docker volumeにgemのキャッシュをロードする
  7. docker volumeにnode_modulesのキャッシュをロードする
  8. docker volumeにbootsnapのキャッシュをロードする
  9. docker volumeにPG_DATAをロードする
  10. テストを実行
  11. bootsnapのキャッシュを保存する
  12. テスト結果をsimplecovでまとめる
  13. docker-compose down

.circleci/config.ymlを公開

では、実際に使っているconfig.ymlから、一部を抜粋しました。(reviewdogのワークフローは今回の本質ではないので削除) 弊社では、parallel_testsとknapsack_proを使っていますので、そこはそのままです。そこは適宜、読み替えてください。

knapsack_proに関しては、過去の記事を読んでください。

patorash.hatenablog.com

では、config.ymlです。長いので必要なときだけ読んでください。

【2020-03-13 追記:不具合修正】

キャッシュするときのkeyの指定にバグがあったので修正しました。 下記のはgemのだけ書いてますが、yarnのほうも同様に直しています。

before: bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}

after: `bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}

不具合の解説

以前の記事では、リストアするときにkeyに以下のように指定をしていました。

  restore_bundle_cache:
    steps:
      - restore_cache:
          key: gemfile-lock-sha256sum-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          keys:
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}-{{ .Branch }}
            - bundle-v{{ .Environment.GEM_CACHE_KEY }}

これだと、ブランチが切り替わるとGemfile.lockのチェックサムで保存してあるキャッシュは無視され、古いgemがリストアされました。その後、bundle installが行われます。 その次のステップで、Gemfile.lockのハッシュ値が変わっていたら、docker volumeからバックアップを取ってから、save_cacheが行われるのですが、Gemfile.lockを更新するプルリクでテストが実行された時点で新しいGemfile.lockのハッシュ値が保存されているため、docker volumeからのバックアップが行われずにsave_cacheしてしまい、generate_cacheが終わった後にtestが実行されると、bundle installしろと怒られてテストが落ちました😥

そのため、save_cacheとrestore_cacheのkeyを修正して、Gemfile.lockのチェックサムを基準に見させるようにしました。

  restore_bundle_cache:
    steps:
      - restore_cache:
          key: gemfile-lock-sha256sum-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}

yarnのほうも同様の理由で修正しています。

【2020-03-22 追記:不具合修正と高速化】

artifactsの保存に失敗していたのを修正しました。CIRCLECI_TEST_REPORTSを、/tmp/test-resultsにしていたのですが、docker-composeでテストを行うと、dockerコンテナの/tmp/test-resultsに保存されてしまい、ホストOSで取得できない状態でした。そこで、共有ディレクトリであるRails.root/tmp/test-resultsに保存するように修正しました。

また、この修正をしている最中にも新たに高速化できそうなポイントが書かれていた記事を見つけたため、それを参考にさらに高速化しました。参照ページをリンクしておきます。

engineering.later.com

では、設定ファイルです。

version: 2.1

executors:
  default:
    machine: true
    working_directory: ~/project
    environment:
      RAILS_ENV: test
      RACK_ENV: test
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"
      CIRCLE_TEST_REPORTS: ./tmp/test-results
      PARALLEL_TESTS_CONCURRENCY: 4
      REVIEWDOG_VERSION: v0.9.17

commands:
  install_docker_compose:
    steps:
      - run:
          name: Install Docker Compose
          command: |
            curl -L https://github.com/docker/compose/releases/download/1.25.4/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
            chmod +x ~/docker-compose
            sudo mv ~/docker-compose /usr/local/bin/docker-compose

  save_docker_images:
    steps:
      - run:
          name: Check cache file, and create docker images cache
          command: |
            if [ ! -e ~/caches/images.tar ]; then
              docker-compose pull postgres redis elasticsearch memcached chrome
              docker-compose build test
              mkdir -p ~/caches
              docker save $(docker images | awk 'NR>=2 && ! /^<none>/{print $1}') -o ~/caches/images.tar
            fi
      - save_cache:
          key: docker-{{ checksum ".dockerignore" }}-{{ checksum "docker-compose.yml" }}-{{ checksum ".dockerdev/Dockerfile" }}-{{ checksum ".dockerdev/entrypoint.sh" }}
          paths: ~/caches/images.tar

  load_docker_images_and_update_libraries_if_necessary:
    steps:
      - run:
          name: Load Docker images and update libraries if necessary
          command: |
            set +e

            yarn_cache_shasum=`[ -r yarn.lock.sha256sum ] && cat yarn.lock.sha256sum`
            yarn_file_shasum=`shasum -a 256 yarn.lock`

            gem_cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            gem_file_shasum=`shasum -a 256 Gemfile.lock`

            # ライブラリの更新が必要な場合、docker-composeを起動してbundle install, yarn installを行う
              if [ "${yarn_cache_shasum}" != "${yarn_file_shasum}" ] || [ "${gem_cache_shasum}" != "${gem_file_shasum}" ] || [ ! -e ~/caches/pg_data.tar ]; then
              docker load -i ~/caches/images.tar
              docker-compose up -d test

              # Load libraries
              if [ -e ~/caches/bundle.tar ]; then
                docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bundle.tar
              fi

              if [ -e ~/caches/node_modules.tar ]; then
                docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/node_modules.tar
              fi

              # bundle install
              if [ "${gem_cache_shasum}" != "${gem_file_shasum}" ]; then
                docker-compose exec test sh -c "bundle check || bundle install --clean"
              fi

              # yarn install
              if [ "${yarn_cache_shasum}" != "${yarn_file_shasum}" ]; then
                docker-compose exec test yarn install
              fi

              # Database Setup & Backup $PGDATA
              if [ ! -e ~/caches/pg_data.tar ]; then
                docker-compose exec \
                  -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
                  test \
                  bin/rails db:migrate:reset
                docker-compose exec \
                  -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
                  -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
                  test \
                  bin/rails parallel:load_structure[$PARALLEL_TESTS_CONCURRENCY]
                docker-compose stop postgres

                # postgresのコンテナから$PGDATAをコールドバックアップする
                pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
                docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar cvf /backup/pg_data.tar -C / var/lib/postgresql/data
                docker kill $pg_container_name
                docker-compose start postgres
              fi
            fi

  load_docker_images:
    steps:
      - run:
          name: Load Docker images
          command: docker load -i ~/caches/images.tar

  restore_docker_images:
    steps:
      - restore_cache:
          key: docker-{{ checksum ".dockerignore" }}-{{ checksum "docker-compose.yml" }}-{{ checksum ".dockerdev/Dockerfile" }}-{{ checksum ".dockerdev/entrypoint.sh" }}
          paths: ~/caches/images.tar

  docker_compose_up:
    steps:
      - run:
          name: docker-compose up
          command: |
            docker-compose up -d test

  save_node_modules_cache:
    steps:
      - run:
          name: Create node_modules cache
          command: |
            set +e
            cache_shasum=`[ -r yarn.lock.sha256sum ] && cat yarn.lock.sha256sum`
            file_shasum=`shasum -a 256 yarn.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 yarn.lock > yarn.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/node_modules.tar -C / app/node_modules
            fi

      - save_cache:
          name: Saving yarn.lock Hash Value
          key: yarn-lock-sha256sum-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
          paths:
            - yarn.lock.sha256sum
      - save_cache:
          name: Saving node_modules Cache
          key: yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
          paths:
            - ~/caches/node_modules.tar

  load_node_modules_cache:
    steps:
      - run:
          name: Load node_modules caches
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/node_modules.tar

  restore_node_modules_cache:
    steps:
      - restore_cache:
          key: yarn-lock-sha256sum-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
      - restore_cache:
          keys:
            - yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}-{{ checksum "yarn.lock" }}
            - yarn-{{ arch }}-v{{ .Environment.YARN_CACHE_KEY }}
            - yarn-{{ arch }}

  save_bundle_cache:
    steps:
      - run:
          name: Create bundle cache
          command: |
            set +e
            cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            file_shasum=`shasum -a 256 Gemfile.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 Gemfile.lock > Gemfile.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bundle.tar -C / bundle
            fi
      - save_cache:
          name: Saving Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - Gemfile.lock.sha256sum
      - save_cache:
          name: Saving Gem Cache
          key: bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/caches/bundle.tar

  load_bundle_cache:
    steps:
      - run:
          name: Load bundle caches
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bundle.tar

  restore_bundle_cache:
    steps:
      - run:
          name: Get the Gemfile.lock from the develop branch
          command: |
            git show develop:Gemfile.lock > DevelopGemfile.lock
      - restore_cache:
          name: Restore Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          name: Restore Gem Cache
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "DevelopGemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}

  docker_compose_down:
    steps:
      - run:
          name: docker-compose down
          command: docker-compose down
          when: always

  sync:
    steps:
      - run: sync

  restore_bootsnap_cache:
    steps:
      - restore_cache:
          name: Restore bootsnap cache
          keys:
            - bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }}
            - bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-develop

  save_bootsnap_cache:
    steps:
      - run:
          name: Create bootsnap cache
          command: |
            docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bootsnap.tar -C / app/tmp/cache
          when: always

      - save_cache:
          name: Saving bootsnap cache
          key: bootsnap-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }}-{{ epoch }}
          paths:
            - ~/caches/bootsnap.tar
          when: always

  load_bootsnap_cache:
    steps:
      - run:
          name: Load bootsnap caches
          command: |
            if [ -e ~/caches/bootsnap.tar ]; then
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar xvf /backup/bootsnap.tar
            fi

  restore_pg_data_cache:
    steps:
      - run: cp config/{database_circleci,database}.yml
      - run:
          name: Database Checksum
          command: |
            find db -type f -exec md5sum {} \; | sort -k 2 > db_dir_checksums.txt
            md5sum config/database.yml >> db_dir_checksums.txt
            echo $PARALLEL_TESTS_CONCURRENCY >> db_dir_checksums.txt
            md5sum docker-compose.yml >> db_dir_checksums.txt
      - run:
          name: cat db_dir_checksums.txt
          command: |
            cat db_dir_checksums.txt
      - restore_cache:
          name: Restore PG_DATA
          key: pgdata-v2-{{ arch }}-{{ checksum "db_dir_checksums.txt" }}

  save_pg_data_cache:
    steps:
      - save_cache:
          name: Saving PG_DATA
          key: pgdata-v2-{{ arch }}-{{ checksum "db_dir_checksums.txt" }}
          paths:
            - ~/caches/pg_data.tar
          when: always

  load_pg_data_cache:
    steps:
      - run:
          name: Load PGDATA cache
          command: |
            docker-compose stop postgres
            pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
            docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar xvf /backup/pg_data.tar
            docker kill $pg_container_name
            docker-compose start postgres

jobs:
  generate_cache:
    executor: default
    steps:
      - checkout
      - install_docker_compose
      - restore_docker_images
      - save_docker_images
      - restore_bundle_cache
      - restore_node_modules_cache
      - restore_bootsnap_cache
      - restore_pg_data_cache
      - load_docker_images_and_update_libraries_if_necessary
      - save_bundle_cache
      - save_node_modules_cache
      - save_pg_data_cache
      - docker_compose_down
      - persist_to_workspace:
          root: ../
          paths:
            - project
            - caches

  test:
    executor: default
    parallelism: 4
    steps:
      - attach_workspace:
          at: ~/
      - install_docker_compose
      - load_docker_images
      - run:
          name: Make download directory
          command: |
            mkdir -p tmp/download
            chmod 777 tmp/download
      - docker_compose_up
      - load_bundle_cache
      - load_node_modules_cache
      - load_bootsnap_cache
      - load_pg_data_cache
      - sync
      - run:
          name: Run rspec in parallel
          command: |
            docker-compose exec \
              -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
              -e CI=$CI \
              -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
              -e KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC=$KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC \
              -e KNAPSACK_PRO_CI_NODE_TOTAL=$CIRCLE_NODE_TOTAL \
              -e KNAPSACK_PRO_CI_NODE_INDEX=$CIRCLE_NODE_INDEX \
              -e KNAPSACK_PRO_BRANCH=$CIRCLE_BRANCH \
              -e KNAPSACK_PRO_COMMIT_HASH=$CIRCLE_SHA1 \
              -e KNAPSACK_PRO_LOG_LEVEL=warn \
              -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
              test \
              bin/parallel_test -n $PARALLEL_TESTS_CONCURRENCY -e './bin/parallel_tests'
      - save_bootsnap_cache
      - deploy:
          command: |
            # テスト実行後なのでSimpleCovの結果をまとめる
            docker-compose exec \
              -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
              -e CI=$CI \
              -e CIRCLE_PROJECT_USERNAME=$CIRCLE_PROJECT_USERNAME \
              -e CIRCLE_PROJECT_REPONAME=$CIRCLE_PROJECT_REPONAME \
              -e CIRCLE_BUILD_NUM=$CIRCLE_BUILD_NUM \
              -e CIRCLE_TOKEN=$CIRCLE_TOKEN \
              -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
              -e CIRCLE_WORKING_DIRECTORY=$CIRCLE_WORKING_DIRECTORY \
              test \
              bundle exec ruby circleci_simplecov_parallel.rb
      - docker_compose_down

      # Save artifacts
      - store_test_results:
          path: ./tmp/test-results

      - store_artifacts:
          path: ./tmp/test-results
          destination: test-results

workflows:
  version: 2
  build:
    jobs:
      - generate_cache
      - test:
          requires:
            - generate_cache

工夫した点

ライブラリのキャッシュの必要性をハッシュ値を保存しておく

executorがdockerの場合は、単純にsave_cache, restore_cacheしておけば、同じcacheのkeyがあるときはスキップすればいいだけです。

しかし、docker-composeでvolumeを使っていてそこにライブラリを入れている場合、ホストOSから直接参照できないので、volumeのバックアップを作成してホストOSから参照できるところに置かなくてはなりません。このプロジェクトの場合は、gem置き場のバックアップに2分かかっていました。

2分かかって出力してから、「あ、このcacheのkeyは既にあるからスキップね」となると、この2分が丸々無駄です!😡とはいえ、更新があった場合はsave_cacheのタイミングで新しいファイルが置いてないと困ります。

そこで、こちらでもGemfile.lockとyarn.lockのsha256のハッシュ値をファイルに保存するようにして、現在のGemfile.lock, yarn.lockと比較させて、違っていたらdocker volumeをバックアップするようにしました。

config.ymlからそこだけ抜粋します(Gemfile.lockのみ)

commands:
  # 略
  save_bundle_cache:
    steps:
      - run:
          name: Create bundle cache
          command: |
            set +e
            cache_shasum=`[ -r Gemfile.lock.sha256sum ] && cat Gemfile.lock.sha256sum`
            file_shasum=`shasum -a 256 Gemfile.lock`
            if [ "${cache_shasum}" != "${file_shasum}" ]; then
              shasum -a 256 Gemfile.lock > Gemfile.lock.sha256sum
              docker run --rm --volumes-from project_test_1 -v ~/caches:/backup busybox tar cvf /backup/bundle.tar -C / bundle
            fi
      - save_cache:
          name: Saving Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - Gemfile.lock.sha256sum
      - save_cache:
          name: Saving Gem Cache
          key: bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/caches/bundle.tar

これで、2分短縮できました🥳(ライブラリの更新がない場合に限る)

dockerに環境変数を渡す

ついうっかり忘れがちでしたので、気をつけましょう。CircleCIのコンテナには、環境変数が設定されていますが、コンテナが起動するdockerのコンテナにはCircleCIが自動で設定してくれる環境変数は反映されていません!必要な環境変数は適宜、渡します!

- run:
    name: Run rspec in parallel
    command: |
      mkdir /tmp/test-results
      docker-compose exec \
        -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
        -e CI=$CI \
        -e CIRCLE_TEST_REPORTS=$CIRCLE_TEST_REPORTS \
        -e KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC=$KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC \
        -e KNAPSACK_PRO_CI_NODE_TOTAL=$CIRCLE_NODE_TOTAL \
        -e KNAPSACK_PRO_CI_NODE_INDEX=$CIRCLE_NODE_INDEX \
        -e KNAPSACK_PRO_BRANCH=$CIRCLE_BRANCH \
        -e KNAPSACK_PRO_COMMIT_HASH=$CIRCLE_SHA1 \
        -e KNAPSACK_PRO_LOG_LEVEL=warn \
        -e PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY \
        test \
        bin/parallel_test -n $PARALLEL_TESTS_CONCURRENCY -e './bin/parallel_tests'

これで、テストもちゃんと通るはず…!!👍

2020-03-22 追記分

追加でやったことを一覧にしておきます。

  1. PARALLEL_TESTS_CONCURRENCYの指定を4に変更した
  2. gemのキャッシュを参照にする際に、基本ブランチを2番目に参照にするよう修正した
  3. 基本ブランチはキャッシュがヒットする可能性が高いから
  4. bootsnapのキャッシュを保存するように修正した
  5. PG_DATAのコールドバックアップを再利用するよう修正した

1つずつ説明していきます。

PARALLEL_TESTS_CONCURRENCYを2から4に変更

これは、先ほど紹介した記事に、machine sizeがmediumなら並列化は4が最適と書かれていたから、実際にしてみたところ、2の時よりも短くなったためです(30秒~1分程度)。mediumだとCPU数がそもそも2なので、劇的な変化はありませんが、恐らくI/O待ちが発生している間に実行できるテスト数が増えることで多少速くなるのかな?と思います(あくまで推測ですが…)

ただし、4並列にすると、Chromeがメモリ不足でエラーを起こすようになるので、以下の設定が必須です。

patorash.hatenablog.com

基本ブランチを2番目に参照するよう修正

これも先ほど紹介された記事にありました。確かに現在のGemfile.lockの値を参照するのが一番で、二番目には基本ブランチのGemfile.lockを参照したほうがキャッシュ効率がよさそうでした。

うちではdevelopブランチが基本ブランチなので、そこからgit showコマンドを使ってDevelopGemfile.lockを作り、restore_cacheする際に使うように修正しました。

commands:
  # 略
  restore_bundle_cache:
    steps:
      - run:
          name: Get the Gemfile.lock from the develop branch
          command: |
            git show develop:Gemfile.lock > DevelopGemfile.lock
      - restore_cache:
          name: Restore Gemfile.lock Hash Value
          key: gemfile-lock-sha256sum-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
      - restore_cache:
          name: Restore Gem Cache
          keys:
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "Gemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}-{{ checksum "DevelopGemfile.lock" }}
            - bundle-{{ arch }}-v{{ .Environment.GEM_CACHE_KEY }}
            - bundle-{{ arch }}
bootsnapのキャッシュを保存するように修正

bootsnapはRailsの起動するまでの処理をキャッシュすることで高速化するためのgemで、Rails5.2から標準になっています。これがない場合はRailsを起動するためのキャッシュ生成で結構時間がとられるので、これを保存して再利用すると、Railsが直ぐ起動できて速くなります。なるはずです。しかし、そこまで効果が高いかと言われるとうちのプロジェクトでは微妙でした…。bootsnapのキャッシュの保存にも時間がかかるので、効果があるプロジェクトだったらやるべきかなとは思います。

PG_DATAのコールドバックアップを再利用するよう修正

これは先ほど紹介した記事から着想を覚えて行った、オリジナルの高速化です。Executorがdockerであれば、先ほどの記事で紹介された手法がいいでしょう。 軽く説明しておくと、parallel_testを使うとテストが並列で行えるので速くなる一方、その分データベースを作らなければならず、そこに結構時間を取られます。各コンテナでDB作成を行わせていたのですが、4つのデータベースを作るのに大体40秒~1分くらいかかっていました。 これを、先ほどの記事では、データベースに関わる変更があるかどうかわかるものと、pg_dumpall.sqlというファイルをキャッシュしておいて、変更がなければpg_dumpall.sqlからデータベースを作るというものでした。この方法だと、わざわざRailsを介さずにデータベースを作成することができるため、高速化が期待できます。

しかし、docker-composeならば、更にPGDATA(PostgreSQLのデータが保存されている場所)を丸々コールドバックアップしてロードすれば、sqlを通してデータベースを作成する手間も省けるのでは?🤔と考えました。最終的にはそれは思った通りで成功し、40秒~1分かかっていた処理が6~10秒になったので効果ありましたが、そこにたどり着くまでに結構ハマりました。

まず、コールドバックアップを取らなければならないので、PostgreSQLを停止しなければなりません。そこで、docker-compose exec postgres /bin/bashでpostgresのコンテナの中に入り、pg_ctl stopを実行したところ、コンテナごと停止しました。まぁ確かに、コンテナが実行していたプロセスを止めたので、そうなるか…と思いましたが、じゃあどうすればコールドバックアップを取れるのか?

結論としては、docker-compose run -d postgres /bin/bashで、一時的にpostgresのコンテナを立ち上げるのですが、PostgreSQLを立ち上げずにbashを立ち上げさせておきます。その際に、一時的なコンテナ名を取得します。 そして、そのコンテナに対してバックアップを行わせました。

# postgresのコンテナから$PGDATAをコールドバックアップする
pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar cvf /backup/pg_data.tar -C / var/lib/postgresql/data
docker kill $pg_container_name

あとは、~/caches/pg_data.tarを各コンテナでロードします。ロードするときも、postgresコンテナは停止させて、runで一時的なコンテナを立ち上げています。終わったら即docker killしています。

docker-compose stop postgres
pg_container_name=`docker-compose run --rm -d postgres /bin/bash`
docker run --rm --volumes-from $pg_container_name -v ~/caches:/backup busybox tar xvf /backup/pg_data.tar
docker kill $pg_container_name
docker-compose start postgres

データベースのセットアップは結構時間のかかる処理ですが、これでDBに変更がない限りは数秒で終わるようになりました👍

感想

ここにたどり着くまでに3週間くらいかかったと思うので、かなり大変でしたが、なんとかなりました。 開発環境とCI環境が同じコンテナを使うので、安心感はありますね。

ただ、テストがやや遅くなるのが…😢これからも高速化できそうなポイントがあったら模索していきます😀

2020-03-22 追記

ここまでの修正で、キャッシュがフル稼働するときの時間ではありますが、30~33分→25~28分くらいにはなりました。

  • 極力docker loadをしない
  • 最もパフォーマンスのよい並列数にする(parallel_testにて)
  • コンパイルキャッシュを再利用する
  • ライブラリの更新がなければ何もしない
  • データベースの更新がなければ何もしない

という方向性で、30分が25分になったので、約16%の高速化です。

多分これ以上となると、テスト自体のリファクタリングをするか、parallelismを上げるしかないと思います!