多段 API Gateway 構成における分散トレーシングの課題を解決し、トレーサビリティを向上させるログ戦略
2023/02/01
目次
多段 API Gateway 構成
AWS において、マイクロサービスアーキテクチャパターンを採用する多くの場合、Amazon API Gateway を複数使用する場合があります。
例えば、EC サイトにおけるバックエンドを下図のように構成するケースを考えます。下図は、バックエンドで稼働する複数のサービス(Catalog サービスと Cart サービス)を BFF がラップし、集約します。 このようなケースでは、クライアントから送信されたリクエストを各マイクロサービスにおいてトレースが困難になります。
本記事では Amazon API Gateway を使用した構成に対して、トレーサビリティを向上するログ戦略を立て、実践します。以下、このように API Gateway を複数ラップする構成を 多段 API Gateway として表現します。
※ Load Balancer は簡単化のために省略しています。
分散トレーシングにおける課題と解決策
マイクロサービスアーキテクチャパターンを採用する多くの場合、リクエストは複数のサービスにまたがります。 各サービスは、DB へのクエリやメッセージの発行など、1 つ以上の操作を実行することでリクエストを処理します。 1つのクライアントから送信されたリクエストに紐づくことをトレースするために、各サービスに対して発行されたリクエストを1意に特定するための ID が必要となります。
これは、一般的に 分散トレーシング における解決策として知られており、microservice.ioでは以下のように解決策が記載されています。
Solution
- Assigns each external request a unique external request id
- Passes the external request id to all services that are involved in handling the request
- Includes the external request id in all log messages
- Records information (e.g. start time, end time) about the requests and operations performed when handling a external request in a centralized service
それでは、各項目について API Gateway とそのバックエンドで稼働するアプリケーションの実装例を見ていきましょう。
① API Gateway にて一意な リクエスト ID を生成する
API Gateway にはリクエストに対して一意のリクエスト ID を割り当てる機能があります。
$context.requestId は、x-amzn-RequestId ヘッダーの値をログに記録します。クライアントは、x-amzn-RequestId ヘッダーの値を上書きできます。API Gateway は、x-amzn-RequestId レスポンスヘッダー内のこのリクエスト ID を返します。$context.extendedRequestId は、API Gateway が生成する一意の ID です。API Gateway は、x-amz-apigw-id レスポンスヘッダー内のこのリクエスト ID を返します。
以下図のように、API Gateway はクライアントからリクエストされたログを2種類の方法で記録します。
パラメータ | 説明 |
---|---|
$context.requestId | リクエストヘッダーの x-amazn-RequestId の値を記録します。リクエストヘッダーに x-amazn-RequestId が含まれない場合、一意な ID を生成します。 |
$context.extendedRequestId | API Gateway が生成する一意の ID を記録します。この値は上書きすることはできません。 なお、この ID は $context.requestId とは異なります。 |
この2種類のリクエスト ID を使い分けることで、バックエンドで稼働するマイクロサービスに対して ID を一意に割り振ることができます。
基本的には外部リクエスト ID として BFF の API Gateway が発行した $context.requestId
を全てのサービスへのリクエストで渡す戦略を考えます。本章で解説するシステムコンポーネントの責務とその概要を図示しています。
API Gateway の機能において生成される リクエスト ID をログに記録します。$context.requestId, $context.extendedRequestId 自体は API Gatway の $context 変数でしかありません。まずはこの変数をロググループに書き込みます。
概して CloudWatch Logs のロググループは AWS の各種リソースに対して1体1となるように作ることが推奨されています。これは Infrastructure as Code 管理する前提で、各種リソースのデプロイ容易性を高めることや、検索性を柔軟にすること、また PutEvents API のクォータ に配慮することが目的です。
ただし、ログに出力する項目の プロパティ(キー)や、ログフォーマット (CLF や JSON, XML, CSV など)は、アプリケーション全体で統一しておくとメリットがあります。 例えば API Gateway 、ECS が以下のように異なるプロパティ(キー)・ログフォーマットで出力した場合を考えましょう。
こうしてしまった場合、アプリケーション全体で横断的に一意なリクエスト ID で検索したい場合に requestId
と request-id
を区別しなければならず、
ユーザからのリクエストを一意に特定することが困難になります。
~ API Gateway ログ (JSON) ~
{
"requestId": "$context.requestId",
"extendedRequestId": "$context.extendedRequestId",
"ip": "$context.identity.sourceIp",
"caller": "$context.identity.caller",
"user": "$context.identity.user",
"requestTime": "$context.requestTime",
"httpMethod": "$context.httpMethod",
"resourcePath": "$context.resourcePath",
"status": "$context.status",
"protocol": "$context.protocol",
"responseLength": "$context.responseLength"
}
~ ECS アプリケーションログ (CSV) ~
request-id, extended-request-id, ip, caller, user, request-time, http-method, resource-path, status, protocol, response-length
ElasticSearch や DataDog のような分散アプリケーションのログトレースを容易にするサービスを使っていたとしても、フォーマットが異なる場合、ログのパース処理が必要となってしまいます。 分散アプリケーション全体では、一意なリクエスト ID はプロパティ(キー)を揃え、同じログフォーマットで出力する と良いでしょう。
② API Gateway の統合リクエストパラメータに リクエスト ID を渡す
Amazon API Gateway API リクエストおよびレスポンスデータマッピングリファレンス によると、APIGateway から後続のバックエンドサービス(今回は ECS)にリクエストする際にパラメータをマッピングできます。
今回の要件では API Gateway が生成した context.requestId を ECS に渡したいので、ヘッダーに付与してみましょう。
integration.request.header.apiGateway-requestId: context.requestId
integration.request.header.apiGateway-extendedRequestId: context.extendedRequestId
これによって、ECS に渡される HTTP リクエストのヘッダーにリクエスト ID を含めることができました。
③ ECS アプリケーションにて リクエスト ID をログに出力する
ここでは Node.js における実装例を紹介しますが、どの Web アプリケーションでも考え方は同じです。 リクエスト単位にとりまわせるコンテキスト変数を保持し、処理の随所に出力するログにリクエスト ID を記録します。 Java では ThreadLoacal を使用することが多いですね。
express を使用した Web アプリケーションを TypeScript で作成します。Logger には pino を使用します。
$ yarn add express pino express-pino-logger uuid express
$ yarn add -D @types/expres @types/express-pino-logger @types/node @types/uuid ts-node typescript
pino には ログ出力する内容を指定するオプションとして serializers
、リクエスト ID を指定するオプションとして genReqId
があります。
以下のように実装しましょう。これで HTTP ヘッダーの値を読み込んでログに出力する リクエスト ID を一意に指定できます。また、serializers
によって リクエスト ID のキーを requestId
に変更しています。
logger.ts
import pino from "pino";
import ExpressPinoLogger from "express-pino-logger";
import { IncomingMessage } from "http";
import { v4 as uuidv4 } from "uuid";
export const logger = pino({
level: "info",
});
export const loggingMiddleware = ExpressPinoLogger({
level: "info",
serializers: {
req: (req) => ({
...req,
requestId: req.id, // serializers によってキーを指定して出力する
}),
},
genReqId: (req: IncomingMessage) => {
return req.headers["apigateway-request-id"] || uuidv4(); // API Gateway の統合リクエストでマッピングされた値を取得する
},
});
express には以下のように組み込みます。
index.ts
import express from "express";
import { logger, loggingMiddleware } from "./logger";
const app = express();
app.use(loggingMiddleware);
// respond with "hello world" when a GET request is made to the homepage
app.get("/", (req, res) => {
res.send("hello world");
});
const PORT = 8080;
app.listen(PORT, () => {
logger.info(`Server now listening at http://localhost:${PORT}`);
});
出力されるログは以下のようになります。
{
"level": 30,
"time": 1653218009516,
"pid": 21222,
"hostname": "my-hostname",
"req": {
"id": "e1d29258-d66e-4c78-97a0-72a91a7e983a",
"method": "GET",
"url": "/",
"query": {},
"params": {},
"headers": {
"apigateway-request-id": "e1d29258-d66e-4c78-97a0-72a91a7e983a" // API Gateway の統合リクエストでマッピングされた値
},
"remoteAddress": "::1",
"remotePort": 55849,
"requestId": "e1d29258-d66e-4c78-97a0-72a91a7e983a" // serializers によってキーを指定して出力
},
"res": {
"statusCode": 200,
"headers": {
"x-powered-by": "Express",
"content-type": "text/html; charset=utf-8",
"content-length": "11",
"etag": "W/\"b-Kq5sNclPz7QV2+lfQIuc6R7oRu0\""
}
},
"responseTime": 1,
"msg": "request completed"
}
これで、アプリケーションが出力するログに API Gatway から発行されたリクエスト ID を記録することができました。
④ 外部リクエストのヘッダーに リクエスト ID を渡す
コンテキスト変数に保持しているリクエスト ID を使用して、外部への API リクエストの HTTP ヘッダーにリクエスト ID を付与します。 まずは express において、リクエストヘッダーから取得した requestId をリクエスト単位に取り回す方法を検討しましょう。 本記事では、 express-http-context も使用していきます。 このライブラリを使用することで、リクエストスコープのコンテキストをどこからでも取得・設定できます。また、API コール用に axios もインストールしておきます。
$ yarn add axios express-http-context
index.ts
import express from "express";
import { logger, loggingMiddleware } from "./logger";
import context from "express-http-context";
import { v4 as uuidv4 } from "uuid";
import axios from "axios";
const app = express();
app.use(loggingMiddleware);
app.use(context.middleware);
// APIGateway の統合リクエストパラメータにセットされたリクエスト ID を取得する
const setRequestId = (req: express.Request, res: express.Response, next: express.NextFunction) => {
context.set("requestId", req.headers["apigateway-request-id"] || uuidv4());
next();
};
// respond with "hello world" when a GET request is made to the homepage
app.get("/", setRequestId, async (req, res) => {
// Call api with requestId header
await axios.get("https://your.backend.apigateway.domain", {
headers: {
"x-amazn-RequestId": context.get("requestId"), // Backend で稼働する APIGateway の $context.requestId を上書きする
},
});
res.send("hello world");
});
const PORT = 8080;
app.listen(PORT, () => {
logger.info(`Server now listening at http://localhost:${PORT}`);
});
これで、後続の APIGateway にもリクエスト ID を伝搬することができました。
まとめ
分散トレーシング、特に Amazon API Gateway を多段に組む構成における実装例を紹介しました。 従来のモノリシックなアーキテクチャでは1つのコンテキストに一意なリクエスト ID を保持するだけで良いですが、コンポーネントが分散している場合、その各所においてリクエスト ID の一意性を保証しなければなりません。 このプラクティスはシンプルでありながら、非常に実践的であり、プロダクション運用には不可欠なものです。ログ設計の際には取りこぼさず、考慮しておきたいものですね。