Flux HelmRelease Stalled — 대기 타임아웃 한 번에 멈춘 릴리스

CI·CD6분조회 –

Keycloak PostgreSQL 의 CPU limit 을 올리는 values 변경을 머지했지만, StatefulSet 에는 예전 값이 그대로 남아 있었습니다.

FluxCD 로 배포하는 클러스터라 values 는 ConfigMap 을 거쳐 HelmRelease 가 helm upgrade 로 반영합니다. 그 경로에서 HelmRelease 하나만 멈춰 있었고, 에러도 알림도 없었습니다.

GitRepository      새 커밋을 당겨 옴               OK
Kustomization      values ConfigMap 적용           OK
ConfigMap          새 limit 값이 들어가 있음        OK
HelmRelease        Stalled — 재시도 안 함           ✗
StatefulSet        예전 값 그대로                   ✗

Stalled 에 찍힌 시각과 실제로 막힌 시각

HelmRelease 조건은 이랬습니다.

[Stalled]  RetriesExceeded: Failed to upgrade after 1 attempt(s)      (2026-06-18)
[Released] UpgradeFailed: Helm upgrade failed for release keycloak/keycloak
           with chart keycloak@24.4.14: context deadline exceeded     (2026-03-25)

Stalled 는 6월로 찍혀 있지만 막힌 건 그때가 아닙니다. 6월 18일은 클러스터 노드를 전면 교체한 날이고 helm-controller 가 재기동하면서 조건을 다시 평가해 새 시각으로 기록한 것이었습니다. 실제로 막힌 건 3월 업그레이드가 context deadline exceeded 로 실패한 때부터라, 발견한 9월까지 170일이 지나 있었습니다.

helm 릴리스 이력도 같은 시점을 가리킵니다.

sh.helm.release.v1.keycloak.v12   deployed   2026-01-21
sh.helm.release.v1.keycloak.v13   failed     2026-03-25

매니페스트는 이미 적용돼 있었음

3월에 실패한 v13 을 릴리스 시크릿에서 풀어 v12 와 비교하면, 바꾸려던 건 Keycloak StatefulSet 의 requests.cpu 를 1 에서 500m 으로 내리는 것 하나뿐이었고, 라이브 StatefulSet 과 파드는 이미 500m 이었습니다.

helm 은 매니페스트를 먼저 적용하고 그다음에 리소스가 Ready 가 될 때까지 기다립니다. v13 은 적용을 마친 뒤 기다리는 동안 기본 대기 시간 5분을 넘겨 failed 로 찍힌 것이었습니다. 3월에 왜 5분을 넘겼는지는 당시 이벤트가 남아 있지 않아 확인하지 못했습니다.

HelmRelease spec 에는 timeout 도 upgrade.remediation 도 없었습니다. 대기 5분에 재시도 0회라, 타임아웃 한 번이면 거기서 끝나는 설정입니다.

spec:
  chart:
    spec: { chart: keycloak, version: 24.4.14, sourceRef: { kind: HelmRepository, name: bitnami } }
  interval: 30m
  valuesFrom:
    - kind: ConfigMap
      name: keycloak-values

스스로 안 풀린 이유 — 두 겹

재시도가 0회라도 interval: 30m 이 있으니 30분마다 다시 시도할 것처럼 보이지만 막고 있던 게 두 겹이었습니다.

첫째는 리컨사일 자체가 돌지 않은 것입니다. helm-controller 는 RetriesExceeded 를 terminal error 로 돌려주고 그러면 spec.interval 에 따른 재큐가 걸리지 않습니다. 이틀 남짓한 helm-controller 로그에서 keycloak 줄은 하나도 없었습니다. 30분 주기라면 110번쯤 찍혀 있어야 하는 자리입니다.

둘째는 리컨사일이 돌더라도 업그레이드를 다시 시도할 조건이 없었던 것입니다. 실패 카운터가 지워져야 다시 시도하는데, 그 조건이 하나도 맞지 않았습니다. helm-controller(v1.4.3)는 아래 넷 중 하나가 바뀌어야 카운터를 리셋합니다.

① spec 변경            lastAttemptedGeneration != generation
② 차트 버전 변경        lastAttemptedRevision   != chart version
③ values 변경          config digest 불일치
④ 명시적 리셋           reconcile.fluxcd.io/resetAt 어노테이션

values 를 valuesFrom 의 ConfigMap 으로 넘기는 구성은 Git 에서 values 를 바꿔도 HelmRelease spec 이 그대로여서 ①이 생기지 않습니다. 차트 버전은 고정해 둬서 ②도 없고 ④는 사람이 걸어야 합니다. 남는 건 ③뿐인데 3월 이후 keycloak values 를 바꾼 커밋이 한 건도 없었으니, 170일 동안 풀릴 계기가 없었던 겁니다.

