MSA/Istio

5. [envoy-internals] Listener Manager

cla9 2023. 5. 23. 10:16

1. 서론

 

이번 포스팅은 지난번에 이어서 Envoy의 주요 컴포넌트 중 하나인 Listener와 Listener를 관리하는 Listener Manager에 대해서 살펴보고자 합니다. 

 


 

 

2. Listener Manager 개요

 

Listener Manager는 단어에서 유추할 수 있듯이 Listener를 관리하는 역할을 수행합니다. 즉 Listener를 생성하고 관리합니다. 두 번째 역할을 LDS를 생성하고 관리하는 역할을 겸합니다. 이번 포스팅에서는 이에 대해서 하나씩 살펴보겠습니다. 먼저 살펴볼 것은 Listener 생성 및 관리 기능입니다.

 

Listener Manager는 Listener를 생성 후 관리한다고 설명했습니다. 여기서 꼭 기억해야할 특징은 Listener Manager가 생성한 Listener는 단일 쓰레드 기반으로 동작하지 않는다는 점입니다. 그렇다면 Envoy는 왜 Listener를 단일 쓰레드로 동작하지 않고 멀티 쓰레드로 동작시켰을까요? 이를 이해하기 위해서 사례를 통해 먼저 사용자의 요청을 처리하는 쓰레드가 하나일 경우 야기되는 문제점을 살펴보겠습니다.

 

 

 

envoy에 여러 API 요청이 들어오게되면, 그 중 하나가 쓰레드를 선점하게되고 해당 요청을 처리할 것입니다. 이때 만약 다른 API 요청이 전달된다면 어떻게 될까요? 쓰레드를 선점하고 있다면, 해당 쓰레드가 처리하는 작업 요청이 끝날 때까지 다른 요청은 대기해야할 것입니다. 따라서 이 경우 병렬성이 매우 떨어지게 됩니다. 특히나 다른 API 요청을 처리하는 Listener의 경우 미리 선점한 Listener의 작업에 의해 해당 API 요청 또한 대기하는 이슈가 발생합니다. 따라서 단일 쓰레드로 이를 처리하기에는 성능 이슈가 필연적으로 발생합니다.

 

따라서 envoy에서는 API 요청을 처리하는 쓰레드를 하나가 아니라 여러개를 구성하도록 설계되었습니다. 또한 생성된 Worker 쓰레드는 모든 Listener와 연결 되어있도록 구성되었습니다.

 

 

 

envoy는 기동 당시 --concurrency 인자를 통해 생성할 Worker 쓰레드의 개수를 지정할 수 있습니다. 만약 위 그림과 같이 3으로 지정하였다면, 내부적으로는 3개의 Worker 쓰레드를 Listener Manager가 생성합니다. 또한 만약 Config에 지정된 Listener가 2개라면 위와 같이 2개의 Listener가 생성됩니다.

 

Listener와 Worker 쓰레드가 모두 생성되고나면, 모든 Worker 쓰레드는 모든 Listener를 Listen 하도록 합니다. 그렇다면 모든 Worker 쓰레드가 모든 Listener를 Listen 하는 것은 어떤 것을 의미할까요?

 

 

 

만약 특정 Listener 별로 Worker 쓰레드를 할당할 수 있다면, 위와 같이 부하가 집중되는 Listener에 쓰레드를 많이 부여할 수 있습니다. 가령 위 그림과 같이 Listener0에 명시적으로 더 많은 쓰레드를 할당함으로써 Listener0 요청에 빠른 처리가 가능합니다.

 

하지만 만약 위와 같이 지정한 상황에서 갑자기 Listener 1쪽에 트래픽이 몰릴 경우에는 해당 Listener에 지정된 쓰레드는 하나이기 때문에 탄력적으로 대응할 수 없습니다.

 

따라서 envoy에서는 Active Listener 별로 영역을 정해서 Worker 쓰레드를 샤딩하지 않도록 구성되어있습니다. 즉 사용자 Connection이 요청되었을 때, 모든 Worker 쓰레드가 모든 Listener를 Listen하고 있기 때문에 어떠한 Worker 쓰레드에 사용자 Connection을 할당하더라도 문제가 없습니다. 또한 어떤 Worker 쓰레드에 사용자 요청을 할당하는 지에 대한 결정은 Kernel이 Socket을 Accept한 다음 결정합니다. 결국 Worker 쓰레드에 대한 Connection 할당 권한은 OS에 있습니다.

 

