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 ではこんな感じでメッセージが投稿されます。
どうして NerdGraph なのか?
具体的な実装の話に入る前に、なぜ NerdGraph を採用したかについて説明しておきます。
New Relic API にはデータインジェスト用 API、NerdGraph(GraphQL)、REST API、機能別 API があります。
ダッシュボードと同じ NRQL を利用してデータを取得する
最新の API であり、New Relic が利用を推奨している
クエリを投げる分には特に REST API と複雑さは変わらない
といった理由から NerdGraph を利用することになりました。ただし、上の記事にある通り NerdGraph ではまだ実行できない機能があるので、その場合は REST API を利用するといいと思います。
作ったクラス・モジュール
今回の Lambda 関数を作るにあたって作成したクラス・モジュールは以下の通りです。
NerdGraph:NerdGraph を使ってクエリを投げるモジュール
SlackMessageBuilder:Slack へ通知するメッセージを作成するクラス
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 を含めた環境変数を指定するとこんな感じでメッセージが投稿されるようになります。
工夫したところ・気をつけたところ
実装をしながらつまづいたところ、気をつけたところなどのポイントを書いていきます。
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 による管理など、実現できなかったものもあります。運用や拡張性と、目の前の課題解決のいいバランスを見極めていきたいものです。