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건에서 인접 프레임 두 개의 순서가 바뀌었지만, 프레임 유실은 없었음.
  • 서버는 닫기 프레임을 받더라도 스트림을 삭제하지 않으므로 프레임이 알 수 없는 스트림으로 처리되어 버려지지 않음.

인용 및 서지 정보