T-CREATOR

NestJS 監視運用:SLI/SLO とダッシュボード設計(Prometheus/Grafana/Loki)

NestJS 監視運用:SLI/SLO とダッシュボード設計(Prometheus/Grafana/Loki)

本番環境で NestJS アプリケーションを安定運用するには、単なる監視だけではなく「どの指標をどう測るか」が重要です。この記事では、サービスレベル指標(SLI)とサービスレベル目標(SLO)の定義から、Prometheus と Grafana、Loki を組み合わせた実践的なダッシュボード設計まで、初心者の方にも分かりやすく解説します。

エラー率や応答時間といったメトリクスを可視化し、問題が起きる前に気づくための監視体制を、一緒に構築していきましょう。

背景

SLI と SLO が必要な理由

従来の監視では「サーバーが落ちていないか」「CPU 使用率は何%か」といったインフラ視点の指標を中心に見ていました。しかし、それだけでは ユーザー体験が良好かどうか を判断できません。

SRE(Site Reliability Engineering)の考え方では、ユーザーにとって重要な指標を定量化し、それに基づいて目標を設定します。これが SLI(Service Level Indicator)と SLO(Service Level Objective)です。

以下の図は、従来型監視と SLI/SLO ベースの監視の違いを示します。

mermaidflowchart TB
  subgraph old["従来型監視"]
    cpu["CPU使用率"]
    mem["メモリ使用率"]
    disk["ディスク容量"]
  end

  subgraph new["SLI/SLOベース監視"]
    availability["可用性<br/>(エラー率)"]
    latency["レイテンシ<br/>(応答時間)"]
    throughput["スループット<br/>(リクエスト数)"]
  end

  old -.->|インフラ視点| infra["サーバー健全性"]
  new -.->|ユーザー視点| user["ユーザー体験"]

図で理解できる要点:

  • 従来型はインフラリソースを監視
  • SLI/SLO はユーザー体験を指標化
  • ユーザー視点での品質保証が可能

NestJS における監視の役割

NestJS は Express または Fastify をベースとした TypeScript フレームワークで、マイクロサービスや REST API の構築に広く使われています。本番運用では、以下のような課題が生じます。

  • エンドポイントごとの応答速度: どの API が遅いのか
  • エラー発生率: どのルートでエラーが多いか
  • 依存サービスの影響: データベースや外部 API の遅延がアプリに波及していないか

これらを可視化するために、Prometheus でメトリクスを収集し、Grafana でダッシュボード化、Loki でログを集約する構成が有効です。

mermaidflowchart LR
  nestjs["NestJS アプリ"] -->|メトリクス| prom["Prometheus"]
  nestjs -->|ログ| loki["Loki"]
  prom -->|データソース| grafana["Grafana"]
  loki -->|データソース| grafana
  grafana -->|可視化| user["運用者"]

図の要点:

  • NestJS から Prometheus と Loki にデータを送信
  • Grafana で一元的にダッシュボード化
  • 運用者は一つの画面でメトリクスとログを確認可能

課題

SLI/SLO を定義しないとどうなるか

目標が曖昧なまま監視を導入すると、以下のような問題が発生します。

#課題影響
1アラートが多すぎて無視される重要な障害を見逃す
2何を改善すべきか分からない優先順位が不明確
3ユーザー体験の劣化に気づけない信頼性低下

SLO を設定することで「99.9% の可用性を保つ」「95 パーセンタイルで応答時間 200ms 以内」といった 明確な基準 が生まれ、チーム全体で改善方針を共有できます。

NestJS でのメトリクス収集の難しさ

NestJS は標準でメトリクス出力機能を持っていません。以下のような工夫が必要です。

  • インターセプターやミドルウェアでメトリクス収集: リクエストごとに HTTP ステータス、レイテンシ、メソッド、パスを記録
  • Prometheus フォーマットでエクスポート: ​/​metrics エンドポイントを用意し、Prometheus がスクレイプできる形式で公開
  • ログとメトリクスの統合: エラー発生時にログと紐づけ、原因調査を迅速化

これらを手動で実装すると手間がかかるため、ライブラリやベストプラクティスを活用することが重要です。

