DevOps Kubernetes Teleport EKS DevOps

EKS 키 회전이 로그인 장애가 되기까지

EKS ServiceAccount 서명키 회전이 로그인 장애로 증폭된 사건을 readiness 격리와 Kubernetes OIDC join 전환으로 제거한 기록.

장애는 작은 인증 키 회전에서 시작됐습니다. EKS의 ServiceAccount 서명키가 바뀌었을 뿐이고, 일반적으로는 플랫폼 내부의 정상적인 키 관리 이벤트입니다. 하지만 이번에는 그 회전이 Teleport bot의 join 실패로 이어졌고, tbot 사이드카가 CrashLoopBackOff에 빠지면서 같은 Pod에 있던 backend까지 NotReady가 됐습니다. Service endpoint에서 backend가 빠지자 로그인 트래픽까지 막혔습니다.

표면적으로는 Teleport와 static_jwks 문제였습니다. 하지만 조금 넓게 보면 다른 문제였습니다. 보조 기능을 담당하던 컴포넌트가 핵심 로그인 경로의 readiness를 끌어내릴 수 있었고, 사람이 직접 들고 있던 공개키 스냅샷이 플랫폼 키 회전에 취약했습니다.

처음에는 static_jwks를 더 잘 관리하는 문제로 보였습니다. EKS OIDC 공개키가 바뀌면 Teleport 토큰의 JWKS를 자동으로 갱신하고, MR을 만들고, Slack으로 알리면 된다고 생각했습니다.

그 자동화는 실제로 효과가 있었습니다. 6일 뒤 다시 발생한 EKS 키 회전을 사람이 보기 전에 감지했고, 장애로 번지기 전에 처리했습니다. 다만 끝까지 가보니 더 나은 답은 자동화를 고도화하는 것이 아니라 static_jwks를 안 쓰는 것이었습니다.

이 글은 Teleport 운영 팁으로 읽어도 되지만, 더 일반적인 Kubernetes 장애 설계 회고로 읽는 편이 낫습니다. 가져갈 것은 하나입니다.

장애 대응 중에 “이 상태를 어떻게 자동화할까?”라는 질문까지 왔다면, 한 번 더 물어봐야 합니다. “이 상태를 아예 안 가져도 되는가?”

이번 케이스에서 static_jwks는 자동화할 대상이 아니라 제거할 상태였습니다. 단, 제거하기 전에 readiness 격리라는 안전망을 먼저 깔아둔 순서가 중요했습니다. 보조 컴포넌트가 실패해도 핵심 요청 경로가 같이 내려가지 않는 상태를 먼저 만들고, 그 위에서 인증 방식을 바꿨습니다.


배경: tbot, Kubernetes join, JWKS

먼저 용어부터 정리해야 합니다. 여기서 tbot은 Teleport Machine ID가 실행하는 봇 프로세스입니다. 애플리케이션이 직접 접근하기 어려운 외부 DB나 내부 리소스에 접근할 수 있도록 Teleport에 join하고, 필요한 identity를 받아 터널을 열어주는 역할을 합니다. 이 환경에서는 backend가 n8n DB를 조회할 때 tbot이 만든 database tunnel을 사용했습니다.

Teleport 입장에서 중요한 질문은 “이 tbot이 정말 허용된 Kubernetes ServiceAccount에서 실행 중인가?”입니다. Kubernetes join 방식에서는 tbot이 자신이 속한 Pod의 ServiceAccount token을 Teleport auth에 제출하고, Teleport는 그 토큰의 서명을 검증한 뒤 허용된 namespace/serviceAccount인지 확인합니다.

이 구조의 핵심은 tbot이 자기 신원을 말로 주장하지 않고 증명한다는 데 있습니다. ServiceAccount token은 Kubernetes 컨트롤플레인이 자신의 개인키로 서명한 토큰입니다. 그 서명은 개인키를 가진 컨트롤플레인만 만들 수 있으므로, 서명이 유효하다는 것은 곧 컨트롤플레인이 이 신원을 보증했다는 뜻입니다. Teleport가 검증하는 대상이 바로 이 보증입니다.

