
이전 글에서 ArgoCD + ntfy 알림을 처음 붙였다. 배포하면 폰에 알림이 온다. 끝? 아니었다. "알림이 온다"와 "쓸 만한 알림이 온다"는 전혀 다른 문제였다.
3일 동안 알림 하나를 제대로 만들려고 삽질했다. 중복 알림, 파싱 에러, 3분 지연, 엉뚱한 커밋 SHA까지. 겪은 문제 6개, 해결 과정 전부 기록한다.
ArgoCD notification trigger에는 oncePer라는 옵션이 있다. 같은 이벤트에 대해 알림을 한 번만 보내는 중복 방지 메커니즘이다. 원리는 간단하다. oncePer에 지정한 값이 이전과 같으면 알림을 건너뛴다. ArgoCD는 이 값을 Application의 annotation에 notified.<trigger>.<hash> 형태로 기록한다.
처음에는 app.status.summary.images[0]를 기준으로 잡았다. 이미지가 바뀌면 새 배포니까 알림을 보내고, 같으면 무시하겠다는 논리다.
문제는 rolling update 도중에 터졌다. Kubernetes가 새 Pod를 올리고 이전 Pod를 내리는 과정에서 summary.images가 [old, new] → [new]로 변한다. oncePer 입장에서는 값이 바뀐 거다. 그래서 알림이 2번 날아간다.
이 문제를 잡는 데만 세 번의 시도가 필요했다.
oncePer: app.status.summary.images[0]위에서 설명한 대로, rolling update 중 이미지 목록이 변동하면서 2회 발송된다.
oncePer: app.status.sync.revisionGit 커밋 SHA가 바뀔 때만 알림을 보내니까 괜찮을 거라 생각했다. 실제로 단일 앱에서는 잘 동작했다.
그런데 내 홈랩은 mono-repo다. homelab 레포 하나에 ArgoCD, cert-manager, museck 등 모든 앱의 매니페스트가 들어있다. 문제는 이거다: homelab에 push 하나 하면 모든 앱의 sync.revision이 같은 SHA로 갱신된다. staging이든 production이든 상관없이.
결과적으로 museck staging만 배포했는데 ArgoCD, cert-manager, monitoring까지 전부 알림이 온다. cross-app 중복이다.
oncePer: app.status.operationState.syncResult.revisionsyncResult.revision은 실제로 sync operation이 실행된 앱에서만 업데이트된다. mono-repo push로 모든 앱의 sync.revision이 바뀌더라도, 실제 변경사항이 있어서 sync가 트리거된 앱만 syncResult가 갱신된다.
이걸로 드디어 "정확히 한 번, 해당 앱에 대해서만" 알림이 온다.
oncePer만 잡으면 끝날 줄 알았는데, ArgoCD notification 설정에는 함정이 더 있었다.
trigger의 when 조건에서 특정 이미지가 포함된 앱만 필터링하려고 이런 조건을 썼다:
{. contains "museck"}notifications-controller 로그에 파싱 에러가 뜬다. ArgoCD의 trigger 조건은 Go의 strings.Contains가 아니라 expr-lang 라이브러리를 쓴다. 문법이 다르다. 복잡한 조건을 trigger에 넣기보다는 per-app annotation으로 알림 대상을 제어하는 게 훨씬 안정적이었다.
ntfy에 보내는 메시지 본문을 Go template으로 작성했다. 이런 식으로:
body: {{ $sha := .app.status.sync.revision }}{{ $sha | trunc 8 }}이것도 에러다. ArgoCD는 설정 파일을 YAML 파싱 먼저, Go template 렌더링 나중 순서로 처리한다. $sha := ... 같은 Go template 변수 선언을 YAML 파서가 먼저 만나서 깨진다.
해결책은 body: | (literal block scalar)로 감싸는 거다. YAML 파서가 내용을 순수 문자열로 취급하고, 그 뒤에 Go template 엔진이 처리한다.
body: |
{{.app.metadata.name}} deployed ({{ or (regexFind "[a-f0-9]{8}$" (call .repo.GetCommitMetadata .app.status.sync.revision).Message) (.app.status.sync.revision | trunc 8) }})mono-repo GitOps에서 한 가지 더 불편한 점이 있다. ArgoCD가 보여주는 sync.revision은 homelab 레포의 커밋 SHA다. 내가 알고 싶은 건 museck-public 레포의 커밋 SHA인데.
CI/CD 파이프라인에서 homelab 레포의 kustomization.yaml을 업데이트할 때, 커밋 메시지에 원본 앱의 SHA를 포함시켜 놨다. 이렇게:
chore(museck): update staging image to abc1234f이제 Go template에서 GetCommitMetadata로 커밋 메시지를 가져오고, regexFind로 8자리 hex SHA를 추출하면 된다:
{{ or (regexFind "[a-f0-9]{8}$" (call .repo.GetCommitMetadata .app.status.sync.revision).Message) (.app.status.sync.revision | trunc 8) }}or를 써서 정규식 매칭이 실패하면 homelab SHA 앞 8자리를 fallback으로 보여준다.
마지막 문제. 배포는 끝났는데 알림이 3분 후에 온다.
trigger 조건이 app.status.health.status == 'Healthy'를 포함하고 있었다. sync가 완료된 직후에는 health check가 아직 갱신되지 않는다. ArgoCD는 다음 resync 사이클에서 health 상태를 업데이트하는데, 기본값이 180초(3분)다.
30초로 줄였다.
configs:
params:
controller.appResyncPeriod: "30"부하가 약간 늘긴 하지만, 홈랩 규모에서는 무시할 수준이다. 배포 완료 후 30초 이내에 알림이 도착한다.
3일간의 삽질 끝에 도달한 최종 설정이다.
notifications:
templates:
template.app-deployed: |
webhook:
ntfy:
method: POST
body: |
{{.app.metadata.name}} deployed ({{ or (regexFind "[a-f0-9]{8}$" (call .repo.GetCommitMetadata .app.status.sync.revision).Message) (.app.status.sync.revision | trunc 8) }})
triggers:
trigger.on-deployed: |
- when: >-
app.status.operationState.phase in ['Succeeded']
and app.status.health.status == 'Healthy'
and app.metadata.name != 'root'
and app.status.operationState.operation.sync != nil
oncePer: app.status.operationState.syncResult.revision
send: [app-deployed]
configs:
params:
controller.appResyncPeriod: "30"각 줄이 하는 일을 정리하면:
phase in ['Succeeded'] — sync operation 성공 시에만health.status == 'Healthy' — Pod가 실제로 Ready 상태일 때만app.metadata.name != 'root' — App of Apps 패턴의 root app 제외operation.sync != nil — rollback 등 sync 외 operation 제외oncePer: syncResult.revision — 실제 sync된 앱만, 딱 1회appResyncPeriod: "30" — health 상태 갱신 주기 30초로 단축syncResult.revision이 유일한 정답이었다.body: | 블록을 쓰지 않으면 YAML 파서가 먼저 깨뜨린다.