mermaidflowchart TB
  req["HTTPリクエスト"] --> interceptor["メトリクス<br/>インターセプター"]
  interceptor -->|収集| counter["リクエストカウンタ"]
  interceptor -->|収集| histogram["レイテンシヒストグラム"]
  counter --> metrics["/metricsエンドポイント"]
  histogram --> metrics
  metrics -->|スクレイプ| prom["Prometheus"]

図の補足:

  • インターセプターで各リクエストのメトリクスを収集
  • カウンターとヒストグラムに記録
  • ​/​metrics を通じて Prometheus が定期的に取得

解決策

SLI と SLO の定義方法

まず、ユーザーにとって重要な指標を洗い出します。NestJS の REST API を例にすると、以下が一般的です。

#SLI説明測定方法
1可用性成功したリクエスト / 全リクエストHTTP ステータスコード 2xx, 3xx を成功とカウント
2レイテンシ95 パーセンタイルの応答時間ヒストグラムで p95 を算出
3スループット秒あたりのリクエスト数リクエストカウンターの増加率

次に、**SLO(目標値)**を設定します。

  • 可用性: 99.9%(月間約 43 分までのダウンタイムを許容)
  • レイテンシ: p95 で 200ms 以内
  • スループット: 最低 100 req/s を維持

これらを Grafana で可視化し、SLO を下回ったらアラートを発火させる仕組みを作ります。

Prometheus と Grafana、Loki の役割分担

監視スタックの各コンポーネントは以下のように役割を分担します。

Prometheus:

  • 時系列データの収集・保存
  • メトリクスのスクレイプ(定期取得)
  • アラートルールの評価

Grafana:

  • ダッシュボードの構築
  • 複数データソース(Prometheus + Loki)の統合表示
  • アラート通知(Slack、Email など)

Loki:

  • ログの収集・保存
  • ログクエリと検索
  • メトリクスと連携したログ調査
mermaidflowchart LR
  app["NestJSアプリ"] -->|メトリクス| prom["Prometheus"]
  app -->|ログ| loki["Loki"]
  prom -->|クエリ| grafana["Grafana"]
  loki -->|クエリ| grafana
  grafana -->|アラート| slack["Slack"]
  grafana -->|ダッシュボード| operator["運用者"]

図の要点:

  • Prometheus と Loki がそれぞれメトリクスとログを担当
  • Grafana が統合ビューを提供
  • アラートは Slack などに通知

NestJS でのメトリクス実装

NestJS でメトリクスを収集するには、@willsoto​/​nestjs-prometheus パッケージが便利です。

パッケージのインストール

bashyarn add @willsoto/nestjs-prometheus prom-client

モジュールの設定

app.module.ts に Prometheus モジュールをインポートします。

typescriptimport { Module } from '@nestjs/common';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';

@Module({
  imports: [
    PrometheusModule.register({
      // /metrics エンドポイントを自動で作成
      defaultMetrics: {
        enabled: true,
      },
    }),
    // 他のモジュール
  ],
})
export class AppModule {}

これで、NestJS アプリケーションに ​/​metrics エンドポイントが追加され、デフォルトのシステムメトリクス(CPU、メモリなど)が取得できるようになります。

カスタムメトリクスの追加

リクエストごとのメトリクスを収集するため、インターセプターを作成します。

typescriptimport {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Counter, Histogram } from 'prom-client';
import { InjectMetric } from '@willsoto/nestjs-prometheus';

上記は必要なインポート文です。次に、インターセプター本体を実装します。

typescript@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(
    @InjectMetric('http_requests_total')
    private readonly requestCounter: Counter<string>,
    @InjectMetric('http_request_duration_seconds')
    private readonly requestHistogram: Histogram<string>
  ) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler
  ): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, path } = request;
    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: () => {
          const duration = (Date.now() - start) / 1000;
          const response = context
            .switchToHttp()
            .getResponse();
          const statusCode = response.statusCode;

          // カウンターを増加
          this.requestCounter.inc({
            method,
            path,
            status: statusCode,
          });
          // ヒストグラムに記録
          this.requestHistogram.observe(
            { method, path },
            duration
          );
        },
        error: (err) => {
          const duration = (Date.now() - start) / 1000;
          // エラー時も記録
          this.requestCounter.inc({
            method,
            path,
            status: 500,
          });
          this.requestHistogram.observe(
            { method, path },
            duration
          );
        },
      })
    );
  }
}