그렇다면 토큰에 서명한 컨트롤플레인은 누구인가? EKS 클러스터에서는 그 컨트롤플레인을 AWS가 운영합니다. 따라서 ServiceAccount token에 서명하는 주체가 곧 EKS이고, 그 서명을 검증하려면 EKS의 공개키가 있어야 합니다. tbot과 Teleport 사이의 연결에 EKS가 끼어드는 이유는 AWS에 따로 인증하기 때문이 아니라, tbot의 신원을 보증한 발급기관이 EKS이기 때문입니다. 이 흐름에는 IAM도 STS도 액세스 키도 등장하지 않습니다. Teleport는 EKS가 서명한 토큰의 서명만 검증합니다.

그 검증에 쓰는 EKS의 공개키 묶음이 JWKS입니다. ServiceAccount token은 JWT이고, JWT 서명 검증에 필요한 공개키 목록을 JWKS(JSON Web Key Set)라고 부릅니다. EKS는 이 JWKS를 자체 OIDC issuer로 공개하므로, Teleport는 그 공개키로 이 토큰이 진짜 EKS가 발급한 것인지 판단합니다.

비유하면 ServiceAccount token은 서명된 출입증이고, JWKS는 그 출입증의 서명을 확인하는 공개키 묶음입니다. 출입증 자체가 맞는 형식이어도, 검증자가 최신 공개키를 모르면 “이 서명이 진짜인지” 판단할 수 없습니다.

static_jwks는 그 공개키 묶음을 Teleport token 설정 안에 직접 복사해 붙여넣는 방식입니다. 이름 그대로 “정적인 JWKS 스냅샷”입니다. 설정 당시에는 문제가 없지만, EKS가 ServiceAccount 서명키를 회전하면 새 키로 서명된 토큰이 나오고, Teleport token 안의 static_jwks가 그 새 키를 모르면 검증이 실패합니다.

정리하면 구조는 이렇습니다.

개념역할이번 장애에서의 의미
tbotTeleport에 join해서 identity와 tunnel을 제공하는 봇join 실패 시 DB 터널이 죽음
ServiceAccount tokentbot이 Kubernetes 안에서 실행 중임을 증명하는 JWTEKS 서명키로 서명됨
JWKSJWT 서명을 검증하는 공개키 목록검증자가 최신 키를 알아야 함
static_jwksJWKS를 Teleport token에 정적으로 저장하는 방식EKS 키 회전 시 stale해질 수 있음
OIDC issuer현재 JWKS 위치를 알려주는 신뢰 기준 URL동적으로 키를 가져올 수 있는 대안

이 배경을 알고 보면 장애의 핵심은 단순합니다. tbot이 틀린 토큰을 낸 것이 아니라, Teleport가 최신 공개키를 모르고 있었던 것입니다.


장애 사슬

문제의 흐름은 이렇게 이어졌습니다.

단계무슨 일이 일어났나왜 커졌나
EKS SA 서명키 회전kid로 ServiceAccount token이 서명됨정상적인 플랫폼 이벤트
Teleport static_jwks staleTeleport token이 새 공개키를 모름bot join 검증 실패
tbot CrashLoopBackOfftbot이 새 identity를 받지 못함터널 기능 중단
backend Pod NotReadytbot이 backend와 같은 Pod에 있음Pod readiness가 같이 내려감
Service endpoint 제외NotReady Pod는 Service에서 빠짐로그인 트래픽까지 차단

장애의 시작점은 EKS 키 회전이었지만, 로그인이 죽은 직접 이유는 애플리케이션 코드가 아니었습니다. backend의 로그인, health, startup 경로는 tbot 터널을 쓰지 않았습니다. 앱 DB는 별도로 접근했고, n8n 분석 기능만 tbot 터널에 의존했습니다.

진짜 증폭 지점은 Kubernetes readiness였습니다. Pod Ready는 단일 컨테이너 상태가 아니라 Pod 안 컨테이너 상태의 조합입니다. tbot이 backend와 같은 Pod의 일반 컨테이너로 들어있으면, tbot이 죽는 순간 backend 프로세스가 살아있어도 Pod 전체가 NotReady가 됩니다. Service는 NotReady Pod를 endpoint에서 제외하므로, “분석 기능용 터널”의 장애가 “로그인 전체 차단”으로 커졌습니다.

