LabHub
배우기 러닝패스 코스

Where Distributed Tracing Breaks

We joined producer and consumer as parent and child, and the trace never ended

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

파일 한 개로 된 작은 큐를 놓고, 생산과 소비를 부모-자식으로 이었을 때 생기는 문제를 덤프로 본 뒤 링크로 바꿉니다. 배치 소비·큐 대기 시간·스팬 종류·링크 속성을 차례로 붙이고, 팬아웃까지 적용한 다음 링크를 따라가 한 주문의 여정을 트레이스 너머로 이어 붙입니다.

왜 중요한가

부모-자식은 '부모가 자식을 기다린다' 는 뜻이다. 그런데 생산자는 소비가 끝나기를 기다리지 않는다. 그 둘을 부모-자식으로 이으면 사용자에게 이미 응답이 나갔는데도 루트 스팬이 닫히지 못하고, 큐가 밀린 날에는 트레이스 하나가 몇 분씩 열려 있는다. 팬아웃이 섞이면 주문 하나가 수천 스팬으로 자라 백엔드가 그 트레이스만 특별 취급하기 시작한다. 링크는 바로 이 자리를 위해 있다 — 인과는 남기되 기다리지는 않는다는 관계다. 소비 쪽을 새 트레이스의 루트로 세우고 생산 스팬을 링크로 가리키면 트레이스 하나하나는 작게 유지되고, 전체 여정은 링크를 따라가며 다시 이어 붙일 수 있다. 헤더를 읽어 끊긴 체인을 잇는 일이나 프로세스 안에서 문맥을 넘기는 일과는 다른 판단이다.

