ServiceAccount projected token 만료로 새벽 호출 — 1년 짜리 토큰을 캐싱한 SDK 이야기
지난주 화요일 새벽 4시쯤 전화가 울렸다. 메시지 큐 컨슈머 한 대가 S3 PutObject에서 ExpiredTokenException 을 계속 뱉고 있다고. 멘탈이 살짝 나갔다. IRSA로 깔끔하게 인증 붙여놨다고 믿고 있던 워크로드였는데.
결론부터 말하면 ServiceAccount projected token이 만료된 뒤 kubelet이 새 토큰을 디스크에 갈아 끼웠는데, 우리 컨슈머 안에 들어있던 SDK는 이걸 다시 읽지 않고 죽은 토큰을 1년 가까이 메모리에 들고 있었다. 정확히는 캐시가 너무 충실했던 게 문제였다.
우리가 잘못 알고 있던 부분
Kubernetes 1.22 이후로 BoundServiceAccountTokenVolume 이 GA되면서 Pod이 마운트하는 토큰은 모두 projected 형태로 바뀌었다. spec에 명시적으로 안 적어도 admission controller가 kube-api-access-XXXX 라는 이름으로 자동 주입한다. 이 토큰은 기본 만료가 1시간이고 kubelet이 80% 시점쯤 갱신해서 같은 파일 경로(/var/run/secrets/kubernetes.io/serviceaccount/token)에 덮어쓴다.
여기까지는 다들 아는 얘기다. 우리가 놓친 건 IRSA 경로였다.
IRSA를 쓰면 eks.amazonaws.com/role-arn 어노테이션을 단 ServiceAccount에 대해 별도의 projected token이 추가로 마운트된다. /var/run/secrets/eks.amazonaws.com/serviceaccount/token 경로에. 이 토큰은 STS AssumeRoleWithWebIdentity 의 입력으로 들어가서 임시 AWS 자격증명으로 교환된다. 그리고 이 토큰의 audience는 sts.amazonaws.com 이고, 만료 시간은 무려 86400초 — 24시간 기본이다. 우리 클러스터에서는 그것도 모자라서 누군가 serviceAccountToken.expirationSeconds: 31536000 (1년)을 박아뒀더라. 옛날에 누가 "재발급 자주 되면 STS 호출 늘어나는 거 아니냐"는 두려움 때문에 넣은 거였다. (커밋 로그 확인했다. 2년 전 본인. 할 말 없음.)
그래서 결국 STS 토큰은 1년짜리, AWS credential은 1시간짜리. 컨슈머가 자격증명 갱신할 때마다 같은 STS 토큰으로 AssumeRoleWithWebIdentity 를 호출해서 새 credential을 받는 구조다. 이게 잘 돌아간다. 1년 동안은.
무엇이 실제로 깨졌나
문제의 컨슈머는 사내 라이브러리에 박힌 오래된 AWS SDK for Java v1을 쓰고 있었다. 정확히는 aws-java-sdk-core 1.11.x 대. 이 SDK의 WebIdentityTokenCredentialsProvider 는 초기화 시점에 토큰 파일을 한 번 읽어서 메모리에 들고, 이후 credential refresh 시점에 다시 디스크를 안 본다.
좀 더 정확히는, 디스크를 보는 시점이 "credential이 expire되어 새로 발급받을 때"인데, 그때도 file path를 다시 읽는 게 아니라 처음에 읽어서 캐싱해둔 토큰 문자열을 그대로 다시 STS에 보낸다. 새 버전(v1.12.x 후반부 + v2 SDK)은 매번 파일 핸들을 열고 다시 읽도록 패치됐는데, 우리가 쓰던 건 그 전이었다.
흐름을 풀어보면 이렇다.
[t=0] Pod 시작. kubelet이 STS audience 토큰을 파일에 씀.
SDK는 이 토큰을 읽어서 메모리에 캐싱.
[t=1h] AWS credential 만료. SDK가 새 credential 요청.
메모리의 STS 토큰 → STS:AssumeRoleWithWebIdentity → OK
[t=24h] kubelet이 STS 토큰 갱신해서 파일에 덮어씀.
SDK는 모름. 계속 t=0 시점 토큰을 들고 있음.
[t=원래토큰만료시점] STS가 "ExpiredToken" 응답.
→ AWS credential 갱신 실패
→ 다음 S3 호출에서 401/403
이상한 부분은 우리는 1년짜리로 박아놨다는 거다. 1년 동안 잘 돌아가다가 갑자기 터졌어야 한다는 얘기다. 그런데 실제로는 배포된 지 한 달도 안 된 컨슈머였다. 왜?
원인은 OIDC issuer URL 회전이었다. 같은 주에 보안팀에서 EKS cluster의 OIDC thumbprint 정책 변경 작업을 하면서, IAM identity provider 측에서 일부 audience에 대한 토큰 검증을 더 엄격하게 바꿨다. 그 결과 "기술적으로는 안 만료된 토큰"이지만 서명 검증 룰에 살짝 안 맞는 케이스가 생겼고, STS가 그걸 ExpiredToken 으로 응답해버렸다. 이게 트리거. SDK 입장에서는 파일을 다시 읽었으면 됐는데, 안 읽어서 빠져나갈 길이 없었다.
새벽에 한 짓들
처음엔 IAM 정책 문제로 의심해서 CloudTrail 부터 까봤다. AssumeRoleWithWebIdentity 요청 자체는 들어오고 있었고, 에러 코드가 ExpiredTokenException. 토큰이 만료됐다는 거잖아. 1년짜리인데?
그래서 Pod에 exec 들어가서 파일을 직접 까봤다.
cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token | cut -d. -f2 | base64 -d
JWT payload를 보니 iat 와 exp 가 멀쩡했다. 만료까지 한참 남았다. 어라.
그제야 "SDK가 들고 있는 토큰이랑 디스크의 토큰이 다른 거 아니냐"는 의심이 들었다. JVM heap dump 떠서 WebIdentityTokenCredentialsProvider 인스턴스를 까보니… 다른 토큰이었다. 디스크엔 최신, 메모리엔 오래된 거. 한참 전에 한 번 읽고 묵혀둔 그 토큰.
당장의 응급조치는 Pod 재시작. kubectl rollout restart deployment/foo-consumer. 새 토큰 읽고 정상화. 30분 안에 알람 다 가라앉았다.
그 다음 한 일
근본 원인은 두 갈래다. 하나는 SDK 버전, 하나는 토큰 만료 정책. 둘 다 손봤다.
SDK 버전 강제: SBOM 스캔 결과를 보고 사내에 깔린 aws-java-sdk v1 1.11.x를 쓰는 서비스가 9개 있었다. Renovate로 그룹 PR 만들어서 1.12.781 이상 또는 v2 SDK 로 옮기는 작업을 시작했다. v2가 깔끔하긴 한데, 마이그레이션 코스트가 있어서 일단 v1 마지막 라인으로 올리는 게 우선. 그리고 OPA로 "v1 1.11.x 이미지는 deploy 못 한다" 룰을 추가했다. dependency-track 에서 라이브러리 버전 뽑아서 admission policy 에 넣는다.
토큰 만료 단축: 1년이라는 숫자가 자체로 안티 패턴이었다. STS audience 토큰은 짧게 가져가는 게 맞다. AWS 권장이 86400초(24시간)인데, 우리는 그것도 줄여서 6시간(21600)으로 박았다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: foo-consumer
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/foo-consumer
eks.amazonaws.com/token-expiration: "21600"
이렇게 하면 STS 토큰이 6시간마다 회전된다. SDK가 어쩌다 캐시를 길게 들고 있어도, 최대 6시간 내에는 자체적으로 토큰 갱신이 일어나도록 강제하는 효과가 있다. 완전한 해결은 아니다. 토큰을 안 다시 읽는 SDK는 6시간 후에도 똑같이 죽는다. 다만 깨지는 시점이 1년 후가 아니라 첫째 날에 깨지므로, 카나리 단계에서 잡힐 가능성이 훨씬 높다. 본질적으로는 "문제가 오래 잠복하지 못하게 막는" 방어막이다.
모니터링 추가: Prometheus에서 IRSA를 쓰는 워크로드 대상으로 aws_sdk_credential_refresh_failures_total 류 메트릭을 수집하기 시작했다. SDK 마다 노출하는 메트릭이 달라서 통일하는 게 좀 귀찮긴 했다. 일부 서비스는 RequestAwsCredentialsExpiredCount 같은 자체 이름을 쓰고, 일부는 X-Ray에서 잡아야 했다. 아직 이 부분은 정리 중이다.
검증되지 않은 가설
다시 처음으로 돌아가서 — 정말 OIDC issuer 변경이 트리거였을까? 이건 100% 확신하기 어렵다. 보안팀과 같이 본 결론은 "시간적으로 일치하고, 다른 ExpiredToken 이슈가 일제히 생긴 시점이 동일하다"는 정도다. 진짜로 검증하려면 issuer 변경 시점을 정확히 알고 token replay 테스트를 돌려봐야 한다. 그건 안 했다. 어차피 SDK 갈고 나면 같은 문제가 안 생기니까.
마무리
길게 썼는데 결국 두 줄 요약하면 이렇다. SDK 버전을 정기적으로 올려라. 그리고 1년짜리 토큰은 만들지 마라, 만들어도 된다고 적혀 있어도. 1년이라는 숫자는 "이 코드는 1년 동안 누구도 안 깰 것이다"는 가정과 같은데, 그 가정은 거의 항상 깨진다.
혹시 비슷한 케이스 만나신 분 있으면 댓글 남겨주세요. 특히 IRSA 말고 EKS Pod Identity 쪽으로 옮기신 분들 후기가 궁금합니다. 우리도 6개월쯤 전에 일부 이전했는데, Pod Identity 쪽은 agent가 토큰 관리를 따로 해줘서 이런 문제가 구조적으로 안 생긴다고 들었다. 다음에 다뤄볼까 한다.