이 사건에서는 그 보조 컴포넌트가 tbot이었습니다. 다른 시스템에서는 로그 수집기, 프록시, secret refresher, metrics exporter일 수 있습니다. 이름보다 중요한 질문은 이것입니다. 그 컴포넌트가 실패했을 때, 정말로 핵심 요청 경로의 readiness까지 내려가야 하는가?

이 지점에서 문제는 두 개로 분리됩니다.

지점문제해결 방향
원인static_jwks가 EKS 키 회전에 취약함키 회전에 깨지지 않는 join 방식으로 변경
증폭tbot 장애가 backend readiness를 끌어내림tbot과 backend의 장애 경계를 분리

둘 중 하나만 고치면 부족했습니다. OIDC join으로 바꿔도 tbot은 다른 이유로 죽을 수 있습니다. 반대로 readiness만 분리하면 로그인은 보호되지만, 키 회전 때마다 tbot은 계속 깨집니다. 그래서 양쪽을 모두 끊었습니다.


먼저 증폭을 끊었다

첫 번째 조치는 tbot을 backend Pod 밖으로 분리하는 것이었습니다.

tbot을 backend Pod에서 분리해 readiness 결합을 끊은 전후 구조

tbot은 backend가 n8n DB로 접근하기 위한 터널을 열어주는 역할이었습니다. 하지만 같은 Pod에 묶인 순간, 터널의 생명주기가 로그인 서버의 생명주기와 결합됐습니다. 기존 구조에서는 한 Pod 안에 backendtbot이 같이 있었고, tbot이 CrashLoopBackOff가 되면 backend 프로세스가 정상이어도 Pod 전체가 NotReady가 됐습니다.

변경 후에는 backend와 tbot을 별도 Pod로 분리했습니다.

컴포넌트변경 전변경 후
backend Podbackend + tbot 동거backend 단독
tbotbackend 사이드카별도 tbot Deployment
DB 터널 접근localhosttbot-db-tunnel Service
readiness 영향tbot 실패가 backend Pod를 NotReady로 만듦tbot 실패가 backend readiness에 참여하지 않음

backend Deployment에서는 tbot 컨테이너, tbot volume, tbot ServiceAccount 결합을 제거했습니다. tbot은 별도 Deployment가 됐고, backend는 tbot-db-tunnel Service를 통해 필요한 DB 터널에 접근합니다. NetworkPolicy로 backend Pod만 tbot Service의 5432 포트에 접근하도록 제한했습니다.

이렇게 하면 tbot이 CrashLoopBackOff가 되어도 backend Pod의 readiness에는 참여하지 않습니다. 로그인과 health는 계속 살아있고, 영향 범위는 tbot 터널을 쓰는 n8n 분석 기능으로 제한됩니다. 여기서 중요한 점은 “tbot을 고가용화했다”가 아니라, tbot이 실패해도 로그인 장애로 증폭되지 않게 만들었다는 것입니다.

운영 장애에서 이 차이는 큽니다. 모든 것을 즉시 복구해야 하는 전면 장애와, 일부 기능 degraded 상태에서 천천히 처리할 수 있는 장애는 대응 난이도가 완전히 다릅니다.

이 판단은 Teleport에만 적용되는 이야기가 아닙니다. sidecar나 agent를 붙일 때마다 “같은 Pod에 있어야 편한가?”와 “같은 Pod의 readiness 운명을 공유해야 하는가?”를 분리해서 봐야 합니다. localhost 접근이 편하다는 이유로 묶은 컴포넌트가, 실제 장애에서는 핵심 서비스의 생존 조건이 되어버릴 수 있습니다.

이 단계에서 장애는 이렇게 바뀝니다.

변경 전변경 후
tbot join 실패 → backend Pod NotReady → 로그인 차단tbot join 실패 → n8n 분석 기능 degraded → 로그인 유지
모든 요청이 막히는 전면 장애특정 기능 장애
즉시 복구 압박원인 분석과 롤백을 차분히 수행 가능

그다음 static_jwks를 관리하려 했다

처음 대응 방향은 static_jwks를 없애는 것이 아니라 최신 상태로 유지하는 것이었습니다. 앞의 비유를 이어가면, 검증 서버가 “현재 유효한 서명 확인용 열쇠 꾸러미”를 설정 파일 안에 직접 들고 있는 방식입니다. Kubernetes issuer의 공개키 스냅샷을 Teleport token에 넣는 방식 자체는 명확하고 단순합니다. 개념적으로는 이런 구조입니다.

