見出し画像

EventBridge & Lambda with Lambda Layer で NerdGraph で New Relic からデータを取得し Slack へ通知する

こんにちは。ユビレジの基盤ユニットの徳元です。ユビレジではサービスの信頼性を測定するためにいろいろなアプローチを取っています。本記事では New Relic からユビレジで稼働するサーバー(以下、ユビレジサーバーと記載)の API リクエスト成功率のデータを取得し、Slack に通知する機能を EventBridge・Lambda・Lambda Layer で実装した内容と、運用して感じたことを紹介します。


要約

  • ユビレジではオブザーバビリティプラットフォームとして New Relic を利用しています

  • サービスの信頼性にも関わる API リクエストの成功率を New Relic から NerdGraph によって取得し、Slack に通知する Lambda を実装しました

  • 今後の実装・運用を考えて Lambda Layer を作成したり、README を充実させました

  • 作った通知機能は API の問題の検知 & 解決の確認、怪しい API の調査に役立っています

出てくる用語の説明

New Relic

New Relic は様々な機能を備えたオブザーバビリティプラットフォームです。

例えばプラットフォームのページでは「フルスタックモニタリング」「オブザーバビリティ体験」「データ取り込みとインサイト」という 3 つのカテゴリの中に多くの機能が入っています。

ユビレジではユビレジサーバーを始めとするいくつかのアプリケーションに New Relic のエージェントを導入し、モニタリングをしています。

NerdGraph

New Relic が提供する GraphQL 形式の API です。New Relic にはこの他に データインジェスト用 API、REST API、機能別 API があります。

Lambda

AWS Lambda はサーバーレスなイベント駆動型コンピューティングサービスであり、様々なイベントをトリガーにしてコードを実行できます。

Lambda Layer

Lambda レイヤーは、コードやデータを含めた zip ファイルのアーカイブです。ライブラリ、カスタムランタイム、データ、または設定ファイルをレイヤーにすることで、デプロイパッケージのサイズを減らしたり、他の Lambda で処理を使い回せたりします。

EventBridge

Amazon EventBridge は、環境の変化の指標である「イベント」を受信し、アプリケーションコンポーネントである「ターゲット」にルーティングするルールを適用するサーバーレスサービスです。

なぜ作ったか

元々は、ユビレジサーバーの状態を観測するのに重要なデータを New Relic のダッシュボードにまとめ、基盤ユニットのデイリーミーティングで何か変わった動きがないか確認していました。

しばらくすると「何もない時にはダッシュボードを極力見なくて済む運用にしたい」という話になり、重要な指標が目標値を下回っていたら Slack へ通知させることになりました。

ダッシュボードでチェックしている指標の中から、まずはユビレジサーバーへの API リクエストの成功率を通知する機能を作ることになりました。

何を作ったか

Lambda を定期実行させ、New Relic の API(NerdGraph)を通してリクエストの成功率を取得するクエリ(NRQL)を投げ、返ってきた結果の中から目標値を下回るものをSlack に通知させています。

本機能の全体像

Slack ではこんな感じでメッセージが投稿されます。

Slack チャンネルでの通知例

どうして NerdGraph なのか?

具体的な実装の話に入る前に、なぜ NerdGraph を採用したかについて説明しておきます。

New Relic API にはデータインジェスト用 API、NerdGraph(GraphQL)、REST API、機能別 API があります。

  • ダッシュボードと同じ NRQL を利用してデータを取得する

  • 最新の API であり、New Relic が利用を推奨している

  • クエリを投げる分には特に REST API と複雑さは変わらない

といった理由から NerdGraph を利用することになりました。ただし、上の記事にある通り NerdGraph ではまだ実行できない機能があるので、その場合は REST API を利用するといいと思います。

作ったクラス・モジュール

今回の Lambda 関数を作るにあたって作成したクラス・モジュールは以下の通りです。

  1.  NerdGraph:NerdGraph を使ってクエリを投げるモジュール

  2. SlackMessageBuilder:Slack へ通知するメッセージを作成するクラス

  3. SlackNotifier:Slack へ通知するクラス

