Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Eightの品質を保ち続ける「Rails × RSpec × AWS」なローカル & CI環境

Eight事業部プロダクト部 Platform Group / Engineering Manager の 藤井洋太郎(yotaro) です。

さて、私が属するPlatform Groupは「アーキテクチャ刷新」「データ基盤整備」「セキュリティ」「環境整備」など多方面での開発・改善を行っています。

本記事では「ローカル開発/CI環境改善」の取り組みについて取り上げたいと思います。

早速ですが、みなさん、

テスト書いてますか?

書いてますよね、そうですよね!!!この令和時代において、テストを書いてない人がいるわけ無いですよね😇

一方で、AWSなどのフルマネージドサービスに依存するテストコード となるとどうでしょうか?
ドキっとする人も多いのではないでしょうか?

外部サービスに依存するテストを真剣にやろうと思うと、意外と考えることが多く後回しになってしまいがちです。

みなさんも以下のような経験があるのではないでしょうか?

マネージドサービスあるある

1. Stubしがち👷🏻‍♂️

Aws.config[:stub_response] = true

AWS SDKを通したAPIリクエストをstubすることで、それっぽい挙動を検証することができます。 しかし、AWSサービス側の制約やリクエストパラメータの検証など細かいケースをケアするにはかなりの体力が必要となってきます。

2. テスト書かなくなりがち🤷🏻‍♂️

stub したコードが多くなってくると、価値の無いテストコードが生まれがちです。 そうなるとテストを書くモチベーションも下がっていきます。

expect(dynamodb_client).to receive(:query).with(expected_query_parameters)

上記のテストは意味がないとまでは言いませんが、経験上安心感を持てるとは言えませんね。

3. ローカルから実環境につなぎがち🚀

また、ローカル環境での動作確認のためにStaging環境に直接つなぐといったことを考えたことがある人もいるのではないでしょうか。

# ~/.aws/credentials
aws_access_key_id=[stagingのkey]
aws_secret_access_key=[stagingのsecret]

仮に設定を誤って migration 等を実行してしまった、本番につないでしまった、なんてことになったらそれこそ大事故です。

これらはあくまで一例ですが、ローカル環境やテスト環境を整備しないと、品質の低下、開発生産性の低下、事故リスクの増加 につながってきます。

ということで、ここからは Eight で改善を積み重ねてきた Rails × AWS なローカル/CI環境を紹介できればと思います。

Eightのローカル環境

結論から言うと、Eightでは主要機能で利用しているAWSサービスのほぼ全てがローカル/CI環境で立ち上がるようになっています。 f:id:yotaro-fujii:20200406233905p:plain

もちろん実AWSサービスではなく、各サービスと互換性がある いわゆるFake系サービス を駆使しています。

Fake系サービスとDockerイメージ

以下にAWS各サービスと互換性のあるFake系サービスを一部まとめてみました。また大変有り難いことにDocker Hub上ですぐに利用できる形の Docker Image も公開されています。*1

AWS Services Fake系 Fake系 Docker Image
Aurora Mysql mysql
Redshift PostgreSQL postgresql
Elasticsearch Elasticsearch Service elasticsearch
Elasticache Memcached & Redis memcached, redis
S3 MinIO minio/minio
DynamoDB DynamoDB Local amazon/dynamodb-local
SQS ElasticMQ softwaremill/elasticmq
CloudSearch nozama-cloudsearch oisinmulvihill/nozama-cloudsearch

EightではこれらのImageを利用しdocker-compose up で各Fake系のDockerコンテナがローカルに一斉に立ち上がるようになっています。

# 例) docker-compose.yml

services:
  dynamodb:
    image: amazon/dynamodb-local:latest
    ports:
      - "8000:8000"
  sqs:
    image: softwaremill/elasticmq:latest
    ports:
      - "9324:9324"
  s3:
    image: minio/minio
    ports:
      - "9000:9000"
...

各サービス立ち上がったら、あとはアプリケーションから接続させていくだけです。

ローカル/CIで動作確認するまでのステップ

