Kubernetes 환경에서 서비스를 운영하다보면 예상치 못한 이슈를 종종 발견하게 됩니다.
특히 대규모의 트래픽을 부담해야 하거나 구동 중인 애플리케이션이 많은 경우처럼 Cluster의 크기가 거대해질수록 다양한 이슈를 만나게 되는데요.
이 경우 단순히 리소스의 부족 문제 뿐만이 아니라 여러 방면에서의 문제를 해결해야 하는 상황이 오기도 합니다.
이번 포스팅에서는 클러스터를 운영하면서 DNS Query Failed 이슈를 해결했던 사례를 공유하고자 합니다.
1. Issue
해당 이슈를 발견하게 된 계기는, 트래픽이 몰리던 시간대에 평소와 다른 Error Log가 확인됐던 것이었습니다.
"io.netty.resolver.dns.DnsResolveContext$SearchDomainUnknownHostException" 가 Error Log의 이름인데요.
이 Log는 SpringGateway 내부의 Netty가 DNS Resolving에 실패했음을 말하고 있습니다.
즉 Kubernetes의 Spring Gateway 애플리케이션이 DNS 질의에 실패해 Endpoint에 도달하지 못하면서 발생한 로그였습니다.
이 이슈는 트래픽이 몰리는 특정한 시간대에 간헐적으로 발생했는데요.
무엇이 이 이슈의 원인인지 분석해보도록 하겠습니다.
2. Analyze
2-1. Cluster Network
이슈가 발생한 환경은 받은 요청을 Backend Application으로 Routing하는 Spring Gateway가 Pod로 존재하는 Kubernetes 클러스터입니다.
먼저 Kubernetes의 Cluster Network 단에서 어떤 문제가 있었는지 확인해보도록 하겠습니다.
이 환경에서 Spring Gateway Pod는 도메인 주소를 Endpoint로 가지는 Backend Application에 요청을 전달하기 위해 DNS Query 절차를 실행합니다.
DNS Query 절차는 아래와 같이 수행합니다.
1. Container내의 Spring Gateway는 "/etc/resolv.conf" 파일에 정의된 주소를 기반으로 DNS 서버를 인지합니다.
Pod의 DNSPolicy 값이 기본값일 경우 "kube-dns" ClusterIP 주소가 DNS 서버 주소가 됩니다.
2. Spring Gateway는 Backend로 요청을 라우팅하기 위해 "kube-dns" 주소로 Domain name Resolving을 시도합니다.
3. "kube-dns" ClusterIP는 CoreDNS pod로 요청을 라우팅합니다.
4. 요청을 받은 CoreDNS는 DNS Query에 대한 응답인 IP 주소를 요청지로 전송합니다.
5. IP 주소를 응답받은 Spring Gateway는 Backend Application으로 요청을 라우팅합니다.
SearchDomainUnknownHostException이라는 Log를 바탕으로 추측해봤을때, 위 절차에서 문제가 되는 단계는 2번 Doamin name resolving임을 유추할 수 있습니다.
즉 도메인 주소를 Resolving하기 위해 전송하는 DNS Query 과정이 문제의 원인임을 알 수 있었습니다.
2-2 Container Network
위 Cluster Network 단에서 확인해봤을때, Spring Gateway Pod가 DNS Query에 실패한 것이 이슈의 원인인 것을 확인할 수 있었는데요.
그렇다면 어떤 이유로 DNS Query가 실패했는지 면밀하게 확인하기 위해 더 아래의 Container Network 단에서 현상을 분석해보겠습니다.
어떤 Pod가 DNSQuery를 수행하기 위해서는 CoreDNS Pod로 요청을 전송해야 합니다.
이 경우 요청 Pod와 CoreDNS Pod가 같은 Node에 존재해 Host내에서 통신할수도 있지만, 각 Pod가 서로 다른 Node에 존재해 Node 밖으로 요청을 보내야 하는 경우가 더 많습니다.
위 다이어그램은 후자의 경우, 즉 Pod가 서로 다른 Node에 존재할때 DNS Query 패킷의 구성 절차를 그린 구성도입니다.
DNS Query 패킷의 구성 절차는 아래와 같습니다.
1. Spring Gateway Pod에서 DNS Query를 위해 Pod IP 주소를 Source로, kube-dns ClusterIP 주소와 53 포트를 Destination으로 하는 UDP 패킷을 생성합니다.
2. CoreDNS Pod는 노드 밖에 있으므로, IPTable의 라우팅 규칙에 따라 kube-dns ClusterIP가 노드 밖의 Pod를 Destination으로 변경합니다.
3. 패킷이 노드 밖으로 나가야 된다는 것을 인지하면, SNAT를 수행해야 하므로 Source IP 주소에 특정한 포트 번호를 부여합니다.
4. 포트 번호를 부여받은 패킷은 SNAT를 수행해 Source IP 주소를 Node IP로 변경합니다.
5. Node의 라우팅을 담당하는 Routing table에서 목적지 노드로 패킷을 전달합니다. CSP에서 제공하는 네트워크에서 Kubernetes를 사용중일 경우 VPC Routing Table이 이 절차를 수행합니다.
6. 패킷을 전달받은 CoreDNS Pod는 요청에 대한 응답을 전송하기 위해 Source와 Destination을 뒤바꾼 패킷을 생성해 전송합니다.
7. CoreDNS가 존재하는 Node에서 나가는 패킷은 DNAT를 수행해 Destination IP를 목적지 Node IP로 변경합니다.
8. 이하 동일한 절차로 Spring Gateway Pod로 응답이 전달됩니다.
위 절차에서 SNAT와 DNAT가 수행되는 이유는, 패킷을 전달해야 하는 Node 단의 Routing table이 Pod IP를 인지하지 못하기 때문입니다.
Routing table이 인지할 수 있는 Node IP로 NAT를 수행해 Routing table이 정상적으로 패킷을 전달할 수 있도록 하는 것이 SNAT와 DNAT의 목적입니다.
SNAT를 수행하기 전에 Source IP에 특정한 포트 번호가 부여되는 이유는 Node를 나가는 패킷들을 식별하고 분리하기 위함입니다.
위 정보들을 바탕으로 원인 파악을 위해 CoreDNS Log와 VPC Flow Log를 확인해봤지만, DNS Query와 관련된 로그가 발견되지 않았습니다.
따라서 해당 이슈는 위 절차의 1~3번, 즉 Node 내 패킷이 구성되는 과정에 문제가 있는 것으로 범위를 좁힐 수 있었습니다.
2-3 Linux Host Network
위 Container Network에서 노드 내 DNS Query 패킷 생성 과정에 이슈의 원인이 있음을 확인할 수 있었습니다.
이제 패킷 생성 과정에서 어떤 문제가 있었는지 원인을 파악하기 위해 더 아래의 Linux Host Network 단에서 문제를 살펴봤습니다.
그리고 조사 결과, 노드 내에서 패킷이 나가지 못하는 경우 아래와 같은 문제가 원인이 됨을 알 수 있었습니다.
1. SNAT가 필요한 패킷이 생성될 경우, Linux의 Netfilter는 Conntrack을 통해서 패킷에 특정 포트 번호를 부여하고, 이 정보들을 테이블에 기록합니다.
2. 이 Conntrack이 관리하는 테이블은 충분히 Consistency하지 않기 때문에 짧은 시간에 대량의 패킷이 생성되는 경우 Race Condition으로 인해 동시에 동일한 포트 번호를 부여하는 경우가 발생합니다.
3. 이 경우 Conntrack은 둘 중 한 패킷을 비정상 패킷으로 판단해 Drop합니다.
4. DNS Query를 위해 생성된 패킷은 UDP 프로토콜을 사용하므로 Spring Gateway는 패킷이 Drop되면 연결을 재시도하지 못한채 DNS Failed 에러를 띄우게 됩니다.
즉 해당 이슈의 발생 원인은 SNAT가 필요한 패킷이 대량으로 생성될 경우, Race Condition으로 인해 Conntrack이 동시에 같은 포트 번호를 부여함으로써 패킷이 Drop된 것이라고 할 수 있습니다.
이제 위 이슈의 근본 원인을 해결하기 위해 어떤 방안을 적용할 수 있는지 알아보겠습니다.
3. Resolution
이 이슈가 발생한 것에는 2가지 이유가 존재합니다.
첫번째는 Spring Gateway가 대규모 트래픽을 라우팅하기 위해 노드 밖의 CoreDNS로 DNS Query를 수행하는 횟수가 너무 많다는 것.
두번째는 DNS Query를 위해 전송되는 패킷이 UDP 프로토콜이기 때문에 Conntrack의 Drop에 대응할 수 없다는 것.
특히 Spring Gateway가 비동기 방식으로 대량의 쓰레드를 통해 패킷을 생성했으므로 해당 이슈가 발생하기 최적인 상황이었습니다.
따라서 이슈를 해결하기 위해서는 DNS Query를 위해 노드 밖으로 보내는 패킷 수를 줄여야 하고, 가능한 경우 TCP 프로토콜을 사용해 패킷 Drop에 대응할 수 있도록 해야 했습니다.
이같은 방안을 적용하기 위해 NodeLocal DNSCache를 사용할 수 있습니다.
3-1. NodeLocal DNSCache란?
NodeLocal DNSCache란 기존 CoreDNS가 수행하는 DNS Resolver 역할을 노드 내에서 처리할 수 있는 DNS Cache Server입니다.
NodeLocal DNSCache의 역할은 DNS Resolving이 필요한 Application이 노드 밖으로 패킷을 보내지 않아도 되도록 DNS 정보를 캐싱해 놓는 것입니다.
노드 내의 DNS Cache Server 역할을 하기 위해 NodeLocal DNSCache는 노드마다 하나씩 띄워지도록 Daemonset을 통해 배포됩니다.
NodeLocal DNSCache는 아래 절차로 역할을 수행합니다.
1. NodeLocal DNSCache가 배포되면 노드의 IPtable 규칙을 수정해 DNS Server로 향하는 DNS Query 패킷을 Link-Local Address(ex: 169.254.20.10)으로 전달되도록 합니다.
2. Pod가 DNS Query 요청을 전송하면 Link-Local Address로 향하는 패킷을 받아 NodeLocal DNSCache가 요청을 받습니다.
3. NodeLocal DNSCache는 해당 도메인에 대한 Cache가 존재해 Cache가 Hit될 경우, 응답을 요청 Pod에 전달합니다.
4. Cache가 Miss될 경우, NodeLocal DNSCache는 Upstream DNS Server, 즉 CoreDNS에 요청을 보내 정보를 가져와 캐싱합니다.
3-2. NodeLocal DNSCache 효과
이같이 동작하는 NodeLocal DNSCache를 이슈가 발생한 클러스터에 적용하면 아래와 같은 효과를 기대할 수 있습니다.
- SNAT를 요구하는 패킷의 대량 생성으로 인한 Conntrack 패킷 Drop 감소
-> NodeLocal DNSCache를 적용할 경우 노드 내에서 캐싱된 정보로 DNS Resolving을 수행할 수 있으므로, DNS Query를 위해 SNAT가 필요한 패킷이 감소 - DNS Query 패킷 구성에 TCP 프로토콜을 사용함으로써 패킷 Drop에 대응 가능
-> NodeLocal DNSCache는 Upstream DNS Server로 요청을 전송할때 TCP 프로토콜을 사용함으로, SNAT가 필요한 DNS Query 패킷이 Drop될 경우에도 연결 재시도 가능
실제로 위 효과가 적용되었는지 확인해보도록 하겠습니다.
UDP -> TCP Connection upgrade
NodeLocal DNSCache 적용 전 CoreDNS Log
NodeLocal DNSCache를 적용하기 전에는 CoreDNS로 들어오는 패킷이 대부분 UDP 프로토콜로 구성된 것임을 확인할 수 있습니다.
NodeLocal DNSCache 적용 후 CoreDNS Log
NodeLocal DNSCache를 적용한 후에는 NodeLocal DNSCache가 Cache Miss일 경우 TCP 프로토콜로 Upstream에 요청을 전송하기 때문에 CoreDNS로 들어오는 패킷이 대부분 TCP 프로토콜로 변경된 것을 확인할 수 있습니다.
Reduce CoreDNS Request
NodeLocal DNSCache 적용 전 CoreDNS Request Metric (UDP)
NodeLocal DNSCache를 적용하기 전에는 CoreDNS로 들어오는 Request 양이 분당 180회 정도로 관측되었습니다.
NodeLocal DNSCache 적용 후 CoreDNS Request Metric (TCP)
NodeLocal DNSCache를 적용한 후에는 CoreDNS로 들어오는 Request 양이 분당 30회 정도로 확연히 낮아진 것을 확인할 수 있었습니다.
위 두 관측 결과에 의해, NodeLocal DNSCache 적용은 CoreDNS로 들어오는 DNS Query 요청량 감소, TCP 프로토콜 사용이라는 2가지 효과를 볼 수 있음을 확인할 수 있습니다.
두 효과로 인해서 이번 이슈였던 DNS Query Failed 에러로 인한 문제를 해결할 수 있었습니다.
4. 마무리
Kubernetes 환경에서 발생한 DNS Query Failed 문제는 특정 시간대에 대량의 트래픽이 몰릴 때 발생했습니다.
이 문제의 근본 원인은 Spring Gateway Pod가 노드 밖의 CoreDNS로 너무 많은 DNS Query를 수행하면서 SNAT 패킷이 대량으로 생성되어 Conntrack에 의해 패킷이 드롭되는 것이었습니다.
이 문제를 해결하기 위해 NodeLocal DNSCache를 도입하여, 노드 내에서 DNS 정보를 캐싱함으로써 DNS Query 요청량을 감소시켰습니다.
NodeLocal DNSCache 적용 후 CoreDNS로의 요청이 크게 줄어들었고, DNS Query 패킷 구성에 TCP 프로토콜을 사용하여 패킷 드롭에 효과적으로 대응할 수 있게 되었습니다. 실제로 이러한 조치를 통해 DNS Query Failed 문제를 성공적으로 해결할 수 있었습니다.
이 문제를 겪고 있는 분들도 이 포스팅이 도움이 되기를 바랍니다.
'Devops' 카테고리의 다른 글
Kubernetes 환경에서 gRPC 어플리케이션 통신 이슈 해결하기 (0) | 2024.07.11 |
---|---|
Terraform을 GitOps 방식으로 사용하기 위한 도구 선택하기(With TACOS) (0) | 2024.01.27 |
EKS Kubernetes의 롤링 업데이트 시 일시적인 500 에러의 원인과 해결 (1) | 2023.11.30 |
Kubecost로 Kubernetes 환경의 FinOps를 구현해보자 (0) | 2023.11.29 |
Clean Code를 구현하기 위해 Sonarqube로 정적 코드 분석을 해보자 (2) | 2023.10.28 |