Connection이 Accept되어 Worker 쓰레드에 Socket이 배정되면, 해당 Socket은 이후에 Worker 쓰레드를 벗어나지 않습니다. 따라서 이후 제공되는 서비스는 최초 연결 시점에 할당된 Worker 쓰레드에서 이루어집니다.

 

지금까지 Listener Manager에서 생성되는 Worker 쓰레드에 대해서 살펴봤습니다. 그렇다면 Worker 쓰레드 생성 외에 Listener Manager는 어떤 역할을 하고 있을까요? Listener Manager가 담당하는 역할에 대해서 살펴보겠습니다.

 


3. Listener 생성

 

 

 

Listener Manager의 첫 번째 역할은 Listener 생성입니다. config 파일에 위치한 static_resources 정보에 포함된 Listener 정보를 살펴보고 그에따른 Listener를 생성합니다.

 

configuration_impl.cc

void MainImpl::initialize(const envoy::config::bootstrap::v3::Bootstrap& bootstrap,
                          Instance& server,
                          Upstream::ClusterManagerFactory& cluster_manager_factory) {
  ...(중략)...

  const auto& listeners = bootstrap.static_resources().listeners();
  ENVOY_LOG(info, "loading {} listener(s)", listeners.size());
  for (ssize_t i = 0; i < listeners.size(); i++) {
    ENVOY_LOG(debug, "listener #{}:", i);
    server.listenerManager().addOrUpdateListener(listeners[i], "", false);
  }

  ...(중략)...
}

 

이는 위 코드와 같이 Config 내용을 파싱한 이후 담겨있는 bootstrap 구조에서 static listener 정보를 추출한 결과를 토대로 Listener Manager에게 Listener를 생성하라고 요구하는 것을 볼 수 있습니다.

 

이때 생성되는 Listener는 어떤 과정을 거치며 내부적으로 어떤 속성을 가지고 있을까요?

 

 

Envoy 관련 첫 포스팅에서 Client 요청이 전달되면, 위 그림과 같은 트래픽 흐름이 전달된다고 설명했습니다. 이때 Listener와 연결된 Listener Filters와 Network Filters는 Listener가 관리합니다. 따라서 Listener의 역할 중 하나는 Listener Filters와 Filter Chains(Network Filters)를 생성하는 것입니다.

 

static_resources:
  listeners:
  - name: listener_https
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 80
    listener_filters:
    - name: "envoy.filters.listener.tls_inspector"
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
    - name: "envoy.filters.listener.http_inspector"
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.listener.http_inspector.v3.HttpInspector

 

 

가령 위와 같이 envoy config 파일에 listener_filters 기입되었다고 가정하면, Listener를 생성하는 단계에서 해당 필터들을 파악하고 생성하는 역할을 수행해야합니다.

 

 

 

이를 위해서 Listener 내부에는 Listener Filters를 생성하는 listener_filter_factories와 Filter Chains를 생성하는 filter_chain_manager가 존재합니다.

 

 

Listener Filters는 Worker 쓰레드에 바인딩된 소켓마다 해당 Filter들이 생성되기 때문에, Filter를 생성하여 Listener가 보관하지 않고 Filter를 생성할 수 있는 Callback 메소드들의 Pointer를 Vector Container에 저장하고 있습니다. 따라서 Client가 접속을 요청했을 경우 Worker 쓰레드에 소켓이 할당되면, 이후 listener_filter_factories에 지정된 Callback 메소드들을 순회하면서 사용자에게 적합한 Filter 목록을 만들어줍니다.

 

출처 :https://www.envoyproxy.io/docs/envoy/latest/intro/life_of_a_request#listener-tcp-accept

 

 

Filter Chains(Network Filters)는 L3/L4를 담당하는 Filter로써 Listener Config 설정에 존재하는 Filter 목록들을 참조하여 내부적으로 여러 Filter Factory를 만듭니다. 

 

    filter_chains:
    - filter_chain_match:
        server_names: ["acme.com"]
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            tls_certificates:
            - certificate_chain: {filename: "certs/servercert.pem"}
              private_key: {filename: "certs/serverkey.pem"}        
      filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          use_remote_address: true
          http2_protocol_options:
            max_concurrent_streams: 100
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["acme.com"]
              routes:
              - match:
                  path: "/foo"
                route:
                  cluster: some_service
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

 

