Where Distributed Tracing Breaks
One span tells you nothing, thirty spans nobody reads
한국어 원문으로 표시합니다.
목표
계측이 한 줄도 없는 주문 처리 코드에 스팬을 직접 넣으면서, 경계를 어디에 그을지 숫자로 정하는 법을 익힙니다. 계측 공백을 재고, 반복을 속성으로 접고, 그 판단을 규칙 파일로 적어 두 번째 핸들러에 그대로 적용합니다.
왜 중요한가
스팬을 하나만 두면 트레이스는 '211밀리초 걸렸다' 말고 아무 말도 하지 않는다. 반복문마다 스팬을 만들면 이번에는 같은 모양 막대가 서른 개 이어져 아무도 끝까지 보지 않는다. 두 실패는 같은 질문을 건너뛴 결과다 — 나중에 이 트레이스로 무엇을 물을 것인가. 그 질문에 답이 되는 자리를 찾는 도구가 계측 공백이다. 부모 구간 중 어느 자식도 덮지 않은 시간이 곧 '아직 모르는 시간' 이고, 그 자리가 다음 스팬을 그을 곳이다. 반대로 반복은 스팬이 아니라 횟수·총시간·최댓값으로 접어야 트레이스가 읽을 수 있는 크기로 남는다. 마지막으로 이 판단을 사람의 취향으로 두면 리뷰마다 다시 싸우게 되므로, 요청당 스팬 상한과 허용 공백을 파일로 적어 기계가 읽게 한다.
단계
/root/tp-boundary/01_root.py를 만드세요./opt/app/tracelab/dump.py의provider로 서비스 이름shop-api짜리 프로바이더를 만들고, 덤프 경로는 환경변수TRACELAB_OUT에서 먼저 읽되 없으면/root/tp-boundary/01-root.jsonl을 씁니다.POST /checkout이라는 이름의SERVER스팬 하나 안에서tracelab.tp_boundary.shop의validate→load_cart→price_of(전 상품) →charge→write_receipt를 차례로 부르고, 끝에flush()를 부릅니다. 그런 다음/opt/otel-lab/bin/python로 돌려 덤프를 만들고,/root/tp-boundary/01-root.txt에spans=와root_ms=두 줄을 적으세요(덤프에서 읽은 값 그대로)./root/tp-boundary/02_split.py를 만드세요. 1단계와 같되, 데이터베이스 왕복load_cart를cart.load스팬으로, 밖으로 나가는charge를payment.charge스팬으로 감쌉니다(자동 계측이 대신 만들어 주는 자리가 이 둘입니다). 덤프 기본 경로는/root/tp-boundary/02-split.jsonl입니다. 돌린 뒤/root/tp-boundary/02-gap.txt에 네 줄root_ms=·covered_ms=·gap_ms=·gap_ratio=을 적으세요.covered_ms는 루트의 자식 구간을 합집합으로 더한 길이이고,gap_ratio는 (루트 − 덮인 시간) ÷ 루트 를 소수 넷째 자리까지 적습니다./root/tp-boundary/03_close.py를 만드세요. 2단계에서 남은 공백을 없애도록validate는order.validate, 상품 값 조회 반복 전체는price.lookup,write_receipt는receipt.write스팬으로 감쌉니다. 스팬 이름은 이 다섯 개(order.validate·cart.load·price.lookup·payment.charge·receipt.write)와 루트POST /checkout로 정확히 맞추세요. 덤프 기본 경로는/root/tp-boundary/03-close.jsonl입니다. 돌린 뒤/root/tp-boundary/03-gap.txt에 2단계와 같은 네 줄을 적고,gap_ratio가 0.05 미만이 되게 하세요./root/tp-boundary/04_peritem.py를 만드세요. 3단계와 같되price.lookup안에서 상품 하나를 조회할 때마다price.item스팬을 하나씩 만듭니다. 덤프 기본 경로는/root/tp-boundary/04-peritem.jsonl입니다. 돌린 뒤/root/tp-boundary/04-count.txt에 세 줄spans_per_request=·item_spans=·spans_per_1000_requests=를 적으세요. 마지막 줄은 요청당 스팬 수에 1000 을 곱한 정수입니다./root/tp-boundary/05_fold.py를 만드세요.price.item스팬을 없애고, 대신price.lookup스팬에 세 속성price.lookup.count(조회 횟수) ·price.lookup.total_ms(합계 밀리초) ·price.lookup.max_ms(가장 오래 걸린 한 건의 밀리초)를 붙입니다. 그리고 가장 오래 걸린 상품을price.lookup.slowest라는 이벤트로 남기되 이벤트 속성에sku를 넣으세요. 덤프 기본 경로는/root/tp-boundary/05-fold.jsonl이고, 전체 스팬 수는 8개를 넘지 않아야 합니다./root/tp-boundary/06_kind.py를 만드세요. 5단계와 같되 모든 스팬에SpanKind를 명시합니다 — 들어온 요청은SERVER, 프로세스 밖으로 나가는 호출(cart.load·payment.charge·receipt.write)은CLIENT, 프로세스 안에서 나눈 구간(order.validate·price.lookup)은INTERNAL입니다. 덤프 기본 경로는/root/tp-boundary/06-kind.jsonl이고, 돌린 뒤/root/tp-boundary/06-kinds.tsv에 스팬마다 한 줄씩<스팬이름><탭><kind>를 시작 시각 순서로 적으세요(머리글 없이 여섯 줄)./root/tp-boundary/budget.txt에 세 줄을 적으세요 —max_spans_per_request=8,max_gap_ratio=0.10,root_kind=SERVER. 그리고/root/tp-boundary/check_budget.py를 만드세요.python3 check_budget.py <규칙파일> <덤프>로 부르면 규칙을 어긴 것마다VIOLATION <규칙키> <지금 값>을 한 줄씩 찍고 종료 코드 1, 다 지켰으면OK로 시작하는 한 줄과 종료 코드 0 을 냅니다. 마지막으로/root/tp-boundary/07-verdict.tsv에 세 줄을 적으세요 — 각 줄은<덤프파일이름><탭><pass|fail><탭><깨진 규칙키 또는 ->이고,02-split.jsonl·04-peritem.jsonl·06-kind.jsonl을 이 순서로 검사한 결과입니다./root/tp-boundary/08_search.py를 만들어GET /search핸들러를 처음부터 계측하세요.shop.parse_query→shop.search_index→ 결과마다shop.hydrate→shop.render순서로 부르고, 스팬은 루트GET /search(SERVER) 아래query.parse(INTERNAL) ·index.search(CLIENT) ·result.hydrate(INTERNAL) ·response.render(INTERNAL) 네 개입니다. 반복은 접어서result.hydrate에result.hydrate.count·result.hydrate.total_ms·result.hydrate.max_ms를 속성으로 답니다. 덤프 기본 경로는/root/tp-boundary/08-search.jsonl이고, 7단계의 검사기를 이 덤프에 돌렸을 때OK가 나와야 합니다.
참고
- 작업 디렉터리는
/root/tp-boundary입니다. 없으면 먼저 만드세요. - 계측 프로그램은 반드시
/opt/otel-lab/bin/python로 돌립니다. 시스템python3에는 OpenTelemetry 가 없습니다. 덤프를 읽고 세는 작은 스크립트는 시스템python3로 충분합니다. - 공용 배선은
/opt/app/tracelab/dump.py(provider·flush), 재료는/opt/app/tracelab/tp_boundary/shop.py(계측이 없는 주문 처리 코드)와/opt/app/tracelab/tp_boundary/gapstat.py(덤프 읽기·합집합 계산·tree출력)입니다. - 흔한 실수: 덤프 파일을 지우지 않고 프로그램을 다시 돌리는 것. 덤프는 이어 쓰기라 스팬이 쌓이고, 트레이스가 두 개가 됩니다.
- 흔한 실수: 공백을 '느린 구간' 으로 읽는 것. 공백은 아직 스팬이 없는 구간이고, 자기 시간·임계 경로와는 다른 질문입니다.
- 이 파드에는 컬렉터도 트레이스 화면도 없습니다. 판정은 덤프의 구조로만 하고, 밀리초 절대값이 아니라 비율로 봅니다.
- OpenTelemetry — Traces · Tracing API 명세 · OpenTelemetry Python 계측 · 라이브러리 계측 지침 · W3C Trace Context
스팬 하나짜리 트레이스로 시작한다
/root/tp-boundary/01_root.py 를 만드세요. /opt/app/tracelab/dump.py 의 provider 로 서비스 이름 shop-api 짜리 프로바이더를 만들고, 덤프 경로는 환경변수 TRACELAB_OUT 에서 먼저 읽되 없으면 /root/tp-boundary/01-root.jsonl 을 씁니다. POST /checkout 이라는 이름의 SERVER 스팬 하나 안에서 tracelab.tp_boundary.shop 의 validate → load_cart → price_of(전 상품) → charge → write_receipt 를 차례로 부르고, 끝에 flush() 를 부릅니다. 그런 다음 /opt/otel-lab/bin/python 로 돌려 덤프를 만들고, /root/tp-boundary/01-root.txt 에 spans= 와 root_ms= 두 줄을 적으세요(덤프에서 읽은 값 그대로).
시스템 python3 에는 OpenTelemetry 가 없습니다. 계측 프로그램은 반드시 otel 이 들어 있는 파이썬으로 돌리세요. 덤프는 이어 쓰기(append)라 같은 파일에 두 번 돌리면 스팬이 쌓입니다 — 다시 돌리기 전에 지우세요. 덤프를 사람 눈으로 보려면 python3 /opt/app/tracelab/tp_boundary/gapstat.py tree <덤프> 가 편합니다.
자동 계측이 주는 두 스팬만 넣고 공백을 잰다
/root/tp-boundary/02_split.py 를 만드세요. 1단계와 같되, 데이터베이스 왕복 load_cart 를 cart.load 스팬으로, 밖으로 나가는 charge 를 payment.charge 스팬으로 감쌉니다(자동 계측이 대신 만들어 주는 자리가 이 둘입니다). 덤프 기본 경로는 /root/tp-boundary/02-split.jsonl 입니다. 돌린 뒤 /root/tp-boundary/02-gap.txt 에 네 줄 root_ms= · covered_ms= · gap_ms= · gap_ratio= 을 적으세요. covered_ms 는 루트의 자식 구간을 합집합으로 더한 길이이고, gap_ratio 는 (루트 − 덮인 시간) ÷ 루트 를 소수 넷째 자리까지 적습니다.
/opt/app/tracelab/tp_boundary/gapstat.py 에 load·root_span·children·union_ns·ms 가 있습니다. 합집합인 이유는 자식끼리 겹칠 수 있기 때문입니다 — 겹친 구간을 두 번 세면 덮인 시간이 부모보다 길어집니다. 이 단계에서 공백이 절반 가까이 나오는 것이 정상입니다.
공백이 큰 구간을 갈라 5% 아래로 내린다
/root/tp-boundary/03_close.py 를 만드세요. 2단계에서 남은 공백을 없애도록 validate 는 order.validate, 상품 값 조회 반복 전체는 price.lookup, write_receipt 는 receipt.write 스팬으로 감쌉니다. 스팬 이름은 이 다섯 개(order.validate·cart.load·price.lookup·payment.charge·receipt.write)와 루트 POST /checkout 로 정확히 맞추세요. 덤프 기본 경로는 /root/tp-boundary/03-close.jsonl 입니다. 돌린 뒤 /root/tp-boundary/03-gap.txt 에 2단계와 같은 네 줄을 적고, gap_ratio 가 0.05 미만이 되게 하세요.
반복 스물네 번을 한 스팬으로 감싸는 것이 핵심입니다 — 이 단계에서는 아직 반복마다 스팬을 만들지 않습니다. 공백이 0 이 되지는 않습니다. 스팬을 시작하고 끝내는 일 자체가 몇 마이크로초를 쓰기 때문입니다. 비율로 보면 무시할 수 있는 크기입니다.
반복마다 스팬을 만들면 몇 개가 되는지 세어 본다
/root/tp-boundary/04_peritem.py 를 만드세요. 3단계와 같되 price.lookup 안에서 상품 하나를 조회할 때마다 price.item 스팬을 하나씩 만듭니다. 덤프 기본 경로는 /root/tp-boundary/04-peritem.jsonl 입니다. 돌린 뒤 /root/tp-boundary/04-count.txt 에 세 줄 spans_per_request= · item_spans= · spans_per_1000_requests= 를 적으세요. 마지막 줄은 요청당 스팬 수에 1000 을 곱한 정수입니다.
세 줄 모두 덤프에서 직접 세어 채웁니다. 장바구니 크기는 /opt/app/tracelab/tp_boundary/shop.py 의 CART_SIZE 로 정해져 있습니다. 상품이 스물네 개일 때 이 정도라면, 상품이 이백 개인 주문에서는 몇 개가 되는지 머릿속으로 곱해 보세요 — 다음 단계의 이유입니다.
반복을 스팬 대신 속성과 이벤트로 접는다
/root/tp-boundary/05_fold.py 를 만드세요. price.item 스팬을 없애고, 대신 price.lookup 스팬에 세 속성 price.lookup.count(조회 횟수) · price.lookup.total_ms(합계 밀리초) · price.lookup.max_ms(가장 오래 걸린 한 건의 밀리초)를 붙입니다. 그리고 가장 오래 걸린 상품을 price.lookup.slowest 라는 이벤트로 남기되 이벤트 속성에 sku 를 넣으세요. 덤프 기본 경로는 /root/tp-boundary/05-fold.jsonl 이고, 전체 스팬 수는 8개를 넘지 않아야 합니다.
한 건의 소요는 time.perf_counter() 로 직접 잽니다. 평균만 남기면 스물네 번 중 한 번이 열 배 느렸다는 사실이 사라집니다 — 그래서 최댓값을 따로 남기고, 그게 무엇이었는지는 시점이 있는 기록인 이벤트로 적습니다. 이벤트는 span.add_event(이름, {속성}) 입니다.
SpanKind 로 경계를 표시한다
/root/tp-boundary/06_kind.py 를 만드세요. 5단계와 같되 모든 스팬에 SpanKind 를 명시합니다 — 들어온 요청은 SERVER, 프로세스 밖으로 나가는 호출(cart.load·payment.charge·receipt.write)은 CLIENT, 프로세스 안에서 나눈 구간(order.validate·price.lookup)은 INTERNAL 입니다. 덤프 기본 경로는 /root/tp-boundary/06-kind.jsonl 이고, 돌린 뒤 /root/tp-boundary/06-kinds.tsv 에 스팬마다 한 줄씩 <스팬이름><탭><kind> 를 시작 시각 순서로 적으세요(머리글 없이 여섯 줄).
CLIENT 는 '우리가 남을 기다린 시간', INTERNAL 은 '우리가 일한 시간' 을 나중에 가르는 기준이 됩니다. 데이터베이스 왕복도 프로세스 밖으로 나가는 호출이라 CLIENT 입니다. tsv 는 덤프에서 그대로 뽑아 만들면 손으로 적다 틀릴 일이 없습니다.
경계 규칙을 파일로 적고 검사기를 만든다
/root/tp-boundary/budget.txt 에 세 줄을 적으세요 — max_spans_per_request=8, max_gap_ratio=0.10, root_kind=SERVER. 그리고 /root/tp-boundary/check_budget.py 를 만드세요. python3 check_budget.py <규칙파일> <덤프> 로 부르면 규칙을 어긴 것마다 VIOLATION <규칙키> <지금 값> 을 한 줄씩 찍고 종료 코드 1, 다 지켰으면 OK 로 시작하는 한 줄과 종료 코드 0 을 냅니다. 마지막으로 /root/tp-boundary/07-verdict.tsv 에 세 줄을 적으세요 — 각 줄은 <덤프파일이름><탭><pass|fail><탭><깨진 규칙키 또는 -> 이고, 02-split.jsonl · 04-peritem.jsonl · 06-kind.jsonl 을 이 순서로 검사한 결과입니다.
공백 계산은 /opt/app/tracelab/tp_boundary/gapstat.py 의 함수를 그대로 쓰면 됩니다. 규칙 파일을 따로 두는 이유는 상한을 코드에 박아 두면 리뷰 때마다 사람의 취향으로 다시 싸우기 때문입니다. 세 덤프 중 둘은 서로 다른 규칙을 어깁니다 — 어느 쪽이 무엇을 어기는지 먼저 짐작해 보고 돌려 보세요.
같은 규칙으로 두 번째 핸들러를 계측한다
/root/tp-boundary/08_search.py 를 만들어 GET /search 핸들러를 처음부터 계측하세요. shop.parse_query → shop.search_index → 결과마다 shop.hydrate → shop.render 순서로 부르고, 스팬은 루트 GET /search(SERVER) 아래 query.parse(INTERNAL) · index.search(CLIENT) · result.hydrate(INTERNAL) · response.render(INTERNAL) 네 개입니다. 반복은 접어서 result.hydrate 에 result.hydrate.count · result.hydrate.total_ms · result.hydrate.max_ms 를 속성으로 답니다. 덤프 기본 경로는 /root/tp-boundary/08-search.jsonl 이고, 7단계의 검사기를 이 덤프에 돌렸을 때 OK 가 나와야 합니다.
앞 일곱 단계에서 배운 순서를 그대로 밟습니다 — 프로세스 경계마다 스팬, 공백이 큰 구간을 가르고, 반복은 접는다. 검색 결과 개수는 shop.py 의 SEARCH_HITS 로 정해져 있습니다. 검사기가 VIOLATION 을 내면 규칙을 고치지 말고 계측을 고치세요.