본문으로 건너뛰기

Audit Chain Integrity (#520 Phase 14 Layer 2)

GenD 의 application audit log 가 HMAC chain 으로 변조 탐지된다. 본 문서는 일일 검증 CronJob 의 운영, chain 활성화 첫 day 의 false positive 처리, Slack 알람 카테고리 별 대응법을 다룬다.

Refs: design doc docs/DESIGN_AUDIT_CHAIN.md (없으면 PR #749/#750/#751 참조).

동작 개요

gend-api 가 사용자 요청 처리

AuditMiddleware 가 record 생성:
{ event, user_id, method, endpoint, status_code, ...,
@timestamp, pod_id, worker_pid, prev_hash, record_hash, hmac }
↓ stdout JSON 로깅 (Fluent Bit 수집 → OpenSearch `gend-audit-YYYY.MM.DD`)

audit-chain-verify CronJob (매일 17:00 UTC = 02:00 KST)
- **UTC 어제** 인덱스 streaming fetch (v1.2+, #3027)
- (pod_id, worker_pid) 그룹별 chain 검증
- record_hash 재계산 + hmac 재계산
- 깨진 record / signed=0 시 Slack 알림 + exit 1
(단 `transition_mode=True` 인 경우 signed=0 + unsigned 만 — broken=0 이면 정상 통과)

:::info 검증 대상은 UTC 어제 인덱스다 (v1.2+, #3027) 인덱스 접미사 YYYY.MM.DDUTC 일자다 (fluent-bit Logstash_Format). 종전의 "KST 어제" 계산은 17:00Z 실행 시점의 UTC 오늘 = 아직 7시간 더 기록될 라이브 인덱스를 검증했고, 그 결과 매일 17:00-24:00Z 구간이 어떤 실행에서도 재검증되지 않는 맹점이 있었다 (2026-08-07 Job 재시도 간 total 이 증가한 것으로 실측). 현재는 실행 시점에 완결된 UTC 어제 인덱스를 전량 검증한다 — 탐지 지연은 늘지만 (최대 41h) 적재 신선도는 audit_ingest_exporter 알람(1h, #2997)이 담당한다. :::

chain 활성화 transition 처리

문제: chain 활성화 day 의 인덱스에는 활성화 이전 record (unsigned, 키 없는 시점에 적재) 와 이후 record (signed) 가 혼재. verifier 가 모든 unsigned 를 broken 으로 카운트하면 첫 day 부터 운영 알람 발생 — false positive.

해결: env GEND_AUDIT_CHAIN_START_DATE (YYYY-MM-DD) 설정 시 target index date 가 그 날짜 이전/같으면 transition_mode=True 로 unsigned record 를 별도 transition_unsigned 로 분류 (broken 카운트 제외).

:::caution 이 값은 UTC 인덱스 일자 스케일로 적는다 (v1.2+, #3027) 비교 상대인 target index date 가 UTC 기준으로 바뀌었다. KST 날짜로 적으면 경계가 하루 어긋나 transition 유예(=unsigned 를 broken 으로 세지 않는 기간)가 하루 더 길어지고, "키 누락 의심" 알람이 그만큼 지연된다. :::

# infra/audit/audit-chain-verify-cronjob.yaml 의 env
- name: GEND_AUDIT_CHAIN_START_DATE
value: "2026-05-03" # GenD prod chain 활성화 일자

활성화 이후 인덱스 (e.g. 2026-05-04 ~ 검증 대상) 는 transition_mode=False 로 strict 검증 — 그 이후의 unsigned record 는 키 누락 의심 알람 발생.

intra-day 프로세스 재시작 처리 (#2646)

문제: 컨테이너가 pod 이름을 유지한 채 in-place 재시작하면 (kubelet liveness 재시작 등) middleware 는 메모리 chain 상태를 잃고 GENESIS 부터 새 세그먼트를 시작한다. verifier 가 이를 모델링하지 않으면 같은 (pod_id, worker_pid) 그룹 중간의 GENESIS record 1건이 그날 나머지 전 record 를 연쇄 broken 으로 만든다 (2026-07-23 prod 실측: 재시작 1회 → broken 56건 false alert).

해결: 그룹 중간의 prev_hash == GENESIS record — carryover 시드가 있는 그룹의 당일 첫 record 가 GENESIS 인 경우 (자정 직후 재시작) 포함 — 는 GENESIS 기준으로 재검증하여 record 자체 (record_hash/hmac 재계산) 가 유효하면 새 세그먼트 시작으로 수용하고 chain_restarts 로 집계한다. HMAC 키 없는 공격자는 유효한 GENESIS record 를 만들 수 없으므로 (hmac 재계산 검증 유지) 변조 탐지력 저하는 없다. chain_restarts > 0 이면 Slack ok 메시지에 suffix 로 표기 — 잦은 재시작 (일 2회+) 은 liveness probe / OOM 등 워크로드 안정성 이슈로 별도 조사.

prev_hash 불연속 재정착 (v1.2+, #3027)

문제: chain 의 앞 구간이 인덱스에서 사라지면 (전일 인덱스 통째 유실 #2974, fluent-bit 버퍼 만료로 중간 record 유실 등) 그 경계 record 의 prev_hash 는 존재하지 않는 record 를 가리킨다. 종전 verifier 는 이 지점에서 expected_prev 를 바꾸지 않아 그룹 잔여 전체가 연쇄 broken 으로 증폭됐다 — 2026-08-07 prod 실측: 실제 불연속 2곳 (08-05 유실일을 관통해 생존한 pod 2개의 그룹 첫 record) 이 broken 1158 건으로 보고됐고, Slack 상위 5건이 전부 한 그룹이라 같은 날 다른 그룹의 진짜 변조가 가려질 수 있었다.

해결: prev_hash 만 기대와 다르고 record 자체가 self-valid (record_hash/hmac 재계산 통과) 면 불연속 지점 1건을 broken 으로 보고한 뒤 expected_prev 를 그 record 로 재정착한다 (chain_discontinuities 집계). #2646 의 GENESIS 재시작 수용과 동일 기준 — HMAC 키 없는 공격자는 self-valid record 를 만들 수 없으므로 탐지력 (alert + exit 1) 저하는 없고, broken 건수가 실제 불연속 지점 수와 일치하게 된다.

알람 메시지의 carryover 라인: 실패 알람에 carryover: prev_index=... groups=N 라인이 포함된다 (v1.2+). carryover_error=... 가 붙어 있으면 전날 인덱스 조회가 실패한 상태 (부재 404 = 유실/적재 중단 이력 의심) 로, 그룹 첫 record 의 prev_hash discontinuity전일 유실의 여파일 가능성이 높다 — 변조 판단 전에 적재 이력 (AuditIndexMissing/AuditIngestStalled 알람 이력, #2974) 을 먼저 확인한다. carryover 라인이 정상 (error 없음) 인데 불연속이 보고되면 인덱스 내 record 삭제/변조 가능성으로 격상 조사한다.

Slack 알람 카테고리

통지 경로가 살아 있는지부터 확인한다 (v1.3+)

:::danger 웹훅이 없으면 이상을 발견해도 내용이 도달하지 않는다 검증기는 이상을 찾으면 Slack 으로 내용을 실어 보내고, 보내지 못했을 때만 exit 1 로 CronJob 을 실패시킨다. 즉 웹훅이 비어 있으면 운영자가 받는 것은 내용 없는 Job failed to complete 한 줄뿐이다 — 감사 문제인지 파드가 죽은 건지 구분할 수 없다.

실제로 그렇게 됐다. 2026-08-07 에 불연속 138건이 발생했는데 통지 경로가 비어 있어 아무도 내용을 받지 못했다 (#3252 / #3259).

정상인 날에도 상태가 로그에 남는다 (v1.3+). 이상이 없어도 주기적으로 확인하라 — 종이 끊긴 것은 화재 전에 알아야 한다:

# 라벨은 `app=audit-chain-verify` 다 (2026-08-09 실측 — `app.kubernetes.io/name` 은 없다)
JOB=$(kubectl -n gend get jobs -l app=audit-chain-verify \
--sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}')
kubectl -n gend logs "job/$JOB" --all-containers | grep AUDIT_ALERT_CHANNEL

파드가 이미 GC 됐으면 Loki 에서 본다:

# {namespace="gend", pod=~"audit-chain-verify.*"} |= "AUDIT_ALERT_CHANNEL"
출력
AUDIT_ALERT_CHANNEL configured=true channel=#genon-gend-개발정상
AUDIT_ALERT_CHANNEL_UNCONFIGURED⛔ 이상을 찾아도 내용이 도달하지 않는다

웹훅은 gend-audit-verify-slack 시크릿의 SLACK_WEBHOOK 키에서 읽는다. HMAC 키 시크릿(gend-audit-hmac-key)과 분리돼 있다 — 그쪽은 SealedSecret 소유라 손으로 키를 더하면 다음 reconcile 에서 사라지고(명령은 성공하는데 값은 남지 않는다), 무엇보다 감사 서명의 단일 진실 공급원이라 웹훅 때문에 재봉인할 값이 아니다. 생성 절차는 infra/monitoring/README.md 참조. :::

수신 채널 (v1.1+)

#genon-gend-개발GEND_AUDIT_VERIFY_SLACK_CHANNEL 로 지정한다.

:::warning 채널을 비우면 베타 피드백 채널로 섞인다 GEND_AUDIT_VERIFY_SLACK_WEBHOOK 은 Alertmanager 와 동일한 웹훅이고, 그 웹훅의 기본 채널은 베타 피드백 채널(#genon-gend-베타테스트)이다. 채널을 지정하지 않으면 성공 시에도 매일 발송되는 감사 알림이 피드백 채널로 섞여 /beta-feedback 루프가 봇 메시지에 묻힌다 (#2850 후속).

오버라이드는 레거시 수신 웹훅에서만 먹는다 — Slack App 방식 웹훅이면 channel 이 조용히 무시되고 기본 채널로 계속 간다. 웹훅을 교체하면 모니터링 가이드의 양방향 판별 절차를 다시 실행한다.

CronJob 은 gend-api:latest + imagePullPolicy: Always 다 — env 만 넣고 코드가 배포되지 않으면 무효이므로, 매니페스트 적용과 이미지 갱신이 짝으로 필요하다. :::

verifier 는 broken 의 패턴에 따라 메시지 카테고리를 분기한다 (#751). 우선순위는 표 행 순서대로 (위가 먼저)audit_chain_verify.decide_category() 와 동일:

#카테고리조건운영자 액션
chain integrity gapbroken 에 record_hash mismatch / prev_hash mismatch / prev_hash discontinuity / hmac mismatch / SIGN_FAILED 포함 (unsigned 외 reason)잠재적 변조 또는 데이터 손상. broken record 의 @timestamp, pod_id, worker_pid 로 정확한 시점 + 위치 식별. prev_hash discontinuity + carryover_error 조합이면 먼저 불연속 재정착 절차로 전일 유실 여부부터 배제 후 보안 인시던트 절차 발동
키 누락 의심signed=0 + unsigned>0 (non-transition)gend-api Pod 의 GEND_AUDIT_HMAC_KEY env 확인 → SealedSecret gend-audit-hmac-keyGEND_AUDIT_HMAC_KEY 키 부재 또는 잘못된 base64? Pod restart 후 재검증
chain transition 의심signed>0 + unsigned>0 (non-transition)새 활성화 cutover 가능 → GEND_AUDIT_CHAIN_START_DATE env 추가 검토. 또는 일부 Pod 만 키 미주입 — Helm/SealedSecret 동기 확인

CronJob 운영

수동 trigger

kubectl --context aks-genos-prod create job \
--from=cronjob/audit-chain-verify \
audit-chain-verify-manual-$(date +%s) -n gend

특정 날짜 검증

# transition_mode 자동 결정 (CHAIN_START_DATE 비교)
kubectl --context aks-genos-prod create job audit-verify-2026-05-03 -n gend \
--from=cronjob/audit-chain-verify
kubectl --context aks-genos-prod set env job/audit-verify-2026-05-03 \
-n gend GEND_AUDIT_VERIFY_DATE=2026.05.03

로그 조회

kubectl --context aks-genos-prod logs job/<job-name> -n gend

기대 로그 (정상):

verify start: index=gend-audit-YYYY.MM.DD verify_ssl=true transition_mode=false
verify done: total=N signed=N unsigned=0 transition_unsigned=0 ok=N chain_restarts=0 chain_discontinuities=0 broken=0

기대 로그 (intra-day 재시작 있던 날 — 정상 통과, #2646):

verify done: total=N signed=N unsigned=0 transition_unsigned=0 ok=N chain_restarts=1 broken=0
:white_check_mark: GenD audit chain ok — index=... (chain restarts: 1 —
process/container in-place restart, #2646)

기대 로그 (transition day, e.g. 2026-05-03):

verify start: index=gend-audit-2026.05.03 verify_ssl=true transition_mode=true
verify done: total=N signed=M unsigned=0 transition_unsigned=K ok=M broken=0
:white_check_mark: GenD audit chain ok — index=... (transition_mode active —
K unsigned records before chain start ignored)

첫 day 검증 결과 (운영 산출, 2026-05-03)

verify done: total=5 signed=2 unsigned=3 transition_unsigned=0 ok=2 broken=3
ERROR :rotating_light: ... key 누락 의심
- unsigned record (chain field 미존재 — 키 누락 의심) ×3

위 결과는 GEND_AUDIT_CHAIN_START_DATE 미설정 상태의 첫 검증. 본 docs 의 env 주입 후 동일 인덱스 검증 시 transition_mode=true 로 unsigned 가 broken 에서 제외됨 → exit 0.

감사 기록 유실 사건 (2026-08-05 ~ 08-07)

:::danger 이 기간의 감사 기록은 완전하지 않습니다 아래 기간에 애플리케이션 감사 레코드가 OpenSearch 에 적재되지 못했습니다. 변조가 아니라 전송 중 유실이며, 유실분은 인덱스에 존재하지 않습니다. 감사 기록을 근거로 이 기간을 판단할 때 이 사실을 함께 고려해야 합니다. :::

확인된 사실

항목내용
기간2026-08-05 ~ 08-07 (UTC)
성격유실 — 변조·삭제가 아님. 남아 있는 레코드는 전부 self-valid
08-08 이후유실 없음

유실 레코드 수 (실측 — Loki 대조)

Loki(독립 수집기)에 남은 원본과 OpenSearch 적재분을 record_hash 로 대조해 직접 셌습니다. 추정이 아닙니다.

날짜Loki 원본OpenSearch 적재유실
2026-08-0523,1540 (인덱스 통째로 부재)23,154
2026-08-0635,10932,5302,579
2026-08-0738,68433,8824,802
합계30,535

:::danger 이 숫자는 애플리케이션 감사(gend-api)만 입니다 같은 사건이 다른 수집 경로에도 영향을 줬고, falco 쪽이 훨씬 큽니다. :::

다른 수집 경로 (실측 2026-08-09)

인덱스08-0508-0608-0708-08판정
gend-audit-falco312,538392,0893,913427,800585,342건 복구 (아래)
gend-audit-pg1,345,5911,194,9381,297,0981,503,386유실 확인 — 하루 약 12만건 추정. 복구는 보류
gend-audit-arango부재106,8807,509소규모
gend-audit-weaviate부재232,291137소규모
gend-audit-vault부재부재부재23,242유실 아님 — 그 뒤 도입(#3209·#3230)

:::tip falco 2026-08-07 — 425,817건 복구했습니다 (2026-08-09) 원본 인덱스는 3,913건뿐이었고(이웃 날짜 31만~42만), Loki 에 429,730건이 남아 있어 425,817건gend-audit-falco-recovered-2026.08.07 로 복구했습니다. 앱 감사 복구분(30,535건)의 약 13.9배입니다. :::

falco 복구 실적 (전 날짜)

날짜원본Loki복구
08-05312,538442,444129,906
08-06392,089421,70829,619
08-073,913429,730425,817
585,342

pgaudit — 유실은 확인, 복구는 보류

측정 조건 — 날짜 2026-08-07(UTC), 인덱스 gend-audit-pg-2026.08.07, Loki 쿼리 {namespace="gend", app="postgresql"} |= "AUDIT:", 1분 창으로 분할 집계 (측정 2026-08-09).

시간대 (UTC)조회 구간OpenSearchLoki(AUDIT:)차이
01시01:00~02:00056,743+56,743
02시02:00~03:00060,724+60,724
12시 (대조)12:00~13:0067,73064,705−3,025
20시 (대조)20:00~21:0062,89160,336−2,555

:::note 대조 구간이 이 판정의 근거입니다 정상 시간대는 두 쪽이 거의 같고(Loki 가 오히려 조금 적음 — 조회 상한), 유실 구간만 OpenSearch 가 0 입니다. 차이가 수집 방식이 아니라 실제 유실임이 갈립니다.

★ Loki 쿼리에 fluent-bit 과 같은 필터(|= "AUDIT:")를 걸어야 합니다. 안 걸면 Loki 는 PostgreSQL 컨테이너의 모든 라인을 세므로 정상 시간대에도 큰 차이가 나와 유실을 부풀립니다(실측: 필터 없이 재면 6시간에 37만건 차이). :::

복구를 보류한 이유: 대조 키(session_id+seq)를 만들 수 없는 문서가 17.4% (무작위 표본 500건 중 87건, gend-audit-pg-2026.08.07, 측정 2026-08-09) 있습니다 (#3302). 그 처리를 정하지 않고 복구하면 "일부만 들어간 인덱스" 가 남는데, 그게 없는 것보다 나쁠 수 있습니다 — 있는 것이 전부라고 오해하게 되기 때문입니다.

:::warning falco 복구본은 앱 감사 복구본과 증명 수준이 다릅니다 앱 감사는 record_hash·hmac재계산해 진짜 그 레코드임을 증명했습니다. falco 에는 서명이 없어 그럴 수 없습니다.

  • 대조 키는 hostname + output 의 sha256 입니다. output 이 나노초 타임스탬프로 시작하므로(11:59:55.005205564: Notice ...) 같은 호스트에서 같은 나노초에 같은 내용이 두 번 나오지 않는 한 유일합니다
  • 같은 키를 가진 이벤트는 하나로 합쳐집니다. 위 조건상 실제로 일어나기 어렵지만, 일어난다면 그 중복은 복구본에 한 건으로만 남습니다
  • 이 키는 중복을 막을 뿐 진위를 보장하지 않습니다 — 비밀키 서명이 없어 hmac 같은 검사를 할 수 없습니다
  • 따라서 falco 복구본이 말할 수 있는 것은 "Loki 에 이 라인이 있었다" 까지입니다
  • 구현: scripts/recover_audit_from_loki.pySOURCES["falco"]["key_fn"]

감사 증거로 인용할 때 이 차이를 함께 밝히십시오. :::

:::note pgaudit 의 "약 18.6만 유실" 은 근거가 약합니다 종전 외삽치입니다. 일간 변동이 1.19M~1.50M 로 크기 때문에 이웃 날짜 비교만으로는 유실을 분리할 수 없습니다. 인용하지 마십시오 — Loki 대조가 선행입니다. :::

chain_discontinuities 를 유실량으로 인용하지 마십시오

검증 리포트의 chain_discontinuitiesself-valid 레코드의 prev_hash 불일치 지점 수입니다. 유실 레코드 수가 아닙니다.

한 (pod, worker) 체인이 통째로 사라지면 불연속으로 셀 지점조차 남지 않고, 연속 유실은 지점 하나로 압축됩니다. 실측 배율:

날짜불연속 지점실제 유실배율
2026-08-061212,57921배
2026-08-071344,80236배

08-07 의 원시 불연속은 233건이었고, 그중 99건은 genesis(파드별 체인 첫 레코드 — 유실이 아님)입니다. mid-chain 불연속만 134건입니다.

원인

애플리케이션은 정상 기록했습니다. Fluent Bit 과 무관한 독립 수집기 (promtail → Loki)가 같은 컨테이너 로그를 수집하고 있었고, 유실된 134건의 prev_hash 를 전수 조회한 결과 134/134 가 Loki 에 존재합니다.

유실 지점은 Fluent Bit → OpenSearch 전송 구간이며, 당시 설정이 원인입니다:

  • 입력 전부 메모리 전용 버퍼(Mem_Buf_Limit 20MB, 파일 버퍼 없음)
  • 출력 전부 Retry_Limit 10
  • tail 오프셋 DB 없음 → 재기동 시 파일 끝부터 읽어 다운타임 분량 복구 불가

하류가 약 3시간 막히자 버퍼가 차고 재시도를 소진해 청크를 영구 폐기했습니다.

:::note 청크 수와 레코드 수는 다릅니다 fluentbit_output_retries_failed_total 이 세는 것은 폐기된 output chunk 수 입니다(감사 경로 3,006 · 전체 18,839). 한 청크에는 여러 레코드가 들어갑니다. 위 표의 유실 레코드 수는 Loki 와 OpenSearch 의 record_hash 대조로 따로 산정한 것입니다. :::

확정하지 못한 것: 하류가 막힌 이유. 당시 파드가 전부 교체되고 이벤트가 GC 되어 직접 증거가 소멸했습니다. OpenSearch 가 쓰기를 거부했다는 관측과, 같은 시각 다른 인덱스는 정상 색인했다는 관측이 엇갈립니다.

관측 공백

이 기간 Prometheus 는 Fluent Bit 메트릭을 하나도 수집하지 않았습니다 (HTTP_Listen 127.0.0.1 + ServiceMonitor 부재). Fluent Bit 은 자기가 무엇을 버렸는지 fluentbit_output_retries_failed_total 로 알리고 있었지만 아무도 볼 수 없었습니다.

발견 경로도 알람이 아니었습니다 — 검증 CronJob 이 실패 상태로 남아 있는 것을 사람이 눈으로 확인했습니다. Alertmanager 는 켜져 있으나 수신자가 없습니다 (#2830).

복구 — 완료 (2026-08-09)

앱 감사분 30,535건을 복구했습니다. Loki(보존 4320h = 180일)에 원본이 남아 있었습니다.

:::info 원본 인덱스에 되쓰지 않았습니다 감사 로그를 사후에 채워 넣으면 "그때 수집된 것" 과 "나중에 넣은 것" 이 구분되지 않습니다 — 완전성을 회복하려다 신뢰성을 잃습니다. 그래서:

  • 대상은 gend-audit-recovered-YYYY.MM.DD — 이름으로 구분됩니다
  • 각 문서에 _recovery 메타(출처·복구 시각·도구·원본 인덱스)를 붙였습니다. 문서를 옮겨 담아도 재구성분임이 남습니다
  • 원본 인덱스는 읽기만 했습니다. ISM 의 read-only 를 해제하지 않았습니다 :::

백필이 지켜야 할 조건 (이 복구가 지킨 것):

  • Loki 의 원본 감사 JSON 을 그대로 넣습니다. event·prev_hash·record_hash· hmac·@timestamp·pod_id·worker_pid 를 변경하거나 재서명하지 않습니다. 재서명하면 그 순간 "원본이 그랬다" 는 증거가 사라집니다
  • 문서 _idrecord_hash 로 고정해 멱등하게 합니다(재실행 0건 확인)
  • 적재 응답이 아니라 재조회로 확인합니다. bulk 200 은 "요청이 받아들여졌다" 입니다

검증 — 표본이 아니라 전량입니다.

복구본 30,535건 전부에 대해 두 가지를 재계산했습니다:

검사무엇을 말하는가결과
record_hash 재계산내용 무결성 — Loki 를 거치며 이스케이프·절단이 없었다30,535 / 30,535 일치
hmac 재계산 (비밀키)진위 — 그때 앱이 이 키로 서명한 레코드다30,535 / 30,535 일치

두 검사는 말하는 바가 다릅니다. record_hash 는 "내용이 그대로다" 이고, hmac 은 "우리 키로 서명된 것이다" 입니다. 후자가 있어야 진위를 말할 수 있습니다.

도구: scripts/recover_audit_from_loki.py

검증 CronJob 의 실패는 이미 지나갔습니다

audit-chain-verify 는 매일 UTC 어제 인덱스만 검증합니다. 따라서 08-07 인덱스를 검증한 것은 08-08 실행 한 번뿐이고, 그 실행이 실패했습니다. 08-09 이후 실행은 08-08 이후 인덱스를 보므로 정상 통과합니다 — 손상된 날짜를 매일 다시 밟지 않습니다.

:::warning 손상된 날짜는 다시 검증되지 않습니다 같은 구조의 반대면입니다 — 어제만 보므로 과거 인덱스의 손상은 재검증되지 않습니다. 08-07 인덱스는 유실로 인해 영구적으로 불완전한 상태이고(불연속 134건), 정기 검증은 그것을 다시 보고하지 않습니다. 이 문서와 gend-audit-recovered-* 인덱스가 그 기록입니다. :::

이 사건은 알람으로 잡히지 않았습니다 (두 겹)

  1. 수신자 부재 — Alertmanager 는 켜져 있으나 알림이 아무 데도 가지 않습니다 (#2830).

  2. 알람 자체가 발화하지 않습니다kube_job_failed > 0audit-chain-verify 가 실제로 잡히지만(실측), KubeJobFailed 룰은 unless 절로 CronJob 이 그 뒤에 한 번이라도 성공하면 과거 실패를 덮습니다. 실측:

    kube_job_failed{...} > 0 → 3건 (audit-chain-verify 포함)
    ALERTS{alertname="KubeJobFailed"} → 0건
    kube_cronjob_status_last_successful_time → 나중 성공이 있어 unless 가 제외

    수신자를 붙였더라도 이 실패는 통지되지 않았을 것입니다. 매일 도는 작업이 "가끔 실패하고 다음엔 성공" 하는 형태면 이 룰은 영원히 조용합니다.

    그래서 알람 룰에만 기대지 말고 CronJob 의 마지막 성공 시각을 직접 봐야 합니다 (report-login-blocked.sh 가 로그가 아니라 Keycloak 상태를 직접 보는 것과 같은 이유).

관련

  • #3252 — 조사 경과·실측
  • #3263 — 재발 방지 (파일 버퍼·오프셋 DB·메트릭 배선)
  • #2830 — 알람 수신자 부재

pgaudit 수집 — 다중행 SQL 처리 (v1.2.1+)

pgaudit 은 SQL 을 원문 그대로 감사 로그에 싣습니다. 쿼리에 개행이 있으면 컨테이너 로그가 여러 줄로 쪼개지고, 둘째 줄부터는 AUDIT: 로 시작하지 않습니다.

2026-08-07 00:00:00.036 UTC [1123988] gend@dagster LOG: AUDIT: SESSION,1,1,READ,
SELECT,TABLE,public.daemon_heartbeats,"SELECT daemon_heartbeats.body
FROM daemon_heartbeats",<none>

:::danger 수정 전에는 이 내용이 사라졌습니다 둘째 줄이 CSV 파서를 통과하지 못하고, 뒤이은 Remove_key log 가 원문까지 지워 @timestamp·flags·stream 만 남았습니다.

prod 실측(2026-08-11, gend-audit-pg-2026.08.07): 1,297,098건 중 244,691건(18.9%) 이 그 상태였습니다 — 누가 무엇을 실행했는지가 없습니다. 문서 수는 정상이라 개수 기반 점검으로는 보이지 않습니다. :::

파이프라인 순서 (바꾸면 재발합니다)

[INPUT] tail multiline.parser cri ← partial(P) 조각 재조립
[FILTER] multiline pg_multiline ← SQL 연속 라인 결합 ★ grep 앞
[FILTER] grep Regex log AUDIT:
[FILTER] parser Parser pgaudit
[FILTER] record_modifier Remove_key log

multiline 필터는 반드시 grep입니다. 뒤에 두면 AUDIT: 없는 연속 라인이 grep 에서 먼저 버려져 합칠 대상이 사라집니다.

cri 이지 docker 가 아닙니다

이 리포의 [PARSER] docker이름만 docker 이고 실제 정의는 CRI 정규식입니다:

^(?<time>[^ ]*) (?<stream>stdout|stderr) (?<flags>[^ ]*) (?<log>.*)$

AKS(containerd)의 /var/log/containers/*.log 가 CRI 형식이기 때문입니다. 여기에 Fluent Bit 내장 multiline.parser docker(Docker JSON 기대)를 쓰면 파싱이 통째로 실패해 pgaudit 수집이 멈춥니다.

문서의 flags 필드(F=full, P=partial)가 CRI 의 지문입니다.

적용 절차 (CI 자동 배포 아님)

Fluent Bit 은 main 머지만으로 반영되지 않습니다. 수동 적용이 필요합니다.

# 1) 롤백 지점 확보
kubectl -n gend get cm fluent-bit-config -o yaml > /tmp/rollback-fb-cm.yaml

# 2) 적용
kubectl -n gend apply -f infra/fluent-bit/configmap.yaml

# 3) ConfigMap 변경만으로는 반영되지 않는다 — 롤아웃 필요
kubectl -n gend rollout restart ds/fluent-bit
kubectl -n gend rollout status ds/fluent-bit --timeout=300s

검증

# 파드가 multiline 필터를 올렸는가
kubectl -n gend logs <fluent-bit-pod> --tail=50 | grep -E "multiline|error"

# 새로 들어온 문서에 내용이 있는가 (오늘 인덱스)
# 내용 없는 문서 비율이 18.9% 에서 떨어져야 한다

:::warning 롤아웃 중 짧은 수집 공백이 생깁니다 그 공백은 오프셋 DB 로 복구되지만, 이번 롤아웃 자체의 공백은 종전과 같습니다. 저트래픽 시간대를 권합니다. :::

이미 사라진 244,691건

이 수정은 앞으로 들어올 로그에만 적용됩니다. 기존 유실분은 Loki 에 원문이 남아 있어 복구 가능하며, 원문이 보존되기 시작하면 #3292 의 pgaudit 복구도 가능해집니다 — 원문 해시를 대조 키로 쓸 수 있어 현재의 키 유일성 문제(29.5%)가 사라집니다.

운영 도구 — 감사 유실 복구 recover_audit_from_loki.py (v1.2.1+)

유실된 감사 레코드를 Loki 에서 별도 인덱스로 복구합니다. 원본은 읽기만 합니다.

누가 실행할 수 있는가

필요이유
OpenSearch 읽기 자격원본·복구 인덱스 대조 (prod 는 admin)
OpenSearch 쓰기 자격복구본 적재 (prod 는 gend_fluent_bit)
Loki 접근원본 조회

★ prod 는 읽기·쓰기 자격이 분리돼 있습니다 — 수집 계정에 읽기를 주지 않는 설계입니다. _refresh 는 또 admin 작업이라 세 번째 조합이 필요합니다.

실행

# 항상 dry-run 부터 — 수가 맞는지 보고 나서 적재한다
python3 scripts/recover_audit_from_loki.py --date 2026-08-07 --source app
python3 scripts/recover_audit_from_loki.py --date 2026-08-07 --source app --apply
환경변수기본설명
LOKI_URL클러스터 내부Loki 엔드포인트
GEND_OPENSEARCH_URL / _USER / _PASSWORD읽기 자격
GEND_OPENSEARCH_WRITE_USER / _WRITE_PASSWORD읽기 자격 재사용쓰기 자격
GEND_OPENSEARCH_CA_BUNDLETLS 검증용 CA
GEND_OPENSEARCH_VERIFY_SSLtruefalse 로만 검증 해제
--source대상 인덱스대조 키
appgend-audit-*record_hash (HMAC 체인)
falcogend-audit-falco-*hostname+output 해시
pgaudit비활성 — 키가 유일하지 않음 (아래)

:::danger pgaudit 복구는 비활성입니다 — 키가 유일하지 않습니다 session_id+seq 를 대조 키로 쓰려 했으나 파서가 이름을 잘못 붙였습니다. pgaudit 의 그 필드는 세션 식별자가 아니라 statement_id/substatement_id(세션 내 카운터)라 값이 '1' 같은 작은 정수입니다.

prod 실측 (표본 20,000건, gend-audit-pg-2026.08.07, 2026-08-11):

distinct / 키 있는 문서판정
session_id+seq4,671 / 15,846 = 29.5%70% 병합
ts+pid+session_id+seq14,948 / 15,846 = 94.3%5.7% 병합

전자로 적재하면 130만건이 3.5만건으로 뭉개집니다. 후자도 5.7%가 조용히 병합되는데, 감사 기록에서는 받아들일 수 없습니다.

추가로 표본의 20.8% 는 키 필드가 아예 없습니다 (#3302 파싱 결함) — 어떤 키로도 대조할 수 없습니다.

근본 해결은 #3302 입니다. Remove_key log 로 원문이 지워지는 것을 고치면 falco 처럼 원문 해시를 쓸 수 있어 유일성 문제가 사라집니다. 순서가 반대였습니다. :::

종료 코드

코드
0복구할 것 없음 (또는 적재 완료)
1복구할 것이 있는데 --apply 없이 끝남
2판정 불가 — 조회·적재 실패, 파싱 실패, 설정 오류

:::warning 2 를 0 과 섞지 마십시오 "조회에 실패했다" 와 "유실이 없다" 는 다릅니다. 자동화에서 2 를 0 으로 처리하면 장애가 정상으로 보고됩니다. :::

복구본의 성질

  • 대상은 <원본접두어>-recovered-YYYY.MM.DD원본은 손대지 않습니다
  • 각 문서에 _recovery 메타(출처·시각·도구·원본 인덱스)
  • _id 를 대조 키로 고정해 멱등 — 재실행 시 0건
  • 재서명하지 않습니다. 원본 JSON 그대로 넣습니다

되돌리기

복구본 인덱스를 지우면 됩니다(DELETE /<접두어>-recovered-YYYY.MM.DD). 원본을 건드리지 않으므로 되돌림이 원본에 영향을 주지 않습니다.

운영 도구 — 알람 보고 check_alert_state.py (v1.2.1+)

발화 중인 알람과 정체된 CronJob 을 GitHub 이슈로 보고합니다 (alert-monitor.yml, 6시간 주기).

★ 알람 룰만 보지 않습니다. KubeJobFailedunless 절 때문에 CronJob 이 그 뒤 한 번이라도 성공하면 과거 실패를 덮습니다 — 매일 도는 작업이 가끔 실패하는 형태면 영원히 조용합니다. 그래서 CronJob 의 마지막 성공 시각을 직접 봅니다.

환경변수기본설명
ALERT_NSkube-prometheus-stackAlertmanager·Prometheus 네임스페이스
TARGET_NSgendCronJob 을 볼 네임스페이스
STALE_FACTOR3마지막 성공이 주기의 N배를 넘으면 정체
IGNORE_ALERTSWatchdog제외할 alertname (쉼표 구분)

종료 코드는 위와 같습니다 — 2 는 판정 불가입니다.

의존성 + 후속 작업

  • design doc: docs/DESIGN_*.md 미존재 시 PR #749 + #750 + #751 본문 참조
  • 활성화 이후 1주 안정 운영 후 GEND_AUDIT_CHAIN_START_DATE env 제거 검토 (transition mode 항상 비활성 — strict 만)
  • 키 회전 (SealedSecret 갱신 + Pod restart) 시점에 chain reset → 그 날도 CHAIN_START_DATE 갱신 필요