올해도 가시다님의 은총을 받아 Kubernetes 스터디에 참여하게 되었다.
이번 스터디는 '24단계 실습으로 정복하는 쿠버네티스' 책과 함께 진행되는 스터디임을 밝힌다.
CNI (Container Network Interface), a Cloud Native Computing Foundation project, consists of a specification and libraries for writing plugins to configure network interfaces in Linux containers, along with a number of supported plugins. CNI concerns itself only with network connectivity of containers and removing allocated resources when the container is deleted. Because of this focus, CNI has a wide range of support and the specification is simple to implement.
CNI는 리눅스 컨테이너용 네트워크 인터페이스라고 정의할 수 있겠다. k8s에서 파드가 외부/내부 통신을 할 때 이 CNI를 사용하게 되는데, 여러 3rd party에서 CNI를 제공한다.
이번 포스팅에서는 kops를 통해 k8s 설치 시 제공되는 AWS VPC CNI에 대해 알아보자.
먼저 kops를 통해 AWS VPC CNI를 사용하는 k8s 클러스터를 설치해보자. 스터디장인 가시다님이 제공해주신 cloudformation 파일로 진행하였다.
# YAML 파일 다운로드
curl -O https://s3.ap-northeast-2.amazonaws.com/cloudformation.cloudneta.net/K8S/kops-oneclick-f1.yaml
# CloudFormation 스택 배포 : 노드 인스턴스 타입 변경 - MasterNodeInstanceType=t3.medium WorkerNodeInstanceType=c5d.large
aws cloudformation deploy --template-file kops-oneclick-f1.yaml --stack-name mykops --parameter-overrides KeyName=kp-gasida SgIngressSshCidr=$(curl -s ipinfo.io/ip)/32 MyIamUserAccessKeyID=AKIA5... MyIamUserSecretAccessKey='CVNa2...' ClusterBaseName='gasida.link' S3StateStore='gasida-k8s-s3' MasterNodeInstanceType=t3.medium WorkerNodeInstanceType=c5d.large --region ap-northeast-2
# 13분 후 작업 SSH 접속
ssh -i ~/.ssh/kp-gasida.pem ec2-user@$(aws cloudformation describe-stacks --stack-name mykops --query 'Stacks[*].Outputs[0].OutputValue' --output text)
# ExternalDNS IAM 정책 생성 : 이미 정책이 있다면 Skip~
curl -s -O https://s3.ap-northeast-2.amazonaws.com/cloudformation.cloudneta.net/AKOS/externaldns/externaldns-aws-r53-policy.json
aws iam create-policy --policy-name AllowExternalDNSUpdates --policy-document file://externaldns-aws-r53-policy.json
# AWSLoadBalancerController IAM 정책 생성 : 이미 정책이 있다면 Skip~
curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.5/docs/install/iam_policy.json
aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam_policy.json
# EC2 instance profiles 에 IAM Policy 추가(attach) : 처음 입력 시 적용이 잘 안될 경우 다시 한번 더 입력 하자! - IAM Role에서 새로고침 먼저 확인!
aws iam attach-role-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy --role-name masters.$KOPS_CLUSTER_NAME
aws iam attach-role-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy --role-name nodes.$KOPS_CLUSTER_NAME
aws iam attach-role-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/AllowExternalDNSUpdates --role-name masters.$KOPS_CLUSTER_NAME
aws iam attach-role-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/AllowExternalDNSUpdates --role-name nodes.$KOPS_CLUSTER_NAME
# (옵션) LimitRanges 기본 정책 삭제
kubectl describe limitranges # LimitRanges 기본 정책 확인 : 컨테이너는 기본적으로 0.1CPU(=100m vcpu)를 최소 보장(개런티)
kubectl delete limitranges limits
kubectl get limitranges
간단히 설명하자면, Installing Kubernetes with kops on AWS 실습과 같이 cloudformation으로 kops를 활용한 k8s 배포, k8s 컨트롤용 EC2 배포를 해준다.
이후, IAM 정책 생성 및 적용을 하여 k8s 각 노드에 ExternalDNS, AWSLoadBalancerController 퍼미션을 허용해 주는 작업을 하는 것이다.
Amazon EKS implements cluster networking through the Amazon VPC Container Network Interface(VPC CNI) plugin. The CNI plugin allows Kubernetes Pods to have the same IP address as they do on the VPC network. More specifically, all containers inside the Pod share a network namespace, and they can communicate with each-other using local ports.
AWS VPC CNI의 가장 큰 특징은 k8s상에서 Node와 Pod의 IP대역이 같다는 것이다.
이렇게 되면, 네트워크 통신 시 Overlay 네트워크를 사용하지 않고 직접 통신하기 때문에, 통신상 이점을 갖는다는 것이다.
그럼 실제로 AWS VPC CNI가 적용이 되었는지 확인해보자.
# CNI 정보 확인
kubectl describe daemonset aws-node --namespace kube-system | grep Image | cut -d "/" -f 2
amazon-k8s-cni-init:v1.12.2
amazon-k8s-cni:v1.12.2
# 노드 IP 확인
aws ec2 describe-instances --query "Reservations[*].Instances[*].{PublicIPAdd:PublicIpAddress,PrivateIPAdd:PrivateIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output table
# 파드 IP 확인
kubectl get pod -n kube-system -o=custom-columns=NAME:.metadata.name,IP:.status.podIP,STATUS:.status.phase
(kimchigood:default) [root@kops-ec2 ~]# aws ec2 describe-instances --query "Reservations[*].Instances[*].{PublicIPAdd:PublicIpAddress,PrivateIPAdd:PrivateIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output table
---------------------------------------------------------------------------------------------------------
| DescribeInstances |
+--------------------------------------------------------+----------------+-----------------+-----------+
| InstanceName | PrivateIPAdd | PublicIPAdd | Status |
+--------------------------------------------------------+----------------+-----------------+-----------+
| nodes-ap-northeast-2c.kimchigood.link | 172.30.69.44 | 13.125.213.230 | running |
| nodes-ap-northeast-2a.kimchigood.link | 172.30.36.248 | 43.201.108.105 | running |
| control-plane-ap-northeast-2a.masters.kimchigood.link | 172.30.45.133 | 13.125.61.111 | running |
| kops-ec2 | 10.0.0.10 | 13.125.197.117 | running |
+--------------------------------------------------------+----------------+-----------------+-----------+
(kimchigood:default) [root@kops-ec2 ~]#
(kimchigood:default) [root@kops-ec2 ~]# kubectl get pod -n kube-system -o=custom-columns=NAME:.metadata.name,IP:.status.podIP,STATUS:.status.phase
NAME IP STATUS
aws-cloud-controller-manager-qfspm 172.30.45.133 Running
aws-load-balancer-controller-df47b7dbf-g9ppr 172.30.45.133 Running
aws-node-gtjhm 172.30.45.133 Running
aws-node-wkds8 172.30.36.248 Running
aws-node-ztfkl 172.30.69.44 Running
cert-manager-9d894d6f-vgsjk 172.30.57.209 Running
cert-manager-cainjector-8659b5599b-h6j7z 172.30.57.211 Running
cert-manager-webhook-854dfb7d9-crxbs 172.30.57.210 Running
coredns-68cd66b8cc-8l284 172.30.89.209 Running
coredns-68cd66b8cc-pkww2 172.30.89.212 Running
coredns-autoscaler-66fbc7dd48-45xrb 172.30.89.210 Running
Node와 Pod의 IP단위가 일치하는 것을 볼 수 있다. 아래 그림을 통해, 조금 더 디테일하게 살펴보자.
Node에서 Network Namespacesms Root와 Per Pod Namespace로 나눠진다. AWS VPC CNI의 경우, t3.medium EC2 기준으로 최대 3개의 ENI를 가질 수 있고, 각 ENI는 자기 자신의 IP를 포함하여 최대 6개 IP를 가질 수 있다.
EC2 Type 별 ENI 갯수는 아래 커맨드를 통해 확인이 가능하다.
# t3 타입의 정보(필터) 확인
aws ec2 describe-instance-types --filters Name=instance-type,Values=t3.* \
--query "InstanceTypes[].{Type: InstanceType, MaxENI: NetworkInfo.MaximumNetworkInterfaces, IPv4addr: NetworkInfo.Ipv4AddressesPerInterface}" \
--output table
--------------------------------------
| DescribeInstanceTypes |
+----------+----------+--------------+
| IPv4addr | MaxENI | Type |
+----------+----------+--------------+
| 15 | 4 | t3.xlarge |
| 15 | 4 | t3.2xlarge |
| 6 | 3 | t3.medium |
| 12 | 3 | t3.large |
| 2 | 2 | t3.nano |
| 4 | 3 | t3.small |
| 2 | 2 | t3.micro |
+----------+----------+--------------+
Node에 배포되는 Pod가 늘어나면, 그 수에 맞게 ENI가 생성되는 방식이다. 특이하게 aws-node, kube-proxy Pod는 Root Namespace의 IP와 같은 IP를 사용한다.
그럼 실제로 Pod를 생성하면, 어떤일이 벌어지는 지 확인해보자.
# [터미널1~2] 워커 노드 1~2 모니터링
ssh -i ~/.ssh/id_rsa ubuntu@$W1PIP
watch -d "ip link | egrep 'ens5|eni' ;echo;echo "[ROUTE TABLE]"; route -n | grep eni"
ssh -i ~/.ssh/id_rsa ubuntu@$W2PIP
watch -d "ip link | egrep 'ens5|eni' ;echo;echo "[ROUTE TABLE]"; route -n | grep eni"
# 테스트용 파드 netshoot-pod 생성
cat <<EOF | kubectl create -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: netshoot-pod
spec:
replicas: 2
selector:
matchLabels:
app: netshoot-pod
template:
metadata:
labels:
app: netshoot-pod
spec:
containers:
- name: netshoot-pod
image: nicolaka/netshoot
command: ["tail"]
args: ["-f", "/dev/null"]
terminationGracePeriodSeconds: 0
EOF
# 파드 이름 변수 지정
PODNAME1=$(kubectl get pod -l app=netshoot-pod -o jsonpath={.items[0].metadata.name})
PODNAME2=$(kubectl get pod -l app=netshoot-pod -o jsonpath={.items[1].metadata.name})
# 파드 확인
kubectl get pod -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME IP
netshoot-pod-7757d5dd99-rnthn 172.30.89.214
netshoot-pod-7757d5dd99-vd6vz 172.30.62.211
[Node1]
# [터미널1~2] 워커 노드 1~2 모니터링
ssh -i ~/.ssh/id_rsa ubuntu@$W1PIP
watch -d "ip link | egrep 'ens5|eni' ;echo;echo "[ROUTE TABLE]"; route -n | grep eni"
Every 2.0s: ip link | egrep 'ens5|eni' ;echo;echo [ROUTE TABLE]; route ... i-084bcce46543d44d7: Thu Mar 16 05:48:57 2023
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP mode DEFAULT group default qlen 1000
4: eniecdadc51b20@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
5: eni0edf1a5e2ed@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
6: eni6f92beb7716@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
7: enif02d679d6af@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
8: eni505d1ef0ea1@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
10: enide7b0c0d04b@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
[ROUTE TABLE]
172.30.89.208 0.0.0.0 255.255.255.255 UH 0 0 0 eniecdadc51b20
172.30.89.209 0.0.0.0 255.255.255.255 UH 0 0 0 eni0edf1a5e2ed
172.30.89.210 0.0.0.0 255.255.255.255 UH 0 0 0 eni6f92beb7716
172.30.89.211 0.0.0.0 255.255.255.255 UH 0 0 0 enif02d679d6af
172.30.89.212 0.0.0.0 255.255.255.255 UH 0 0 0 eni505d1ef0ea1
172.30.89.214 0.0.0.0 255.255.255.255 UH 0 0 0 enide7b0c0d04b
[Node2]
ssh -i ~/.ssh/id_rsa ubuntu@$W2PIP
watch -d "ip link | egrep 'ens5|eni' ;echo;echo "[ROUTE TABLE]"; route -n | grep eni"
Every 2.0s: ip link | egrep 'ens5|eni' ;echo;echo [ROUTE TABLE]; route... i-05bcd0c33e3822d67: Thu Mar 16 05:50:16 2023
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP mode DEFAULT group default qlen 1000
4: eni8a856a91bf7@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
5: eni6e013bf5310@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
7: eni0f8cb7773c4@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP mode DEFAULT group default
[ROUTE TABLE]
172.30.62.208 0.0.0.0 255.255.255.255 UH 0 0 0 eni8a856a91bf7
172.30.62.209 0.0.0.0 255.255.255.255 UH 0 0 0 eni6e013bf5310
172.30.62.211 0.0.0.0 255.255.255.255 UH 0 0 0 eni0f8cb7773c4
172.30.89.214, 172.30.62.211가 각 Node의 Route Table에 추가되었고, eni도 등록된 것을 볼 수 있다.
서로 다른 Node에 배포되어 있는 Pod들은 어떻게 통신하게 될까?
AWS VPC CNI에서는 위에 설명한 바와 같이 Node와 Pod의 대역이 같으므로, Overlay 통신을 하지 않는다고 했다.
이론적으로 서로 다른 Node에 있는 Pod간 통신을 하게 되면 NAT 없이 통신이 되어야 한다.
tcp dump를 사용하여, 테스트를 해보자, Ping을 날리기 전 각 노드에서 tcp dump를 보고 있어야 한다.
# 파드 IP 변수 지정
PODIP1=$(kubectl get pod -l app=netshoot-pod -o jsonpath={.items[0].status.podIP})
PODIP2=$(kubectl get pod -l app=netshoot-pod -o jsonpath={.items[1].status.podIP})
# 파드1 Shell 에서 파드2로 ping 테스트
kubectl exec -it $PODNAME1 -- ping -c 2 $PODIP2
# 파드2 Shell 에서 파드1로**텍스트** ping 테스트
kubectl exec -it $PODNAME2 -- ping -c 2 $PODIP1
# 워커 노드 EC2 : TCPDUMP 확인 - ens6 에서 패킷 덤프 확인이 되나요?
sudo tcpdump -i any -nn icmp
k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
netshoot-pod-7757d5dd99-rnthn 1/1 Running 0 16m 172.30.89.214 i-084bcce46543d44d7 <none> <none>
netshoot-pod-7757d5dd99-vd6vz 1/1 Running 0 16m 172.30.62.211 i-05bcd0c33e3822d67 <none> <none>
# 결과
sudo tcpdump -i any -nn icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), capture size 262144 bytes
06:02:29.065402 IP 172.30.89.214 > 172.30.62.211: ICMP echo request, id 59497, seq 1, length 64
06:02:29.065449 IP 172.30.89.214 > 172.30.62.211: ICMP echo request, id 59497, seq 1, length 64
06:02:29.066381 IP 172.30.62.211 > 172.30.89.214: ICMP echo reply, id 59497, seq 1, length 64
06:02:29.066466 IP 172.30.62.211 > 172.30.89.214: ICMP echo reply, id 59497, seq 1, length 64
06:02:30.065864 IP 172.30.89.214 > 172.30.62.211: ICMP echo request, id 59497, seq 2, length 64
06:02:30.065900 IP 172.30.89.214 > 172.30.62.211: ICMP echo request, id 59497, seq 2, length 64
06:02:30.066774 IP 172.30.62.211 > 172.30.89.214: ICMP echo reply, id 59497, seq 2, length 64
06:02:30.066785 IP 172.30.62.211 > 172.30.89.214: ICMP echo reply, id 59497, seq 2, length 64
IP 172.30.89.214 > 172.30.62.211: ICMP echo request -> 정확히 Pod1의 IP가 Pod2의 IP로 이동하는 것이 찍혔다. 이것은 AWS VPC CNI만의 특징이라고 할 수 있다. 왜 이게 가능할까? 바로, Node와 Pod의 IP대역이 같기 때문이다.
일반적으로 Node간 Pod 통신 시 Pod1의 요청이 Node1의 IP로 SNAT이 되어 전달 될 것이다.
그림출처: https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/cni-proposal.md
위 그림은 복잡하지만, 원리는 간단하다. Pod에서 외부 통신할 때는 iptables 룰에 따라 Node의 Public IP로 SNAT 되어 전달이 된다.
# Node IP 확인
k get nodes -owide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
i-0568fbc3a17a02d7b Ready control-plane 5h53m v1.24.11 172.30.45.133 13.125.61.111 Ubuntu 20.04.5 LTS 5.15.0-1031-aws containerd://1.6.18
i-05bcd0c33e3822d67 Ready node 5h50m v1.24.11 172.30.36.248 43.201.108.105 Ubuntu 20.04.5 LTS 5.15.0-1031-aws containerd://1.6.18
i-084bcce46543d44d7 Ready node 5h51m v1.24.11 172.30.69.44 13.125.213.230 Ubuntu 20.04.5 LTS 5.15.0-1031-aws containerd://1.6.18
# Pod IP 확인
k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
netshoot-pod-7757d5dd99-rnthn 1/1 Running 0 16m 172.30.89.214 i-084bcce46543d44d7 <none> <none>
netshoot-pod-7757d5dd99-vd6vz 1/1 Running 0 16m 172.30.62.211 i-05bcd0c33e3822d67 <none> <none>
# Pod1에서 외부 통신
kubectl exec -it $PODNAME1 -- curl -s ipinfo.io/ip ; echo
13.125.213.230
위 실습코드에서 Pod1이 외부통신할 때 자신이 나가는 IP를 조회하면, Node의 Public IP와 같은 값인 13.125.213.230가 나오게 된다. iptables 룰에 의해 SNAT이 된건데, 직접 Node로 접근하여, 살펴보자.
ssh -i ~/.ssh/id_rsa ubuntu@$W1PIP
sudo iptables -t nat -S | grep 'A AWS-SNAT-CHAIN'
-A AWS-SNAT-CHAIN-0 ! -d 172.30.0.0/16 -m comment --comment "AWS SNAT CHAIN" -j AWS-SNAT-CHAIN-1
-A AWS-SNAT-CHAIN-1 ! -o vlan+ -m comment --comment "AWS, SNAT" -m addrtype ! --dst-type LOCAL -j SNAT --to-source 172.30.69.44 --random-fully
대충 보면, 172.30.0.0/16 대역의 IP 요청이 오면, SNAT --to-source 172.30.69.44 여기로 SNAT하라는 룰로 보인다. 172.30.69.44은 Node Private IP이고, 결국 외부로 나갈 때는 Public IP인 13.125.213.230로 바뀌어서 나갈 것이다.
내 부족한 네트워크 지식으로 설명하자면, Private IP로 외부 통신을 하게되면 외부서버에서 응답을 할 때 줄 곳을 찾을 수 없다. 그래서 Public IP로 SNAT 되는 것이다.
마찬가지로 Node간 Pod 통신 시에도 원래는 SNAT이 적용되겠지만, AWS VPC CNI는 대역이 같기 때문에 다이렉트하게 통신이 되는 것이다.
이것은 k8s 네트워크의 일부이고 더 깊게 들어가면 처음 보는 명령어나 기능들이 많다. 네트워크 세계는 정말 복잡한 것 같다.
Pod 간 통신, Pod에서 외부세계로 통신은 어찌보면 k8s에서 아주 당연한 기능이다. 그런데 이렇게 Node에서 TCP 덤프까지 떠가며 확인해보지는 못했었다.
나에게 네트워크라는 영역은 아직도 미지의 세계처럼 보인다. 작년에 WebRTC 프로젝트를 할 때 k8s를 사용했었는데, UDP 트래픽에 이슈가 있었다.
삽질을 얼마나 했는지, 결국 Node의 iptables 룰까지 들여다보는 지경에 이르렀다. 생전 듣도보도 못한 iptables, conntrack.. 여차저차 해결은 했지만, 이런 것들은 너무나 생소해서 그땐 공포에 가까웠다.
이번 스터디에서 네트워크를 조금이나마 경험할 수 있어서 정말 값진 경험을 하고 있는 것 같다. 어플리케이션 개발과는 또다른 매력이 있는 것 같아, 틈틈히 공부해야겠다.