スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

ActiveModel の callback と Datadog Event を使って複雑なコードを整理した話

こんにちは、小中高プロダクト基盤開発グループで Product Platform Engineer をやっている @shimiwaka です。

私の所属する基盤開発グループでは、スタディサプリの認証基盤の開発を行っています。

この記事では、その認証基盤の開発時にお世話になった ActiveModel の callback と Datadog Event について紹介していきます。

認証基盤とは

サプリのユーザーやアクセストークンなどの、認証に関わる情報は MongoDB に保存されています。

サプリは多数のアプリケーションから成り立っています。それらの一部はその MongoDB を共有しており、 OR Mapper の定義を社内 gem として共有した「分断されたモノリス」として構成されています。

この記事では、モノリスアプリケーションで使用されている Rails について書きます。

モノリスの各アプリケーションは必要に応じて ID とパスワードを照合してログイン処理を行ったり、アクセストークンが有効なものであるか検証を行ったりするわけですが、これらの処理はアプリケーションごとに独自に実装されていました。

そのため、メンテナンス性が悪いことはもちろん、アクセストークンの有効期限の扱いがアプリケーションによって違うなどの細かい不整合が起きてしまっていました。

また、MongoDB への依存も問題となります。サプリの MongoDB は非常に巨大になっており、MongoDB に依存するサービスは極力減らしたいのですが、認証を行うモノリスアプリケーションは常に MongoDB に依存してしまいます。

これらを整理して生み出したい、認証に関する堅牢なプラットフォームを、私達は「認証基盤」と呼んでいます。

認証基盤の開発の第一段階として、認証に関する処理をモジュール化し、1つのインターフェースにまとめます。こうすることでメンテナンス性が向上し、サービス間の処理の違いといった不整合が起こらなくなります。

さらに、認証を Web API として提供するアプリケーションを立ち上げ、認証モジュールが内部的にそのサービスに接続するようにしようと考えています。こうすれば、MongoDB に依存するのはそのアプリケーションだけになり、認証機能のコントロールが容易になります。

ここまで来れば、認証に関する情報を MongoDB から別の DB に移すこともできるようになるはずです。

これが、認証基盤の開発が最終的に見据えているものです。

モジュールへの置き換え

認証のユースケースはさまざまですが、以下のようなものがあります。

  • ユーザーの認証(ID/パスワードの照合)
  • アクセストークンの発行
  • アクセストークンの検証
  • アクセストークンの削除
  • アクセストークンの無効化

まずは認証用のモジュールを作り、上記の機能を実装していきます。そして各モノリスアプリケーションで独自に実装されている認証の処理を、このモジュールを使うものに置換して統一していきます。

ところが、コードベースが巨大すぎ、前述のように実装方法もバラバラなので、認証に関する処理を行っている場所をすべて調べるのは困難でした。

そもそも把握しきれない状況を解消したいためにこの活動は始まったので、当然なのかもしれません。

たとえば、認証モジュールによる置き換えを行ったつもりでも、認証モジュールを使わずにひそかにアクセストークンの検証を行っているコードが存在しうる、ということです。

これを調査するために、ActiveModel の callback 機能を使いました。

ActiveModel の callback

ActiveModel はモデルに対してコールバックを作る仕組みを提供しており、それを使っている ActiveRecord ではレコードが生成された後は after_create など、任意のタイミングでコールバックメソッドを呼ばせることができます。

私達は MongoDB の OR Mapper として MongoMapper を使っていますが、MongoMapper でも同様の機能が使えます。

生成以外も同様に、更新は after_update、削除は after_destroy などが使えます。詳しくは公式ドキュメントを読むと良いでしょう。

コールバックメソッドの中で、生成がどこから行われたかを caller_locations などを見て調べることで、認証モジュールを経由しないアクセストークンの発行が行われた時だけ特定の処理を行うことができます。

使い方は非常に簡単で、アクセストークンのモデルに以下のようにモジュールを指定して after_create を定義します。(説明のために簡単に書きましたが、実際に私達が使っているコードはもう少し複雑です)

class AccessToken
  include MongoMapper::Document

  after_create AccessTokenCallbacks
end

そして、AccessTokenCallbacks を定義します。ここでは、認証モジュールの実装された auth_module.rb 以外からアクセストークンが生成された時に警告を出すものとしました。