このインターセプターは、各リクエストの メソッド、パス、ステータスコード、処理時間 を記録します。

メトリクスの登録

app.module.ts でメトリクスを登録します。

typescriptimport { Module } from '@nestjs/common';
import {
  PrometheusModule,
  makeCounterProvider,
  makeHistogramProvider,
} from '@willsoto/nestjs-prometheus';

@Module({
  imports: [PrometheusModule.register()],
  providers: [
    makeCounterProvider({
      name: 'http_requests_total',
      help: 'Total number of HTTP requests',
      labelNames: ['method', 'path', 'status'],
    }),
    makeHistogramProvider({
      name: 'http_request_duration_seconds',
      help: 'Duration of HTTP requests in seconds',
      labelNames: ['method', 'path'],
      buckets: [0.1, 0.3, 0.5, 1, 2, 5],
    }),
    MetricsInterceptor,
  ],
})
export class AppModule {}

バケットは、ヒストグラムの区間を定義します。ここでは 0.1 秒、0.3 秒、0.5 秒、1 秒、2 秒、5 秒という区間を設定しています。

インターセプターのグローバル適用

main.ts でインターセプターを全エンドポイントに適用します。

typescriptimport { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MetricsInterceptor } from './metrics.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // グローバルインターセプター
  const metricsInterceptor = app.get(MetricsInterceptor);
  app.useGlobalInterceptors(metricsInterceptor);

  await app.listen(3000);
}
bootstrap();

これで、すべてのリクエストでメトリクスが自動収集されるようになります。

Prometheus の設定

Prometheus は定期的に ​/​metrics をスクレイプしてデータを収集します。以下は prometheus.yml の設定例です。

yamlglobal:
  scrape_interval: 15s # 15秒ごとにスクレイプ

scrape_configs:
  - job_name: 'nestjs-app'
    static_configs:
      - targets: ['nestjs:3000'] # NestJSアプリのホスト名とポート
    metrics_path: '/metrics'

この設定により、Prometheus は 15 秒ごとに NestJS の ​/​metrics エンドポイントにアクセスし、メトリクスを取得します。

Docker Compose での構成例

NestJS、Prometheus、Grafana、Loki を Docker Compose で構成します。

yamlversion: '3.8'

services:
  nestjs:
    build: .
    ports:
      - '3000:3000'
    networks:
      - monitoring

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - '9090:9090'
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:latest
    ports:
      - '3001:3000'
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    networks:
      - monitoring

  loki:
    image: grafana/loki:latest
    ports:
      - '3100:3100'
    networks:
      - monitoring

  promtail:
    image: grafana/promtail:latest
    volumes:
      - /var/log:/var/log
      - ./promtail-config.yml:/etc/promtail/config.yml
    networks:
      - monitoring

networks:
  monitoring:
    driver: bridge

この構成では、すべてのコンポーネントが monitoring ネットワーク上で通信します。

Loki でのログ収集

Loki はログを収集・保存するシステムです。Promtail がログファイルを読み取り、Loki に送信します。

Promtail の設定

promtail-config.yml を作成します。

