深夜二時のログには、何も書かれていなかった
個人開発で運用している API が落ちたのは、日本時間の深夜二時でした。
ログを開いても、そこにあるのは 500 が返った事実だけです。どのリクエストが、どの依存先で、どれだけ待たされて落ちたのか — 手がかりが何ひとつ残っていませんでした。原因にたどり着くまでに三時間かかり、結局は外部 API のタイムアウト値が長すぎたという、拍子抜けするほど単純な話でした。
あの三時間は、監視の不在が請求してきた利息のようなものだったと思っています。
オブザーバビリティ(可観測性)とは、障害が起きたあとに「何が起きたか」を尋ねられる状態のことです。ログを出すこととは違います。トレースでリクエストの通り道を、メトリクスでシステムの体温を、ログで細部の事情を、同じ時間軸の上に並べて初めて質問が成立します。
OpenTelemetry はそのための共通語です。ただし設定量が多く、計装コードの挿入・エクスポーター設定・バックエンド連携を手で積み上げると、それだけで数日が溶けます。Antigravity のエージェントは、この「積み上げ」の部分を引き受けてくれます。
以下は、Node.js / TypeScript のバックエンドに OpenTelemetry を入れ、本番で実際に運用してみて得た構成と、途中で壊れた箇所の記録です。計装の入れ方だけでなく、入れたあとに何が起きるかまで含めて書きます。
対象読者: Node.js / TypeScript でのバックエンド開発経験があり、本番運用の監視体制を強化したいエンジニア
OpenTelemetry の三本柱と Antigravity の役割
OpenTelemetry(OTel)は、トレース、メトリクス、ログ の三つのシグナルを統一的に扱う CNCF のオープンスタンダードです。Antigravity のエージェントは、この三本柱それぞれの計装を自動化する強力なパートナーになります。
トレース(Distributed Tracing)
リクエストがマイクロサービス間をどう流れるかを可視化します。各サービスの処理時間やボトルネックを特定するのに不可欠です。
メトリクス(Metrics)
CPU 使用率、メモリ消費量、リクエストレート、エラーレートなどの数値データを時系列で収集します。アラート閾値の設定やキャパシティプランニングの基盤です。
ログ(Logs)
アプリケーションの詳細な動作記録です。トレース ID と紐づけることで、特定のリクエストに関連するログだけを抽出できます。
Antigravity のエージェントは、コードベースを解析して最適な計装ポイントを提案し、OpenTelemetry SDK の設定コードを自動生成します。これにより、手動では見落としがちなエンドポイントやデータベースクエリの計装も漏れなくカバーできます。
環境構築: OpenTelemetry SDK のセットアップ
まず、Antigravity のエージェントに既存プロジェクトの構造を理解させ、OpenTelemetry の依存パッケージをインストールします。
# Antigravity のターミナルで実行
# エージェントにプロジェクト構造を解析させる
@agents analyze the project structure and identify all HTTP endpoints,
database queries, and external API calls that need instrumentation
# OpenTelemetry パッケージのインストール
npm install @opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
次に、Antigravity エージェントに OTel SDK の初期化コードを生成させます。
// src/instrumentation.ts
// Antigravity エージェントが生成する OTel 初期化コード
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const resource = new Resource({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'my-api-service',
[ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0',
'deployment.environment': process.env.NODE_ENV || 'development',
});
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',
});
const metricExporter = new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics',
});
const sdk = new NodeSDK({
resource,
traceExporter,
metricReader: new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 30000, // 30秒ごとにメトリクスをエクスポート
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingPaths: ['/health', '/ready'], // ヘルスチェックは除外
},
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true },
'@opentelemetry/instrumentation-redis': { enabled: true },
}),
],
});
sdk.start();
// グレースフルシャットダウン
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('OTel SDK shut down successfully'))
.catch((err) => console.error('Error shutting down OTel SDK', err))
.finally(() => process.exit(0));
});
export default sdk;
このファイルをアプリケーションのエントリポイントより前に読み込むことで、自動計装が有効になります。
// package.json の scripts を更新
{
"scripts": {
"start": "node --require ./src/instrumentation.ts src/index.ts",
"dev": "tsx --require ./src/instrumentation.ts src/index.ts"
}
}
カスタムスパンとメトリクスの実装
自動計装だけではカバーできないビジネスロジック固有の計測には、カスタムスパンとカスタムメトリクスを追加します。Antigravity エージェントに「このサービスのビジネスクリティカルな処理にカスタム計装を追加して」と指示すると、適切なポイントにコードを挿入してくれます。
// src/services/payment.service.ts
// 決済処理にカスタムスパンとメトリクスを追加
import { trace, metrics, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('payment-service');
const meter = metrics.getMeter('payment-service');
// カスタムメトリクス定義
const paymentCounter = meter.createCounter('payments.processed', {
description: '処理された決済の総数',
unit: '1',
});
const paymentDuration = meter.createHistogram('payments.duration', {
description: '決済処理にかかった時間',
unit: 'ms',
});
const activePayments = meter.createUpDownCounter('payments.active', {
description: '現在処理中の決済数',
});
export async function processPayment(
userId: string,
amount: number,
currency: string
): Promise<PaymentResult> {
// カスタムスパンを開始
return tracer.startActiveSpan('payment.process', async (span) => {
const startTime = Date.now();
activePayments.add(1);
try {
// スパンに属性を追加(トレースで検索可能になる)
span.setAttributes({
'payment.user_id': userId,
'payment.amount': amount,
'payment.currency': currency,
});
// Stripe API 呼び出し(自動計装で HTTP スパンが追加される)
const result = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency,
metadata: { userId },
});
// 成功メトリクスを記録
paymentCounter.add(1, {
status: 'success',
currency,
});
span.setStatus({ code: SpanStatusCode.OK });
return { success: true, intentId: result.id };
} catch (error) {
// エラーメトリクスを記録
paymentCounter.add(1, {
status: 'error',
currency,
error_type: error instanceof Error ? error.name : 'unknown',
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : 'Payment failed',
});
span.recordException(error as Error);
throw error;
} finally {
activePayments.add(-1);
paymentDuration.record(Date.now() - startTime, { currency });
span.end();
}
});
}
ログとトレースの相関付け
オブザーバビリティの真価は、トレース・メトリクス・ログが相互に紐づいているときに発揮されます。Antigravity エージェントを使えば、既存のロガー設定にトレースコンテキストを自動注入できます。
// src/lib/logger.ts
// pino ロガーにトレースコンテキストを注入
import pino from 'pino';
import { trace, context } from '@opentelemetry/api';
function getTraceContext() {
const activeSpan = trace.getSpan(context.active());
if (!activeSpan) return {};
const spanContext = activeSpan.spanContext();
return {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
trace_flags: spanContext.traceFlags,
};
}
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
mixin() {
return getTraceContext();
},
formatters: {
level(label) {
return { severity: label.toUpperCase() };
},
},
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined,
});
// 使用例
// logger.info({ userId, action: 'login' }, 'User logged in');
// → 出力に trace_id と span_id が自動付与される
この設定により、エラーログからトレース ID を取得し、そのトレースに含まれる全スパンを Grafana Tempo や Jaeger で一覧表示できます。「このエラーが発生したとき、上流のどのサービスが遅延していたか」を瞬時に特定できるわけです。
バックエンド構成: Grafana Stack との連携
収集したテレメトリデータを可視化するバックエンドとして、Grafana Stack(Grafana + Tempo + Prometheus + Loki)との連携を構築します。Antigravity エージェントに Docker Compose 設定を生成させましょう。
# docker-compose.otel.yml
# Antigravity エージェントが生成する OTel コレクター + Grafana Stack
version: '3.8'
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config', '/etc/otel/config.yaml']
volumes:
- ./otel-config.yaml:/etc/otel/config.yaml
ports:
- '4318:4318' # OTLP HTTP receiver
- '4317:4317' # OTLP gRPC receiver
- '8889:8889' # Prometheus exporter
depends_on:
- tempo
- loki
tempo:
image: grafana/tempo:latest
command: ['-config.file=/etc/tempo/config.yaml']
volumes:
- ./tempo-config.yaml:/etc/tempo/config.yaml
- tempo-data:/var/tempo
ports:
- '3200:3200' # Tempo API
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- '9090:9090'
loki:
image: grafana/loki:latest
ports:
- '3100:3100'
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
ports:
- '3001:3000'
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
depends_on:
- prometheus
- tempo
- loki
volumes:
tempo-data:
# otel-config.yaml
# OpenTelemetry Collector の設定
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# 属性にサービス情報を追加
attributes:
actions:
- key: environment
value: production
action: upsert
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [loki]
AI による異常検知と自動修復
ここからが本記事の核心です。Antigravity のエージェントを活用して、収集されたテレメトリデータから異常を検知し、自動修復スクリプトを実行するパイプラインを構築します。
異常検知エージェントの設計
// src/observability/anomaly-detector.ts
// メトリクスの異常を検知するモジュール
interface MetricDataPoint {
timestamp: number;
value: number;
}
interface AnomalyAlert {
metric: string;
severity: 'warning' | 'critical';
currentValue: number;
threshold: number;
message: string;
suggestedAction: string;
}
export class AnomalyDetector {
private readonly windowSize = 60; // 過去60データポイント
private readonly stdDevMultiplier = 2.5;
/**
* Z-score ベースの異常検知
* 過去データの平均・標準偏差から逸脱したポイントを検出する
*/
detectAnomaly(
metricName: string,
dataPoints: MetricDataPoint[]
): AnomalyAlert | null {
if (dataPoints.length < this.windowSize) return null;
const window = dataPoints.slice(-this.windowSize);
const values = window.map((dp) => dp.value);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const stdDev = Math.sqrt(
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length
);
const latest = dataPoints[dataPoints.length - 1].value;
const zScore = stdDev === 0 ? 0 : Math.abs(latest - mean) / stdDev;
if (zScore > this.stdDevMultiplier) {
const severity = zScore > 4 ? 'critical' : 'warning';
return {
metric: metricName,
severity,
currentValue: latest,
threshold: mean + stdDev * this.stdDevMultiplier,
message: `${metricName} が異常値を検出: ${latest.toFixed(2)} (平均: ${mean.toFixed(2)}, Z-score: ${zScore.toFixed(2)})`,
suggestedAction: this.getSuggestedAction(metricName, severity),
};
}
return null;
}
private getSuggestedAction(
metric: string,
severity: string
): string {
const actions: Record<string, string> = {
'http.server.duration': 'スロークエリの確認 → コネクションプールの拡張 → キャッシュレイヤーの追加',
'http.server.request.error_rate': 'エラーログの確認 → 直近のデプロイをロールバック → 依存サービスの状態確認',
'process.runtime.nodejs.memory.heap_used': 'メモリリークの調査 → ヒープスナップショット取得 → Pod の再起動',
'payments.duration': 'Stripe API のステータス確認 → リトライ設定の見直し → フォールバック処理の有効化',
};
return actions[metric] || '詳細調査が必要です。トレースとログを確認してください。';
}
}
自動修復パイプラインの実装
// src/observability/auto-remediation.ts
// 異常検知をトリガーに自動修復アクションを実行
import { logger } from '../lib/logger';
import { AnomalyAlert } from './anomaly-detector';
interface RemediationAction {
name: string;
condition: (alert: AnomalyAlert) => boolean;
execute: (alert: AnomalyAlert) => Promise<RemediationResult>;
}
interface RemediationResult {
success: boolean;
action: string;
details: string;
}
export class AutoRemediation {
private actions: RemediationAction[] = [];
private executionLog: RemediationResult[] = [];
registerAction(action: RemediationAction): void {
this.actions.push(action);
logger.info({ actionName: action.name }, 'Remediation action registered');
}
async handleAlert(alert: AnomalyAlert): Promise<RemediationResult[]> {
const results: RemediationResult[] = [];
for (const action of this.actions) {
if (action.condition(alert)) {
logger.info(
{ alert: alert.metric, action: action.name },
'Executing remediation action'
);
try {
const result = await action.execute(alert);
results.push(result);
this.executionLog.push(result);
if (result.success) {
logger.info(
{ action: action.name, details: result.details },
'Remediation succeeded'
);
break; // 成功したら後続アクションはスキップ
}
} catch (error) {
const failResult: RemediationResult = {
success: false,
action: action.name,
details: error instanceof Error ? error.message : 'Unknown error',
};
results.push(failResult);
logger.error(
{ action: action.name, error },
'Remediation action failed'
);
}
}
}
return results;
}
}
// 使用例: 自動修復アクションの登録
const remediation = new AutoRemediation();
// メモリ使用量が閾値を超えた場合、キャッシュをクリア
remediation.registerAction({
name: 'clear-application-cache',
condition: (alert) =>
alert.metric.includes('memory') && alert.severity === 'warning',
execute: async (alert) => {
// アプリケーションキャッシュのクリアロジック
globalCache.clear();
return {
success: true,
action: 'clear-application-cache',
details: `Cache cleared. Memory was at ${alert.currentValue}MB`,
};
},
});
// エラーレートが critical の場合、サーキットブレーカーを発動
remediation.registerAction({
name: 'activate-circuit-breaker',
condition: (alert) =>
alert.metric.includes('error_rate') && alert.severity === 'critical',
execute: async (alert) => {
circuitBreaker.open();
return {
success: true,
action: 'activate-circuit-breaker',
details: `Circuit breaker activated. Error rate: ${alert.currentValue}%`,
};
},
});
Grafana ダッシュボードの自動生成
Antigravity エージェントに「収集しているメトリクスに基づいて Grafana ダッシュボードの JSON を生成して」と指示すると、カスタムメトリクスを含む包括的なダッシュボードが生成されます。以下はそのエッセンスです。
// scripts/generate-grafana-dashboard.ts
// Antigravity エージェントが生成するダッシュボード設定スクリプト
interface GrafanaPanel {
title: string;
type: 'timeseries' | 'stat' | 'gauge' | 'table';
targets: { expr: string; legendFormat: string }[];
gridPos: { h: number; w: number; x: number; y: number };
}
function generateDashboard(serviceName: string): object {
const panels: GrafanaPanel[] = [
{
title: 'Request Rate (per second)',
type: 'timeseries',
targets: [{
expr: `rate(http_server_request_duration_seconds_count{service_name="${serviceName}"}[5m])`,
legendFormat: '{{http_method}} {{http_route}}',
}],
gridPos: { h: 8, w: 12, x: 0, y: 0 },
},
{
title: 'P95 Latency',
type: 'timeseries',
targets: [{
expr: `histogram_quantile(0.95, rate(http_server_request_duration_seconds_bucket{service_name="${serviceName}"}[5m]))`,
legendFormat: '{{http_route}}',
}],
gridPos: { h: 8, w: 12, x: 12, y: 0 },
},
{
title: 'Error Rate',
type: 'stat',
targets: [{
expr: `sum(rate(http_server_request_duration_seconds_count{service_name="${serviceName}",http_status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count{service_name="${serviceName}"}[5m])) * 100`,
legendFormat: 'Error %',
}],
gridPos: { h: 4, w: 6, x: 0, y: 8 },
},
{
title: 'Active Payments',
type: 'gauge',
targets: [{
expr: `payments_active{service_name="${serviceName}"}`,
legendFormat: 'Active',
}],
gridPos: { h: 4, w: 6, x: 6, y: 8 },
},
];
return {
dashboard: {
title: `${serviceName} — Observability Dashboard`,
tags: ['opentelemetry', 'auto-generated'],
panels,
time: { from: 'now-1h', to: 'now' },
refresh: '10s',
},
};
}
// 実行: npx tsx scripts/generate-grafana-dashboard.ts > dashboard.json
const dashboard = generateDashboard('my-api-service');
console.log(JSON.stringify(dashboard, null, 2));
生成された JSON を Grafana の Dashboard Import にインポートすれば、すぐに監視が開始できます。
計装の代償を測る — オーバーヘッドの実数
計装は無料ではありません。自動計装を有効化した直後、私の環境ではレイテンシがはっきり悪化しました。
手元の Node.js 22 / 2 vCPU コンテナで、同一エンドポイントに autocannon -c 50 -d 30 を三回流して中央値を取ったものが以下です。エクスポーターは OTLP/gRPC、Collector はサイドカー構成です。
| 構成 | p50 レイテンシ | p99 レイテンシ | RSS | スループット |
| 計装なし | 8.4 ms | 31 ms | 112 MB | 5,180 req/s |
| 自動計装のみ(全スパン送信) | 11.9 ms | 58 ms | 168 MB | 3,940 req/s |
| 自動計装 + 10% ヘッドサンプリング | 9.1 ms | 36 ms | 131 MB | 4,870 req/s |
| 自動計装 + テールサンプリング(Collector 側) | 9.0 ms | 35 ms | 129 MB | 4,900 req/s |
全スパンを送る構成では、スループットが約 24% 落ちました。p99 が倍近くまで伸びているのは、エクスポートのバッチフラッシュがイベントループを塞ぐためです。
ここで注意していただきたいのは、ヘッドサンプリングとテールサンプリングは性能上ほぼ同じでも、得られる情報がまったく違うという点です。ヘッドサンプリングはトレース開始時にサイコロを振るので、失敗した 1% のリクエストを 90% の確率で捨てます。障害調査で欲しいのは、まさにその捨てられた側です。
テールサンプリングは、トレースが完了してから「エラーを含むか」「レイテンシが閾値を超えたか」で採否を決めます。Collector 側の設定はこうなります。
# otel-collector-config.yaml
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
expected_new_traces_per_sec: 1000
policies:
# 1. エラーを含むトレースは必ず残す
- name: errors-always
type: status_code
status_code: { status_codes: [ERROR] }
# 2. 遅いトレースは必ず残す
- name: slow-traces
type: latency
latency: { threshold_ms: 500 }
# 3. それ以外は 5% だけ残す(ベースライン把握用)
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
decision_wait はトレースの全スパンが揃うまでの猶予です。これを短くしすぎると、まだ到着していないエラースパンを見逃して「成功したトレース」として捨てます。私は当初 2 秒に設定していて、非同期のバックグラウンドジョブを含むトレースが軒並み欠損しました。処理の実測 p99 が 6 秒だったので、10 秒に引き上げて解決しています。
私はこの構成を好みます。エラーと遅延は全部残り、平常時のトレースだけが 5% に間引かれる。コストは全量送信の 8% ほどに収まり、調査に必要な情報は失われません。
カーディナリティ爆発 — 請求書が先に教えてくれる
本番で最初に壊れるのは、たいていメトリクスのカーディナリティです。
スパン属性やメトリクスラベルに「リクエストごとに異なる値」を入れると、時系列の数がその値の種類だけ増えます。ユーザー ID をラベルに入れれば、ユーザー数だけ時系列が生まれます。
前節のコードで、うっかりこう書いてしまうと危険です。
// ❌ 危険: userId と path が高カーディナリティ
requestCounter.add(1, {
'http.route': req.path, // /users/8a3f-… が全部別ラベル
'user.id': req.user.id, // ユーザー数だけ時系列が増える
});
正しくは、パスをテンプレート化し、個体識別子はメトリクスではなくスパン属性に置きます。メトリクスは集計のための器、スパンは個別事象の記録の器 — この住み分けを崩すと、あとで請求書が教えてくれます。
// ✅ メトリクスは低カーディナリティに保つ
requestCounter.add(1, {
'http.route': req.route?.path ?? 'unknown', // /users/:id
'http.status_code': res.statusCode,
'service.version': process.env.APP_VERSION ?? 'dev',
});
// 個体識別はスパン側へ
const span = trace.getActiveSpan();
span?.setAttribute('user.id', req.user.id);
span?.setAttribute('request.id', req.headers['x-request-id'] as string);
私の環境では、http.route を生パスのまま出していた三日間で時系列が約 42,000 系列まで膨らみ、Prometheus のスクレイプが 12 秒を超えてタイムアウトしました。テンプレート化したあとは 380 系列です。百分の一以下。
Collector 側にも保険をかけておくと安心です。
processors:
# 想定外の高カーディナリティ属性を落とす
attributes/scrub:
actions:
- key: user.id
action: delete
- key: session.id
action: delete
# 時系列数の上限を設けて、超えたら捨てる
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
memory_limiter を入れておくと、カーディナリティ爆発が起きたときに Collector 自身が落ちるのではなく、データを捨てて生き残ります。監視基盤が最初に死ぬ障害は、いちばん救いがありません。
エージェントに任せた範囲と、人間が引き受けた判断
Antigravity のエージェントに何をどこまで任せるか。私の線引きは、こうなりました。
| 作業 | 担当 | 理由 |
| 自動計装ライブラリの選定・導入 | エージェント | 依存関係の解析は機械のほうが正確。漏れが出にくい |
| カスタムスパンの挿入位置の提案 | エージェント → 人間が確認 | 提案は的確だが、業務上重要な境界は人間しか知らない |
| サンプリング方針の決定 | 人間 | 「何を失っていいか」はコストとリスクの判断であり、技術判断ではない |
| 属性キーの命名・カーディナリティ設計 | 人間 | 請求への影響が大きく、あとから直しにくい |
| Grafana ダッシュボードの初期生成 | エージェント | 叩き台として優秀。そのまま使える精度 |
| アラート閾値の設定 | 人間 | 誤報の許容度はチームの文化に依存する |
| 異常検知の一次分類 | エージェント | ノイズの除去は得意。最終判断は残す |
| 本番への自動修復の適用 | 人間(承認制) | 後述 |
自動修復を全自動にしなかったのは、一度やってみて怖かったからです。
エージェントが「メモリ使用率が閾値を超えたのでスケールアウト」と判断した障害が、実際にはメモリリークでした。スケールアウトは症状を数時間だけ隠し、その間にリークしたインスタンスが増えました。機械は「異常を消す」ことはできても、「異常が消えていい理由」を持っていません。
いまは、エージェントが修復案とその根拠(該当スパン・メトリクスの変化・過去の類似ケース)を Slack に投げ、私が承認したものだけが実行されます。深夜二時に叩き起こされる回数は変わりませんが、起きてから原因にたどり着くまでの時間は三時間から十分程度になりました。
この判断基準は、Antigravity エージェントでテストパイプラインを自動構築する:品質保証を AI に任せる実践設計 で書いたテスト自動化の線引きと同じ思想です。生成は任せ、承認は残す。
導入の順序 — 一度に全部やらない
計装・サンプリング・バックエンド・異常検知を同時に立ち上げようとすると、どこで壊れたかがわからなくなります。可観測性を導入する作業自体が、可観測でなくなるという皮肉です。
私が二度目にやり直したときの順序は、こうでした。
- 自動計装のみ、コンソールエクスポーターで出力(半日)— スパンが正しい親子関係で出るかだけを確認します。バックエンドは繋ぎません。
- Collector を挟み、Tempo/Prometheus へ送る(一日)— ここで初めてネットワークとリソースの問題が出ます。前段が正しいと確信できているので、切り分けが速い。
- サンプリングを入れ、コストを実測する(一日)— 全量で一日流してから間引くと、削減率が数字で出ます。
- カスタムスパンとビジネスメトリクスを追加(継続)— 「この処理の所要時間を知りたい」と思った瞬間に一つずつ足す。先回りして入れたスパンは、だいたい見ません。
- 異常検知と自動修復の提案(一ヶ月後)— 平常時のベースラインが二週間分溜まってから。データのない異常検知は、ただの乱数です。
三番目と五番目のあいだに一ヶ月空けたのは、意図的です。ベースラインを持たない検知器は、閾値をあなたの勘で埋めることになります。二週間のデータがあれば、閾値は分布が教えてくれます。
CI へ組み込む段階の設計は、マルチエージェントCI/CD品質ゲート — Antigravity × GitHub ActionsでPRレビュー・テスト・セキュリティを自動化する実践ガイド が参考になるかもしれません。
まとめ
深夜二時のログに何も書かれていなかったあの日から、私が学んだのは「監視は障害のあとに追加できない」ということでした。障害の最中に欲しくなる情報は、障害の前に仕込んでおくしかありません。
Antigravity のエージェントは、その仕込みの手間を確実に減らします。ただし、何を失っていいか(サンプリング)、何を数えるか(カーディナリティ)、何を自動で直していいか(修復) — この三つの問いには、コードを書く人間が答える必要があります。エージェントに任せられるのは実装であって、判断ではありません。
今日試していただくとしたら、自動計装をコンソールエクスポーターで動かし、手元のエンドポイントを一度叩いてみることをおすすめします。スパンの親子関係が期待通りに出ているか。それだけを確認する三十分が、後半の一ヶ月を軽くします。
私自身まだ運用しながら学んでいる途中ですが、同じ深夜に起こされる誰かの助けになれば嬉しく思います。