아무도 배포하지 않았는데 배포 실패 알림이 왔다

ExternalSecret의 gRPC 연결이 끊긴 1.1초와 reconcile 타이밍이 겹치면서 ArgoCD가 Degraded를 감지했다. 6초 만에 자동 복구됐지만, 알림 메시지가 실제 상황을 전혀 반영하지 못하고 있었다.

새벽 3시, Slack에 알림이 떨어졌다. “신규 배포에 실패했습니다.” 배포한 사람은 아무도 없었다. GitOps 매니페스트 저장소의 최근 커밋을 확인해도 해당 서비스의 마지막 배포는 8일 전이었다. 알림이 말하는 “배포 실패”는 실제로 일어나지 않은 일이었고, 진짜 원인은 gRPC 연결이 닫히는 1.1초 동안의 타이밍 충돌이었다.


새벽 3시, 배포한 사람은 아무도 없었다

첫 번째 가설은 “누군가 새벽에 배포를 했다”였다. GitOps 매니페스트 저장소의 최근 이틀간 커밋을 조회했다. 해당 워커 서비스와 관련된 커밋은 없었고, ArgoCD sync history에도 새로운 기록이 없었다. 배포는 없었다 — 가설 기각.

두 번째 가설은 “인프라에 문제가 생겼다”였다. 프로덕션 EKS 클러스터를 조회했다.

$ kubectl get deployment worker-app -n worker
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
worker-app   2/2     2            2           7d16h

Deployment 2/2 Ready, Pod 재시작 0회, 노드 5대 모두 Ready 상태로 22~27일째 운영 중이었다. ReplicaSet도 하나만 active였고 변경 이력이 없었다. 인프라는 정상이었다 — 가설 기각.

그렇다면 6초 동안 무엇이 ArgoCD를 Degraded로 만들었는가? ArgoCD Application의 status.health.lastTransitionTime이 03:17:05로 기록되어 있었다. 이 시각에 Healthy로 복귀한 것이다. 그 직전 6초 동안 벌어진 일을 추적해야 했다.


CloudTrail에서 사라진 1분이 단서였다

이 서비스는 ExternalSecret을 통해 AWS Secrets Manager에서 비밀 값을 가져와 K8s Secret으로 동기화한다. ExternalSecret 컨트롤러는 매 refreshInterval(1분)마다 reconcile을 수행하므로, 장애 시점에 이 동기화가 실패했을 가능성이 있었다.

CloudTrail에서 해당 secret에 대한 GetSecretValue 호출 이력을 조회했다. 정상 상태에서는 정확히 매분 :59초에 호출이 기록되고 있었다.

시각 (KST)GetSecretValue상태
03:13:59✅ 호출 기록 있음정상
03:14:59✅ 호출 기록 있음정상
03:15:59✅ 호출 기록 있음정상
03:16:59❌ 호출 기록 없음실패
03:17:04✅ 호출 기록 있음 (5초 지연)복구

03:16:59에 호출 기록이 없었다. GetSecretValue가 실패하면 AWS API에 도달하지 못하므로 CloudTrail에 기록이 남지 않는다. 다음 성공 기록은 03:17:04 — 정상 주기보다 5초가 밀려 있었다.

Loki에서 ExternalSecret 컨트롤러의 에러 로그를 조회하자, 03:16:59에 Reconciler error 3건이 확인되었다. 에러 메시지의 핵심은 다음과 같았다.

rpc error: code = Canceled desc = grpc: the client connection is closing

kube-apiserver와의 gRPC 연결이 닫히는 순간에 SA(ServiceAccount) 토큰 생성 요청이 들어간 것이었다. ExternalSecret 동기화 실패 — 가설 채택.


인증 체인의 첫 번째 링크가 끊어지면 전체가 멈춘다

이 에러가 왜 Secrets Manager 호출 실패까지 이어지는지 이해하려면, ExternalSecret이 AWS Secrets Manager에 접근하는 과정을 알아야 한다. IRSA(IAM Roles for Service Accounts) 기반 인증은 3단계 직렬 체인으로 구성된다.

flowchart LR
    subgraph "프로덕션 EKS 클러스터"
        ES["ExternalSecret<br/>Controller"]
        KA["kube-apiserver<br/>(gRPC)"]
        SEC["K8s Secret"]
        DEPLOY["Worker Deployment<br/>2/2 Ready"]
    end
    subgraph "AWS"
        STS["AWS STS"]
        SM["Secrets Manager"]
    end
    subgraph "관리 클러스터"
        ARGO["ArgoCD"]
    end
    ES -- "① SA 토큰 요청" --> KA
    ES -- "② AssumeRoleWithWebIdentity" --> STS
    ES -- "③ GetSecretValue" --> SM
    ES -- "④ Secret 동기화" --> SEC
    SEC -. "envFrom" .-> DEPLOY
    ARGO -- "health 모니터링" --> ES

①번 SA 토큰 발급이 실패하면 ②번 STS 호출로 넘어갈 수 없고, ②번이 없으면 ③번 Secrets Manager 호출도 불가능하다. 직렬 의존성 구조에서 첫 번째 링크가 끊어지면 체인 전체가 실패한다.

에러 메시지를 안쪽(gRPC)부터 바깥(controller-runtime)으로 분해하면 이 연쇄 실패의 경로가 명확해진다.

계층에러의미
gRPCthe client connection is closingkube-apiserver와의 연결이 닫히는 중에 요청이 들어옴
kube-apiserverfailed to generate token: while signing jwt연결이 닫히면서 JWT 서명 요청 자체가 취소됨
IRSAfailed to refresh cached credentialsSA 토큰 없이는 IRSA credential 갱신 불가
External SecretsGetSecretValue, get identity: get credentialscredential 없이는 Secrets Manager API 호출 불가
controller-runtimeReconciler error에러를 로깅하고 backoff requeue

gRPC 연결이 닫힌 원인은 무엇이었을까? gRPC-Go FAQ에 따르면, the client connection is closing 에러는 서버가 MaxConnectionAge에 의해 오래된 연결을 종료할 때 발생할 수 있다. EKS managed kube-apiserver의 gRPC 서버 설정에는 직접 접근할 수 없지만, 에러 메시지의 패턴이 이 동작과 일치했다. EKS 클러스터 업데이트 이력도 0건이었고, kube-system 이벤트에도 이상이 없었다. kube-apiserver의 gRPC MaxConnectionAge에 의한 연결 종료로 추정된다.

ExternalSecret 컨트롤러의 reconcile이 연결 종료 시점과 정확히 겹쳤고, controller-runtime의 backoff requeue가 약 5초 후 재시도에 성공하여 03:17:04에 GetSecretValue가 정상 호출되었다. 아래는 이 과정에서 ExternalSecret의 상태 전이를 보여준다.

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로 즉시 감지

60초 중 1.1초, 타이밍의 불운

의문이 남았다. 같은 ExternalSecret 컨트롤러가 7개 프로덕션 secret을 관리하는데, 왜 이 서비스만 영향을 받았는가?

같은 시간대의 Loki 로그를 확인했다. 나머지 6개 secret은 모두 에러 없이 정상 fetch되었다.

Secret시각 (UTC)결과
production/admin-api18:17:15✅ 정상
production/api18:15:13✅ 정상
production/cms-admin18:07:14✅ 정상
production/cms-api18:11:11✅ 정상
production/cms-web18:14:17✅ 정상
production/web18:13:25✅ 정상
production/worker18:16:59❌ gRPC 끊김

gRPC 연결이 닫히는 동안의 race window는 Loki 로그 기준 약 1.1초였다. refreshInterval이 60초이므로, gRPC 연결 종료 1회당 reconcile이 이 윈도우와 겹칠 확률은 약 1.8%(1.1초 / 60초)다. 워커 서비스의 reconcile 타이밍만이 이 1.1초 윈도우 안에 들어간 것이었다.

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: gRPC 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

ArgoCD는 Kubernetes watch로 managed resource를 감시한다. ExternalSecret의 status.conditionsReady=False로 변경되는 순간 watch event가 발생하고, ArgoCD의 built-in Lua health check가 이를 Degraded로 판정한다. grace period나 flapping 방지 메커니즘이 없기 때문에, 수초 단위의 일시적 실패도 즉시 알림으로 이어졌다.


진짜 문제는 장애가 아니라 알림이었다

6초 만에 자동 복구되었고, Pod 재시작도 트래픽 유실도 없었다. 서비스에는 아무 영향이 없었다. 하지만 알림 메시지는 “신규 배포에 실패했습니다”였다. 실제로는 배포와 무관한 ExternalSecret gRPC 실패였는데, 알림만 보면 누군가의 배포가 실패한 것처럼 보였다.

ArgoCD on-health-degraded 알림 템플릿이 모든 Degraded 상태에 대해 동일한 고정 문구를 사용하고 있었다. Degraded를 유발한 리소스의 종류(kind)나 이름도 알림에 포함되지 않아, 알림만으로는 원인 파악이 불가능했다.

즉시 조치 — 알림 템플릿의 문구를 수정했다.

# Before
text: "신규 배포에 실패했습니다"
 
# After
text: "Application의 health가 Degraded 상태입니다. 아래 리소스를 확인하세요."

이 변경으로 알림이 실제 상태를 더 정확하게 전달하게 되었다.

근본 수정 — 두 가지를 추가로 검토 중이다.

첫째, Degraded를 유발한 리소스의 kind/name을 알림에 포함하는 것이다. 현재 ArgoCD Notifications 템플릿에서 app.status.resources를 순회하여 unhealthy 리소스를 표시할 수 있는지 확인이 필요하다.

둘째, ExternalSecret의 refreshInterval을 1분에서 5분으로 완화하는 것이다. gRPC 연결 종료와 reconcile이 겹칠 빈도가 1/5로 줄어든다. 다만 secret 변경의 반영 지연이 1분에서 5분으로 늘어나므로, 긴급 secret 교체가 필요한 상황에서의 트레이드오프를 고려해야 한다.

Deployment에 reloader.stakater.com/auto: "false"가 설정되어 있었다는 점도 기록해 둘 만하다. 만약 "true"였다면 ExternalSecret이 일시적으로 실패하는 동안 Secret 변경이 감지되어 불필요한 Pod rolling restart가 트리거될 수 있었다.


이 장애가 남긴 것

“배포 실패” 알림을 받았을 때 가장 먼저 한 일은 GitOps 저장소의 커밋 이력을 확인하는 것이었다. 이 판단 자체는 합리적이었지만, 알림 문구를 액면 그대로 받아들인 결과 “배포”라는 프레임 안에서만 원인을 찾으려 했다. ArgoCD의 Degraded는 배포 실패만 의미하지 않는다. ExternalSecret, Ingress, PDB 등 모든 managed resource의 health 변화가 Degraded를 유발할 수 있다. 알림이 전달하는 것은 “무엇이 실패했는가”가 아니라 “어딘가에서 상태가 변했다”는 신호일 뿐이다. 알림 문구를 믿기 전에, 어떤 리소스가 unhealthy인지부터 확인하는 것이 올바른 순서다.

gRPC 연결은 언제든 끊어질 수 있다. kube-apiserver와의 gRPC 연결은 MaxConnectionAge, keepalive timeout 등에 의해 주기적으로 끊어지며, 이것은 정상적인 동작이다. controller-runtime의 backoff requeue가 자동으로 재시도를 수행하기 때문에 대부분의 경우 수초 내에 복구된다. 문제는 이 수초의 틈 사이에 ArgoCD가 Degraded를 감지하고 알림을 보낼 수 있다는 점이다. 자동 복구가 동작하는 시스템에서는, 알림의 역할이 “즉시 개입하라”가 아니라 “패턴을 관찰하라”에 가깝다.

낮은 확률도 충분한 시행 횟수 앞에서는 실현된다. gRPC 연결 종료와 reconcile이 겹칠 확률은 1.8%에 불과했다. 하지만 매일 수회의 gRPC 연결 종료가 발생하고, 7개 secret이 각각 매분 reconcile을 수행한다면, 수일~수주에 1회는 겹치게 된다. CloudTrail 5일 관측 기간 중 실제 발생은 1건이었다. 확률이 낮다는 이유로 race condition을 무시할 수는 없다. 그러나 이 정도 빈도와 영향도라면, 과도한 방어 조치보다 알림의 정확성을 높이는 것이 더 효과적인 대응이다.


앞으로의 방향

가장 시급한 개선은 알림 템플릿의 정확성이다. “신규 배포에 실패했습니다”를 “Application의 health가 Degraded 상태입니다”로 변경하고, Degraded를 유발한 리소스의 kind/name을 알림에 포함하여 알림만으로 원인 파악의 첫 단서를 얻을 수 있도록 한다.

ExternalSecret의 refreshInterval을 1분에서 5분으로 완화하는 것도 검토 중이다. secret이 변경되는 빈도가 낮은 서비스에서는 5분 주기로도 충분하며, gRPC 연결 종료와 reconcile이 겹칠 확률을 0.36%로 낮출 수 있다. 다만 이 변경은 서비스별 secret 변경 빈도와 긴급도를 고려하여 개별적으로 적용할 예정이다.

ArgoCD Notifications에는 시간 기반 debounce 기능이 없다. oncePer 설정으로 같은 Git revision에 대해 알림을 1회로 제한할 수 있지만, “N초 이상 지속될 때만 알림”이라는 조건은 지원하지 않는다. 6초 만에 자동 복구되는 이벤트에 대해 새벽 3시 알림이 발생하는 구조는, 현재 ArgoCD Notifications의 한계 안에서는 완전히 해결하기 어렵다.


참고 자료


정보 공백 요약

#필요한 정보이유위치
1CloudTrail GetSecretValue 호출 이력 스크린샷03:16:59 호출 누락이라는 핵심 관찰의 시각적 근거CloudTrail에서 사라진 1분 섹션
2ArgoCD Notification 템플릿에서 unhealthy resource kind/name을 표시하는 Go template 예시알림 개선의 구체적 구현 방법 제시해결 방법 섹션
3gRPC 연결 종료 빈도에 대한 장기(30일 이상) 관측 데이터재발 빈도 추정의 신뢰도 향상60초 중 1.1초 섹션