他のテストがタイムトラベルに巻き込まれないよう確実に Timecop.return する(for RSpec)

テストコード内で Timecop.return し忘れると、後続のテストでも Timecop.freezeTimecop.travel によるタイムトラベルを続行してしまうため、正常な時刻に戻らず、全く関係のないテストが落ちることがある。

例えば、以下のように Timecop.return を記述し忘れるケースがある。 今日これに遭遇してしまったので、Timecop.return するいくつかの方法を知識整理も兼ねてまとめてみたいと思う。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  it { some_code }
end

一番単純な方法は、after ブロックで return することである。これで example 実行後にちゃんと時間がもとに戻る。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  after do
    Timecop.return
  end

  it { some_code }
end

あるいは、メソッドブロックで freeze させたいコード片を囲っても良い。こっちのほうが Ruby らしい。

describe Foo do
  it do
    Timecop.freeze(2000, 10, 10, 9, 0, 0) { some_code }
  end
end

このようなイケてる shared context を定義すれば、spec から Timecop を隠蔽できる。美しい。

RSpec.shared_context 'with frozen time' do |options|
  let(:frozen_time) { options[:at] } if options && options[:at]

  around do |e|
    Timecop.freeze(frozen_time) { e.run }
  end
end
describe Foo do
  include_context 'with frozen time', at: Time.current
  it { some_code }
end

このように Timecop.return する方法はいくつかあるが、今回は example 実行後に必ず Timecop.return を呼び出す Helper を作った。

module RSpec
  module TimecopReturnHelper
    def self.included(rspec)
      rspec.after(:each) do |_|
        Timecop.return
      end
    end
  end
end
RSpec.configure do |config|
  config.include RSpec::TimecopReturnHelper
end

これによって、他のテストのタイムトラベルに巻き込まれることを確実に防げるし、二度と Timecop.return を書き忘れない。

spec 側ですでに Timecop.return している場合は、二重に Timecop.return への呼び出しが発生するが、これによってテスト時間が延びることは今のところなさそうである。

最初から git rebase しやすくするためのコミットの作り方

概要

GitHub 上でレビューしやすい状態にするため、いつも git rebase でコミットの整理を行うようにしているが、時々 rebase 時に conflict を起こして面倒になることがあった。最近思いついた方法で予めコミットを作っておけば git rebase がしやすくなりそうだと思ったのでその方法を書いた。

ルール

以下のルールをブランチを切った時点から意識する。あらかた実装が終わり微調整の段階になった時点で git rebase でコミットの整理を行う。

  1. コンポーネント(クラス、モジュール、React コンポーネントなど)の追加と適用は同じコミットに混ぜない
    • ファイルの追加・削除は、1 ファイルごとに 1 コミット
    • 追加したコンポーネントを別のコンポーネントから利用するためのコミットは、別のコミットとして切り出す
    • 同じコミットに混ぜてしまうとあとで分離したいときに面倒だし、コミット順を変えたりするとよく conflict してうざいので、最初から分けてしまう。
  2. コミット全体の流れを機能やリファクタを完成する一連のストーリーとして作る
    • コンポーネント作成・削除: 機能実現やリファクタに必要な部品を追加、削除を行う
    • コンポーネント統合: 作成したコンポーネント群を利用し、機能が働く状態にする
    • 調整: 本筋とは関係の薄い修正(Linter の警告の抑制や見た目の調整など)を行う
  3. 必要に応じてコミットの本文(body)にコミットの意図や詳細を書く
    • あとで rebase するときの助けになるし、コードレビューもしやすくなる
    • コンポーネント統合のコミットは、時間がないときはどうしても一つにまとめてしまいがちなので、コミットの本文に変更の詳細を書いてごまかす

コミット例

業務中のコミットを一部修正して抜粋した。

1. サーバサイド: コンポーネント作成・削除

デプロイ後に走らせるためのスクリプトの追加と、既存コードへのメソッド追加。