ここからは、Railsアプリケーションから各サービスに対する接続の仕方を説明していきます。 ステップとしては大きく以下になるかと思います。

  1. 各AWS Client の初期化設定
  2. 各リソースに対するBacketやテーブル、インデックスの作成処理
  3. RSpecの設定
  4. RSpec高速化のためのTIPS

1. AWS Clientを初期化する

ローカルから各サービスへ接続する方法はとても簡単です。
Railsの初期化処理で以下のように、AWS SDK / Clientの初期値を指定してあげれば OK です。

# ~/config/initializers/aws.rb

if Rails.env.development? || Rails.env.test?
  # サービスによっては`region`や`key`の値でデータを分けてくれるので Rails.env を入れておくのがベター
  Aws.config[:region] = "#{Rails.env}_dummy_region"
  Aws.config[:access_key_id] = "#{Rails.env}_acceess_key_id"
  Aws.conifg[:secret_access_key] = "#{Rails.env}_secret_access_key"
  Aws.config[:stub_responses] = true # Fake系に接続しないサービスの場合

  # 各サービス毎のendpointや各種設定. エンドポイント等は環境変数にしておくとよりよい
  Aws.config[:dynamodb] = {
    endpoint: 'http://localhost:8000',
    stub_responses: false,
  },
  Aws.config[:s3] = {
    endpoint: 'http://localhost:9000',
    force_path_style:  true,
    stub_responses: false,
  }
end

上記がセットできれば、以下のようなコードでも各設定をデフォルト値として読み込んでくれます。

client = Aws::DynamoDB::Client.new
client.list_tables
=> #<struct Aws::DynamoDB::Types::ListTablesOutput table_names=[...

これだけで development や test 環境の準備ができました。簡単ですね 👏🏻

2. 初期化するためのRake Taskを用意する

接続ができたので、次は Table や Bucket、Queue 等を用意します。

Eightでは以下の用にサービス毎にyamlファイルを用意しリソース管理をしています。 *2

s3:
  buckets:
    - hoge-bucket-<%= Rails.env %>
    - fuga-bucket-<%= Rails.env %>

sqs:
  queues:
    - hoge_queue_<%= Rails.env %>
    - fuga_queue_<%= Rails.env %>

dynamodb:
  tables:
    hoge_table
      :table_name: hoge_table_<%= Rails.env %>
      :key_schema: 
        - :attribute_name: attr1
           :key_type: HASH

この設定を読み込み、各AWS Clientを使ってリソースの作成APIを呼び出していくイメージです。

しかしこれらのセットアップ処理は、作成しては壊してを繰り返すような開発環境では大変なので、Eightでは簡単な Rake Task を自作して手順に組み込んでいます。

bin/rake dynamodb:create_tables
bin/rake s3:create_buckets
bin/rake sqs:create_queues

db migrationのように細かい処理が必要になることはほぼ無いので、作成や削除コマンドだけ用意すれば十分かと思います。

S3に対するRakeコマンドは以下のようなコードで定義することができます。

namespace :s3 do
  desc 'create buckets for S3'
  task create_buckets: :environment do
    s3_client = Aws::S3::Client.new
    bucket_names = [上記のyamlファイルから読み込む]
    bucket_names.each do |bucket_name|
      s3_client.create_bucket(bucket: bucket_name)
    rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
      puts "`#{bucket_name}` already exists."
    end
  end
end

ここまででローカルやテスト環境でAWSと連携した開発やテストの実行をすることができるようになったはずです 🎉

3. RSpecで動くようにする

さて、ここからは テスト/RSpec の設定になります。
すでにテスト環境でもAWSサービスには接続できるはずですが、リソースのクリーニング(初期化)処理をしていないため、このままでは適切なテストを行えません。