このうち 1 と 3 は今後他の指標も通知することを想定して Lambda Layer として追加しました。

NerdGraph

graphql-client を使って実装しています。
NRQL とアカウント ID を受け取って、NerdGraph API へリクエストを投げているだけです。
ライブラリのように使われることを想定して、必要な環境変数がなかったらエラーを吐くようにしています。

# frozen_string_literal: true

require "graphql/client"
require "graphql/client/http"

class NerdGraphEnvVariableError < StandardError; end

module NerdGraph
  API_KEY = ENV["NEWRELIC_API_KEY"]
  API_URL = "https://api.newrelic.com/graphql"

  HTTP = GraphQL::Client::HTTP.new(API_URL) do
    def headers(_context)
      raise NerdGraphEnvVariableError.new("環境変数 NEWRELIC_API_KEY が渡されていません") unless API_KEY

      # Optionally set any HTTP headers
      { "API-Key": API_KEY }
    end
  end

  Schema = GraphQL::Client.load_schema(HTTP)

  Client = GraphQL::Client.new(schema: Schema, execute: HTTP)

  BASE_QUERY = Client.parse <<~GRAPHQL
    query($accountId: Int!, $nrql: Nrql!) {
      actor {
        account(id: $accountId) {
          nrql(query: $nrql) { results }
        }
      }
    }
  GRAPHQL

  def query(nrql:, account_id: nil)
    Client.query(BASE_QUERY, variables: { accountId: account_id, nrql: nrql })
  end

  module_function :query
end

SlackMessageBuilder

NerdGraph のデータを受け取り、Slack に送るメッセージをつくるクラスです。クラスとして分けたものの、実際には Lambda スクリプトの中に書いています。

特定のスクリプトに依存した使われ方をするので、特に初期化時の値チェックなどは行なっていません。

# これらの定数は Lambda スクリプトの中で書かれている
QUERY_SUCCESS_RATE_ALIAS = "successRate"
QUERY_COUNT_ALIAS = "count"
QUERY_NAME_COLUMN = "name"

class SlackMessageBuilder
  DECIMAL_PLACES = 1

  private attr_reader :results, :threshold, :period_in_days

  def initialize(results:, threshold:, period_in_days:)
    @results = results
    @threshold = threshold
    @period_in_days = period_in_days
  end

  def build
    <<~EOS
      【直近 #{period_in_days} 日間で成功率 #{threshold}% 未満の API】
      成功率/エンドポイント/リクエスト数

      #{body}

      <https://one.newrelic.com/dashboards/detail/xxx | ダッシュボードを見る>
    EOS
  end

  private

  def body
    results.map do |result|
      "#{result[QUERY_SUCCESS_RATE_ALIAS].round(DECIMAL_PLACES)}% `#{result[QUERY_NAME_COLUMN]}` (#{result[QUERY_COUNT_ALIAS]}回)"
    end.join("\n")
  end
end

SlackNotifier

Webhook URL と投稿メッセージを受け取って、実際に通知するクラスです。
このクラスも Layer として登録され、ライブラリのように利用されることを想定しているので初期化時に値のチェックを行っています。

require "net/http"

class SlackNotifierArgumentError < StandardError; end

class SlackNotifier
  private attr_reader :text, :uri

  def initialize(text:, uri:)
    raise SlackNotifierArgumentError.new("text は必須です") if text.nil? || text.empty?
    raise SlackNotifierArgumentError.new("uri は必須です") if uri.nil? || uri.empty?

    @text = text
    @uri = URI.parse(uri)
  end

  def notify
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.start do
      request = Net::HTTP::Post.new(uri.path)
      request.set_form_data(payload: payload)
      http.request(request)
    end
  end

  private

  def payload
    { text: text }.to_json
  end
end

Lambda スクリプト

これらのクラスを利用した Lambda スクリプト(lambda_function.rb)は以下の通りです。

# frozen_string_literal: true

require "nerd_graph"
require "slack_notifier"

QUERY_SUCCESS_RATE_ALIAS = "successRate"
QUERY_COUNT_ALIAS = "count"
QUERY_NAME_COLUMN = "name"

