patorashのブログ

方向性はまだない

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を上げるしかないと思います!

docker-composeでseleniumを使っててChromeがクラッシュする際に対処法について

先日こんな記事を書いていました。

patorash.hatenablog.com

この記事では、Capybaraの設定について取り上げていたのですが、CapybaraがInvalid session idのエラーを起こす原因については特定できていませんでした。おそらくメモリが不足しているから、という推測はありましたが、どうやらそれで決定のようです。

ひとまず、シングルプロセスでのテストの完走は果たしたので、2並列で行なったところ、そちらも完走しました🎉🎉🎉

しかし、3並列にしたらいきなり落ちまくりました😱

やはりメモリ不足が原因のようです。

Chromeのコンテナにメモリを設定するには?

以下の記事を参考にしました。

qiita.com

この記事で紹介されていた記事に書いてありました。

qiita.com

shmサイズとな!?

shmサイズは、Chromeがコンテンツをダウンロードした際の一時ファイル領域のサイズのようです。複数のChromeを立ち上げると、ここが枯渇してしまい、クラッシュするようです。 shmサイズはデフォルトで64mらしいので、並列実行する分、多めに設定してみます。

shmサイズを設定する

shmサイズを設定するには、docker-compose.ymlのchromeの箇所を修正します。

version: '3.3'

services:
  # 略
  chrome:
    image: selenium/standalone-chrome:latest
    shm_size: 256m # とりあえず4倍に…
    ports:
      - '4444:4444'
    volumes:
      - ./tmp/download:/tmp/download

  # 略

この設定で4並列でテストを実行したところ、問題なくテストが完了しました🥳🥳🥳

ようやくCircleCIの設定にいけそうです。

第22回岡山Ruby,Ruby on Rails勉強会で登壇してきた

昨日になりますが、約2年ぶりに岡山Ruby,Ruby on Rails勉強会が開催されました。 そんなに間が空いてたかな…と思ったけれど、確かに空いてた。

今回は、登壇者として申し込んでいました。

自分の発表

今回は数ヶ月前に仕事で調べていたOpenID Connectを題材にして発表しました。 ゴールをOAuth2.0とOpenID Connectについてざっくりとわかってもらうことにして、RailsOpenID Connectを使うには?に軽く触れるという感じです。 まぁ自分もまだRailsではOpenID Connectでログインできた!というあたりまでしかできていませんが…😅

追記

2020-03-04: OAuth, OpenID Connectに詳しい方々からご指摘やアドバイスを頂き、スライドを修正しています。発表時点のものとは異なっています。

デモでは、権限の認可の結果がキャッシュされていて、確認画面が表示されなかったのでもたついてしまいましたが、本来は以下のような確認画面が出るはずでした。

f:id:patorash:20200301085628p:plain
OpenID Connectの権限確認画面

資料作りでは、ページ数が増えすぎてコンセプトがブレてるなと思ったので、5日前になって作り直そうと思って始めたものの、発表日前日どころか当日の1時間半前まで資料を作ってました。

資料を作るに当たって

資料を作るに当たってというか、OAuth2.0とOpenID Connectを理解するに当たって、本を3冊読みました。それらを紹介しておきます。

かなりのボリュームですが、OAuth2.0についてと、実装サンプルがあるため、写経をすることですごく勉強になりました。サンプルはnode.jsで作られているのですが、ライブラリのアップデートがあってそのままでは動かないという地雷がありました😇 ただ、やはり翻訳本ということもあってか、理解につまづくこともしばしばありました…。後で紹介する本を読んだあとに、また読むと、理解が深まりました。

Auth屋さんのOAuth2.0についての本です。OpenID Connectについては載ってないのですが、PKCEのこととか勉強になりました。

booth.pm

Auth屋さんOpenID Connectについての本です。OpenID Connectについては、OAuth徹底入門のほうでも書かれているのですが、理解のしやすさでいえば断然こちらの本だと思います。資料を作るときに何度も参考にさせてもらいました😄

他の発表について

パズルで学ぶRuby