module AccessTokenCallbacks
  CALLER_FILE = 'auth_module.rb'
  private_constant :CALLER_FILE

  def self.after_create(access_token)
    if ENV['environment'] == 'develop'
      unless called_from_auth_module?
        send_alert("unexpected access token creation!")
      end
    end
  end

  private_class_method def self.called_from_auth_module?
    caller_locations.any? { |caller_location| caller_location.path.include?(CALLER_FILE) }
  end

  private_class_method def self.send_alert
    ...
  end
end

これだけです。

アクセストークンが生成されるたびに caller_locations をループして調べるのはパフォーマンス劣化の不安があったので、この実装は開発環境にだけ反映させました。

サプリには、全チームが共有して使っている開発環境があり、その環境で各チームが自分の扱う領域の動作確認をしていますので、ユースケースを網羅するには開発環境だけで十分だと考えました。

Datadog Event による通知

基本的に私達はコミュニケーションに Slack を使っているので、そういった想定外の生成が検知された時は Slack に通知してほしいと考えました。

Slack に通知する方法は、Sentry や Datadog などを使うことが考えられますが、今回は Datadog Event を使いました。

Datadog Event は Sentry と比較して、イベントを複雑なクエリで検索ができる、イベントをメトリクス化して長期的に傾向を把握できる、Datadog のダッシュボードで他のメトリクスと一緒に確認できるなどのメリットがあります。

今回の用途ではそれらはあまり関係がなかったのですが、万が一大量に通知が来た場合に Sentry の quota 制限が心配であるということから Datadog Event を選択しました。ただ、Datadog Event でも大量に来るとコストがかかってしまう(ことがあとでわかった)ため、結果的には特に Datadog Event を使わなければならない理由はありませんでした。

強いて言えば、社内で Datadog Event をほとんど使っておらず無料枠の余裕があったことと、使われていないので一度試してみたかったというのが理由になります。

この記事を読んでいる人にも、使ってみたいが使い方がわからないという人がいるかもしれませんので、紹介します。

実装方法は、Rails 側で Datadog モジュールの初期化ができていれば非常に簡単です。たとえば、以下のようになります。

statsd = Datadog::Statsd.client
statsd.event('fugafuga', '@slack-{CHANNEL_NAME}\nhogehoge')
statsd.flush

{CHANNEL-NAME} を slack のチャンネル名に変え、そのチャンネルの方では、Datadog アプリを invite しておく必要があります。

flush を行わないとすぐに送られない場合がありますが、リアルタイム性をそこまで求めなければ必要はないかもしれません。

このような通知が Slack に来ます。

Slack 通知の例

Datadog 上ではこのように表示されます。画面には写せませんが、Tags として環境変数などの色々な情報が一緒に送られてきています。この情報が役立つこともあるでしょう。

Datadog Event の表示

もし、caller_locations のように、単なるメッセージではなく長い情報を載せたいのであれば、truncate_if_too_long というオプションを true にすると良いでしょう。長いメッセージでも省略されることがなくなります。

statsd.event('fugafuga', '@slack-{CHANNEL_NAME}\n called by:#{caller_locations}', truncate_if_too_long: true)

気をつけたいのは、同じ内容が何度も送られてきたとき流量を制限するような仕組みはないことです。

今回は開発環境だけに導入したので、それほど多くのアクセスはありませんでしたが、本番環境に導入すると大量にアラートが来続け、本番デプロイしない限り止めることもできないという状況になりうるので、注意が必要です。

その効果

実際に、この仕組みにより想定していないアクセストークンの生成を検知することができました。

具体的には、ネイティブアプリからの特定の操作でだけ行われるアクセストークンの発行処理というニッチなもので、コードを grep しただけでは気づくことが出来ないものでした。

生成だけでなく、更新、削除などにも同様の仕組みが応用可能です。

おわりに

いかがでしたでしょうか。巨大なコードベースを人間の目だけで把握するのは非常に困難なので、こういった方法があることを覚えておくと役に立つかもしれません。

最初に述べたように、認証基盤の開発はモジュールの置き換えで終わったわけではありません。まだ長い道のりの途中です。一緒に堅牢な認証プラットフォームを作ってくれる仲間を募集しています。

ポジションの募集要項はこちらにあります。それでは。