분산 트레이싱이 끊기는 자리 · 재시도와 부분 실패 · 실습
재시도한 요청 하나를 스팬 몇 개로 그릴 것인가
목표
재시도를 한 스팬에 담았을 때 덤프가 무엇을 잃는지 직접 보고, 시도마다 스팬을 만들어 오류를 옳은 자리에 단 뒤, 같은 자료에서 스팬 기준 오류율과 요청 기준 오류율이 얼마나 벌어지는지 계산합니다. 마지막에는 그 규칙을 린터로 굳혀 규칙을 어긴 덤프를 잡아냅니다.
왜 중요한가
재시도는 거의 모든 서비스에 들어 있지만 트레이스에 어떻게 그릴지는 거의 아무도 정해 두지 않는다. 한 스팬 안에서 조용히 되풀이하면 어느 시도가 얼마나 걸렸는지와 무엇 때문에 실패했는지가 통째로 사라지고, 남는 것은 '이 스팬이 오래 걸렸다' 뿐이다. 반대로 시도마다 스팬을 만들되 오류를 아무 데나 달면 스팬으로 센 오류율이 사용자가 겪은 실패보다 두 배 넘게 부풀어 대시보드가 거짓말을 한다. 감싸는 스팬은 사용자가 겪은 하나의 일이고 시도 스팬은 상류에 실제로 나간 한 번의 호출이라는 두 층을 나눠 두면, 어느 숫자를 어디서 세야 하는지가 저절로 정해진다. 잡은 예외를 어떤 API 로 남기느냐는 SDK 수명주기 모듈의 몫이고, 여기서 정하는 것은 스팬을 몇 개로 나누고 어디에 오류를 달 것인가다.
단계
1. /root/tp-retry/one_span.py 를 만드세요. 덤프 경로는 환경변수 TRACELAB_OUT 에서 먼저 읽고 없으면 /root/tp-retry/01-one.jsonl 을 씁니다. provider("shop-api", OUT) 로 트레이서를 얻어 charge 스팬 하나만 만들고, 그 안에서 upstream.call("charge", 시도번호, idempotency_key="ord-7781") 을 성공할 때까지 되풀이합니다(실패하면 upstream.backoff_s(시도번호) 초만큼 쉽니다). 프로그램 끝에 flush() 를 부르세요. 그다음 /root/tp-retry/01-lost.txt 에 두 줄을 적습니다 — attempts= 뒤에 실제로 몇 번 시도했는지 정수로, lost= 뒤에 이 덤프로는 알 수 없게 된 것이 무엇인지 40자 이상으로.
2. /root/tp-retry/attempts.py 를 만드세요. 기본 덤프 경로는 /root/tp-retry/02-attempts.jsonl 입니다. 감싸는 스팬 charge 는 그대로 두고, 시도 하나마다 자식 스팬 charge.attempt 를 만들어 정수 속성 retry.attempt 에 몇 번째 시도인지(1부터) 적습니다. 대기는 시도 스팬 밖에서 합니다. 돌리고 나면 덤프에 스팬이 4개(감싸는 것 1 + 시도 3) 들어 있어야 합니다.
3. /root/tp-retry/error_place.py 를 만드세요. 기본 덤프 경로는 /root/tp-retry/03-error.jsonl 입니다. 실패한 시도 스팬에는 상태를 ERROR 로 세우고 문자열 속성 error.type 에 exc.kind 를 적습니다. 성공한 시도 스팬의 상태는 건드리지 않습니다. 감싸는 charge 스팬은 마지막에 상태를 OK 로 세웁니다 — 사용자가 겪은 결과는 성공이기 때문입니다.
4. /root/tp-retry/rates.py 를 만드세요. 기본 덤프 경로는 /root/tp-retry/04-rates.jsonl 이고, 작업 네 가지 charge·quote·ship·notify 를 차례로 처리합니다. 작업마다 감싸는 스팬 <작업>.request 와 시도 스팬 <작업>.attempt 를 만들고 시도는 최대 3번까지만 합니다. 끝내 실패한 작업의 감싸는 스팬은 ERROR, 성공한 작업은 OK 입니다. 그다음 /root/tp-retry/04-rates.tsv 에 탭으로 나눈 네 칸 두 줄을 적으세요 — 첫 줄은 span_level, 둘째 줄은 request_level 이고 칸은 <id> <오류 수> <전체 수> <비율> 입니다. span_level 은 덤프의 모든 스팬을 세고, request_level 은 부모가 없는 스팬만 셉니다. 비율은 소수 넷째 자리까지.
5. /root/tp-retry/backoff.py 를 만드세요(기본 덤프 경로 /root/tp-retry/05-backoff.jsonl). charge 하나만 다시 처리하되, 대기를 마칠 때마다 감싸는 스팬에 이벤트 retry.backoff 를 더하고 그 이벤트에 retry.attempt(정수)와 backoff.ms(밀리초) 를 답니다. 마지막에 감싸는 스팬의 속성 retry.backoff_ms_total 에 대기 시간의 합을 밀리초로 적습니다. 그다음 /root/tp-retry/05-gap.txt 에 두 줄을 적으세요 — backoff_total_ms= 뒤에 기록한 합계, uncovered_ms= 뒤에 감싸는 스팬의 길이에서 시도 스팬들이 덮은 구간을 뺀 값(소수 첫째 자리까지).
6. /root/tp-retry/attrs.py 를 만드세요(기본 덤프 경로 /root/tp-retry/06-attrs.jsonl). charge 를 다시 처리하되 감싸는 스팬에 retry.count(정수, 실제 시도 횟수)·retry.last_error(마지막 실패의 종류)·idempotency.key(ord-7781) 를 달고, 시도 스팬마다 retry.attempt 와 같은 값의 idempotency.key 를 답니다. 실패한 시도에는 3단계처럼 error.type 과 ERROR 상태를 그대로 둡니다. 그리고 /root/tp-retry/06-rules.tsv 에 탭으로 나눈 세 칸 네 줄을 적으세요 — 첫 칸은 속성 이름으로 순서대로 retry.count·retry.last_error·idempotency.key·retry.attempt, 둘째 칸은 그 속성이 붙는 자리로 root 또는 attempt, 셋째 칸은 왜 그 자리인지 20자 이상으로 적습니다.
7. /root/tp-retry/ship_spans.py 를 만드세요(기본 덤프 경로 /root/tp-retry/07-ship.jsonl). 재료 tracelab.tp_retry.shipping 의 dispatch(order_id, hooks) 는 반복문을 이미 갖고 있고 attempt_begin·attempt_end·waited 세 곳만 열어 둡니다. shipping.Hooks 를 상속한 클래스로 그 세 곳에서 스팬을 만들어, 서비스 이름 shipping-api 로 감싸는 스팬 ship.dispatch 와 시도 스팬 ship.attempt 를 6단계와 같은 속성 규칙으로 남기세요. 감싸는 스팬에는 retry.backoff_ms_total 도 함께 답니다. 멱등 키는 ord-7781 입니다.
8. /root/tp-retry/retry_lint.py 를 만드세요. python3 retry_lint.py <덤프경로> 로 돌리면 규칙을 어긴 곳마다 <규칙이름><탭><스팬아이디> 를 한 줄씩 찍고 종료 코드 1 로 끝나고, 어긴 곳이 없으면 ok<탭><루트 스팬 수> 한 줄을 찍고 0 으로 끝납니다. 규칙 이름은 정확히 네 가지입니다 — root-status(마지막 시도가 실패가 아닌데 감싸는 스팬이 ERROR), retry-count(감싸는 스팬의 retry.count 가 자식 수와 다름), attempt-error-type(ERROR 인 시도 스팬에 error.type 이 없음), idem-key(시도 스팬의 idempotency.key 가 감싸는 스팬과 다름). 만든 린터를 /root/tp-retry/06-attrs.jsonl 에 돌려 통과하는지 보고, /opt/app/tracelab/tp_retry/broken.jsonl 에 돌린 출력을 /root/tp-retry/08-lint.txt 에 저장하세요.
참고
- 작업 디렉터리는
/root/tp-retry입니다. 없으면 먼저 만드세요. - 계측 프로그램은 반드시
/opt/otel-lab/bin/python <파일>로 돌립니다. 시스템python3에는 OpenTelemetry SDK 가 없습니다. 반대로 덤프만 읽는 프로그램은 시스템python3로 돌리세요. - 재료는
/opt/app/tracelab/tp_retry/upstream.py(결정적으로 실패하는 상류)와/opt/app/tracelab/tp_retry/shipping.py(훅만 열려 있는 두 번째 서비스), 그리고 반례 덤프/opt/app/tracelab/tp_retry/broken.jsonl입니다. 공용 배선은/opt/app/tracelab/dump.py이고 덤프 읽기 도우미는/opt/lab/checks/_tplib.py입니다. - 흔한 실수: 덤프 파일을 지우지 않고 프로그램을 두 번 돌리는 것. 덤프는 이어 붙이므로 스팬이 두 배가 됩니다.
- 흔한 실수: 대기(backoff)를 시도 스팬 안에서 하는 것. 그러면 그 시도가 실제보다 오래 걸린 것으로 기록됩니다.
- [Traces (OpenTelemetry Concepts)](https://opentelemetry.io/docs/concepts/signals/traces/) · [Tracing API 명세](https://opentelemetry.io/docs/specs/otel/trace/api/) · [HTTP 스팬 시맨틱 컨벤션](https://opentelemetry.io/docs/specs/semconv/http/http-spans/) · [error.type 속성 레지스트리](https://opentelemetry.io/docs/specs/semconv/registry/attributes/error/) · [Python 계측 문서](https://opentelemetry.io/docs/languages/python/instrumentation/)
단계 8개
- 재시도를 한 스팬에 담으면 덤프가 무엇을 잃는가
- 시도마다 스팬을 만들어 시간을 드러낸다
- 실패한 시도만 오류로, 감싸는 스팬은 정상으로
- 스팬으로 오류율을 세면 재시도가 두 번 세어진다
- 대기에 쓴 시간은 어느 스팬에도 들어 있지 않다
- 속성 규칙을 정하고 그대로 계측한다
- 고칠 수 없는 남의 반복문에 같은 규칙을 끼운다
- 규칙을 린터로 굳혀 어긴 덤프를 잡아낸다