콘텐츠로 이동
Study Note관측

11. 내 앱 계측하기 — 신호는 앱에서 시작된다

결론부터
스택을 소비하는 쪽에서 신호를 만드는 쪽으로 자리를 바꾼다 — 실행 명령 하나로 자동 계측을 붙이고, 수동 스팬 하나로 비즈니스 구간을 보강한다
이 장에서 처음 나오는 말2개
zero-code 계측Zero-code Instrumentation
앱 코드를 고치지 않고 실행 명령과 환경변수만으로 자동 계측을 붙이는 방식. [4장](/observability/04-tempo/)의 "자동 계측"을 Python에서는 opentelemetry-instrument 명령이 맡는다.
수동 스팬Manual Span
자동 계측이 못 잡는 구간을 코드에서 직접 여는 스팬. 이 실습에서는 재고 예약 구간 inventory.reserve가 그것이다.

8~10장의 rolldice는 계측이 이미 끝난 공식 앱이었다 — Java agent가 신호를 만들었고 우리는 소비만 했다. 실무에서 만나는 질문은 반대쪽이다. “내 서비스는 이 스택에 어떻게 연결하나.” 이 장은 레포에 들어 있는 작은 Flask checkout 앱으로 그 질문을 직접 밟는다 — 자동 계측을 붙이고, 수동 스팬으로 보강하고, 로그가 trace_id를 얻는 것까지.

공식 앱이 못 주던 것도 하나 얻는다. rolldice의 오류는 상시 무작위였지만, 이 앱의 트래픽은 정상 15초 → 장애 30초 → 회복 15초로 고정되어 있어 조사의 첫 질문 — “언제부터?” — 이 처음으로 성립한다.

8~10장 (공식 rolldice)이 장 (자체 checkout 앱)
계측Java agent가 이미 붙어 있다우리가 직접 붙인다
오류상시 무작위 약 30%정상 → 장애 → 회복 고정 구간
스팬server 스팬 하나server 스팬 + 자식 스팬 inventory.reserve
답하는 질문오류 요청 하나 따라가기언제부터? 그리고 어디서?
traffic.py가 정상·장애·회복 시나리오로 checkout 앱을 호출하고 OTLP로 Collector에 보내 Prometheus·Loki·Tempo를 거쳐 Grafana로 모이는 실습 구성

실습 파일은 이 레포의 labs/observability/instrument-your-app/에 있다. 쿠버네티스 매니페스트가 없다 — 앱은 host에서 실행하고 8장의 port-forward로 신호를 보낸다.

  • 디렉터리labs/observability/instrument-your-app/
    • app.py 계측이 들어간 Flask 앱 — 이 절에서 읽는다
    • traffic.py 고정 시나리오 트래픽 (표준 라이브러리만 사용)
    • requirements.txt Flask와 OpenTelemetry distro 고정 버전
    • README.md

app.py의 핵심은 /checkout 핸들러 안의 수동 스팬 하나다.

with tracer.start_as_current_span("inventory.reserve") as span:
span.set_attribute("order.id", order_id)
span.set_attribute("inventory.warehouse", "seoul-1")
if incident:
time.sleep(1.2)
span.set_attribute("error.type", "inventory_timeout")
span.set_status(Status(StatusCode.ERROR, "inventory timed out"))
logger.error("inventory reservation timed out order_id=%s", order_id)
return jsonify(error="inventory timeout", order_id=order_id), 503

여기서 넷을 확인한다.

  • start_as_current_span("inventory.reserve") — HTTP server 스팬은 자동 계측이 만들지만, “재고 예약”이라는 비즈니스 구간은 앱만 안다. 그래서 이 한 구간만 수동으로 연다. 4장의 “자동 계측으로 시작하고 부족한 구간을 SDK로 채운다”가 코드로는 이 모양이다
  • set_attribute("order.id", order_id) — 요청마다 바뀌는 무한 값이지만 스팬 속성에는 넣어도 된다. 메트릭·로그 라벨이었다면 스택을 죽였을 값이다 (4장의 카디널리티 대비)
  • set_status(ERROR)와 logger.error(...) — 같은 사건이 트레이스와 로그 양쪽에 남는다. 뒤에서 이 둘이 trace_id로 이어지는 것을 확인한다
  • 없는 것 — 엔드포인트·exporter·서비스 이름 설정 코드가 없다. 그건 코드가 아니라 배포 설정(환경변수)의 몫이고, 그래서 저장소를 바꿔도 앱 코드는 그대로다

