OpenTelemetry 지표 정리: 계측기 고르기부터 Prometheus까지

OpenTelemetry 지표 정리: 계측기 고르기부터 Prometheus까지

수집기 정리까지 오면서 흘려보낸 건 추적뿐이었습니다. 추적은 요청 하나를 아주 자세히 보여 주지만 “지난 한 시간 동안 오류율이 몇 퍼센트였나”에는 답하지 못해요. 요청 하나하나를 다 뒤져 세야 하니까요.

지표, 로그, 추적 정리에서 정리했듯 그건 지표(metric)의 몫입니다. 이 글에서는 지표를 만드는 계측기(instrument) 종류를 고르는 법, 속성을 붙일 때 조심할 것, 그리고 Prometheus까지 연결하는 과정을 다루겠습니다.

계측기는 네 가지

지표를 만들려면 먼저 미터(meter)를 얻고 거기서 계측기를 만듭니다. 종류가 여럿인데, 고르는 기준은 “이 숫자가 어떻게 움직이는가”입니다.

카운터(counter)는 늘기만 합니다. 요청 수, 오류 수, 처리한 메시지 수처럼 세는 값이죠. 줄어들 일이 없으니 프로세스가 재시작하면 0으로 돌아가고, 그래서 백엔드는 값 자체보다 증가분을 봅니다.

업다운카운터(UpDownCounter)는 늘고 줄어듭니다. 큐에 쌓인 작업 수나 열린 커넥션 수처럼요. 카운터와 이름은 비슷하지만 다루는 쪽에서 완전히 다르게 해석하니 섞으면 안 됩니다.

히스토그램(histogram)은 값의 분포를 봅니다. 응답 시간이 대표적이죠. 평균만 필요하다면 합과 개수만 세면 되지만, p99를 알고 싶으면 값이 어느 구간에 몇 개씩 떨어졌는지를 알아야 합니다.

게이지(gauge)는 그 순간의 값입니다. 현재 메모리 사용량이나 온도처럼 합산이 의미 없는 값이죠.

여기에 비동기 변형이 있습니다. 코드가 흐르는 자리에서 기록할 수 없는 값, 예를 들어 현재 큐 길이 같은 건 내보낼 때마다 콜백을 불러 읽어 가게 합니다. createObservableGauge 같은 이름으로 만들고, 등록한 콜백이 주기마다 호출돼요.

두 개만 만들어 보기

주문 API에 요청 수와 처리 시간을 붙여 보겠습니다. 추적 때와 마찬가지로 설정은 따로 떼어 둡니다.

instrument.ts
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { metrics } from "@opentelemetry/api";

const provider = new MeterProvider({
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "orders" }),
  readers: [
    new PeriodicExportingMetricReader({
      exporter: new OTLPMetricExporter(),
      exportIntervalMillis: 5000, // 5초마다 모아서 보냅니다
    }),
  ],
});

metrics.setGlobalMeterProvider(provider);

추적과 다른 점이 보이나요. 추적은 스팬이 끝날 때마다 내보낼 수 있지만 지표는 주기를 정해 내보냅니다. 그사이에는 메모리에서 값이 누적되고요. 요청이 초당 수천 건이어도 5초에 한 번만 나가니, 지표가 추적보다 싸다는 말이 여기서 나옵니다.

서버 코드입니다.

server.ts
import "./instrument";
import { metrics } from "@opentelemetry/api";

const meter = metrics.getMeter("orders");

const requests = meter.createCounter("orders.requests", {
  description: "주문 API가 받은 요청 수",
});
const duration = meter.createHistogram("orders.duration", {
  description: "주문 처리에 걸린 시간",
  unit: "ms",
});

Bun.serve({
  port: 3000,
  async fetch(request) {
    const started = performance.now();
    const id = new URL(request.url).pathname.split("/").at(-1) ?? "";
    await Bun.sleep(id === "slow" ? 120 : 8);
    const status = id === "404" ? 404 : 200;

    requests.add(1, {
      "http.route": "/orders/:id",
      "http.response.status_code": status,
    });
    duration.record(performance.now() - started, { "http.route": "/orders/:id" });

    return new Response("ok", { status });
  },
});

계측기는 모듈 수준에서 한 번만 만듭니다. 요청마다 createCounter를 부르면 같은 이름의 계측기가 계속 생겨 메모리를 먹어요.

속성이 시계열을 쪼갠다

add와 record의 두 번째 인자가 속성(attribute)입니다. 지표에 붙는 속성은 추적의 속성과 성격이 달라요. 추적에서는 스팬 하나에 붙는 꼬리표지만, 지표에서는 속성 조합마다 별도의 시계열이 생깁니다.

위 코드에서 요청 수는 경로와 상태 코드 조합으로 나뉩니다. 경로가 10개고 상태 코드가 5종이면 시계열이 50개죠. 여기에 사용자 ID를 넣으면 어떻게 될까요. 사용자가 10만 명이면 시계열이 500만 개가 되고, 지표 저장소는 시계열 수에 비례해 메모리를 씁니다. 💥

그래서 지표 속성에는 값의 가짓수가 적고 예측 가능한 것만 넣습니다. 경로 패턴, 상태 코드, 메서드, 지역 정도요. 사용자 ID나 주문 번호처럼 무한히 늘어나는 값은 추적과 로그의 자리입니다. 관측성 정리에서 다룬 카디널리티 이야기가 그대로 적용되는 지점이에요. 이런 시계열이 지표 저장소에서 실제로 어떻게 불어나고, 어떻게 찾아서 줄이는지는 지표 카디널리티 정리에서 따로 다뤘습니다.

