LabHub
学习 学习路径 课程

分布式链路断掉的地方

把生产者和消费者连成父子后,这条链路再也结束不了

在 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 은 필요 없습니다.