파드 내부의 배치 잡이 Kubernetes API를 호출하려는데 401 Unauthorized가 발생합니다. 운영팀은 사람 계정과 파드가 사용하는 ServiceAccount를 구분해야 권한을 안전하게 부여할 수 있습니다. ServiceAccount는 워크로드가 클러스터와 통신할 때 쓰는 신원입니다.
ServiceAccount — 파드의 K8s API 접근 권한
운영팀에서 배포 자동화 도구를 개발한다고 상상해보세요. 이 도구는 파드 안에서 실행되면서 다른 Deployment의 레플리카 수를 동적으로 조정해야 합니다. 아무 설정 없이 K8s API를 호출하면 403 Forbidden이 돌아옵니다. 단순히 파드가 실행 중이라는 사실만으로는 클러스터를 제어할 권한이 없기 때문입니다. 여기에 더해, AWS S3 버킷에서 설정 파일을 내려받아야 한다면 어떻게 할까요? 예전 방식처럼 Access Key를 Secret에 넣어두면 키가 노출될 위험이 있습니다. ServiceAccount는 파드에 클러스터 내부 ID를 부여하고, IRSA는 그 ID를 AWS IAM 권한과 연결하여 이 두 문제를 모두 해결합니다. 이 모듈에서는 ServiceAccount의 동작 원리부터 실무에서 자주 쓰는 IRSA 패턴까지 단계적으로 익힙니다.
- 1ServiceAccount의 역할과 default 계정의 한계
- 2ServiceAccount 토큰 자동 마운트 경로와 구조
- 3전용 ServiceAccount 생성 + RBAC 연결 패턴
- 4automountServiceAccountToken으로 공격 표면 최소화
- 5IRSA (IAM Roles for Service Accounts) 원리와 설정
- 6파드 내부에서 K8s API 직접 호출 실습
kubectl cluster-infokubectl get serviceaccountkubectl get sa default -o yamlkubectl create namespace sa-demoServiceAccount 토큰이 파드에 마운트되는 구조
K8s는 파드가 생성될 때 ServiceAccount 토큰을 /var/run/secrets/kubernetes.io/serviceaccount/ 경로에 자동으로 마운트합니다. 이 디렉토리에는 세 파일이 있습니다.
# 파드 안에서 실행
ls /var/run/secrets/kubernetes.io/serviceaccount/
# ca.crt namespace token
# token: JWT 형태의 ServiceAccount 토큰
cat /var/run/secrets/kubernetes.io/serviceaccount/token
# eyJhbGciOiJSUzI1NiIsImtpZCI6Ii...
# namespace: 현재 파드가 속한 네임스페이스
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
# default
# ca.crt: K8s API 서버의 TLS 인증서 검증용 CA
ls -la /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
이 토큰을 Bearer 헤더에 넣고 API 서버 주소로 요청을 보내면 K8s API를 호출할 수 있습니다.
전용 ServiceAccount 생성과 RBAC 연결
파드 안에서 Kubernetes API를 호출하는 도구를 배포할 때, 아무 ServiceAccount 설정 없이 실행하면 default 계정이 사용됩니다. default 계정은 기본적으로 권한이 거의 없어 403이 반환됩니다. 그렇다고 cluster-admin을 부여하면 이 파드가 탈취됐을 때 클러스터 전체가 위험해집니다. 전용 ServiceAccount를 만들고 필요한 최소 권한만 Role로 정의해 바인딩하는 것이 올바른 방법입니다. 파드별로 독립된 신원을 부여하면 사고 발생 시 피해 범위도 해당 Role로 제한됩니다.
API를 호출해야 하는 파드는 반드시 전용 ServiceAccount를 만들고, 필요한 권한만 Role로 묶어 바인딩해야 합니다.

# serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: deployment-scaler
namespace: sa-demo
annotations:
description: "Deployment 레플리카 수 조정용 서비스 계정"
---
# role.yaml — 필요한 최소 권한만 부여
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployment-scaler-role
namespace: sa-demo
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
# rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deployment-scaler-binding
namespace: sa-demo
subjects:
- kind: ServiceAccount
name: deployment-scaler
namespace: sa-demo
roleRef:
kind: Role
name: deployment-scaler-role
apiGroup: rbac.authorization.k8s.io
kubectl apply -f serviceaccount.yaml -f role.yaml -f rolebinding.yaml
# 파드에 ServiceAccount 지정
kubectl run scaler-pod \
--image=curlimages/curl:latest \
--serviceaccount=deployment-scaler \
--namespace=sa-demo \
--command -- sleep 3600
# 파드 안에서 K8s API 호출 테스트
kubectl exec -it scaler-pod -n sa-demo -- sh
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"
CA="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
# Deployment 목록 조회
curl -s --cacert $CA \
-H "Authorization: Bearer $TOKEN" \
"$APISERVER/apis/apps/v1/namespaces/sa-demo/deployments" | jq '.items[].metadata.name'
automountServiceAccountToken: false — 불필요한 토큰 제거
보안 감사에서 "모든 파드에 ServiceAccount 토큰이 마운트되어 있고, 그 중 일부는 K8s API를 전혀 사용하지 않음에도 불구하고 토큰이 노출되어 있습니다"라는 지적이 나왔습니다. 컨테이너 취약점을 통해 공격자가 파드에 접근하면 /var/run/secrets/kubernetes.io/serviceaccount/token을 바로 읽어 클러스터 API 호출에 악용할 수 있습니다. 사용하지 않는 토큰은 처음부터 마운트하지 않는 것이 가장 확실한 방어입니다.
K8s API를 호출하지 않는 파드에는 토큰을 마운트하지 않는 것이 보안 모범 사례입니다. 컨테이너가 탈취되어도 공격자가 클러스터 API를 악용할 수 없습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
namespace: sa-demo
spec:
replicas: 2
selector:
matchLabels:
app: web-frontend
template:
metadata:
labels:
app: web-frontend
spec:
# 토큰 마운트 비활성화 — K8s API 호출 없는 파드
automountServiceAccountToken: false
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
kubectl apply -f web-frontend.yaml
# 토큰 마운트 여부 확인
kubectl exec -it $(kubectl get pod -n sa-demo -l app=web-frontend -o name | head -1) \
-n sa-demo -- ls /var/run/secrets/ 2>&1
# ls: cannot access '/var/run/secrets/': No such file or directory
# ← 토큰이 없어서 디렉토리 자체가 존재하지 않음
IRSA — ServiceAccount로 AWS 권한 얻기
EKS에서 파드가 S3, DynamoDB, Secrets Manager 같은 AWS 리소스에 접근해야 할 때 IRSA를 사용합니다. 노드 전체에 IAM 역할을 붙이는 방식과 달리 ServiceAccount 단위로 최소 권한을 부여합니다.
# 1. EKS OIDC 제공자 확인
aws eks describe-cluster --name my-cluster \
--query "cluster.identity.oidc.issuer" --output text
# https://oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE1234
# 2. IAM OIDC 제공자 등록 (클러스터당 1회)
eksctl utils associate-iam-oidc-provider \
--cluster my-cluster \
--approve
# 3. IAM 역할 생성 (S3 읽기 권한)
eksctl create iamserviceaccount \
--cluster my-cluster \
--namespace sa-demo \
--name s3-reader \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \
--approve
# eksctl이 자동 생성하는 ServiceAccount
# (수동으로 생성하는 경우 아래와 같이 annotation 추가)
apiVersion: v1
kind: ServiceAccount
metadata:
name: s3-reader
namespace: sa-demo
annotations:
# IAM 역할 ARN 연결
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/sa-demo-s3-reader
# IRSA를 사용하는 파드
apiVersion: v1
kind: Pod
metadata:
name: s3-reader-pod
namespace: sa-demo
spec:
serviceAccountName: s3-reader
containers:
- name: aws-cli
image: amazon/aws-cli:latest
command: ["sleep", "3600"]
env:
# AWS SDK가 자동으로 토큰을 읽는 환경 변수 (EKS가 자동 주입)
- name: AWS_ROLE_ARN
valueFrom:
fieldRef:
fieldPath: metadata.annotations['eks.amazonaws.com/role-arn']
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
# 파드 안에서 S3 접근 테스트 (자격증명 없이 동작)
kubectl exec -it s3-reader-pod -n sa-demo -- \
aws s3 ls s3://my-config-bucket/
# 2024-01-15 09:30:00 1234 app-config.yaml
실습: 파드 내부에서 K8s API 직접 호출
ServiceAccount 토큰을 사용해 K8s API를 직접 호출하고, RBAC 권한이 어떻게 동작하는지 확인합니다.
# 1. 실습 환경 준비
kubectl create namespace api-test
# 권한이 없는 SA로 API 호출 시도
kubectl run no-perm-pod \
--image=curlimages/curl:latest \
--namespace=api-test \
--command -- sleep 3600
kubectl exec -it no-perm-pod -n api-test -- sh -c '
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"
CA="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
curl -s --cacert $CA -H "Authorization: Bearer $TOKEN" \
"$APISERVER/api/v1/namespaces/api-test/pods"
'
예상 결과 (권한 없을 때):
{
"kind": "Status",
"apiVersion": "v1",
"status": "Failure",
"message": "pods is forbidden: User \"system:serviceaccount:api-test:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"api-test\"",
"reason": "Forbidden",
"code": 403
}
# 2. Pod 읽기 권한 부여
cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: api-test
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: default-pod-reader
namespace: api-test
subjects:
- kind: ServiceAccount
name: default
namespace: api-test
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
EOF
# 3. 다시 API 호출 — 이제 성공
kubectl exec -it no-perm-pod -n api-test -- sh -c '
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"
CA="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
curl -s --cacert $CA -H "Authorization: Bearer $TOKEN" \
"$APISERVER/api/v1/namespaces/api-test/pods" | python3 -m json.tool | grep '"name"'
'
# "name": "no-perm-pod"
# 4. 권한 확인 명령어
kubectl auth can-i list pods \
--as=system:serviceaccount:api-test:default \
--namespace=api-test
# yes
kubectl auth can-i delete pods \
--as=system:serviceaccount:api-test:default \
--namespace=api-test
# no
K8s 1.21 이후 ServiceAccount 토큰은 기본적으로 시간 제한이 있는 Projected Token으로 발급됩니다(기본 1시간). 오래 실행되는 파드에서 초기화 이후 한참이 지나 토큰을 읽으면 만료된 토큰을 사용해 401 오류가 발생합니다.
# 증상: 파드가 장시간 실행 후 K8s API 호출 실패
kubectl logs api-caller-pod
# Error: API call failed: 401 Unauthorized
# time="2024-01-15T08:00:00Z" token_expired=true
# 원인 진단: 토큰 만료 시각 확인
kubectl exec -it api-caller-pod -- sh -c '
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# JWT 페이로드 디코딩 (base64)
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
'
# {
# "aud": ["https://kubernetes.default.svc"],
# "exp": 1705298400, ← Unix 타임스탬프 (이미 만료됨)
# "iat": 1705294800,
# "iss": "https://oidc.eks...",
# }
# 해결책 1: 토큰을 캐시하지 말고 매번 파일에서 새로 읽기
# 잘못된 방식 (시작 시 한 번만 읽음)
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# 올바른 방식 (매 요청마다 새로 읽음)
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# 해결책 2: K8s 공식 SDK 사용 — 토큰 갱신 자동 처리
# Python: kubernetes 라이브러리가 자동으로 토큰을 갱신합니다
pip install kubernetes
# from kubernetes import client, config
# config.load_incluster_config() # 클러스터 내부에서 자동 설정
# 해결책 3: 토큰 만료 시간 연장 (보안상 권장하지 않음)
# ServiceAccount에 TokenRequest API로 장기 토큰 발급
kubectl create token deployment-scaler \
--namespace=sa-demo \
--duration=24h
근본 원인: Projected Service Account Token(K8s 1.21+)은 kubelet이 자동으로 갱신하지만, 애플리케이션이 토큰을 메모리에 캐시하면 오래된 토큰을 계속 사용합니다. K8s 공식 클라이언트 라이브러리(Go k8s.io/client-go, Python kubernetes)는 이 갱신을 자동으로 처리하므로 가능하면 직접 curl 대신 공식 SDK를 사용하세요.
시나리오: 모니터링 에이전트를 파드로 배포하는 상황
SRE 팀 막내로서 "클러스터 내 모든 파드의 상태를 수집해 Slack에 보내는 모니터링 에이전트를 배포해줘"라는 요청을 받았습니다. 에이전트가 K8s API로 파드 목록을 조회하고 AWS Secrets Manager에서 Slack Webhook URL을 가져와야 합니다.
# 1. 필요 권한 목록 작성 (최소 권한 원칙)
# K8s: 클러스터 전체 파드 읽기 (ClusterRole 필요)
# AWS: Secrets Manager 특정 시크릿 읽기
# 2. ClusterRole 생성 (네임스페이스 경계 없이 파드 조회)
cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-monitor
rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list"]
EOF
# 3. ServiceAccount + IRSA 생성
eksctl create iamserviceaccount \
--cluster production \
--namespace monitoring \
--name pod-monitor-sa \
--attach-policy-arn arn:aws:iam::123456789012:policy/SlackSecretReadOnly \
--approve
# 4. ClusterRoleBinding
kubectl create clusterrolebinding pod-monitor-binding \
--clusterrole=pod-monitor \
--serviceaccount=monitoring:pod-monitor-sa
# 5. 배포
kubectl run pod-monitor \
--image=myrepo/pod-monitor:v1.2 \
--namespace=monitoring \
--serviceaccount=pod-monitor-sa
실무 포인트: ClusterRole은 필요한 경우에만 사용하고, 가능하면 네임스페이스 범위의 Role을 선호합니다. 모니터링 에이전트처럼 클러스터 전체를 봐야 하는 경우가 ClusterRole의 정당한 사용 사례입니다. IRSA를 사용하면 Access Key 없이도 AWS 리소스에 안전하게 접근할 수 있습니다.
핵심 요약
| 개념 | 명령/설정 | 실무 사용 빈도 |
|---|---|---|
| SA 조회 | kubectl get sa | 자주 |
| 전용 SA 생성 | kubectl create sa <name> | 서비스 배포 시 |
| 토큰 수동 발급 | kubectl create token <sa> | 디버깅 시 |
| 토큰 마운트 경로 | /var/run/secrets/kubernetes.io/serviceaccount/ | API 호출 시 |
| 토큰 비활성화 | automountServiceAccountToken: false | 일반 파드에 |
| IRSA 설정 | eks.amazonaws.com/role-arn annotation | EKS + AWS 연동 |
| 권한 확인 | kubectl auth can-i <verb> <resource> --as=system:serviceaccount:<ns>:<sa> | RBAC 디버깅 |