メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

【初心者向け】レビュワーをイライラさせるRSpec集と解決方法

こんにちは。メドピアにjoinして3ヶ月目の保立です。
毎週のように新しい開発が進むため、毎日楽しくソースコードを書かせてもらっています。

テストコードを制するものは、Railsを制す!!!
ということで、今回はメドピアのRSpecについてです。
メドピアでは、RSpecを用いてテストコードを書いており、
- 1) models配下に記載するビジネスロジックに対するUnitTest
- 2) 機能ごとのEndToEndTest (E2E Test)
の2種類のテストコードを書いています。

RSpecについて、書こうと思ったきっかけ

RSpecは(というかRuby自体が)様々な書き方で動かすことができるため、統一したルールがないと、書いた人によってバラバラなテストコードになります。
私も初めてRSpecを書いた際に、参考書やソースコードによって書き方がバラバラで、どのRSpecを参考にすればよいのか迷いました。
RSpecソースコードの仕様を理解できると、レビュワーや後から開発する人のストレスが軽減できますが、テストコードごとのフォーマットがバラバラだと、テストコードを理解するのに、時間がかかってしまいます。(イライラする人が出てきちゃいます)
そこで、今回は、メドピア内部のルールや、今まで指摘を受けたこと、私が気をつけていることをまとめていきます。


目次

  1. describe/context/itのフォーマットが統一されていない
  2. インスタンス変数を使用している
  3. letで定義した変数名が、何を表しているかわからない
  4. beforeブロック内でテストを行う
  5. テスト対象が同じ、複数のテストケースで、subjectが使われていない
  6. 環境や時間によって失敗する

1. describe/context/itのフォーマットが統一されていない

テスト対象、条件、振る舞いが決まったところに決まったフォーマットで記載されていれば、そのテストコードが何を表しており、実際のソースコードがどのような仕様なのかを簡単につかむことができます。
下記は、フォーマットが統一されていない例です。

# contextが日本語だったり英語だったり式だったり
describe 'valid?' do
  context '非公開のとき'
  context 'publish'
  context 'expired < Time.now'  
end

# itに条件が書かれている
describe 'valid?' do
  it '◯◯で△△のとき、回答が登録されること'
end

テストの条件や結果のフォーマットが異ならないように、メドピアでは、以下のように記載しています。

内容 書き方
describe テストの対象 #インスタンスメソッド名
.クラスメソッド名
〜画面 〜処理(E2Eテスト)
context テストの前提条件 〜のとき
it テストの結果 〜されること 〜となること

また、なるべくcontextやitでは主語を書くようにしています。
context '非公開のとき' ではなく、context '質問が非公開のとき'のように書いています。

2. インスタンス変数を使用している

RSpecでは「インスタンス変数」(@から始まる変数)を使わず、「let / let!」を使って、テストで使用する変数を定義します。

# インスタンス変数を使用する
describe 'valid?' do
  before do
    @question = create(:question)
  end

  it 'is valid' do
    expect(@question).to be_valid
  end
end

「let / let!」を使うと、以下のように書き換えることができます。

describe 'valid?' do
  let(:question) { create(:question) }

  it '質問が登録されること' do
    expect(question).to be_valid
  end
end

インスタンス変数を使わない方がいい理由については、以下のリンクをご参考ください。


3. letで定義した変数名が、何を表しているかわからない

letで「インスタンス変数」の代わりに、テストを行う変数を定義できます。 しかし、定義した変数名が何を表すかが一目でわからないと、わかりやすいテストコードとは言えません。

describe 'valid?' do
  let(:question_1) { create(:question, title: 'タイトル') }
  let(:question_2) { create(:question, title: nil) }
  
  it 'タイトルが設定されている質問が、登録できること' do
    expect(question_1).to be_valid
  end

  it 'タイトルが設定されていない質問が、登録できないこと' do
    expect(question_2).to be_invalid
  end
end

このように、モデル名_1, 2 … というように変数名を設定すると、後から読んだ人は、どの変数が何を表しているのか理解するのに時間がかかってしまいます。
letで宣言する変数名には、以下のように何を表しているのかわかりやすい変数名を選ぶことを心がけています。

describe 'valid?' do
  # 基本パターンの場合、model名をそのまま変数名にすることが多いです。
  let(:question) { create(:question, title: 'タイトル') }
  # 例外パターンの場合、model名の後に例外内容を表す変数名にします。
  let(:question_without_title) { create(:question, title: nil) }
  
  it 'タイトルが設定されている質問が、登録できること' do
    expect(question).to be_valid
  end

  it 'タイトルが設定されていない質問が、登録できないこと' do
    expect(question_without_title).to be_invalid
  end
end


4. beforeブロック内でテストを行う

beforeは、テストの前提条件を用意するためのブロックです。
しかし、beforeがやるべきでないテストの実施を、beforeブロックに書かれていることもあります。

context '登録ボタンをクリックしたとき' do
  # before内でテストしているケース
  before do
    within '#button' do
      expect(page).to have_css '.disabled' # 非活性であることをチェック
    end
    fill_in 'name', with: '山田太郎'
    within '#button' do
      expect(page).to have_css '.enabled' # 活性であることをチェック
    end
    click_on '登録する'
  end

  # 登録処理のテストが続く 
  it '登録されること'
  it '画面遷移すること'  
end

上記の例のように、beforeでテストをしてしまうと、テストの前提条件がどこで何をテストしているのかわかりづらいだけでなく、before内のテストで失敗した際に、後続のテストが行われなくなってしまいます。

5. テスト対象が同じ、複数のテストケースで、subjectが使われていない

subject を使用すると、そのメソッド内のテスト内容を一括で設定することができます。

# subjectを使わない
describe '#human_time_distance' do
  context '現在時刻と一致するとき' do
    let(:from_time) { now }
    it { expect(helper.human_time_distance(from_time)).to eq '' }
  end

  context '現在時刻より前の時刻のとき' do
    let(:from_time) { now - 1.second }
    it { expect(helper.human_time_distance(from_time)).to eq '過去' }
  end

  context '現在時刻より後の時刻のとき' do
    let(:from_time) { now + 1.second }
    it { expect(helper.human_time_distance(from_time)).to eq '未来' }
  end
end

expect(helper.human_time_distance(from_time))subjectにまとめると以下のようになります。

describe '#human_time_distance' do
  subject { helper.human_time_distance(from_time) }
  
  context '現在時刻と一致するとき' do
    let(:from_time) { now }
    it { is_expected.to eq '' }
  end

  context '現在時刻より前の時刻のとき' do
    let(:from_time) { now - 1.second }
    it { is_expected.to eq '過去' }
  end

  context '現在時刻より後の時刻のとき' do
    let(:from_time) { now + 1.second }
    it { is_expected.to eq '未来' }
  end
end

特に、メソッドの返却値のテストやバリデーションのテストでは、積極的にsubjectを用いて、DRYなテストコードを心がけています。

6. 環境や時間によって失敗する

ローカルでテストした時には動いたけど、特定の環境ではたまに上手くいかないテストケースもよくあると思います。
メドピアでも、ローカルではうまくいくのに、CI上では3, 4回に1回くらい失敗するテストがありました。 以下の例は、メドピアでも比較的多かったケースです。

# let!でmodel作成後、テストまで時間がかかるケース
let!(:question_published) { create(:question, published_at: Time.current) }
let!(:question_non_published) { create(:question, published_at: Time.current + 1.second) }

before do
  # 色々処理が書かれる
  # 色々処理が書かれる
end