そこで DatabaseCleaner / DatabaseRewinder のような処理を各リソースに対して実行できるようにしていきます。
spec/support/*.rb といったファイルを用意して、以下のような初期化処理を定義します。

# S3の例 : ./spec/support/s3.rb

s3_resource = Aws::S3::Resource.new(client: Aws::S3::Client.new)
bucket_names = [yamlファイルから読み込む]

RSpec.configure do |config|
  # Bucketを作成
  config.before(:suite) do
    bucket_names.each do |bucket_name|
      s3_resource.create_bucket(bucket: bucket_name)
    rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
      nil
    end
  end

  # Bucketを初期化
  config.prepend_before(:each) do |example|
    bucket_names.each do |bucket_name|
      s3_resource.bucket(bucket_name).clear!
    end
  end
end

before(:suite) でRSpec実行時にバケットを作成、
prepend_before(:each) で各テストケース実行前にバケットの内容を削除するようにしています。
上記の例は S3 になりますが、DynamoDBSQS においても同じ考え方で初期化処理を記述できます。

4. RSpecを高速化する

各サービスごとに初期化処理が入ったので、さぁ完璧だ!と思うかもしれませんが、実はこれだけでは実用に耐えません。 当初 Eight で導入した際は、テスト実行時間が10分だったものが40分になってしまいました🐢
当然ですが全てのテストケースにおいて各リソースへ削除のオペレーションを実行することは、相当なコストだということです。

RSpecのタグ機能を使って初期化処理を局所化する

S3やDynamoDBのデータはRDBと比べて、初期化処理を必要とするケースは限られています。 そこで、S3やDynamoDBのデータ操作に関連するテストケースのみでクリーニングできればテストの実行時間を大幅に短縮できます。

特定のテストにおいて振る舞いを変えるには、RSpecのタグ機能を利用することで実現できます。

spec/support/dynamodb.rb
RSpec.configure do |config|
  …

  config.prepend_before(:each, :dynamodb) do
    # dynamodbというタグが付けられたテストケースだけ、初期化処理を実行するend
end
各テストケース
describe 'DynamoDBに対する操作', :dynamodb do
  # dynamodbの初期化処理が行われるend

describe 'DynamoDBが関係ない操作' do
  # DynamoDBの初期化処理は行われない。
  # DynamoDBのItemの存在有無がテストの結果に影響しない場合は初期化しない。end

DynamoDBだけでなく、S3、SQS、Redis、Memacached、Elasticsearchに対する初期化設定とRSpecタグを使いわけるようにしています。
結果的に、導入前後で数分程度(約10分→13分)の実行時間の差に抑えることができ、この対応だけでも効果はかなり大きかったです。

テストにおける注意点

WebMockを利用しているケース

テストにおいて外部リクエストを行わないよう、WebMockを利用している方も多いかと思います。
その場合は各サービスに対するアクセスも弾かれてしまうため、各々の環境に合わせて許可をしておく必要あります。

config.before(:suite) doWebMock.disable_net_connect!(
    allow_localhost: true, # 各コンテナがlocalhostで起動している場合はこれだけで十分
    allow: [
      Aws::DynamoDB::Client.new.config.endpoint.to_s, # DynamoDB Localへのアクセスを許可
      Aws::S3::Client.new.config.endpoint.to_s,       # S3へのアクセスを許可
      …
    ], 
  )
end

これで、理想のローカル環境に

ここまで来れば、development環境もtest環境も完璧に動いているはずです!
ローカルでの動作確認やテスト実行において、AWS結合環境と同等の環境を再現できることは、開発生産性や安心感という点で想像以上に大きいものになるはずです。
リソースのセットアップ処理や、RSpecでの初期化処理が少し大変ですが、一度定義してしまえば継続的に使えるので複利で効いてくるものになるのでオススメです。

まとめ

Eightでは、本記事のような開発環境やテスト環境に対する取り組みをサービス開始当初から継続的に改善し続けてきました。 そのため、AWS等の外部サービスをコアに利用する機能が多くある中でも、生産性を落とさず品質の高いデリバリーを日々提供できていると感じています。

「ローカルで動かない」は健全じゃない

近年では、コンテナ技術の発展やOSSの拡がりにより、我々が日々開発する環境もとても便利になってきたと感じます。
今までこういった下回りの環境整備に対して二の足を踏んでいた方も、ぜひこの機会にチャレンジしてもらえればと思います。

サービスの安定稼働と継続的成長のために、妥協なき開発環境を目指していければとおもいます💪🏻


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

*1:LocalStackの利用を検討してみるのも良いと思います。https://localstack.cloud/

*2:Eightではリソース管理がしやすいよう Rails.env でリソース名が変わるように規約を設けています。

© Sansan, Inc.