1P by GN⁺ 12시간전 | ★ favorite | 댓글 1개
  • 고성능 웹 서버를 만들기 위해 기존에는 select(), poll(), epoll 등 다양한 이벤트 기반 모델이 사용됨
  • 하지만 이러한 시스템콜들의 성능 한계로 io_uring이 등장, 요청을 큐에 넣어 커널이 비동기로 처리하는 방식을 도입함
  • kTLS는 커널이 TLS 암호화 처리를 담당, sendfile() 사용 가능성과 하드웨어 오프로딩 등 추가적인 최적화가 가능해짐
  • Descriptorless files의 도입으로 파일디스크립터를 직접적으로 전달하지 않으면서 io_uring에 최적화된 접근 방식 제공함
  • Rust, io_uring, kTLS를 결합한 tarweb 오픈소스 프로젝트를 통해 요청별로 추가 시스템콜 없이 HTTPS를 제공, 안전성과 메모리 관리에 관한 이슈도 논의함

고성능 웹 서버 구조의 진화

  • 2000년대 초반부터 고용량 웹 서버에 대한 요구가 증가함
  • 초기에는 각 요청마다 새로운 프로세스를 생성하는 방식이 일반적이었으나, 이는 높은 비용 문제로 인해 preforking 기법이 등장함
  • 이후 스레드 도입 및 select(), poll() 활성화를 거쳐 컨텍스트 스위칭 비용을 줄이는 방식으로 발전함
  • 다만, select()와 poll() 방식도 연결 수가 많아질수록 커널에 큰 배열을 빈번히 전달해야 하므로 확장성에 한계가 존재함

epoll의 등장

  • Linux 환경에서는 epoll이 도입되어 기존 방식보다 효율적인 다중 연결 처리가 가능해짐
  • epoll은 변경점(델타)만 처리하여 불필요한 리소스 소모를 줄임
  • 모든 시스템콜이 완전히 없어지지는 않지만, 비용이 상당히 줄어듦

io_uring 개요

  • io_uring은 각 요청마다 시스템콜을 호출하는 대신, 커널이 비동기적으로 처리할 수 있도록 요청을 메모리 상 큐에 추가함
  • 예를 들어, accept()를 큐에 넣어두면 커널이 처리 후 완료 큐에 결과를 반환함
  • 웹서버는 큐에 요청을 추가하고, 결과는 별도 메모리 영역에서 확인하는 방식으로 작동함
  • 바쁜 루프(busy loop)를 피하기 위해, 큐에 변화가 없으면 웹서버와 커널 모두 필요한 경우에만 시스템콜을 호출하여 절전 효과를 얻음
  • 적절한 라이브러리를 활용하면, 활성화된 서버는 요청 처리 중 별도의 시스템콜 없이 작동 가능함

멀티 코어와 NUMA 환경

  • 현대 CPU의 다중 코어 환경을 고려해 코어별 단일 스레드 실행 및 데이터 구조의 공유를 최소화하는 전략이 유효함
  • NUMA 환경에서는 각 스레드가 자신의 로컬 노드 메모리에만 접근하여 최적화
  • 요청 분배의 완벽한 균형은 추가 연구가 필요함

메모리 할당

  • 커널과 웹서버 모두에서 메모리 할당이 남아있으며, 사용자 공간에서의 할당도 결국 시스템콜로 연결됨
  • 웹서버 단에서는 연결당 고정 크기의 메모리 블록을 미리 할당하여 파편화 및 부족 문제를 예방함
  • 커널 측에서도 연결별로 입출력 버퍼가 필요하며, 소켓 옵션 등으로 일부 조정 가능함
  • 메모리 부족 현상 발생 시 심각한 장애로 이어질 수 있음

kTLS(커널 TLS) 소개

  • kTLS는 Linux 커널에서 암호화 및 복호화 연산을 담당하는 기능임
  • 핸드셰이크는 애플리케이션에서 처리하지만, 그 이후로는 커널이 순수 텍스트처럼 데이터 전송을 처리함
  • sendfile() 사용이 가능해져 유저-커널 공간 간 메모리 복사를 줄일 수 있음
  • 네트워크 카드가 지원할 경우, 암호화 연산까지 하드웨어에 오프로딩할 수 있는 이점이 있음