同僚のeryskさんの発表。RubyKaigi2019であった、Cookpadさんが考えたRubyパズルをみんなでやってみよう、というものでした。制限時間があり、途中でヒントを出していく方式で、みんなも考えながらRubyの構文に触れることができてとてもよかったと思います。会社で一度発表練習をしていたのですが、その際のフィードバックをすごく活かせてたなと感心しました。

delayed jobの運用

@ore_publicさんの発表。バックグラウンドジョブの制御で抱えていた課題をどうしたか?というもの。delayed jobはジョブにプライオリティをつけて優先度を指定することができるけれど、とはいえ、一度ジョブが動き始めると優先度の高いジョブが後で追加されてもそのジョブが終わるまでは何もできない。これはこのところ勉強しているデータベーススペシャリストの午前問題で出てきた、優先度逆転というやつだな!と思いながら聞いてました。排他制御が働くとどうしてもそうなるので、今回は優先度が最高のジョブのみ処理するプロセスと、その他のジョブを処理する2つのプロセスを置くことで対処したという話でした。

あとは、プロセスの監視でmonitを使って自動起動させたりとか、monitの監視をMackerelで行なったりなど…。発表後の質疑では、systemdにしたほうがいいよという話も。

"Railsをはじめよう"を真似てみた

qt_luigi(岩田プロ)さんの発表。普段は趣味でgoを使っているという話でしたが、Railsを触ってみたら勉強になることがあるかもな、ということで他言語使いからの目線で「Railsをはじめよう」をやってみたという発表で、どういうところでつまづいたか、とか、RESTfulやルーティングについてとかを調べた過程がわかって、Railsの大まかな処理の流れを再確認できてよかったです。質疑では意見交換や、そこの解釈は実際はこうですよ、みたいな話ができてよかったと思います。

それとは別に、なぜか岩田プロのPCのバッテリーが残り少ないのに充電せずに発表してました。

そして・・・

しかし質疑応答している間に残り9%になってました。

Rubyで試すZipkin

岡山大学の乃村研究室の学生による、分散トレーシングシステムのZipkinに関する発表でした。

マイクロサービス化していると、1つの処理に複数のサービスが関わっていることがありますが、そのままだとログが追えないため、zipkinでラベル付して追えるようにしてありました。この発表では、sinatrarailsで複数のサービスを立ち上げ、それを追うためにrackミドルウェアでZipkinのラベルを貼るための設定と、ラベルを送るFaradayのプラグインの設定などを解説していました。

ZipkinサーバはDockerで起動できるようなので、導入も簡単そうでよさそうだなと思いました!👍👍👍

懇親会は中止

今回は新型コロナウィルスの影響もあって、懇親会は中止となってしまったのが残念でしたが、致し方なし…。

また頻度を上げて開催していくとのことだったので、まだ発表したことない人はぜひチャレンジしていってください!

docker-composeでseleniumを使っている際のCapybaraの設定について

以前にこんな記事を書きました。

patorash.hatenablog.com

今回はこれの続きみたいなものです。 まだCircleCIでのテスト実行まで至らず、ローカルでテストが全部通ることを目標に調整中です。 featurespec以外のテストは完走したので、問題はfeaturespecのみ。 ということで、Capybaraの設定の見直しです。

エラーの傾向を見る

落ちたエラーを確認したところ、

  • 画像をダウンロードしようとしてURLが間違っていてエラー
  • ファイルのダウンロードが完了したことを検出できずにエラー
  • ChromeのSession IDが不正

みたいなのが大半でした。

特に最後のが結構困ったもので、これが起きるとrspecの後処理が完走せずに次のテストに行ってしまって巻き込みエラーになっているみたいなので、どうしたものか、という状況です。

Capybaraの設定を見直す

Before

とりあえずBeforeのやつを貼ります。spec/support/capybara.rbです。 WaitForDownloadモジュールは私が作っているファイルのダウンロードを待つためのモジュールです。

詳しくは私が書いたQiitaの記事を…。

qiita.com

