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.DD 는 UTC 일자다 (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 gap | broken 에 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-key 의 GEND_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-05 | 23,154 | 0 (인덱스 통째로 부재) | 23,154 |
| 2026-08-06 | 35,109 | 32,530 | 2,579 |
| 2026-08-07 | 38,684 | 33,882 | 4,802 |
| 합계 | 30,535 |
:::danger 이 숫자는 애플리케이션 감사(gend-api)만 입니다 같은 사건이 다른 수집 경로에도 영향을 줬고, falco 쪽이 훨씬 큽니다. :::
다른 수집 경로 (실측 2026-08-09)
| 인덱스 | 08-05 | 08-06 | 08-07 | 08-08 | 판정 |
|---|---|---|---|---|---|
gend-audit-falco | 312,538 | 392,089 | 3,913 | 427,800 | 585,342건 복구 (아래) |
gend-audit-pg | 1,345,591 | 1,194,938 | 1,297,098 | 1,503,386 | 유실 확인 — 하루 약 12만건 추정. 복구는 보류 |
gend-audit-arango | 부재 | 10 | 6,880 | 7,509 | 소규모 |
gend-audit-weaviate | 부재 | 23 | 2,291 | 137 | 소규모 |
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-05 | 312,538 | 442,444 | 129,906 |
| 08-06 | 392,089 | 421,708 | 29,619 |
| 08-07 | 3,913 | 429,730 | 425,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) | 조회 구간 | OpenSearch | Loki(AUDIT:) | 차이 |
|---|---|---|---|---|
| 01시 | 01:00~02:00 | 0 | 56,743 | +56,743 |
| 02시 | 02:00~03:00 | 0 | 60,724 | +60,724 |
| 12시 (대조) | 12:00~13:00 | 67,730 | 64,705 | −3,025 |
| 20시 (대조) | 20:00~21:00 | 62,891 | 60,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.py의SOURCES["falco"]["key_fn"]
감사 증거로 인용할 때 이 차이를 함께 밝히십시오. :::
:::note pgaudit 의 "약 18.6만 유실" 은 근거가 약합니다 종전 외삽치입니다. 일간 변동이 1.19M~1.50M 로 크기 때문에 이웃 날짜 비교만으로는 유실을 분리할 수 없습니다. 인용하지 마십시오 — Loki 대조가 선행입니다. :::
★ chain_discontinuities 를 유실량으로 인용하지 마십시오
검증 리포트의 chain_discontinuities 는 self-valid 레코드의 prev_hash
불일치 지점 수입니다. 유실 레코드 수가 아닙니다.
한 (pod, worker) 체인이 통째로 사라지면 불연속으로 셀 지점조차 남지 않고, 연속 유실은 지점 하나로 압축됩니다. 실측 배율:
| 날짜 | 불연속 지점 | 실제 유실 | 배율 |
|---|---|---|---|
| 2026-08-06 | 121 | 2,579 | 21배 |
| 2026-08-07 | 134 | 4,802 | 36배 |
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를 변경하거나 재서명하지 않습니다. 재서명하면 그 순간 "원본이 그랬다" 는 증거가 사라집니다 - 문서
_id를record_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-*
인덱스가 그 기록입니다.
:::
이 사건은 알람으로 잡히지 않았습니다 (두 겹)
-
수신자 부재 — Alertmanager 는 켜져 있으나 알림이 아무 데도 가지 않습니다 (#2830).
-
★ 알람 자체가 발화하지 않습니다 —
kube_job_failed > 0에audit-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 상태를 직접 보는 것과 같은 이유).
관련
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_BUNDLE | — | TLS 검증용 CA |
GEND_OPENSEARCH_VERIFY_SSL | true | false 로만 검증 해제 |
--source | 대상 인덱스 | 대조 키 |
|---|---|---|
app | gend-audit-* | record_hash (HMAC 체인) |
falco | gend-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+seq | 4,671 / 15,846 = 29.5% | 70% 병합 |
ts+pid+session_id+seq | 14,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시간 주기).
★ 알람 룰만 보지 않습니다. KubeJobFailed 는 unless 절 때문에 CronJob 이 그 뒤
한 번이라도 성공하면 과거 실패를 덮습니다 — 매일 도는 작업이 가끔 실패하는 형태면
영원히 조용합니다. 그래서 CronJob 의 마지막 성공 시각을 직접 봅니다.
| 환경변수 | 기본 | 설명 |
|---|---|---|
ALERT_NS | kube-prometheus-stack | Alertmanager·Prometheus 네임스페이스 |
TARGET_NS | gend | CronJob 을 볼 네임스페이스 |
STALE_FACTOR | 3 | 마지막 성공이 주기의 N배를 넘으면 정체 |
IGNORE_ALERTS | Watchdog | 제외할 alertname (쉼표 구분) |
종료 코드는 위와 같습니다 — 2 는 판정 불가입니다.
의존성 + 후속 작업
- design doc:
docs/DESIGN_*.md미존재 시 PR #749 + #750 + #751 본문 참조 - 활성화 이후 1주 안정 운영 후
GEND_AUDIT_CHAIN_START_DATEenv 제거 검토 (transition mode 항상 비활성 — strict 만) - 키 회전 (SealedSecret 갱신 + Pod restart) 시점에 chain reset → 그 날도 CHAIN_START_DATE 갱신 필요