0. Contents
1. Overview
3장 정리: 서버 한 대 안의 이야기
- 방(네임스페이스)을 만들었다
- 문(인터페이스)을 달고, 파이프(veth)로 호스트의 브리지에 꽂았다
- 호스트가 라우팅과 NAT 로 밖에 내보냈다
이 편: 서버가 여러 대일 때
- 방은 Pod 라는 이름을 얻는다
- 서버는 노드가 된다
- 3장에서 손으로 친 명령은 CNI 플러그인과 kube-proxy 가 대신 친다
쿠버네티스 네트워킹의 문제 네 가지
- ① 같은 Pod 안의 컨테이너끼리
- ② 다른 노드의 Pod 끼리
- ③ 계속 교체되는 Pod 를 고정 주소로
- ④ 클러스터 밖에서 안으로
문제별 담당과 이 편의 범위
- ①② 는 Pod 네트워크의 몫이고, ③④ 는 그 위에 얹는 Service·DNS·Ingress 의 몫이다
- 여기서는 Pod 네트워크 — ①② 와 ③ 의 출발점 — 와 그것을 만드는 쪽(컨트롤 플레인, kubelet, CNI)을 본다
2. Four Problems — 방 하나, 방끼리, 바뀌는 방, 밖에서
네 문제와 푸는 쪽
- 네 문제를 그림 하나씩으로 둔다
- 뒤의 절은 전부 이 넷 중 어디에 속하는지로 읽힌다
- ①② 는 Pod 네트워크가 푼다
- 4.1 의 세 규칙은 ② 를 위해 Pod 네트워크가 지켜야 하는 요구 사항이다
- ③④ 는 Pod 네트워크 위에 얹는 Service·DNS·Ingress 가 푼다
2.1 사설 IP 와 라우팅 표
Docker bridge 의 한계: 호스트 한 대 기준
- Docker 의 기본 bridge 는 호스트 한 대 기준이다
- 서버 A 의 docker0 과 서버 B 의 docker0 은 서로 모른다
- 둘 다 172.17.0.0/16 을 써서 주소까지 겹친다
- 그래서 다른 서버의 컨테이너에 가려면 03-2 의 5.2 처럼 호스트 포트를 열고 NAT 로 나가야 했다
- 쿠버네티스는 이것을 앱마다 손으로 하지 말고, 클러스터가 처음부터 깔아 두라고 요구한다
닿지 못하는 이유: 라우팅 표에 없는 주소
- 서버 A 의 컨테이너가 서버 B 의 컨테이너 172.17.2.3 에 닿지 못하는 이유는 172.17 이 사설 주소라서가 아니다
- 서버 A 의 라우팅 표에 172.17.2.3 을 어디로 보낼지 적혀 있지 않다
- 중간 라우터도 그 주소를 모른다
- 설령 도착해도 답장이 서버 A 로 돌아올 줄이 서버 B 의 표에 없다
노드 IP 와의 차이
- 노드 IP 10.0.0.x 도 사설 주소인데 라우터는 거기에 닿는다
- 라우터 자신의 다리가 10.0.0.1 로 그 대역에 꽂혀 있어 이미 안다
- 172.17.x 는 각 서버 안의 Docker 가 혼자 정한 번호라 라우터도 다른 서버도 들은 적이 없다
- 두 서버가 같은 번호를 쓰니 들어도 구분하지 못한다
- 사설이냐 아니냐가 아니라 라우터의 표에 있느냐가 차이다
필요한 경로 두 줄
- 가는 길 — 서버 A 와 중간 라우터의 표에
172.17.2.0/24 → 서버 B - 오는 길 — 서버 B 와 라우터의 표에
172.17.1.0/24 → 서버 A
경로를 만드는 방식 세 가지
- 이 두 줄을 어떻게 만드느냐가 CNI 구현의 차이다
- 진짜 라우터에 경로를 알려준다 (BGP)
- 패킷을 노드 IP 로 감싸서 라우터가 몰라도 되게 한다 (터널)
- 처음부터 라우터가 아는 VPC 주소를 Pod 에 준다 (VPC CNI)
- 7절의 비교표가 이 셋을 나눈다
3. Pod — 방 하나에 IP 하나
3.1 같은 방, 같은 포트 공간
Pod: 네임스페이스 하나에 컨테이너 여럿
- Pod 는 네트워크 네임스페이스 하나에 컨테이너를 여럿 넣은 것이다
- 03-2 에서
--network container:web으로 손으로 했던 구성이 Pod 의 기본값이다 - IP 는 컨테이너가 아니라 Pod 에 하나 붙는다
- eth0 과 lo 도 하나, 포트 공간도 하나다
- 방 하나에 문이 하나인 것이지 네임스페이스의 규칙이 그런 것은 아니다
- 호스트 루트 방에는 ens3, docker0, lo 가 문 셋이고 IP 도 셋이다
- IP 는 방이 아니라 문에 붙는다
0.0.0.0 과 127.0.0.1 의 뜻
- 0.0.0.0:8080 에 bind 한 소켓은 “이 방에 도착한 8080 패킷은 도착 IP 를 가리지 않고 받는다” 는 뜻이다
- 같은 Pod 의 사이드카가 127.0.0.1 로 불러도, 다른 Pod 가 10.244.1.5 로 불러도 받는다
- 127.0.0.1 은 고정된 장소가 아니라 “지금 패킷을 보내는 프로세스가 있는 방의 lo” 다
- 커널은 자기 방의 IP 로 가는 패킷을 전부 lo 로 돌린다(local 라우팅 테이블)
- Pod 안에서
curl 10.244.1.5를 쳐도 eth0 으로 나가지 않는다
- Pod 안에서
포트 충돌: Pod 단위
- 같은 Pod 안에서 두 컨테이너가 같은 (IP, 포트) 에 listen 하면 두 번째가
address already in use로 죽는다 → Pod 는 CrashLoopBackOff 가 된다 - 0.0.0.0:8080 과 127.0.0.1:8080 도 리눅스는 겹치는 것으로 본다
- 사이드카를 붙일 때 포트 계획은 Pod 단위로 한다
3.2 공유하는 것과 아닌 것
공유 범위: 03-1 의 4.3 에서 본 구분
- 네트워크를 공유한다고 전부 같아지는 것은 아니다
- 나눠 쓰는 것 — net, IPC, UTS
- 따로 갖는 것 — mnt, pid, cgroup
- 그래서 웹 서버와 로그 수집기를 컨테이너 둘로 나누면 이미지와 CPU·메모리 제한을 따로 두면서도 localhost 로 통한다
- 컨테이너의 limits 와 쿠버네티스 네임스페이스 단위의 ResourceQuota 는 다른 층이다
- “네임스페이스” 라는 말이 리눅스 커널의 격리 단위와 API 객체의 논리 구획, 두 층에서 쓰인다
예외 두 가지
hostNetwork: true— Pod 가 자기 방 없이 호스트 방에 들어간다- Docker 의 host 모드와 같다
- Pod IP 가 노드 IP 가 되고 호스트와 포트 공간을 공유한다
- kube-proxy 와 CNI 에이전트처럼 Pod 네트워크 자체를 만드는 것들이 이렇게 뜬다
- dual-stack — eth0 하나에 IPv4 와 IPv6 주소가 하나씩 붙는다
3.3 containerPort 의 역할
apiVersion: v1
kind: Pod
metadata:
name: go-web
labels:
app: web # Service 와 NetworkPolicy 가 이 꼬리표로 Pod 를 고른다
spec:
containers:
- name: web
image: go-web:cn4
ports:
- name: http # 아래 probe 와 Service 가 숫자 대신 이 이름으로 가리킨다
containerPort: 8080
protocol: TCP
containerPort: EXPOSE 와 같은 성격
- containerPort 는 Dockerfile 의 EXPOSE 와 같은 성격이다
- 적어도 프로세스가 bind 하지 않으면 아무것도 듣지 않는다
- 적어도 밖에서 자동으로 열리지 않는다
- 안 적어도 프로세스가 연 포트는 Pod IP 로 닿는다
- 포트를 여는 것은 프로세스의 bind, 밖에서 닿게 하는 것은 Service, 막는 것은 NetworkPolicy 다
- 이 필드는 셋 중 어느 것도 아니다
- 적는 이유는 사람이 읽기 위해서와
port: http처럼 이름으로 참조하기 위해서다
3.4 Pod 의 교체와 IP 의 수명
Pod 교체와 새 IP
- Pod 를 직접 만들면 죽어도 아무도 다시 띄우지 않는다
- 그래서 Deployment 가 ReplicaSet 을 통해 “이 템플릿으로 3개 유지” 를 맡는다
- 이미지를 바꾸면 기존 Pod 를 고치는 것이 아니라 새 Pod 를 띄우고 옛 Pod 를 지운다
- 지운 Pod 의 IP 는 반납되고 새 Pod 는 새 IP 를 받는다
- 2절의 그림 ③이 그 장면이다
- 바뀌는 쪽과 바뀌지 않는 쪽을 떼어 놓는 것이 Service 와 DNS 다
IP 가 유지되는 경계: Pod 삭제와 컨테이너 재시작
- Pod 는 이동하지 않는다
- “다른 노드로 옮겨갔다” 는 죽고, 컨트롤러가 다른 UID 로 새로 만든 것이다
- 컨테이너 재시작은 같은 방 안에서 프로세스만 다시 띄우는 것이라 IP 가 그대로다
- pause 라는 아무 일도 안 하는 작은 프로세스가 방을 붙들고 있다
- 네임스페이스는 안에 프로세스가 하나라도 있어야 살아 있는 커널 객체다
- 앱만 있는 방은 앱이 죽는 순간 사라진다
- emptyDir 이 컨테이너 재시작은 견디고 Pod 삭제에는 사라지는 것도 같은 경계다
StatefulSet 의 “안정적 네트워크 정체성” 범위
- 고정되는 것 — Pod 이름 web-0, 호스트명, DNS 이름, 붙는 디스크
- 고정되지 않는 것 — 그 DNS 이름이 가리키는 IP
- Pod spec 에 “이 IP 를 달라” 는 표준 필드는 없다
- 고정 주소는 CNI 구현의 별도 기능이다
4. Pod Network — 세 규칙과 경계
4.1 세 규칙
세 규칙의 성격과 만드는 쪽
- 세 규칙은 2절의 문제 ② 를 풀기 위해 Pod 네트워크가 지켜야 하는 요구 사항이다
- 만드는 것은 CNI 플러그인이고, 어느 것을 깔지는 구성자의 몫이다
- kubeadm 클러스터는 CNI 를 깔기 전까지 노드가 NotReady 다
Docker 와의 차이: MASQUERADE 조건
- Docker 가 서버 밖에서 깨지는 자리는 한 곳이다 — MASQUERADE 규칙의
! -o docker0- 브리지 밖으로 나가는 패킷은 전부 출발지가 바뀐다
- 쿠버네티스가 바꾸는 것은 셋이다
- 노드마다 겹치지 않는 서브넷을 준다
- 호스트 라우팅에
10.244.2.0/24 → 노드 B를 넣는다 - MASQUERADE 조건을
! -d 10.244.0.0/16으로 바꾼다 — Pod 대역끼리는 NAT 하지 않는다
책의 서술과 층의 구분
- 책의 “모든 컨테이너는 NAT 없이” 는 범위를 빠뜨린 서술이다
- Pod 네트워크 층(CNI)과 Service 층(kube-proxy)은 별개다
- Flannel + kube-proxy 도, Cilium 이 둘 다 맡는 구성도 된다
주의: 정책 기능이 없는 CNI
- 정책 기능이 없는 CNI 에서는 NetworkPolicy 를 만들어도 아무 일이 안 일어난다
kubectl apply는 성공하고 에러도 없다
- Flannel 단독 설치와 kind 의 기본 kindnet 이 그렇다
- kind 에서 kindnet 을 끄고 Cilium 을 까는 이유다
4.2 왕복 경로와 연결 방향
“A 가 B 에 닿는다” 의 세 층
- 가는 길 — A 의 패킷이 B 에 도착하는가
- 오는 길 — B 의 답장이 A 에 도착하는가
- 시작 방향 — 누가 먼저 말을 걸 수 있는가
TCP 와 conntrack
- TCP 는 SYN·SYN-ACK·ACK 가 오가야 연결이 된다 → 가는 길만 있으면 “ping 은 되는데 curl 은 안 되는” 상황이 생긴다
- 세 번째 층은 conntrack 이 맡는다
- 방화벽이 “A 가 시작한 연결의 답장은 통과, B 가 새로 시작하는 것은 차단” 을 구분할 수 있는 것은 그 장부 덕이다
- NetworkPolicy 에서 egress 만 열어도 응답을 따로 열 필요가 없는 이유가 여기 있다
책의 오류: “Pod 에는 MAC 이 없다”
- 책의 “Pod 에는 MAC 이 없어 L2 통신이 불가능하다” 는 틀렸다
- Pod 의 eth0 은 veth 라 MAC 이 있다
- 쿠버네티스가 Pod 간 연결을 L3 로 요구한다는 것과 인터페이스에 MAC 이 있고 없고는 다른 이야기다
- 브리지로 스위칭할지, 라우팅할지, 캡슐화할지는 구현의 선택이다
- 7.4 의 그림이 그 선택이다
4.3 경계 — isolated · flat · island
클러스터 밖과의 관계를 가르는 기준
- 클러스터 안의 Pod 끼리가 아니라 클러스터 밖과의 관계는 질문 하나로 갈린다
- 밖의 호스트가 Pod IP 로 직접 패킷을 보낼 수 있는가
island 와 flat
- Docker bridge 가 island 다
- 대부분의 쿠버네티스 클러스터도 island 다
- island 의 대가 — 나갈 때 노드 IP 로 변장하니 외부 DB 로그에는 노드 A 의 Pod 50개가 전부 같은 IP 로 찍힌다
- 클러스터 안에서 금지한 NAT 를 경계에서는 쓰기 때문이다
- flat 의 대표 — EKS 의 VPC CNI
- Pod 가 VPC 주소를 받으니 VPC 안의 EC2 가 Pod 에 직접 닿는다
- ALB 가 Pod IP 를 타깃으로 직접 등록한다
- 보안 그룹을 Pod 단위로 걸 수 있다
- flat 이어도 Pod IP 는 교체되면 바뀐다
- “지금 IP 가 뭔지” 는 Service 와 EndpointSlice 가 늘 알고 있다
- flat 의 가치는 그 IP 를 찾은 다음 변환 없이 그대로 쓸 수 있다는 데 있다
경계 분류와 overlay·native routing 의 차이
- 이 분류와 overlay·native routing 은 다른 질문에 답한다
- 앞은 클러스터 경계의 도달 가능성
- 뒤는 노드 사이에 패킷을 어떻게 나르느냐
- overlay + island 가 가장 흔하지만 native routing + island(라우터는 아는데 방화벽으로 막음)도 있다
방향 용어: ingress 와 egress
- 방향 용어도 책이 거꾸로 적었다
- ingress 는 기준 대상에 들어오는 것, egress 는 나가는 것
- Pod A → Pod B 패킷 하나가 A 에게는 egress 이고 B 에게는 ingress 다
- 대문자 Ingress 리소스는 이 방향 용어와 다른 API 객체다
4.4 주소 공간의 계획
Pod 대역이 겹치면 안 되는 상대 네 가지
- 노드 네트워크
- 회사 사설망·VPN
- 다른 클러스터의 Pod 대역
- Service 대역
클러스터 전체에서 유일한 Pod IP
- Docker 에서는 호스트마다 172.17.0.0/16 이 겹쳐도 NAT 로 나가니 상관없었다
- 쿠버네티스에서는 Pod IP 가 노드를 넘어 그대로 보이므로 클러스터 전체에서 유일해야 한다
- 10.244.0.0/16 이 자주 보이는 것은 Flannel 의 기본값이라서이지 표준이 아니다
- 격리된 클러스터 둘이 같은 대역을 써도 당장은 돌아가지만 나중에 잇는 순간 드러난다
노드당 Pod 수의 상한
- /24 를 받았다고 Pod 256개를 띄울 수는 없다
- 네트워크 주소와 브로드캐스트, 게이트웨이(브리지 IP), 플러그인 예약분이 빠진다
- kubelet 의 maxPods 기본값이 110 이다
- 둘 중 작은 쪽이 상한이다
- 노드당 /24, 클러스터 /16 이면 노드 256대에 약 28,000 Pod 다
- 모자라면 떨어진 대역을 풀로 더 붙인다
- Pod CIDR 이 반드시 하나의 연속된 대역이어야 하는 것은 아니다
5. Control Plane — 규칙을 쓰는 쪽과 패킷을 나르는 쪽
5.1 제어와 데이터
컨트롤 플레인 넷의 역할
- API server — 모든 읽기·쓰기의 창구
- etcd — 그 뒤의 장부
- scheduler — 빈 nodeName 을 채우는 배치 담당
- kube-controller-manager — “3개라고 적혔는데 2개네” 를 메우는 순찰 담당
제어와 데이터의 구분
- 이 넷 중 누구도 패킷을 나르지 않는다
- 어떤 Pod 가 Service 의 백엔드인지 보고 규칙을 바꾸는 것은 제어다
- 그 규칙대로 패킷을 보내거나 주소를 바꾸는 것은 데이터다
- 3장에서 본 veth·브리지·라우팅 테이블·iptables 규칙이 전부 데이터 층이다
- 4장에서 새로 나오는 kube-proxy·CNI 에이전트·EndpointSlice 가 그것을 자동으로 설정하는 제어 층이다
5.2 두 단계 IPAM
상위 구성: Node IPAM
- 4.4 에서 “노드끼리 안 겹치게 하는 것은 상위 구성의 책임” 이라고 했다
- 그 상위 구성이 kube-controller-manager 안의 Node IPAM 이다
1단계의 기록 위치: Node 객체의 podCIDR
| 옵션 | 뜻 |
|---|---|
--allocate-node-cidrs | 노드에 Pod 블록을 나눠 줄지. 실행 파일 기본값 false, kubeadm·kind 는 true 로 넣어 준다 |
--cluster-cidr | 쪼갤 전체 범위 |
--node-cidr-mask-size | 노드 블록 크기. 기본 IPv4 /24, IPv6 /64 |
--configure-cloud-routes | 나눈 블록을 클라우드 라우팅 테이블에도 적을지 — 켜면 터널 없이 flat 이 된다 |
--service-cluster-ip-range | Service 주소 범위. API server 설정과 맞춰야 하고, Pod IPAM 과는 다른 기능이다 |
실제 옵션 확인
- 내 클러스터에서 실제로 어떤 옵션이 붙어 실행됐는지는 kube-system 의 kube-controller-manager Pod 명세에 있다
kubectl -n kube-system get pod -l component=kube-controller-manager -o yaml | grep -- '--'
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.podCIDR}{"\n"}{end}'
설정을 읽는 쪽이 다른 경우
--cluster-cidr를 바꿨는데 Pod IP 가 그대로라면 설정이 안 먹은 것이 아니라 그 설정을 읽는 쪽이 아닌 것이다- Cilium 의 cluster-pool 모드와 VPC CNI 는 1단계를 자기 operator 와 ENI 로 대신하고 podCIDR 필드를 무시한다
5.3 Pod 생성 순서 — kubelet, CRI, CNI, pause
배치와 노드 안의 순서
- scheduler 는 “지금 CPU 사용률 30% 인 노드” 가 아니라 “예약된 requests 합이 아직 여유 있는 노드” 를 고른다
- 방이 비어 있어도 예약이 차 있으면 못 들어가는 호텔과 같다
- 그 뒤는 전부 노드 안이다
- containerd 가 방을 만들어 pause 를 띄운 뒤 CNI 를 부른다
- 그 다음에야 runc 가 앱 컨테이너를 그 방에 넣는다
- CNI 가 앱보다 앞에 있는 것이 3.4 의 “앱이 죽어도 네트워크는 남는다” 의 이유다
겹쳐 있는 약속 세 가지
- 여기에 약속이 셋 겹쳐 있고 서로 섞이지 않는다
- CRI — kubelet 이 런타임(containerd, CRI-O)을 부르는 약속
- OCI — 런타임이 저수준 실행기(runc, crun)를 부르는 약속
- CNI — 런타임이 네트워크 플러그인(Flannel, Calico, Cilium)을 부르는 약속
- 셋은 독립적으로 고른다
- Cilium 은 CRI 구현체가 아니고 containerd 는 네트워크를 모른다
kubectl get pods -A -w --field-selector spec.nodeName=node-1 # 한 노드에 배정된 Pod 만, -w 로 변화 추적
5.4 구성요소 사이의 통신
포트와 접근 경로
- 표의 포트는 대표값이다
- API server 앞의 로드밸런서가 443 을 쓸 수 있다
- Pod 가 클러스터 상태를 알고 싶으면 API server 에 물어야 RBAC 을 거친다
- etcd 에는 Secret 이 기본값으로 평문으로 들어 있으니 Pod 가 거기 닿으면 안 된다
- K8sPatterns.25 의 etcd 암호화가 이 자리의 이야기였다
5.5 kube-proxy 의 역할과 모드
kube-proxy 의 역할: 규칙만 쓴다
- kube-proxy 는 Service 와 EndpointSlice 를 감시하고 노드의 전달 규칙을 쓴다
- 이름에 proxy 가 들어 있지만 패킷은 kube-proxy 프로세스를 지나지 않는다
- 아주 옛날 userspace 모드가 그랬고 이름이 거기서 왔을 뿐이다
- 실제 DNAT 는 커널의 netfilter 가 한다
- kube-proxy 가 죽으면 규칙 갱신이 멈출 뿐 기존 규칙으로 계속 동작한다
| 모드 | 상태 | 비고 |
|---|---|---|
| userspace | 삭제됨 | 패킷이 프로세스를 지나던 원조 |
| iptables | 계속 사용. 많은 배포판의 기본 | Service 수천 개면 규칙이 길어져 느려진다 |
| IPVS | v1.35 부터 deprecated | 책이 “새 권장” 이라고 쓴 것. 지금은 아니다 |
| nftables | v1.33 부터 Stable, 커널 5.13+ | iptables 의 후계. 규칙이 많아도 빠르다 |
노드마다 하나씩 도는 이유
- DaemonSet 이라 모든 노드에 하나씩 돌고, 각자 자기 노드의 iptables 에 같은 규칙을 쓴다
- 노드마다 필요한 이유 — DNAT 는 보내는 Pod 가 있는 노드에서 일어난다
- Service IP 는 어느 인터페이스에도 없는 가상 주소라 자기 노드를 떠나기 전에 진짜 Pod IP 로 바뀌어야 한다
- 규칙의 실제 모양과 PREROUTING·OUTPUT 두 입구는 노드의 nat 테이블에 그대로 적혀 있다
- Cilium 은 이 일을 eBPF 로 대신할 수 있다(kubeProxyReplacement)
6. Probes — kubelet 의 상태 검사
6.1 Running 과 Ready 의 차이
상태를 적는 세 층
- 문은 열었는데 주방이 준비 중이면 손님을 들이면 안 된다
- 쿠버네티스는 이 둘을 세 층으로 적는다
- Pod 전체 단계
status.phase— Pending · Running · Succeeded · Failed · Unknown - Pod 조건
status.conditions— PodScheduled · ContainersReady · Ready - 컨테이너별 상태
status.containerStatuses— Waiting · Running · Terminated
- Pod 전체 단계
네트워크에서 중요한 층: Ready 조건
- 네트워크에서 중요한 것은 두 번째 층의 Ready 다
- Service 는 Ready 가 True 인 Pod 에만 트래픽을 보낸다
STATUS 열: 세 층을 섞은 요약
kubectl get pods의 STATUS 열은 세 층을 섞어 사람이 보기 좋게 만든 요약이다- CrashLoopBackOff 는 phase 가 아니라 컨테이너 상태 Waiting 의 reason 이다
- 그때 phase 는 Running 이다
- STATUS 가 Running 이어도 READY 열이 0/1 이면 그 Pod 는 Service 에서 트래픽을 못 받는다
- 두 열을 같이 본다
- probe 는 phase 를 건드리지 않는다
- readiness 실패는 Ready 조건을 바꾼다
- liveness·startup 실패는 그 컨테이너의 재시작을 바꾼다
6.2 세 probe 의 차이 — 실패했을 때의 동작
probe 세 가지의 용도
- startup — 느리게 뜨는 앱을 위한 것
- “다 뜰 때까지 liveness 로 죽이지 마”
- liveness — 데드락처럼 프로세스는 살아 있는데 멈춘 상태를 잡아 재시작
- readiness — 살아 있고 멈추지도 않았지만 지금은 요청을 못 받는 상태(워밍업, 과부하, DB 끊김)에서 트래픽만 뺀다
- 시작 때만이 아니라 운영 중에도 계속 쓰인다
- 재시작 없이 들어갔다 나왔다 한다
probe 를 안 적었을 때
- probe 를 안 적으면 쿠버네티스가 알아서 앱 안을 봐 주지 않는다
- 컨테이너 프로세스가 떠 있으면 바로 Ready 다
- 앱이 안에서 멈춰 있어도 모른다
6.3 spec 과 앱 코드
| 방식 | 앱이 준비할 것 | 비고 |
|---|---|---|
| httpGet | 그 경로에 200~399 를 돌려주는 핸들러 | 가장 흔하다. scheme: HTTPS 는 인증서를 검증하지 않는다 |
| grpc | gRPC Health Checking 서비스 구현 | v1.27 부터 내장. 숫자 포트만 |
| tcpSocket | 없음. 포트만 열려 있으면 된다 | 얕다. 앱이 멈춰 있어도 성공 |
| exec | 이미지 안에 그 명령이 있어야 한다 | scratch 이미지에는 sh 도 없다 |
방식 선택: 앱의 창구 종류에 맞춘다
- HTTP 를 받으면 httpGet
- gRPC 면 grpc
- 자기 프로토콜만 쓰는 DB 면 tcpSocket 이나 exec(
pg_isready) - 포트가 아예 없는 워커면 exec
네트워크 probe 의 조건: 0.0.0.0 bind
- httpGet·tcpSocket·grpc 는 kubelet 이 호스트 방에서 Pod IP 로 보내는 진짜 패킷이다
- 앱이 0.0.0.0 에 bind 해야 한다
- 4.1 의 규칙 3 이 필요했던 이유다
- exec 는 CRI 로 컨테이너 안에서 명령을 돌리므로 이 제약이 없다
6.4 /healthz 와 /readyz 를 나누는 이유
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 2
failureThreshold: 30 # 2초 × 30 = 최대 60초 기다려 준다. 빨리 뜨면 빨리 끝난다
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3 # 멈춘 뒤 약 30초 안에 재시작. 재시작은 비싸니 느긋하게
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 1
failureThreshold: 3 # 약 15초 안에 Service 에서 뺀다. 빼는 것은 싸니 빠르게
liveness 의 범위: 재시작으로 나아지는 문제만
- liveness 는 재시작으로 나아지는 문제만 잡아야 한다
- /healthz 가 DB 에 ping 을 날리게 만들면 이렇게 된다
- DB 장애 → 웹 Pod 50개가 전부 liveness 실패 → 50개 전부 재시작, 캐시 소실
- DB 복구 → 50개가 동시에 워밍업하며 DB 를 다시 때림 → DB 다시 느려짐 → 반복
나누는 기준: 재시작하면 나아지나
- 웹 프로세스는 멀쩡했는데 재시작이 문제를 키운 것이다
- 기준은 “재시작하면 나아지나” 다
- DB 끊김처럼 재시작해도 안 나아지는 것은 /readyz 에서 503 으로 돌려 트래픽만 뺀다
- 경로 이름 자체에 쿠버네티스가 정한 동작은 없다
- 그 200 이 무엇을 확인한 결과인가가 전부다
시간 값 읽는 법
- failureThreshold 는 “도달하면” 이다
- 3이면 세 번째 실패에서 조치한다
- periodSeconds × failureThreshold 는 설계용 어림값이지 복구 시간의 보장이 아니다
- 조치가 시작되는 시점이고, 새 컨테이너가 Ready 되기까지는 별도다
- startup 이 있으면 liveness 에 initialDelaySeconds 를 줄 필요가 없다
6.5 Ready 가 트래픽에 반영되는 과정
EndpointSlice: Service 의 백엔드 목록
- EndpointSlice 는 “이 Service 의 백엔드 Pod IP 목록” 이고 kube-proxy 가 읽는 것도 이것이다
- 책 시절의 Endpoints 는 객체 하나에 모든 IP 를 넣어, Pod 가 수천 개면 갱신 때마다 전부 다시 보내야 했다
- 그래서 슬라이스당 100개로 쪼갰고, v1.33 부터 Endpoints 는 deprecated 다
- 책의 “readiness 실패 시 Endpoints 에서 IP 가 사라진다” 는 지금 구성에 그대로 적용되지 않는다
- 주소는 남고 ready 표시만 바뀐다
publishNotReadyAddresses를 켠 Service 는 준비 안 된 Pod 에도 보낸다- StatefulSet 의 DB 클러스터 초기화처럼 준비 전에도 서로 찾아야 할 때 쓴다
롤링 업데이트와 readiness
- 롤링 업데이트는 새 Pod 가 Ready 가 되어야 다음 옛 Pod 를 지운다
- readiness 가 없으면 컨테이너가 뜨자마자 Ready 로 보니, 앱이 워밍업 중인데 옛 Pod 가 전부 사라진다
- readiness probe 가 무중단 배포의 전제 조건인 이유다
readinessGates: 밖의 조건까지 기다리기
- 컨테이너는 준비됐는데 밖의 조건이 남을 때가 있다
- ALB 가 새 Pod IP 를 타깃에 등록하는 데 10~30초가 걸린다
- 그 사이 옛 Pod 가 지워지면 502 가 난다
readinessGates에 조건 이름을 선언하면 kubelet 은 컨테이너 준비와 그 조건을 같이 평가한다- ALB 컨트롤러가 “등록 끝” 이라고 status 에 적어 줄 때까지 Pod 는 Ready 가 되지 않는다
주의: 조건을 바꿔 줄 컨트롤러가 없을 때
- 선언만 하고 그 조건을 True 로 바꿔 줄 컨트롤러가 없으면 문제가 된다
- status 에 없는 조건은 False 로 치므로 Pod 가 영원히 Ready 가 안 된다
- 3.3 의 containerPort 와 같은 종류다
- 적었다고 동작하는 것이 아니다
7. CNI — 약속, IPAM, 구현
7.1 여섯 작업과 sandbox 의 방
CNI 규격의 범위
- CNI 는 런타임이 실행 파일을 띄우고 stdin 으로 JSON 을 주고 stdout 으로 JSON 을 받는 약속이다
- 03-2 의 4절에서 그린 그대로다
- 규격은 호출 방식과 입력·결과 형식만 정한다
- 브리지·라우팅·터널 중 무엇을 쓸지는 정하지 않는다
- 규격 현재 버전은 1.1.0 이다
- 책의 “0.4 이며 곧 1.0” 은 지났다
| 작업 | 목적 |
|---|---|
| ADD / DEL | 연결(veth·IP·라우팅) / 해제(자원 반납) |
| CHECK | 이 연결이 기대한 구성과 맞나 — 특정 방의 배선 점검 |
| STATUS (1.1) | 플러그인이 새 요청을 받을 수 있나 — 기사가 출근했나 |
| VERSION | 지원하는 규격 버전 |
| GC (1.1) | 살아 있는 sandbox 목록을 주고 나머지 잔여물을 치우게 |
STATUS 가 생긴 이유
- STATUS 가 생긴 것은 Cilium 류 때문이다
- 실행 파일은 있는데 에이전트가 아직 안 떴으면 ADD 가 실패한다
- 런타임이 미리 물어보면 그 노드를 잠시 NotReady 로 두고 Pod 를 안 보낸다
호출 대상은 sandbox 의 방
- 환경변수 CNI_NETNS 는
/var/run/netns/cni-…같은 경로이고, 그 방은 pause 가 잡고 있는 sandbox 의 방이다 - CNI_CONTAINERID 도 앱 컨테이너가 아니라 sandbox 의 ID 다
- 앱이 재시작돼도 CNI 는 다시 불리지 않는다
- 플러그인은 로그를 stderr 로 보내야 한다
- stdout 에 찍으면 런타임의 JSON 파싱이 깨져 Pod 가 ContainerCreating 에 멈춘다
“Cilium” 은 실행 파일 하나가 아니다
- /opt/cni/bin/cilium-cni 는 작은 실행 파일이다
- 실제 일은 노드마다 떠 있는 cilium-agent 에 유닉스 소켓으로 넘긴다
- 규격은 “실행 파일이 호출된다” 만 정하고, 그 안에서 에이전트에 위임하든 말든 자유다
7.2 chaining 과 Multus 의 차이
“플러그인이 여러 개” 의 두 뜻
- chaining — 한 네트워크에 플러그인을 직렬로 건다
- bridge 가 veth 와 IP 를 만든 결과(prevResult)를 portmap 이 받아 hostPort 의 DNAT 규칙을 더한다
- bandwidth 가 제한을 건다
- 문은 여전히 eth0 하나다
- Multus — Pod 에 네트워크를 여럿 붙이는 메타 플러그인이다
- eth0 외에 net1 이 생기고 IP 도 둘이 된다
- 3.1 의 “IP 는 문에 붙는다” 가 여기서 드러난다
- 쿠버네티스 자체는 eth0 만 안다
- net1 은 Service 와 NetworkPolicy 의 대상이 아니다
7.3 IPAM 의 결과와 적용
5.2 의 2단계(경비실) 내부
- 메인 플러그인(bridge)이 IPAM 플러그인(host-local)을 또 실행 파일로 부른다
- 돌려받은 JSON 을 그대로 적용한다
{
"cniVersion": "1.1.0",
"ips": [{ "address": "10.244.1.10/24", "gateway": "10.244.1.1" }],
"routes": [{ "dst": "0.0.0.0/0", "gw": "10.244.1.1" }],
"dns": { "nameservers": ["10.96.0.10"], "search": ["default.svc.cluster.local", "svc.cluster.local"] }
}
결과 JSON 읽기
address의 /24 가 “옆집 명단” 의 크기다- Cilium 이면 여기가 /32 다
gateway가 브리지 IP,routes가 라우팅 테이블에 들어갈 줄이다- IPAM 은 숫자를 정하기만 하고 인터페이스를 건드리지 않는다
- 적용은 메인 플러그인이, 패킷 전달은 커널이 한다
- 역할이 세 겹이다
dns는 host-local 이 정하는 값이 아니라 설정 파일의 것을 되돌려 준 것이다- 실제 Pod 의 /etc/resolv.conf 는 kubelet 이 dnsPolicy 를 보고 쓴다
설정 경로와 읽는 쪽
- 설정은 /etc/cni/net.d/, 실행 파일은 /opt/cni/bin/ 에 두는 것이 관례다
- 그 경로를 읽는 것은 kubelet 이 아니라 containerd 다
- v1.24 에 kubelet 의
--network-plugin,--cni-bin-dir옵션이 제거됐다- 오래된 글을 따라 kubelet 에 붙이면 시작조차 안 된다
회수: 할당의 일부
- 회수도 할당의 일부다
- host-local 은
/var/lib/cni/networks/<망>/10.244.1.10파일로 “쓰는 중” 을 표시한다 - 노드가 갑자기 꺼져 DEL 이 안 불리면 그 IP 가 영영 안 풀린다
- /24 는 253개뿐이라 누적되면 Pod 가 IP 없음으로 못 뜬다
- GC 가 그 잔여물을 치우라는 요청이다
7.4 구현 넷 — 두 축, 그리고 /24 와 /32
| 구현 | 노드 사이 전달 | 정책·주소·상태 |
|---|---|---|
| Calico | IP 라우팅 또는 VXLAN·IP-in-IP. BGP 는 선택 | NetworkPolicy + 확장 정책. Felix 가 노드 상태 반영 |
| Cilium | eBPF. VXLAN·Geneve 터널 또는 네이티브 라우팅 | NetworkPolicy + L7 확장. CRD 로 상태 관리, IPAM 여러 방식 |
| Flannel | 노드에 서브넷 주고 VXLAN 등으로 연결 | 정책 집행 없음. netpol.enabled 로 집행기 추가 가능 |
| VPC CNI | ENI 의 VPC 주소를 Pod 에 직접 | VPC 서브넷·ENI 한도가 제약. 정책은 설정 확인 |
구현을 가르는 축 두 개
- 노드 사이를 터널로 감싸느냐, 라우터가 Pod 대역을 알게 하느냐
- 정책을 집행하느냐, IPAM 은 누가 하느냐
책의 분류와 지금의 차이
- 책의 분류 — Calico 는 BGP 라우팅, Flannel 은 정책 됨, Weave 는 현역 — 는 전부 지금과 다르다
- Weave 는 2024년 6월에 저장소가 닫혔다
터널의 비용과 한계
- 터널은 기반 네트워크가 노드 IP 만 알면 되지만, 패킷마다 헤더가 붙어 MTU 가 1450 으로 준다
- 터널이라고 암호화되는 것은 아니다
- VXLAN 은 UDP 로 감쌀 뿐이라 중간에서 뜯으면 Pod 패킷이 그대로 보인다
같은 노드 안의 차이: /24 와 /32
- 같은 노드 안에서도 구현이 갈린다
- Pod 에 /24 를 주느냐 /32 를 주느냐다
/32: “이 대역에 나 혼자”
- 인터페이스에 IP 를 붙이면 커널이 “같은 대역은 게이트웨이 없이 직접” 이라는 scope link 라우트를 자동으로 만든다
- /32 면 옆집이 없으니 그 줄이 안 생기고, 모든 패킷이 게이트웨이로 간다
- 게이트웨이는 호스트라서 호스트의 라우팅 테이블이 “10.244.1.6 은 vethB 로” 를 보고 넘긴다
비유: 복도가 있는 층과 관리실로만 열리는 층
- 비유로는 복도가 있는 층(/24)과 모든 방문이 관리실로만 열리는 층(/32)이다
- 관리실을 반드시 지나게 하면 거기서 “A 는 B 방에 못 들어감” 을 막고, 누가 어디 갔는지 기록하고, 검사를 더할 수 있다
- Cilium 과 Calico 가 복도를 없앤 이유다
- 169.254.1.1 은 실제 주소가 아니라 “일단 veth 반대편으로” 라는 자리표시자다
- 호스트 쪽 veth 가 ARP 에 대신 답한다(proxy ARP)
7.5 Cilium 의 구성 요소
| 구성 요소 | 역할 | 어디서 |
|---|---|---|
| cilium-operator | 클러스터 전체 — 노드마다 Pod 블록 나눔(5.2 의 1단계). 패킷 경로 아님 | Deployment Pod 1~2개 |
| cilium-agent | 노드마다 — 블록에서 IP 꺼냄(2단계), eBPF 프로그램 로드, 정책 반영 | DaemonSet, hostNetwork |
| cilium-cni | Pod 생성 때 런타임이 부름. agent 에 위임 | /opt/cni/bin/ |
| cilium CLI | 설치, cilium status, 연결성 테스트 | 관리자 PC, kubeconfig |
| cilium-dbg | 특정 노드 agent 의 내부 상태, eBPF 맵 | cilium-agent Pod 안 |
| Hubble | 흐름 관찰. Relay 가 노드별 정보를 모으고 CLI·UI 로 | Relay 는 Pod |
cilium CLI 와 cilium-dbg 의 구분
- 옛 문서는 둘 다
cilium이라고 써서 복붙하면command not found가 난다 - 관리자 PC 의
cilium status— 클러스터 전체가 정상인가 - Pod 안의
cilium-dbg status— 이 노드의 agent 가 어떤 상태인가
플러그인 선택: 제약에서 시작
- 플러그인을 고를 때는 기능 이름 개수가 아니라 제약에서 시작한다
- 라우터를 건드릴 수 있나 — 없으면 터널, VPC 면 VPC CNI
- 정책이 L3/L4 로 충분한가 — HTTP 경로까지면 Cilium
- 커널 버전이 되나 — eBPF 는 5.10+, nftables kube-proxy 는 5.13+
- 장애 때 무엇을 볼 수 있나 — Hubble 같은 흐름 관찰
성능 판단
- “eBPF 라서 빠르다” 는 단정하지 않는다
- 같은 정책, 같은 트래픽으로 재야 안다
8. Discussion
8.1 3장 재료의 새 이름
| 3장에서 | 4장에서 | 누가 치나 |
|---|---|---|
ip netns add 로 만든 방 | Pod sandbox. pause 가 붙들고 있다 | containerd |
ip link add veth + 브리지에 꽂기 | CNI ADD | 런타임이 부른 플러그인 |
ip addr add 10.203.0.2/24 | IPAM 결과의 ips[].address | host-local 또는 CNI 의 IPAM |
호스트의 ip route add | podCIDR 과 노드 간 라우팅·터널 | CNI 에이전트 |
-p 의 DNAT 한 줄 | Service 의 KUBE-SVC → KUBE-SEP 규칙 | kube-proxy |
MASQUERADE ! -o docker0 | MASQUERADE ! -d 10.244.0.0/16 | CNI 또는 노드 설정 |
curl 로 직접 두드리기 | kubelet 의 probe | kubelet |
핵심 메시지
3장이 “컨테이너는 방 안의 프로세스 하나” 였다면 이 편은 “Pod 는 IP 하나를 가진 방이고, 그 방들이 노드를 넘어 NAT 없이 닿아야 한다” 다. 그 요구를 실제로 만드는 것은 노드의 라우팅 테이블과 nat 테이블에 적힌 규칙이고, 누가 그 줄을 썼는지 — CNI 인지 kube-proxy 인지 — 를 나누어 읽으면 Pod 네트워크와 Service 구현이 다른 층이라는 것이 보인다.
9. References
"pods can communicate with all other pods on any other node without NAT"
"Within a Pod, containers share an IP address and port space, and can find each other via localhost"
"--allocate-node-cidrs Should CIDRs for Pods be allocated and set on the cloud provider."
"A probe is a diagnostic performed periodically by the kubelet on a container."
"CNI_NETNS: A reference to the container's 'isolation domain'. If using network namespaces, then a path to the network namespace"
"kube-proxy ... is not itself in the data path"