Date:   Mon Aug 13 16:35:19 2018 +0900

    [#4270] Add script to create document
    この機能のデプロイ時に script を実行することで、最新の件数を保存する

A   script/2018/20180813_create_document.rb
Date:   Mon Aug 13 16:58:23 2018 +0900

    [#4270] Add RestClient#update_document

M   app/models/rest_client.rb

2. サーバサイド: コンポーネント統合

1 で追加したコードを利用して、サーバサイドの機能を実現する。

これは好みだが、実装とテストは同じコミットに含めたほうが、レビュー時に実装とテストを見比べやすくなって良いと思う。

Date:   Mon Aug 13 17:07:29 2018 +0900

    [#4270] Implement server-to-client notification

M   app/models/history.rb
M   spec/models/history_spec.rb

3. クライアントサイド: コンポーネント作成・削除

既存の React コンポーネントへの機能追加。

Date:   Mon Aug 13 19:43:04 2018 +0900

    [#4270] Add status badge

M   spa/src/Components/Common/CategoryMenu.tsx
Date:   Tue Aug 14 12:11:01 2018 +0900

    [#4270] Add requestKey props to reload current request

M   spa/src/Components/Common/Query.tsx

4. クライアントサイド: コンポーネント統合

ここまでの変更を統合して、全体の機能を実現する。

ここはもう少しコミットを分けても良かったかもしれない。コミット分割が面倒になったときは、コミット本文に変更の詳細を書いてごまかす。

Date:   Mon Aug 13 21:47:46 2018 +0900

    [#4270] Implement real-time status badge

M   spa/src/Components/List.tsx
M   spa/src/Containers/Common/List.ts
M   spa/src/Containers/XXXList.tsx
M   spa/src/Containers/YYYList.tsx

5. 調整

本筋と関係のない spec や linter まわりの修正を行った。

Date:   Tue Aug 14 18:05:27 2018 +0900

    [#4270] Fix rubocop offences

M   spec/models/history_spec.rb
Date:   Wed Aug 15 11:50:49 2018 +0900

    [#4270] Stub out RestClient

M   spec/models/history_spec.rb
Date:   Wed Aug 15 14:24:36 2018 +0900

    [#4270] Reset mock object

M   spec/models/history_spec.rb
Date:   Wed Aug 15 16:26:33 2018 +0900

    [#4270] Disable model callback in test env

M   app/models/history.rb

最後に

皆さんが普段実践している「git rebase しやすくするためのコミットの作り方」や、それに限らずもっと良さげなコミットの作り方があれば教えてほしいです!

OpenCV で手本の文字と自分が書いた文字をマッチングしてみた(その1:特徴点マッチング)

OpenCV は初めて触った上、画像処理の知識がないので雑なまとめです。

はじめに

書道で使う手本は文字が小さく、手本は何十ページもあるため、後で自分が書いたものと手本を見返そうとしても探すのが大変だったりする。

f:id:nyamadori:20180603003552j:plainf:id:nyamadori:20180603003604j:plain
左: 手本をもとに自分が書いたもの、右: 手本の一部。手本の真ん中の行が書いた場所

自分が書いた文字と予めスキャンしておいた手本の中の文字とを自動でマッチングし、手本の文字と自分の文字をあわせた合成画像を生成できれば、あとで見返すのに便利そうである*1。例えば以下のような感じ。このように並べてみればニュアンスの違いが一目瞭然だし、一枚の画像に合成しておけば、スマホでいつでも確認できる。

f:id:nyamadori:20180603021624p:plain

OpenCV の特徴点マッチング

上のような合成画像を作るには、いろいろやることがありそうだが、ひとまず OpenCV の特徴点マッチングを用いて、手本の文字と自分の文字のマッチングを試みた。特徴点マッチングについては、以下を参照してほしい。雰囲気がつかめると思う。

特徴点マッチングはこのように実装した。このプログラムを実行すると、左側は自分が書いたもの、右側は手本に合成された一枚の画像が GUI で表示される。結果画像の左側と右側の間には、マッチした特徴点同士が線で結ばれる。

import numpy as np
import cv2 as cv
import matplotlib
from matplotlib import pyplot as plt

# 手本をもとに自分が書いたもの
origin1 = cv.blur(cv.imread('a_reproduce.jpg', cv.IMREAD_GRAYSCALE), (10, 10))

# 手本
origin2 = cv.blur(cv.imread('a_model.jpg', cv.IMREAD_GRAYSCALE), (10, 10))

# リサイズしないと処理速度的に厳しかった
resized_image_1 = cv.resize(origin1, None, fx=0.2, fy=0.2, interpolation=cv.INTER_CUBIC)
resized_image_2 = cv.resize(origin2, None, fx=0.2, fy=0.2, interpolation=cv.INTER_CUBIC)

# 二値画像に変換
ret1, preprocessed_image_1 = cv.threshold(resized_image_1, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)
ret2, preprocessed_image_2 = cv.threshold(resized_image_2, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)

# 特徴点検出器をインスタンス化
detector = cv.AKAZE_create()

# 特徴点を検出
kp1, des1 = detector.detectAndCompute(preprocessed_image_1, None)
kp2, des2 = detector.detectAndCompute(preprocessed_image_2, None)

bf = cv.BFMatcher()
matches = bf.knnMatch(des1, des2, k=2)
good = []
for m,n in matches:
    if m.distance < 0.75 * n.distance:
        good.append([m])

img3 = cv.drawMatchesKnn(preprocessed_image_1, kp1, preprocessed_image_2, kp2, good, None, flags=2)

plt.imshow(img3) # 結果画像を表示
plt.show()

結果は次のようになった。ひと目で見て分かる通り、うまくマッチングできていない。うまくマッチングできていれば文字を繋ぐ線が分散せず、一つの対応する文字に集中する。

f:id:nyamadori:20180603010004p:plain

以下は正しくマッチできている箇所。周辺の空間に特徴があれば、精度高くマッチングできそう。

f:id:nyamadori:20180603010258p:plain

以下はミスマッチの箇所。エッジの角度やコーナーの形が類似する部分でミスマッチが多発しているっぽい。

f:id:nyamadori:20180603010308p:plain

考察

  • 特徴点マッチングは、エッジの角度や空間の形状をもとにしていると思われる
  • 文字は単純な線や空間で構成されているため、エッジの角度や空間の形状をもとにした特徴点マッチングでは、文字同士 をマッチングするのが難しい
  • 文字同士をマッチングするには、エッジの角度や空間の形状という細かい粒度ではなく、「文字」を塊として手本とマッチングする必要があると考えられる

おわりに

画像処理をちゃんと分かってなくてもそれっぽい結果が得られる OpenCV すごい。

次は、「文字」を塊として扱いやすいテンプレートマッチングを試してみたい。

*1:半紙一枚書くたびに写真を撮ればいいと思うかもしれないが、集中力が切れてしまう

Docker Compose で MySQL コンテナを立てるときに MYSQL_ROOT_PASSWORD 環境変数の指定でハマった

結論

  1. Docker Compose のサービスの環境変数は Hash 形式で設定するのがおすすめ
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_ROOT_PASSWORD: '' # **空文字列** がパスワードとして設定される
  1. docker-compose down db だけだと、db コンテナに紐づく volume は削除されない。MYSQL_ROOT_PASSWORD 周りの環境設定を書き換えた後は、ボリュームを含めて削除する必要がある

背景

罠1. 配列形式で環境変数を指定したときの解釈

配列形式の環境変数指定の場合、""空文字列ではなく、ダブルクオーテーション 2 文字("")の文字列として解釈されて、MySQL ユーザが作成される。

environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
- MYSQL_ROOT_PASSWORD="" # 「""」がパスワードとして設定される

この挙動はパット見ではすぐ気づけないので、以下のような Hash 形式で指定するのがおすすめ。

environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_ROOT_PASSWORD: '' # **空文字列** がパスワードとして設定される

そもそもこのケースの場合、パスワードが空なので MYSQL_ROOT_PASSWORD 環境変数の指定は不要である。

environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

罠2. docker-compose down db だと volume は削除されない

  • docker-compose down db しても、オプション無しではサービスがマウントしているボリュームが削除されない
    • MySQL コンテナのデータは、MySQL ユーザも含めボリュームに永続化されるため、 docker-compose down しても作成した MySQL ユーザは削除されない
    • MYSQL_ROOT_PASSWORD 周りの環境設定を書き換えた後は、ボリュームを含めて削除する必要がある

docker-compose down docker-compose up して試行錯誤中の環境変数を試してみるも、docker-compose down だけだと MySQL ユーザが削除されない。環境変数の設定値を試行錯誤するうちに、MYSQL_ALLOW_EMPTY_PASSWORD が正しく設定された MySQL ユーザが知らないうちに作られてしまっていたため、以下の記述をすればまっさらな環境でも空パスワードの MySQL ユーザができると勘違いしていた。

# 誤った記述
environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
- MYSQL_ROOT_PASSWORD="" # 「""」がパスワードとして設定される

Sidekiq を使うとき、ActiveRecord のコネクションプール数 >= Sidekiq のスレッド数 + 1 にするのが良さそう

以下の記事に書かれていたことそのままだが備忘としてまとめた。

repl.info

  • ActiveRecord のコネクションプールは、各 Sidekiq ワーカスレッドと そのスレッドが動作するプロセス(Sidekiq プロセス) に一つずつ割り当てられる
  • Sidekiq の並列数(ワーカスレッド数)と、コネクションプール上限数が等しいとき、ワーカスレッドに割り当てられるはずのプールが一つ不足する(Sidekiq プロセスに一つ割り当てられるため)
  • この状態でかつ、database.yml の checkout_timeout に設定した秒数(デフォルトは 5 秒)以上かかるジョブを実行する場合
    • 2 つのワーカスレッドに処理を割り当てると、片方のワーカスレッドはコネクションを取得できるので処理が実行できる
    • しかし、もう片方のワーカスレッドは、はじめのワーカスレッドと Sidekiq プロセスによってコネクションプールに空きがないため、checkout_timeout 秒以内にコネクションを取得できず、ジョブの実行に失敗する
  • したがって、ActiveRecord のコネクションプール数は、ワーカスレッド数 + 1 以上にするのが適切である

以上!

開発用データ入り MySQL Docker イメージを作成する仕組みを AWS CodeBuild で構築したよ

流れをぶった切ってしまってすみません! Speee Advent Calendar 2017 の 20 日目の記事です。

19 日目: スポンサー活動を本気でやってみた1年でした

概要

開発用のデータは、Web アプリケーションを作る上で必要不可欠です。しかし、手動で Jenkins から日時更新のダンプデータをローカルに落としてきて MySQL にインポートするのはダサいです。はじめから開発データが入った MySQL Docker イメージを配布しておけば、手動でダンプデータをインポートする必要がなく便利です。そこで AWS CodeBuild で開発データ入り MySQL イメージを作る仕組みを構築しました。運用はまだできていませんが、CodeBuild を使う上でハマったことがいくつかあったので書こうと思います。

きっかけ

ある日、開発環境の MySQL コンテナのデータがぶっ壊れました。データを残しつつ復旧するのが面倒だったので、MySQL コンテナを再ビルド、開発データをインポートし復旧させました。復旧に数十分かかったのですが、仮に開発データが Docker Volume ではなく、本体コンテナのイメージレイヤーに含まれていれば、データが壊れたとしても、コンテナを再起動することで破損したファイルを持つイメージレイヤーが消え、容易に復旧できるのではと思ったのです。

実際、それほど頻繁にデータは壊れませんが、はじめから開発データが入った MySQL イメージを配布しておけば、手動でダンプデータをインポートする必要がなくなり便利になるはずです。

仕組み

以下の図で説明します。まず、Jenkins が本番環境のマスク済みデータ(開発データ)を S3 バケットにアップロードします。Lambda は、S3 バケットへのアップロードを検知し、CodeBuild をキックします。それを受け CodeBuild は、最新の開発データを S3 から取得し、Docker コンテナ上で動作する MySQL に開発データをインポートします。インポート後、Docker コンテナの状態をイメージとして保存、ECR に push します。ECR に push されたイメージは、docker-compose 等から利用できます。

f:id:nyamadori:20171222010826p:plain

CodeBuild

以下がこの仕組みで作った CodeBuild の YAML ファイルです。この YAML ファイルをビルドスペックといい、buildspec.yml というファイル名をつけます。CircleCI のような感じの設定ファイルでビルド手順を記述できますが、いくつかハマリポイントがありました。環境変数は、AWS コンソール画面から与えています。

version: 0.2 # ハマリポイント 1

phases:
  pre_build:
    commands:
      - $(aws ecr get-login --region $AWS_DEFAULT_REGION --no-include-email) # ハマリポイント 2
      - aws s3 cp s3://${DUMP_BUCKET}/${DUMP_PATH} .
      - docker build -t mysql_without_volume docker/mysql_without_volume
      - docker run -d --name mysql_with_seed -e 'MYSQL_ALLOW_EMPTY_PASSWORD=yes' -p 3306:3306 mysql_without_volume
      - sudo apt update && sudo apt-get -y install mysql-client
  build:
    commands:
      - DOCKER_IMAGE_TAG=$(date '+%Y%m%d')
      - DOCKER_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}"
      - echo $DOCKER_REPO
      - echo $DOCKER_IMAGE_TAG
      - |
          mysql -u root -h 127.0.0.1 <<-SQL
            create database ${DUMP_DATABASE} CHARACTER SET utf8mb4;
          SQL
      - gzip -dc $(basename $DUMP_PATH) | mysql -u root -h 127.0.0.1 -f ${DUMP_DATABASE}
      - docker commit mysql_with_seed "${DOCKER_REPO}:latest"
      - docker commit mysql_with_seed "${DOCKER_REPO}:${DOCKER_IMAGE_TAG}"
  post_build:
    commands:
      - docker push "${DOCKER_REPO}:latest"
      - docker push "${DOCKER_REPO}:${DOCKER_IMAGE_TAG}"

MySQL Docker イメージ

この仕組みは、開発用データと一緒に MySQL イメージを配布できるようにするため、Docker 公式の MySQL 5.6 イメージ から Volume を取り除いた独自のイメージを使っています。といっても、公式 Dockerfile から VOLUME の記述をコメントアウト しただけです。これで DB のデータをコンテナのイメージレイヤーに含めることができます。

成果物

github.com

ハマりどころ

環境変数

ビルドスペックのバージョン 0.1 は、1 コマンドごとにシェルのインスタンスが独立していて、カレントディレクトリや環境変数の状態を共有することができません。 例えば、以下のようなビルドスペックにおいて、最初に定義した環境変数 DOCKER_IMAGE_TAG DOCKER_REPO の値を、後ろの echo している行で取得することができません。

version: 0.1

phases:
  build:
    commands:
      - DOCKER_IMAGE_TAG=$(date '+%Y%m%d')
      - DOCKER_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}"
      - echo $DOCKER_REPO       # => 何も表示されない
      - echo $DOCKER_IMAGE_TAG  # => 何も表示されない

対策は、バージョン 0.2 のビルドスペックを使うことです。バージョン 0.2 から前のコマンドで定義した環境変数を、後ろのコマンドに引き継いでくれます。phase が分かれていても問題ありません。 どうしてもバージョン 0.1 を使う必要がある場合は、シェルスクリプトとして切り出すか、環境変数の定義と参照を 1 コマンドにまとめることになると思います。

docs.aws.amazon.com

CodeBuild のサンプル解説ページで想定している Docker バージョンが古く、ECR ログインがコマンドオプションでこける

今回の仕組みを作るにあたり、以下の AWS 公式が提供しているサンプルを参考にしましたが、ECR のログインコマンドでコケてしまいました。

docs.aws.amazon.com

原因としては、Docker 17.06 以降 docker login コマンドから --email オプションが削除されたものの、aws ecr get-login コマンドが使う docker login コマンドに --email オプションが含まれていて、コマンドエラーになったようです。対策は、以下のように aws ecr get-login コマンドに ----no-include-email を付与することです。これで正常に動作するはずです。

- $(aws ecr get-login --region $AWS_DEFAULT_REGION --no-include-email)

docs.docker.com github.com

感想

今回、初めて CodeBuild を触りましたが、一言で表すと CircleCI のような感じです。AWS のサービスなので、すでに利用している EC2 や S3 等と密に連携できたり、サーバレスでリソースを気にせずジョブを実行できるし、CircleCI に置くのが憚られるセンシティブなデータを扱ったり、多数のビルドジョブによるテスト実行だったりに使えそうだと思いました。

21日目は、「朝起きれない問題に本気で向き合う目覚ましサービスを作った」です!

kohtaro24.hatenablog.jp

よくコケる不安定な feature spec への対処療法 rspec-retry gem

背景

業務で開発している Web サービスの feature spec が非常に不安定で、feature spec が CI 上でコケるたびに、ビルドジョブをよく手動で再実行していました。1 回の再実行でパスすることもあれば、まれに 10 回以上の再実行が必要なこともありました。

その Web サービスでは、CI のジョブ終了までに約 8 分(中央値)かかります。つまり、リトライの回数が増えるたび、大体 8 の倍数分リリースに要する時間が増加してしまいます。

このような状態だと、価値をユーザに早く届けられなくなってしまうし、個人的にもフラストレーションがたまっていたので、 2 ヶ月前に rspec-retry gem を導入しました。

rspec-retry gem

rspec-retry gem は、RSpec の example に :retry オプションを追加し、spec が成功するまでオプションで指定した回数だけリトライしてくれる gem です。

今回は、fail しやすい feature spec 全体に対して、retry オプションを付与するようにしました。

RSpec.configure do |config|
  # run retry only on features
  config.around :each, type: :feature do |ex|
    ex.run_with_retry retry: 3
  end

  config.verbose_retry = true
  config.display_try_failure_messages = true
end

導入した結果

以下の画像は、その Web サービスの CircleCI の Insights から見られるグラフで、縦軸が完了までにかかった時間、横軸がビルドの実行日時です。縦棒は、すべてのジョブではなく、ある時点でサンプルしたジョブで、緑が成功したジョブ、赤が失敗したジョブを表しています。

結果はグラフの通りで、rspec-retry 導入以降はジョブ実行中のリトライが効いたことで、手動でリトライを行うことがほとんどなくなりました。素晴らしい!

f:id:nyamadori:20171119192821p:plain

とはいえ、あくまでこの方法は対処療法で、本来ならリトライが必要にならない spec に修正するのが根本対策です。同じように困っている方がいたらお試しあれ。

リンク