403은 3편, 401·"토큰 파일 없음"은 이번 편입니다
먼저 결론부터 말하면, 401 Unauthorized와 "토큰 파일이 없다"는 RBAC 문제가 아닙니다. 이 둘은 아예 다른 층위입니다.
- 401 Unauthorized / 토큰 없음 → 인증(authentication) 실패. "너 누구야?"에 대답을 못 한 상태. RBAC RoleBinding을 아무리 고쳐도 절대 안 고쳐집니다.
- 403 Forbidden → 인가(authorization) 실패. 신원은 확인됐는데 권한이 없는 상태. 이건 K8s RBAC Forbidden 403 진단 계열의 접근이 필요합니다.
또 하나 경계를 그어둡니다. 기존에 발행한 kubectl Unauthorized 원인별 3분 진단·복구 런북 (EKS 재발급)은 내 노트북의 kubeconfig 인증이 깨진 경우를 다룹니다. 이 글은 Pod 내부에서 돌아가는 워크로드가 API 서버를 호출할 때 쓰는 ServiceAccount 토큰이 주제입니다. 오퍼레이터, 사이드카, 인그레스 컨트롤러, in-cluster CI 러너처럼 컨테이너 안에서 rest.InClusterConfig()를 쓰는 코드가 대상입니다.
이 증상이 2022년 이후 급증한 배경은 명확합니다. Kubernetes 1.24에서 ServiceAccount에 대한 Secret 자동 생성이 폐지됐고, 그 이전부터 진행되던 BoundServiceAccountTokenVolume 전환으로 토큰이 만료되는 단기 자격증명으로 바뀌었기 때문입니다. 1.23 이하에서 잘 돌던 매니페스트를 1.24+ 클러스터에 그대로 올리면, 특히 secretName을 직접 참조하던 파이프라인이 조용히 깨집니다.
30초 판정표: 에러 원문 역인덱스
검색창에 친 문자열 그대로 아래 표에서 찾으세요. 각 행의 "점프" 열이 해당 처방 섹션입니다.
| 에러 원문 / 증상 | 1차 원인 갈래 | 점프 |
|---|---|---|
open /var/run/secrets/kubernetes.io/serviceaccount/token: no such file or directory | automount 비활성(SA 또는 Pod 레벨) | automount 우선순위 |
Unauthorized (본문 그대로 한 단어) | 토큰이 만료됐거나 앱이 옛 토큰을 캐싱 | 토큰 캐싱 |
the server has asked for the client to provide credentials | 토큰 자체가 요청에 안 붙음(경로 오인식·빈 파일) | automount 우선순위 |
token is expired / Token has expired | TokenRequest 기본 만료(1시간) + 재읽기 미구현 | 토큰 캐싱 |
serviceaccounts "xxx" not found | SA 미생성 또는 네임스페이스 불일치 | 진단 2줄 컷 |
SA를 만들었는데 kubectl get secret에 토큰 Secret이 안 보임 | 1.24+ 정책 변경(자동 생성 폐지) | 장기 토큰이 필요할 때 |
| 처방 다 했는데 여전히 401 | audience 불일치 / issuer 설정 / 노드 시계 오차 | 그래도 401일 때 |
버전별 동작 차이표와 진단 2줄 컷
이 경우 정답은 **"내 클러스터 마이너 버전부터 확인"**입니다. 같은 YAML이 버전에 따라 전혀 다르게 동작합니다.
| 버전 | 토큰 형태 | 만료 | SA Secret 자동 생성 | 갱신 주체 |
|---|---|---|---|---|
| ~1.20 | Secret 기반 레거시 JWT | 없음(무기한) | O (SA 생성 시 자동) | 없음(갱신 불필요) |
| 1.21~1.23 | projected 바운드 토큰이 기본 마운트 | 기본 1시간(요청 시 지정) | O (아직 생성됨) | kubelet이 자동 갱신 |
| 1.24~1.28 | projected 바운드 토큰만 | 기본 1시간 | X (폐지) — kubectl create token 사용 | kubelet |
| 1.29~1.31 | projected 바운드 토큰 + 사용 추적 | 기본 1시간 | X | kubelet, 레거시 토큰 정리 컨트롤러 동작 |
1.29 이후로는 레거시 SA 토큰이 일정 기간 사용되지 않으면 정리 대상이 되고, 마지막 사용 시각을 라벨/메트릭으로 추적할 수 있게 됐습니다. 즉 "예전에 수동으로 만들어 둔 토큰 Secret"에 의존하는 파이프라인은 시한폭탄입니다.
진단 명령 3종 (복붙용)
1) SA 레벨 automount 설정 확인
kubectl -n <ns> get sa <sa-name> -o jsonpath='{.metadata.name}{" automount="}{.automountServiceAccountToken}{"\n"}'예상 정상 결과: my-sa automount= (비어 있으면 기본값 true라 마운트됨).
automount=false가 찍히면 → automount 갈래로 이동.
2) Pod 안에 토큰 파일이 실제로 있는지 + 만료 시각 디코딩
kubectl -n <ns> exec <pod> -- ls -l /var/run/secrets/kubernetes.io/serviceaccount/
kubectl -n <ns> exec <pod> -- sh -c 'cat /var/run/secrets/kubernetes.io/serviceaccount/token' | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp, .iat, .aud, .sub'예상 정상 결과: ca.crt, namespace, token 3개 파일이 보이고, JSON에 exp(만료 epoch), aud(대상 audience, 보통 ["https://kubernetes.default.svc"]), sub(system:serviceaccount:<ns>:<sa>)가 출력됩니다.
분기:
No such file or directory→ automount 비활성. (a) 섹션.- 파일은 있는데
exp가 현재 시각보다 과거 → 앱이 갱신된 파일을 다시 읽지 않았을 가능성. (c) 섹션. aud가sts.amazonaws.com같은 외부 값만 있음 → API 서버용 audience가 아님. 실패 분기 섹션.base64: invalid input→ 토큰 파일이 비었거나 레거시 형식.
3) 토큰 발급 자체가 되는지 검증
kubectl -n <ns> create token <sa-name> --duration=30m정상이면 eyJ...로 시작하는 JWT가 출력됩니다. 여기서 serviceaccounts "xxx" not found가 나면 SA 자체가 없거나 네임스페이스를 잘못 본 것이고, 이건 RBAC이 아니라 오브젝트 부재 문제입니다.
원인별 처방
(a) automount 우선순위: Pod가 SA를 이깁니다
이 경우 정답은 **"Pod spec의 설정이 ServiceAccount 설정을 덮어쓴다"**를 기억하는 것입니다. SA에서 켜 뒀어도 Pod에서 껐으면 안 붙습니다.
SA automountServiceAccountToken | Pod spec automountServiceAccountToken | 결과 |
|---|---|---|
| 미지정(기본 true) | 미지정 | 마운트됨 ✅ |
false | 미지정 | 마운트 안 됨 ❌ |
false | true | 마운트됨 ✅ (Pod가 우선) |
미지정 / true | false | 마운트 안 됨 ❌ (Pod가 우선) |
두 값을 한 번에 보는 명령:
kubectl -n <ns> get pod <pod> -o jsonpath='{.spec.serviceAccountName}{" podAutomount="}{.spec.automountServiceAccountToken}{"\n"}'보안 하드닝 정책(PSS restricted 프로파일, Kyverno/OPA 룰)에서 default SA의 automount를 일괄로 끄는 경우가 실무에서 가장 자주 보고되는 원인입니다. 정책은 유지하되, API를 호출해야 하는 워크로드만 Pod 레벨에서 예외 처리하는 방식이 안전합니다.
spec:
serviceAccountName: my-operator-sa
automountServiceAccountToken: true # 정책으로 SA가 false여도 이 Pod만 예외(b) projected volume로 만료·audience 직접 제어
이 경우 정답은 **"automount에 맡기지 말고 projected volume을 명시"**입니다. 만료 시간과 audience를 코드가 기대하는 값으로 고정할 수 있습니다.
apiVersion: v1
kind: Pod
metadata:
name: api-caller
spec:
serviceAccountName: my-operator-sa
automountServiceAccountToken: false # 기본 마운트는 끄고
containers:
- name: app
image: my/app:1.0
volumeMounts:
- name: sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: sa-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 최소 600, 요청값은 클러스터 정책에 의해 조정될 수 있음
audience: https://kubernetes.default.svcaudience를 지정하면 그 토큰은 해당 대상에만 유효합니다. EKS IRSA나 GKE Workload Identity가 바로 이 메커니즘을 씁니다 — 클라우드 STS용 audience 토큰을 별도 경로에 주입하죠. 그래서 IRSA용 토큰 경로를 API 호출에 재사용하면 401이 납니다. 두 토큰은 목적이 다릅니다.
(c) 앱이 토큰을 한 번만 읽고 캐싱하는 함정
이 경우 정답은 **"파일을 주기적으로 다시 읽어라"**입니다. kubelet은 토큰 파일을 만료 전에 갱신해 주지만, 애플리케이션이 프로세스 시작 시 한 번 읽고 메모리에 들고 있으면 갱신본을 영원히 못 봅니다. 결과는 정확히 1시간 뒤 Unauthorized 또는 token is expired.
- client-go 최신 버전:
rest.InClusterConfig()경로로 만든 클라이언트는 토큰 파일 변경을 감지해 다시 읽습니다. 이 경우 보통 문제가 없습니다. - 직접
os.ReadFile로 읽어 헤더에 붙이는 코드 / curl 스크립트 / 자체 HTTP 클라이언트: 갱신을 못 봅니다. 여기가 대부분의 사고 지점입니다.
Go에서 매 요청마다 다시 읽는 최소 패턴:
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
type reloadingTransport struct{ base http.RoundTripper }
func (t *reloadingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
b, err := os.ReadFile(tokenPath) // 매 요청 재읽기 (tmpfs라 비용 낮음)
if err != nil {
return nil, fmt.Errorf("SA 토큰 읽기 실패: %w", err)
}
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(b)))
return t.base.RoundTrip(r)
}Bash/사이드카에서 호출한다면 변수에 담아두지 말고 매번 $(cat ...)로 읽는 것만으로 해결됩니다.
curl -sS --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
https://kubernetes.default.svc/api/v1/namespaces/default/pods파이썬(kubernetes 클라이언트)의 경우도 config.load_incluster_config()를 프로세스 시작 시 한 번만 호출하고 장시간 구동하는 데몬이라면 주기적 재호출 또는 재인증 로직이 필요합니다.
(d) CI·외부 시스템용 장기 토큰이 필요할 때
이 경우 정답은 **"수동 Secret 대신 kubectl create token 주기 갱신"**입니다.
# CI 파이프라인 스텝마다 짧게 발급 (권장)
TOKEN=$(kubectl -n ci create token ci-runner-sa --duration=1h)| 방식 | 만료 | 유출 시 회수 | 감사 추적 | 권장 |
|---|---|---|---|---|
kubectl create token --duration | 지정한 짧은 기간 | 만료로 자연 소멸 | 발급 이벤트 남음 | ✅ |
수동 kubernetes.io/service-account-token Secret | 사실상 무기한 | Secret 삭제로 즉시 회수 | 사용 추적 어려움 | ⚠️ 최후 수단 |
| 클라우드 워크로드 아이덴티티(IRSA/Pod Identity/GKE WI) | 단기 자동 갱신 | IAM 정책으로 통제 | 클라우드 감사로그 | ✅ (클라우드 한정) |
--duration은 API 서버의 --service-account-max-token-expiration 설정에 의해 상한이 걸릴 수 있습니다. 요청한 값보다 짧게 발급될 수 있으니 발급 후 exp를 디코딩해 실제 값을 확인하세요.
수동 Secret이 정말 불가피하다면(예: 토큰 갱신을 지원하지 않는 레거시 외부 시스템) 최소 권한 전용 SA를 별도로 만들고, 만료가 없다는 점을 리스크 등록부에 명시하고, 정기 로테이션 일정을 잡아두는 것이 최소한의 방어선입니다.
보안 관점: 끄기, 회수, 추적
끄기. default SA의 automount를 끄는 것은 좋은 기본값입니다. API를 호출하지 않는 대다수 워크로드에 자격증명을 뿌리지 않게 되니까요.
kubectl -n <ns> patch serviceaccount default -p '{"automountServiceAccountToken": false}'파급 효과: 이 네임스페이스에서 default SA를 쓰면서 API를 호출하던 워크로드가 즉시 깨집니다. 적용 전에 해당 네임스페이스의 Pod들이 어떤 SA를 쓰는지 훑어보세요.
kubectl -n <ns> get pods -o custom-columns='POD:.metadata.name,SA:.spec.serviceAccountName'회수. 여기가 중요합니다 — 바운드 토큰은 개별 무효화가 안 됩니다.
| 토큰 유형 | 유출 시 회수 방법 |
|---|---|
| projected 바운드 토큰 | 개별 취소 불가. 만료(기본 1시간) 대기, 또는 SA 삭제·재생성으로 강제 무효화(해당 SA의 모든 토큰이 무효). 단 이 SA를 쓰는 Pod 전부 재시작 필요 |
| 레거시 Secret 토큰 | 해당 Secret 삭제 → 즉시 무효 |
| 노드에 바인딩된 토큰 | 해당 Pod/노드 삭제 시 자동 무효화(바인딩 대상 소멸) |
바운드 토큰은 .spec.boundObjectRef로 Pod에 묶이기 때문에, Pod가 사라지면 토큰도 유효성을 잃습니다. 사고 대응 시 "Pod 삭제"가 곧 부분적 회수 조치가 되는 셈입니다.
추적. 감사로그(audit log)에서 특정 SA의 사용 이력을 뽑는 쿼리 패턴:
# 감사로그 JSON 라인에서 특정 SA의 호출만 추출
jq -c 'select(.user.username == "system:serviceaccount:prod:my-operator-sa")
| {ts: .requestReceivedTimestamp, verb, uri: .requestURI, ip: .sourceIPs[0], code: .responseStatus.code}' \
audit.log | head -50responseStatus.code가 401이면 인증 실패, 403이면 인가 실패 — 로그 한 줄에서 이번 글의 주제인지 3편의 주제인지 바로 갈립니다. 예상치 못한 sourceIPs가 보이면 토큰 유출을 의심할 근거가 됩니다.
그래도 401일 때: 실패 분기 체크리스트
위 처방을 다 했는데도 401이면 순서대로 확인하세요.
- audience 불일치. 토큰의
aud가 API 서버가 허용하는 값인지 확인합니다.kubectl create token <sa> | cut -d. -f2 | base64 -d | jq .aud로 기본 audience를 확인하고, Pod의 토큰과 비교하세요. IRSA/Workload Identity를 쓰는 클러스터에서 클라우드용 토큰을 API 호출에 잘못 쓰는 사례가 흔합니다. - API 서버 설정.
--service-account-issuer,--api-audiences,--service-account-key-file값이 발급/검증 양쪽에서 일관적인지 봅니다. 관리형 클러스터(EKS/GKE/AKS)는 직접 수정 불가이므로, 이 단계에서 이상하면 클라우드 공식 문서 확인이 필요합니다. - 노드 시계 오차.
iat/nbf가 미래로 잡히면 API 서버가 거부합니다.kubectl exec <pod> -- date -u와 컨트롤 플레인 시각을 비교하고, 노드의 NTP(chronyd/systemd-timesyncd) 동기화 상태를 확인하세요. 수 분 단위 오차만으로도 재현됩니다. - 인증 웹훅/프록시. 사내 인증 프록시나 서비스 메시가
Authorization헤더를 덮어쓰거나 제거하는지 확인합니다. mTLS 사이드카가 헤더를 재작성하는 구성에서 종종 발생합니다. - 토큰 파일 개행/공백. 셸로 조립한 헤더에서 개행이 섞이면 헤더가 깨집니다.
tr -d '\n'또는TrimSpace를 반드시 넣으세요.
재발 방지
- 배포 템플릿에
automountServiceAccountToken을 명시적으로 적어 기본값 변화에 흔들리지 않게 합니다. - API를 호출하는 앱은 "토큰 재읽기" 여부를 코드리뷰 체크리스트에 넣습니다.
- 스테이징에서
expirationSeconds: 600같은 짧은 만료로 돌려 만료 처리 버그를 조기에 노출시킵니다. - 클러스터 업그레이드 전, 수동 SA 토큰 Secret과
secretName참조를 전수 조사합니다.
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token여기에 결과가 나온다면 1.24+ 환경에서 레거시 방식에 의존 중이라는 뜻입니다. 마이그레이션 대상 목록으로 삼으세요.
자주 묻는 질문 (FAQ)
Q1. 1.24로 올렸더니 SA를 만들어도 Secret이 안 생깁니다. 버그인가요?
버그가 아니라 의도된 변경입니다. 1.24부터 ServiceAccount 생성 시 토큰 Secret을 자동 생성하지 않습니다. 토큰이 필요하면 kubectl create token <sa>로 단기 토큰을 발급하거나, Pod에서는 projected volume(기본 automount)을 사용하세요.
Q2. 401과 403 중 어느 쪽인지 로그만 보고 구분하는 방법은?
HTTP 상태 코드로 갈립니다. 401은 "누군지 모르겠다"(토큰 없음·만료·서명 검증 실패), 403은 "누군지는 알겠는데 권한이 없다"입니다. 403 메시지에는 보통 User "system:serviceaccount:ns:sa" cannot list resource ...처럼 신원이 찍혀 있습니다. 신원이 찍혔으면 인증은 성공한 것이므로 RBAC을 보면 됩니다.
Q3. 토큰이 1시간마다 만료되는데 앱을 매시간 재시작해야 하나요? 아닙니다. kubelet이 만료 전에 토큰 파일을 갱신합니다. 앱이 파일을 다시 읽기만 하면 됩니다. 재시작이 필요한 상황이라면 그건 캐싱 버그이므로, 위의 재읽기 패턴을 적용하는 것이 정답입니다.
Q4. expirationSeconds를 아주 길게(예: 1년) 설정할 수 있나요?
요청은 할 수 있지만 API 서버 정책 상한에 의해 잘릴 수 있고, 보안상 권장되지 않습니다. 단기 자격증명이 제로트러스트 모델의 기본 전제이기 때문입니다. 장기 자격증명이 정말 필요한 외부 연동이라면 토큰 대신 클라우드 워크로드 아이덴티티나 주기적 재발급 파이프라인을 쓰는 편이 낫습니다.
Q5. 토큰이 유출됐습니다. 즉시 무효화할 방법이 있나요? 바운드 토큰은 개별 취소 API가 없습니다. 현실적인 선택지는 (1) 해당 SA를 삭제 후 재생성해 기존 토큰 전부를 무효화하고 관련 Pod를 재시작하거나, (2) 토큰이 Pod에 바인딩돼 있다면 그 Pod를 삭제하거나, (3) 만료를 기다리는 것입니다. 레거시 Secret 토큰이라면 Secret을 지우면 즉시 회수됩니다. 병행해서 감사로그로 유출 기간 동안의 호출 이력을 반드시 확인하세요.
Q6. default SA의 automount를 끄면 어떤 게 깨지나요?
그 네임스페이스에서 default SA를 쓰면서 API 서버를 호출하던 모든 Pod가 토큰 파일을 잃습니다. 대표적으로 일부 모니터링 에이전트, 서비스 디스커버리를 하는 앱, 자체 리더 선출 로직을 가진 앱이 영향을 받습니다. 적용 전에 네임스페이스의 Pod-SA 매핑을 조회하고, 필요한 Pod에는 Pod 레벨 automountServiceAccountToken: true로 예외를 주세요.
Q7. EKS에서 IRSA를 쓰는데 API 서버 호출이 401입니다.
IRSA가 주입하는 토큰(AWS_WEB_IDENTITY_TOKEN_FILE)은 audience가 sts.amazonaws.com으로, AWS STS 전용입니다. Kubernetes API 호출에는 /var/run/secrets/kubernetes.io/serviceaccount/token을 써야 합니다. 두 경로를 혼동하지 않았는지 먼저 확인하세요.
다음 5편에서는 네트워크 레벨 차단 — NetworkPolicy와 mTLS 실패 진단을 다룹니다. 인증도 인가도 통과했는데 연결 자체가 막히는 경우, connection refused와 i/o timeout을 어떻게 갈라내고 정책 규칙을 역추적할지 같은 방식의 판정표로 정리하겠습니다.
AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.