k8s에서 간헐적으로 Connection이 Reset된 이유
개요
k8s환경에서 간헐적으로 rst 패킷이 발생하는 경우가 존재했습니다.
특이한 점으로 모든 서버에서 일어나지 않았고 특정 서버에서만 발생했습니다.
해당 서버는 다른 서버에 비해 HTTP Request Body에 큰 데이터를 담아 보내는 특징이 있었고 비슷한 버그 사례가 kubernetes 공식문서에 존재하는 것을 사내 SRE 분께서 찾아주셨습니다.
해당 아티클을 기반으로 문제가 해결되었고 네트워크와 k8s 환경을 이해해보기 위해 당시 상황과 아티클을 나만의 글로 다시 정리해보고자 합니다.
아티클 정리
kube-proxy Subtleties: Debugging an Intermittent Connection Reset 주제의 글이며 2019년 글으로 6년 전의 글입니다.
직역해 보면 Kube-proxy의 미묘한 점: 간헐적 연결 재설정 디버깅에 관한 주제를 담고 있습니다.
증상으로는 대용량 파일을 주고받을 때 connection reset이 간헐적으로 발생하고 있었으며 이 증상은 쿠버네티스 환경에서만 발생하고 있었습니다.
문제에 대해 접근하기 전 쿠버네티스 네트워크에 기본 사항에 대해 먼저 소개합니다.
Pod-to-Pod
쿠버네티스에서 pod는 모두 고유한 IP 주소를 가지며, 파드간 ping을 주고받고 TCP, UDP 패킷을 보낼 수 있습니다.
이때 Container Network Interface라는 표준을 지켜 서로 다른 VM 혹은 물리 머신과 통신할 수 있게 됩니다.
Pod-to-external
Pod가 외부 인터넷 등으로 트래픽을 보내기 위해서는 Source Network Address Translation(SNAT)을 활용합니다.
SNAT는 내부 pod의 IP:port를 host의 IP:port로 변경하고 보낸 트래픽에 대한 응답 패킷이 호스트로 돌아오면 다시 파드의 IP:port로 변경하여 파드로 보내줍니다.
Pod의 내부 IP는 클래스터 내부에서만 유효하기 때문에 외부에서는 이를 인식할 수 없습니다.
Pod-to-Service
쿠버네티스에서 Pod는 언제든지 사라질 수 있기 때문에 특정 Pod IP를 사용해서 통신하면 안정적인 서비스를 제공하기 어렵습니다.
이를 위해 쿠버네티스에서는 Pod 앞에 Service 개념을 제공하여 안정적인 연결을 보장합니다.
이 Service는 L4 로드 밸런서 역할을 수행하여 트래픽을 여러 Pod로 분산해 주고 Pod가 죽거나 새로 생성되어도 클라이언트 입장에서는 같은 주소로 접속할 수 있습니다.
쿠버네티스에서는 여러 유형의 Service가 존재하지만 기본적으로 ClusterIP 타입이 있습니다.
클러스터 내부에서만 접근 가능한 가상 IP(Virtual IP, VIP)를 가지며 클러스터 외부에서는 접근이 불가능합니다.
이를 통해 Pod 들이 서로 안정적으로 통신할 수 있도록 보장합니다.
ClusterIP 타입 이외에도 NodePort, LoadBalancer, ExternalName 등이 존재합니다.
- ClusterIP → 내부에서만 접근 가능 (기본값)
- NodePort → 노드의 특정 포트로 외부 접근 가능
- LoadBalancer → 클라우드 로드 밸런서를 통해 인터넷 접근 가능
- ExternalName → 내부 DNS를 외부 도메인으로 매핑
쿠버네티스에서 Service를 구현하는 컴포넌트를 kube-proxy라고 부릅니다.
각 노드에 존재하여 Pod와 Service 간의 NAT을 처리하는 복잡한 iptables 규칙을 설정합니다.
kube-proxy는 서비스에 대한 요청이 들어오면 iptables 규칙을 기반으로 적절한 Pod에게 전달합니다.
쿠버네티스 노드에서 iptables-save 명령을 실행하면 쿠버네티스에 설정된 규칙을 볼 수 있습니다.
중요한 규칙으로는 KUBE-SERVICES, KUBE-SVC-*, KUBE-SEP-* 가 있습니다.
kube-proxy가 NAT(Network Address Translation) 역할을 수행하는 것을 위 그림이 보여줍니다.
Pod A가 Service 1의 주소로 네트워크 통신을 수행하면 Pod A는 Cluster IP로 보내주지만 DNAT(Destination NAT)을 수행하여 도착지의 주소를 Pod B로 변경하게 됩니다.
DNAT에서는 conntrack라는 기능이 동작하여 connection의 상태를 추적합니다.
DNAT은 패킷의 목적지 주소를 바꾸는데 이를 변경한 후에는 원래의 목적지 주소로 돌려보내기 위해서 상태 정보를 기억해야 합니다.
iptables는 conntrack 상태에 의존하여 패킷의 행선지를 결정합니다.
패킷의 행선지를 결정하기 위해 NEW, ESTABLISHED, RELATED, INVALID 4가지의 상태가 특히 중요합니다.
- NEW : SYN 패킷이 수신된 상태로 conntrack는 아무것도 알지 못하는 상태입니다.
- ESTABLISHED : handshake가 완료된 후 발생하며, 연결이 정상적으로 설정되어 데이터 송수신이 이루어지는 상태로 conntrack는 이 패킷이 연결된 connection에 속한다는 것을 인지하고 있습니다.
- RELATED: 패킷은 어떤 연결에도 속하지 않지만 다른 연결에 연결되어 있어 FTP와 같은 프로토콜에 특히 유용합니다.
- INVALID : 패킷에 문제가 있어 conntrack가 이를 처리할 수 없는 상태입니다. (현재 쿠너베티스 connection reset이 발생하는 문제에서 중요한 역할의 상태입니다.)
그렇다면 실제로 예상하지 못한 connection reset이 발생하는 주요 원인은 무엇일까요?
위 다이어그램에서 패킷 3이 capacity의 부족 혹은 TCP window를 벗어나는 경우 INVALID 상태가 될 수 있습니다.
이때 이를 삭제하는 iptables 규칙이 없기 때문에 패킷 4에서 SRC IP 주소가 Kube-Proxy 주소로 변환되지 않게 됩니다.
Client Pod는 Server Pod의 IP에 연결된 적이 없기 때문에 RST 패킷을 전송하게 됩니다.
근본원인이 파악되었으니 위 문제를 어떻게 해결할 수 있을까요?
k8s 1.15 버전 이상에서는 conntrack에 의해 유효하지 않은 것으로 간주되는 패킷이 삭제되어 클라이언트 파트에 도달하지 않도록 합니다.
현재 서비스의 k8s 버전은?
현재 서비스에는 k8s를 1.20 이상 버전을 활용하고 있었습니다.
그러면 위 connection reset 문제가 발생하지 않아야 합니다.
다만 현재 서비스는 standalone k8s여서 kube-proxy를 활용하고 있지 않았습니다.
standalone k8s란 단일 노드에서 실행되는 쿠버네티스 환경을 뜻합니다.
docker-proxy를 활용하여 ip:port로 진입하면 container-ip:port로 로드밸런싱을 수행하고 있었습니다.
docker-proxy란 -p 옵션을 활용하여 포트를 리스닝한 후 컨테이너의 port로 넘기는 역할을 수행합니다.
따라서 docker-proxy 과정에서 INVALID 패킷이 발생하면 DNAT, SNAT을 수행하는 과정에서 위 문제가 발생할 수 있습니다.
해결방법으로는 net.netfilter.nf_conntrack_tcp_be_liberal = 1 으로 세팅하여 해결할 수 있습니다.
해당 옵션은 INVALID 상태의 패킷을 더 관대하게 처리하도록 설정하는 옵션으로 일부 예외적인 상황에서도 패킷을 통과시킵니다.
다만 정상적인 TCP 세션이 아닌 패킷도 허용될 가능성이 높아져 보안적으로 단점이 존재합니다.
참고자료
https://github.com/kubernetes/kubernetes/issues/119887
https://velog.io/@_gyullbb/Cilium