THRESHOLD = ENV["THRESHOLD"] || xx
PERIOD_IN_DAYS = ENV["PERIOD_IN_DAYS"] || x

API_SUCCESS_RATE_NRQL = <<~NRQL
  FROM (
    SELECT percentage(count(*), WHERE error IS false OR error IS NULL) AS #{QUERY_SUCCESS_RATE_ALIAS}, count(*) AS #{QUERY_COUNT_ALIAS}
    FROM Transaction
    FACET #{QUERY_NAME_COLUMN}
    LIMIT MAX
  )
  SELECT #{QUERY_SUCCESS_RATE_ALIAS}, #{QUERY_COUNT_ALIAS}, #{QUERY_NAME_COLUMN}
  WHERE #{QUERY_SUCCESS_RATE_ALIAS} < #{THRESHOLD}
  ORDER BY #{QUERY_SUCCESS_RATE_ALIAS}
  LIMIT MAX
  SINCE #{PERIOD_IN_DAYS} DAYS AGO
NRQL

class SlackMessageBuilder
  DECIMAL_PLACES = 1

  private attr_reader :results, :threshold, :period_in_days

  def initialize(results:, threshold:, period_in_days:)
    @results = results
    @threshold = threshold
    @period_in_days = period_in_days
  end

  def build
    <<~EOS
      【直近 #{period_in_days} 日間で成功率 #{threshold}% 未満の API】
      成功率/エンドポイント/リクエスト数

      #{body}

      <https://one.newrelic.com/dashboards/detail/xxx | ダッシュボードを見る>
    EOS
  end

  private

  def body
    results.map do |result|
      "#{result[QUERY_SUCCESS_RATE_ALIAS].round(DECIMAL_PLACES)}% `#{result[QUERY_NAME_COLUMN]}` (#{result[QUERY_COUNT_ALIAS]}回)"
    end.join("\n")
  end
end

class UbiregiServerAPIRequestSuccessWatcherError < StandardError; end

def lambda_handler(event: nil, context: nil)
  account_id = ENV["ACCOUNT_ID"].to_i
  raise UbiregiServerAPIRequestSuccessWatcherError.new("環境変数 ACCOUNT_ID が無効です") unless account_id > 0

  query_result = NerdGraph.query(nrql: API_SUCCESS_RATE_NRQL, account_id: account_id)
  response = SlackNotifier.new(
    text: SlackMessageBuilder.new(
      results: query_result.data.actor.account.nrql.results,
      threshold: THRESHOLD,
      period_in_days: PERIOD_IN_DAYS
    ).build,
    uri: ENV["SLACK_WEBHOOK_URL"]
  ).notify

  response.value
end

EventBridge・Lambda・Lambda Layer の設定

あとは AWS のコンソールで zip ファイルをアップロードして Lambda と Lambda Layer を追加し、Lambda 関数に紐づけ、EventBridge でイベントパターンを cron 式で記述します。

通知したい Slack チャンネルに Slack App を追加し、Webhook URL を含めた環境変数を指定するとこんな感じでメッセージが投稿されるようになります。

Slack チャンネルでの通知例(再掲)

工夫したところ・気をつけたところ

実装をしながらつまづいたところ、気をつけたところなどのポイントを書いていきます。

NerdGraph エクスプローラーでリクエスト・レスポンスのイメージを掴む

New Relic は NerdGraph エクスプローラーという、GraphiQL を使用し構築した NerdGraph 用の GUI があります。

これを利用することで、どんなクエリでデータを取得できるかを確認できます。

NRQL は、ダッシュボードの Widget(今回で言うと API リクエスト成功率を表示しているセクション的なもの)のメニューから「View query」を押してそのまま利用すれば OK です。

気をつけたいのは、正しい API キーにするのはもちろん、正しい accountId をクエリ時に渡すことです。例えばアカウント A のデータに NRQL を実行したい場合、API キーは(設定・権限によっては)A、B どちらのものでも実行できることがあります。しかし GraphQL Query の accountId は A のものでなければ値が返ってきません。

Lambda Layer のディレクトリ構成

