「障害は起きている。ログも大量に出ている。なのに、なぜ遅いのか・どこで失敗しているのかが分からない」
「アラートが鳴りすぎて、誰も見なくなった。本当に重要な1件が、ノイズに埋もれる」
「console.log は仕込んだが、リクエストをまたいで追えない。CPU・メモリのメトリクスはあるが、ユーザー体験と結びつかない」
「『99.9%を目指す』とは言うが、それが今どれだけ守れているのか、誰も数字で答えられない」
本番運用でこれらに心当たりがあるなら、足りないのは「ログの量」ではなく 可観測性(Observability)の設計 です。私自身、経済産業大臣賞を受賞したB2Bサブスクリプション SaaSの決済プラットフォームで、冪等性・原子的トランザクション・ゼロダウンタイム移行を実装し 本番二重課金0件 を達成しました。その裏側を支えたのが、相関ID付き構造化ログ・分散トレース・SLOベースのアラートという可観測性レイヤーです。API Gateway → NLB → ALB → ECS という構成で221エンドポイントを運用した経験から、この記事を書いています。
この記事は AWS / OpenTelemetry の公式ドキュメント(2026年6月時点)に忠実 に、「どの場面で・どう使うか」を実コード付きで解説します。各セクション末尾に参照した公式URLを明記しているので、自分の環境で一次情報を確認しながら進められます。
この記事で扱う範囲(地図)
- 可観測性の3本柱(ログ/メトリクス/トレース)とOpenTelemetryの位置づけ
- 構造化ログ:JSON化・相関ID伝播・ログレベル・PII墨消し・CloudWatch Logs集約
- 分散トレース:OpenTelemetry計装 + ADOT CollectorをECS Fargateサイドカーで動かす
- メトリクス:RED / USE と EMF(Embedded Metric Format)
- SLO / SLI / エラーバジェット / バーンレート
- アラート設計:複合アラーム・症状ベース・アラート疲れの回避
- 回復性とコスト:サンプリング・保持期間・ダッシュボード
- テスト容易性:シンセティクス監視・計装のユニット検証
なぜ「ログは出ているのに原因が分からない」のか
典型的な失敗には、はっきりした構造があります。
| 症状 | 根本原因 | この記事での処方箋 |
|---|---|---|
| リクエストをまたいで追えない | 相関ID(trace_id / request_id)がログに無い | 3章:相関IDの生成と伝播 |
| 「遅い」は分かるが「どこが」分からない | 分散トレースが無く、サービス境界が不可視 | 4章:OpenTelemetry + ADOT |
| CPU/メモリは見えるがユーザー影響が見えない | メトリクスがリソース指標に偏り、RED欠如 | 5章:RED / USE と EMF |
| アラートが鳴りすぎて無視される | 原因ベースのしきい値アラート乱立 | 7章:SLOバーンレート・症状ベース |
| 「目標を守れているか」を数字で言えない | SLI/SLOが定義されていない | 6章:SLO / エラーバジェット |
可観測性とは「システムの外部出力(テレメトリ)から、内部状態を推論できる度合い」です。事前に想定していなかった質問(「なぜ、この特定ユーザーのこの決済だけ500msも遅いのか?」)に、再デプロイなしで答えられること——それがゴールです。
1. 可観測性の3本柱とOpenTelemetryの位置づけ
OpenTelemetryでは、テレメトリの種類を シグナル(signals) と呼び、主要なものに トレース(traces)/メトリクス(metrics)/ログ(logs) があります。よく「3本柱」と表現されるものです。
- ログ(Logs):個々の離散イベントの記録。「何が起きたか」。
- メトリクス(Metrics):時系列の集計値。「どれくらいの規模で起きているか」。
- トレース(Traces):1リクエストがシステムを通過する経路全体。「どこで起きたか」。
トレースは スパン(span) で構成され、スパンは作業単位を表します。各スパンは Span Context を持ち、その中に trace_id(1トレース内の全スパンを束ねる)と span_id(個々のスパンを一意に識別)が含まれます。子スパンは親の span_id を参照し、これにより異なるプロセス・サービス・データセンターをまたいだスパンが1本のトレースに組み上がります。この組み上げを可能にする中核概念が コンテキスト伝播(Context Propagation) です。
ここで重要なのが OpenTelemetry の ベンダー中立性 です。計装(instrumentation)はOpenTelemetryの標準APIで一度書けば、エクスポート先は OpenTelemetry Collector でも、任意のOSS/商用バックエンドでも選べます。アプリのコードを書き換えずに、X-Ray・CloudWatch・他社SaaSへ送り先を切り替えられる——これがロックイン回避の本質です。
2. 構造化ログ:相関IDで「点」を「線」にする
なぜJSON構造化か
console.log("user paid", amount) のようなプレーンテキストは、人間には読めても機械には検索・集計できません。CloudWatch Logs Insightsでフィールド単位にクエリし、メトリクスに変換し、トレースと相関させるには、ログは 構造化されたJSON であるべきです。
最小限の構造化ロガー(pino相当の薄いラッパー)の例です。重要なのは「ログレベル」「相関ID」「PII墨消し」の3点を最初から組み込むことです。
// logger.ts — 構造化ロガーの最小実装
import { AsyncLocalStorage } from "node:async_hooks";
type LogLevel = "debug" | "info" | "warn" | "error";
// レベルを数値化して、環境変数で出力しきい値を制御する
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
const MIN_LEVEL = LEVELS[(process.env.LOG_LEVEL as LogLevel) ?? "info"];
// リクエストスコープの相関コンテキストを保持する(後述のミドルウェアで設定)
export const requestContext = new AsyncLocalStorage<{
requestId: string;
traceId?: string;
}>();
// PIIをログに残さないための墨消し。鍵名ベースで再帰的にマスクする
const PII_KEYS = new Set(["email", "password", "cardNumber", "phone", "token"]);
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) =>
PII_KEYS.has(k) ? [k, "[REDACTED]"] : [k, redact(v)],
),
);
}
return value;
}
function emit(level: LogLevel, message: string, fields: Record<string, unknown> = {}): void {
if (LEVELS[level] < MIN_LEVEL) return;
const ctx = requestContext.getStore();
const line = {
level,
message,
timestamp: new Date().toISOString(),
// 相関ID:これがログを横断検索可能にする要
requestId: ctx?.requestId,
traceId: ctx?.traceId,
...redact(fields),
};
// CloudWatch Logs は標準出力の1行=1イベントとして取り込む
process.stdout.write(`${JSON.stringify(line)}\n`);
}
export const log = {
debug: (m: string, f?: Record<string, unknown>) => emit("debug", m, f),
info: (m: string, f?: Record<string, unknown>) => emit("info", m, f),
warn: (m: string, f?: Record<string, unknown>) => emit("warn", m, f),
error: (m: string, f?: Record<string, unknown>) => emit("error", m, f),
};
相関IDの生成と伝播
相関の起点は「リクエスト境界でIDを採番し、AsyncLocalStorage に載せる」ことです。OpenTelemetryで計装している場合は、その traceId をそのままログに焼き込むと、ログとトレースが双方向にジャンプできるようになります。
// middleware.ts — Express の相関IDミドルウェア
import { randomUUID } from "node:crypto";
import { trace } from "@opentelemetry/api";
import { requestContext } from "./logger";
import type { Request, Response, NextFunction } from "express";
export function correlationMiddleware(req: Request, res: Response, next: NextFunction): void {
// 上流(ALB/フロント)が付けた X-Request-Id を尊重し、無ければ採番する
const requestId = (req.header("x-request-id") ?? randomUUID()).slice(0, 64);
// 進行中のスパンから traceId を取得(OTel計装が前提)
const traceId = trace.getActiveSpan()?.spanContext().traceId;
res.setHeader("x-request-id", requestId);
// このリクエストの処理が終わるまで、ログは自動で相関IDを含む
requestContext.run({ requestId, traceId }, () => next());
}
ログレベル設計と集約
- debug:開発・障害調査時のみ。本番の既定では出さない(
LOG_LEVEL=info)。 - info:ビジネスイベント(決済成立、ジョブ完了など)。後でメトリクス化する候補。
- warn:自動回復した異常(リトライ成功、フォールバック発動)。
- error:ユーザー影響のある失敗。アラートやSLI計算の入力になる。
ECS Fargateでは、コンテナの標準出力を awslogs ログドライバ で CloudWatch Logs に集約するのが標準です(タスク定義の logConfiguration で設定)。アプリ側は「JSONを1行ずつ標準出力に書く」だけでよく、ファイルローテーションやエージェント常駐は不要になります。
出典: Send Amazon ECS logs to CloudWatch — Amazon ECS / Analyzing log data with CloudWatch Logs Insights
3. 分散トレース:OpenTelemetry計装 + ADOTサイドカー
計装:まず自動計装、足りなければ手動計装
OpenTelemetryのNode.js計装は、自動計装(auto-instrumentation) から始めるのが定石です。Express・HTTP・gRPC・各種DBドライバなどに対し、コードをほぼ書き換えずにスパンを生成できます。アプリ本体より 先に 読み込む必要があるため、--import(または --require)で起動時に注入します。
# 必要パッケージ(OpenTelemetry公式の手順に準拠)
npm install @opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-proto
// instrumentation.ts — アプリより前にロードされる
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
const sdk = new NodeSDK({
// 同一タスク内のADOTサイドカーへ OTLP/HTTP で送る(localhost:4318)
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`
: "http://localhost:4318/v1/traces",
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
ビジネス的に意味のある区間(「決済処理」「在庫引当」など)は 手動計装 で明示的なスパンに切り出すと、トレース上で原因区間が一目で分かります。
// payment.ts — 手動計装でビジネス区間を可視化する
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("payment-service");
export async function charge(orderId: string, amountJpy: number): Promise<void> {
// span 名は「動作.対象」の規約に寄せると検索しやすい
await tracer.startActiveSpan("payment.charge", async (span) => {
// 高カーディナリティのIDは属性に。メトリクスのディメンションには使わない
span.setAttribute("order.id", orderId);
span.setAttribute("payment.amount_jpy", amountJpy);
try {
await callPaymentProvider(orderId, amountJpy);
span.setStatus({ code: SpanStatusCode.OK });
} catch (err) {
// 例外をスパンに記録すると、トレース上でエラー区間が赤くなる
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: "charge failed" });
throw err;
} finally {
span.end();
}
});
}
ADOT CollectorをECS Fargateのサイドカーで動かす
ADOT(AWS Distro for OpenTelemetry)Collector を 同一タスク内のサイドカー として動かすと、アプリは localhost の Collector に OTLP で送るだけでよく、エクスポート先(X-Ray・CloudWatch・Prometheus等)の差し替えは Collector 設定だけで完結します。AWS公式のX-Ray連携タスク定義スニペットでは、イメージに public.ecr.aws/aws-observability/aws-otel-collector を使い、--config で設定ファイルを指定します。
# ecs_task.tf — アプリ + ADOTサイドカー の Fargate タスク定義
resource "aws_ecs_task_definition" "app" {
family = "payment-api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "1024"
memory = "3072"
task_role_arn = aws_iam_role.task.arn # アプリ + 計装の権限
execution_role_arn = aws_iam_role.execution.arn # イメージpull/ログ書込
container_definitions = jsonencode([
{
name = "payment-api"
image = "${aws_ecr_repository.app.repository_url}:latest"
essential = true
environment = [
# awsvpc では同一タスクのコンテナは localhost で通信できる
{ name = "OTEL_EXPORTER_OTLP_ENDPOINT", value = "http://localhost:4318" },
{ name = "OTEL_SERVICE_NAME", value = "payment-api" },
{ name = "LOG_LEVEL", value = "info" }
]
# Collector を先に起動してから、アプリを起動する
dependsOn = [{ containerName = "aws-otel-collector", condition = "START" }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/payment-api"
"awslogs-region" = var.region
"awslogs-stream-prefix" = "ecs"
"awslogs-create-group" = "true"
}
}
},
{
name = "aws-otel-collector"
image = "public.ecr.aws/aws-observability/aws-otel-collector:v0.30.0"
essential = true
# ECSが同梱する設定を使う例。X-Ray + EMF を有効化する設定に差し替え可能
command = ["--config=/etc/ecs/ecs-default-config.yaml"]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/ecs-aws-otel-sidecar-collector"
"awslogs-region" = var.region
"awslogs-stream-prefix" = "ecs"
"awslogs-create-group" = "true"
}
}
}
])
}
X-Ray へトレースを書き込むには、タスクロールに AWSXRayDaemonWriteAccess、CloudWatch メトリクス/ログを Collector から出すには CloudWatchAgentServerPolicy を付与します(最小権限が必要なら、これらを参考に必要アクションだけ抜き出します)。
# iam.tf — タスクロールに可観測性の最小権限を付与
resource "aws_iam_role_policy_attachment" "xray" {
role = aws_iam_role.task.name
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
}
resource "aws_iam_role_policy_attachment" "cw_agent" {
role = aws_iam_role.task.name
policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}
Collector の設定では、受信は OTLP receiver(gRPC 4317 / HTTP 4318)、送信は awsxray exporter(トレース)と awsemf exporter(メトリクスをEMFとしてCloudWatchへ)を組み合わせるのが定番です。
# otel-config.yaml — ADOT Collector の受信/処理/送信
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {} # スパン/メトリクスをまとめて送り、API呼び出しを削減する
exporters:
awsxray: {} # トレース → AWS X-Ray
awsemf: # メトリクス → CloudWatch(EMF経由)
namespace: PaymentApi
log_group_name: /metrics/payment-api
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [awsxray]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [awsemf]
出典: OpenTelemetry Node.js — getting started / Specifying the ADOT sidecar for AWS X-Ray integration — Amazon ECS
4. メトリクス:RED / USE と EMF
何を測るか:REDとUSE
メトリクスは闇雲に増やすとコストとノイズになります。2つの定番フレームワークで「測る対象」を絞ります。
| フレームワーク | 対象 | 指標 | 主な用途 |
|---|---|---|---|
| RED | リクエスト駆動のサービス | Rate(流量)/ Errors(エラー数)/ Duration(レイテンシ) | ユーザー影響・SLIの素 |
| USE | リソース(CPU/メモリ/接続プール等) | Utilization(使用率)/ Saturation(飽和度)/ Errors(エラー) | ボトルネック診断 |
原則は「SLI と症状アラートには RED、原因調査には USE」です。ユーザーは内部のCPU使用率ではなく、リクエストの成否と速さを体験するからです。
EMF:ログと同じ経路でカスタムメトリクスを出す
Embedded Metric Format(EMF) は、構造化ログイベントに _aws メタデータを埋め込むことで、CloudWatch Logs が自動的にメトリクス値を抽出する仕様です。アプリは「JSONログを1行出す」だけで、別途 PutMetricData API を叩かずにカスタムメトリクスを発行できます。ルートノードに _aws.CloudWatchMetrics(Namespace・Dimensions・Metrics)を含めるのが要件です。
// emf.ts — RED の Duration/Errors を EMF で発行する
type EmfUnit = "Milliseconds" | "Count";
function putEmf(
namespace: string,
metrics: { name: string; unit: EmfUnit; value: number }[],
dimensions: Record<string, string>,
): void {
const line: Record<string, unknown> = {
_aws: {
Timestamp: Date.now(),
CloudWatchMetrics: [
{
Namespace: namespace,
// 注意:高カーディナリティ(requestId等)をディメンションにしない。
// 組み合わせごとに課金対象メトリクスが生成される
Dimensions: [Object.keys(dimensions)],
Metrics: metrics.map((m) => ({ Name: m.name, Unit: m.unit })),
},
],
},
...dimensions,
};
for (const m of metrics) line[m.name] = m.value;
process.stdout.write(`${JSON.stringify(line)}\n`);
}
export function recordRequest(route: string, status: number, durationMs: number): void {
putEmf(
"PaymentApi",
[
{ name: "Duration", unit: "Milliseconds", value: durationMs },
{ name: "Errors", unit: "Count", value: status >= 500 ? 1 : 0 },
{ name: "Count", unit: "Count", value: 1 },
],
// ディメンションは低カーディナリティに保つ(route と環境程度)
{ Route: route, Environment: process.env.NODE_ENV ?? "production" },
);
}
公式仕様の最小例(リクエストレイテンシ)はこの形です。Dimensions の各組み合わせが1つのCloudWatchメトリクスになる点に注意してください。
{
"_aws": {
"Timestamp": 1574109732004,
"CloudWatchMetrics": [
{
"Namespace": "PaymentApi",
"Dimensions": [["Route", "Environment"]],
"Metrics": [{ "Name": "Duration", "Unit": "Milliseconds" }]
}
]
},
"Route": "/charge",
"Environment": "production",
"Duration": 135.5
}
出典: Specification: Embedded metric format — Amazon CloudWatch
5. SLO / SLI / エラーバジェット
定義を揃える
- SLI(Service Level Indicator):サービスの良し悪しを表す指標。多くは「良いイベント数 ÷ 全イベント数」の比率(例:5xxでないリクエストの割合、しきい値以下のレイテンシの割合)。
- SLO(Service Level Objective):SLIの目標値(例:「30日間で可用性99.9%」)。
- エラーバジェット(Error Budget):SLO期間内に許容される失敗量。99.9%なら、許容失敗は0.1%。この0.1%を「使い切れる予算」と捉える のが核心です。
- バーンレート(Burn Rate):エラーバジェットを消費する速度。
1なら期間ちょうどで使い切るペース、10なら10倍速で枯渇するペース。
SLIの選び方
SLIは ユーザー体験に直結し、かつ計測可能 なものを選びます。前章のREDメトリクス(Errors / Duration)がそのまま素材になります。
| SLIタイプ | 良いイベントの定義 | データ源 |
|---|---|---|
| 可用性 | HTTPステータスが5xxでないリクエスト | EMFの Errors / Count |
| レイテンシ | 応答が300ms以下のリクエスト | EMFの Duration |
| 鮮度 | 規定時間内に処理されたジョブ | バッチ完了メトリクス |
エラーバジェットで意思決定する
エラーバジェットは「いつ機能開発を止め、信頼性に投資するか」を 政治でなく数字で 決めるための仕組みです。予算が潤沢なら新機能を攻める。予算が枯渇に近いなら、リリースを凍結し回復性改善に振る。SREとプロダクトの合意形成を、感情論から解放します。
6. アラート設計:症状で鳴らし、原因で鳴らさない
アンチパターン:原因ベースのしきい値乱立
「CPU 80%超で通知」「特定APIの500が1件で通知」——こうした 原因ベース のアラートを積み上げると、ユーザー影響がないのに鳴り続け、やがて全員が無視します(アラート疲れ)。原則は 症状(symptom)で鳴らす:ユーザーが実際に困っている兆候(エラー率の上昇、レイテンシ悪化、SLO違反ペース)だけをページング(呼び出し)対象にします。
バーンレート・アラート(多窓・多バーンレート)
Google SRE Workbook が推奨する multiwindow, multi-burn-rate は、長短2つの時間窓を組み合わせ、「本当に進行中の問題」だけを高精度に検知します。代表的な推奨値は以下です。
| 重大度 | 長い窓 | 短い窓 | バーンレート | 期間中の予算消費 |
|---|---|---|---|---|
| Page(即時対応) | 1時間 | 5分 | 14.4x | 2% |
| Page(即時対応) | 6時間 | 30分 | 6x | 5% |
| Ticket(チケット) | 3日 | 6時間 | 1x | 10% |
短い窓は「問題がまだ続いているか」の確認(リセットの速さ)に、長い窓は「誤検知の抑制」に効きます。両方が同時に閾値を超えたときだけ鳴らすことで、精度と再現率を両立します。
CloudWatch 複合アラームで「両方の窓」を結合する
「長い窓のアラーム」と「短い窓のアラーム」を個別に作り、複合アラーム(composite alarm) の AlarmRule で AND 結合すれば、上記のバーンレート条件をそのまま実装できます。ルール式は ALARM(...) / OK(...) と AND / OR / NOT で記述します。
# alarms.tf — 1時間/5分の2窓を AND 結合する Page アラーム
resource "aws_cloudwatch_metric_alarm" "burn_long" {
alarm_name = "slo-burn-1h"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
threshold = 14.4 # 14.4倍速のバーンレート
# metric_query でエラー率 / (1 - SLO) を計算する想定(式は省略)
metric_name = "ErrorRate"
namespace = "PaymentApi"
period = 3600
statistic = "Average"
}
resource "aws_cloudwatch_metric_alarm" "burn_short" {
alarm_name = "slo-burn-5m"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
threshold = 14.4
metric_name = "ErrorRate"
namespace = "PaymentApi"
period = 300
statistic = "Average"
}
resource "aws_cloudwatch_composite_alarm" "burn_page" {
alarm_name = "slo-burn-page"
# 両方の窓が同時に発火したときだけ、オンコールを呼び出す
alarm_rule = join(" ", [
"ALARM(${aws_cloudwatch_metric_alarm.burn_long.alarm_name})",
"AND",
"ALARM(${aws_cloudwatch_metric_alarm.burn_short.alarm_name})",
])
alarm_actions = [aws_sns_topic.oncall.arn]
}
オンコール運用の最低ライン
- ページングは「今すぐ人が動かないとユーザーが困る」事象だけ。それ以外はチケット(Ticket)かダッシュボード確認に降格。
- 各アラームに ランブック(対応手順)へのリンク を持たせる(複合アラームの説明欄はMarkdown対応)。
- 鳴ったが何もしなかったアラートは定期的に棚卸しし、削除または閾値見直しを行う。
出典: Create a composite alarm — Amazon CloudWatch / Alerting on SLOs — Google SRE Workbook
7. 回復性とコスト:サンプリング・保持・ダッシュボード
可観測性は「全部を最高精度で記録」すると簡単に高コストになります。回復性の確保とコストのバランスを設計に織り込みます。
- トレースのサンプリング:全リクエストをトレースする必要はありません。低トラフィックなら100%、高トラフィックなら確率サンプリング(例:10%)に、ただし エラーは取りこぼさない ように尾部サンプリング(tail sampling)でエラー時は確実に保存する、といった方針を Collector 側で構成します。
- ログ保持期間:CloudWatch Logs のロググループに保持期間を設定し、ホットな調査用は短め(例:30日)、監査用は長期ストレージ(S3エクスポート)へ。
infinite保持は事故の元です。 - EMFのカーディナリティ管理:4章で触れた通り、
requestIdのような高カーディナリティをディメンションにすると、組み合わせごとに課金メトリクスが量産されます。ディメンションは低カーディナリティに保ちます。 - 回復性パターンとの連携:リトライ(指数バックオフ)・サーキットブレーカ・タイムアウトは、それぞれ専用のメトリクス(リトライ回数、ブレーカ開閉、タイムアウト件数)を出すことで「効いているか」を可観測にします。回路が開いた瞬間をトレースの属性に残すと、障害の連鎖が一目で追えます。
- ダッシュボード設計:1画面に「RED(ユーザー影響)→ SLOバーンレート → USE(原因候補)」の順で並べると、上から下へ「困っているか→どれだけ→なぜ」を追える調査動線になります。
# logs.tf — ロググループに保持期間を必ず設定する
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/payment-api"
retention_in_days = 30 # 無期限保持はコスト事故。用途に応じて明示する
}
出典: Change log data retention in CloudWatch Logs / Sampling — OpenTelemetry
8. テスト容易性:可観測性そのものをテストする
可観測性は「いざ障害」のときに初めて使われるため、普段から動作を検証 していないと、必要な瞬間に欠落が露呈します。
- シンセティクス(合成)監視:CloudWatch Synthetics の Canary で、本番URLへ定期的に擬似リクエストを送り、外形(ユーザー視点)の可用性とレイテンシをSLIとして計測します。実ユーザーが少ない時間帯でも継続的にSLIを生成でき、デプロイ直後のリグレッション検知にも有効です。
- 計装のユニット検証:OpenTelemetryには InMemorySpanExporter があり、テスト内で生成されたスパンを配列として取得・検証できます。「決済成功時に
payment.chargeスパンが1本生成され、status=OKであること」をユニットテストで固定できます。
// payment.test.ts — 生成スパンを検証する(vitest想定)
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import {
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
const exporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();
afterEach(() => exporter.reset());
test("charge emits a single OK span", async () => {
await charge("order-1", 1200);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe("payment.charge");
expect(spans[0].attributes["payment.amount_jpy"]).toBe(1200);
});
出典: Using synthetic monitoring — Amazon CloudWatch / Testing — OpenTelemetry JS
FAQ
Q1. ADOT Collector は「サイドカー」と「単独サービス(central collector)」のどちらで動かすべき?
小〜中規模で、まずシンプルに始めるならサイドカーが楽です(アプリと同一タスクなので localhost 送信・ネットワーク設計が単純)。大規模で、サンプリングや集約を一元管理したい・Collector台数を最適化したい場合は中央集約型(ゲートウェイ構成)を検討します。両者は段階的に移行できます。
Q2. X-Ray と OTLP、どちらにエクスポートすべき? AWSネイティブで完結し、ServiceMap等のAWS機能を活かすなら X-Ray exporter。将来他社バックエンドやOSS(Jaeger/Grafana Tempo等)への移行余地を残すなら OTLP で出して Collector 側で振り分けます。OpenTelemetryで計装してあれば、この判断はアプリのコードを変えずに Collector 設定で切り替えられます。
Q3. ログとメトリクスを別々に出すのは二度手間では?
EMFを使えば「JSONログを1行出す」だけで、CloudWatch Logs がメトリクスを自動抽出します。PutMetricData の追加API呼び出しが不要になり、ログとメトリクスが同一イベントから生成されるため整合性も取れます。RED指標はEMFで出すのが効率的です。
Q4. SLOの数字(99.9%等)はどう決める? 「技術的に出せる最高値」ではなく「ユーザーが満足する最低ライン」を起点にします。過剰なSLO(99.999%等)は、達成コストが指数的に上がる割にユーザーには知覚されないことが多いです。まず現状のSLIを計測し、現実的な目標から始めて、エラーバジェットの消費実績を見ながら調整します。
Q5. アラートが多すぎる既存環境を、どこから直せばよい? まず「過去90日で鳴ったが、誰も対応しなかったアラート」を棚卸しして削除/降格します。次に、残った重要アラートを 症状ベース(SLOバーンレート) に置き換えます。原因ベースのアラートは消すのではなく「ページング→ダッシュボード/チケット」に降格させると、調査時の手がかりは残せます。
まとめ:可観測性は「設計」であって「ツール導入」ではない
- 可観測性の3本柱(ログ/メトリクス/トレース)を、相関ID で1本に束ねる。
- 計装は OpenTelemetry(ベンダー中立) で書き、ECS Fargateでは ADOT Collectorサイドカー に集約。送り先(X-Ray/CloudWatch/OSS)はアプリを変えずに切り替える。
- メトリクスは RED / USE で対象を絞り、EMF でログと同一経路から発行する。
- アラートは SLO・エラーバジェット・バーンレート に基づき、症状で鳴らし原因で鳴らさない。複合アラームで多窓を結合する。
- サンプリング・保持・カーディナリティで コストを設計 し、シンセティクスと計装ユニットテストで 可観測性自体を検証 する。
私は経済産業大臣賞を受賞したB2B SaaSの決済プラットフォームで、こうした可観測性・回復性・IAMを横断実装し、冪等性・原子的トランザクション・ゼロダウンタイム移行により 本番二重課金0件 を達成しました(API Gateway → NLB → ALB → ECS、221エンドポイント運用)。そして今は 「一人 × 生成AI(Claude Code)」 で、こうした堅牢な運用基盤を 速く・安全に 構築します。AIはアクセラレータであり、設計判断と本番投入の前には必ず人間の検証ゲートを通します。
「ログは出ているのに原因が分からない」「アラートが鳴りすぎて機能していない」——そんな本番運用の可観測性・SRE課題があれば、お気軽にご相談ください。
参考(AWS / OpenTelemetry 公式)
- Traces — OpenTelemetry
- OpenTelemetry Node.js — getting started
- Sampling — OpenTelemetry
- Specifying the ADOT sidecar for AWS X-Ray integration — Amazon ECS
- Send Amazon ECS logs to CloudWatch — Amazon ECS
- Specification: Embedded metric format — Amazon CloudWatch
- Create a composite alarm — Amazon CloudWatch
- CloudWatch Logs Insights — Amazon CloudWatch
- Using synthetic monitoring — Amazon CloudWatch
- Alerting on SLOs — Google SRE Workbook