Capybara.register_driver :selenium do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.headless!
  options.add_argument '--disable-gpu'
  options.add_argument '--window-size=1680,1050'
  options.add_argument '--blink-settings=imagesEnabled=false'
  options.add_argument '--lang=ja'
  # options.add_argument '--no-sandbox'
  # options.add_argument '--no-zygote'
  driver = Capybara::Selenium::Driver.new(app,
                                          url: ENV.fetch('SELENIUM_DRIVER_URL'),
                                          browser: :remote,
                                          options: options,
                                          desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
                                            login_prefs: { browser: 'ALL' },
                                            loggingPrefs: { browser: 'ALL' },
                                          ),
  )
  bridge = driver.browser.send(:bridge)

  path = "session/#{bridge.session_id}/chromium/send_command"

  bridge.http.call(:post, path, cmd: 'Page.setDownloadBehavior',
                                params: {
                                  behavior: 'allow',
                                  downloadPath: WaitForDownload::PATH,
                                })
  driver
end

Capybara.configure do |config|
  config.server_host = "test" # <= chromeから見た、テストを実行するイメージ名を指定
  config.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i
  config.app_host = "http://#{config.server_host}:#{config.server_port}" # <= Seleniumが接続するテストを実行するAppHostのURL

  config.javascript_driver = :selenium
  config.default_max_wait_time = ENV['CI'].present? ? 15 : 5
  config.ignore_hidden_elements = true
  config.server = :puma, { Silent: true }
end

画像のダウンロードをしない設定を調査

Capybaraの設定は特に変えていないにも関わらず、画像をダウンロードしようとするということは、リモートのChromeに設定が渡ってないってことか?と思い、調査開始。 すると、desired_capabilitiesオプションで設定するっぽい感じの情報を見つけました。

qiita.com

これを参考にして、desired_capabilitiesに対してcrhomeOptionsを定義してそちらにChromeに関するオプションの配列を渡したところ、動き始めました🎉

よりよい書き方を模索

しかし、Selenium::WebDriver::Chrome::Optionsクラスがあるにも関わらず、オプションを配列にして渡すかね?と思って綺麗な書き方を模索するためにコードを読みました。

selenium/options.rb at master · SeleniumHQ/selenium · GitHub

すると、as_jsonメソッドを発見。これを実行すると、goog:chromeOptionsというKeyを持つHashを作ってくれます(as_jsonとは?🤔)これを、desired_capabilitiesに設定するようにしたところ、リモートのChromeにもいい感じに反映されました。👍👍👍

この時点でのregister_driverだけを書き出します。

Capybara.register_driver :selenium do |app|
  chrome_options = Selenium::WebDriver::Chrome::Options.new
  chrome_options.headless!
  %w(
    no-sandbox
    disable-gpu
    window-size=1440,900
    disable-desktop-notifications
    disable-extensions
    blink-settings=imagesEnabled=false
    lang=ja
  ).each { |option| chrome_options.add_argument(option) }
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(chrome_options.as_json)
  driver = Capybara::Selenium::Driver.new(
      app,
      url: ENV.fetch('SELENIUM_DRIVER_URL'),
      options: chrome_options,
      browser: :remote,
      desired_capabilities: capabilities,
  )

  bridge = driver.browser.send(:bridge)
  path = "session/#{bridge.session_id}/chromium/send_command"
  bridge.http.call(:post, path, cmd: 'Page.setDownloadBehavior',
                                params: {
                                  behavior: 'allow',
                                  downloadPath: WaitForDownload::PATH,
                                })
  driver
end

ダウンロードしたファイルを検出したい

ファイルをダウンロードするまで待つ、という処理をしているのですが、rspecを実行しているコンテナとchromeが動いているコンテナは異なるため、ダウンロードしたファイルはchromeのコンテナ内に保存され、rspecのコンテナでは見つかりません。そりゃそうですね。そこで、docker-compose.ymlを修正して、ホストOSとファイル共有するようにします。chromeコンテナでダウンロードしたファイルをrspecのコンテナからも参照できるようにするわけです。

