쿠버네티스 아키텍처와 gRPC
gRPC는 어디에 쓰이는 걸까?
이전 포스트에서 gRPC가 내부 통신을 빠르게 해준다는 걸 알아봤다. 그럼 실제로 어디에 쓰이는 걸까? 가장 가까운 예로 쿠버네티스를 찾을 수 있었다.
쿠버네티스의 핵심 구성 요소들은 서로 gRPC로 통신하고, 새로운 컨테이너 런타임이나 네트워크 플러그인을 끼워넣을 수 있는 것도 gRPC 인터페이스 덕분이다.
쿠버네티스 아키텍처
쿠버네티스는 크게 두 파트로 나뉜다. 전체를 관리하는 컨트롤 플레인과 실제 컨테이너가 실행되는 노드다.
컨트롤 플레인
클러스터의 두뇌 역할을 한다. 네 가지 핵심 컴포넌트로 구성된다.
- API 서버: 모든 요청의 관문이다. 외부 요청도, 내부 구성 요소끼리의 통신도 전부 API 서버를 거친다
- etcd: 클러스터의 메모리다. 모든 상태 정보(설정값, 어떤 노드에 뭐가 떠있는지 등)를 저장하는 key-value 저장소
- 스케줄러: 새로 만든 Pod를 어떤 노드에 배치할지를 결정한다
- 컨트롤러 매니저: 현재 떠 있는 컨테이너가 선언된 상태와 일치하는지를 계속 확인하고, 하나가 죽으면 다시 살려내는 일을 한다
노드
실제 컨테이너들이 실행되는 워커 머신이다. 세 가지 컴포넌트가 있다.
- kubelet: 컨트롤 플레인과 소통하는 에이전트다. 해당 노드에 어떤 컨테이너가 띄워져야 하는지 명령을 받고, 실행하고, 상태를 보고한다
- kube-proxy: 노드로 들어오는 네트워크 트래픽을 어떤 컨테이너로 보낼지 정리하는 네트워크 규칙을 관리한다. 쉽게 말하면 로드 밸런서 역할이다. 서비스 이름으로 오는 트래픽을 살아있는 Pod에게 나누어준다
- 컨테이너 런타임: 실제로 컨테이너를 구동시키는 실행 엔진이다
인터페이스 중심 설계: 쿠버네티스가 유연한 이유
쿠버네티스가 유연한 확장이 가능한 이유는 처음부터 표준 인터페이스만 정해두고 실제 동작은 구현하지 않겠다는 설계 철학이 있기 때문이다.
쿠버네티스가 정의한 gRPC 인터페이스 규격은 세 가지다.
- CRI (Container Runtime Interface): “컨테이너 실행” 규격
- CSI (Container Storage Interface): “디스크 연결” 규격
- CNI (Container Network Interface): “네트워크 설정” 규격
이 규격들은 모두 .proto 파일로 정의되어 있다. 예를 들어, 새로운 컨테이너 엔진을 만들고 싶다면 쿠버네티스가 정의한 runtime.proto의 명세대로 gRPC 서버를 구현하기만 하면 된다. 이 구조 덕분에 Docker 대신 containerd를 쓰든, Calico 대신 Cilium을 쓰든, 쿠버네티스 코어를 건드릴 필요가 없다.
Pod가 생성되기까지: 전체 흐름
사용자가 YAML을 선언하고 나서 실제로 컨테이너가 뜨기까지의 과정을 따라가보자.
1단계: 인증과 저장
kubectl은 사용자가 작성한 YAML을 API 서버에 전달한다. API 서버는 인증/인가를 확인하고, 이상이 없으면 YAML 파일 내용을 etcd에 저장한다.
2단계: 배치 결정
스케줄러는 API 서버를 감시하고 있다가, 노드에 배정되지 않은 Pod가 생긴 걸 확인한다. 배정되지 않은 Pod란 etcd에 저장된 Pod 정보 중 nodeName 필드가 비어있는 경우를 의미한다.
그럼 스케줄러는 각 노드의 리소스 상황을 확인하고 가장 적절한 노드를 골라 API 서버에 보고한다. 이때 필터링과 스코어링 두 단계를 거친다.
- 필터링: Pod가 요구하는 CPU/메모리가 충분한가? 특정 노드에만 띄우라는 조건(nodeSelector 등)이 있는가? 등 노드의 사양이 충분한지를 판단하여 후보를 거른다
- 스코어링: 남은 후보 노드들에 점수를 매긴다. 이미지가 이미 다운로드되어 있는지, 리소스 여유가 더 많은지 등을 따져서 가장 점수가 높은 노드를 선택한다
3단계: 명령 전달
kubelet이 자신의 노드에 할당된 새로운 Pod 정보를 API 서버로부터 전달받는다. kubelet은 미리 생성된 gRPC Stub을 이용해 노드의 컨테이너 런타임에 명령을 보낸다. 이때 kubelet과 컨테이너 런타임 사이의 통신은 CRI 규격을 통해 구성되어 있다.
4단계: 실제 생성
컨테이너 런타임은 명령을 받고 이미지를 pull한 후 컨테이너를 생성한다. 그 다음 두 가지 추가 작업이 진행된다.
네트워크 설정: 컨테이너에 IP 주소를 할당하기 위해 gRPC 통신으로 CNI 플러그인에게 네트워크 설정을 요청한다. CNI 플러그인은 컨테이너 내부에 고유한 IP 주소를 부여하고, 컨테이너끼리 서로 통신할 수 있는 네트워크를 구성하는 역할을 한다.
스토리지 연결: 영구 저장소가 필요한 경우 gRPC 통신으로 CSI 플러그인에게 디스크 연결을 요청한다. CSI 플러그인은 컨테이너가 삭제되어도 데이터가 날아가지 않도록 외부 저장소와 컨테이너를 연결해주는 역할을 한다.
5단계: 완료 보고
kubelet이 API 서버에게 생성 완료 사실을 알린다. kubelet은 컨테이너 런타임으로부터 gRPC 응답으로 작업 완료 사실을 전달받고, 이후에도 주기적으로 gRPC 폴링을 통해 컨테이너 내부 프로세스의 상태를 실시간으로 체크하여 보고한다.
API 서버는 이 상태를 다시 etcd에 기록하며, 이로써 선언된 상태와 실제 상태가 일치하게 된다.