LabHub

KCSA — 쿠버네티스 보안 어소시에이트 · 클러스터 컴포넌트 보안 · 이론

요청 하나가 apiserver 를 통과하는 길

LabHub 에서 이어서 보기

한 줄 요약

apiserver 로 들어온 요청은 인증 → 인가 → 어드미션 → 검증 → 저장 순으로 흐릅니다.
각 단계가 무엇을 판단하고 무엇은 판단하지 않는지 알면 대부분의 보안 질문이 풀립니다.

왜 이게 필요했나

"이 요청은 왜 거부됐나"에 답하려면 어느 단계에서 걸렸는지를 알아야 합니다.
401 은 인증 실패, 403 은 인가 실패, 어드미션 웹훅의 거부는 또 다른 메시지입니다.
단계를 모르면 RBAC 을 고쳐야 할 때 인증서를 만지게 됩니다.

어떻게 동작하나

1단계: 인증(Authentication) — "당신은 누구입니까"

apiserver 는 여러 인증기(authenticator)를 차례로 시도하고, 하나라도 성공하면 그 신원으로
진행합니다. 전부 실패하면 요청자는 system:anonymous(그룹 system:unauthenticated)가 됩니다.

| 방식 | 쓰이는 곳 |
| --- | --- |
| X.509 클라이언트 인증서 | kubelet, 컨트롤러, 관리자 kubeconfig. CN 이 사용자명, O 가 그룹 |
| ServiceAccount 토큰(JWT) | 파드 안에서 API 호출 |
| OIDC 토큰 | 사람 사용자를 회사 IdP 와 연동 |
| 웹훅 토큰 인증 | 외부 인증 시스템 위임 |

중요한 사실 하나: 쿠버네티스에는 User 오브젝트가 없습니다. 사용자는 인증기가 만들어 낸
문자열일 뿐이고, 그래서 "사용자를 삭제"할 수 없습니다. 인증서를 폐기하거나 IdP 에서 끊어야 합니다.

--anonymous-auth=true 는 인증 실패를 거부로 만들지 않고 익명 신원을 부여합니다.
익명에게 무엇을 허용할지는 인가 단계가 결정하므로, 익명 접근 자체보다 익명에 붙은 권한
진짜 문제입니다. 기본 클러스터에서 익명은 /healthz, /version 같은 공개 정보만 볼 수 있습니다.

system:masters 그룹은 특별합니다. 이 그룹의 멤버는 모든 RBAC 검사를 우회하고,
RoleBinding 이나 ClusterRoleBinding 을 삭제해도 권한을 회수할 수 없습니다.
인증서에 O=system:masters 가 박히면 그 인증서가 유효한 동안은 사실상 회수 불가능한 최고 권한입니다.

2단계: 인가(Authorization) — "그 일을 해도 됩니까"

인가 모듈도 순서대로 평가되고 하나라도 허용하면 통과합니다. --authorization-mode 플래그로 지정합니다.

RBAC 은 허용만 있고 거부가 없습니다. 어디에도 허용이 없으면 거부입니다.
그래서 "이 사람에게서 권한을 빼앗는 Role" 같은 건 만들 수 없고, 바인딩을 없애야 합니다.

3단계: 어드미션(Admission) — "이 내용이 정책에 맞습니까"

인가를 통과한 요청이라도 내용이 규칙에 맞는지 봅니다. 순서가 중요합니다 —
Mutating 이 먼저, Validating 이 나중. 먼저 고치고 그다음 검사해야 앞뒤가 맞습니다.

여기에 사는 대표 플러그인이 PodSecurity(PSA)입니다. 그리고 외부 정책 엔진
(OPA Gatekeeper, Kyverno)도 웹훅으로 이 단계에 끼어듭니다.

어드미션 웹훅에는 운영상 함정이 있습니다. failurePolicyFail 이면 웹훅이 죽었을 때
모든 파드 생성이 거부되고, Ignore 면 정책이 통째로 우회됩니다. 저자의 블로그가 정리한 판단은
분명합니다 — "프로덕션에서는 Fail 을 유지하되 Gatekeeper 파드의 리소스와 replicas 를 충분히
확보한다." 가용성 문제를 정책을 약화시켜 푸는 게 아니라 엔지니어링으로 풀라는 뜻입니다.