spec:
join_method: kubernetes
kubernetes:
type: static_jwks
static_jwks:
# JWKS를 YAML로 중첩하는 게 아니라, JWKS JSON 문자열을 통째로 박아둔다
jwks: |-
{"keys":[{"kty":"RSA","kid":"old-key-id","alg":"RS256","n":"0AwMG...","e":"AQAB"}]}

문제는 “스냅샷”이라는 점입니다. EKS ServiceAccount token을 서명하는 키가 바뀌면 새 kid가 생깁니다. Teleport token의 static_jwks가 그 새 키를 모르면, tbot이 제출한 ServiceAccount token은 정상 토큰이어도 검증에 실패합니다.

그래서 중간 단계로 JWKS drift 자동화를 만들었습니다. 이 자동화의 목적은 최종 구조를 아름답게 만드는 것이 아니라, 근본 해결까지 가는 동안 같은 유형의 회전을 장애 전에 잡는 것이었습니다.

입력처리결과
EKS OIDC 현재 키 목록Git의 static_jwks 키 목록과 비교Git에 없는 새 kid 탐지
drift 탐지 결과스크립트가 JWKS를 갱신자동 MR 생성
MR/스케줄 결과Slack 알림사람이 머지 후 tctl apply

핵심은 EKS의 현재 키 목록과 Git에 저장된 static_jwks 키 목록을 비교하는 것이었습니다. EKS에는 있는데 Git에는 없는 kid가 있으면, 다음 재인증 때 tbot이 깨질 수 있습니다.

처음에는 이 자동화가 “과한가?” 싶을 수도 있습니다. 하지만 실제로 6일 뒤 EKS 키가 다시 돌았습니다. 자동화가 그 회전을 감지했고, MR을 만들었고, 장애로 번지기 전에 처리할 수 있었습니다. 6일이라는 숫자가 중요한 이유는 “언젠가 다시 돌 수 있다”가 아니라, 핫픽스 직후의 짧은 기간 안에 같은 유형의 회전이 실제로 다시 왔다는 뜻이기 때문입니다. 이 자동화가 없었다면 다음 tbot renewal 시점에 다시 join 실패를 만났을 수 있습니다.

이 단계는 실패한 작업이 아닙니다. 오히려 정확히 역할을 했습니다. 사람이 놓칠 수 있는 키 회전을 잡았고, static_jwks 방식의 위험이 이론이 아니라 실제 운영 위험이라는 점도 증명했습니다. 다만 잘 관리하는 답은 찾았어도, 더 단순한 답을 놓칠 뻔했습니다. 왜 우리가 JWKS를 직접 관리해야 하지?

자동화와 설계 변경은 경쟁 관계가 아니었습니다. 자동화는 시간을 벌었고, 설계 변경은 관리할 상태를 없앴습니다. 둘의 역할이 달랐습니다.


답은 OIDC join이었다

Teleport Kubernetes join에는 oidc subtype이 있었습니다.

static_jwks 방식이 공개키 스냅샷을 token에 박아두는 방식이라면, oidc 방식은 issuer URL을 선언합니다. 검증자가 열쇠 꾸러미를 직접 들고 있는 대신, “항상 최신 열쇠를 받을 수 있는 공식 주소”를 신뢰하는 구조입니다. Teleport auth는 join 시점에 issuer의 discovery document를 읽고, jwks_uri에서 현재 키를 가져와 검증합니다. 개념적으로는 이렇게 바뀝니다.

spec:
join_method: kubernetes
kubernetes:
type: oidc
oidc:
issuer: https://oidc.eks.<region>.amazonaws.com/id/<issuer-id>

YAML 차이는 작아 보이지만 운영 의미는 완전히 다릅니다.

방식Teleport가 신뢰하는 것키 회전 때 필요한 일
static_jwkstoken에 저장된 공개키 스냅샷새 키를 Git에 반영하고 live apply
oidcissuer URL없음. join 시 현재 JWKS를 동적 fetch

static_jwks에서는 키 회전이 운영 이벤트입니다. 감지, MR, 리뷰, apply, 검증이 필요합니다. 반면 oidc에서는 키 회전이 issuer 내부 이벤트입니다. Teleport는 join 때 현재 키를 가져오므로, 우리가 공개키를 들고 있을 이유가 없습니다. 앞서 만든 자동화 전체가 불필요해집니다.