가령 위와같이 Listener 하위에 filter_chains를 정의했다고 가정해봅시다. 그러면 Listener를 생성하는 과정에서 해당 Config 내용을 확인하고 Transport Socket Factory와 Network Filter Factory를 구성합니다.

 

이때 filter_chain_manager의 역할은 사용자가 입력한 Filter Config 내용이 정상인지 검증을 하고 Filter 목록을 빠르게 찾기 위한 자료구조 및 Filter 생성 Callback을 생성하여 저장합니다. 이때 생성되는 Callback은 Listener와 마찬가지로 사용자가 실제 접속해서 Socket이 만들어졌을 때 해당 Socket에서 Network Filters 생성을 할때 사용됩니다.

 

 

listener_manager_impl.cc

    for (auto& address : listener.addresses()) {
      listener.addSocketFactory(std::make_unique<ListenSocketFactoryImpl>(
          factory_, address, socket_type, listener.listenSocketOptions(), listener.name(),
          listener.tcpBacklogSize(), bind_type, creation_options, server_.options().concurrency()));
    }

 

지금까지 listener 생성 과정에서 Listener Filters와 Filter Chains(Network Filters)가 생성되는 것을 살펴봤습니다. 그 밖에 중요하게 살펴봐야할 점은 Listener가 외부 요청으로부터 Accept 하기 위한 Socket을 생성한다는 점입니다. 그리고 생성되는 Socket의 개수는 --concurrency로 전달된 인자 개수 만큼 생성됩니다.

 

그렇다면 왜 --concurrency 만큼 Socket을 생성할까요?

 

 

그 이유는 향후 Listener 별로 Worker 쓰레드에게 모두 바인딩을 수행하는데, 이때 Listener에서 생성한 Socket을 개별 쓰레드에서 참조가 가능하도록 하기 위함입니다.

 

listener_impl.cc

Network::SocketSharedPtr socket = factory.createListenSocket(
    local_address_, socket_type, options_, bind_type_, socket_creation_options_, worker_index);

 

따라서 Listener 생성 과정에서 Listner Component Factory가 Socket을 생성하여 개별 Worker 쓰레드에서 향후 참조할 수 있도록 사전 작업을 수행합니다.


3-1 Worker 생성 및 Listener 바인드

 

listener_manager_impl.cc

  for (uint32_t i = 0; i < server.options().concurrency(); i++) {
    workers_.emplace_back(
        worker_factory.createWorker(i, server.overloadManager(), absl::StrCat("worker_", i)));
  }

 

Listener Manager가 생성될 때, 생성자 코드 내부에서는 위와 같이 전달받은 worker_factory를 통해서 Worker 쓰레드를 생성하는 것을 볼 수 있습니다. 그리고 생성하는 쓰레드의 개수는 인자로 전달된 --concurrency에 기반하고 있습니다.

 

listener_manager_impl.cc

void ListenerManagerImpl::startWorkers(GuardDog& guard_dog, std::function<void()> callback) {
  ...(중략)...
  uint32_t i = 0;

  ...(중략)...
  
  for (auto listener_it = active_listeners_.begin(); listener_it != active_listeners_.end();) {
    auto& listener = *listener_it;
    listener_it++;

   ...(중략)...
    for (const auto& worker : workers_) {
      addListenerToWorker(*worker, absl::nullopt, *listener,
                          [this, listeners_pending_init, callback]() {
                            if (--(*listeners_pending_init) == 0) {
                              stats_.workers_started_.set(1);
                              callback();
                            }
                          });
    }
  }
  for (const auto& worker : workers_) {
    ...(중략)...
    worker->start(guard_dog, worker_started_running);
    ...(중략)...
    i++;
  }

  ...(후략)...
}

 

이후 Envoy가 기동 될때, 위 코드와 같이 등록된 모든 active_listeners를 순회하면서, 모든 worker 쓰레드에 bind 하는 것을 볼 수 있습니다. 해당 내용에 대해서 조금 더 자세히 살펴보면 다음과 같습니다.

 

 