実装ができて Lambda Layer を追加するとなった時に、レイヤーとして追加するコードを zip に圧縮する必要があるのですが、そのディレクトリがぱっと見わかりづらいです。

例えば NerdGraph モジュールを定義した nerd_graph.rb の場合、以下のようなディレクトリ構造を持った ruby フォルダを zip にします。

// ruby 3.2.2 の場合
ruby
├── gems
│   └── 3.2.0
│       ├── build_info
│       ├── cache
│       ├── doc
│       ├── extensions
│       ├── gems
│       ├── plugins
│       └── specifications
└── lib
    └── nerd_graph.rb

ドキュメントに書いてあるとおり GEM_PATH と RUBYLIB が指すパスなので、開発時とデプロイ時のディレクトリ構造が異なる場合は、デプロイ時にデプロイ用フォルダを作成しなければなりません。

README に開発に役立つ情報を書く

現在ユビレジには多くの Lambda 関数があるわけではなく、Lambda のスクリプトは他の雑多なコードが保管されているリポジトリに保管されています。

Lambda に関しては決まった開発フローや管理方法があるわけでもなく、開発時にどうするか迷うこともあったので、できるだけ README を充実させるようにしました。

具体的には以下の項目を README に書きました。

  • この Lambda 関数の概要

  • 開発環境(Ruby、Bundler のバージョン)

  • 開発時の Lambda Layer 用コードの修正例

  • 手元での動作確認方法

  • 実際に作成した Lambda・Lambda Layer の URL

  • デプロイ方法・Layer デプロイ時のディレクトリ構成

ディレクトリ構成については開発後にコマンドを打てばデプロイ準備が整うように、こんな感じでコマンド例も書いておきました。

# bundle install 後に
mkdir -p ruby/lib ruby/gems

cp -r lib/vendor/bundle/ruby/ ruby/gems/
cp lib/nerd_graph.rb ruby/lib

zip -r nerd_graph ruby

運用しながら感じたこと

まだ運用を始めて日が浅いのですが、この通知が具体的に役に立てた出来事が 2 つあったので少しだけ紹介させていただきます。

API の問題の検知 & 解決の確認ができる

先ほど例として挙げた通知メッセージには著しく成功率が低い API がありました。実はこの問題、開発メンバーによって認識され、バグが修正されていました。

その通知のタイミングではいくつかの理由でまだ開発メンバーをチャンネルに入れていなかったため、通知起因で修正が行われたわけではありませんが、問題が通知されていたことが確認できました。そして、バグ修正の PR がマージされた次の日には問題の API はアラートされなくなりました。

この件によって通知が問題の検知 & 修正の確認に役立つことが確認できました。

メンバー間で通知内容に対する共通認識が得られる

開発メンバーをチャンネルに招待した際、通知内容を見たあるメンバーが「なんでこの API のリクエスト成功率がこんなに低いんだろう?」と疑問に思い調査をした結果、そのエラーが想定内のものであったこと、API の利用母数が少ないため成功率が低く見えていることがわかりました。

すぐに対応すべき問題ではなかったとしても、怪しい API があれば実装に詳しいメンバーが調査し、その見解をチャンネル内で書いておくだけで、チャンネル内のメンバーが通知内容に対して共通認識を持つことができます。

これは限られたメンバーで New Relic のダッシュボードを見たり、話し合うだけではできなかったことです。

まとめ

今回は EventBridge・Lambda・Lambda Layer で New Relic から API リクエスト成功率を取得し Slack へ通知する機能について紹介しました。

Lambda Layer を作成したり、README を充実させることによって、今後の運用や新しい Lambda の開発ではスムーズな実装が期待できます。

実際、この機能の実装後に別のインフラのデータを通知する処理を Lambda で書く機会があったのですが、SlackNotifier を使い回すことで API コールとメッセージの内容だけに集中することができました。

また、出来た通知機能は API の問題の検知 & 解決の確認、怪しい API の調査に役立つことが確認できました。

今回のシンプルな Lambda でも色々と保守性を考える余地がありました。Terraform による管理など、実現できなかったものもあります。運用や拡張性と、目の前の課題解決のいいバランスを見極めていきたいものです。


いいなと思ったら応援しよう!