8장의 kind 클러스터와 터미널 A의 port-forward(특히 4318)가 살아 있어야 한다. rolldice와 traffic script(터미널 B·C)는 8080 포트가 겹치므로 Ctrl+C로 끝낸다. Python도 필요하다 — 이 실습은 Python 3.13에서 확인했고, macOS 시스템 기본 3.9에서는 고정한 OpenTelemetry 의존성이 설치되지 않는다.

터미널 창
kubectl get pod
python3 --version
  1. 의존성을 가상 환경에 설치한다

    터미널 창
    cd study-starlight/labs/observability/instrument-your-app
    python3 -m venv .venv && source .venv/bin/activate
    pip install -r requirements.txt
    opentelemetry-bootstrap -a install

    opentelemetry-bootstrap은 설치된 라이브러리(여기서는 Flask)를 보고 거기에 맞는 자동 계측 패키지를 찾아 마저 설치한다. 앱 코드는 여전히 손대지 않았다.

  2. 계측 설정을 환경변수로 주고 앱을 실행한다

    터미널 창
    export OTEL_SERVICE_NAME=checkout-lab
    export OTEL_RESOURCE_ATTRIBUTES=service.namespace=study,deployment.environment.name=lab
    export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
    export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
    export OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp
    export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
    export OTEL_METRIC_EXPORT_INTERVAL=5000
    export OTEL_PYTHON_EXCLUDED_URLS=health
    opentelemetry-instrument flask --app app run --host 127.0.0.1 --port 8080
    환경변수정하는 것
    OTEL_SERVICE_NAME리소스 속성 service.name. 안 주면 전부 unknown_service가 된다 (4장의 함정)
    OTEL_EXPORTER_OTLP_ENDPOINT신호를 보낼 곳 — 터미널 A가 전달 중인 kind 안의 Collector
    OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLEDPython logging 레코드를 trace_id가 실린 OTLP 로그로 내보낸다
    OTEL_PYTHON_EXCLUDED_URLS/health처럼 신호가 소음이 되는 경로 제외
  3. 한 번씩 눌러 보고 시나리오를 돌린다 (새 터미널에서)

    터미널 창
    curl -s "http://127.0.0.1:8080/checkout"
    curl -s "http://127.0.0.1:8080/checkout?incident=true"
    cd study-starlight/labs/observability/instrument-your-app
    python3 traffic.py

    traffic.py는 표준 라이브러리만 쓰므로 가상 환경 없이 실행해도 된다. 약 1분 뒤:

    [baseline] 200=60, 503=0, connection_error=0
    [incident] 200=30, 503=90, connection_error=0
    [recovery] 200=60, 503=0, connection_error=0
    시나리오 완료 — Grafana에서 최근 5분을 조사하세요.

🔎 관찰 포인트: 앱 터미널에서 checkout completed(INFO)와 inventory reservation timed out(ERROR)이 섞여 흐른다. 계측을 위해 앱이 새로 한 일은 없다 — 실행 명령과 환경변수가 전부였다.

조사 — 이번에는 “언제부터”가 보인다

섹션 제목: “조사 — 이번에는 “언제부터”가 보인다”

