Certified Kubernetes Security Specialist (CKS) #5 ServiceAccount 토큰 관리, API 액세스 제한, 클러스터 업그레이드
#4 RBAC 최소 권한 깊이에서 누가 무엇을 할 수 있는지를 RBAC으로 좁혔다면, 이번 글은 그 권한을 실어 나르는 ServiceAccount 토큰을 관리하는 일입니다. Pod안에 자동으로 꽂히는 토큰 한 장이 탈취되면 RBAC으로 아무리 권한을 좁혀도 그 좁힌 권한만큼은 그대로 공격자 손에 넘어갑니다. 그래서 Cluster Hardening도메인은 불필요한 토큰을 애초에 마운트하지 않는 것부터 시작합니다.
이번 글에서는 ServiceAccount 토큰 마운트를 끄는 법, bound 토큰의 만료와 audience, anonymous 인증 차단과 kubelet API 보호, 그리고 보안 패치를 위한 클러스터 업그레이드까지 Cluster Hardening의 나머지 절반을 정리하겠습니다.
ServiceAccount 토큰이란 무엇인가 #
모든 Pod는 쿠버네티스 API에 자신을 증명할 신원이 필요합니다. 그 신원이 ServiceAccount(SA)이고, SA의 자격 증명이 곧 토큰입니다. 토큰은 JWT 형식의 문자열로, kube-apiserver가 이를 검증해 “이 요청은 default 네임스페이스의 build-bot SA가 보낸 것"임을 판단한 뒤, 그 SA에 묶인 RBAC 권한으로 요청을 허용하거나 거부합니다.
문제는 쿠버네티스가 기본적으로 모든 Pod에 그 네임스페이스의 default SA 토큰을 자동으로 마운트한다는 점입니다. Pod안에서 보면 다음 경로에 토큰이 들어 있습니다.
/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
/var/run/secrets/kubernetes.io/serviceaccount/namespace대부분의 애플리케이션 Pod는 쿠버네티스 API를 직접 호출할 일이 없습니다. 그런데도 토큰이 마운트되어 있으면, 그 Pod가 침해당했을 때 공격자가 이 토큰을 그대로 들고 API서버에 접근합니다. 즉 쓰지도 않는 토큰이 공격 표면을 넓히는 셈입니다.
automountServiceAccountToken으로 마운트 차단 #
컨테이너가 침해되면 파일 시스템에 평문으로 놓인 토큰을 읽어 API서버를 호출할 수 있고, 그 SA에 부여된 권한만큼 클러스터를 조작할 수 있습니다. RBAC으로 권한을 좁히는 것이 1차 방어라면, 애초에 토큰을 꽂지 않는 것이 더 근본적인 차단입니다. 마운트를 끄는 설정이 automountServiceAccountToken: false이고, 두 곳에 둘 수 있습니다.
Pod 레벨에서 끄기 #
특정 Pod에만 마운트를 끄려면 Pod spec에 직접 둡니다.
apiVersion: v1
kind: Pod
metadata:
name: no-token-pod
namespace: default
spec:
automountServiceAccountToken: false
containers:
- name: app
image: nginx:1.27이 Pod안에서는 /var/run/secrets/kubernetes.io/serviceaccount/ 경로가 비어 있습니다. API를 쓰지 않는 애플리케이션이라면 이렇게 두는 것이 안전합니다.
ServiceAccount 레벨에서 끄기 #
같은 SA를 쓰는 모든 Pod에 일괄로 끄려면 ServiceAccount 객체에 둡니다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: restricted-sa
namespace: default
automountServiceAccountToken: false이 SA를 쓰는 Pod는 따로 지정하지 않아도 토큰이 마운트되지 않습니다. 단, Pod 레벨 설정이 SA 레벨 설정을 덮어씁니다. 즉 SA에서 false로 두어도 특정 Pod에서 true로 두면 그 Pod에는 토큰이 마운트됩니다. 반대로 SA에서 true(기본값)여도 Pod에서 false로 두면 그 Pod에는 마운트되지 않습니다.
| 설정 위치 | 값 | 결과 |
|---|---|---|
| Pod | false | 항상 마운트 안 됨(SA 설정 무시) |
| Pod | true | 항상 마운트됨(SA 설정 무시) |
| Pod 미지정 + SA | false | 마운트 안 됨 |
| Pod 미지정 + SA | 기본값 | 마운트됨 |
시험에서는 “이 Pod에 ServiceAccount 토큰이 마운트되지 않게 하라” 또는 “이 SA를 쓰는 워크로드에 토큰 자동 마운트를 끄라"는 작업이 단골입니다. 어느 레벨에 두라는 것인지 문장을 정확히 읽고, 우선순위를 떠올려 맞는 위치에 설정하는 것이 핵심입니다.
전용 SA를 만들어 명시적으로 연결 #
default SA를 그대로 쓰는 대신, 워크로드마다 전용 SA를 만들고 필요한 최소 권한만 RBAC으로 부여하는 것이 권장 패턴입니다. API를 호출해야 하는 Pod라면 토큰을 켜되 전용 SA로 격리하고, 호출하지 않는 Pod라면 끕니다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: build-bot
namespace: ci
automountServiceAccountToken: true
---
apiVersion: v1
kind: Pod
metadata:
name: builder
namespace: ci
spec:
serviceAccountName: build-bot
containers:
- name: builder
image: gcr.io/kaniko-project/executor:latestserviceAccountName을 지정하지 않으면 default SA가 붙으므로, 권한이 필요한 Pod일수록 전용 SA를 명시하는 습관이 좋습니다.
bound ServiceAccount 토큰 #
토큰의 종류도 알아 두어야 합니다. 쿠버네티스 1.24 이전에는 ServiceAccount를 만들면 대응하는 Secret이 자동으로 생성되어, 그 안에 만료 없는 영구 토큰이 들어 있었습니다. 이것이 legacy 토큰이고, 만료가 없으니 한 번 새면 무기한으로 유효해 보안 관점에서 좋지 않았습니다.
projected 토큰(bound token) #
1.24부터는 Pod가 뜰 때 kubelet이 projected volume으로 토큰을 발급합니다. 이 토큰이 bound ServiceAccount 토큰이고, 다음 특성을 가집니다.
- **만료(expiration)**가 있습니다. 기본 1시간 단위로 kubelet이 자동으로 토큰을 갱신해 다시 마운트합니다.
- audience가 묶입니다. 토큰이 어느 수신자(API서버 등)를 위한 것인지 명시되어, 엉뚱한 곳에 재사용되지 않습니다.
- 그 Pod의 수명에 bound됩니다. Pod가 사라지면 토큰도 무효가 됩니다.
즉 토큰이 탈취되더라도 짧은 만료와 audience 제약 덕분에 피해 범위와 시간이 크게 줄어듭니다. 직접 Pod spec에 projected 토큰을 명시할 수도 있습니다.
apiVersion: v1
kind: Pod
metadata:
name: api-client
spec:
serviceAccountName: build-bot
containers:
- name: app
image: nginx:1.27
volumeMounts:
- name: token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600
audience: vaultexpirationSeconds로 만료를 더 짧게 줄이고, audience로 토큰을 특정 수신자에만 유효하게 묶을 수 있습니다.
legacy Secret 토큰과의 차이 #
| 구분 | legacy Secret 토큰 | bound(projected) 토큰 |
|---|---|---|
| 발급 위치 | SA에 묶인 Secret 객체 | Pod의 projected volume |
| 만료 | 없음(영구) | 있음(기본 1시간, 자동 갱신) |
| audience | 없음 | 지정 가능 |
| 수명 | SA/Secret이 사는 동안 | Pod 수명에 묶임 |
| 권장 여부 | 비권장(특수 목적만) | 기본 권장 |
여전히 만료 없는 토큰이 필요한 경우(외부 시스템 연동 등)에는 다음처럼 Secret을 명시적으로 만들 수 있지만, CKS 관점에서는 꼭 필요한 때만 쓰고 평소에는 bound 토큰을 쓰는 것이 정답입니다.
apiVersion: v1
kind: Secret
metadata:
name: build-bot-token
namespace: ci
annotations:
kubernetes.io/service-account.name: build-bot
type: kubernetes.io/service-account-token이렇게 만든 Secret에는 만료 없는 토큰이 채워지므로, 새 나가면 위험이 큽니다. 시험에서 “만료 없는 토큰을 만들라"는 작업이 나오면 이 형태를 떠올리되, 보안 모범 사례로는 권장되지 않는다는 점도 함께 기억해 두겠습니다.
API 액세스 제한 #
토큰을 좁혔다면, 다음은 API서버 자체로 들어오는 입구를 좁힐 차례입니다. Cluster Hardening의 또 다른 축이 인증되지 않은 접근과 과도한 노출을 막는 것입니다.
anonymous 인증 끄기 #
kube-apiserver는 기본적으로 익명 요청을 system:anonymous 사용자로 받아들입니다. RBAC이 잘 잡혀 있으면 익명 사용자가 할 수 있는 일은 거의 없지만, 공격 표면을 줄이는 차원에서 익명 인증 자체를 끄는 것이 권장됩니다. API서버 매니페스트에 다음 플래그를 둡니다.
# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
containers:
- command:
- kube-apiserver
- --anonymous-auth=false
# ... 나머지 플래그/etc/kubernetes/manifests/의 static Pod 매니페스트를 수정하면 kubelet이 API서버 Pod를 자동으로 재시작합니다. 수정 직후 잠시 API서버가 내려가므로, 들여쓰기와 따옴표를 정확히 맞추는 것이 중요합니다. 한 글자만 틀려도 API서버가 다시 뜨지 못합니다.
다만 --anonymous-auth=false로 두면 일부 헬스 체크 경로(/healthz, /livez, /readyz)에 영향이 갈 수 있으므로, 시험에서는 작업이 요구하는 범위 안에서만 끄고 영향 범위를 확인하는 것이 안전합니다.
RBAC으로 입구 좁히기 #
#4에서 다룬 RBAC이 API 액세스 제한의 본체입니다. 인증을 통과한 요청이라도 인가 단계에서 권한이 없으면 거부됩니다. 다음을 확인합니다.
system:anonymous와system:unauthenticated그룹에 불필요한 RoleBinding/ClusterRoleBinding이 묶여 있지 않은지 점검합니다.- 광범위한
cluster-admin이 여러 SA에 붙어 있지 않은지 줄입니다. - 와일드카드(
*) verb와 resource를 구체적인 권한으로 좁힙니다.
kubelet API 보호 #
API서버만큼 중요한 것이 각 노드의 kubelet API입니다. kubelet은 노드에서 컨테이너를 실제로 굴리는 컴포넌트이고, 그 API가 열려 있으면 Pod 목록 조회나 컨테이너 안 명령 실행까지 가능해 매우 위험합니다. CKS에서 자주 점검하는 항목이 다음 둘입니다.
# /var/lib/kubelet/config.yaml
readOnlyPort: 0
authentication:
anonymous:
enabled: false
webhook:
enabled: true
authorization:
mode: WebhookreadOnlyPort: 0으로 인증 없이 열리던 10255 읽기 전용 포트를 닫습니다. 이 포트가 열려 있으면 누구나 노드의 Pod 정보와 메트릭을 인증 없이 읽을 수 있습니다.anonymous.enabled: false로 kubelet에 대한 익명 요청을 차단합니다.authorization.mode: Webhook으로 kubelet API 요청을 API서버의 인가에 위임해, 인증된 요청이라도 권한을 다시 확인하게 합니다.
설정을 바꾼 뒤에는 systemctl restart kubelet으로 kubelet을 재시작해 반영합니다. 이 세 항목은 CIS benchmark(#3의 kube-bench)에서도 점검하는 항목이므로 함께 묶어 기억해 두면 좋습니다.
클러스터 업그레이드로 보안 패치 적용 #
마지막 축은 클러스터를 최신 버전으로 유지하는 것입니다. 쿠버네티스에는 정기적으로 CVE(알려진 취약점)가 보고되고, 패치는 새 마이너/패치 버전으로 배포됩니다. 오래된 버전을 쓰면 이미 공개된 취약점에 그대로 노출되므로, 업그레이드 자체가 보안 작업입니다.
왜 업그레이드가 보안인가 #
- 컴포넌트(kube-apiserver, kubelet, etcd 등)의 알려진 취약점이 패치됩니다.
- 보안 기능(예: bound 토큰, PSA)의 개선이 새 버전에서 들어옵니다.
- 지원 종료(EOL) 버전은 더 이상 보안 패치를 받지 못하므로, 지원 윈도 안의 버전을 유지해야 합니다.
kubeadm 업그레이드 절차 요약 #
실제 업그레이드 절차는 CKA의 영역이라 여기서는 핵심만 짚겠습니다. 자세한 단계는 CKA #6 클러스터 업그레이드에서 다룹니다.
# 1) 컨트롤 플레인 노드에서 kubeadm 업그레이드
apt-get update && apt-get install -y kubeadm=1.31.x-*
kubeadm upgrade plan
kubeadm upgrade apply v1.31.x
# 2) 해당 노드를 drain하고 kubelet/kubectl 업그레이드
kubectl drain <node> --ignore-daemonsets
apt-get install -y kubelet=1.31.x-* kubectl=1.31.x-*
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon <node>
# 3) 워커 노드는 kubeadm upgrade node 후 동일하게 kubelet 갱신핵심은 한 번에 한 마이너 버전씩 올린다는 점, 그리고 컨트롤 플레인을 먼저 올린 뒤 워커를 올린다는 순서입니다. CKS 관점에서는 업그레이드의 동기가 보안 패치 적용과 CVE 대응이라는 맥락을 이해하면 충분합니다.
시험 포인트 #
- automountServiceAccountToken: false 가 이 도메인의 1순위 단골입니다. Pod 레벨과 SA 레벨 두 곳에 둘 수 있고, Pod 레벨이 SA 레벨을 덮어쓴다는 우선순위를 외워 둡니다.
- API를 쓰지 않는 Pod는 토큰을 끄고, 쓰는 Pod는 전용 SA + 최소 RBAC으로 격리합니다. default SA에 권한을 붙이지 않습니다.
- bound(projected) 토큰은 만료,audience,Pod 수명 bound가 특징입니다. legacy Secret 토큰은 만료가 없어 비권장이며, 꼭 필요할 때만
kubernetes.io/service-account-tokenSecret으로 만듭니다. - API서버는
--anonymous-auth=false, kubelet은readOnlyPort: 0,anonymous.enabled: false,authorization.mode: Webhook으로 입구를 좁힙니다. - static Pod 매니페스트(
/etc/kubernetes/manifests/)나 kubelet config를 수정한 뒤에는 재시작,반영을 확인합니다. 들여쓰기 오류로 컴포넌트가 안 뜨는 사고가 흔합니다. - 클러스터 업그레이드는 CVE 대응을 위한 보안 작업입니다. 한 번에 한 마이너 버전, 컨트롤 플레인 먼저, 워커 나중 순서를 기억합니다.
정리 #
이번 글에서 잡은 것:
- ServiceAccount 토큰은 Pod의 신원이고, 기본적으로 모든 Pod에 자동 마운트되어 공격 표면을 넓힙니다.
- 쓰지 않는 토큰은
automountServiceAccountToken: false로 차단합니다. Pod 레벨이 SA 레벨보다 우선합니다. - bound 토큰은 만료,audience,Pod bound로 탈취 피해를 줄입니다. legacy Secret 토큰은 만료가 없어 특수 목적에만 씁니다.
- API 액세스는 anonymous 인증 차단, RBAC 최소화, kubelet API 보호(
readOnlyPort: 0외)로 좁힙니다. - 클러스터 업그레이드는 CVE 패치를 적용하는 보안 작업입니다. 컨트롤 플레인 먼저, 한 마이너 버전씩 올립니다.
CKAD 관점에서 ServiceAccount와 Pod 설정의 기본을 더 보고 싶다면 CKAD #14 ServiceAccount와 보안 컨텍스트를, 업그레이드 절차의 전체 단계는 CKA #6 클러스터 업그레이드를 함께 보면 좋습니다.
다음: AppArmor 프로파일 #
Cluster Hardening까지 마쳤으니, 이제 노드의 리눅스 커널 수준으로 내려갑니다. 컨테이너가 호스트에서 무엇을 할 수 있는지를 운영체제 차원에서 가두는 도메인이 System Hardening입니다.
#6 AppArmor 프로파일 (System Hardening)에서는 AppArmor가 파일 접근과 capability를 어떻게 제한하는지, 프로파일을 작성해 노드에 로드하는 법, 그리고 그 프로파일을 Pod에 붙여 컨테이너의 행동을 가두는 패턴을 직접 만들어 보며 정리하겠습니다.