Descriptorless Files

  • 사용자 공간에서 커널 공간으로 파일디스크립터를 직접 전달할 때 발생하는 오버헤드를 줄이기 위해 등장한 방식임
  • register_files를 이용해 io_uring에만 유효한 별도의 '정수' 파일번호를 사용하며, /proc/pid/fd에는 표시되지 않음
  • 시스템의 ulimit 제한은 여전히 적용됨

tarweb 프로젝트 소개

  • tarweb은 위 모든 기술을 적용한 예시 웹서버 오픈소스 프로젝트임
  • 단일 tar 파일 내용을 제공하는 구조로, Rust, io_uring, kTLS 등 최신 고성능 기술이 결합되어 있음
  • 실사용 과정에서 io_uring과 kTLS의 호환성 문제(setsockopt 미지원 등)가 있어 Pull Request로 일부 이슈를 해결함
  • 프로젝트는 아직 미완성 단계이며, Rust의 rustls 라이브러리가 핸드셰이크 과정에서 메모리 할당을 수행할 수 있음
  • 핵심은 각 요청별 추가 시스템콜 없이 HTTPS 서비스가 가능하다는 점임

벤치마크 및 성능 측정

  • 저자는 아직 충분한 벤치마크를 진행하지 않았으며, 코드 정비 후 성능 테스트 예정임

io_uring과 Rust의 안전성 문제

  • 동기식 시스템콜과 달리, io_uring에서는 완료 이벤트 이전까지 메모리 버퍼가 해제되지 않아야 함
  • io-uring 크레이트는 Rust의 컴파일 타임 안전성을 보장하지 않으며, 런타임 체크도 부족함
  • 잘못 사용 시 C++과 유사하게 심각한 문제까지 이어질 수 있어, Rust 본연의 안전성이 약화됨
  • pinning과 borrow checker를 적극적으로 활용하는 별도의 safer-ring 크레이트가 필요함
  • 이 문제는 이미 커뮤니티에서 논의 중임

참고 및 추가 링크

  • 본 내용은 2025-08-22 기준 HackerNews에서 논의된 포스트임