이전에 Listener 생성 과정을 살펴보면서, 위 그림과 같이 Worker 쓰레드 개수 만큼 개별 Listener에서 Socket을 생성함을 확인했습니다. 이때 생성된 Socket을 Worker 쓰레드에서 사용하기 위해서는 소켓을 각 쓰레드별로 바인딩하는 작업을 수행해야합니다.

 

 

connection_handler_impl.cc

for (auto& socket_factory : config.listenSocketFactories()) {
  auto address = socket_factory->localAddress();
  // worker_index_ doesn't have a value on the main thread for the admin server.
  details->addActiveListener(
      config, address, listener_reject_fraction_, disable_listeners_,
      std::make_unique<ActiveTcpListener>(
          *this, config, runtime,
          socket_factory->getListenSocket(worker_index_.has_value() ? *worker_index_ : 0),
          address, config.connectionBalancer(*address)));
}

 

따라서 개별 쓰레드에서는 위 코드를 수행하면서, ActiveTcpListener를 생성하는 것을 확인할 수 있습니다. 위 코드에서 주목할 부분은 socket_factory(Listener)로부터 getListenSocket 메소드를 호출하면서 인자로 Worker 쓰레드의 index 번호를 넘기는 것을 확인할 수 있습니다.

 

listener_impl.cc

Network::SocketSharedPtr ListenSocketFactoryImpl::getListenSocket(uint32_t worker_index) {  
  ASSERT(worker_index < sockets_.size() && sockets_[worker_index] != nullptr);
  return sockets_[worker_index];
}

 

이때 Listener 내부에서는 Worker 인덱스에 해당하는 Socket을 반환함으로써 Worker 쓰레드에서 해당 Listener를 참조할 수 있도록 합니다.

 


 

4. LDS 관리

 

Listener Manager의 두 번째 역할은 LDS를 통한 dynamic resource 처리입니다. 이때 LDS의 생성은 Listener Component Factory에서 수행하며, 생성된 LDS에서 Listener Manager를 참조하도록 하여 LDS 정보가 수신되었을 때, Listener Manager가 이를 처리할 수 있도록 연관관계가 맺어져있습니다.

 

LDS 생성 및 처리 과정을 보면 아래와 같습니다.

 

 

 

 

1. Listener Component Factory로부터 LDS를 생성합니다.

 

2. Listener Manager를 LDS에서 참조하도록 합니다.

 

3. xDS API 수행을 위해 Cluster Manager로부터 Subscription Factory 인스턴스를 요청합니다.

 

4. Subscription Factory로부터 Subscription 생성을 요청합니다.

 

5. Subscription Factory에서는 gRPC 통신이 가능하도록 설정 후 Subscription을 발급합니다.

 

6. gRPC Mux를 통해 Management Server와 통신을 수행합니다.

 

7. LDS api_type 변경 이벤트 통지 시, 해당 Subscription에 알림을 전달합니다.

 

8. Subscription내에 매핑된 LDS Callback을 호출합니다.

 

9. LDS에서는 Listener Manager를 참조하여 변경 사항에 맞추어 Listener 정보를 동기화 시킵니다.

 

위와 같은 과정을 거쳐 Listener Manager에서는 LDS 요청에 맞추어 최신 정보를 동기화시킵니다. 해당 과정의 대부분은 이전 Cluster Manager 설명 포스팅을 통해서 자세하게 소개하였으므로 해당 내용을 이해했다면, 위 과정의 흐름의 진행은 자연스러울 것입니다. 혹시 기억이 잘 안나신다면, 이전 포스팅 정독을 통해 관련 내용을 복기해보시는 것을 추천드립니다.

 


5. 마치며

 

지금까지 Listener Manager를 기동하면서 Worker 쓰레드 및 Listener 생성하여 이를 바인딩하는 역할과 LDS 처리역할에 대해서 살펴봤습니다. 또한 개별 Listener에서는 Configuration에 따라서 여러 Filter를 생성하기 위한 Callback 리스트를 관리하는 것을 살펴볼 수 있었습니다. 이 과정에서 생성된 정보등을 통해 향후 Client에서 Listener에 접속을 요청하면 이를 전달받아 후속 처리를 진행할 수 있습니다. 다만 이번 포스팅에서는 해당 내용에 대해서는 다루지 않으며, 이 다음 포스팅에서 조금 더 자세하게 접속 요청이 어떻게 처리되는지를 살펴보겠습니다.