Grafana(http://127.0.0.1:3000)에서 시간 범위를 Last 5 minutes로 맞추고 Explore를 연다. 이동 자체는 9장에서 익힌 그대로다 — 여기서는 자체 앱이라 새로 보이는 것만 확인한다.

  1. 메트릭 — 503 비율과 p95 (Prometheus)

    sum(rate(http_server_duration_milliseconds_count{
    service_name="checkout-lab", http_status_code=~"5.."
    }[1m]))
    /
    sum(rate(http_server_duration_milliseconds_count{
    service_name="checkout-lab"
    }[1m]))
    histogram_quantile(0.95,
    sum by (le) (
    rate(http_server_duration_milliseconds_bucket{
    service_name="checkout-lab"
    }[1m])
    )
    ) / 1000

    🔎 두 그래프에 시작점 · 정점 · 회복이 있다. 무작위 오류뿐이던 rolldice에서는 못 하던 “언제부터?”에 처음으로 답했다 — 이제 이 구간이 다음 신호의 검색 범위가 된다. p95 값 자체는 실제 1.2초보다 크게 나올 수 있다 — 히스토그램은 버킷 경계 사이를 보간해 추정하기 때문이다 (2장의 히스토그램 절).

  2. 로그 — 문장 얻기 (Split → Loki)

    {service_name="checkout-lab"} |= "inventory reservation timed out"

    로그 한 줄을 펼쳐 trace_id가 붙어 있는지 본다. 앱은 logger.error() 한 줄을 불렀을 뿐인데, logging 자동 계측이 그 시점의 trace 컨텍스트를 실어 보냈다. order_id는 본문에 있다 — 스트림 라벨이 아니다.

  3. 트레이스 — 지점 찍기 (trace_id의 Trace 링크)

    워터폴에서 root 스팬 GET /checkout(약 1.2초, error) 아래 자식 스팬 inventory.reserve가 시간 대부분을 차지하는 것을 본다. 스팬 속성에서 코드로 넣은 값 셋 — order.id · inventory.warehouse=seoul-1 · error.type=inventory_timeout — 을 확인한다. server 스팬 하나뿐이던 rolldice와 달리 “어디서?”까지 답이 나온다.

  4. 다시 로그로 — 스팬의 Logs for this span으로 돌아온다. 메트릭 → 로그 → 트레이스 → 로그 한 바퀴가 자체 앱에서도 끊기지 않았다.

  1. traffic.py의 장애 구간 길이와 incident_ratio를 바꿔 다시 돌리고, 그래프의 정점 높이와 폭이 어떻게 달라지는지 비교한다.
  2. app.py에 두 번째 수동 스팬(예: payments.charge)을 열고 워터폴에서 형제 스팬으로 나타나는지 확인한다.
  3. TraceQL로 코드에서 넣은 속성을 직접 검색한다 — { span.inventory.warehouse = "seoul-1" && status = error }.
  4. OTEL_SERVICE_NAME을 바꿔 재실행하면 Grafana에 새 서비스로 잡히는 것을 확인한다. 리소스 속성이 코드가 아니라 배포 설정이라는 증거다.
  5. p95 query에서 Exemplars 표시를 켜고, 장애 구간의 표본 점에서 Tempo로 건너간다 — Python SDK도 히스토그램에 exemplar를 실어 보냈다.
증상확인
No matching distribution found for opentelemetry-distroPython 버전이 낮다 — 3.9에서 재현된다. 3.13으로 venv를 다시 만든다
pip install·opentelemetry-bootstrap 실패PyPI egress · 사내 proxy와 CA
opentelemetry-instrument: command not found가상 환경이 활성화됐는지 (source .venv/bin/activate)
Address already in use (8080)rolldice(터미널 B)가 아직 떠 있는지
신호가 하나도 안 들어옴터미널 A의 4318 port-forward, 앱 터미널의 export 오류 메시지
메트릭 이름이 문서와 다름label browser에서 실제 이름 확인 — distro 버전이 바뀌면 이름도 바뀐다
로그에 trace_id가 없음OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true를 줬는지
traffic.py가 connection_error만 셈앱 프로세스가 살아 있는지, 포트가 8080인지

앱과 traffic 터미널에서 Ctrl+C를 누르고 deactivate로 가상 환경을 빠져나온다. .venv는 지워도 되고 다음 실습을 위해 둬도 된다. 관측 실습 전체를 끝낸다면 전용 클러스터만 삭제한다.

터미널 창
kind delete cluster --name observability-lab

여기까지가 이 덱의 마지막 실습이다. 내 앱을 연결해 봤으니, 운영 도입은 7장 체크리스트의 kube-prometheus-stack → 알림 → Loki·Alloy → 연결 → Tempo 순서로 이어 간다.