TECH PLAY

タイミー

タイミー の技術ブログ

264

はじめましてこんにちは。 夏が本気を出してきて最近麺類しか口にしていないサーバサイドエンジニアのかしまです。 この度 API にてHTTP Status Codeとは別に、例外に対応するエラーコードを返すよう奮闘したのでその知見を共有したいと思います。 やりたいこと API にて例外が発生した場合、以下の形式でレスポンスを返すようにします。 { "errors": [ { "code": "code1", "message": "message1", }, { "code": "code2", "message": "message2", }, ] } Why? 今回追加した API は外部のアプリケーションとの連携に使うため、以下の要件を満たす必要がありました。 クライアントサイドがハンドリングしやすいような設計にする エラーの識別子は不変であること そのためエラーメッセージとは別に、コードを返すことにしました。 次の章から実際にどのように実装したかを紹介したいと思います。 rescue_fromにて例外をキャッチする rescue_fromとは例外をキャッチして指定したメソッドを実行してくれるという大変便利なものです。 railsguides.jp これを API の基盤となるcontrollerに埋め込みます。 class APIController < ActionController :: Base rescue_from StandardError , with : :error500 def error500 (error) render json : { errors : [{ code : ' E9999 ' , message : ' 例外が発生しました ' }] }, status : :internal_server_error end end このクラスを継承したcontrollerにてStandardErrorが発生するとerror500メソッドが呼び出され、期待するエラーレスポンスを返すことができます。簡単ですね。 他の例外も同じように実装していけば良さそうです。 Modelのvalidationエラーの場合 さてここからが本題なのですが ActiveRecord::RecordInvalid (modelのvalidationエラー)が発生した場合、例外の原因に対応したエラーコードを動的に取得して返すようにします。 独自で定義したModelのvalidationはサービスの ドメイン に紐づくものも多いため、同じ例外でも原因をコードで識別できるようにするためです。 class Offering < ApplicationRecord # Rails標準の汎用的なvalidation validates :start_at , presence : true validates :end_at , presence : true # 独自のvalidation # これらが発生した場合はそれぞれ定義したエラーコードを返したい validate :check_minimum_hourly_wage validate :check_rests_presence_and_minutes private def check_minimum_hourly_wage # 処理 errors.add( :hourly_wage , :greater_than_prefecture_minimum_wage ) end def check_rests_presence_and_minutes # 処理 errors.add( :base , :greater_then_or_eq_default_rest_minutes ) end end どうやってエラーコードを取得するか ActiveRecord::RecordInvalid をrescue_fromにてキャッチすると、以下のように ActiveModel::Errors の インスタンス にアクセスできます。 def error404 (error) error.record.errors.class # => ActiveModel::Errors end ActiveModel::Errors の インスタンス は details というattributesを持っています。 details 内にエラーが発生したattribute名をkey、エラーメッセージkeyを value *1 として配列で持っています。 error.record.errors.details => { :base =>[{ :error => :greater_then_or_eq_default_rest_minutes }]} # 配列内ではerrorをkey、エラーメッセージkeyをvalueとして持っている これらの情報からエラーコードを取得すれば良さそうです。 エラーコードの管理について タイミーでは定数管理に Config を使っているのでエラーコードもymlで管理することにしました。 error_codes: bad_request: E0400 unauthorized: E0401 forbidden: E0403 not_found: E0404 unprocessable_entity: E0422 internal_server_error: E9999 models: offering: base: greater_then_or_eq_default_rest_minutes: E2000 hourly_wage: greater_than_prefecture_minimum_wage: E3000 これにより、以下のようにエラーコードを取得できます。 Settings .error_codes.models.user.base.hoge => E2000 エラーメッセージとエラーコードを対応させる エラーコードの取得方法が決まったのであとはエラーメッセージとペアにすれば良さそうです。 ActiveModel::Errors の インスタンス は messages というattributes名でエラーが発生したattribute名をkey、エラーメッセージkeyからlocaleに対応したエラーメッセージを value として配列で持っています。 error.record.errors.messages => { :base =>[ " 法的休憩時間を満たしていません " ]} しかしこれではどのメッセージがどのエラーメッセージkeyに対応するのかわかりません。 そのため messages の情報を使わず、 details の情報からエラーメッセージも取得する必要があります。 エラーメッセージを取得するため、どのようにエラーメッセージが格納されているのかを把握するため、 ActiveModel::Errors#add メソッドをソースを読んでみると、 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L311 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L535 def add (attribute, message = :invalid , options = {}) message = message.call if message.respond_to?( :call ) detail = normalize_detail(message, options) message = normalize_message(attribute, message, options) if exception = options[ :strict ] exception = ActiveModel :: StrictValidationFailed if exception == true raise exception, full_message(attribute, message) end details[attribute.to_sym] << detail messages[attribute.to_sym] << message end ~ 略 ~ private def normalize_message (attribute, message, options) case message when Symbol generate_message(attribute, message, options.except(* CALLBACKS_OPTIONS )) else message end end どうやら generate_message をいうメソッドを使えば messages に格納されているメッセージを生成できそうです。 以上を踏まえた上で完成したメソッドがこちら def error422 (err) class_name = err.record.class.name.underscore active_model_errors = err.record.errors errors = [] active_model_errors.details.each do | attribute , attribute_errors | attribute_errors.each do | error_info | # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している error_key = error_info.delete( :error ) message = active_model_errors.generate_message( attribute, error_key, error_info.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す message : active_model_errors.full_message(attribute, message), } end end render json : { errors : errors }, status : :unprocessable_entity end 試しにvalidationエラーを起こしてみた結果以下のレスポンスが返りました🎉 { "errors": [ { "code": "E2000", "message": "法的休憩時間を満たしていません", } ] } 問題点 一見良さそうですが、以下の問題が発覚しました。 1. errors.add時にoptionでerrorという名のkeyを渡されると、エラーメッセージkeyが上書きされる errors.addメソッドでは、以下のようにoptionを渡せます。 errors.add( :base , :hoge , reason : ' reason ' ) 渡したoptionはlocaleファイル内で参照できたりします。 ja: activerecord: errors: models: user: attributes: base: hoge: %{reason} のため登録できません そして ActiveModel::Errors の details の内容は以下のようになります。 error.record.errors.details => { :base =>[{ :error => :hoge , :reason => ' reason ' }]} では errors.add時にerrorというオプションを渡したらどうなるかというと。。。 errors.add( :base , :hoge , error : ' error ' ) errors.details => { :base =>[{ :error => ' error ' }]} はい。エラーメッセージkeyが上書きされてますね。 以下の処理でoptionのmergeを行った結果を details に格納しているのでこのような結果になります。 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L545 def normalize_detail (message, options) { error : message }.merge(options.except(* CALLBACKS_OPTIONS + MESSAGE_OPTIONS )) end 対応策 幸い現時点でerrorオプションを使用しているコードはありませんでしたが、今後もしerrorオプションを渡された場合、 エラーメッセージkeyの取得は不可能です。 そのため、エラーメッセージkeyが上書きされgenerate_messageにて適切なメッセージが取得できなかった場合は汎用的なメッセージを返すと共に適切なメッセージを返せなかったことを検知 *2 できるようにしました。 def error422 (err) class_name = err.record.class.name.underscore active_model_errors = err.record.errors errors = [] active_model_errors.details.each do | attribute , attribute_errors | attribute_errors.each do | error_info | # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している error_key = error_info.delete( :error ) message = active_model_errors.generate_message( attribute, error_key, error_info.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) # Sentry通知 LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す message : active_model_errors.full_message(attribute, message), } end end render json : { errors : errors }, status : :unprocessable_entity end 追加した処理は以下の部分です。 if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) # Sentry通知 LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end generate_message内部では I18n を使用しています。 オプションで渡す場合、locale keyが存在する確率は低いだろうという思想のもと、戻り値に translation missing: が含まれる場合、汎用的なメッセージを返すと共にSentryに通知するようにしました。 *3 2. 関連テーブルのvalidationに引っかかった場合、generate_messageで例外を吐く ActiveRecord では関連テーブルを含めたデータ保存を以下のように行うことができます。 offering = Offering .new(offering_params) offering.rests.new(rests_params) offering.save! 上記の例で、restsの保存処理にてvalidationエラーが発生すると、generate_messageメソッドの内部でエラーが発生しました。 NoMethodError : undefined method ` rests.start_at' for #<Offering:0x00005570d93a1de0> Did you mean? rests_attributes= from /usr/local/bundle/gems/activemodel-6.0.3.2/lib/active_model/attribute_methods.rb:432:in ` method_missing ' Caused by ActiveRecord::RecordInvalid: 休憩開始時間は休憩終了時間より大きい値にしてください from /usr/local/bundle/gems/activerecord-6.0.3.2/lib/active_record/validations.rb:80:in `raise_validation_error ' なんだこれは。。ということでgenerate_messageメソッドのソースを読んでみると https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L482 def generate_message (attribute, type = :invalid , options = {}) type = options.delete( :message ) if options[ :message ].is_a?( Symbol ) value = (attribute != :base ? @base .send( :read_attribute_for_validation , attribute) : nil ) attribute名がbaseではない場合、レコードに対してattributeのメソッド呼び出しを行なっています。 *4 つまり details に格納されているattribute名が rests.start_at になっているため、そんなメソッドはねぇ!と怒られているようです。 対応策 どうやら関連テーブルのデータにてvalidationエラーが起きた場合、attribute名は #{relation名}.#{attribute名} になるということがわかりました。 *5 そのためattribute名に . が存在したらrelation名とattribute名に分割し、関連レコードをチェックして、エラーが発生していたらコードを取得してメッセージを生成を行うようにしました。 def error422 (err) errors = :: ExternalCoordinationAPI :: ErrorResponse .new(err).create.errors render json : { errors : errors }, status : :unprocessable_entity end module ExternalCoordinationAPI class ErrorResponse attr_reader :record , :error , :errors , :active_model_errors def initialize (error) @error = error @record = error.record @errors = [] @active_model_errors = record.errors end def create active_model_errors.details.each do | attribute , options | if attribute.to_s.include?( ' . ' ) relation_name, _attribute_name = attribute.to_s.split( ' . ' ) record.send(relation_name).each do | relation_record | next if relation_record.errors.empty? relation_record_active_model_errors = relation_record.errors relation_record_active_model_errors.details.each do | relation_record_attribute , relation_options | relation_options.each do | option | error_key = option.delete( :error ) message = generate_message(relation_record_attribute, error_key, option) add_error(relation_record.class.name.underscore, relation_record_attribute, error_key, message) end end end else options.each do | option | error_key = option.delete( :error ) message = generate_message(attribute, error_key, option) add_error(record.class.name.underscore, attribute, error_key, message) end end end self rescue StandardError => e LogService .error_with_sentry(e, ' 外部向けAPIにて422エラー生成時にエラーが発生しました。 ' ) errors << { code : Settings .error_codes.unprocessable_entity, message : I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ), } self end private def generate_message (attribute, error_key, options) # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している message = active_model_errors.generate_message( attribute, error_key, options.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end message end def add_error (class_name, attribute, error_key, message) errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, message : active_model_errors.full_message(attribute, message), } end end end もしgenerate_messageにてまだ予想できていないエラーが発生した場合に備え、StandardErrorをキャッチして汎用的なメッセージを返すようにしました。 終わりに 正直かなり力技に頼った実装になってしまっており、もっとうまくできるのではないかと思っています。 特にerrorオプションを渡せないなど本来の機能を制限してしまっている箇所はどうにかしたい気持ちが強いです。 もし良い方法をご存知の方がいらっしゃったら是非アド バイス をいただけたらと思います。 *1 : errors.add時に第二引数に渡した値が入ります。タイミーではエラーメッセージは全てlocaleで定義しているので、errors.add(:base, 'エラーメッセージ')というように文字列を渡さないようにしています。 *2 : タイミーではSentryを使ってます *3 : 対応するlocale keyが存在したら。。。オプションkeyにerrorは使用しないというルール化をするほうが良さそうです。 *4 : read_attribute_for_validationはsendのaliasでした。 https://github.com/rails/rails/blob/e6c6f1b4115495b27a2d32a4bd5c95256db695b1/activemodel/lib/active_model/validations.rb#L402 *5 : どの箇所でこのようなattribute名をセットしてるのかまで追いたかったのですが、力及ばすでした。無念。
アバター
SREとコーポレートエンジニアをやっている @sion_cojp です。 今回は新オフィスのネットワーク構築を実施したので、こちらについてお話します。 私自身、学生時代の研究や、10年前にデータセンターのネットワーク構築しか経験がないため、所々おかしな点があるかもしれませんが、ご了承ください。 また今回相談に乗ってくださった @kajinari さんに感謝の意を表します。 TL;DR 旧オフィスはとても不安定なネットワークでしたが coltの回線と meraki のネットワーク機器で 快適なネットワークができました 予算問題で達成できなかったこと: L3/L2機器 + ネットワーク回線の 冗長化 なんで今オフィス移転? 旧オフィスの契約終了が7月末だったため、新しいオフィスはコロナが流行する前に契約しました。 違約金など考慮した結果、オフィス移転の判断をしております。 もちろん新オフィスを構えましたが、会社としては現在もリモートワーク推奨です。 旧オフィスの課題 回線が家庭用だったため、数百人規模には耐えれませんでした。 docker pullしてネットワークが落ちたこともありますし、日常的に遅くなったりとても不安定でした。 またネットワーク構築をアウトソースしているため、ネットワーク機器にアクセス出来ず、設定変更するにも連絡しないとダメでした。 これを踏まえて、自分たちでネットワーク構築する判断をしました。 設計 こちらの図はdraw.ioで書いて、 png + drawioファイルを GitHub で管理しております。 L3, L2ですが、予算の兼ね合いで一旦シングル構成にしております。APに関しても工事と予算の関係上、半分の台数のみ購入となっております。 そのため弊社で根幹となっている、カスタマーサポートと「ネットワークが止まった場合のフロー」を相談し合意の上でシングル構成にしました。 いずれ冗長構成にする予定です。 配線 天井と床下があり、それぞれ図面から長さを測って作成しました。 同じように GitHub で管理しております。 こちらをオフィス移転の工事業者に提出して配線依頼しました。 また年内は諸事情により、図の右半分だけのオフィス運用になっておりますが、配線だけは全部完了させて、使ってないLANケーブルはネットワークポートのほうでshutdownしております。 業者を呼ぶ手間と料金を省きたかったからです。 L3, L2 コンソールケーブルや SSH で設定...私は可能性ですが、今時代だと管理コストが高いため、全て cisco meraki にしました。 選定理由はオフィスネットワークで安定した結果を出しておりますし、 dashboard から機器の管理, 設定が可能なのである程度ネットワーク知識さえあれば設定できると思ったからです。 また meraki だとWLC(ワイヤレスコントローラー)が不要なのもメリットです。 こちらはCTCから下記を購入しております。 L3(MX100) ×1台, L2(MS120-48) ×1台, AP(MR36) ×3台(1台は今の所予備機) 機種の選定理由は下記。 MX100...数年後見据えた人員計画を元に、最大収容人数と合致したのがこれでした。 MS120-48...1000Base対応 + 要件的に48ポート欲しかったのでこちらに。 MR36...将来クライアントで利用できるWiFi6(802.11ax)対応 + PoE。ある程度長く使えそうだと思いました。 VLANや設定周り 有線LANは MACアドレス フィルタリングと固定IPで管理するようにしております。これにより第 三者 が有線LANを物理的に奪ってからの クラッキング リスクを防ぎます。 WiFi に関しては、ゲスト用はプリンタに接続出来ないようにしてます。プリンタを踏み台にした クラッキング リスクを防いでます。 また弊社にはネットワークカメラがあるので、そちらにはどの端末からもLocal IPアクセス出来ないようVLANを切ってます。 AP APの配置は柱などで電波が届かない場所を考慮しながら設置します。 そしてAPの台数を決めるのにクライアント数の計算は必要です。 社員1人あたり、2~3クライアント(PC, スマフォ,検証端末)* 100 = 200~300クライアント。 また、いずれできるオープンスペースの収容人数も追加計算しました。 一般的には1APあたり20~25らしいですがあくまで目安なので、もっと多くのクライアントを捌くことも出来ます。 そこら辺は経験の上、台数を決めて、もし足りなくなったら増やす方針にしました。 またLANケーブルから電力供給するため、PoE対応のものが必須です。 ラック 摂津金属工業のサイトから購入しました。 選定理由はデータセンターでも実績があり、安心感がありました。 ポイントは ONU (実際は回線業者から提供されたスイッチ)を置く棚板 + L3 ×2台 + L2 ×2台分のU数は確保したいですね。 またLANケーブルを地面に置いたりする可能性があるので、その分のスペースも確保すると良いです。 最終的に購入したのは下記となります。 - SKR-16U60A0VB - 背面マウントのSKRO-16UPF-B(黒色)追加、 - コインロックのSKRO-3CL追加 - 棚板(D600㎜)1セット、耐サージ機能付OAタップ(8口)2本 電源 電源の 冗長化 のために、オフィスのビルに2系統引いてます。 (と言っても今はシングル構成なので、意味ないですが将来のため。。) 回線 Colt 300M Fixed(帯域保証)の回線を利用してます。 選定理由は金融機関でも使われてるため安心して利用出来そうだったからです。 社員が増えれば1Gにupgradeも良いでしょう。 またネットワーク冗長で、Nuroあたりの従量課金もサブで利用したかったのですが、予算のため今回はやっておりません。 ラック内配線のポイント 実際のラックです。 配線のポイントとしては、ラックの外側にLANケーブルを這わせることです。 もし面倒くさがってラックの内側からケーブリングしてしまうと、機器を追加マウントする時にケーブルが邪魔になる可能性があります。 また今後追加予定の機器を想定してスペースを確保してます。 GitHub で管理してるもの ネットワーク設計図 天井、床下の配線図 固定IP/VLAN/ SSID のリストとdescription 購入->初期設定の手順やこの構成になった経緯 各機器の問い合わせ先と契約内容 などを管理してます。余談ですが、ネットワーク以外にもプリンタ周りや、セキュリティソフト関連も GitHub で管理しております。 どれくらい期間かかるの? 基本的にネットワーク機器、ラック、回線の手配で2ヶ月はみた方がいいでしょう 特に回線ですが、コロナ禍やオリンピックの影響で手配が遅れる可能性が高いです。 参考にした資料は? 出たエラーに関して google で検索すれば大抵の内容は https://community.meraki.com/ で議論されてるので、それを読めば解決しました。 アウトソースしなかったメリットについて ネットワーク機器のアクセス権があるので、好きなタイミングで自分たちで設定出来るのが嬉しいですね。 ある程度ネットワークの知識があるならアウトソースしないほうがコストと運用面でメリットが大きいと思います。 まとめ 素人ながらネットワーク設計をしっかり調べてやった結果、旧オフィスと比べ物にならないくらい快適なネットワーク環境になりました。 移転後、ネットワーク周りの問い合わせが来ないかとても不安でしたがそれもなかったです。 meraki には様々な設定があるみたいなので、もっと学んで強化していきたいと思います。
アバター
こんにちは、 タイミーデリバリー 開発チームの宮城です。 今回は弊社のOpenAPI3ベースの スキーマ 駆動開発の運用方法を紹介します。 TL;DR 技術スタックは OpenAPI3, Swagger UI, Committee, ActiveModelSerializers Committeeを利用してOpenAPI準拠のRequest Specを行う OpenAPI3のrequiredキーワードに注意する 背景 タイミーデリバリーでは、 Rails による API サーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向け iOS アプリとしてSwiftを採用しています。 1つの モノリス な Rails で利用者別にネームスペースを区切り、それぞれエンドポイントを提供しています。 サーバーサイドとクライアントサイドを分離し並行して開発を進めるために スキーマ 駆動開発を導入しました。 スキーマ 駆動開発の詳しい説明やメリットについては既に多くの記事が存在するため、ここでは参考にさせていただいた記事の紹介までとさせていただきます。 RubyKaigi 2019でOpenAPI 3について登壇しました - おおたの物置 スキーマファースト開発のススメ - onk.ninja スキーマ駆動開発のススメ - Studyplus Engineering Blog この記事では、実際に スキーマ 駆動開発を開発フローに導入する際のTipsを記したいと思います。 技術スタックは ActiveModelSerializers, OpenAPI3, Swagger UI, Committee Rails で API サーバーを構築するにあたって、 json の生成には ActiveModelSerializers を採用しました。 スキーマ 定義には OpenAPI3 を採用し、 Swagger UI でドキュメントを閲覧できるようにしています。 この3つに関しては近年では割とよくある一般的な技術スタックかなと思っています。 スキーマ 定義を記述する YAML ファイルは Rails の リポジトリ に配置しています。 . ├── app/ ... アプリケーションのメインのソースコード ├── bin/ ├── config/ ├── db/ ├── etc/ │ └── docs/ │    ├── docker-compose.yml │    ├── README.md │    └── swagger/ │    └── swagger.yml ルート直下のetc ディレクト リにはアプリケーションの実行には直接は関係ないものをまとめており、Dockerイメージのビルド時にdockerignoreしています。 スキーマ 定義以外ではデプロイの設定が入ってたりします。 スキーマ 定義は分割していない スキーマ 定義は swagger.yml に全て記載しており、 YAML の分割などは一旦していません。 別のプロジェクトで細かく分割して ディレクト リを分け、jsで ディレクト リを監視しマージする運用をしていたことがありましたが、マージする仕組みを新規開発者に理解してもらうコストがかなり高く、運用しづらかった経験がありました。 今回は新規プロジェクトで API の数も少ないため、一旦1つの YAML ファイルのみで運用してみています。今の所チームメンバーからの不満は少ないですが、 ios とvueでネームスペースを切っているのでそれくらいは分けてもいいかなと思っています。 Docker ComposeでSwagger UIを立ち上げる Swagger UIを利用したドキュメントの閲覧方法は、どこかに ホスティング したりCircleCIのartifactsを使ったりはせず、Docker Composeで開発者自身のローカルで立ち上げるようにしています。 チームメンバーがDockerでの開発に慣れていたためこの選択をしました。 docker-compose.ymlはこんな感じです。 Swagger UIを立ち上げるdocker-compose.yml version : '3' services : redoc : image : redocly/redoc:latest container_name : redoc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : SPEC_URL : swagger/swagger.yml ports : - "8081:80" doc : image : swaggerapi/swagger-ui:latest container_name : doc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : API_URL : swagger/swagger.yml ports : - "8080:8080" Swagger UIと Redoc が立ち上がります。 RedocもOpenAPIベースのドキュメント表示ツールで、開発者が自由にどちらでも見ていいことにしています。 Committeeを利用してOpenAPI準拠のRequest Specを行う スキーマ 駆動開発では、開発を始める前にまず スキーマ を定義しそれを信頼することが求められます。 しかし起こりうる課題として スキーマ と実装の乖離があります。 スキーマ の信頼性が失われると結局サーバーサイド開発者とクライアントサイド開発者の無駄なコミュニケーションが発生し、 スキーマ は形骸化してしまいます。 Rails の開発においてこの問題を防ぐために、committeeとcommittee- rails というGemを採用しました。 github.com github.com committeeは、OpenAPIで定義した スキーマ を利用してリク エス トとレスポンスの検証を行う ミドルウェア を提供してくれます。 committee- rails はラッパーライブラリで、 rails での導入を簡単にしてくれます。 committee- rails は rails_helper.rb に以下の記述を追記することで導入できます。 # configured for committee-rails config.add_setting :committee_options config.committee_options = { schema_path : Rails .root.join( ' etc ' , ' docs ' , ' swagger ' , ' swagger.yml ' ).to_s, } include Committee :: Rails :: Test :: Methods スキーマ が配置されているパスを設定しています。 committeeを導入する前は、元々OpenAPIの定義は Rails とは別の リポジトリ として用意していました。実装のコミットと スキーマ 定義のコミットは分けた方が見通しがいいだろうと思ってのことです。 しかしcommitteeを導入するとなるとprivateの別 リポジトリ の ソースコード を読みに行くのは非常に面倒になってしまうので、上記のメリットだけであるならば Rails の リポジトリ にOpenAPI定義まで含めた方が楽だろうということで、 Rails の リポジトリ に統合することにしました。運用上困ることは特に起きていません。 committeeを導入することで、 rspec のrequest specで assert_response_schema_confirm が使えるようになります。 例えばUser自身のリソースを返すエンドポイントがあったとします。(認証などは除いています) paths : '/users' : get : tags : - user summary : User自身の情報 description : 名前とメールアドレスの取得 responses : '200' : content : application/json : schema : $ref : '#/components/schemas/UserSchema' components : schemas : UserSchema : type : object required : - user_id - family_name - given_name - email properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 Rails 側で対応するserializerとcontrollerはこんな感じになります。 # app/serializers/user_serializer.rb class UserSerializer < ApplicationSerializer attribute :id , key : :user_id attributes :family_name , :given_name , :email end # app/controllers/users_controllers.rb class UsersController < ApplicationController def show render json : current_user, serializer : UserSerializer end end request specはこんな感じです。 RSpec .describe ' Users ' , type : :request do describe ' #show GET ' do let( :user ) { FactoryBot .create( :user ) } subject { get(user_v1_users_me_path) } before { subject } context ' when success ' do let( :return_http_status ) { :ok } it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end end assert_response_schema_confirm を利用することで、OpenAPI定義に準拠しているかをチェックすることができます。 この時点ではテストは通ります。 ここから、例えばUserSchemaに誕生日が追加されたとします。 components : schemas : UserSchema : type : object required : - userId - family_name - given_name - email - birthday properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 birthday : example : 1990-01-01 type : string format : date Serializerにbirthdayは定義していないため、このまま再度Request Specを実行するとテストは通らなくなります。 .F Failures : 1 ) Users #show GET when success return expected body schema Failure / Error : assert_response_schema_confirm Committee :: InvalidResponse : #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:35:in `rescue in validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:30:in `validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/response_validator.rb:20:in `call' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3.rb:38:in `response_validate' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/test/methods.rb:27:in `assert_response_schema_confirm' # ./spec/requests/user/v1/users/me_request_spec.rb:32:in `block (4 levels) in <main>' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call' # -e:1:in `<main>' # ------------------ # --- Caused by: --- # OpenAPIParser::NotExistRequiredKey: # #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/openapi_parser-0.11.2/lib/openapi_parser/schema_validator.rb:62:in `validate_data' Finished in 1.33 seconds (files took 0.46133 seconds to load ) 2 examples, 1 failure Failed examples: rspec ./spec/requests/users_request_spec.rb: 31 # Users #show GET when success return expected body schema このように、OpenAPI定義に準拠していなかった場合はテストを落とすことができます。導入も簡単で非常に便利です。 ちなみに assert_response_schema_confirm を全てのエンドポイントで記述するのは面倒なので、実際はshared_exampleにまとめています。 # spec/support/response_helper.rb module ResponseHelper shared_examples ' response status & body compare to swagger ' do it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end OpenAPI3のrequiredに注意する CommitteeによってOpenAPI定義に準拠していることを確認していますが、これはOpenAPI定義でrequiredを設定しなければプロパティの有無の検証ができず、テストが全て通ってしまいます。 components : schemas : UserSchema : type : object required : <- ここに必須とするpropertiesを設定しなければ、テストが全て通ってしまう - user_id - family_name - given_name - email properties : # 省略 type: object を指定する場合は、基本的に併せてrequiredを設定するものと考えたほうが良いかと思います。 その他にもcommitteeが検証してくれる項目は多岐にわたりますが、それぞれOpenAPI定義で指定しなければ検証はされません。(当たり前ですが) 配列のminItemsを指定することで、空配列をバリデーションする リク エス トのContent-Type リク エス トのパスの存在可否 レスポンスの ステータスコード の存在可否 Committeeでテストが落ちて欲しいシチュエーションでテストが通ってしまった場合、OpenAPIで定義していないだけということがよくありました。 同様の問題として、OpenAPI定義でよく悩むのはnullableの扱いです。 qiita.com OpenAPI3になってからはpropertiesに nullable: true フラグを設定することができるようになりましたが、Swagger UIでは表示はまだされません。 Committeeの検証においては、requiredを指定しpropertiesで型を定義すればデフォルトで nullable: false となり、 value がnullの場合はテストが落ちるようになります。 ですので基本的にはキーとバリューが確実に存在する場合はrequiredのみを指定しておき、 value のnullを許容したいにしたい場合のみ nullable: true , nullable: false は指定しなくてもよい、みたいな運用をしています。 もう一つ、例えばOpenAPIで定義していないcreated_atやupdated_atをレスポンスとして返していたとしても、Committeeは過分に関してはチェックをしてくれません。 スキーマ 駆動開発を実践しているならばOpenAPIに定義されているpropertiesがあればクライアントサイドの開発は進められるはずなので、過分があったとしても特に問題はないはずではありますが… この運用でよかったこと API ドキュメントの信頼性が高く、保守し続けられることが仕組み化できた スキーマ 駆動開発は何度か経験しているものの、今回のプロジェクトで初めてCommitteeを導入しました。Committeeによって スキーマ に準拠していることを検証できるようになったため、 API ドキュメントの信頼性がより高められました。 Committeeの導入により、バリデーションを意識して YAML を書くようになった requiredやminItemsなどバリデーションを意識して適切なpropertiesを設定するようになり、より詳細なドキュメントが残せるようになりました。 まとめ 今回は スキーマ 駆動開発を実際にどのように運用しているのかを紹介しました。今回では紹介しきれませんでしたが、リソース指向の スキーマ 定義についてのチームの試行錯誤や、snake_caseからcamelCaseへの変換をコントローラー層で行っている例、コントローラー層での汎用的なエラーハンドリングなど、 API 開発のTipsを今後も紹介していこうと思っています。 質問や指摘お待ちしております!
アバター
こんにちは、タイミーSREチームの宮城です。 今回は弊社が Redash をFargateで構築/運用している話を紹介します。 背景 タイミーでは、CSやセールスのKPI策定から毎月の事業数値に至るまで、Redashが様々な用途で活用されています。 Fargateで構築する以前はEC2上のdocker-composeで運用されていましたが、以下の課題がありました。 オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する。 その度slack上から再起動していた セットアップしたエンジニアが退社しており、インフラ構成図やノウハウの共有、IaCによる管理ができていない。 クエリや ダッシュ ボードなどのデータの定期的なバックアップができていない。 v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 上記をFargateに移行することで解決することができました。 移行後の アーキテクチャ Redashで利用する ミドルウェア に関しては下記 コンポーネント を使い、全てをterraformで管理しています。 - PostgreSQL -> RDS - Redis -> ElastiCache それぞれの構成の紹介 ここからは、それぞれの構成をTerraformの ソースコード やタスク定義の JSON などを交えつつ説明していきます。 RDS/Elasticache ダッシュ ボードなどのデータが定期的なバックアップが行われていない問題は、RDSでsnapshotを取得することで解決しました。 それぞれ一番小さい インスタンス タイプのシングルAZ構成で構築しています。 実際に運用してみて負荷が大きければスペックを上げるつもりでしたが、現状問題なく捌けています。 将来、可用性を高めるためマルチAZにすることも容易であり、こういった柔軟なサーバーリソースの活用も クラウド の利点といえるでしょう。 RDSのTerraform privateサブネットに置いたシンプルな構成です。 applyが完了したらrootユーザーのパスワードを AWS コンソール上から変更し、接続情報をSecretsManagerに保管しています。 resource "aws_db_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Postgresの5432ポートを開くセキュリティグループ */ resource "aws_security_group" "rds-redash" { name = "rds-postgres-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "rds-redash-ingress" { type = "ingress" from_port = 5432 to_port = 5432 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } resource "aws_security_group_rule" "rds-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_db_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } resource "aws_rds_cluster_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } /* DBクラスター */ resource "aws_rds_cluster" "redash" { cluster_identifier = "redash" engine = "aurora-postgresql" engine_version = "11.6" master_username = "postgres" master_password = "password" // 仮の値 backup_retention_period = 5 preferred_backup_window = "07:00-09:00" db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.redash.name db_subnet_group_name = aws_db_subnet_group.redash.name skip_final_snapshot = true availability_zones = [ "ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d", ] vpc_security_group_ids = [ aws_security_group.rds-redash.id ] lifecycle { ignore_changes = [ master_password, // passwordはsecrets managerで管理しています。 ] } } /* プライマリDB */ resource "aws_rds_cluster_instance" "redash" { identifier = "redash-1" cluster_identifier = aws_rds_cluster.redash.id instance_class = "db.t3.medium" engine = "aurora-postgresql" engine_version = "11.6" } ElastiCacheのTerraform RDSとほぼ同じです。 /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_elasticache_parameter_group" "redash" { name = "redash" family = "redis5.0" } resource "aws_elasticache_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Redashの6379ポートを開くセキュリティグループ */ resource "aws_security_group" "redis-redash" { name = "redis-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "redis-redash-ingress" { type = "ingress" from_port = 6379 to_port = 6379 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } resource "aws_security_group_rule" "redis-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } /* Redashでジョブのキューイングを行うRedis */ resource "aws_elasticache_cluster" "redash" { cluster_id = "redash" engine = "redis" node_type = "cache.t2.micro" num_cache_nodes = 1 parameter_group_name = aws_elasticache_parameter_group.redash.name subnet_group_name = aws_elasticache_parameter_group.redash.name security_group_ids = [ aws_security_group.redis-redash.id ] engine_version = "5.0.6" port = 6379 } ECS Fargate タイミーではFargate Serviceを構築するためのTerraform Moduleがあり、Redash構築でも利用しています。 CPUやメモリを 閾値 としたオートスケーリングや、firelensを利用したDatadog Logsへのログ配信が容易に行えるようになっています。 この説明については後日記事にしたいと思います。 FargateではRedashの 公式Dockerイメージ をコンテナで実行しています。 ECS Cluster内に4つのECS Serviceが動いており、それぞれの役割は以下です。 Server ... WebUIを提供するサービス Scheduled Worker ... スケジューリングされたクエリを処理する Adhoc Worker ... 都度実行されるクエリを処理する Scheduler ... Redisにjobをキューイングする Redashは実行するコマンドを変更することによって、それぞれの役割を振る舞うことができます。 さらに 環境変数 を設定することで柔軟に設定を変更することができます。 redash.io 環境変数 がどのように設定されているかを知ることで、それぞれのサービスの理解がしやすくなるかと思います。 ここではそれぞれのサービスで利用するタスク定義から、実行コマンドと設定した 環境変数 を抜粋して説明します。 Server ServerはALBに紐付けられWeb UIを提供します。 ユーザーが実行するクエリの処理はこのサービスでは行いません。 クエリはWorkerが処理し、 PostgreSQL に書き込まれた結果をWebUIが表示します。 " command ": [ " server " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " REDASH_THROTTLE_LOGIN_PATTERN ", " value ": " 1000/minute " } , { " name ": " REDASH_WEB_WORKERS ", " value ": " 4 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } , { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } ] , REDASH_DATABASE_URL REDASH_COOKIE_SECRET REDASH_MAIL_PASSWORD は秘匿情報のためParameter Storeに保管し、値をコンテナ起動時に注入しています。 REDASH_DATABASE_URL が秘匿情報な理由はパスワードも含めた接続情報なためです。 postgres://<ユーザー名>:<パスワード>@ホスト名 といった文字列が格納されています。 注意すべき点は REDASH_THROTTLE_LOGIN_PATTERN です。これは /login エンドポイントへのレートリミットが設定されており、デフォルトで "50/hour" が設定されています。 FargateにおいてALBのヘルスチェックは有効にしておきたいところですが、Redashにはヘルスチェック用のパスが用意されておらず、ログインせずとも見られるページは /login だけでした。そのためレートリミットを緩和することでヘルスチェックができるようにしています。 メールの送信にはタイミーではSendGridを利用しています。 Adhoc Worker, Scheduled Worker Adhoc Workerはユーザーが都度実行するクエリを処理し、Scheduled Workerは定期実行されるクエリを処理します。 Redisのキューを受け取って処理を開始し、データソースに問い合わせた結果を PostgreSQL に保存します。 " command ": [ " worker " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " queries " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 2 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , 上記はAdhoc Workerのタスク定義ですが、 Scheduled Workerとの違いは QUEUES がqueriesかscheduled_queriesかどうかのみです。 カンマで区切って両方指定することで、1つのworkerで両方の責務を担うこともできます。 EC2の頃に インスタンス のCPUを押し上げていたのはこのAdhoc Workerでした。非エンジニアで SQL に慣れていないメンバーが多いため、パフォーマンスを考慮できず数分以上かかる重いクエリがたくさん叩かれることが原因でした。 そのためAdhoc WorkerのサービスのみCPUとメモリを増やし、コンテナの最低数/最大数を増やすことで解決しました。 サービスを分割したことで、特定のコンテナのみスペックを増強することができるようになったのもFargate化の利点です。 Scheduler Redash Schedulerは Python のライブラリ RQ Scheduler を利用し、Redisにキューを追加します。 " command ": [ " scheduler " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " celery " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 5 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , QUEUES を celery に指定することで、Schedulerとして振る舞います。 サービスを分割して運用しているものの負荷が全然かからないため、Scheduled Workerとの統合も検討しています。 firelensを利用した、Datadog Logsへのログ転送 上記で紹介した4つのECS Serviceのログは、firelensを通してDatadog LogsとS3に転送されています。 id:sion_cojp のこちらの記事で詳しく紹介しています。 sioncojp.hateblo.jp Datadog Dashboard による監視 ALB, ECS Service, RDSを ダッシュ ボードで一覧できるようにしました。 DatadogもTerraformで管理しており、 ダッシュ ボード作成作業はコピペで済むようになって楽です。Datadogはapplyが早いのもよいです。 ダッシュ ボードのTerraform resource "datadog_dashboard" "redash" { title = "[${local.env}] ${var.service_name}" description = "Created using the Datadog provider in Terraform" layout_type = "ordered" widget { group_definition { layout_type = "ordered" title = "ALB: ${var.service_name}" widget { timeseries_definition { title = "リクエスト数" request { q = "sum:aws.applicationelb.request_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "4xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_4xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "5xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_5xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "コネクション数" request { q = "sum:aws.applicationelb.active_connection_count{name:${var.service_name},env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる正常なコンテナ数" request { q = "sum:aws.applicationelb.healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる異常なコンテナ数" request { q = "sum:aws.applicationelb.un_healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-server" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduler" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduled-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-adhoc-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "RDS: ${var.service_name}" widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.rds.cpuutilization{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "DBコネクション数(MAX)" request { q = "max:aws.rds.database_connections{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "空きストレージ容量 (MB)" request { q = "max:aws.rds.free_storage_space{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "使用可能なメモリ(MB)" request { q = "max:aws.rds.freeable_memory{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } } } } その他 Redashを AWS で構築するにあたって、Route53や ACM , WAFなどを使用しましたが、今回は記事が長くなってしまうため割愛します。 また、 stg 環境とprod環境を AWS アカウント単位分けており、 stg 環境として全く同じ構成のRedashを立てています。 理由はredashのバージョンアップや saml を使ったSSO認証の検証のためです。 今はコンテナ数を0にして寝かせています。 Fargateに移行した利点 抱えていた課題がほぼ解消できた 1. オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する → オートスケールができるようになり、サービスが停止することはなくなりました。 2. セットアップしたエンジニアが退社しており、インフラ構成図やノウハウ、IaCによる管理ができていない。 → 全てコードで管理されている。構成図や wiki も残すことで、後任者がキャッチアップできるようになりました。 3. ダッシュ ボードなどのデータの定期的なバックアップができていない。 → AWS マネージドサービスに移行し、RDSのスナップショットで解決しました。 4. v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 → stg 環境があるので、本番環境で実施する前に試すことができるようになりました。 5. 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 → datadogでモニタリングができるようになりました。まだコンテナ数などの調整中のため、アラートは保留としています。 また移行前は週に数回オンコールが発生していたが、移行してからほぼ0になりました。 サービスをきちんと分離したことで、負荷がかかることが多いAdhoc Workerのみスペックを上げる事が可能になった それまでは重いクエリを実行するとEC2 インスタンス のCPUが100%に達して他のユーザーにも影響を与えてしまっていたのが、サービスを分離したことでAdhoc Worker以外のサービスへの影響を減らすことができるようになりました。かつAdhoc Workerのみスペックを上げることができるようになりました。コンテナとサーバレスの特性をうまく活かすことができたと思っています。 まだ残っている課題 Redashのバージョンをv7からv8に上げる v8にアップデートできるとクエリ名を日本語で正しく検索できるようになるため、社内からアップデートしてほしいと要望があります。しかし今回のFargate移行でアップデートしやすくなったため、近いうちに着手します。 ログイン認証をSSOで行う タイミーでは従業員に発行する各種アカウントをGSuiteでのSSOでできるよう移行を進めています。Redashも SAML 認証によるSSOに対応しているので、次にやっていきたいと思っています。 まとめ 今回EC2で動いていたRedashをFargateに移行することによって、Redashにまつわる事柄全てをマネージドサービスとIaCで管理することができるようになりました。タイミーのSREがアプリケーションをどのように運用しているかも紹介できたかと思っています。 また今回よりタイミーのプロダクトチームのブログを開設することになりました。SRE/サーバーサイドエンジニア/フロントエンドエンジニア/デザイナーそれぞれの、タイミーのプロダクトにまつわる記事を投稿していきたいと思っております。ぜひお楽しみに!
アバター