Tech Artifacts/K8s Security

K8s Security - 03. fine-grained kubelet API authorization

Pod 목록을 읽으려면 exec 권한이 필요했다 — fine-grained kubelet API authorization

Prometheus가 노드의 Pod 목록을 수집하려 한다. 노드에 들어가 kubectl이 보는 것과 비슷한 정보를 얻으려면, kubelet이 10250 포트에 연 HTTPS API를 호출해야 한다. 그런데 1.32 이전의 kubelet에서 /pods를 읽으려면 RBAC상 nodes/proxy 권한이 필요했다. 문제는 이 nodes/proxy/pods(읽기)뿐 아니라 /exec/...(컨테이너 안에서 임의 명령 실행)를 인가하는 같은 권한이라는 점이다. 모니터링 에이전트가 Pod 개수를 세려다 노드 전체에 셸을 띄울 수 있는 능력을 함께 받은 셈이다.

이 글은 kubelet API의 읽기 엔드포인트(/pods, /healthz, /configz)와 실행 엔드포인트(/exec, /attach, /run)가 어떻게 갈라지게 됐는지, 그리고 그 분리가 왜 1.32 alpha에서 출발해 1.36에서야 기본 활성화되는 GA가 됐는지를 따진다.

kubelet API는 왜 별도의 보안 표면인가

kubelet은 노드에서 컨테이너를 띄우고 유지하는 컴포넌트다. 02장(02-architecture)에서 kubelet이 kube-apiserver를 watch해 자기 일거리를 당겨온다고 봤다. 그런데 kubelet은 스스로도 API 서버다. 노드의 10250 포트로 HTTPS API를 열고, Pod 목록·로그·exec·stats·healthz 등을 제공한다.

이 kubelet API가 kube-apiserver의 RBAC와 다른 축*인 이유는, 10250이 노드 위의 모든 Pod에 직결하기 때문이다. kube-apiserver RBAC으로 어떤 사용자가 Pod를 " 못 본다"고 설정해 두어도, 그 사용자가 노드의 10250에 도달하면 kube-apiserver를 거치지 않고 그 노드의 Pod 로그·환경변수·셸에 닿는다. 그래서 10250 자체에 대한 인가가 *별도로 필요하다.

flowchart LR
    C["클라이언트<br/>(모니터링/로그/디버그)"] -->|"kubelet API<br/>노드:10250"| KL["kubelet"]
    KL -->|"SubjectAccessReview"| API["kube-apiserver<br/>(RBAC / Node authorizer)"]
    API -->|"허용/거부"| KL
    KL -->|"허용 시"| P["노드의 Pod<br/>(/pods, /logs, /exec, /stats)"]

화살표의 방향이 핵심이다. 클라이언트 → kubelet(10250) → apiserver(SAR) → kubelet → Pod. 인가 결정은 apiserver가 내리지만, 인가를 검사하는 주체는 kubelet이다. 이 구조를 이해하면 뒤의 SAR 흐름이 자연스럽다.

과거 — /pods(읽기)와 /exec(실행)가 같은 권한 하나였다

kubelet 10250 API는 오래전부터 인증·인가를 지원했다. 하지만 인가 단위가 거칠었다. /log, /metrics, /spec, /stats 같은 일부 엔드포인트는 자기 전용 subresource(nodes/log, nodes/metrics 등)로 매핑됐지만, 나머지 전부nodes/proxy 하나로 몰아넣었다. 그 "나머지"에 /pods, /healthz, /configz 같은 읽기 엔드포인트는 물론이고 /exec, /attach, /run, /debug 같은 실행 엔드포인트까지 포함됐다. 모니터링 에이전트가 노드의 /pods를 읽으려면 nodes/proxy를 받아야 했고, 그 한 권한이 곧 컨테이너 명령 실행 능력이었다. 최소 권한 원칙이 구조적으로 불가능한 설계다.

