Zuul 2 : 비동기, 논-블록킹 시스템에 대한 넷플릭스 여정 (번역)
2016년 9월 21에 넷플릭스 블로그에 올라온 ‘Zuul 2 : The Netflix Journey to Asynchronous, Non-Blocking Systems‘의 번역글입니다. 오탈자와 오역에 대한 지적은 메일(chanwook.god@gmail.com)로 주시면 바로바로 반영하도록 하겠습니다.
번역 검수를 맡아준 구글 번역기에 감사드립니다^^.
중요 용어인 Blocking은 ‘블록킹’으로 Non-Blocking은 ‘논-블록킹’으로 번역하였습니다. 마땅히 한글 표현을 찾기 어려웠습니다.
최근 넷플릭스의 클라우드 게이트웨이인 Zuul에 중요한 아키텍처 변화가 있었습니다. 아무도 눈치 못 챘나요!? 아마도 없겠죠… Zuul 2는 이전 Zuul과 같은 일(역할)을 합니다. 바로 넷플릭스 서버 인프라스트럭처의 정문 역할을 하면서, 전세계 모든 넷플릭스 사용자의 트래픽을 담당합니다. 또한 요청을 라우팅하고 개발자의 테스팅과 디버깅을 지원하고, 전반적인 서비스 상태에 대한 심도있는 통찰력을 제공하며, 공격으로부터 넷플릭스를 보호하고, AWS 리전에 문제가 생겼을 때 다른 클라우드 리전으로 트래픽 경로를 바꿔줍니다. Zuul 2와 기존 버전 사이의 중요한 아키텍처 차이점은 Zuul 2가 Netty를 사용해 비동기와 논-블록킹 프레임워크 기반으로 실행된다는 점입니다. 지난 몇 달간 실 환경에서 돌려보니 주요 이점으로 넷플릭스 규모에서 디바이스와 웹 브라우저가 넷플릭스와 지속적인 연결을 맺을 수 있는 능력을 Zuul이 제공한다는 점입니다. 여러 개의 디바이스를 연결해서 사용하는 8천 3백만 이상의 회원이 있기 때문에 이것 만으로도 엄청난 규모의 도전입니다. 클라우드 인프라스트럭처에서 영속적인 커넥션(persistent connection)을 유지함으로서 다양하고 흥미로운 제품 기능과 혁신이 가능해졌고, 평균 디바이스 요청을 줄일수 있었으며, 디바이스 성능을 향상시키고 고객 경험을 더 잘 이해하고 디버깅할 수 있었습니다 (역주: 영속적인 커넥션(persistent connection)이 HTTP의 Persistent Connection을 의미하는 건가 싶지만 문맥상으로는 그것 보다는 사용자 별로 서버와 커넥션을 끊지 않고 유지하는 구조 자체를 의미하는 걸로 이해했습니다). 또한 Zuul2가 복원력(resiliency)이란 혜택과 대기시간(latency) 측면에서 성능 향상, 처리량(throughput), 그리고 비용 개선을 해주기를 기대했습니다. 그러나 이전 글에서 배웠던것처럼 이런 열망과 결과는 달랐습니다.
블록킹과 논-블록킹 시스템 간의 차이
왜 Zuul 2를 만들었는지 이해하기 위해서는 비동기와 논-블록킹(“async”)과 멀티쓰레드, 블록킹(“blocking”) 시스템 간의 차이를 먼저 이해해야 합니다. 이론과 실전 측면에서 모두 이해해야 합니다.
Zuul 1은 서블릿 프레임워크 기반으로 만들어졌습니다. 서블릿 시스템은 블록킹과 멀티쓰레드이며, 커넥션 당 하나의 쓰레드를 사용해서 요청을 처리한다는 걸 의미합니다. I/O 운영은 I/O 실행 시에 쓰레드 풀에서 작업 쓰레드를 선택해 수행되며, 요청 쓰레드는 작업 쓰레드가 완료될 때까지 차단됩니다(blocked). 작업 쓰레드는 작업이 완료되면 요청 쓰레드에게 알려줍니다. 이러한 방식은 각 인스턴스가 100개의 동시 커넥션 처리하는 최신 멀티코어 AWS 인스턴스에서 아주 잘 동작합니다. 그러나 백엔드 대기시간이 증가하거나 디바이스가 에러 때문에 재시도를 하는 등 무언가 잘못되었을 때는 액티브 커넥션과 쓰레드의 수가 증가하게 됩니다. 이런 일이 일어나면 노드에 문제가 발생하고 쓰레드가 서버 부하를 급증시키고 클러스터를 부셔버리면서 죽음의 나선형(death spiral)에 들어갈 수 있습니다(역주:쉽게 망했다라고 이해하면 되겠습니다.. 더 좋은 번역 표현을 찾기가 어렵네요..) 이러한 위험을 상쇄하고자 넷플릭스에서는 이벤트가 발생하는 동안 블록킹 시스템을 안정적으로 유지하도록 도와주는 쓰로틀링(throttling) 메커니즘과 라이브러리(예를 들면, Hystrix)를 만들었습니다.
비동기 시스템은 일반적으로 CPU 당 하나의 쓰레드가 모든 요청과 응답을 다루는 것과는 다르게 운영됩니다. 요청과 응답의 생명주기는 이벤트와 콜백을 통해 제어됩니다. 각 요청마다 쓰레드가 있지는 않기 때문에 커넥션의 비용이 매우 저렴합니다. 대신 파일 서술자(file descriptor)와 리스너 추가 비용이 들어 갑니다. 블록킹 모델에서 커넥션의 비용은 쓰레드에 있고 메모리와 시스템 과부하가 큽니다. 데이터가 같은 CPU에 유지되니 CPU 수준의 캐시를 좀더 사용하며 컨텍스트 전환이 더 적기 때문에 일정 부분 효율성을 얻게 됩니다. 또한 백엔드 대기시간과 “재시도 폭풍(retry storms)”(고객과 디바이스가 문제가 발생했을 때 재시도하는 요청)의 좋지 않은 결과가 시스템에 미치는 스트레스 또한 더 작습니다. 큐에서 커넥션과 이벤트 증가가 쓰레드를 쌓는 것보다 훨씬 저렴하기 때문입니다.
비동기 시스템 장점이 대단하게 들리겠지만 위 혜택은 운영 비용이 발생합니다. 블록킹 시스템은 이해하고 디버깅하기 쉽고, 쓰레드는 항상 하나의 작업을 처리하기 때문에 쓰레드의 스택은 요청 또는 생성된 작업의 처리 과정에 대한 정확한 스냇샵이 됩니다. 그리고 쓰레드 덤프는 잠금에 따라 여러 쓰레드에 걸친 요청을 따라서 읽을 수 있습니다. 던져진 예외가 스택을 일으키게 됩니다. “포괄적인(catch-all)” 예외 핸들러는 명시적으로 예외를 잡지 않아도 모든 예외를 처리해 줄 수 있습니다.
반대로 비동기는 콜백 기반이며 이벤트 반복으로 동작합니다. 이벤트 반복의 스택 트레이스에서는 요청을 따라서 추적하는 것이 아무런 도움이 되지 않습니다. 이벤트와 콜백이 처리될 때 요청을 따라가기가 어렵고, 이러한 문제를 디버깅하도록 지원하는 도구는 매우 부족합니다. 예외를 처리하지 못하거나 부정확하게 처리되는 상태 변화와 같은 극단적인 경우(Edge cases)로 인해 결과적으로 리소스를 기다리게 만들어 ByteBuf 누수, 파일 서술자(file descriptor) 누수, 응답 유실 등이 발생합니다. 이러한 이슈의 유형은 디버깅하기 정말 어렵다는 것이 증명되었습니다. 어떤 이벤트가 적절하게 처리되고, 어떤 이벤트는 적절하지 못하게 처리되었는지를 알기 어렵기 때문입니다.
논-블록킹 Zuul 만들기
넷플릭스 인프라스트럭처에서 Zuul 2를 만드는 건 예상보다 더 어려웠습니다. 넷플릭스 생태계의 많은 서비스는 블록킹이란 가정으로 만들어졌습니다. 넷플릭스의 핵심 네트워킹 라이브러리 역시 블록킹 아키텍처라는 가정으로 만들어졌습니다. 많은 라이브러리가 쓰레드 로컬 변수를 사용해서 요청에 대한 컨텍스트를 구성하고 저장합니다. 여러 요청이 동일한 쓰레드에서 처리되는 비동기 논-블록킹 영역에서는 쓰레드 로컬 변수가 동작하지 않습니다. 결과적으로 Zuul 2를 만들면서 나온 복잡성의 상당수는 쓰레드 로컬 변수가 사용되는 어두운 구석을 알아가는 과정에서 발생했습니다. 또 다른 도전은 블록킹 네트워킹 로직을 논-블록킹 네크워킹 코드로 전환하고, 라이브러리 안쪽 깊이 있는 블록킹 코드를 찾아내고, 리소스 누수를 고치고, 핵심 인프라스트럭처를 비동기로 실행하도록 전환하는데 있었습니다. 블록킹 네트웍 로직을 비동기로 전환하기 위한 한 방 전략(one-size-fits-all strategy)이란 없었습니다. 개별적으로 분석하고 리팩터링을 해야만 했습니다. 핵심 넷플릭스 라이브러리에 동일하게 적용기 위해서 일부 코드가 수정되었고, 일부는 비동기 작업을 위해 포크해서 리팩터링 해야 했었습니다. 코드 블럭과 라이브러리가 블록킹인 경우를 발견하기 위해 서버를 인스트루멘팅하는데는 오픈소스 프로젝트인 Reactive-Audit이 유용했습니다.
우리는 Zuul 2를 만드 데 매우 흥미로운 접근 방법을 사용했습니다. 블록킹 시스템은 코드를 비동기로 실행할 수 있기 때문에 먼저 Zuul 필터를 바꿔서 비동기로 실행할 수 있도록 필터 코드를 변경하기 시작했습니다. Zuul 필터는 게이트웨이 함수(라우팅, 로깅, 리저브 프록시, DDOS 보호 등)가 동작하도록 만들어주는 특수한 로직을 포함하고 있습니다. Zuul의 핵심인 기본 Zuul 필터 클래스를 리팩터링 했고, RxJava를 사용해 Zuul 필터가 비동기로 실행할 수 있도록 했습니다. 이제 I/O 연산에서 사용하는 비동기 필터와 I/O가 필요 없는 논리적인 연산을 실행하는 동기 필터라는 두 가지 유형을 함께 사용하게 됐습니다. 비동기 Zuul 필터는 블록킹 시스템과 논-블록킹 시스템에서 모두 똑같은 필터 로직을 실행할 수 있었습니다. 이로써 하나의 필터 묶음으로 작업할 수 있게 되었는데, 하나의 코드 기반에서 Netty 기반 아키텍처를 개발하는 동시에 파트너를 위한 게이트웨이 기능을 개발할 수 있었습니다. 비동기 Zuul 필터가 준비되고 나서는 Zuul 2 구축은 Zuul 인프라스트럭처의 나머지 부분을 비동기와 논-블록킹으로 실행하도록 만드는 “간단한” 문제였습니다. 동일한 Zuul 필터가 두 아키텍처 모두에 포함될 수 있습니다.
운영에서 Zuul 2 적용 결과
비동기 아키텍처 게이트웨이가 주는 혜택은 가설과 매우 달랐습니다. 일부 사람들은 컨텍스트 전환이 줄고 CPU 캐시를 좀더 효율적으로 사용하기 때문에 효율성이 크게 향상될 것이라 생각했으며, 다른 사람들은 효율이 전혀 없을 것이라 생각했습니다. 변화와 개발 노력의 복잡성에 대한 의견 또한 달랐습니다.
그렇다면 이러한 아키텍처 측면의 변화를 통해서 무엇을 얻었을까요? 그리고 그만한 가치가 있었을까요? 이 주제는 아직 뜨겁게 논쟁 중입니다. 클라우드 게이트웨이 팀은 넷플릭스에서 비동기 기반 서비스를 만들고 테스트하기 위한 노력을 개척했습니다. 비동기를 사용하는 마이크로서비스가 넷플릭스에서 작동하는 방식에 대해 이해하는 데 많은 관심을 가졌고, Zuul이 혜택을 확인할 수 있는 이상적인 서비스처럼 보였습니다.
비동기와 논-블록킹으로 마이그레이션 하면서 효율성이 크게 향상되지는 못했지만 커넥션 규모 확장의 목표는 달성했습니다. Zuul은 디바이스 간에 푸쉬와 양방향 커뮤니케이션을 가능하게 해주면서 네트워크 연결 비용을 크게 줄이는 효과를 보았습니다. 이러한 기능은 더 나은 실시간 사용자 경험의 혁신을 가능하게 해줬고, 푸쉬 알림을 통해 현재 “chatty” 디바이스 프로토콜(API 트래픽의 상당한 비중을 차지하는)을 교체함으로써 전체 클라우드 비용을 절감해주었습니다. 또한 블록킹 모델보다 조금 더 기존 시스템의 재시도 폭풍(retry storms)과 대기시간을 처리할 때 약간의 복원력 혜택을 얻었습니다. 우리는 이 분야를 지속적으로 개선할 생각입니다. 그렇지만 복원력 장점은 간단하게 얻을 수 없었으며 (역주:피나는?) 노력과 튜닝 없이는 얻을 수 없었다는 걸 기억해야 합니다.
Zuul의 핵심 비즈니스 로직을 블록킹 또는 비동기 아키텍처로 떨어트리는 능력으로 인해서 비동기와 블록킹을 비슷한 유형으로 비교했다는 점이 흥미롭습니다. 그렇다면 매우 다른 방식이지만 정확히 동일한 작업을 하는 두 시스템을 기능, 성능, 복원력 측면에서 비교하면 어떨까요? 최근 몇 달 운영 환경에서 Zuul 2를 실행한 후에 우리의 평가는 CPU에 바인딩이 더 많은 시스템 일수록 효율성이 떨어진다는 결론을 얻었습니다.
API, 플레이백, 웹사이트, 로깅과 같은 기존 서비스 앞에 몇 개의 다른 Zuul 클러스터를 가지고 있습니다. 각각의 기존 서비스는 해당 서비스에 대응하는 Zuul 클러스터를 통해서 처리하도록 되어있습니다. 예를 들어, API 서비스의 앞에 Zuul 클러스터는 메트릭 계산, 로깅, 수신하는 페이로드 복호화와 응답 압축하기를 포함하는 모든 클러스터에 대한 대부분의 온 박스(on-box, 역주: 무슨 뜻일까요?) 작업을 합니다. 이 클러스터에 적용된 블록킹을 비동기 Zuul 2로 교체한다고 해서 효율성이 향상된다고 보지는 않습니다. 용량(capacity)과 CPU 관점에서 근본적으로 동일하다면, API 앞에 있는 Zuul 서비스가 얼마나 많이 CPU를 사용하는지가 중요합니다. 또한 노드 당 거의 동일한 처리량으로 떨어지는 경향이 있습니다.(역주: Zuul2를 적용해도 노드 당 처리량을 동일하다라고 이해했습니다)
로깅 서비스와 관련된 Zuul 클러스터는 다른 성능 프로파일을 보여주었습니다. Zuul이 일반적으로 디바이스에서 받는 로깅과 분석 메시지는 쓰기가 많기 때문에 요청이 많지만, Zuul의 응답은 작고 암호화를 하지 않습니다. 결과적으로 Zuul은 이 클러스터에서는 훨씬 적은 작업을 하게 됩니다. 여전히 CPU 사용량 측면에서는 Netty 기반 Zuul 실행을 통해 CPU 사용율을 25% 줄이면 이에 맞춰서 처리량이 대략 25% 정도 증가하게 됩니다. 따라서 실제로 시스템이 하는 일이 줄어들면 비동기에 의해서 얻을 수 있는 효율성이 더 높아진다는 것을 관찰했습니다.
전반적으로 아키텍처 변경으로 얻을 수 있는 가치는 높았습니다. 커넥션 규모를 늘릴 수 있다 점이 중요한 혜택이었지만 비용이 많이 들었습니다. 우리는 디버깅하고, 개발하고, 테스트하기 훨씬 더 복잡한 시스템을 보유하고 있으며, 블록킹 시스템이란 가정에서 운영중인 넷플릭스 생태계 안에서 동작하고 있습니다. 이 생태계가 조만간 바로 변할 것 같지는 않기 때문에, 게이트웨이에 더 많은 기능을 추가하고 통합하면서 클라이언트 라이브러리 및 기타 지원코드에서 쓰레드 로컬 변수와 다른 가정을 계속 찾아갈 생각입니다. 또한 블록킹 호출을 비동기로 재작성 해야할 필요가 있습니다. 이것은 잘 정립된 플랫폼과 블록킹으로 가정해 만들어진 코드의 본체를 함께 동작시켜야 하는 아주 독특한 엔지니어링 도전입니다. 미개척지(greenfield)에서 Zuul 2를 만들고 통합하는 것이 이런 복잡성을 일부 피할 수 있게 해줬지만, 넷플릭스 생태계에서 내에서 이러한 라이브러리와 서비스가 게이트웨이와 작업의 기능에 필수적인 환경에서 운영되고 있습니다.
Zuul 2는 오픈소스로 릴리스하는 프로세스를 거치고 있습니다. 일단 릴리스 되고나면 이러한 경험에 대해 듣고 싶고 기여를 위한 공유를 기대하고 있습니다! 우리는 Zuul 2에 http/2와 웹 소켓 지원과 같은 새로운 기능 추가를 계획하고 있으며, 이러한 혁신을 통해 커뮤니티 역시 혜택을 받을 수 있도록 하겠습니다.
클라우드 게이트웨이 팀 (Mikey Cohen, Mike Smith, Susheel Aroskar, Arthur Gonigberg, Gayathri Varadarajan, Sudheer Vinukonda) (역주: 개인 트위터 링크는 생략..)