4단계: etcd 저장과 저장 시 암호화

apiserver 는 오브젝트를 직렬화(기본 protobuf)한 뒤 transformer 를 거쳐 etcd 에 씁니다.
transformer 가 identity 면 평문, aescbc/aesgcm/kms 면 암호문입니다.

apiVersion: apiserver.config.k8s.io/v1kind: EncryptionConfigurationresources:  - resources: [secrets]    providers:      - aescbc:          keys: [{name: key1, secret: <base64>}]      - identity: {}

providers 배열의 순서가 전부입니다. 첫 번째가 쓰기에 쓰이고, 모든 프로바이더가 읽기에 쓰입니다.
그래서 identity 를 맨 앞에 두면 평문 저장으로 되돌아가고, 맨 뒤에 두면 "새로 쓰는 건 암호화,
예전 평문도 읽기 가능" 이 됩니다. 그리고 **설정을 켜도 기존 Secret 은 다시 쓰기 전까지 평문으로
남습니다** — kubectl get secrets -A -o json | kubectl replace -f - 같은 전체 재작성이 필요합니다.

KMS v2 프로바이더는 한 걸음 더 갑니다. DEK 는 로컬에서 만들고 캐싱하며, 원격 KMS 는 KEK 만
관리합니다(봉투 암호화). 키 순환에 apiserver 재시작이 필요 없습니다.

5단계: kubelet 의 인증/인가

kubelet 은 두 방향의 신원을 갖습니다.

컨트롤러 매니저와 토큰 발급

컨트롤러 매니저는 ServiceAccount 컨트롤러와 토큰 컨트롤러를 돌리고, --service-account-private-key-file
로 받은 키로 SA 토큰에 서명합니다. 이 키를 얻으면 임의의 SA 를 사칭하는 토큰을 직접 발급할 수 있습니다.
apiserver 는 대응하는 공개키로 검증만 하니까요.

v1.24 부터는 SA 를 만들어도 영구 토큰 Secret 이 자동 생성되지 않고, TokenRequest API 기반의
시간 제한 토큰이 기본입니다(kubectl create token <sa> --duration=3600s). 보안상 큰 개선입니다.
여기서 --service-account-lookup=false 가 위험한 이유도 나옵니다 — 이 값이 false 면
이미 삭제된 SA 의 토큰도 서명만 유효하면 계속 통과합니다.

현장에서 만나는 모습

저자의 홈랩 확장 기록에 이 체계의 급소가 그대로 나옵니다. 컨트롤 플레인을 추가하려면
CA 개인키가 필요합니다. 새 노드도 인증서를 발급하는 주체가 되어야 하니까요.
kubeadm 은 이 파일을 scp 로 나르게 하지 않고 kubeadm init phase upload-certs --upload-certs
암호화해 클러스터 안의 Secret 으로 올리고, 64자리 16진수 certificate-key 를 그 암호문의 열쇠로 줍니다.

그리고 이 Secret 은 2시간 뒤 자동 삭제됩니다. 클러스터의 루트 신뢰인 CA 개인키가
암호화된 상태로나마 클러스터 안에 존재하는 시간을 최소화하려는 설계입니다.
"민감한 자료의 노출 창(window)을 줄인다"는 원칙이 제품 기능으로 구현된 좋은 예입니다.

또 하나. 그 홈랩에서는 컨트롤 플레인을 2 대로 늘리지 않고 곧바로 3 대까지 갔습니다.
이유가 명확합니다 — **etcd 쿼럼은 과반이고 멤버 2 의 과반은 2 이므로, 2 멤버는 1 멤버보다
장애 확률이 오히려 높습니다.** 둘 중 아무나 죽으면 쓰기 불능이 되니까요.
가용성 설계에서 "많을수록 낫다"가 틀리는 대표 사례입니다.

다음 실습에서 할 것

다음 실습에서 apiserver 의 위험 플래그 목록을 직접 작성하고, EncryptionConfiguration YAML 을
프로바이더 순서까지 맞춰 쓰고, kubelet 점검 항목을 정리합니다. 그리고 네임스페이스 두 개에
서비스어카운트와 Role 을 걸어 격리가 실제로 작동하는지 kubectl auth can-i 로 증명하고,
익명 사용자가 지금 이 클러스터에서 무엇을 할 수 있는지 관찰해 기록합니다.