한 가지 짚고 갈 점은, OIDC join이 static_jwks보다 보안이 약해지는 게 아니라는 것입니다. issuer는 원래 공개된 OIDC discovery 엔드포인트이고, Teleport는 여전히 토큰의 서명을 검증하고 허용된 namespace/serviceAccount인지 확인합니다. 두 방식이 신뢰하는 키는 동일한 EKS 공개키입니다. 달라진 건 “그 공개키를 우리가 token 안에 들고 있느냐, Teleport가 join 때마다 가져오느냐”뿐입니다. 신뢰 모델은 그대로입니다. 바뀐 건 보안 수준이 아니라 운영 부담입니다.

여기서 중요한 차이는 “운영자가 덜 바빠졌다”가 아니라 “운영자가 소유하던 상태가 줄었다”입니다. 스케줄, deploy key, CI variable, 자동 MR, 적용 절차가 전부 사라집니다. 적게 움직이는 시스템이 더 안정적인 경우가 있습니다.


바로 바꾸지 않고 검증한 것들

인증 방식 변경은 작은 YAML 수정처럼 보여도 운영에서는 위험한 변경입니다. 특히 Teleport token은 Git에 머지했다고 live가 자동으로 바뀌지 않았습니다. Git은 SSOT였지만, 실제 Teleport에는 tctl create -f --force로 별도 반영해야 했습니다. 그래서 적용 전에 세 가지를 확인했습니다.

첫째, EKS OIDC issuer에 Teleport auth가 접근할 수 있어야 합니다. .well-known/openid-configuration을 열 수 있고, 거기서 나온 jwks_uri도 접근 가능해야 합니다.

둘째, 실제 ServiceAccount token의 iss claim이 token에 선언할 issuer와 정확히 일치해야 합니다. 이 값은 기억이나 문서 요약으로 처리하면 안 됩니다. trailing slash 하나가 달라도 검증이 깨질 수 있으므로 kubectl create token으로 실제 토큰을 만들고 payload를 디코딩해서 확인했습니다.

Terminal window
kubectl -n <namespace> create token <service-account> \
| cut -d. -f2 \
| base64 -d 2>/dev/null \
| jq .iss

셋째, 사용 중인 Teleport 버전이 Kubernetes OIDC join subtype을 지원하는지 확인했습니다. “Teleport Kubernetes join은 in-cluster 아니면 static_jwks”라는 기존 프레임 때문에 처음에는 이 선택지를 놓쳤습니다. 버전과 공식 스키마를 다시 확인하고 나서야 oidc subtype을 적용할 수 있었습니다.

적용 후에는 한 번 성공했다고 끝내지 않았습니다. tbot은 주기적으로 ServiceAccount token으로 재join하고 identity generation을 갱신합니다. 그래서 OIDC 전환이 진짜로 동작하는지는 다음 renewal에서 드러납니다. 한 번이 아니라 두 사이클을 봤습니다. generation이 13에서 14, 다시 15로 증가했고, 에러 없이 갱신됐습니다.

그 뒤 JWKS drift 자동화는 철거했습니다. Pipeline Schedule, deploy key, CI variable, drift script 모두 필요 없어졌습니다. 남겨두면 “안 쓰는 안전장치”가 아니라 “미래 운영자를 헷갈리게 하는 죽은 절차”가 됩니다. 새 방식이 들어왔는데 옛 방식의 경보와 runbook이 남아 있으면, 다음 운영자는 둘 중 무엇을 믿어야 할지 고민하게 됩니다.


순서가 리스크를 결정했다

돌이켜보면 중요한 건 최종 해법만이 아니었습니다. 순서도 중요했습니다.

우리는 먼저 readiness 격리를 깔았습니다. 그래서 OIDC 전환이 실패해도 backend 로그인은 보호되는 상태였습니다. tbot이 join에 실패해도 backend Pod는 Ready를 유지하고, 장애 범위는 터널 기능으로 제한됩니다.

그 다음 static_jwks 자동화가 실제 회전을 한 번 막아줬습니다. 자동화는 최종 해법은 아니었지만, 근본 해결까지 가는 동안 시간을 벌어준 안전판이었습니다.