version: '3.3'

services:
  # 略
  chrome:
    image: selenium/standalone-chrome:latest
    ports:
      - '4444:4444'
    volumes:
      - ./tmp/download:/tmp/download
  # 略

これで、ダウンロードしたファイルを検出できるようになりました!

ダウンロードファイルのパス指定方法が変わったらしい

これを調べているときに、メドピアさんのブログを見かけて、ダウンロードのファイルパス指定方法が変わったことを知りました。

tech.medpeer.co.jp

こっちのほうがわかりやすいので、変更しました。テストを実行しても問題なくファイルがダウンロードされていたのでOK。コードが簡潔になりました👍

この時点でのregister_driverを書き出します。

Capybara.register_driver :selenium do |app|
  chrome_options = Selenium::WebDriver::Chrome::Options.new
  chrome_options.headless!
  %w(
    no-sandbox
    disable-gpu
    window-size=1440,900
    disable-desktop-notifications
    disable-extensions
    blink-settings=imagesEnabled=false
    lang=ja
  ).each { |option| chrome_options.add_argument(option) }
  # ダウンロードディレクトリを設定
  chrome_options.add_preference(:download, default_directory: "/tmp/download")
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(chrome_options.as_json)
 Capybara::Selenium::Driver.new(
      app,
      url: ENV.fetch('SELENIUM_DRIVER_URL'),
      options: chrome_options,
      browser: :remote,
      desired_capabilities: capabilities,
  )
end

bridge云々のコードがなくなってわかりやすくなりました。

並列化に対応したい

弊社ではparallel_testsを使っているので、ファイルのダウンロードが並列に行われても影響がないようにしたいので、ダウンロードパスを修正します。

# ダウンロードディレクトリを設定
chrome_options.add_preference(:download, default_directory: "/tmp/download/#{ENV.fetch('TEST_ENV_NUMBER', '1')}")

こうすることで、並列実行したら、

  1. /tmp/download/1
  2. /tmp/download/2
  3. /tmp/download/3

のようにディレクトリができるようになります。

併せて、wait_for_download.rbも修正しました。

module WaitForDownload
  PATH = Rails.root.join("tmp/download/#{ENV.fetch('TEST_ENV_NUMBER', '1')}")
  # 他は略
end

After

ということで、これがとりあえず今のところのspec/support/capybara.rbの全コードになります。

Capybara.register_driver :selenium do |app|
  chrome_options = Selenium::WebDriver::Chrome::Options.new
  chrome_options.headless!
  %w(
    no-sandbox
    disable-gpu
    window-size=1440,900
    disable-desktop-notifications
    disable-extensions
    blink-settings=imagesEnabled=false
    lang=ja
  ).each { |option| chrome_options.add_argument(option) }
  chrome_options.add_preference(:download, default_directory: "/tmp/download/#{ENV.fetch('TEST_ENV_NUMBER', '1')}")
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(chrome_options.as_json)
  Capybara::Selenium::Driver.new(
      app,
      url: ENV.fetch('SELENIUM_DRIVER_URL'),
      options: chrome_options,
      browser: :remote,
      desired_capabilities: capabilities,
  )
end

Capybara.configure do |config|
  config.server_host = "test"
  config.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i
  config.app_host = "http://#{config.server_host}:#{config.server_port}"

  config.javascript_driver = :selenium
  config.default_max_wait_time = ENV['CI'].present? ? 15 : 5
  config.ignore_hidden_elements = true
  config.server = :puma, { Silent: true }
end

SessionIDが不正のやつは?🤔

まだ調査中です🥺 Chromeがメモリを使いすぎてクラッシュするみたいな話を見かけたので、window-sizeを調整したりもしているのですが、直りません。

qiita.com

window-size云々よりも、並列化で動かすことでメモリがなくなってクラッシュしている可能性が高いかなと推測しております。 まぁとりあえずシングルプロセスでの完走を目指します🏃‍♂️

追記:調査完了

Chromeへのメモリ割当方法が分かったので記事にしました。

patorash.hatenablog.com