실제로 푼 것 — values 변경과 requestedAt

이번엔 values 를 막 바꾼 참이라 ③이 성립해 있었고 남은 건 리컨사일을 한 번 돌리는 것이었습니다.

kubectl -n keycloak annotate helmrelease keycloak \
  reconcile.fluxcd.io/requestedAt="$(date -u +%Y-%m-%dT%H:%M:%SZ)" --overwrite

helm-controller 로그와 릴리스 이력을 시간순으로 맞춰 보면 이렇습니다.

04:20:16Z  ConfigMap 갱신                              → 리셋 조건 ③ 성립
04:32:08Z  requestedAt 어노테이션                       → 리컨사일 발생
04:32:11Z  "release conditions have changed since last failure"
04:32:26Z  upgrade 성공 (v14 deployed)

ConfigMap 이 바뀐 뒤 12분 동안 helm-controller 로그에 keycloak 이 한 줄도 없는 게 첫째 겹의 증거로, values 가 바뀌어도 리컨사일은 돌지 않았던 겁니다. 반대로 values 변경 없이 어노테이션만 걸었다면 리컨사일은 돌아도 리셋 조건이 안 맞아 그대로 RetriesExceeded 로 돌아갔을 겁니다. 둘 다 있어야 했고 이번엔 우연히 둘 다 있었습니다.

requestedAt 은 리컨사일을 당기기만 할 뿐 generation 을 올리지 않아서 그것만으로는 카운터가 지워지지 않습니다. 바꿀 values 없이 재시도만 시키려면 resetAt 을 같이 걸고 두 값을 반드시 같게 맞춥니다. helm-controller 는 requestedAt 과 값이 같을 때만 resetAt 을 처리해서 $(date) 를 따로 두 번 부르면 1초 차이로 리셋이 안 걸리고, 그 타임스탬프는 처리된 것으로 기록돼 같은 값을 다시 걸어도 무시됩니다.

TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
kubectl -n keycloak annotate helmrelease keycloak --overwrite \
  reconcile.fluxcd.io/requestedAt="$TS" \
  reconcile.fluxcd.io/resetAt="$TS"

다시 돌리기 전에 대조한 것

반년 묵은 릴리스를 다시 돌리면 그사이 쌓인 변경이 한꺼번에 나갈 수 있어서 어노테이션을 걸기 전에 네 가지를 맞춰 봤습니다. 실패한 v13 의 values 와 현재 ConfigMap 의 차이는 이번에 고친 4줄뿐이었고 v13 매니페스트가 관리하는 오브젝트 16개를 라이브와 비교하니 cpu: 2 와 cpu: "2" 같은 표기 차이 하나만 나왔습니다. bitnami 차트에서 자주 사고가 나는 비밀번호 재생성은 비밀번호를 values 에 고정해 둔 구성이라 해당이 없었고, StatefulSet 의 volumeClaimTemplates 같은 불변 필드도 그대로였습니다.

업그레이드는 20초 만에 끝났고 그사이 PostgreSQL 파드가 재생성됐지만 Keycloak 파드는 readiness 를 잃지 않고 엔드포인트에 그대로 남아 있었습니다.

같은 상태를 찾는 법과 막는 설정

하나가 이렇게 멈춰 있으면 다른 것도 그럴 수 있어서 클러스터마다 훑었고, 운영 클러스터에서 같은 상태로 158일, 423일째 멈춰 있던 HelmRelease 가 네 개 더 나왔습니다.

kubectl get helmrelease -A -o json | jq -r '.items[]
  | select(.status.conditions[]? | select(.type=="Stalled" and .status=="True"))
  | "\(.metadata.namespace)/\(.metadata.name)"'

Stalled 없이 Ready=False 만 있는 건 재큐가 살아 있어 계속 재시도하는 중이라 이 경우와 다릅니다.

HelmRelease 에 대기 시간과 재시도를 주면 타임아웃 한 번에 굳어 버리는 일은 막을 수 있습니다.

spec:
  timeout: 10m
  upgrade:
    remediation:
      retries: 2

values 를 머지했는데 반영이 안 되면 HelmRelease 조건부터 열어 봅니다. Stalled 에 RetriesExceeded 가 있으면 기다려도 풀리지 않는 상태이고 언제부터 막혔는지는 Stalled 가 아니라 Released 조건의 시각과 helm 릴리스 이력에서 확인합니다.

  1. 불러오는 중