무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
ArgoCD 배포 알림 고도화 — bauhaus 키 비주얼
홈랩 삽질기
2026. 2. 4.

ArgoCD 배포 알림 고도화: oncePer 중복 방지부터 resync 튜닝까지

argocdntfykubernetesgitopshomelabnotification

이전 글에서 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 삼세대기

이 문제를 잡는 데만 세 번의 시도가 필요했다.

1세대: summary.images 기반

oncePer: app.status.summary.images[0]

위에서 설명한 대로, rolling update 중 이미지 목록이 변동하면서 2회 발송된다.

2세대: sync.revision 기반

oncePer: app.status.sync.revision

Git 커밋 SHA가 바뀔 때만 알림을 보내니까 괜찮을 거라 생각했다. 실제로 단일 앱에서는 잘 동작했다.

그런데 내 홈랩은 mono-repo다. homelab 레포 하나에 ArgoCD, cert-manager, museck 등 모든 앱의 매니페스트가 들어있다. 문제는 이거다: homelab에 push 하나 하면 모든 앱의 sync.revision이 같은 SHA로 갱신된다. staging이든 production이든 상관없이.

결과적으로 museck staging만 배포했는데 ArgoCD, cert-manager, monitoring까지 전부 알림이 온다. cross-app 중복이다.

3세대 (최종): syncResult.revision 기반

oncePer: app.status.operationState.syncResult.revision

syncResult.revision은 실제로 sync operation이 실행된 앱에서만 업데이트된다. mono-repo push로 모든 앱의 sync.revision이 바뀌더라도, 실제 변경사항이 있어서 sync가 트리거된 앱만 syncResult가 갱신된다.

이걸로 드디어 "정확히 한 번, 해당 앱에 대해서만" 알림이 온다.

expr-lang과 Go template의 함정들

oncePer만 잡으면 끝날 줄 알았는데, ArgoCD notification 설정에는 함정이 더 있었다.

expr-lang 파싱 에러

trigger의 when 조건에서 특정 이미지가 포함된 앱만 필터링하려고 이런 조건을 썼다:

{. contains "museck"}

notifications-controller 로그에 파싱 에러가 뜬다. ArgoCD의 trigger 조건은 Go의 strings.Contains가 아니라 expr-lang 라이브러리를 쓴다. 문법이 다르다. 복잡한 조건을 trigger에 넣기보다는 per-app annotation으로 알림 대상을 제어하는 게 훨씬 안정적이었다.

Go template vs YAML 파서 충돌

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) }})

배포 알림에 진짜 커밋 SHA 넣기

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으로 보여준다.

resync 간격과 알림 지연

마지막 문제. 배포는 끝났는데 알림이 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초로 단축

배운 것들

  1. oncePer의 기준 필드가 모든 것을 결정한다. 어떤 값이 "변했다"고 판단하느냐에 따라 알림 품질이 완전히 달라진다. mono-repo에서는 syncResult.revision이 유일한 정답이었다.
  2. YAML 파서와 템플릿 엔진의 실행 순서를 알아야 한다. ArgoCD 설정에서 Go template을 쓸 때 body: | 블록을 쓰지 않으면 YAML 파서가 먼저 깨뜨린다.
  3. "알림이 온다"는 시작일 뿐이다. 정확히 한 번, 의미 있는 내용으로, 빠르게 오는 알림을 만드는 건 별개의 엔지니어링이다. 배포 파이프라인만큼 알림 파이프라인에도 신경 써야 한다.
  4. resync 간격은 알림 지연에 직접 영향을 준다. health check 기반 trigger를 쓴다면, resync 주기를 반드시 확인해야 한다.

자주 묻는 질문

ArgoCD oncePer에서 어떤 필드를 기준으로 삼아야 하나요?
mono-repo 환경에서는 app.status.operationState.syncResult.revision을 사용해야 합니다. sync.revision은 mono-repo push 시 모든 앱에 동일하게 갱신되어 cross-app 중복이 발생하고, summary.images는 rolling update 중 변동하여 이중 알림이 발생합니다.
ArgoCD 알림이 배포 후 3분이나 지연되는 이유는 무엇인가요?
trigger 조건에 health.status == 'Healthy'가 포함되어 있으면 sync 완료 후 다음 resync 사이클에서 health 상태가 갱신됩니다. 기본 resync 간격이 180초이므로 최대 3분 지연됩니다. controller.appResyncPeriod를 30초로 줄이면 해결됩니다.
ArgoCD notification template에서 Go template 변수 선언 시 에러가 나는 이유는?
ArgoCD는 YAML 파싱을 먼저 하고 Go template 렌더링을 나중에 합니다. $var := ... 같은 구문이 YAML 파서에서 에러를 유발합니다. body: | (literal block scalar)로 감싸면 YAML 파서가 내용을 문자열로 처리하므로 문제가 해결됩니다.
홈랩 삽질기(17/19)
Prev

와일드카드 서브도메인으로 멀티사이트 블로그 운영하기: Cloudflare Tunnel + Traefik v3

Next

Gitea 운영 삽질기: webhook 차단, LevelDB 락, 1.25 업그레이드