Compute でネットワークエラーログをデプロイ
ネットワークエラーログ (NEL) により、ユーザーが Web サイトにアクセスしようとした際に遭遇したネットワーク問題に対する貴重なインサイトが得られます。また、W3C の仕様の一つであるこの機能は複数のブラウザによってサポートされているため、エラーの検出や報告に大いに役立つことが期待できます。実際、Fastly インサイトを使用して NEL を試したところ、Fastly の新しいサーバーレスコンピューティング環境 Computeのユースケースとして NEL レポートの処理が最適であることが分かりました。Compute を利用することで、データを効率的に解析してエンリッチ化し、JSON を再シリアル化してから生成したレポートを BigQuery などのサードパーティのエンドポイントに送信することが可能になります。つまり、この問題を解決するのに従来使用されてきたより複雑でエラーが発生しやすい処理パイプラインが不要になります。
もっとも安全かつ高パフォーマンスでスケーラブルなサーバーレスコンピューティング環境を提供する Compute を活用して複雑なロジックをエッジにアップロードしてデプロイする方法についてはこれまでにもたびたびご説明してきましたが、今回は C@E を使用して内部の問題を解決する方法をご紹介します。この記事では、NEL のレポートパイプラインを構築する方法について詳しく解説し、Compute を利用してパフォーマンスとセキュリティを強化しながらその過程で生じる問題を解決してパイプラインを最適化する方法をご紹介します。
一般的なレポートパイプライン
概念的には、NEL のレポートパイプラインを構築するための要件はごくわずかです。
OPTIONS リクエストに204 (コンテンツなし) と適切な CORS ヘッダーで応答し、プリフライトのセキュリティ要件を満たす。
レポートを送信する POST リクエストに「204 コンテンツなし」の HTTP レスポンスで応答する。
レポートを収集し、必要に応じてメタデーターを加え、分析パイプラインにレポートをログする。
ログされたデータを分析できるよう必要に応じて処理可能な形式に変換し、データベースに保存する。
ブラウザによって NEL レポートが POST ボディのペイロードとして JSON 形式で提供されます。データはそのままでも分析可能ですが、Fastly ではリクエストに関するより豊富なデータを取得できるため、エッジで利用可能なメタデータをキャプチャして各レポートにコンテキストを追加することでより優れた分析が可能になります。そのために、Fastly のリアルタイムログ機能でレポートをログする前に、地理的な位置情報やタイムスタンプを各レポートに追加することをお薦めします。
さらにエキサイティングなことに、ブラウザは複数の NEL エラーレポートをバッチ化して一つの POST リクエストとしてまとめて送信することが可能であり (私たちの観測では、そうするようになるとみています)、これによってリソースをより効率的に活用できるようになります。すなわち、各リクエストの POST ボディには1つまたは1つ以上のレポートが含まれ、JSON レポートオブジェクトの配列として送信されます。
エッジでレポートを解析して複雑な JSON オブジェクトを構築するのは容易ではないため、エンベロープとして機能する新しい JSON オブジェクトを構築し、メタデータをオブジェクトの個々のプロパティとして指定し、レポートの配列をオブジェクトの別のプロパティとして追加してからエンベロープオブジェクトを改行区切りの JSON 形式でストレージエンドポイント (この例ではGoogle Cloud Storage) にログするというアプローチを採用しました。
この時点では、各行にまだ多くのレポートが含まれる可能性があるため、生のログデータは必ずしも分析に使用できるとは限りません。データベースの各行にレポートが一つずつある状態が望ましいです。これを実現するため、Google の Cloud Functions を使用して共通の抽出・変換・読み込み (ETL) パイプラインをデプロイしてログを処理し、個々のレポートを解析し、メタデータとマージして各レポートが個別の行として BigQuery に挿入されるようにしました。以下の図は、開始から終了までの全体的な流れを示しています。
構築したパイプラインは機能的で、本番環境で役に立ちました。しかし、改善の余地があることも明白でした。エッジでレポートを解析し、レポートごとに個々の JSON オブジェクトを構築し (さらに Fastly が提供するメタデータをそれぞれに追加して)、結果をエッジから直接 BigQuery に送信することができれば、プロセスにおける移動回数が減り、将来的に拡張可能でより効率的な収集・ログ・分析パイプラインを作成することができます。
そして Compute ツールセットでは、まさにこのようなことが実現可能です。私たちが新しいプラットフォームに現在のパイプラインを移行させようと思ったのもこれが主な理由でした。
Compute で NEL レポートを収集
特定の言語に依存しない Compute は Rust など、使い慣れたプログラミング言語を使用してエッジでプログラミングすることができます。Rust は JSON を解析して強く型付けされたデータ構造に変換する機能や強力なパターンマッチ機能などをネイティブサポートしています。これらの機能を利用することで、レポートを抽出して変換し、BigQuery に送信する専用の収集エンドポイントを構築してデプロイすることが可能になり、前述のイテレーションで必要だったコンポーネントの多くが不要になりました。それでは、これを実現するために使用した重要なロジックをいくつか見てみましょう (GitHub でアプリケーション全体を閲覧できます)。
ルーティング
多くのサーバーレスプラットフォームと同様に、Compute のプログラムにはリクエストを受信してレスポンスを返す単一関数のエントリーポイントがあります。そこでまず、HTTP ルーティングのロジックを含む、プログラムのエントリーポイントの関数を定義する必要があります。幸い、Rust のパターンマッチシンタックスにより、受信リクエストなどの型の構造や値を簡単に照合することができるため、リクエストが POST の場合はこのように処理して、POST でない場合は別の処理をするというように指定することが可能になります。
#[fastly::main]
fn main(req: Request<Body>) -> Result<Response<Body>, Error> {
// Pattern match on the request method and path.
match (req.method(), req.uri().path()) {
// If a CORS preflight OPTIONS request, return a 204 no content.
(&Method::OPTIONS, "/report") => generate_no_content_response(),
// If a POST request pass to the `handler_reports` request handler.
(&Method::POST, "/report") => handle_reports(req),
// For all other requests return a 404 not found.
_ => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))?),
}
}
ここでは、リクエストのメソッドと URL パスを照合します。レポートパスへの OPTIONS リクエストの場合、即座に204レスポンスを返し、同じパスへの POST リクエストの場合、handle_reports
関数にリクエストがパスされ、それ以外の場合は 404 Not Found を返します。
JSON の解析
以前のパイプラインにおける Cloud Functions の主な役割は、JSON を解析して各レポートを抽出し、グローバルメタデータをトランスクルードした後、各レポートを個別の行としてデータベースに挿入することでした。Computeのメリットの一つは、serde_json のような優れた Rust のクレートなど、このような課題を安全かつ効率的に解決するために設計されたモジュールから成るリッチで成熟したエコシステムを活用できることです。
Serde を使用することで、リクエストボディのデータストリームを読み込み、JSON として解析し、事前に定義され、強く型付けされた Rust データ構造に変換することができます。NEL は W3C 仕様の一つであるため、POST のペイロードもすべてのユーザーエージェントが準拠する必要がある事前に定義された構造になっています。その結果、不正な形式または悪意のあるレポートがエンドポイントに送信されても仕様に準拠しないため、Serde によってブロックされるという二次的なメリットがあります。さらに、データを消費する前に BigQuery でデータを整理する後処理が不要になります。型の安全性が第一です!
/// `Report` models a Network Error Log report.
#[derive(Serialize, Deserialize, Clone)]
pub struct Report {
pub user_agent: String,
pub url: String,
#[serde(rename = "type")]
pub report_type: String,
pub body: ReportBody,
pub age: i64,
}
// Parse the NEL reports from the request JSON body using serde_json.
// If successful, bind the reports to the `reports` variable, transform and log.
if let Ok(reports) = serde_json::from_reader::<Body, Vec<Report>>(body) {
// Processing logic...
}
変換とログ
クライアントから送信され、構造化された NEL レポートのリストにアクセスできるようになったわけですが、最終的に各レポートを BigQuery エンドポイントにログする前に、地理的な位置情報を示す Geo IP メタデータを追加して各レポートをエンリッチ化することができます。ここで、柔軟なプログラミングをエッジで行える環境の真の威力が発揮されます。
まず、このメタデータを ClientData
構造体の形でモデル化し、IP アドレスとユーザーエージェントの文字列に対応できるコンストラクタメソッドを実装します。このコンストラクタメソッドでは以下が行われます。
まず、クライアントの完全な IP アドレスをデータベースに保存する必要はないため、プライバシーが保護されたプリフィックスに IP アドレスを切り詰めます。
次に、インポートした fastly::geo モジュールから geo_lookup 関数を呼び出します。これにより、国コードや自律システム名など、特定の IP アドレスに関連付けられた地理的データが返されます。
最後に、ユーザーエージェント文字列を解析し、ファミリー、メジャー、マイナー、パッチバージョンの文字列に正規化します (IP アドレスと同様に、すべての情報は必要ありません)。
use fastly::geo::{geo_lookup, Continent};
/// `ClientData` models information about a client.
///
/// Models information about a client which sent the NEL report request, such as
/// geo IP data and User Agent.
#[derive(Serialize, Deserialize, Clone)]
pub struct ClientData {
client_ip: String,
client_user_agent: String,
client_asn: u32,
client_asname: String,
client_city: String,
client_country_code: String,
client_continent_code: Continent,
client_latitude: f64,
client_longitude: f64,
}
impl ClientData {
/// Returns a `ClientData` using information from the downstream request.
pub fn new(client_ip: IpAddr, client_user_agent: &str) -> Result<ClientData, Error> {
// First, truncate the IP to a privacy safe prefix.
let truncated_ip = truncate_ip_to_prefix(client_ip)?;
// Lookup the geo IP data from the client IP. If no match return an
// error.
match geo_lookup(client_ip) {
Some(geo) => Ok(ClientData {
client_ip: truncated_ip,
client_user_agent: UserAgent::from_str(client_user_agent)?.to_string(), // Parse the User-Agent string to family, major, minor, patch.
client_asn: geo.as_number(),
client_asname: geo.as_name().to_string(),
client_city: geo.city().to_string(),
client_country_code: geo.country_code().to_string(),
client_latitude: geo.latitude(),
client_longitude: geo.longitude(),
client_continent_code: geo.continent(),
}),
None => Err(anyhow!("Unable to lookup geo IP data")),
}
}
}
これでメタデータが実装されたので、その新しいインスタンスを構築し、ログするレポートのリストを生成することができます。これを実行するため、レポートのボディとクライアントのメタデータを受信タイムスタンプとともに単一のオブジェクトとしてマージする LogLine
エンベロープを構築し、解析された各レポートをマッピングします。NEL では、すでにエッジに存在するメタデータを追加するだけで十分ですが、オリジンサービスから追加データを取得するなど、より高度なプロセスを実装することも可能です。
// Construct a new `ClientData` structure from the IP and User Agent.
let client_data = ClientData::new(client_ip, client_user_agent)?;
// Generate a list of reports to be logged by mapping over each raw NEL
// report, merging it with the `ClientData` from above and transform it
// to a `LogLine`.
let logs: Vec<LogLine> = reports
.into_iter()
.map(|report| LogLine::new(report, client_data.clone()))
.filter_map(Result::ok)
.collect();
最後に、ログのリストを反復処理し、Serde で各ログを JSON 文字列にシリアル化してその行を BigQuery ログエンドポイントに送信します。これにより、Rust の数行において ETL パイプラインが不要になります🎉
// Create a handle to the upstream logging endpoint that we want to emit
// the reports too.
let mut endpoint = Endpoint::from_name("reports");
// Loop over each log line serializing it back to JSON and write it to
// the logging endpoint.
for log in logs.iter() {
if let Ok(json) = serde_json::to_string(&log) {
// Log to BigQuery by writing the JSON string to our endpoint.
writeln!(endpoint, "{}", json)?;
}
}
以下は、このプログラムの処理結果のサンプルです。BigQuery のスキーマにマッチするように JSON オブジェクトがきれいに構造化されています。
{
"timestamp": 1597148043,
"client": {
"client_ip": "",
"client_user_agent": "Chrome 84.0.4147",
"client_asn": 5089,
"client_asname": "virgin media limited",
"client_city": "haringey",
"client_country_code": "GB",
"client_continent_code": "EU",
"client_latitude": 51.570,
"client_longitude": -0.120
},
"report": {
"url": "https://www.fastly-insights.com/",
"type": "network-error",
"body": {
"type": "abandoned",
"status_code": "0",
"server_ip": "",
"method": "GET",
"protocol": "http/1.1",
"sampling_fraction": "1",
"phase": "application",
"elapsed_time": "27"
},
"age": "34879"
}
}
これで、発生したネットワークエラーのタイプ、エラーが発生したネットワーク、レポートを生成したユーザーエージェントのタイプ、エラーが発生したおおよその地理的位置など、必要な情報をすべて取得できるようになりました。また、十分にリッチなデータが得られるため、リアルタイムのダッシュボードやアラートシステムを構築し、Fastly ネットワークへのアクセスのどこでどのような障害が発生しているのかを把握するのに役立てることが可能になります。
複雑さを減らし、スピードと安全性を強化
NEL のレポートパイプラインを Computeに移行することで、システムの2つの移動工程 (一時的なストレージバケットと Cloud Functions) を排除することができました。その結果、運用上のオーバーヘッドとシステムの全体的なコストの削減という大きなメリットが得られました。
しかし、一番のメリットは、パフォーマンスやセキュリティ、データの整合性の強化でした。
以前はレポートの受信後、数分待つ必要がありましたが、今では数秒以内に BigQuery にレポートが表示されるようになり、Fastly では標準のリアルタイムログを NEL でも実現できるようになりました。
Rust で強く型付けされたシステムを JSON 解析に使用することで、有効なレポートのみをログすることができます。これにより、他の要素に縛られずに単一のコードベースでスキーマの変更を簡単に実行できるようになり、後処理が不要になります。
結果として、新しいパイプラインのアーキテクチャは以下の図のように前よりもずっとシンプルになりました。
これは、Fastly 社内で Compute のメリットを活用し始めたほんの一例であり、今後、さらに多くのユースケースを皆さんにご紹介できるのを楽しみにしています。
実際にお試しください
まだピンとこない方や、アプリケーション全体のコードを参照したい方は、GitHub でご覧いただけます。また、Compute ベータ版をご利用のお客様は、NEL スターターキットを使用して CLI コマンド一つで新規プロジェクトを簡単に開始できます。
$ fastly compute init --from https://github.com/fastly/fastly-template-rust-nel.git
ベータ版をご利用でないお客様は、ぜひ今すぐご登録ください。お客様が Compute の真の可能性を引き出して、さまざまなユースケースを実現していただければ私たちも嬉しいです。皆さまのご体験をぜひお聞かせください。早速、このプラットフォームを使って優れたアプリケーションを構築してみてください。