역사 박스 — 왜 읽기 에이전트들이 10250으로 몰렸나. 예전엔 /healthz, /pods인증 없는 read-only 포트 10255에도 열려 있었다. 편했지만 누구나 접근 가능해 보안 최악의 관행으로 꼽혔고, 10255는 기본 비활성화됐다. (Kubernetes docs - Kubelet authentication/authorization) 그러자 모니터링·로그 에이전트들이 인증이 되는 10250으로 옮겨야 했고, 그 순간 nodes/proxy라는 과도하게 넓은 권한을 떠안게 됐다.

읽기 subresource를 proxy에서 떼어내다 — KEP-2862

KEP-2862(SIG Node, 2024-09-28 implementable 승인)의 접근은 단순하다. 읽기 전용인 /pods, /healthz, /configz각자 전용 subresource를 부여하고, 기존 nodes/proxy그대로 둔다(하위 호환). (KEP-2862: Fine-grained Kubelet API Authorization)

kubelet 요청 path 인가에 쓰이는 subresource 비고
/pods, /runningPods pods, proxy 1.32+ pods 추가(읽기)
/healthz, /healthz/* healthz, proxy 1.32+ healthz 추가(읽기)
/configz configz, proxy 1.32+ configz 추가(읽기)
/metrics/* metrics KEP 이전부터 전용
/stats/* stats KEP 이전부터 전용
/logs/* log KEP 이전부터 전용
/spec/* spec KEP 이전부터 전용
/checkpoint/* checkpoint 전용
/exec/*, /attach/*, /run/*, /portForward/*, /debug/* proxy 여전히 분리 안 됨

둘이 나열된 곳(pods, proxy)은 둘 중 하나만 있으면 허용된다. KEP가 새로 만든 건 pods, healthz, configz 세 개뿐이고, 나머지 읽기 subresource(metrics, stats, log, spec)는 원래부터 따로 있었다. 맨 아래 행이 이 표의 핵심이다 — /exec, /attach, /run, /portForward, /debug는 fine-grained의 대상이 아니다. 이들은 1.36에서도 여전히 nodes/proxy만 필요로 한다.

어떻게 동작하나 — SubjectAccessReview 2단, 그리고 fallback

kubelet은 10250으로 들어온 요청을 인가할 때 kube-apiserverSubjectAccessReview(SAR)를 보낸다. feature gate KubeletFineGrainedAuthz가 켜져 있으면 kubelet은 요청 path에 맞춰 두 개의 후보 subresource를 만들어 순서대로 검사한다. (KEP-2862 - Design Details)

flowchart TD
    REQ["kubelet 10250 요청<br/>예: GET /pods"] --> FG{"feature gate<br/>KubeletFineGrainedAuthz"}
    FG -->|"off (1.31 이하)"| SAR1["SAR: subresource=proxy"]
    FG -->|"on (1.36 기본)"| SAR2["1차 SAR: subresource=pods"]
    SAR2 -->|"허용"| OK["승인 (캐싱)"]
    SAR2 -->|"거부"| SAR3["2차 SAR: subresource=proxy<br/>(fallback)"]
    SAR3 -->|"허용"| OK
    SAR3 -->|"거부"| DENY["403"]
    SAR1 --> OK
    SAR1 --> DENY

이 2단 구조가 하위 호환을 만든다. 새 fine-grained subresource(pods)로 먼저 물어보고, 거부당하면 낡은 proxy로 다시 물어본다. 그래서 기존에 nodes/proxy만 가진 구형 에이전트는 1.36으로 올려도 그대로 동작한다. 동시에 nodes/pods만 가진 새 에이전트는 첫 SAR에서 바로 허용되므로 nodes/proxy가 전혀 필요 없다. SAR 본문은 자원 요청으로 채워진다.

# kubelet이 apiserver에 보내는 SubjectAccessReview (개념)
apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview
spec:
  user: monitoring-prometheus        # 요청한 클라이언트 신원
  resourceAttributes:
    group: ""
    resource: "nodes"
    name: "worker-3"                 # 요청이 향한 노드
    subresource: "pods"              # ← 1차: fine-grained. 거부 시 proxy 로 재질의
    verb: "get"

비용이 하나 있다. fine-grained subresource로 거부당한 요청은 두 번째 SAR까지 가므로, 최악의 경우 SAR 횟수가 두 배다. 다만 kubelet은 DelegatingAuthorizerConfig로 허용·거부 결과를 캐싱하므로, 같은 (사용자, subresource) 조합의 후속 요청은 캐시 적중으로 추가 비용이 사라진다.

/exec, /run은 왜 여전히 proxy인가

"실행 엔드포인트도 각자 subresource로 쪼개면 더 안전하지 않나?"라는 의문이 당연하다. KEP는 이 질문에 명시적으로 답한다. /exec/를 허용받은 공격자에게 /run/이나 /attach/추가로 의미 있는 권한 상승이 되는 경우를 생각해 낼 수 없었다 — 이들 중 하나만 있어도 노드에서 임의 명령을 실행할 수 있으므로, 나머지를 분리해봤자 보안 경계가 늘지 않는다. 반면 /pods, /healthz, /configz는 읽기 전용이라 분리하면 실질적인 최소 권한이 성립한다. (KEP-2862 - Notes)

즉, 실행 계열은 본질적으로 하나의 강한 권한으로 취급하는 쪽이 의미 있고, 읽기 계열만 분리하는 게 실익이라는 판단이다. 이 설계 결정을 알면 KEP가 모든 엔드포인트를 쪼개지 않은 이유가 보인다. 쪼개는 데 의미가 있는 경계만 쪼갰다.

Node authorizer와는 다른 축이다

"Node authorizer와 fine-grained 중 뭘 쓰나?"는 흔한 오해다. 둘은 방향이 다른 별개의 인가다.

  Node authorizer fine-grained kubelet authz
무엇을 인가하나 kubelet → apiserver 접근 클라이언트 → kubelet(10250) 접근
단위 "이 노드의 자원만" (노드 신원 국한) "이 kubelet 엔드포인트만" (엔드포인트 세분화)
누가 검사 apiserver의 인가자 kubelet이 apiserver에 SAR로 질의
출처 오래된 별개 기능 KEP-2862 (1.36 GA)

Node authorizer는 kubelet이 system:node:<name> 신원으로 apiserver에 접속할 때 "자기 노드 자원만" 보게 좁히는, apiserver 쪽 인가자다. (Kubernetes docs - Node authorizer) 반면 fine-grained는 반대 방향 — 클라이언트가 kubelet의 10250을 두드릴 때 어떤 엔드포인트까지 허용할지를 다룬다. 두 축이 합쳐 "이 노드의, 이런 종류의 kubelet 호출만"이 되는 게 아니라, 그냥 서로 다른 문제를 푼다.

직접 설계해 보기 — observability 에이전트의 최소 권한

KEP-2862의 실익이 가장 잘 드러나는 곳은 모니터링·로그 스택이다. Prometheus가 노드의 /pods, /metrics, /stats를 긁어야 한다고 하자. 1.32 이전엔 이 모든 걸 위해 nodes/proxy를 줘야 했고, 그게 곧 /exec 허용이었다. 1.36부턴 실행 subresource를 빼고 읽기 subresource만 줄 수 있다.

# Kubernetes 1.36 (KubeletFineGrainedAuthz GA, 기본 활성화)
# 모니터링 에이전트용 최소 권한 — nodes/proxy 가 없다 = exec/attach 불가
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: kubelet-observability
rules:
- apiGroups: [""]
  resources:
  - nodes/metrics   # /metrics/*
  - nodes/stats     # /stats/*
  - nodes/log       # /logs/*
  - nodes/pods      # /pods, /runningPods  (KEP-2862, 1.32+)
  - nodes/healthz   # /healthz*            (KEP-2862, 1.32+)
  - nodes/configz   # /configz             (KEP-2862, 1.32+)
  verbs: ["get"]

이 ClusterRole엔 nodes/proxy가 없다. 그 한 사실이 곧 "이 에이전트는 노드에서 컨테이너 명령을 실행할 수 없다"를 보장한다. kubectl auth can-i로 이걸 눈으로 확인한다.

# Kubernetes 1.36, kind
# fine-grained subresource는 yes, nodes/proxy는 no 여야 최소 권한이 성립한 것
kubectl auth can-i get nodes/metrics --as=system:serviceaccount:monitoring:prometheus
kubectl auth can-i get nodes/pods    --as=system:serviceaccount:monitoring:prometheus
kubectl auth can-i get nodes/proxy   --as=system:serviceaccount:monitoring:prometheus
yes
yes
no

마지막 줄이 no이면서 위 두 줄이 yes면, 과거의 넓은 nodes/proxy 부여가 fine-grained subresource로 완전히 대체됐다는 뜻이다.

내장 ClusterRole system:kubelet-api-admin은 feature gate가 켜지면 어노테이션에 의해 자동으로 nodes/pods, nodes/healthz, nodes/configz를 포함하도록 갱신된다. apiserver가 kubelet에 접근하는 데는 수동 변경이 필요 없고, 손봐야 할 건 추가로 부여한 커스텀 Role들뿐이다.

전제 조건 — kubelet webhook authorization이 켜져 있어야 한다

fine-grained authz는 무조건 동작하지 않는다. kubelet의 인가 모드가 Webhook이어야 SAR을 apiserver에 보내고, 그래야 fine-grained 검사가 일어난다. AlwaysAllow 모드면 검사 자체가 생략된다. kubeadm으로 부트스트랩한 클러스터는 기본 webhook이지만, 커스텀 구성이면 확인이 필요하다.

인증 쪽에도 걸린 게 있다. ServiceAccount 토큰으로 접근하는 에이전트(Prometheus 등)는 kubelet이 --authentication-token-webhook=true로 설정돼 있어야 토큰을 받아들인다. (Kubernetes docs - Kubelet authentication/authorization) 그렇지 않으면 RBAC이고 뭐고 401이 먼저 뜬다. fine-grained 인가는 인증을 통과한 뒤에야 검사되는 층이다.

점진적 도입과 버전 비대칭 — fallback이 주는 안전망

feature gate KubeletFineGrainedAuthz의 졸업 경로를 정리한다. (KEP-2862 - Implementation History)

버전 상태 기본
1.32 alpha (KEP implementable 2024-09-28) off
1.33 beta (2025-01-22) on
1.36 GA / Stable (PR #136116, 2026-01-26 병합) on (locked)

GA가 "무조건 당장 켜라"는 아니다. 의미는 API가 보장되고 기본 켜진다는 것일 뿐, 기존 Role을 점검하는 작업은 운영자 몫이다. 1.36으로 올린 뒤 권할 만한 절차는 이렇다.

  1. gate 확인: apiserver와 kubelet 양쪽에서 게이트가 켜졌는지. 1.36부턴 기본 on이지만, 매니지드(EKS/GKE/AKS)는 업스트림보다 보통 몇 마이너 뒤짐.
    # apiserver 측 게이트 (1 = 켜짐)
    kubectl get --raw /metrics | grep kubernetes_feature_enabled | grep KubeletFineGrainedAuthz
    kubernetes_feature_enabled{feature_gate="KubeletFineGrainedAuthz"} 1
    kubelet 측 게이트는 노드에서 curl -k https://$MY_NODE_IP:10250/metrics로 같은 메트릭을 확인한다(단 10250 접근 자체에 권한이 필요).
  2. nodes/proxy 부여 감사: nodes/proxy를 준 Role/Binding을 모두 찾는다.
  3. fine-grained subresource로 교체: 읽기만 필요한 에이전트의 Role에서 nodes/proxy를 빼고 위 ClusterRole처럼 구체적 subresource로 바꾼다.
  4. can-i로 확인: 대상 SA가 fine-grained는 yes, nodes/proxyno인지 검증.

주의할 비대칭이 있다. fine-grained subresource만 가진 워크로드는, kubelet 쪽 게이트가 꺼지는(다운그레이드) 순간 인가를 잃는다. 반대로 낡은 nodes/proxy만 가진 워크로드는 게이트 on/off와 무관하게 항상 동작한다(fallback 덕분). 그래서 마이그레이션은 nodes/proxy남겨둔 채 fine-grained를 추가하는 쪽이 안전하고, 충분히 검증된 뒤에야 nodes/proxy를 거둔다.

침해 시나리오 — 피해 반경이 어디서 줄어드나

한 노드가 탈취돼 공격자가 그 노드의 모니터링 에이전트 자격증명(SA 토큰)까지 손에 넣었다고 하자.

flowchart TD
    ATK["공격자<br/>(탈취한 모니터링 SA 토큰)"] --> AUTH{"에이전트의 RBAC"}
    AUTH -->|"과거: nodes/proxy"| C1["/exec = 노드 셸<br/>모든 Pod 환경변수(비밀)"]
    AUTH -->|"1.36: nodes/metrics 외 읽기만"| C2["/metrics, /pods, /stats 조회"]
    C2 -. "/exec 시도 → 2차 SAR proxy 거부" .-> BLOCK["403"]

fine-grained는 노드 자체가 뚫리는 걸 막지 않는다. 다만, 모니터링 에이전트 자격증명이 유출됐을 때 그 자격으로 할 수 있는 일을 읽기로 한정한다. 에이전트가 nodes/proxy를 안 가졌으므로, 공격자가 그 토큰으로 /exec를 시도하면 2차 SAR의 proxy 검사에서 거부된다. 한 자격증명의 침해가 노드 전체 탈취로 곧장 이어지지 않게 하는 심층 방어의 한 층이다. 물론 완전 방어는 아니다(허용된 읽기 범위 내에선 여전히 메타데이터 노출).

1.36이 보안 기준점인 이유 — 함께 GA한 User Namespaces

fine-grained kubelet authz가 단독으로 1.36에서 GA한 게 아니다. 같은 릴리스에서 User Namespaces in pods도 Stable로 졸업했다. (Kubernetes v1.36 릴리스 - Stable 목록) 이 둘이 같은 릴리스에 묶인 건 우연이 아니라, 노드 보안의 두 면을 동시에 닫는 짝이다.

  • fine-grained kubelet authz(이 글): 누가 kubelet API의 어느 엔드포인트에 닿을 수 있는가. 외부 주체의 접근 범위를 좁힌다.
  • User Namespaces: 컨테이너 안의 root(uid 0)를 노드의 비루트 UID로 매핑. 컨테이너가 뚫려도 노드 root 권한 상승이 안 되게 한다.

둘이 보완하는 지점이 있다. fine-grained가 막지 못하는 경로 — 예컨대 악의적인 Pod가 컨테이너 런타임 취약점으로 노드로 탈출하려 할 때 — User Namespaces가 컨테이너 root를 노드 비루트로 묶어 권한 상승을 깎는다. 반대로 User Namespaces가 다루지 않는 "누가 kubelet 10250을 두드리나"는 fine-grained가 담당한다. 05장(Pod Security Admission)의 restricted 프로파일이 요구하는 runAsNonRoot를 User Namespaces가 구조적으로 뒷받침한다는 점까지 합치면, 1.36은 "접근 통제 + 컨테이너 격리가 함께 성숙한 릴리스"로 읽힌다. 이게 본 학습서가 1.36을 기준선으로 잡는 근거 중 하나다.

노드 보안의 여러 층에서 fine-grained가 서는 자리

fine-grained kubelet authz가 노드 보안의 전부가 아니다. 이 기능이 제자리를 찾으려면 같이 켜야 할 층들을 봐야 한다.

막는 공격 경로 이 글/시리즈에서
fine-grained kubelet authz 모니터링 자격증명 유출 → /exec 남용 이 글 (KEP-2862)
Node authorizer kubelet 신원이 다른 노드 자원을 훔쳐보는 것 본문(별개 축)
Pod Security Admission privileged Pod로 노드로 탈출 05장
User Namespaces(1.36 GA) 컨테이너 root → 노드 root 권한 상승 바로 위 절
NetworkPolicy Pod 간 횡적 이동 02·09장

이 층들이 독립적으로 각자의 경로를 막는다. fine-grained만 켜고 Pod Security Admission을 안 켜면 privileged Pod 탈출 경로가 그대로 열려 있다. 한 층만 믿지 말고, 노드 보안도 "심층 방어"가 원칙이다 — 이게 본 영역(03-k8s-security) 전체를 관통하는 태도다.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"fine-grained가 /exec를 막아준다" 아니다. /exec는 여전히 nodes/proxy. fine-grained는 읽기 엔드포인트(/pods,/healthz,/configz)를 proxy에서 떼어낸 것. 에이전트에 proxy를 안 줘서 결과적으로 exec를 못 하게 될 뿐
"1.36에선 kubelet API가 기본 거부다" 아니다. 기본 거부는 NetworkPolicy 등의 개념. kubelet authz는 부여한 RBAC subresource로 결정
"Node authorizer를 대체한다" 별개 축. Node authorizer는 kubelet→apiserver 방향, fine-grained는 클라이언트→kubelet 방향
"KEP-2862가 모든 엔드포인트를 쪼갰다" 읽기 3개(pods,healthz,configz)만 추가. 실행 계열(/exec,/run,/attach)은 보안상 의미 없다고 판단해 그대로 proxy
"alpha/beta/GA는 다 똑같다" 다르다. alpha(1.32, 끔)/beta(1.33, 기본 켬)/GA(1.36, 영구·프로덕션). API 보장 여부가 다르다
"kubelet webhook authz가 꺼져도 동작한다" 아니다. --authorization-mode=Webhook이어야 SAR을 보내고, 그래야 fine-grained 검사가 일어난다

요약 — 이 글의 결론

  • kubelet의 10250 API는 노드의 모든 Pod에 직결하는 별도 보안 표면. apiserver RBAC과 다른 축이다.
  • 과거엔 /pods(읽기)와 /exec(실행)가 같은 nodes/proxy로 묶여, 읽기 에이전트가 곧 실행 권한을 받았다.
  • KEP-2862(feature gate KubeletFineGrainedAuthz, 1.36 GA·기본 활성화)가 /pods, /healthz, /configz에 전용 subresource를 줘 읽기와 실행을 분리했다.
  • 동작은 SAR 2단 — fine-grained subresource로 먼저 검사, 거부 시 proxy로 fallback. 이 fallback이 하위 호환을 만든다.
  • 실행 계열(/exec,/run,/attach)은 fine-grained 대상이 아니다. 읽기만 분리한다는 점이 핵심이며, KEP가 명시한 설계 근거다.
  • 전제는 kubelet webhook authorization. 읽기 에이전트엔 nodes/metrics·nodes/pods 등만 주고 nodes/proxy를 빼면, kubectl auth can-i로 확인 가능한 최소 권한이 성립한다.

생각해 볼 문제

  1. 모니터링 에이전트에 nodes/podsnodes/metrics만 주고 nodes/proxy를 뺐다. 이 에이전트가 kubectl exec 기능을 하는 디버그 사이드카를 호출하면 어떻게 되나?
  2. fine-grained subresource만 가진 워크로드가 있는 클러스터를 1.33(beta)에서 1.31로 다운그레이드해야 한다. 어떤 일이 벌어지나?
  3. KEP가 /exec·/run·/attach를 굳이 분리하지 않은 근거를, "최소 권한 원칙" 관점에서 비판해 보라. 그리고 옹호해 보라.
  4. kubelet이 --authorization-mode=AlwaysAllow로 설정돼 있다. KubeletFineGrainedAuthz 게이트가 켜져 있는데 fine-grained가 동작할까?
  5. SAR 2단 구조가 캐싱 없이 매 요청마다 두 번의 apiserver 왕복을 만든다고 가정하자. 대규모 클러스터(노드 1000개, 모니터링 에이전트 1000개)에서 apiserver에 가해지는 부하를 어떻게 추정하겠는가?
  6. Node authorizer가 "자기 노드 자원만"을 강제하는 것과, fine-grained가 "/pods는 되지만 /exec는 안 된다"를 강제하는 것은 각각 어떤 공격 경로를 막나?
  7. 1.36에서 GA한 User Namespaces(컨테이너 UID를 노드 비루트로 매핑)와 fine-grained kubelet authz가 함께 켜졌을 때, 노드 탈취 시나리오의 피해 반경이 어떻게 바뀌는가?

참고