Prometheus로 내보내기

수집기에 지표 파이프라인을 하나 더 만듭니다. 추적 파이프라인과 나란히 놓으면 됩니다.

collector.yaml
exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp/jaeger]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [prometheus]

prometheus 내보내기는 다른 내보내기와 방향이 반대입니다. 어딘가로 밀어 넣는 게 아니라 긁어 갈 수 있게 엔드포인트를 열어 두거든요. Prometheus가 주기적으로 와서 가져가는 방식이라 그렇습니다.

요청을 몇 번 보낸 뒤 http://localhost:8889/metrics를 열어 보면 이렇게 나옵니다.

결과
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="10"} 6
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="100"} 6
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="250"} 7
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="+Inf"} 7
orders_duration_milliseconds_sum{http_route="/orders/:id",job="orders"} 175.99345900000026
orders_duration_milliseconds_count{http_route="/orders/:id",job="orders"} 7
orders_requests_total{http_response_status_code="200",http_route="/orders/:id",job="orders"} 6
orders_requests_total{http_response_status_code="404",http_route="/orders/:id",job="orders"} 1

이름이 바뀐 게 보입니다. orders.duration이 orders_duration_milliseconds가 됐고 orders.requests는 orders_requests_total이 됐어요. Prometheus는 이름에 점을 쓸 수 없어 밑줄로 바꾸고, 단위와 _total 접미사를 붙이는 관례가 있어서 내보내기가 이를 따릅니다. 속성 이름의 점도 같은 이유로 밑줄이 됐고요.

숫자를 읽어 보면 히스토그램이 어떻게 생겼는지 알 수 있습니다. le="10"이 6이고 le="250"이 7이니, 7번의 요청 중 6번은 10밀리초 안에 끝났고 나머지 하나가 100밀리초와 250밀리초 사이에 있었다는 뜻이죠. 버킷은 누적값이라 항상 같거나 늘어납니다.

뷰로 버킷 손보기

기본 버킷 경계는 16개로 꽤 촘촘한데, 값이 밀리초 단위 두 자릿수에 몰려 있다면 5초나 10초짜리 버킷은 낭비입니다. 버킷 하나가 시계열 하나라 그대로 비용이고요.

뷰(view)는 계측기에서 나온 값을 내보내기 전에 손보는 규칙입니다. 계측 코드는 그대로 두고 설정만 바꾸는 자리죠.

instrument.ts
const provider = new MeterProvider({
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "orders" }),
  views: [
    {
      instrumentName: "orders.duration",
      aggregation: {
        type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
        options: { boundaries: [5, 10, 25, 50, 100, 250] },
      },
    },
  ],
  readers: [/* ... */],
});

다시 긁어 보면 버킷이 줄어 있습니다.

결과
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="5"} 0
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="10"} 3
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="25"} 3
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="50"} 3
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="100"} 3
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="250"} 4
orders_duration_milliseconds_bucket{http_route="/orders/:id",job="orders",le="+Inf"} 4

16개에서 7개로 줄었습니다. 뷰로 할 수 있는 일은 더 있어요. 특정 속성을 떨어뜨려 카디널리티를 낮추거나, 아예 DROP으로 지정해 그 계측기를 통째로 버릴 수도 있습니다. 남이 만든 라이브러리가 내보내는 지표가 부담스러울 때 쓰는 방법이죠.

한 가지 짚어 둘 건 버킷 경계를 바꾸면 과거 데이터와 섞이지 않는다는 점입니다. 경계가 다른 히스토그램끼리는 합칠 수 없으니, 운영 중에 바꾸면 그 시점에서 그래프가 끊깁니다. 처음에 넉넉히 잡고 데이터를 보고 한 번에 정하는 편이 낫습니다.

무엇을 재야 할까

계측기를 만들다 보면 재고 싶은 게 계속 늘어납니다. 기준이 없으면 지표가 수백 개가 되고 아무도 안 보게 되죠.

가장 확실한 출발점은 SLI입니다. 좋은 이벤트를 전체 이벤트로 나눈 값이 SLI인데, 분자와 분모를 만들 수 있는 지표가 먼저 있어야 하니까요. 요청 수를 상태 코드별로 세면 가용성 SLI가 나오고, 응답 시간 히스토그램이 있으면 지연 시간 SLI가 나옵니다. 위에서 만든 두 계측기가 바로 그 분자와 분모를 만들고요.

그다음은 알림을 걸 대상입니다. 이 숫자가 나빠지면 사람을 깨울 건지 자문해 보고, 아니라면 급하지 않은 겁니다.

나머지는 필요해질 때 추가해도 늦지 않습니다. 지표는 미리 정한 질문에만 답하니 빠뜨린 게 있으면 아쉽지만, 그 빈자리는 추적과 로그가 메워 줍니다.

마치며

계측기는 값이 어떻게 움직이는지로 고릅니다. 늘기만 하면 카운터, 오르내리면 업다운카운터, 분포를 봐야 하면 히스토그램, 그 순간의 값이면 게이지예요. 속성은 조합마다 시계열을 만드니 가짓수가 적은 것만 넣어야 하고, 버킷이나 불필요한 지표는 계측 코드를 건드리지 않고 뷰에서 정리할 수 있습니다.

남은 신호가 하나 있죠. 다음 글에서는 이미 쓰고 있는 로깅 라이브러리를 그대로 두면서 로그를 추적과 이어 붙이는 방법을 다루겠습니다.

계측기별 쓰임과 이름 짓는 관례는 OpenTelemetry 공식 문서의 Metrics에 정리돼 있습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord