
지난주에 클러스터 두 개 사이에 SPIRE Federation을 붙이려고 하다가 정말 삽질을 오지게 했다. 결국 붙이긴 했는데, 그 과정에서 문서만 봐서는 절대 안 걸리는 함정 몇 개를 밟았다. 다음에 또 이런 걸 세팅할 나 자신을 위한 기록이자, 혹시 지금 SPIRE federation을 붙이려는 분들을 위한 삽질 로그다.
상황
우리 팀은 원래 단일 EKS 클러스터에서 SPIRE로 워크로드 identity를 발급받아 쓰고 있었다. Istio가 sidecar mTLS에 SPIFFE ID를 쓰고, 몇몇 배치 잡이 Vault 대신 SPIRE-issued SVID로 S3에 접근한다. 잘 돌아갔다.
문제는 데이터플랫폼 팀이 별도 계정, 별도 VPC, 별도 EKS 클러스터를 굴리기 시작하면서 시작됐다. 그쪽에서 우리 클러스터의 gRPC 서비스를 호출해야 하는데, 서로 다른 SPIRE 서버가 발급한 SVID는 당연히 서로 검증이 안 됐다. 서비스 사이에 굳이 API Gateway 하나 더 세울 거면 SPIRE Federation을 쓰는 게 맞아 보였다. Nested SPIRE도 잠깐 고민했는데 조직/네트워크 경계가 명확히 갈리는 상황이라 Federation이 더 자연스러웠다.
계획은 단순했다. 각 클러스터의 SPIRE server가 https_spiffe 프로필로 bundle endpoint를 열고, 서로의 trust bundle을 교환한 뒤 ClusterFederatedTrustDomain CRD로 관계를 선언하면 끝. 문서만 보면 반나절이면 끝나는 일이었다.
반나절은커녕 3일이 걸렸다.
첫 번째 함정: bootstrap trust bundle을 어떻게 처음 넘길 것인가
문서를 읽고 나서 처음 든 의문이 이거였다. https_spiffe 프로필은 상대방 endpoint의 SPIFFE ID를 알고, 그걸 발급한 CA도 신뢰해야 검증이 된다. 그런데 그 CA를 신뢰하려면 상대방의 trust bundle이 필요하다. 상대방의 trust bundle을 받으려면 endpoint를 호출해야 하는데, endpoint를 인증하려면 다시 trust bundle이 필요하다. 닭이 먼저냐 달걀이 먼저냐.
문서에서는 "처음 한 번은 out-of-band로 bootstrap bundle을 넘겨야 한다"고만 짧게 쓰여 있다. 그래서 그냥 spire-server bundle show -format spiffe로 뽑은 JSON을 Slack DM으로 보내고 상대 팀이 kubectl apply를 했다. 여기까지는 문제 없다.
문제는 그 다음이었다. SPIRE 서버는 주기적으로 자기 CA를 rotate하는데, 초기 bootstrap bundle은 rotate가 되면 무효화된다. Federation endpoint를 통해서 새 bundle을 자동으로 pull 하도록 되어 있어야 한다. 근데 우리는 처음에 이 자동 refresh를 안 켰다.
정확히는 켰는데, refresh_hint를 default(300초)로 뒀고, endpoint URL을 잘못된 걸 넣어놨었다. 그러니까 매 5분마다 refresh를 시도하다가 실패하고 있었던 거다. 로그에는 failed to fetch bundle 정도가 조용히 찍혔다. 아무도 안 봤다.
그러다 어느 날 새벽 3시에 갑자기 mTLS가 다 깨졌다. CA rotation이 돌면서 bootstrap 시점 CA가 폐기됐고, refresh는 안 되고 있었으니 상대 클러스터에서 우리 SVID를 검증할 수 없게 된 거다. 새벽에 전화받고 눈이 번쩍 떠졌다. refresh_hint를 60초로 낮추고 endpoint URL을 고쳤다. 근본 원인은 알림 부재였다. spire_server_bundle_refresh_failed 메트릭을 안 보고 있었다.
교훈이라면, federation 붙이기 전에 bundle refresh 실패 메트릭 알림부터 걸어야 한다는 거다. 지금은 5분 이상 refresh 실패하면 무조건 페이지 되게 해뒀다.
두 번째 함정: endpoint 노출을 어디로 할 것인가
우리 SPIRE 서버는 클러스터 내부 서비스로만 노출되어 있었다. Federation을 위해서는 상대방이 인터넷 또는 사설 네트워크 경로로 우리 endpoint를 호출해야 한다.
세 가지 옵션이 있었다.
옵션 1은 인터넷 노출. NLB 붙이고 도메인 붙이고 endpoint 열기. 심리적으로는 제일 꺼려졌지만 https_spiffe 프로필은 SPIFFE 자체 CA로 mTLS-ish 검증을 하기 때문에 사실 인터넷에 열려도 노출된 정보는 trust bundle뿐이다. Trust bundle은 공개 정보다.
옵션 2는 VPC peering을 이용한 사설 네트워크. 이미 데이터플랫폼 계정과 우리 계정 사이에 peering이 있었다. Private NLB로 열고 route53 private hosted zone에 도메인 등록.
옵션 3은 인그레스 컨트롤러 뒤에 두기. 근데 이러면 TLS termination이 인그레스에서 일어나면서 https_spiffe 프로필에 필요한 클라이언트 인증서 검증이 이상해질 위험이 있다.
우리는 결국 옵션 2로 갔다. 이유는 여러 가지가 있는데, 가장 큰 건 조직 보안팀 승인이었다. "인터넷에 노출되는 SPIRE endpoint"라는 문장을 승인 요청서에 넣는 순간 몇 주가 늘어질 게 뻔했다. 사설 네트워크로 가면 그런 문답이 없다.
여기서 삽질 포인트. Private NLB로 열었는데, endpoint URL을 route53 private zone 도메인으로 넣으니 상대 클러스터의 SPIRE 서버가 DNS를 못 풀었다. 상대 클러스터 VPC의 DNS resolver가 우리 private zone을 모르는 게 당연했다. Route53 Resolver rule을 붙이거나, 아예 그쪽 CoreDNS 커스텀 config에 forward를 넣어야 했다. 우리는 Resolver rule로 갔다. 이거 하나에 반나절 넘게 태웠다.
세 번째 함정: ClusterFederatedTrustDomain에서 endpoint SPIFFE ID를 잘못 넣는 실수
spire-controller-manager가 관리하는 ClusterFederatedTrustDomain CRD를 써서 federation 관계를 선언한다. 필드가 몇 개 안 되는데 딱 하나 헷갈리는 게 있다. bundleEndpointProfile.endpointSPIFFEID.
이건 상대방의 bundle endpoint 서버가 스스로 제시할 SPIFFE ID다. 처음에는 상대방 trust domain의 root ID(예: spiffe://data.example.com)를 넣으면 되는 줄 알았다. 그렇게 넣으니 handshake가 계속 실패했다.
정답은 상대방 SPIRE 서버 프로세스에 부여된 SPIFFE ID였다. 우리 SPIRE 배포에서는 spiffe://data.example.com/spire/server가 그 값이었다. 문서에도 나와 있는데 endpoint_spiffe_id가 어디를 가리키는지를 처음 보는 사람 관점에서 명확하지 않게 쓰여 있다. 트러스트 도메인 ID로 착각하기 쉽다.
이 삽질을 하는 동안 상대 클러스터 로그에는 이런 메시지가 찍히고 있었다.
level=warn msg="Failed to authenticate bundle endpoint"
error="server SPIFFE ID mismatch: expected \"spiffe://data.example.com\",
got \"spiffe://data.example.com/spire/server\""
이제 와서 로그를 다시 보면 답이 바로 나와 있었는데, 처음 볼 때는 저 mismatch가 무슨 뜻인지 감이 안 왔다. 자세히 안 읽고 그냥 "handshake 실패인가 보다"라고 넘긴 게 문제였다. 반성.
잘 붙은 뒤에도 남은 숙제
Federation을 붙이고 나니 상대 클러스터의 SVID를 우리 쪽에서 검증할 수 있게 됐다. 이걸로 뭘 하냐가 그다음이었다.
우리는 gRPC 서버의 Authorization 인터셉터에서 클라이언트 SVID를 뽑아서 authz 정책을 적용한다. Federation 이전에는 spiffe://ours.example.com/* 만 허용하면 됐지만, 이제는 spiffe://data.example.com/svc/analytics 같은 특정 워크로드도 허용해야 한다.
여기서 실수하기 쉬운 게 authz 정책을 너무 넓게 여는 거다. spiffe://data.example.com/* 로 열어버리면 상대 클러스터의 아무 워크로드가 우리 서비스에 접근할 수 있다. 이건 원래 SPIRE Federation이 주는 최소 권한 원칙을 다 깨는 거다. 우리는 각 상대 서비스별로 명시적 워크로드 SPIFFE ID를 화이트리스트에 올리는 방향으로 갔다. 좀 귀찮지만 그래야 맞다.
또 하나 남은 숙제는 identity federation을 서비스간 mTLS뿐 아니라 Vault 접근에도 확대할지 여부다. 상대 팀도 Vault를 쓰는데 인증 방법이 다르다. Vault의 SPIFFE auth method를 붙이고 federation을 태우면 crss-cluster 시크릿 접근도 가능해진다. 근데 그러려면 authz 정책을 얼마나 세밀하게 유지할 수 있느냐가 문제고, 이건 아직 우리 팀 내부에서도 결론이 안 났다. 좀 더 파봐야 한다.
정리하자면
SPIRE Federation은 개념은 깔끔한데 실제로 붙이려고 하면 문서에 안 나와 있는 함정이 여러 겹 있다. 특히 bootstrap과 refresh 사이의 시간차, endpoint SPIFFE ID 필드의 정확한 의미, 그리고 endpoint를 어디에 노출할 것인가 하는 네트워크 설계 결정.
한 번만 겪으면 다시는 안 헷갈릴 거지만, 처음 겪을 때는 3일씩 태울 만한 지뢰밭이다. 이 글이 두 번째 사람에게는 하루라도 아껴줬으면 한다.
혹시 SPIRE Federation을 다른 방식으로 붙여본 분 있으면 댓글로 공유 부탁드립니다. 특히 authz 정책 관리 어떻게 하시는지 궁금합니다.
'IT > DevSecOps' 카테고리의 다른 글
| Trivy vs Grype, 컨테이너 스캐너 뭘 쓸까 (0) | 2026.07.11 |
|---|---|
| Trivy Operator 도입 후 CVE 알림이 홍수처럼 쏟아진 이야기 (0) | 2026.07.07 |
| Vault Dynamic Secrets로 PostgreSQL 크리덴셜 관리하기 (0) | 2026.07.06 |
| External Secrets Operator + AWS Secrets Manager, 실무 세팅 가이드 (0) | 2026.07.03 |
| Falco eBPF probe는 어떻게 syscall을 잡는가 — modern probe 내부 들여다보기 (1) | 2026.07.01 |