2.2 kube-apiserver
kubectl 명령도, 스케줄러의 배정 기록도, kubelet의 상태 보고도, CI 파이프라인이 보내는 배포 요청도 전부 한 곳에 도착한다. kube-apiserver는 Kubernetes 클러스터의 유일한 API 창구다. 클러스터에 무언가를 만들거나 바꾸는 모든 경로가 이 프로세스를 지나며, 여기서 누가 보냈는지, 해도 되는 일인지, 형식이 맞는지 검사받는다. apiserver 자체는 상태를 자기 안에 두지 않는 무상태(stateless) HTTP 서버라서, 부하가 늘면 로드밸런서 뒤에 여러 대를 나란히 늘리는 것으로 대응한다.
요청 처리 체인
Pod 생성 요청 하나가 etcd에 저장되기까지 다섯 단계를 거친다.
flowchart TD
REQ["요청 도착"] --> AUTHN["인증: 누구인가"]
AUTHN --> AUTHZ["인가: 권한이 있는가"]
AUTHZ --> MUT["mutating 어드미션"]
MUT --> VAL["스키마 검증"]
VAL --> VADM["validating 어드미션"]
VADM --> STORE[("etcd 저장")]
인증(authentication)은 요청을 보낸 주체를 식별한다. 클라이언트 인증서, ServiceAccount token, OIDC 토큰 같은 방식이 쓰이고, 어느 것으로도 식별되지 않는 요청은 익명으로 처리되거나 거부된다. 인가(authorization)는 식별된 주체가 그 동작을 해도 되는지 판정한다. 표준 방식은 RBAC이다. “이 사용자가 default 네임스페이스에서 Pod를 생성해도 되는가” 같은 질문에 Role과 RoleBinding을 대조해 답한다.
어드미션(admission)은 인가까지 통과한 요청을 정책 관점에서 검사하고 필요하면 고치는 단계다. mutating 어드미션은 요청 객체를 수정한다. 예를 들어 컨테이너에 resource 요청량이 비어 있으면 LimitRange에 정의된 기본값을 채워 넣는 식이다. 이어서 apiserver가 객체를 스키마와 대조해 필드 형식을 검증하고, 마지막으로 validating 어드미션이 수정까지 끝난 최종 객체를 놓고 정책 위반 여부를 판정한다. 두 어드미션 모두 webhook으로 확장되므로, 조직 자체의 보안 정책이나 표준 검사를 이 체인에 추가하는 것이 가능하다.
다섯 단계를 전부 통과한 객체만 etcd에 기록되고, 기록이 완료되어야 클라이언트가 성공 응답을 받는다. 어느 단계에서든 실패하면 요청은 거기서 끝나고 etcd에는 아무 흔적도 남지 않는다.
REST 리소스 경로
apiserver의 API는 평범한 REST다. 모든 리소스가 URL 경로를 갖고 GET, POST, PUT, PATCH, DELETE로 조작된다. 경로는 API 그룹에 따라 두 형태로 나뉜다.
- core 그룹:
/api/v1아래. Pod, Service, ConfigMap, Node처럼 초기부터 있던 리소스로, 역사적 이유로 그룹 이름이 없다. - named 그룹:
/apis/<그룹>/<버전>아래. Deployment는/apis/apps/v1, Job은/apis/batch/v1, Role은/apis/rbac.authorization.k8s.io/v1에 있다.
kubectl이 감추고 있을 뿐, 경로를 직접 지정해도 같은 결과를 받는다.
# default 네임스페이스의 Pod 목록을 REST 경로로 직접 조회
kubectl get --raw /api/v1/namespaces/default/pods
# Deployment는 apps 그룹에 있다
kubectl get --raw /apis/apps/v1/namespaces/default/deployments그룹이 나뉜 덕에 API 버전 관리도 그룹 단위로 독립적이다. apps 그룹이 v1로 안정화된 뒤에도 다른 그룹은 각자의 속도로 alpha, beta, GA 단계를 밟는다. 클러스터가 제공하는 전체 리소스와 소속 그룹은 kubectl api-resources를 실행해 확인한다.
watch: 폴링 없이 변경을 아는 방법
스케줄러와 컨트롤러는 새 Pod가 생겼는지 계속 알아야 한다. 주기적으로 목록을 다시 조회하는 폴링은 지연도 크고 apiserver 부하도 크다. 대신 Kubernetes의 모든 컴포넌트는 watch를 사용한다.
watch는 HTTP long polling이다. 클라이언트가 ?watch=true를 붙여 GET 요청을 보내면 apiserver는 연결을 끊지 않고 유지하다가, 해당 리소스에 변경이 생길 때마다 ADDED, MODIFIED, DELETED 이벤트를 같은 연결로 이어서 전송한다. kubectl get pods -w가 이 메커니즘을 그대로 노출한 명령이다.
이때 기준점 역할을 하는 값이 resourceVersion이다. 모든 객체와 목록 응답에는 저장소 기준의 버전이 붙어 있고, 클라이언트는 “이 버전 이후의 변경부터 보내라"고 요청한다. 표준 패턴은 list 후 watch다. 먼저 전체 목록을 받아 현재 상태를 파악하고, 그 응답의 resourceVersion부터 watch를 시작하면 목록과 이벤트 스트림 사이에 누락 구간이 생기지 않는다. 연결이 끊기면 마지막으로 받은 resourceVersion부터 재개하고, 그 버전이 apiserver가 보관하기에 너무 오래된 것이면 410 Gone 응답을 받아 list부터 다시 시작한다. 스케줄러가 새 Pod에 즉시 반응하고 kubelet이 자기 배정을 즉시 아는 것은 전부 이 list-watch 위에서 동작하는 것이다.
apiserver만 etcd와 대화하는 이유
어느 컴포넌트든 etcd 클라이언트로 직접 읽고 쓰면 한 단계를 줄일 텐데, Kubernetes는 etcd 접근을 apiserver 하나로 제한한다. 이유는 위의 처리 체인 그 자체다. etcd에 직접 쓰는 경로가 하나라도 있으면 인증도, RBAC도, 어드미션 정책도 전부 우회된다. 모든 쓰기가 apiserver를 지나야 정책이 예외 없이 적용되고, 감사 로그에 모든 변경의 주체가 남는다.
보안 외의 이점도 있다. 저장 포맷이 apiserver 내부로 캡슐화되므로, Secret을 저장 시점에 암호화(encryption at rest)하거나 저장 스키마를 바꾸는 일이 다른 컴포넌트에 영향을 주지 않는다. 부하 관점에서는 watch cache가 중요하다. 수천 개의 kubelet과 컨트롤러가 거는 watch가 전부 etcd로 향하면 etcd가 감당하기 어렵지만, apiserver가 etcd에는 리소스별 watch를 하나만 걸고 그 변경을 메모리 캐시에서 여러 클라이언트 watch로 분배하므로 etcd가 받는 부하는 클라이언트 수와 무관하게 일정하다. 노드 관점에서는 공격 면이 줄어든다. 노드 하나가 탈취되어도 그 노드의 자격증명으로 apiserver가 허용한 범위만 접근하게 될 뿐, 클러스터 상태 전체가 담긴 etcd로 가는 경로 자체가 없다.
정리
kube-apiserver는 인증, 인가, 어드미션, 검증을 통과한 요청만 etcd에 기록하는 클러스터의 유일한 관문이다. 리소스는 /api/v1과 /apis/<그룹>/<버전> 아래의 REST 경로로 노출되고, 모든 컴포넌트는 list-watch와 resourceVersion으로 변경을 누락 없이 추적한다. etcd 접근이 apiserver로 단일화된 덕에 정책 우회가 차단되고 etcd 부하도 일정하게 유지된다.