it '質問が表示されること' do
  # 色々テストが書かれる
  # 色々テストが書かれる

  expect(question_published).to be_valid
  expect(question_non_published).to be_invalid
end

上記の例では、published_atが現在時刻以降のquestionのみ表示するテストですが、1秒後に公開されるquestion_non_publishedを作成してからテストするまでにタイムラグが生じ、たまにうまくいかないことがあります。
このような場合は、テストを分割するか、1.secondをもっと大きな値に変更することで、解決できます。
常に成功するテストでないと、プロジェクト全体の生産性に影響を与えるので、現在の環境で常に動くテストコードを書く必要があります。



おわりに

今まで指摘されたこと、気をつけていることをまとめました。
他にも、Better Specs などを見て、勉強しています。 たかがテストコードですが、テストコードが読みやすいかどうかは、企業の文化によって大きく違うと思います。
RSpecのテストコードを書いたり、レビューする一助になれば幸いです。


是非読者になってください(ง `ω´)ง


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

form objectを使ってみよう

こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。

Ruby化のプロジェクトが始まって1年が過ぎました。新しいメンバーも入り、Railsのコード量は日に日に多くなっています。可読性を保ちつつアプリケーションを大きくしていくために、使える知見をチームメンバーに効率よく伝えていくのが大事だと感じる今日このごろです。

普段メドピア内ではコードレビューや社内勉強会などで知識のシェアを行っています。そんなとき、ブログ記事や書籍などのまとまった文章があると「これ読んでおいて」と言うだけで良くなるので楽です。先日form objectを使ったほうがいいですよーという内容でレビューコメントをつけようとしたところ、日本語で詳しくまとまった文章が見当たりませんでした><なければ自分で書くしかありません。そこで今回はRailsにおいて可読性を保つための知見である、form objectについてまとめたいと思います。

form object って何?

form_withのmodelオプション*1にActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。

利点は大きく次の2点です。

  • DBを使わないフォームでも、Active Recordを利用した場合と同じお作法を利用できるので可読性が増す
  • 他の箇所に分散されがちなロジックをform object内に集めることができ、凝集度を高められる

具体例を見ていきましょう。

具体例

Rails 5.1.0でサービス管理者にフィードバックを返すフォームを作ってみます。フィードバック内容をデータベースに保存したい場合、素直に書くと次のようになるでしょう*2

class Feedback < ApplicationRecord
  validates :title, :body, presence: true
end

class FeedbacksController < ApplicationController
  def new
    @feedback = Feedback.new
  end

  def create
    @feedback = Feedback.new(feedback_params)
    if @feedback.save
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      render :new
    end
  end

  private

  def feedback_params
    params.require(:feedback).permit(:title, :body)
  end
end
<%= form_with model: @feedback, local: true do |f| %>
  <% if @feedback.errors.any? %>
    <% @feedback.errors.full_messages.each do |message| %>
      <%= message %>
    <% end %>
  <% end %>
  <%= f.label :title %>
  <%= f.text_field :title %>
  <%= f.label :body %>
  <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

ここでフィードバックをデータベースに保存せずに、サービス管理者へメールを送信するようにコードを変更してみましょう。データベースに保存しないので、ApplicationRecordを継承したモデルは使用しません。

そうしたときにこんな感じのコードを書いてしまう人が多いのではないでしょうか。

class FeedbacksController < ApplicationController
  def new
  end

  def create
    if params[:title].present? && params[:body].present?
      AdminMailer.feedback(params[:title], params[:body]).deliver_later
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      @error_messages = []
      @error_messages << 'タイトルを入力してください' if params[:title].blank?
      @error_messages << '本文を入力してください' if params[:body].blank?
      render :new
    end
  end
end
<%= form_with url: feedbacks_path, local: true do %>
  <% @error_messages && @error_messages.each do |message| %>
    <%= message %>
  <% end %>
  <%= label_tag :title %>
  <%= text_field_tag :title, params[:title] %>
  <%= label_tag :body %>
  <%= text_area_tag :body, params[:body] %>
  <%= submit_tag %>
<% end %>

少し乱雑なコードになりました。まず、コントローラにロジックを記述してしまっています。また、独自のお作法でフォームを作成したため、ビューとコントローラを両方読まないとどのように動くのか、すぐに理解することができなくなりました。例えばビューとコントローラで@error_messagesという変数を利用していますが、どのような形式で情報が格納されているのか、どのように使用したらいいのかはビューとコントローラ両方を読まないと判断できません。

今回の例は項目数が少なく簡単なため、この程度であれば問題ないと判断する方もいるかもしれません。しかし、フォームの入力項目数が増えたり種類(セレクトボックス、チェックボックス、ラジオボタン)が増えたときのことを想像してみてください。コントローラが肥大化して読みづらくなり、ビューにどんなときに何が描画されるのかもわかりづらく、修正するのが大変になりそうですね。

これを解決するためにform objectを利用してみます。

form object を利用した例

form objectとして、ActiveModel::Modelをincludeしたクラスを用意します。 Active Modelは、Active RecordからDBに依存する部分を除いた振る舞いを提供しているライブラリです。これを利用することにより、DBを利用しないフォームでもActive Recordを利用したときと同じような記述をすることができます。

コードを見てみましょう。

class Feedback
  include ActiveModel::Model
  attr_accessor :title, :body

  validates :title, :body, presence: true

  def save
    return false if invalid?
    AdminMailer.feedback(title, body).deliver_later
    true
  end
end

class FeedbacksController < ApplicationController
  def new
    @feedback = Feedback.new
  end

  def create
    @feedback = Feedback.new(feedback_params)
    if @feedback.save
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      render :new
    end
  end

  private

  def feedback_params
    params.require(:feedback).permit(:title, :body)
  end
end
<%= form_with model: @feedback, local: true do |f| %>
  <% if @feedback.errors.any? %>
    <% @feedback.errors.full_messages.each do |message| %>
      <%= message %>
    <% end %>
  <% end %>
  <%= f.label :title %>
  <%= f.text_field :title %>
  <%= f.label :body %>
  <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

Feedbackクラスを除けば、Active Recordを利用した例と全く同じコードになりました。

このようにform objectを利用すると、Active Recordを利用した場合と同じお作法でビューとコントローラを記述することができて大変便利です。

個人的には、form_withでは必ずmodelオプションを利用する(form_tagを使わずにform_forを使う)という規則をチーム内に作ってしまってもよいのではないかと考えています。

その他の利用例

ここまでの文章を読むと「form objectはActive Recordを使わないフォームでだけ利用する」という理解をしてしまう方が多いのではないでしょうか。しかしform objectはActive Recordを利用する場合にも使えます。

例えば次のようなUserモデルがあるとします。

class User < ApplicationRecord
  has_secure_password

  validates :email,
            presence: true,
            format: { with: /\A.+@.+\z/ }
  validates :password, length: { minimum: 6 }, on: :create

  after_create_commit :send_welcome_mail

  def send_welcome_mail
    UserMailer.welcome(self).deliver_later
  end
end

よくある形だと思いますが、passwordのバリデーションやコールバックは、ユーザ作成時以外は必要のないものです。emailのバリデーションもユーザ作成時とメールアドレス変更時だけ必要なものなので、常に必要というわけではありません。

こんな時にform objectを利用してUserモデルに書かれたロジックを外出しすることができます。

class Signup
  include ActiveModel::Model

  attr_accessor :email, :password, :password_confirmation

  validates :email,
            presence: true,
            format: { with: /\A.+@.+\z/ }
  validates :password, presence: true, length: { minimum: 6 }, confirmation: { allow_blank: true }

  def save
    return false if invalid?

    user = User.new(email: email, password: password, password_confirmation: password_confirmation)
    user.save!
    UserMailer.welcome(user).deliver_later
    true
  end
end
class User < ApplicationRecord
  has_secure_password
end

Userモデルがスッキリしましたね。

他にも、複数のActive Recordを一度に保存するようなフォームなどでもform objectを活用することができます。

form objectをもっと活用する

これまでの例では、すべてのform objectにActiveModel::Modelをincludeしてきました。簡単なコード例なのでこれで十分だと思いますが、実際の現場でのコードはもっと複雑になります。そんなときActive Modelだけだと少し面倒に思う部分も出てくるはずです。

例えば先程のユーザ登録用のform objectに、サービス運営側からのダイレクトメールを受け取るか否かのチェックボックスを追加したとしましょう。Railsのビューヘルパーであるcheck_boxメソッドはチェックした場合は文字列の"1"、チェックしない場合は"0"がパラメータとして送信されてきます。Active ModelはActive Recordとは異なり、属性の自動的なキャストは行いません*3。booleanとして扱いたい場合、キャストの処理は自分で書く必要があります。

class Signup
  attr_accessor :email, :password, :password_confirmation, :accept_dm

  # 略

  def accept_dm
    ActiveRecord::Type::Boolean.new.cast(@accept_dm)
  end
end

signup = Signup.new(accept_dm: '1')
signup.accept_dm #=> true
signup = Signup.new(accept_dm: '0')
signup.accept_dm #=> false

独自にキャストしないといけない属性の数が多かったり、種類が豊富だったりしたら面倒ですね。そんなときに使える、属性の型を定義できるgemやform objectのためのgemがいくつか存在するので、form objectをバリバリ使ってみたくなった方は一度目を通しておくと良いと思います。

まとめ

form objectの利点や使いどころについて一通り解説しました。

form objectという概念自体は4~5年ほど前から広まっているはずなのですが、日本語でのまとまった文章がないことから、日本ではあまり普及していないような気がします。このエントリがform object普及の一助になって、読みやすいRailsのコードが少しでも増えることを願っています。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:もしくはform_for

*2:ビューテンプレートのデザインは省略しています

*3:カラムの型情報がないので当たり前ですが

2泊3日の開発合宿 in 湯河原 おんやど 恵に行ってきました!!!!

こんにちは。メドピアに入社してもうすぐ1年の栢割(カヤワリ)です。
そろそろ開発合宿の時期がやって参りました!!!!弊社は2013年から開発合宿を定期的に行っており、今回で第8回目になります。
(自分は第6回目にも参加し、ブログも書かせて頂きました。)

tech.medpeer.co.jp
今回も私の方から合宿の様子を書かせていただきます。よろしくお願いします!!!!

合宿テーマ

開発合宿のテーマは毎回異なり、技術研鑽や技術的負債の解消、他にも新規サービス立ち上げ等を行ったこともあります。過去の開発合宿では新規サービス立ち上げをテーマに実施され、弊社のサービスとして事業化されているものもあります。

合宿に行く前に各自、取り組む内容について宣言してから開発します。今回はこんな感じ。

  • 社内の業務改善ツール開発
  • これまで懐に温めてきた医療系サービス開発
  • メドピア既存サービスに新機能追加
  • 話題の新技術を使って開発

取り組み方は自由でチームを組んでも良し。個人でモクモクと取り組むも良し。特に取り組み方について制約は無いので、自身が取り組みたかった開発がしやすいです。
しかし、ただ開発するだけでは自己満足で終わってしまうので、今回は最終日に会社へ戻り、成果発表LTをすることになりました。

エンジニアに嬉しい旅館「湯河原 おんやど 恵」さん

f:id:takayukikayawari:20170314143607j:plain さっそく足湯に浸かっている写真ですが、これを見てピンときた方いるのではないでしょうか?
今回の合宿は湯河原の「おんやど 恵(めぐみ)」さんにお邪魔しました。SE出身の社長さんが経営されている旅館で、エンジニアに嬉しい「開発合宿プラン」などが用意されています www.onyadomegumi.co.jp
当日は湯河原駅に直接集合!湯河原駅からタクシーで5~10分程度で到着しました。 f:id:takayukikayawari:20170314143650j:plain

お部屋が空くまで少し時間があったので、先に会議室へ…。
かなり広い会議室でした。合宿参加者は7人でしたが、この人数には広すぎるくらい。電源タップ、スピーカー、USB充電器、おやつ、ホワイトボード、腰が痛くならないグッズ等、設備品も充実してました。食事をしてる最中も、旅館の方が会議室の飲み物やお菓子の補充してくれます。ありがたや〜。(余談ですが、ディスプレイのレンタルプランもあるそうです。) f:id:takayukikayawari:20170315100928j:plain

基本的にお食事、お風呂、寝る時以外は開発に没頭します!!レッドブルかまして夜遅くまで開発するメンバーもいました。 f:id:takayukikayawari:20170314180547j:plainf:id:takayukikayawari:20170315104859j:plain お菓子も摘みながらモクモク…。 f:id:takayukikayawari:20170314180820j:plain

開発の合間には戦士の休息…。足湯でリフレッシュします。気持ちいいべ〜。もちろん温泉もあります。露天風呂は最高でした。 f:id:takayukikayawari:20170314181211j:plain

美味しいご飯レポート

旅館のご飯も美味しかったです。
(※ココから美味しそうな写真が続くので、ご飯時に見るとお腹が空いてきます。ご注意ください。) f:id:takayukikayawari:20170314181544j:plain f:id:takayukikayawari:20170314182243j:plain:w290f:id:takayukikayawari:20170314183252j:plain:w290f:id:takayukikayawari:20170314183653j:plain:w290f:id:takayukikayawari:20170315183027j:plain:w290 f:id:takayukikayawari:20170314183733j:plain:w290f:id:takayukikayawari:20170314183755j:plain:w290
宿の近くにあったラーメン屋さん。あさりラーメンが美味しかったです。 f:id:takayukikayawari:20170314182140j:plain f:id:takayukikayawari:20170314182005j:plain
お店を探している道中……
弊社のRailsプロジェクト開発で大変お世話になった株式会社 万葉さんが…!!??   f:id:takayukikayawari:20170314184527j:plain
……………お茶屋さんでした…。

そして最終日…成果発表へ!!

2泊3日の合宿を終え、成果発表のために会社へ戻ります。 f:id:takayukikayawari:20170314184148j:plain

お昼過ぎに会社に到着し成果発表LTをしました。弊社代表の石見をはじめ、エンジニア以外の方々も見に来てくれました。 f:id:takayukikayawari:20170315175325j:plain

まとめ

開発合宿は普段の業務でなかなか取り組めないタスクや負債を潰したり、新しい技術に集中して取り組める絶好の機会だと思います。 また、2泊3日という短い時間で成果を出す必要があるので、自身がこの短期間にどこまで出来るかを確認できる良い機会でもあると思います。次回も是非参加して、成果発表LTで皆さんに「おぉ!!」と驚かれる物を作りたいです…。

以上!2泊3日のメドピア開発合宿 in 湯河原 おんやど 恵の様子でした。

イマドキのジョブスケジューラについて考える

こんにちは。Ruby化をすすめるメドピアをお手伝いしている@willnetといいます。

メドピアではPHPからRubyに移行するにあたり、単純に言語を置き換えるだけではなく、言語以外の仕組みについても適宜見直しを行っています。今回はそのうちジョブスケジューラを見直した件について書いていきます。

言語を置き換えた話はこちらを参考にしてください。

レガシーな独自フレームワークから脱却してRailsへ徐々に移行している話 - メドピア開発者ブログ

そもそもジョブスケジューラってなに

「毎日1時になったら前日のアクセスログを集計して統計データとしてまとめる」などといった定期的に実行するジョブを登録するためのものです。

ウェブサービスを作るときのジョブスケジューラといったらやっぱりcronですよね。メドピアでもこれまでcronを活用していました。しかしサービスが小さいうちはcronでもそれほど問題ないのですが、サービスが育つにつれだんだん問題が顕になってきます。

cronの問題ってなに

以下列挙します

スケジュールをバージョン管理できない

crontab -e などで直接crontabファイルを編集した場合、変更の履歴が残りません。つまり以前のバージョンに切り戻したり、過去の履歴を振り返ったりすることができません。

余談ですがcrontab -eを打とうとしてeの隣のrを押してしまい、crontab -rでcrontabの設定を消去してしまった経験のある人はチームに一人くらいいるんじゃないでしょうか。

間違えやすい記法

crontabの記法は一見してわかりづらく、設定ミスをしやすいものになっています。

例えば次のようなcronを設定したとします。

5 2 1 * * /your/batch/command

これは毎月1日の2時5分に/your/batch/commandを実行するcronジョブですが、ぱっと見ただけで理解するのは難しくはないでしょうか。あとは*/10とか1-5,10-20とかの特殊記号を使ったときも、本当にこの書き方で想定通りの時間に実行できるのか不安になります。

cronサーバを分散できない

ウェブサービスで負荷が高まったときには、仮にアプリケーションサーバボトルネックであれば同じサーバを追加(スケールアウト)することで負荷を分散することができます。しかしcronサーバで同じことをすると複数のサーバで同じジョブが同時に実行されることになってしまいます。そのため大抵の環境においてcronサーバは1台だけで運用されているはずです。

開発が進みcronジョブが増えてくると、時間帯によっては複数のジョブが1台のcronサーバで並列に実行されて負荷が異常に高まり、予想しない挙動や障害につながっていきます。

デバッグしづらい

cronの環境と、普段使っているシェルの環境との違いでジョブが失敗することはよくありますが、特にそれを示唆するような出力はないことが多いです。そのためこの手の経験がないと「手元で実行すると動くのになぜかcronだと動かない!なぜ!?」のようにハマります。

ログを追いづらい

cronを実行した際の出力はメールで送信されますが、2017年の現在その機能を使っている方はあまりいないのではないでしょうか。普段は自前でログを頑張って出力したものを保持しておき、エラーが起きたときにはAirbrakeなどのエラー管理システムと連携して通知、ログを漁って原因を究明する…というケースが多そうです。

ジョブごとに成功/失敗のステータスやログがまとまっていると便利ですが、そこまで自前で実装するのは大変ですね。

どうやって問題を解決するか

cronの問題についていろいろ書きましたが、どのようにしたらこれらの問題を解決できるのでしょうか。cronそのものを改善させる方向と、cronをやめる方向で考えてみます。

whenever で cron を改善させる

これまで挙げたcronの問題のうちいくつかはwheneverというgemを利用することで改善可能です。使っている方も多いのではないでしょうか。

wheneverを使うと次のような記法でジョブをファイルに定義し、crontab用の記法に変換して登録することができます。

every 1.day, :at => '4:30 am' do
  runner "MyModel.task_to_run_at_four_thirty_in_the_morning"
end

このファイルをバージョン管理しておき、デプロイ時に自動でcrontabを更新するようにします。これで、バージョン管理の問題と、記法の問題を解決できました。

しかし、他の問題は変わらず残っています。

cron をやめる

調べると、cronを置き換えようとするプロジェクトはたくさんあることがわかります。

上記はなるべくRubyで作られたツールの中から選びました。つまりRubyにこだわらなければもっとたくさんの選択肢があるわけです。悩みますね><。

全ての問題を完全に解決するツールは存在しなかったので、次のような観点でツールを選定しました。

  • 適度に問題を解決していること
  • Ruby を使ったプロジェクトであること
  • メンテナンスが続いていること
  • 最悪メンテナンスが停滞しても自分たちでなんとかできること
  • 導入のしやすさ

結果として、sidekiq-cronを採用することにしました。

sidekiq-cron

sidekiq-cron はバックグラウンドジョブを扱うgemであるsidekiqを拡張し、cron的な機能を追加してくれるgemです。

主なメリットとしては、

  • sidekiqに相乗りする形で利用できるので導入が楽
  • ジョブ失敗時にリトライさせることができる
  • sidekiqのワーカーを増やすことで負荷分散できる
  • yamlでスケジューリングを定義するのでスケジュールのバージョン管理ができる
  • sidekiqがweb uiを用意しているので、ジョブの状態を確認できる*1

などがあります。慣れているsidekiqをそのまま使う感覚でいけるのがいいですね。

時間を指定する記法はcrontabと同じだったり、詳細なログ出力は独自で頑張る必要がありますが、cron単体の時よりはだいぶ前進できた気がします。

将来的にどうするか

sidekiq-cronは本番導入されており、今の段階では特に問題はないのですが、遅くても数年したら乗り換えを検討する必要がありそうです。

例えば複数のRailsアプリを管理するようになった場合に、複数アプリを横断したジョブスケジューラを管理したいという要望にはsidekiq-cronでは応えられません。また、ジョブの数が数十〜百になった場合にジョブをどうやって管理するかも悩ましいところですし、ログの出力が弱いことが問題となるケースも今後出てくるでしょう。

と、そんな折に昨年OSS化されたkuroko2を軽く触ってみたところ、なかなか良さそうでした*2。次回乗り換えを検討する際の有力な候補となりそうです。

kuroko2の詳細は以下のリンクを参考にどうぞ。

まとめ

開発しているアプリケーションの規模によって適切なツールは変わってきます。小規模なアプリケーションのジョブスケジューラであればcronはまったく問題ないと思いますが、アプリケーションが成長していくにつれてより適したツールに乗り換えていく必要があるでしょう。このエントリが次のツール選定の参考になれば幸いです。

*1:sidekiqの仕様上、WebUI上にログはほぼ残らないので、sidekiq-failuresを利用して失敗したジョブのエラーを閲覧できるようにしています。

*2:ツール選定当時はOSS化されていなかった

レガシーな独自フレームワークから脱却してRailsへ徐々に移行している話

みなさんこんにちわ。 メドピアでエンジニアをやっている内田と申します。

現在メドピアではPHPで作られたレガシーな独自フレームワーク (以下FW) からRailsへと移行するプロジェクトが進んでいます。 今回は移行に向けて行ったことについて共有したいと思います。

移行の計画

メドピア株式会社では、医師限定のコミュニティサイト「MedPeer 」を運営しています。 「MedPeer 」サービス内では、薬剤評価掲示版、症例相談、Forum、ニュースなど、医師同士が情報交換をするための、機能の異なる複数のサービスを提供しています。

それらサービスの内部では7年前に作られたPHPの独自FWが採用されており、コードが肥大化したことで機能の変更や追加がとても困難になっていたことが課題でした。

そうした課題を解決するために、アーキテクチャの見直しを含めたリプレースがエンジニアの主導で計画されました。 様々な言語を比較する中で、生産性の高さやコミュニティが活発なところに魅力を感じて言語はRubyへと決め、FWは独自で作るよりもOSSの方がより多くの開発者によって開発が行われているため、新しい機能が使えたり安定性が高いといったメリットがあるだろうと考え、Railsへと移行することに決めました。

しかし、移行するにしても「MedPeer」はとてもモノリシックな上に、機能によってはもはや誰も知らないコードなども存在しており、一筋縄ではいきません。 一度に全てを移行するという方針だと数年はかかりそうです。その間ビジネスを止めるわけにはいかないので、移行の間も新サービスの開発だったり機能の追加を常に行っていく必要があります。

そこで、既存の機能追加は今まで通りレガシーな旧環境で開発しつつ、新サービスやサービスごと移行したいものに関してはRailsの新環境で開発していく、という方針を取ることにして、両環境を運用しながら徐々に移行することにしました。そのためにまずは旧環境と新環境2つを連携して並列稼働させる仕組みが必要でした。

やったこと

具体的にやったことは以下の3つです

  • urlごとの振り分け

  • 共通処理の切り出し

  • データの同期

urlごとの振り分け

urlの振り分けは、nginxを使ったリバースプロキシー先の振り分けです。ドメインは両環境で同じにしてたとえば、/aaaときたurlは旧環境に、/bbbときたurlは新環境にと振り分けるようにしました。 幸いMedPeerは前述の通り複数のサービスで成り立っておりサービスごとにurlが異なり依存度も少ないためこの方法は合っていました。 注意点としては両環境でurlがバッティングしないように設計する必要があったり、同一ドメインであるためcookieの扱いに気をつける必要があることです。

f:id:yutauchida:20170130140305p:plain

共通処理の切り出し

両環境にて共通に処理する必要のある機能が存在します。たとえば認証です。認証したら環境をまたいでもセッションをセキュアに維持できる仕組みが必要です。 これに対しては既にAPIゲートウェイとして認証部分を別のアプリケーションとして切り出していたためそれを新環境でも利用するようにしました。こういった共通処理を切り出しておくと他サービスでも連携しやすいといったメリットがあります。実際にメドピアの他サービス(キャリア、イシコメ)でも認証部分はAPIゲートウェイを利用しており、サービスのスケールに寄与しています。APIゲートウェイを開発した際の話についてご興味ある方はこちらをご参照ください。

Golang(Go言語)を採用して、たった二人で基盤となるAPIゲートウェイを開発した話 - メドピア開発者ブログ

データの同期

両環境ではユーザーテーブルなど共通のデータを利用する必要があります。 当初は現行のDBを共用することも考えましたが以下の理由でやめました。

  • テーブルの設計がRailsに最適化されていない

  • 移行を機にDB構造を見直したい

RailsはDBに対してある一定の規約があります。例えば主キーはidが望ましいといったことです。 旧環境のDBでは主キーがid以外に設定されていたり、テーブル名が複数形になっていないなどRailsを利用する にあたって最適化されていないという問題がありました。また、長年DBを運用していて設計上見直したい部分が多々ありました。例えば 以下の内容です。

  • 不要なテーブル、カラムが存在している

  • カラム名が適切でないため理解しにくい

  • 正規化が適切にされていない

  • indexやunique制約が適切に貼られていない

移行をいい機会としてこれらを見直して再設計したいこともあり、DBは新しく作るという方針を立てました。 そのために2つのDBのデータを同期する必要があったため、両環境のDBを橋渡しする役割のアプリケーションを作りました。 それがDB-SYNCです。

f:id:yutauchida:20170130152525p:plain

DB-SYNCは旧環境のデータを新環境でも使いたい場合にデータを任意の形にコンバートします。例えば旧環境であるユーザーのEmailが更新されたとします。 その際に更新があったことを通知するテーブルに登録されます。DB-SYNCはcron&キューの処理としてそのテーブルをreadして更新のあった対象のテーブル とデータを判別します。その後、旧環境のDBから対象のデータをFetchして、新環境のDBに新しく設計されたテーブルのどのカラムに対応するのか を変換ルールが定義されたファイルを参照しながらUPSERTを行います。変換ルールが定義されたファイルは例えば以下のような内容が記載されます。

user:
  old_table: portal.users
  column_name_mappings:
    id:              old_user_id
    last_login_time: last_logged_in_at
    account_id:      email
    state_cd:        registration_status
    profession_id:   profession_type
    physical_deletable: true
    unsync_columns:
      - id

DB-SYNCでは命名の変更だけでなく、2つのテーブル を統合して再設計した一つのテーブルにデータを登録していくといったことも可能になっています。その他のポイントとしてはデータの流れは 旧環境から新環境への一方向のみとしています。理由としては、両環境でデータが行き来すると煩雑になり障害発生時に原因の究明に時間がかか るのではと考えたのと、逆方向へのデータ同期によりそれを使う機能を開発する要件があるならば、それは新環境での開発を促すべきだというポリシ ーのもとで、そのようにルール化しました。

まとめ

以上のような施策を行い2016年の夏にRailsで作られた新システムがリリースされており、現在は2つの環境が安定して動いています。 徐々に移行することで比較的小さいリリースとなるので大きな障害が起きにくいといった安心感があるのと、Railsでの開発ができるようになったので楽しんで開発しているエンジニアが見られるようになったのはポジティブな要素だと感じています。

また、Railsアプリケーションの立ち上げについてはパーフェクトRuby on Rails共著の前島さんを始め、株式会社 万葉のエンジニアさんなどRails に知見があるメンバーに推進してもらいました。今後規模が大きくなると予想されるアプリケーションで新しい言語やFWを採用するに あたって経験豊富なエンジニアにしっかりとした土台を作ってもらい、その上で開発していくことが長く運用していくうえで大事だと考えています。

近頃、大規模アプリケーションをRails化しているという案件を聞くことが多くなった気がします。そういった計画を考えている方に弊社 の事例が一つでも参考になれば幸いです。

事業拡大に伴いコーポレートサイトのリニューアルを行いました!そして、コーポレートサイトだから色々技術的に挑戦してみました

Introduction

こんにちは、元Sake部長@shinofara(篠原)です。
Golang(Go言語)を採用して、たった二人で基盤となるAPIゲートウェイを開発した話 以来の登場です。

今回調子に乗って、各セクションを英語にしてみたのですが、途中で力尽きてしまいました。。

それはさておき、みなさんお気づきでしたか?
実は2016年11月10にフルリニューアルした、弊社コーポレートサイト(以下、COJP)を公開していました。

f:id:shinofara:20161125205953p:plain:w300

今までと比べると、とても最近感がありますよね!
ちなみに、11月9日時点までのサイトは、以下の画像です。
f:id:shinofara:20161125205635p:plain:w300

デザイン刷新しただけ?の様に思えますが、技術的な観点でも旧COJPと全く違う物になりました!!
今回は、その裏側を紹介したいと思います。
デザイン観点でのブログはこちら

※ちなみに僕がアサインされたのはリリース3週間前

Background of renewal.

今までのCOJPには様々な課題がありました。
あるあるなモノから、メドピアだけのものだとか

  • エンジニアがいないとデザイン修正が出来ない
  • 1台のサーバに WordPress が複数稼働している状態で、冗長構成に出来ない。
  • そもそも WordPress魔改造されていてセキュリティ対応し難いし、脆弱性多発しやすい
    CVEレポートがおおい
  • そもそも構成を理解している人が居ない(外注)為、ローカルで再現出来ず開発出来ない
  • そもそもデザイン自体が古いので、新しくしたい

Renewal overview.

リニューアルに対しての、企画要望と課題、そして対応方法

Requirement.

リニューアルを行う際に、下記の様な要望がありました。

  1. 更新のある記事は、WordPress 等のCMSで入稿したい
  2. TOPページとか、更新がめったにないサイトは、デザイナが直接更新したい
  3. 問い合わせに対して、自動返答+社内に履歴を残したい
  4. 旧社長ブログや、プレスリリースなどのSNSシェア数などは可能な限り引き継ぎたい(可能な限りとはあるが、これは限りなく100%に近い可能な限り)

という感じで、ざっくりまとめると今より色々な面で良くしたいけど、過去は全部引き継いでね♡という感じでした。

Issue.

あがった要望に対して、見えてきた課題もあります。

  1. WordPress 本番運用はセキュリティ的な問題で、NG
  2. 運用者が、FTPクライアントなどでupload出来る場所が必要
  3. 問い合わせ処理と管理は動的な物なので、サーバサイドの仕組みが何かしら必要
  4. 旧サイトのURLは動的URL (https://medpeer.co.jp/?p=1632

How to respond?

そんな課題に対して、今回行った対応です。

  1. ステージング環境に構築した WordPress から本番用S3に静的ファイルの書きだしを行う事で対応する。(StaticPressを利用) *Updateが止まってるのが悩み
  2. S3に専用バケットを作成して、運用者ごとのIAMでAccess出来る様にする
  3. 会社としてRails化を進めているのでPOSTを受けてメール送信を行う処理をRails5で作成
  4. StaticPressを使う関係上URLは、静的な物(https://medpeer.co.jp/blog/1632.html)になってしまう為、この場合でも旧URLのソーシャルスコアを維持できるように対応

Technical point of view

技術的な取り組みとしても幾つか新しい事をやったりもしています。 今回のリニューアルでは、技術的に以下の取組を行いました。

1. All applications are operated by Docker

最近では珍しくなくなってきたDockerをフル活用しています。 Dockerを使う事で、再現性のある環境を共有・展開出来るようになり、とある環境では動かないと言った問題が起こりにくくなります。

2. Blue Green Deployment(Operation not using SSH)

DockerとECSを使う事で、EC2に対してSSHを用いる事無く、コンテナの作り捨てが出来るようになります。 また、イメージが存在していればトラブルが発生していても、過去の動いていたバージョンにロールバックする事も容易に出来ます。

3. Engineerless operation

エンジニアにデプロイ依頼が発生しているとスピード感が損なわれる為、動的部分以外はエンジニア介入しない公開フローと公開できる仕組みを整えました。

4. Infrastructure as Code

Terraformでインフラ構成管理、Dockerでアプリケーションサーバ構成管理を行う事で、環境構築の再現性を確保 また、コード化する事で、PRによるレビューも行い、インフラ事故を軽減

Infrastructure

今回のCOJPでは、開発環境は、 docker-compose で、ステージング、本番は AWS を使っており、更にステージングではWordPressPHPで動いているのに対して、本番ではS3からの静的配信になっています。
その為、各環境で使っている物が少しずつ変わってきます。

各環境毎に利用しているサービスの違い

本番/ステージングがAWSですが、開発環境の構成も可能な限り同じものを用意してます。

要素 本番 ステージング 開発
SSL AWS Certificate Manager AWS Certificate Manager オレオレCA認証SSL証明書
Load Balancer ALB ALB HAProxy Container
WordPress S3 WordPress Container Wordpress Container
Static File Storage S3 S3 Nginx Container
Rails Rails(Puma) Continer Rails(Puma) Continer Rails(Puma) Continer
SMTP SES SES MailCatcher Container
Mail Log Data Storage DynamoDB DynamoDB DynamoDB Local Container
Wordpress Data Storage None RDS (MariaDB) MariaDB Container
Log Cloud Watch Logs Cloud Watch Logs Stdout

How about services provided by AWS?

ALB,DynamoDB,etc..と言ったAWS提供サービスは、ローカルには存在しませんので、 HAProxy, DynamoDB-local, MailCatcherで代用してます。

ALBの代わりに使ってるHAProxyって何?

HAProxy は、簡単に書くと多機能プロキシサーバで、ALBの様にPATH Routingが出来ます。

DynamoDBの代わりに使ってるDynamoDB-localって何?

RDSの場合は、MySQLなどで代用は可能ですが、AWS提供のDynamoDBは代わりが効きませんが、DynamoDB Local が公式提供されていますので、こちらを使います。

SESの代わりに使ってるMailCathcerって何?

MailCatcherは、SMTPサーバとメールクライアントを同時に提供してくれるアプリケーションになります。 開発環境に導入する事で間違えて本番に.....という事故を防ぐ事ができます。

それぞれの環境毎のinfrastructureについて

環境 ツール
本番・ステージング Terraform / ECR / ECS
開発 Docker Compose

本番/ステージングと開発環境では、ECS/ECRとdocker-composeと違いはあるものの 各アプリケーションは同じDocker Imageを使いまわせる為、便利ですね。

全ての環境の構成図

f:id:shinofara:20161115141208p:plain:w300

About AWS configuration

今回はAWSのインフラに関して、更に深く書いてみたいと思います。

Services

  • ALB
  • ECS
  • ECR
  • S3
  • DynamoDB
  • RDS ( stg only )
  • Cloud Watch Logs

Policy

どのように接続したか

EC2から各サービスを利用するにあたって、IAM Userを作るのでは無く、EC2にIAM ROLEを割り当てました。

なぜ?

  1. IAMの管理がめんどくさい
  2. 何かしらの事情で、キーやシークレットが流出したら削除、もしくは変更しないといけない
  3. そもそも秘密情報の管理方法を考えなくてはいけない

など管理面でワズらしい事がおきてしまいます。

EC2に紐付けるとどうなるの?

EC2に紐付けると上記問題は解決できますし、この割当てられたEC2からしか各サービスへのアクションを行えなくなります。 これだとEC2に入れれば許可された範囲で何かできてしまうのでは?となりますが、この点に関してはキーを発行した場合でも同様の事が言える為、別の問題という事になります。

公式ドキュメントはこちら

ECSに対して付与したPolicy

{
  "Version": "2012-10-17",
  "Statement": [
      {
      "Action": "elasticloadbalancing:*",
      "Effect": "Allow",
      "Resource": "*"
    },
    {
      "Action": "ecs:*",
      "Effect": "Allow",
      "Resource": "*"
    },
    {
    "Action": ["logs:CreateLogStream","logs:PutLogEvents"],
      "Effect": "Allow",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:GetDownloadUrlForLayer",
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
          "arn:aws:s3:::<Bucket Name>",
          "arn:aws:s3:::<Bucket Name>/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "dynamodb:PutItem",
      "Resource": [
             "arn:aws:dynamodb:ap-northeast-1:<AWS Account ID>:table/<Table Mame>",
      ]
    },
    {
      "Effect": "Allow",
      "Action": ["SES:SendEmail", "ses:SendRawEmail"],
      "Resource": [
             "arn:aws:ses:us-west-2:<AWS Account ID>:identity/<Domain>",
      ]
    }

  ]
}

TerraformとECS、ECR

Terraformでも、ECS/ECRの操作は出来ますが、今回は2重管理は避けたかったので、 ECS Clusterを作成するところまでをTerraform、 ECS Service作成、Task Difinition作成などは、自作のスクリプトで行っています。 理由としては、task.jsonをterraform管理ではなく、デプロイスクリプト管理にしたかったという理由です

Terraform

Terraformは、開発も活発で結構頻繁にバージョンがあがるのですが、たまにバージョンアップの影響で forces new resource と強制再構築されそうになる時がたまにあります。
過去の例としては、0.6.14 から 0.6.15 にあげる際に、 security_groups ではなく vpc_security_group_ids に書き換えないといけない変更が入っていて、plan せずに apply すると再構築に....
今例に上げた問題は、CHANGELOG.md#0615-april-22-2016 にも書かれているので、見ていれば気付けるのですが、見逃すと怖いですね。

aws_instance - if you still use security_groups field for SG IDs - i.e. inside VPC, this will generate diffs during plan and apply will recreate the resource. Terraform expects IDs (VPC SGs) inside vpc_security_group_ids.

更に言えば、複数のサービスに関わっていると、それぞれを実行したバージョンが違ったりと、毎回最新に追従できていれば問題無いのですが、なかなか出来ない事もあります。

ですので、弊社では誰がどこで実行しても、バージョンによる問題が起きないように、Terraform の実行にも Docker を使っています。
Dockerイメージは、Docker Hubで公開されている hashicorp/terraformを使っています。

簡単な使い方は、下記のとおりです。

OPTION=''
docker run --rm -v ~/.aws:/root/.aws:ro -v ${PWD}:/work -w /work hashicorp/terraform:0.7.13 plan ${OPTION}
docker run --rm -v ~/.aws:/root/.aws:ro -v ${PWD}:/work -w /work hashicorp/terraform:latest plan ${OPTION}

その他利用可能なバージョンはこちら

このようにして置くことで、違うバージョンで実行してしまって再構築が走ったりするリスクは回避できます。 ※でも頑張って追従しましょうね。

ECS with ALB

いいツールが見つからなかったので、自作しました。

Finally

このような感じで、COJPのリニューアルが完了しました。 Dockerイメージ管理下の、WordPressや、Railsはエンジニアの手が必要ですが、日々運用に置いてはエンジニアレスを実現する事ができました。
また、全てのコンテンツを静的配信化することで、スケールしやすくもなり、負荷に怯える日々もなくなりました。(S3のお金は...)

それに、ECSを使うことで、厳密にはDockerを使う事で、CVEなどの対応時に、稼働中のサーバ更新・再起動して動くか分からないと怯えるという事があったのが、動く事を保証した上で、イメージ再作成も行えるようになりました。 罠は多いですが、

では、皆さんよきECS/ECR生活を(๑•̀ㅂ•́)و✧

TerraformでCloudWatch EventsのEBSスナップショット定期作成機能を設定する

はじめに

世の中の医療・ヘルスケア情報を医師たちが実名で解説するWEBメディア、イシコメ開発担当の大谷です。 イシコメは少数の開発者で開発・運営しているため、省力化のためTerraformなどのツールによりインフラ管理を自動化しています。

今回は、TerraformでEBSのスナップショットをサーバレスで定期的に作成する方法について調査したため、その方法を共有します。

本稿ではTerraform自体の解説は行いません。 Terraformを使ったことが無い方は、公式サイトのチュートリアルが分かりやすいので是非試してみて下さい。

尚、使用したTerraformのバージョンはv0.6.16です。

手順

CloudWatch EventsのEBSスナップショット作成機能をTerraformで設定します。 必要なのは.tfファイル一つですが、その中に記述する要素について順を追って説明します。

1. AWSのproviderを準備する

いつもの設定です。

variable "access_key" {}
variable "secret_key" {}
variable "aws_account_id" {}
variable "region" {
  default = "ap-northeast-1"
}

provider "aws" {
  access_key = "${var.access_key}"
  secret_key = "${var.secret_key}"
  region = "${var.region}"
}

2. 取得対象のEBSのIDを指定する

ハードコードにしてもいいですが、実用的ではないので変数化してしまいます。

variable "ebs_id" {}

3. IAM Roleを作成する

CloudWatch EventsがEBSを作成できるように権限を設定します。

resource "aws_iam_role" "cloudwatch_events_automatic_execution" {
  name = "cloudwatch_events_automatic_execution"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
      {
          "Action": "sts:AssumeRole",
          "Principal": {
            "Service": "events.amazonaws.com"
          },
          "Effect": "Allow"
      }
  ]
}
EOF
}

resource "aws_iam_role_policy" "cloudwatch_events_automatic_execution_policy" {
  name = "cloudwatch_events_automatic_execution_policy"
  role = "${aws_iam_role.cloudwatch_events_automatic_execution.id}"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "ec2:Describe*",
                "ec2:CreateSnapshot"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}
EOF
}

4. EBSスナップショット作成のスケジュールを設定する

aws_cloudwatch_event_ruleリソースでCloudWatch Eventsのルールを作成します。 ここでは主に先程作成したIAM Roleの指定と、実行間隔を設定することになります。 今回は、1日1回実行するようにしてみましたが、変更したい場合はCloudWatch Eventsの仕様に従ってschedule_expressionオプションを変更して下さい。

resource "aws_cloudwatch_event_rule" "take_snapshots_every_day" {
  name = "take_snapshots_every_day"
  description = "Fires every day"
  schedule_expression = "rate(1 day)"
  role_arn = "${aws_iam_role.cloudwatch_events_automatic_execution.arn}"
}

5. EBSスナップショット作成用のイベントターゲットを設定する

ここでスナップショット作成対象EBSボリュームのIDを${var.ebs_id}ではなく${aws_ebs_volume.your_volume.id}のような形でtfファイルの他の場所で作成したEBSボリュームのIDで置き換えるとインフラ一式の作成にあわせてIDが自動で設定されるので便利だと思います。

resource "aws_cloudwatch_event_target" "take_snapshots_every_day" {
  rule = "${aws_cloudwatch_event_rule.take_snapshots_every_day.name}"
  arn = "arn:aws:automation:${var.region}:${var.aws_account_id}:action/EBSCreateSnapshot/EBSCreateSnapshot_${aws_cloudwatch_event_rule.take_snapshots_every_day.name}"
  input = "\"arn:aws:ec2:${var.region}:${var.aws_account_id}:volume/${var.ebs_id}\""
}

実行してみる

今回はaccess_keyなどの設定をsecrets.tfvarsファイルに書いて実行しました。

aws_account_id = "<< substitute me >>"
access_key = "<< substitute me >>"
secret_key = "<< substitute me >>"
ebs_id = "<< substitute me >>"

plan

% terraform plan -state=terraform.tfstate -var-file=secrets.tfvars
Refreshing Terraform state prior to plan...


The Terraform execution plan has been generated and is shown below.
Resources are shown in alphabetical order for quick scanning. Green resources
will be created (or destroyed and then created if an existing resource
exists), yellow resources are being changed in-place, and red resources
will be destroyed.

Note: You didn't specify an "-out" parameter to save this plan, so when
"apply" is called, Terraform can't guarantee this is what will execute.

+ aws_cloudwatch_event_rule.take_snapshots_every_day
    arn:                 "" => "<computed>"
    description:         "" => "Fires every day"
    is_enabled:          "" => "1"
    name:                "" => "take_snapshots_every_day"
    role_arn:            "" => "${aws_iam_role.cloudwatch_events_automatic_execution.arn}"
    schedule_expression: "" => "rate(1 day)"

+ aws_cloudwatch_event_target.take_snapshots_every_day
    arn:       "" => "arn:aws:automation:ap-northeast-1:xxxxxxxxxxxx:action/EBSCreateSnapshot/EBSCreateSnapshot_take_snapshots_every_day"
    input:     "" => "\"arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:volume/vol-xxxxxxxx\""
    rule:      "" => "take_snapshots_every_day"
    target_id: "" => "<computed>"

+ aws_iam_role.cloudwatch_events_automatic_execution
    arn:                "" => "<computed>"
    assume_role_policy: "" => "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n      {\n          \"Action\": \"sts:AssumeRole\",\n          \"Principal\": {\n            \"Service\": \"events.amazonaws.com\"\n          },\n          \"Effect\": \"Allow\"\n      }\n  ]\n}\n"
    name:               "" => "cloudwatch_events_automatic_execution"
    path:               "" => "/"
    unique_id:          "" => "<computed>"

+ aws_iam_role_policy.cloudwatch_events_automatic_execution_policy
    name:   "" => "cloudwatch_events_automatic_execution_policy"
    policy: "" => "{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Action\": [\n                \"ec2:Describe*\",\n                \"ec2:CreateSnapshot\"\n            ],\n            \"Effect\": \"Allow\",\n            \"Resource\": \"*\"\n        }\n    ]\n}\n"
    role:   "" => "${aws_iam_role.cloudwatch_events_automatic_execution.id}"


Plan: 4 to add, 0 to change, 0 to destroy.

apply

% terraform apply -state=terraform.tfstate -var-file=secrets.tfvars
aws_iam_role.cloudwatch_events_automatic_execution: Creating...
  arn:                "" => "<computed>"
  assume_role_policy: "" => "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n      {\n          \"Action\": \"sts:AssumeRole\",\n          \"Principal\": {\n            \"Service\": \"events.amazonaws.com\"\n          },\n          \"Effect\": \"Allow\"\n      }\n  ]\n}\n"
  name:               "" => "cloudwatch_events_automatic_execution"
  path:               "" => "/"
  unique_id:          "" => "<computed>"
aws_iam_role.cloudwatch_events_automatic_execution: Creation complete
aws_iam_role_policy.cloudwatch_events_automatic_execution_policy: Creating...
  name:   "" => "cloudwatch_events_automatic_execution_policy"
  policy: "" => "{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Action\": [\n                \"ec2:Describe*\",\n                \"ec2:CreateSnapshot\"\n            ],\n            \"Effect\": \"Allow\",\n            \"Resource\": \"*\"\n        }\n    ]\n}\n"
  role:   "" => "cloudwatch_events_automatic_execution"
aws_cloudwatch_event_rule.take_snapshots_every_day: Creating...
  arn:                 "" => "<computed>"
  description:         "" => "Fires every day"
  is_enabled:          "" => "1"
  name:                "" => "take_snapshots_every_day"
  role_arn:            "" => "arn:aws:iam::xxxxxxxxxxxx:role/cloudwatch_events_automatic_execution"
  schedule_expression: "" => "rate(1 day)"
aws_iam_role_policy.cloudwatch_events_automatic_execution_policy: Creation complete
aws_cloudwatch_event_rule.take_snapshots_every_day: Still creating... (10s elapsed)
aws_cloudwatch_event_rule.take_snapshots_every_day: Creation complete
aws_cloudwatch_event_target.take_snapshots_every_day: Creating...
  arn:       "" => "arn:aws:automation:ap-northeast-1:xxxxxxxxxxxx:action/EBSCreateSnapshot/EBSCreateSnapshot_take_snapshots_every_day"
  input:     "" => "\"arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:volume/vol-xxxxxxxx\""
  rule:      "" => "take_snapshots_every_day"
  target_id: "" => "<computed>"
aws_cloudwatch_event_target.take_snapshots_every_day: Creation complete

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

awsコマンドで確認

コマンドラインで確認すると、CloudWatch EventsのルールとIAM Roleの一式が作られています。

% aws iam get-role --role-name cloudwatch_events_automatic_execution
{
    "Role": {
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Action": "sts:AssumeRole",
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "events.amazonaws.com"
                    }
                }
            ]
        },
        "RoleId": "XXXXXXXXXXXXXXXXXXXXX",
        "CreateDate": "2016-05-31T12:54:18Z",
        "RoleName": "cloudwatch_events_automatic_execution",
        "Path": "/",
        "Arn": "arn:aws:iam::xxxxxxxxxxxx:role/cloudwatch_events_automatic_execution"
    }
}
% aws iam get-role-policy --role-name cloudwatch_events_automatic_execution --policy-name cloudwatch_events_automatic_execution_policy
{
    "RoleName": "cloudwatch_events_automatic_execution",
    "PolicyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": [
                    "ec2:Describe*",
                    "ec2:CreateSnapshot"
                ],
                "Resource": "*",
                "Effect": "Allow"
            }
        ]
    },
    "PolicyName": "cloudwatch_events_automatic_execution_policy"
}
% aws events list-rules
{
    "Rules": [
        {
            "ScheduleExpression": "rate(1 day)",
            "Name": "take_snapshots_every_day",
            "RoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/cloudwatch_events_automatic_execution",
            "State": "ENABLED",
            "Arn": "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:rule/take_snapshots_every_day",
            "Description": "Fires every day"
        }
    ]
}
% aws events list-targets-by-rule --rule take_snapshots_every_day
{
    "Targets": [
        {
            "Input": "\"arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:volume/vol-xxxxxxxx\"",
            "Id": "terraform-xxxxxxxxxxxxxxxxxxxxxxxxxx",
            "Arn": "arn:aws:automation:ap-northeast-1:xxxxxxxxxxxx:action/EBSCreateSnapshot/EBSCreateSnapshot_take_snapshots_every_day"
        }
    ]
}

おわりに

今回は、TerraformでCloudWatch EventsによるEBSボリュームのスナップショット作成機能を設定してみました。 マネジメントコンソールからなら簡単に設定できるのにTerraformでやろうとすると色々調べて回らないといけないので大変でした。 しかし、一度コードに落としてしまえば使いまわせるのがTerraformの良いところですね。

ところで、この方法は設定しなければいけない要素が少なくて手軽ではあるんですが、古いスナップショットを手動で作成しなければいけなかったりと万能ではありません。 より細かい制御がしたい場合はLambdaを使うことになります。 Lambdaも同様にTerraformで設定できるので、興味が湧いたら調べてみて下さいませ。

それでは

ソースコード

今回実行したtfファイルのサンプルコードです。

variable "access_key" {}
variable "secret_key" {}
variable "aws_account_id" {}
variable "region" {
  default = "ap-northeast-1"
}
variable "ebs_id" {}

provider "aws" {
  access_key = "${var.access_key}"
  secret_key = "${var.secret_key}"
  region = "${var.region}"
}

resource "aws_iam_role" "cloudwatch_events_automatic_execution" {
  name = "cloudwatch_events_automatic_execution"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
      {
          "Action": "sts:AssumeRole",
          "Principal": {
            "Service": "events.amazonaws.com"
          },
          "Effect": "Allow"
      }
  ]
}
EOF
}

resource "aws_iam_role_policy" "cloudwatch_events_automatic_execution_policy" {
  name = "cloudwatch_events_automatic_execution_policy"
  role = "${aws_iam_role.cloudwatch_events_automatic_execution.id}"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "ec2:Describe*",
                "ec2:CreateSnapshot"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}
EOF
}

resource "aws_cloudwatch_event_rule" "take_snapshots_every_day" {
  name = "take_snapshots_every_day"
  description = "Fires every day"
  schedule_expression = "rate(1 day)"
  role_arn = "${aws_iam_role.cloudwatch_events_automatic_execution.arn}"
}

resource "aws_cloudwatch_event_target" "take_snapshots_every_day" {
  rule = "${aws_cloudwatch_event_rule.take_snapshots_every_day.name}"
  arn = "arn:aws:automation:${var.region}:${var.aws_account_id}:action/EBSCreateSnapshot/EBSCreateSnapshot_${aws_cloudwatch_event_rule.take_snapshots_every_day.name}"
  input = "\"arn:aws:ec2:${var.region}:${var.aws_account_id}:volume/${var.ebs_id}\""
}