TL;DR
- Rust 기반 컨테이너 런타임에서 로그가 간헐적으로 마지막 항목을 출력하지 못하는 문제는
ttrpc-rust클라이언트의 프레임 처리 순서 때문에 발생하며, 닫기 프레임이 마지막 데이터 프레임보다 먼저 처리되면 페이로드가 조용히 유실됨. ttrpc는 같은 호스트의 프로세스 간 통신에 주로 쓰이며, 10바이트 헤더와 프로토콜 버퍼 페이로드를 사용하고 흐름 제어·핑·핸드셰이크 기능이 없음.- 클라이언트가 수신 프레임마다 작업을 생성하면 작업 순서가 보장되지 않아 닫기 프레임이 스트림을 먼저 제거할 수 있음.
- 프레임을 읽는 작업에서 바로 처리하면 순서는 지켜지지만, 용량이 100개인 제한된 다중 생산자·단일 소비자 채널이 가득 찼을 때 연결 전체에 헤드오브라인 블로킹이 생김.
- 더 나은 해결책은 단일 리더가 프레임을 와이어 순서대로 각 호출의 무제한 메일박스에 넣는 방식이며, 읽지 않는 스트림은 메모리를 계속 소비할 수 있음.
동기
- 인공지능(AI) 기반 프로그래밍이 확산되기 전에는 안정적인 소프트웨어 스택의 구성 요소가 하나의 언어로 작성되는 것이 일반적이었으며, 컨테이너 생태계의
containerd, shim,runc는 Go로 작성됨. - 대형 언어 모델(LLM)을 활용하는 시대에는 새로운 언어가 생태계에 진입해 안정적인 런타임을 빠르게 구축할 수 있으며, Rust를 이 생태계에 더 많이 도입하려는 시도가 이어짐.
- Rust로 작성된 컨테이너 런타임에서 실행 중인 파드의 로그 스트림이 간헐적으로 갑자기 끝나는 현상을 발견함.
- 1부터 100까지 출력하는 파드를 추가로 배포하자 로그가 때때로 99에서 끝났지만, 97이나 98에서 끝나는 경우는 없었음. 처음에는 런타임 버그로 생각했으나, 문제는 더 깊은 스택의 프로토콜 구현에서 발견됨.
- 이 문서는
containerd/ttrpc-rust#312변경 요청을 바탕으로ttrpc내부 구조와 버그가 나타나는 방식을 정리함.
ttrpc 프로토콜 개요
ttrpc는 단순하고 메모리 사용량이 적으며 바이너리 크기가 작은 원격 프로시저 호출(RPC) 프로토콜임. 네트워크를 통한 호출이 아니라 같은 호스트에 있는 프로세스 간 통신을 위한 설계임.- 주로
containerd와 shim, Kata Containers 런타임과 가상 머신 내부 에이전트 등 컨테이너 런타임 구성 요소 사이에서 사용됨. - gRPC 서비스 정의의 프로토콜 버퍼를 재사용하지만 HTTP/2를 기반으로 하지 않으며 전송 계층 보안(TLS)도 없고, gRPC와 같은 와이어 프로토콜을 사용하지 않음.
- 경량 프레이밍 프로토콜을 사용하며, 각 메시지는 10바이트 헤더 뒤에 프로토콜 버퍼 페이로드가 붙고 하나의 연결에서 여러 호출이 별도 스트림으로 전달됨.
- 신뢰할 수 없는 연결을 처리하는 핸드셰이크, 리셋, 핑, 흐름 제어 기능이 없음. 제어 프레임 대신 두 개의 플래그로 스트림을 열고 닫음.
버그의 발생 방식
- 이 버그는 Tokio의 멀티스레드 런타임(
rt-multi-thread)에서 발생함. - 클라이언트는 들어오는 프레임마다 작업을 생성하며, 각 작업의 실행 순서는 보장되지 않음.
- 닫기 프레임(
flags 05)이 처리되면 클라이언트의 스트림 맵에서 해당 스트림 식별자가 제거됨. - 마지막 데이터 프레임을 처리하는 작업보다 닫기 프레임 작업이 먼저 실행되면, 데이터 프레임의 페이로드가 조용히 버려짐.
- 기존 구현은 각 프레임을 처리할 때마다
tokio::spawn으로 작업을 만들고, 스트림 응답 채널을 찾아 메시지를 전달하는 방식임.
단순한 수정안
- 프레임마다 새 작업을 생성하는 방식을 제거하고, 리더 작업이 읽은 순서대로 각 프레임을 직접 처리하는 방식이 제안됨.
- 리더가 각 프레임 처리를 기다리므로 닫기 프레임이 앞선 데이터 프레임보다 먼저 처리되지 않음.
- 그러나 이 수정안은 완전하지 않음. 리뷰어는 연결 전체에 영향을 주는 헤드오브라인 블로킹 문제를 지적함.
- 각 호출의 프레임은 용량 100개인 Tokio 다중 생산자·단일 소비자(mpsc) 채널을 거쳐 호출자에게 전달되며, 채널이 가득 차면
send().await가 대기함. - 리더가 직접 전송을 수행하면 채널 하나가 가득 찼을 때 리더가 멈추고, 같은 연결의 다른 모든 호출도 그 뒤에서 대기함.
- 리뷰어는 아무도 읽지 않는 200개 프레임 스트림을 둔 채 관련 없는 단항 RPC를 실행하는 재현 사례를 제시했으며, 이 수정안에서는 해당 RPC가 시간 초과됨.
더 나은 수정안
- 각 호출에 리더만 쓰는 무제한 큐인 개별 메일박스를 할당함.
- 단일 리더가 모든 프레임을 와이어 순서대로 처리하며, 프레임마다 새 작업을 생성하지 않음.
- 리더는 프레임을 메일박스에 추가하고 다음 프레임으로 이동하므로 호출자가 느려도 기다리지 않으며, 한 스트림의 지연이 다른 스트림을 멈추지 않음.
- 닫기 프레임도 데이터 프레임 뒤에 같은 메일박스에 들어감.
- 아무도 읽지 않는 스트림은 메모리를 계속 소비할 수 있음.
ttrpc에는 흐름 제어가 없으므로 하드 제한을 두면 연결을 막거나 해당 스트림에 오류를 발생시켜야 함.
클라이언트와 서버의 비대칭성
- 서버도 프레임을 Tokio 작업에 전달하지만, 이 수정이 필요한 쪽은 클라이언트뿐임.
- 송신은 스트림의 프레임을 한 번에 하나씩 대기하며 전송하고, 단일 작성기가 하나의 큐를 비우므로 순서가 유지됨.
- 기존 클라이언트는 수신 프레임마다 작업이 자유롭게 실행되도록 했고, 닫기 작업이 스트림을 삭제하면서 앞선 페이로드가 유실될 수 있었음.
- 서버 리더는 각 프레임의 작업이 시작될 때까지 기다리므로 프레임 순서가 바뀔 수 있는 시간 범위가 매우 짧음.
- 병합된 코드의 스트레스 테스트에서는 클라이언트 스트리밍 호출 약 2,000건 중 1건에서 인접 프레임 두 개의 순서가 바뀌었지만, 프레임 유실은 없었음.
- 서버는 닫기 프레임을 받더라도 스트림을 삭제하지 않으므로 프레임이 알 수 없는 스트림으로 처리되어 버려지지 않음.
인용 및 서지 정보
- Shiv Bhosale, “Data loss in ttrpc-rust”, notes.shvbsle.in, 2026. https://notes.shvbsle.in/ttrpc-data-loss
- 서지 정보의 URL: https://notes.shvbsle.in/ttrpc-data-loss/
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요