새벽 3시에 배포 실패 알림이 왔다.
배포한 사람은 아무도 없었다. GitOps 매니페스트 저장소의 커밋 이력도 당연히 없었고, ArgoCD sync history도 없었다. 해당 서비스의 마지막 배포는 8일 전이었다. 배포를 안했는데 배포 실패 알림이 온다고?
끝까지 파고들어 보니, 원인은 배포가 아니었다. ExternalSecret의 gRPC 연결이 끊어진 시각과 ExternalSecret의 reconcile 타이밍이 정확히 겹쳐 일시적으로 ArgoCD App이 Degraded가 되었던 것이다.
먼저 알아야 할 것
1. ArgoCD는 Application의 상태를 어떻게 판단하는가?
ArgoCD는 관리하는 K8s 리소스들의 상태를 지속적으로 watch한다. 여기서 watch는 주기적으로 확인하는 폴링이 아니다. Kubernetes watch API를 통해 리소스에 변경이 생기는 순간 즉시 이벤트를 받는 구조다. 그래서 ExternalSecret의 status.conditions가 바뀌는 순간 ArgoCD가 바로 감지한다. 리소스 타입별로 health check 로직이 정의되어 있는데, 초기 built-in 리소스(Deployment, Pod 등)는 Go 코드로 구현되어 있고, ExternalSecret처럼 나중에 추가된 커스텀 리소스는 Lua 스크립트로 정의된다. (ArgoCD 공식 문서)
ExternalSecret의 경우, ArgoCD 소스의 Lua health check가 다음과 같이 정의되어 있다:
if condition.type == "Ready" and condition.status == "False" then
hs.status = "Degraded"
hs.message = condition.message
return hs
endstatus.conditions에 Ready=False가 되는 순간 즉시 Degraded로 판정한다. 이 전환에 grace period나 debounce가 없다.
Application의 health는 그 Application이 관리하는 리소스 중 가장 나쁜 상태를 그대로 따라간다. (ArgoCD 공식 문서)
The App health will be the worst health of its immediate child resources, based on the following priority (from most to least healthy): Healthy, Suspended, Progressing, Missing, Degraded, Unknown.
즉, 관리 중인 리소스 중 하나라도 Degraded가 되면 Application 전체가 Degraded로 전환된다. Deployment의 Pod가 죽을 때만이 아니다. Ingress, ExternalSecret, PDB 등 Application이 직접 관리하는 리소스가 Degraded 상태가 되면 모두 알림 대상이 된다.
2. ExternalSecret은 어떻게 AWS Secrets Manager의 값을 가져오는가?
ExternalSecret의 역할은 단순하다. Secrets Manager에 저장된 값을 읽어서 K8s Secret에 써준다. refreshInterval마다 반복한다.
근데 어떻게 읽어오지? → Secrets Manager는 인증된 요청만 받는다. 자격증명이 없으면 거부된다.
자격증명은 어떻게 마련하지? → 가장 단순한 방법은 Access Key를 Pod에 넣는 것이다. 하지만 키가 탈취되면 AWS 리소스 전체가 위험해지고, Pod마다 키를 관리하는 것도 부담이다. 그래서 IRSA(IAM Roles for Service Accounts) 를 쓴다.
IRSA는 어떻게 작동하지? → “이 Pod가 우리 클러스터에서 실행 중이라는 사실 자체를 신원 증명으로 쓴다.” EKS 클러스터를 AWS IAM에 OIDC Provider로 등록해두면, 클러스터가 발급한 토큰을 AWS가 신뢰한다. 토큰을 STS에 제출하면 임시 자격증명으로 교환해준다. 단, OIDC Provider 등록은 EKS 클러스터를 생성한다고 자동으로 되지 않는다. eksctl utils associate-iam-oidc-provider 또는 Terraform으로 별도 등록해야 한다.
그 토큰은 어디서 발급받지? → kube-apiserver다. ExternalSecret 컨트롤러가 TokenRequest API를 호출하면 “나는 이 클러스터의 이 ServiceAccount다”라는 서명된 JWT가 반환된다. 단, 이 API는 누구나 호출할 수 있는 게 아니다. Kubernetes RBAC로 제어되며, ESO 설치 시 ClusterRole에 serviceaccounts/token create 권한이 부여되어 있기 때문에 컨트롤러가 호출할 수 있는 것이다.
이 구조가 3단계 직렬 체인이 된다.
sequenceDiagram participant ES as ExternalSecret Controller participant KA as kube-apiserver participant STS as AWS STS participant OIDC as OIDC Provider participant SM as Secrets Manager participant SEC as K8s Secret Note over ES,KA: ① 신원 증명 발급 (클러스터 내부) ES->>KA: SA 토큰 요청 (TokenRequest API) KA-->>ES: JWT 반환 ("나는 이 클러스터의 Pod다") Note over ES,OIDC: ② 임시 자격증명 발급 (AWS) ES->>STS: 토큰 제출 (AssumeRoleWithWebIdentity) STS->>OIDC: 서명 검증 ("이 토큰 진짜야?") OIDC-->>STS: 검증 완료 STS-->>ES: 임시 자격증명 발급 Note over ES,SM: ③ 비밀 값 조회 ES->>SM: GetSecretValue SM-->>ES: 비밀 값 반환 Note over ES,SEC: ④ 동기화 ES->>SEC: K8s Secret 업데이트
①번이 실패하면 토큰이 없으니 ②번 STS 호출이 불가능하고, ②번이 없으면 ③번도 불가능하다. 첫 번째 링크가 끊어지면 체인 전체가 멈춘다.
①번 토큰 요청은 kube-apiserver와 맺어둔 연결 위에서 전송되는데, 컨트롤러는 이 연결을 내부적으로 gRPC로 관리한다. 연결이 닫히는 순간에 요청이 들어오면 grpc: the client connection is closing 에러와 함께 취소된다. 토큰이 없으니 이후 STS, Secrets Manager 호출도 전부 막힌다.
가설과 검증
ArgoCD Application의 status.health.lastTransitionTime이 03:17:05로 기록되어 있었다. 이 시각에 Healthy로 복귀한 것이다. 복귀했다는 건, 직전 어느 시점에 Degraded로 전환됐다는 뜻이다. 언제부터 Degraded였는지, 그 사이에 무슨 일이 있었는지 추적해야 했다.
1단계: 클러스터에 이상이 있는가?
알림 문구가 “배포 실패”였으니, 진짜 배포 문제라면 클러스터에 흔적이 남는다. 배포 실패가 클러스터 수준에서 드러나는 가장 직접적인 형태는 세 가지다: Pod가 죽거나, 롤아웃이 진행 중이거나, 노드가 이상하거나. 이 세 가지를 먼저 확인했다.
$ kubectl get deployment worker-app -n worker
NAME READY UP-TO-DATE AVAILABLE AGE
worker-app 2/2 2 2 7d16h- Pod가 죽었는가? → Deployment 2/2 Ready, 재시작 0회
- 롤아웃이 있었는가? → ReplicaSet 하나만 active, 변경 이력 없음
- 노드에 문제가 있었는가? → 5대 모두 Ready, 22~27일째 운영 중
세 질문의 답이 모두 ‘이상 없음’이었다. “배포 실패”라는 알림 문구가 실제 원인을 가리키고 있지 않다는 뜻이다. 원인은 다른 곳에 있었다.
배포도 없었고 Pod도 멀쩡하다면, 어떤 리소스가 Degraded를 유발했을까?
2단계: 어떤 리소스가 Degraded를 유발했는가?
ArgoCD Application의 리소스 트리를 확인했다. Deployment와 Service는 모두 Healthy 상태였다. ExternalSecret 리소스의 status를 조회하자, 03:16:59에 Ready=False로 전환된 기록이 있었고 03:17:05에 Ready=True로 복귀했다. 6초 동안 ExternalSecret이 unhealthy 상태였다.
ExternalSecret은 refreshInterval: 1m으로 설정되어 있었다. 매 1분마다 Secrets Manager에서 값을 가져오는 reconcile을 수행한다. 03:16:59는 이 reconcile이 실행되어야 하는 시각이었다. 즉, reconcile이 실행되자마자 실패했다는 뜻이다. 단순한 일시적 오류가 아니라, 그 타이밍에 무언가 잘못됐다는 뜻이다.
그렇다면 어디서 실패한 걸까? ExternalSecret이 Secrets Manager까지 도달하지 못했을 수도 있고, 도달했지만 거부됐을 수도 있다. 이 두 가지를 구분하려면 AWS 쪽 기록을 봐야 한다.
3단계: CloudTrail이 보여준 1분의 공백
ExternalSecret이 Secrets Manager에서 값을 가져올 때마다 GetSecretValue API 호출이 AWS CloudTrail에 기록된다. 단, Secrets Manager API 호출은 CloudTrail의 Data Events에 해당하며 기본적으로 비활성화되어 있다. 이 팀은 사전에 Data Events를 활성화해두었기 때문에 이 이력을 조회할 수 있었다. 정상 상태에서는 정확히 매분 :59초에 호출이 기록되고 있었다.
| 시각 (KST) | GetSecretValue | 상태 |
|---|---|---|
| 03:13:59 | ✅ 호출 기록 있음 | 정상 |
| 03:14:59 | ✅ 호출 기록 있음 | 정상 |
| 03:15:59 | ✅ 호출 기록 있음 | 정상 |
| 03:16:59 | ❌ 호출 기록 없음 | 실패 |
| 03:17:04 | ✅ 호출 기록 있음 (5초 지연) | 복구 |
03:16:59에 GetSecretValue 호출이 없었다. API에 도달하지 못했으니 CloudTrail에 기록이 남지 않은 것이다. 다음 성공 기록은 03:17:04로, 정상 주기보다 5초 밀려 있었다.
CloudTrail이 알려준 건 “Secrets Manager에 닿지도 못했다”는 사실이다. 즉 IRSA 체인에서 앞쪽 어딘가 — kube-apiserver 또는 STS 단계 — 가 실패한 것이다. 정확히 어느 단계에서 어떤 이유로 실패했는지는 컨트롤러의 에러 로그에 있다.
4단계: gRPC 연결이 끊어진 순간에 reconcile이 들어갔다
Loki에서 ExternalSecret 컨트롤러의 에러 로그를 조회하자, 03:16:59에 Reconciler error 3건이 확인되었다.
rpc error: code = Canceled desc = grpc: the client connection is closing
에러의 전체 스택을 펼치면 인증 체인 전체의 실패 경로가 보인다.
| 계층 | 에러 | 의미 |
|---|---|---|
| gRPC | the client connection is closing | kube-apiserver 연결이 닫히는 중에 요청이 들어옴 |
| kube-apiserver | failed to generate token: while signing jwt | 연결이 닫히면서 JWT 서명 요청이 취소됨 |
| IRSA | failed to refresh cached credentials | SA 토큰 없이 IRSA credential 갱신 불가 |
| External Secrets | GetSecretValue, get identity: get credentials | credential 없이 Secrets Manager API 호출 불가 |
| controller-runtime | Reconciler error | 에러 로깅 후 backoff requeue |
배경지식 섹션에서 설명한 직렬 체인이 그대로 무너진 것이다. gRPC 연결 하나가 닫히는 순간에 reconcile이 들어갔고, 체인의 첫 번째 링크가 끊어지면서 Secrets Manager까지 도달하지 못했다.
gRPC 연결 종료가 인증 체인 전체를 멈췄다
gRPC 연결은 왜 끊어진 것일까?
EKS managed kube-apiserver의 내부 설정에는 직접 접근할 수 없다. AWS가 관리하는 컨트롤 플레인이기 때문에 gRPC 서버 설정을 조회할 방법이 없다. 다만 gRPC-Go Keepalive 문서에 따르면, the client connection is closing 에러는 서버가 MaxConnectionAge에 의해 오래된 연결을 종료할 때 발생한다. (gRPC 참고) kube-system 이벤트와 클러스터 업데이트 이력에는 이상이 없었다. kube-apiserver의 gRPC MaxConnectionAge에 의한 연결 종료로 추정된다. 확인이 아닌 추정인 이유가 여기 있다.
gRPC 연결이 닫히는 것 자체는 정상적인 동작이다. controller-runtime은 에러가 발생하면 해당 reconcile을 즉시 재시도하지 않고, 지수 백오프(exponential backoff) 방식으로 대기 시간을 늘려가며 재시도한다. 이번 케이스에서는 약 5초 후 재시도에 성공했다. 실제로 03:17:04에 GetSecretValue가 정상 호출되었다. 문제는 이 수초의 틈 사이에 ArgoCD가 Degraded를 감지하고 알림을 보낸다는 것이다.
전체 흐름을 정리하면:
sequenceDiagram participant ES as ExternalSecret Controller participant KA as kube-apiserver (gRPC) participant STS as AWS STS participant SM as Secrets Manager participant AC as ArgoCD Note over KA: MaxConnectionAge 도달 (추정)<br/>→ GOAWAY 프레임 전송 Note over ES,KA: 기존 gRPC transport: closing 상태 ES->>KA: TokenRequest API (SA 토큰 요청) KA--xES: rpc error: code = Canceled<br/>connection is closing Note over ES: SA 토큰 실패 → credential chain 전체 실패 ES->>AC: ExternalSecret Ready=False AC->>AC: Health → Degraded, Slack 알림 발송 Note over ES,KA: ~5초 후, 새 gRPC transport 수립 ES->>KA: TokenRequest API (재시도) KA-->>ES: SA 토큰 발급 성공 ES->>STS: AssumeRoleWithWebIdentity STS-->>ES: credential 발급 ES->>SM: GetSecretValue SM-->>ES: Secret 값 반환 ES->>AC: ExternalSecret Ready=True AC->>AC: Health → Healthy
60초 중 1.1초, 타이밍의 불운
의문이 남았다. 같은 ExternalSecret 컨트롤러가 7개 프로덕션 시크릿을 관리하는데, 왜 워커 서비스만 영향을 받았는가?
같은 시간대의 Loki 로그를 확인했다. 나머지 6개 시크릿은 에러 없이 정상 fetch되었다.
| Secret | 시각 (UTC) | 결과 |
|---|---|---|
production/service-a | 18:17:15 | ✅ 정상 |
production/service-b | 18:15:13 | ✅ 정상 |
production/service-c | 18:07:14 | ✅ 정상 |
production/service-d | 18:11:11 | ✅ 정상 |
production/service-e | 18:14:17 | ✅ 정상 |
production/service-f | 18:13:25 | ✅ 정상 |
production/worker | 18:16:59 | ❌ gRPC 끊김 |
각 ExternalSecret은 refreshInterval: 1m이지만 생성된 시각이 달라서 reconcile이 실행되는 시각도 제각각이다. gRPC 연결이 닫히는 동안의 race window는 Loki 로그 기준 약 1.1초였다. 첫 번째 grpc: the client connection is closing 에러가 찍힌 시각과 새 연결이 수립되어 다음 요청이 성공한 시각의 차이로 측정했다. 60초 주기에서 1.1초의 윈도우와 겹칠 확률은 약 **1.8%**다. 워커 서비스의 reconcile 타이밍만이 이 1.1초 안에 들어간 것이었다.
stateDiagram-v2 [*] --> Ready: 정상 reconcile (매 60초) Ready --> Ready: reconcile 성공 Ready --> SecretSyncError: gRPC 연결 끊김<br/>(SA 토큰 발급 실패) SecretSyncError --> Ready: backoff retry 성공 (~5초) note right of SecretSyncError: ArgoCD watch가 이 상태를<br/>Degraded로 즉시 감지
진짜 문제는 알림이었다
6초 만에 자동 복구되었고, Pod 재시작도 트래픽 유실도 없었다. 서비스에는 아무 영향이 없었다. 하지만 알림 메시지는 “신규 배포에 실패했습니다”였다. 배포와 무관한 ExternalSecret gRPC 실패였는데, 알림만 보면 누군가의 배포가 실패한 것처럼 보였다.
ArgoCD on-health-degraded 알림 템플릿이 모든 Degraded 상태에 대해 동일한 고정 문구를 사용하고 있었다. Degraded를 유발한 리소스의 kind나 name이 알림에 포함되지 않아, 알림만으로는 원인 파악이 불가능했다.
두 가지 개선을 검토했다.
첫째, 알림 템플릿을 개선한다. ArgoCD Notifications 템플릿에서 app.status.resources를 순회하여 unhealthy 리소스를 표시하면, 첫 확인 시점에 범위를 즉시 좁힐 수 있다.
# ArgoCD Notifications configmap
template.app-degraded:
message: |
*{{.app.metadata.name}}* — Health Degraded
Degraded resources:
{{- range .app.status.resources}}
{{- if and .health (eq .health.status "Degraded")}}
• {{.kind}}/{{.name}}: {{.health.message}}
{{- end}}
{{- end}}이 템플릿이 적용되었다면 알림에 ExternalSecret/worker-secret: SecretSyncError가 표시되어, 배포 이력을 뒤지는 시간을 줄일 수 있었다.
둘째, ExternalSecret의 refreshInterval을 1분에서 5분으로 완화하는 것을 검토한다. gRPC 연결 종료와 reconcile이 겹칠 확률이 1.8%에서 0.36%로 줄어든다. 다만 시크릿 변경이 반영되는 지연이 1분에서 5분으로 늘어나므로, 긴급 시크릿 교체가 잦은 서비스와 그렇지 않은 서비스를 구분해서 개별 적용할 예정이다.
한 가지 더. 이 시점에서 “ExternalSecret이 일시적으로 실패했을 때 다른 사이드 이펙트는 없었는가”를 확인했다. ExternalSecret이 관리하는 K8s Secret이 변경되는 경우 Reloader 같은 오퍼레이터가 연쇄 반응을 일으킬 수 있기 때문이다. Deployment에 reloader.stakater.com/auto: "false"가 설정되어 있었다. Reloader는 ConfigMap이나 Secret이 변경될 때 해당 Pod를 자동으로 재시작해주는 오퍼레이터다. "true"로 설정되어 있었다면, ExternalSecret이 일시적으로 실패하는 동안 Secret이 변경된 것으로 감지되어 불필요한 Pod rolling restart가 트리거될 수 있었다. 이 설정이 이미 "false"였던 것이 오히려 다행이었다.
이 장애가 남긴 것
“배포 실패” 알림을 받았을 때 가장 먼저 한 일은 GitOps 저장소의 커밋 이력을 확인하는 것이었다. 이 판단 자체는 합리적이었지만, 알림 문구를 액면 그대로 받아들인 결과 “배포”라는 프레임 안에서만 원인을 찾으려 했다. ArgoCD의 Degraded는 배포 실패만 의미하지 않는다. 알림이 전달하는 것은 “무엇이 실패했는가”가 아니라 “어딘가에서 상태가 변했다”는 신호일 뿐이다. 알림 문구를 믿기 전에, 어떤 리소스가 unhealthy인지 먼저 확인하는 것이 올바른 순서다.
gRPC 연결은 언제든 끊어질 수 있다. MaxConnectionAge나 keepalive timeout에 의해 주기적으로 끊어지며, 이것은 정상적인 동작이다. controller-runtime의 backoff requeue가 자동으로 재시도를 수행하기 때문에 대부분 수초 내에 복구된다. 자동 복구가 동작하는 시스템에서는, 알림의 역할이 “즉시 개입하라”가 아니라 “패턴을 관찰하라”에 가깝다.
낮은 확률도 충분한 시행 횟수 앞에서는 실현된다. 1.8%의 확률은 낮아 보이지만, 매일 수회의 gRPC 연결 종료가 발생하고 7개의 시크릿이 각각 매분 reconcile을 수행한다면, 수일 내에 한 번은 겹친다. 확률이 낮다는 이유로 race condition을 무시할 수는 없다. 다만 이 정도 빈도와 영향도라면, 방어 조치보다 알림의 정확성을 높이는 것이 더 효과적인 대응이다.
앞으로의 방향
가장 시급한 개선은 알림 템플릿의 정확성이다. “신규 배포에 실패했습니다”를 “Application의 health가 Degraded 상태입니다”로 변경하고, Degraded를 유발한 리소스의 kind/name을 알림에 포함하여 알림만으로 원인 파악의 첫 단서를 얻을 수 있도록 한다.
ExternalSecret refreshInterval은 서비스별 시크릿 변경 빈도를 고려해 개별 적용할 예정이다. 변경 빈도가 낮은 서비스는 5분으로 완화하고, 긴급 교체가 잦은 서비스는 1분을 유지한다.
ArgoCD Notifications에는 시간 기반 debounce 기능이 없다. oncePer 설정으로 같은 Git revision에 대해 알림을 1회로 제한할 수 있지만, “N초 이상 지속될 때만 알림”이라는 조건은 지원하지 않는다. 6초 만에 자동 복구되는 이벤트에 대해 새벽 3시 알림이 발생하는 구조는, 현재 ArgoCD Notifications의 한계 안에서는 완전히 해결하기 어렵다.