yamlserver:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: nestjs
    static_configs:
      - targets:
          - localhost
        labels:
          job: nestjs
          __path__: /var/log/nestjs/*.log

この設定により、Promtail は ​/​var​/​log​/​nestjs​/​*.log を監視し、Loki に送信します。

NestJS のログ出力

NestJS でログをファイルに出力するには、Winston などのロガーを使います。

bashyarn add winston nest-winston

次に、ロガーをセットアップします。

typescriptimport { WinstonModule } from 'nest-winston';
import * as winston from 'winston';

const logger = WinstonModule.createLogger({
  transports: [
    new winston.transports.File({
      filename: '/var/log/nestjs/app.log',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.json()
      ),
    }),
  ],
});

main.ts でロガーを適用します。

typescriptimport { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './logger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger,
  });
  await app.listen(3000);
}
bootstrap();

これで、NestJS のログが ​/​var​/​log​/​nestjs​/​app.log に JSON 形式で出力され、Promtail が Loki に送信します。

Grafana でのダッシュボード設計

Grafana では、Prometheus と Loki をデータソースとして追加し、ダッシュボードを構築します。

データソースの追加

Grafana にログインし、Configuration > Data Sources から以下を追加します。

#名前タイプURL
1PrometheusPrometheushttp://prometheus:9090
2LokiLokihttp://loki:3100

SLI ダッシュボードの作成

以下の指標をパネルとして配置します。

可用性(エラー率):

promql1 - (
  sum(rate(http_requests_total{status=~"5.."}[5m]))
  /
  sum(rate(http_requests_total[5m]))
)

このクエリは、過去 5 分間の成功率を計算します。SLO が 99.9% なら、閾値を 0.999 に設定します。

レイテンシ(p95):

promqlhistogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, path))

このクエリは、95 パーセンタイルの応答時間をパスごとに算出します。

スループット:

promqlsum(rate(http_requests_total[5m])) by (method, path)

このクエリは、メソッドとパスごとのリクエスト数を表示します。

ログパネルの追加

Loki をデータソースとして使い、エラーログを表示します。

logql{job="nestjs"} |= "error" | json

このクエリは、job="nestjs" のログから error を含む行を抽出し、JSON としてパースします。

アラートの設定

Grafana でアラートを設定すると、SLO を下回った際に通知を受け取れます。

可用性アラート:

  • 条件: 可用性が 0.999 を下回る
  • 期間: 5 分間
  • 通知先: Slack

Grafana の Alerting > Notification channels で Slack Webhook URL を設定し、パネルの Alert タブで条件を設定します。

具体例

サンプルアプリケーションの構築

実際に動作する NestJS アプリを構築し、監視スタックを統合します。

プロジェクトの初期化

bashyarn global add @nestjs/cli
nest new nestjs-monitoring
cd nestjs-monitoring

必要なパッケージのインストール

bashyarn add @willsoto/nestjs-prometheus prom-client winston nest-winston

メトリクスとロガーのセットアップ

前述の手順に従い、メトリクスインターセプターとロガーを設定します。

サンプルエンドポイントの作成

src​/​app.controller.ts にエンドポイントを追加します。

typescriptimport {
  Controller,
  Get,
  InternalServerErrorException,
} from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('slow')
  async getSlow(): Promise<string> {
    // 意図的に遅延を発生
    await new Promise((resolve) =>
      setTimeout(resolve, 2000)
    );
    return 'Slow response';
  }

  @Get('error')
  getError(): never {
    // 意図的にエラーを発生
    throw new InternalServerErrorException(
      'Something went wrong'
    );
  }
}

これらのエンドポイントは、レイテンシやエラー率をテストするために使います。

Docker Compose でのデプロイ

前述の docker-compose.yml を使い、すべてのサービスを起動します。

bashdocker-compose up -d

メトリクスの確認

ブラウザで http:​/​​/​localhost:3000​/​metrics にアクセスすると、以下のようなメトリクスが表示されます。

ini# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/",status="200"} 10
http_requests_total{method="GET",path="/slow",status="200"} 2
http_requests_total{method="GET",path="/error",status="500"} 5

# HELP http_request_duration_seconds Duration of HTTP requests in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.1",method="GET",path="/"} 10
http_request_duration_seconds_bucket{le="0.3",method="GET",path="/"} 10
http_request_duration_seconds_bucket{le="0.5",method="GET",path="/"} 10
http_request_duration_seconds_bucket{le="1",method="GET",path="/"} 10
http_request_duration_seconds_bucket{le="2",method="GET",path="/slow"} 0
http_request_duration_seconds_bucket{le="5",method="GET",path="/slow"} 2
http_request_duration_seconds_sum{method="GET",path="/"} 0.5
http_request_duration_seconds_count{method="GET",path="/"} 10

この出力から、各エンドポイントのリクエスト数とレイテンシが確認できます。

Grafana でのダッシュボード構築例

Grafana(http:​/​​/​localhost:3001)にアクセスし、以下のパネルを配置します。

パネル 1:可用性(Success Rate)

  • クエリ: 前述の可用性クエリ
  • 可視化: Stat(数値表示)
  • 閾値: 0.999 以下で赤色

パネル 2:p95 レイテンシ

  • クエリ: 前述のレイテンシクエリ
  • 可視化: Graph(折れ線グラフ)
  • 閾値: 200ms 以上で黄色

パネル 3:リクエスト数

  • クエリ: 前述のスループットクエリ
  • 可視化: Graph
  • 凡例: メソッドとパスごとに分ける

パネル 4:エラーログ

  • データソース: Loki
  • クエリ: {job="nestjs"} |= "error"
  • 可視化: Logs

以下は、ダッシュボードの構造を示す図です。

mermaidflowchart TB
  dashboard["Grafanaダッシュボード"]
  dashboard --> panel1["可用性<br/>(Success Rate)"]
  dashboard --> panel2["p95レイテンシ"]
  dashboard --> panel3["リクエスト数"]
  dashboard --> panel4["エラーログ"]
  panel1 --> prom1["Prometheusクエリ"]
  panel2 --> prom2["Prometheusクエリ"]
  panel3 --> prom3["Prometheusクエリ"]
  panel4 --> loki1["Lokiクエリ"]

図の補足:

  • ダッシュボードは 4 つのパネルで構成
  • パネル 1〜3 は Prometheus、パネル 4 は Loki を使用
  • 一つの画面でメトリクスとログを統合表示

負荷テストと SLO 検証

実際に負荷をかけて、SLO が守られているか検証します。

負荷テストツールのインストール

bashyarn global add autocannon

負荷テストの実行

bashautocannon -c 100 -d 60 http://localhost:3000

このコマンドは、100 接続で 60 秒間リクエストを送り続けます。

Grafana での確認

負荷テスト中に Grafana を見ると、リアルタイムでメトリクスが更新されます。可用性が 99.9% を下回ったり、p95 レイテンシが 200ms を超えたりした場合、アラートが発火します。

エラーエンドポイントへの負荷テスト

bashautocannon -c 50 -d 30 http://localhost:3000/error

このテストでは、エラー率が急上昇し、アラートが Slack に通知されることを確認できます。

ログとメトリクスの相関分析

エラー率が上昇した際、Loki でログを検索して原因を特定します。

logql{job="nestjs"} |= "InternalServerErrorException" | json | line_format "{{.message}}"

このクエリは、エラーメッセージを抽出し、どのエンドポイントでエラーが発生しているかを特定します。

Grafana の Explore 機能を使うと、Prometheus のメトリクスと Loki のログを同じ時間軸で表示でき、相関分析が容易になります。

まとめ

この記事では、NestJS アプリケーションにおける SLI/SLO ベースの監視運用を、Prometheus、Grafana、Loki を使って実現する方法を解説しました。

重要なポイントをおさらいしましょう。

SLI と SLO の定義:

  • ユーザー視点で重要な指標を選ぶ
  • 可用性、レイテンシ、スループットが基本
  • 定量的な目標を設定し、チームで共有する

メトリクス収集の実装:

  • @willsoto​/​nestjs-prometheus でメトリクスを収集
  • インターセプターで HTTP リクエストを記録
  • ​/​metrics エンドポイントで Prometheus に公開

ログ収集の統合:

  • Winston でログをファイル出力
  • Promtail が Loki に転送
  • Grafana でメトリクスとログを統合表示

ダッシュボード設計:

  • SLO に基づいたパネルを配置
  • アラートで SLO 違反を通知
  • 負荷テストで検証し、継続的に改善

これらの仕組みを導入することで、問題が発生する前に気づき、迅速に対処できる体制が整います。ユーザー体験を守りながら、運用負荷を減らすことができるでしょう。

最初は小さく始めて、少しずつ改善していくのがおすすめです。まずは可用性とレイテンシの 2 つの SLI から始め、運用に慣れてきたら指標を増やしていきましょう。

関連リンク