단계

  1. /root/tp-links/naive.py 를 만드세요. 덤프 경로는 TRACELAB_OUT 에서 먼저 읽고 없으면 /root/tp-links/01-naive.jsonl 을 씁니다. 큐 파일은 덤프와 같은 디렉터리의 01-q.jsonl 로 잡고 시작할 때 비웁니다. order.submit 스팬 하나 안에서 bus.publish 로 메시지 3건(m-1~m-3)을 넣고 time.sleep(0.25) 로 큐에서 기다린 뒤, 같은 스팬 안에서 bus.poll 로 꺼내 메시지마다 자식 스팬 order.handle 을 만들어 bus.handle 을 부릅니다. 그다음 /root/tp-links/01-problem.txt 에 네 줄을 적으세요 — traces= 뒤에 덤프의 트레이스 수, root_span= 뒤에 루트 스팬 이름, wait_ms= 뒤에 루트 스팬 길이에서 자식들이 덮은 구간을 뺀 값을 정수로, problem= 뒤에 이 모양의 문제가 무엇인지 40자 이상으로.
  2. /root/tp-links/linked.py 를 만드세요(기본 덤프 경로 /root/tp-links/02-linked.jsonl, 큐 파일 02-q.jsonl). order.submit 안에서 메시지마다 스팬 order.publish 를 만들고, 그 스팬의 trace_idspan_id 를 16진 문자열로 메시지에 실어 보냅니다. 기다린 뒤 꺼내는 쪽은 order.submit 에서 돌려 스팬 order.process 가 트레이스의 루트가 되게 하고, 메시지에 실려 온 아이디로 Link 를 만들어 links= 로 겁니다. 두 스팬 모두 messaging.message.id 속성에 메시지 아이디를 적습니다.
  3. /root/tp-links/batch.py 를 만드세요(기본 덤프 경로 /root/tp-links/03-batch.jsonl, 큐 파일 03-q.jsonl). 이번에는 메시지를 5건(m-1~m-5) 넣고, 기다린 뒤 bus.poll(Q, 5)한 번에 꺼냅니다. 꺼낸 배치 전체를 스팬 하나 order.process.batch 로 처리하고, 그 스팬에 꺼낸 메시지 수만큼 링크를 걸고 정수 속성 messaging.batch.message_count 에 건수를 적습니다. 이 스팬도 트레이스의 루트여야 합니다.
  4. /root/tp-links/wait.py 를 만드세요(기본 덤프 경로 /root/tp-links/04-wait.jsonl, 큐 파일 04-q.jsonl). 2단계의 모양으로 돌아가되 메시지에 produced_at_ns 칸을 더해 bus.now_ns() 값을 실어 보내고, 소비 스팬 order.process 에 그 시각과 지금의 차이를 밀리초로 계산해 속성 messaging.queue.wait_ms 로 적습니다. 그리고 /root/tp-links/04-wait.tsv 에 메시지마다 한 줄씩 <메시지 아이디><탭><기다린 밀리초 소수 첫째 자리> 를 적으세요(세 줄).
  5. /root/tp-links/kinds.py 를 만드세요(기본 덤프 경로 /root/tp-links/05-kinds.jsonl, 큐 파일 05-q.jsonl). 4단계의 흐름에 스팬 종류를 붙입니다 — order.publishSpanKind.PRODUCER, order.processSpanKind.CONSUMER, 감싸는 order.submit 은 그대로 둡니다. 그리고 두 메시징 스팬에 messaging.system(labbusmessaging.destination.name(ordersmessaging.operation.type(생산은 send, 소비는 processmessaging.operation.name·messaging.message.id 를 답니다. 그다음 /root/tp-links/05-kinds.tsv 에 세 줄을 적으세요 — 각 줄은 <스팬 이름><탭><종류><탭><operation.type> 이고 순서는 order.submit, order.publish, order.process 이며 order.submit 의 셋째 칸은 - 입니다.
  6. /root/tp-links/linkattrs.py 를 만드세요(기본 덤프 경로 /root/tp-links/06-linkattrs.jsonl, 큐 파일 06-q.jsonl). 5단계와 같은 흐름이되 Link 를 만들 때 두 번째 인자로 속성을 함께 넘깁니다 — link.relationqueue.message, messaging.message.id 에 그 메시지의 아이디, messaging.destination.nameorders 를 적습니다. 덤프의 links 칸마다 이 세 속성이 들어 있어야 합니다.
  7. /root/tp-links/fanout.py 를 만드세요(기본 덤프 경로 /root/tp-links/07-fanout.jsonl, 큐 파일 07-q.jsonl). 6단계의 규칙을 그대로 쓰되 흐름을 늘립니다 — order.submit 이 메시지 3건을 만들고, 소비 쪽에서 주문 A-1002 를 처리하는 order.process 스팬 안에서 두 건을 더 만듭니다(스팬 이름 invoice.publishemail.publish, 메시지 아이디 inv-2eml-2). 잠시 기다린 뒤 그 두 건을 꺼내 invoice.processemail.process 로 각각 처리하며, 이들도 새 트레이스의 루트이고 링크로 앞의 생산 스팬을 가리킵니다. 덤프에는 트레이스가 6개 들어 있어야 합니다.
  8. /root/tp-links/journey.py 를 만드세요. python3 journey.py <덤프> <메시지아이디> 로 돌리면 그 메시지를 만든 order.publish 스팬에서 출발해, 링크로 이어진 스팬을 홉 단위로 따라가며 한 줄씩 <홉><탭><trace_id><탭><스팬이름> 을 찍습니다. 출발 스팬이 홉 0 이고, 다음 홉은 앞 홉의 스팬 또는 그 스팬의 자손을 링크로 가리키는 스팬들이며 같은 홉 안에서는 스팬 이름 오름차순으로 찍습니다. 더 따라갈 곳이 없으면 멈춥니다. 이 프로그램을 /root/tp-links/07-fanout.jsonlm-2 로 돌린 출력을 /root/tp-links/08-journey.tsv 에 저장하세요.

참고

생산과 소비를 부모-자식으로 이으면 무슨 일이 나는가

/root/tp-links/naive.py 를 만드세요. 덤프 경로는 TRACELAB_OUT 에서 먼저 읽고 없으면 /root/tp-links/01-naive.jsonl 을 씁니다. 큐 파일은 덤프와 같은 디렉터리의 01-q.jsonl 로 잡고 시작할 때 비웁니다. order.submit 스팬 하나 안에서 bus.publish 로 메시지 3건(m-1~m-3)을 넣고 time.sleep(0.25) 로 큐에서 기다린 뒤, 같은 스팬 안에서 bus.poll 로 꺼내 메시지마다 자식 스팬 order.handle 을 만들어 bus.handle 을 부릅니다. 그다음 /root/tp-links/01-problem.txt 에 네 줄을 적으세요 — traces= 뒤에 덤프의 트레이스 수, root_span= 뒤에 루트 스팬 이름, wait_ms= 뒤에 루트 스팬 길이에서 자식들이 덮은 구간을 뺀 값을 정수로, problem= 뒤에 이 모양의 문제가 무엇인지 40자 이상으로.

재료는 /opt/app/tracelab/tp_links/bus.py 입니다 — publish·poll·handle·now_ns 가 있습니다. 덮이지 않은 구간은 /opt/lab/checks/_tplib.pycovered_ns(부모, 자식들) 로 구합니다. 계측 프로그램은 /opt/otel-lab/bin/python 으로 돌리고, 덤프는 다시 만들기 전에 지우세요.

소비를 새 트레이스의 루트로 세우고 링크로 잇는다

/root/tp-links/linked.py 를 만드세요(기본 덤프 경로 /root/tp-links/02-linked.jsonl, 큐 파일 02-q.jsonl). order.submit 안에서 메시지마다 스팬 order.publish 를 만들고, 그 스팬의 trace_idspan_id 를 16진 문자열로 메시지에 실어 보냅니다. 기다린 뒤 꺼내는 쪽은 order.submit 에서 돌려 스팬 order.process 가 트레이스의 루트가 되게 하고, 메시지에 실려 온 아이디로 Link 를 만들어 links= 로 겁니다. 두 스팬 모두 messaging.message.id 속성에 메시지 아이디를 적습니다.

SpanContext(trace_id=..., span_id=..., is_remote=True, trace_flags=TraceFlags(TraceFlags.SAMPLED)) 로 문맥을 만들고 Link(ctx) 로 감쌉니다. 16진 문자열은 format(값, "032x")format(값, "016x") 로 만들고 되돌릴 때는 int(문자열, 16) 입니다. 소비 반복문이 with order.submit 블록 안에 남아 있으면 루트가 되지 않으니 들여쓰기를 보세요.

배치 소비는 링크 여러 개인 스팬 하나로

/root/tp-links/batch.py 를 만드세요(기본 덤프 경로 /root/tp-links/03-batch.jsonl, 큐 파일 03-q.jsonl). 이번에는 메시지를 5건(m-1~m-5) 넣고, 기다린 뒤 bus.poll(Q, 5)한 번에 꺼냅니다. 꺼낸 배치 전체를 스팬 하나 order.process.batch 로 처리하고, 그 스팬에 꺼낸 메시지 수만큼 링크를 걸고 정수 속성 messaging.batch.message_count 에 건수를 적습니다. 이 스팬도 트레이스의 루트여야 합니다.

links= 에는 목록을 줍니다 — 메시지마다 Link 를 만들어 리스트로 넘기세요. 메시지마다 스팬을 만들면 배치를 처리한 한 번의 일이 흩어지고, 링크 없이 스팬 하나만 만들면 어느 메시지가 그 배치에 들어 있었는지 알 수 없습니다. 둘 다 피하는 모양이 이 단계의 답입니다.

큐에서 기다린 시간을 어떻게 잴 것인가

/root/tp-links/wait.py 를 만드세요(기본 덤프 경로 /root/tp-links/04-wait.jsonl, 큐 파일 04-q.jsonl). 2단계의 모양으로 돌아가되 메시지에 produced_at_ns 칸을 더해 bus.now_ns() 값을 실어 보내고, 소비 스팬 order.process 에 그 시각과 지금의 차이를 밀리초로 계산해 속성 messaging.queue.wait_ms 로 적습니다. 그리고 /root/tp-links/04-wait.tsv 에 메시지마다 한 줄씩 <메시지 아이디><탭><기다린 밀리초 소수 첫째 자리> 를 적으세요(세 줄).

처리는 한 건씩 차례로 하므로 뒤에 꺼낸 메시지일수록 더 오래 기다립니다 — 세 값이 같지 않은 것이 정상입니다. 시각은 큐가 아니라 생산자가 찍어야 합니다. 소비자가 꺼낸 순간을 시작으로 삼으면 기다린 시간이 언제나 0 이 됩니다.

PRODUCER 와 CONSUMER 는 언제 쓰는가

/root/tp-links/kinds.py 를 만드세요(기본 덤프 경로 /root/tp-links/05-kinds.jsonl, 큐 파일 05-q.jsonl). 4단계의 흐름에 스팬 종류를 붙입니다 — order.publishSpanKind.PRODUCER, order.processSpanKind.CONSUMER, 감싸는 order.submit 은 그대로 둡니다. 그리고 두 메시징 스팬에 messaging.system(labbusmessaging.destination.name(ordersmessaging.operation.type(생산은 send, 소비는 processmessaging.operation.name·messaging.message.id 를 답니다. 그다음 /root/tp-links/05-kinds.tsv 에 세 줄을 적으세요 — 각 줄은 <스팬 이름><탭><종류><탭><operation.type> 이고 순서는 order.submit, order.publish, order.process 이며 order.submit 의 셋째 칸은 - 입니다.

메시징 시맨틱 컨벤션의 표는 작업 종류로 스팬 종류를 정합니다 — 만들기와 보내기는 PRODUCER, 애플리케이션이 메시지를 처리하는 자리는 CONSUMER 입니다. 업무 흐름을 감싸는 스팬은 메시징 스팬이 아니므로 종류를 바꾸지 않습니다. 덤프의 kind 칸에 이름 그대로 찍힙니다.

링크에 왜 이어져 있는지를 적는다

/root/tp-links/linkattrs.py 를 만드세요(기본 덤프 경로 /root/tp-links/06-linkattrs.jsonl, 큐 파일 06-q.jsonl). 5단계와 같은 흐름이되 Link 를 만들 때 두 번째 인자로 속성을 함께 넘깁니다 — link.relationqueue.message, messaging.message.id 에 그 메시지의 아이디, messaging.destination.nameorders 를 적습니다. 덤프의 links 칸마다 이 세 속성이 들어 있어야 합니다.

Link(ctx, {"키": "값"}) 처럼 두 번째 인자가 속성입니다. 링크만 있으면 '이어져 있다' 는 사실은 남지만 왜 이어져 있는지는 남지 않습니다 — 같은 두 스팬이 큐 때문에 이어질 수도, 재처리 때문에 이어질 수도 있는데 그 둘은 읽는 사람에게 전혀 다른 이야기입니다.

하나가 여럿을 만드는 팬아웃까지 적용한다

/root/tp-links/fanout.py 를 만드세요(기본 덤프 경로 /root/tp-links/07-fanout.jsonl, 큐 파일 07-q.jsonl). 6단계의 규칙을 그대로 쓰되 흐름을 늘립니다 — order.submit 이 메시지 3건을 만들고, 소비 쪽에서 주문 A-1002 를 처리하는 order.process 스팬 안에서 두 건을 더 만듭니다(스팬 이름 invoice.publishemail.publish, 메시지 아이디 inv-2eml-2). 잠시 기다린 뒤 그 두 건을 꺼내 invoice.processemail.process 로 각각 처리하며, 이들도 새 트레이스의 루트이고 링크로 앞의 생산 스팬을 가리킵니다. 덤프에는 트레이스가 6개 들어 있어야 합니다.

두 번째 홉의 생산 스팬은 소비 스팬의 자식입니다 — 같은 트레이스 안의 동기 호출이니 부모-자식이 맞습니다. 링크로 건너뛰는 것은 큐를 지날 때뿐입니다. 어느 관계를 무엇으로 이을지가 이 단계의 전부이고, 트레이스 수를 세어 보면 제대로 나뉘었는지 바로 알 수 있습니다.

링크를 따라가 여정을 다시 이어 붙인다

/root/tp-links/journey.py 를 만드세요. python3 journey.py <덤프> <메시지아이디> 로 돌리면 그 메시지를 만든 order.publish 스팬에서 출발해, 링크로 이어진 스팬을 홉 단위로 따라가며 한 줄씩 <홉><탭><trace_id><탭><스팬이름> 을 찍습니다. 출발 스팬이 홉 0 이고, 다음 홉은 앞 홉의 스팬 또는 그 스팬의 자손을 링크로 가리키는 스팬들이며 같은 홉 안에서는 스팬 이름 오름차순으로 찍습니다. 더 따라갈 곳이 없으면 멈춥니다. 이 프로그램을 /root/tp-links/07-fanout.jsonlm-2 로 돌린 출력을 /root/tp-links/08-journey.tsv 에 저장하세요.

두 번째 홉으로 넘어가려면 자손까지 봐야 합니다 — 청구서 메시지를 만든 스팬은 order.process 의 자식이지 order.process 자신이 아니기 때문입니다. 채점기는 여러분의 프로그램을 다른 메시지 아이디로도 돌려 보므로 m-2 를 코드에 박아 두면 안 됩니다. 덤프를 읽는 일에 otel 은 필요 없습니다.