把生产者和消费者连成父子后,这条链路再也结束不了
한국어 원문으로 표시합니다.
목표
파일 한 개로 된 작은 큐를 놓고, 생산과 소비를 부모-자식으로 이었을 때 생기는 문제를 덤프로 본 뒤 링크로 바꿉니다. 배치 소비·큐 대기 시간·스팬 종류·링크 속성을 차례로 붙이고, 팬아웃까지 적용한 다음 링크를 따라가 한 주문의 여정을 트레이스 너머로 이어 붙입니다.
왜 중요한가
부모-자식은 '부모가 자식을 기다린다' 는 뜻이다. 그런데 생산자는 소비가 끝나기를 기다리지 않는다. 그 둘을 부모-자식으로 이으면 사용자에게 이미 응답이 나갔는데도 루트 스팬이 닫히지 못하고, 큐가 밀린 날에는 트레이스 하나가 몇 분씩 열려 있는다. 팬아웃이 섞이면 주문 하나가 수천 스팬으로 자라 백엔드가 그 트레이스만 특별 취급하기 시작한다. 링크는 바로 이 자리를 위해 있다 — 인과는 남기되 기다리지는 않는다는 관계다. 소비 쪽을 새 트레이스의 루트로 세우고 생산 스팬을 링크로 가리키면 트레이스 하나하나는 작게 유지되고, 전체 여정은 링크를 따라가며 다시 이어 붙일 수 있다. 헤더를 읽어 끊긴 체인을 잇는 일이나 프로세스 안에서 문맥을 넘기는 일과는 다른 판단이다.
단계
/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자 이상으로./root/tp-links/linked.py를 만드세요(기본 덤프 경로/root/tp-links/02-linked.jsonl, 큐 파일02-q.jsonl).order.submit안에서 메시지마다 스팬order.publish를 만들고, 그 스팬의trace_id와span_id를 16진 문자열로 메시지에 실어 보냅니다. 기다린 뒤 꺼내는 쪽은order.submit밖에서 돌려 스팬order.process가 트레이스의 루트가 되게 하고, 메시지에 실려 온 아이디로Link를 만들어links=로 겁니다. 두 스팬 모두messaging.message.id속성에 메시지 아이디를 적습니다./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에 건수를 적습니다. 이 스팬도 트레이스의 루트여야 합니다./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에 메시지마다 한 줄씩<메시지 아이디><탭><기다린 밀리초 소수 첫째 자리>를 적으세요(세 줄)./root/tp-links/kinds.py를 만드세요(기본 덤프 경로/root/tp-links/05-kinds.jsonl, 큐 파일05-q.jsonl). 4단계의 흐름에 스팬 종류를 붙입니다 —order.publish는SpanKind.PRODUCER,order.process는SpanKind.CONSUMER, 감싸는order.submit은 그대로 둡니다. 그리고 두 메시징 스팬에messaging.system(labbus)·messaging.destination.name(orders)·messaging.operation.type(생산은send, 소비는process)·messaging.operation.name·messaging.message.id를 답니다. 그다음/root/tp-links/05-kinds.tsv에 세 줄을 적으세요 — 각 줄은<스팬 이름><탭><종류><탭><operation.type>이고 순서는order.submit,order.publish,order.process이며order.submit의 셋째 칸은-입니다./root/tp-links/linkattrs.py를 만드세요(기본 덤프 경로/root/tp-links/06-linkattrs.jsonl, 큐 파일06-q.jsonl). 5단계와 같은 흐름이되Link를 만들 때 두 번째 인자로 속성을 함께 넘깁니다 —link.relation에queue.message,messaging.message.id에 그 메시지의 아이디,messaging.destination.name에orders를 적습니다. 덤프의links칸마다 이 세 속성이 들어 있어야 합니다./root/tp-links/fanout.py를 만드세요(기본 덤프 경로/root/tp-links/07-fanout.jsonl, 큐 파일07-q.jsonl). 6단계의 규칙을 그대로 쓰되 흐름을 늘립니다 —order.submit이 메시지 3건을 만들고, 소비 쪽에서 주문A-1002를 처리하는order.process스팬 안에서 두 건을 더 만듭니다(스팬 이름invoice.publish와email.publish, 메시지 아이디inv-2와eml-2). 잠시 기다린 뒤 그 두 건을 꺼내invoice.process와email.process로 각각 처리하며, 이들도 새 트레이스의 루트이고 링크로 앞의 생산 스팬을 가리킵니다. 덤프에는 트레이스가 6개 들어 있어야 합니다./root/tp-links/journey.py를 만드세요.python3 journey.py <덤프> <메시지아이디>로 돌리면 그 메시지를 만든order.publish스팬에서 출발해, 링크로 이어진 스팬을 홉 단위로 따라가며 한 줄씩<홉><탭><trace_id><탭><스팬이름>을 찍습니다. 출발 스팬이 홉 0 이고, 다음 홉은 앞 홉의 스팬 또는 그 스팬의 자손을 링크로 가리키는 스팬들이며 같은 홉 안에서는 스팬 이름 오름차순으로 찍습니다. 더 따라갈 곳이 없으면 멈춥니다. 이 프로그램을/root/tp-links/07-fanout.jsonl과m-2로 돌린 출력을/root/tp-links/08-journey.tsv에 저장하세요.
참고
- 작업 디렉터리는
/root/tp-links입니다. 없으면 먼저 만드세요. - 계측 프로그램은 반드시
/opt/otel-lab/bin/python <파일>로 돌립니다. 시스템python3에는 OpenTelemetry SDK 가 없습니다. 덤프만 읽는 프로그램은 시스템python3로 돌리세요. - 재료는
/opt/app/tracelab/tp_links/bus.py(파일 한 개로 된 큐)이고 공용 배선은/opt/app/tracelab/dump.py, 덤프 읽기 도우미는/opt/lab/checks/_tplib.py입니다. 큐 파일은 단계마다 따로 두고 시작할 때 비웁니다. - 흔한 실수: 소비 반복문을
order.submit블록 안에 둔 채 링크만 거는 것. 그러면 링크도 있고 부모도 있어 트레이스가 여전히 하나로 붙습니다. 덤프의parent_id가 비어 있는지로 확인하세요. - Traces (OpenTelemetry Concepts) · Tracing API 명세 · 메시징 스팬 시맨틱 컨벤션 · 메시징 속성 레지스트리 · Python 계측 문서
생산과 소비를 부모-자식으로 이으면 무슨 일이 나는가
/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.py 의 covered_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_id 와 span_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.publish 는 SpanKind.PRODUCER, order.process 는 SpanKind.CONSUMER, 감싸는 order.submit 은 그대로 둡니다. 그리고 두 메시징 스팬에 messaging.system(labbus)·messaging.destination.name(orders)·messaging.operation.type(생산은 send, 소비는 process)·messaging.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.relation 에 queue.message, messaging.message.id 에 그 메시지의 아이디, messaging.destination.name 에 orders 를 적습니다. 덤프의 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.publish 와 email.publish, 메시지 아이디 inv-2 와 eml-2). 잠시 기다린 뒤 그 두 건을 꺼내 invoice.process 와 email.process 로 각각 처리하며, 이들도 새 트레이스의 루트이고 링크로 앞의 생산 스팬을 가리킵니다. 덤프에는 트레이스가 6개 들어 있어야 합니다.
두 번째 홉의 생산 스팬은 소비 스팬의 자식입니다 — 같은 트레이스 안의 동기 호출이니 부모-자식이 맞습니다. 링크로 건너뛰는 것은 큐를 지날 때뿐입니다. 어느 관계를 무엇으로 이을지가 이 단계의 전부이고, 트레이스 수를 세어 보면 제대로 나뉘었는지 바로 알 수 있습니다.
링크를 따라가 여정을 다시 이어 붙인다
/root/tp-links/journey.py 를 만드세요. python3 journey.py <덤프> <메시지아이디> 로 돌리면 그 메시지를 만든 order.publish 스팬에서 출발해, 링크로 이어진 스팬을 홉 단위로 따라가며 한 줄씩 <홉><탭><trace_id><탭><스팬이름> 을 찍습니다. 출발 스팬이 홉 0 이고, 다음 홉은 앞 홉의 스팬 또는 그 스팬의 자손을 링크로 가리키는 스팬들이며 같은 홉 안에서는 스팬 이름 오름차순으로 찍습니다. 더 따라갈 곳이 없으면 멈춥니다. 이 프로그램을 /root/tp-links/07-fanout.jsonl 과 m-2 로 돌린 출력을 /root/tp-links/08-journey.tsv 에 저장하세요.
두 번째 홉으로 넘어가려면 자손까지 봐야 합니다 — 청구서 메시지를 만든 스팬은 order.process 의 자식이지 order.process 자신이 아니기 때문입니다. 채점기는 여러분의 프로그램을 다른 메시지 아이디로도 돌려 보므로 m-2 를 코드에 박아 두면 안 됩니다. 덤프를 읽는 일에 otel 은 필요 없습니다.