Hacker News 의견
  • io_uring을 사용해서 쓰기 작업을 제출할 때, 메모리 위치가 해제되거나 덮어쓰기 되지 않도록 해야 하는데, io-uring crate API는 Rust의 borrow checker가 이 부분에서 도움을 주지 않고 런타임 체크도 없는 것으로 보임
    이런 상황에 대해 쓴 글과 댓글들을 봤는데, 결과적으로 io_uring을 감싼 안전한 Rust 비동기 라이브러리 만들기가 정말 어렵다는 인상임
    tokio 팀의 Alice가 최근에는 이 문제를 극복하려는 관심이 크지 않다고 언급한 것도 기억남
    지금 성능이 "충분히 좋음" 상태이기 때문임
    참고: https://boats.gitlab.io/blog/post/io-uring/

    • Rust async에 대해 아쉬운 점이 많은데, 그중 하나가 이런 부분임
      Rust async는 epoll이 표준이던 시기에 설계됐고 IOCP에는 신경을 거의 안 썼음
      동기 syscall에는 이런 문제가 없는 이유는, read 호출 시 버퍼의 가변 참조를 커널에 넘기지만 네이티브 Rust의 ownership/borrow 모델과 잘 맞음
      그런데 completion-based IO는 소유권 모델에 제대로 맞추려면, 작업이 완료될 때까지 사용자 코드가 계속 실행되지 않음을 보장해야 하고, 이걸 state machine polling 구조로는 할 수 없음
      스레딩 모델이나 green thread 구조가 여기서는 딱 맞음
      만약 Rust가 "async 전용 타겟"을 추가했다면 더 나았을 것 같음
      Rust 개발진이 비동기 stackless polling 모델에 많은 기대를 걸었는데, 그 결말을 지켜보고 있는 중임

    • Rust의 borrow checker가 제대로 지원하지 못하는 소유권 모델이 있다고 생각함
      임시로 “핫 포테이토 소유권”이라고 부르는데, 버퍼를 잠시 넘겨줬다가 다시 돌려받는 구조임
      Rust로 이런 패턴을 안전하게 코드로 짜려니 되게 어렵고 코드가 난잡해짐

    • tokio팀 Alice의 말과는 달리, 파일 IO 쪽에선 관심이 있음
      파일 IO는 이미 spawn_blocking 방식으로 구현해서 io_uring의 동일한 버퍼 이슈를 겪고 있고, io_uring으로 옮기는 건 그리 어렵지 않음
      하지만 tokio::net의 기존 API는 io_uring 기반 버퍼 API와 호환되지 않아서, readiness 체크는 할 수 있어도 완전한 지원은 어려움

    • 안전한 io_uring 인터페이스를 만들려면, 링에서 소유한 버퍼를 받아 쓰고, 쓰기를 시작할 때 다시 돌려주는 방식이 가장 적합하다고 생각함

    • 꼭 모든 것을 borrows로 표현할 필요 없음
      Slab 같은 데이터 구조를 사용하면 cancel safe하게 만들 수 있음
      참고: https://github.com/steelcake/io2

  • 이번 글 정말 재밌게 읽었음
    성능 테스트가 기대되지만, 작성자가 벤치마크보다 먼저 코드를 깔끔하게 정비하겠다고 한 점이 인상 깊었음
    벤치마크만 중시하는 이 시대에 이렇게 고민하는 사람이 있다는 게 신선함
    11살 무렵 데이터베이스를 구축하려는 시도에서 cgi-bin을 접했고, 그게 요청마다 새 프로세스를 띄우던 방식이었다는 걸 이제야 깨달음
    sendfile이 대형 게임 포럼에서 데모 다운로드를 동시에 처리할 때 게임체인저였고, Netflix의 40ms 감소 사례나 GTA 5의 70% 로딩 타임 단축 등 결과를 보며 더 임팩트 있는 엔지니어링이 숨겨져 있다고 느낌
    관련 링크: Common Gateway Interface, Netflix 40ms 사례, GTA Online 로딩 단축

    • CGI뿐 아니라 옛날 CERN, Apache 계열 HTTP 세션은 서버 전체를 포크해서 동작시켰음
      시간이 지나면서 나아졌지만, Apache의 구성 방식 때문에 nginx처럼 애초에 이벤트 기반 I/O로 설계된 경량 서버가 큰 인기를 얻게 됨

    • sendfile의 효율성에는 회의적임
      90년대 말에 유행하긴 했지만 실제로는 성능 이득이 미미하다고 생각함

  • 대부분의 클라우드 워크로드 오케스트레이터(CloudRun, GKE, EKS, 로컬 Docker 등)는 io_uring을 기본적으로 비활성화함
    이 부분이 개선되지 않으면 당분간은 io_uring이 매우 한정된 기술로 남을 것 같음

    • 왜 그들은 io_uring을 비활성화 하는 걸까라는 의문이 듬

    • 이런 상황이라면 다시 셀프 호스팅으로 돌아가야 함

  • 정말 재미있게 읽었음
    벤치마크를 기다릴 테니 천천히 해도 되고, 벤치마크보다 먼저 코드 정리를 중시하는 저자의 마인드가 너무 인상적임
    요즘은 벤치마크 점수에 올인하는 프로젝트가 많은데, 이런 사고방식이 정말 신선하고 존경스러움
    ktls나 io_uring이 이렇게까지 다양하게 쓰일 수 있는지는 몰랐음

  • 현 시점의 비동기 처리 상황은 아래와 같음
    Rust: Futures, Pin, Waker, async runtime, Send/Sync bounds, async 트레이트 오브젝트 등 다양한 개념 이해 필요
    C++20: coroutines
    Go: goroutines
    Java21+: 가상 스레드

    • C++ 코루틴은 Pin이 해결하는 문제를 피하려고 힙 할당을 사용함
      이건 C++가 추구하는 "제로 오버헤드" 원칙에서 크게 벗어난 부분임
      Rust가 미래에도 async 트레이트를 도입하는 데 시간이 오래 걸렸던 이유도 Rust는 futures를 힙 할당하지 않기 때문임
      퍼포먼스/이식성 대 복잡성의 트레이드오프가 각자 프로젝트에 따라 가치가 달라질 수 있음

    • Send/Sync 관련 제약은 다른 언어에도 여전히 의미가 있고, 해당 제약이 없으면 미묘하게 잘못된 코드를 더 쉽게 쓸 수 있음

    • "충분히 괜찮은" 수준의 Rust 코드를 쓴다면, 그리고 남이 만들어놓은 mid-level 프리미티브를 사용한다면, 굳이 저런 개념을 전부 알 필요는 없음

    • Rust는 저런 개념을 이해하지 않으면 아예 컴파일이 안되도록 강제함
      Go는 goroutine이 비동기가 아니고, 채널을 이해하지 않으면 goroutine을 이해할 수 없음
      Go의 채널 구현은 독특해서 경계 사례의 동작이 상식적으로 예측이 잘 안 됨
      Go는 깊이 이해 안 해도 코딩이 되니, 장단점이 있음
      "저렴한 스레드"는 비동기와 동일하지 않음
      tarweb(블로그에 나온 서버)은 io_uring 기반 이벤트 루프의 싱글 스레드 구조로, CPU 코어당 하나씩 스레드를 두는 아이디어임
      "대규모 동시성의 현주소"보단 "저렴한 스레드의 현주소"가 더 맞는 말일 듯함
      cheap thread와 async loop의 가장 큰 차별점은 reasoning이 쉬운 것임
      단점도 있는데, 각 스레드는 가볍긴 해도 스택 크기를 필요로 함

  • kTLS가 확실히 발전임
    나도 몇 년 전에 진짜로 요청당 syscall이 0인 서버를 만들어서 블로그에 글로 남김 (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
    다만 항상 busy-looping을 해야 한다는 단점이 있음
    io_uring은 최근 몇 년간 정말 인상적인 속도로 발전해왔음

  • 이 프로젝트가 정말 멋지고 오래 전부터 비슷한 걸 구상해왔기 때문에 누군가 구현해서 기쁨
    BPF도 rust로 작성하려면 Aya를 추천함
    Aya 프로젝트 Github

  • kTLS의 현재 상태가 궁금함
    얼마 전 Cilium 개발자에게 물어보니, Thomas Graf는 기대한다고 했지만 실제로는 많은 리눅스 배포판에서 커널 지원이 부족해 기본 활성화는 아직 멀었다고 함

    • 아쉽긴 한데, 활성화가 얼마나 어려운지도 궁금함
      커널을 커스텀해야 하는 건지, 아니면 런타임에서 바로 켤 수 있는 건지
      FreeBSD는 13버전부터 커널/openssl에 kTLS가 들어갔고, sysctl (kern.ipc.tls.enable=1)로 런타임 토글이 가능함
      FreeBSD-15에서는 기본값이 활성화로 바뀌고, Netflix에서는 거의 10년 동안 트래픽 암호화에 kTLS를 써옴

    • kTLS는 전반적으로 나쁜 아이디어처럼 느껴짐

  • 한 코어당 하나의 스레드 구조가 타임 슬라이스 기반 시스템에서 맞는지 의문임
    내 경험상으로는 "오버서브스크라이빙" 방식(코어 수보다 스레드를 더 두는 것)이 실제 벽 시계 시간 상의 이득을 줌
    프리엠티브 스케줄링이 없을 때나 한 코어당 하나가 더 맞을 듯함
    물론 그럼 Unix 얘기는 아님

    • 낮은 지연과 높은 처리량을 원한다면 코어를 격리해서 스레드를 고정시키는 방법이 효과적임
      이런 방식은 Linux에서 잘 작동하고, 트레이딩 시스템 등에서는 비효율을 감수하고서라도 많이 씀
      코어들이 대부분 유휴 상태로 spin하고 실제로는 일이 없지만, latency와 throughput에서는 최적임

    • thread-per-core 구조의 함정은 "편리한 부분만 가져다 쓰자"라고 착각하는 거임
      사실상 올인하거나 안 쓰거나 둘 중 하나임
      반쪽짜리 구현은 전혀 효율이 나지 않음
      다만 올바르게 설계하면 거의 모든 상황에서 효율이 높음
      TPC 설계 노하우(코어 간 로드밸런싱 등)을 잘 아는 개발자는 드묾

    • thread-per-core에서 "CPU 바운드"일 때만 효율적임
      이 서버 프로젝트처럼 대부분의 작업이 비동기적이고 이벤트 기반일 때, 서버는 거의 I/O나 syscall 대기 없이 다음 요청으로 이동해서 이론적으로는 코어당 하나의 스레드가 정확한 구조임
      하지만 현실 세계에선 이런 이상적 상황이 거의 없으니 무조건 nproc 스레드로 제한하는 건 위험하다는 점을 명심해야 함

    • io_uring에서는 한 코어당 사용자 스레드 하나만 두는 것도 나쁜 선택이 아니라고 보임
      커널에서 쓰레드 풀로 동작하기 때문임

  • DPDK와 같이 커널을 완전히 우회하는 스타일도 보고 싶음