마지막으로 OIDC join으로 전환하면서 키 회전 대응 자체를 없앴습니다.

순서역할실패했을 때의 영향
readiness 격리장애 증폭 차단OIDC 전환 실패 시에도 로그인 보호
JWKS drift 자동화과도기 안전판실제 재회전을 장애 전 감지
OIDC join 전환근본 원인 제거키 회전 대응 자체 제거
JWKS 자동화 철거운영 복잡도 제거죽은 절차가 남지 않음

만약 OIDC 전환을 먼저 시도했다면, 실패 시 로그인 장애로 다시 번질 수 있었습니다. 반대로 자동화에서 멈췄다면, 키 회전이라는 운영 이벤트는 계속 남았을 겁니다.

안전망을 먼저 만들고, 그 위에서 근본 원인을 제거했습니다.


배운 것

이번 작업에서 가장 큰 교훈은 “자동화할수록 좋은가?”가 아니었습니다.

자동화는 필요했습니다. 실제 회전을 잡았고, 장애를 막았습니다. 하지만 자동화는 운영 복잡도를 없앤 것이 아니라 관리 가능한 형태로 바꾼 것입니다. 스케줄, deploy key, CI variable, MR, Slack, apply 절차가 생겼습니다.

OIDC join은 다릅니다. 관리해야 할 상태 자체를 제거했습니다.

이 차이를 구분해야 합니다.

질문자동화의 답설계의 답
키가 바뀌면 어떻게 알지?drift detector알 필요 없게 한다
새 키를 어떻게 반영하지?자동 MR + apply반영할 키를 저장하지 않는다
사람이 놓치면?Slack 알림놓칠 이벤트를 없앤다

물론 항상 “없애는 설계”가 가능한 것은 아닙니다. 외부 시스템 제약 때문에 자동화가 최선인 경우도 많습니다. 하지만 이번에는 자동화 너머에 더 단순한 답이 있었습니다. 운영에서 가장 좋은 자동화는 종종 자동화를 삭제하게 만드는 설계 변경입니다. JWKS drift를 더 빨리 감지하는 것보다, drift가 생길 상태를 없애는 쪽이 더 단순하고 안전했습니다.


운영적으로 남은 것

근본 원인을 제거해도 문서와 주석은 남습니다. runbook이 아직 static_jwks 회전 대응을 안내하면, 다음 운영자는 존재하지 않는 절차를 따라가게 됩니다.

그래서 후속 작업은 코드보다 문서 정합성에 가깝습니다.

  • runbook의 JWKS 회전 대응 절차를 OIDC join 기준으로 교체하거나 제거
  • 차트 주석과 values 설명에서 stale sidecar, native sidecar 표현 정리
  • tbot-db-tunnel NetworkPolicy가 실제 CNI에서 backend 외 접근을 차단하는지 e2e 확인

그리고 한 가지 운영 메모도 남겼습니다. tsh kube login 같은 명령이 격리 kubeconfig의 current-context를 바꿀 수 있습니다. 이번에도 dev를 보고 있다고 생각한 순간 prod context를 가리키는 오염을 발견했습니다. 도구 출력도 검증 대상입니다.


핵심 정리

이번 장애는 EKS 키 회전 하나가 곧바로 로그인 장애가 된 사건이었습니다. 원인은 static_jwks stale이었고, 증폭기는 tbot과 backend의 readiness 결합이었습니다.

해결은 세 단계였습니다.

  • tbot을 별도 파드로 분리해 backend readiness와 장애 경계를 분리했습니다.
  • static_jwks drift 자동화로 과도기 키 회전을 실제로 한 번 방어했습니다.
  • Teleport Kubernetes OIDC join으로 전환해 JWKS 관리 자체를 제거했습니다.

가장 중요한 판단은 마지막입니다.

static_jwks를 완벽히 관리하는 것보다, static_jwks를 안 쓰는 편이 더 단순하고 더 안전했습니다.

장애 대응에서 자동화는 강력한 도구입니다. 하지만 자동화는 상태를 더 빠르게 관리할 뿐, 상태 자체를 없애지는 못합니다. 이번에는 자동화가 시간을 벌었고, OIDC 전환이 문제를 없앴습니다. 보조 컴포넌트의 실패는 격리했고, 직접 들고 있던 인증 상태는 제거했습니다.

관련 콘텐츠

댓글