ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kubernetes 서비스와 네트워킹 - ③
    DevOps/Kubernetes 2023. 5. 24. 11:38

     

    들어가기 전에

    첫번째 포스트에서는 한 Pod 가 다른 노드에 위치하는 다른 Pod 들과 어떻게 서로 통신하는지 알아보았다.

    두번째 포스트에서는 service network 가 어떻게 Pod 들의 부하를 분산 시키는지 설명하였고 이를 통해 클러스터 내의 클라이언트가 안정적으로 각 Pod 들과 통신할 수 있는것을 확인할 수 있었다. 마지막 세번째 포스트에서는 앞선 포스팅 네트워킹 개념들을 가지고 어떻게 기술을 이용하여 클러스터 외부에서 각 Pod 들로 트래픽을 전달하는지 알아보려고 한다.

     

     

    라우팅은 로드 밸런싱이 아니다

    두번째 포스트에서 Deployment 를 이용하여 몇가지 Pod 들을 만들었고 그것들에 "Cluster IP" 이름의 서비스 IP를 부여했다. 그리고 각 Pod 들은 이 서비스 IP를 이용하여 request 를 요청했다. 이 예제를 이번 포스트에서도 지속적으로 사용하겠다.

    먼저 ClusterIP 10.3.241.152 는 Pod 네트워크나 node 네트워크와는 다르게 또 다른 네트워크라는 것을 기억해야한다.

    이 네트워크 공간을 service 네트워크라고 애기하겠다. 사실 이것은 네트워크라고 불리기에는 조금 무리가 있다. 왜냐하면 이 네트워크는 실제 어느 네트워크 device에 연결된 것이 아니라 네트워크 전체가 routing rule에 의해서 구성되어 있기 때문이다. 예시에서, 우린 이것이 k8s 컴포넌트 중 하나인 kube-proxy 가 리눅스 커널 모듈인 netfilter 를 이용하여 ClusterIP로 보내지는 패킷을 낚아채어 healthy Pod 로 패킷을 보내는 것을 확인할 수 있다.

     

     

    지금까지 네트워크를 설명할 때 "연결"(connections) 혹은 "요청"(request)이나 더 모호하게 "traffic" 이라는 단어를 사용했다. k8s ingress 가 어떻게 동작하는지 알기 위해서는 조금 더 자세히 알아볼 필요가 있다.

    연결과 요청은 OSI Layer 4(tcp)나 Layer 7(http, rpc, etc)에서 동작한다. Netfilter 라우팅 규칙은 IP packet 레벨인 Layer 3 에서 동작한다. netfilter 를 포함한 모든 라우터는 라우팅 결정을 IP packet 기준으로 한다. 일반적으로 어디서부터 왔고 어디로 가야하는지를 의미한다. 이것을 Layer 3 의 용어를 사용해서 설명 한다면 10.3.241.152:80 서비스로 향하는 모든 패킷이 각 노드의 eth0 interface 에 도착하게 되면 netfilter 가 규칙에 따라 해당 패킷을 Pod 로 전달한다.

     

    외부에서 클러스터 안으로 들어오는 클라이언트도 이와 똑같은 라우팅 방식을 이용해서 패킷을 전달해야 하는 것이 분명해 보인다. 이것은 외부 클라이언트들도 똑같이 요청을 할 때, ClusterIP 나 Port 를 이용하여 연결을 시도해야 한다. 왜냐하면 ClusterIP 가 각 Pod 의 앞단에 위치하여 각 Pod 들의 IP 주소를 알지 못해도 해당 Pod로 요청을 전달할 수 있게 해주는 역할을 해주기 때문이다. 문제는 ClusterIP 는 해당 노드의 네트워크 interface 에서만 접근이 된다. 클러스터 외부에서는 해당 주소 대역에 대해서 전혀 알지 못한다. 자 그럼, 어떻게 하면 외부 public IP endpoint 에서 밖에 보이지 않는 노드안 네트워크 interface 로 트랙픽을 전달할까?

     

    만약에 외부에서 접근 가능한 어떤 Service IP가 있다고 가정해 보자. 이 Service IP는 next hop이 특정 Node 로 라우팅 설정되어 있다. 그러면 이 Service IP(예시에서는 10.3.241.152:80)는 단지 내부 네트워크에 한정되어서 동작하지 않고 어디에서 온 트래픽인지 상관없이 Node 를 경유하여 원하는 Pod 까지 정상적으로 패킷이 전달된다. 그럼 생각할 수 있는 해결책이 그냥 클라이언트에 Cluster IP 를 그냥 전달하는 것은 어떨까? 적절하게 사용자 친화적인 도메인 이름을 붙이고 해당 패킷이 어느 Node 로 전달되어야 하는지에 대한 규칙과 함께 말이다.

     

     

    이렇게 설정한다면 실제로 잘 동작할 것이다. 클라이언트에서 Service IP를 호출하면 정의된 라우팅 규칙에 따라 특정 Node 에 전달이 되고 그 Node 의 네트워크 interface 에서는 기존과 같이 netfilter 를 이용하여 원하는 Pod 로 전달이 될 것이다. 이 방법은 괜찮아 보이지만(비록 가정이긴 하지만) 사실 큰 문제점을 가지고 있다.

    가장 먼저 Node 들 또한 마찬가지로 Pod 와 같이 대체 가능한 자원(ephemeral) 이다. Pod 만큼은 아니지만 새로운 VM 으로 대체될 수 있거나 클러스터가 scale up / down 할 수 있다. Layer 3 에서 동작하는 라우터들은 service 가 healthy 한지 아닌지 알 길이 없다. 그들은 단지 다음 목적지(next hop)가 안정적으로 동작하고 정상 동작하길 빠랄 뿐이다. 만약에 Node 가 더 이상 네트워크 내에 있지 않는다면 꽤 오래 시간 동안 라우팅 테이블에 문제가 생길 수 있다. 만약에 Node 가 지속한다 하더라도 모든 트래픽이 한 Node 를 거쳐가게 될 것이고 이것은 최적의(optimal) 선택이 될 수는 없다. (단일 장애점 발생 및 성능 측면 등)

     

    만약에 클러스터 외부의 클라이언트 트래픽을 내부로 전달하고 싶다면 그 방법이 단일한 노드에 의한 방법이 되어서는 안될 것이다. 이런 상황에서 단순히 라우터를 이용하여 문제를 해결 할 수 있는 방법은 마땅히 없어 보인다. k8s의 역할을 클러스터 외부에 존재하는 라우터를 관리하는데까지 넓히는 방법에 대해서는 k8s 설계자들이 동의하지 않았다. 왜냐하면 클라이언트의 요청을 분산하여 Node 들에게 전달해주는 존재가 이미 있기 때문이다. 그 이름은 로드 밸런서이다.

    그리고 k8s에서는 이것을 이용하여 안정적으로 외부의 트래픽을 전달 받는데에 활용한다. 이제 Layer 3 레벨을 떠나서 연결에 대해서 이야기 해보자.

     

    로드 밸런서를 이용하여 클라이언트 트래픽을 각 노드로 분산 시키기 위해서는 먼저 클라이언트가 접속할 수 있는 공인 IP가 필요하다. 또한 로드 밸런서에서 각 노드로 트래픽을 전달할 수 있게 각 노드의 주소들이 필요하다. 이러한 이유 때문에 Cluster IP 를 이용해서는 안정적인 라우팅 규칙을 만들어 낼 수 없다. 이런 service 네트워크를 제외하고 사용할 수 있을만한 네트워크는 각 노드들의 ethernet interface 인 10.100.0.0/24 대역을 사용하는 것 외에는 없다. Node 네트워크에 위치한 gateway 라우터에서는 이미 패킷을 각 Node 로 어떻게 보낼 수 있는지 알고 있다.(네트워크 관리자에 의해 제공되는 default gateway) 그렇기 때문에 로드 밸런서로 보내진 패킷은 정확히 알맞는 Node 로 전달될 것이다. 그렇지만 Service 네트워크에서 80포트를 사용하고 싶다고 하더라도 직접 Node 네트워크에서 80 포트를 사용할 수 없다.

    그렇게 했을 경우 에러가 발생한다.

     

     

    에러가 발생하는 이유는 당연하다. 왜냐하면 실제로 해당 Node 에서 80 포트를 듣고 있는 프로세스가 없기 때문이다.

    또한 Node 주소를 그대로 사용한다면 netfilter 에 의해서 우리가 원하는 Pod 로 패킷을 전달할 수 없기 때문이다. netfilter 는 Service IP(10.3.241.152:80)를 바라보고 있지 노드의 IP를 바라보지 않는다. 그렇기 때문에 해당 Node interface 에 도착한 패킷은 제대로 원하는 목적지에 전달되지 못하고 커널에 의해 ECONNREFUSED 에러가 발생한다. 이것을 우리에게 딜레마를 안긴다. netfilter 에 의해 동작하는 네트워크는 Node 네트워크에서 잘 동작하지 않고 반대로 Node 네트워크에서 잘 동작하는 네트워크는 netfilter 에 의해 패킷이 잘 전달되지 않는다. 이 문제를 해결하기 위해서 이 두 네트워크를 연결해주는 bridge 를 생성하는 것 외에는 방법이 없어 보인다. 그리고 k8s 에서는 바로 이 역할을 담당하는 녀석이 존재한다.

    그 이름은 NodePort 라 한다.

     

     

    NodePort Service

    지난 포스트에서 service 를 생성할 때 특별히 서비스 타입을 지정하지 않았다.

    그렇기 때문에 default 타입인 ClusterIP 로 service 가 생성되었다. 몇가지 다른 타입을 설정할 수가 있는데 여기서는 NodePort 에 대해서 설명하려고 한다. 아래 NodePort 예시를 보자.

    kind: Service
    apiVersion: v1
    metadata:
      name: service-test
    spec:
      type: NodePort
      selector:
        app: service_test_pod
      ports:
      - port: 80
        targetPort: http

     

    NodePort 타입의 서비스는 기본적으로 ClusterIP 타입과 동일하지만 몇가지 기능들을 더 가지고 있다.

    NodePort 타입 서비스는 Node 네트워크의 IP 를 통하여 접근을 할 수 있을 뿐만 아니라 ClusterIP 로도 접근이 가능하다. 이것이 가능한 이유는 매우 간단하다. k8s 가 NodePort 타입의 서비스를 생성하면 kube-proxy 가 각 노드의 eth0 네트워크 interface 에 30000-32767 포트 사이의 임의의 포트를 할당한다. (그렇기 때문에 이름이 NodePort 이다) 그리고 할당된 포트로 요청이 오게 되면 이것을 매핑된 ClusterIP로 전달한다. 위의 예시 service 를 생성하고 kubectl get svc service-text 라고 입력하면 다음과 같이 Node 에 Port 가 할당된 것을 볼 수 있다.

     

    $ kubectl get svc service-test
    NAME           CLUSTER-IP     EXTERNAL-IP   PORT(S)           AGE  
    service-test   10.3.241.152   <none>        80:32213/TCP      1m

     

    해당 예시에서는 NodePort 가 32213 으로 할당되었다. 이제 클라이언트가 10.100.0.2:32213 Node, 10.100.0.3:32213 Node 중 아무 Node 에 요청을 날리게 되면 이것이 ClusterIP 로 전달된다. 이러한 방법으로 클러스터 외부의 요청이 내부의 ClusterIP 까지의 전달되는 것을 알 수 있었다.

     

     

    위의 그림에서 클라이언트는 로드 밸런서의 공인 IP로 연결을 한다. 로드 밸런서는 Node 하나를 선택하여 32213 포트로 패킷을 전달한다. (10.100.0.3:32213) kube-proxy 는 해당 연결을 받아서 그것을 ClusterIP 10.3.241.152:80 로 전달한다.

    그리고 이제 netfilter 가 규칙이 매칭되는 것을 확인하고 최종적으로 해당 service IP 를 실제 Pod IP(10.0.2.2:8080)로 바꾸어 전달한다. 이런 일련의 과정이 조금 복잡해 보일 수도 있고 실제로도 복잡한 면이 없잖아 있다. 하지만 k8s 의 네트워킹 기능들을 가능하게 하려면 이러한 방법 외에는 다른 간단한 방법이 있어보이지 않다.

     

    이러한 네트워킹 방법에 문제가 전혀 없는 것은 아니다. NodePort 를 사용하게 되면 클라이언트 측에 non-standard 포트를 열어주어야 한다. 하지만 로드 밸런서를 사용하게 되면 보통 이런 문제는 해결이 된다. 왜냐하면 로드 밸런서에서는 일반적인 포트를 열어주고 실제 NodePort 의 포트는 사용자로부터 안보이게 만들어 버리면 되기 때문이다. 하지만 Google Cloud 의 내부 로드 밸런싱과 같은 경우에는 어쩔 수 없이 NodePort 를 보이게끔 설정해야 할 수도 있다. NordPort 는 또한 한정된 자원이다. 2768개 포트는 사실 아무리 큰 클러스터에도 충분한 포트 수이긴하다. 대부분의 경우에 k8s로 하여금 랜덤하게 아무 포트를 지정하게 만들어도 상관없다. 하지만 필요한 경우 사용자가 직접 포트를 지정할 수도 있다.

    마지막으로 요청자의 source IP를 의도치 않게 가린다는 제약 사항이 생긴다. 이러한 이슈에 대해서는 여기를 확인하자.

     

    NodePort 는 외부 트래픽이 k8s 안으로 들어오기 위한 기초가 되는 매커니즘이다.

    하지만 그것 자체로 완전한 솔루션이 될 수는 없다. 위와 같은 이유로 항상 로드 밸런서를 앞단에 두게 된다. 그것이 외부로부터든 내부로부터 오는 트래픽에 상관없이 말이다. k8s 플랫폼 설계자들은 이러한 사용성을 깨닫게 되고 2개의 다른 방법을 통해 k8s 를 설정할 수 있게 만들었다.

     

     

    LoadBalancer Services and Ingress Resources

    마지막 두개 컨셉은 k8s 네트워킹 중에서 가장 복잡한 기능을 담당하긴 하지만 이미 지금까지 우리가 이야기한 k8s 네트워킹 기법들을 기반하여 동작한다.

    모든 외부 트래픽은 NodePort 를 통해 클러스터 내부로 들어오게 된다. k8s 설계자들은 여기까지만 만들었어도 충분했지만 로드밸런서 API 지원이 가능한 환경에서는 k8s가 직접 이 모든 것을 담당하게 만들 수 있다.

     

    첫번째로 가장 간단하게 이것을 가능하게 만들어 주는 방법이 바로 세번째 service type 인 LoadBalancer 타입을 사용하는 것이다. 말 그대로 LoadBalancer 타입 서비스는 기존 NodePort 기능을 더하여 로드 밸런서를 통한 접근까지 완벽하게 해결하는 기능을 가진다. 이것은 GCP 나 AWS 와 같이 API 를 통하여 로드 밸런서를 생성할 수 있는 클라우드 환경을 사용한다는 것을 가정한다.

     

    kind: Service
    apiVersion: v1
    metadata:
      name: service-test
    spec:
      type: LoadBalancer
      selector:
        app: service_test_pod
      ports:
      - port: 80
        targetPort: http

     

    기존의 Service 를 지우고 위와 같은 형식의 service 를 GKE 에 다시 생성하여 kubectl get svc service-test 명령을 내리게 되면 다음과 같이 외부 공인 IP가 생성된 것을 바로 확인할 수 있다.(35.184.97.156)

     

    $ kubectl get svc service-test  
    NAME      CLUSTER-IP      EXTERNAL-IP     PORT(S)          AGE  
    openvpn   10.3.241.52     35.184.97.156   80:32213/TCP     5m

     

    제가 "바로"라고 했지만 사실 외부 공인 IP가 할당되기 위해서 수분이 걸릴 수도 있다. 이는 생성되어야 할 리소스들의 양에 비하면 그리 놀랄 일이 아니다. GCP 를 예로 들자면, 먼저 forwarding rule이 설정되어야 하고 target proxy, 백엔드 서비스와 instance group, 마지막으로 외부 공인 IP가 생성되어야 한다.(전부 GCP에서 사용하는 용어들이다.)

     

    일단 공인 IP가 만들어졌다면 해당 IP를 통해서 서비스에 접속할 수 있다. 해당 IP에 도메인 네임을 지정하고 사용자에게 전달할 수 있다. 그렇게 되면 서비스가 삭제되거나 새롭게 만들어지지 않는 이상, IP가 변경될 수 없다.

     

    LoadBalancer 서비스 타입에는 몇가지 제약 사항이 있다. 먼저 TLS termination 설정이 불가능하다. 또한 virtual host 나 path-base routing (L7 layer routing)이 불가능하다. 그렇기 때문에 한개의 로드 밸런서를 이용하여 여러 서비스에 연결을 하는 것이 불가능하다. 이러한 제약 사항 때문에 k8s 1.2 버전에서는 Ingress 라는 서비스 타입을 제공하기 시작했다.

    LoadBalancer 서비스 타입은 단지 한개의 내부 서비스를 외부 사용자들에게 접근 가능하도록 만드는 일을 담당한다. 반대로 Ingress 서비스 타입은 여러개의 서비스가 한개 로드 밸런서를 통해 유연한 설정을 할 수 있게 만든다. Ingress API 는 TLS termination 이나 virtual hosts, path-based routing 을 가능하게 한다.

    Ingress 를 이용하면 쉽게 한개의 로드 밸런서로 여러개의 backend 서비스들을 연결할 수 있게 만들어준다.

     

    Ingress API 는 너무 양이 방대하여 여기에서 모든 것을 이야기하는 것은 힘들 것 같다. 또한 Ingress 자체는 우리가 배운 k8s 네트워크와 크게 더 더해지는 것이 없다. 그 구현 자체는 지금까지 배운 k8s 패턴과 크게 다르지 않다. 리소스 타입과 그 리소스 타입을 관리하는 컨트롤러가 존재한다. 여기서 리소스는 Ingress 이고 그것을 Ingress-controller 가 관리한다.

    아래 코드가 Ingress 리소스 예시이다.

     

    apiVersion: extensions/v1beta1
    kind: Ingress
    metadata:
      name: test-ingress
      annotations:
        kubernetes.io/ingress.class: "gce"
    spec:
      tls:
        - secretName: my-ssl-secret
      rules:
      - host: testhost.com
        http:
          paths:
          - path: /*
            backend:
              serviceName: service-test
              servicePort: 80

     

    Ingress-controller 는 위의 방식대로 들어오는 요청을 적절한 서비스로 전달해야 하는 역할을 담당한다.

    Ingress 를 사용할 때, 요청 받을 서비스를 NodePort 타입으로 설정을 하고 Ingress-controller 로 하여금 어떻게 요청을 각 Node 에 전달할지 파악하게 된다. 각 클라우드 플랫폼 마다의 Ingress-controller 구현체가 있다. GCP 에서는 cloud load balancer 가 있고 AWS 에서는 elastic load balancer 가 있고 오픈소스로는 nginx 나 haproxy 등이 있다.

     

    한가지 주의해야 할 점은, 어떤 환경에서는 LoadBalancer 타입과 Ingress 를 같이 쓰면 작은 이슈가 생기는 것을 유의해야한다. 대부분의 경우 이상 없이 동작하겠지만 일반적으로는 간단한 서비스에도 Ingress 만 사용하기를 권장한다.

     

     

     

    HostPort and HostNetwork

    마지막으로 소개하고 싶은 두 가지 내용은 실질적으로 사용하는 방법이라기 보다는 실험용이다. 사실 99.99% 의 경우 anti-pattern 으로 사용될 것이고 실제로 사용하는 곳이 있다면 전체적으로 아키텍처 디자인 리뷰를 다시 받아 봐야할 것이다.

     

    첫번째는 HostPort 이다. 이것은 컨테이너 기능 중 하나이다. 만약 특정 포트 번호를 Node 에 열게 되면 해당 노드의 해당 포트로 들어오는 요청은 곧바로 컨테이너로 직접 전달이 된다. proxying 되지 않으며 해당 컨테이너가 위치한 Node 의 포트만 열린다.

    k8s에 DaemonSets 과 StatefulSets 과 같은 리소스가 존재하지 않았을 때 사용하던 기법으로 오직 한개의 컨테이너만 어느 한개의 Node 위에 실행되길 바랄 때 사용하였다. 예를 들어 elasticsearch 클러스터를 생성할 때 HostPort 를 9200 으로 설정하고 각 컨테이너들이 마치 elasticsearch 의 노드처럼 인식되게 만들었다. 현재는 이러한 방법을 사용하지 않기 때문에 k8s 컴포넌트를 개발하는 사람이 아닌 이상 사용할 일이 거의 없다.

     

    두번째 HostNetwork 는 더 이상한 방법이다. 이것은 Pod 의 기능 중 하나이다. 해당 필드의 값을 true 로 설정하면 도커를 실행할 때 --network=host 로 설정한 것과 똑같은 효과를 얻을 수 있다. 이것은 Pod 안에 들어있는 모든 컨테이너들이 노드의 네트워크 namespace 와 동일한 네트워크를 사용하게끔 만든다. (모두가 eth0 를 접근할 수 있다는 것이다) 아마도 이것을 사용할 일이 없을 것 같지만 혹여나 사용 한다고 하더라도 그것은 여러분이 이미 k8s 전문가일 가능성이 높을 것이다.

     

    'DevOps > Kubernetes' 카테고리의 다른 글

    Kubernetes 서비스와 네트워킹 - ②  (0) 2023.05.23
    Kubernetes 서비스와 네트워킹 - ①  (0) 2023.05.16
Designed by Tistory.