본문으로 건너뛰기

ADR-0040: 매니페스트 Secret 격리와 create-if-missing 적용

배경 — 실제로 난 사고

ADR-0038 Phase B 를 prod 에 적용하면서 운영 런북대로 kubectl apply -f infra/mlflow/configmap.yaml 을 실행했습니다. 그런데 그 파일에는 --- 뒤에 kind: Secret 이 함께 들어 있었고, 라이브 mlflow DB 비밀번호가 레포에 하드코딩된 값으로 덮여 mlflow 가 password authentication failed 로 CrashLoop 했습니다.

적용 로그에는 secret/mlflow-secret configured 한 줄이 있었지만 configmap/mlflow-config unchanged 같은 무해한 줄 사이에 섞여 있어 사고가 난 뒤에야 알아챘습니다. 복구는 gend-pg-secret.passwordmlflow-secret 을 patch 하고 rollout restart 해서 했습니다.

조사해 보니 같은 지뢰가 레포 전역에 있었습니다 — infra/mlflow/configmap.yaml, infra/seaweedfs/deployment.yaml, infra/kserve/install.yaml.

결정

1. 파일 격리 — 매니페스트 1파일은 1종류

kind: Secret 을 포함하는 인프라 YAML 은 Secret 만 담는다. Secret 은 언제나 infra/<컴포넌트>/secret.yaml 로 분리한다.

CI 가드 scripts/test_manifest_secret_isolation.py 가 이를 강제한다 (.github/workflows/test.ymltest-manifest-secret-isolation 잡, path filter 없음). 파싱은 정규식이 아니라 yaml.safe_load_all 로 하고, kind: List 는 재귀 전개하며, data(base64) 는 패딩 보정 후 디코드한다.

2. 적용은 create-if-missing — 파일 분리만으로는 부족했다

분리해도 배포 스크립트가 apply -f secret.yaml무조건 실행하면 회전된 prod 자격증명이 레포의 정적값으로 되돌아간다. 그래서 Secret 을 적용하는 모든 배포 스크립트를 3분기로 바꾼다:

if get A && get B; then echo "✓ 기존 유지(회전값 보존)"
elif get A || get B; then echo "⚠ 한쪽만 존재 — 자동 apply 건너뜀, 수동 확인"
else apply -f secret.yaml
fi

2분기(if/else)로는 안 된다. 한 파일에 두 Secret 이 들어 있을 때 "부분 존재" 가 else 로 떨어져 파일 전체를 apply 하고, 이미 존재하던(회전됐을 수 있는) 쪽을 덮어쓴다. 실제로 infra/scripts/02-deploy-storage.sh 에 있던 버그다.

3. 미분류 Secret 파일은 fail-closed

secret.yaml 외의 secret-*.yaml 은 명시적 allowlist(_SEEDONLY_SECRETS)에 등재된 것만 apply 하고, 미등재 파일을 만나면 배포를 중단한다(exit 1).

경고만 내면 운영자가 로그에서 놓치는 순간 새 Secret 이 생성되지 않은 채 배포가 "성공" 으로 끝난다 — 이 ADR 이 없애려는 사고와 같은 형태다. 조용한 clobber 보다 시끄러운 중단이 낫다.

새 파일을 추가할 때의 결정 규칙:

성격조치
정적/부트스트랩 전용(회전 안 됨)_SEEDONLY_SECRETS 에 basename 추가
회전되는 실자격증명 보유위 create-if-missing 분기를 별도로 신설

4. 하드코딩 값 allowlist 는 값에 바인딩한다

dev well-known 자격증명(Kind 로컬 gend/gend123 등)은 실용상 레포에 남아야 해서 ALLOWED_HARDCODED_SECRET_VALUES 로 예외 처리한다. 단 (파일, 키) 만 등재하면 등재된 키의 값을 진짜 prod 자격증명으로 바꿔도 가드가 통과시킨다.

그래서 각 항목에 승인된 값의 SHA-256 digest 를 함께 고정하고, 값이 달라지면 위반으로 올린다. 값을 의도적으로 바꿀 때는 digest 도 함께 갱신하고 사유를 재검토한다. 실패 메시지에는 값이 아니라 digest 앞 12자만 싣는다.

5. 런북은 apply 전 kind 확인을 명시한다

운영 문서에서 apply -f <secret 포함 가능 파일> 절차를 지우고, 대신 grep -n '^kind:' 선행 확인과 Secret 존재/키 검증으로 대체한다.

대안과 기각 사유

대안기각 사유
파일 분리만 하고 스크립트는 그대로무조건 apply 가 회전값을 되돌린다 — 사고의 절반만 막는다
전부 SealedSecret/ESO 로 이관옳은 방향이지만 범위가 크다. 이관 전까지 평문 경로가 그대로 노출되므로 이 ADR 이 먼저 필요하다. mlflow 는 ESO 이관(#1107 Phase 3) 시 §2 의 재작성 블록을 삭제하고 ESO 에 위임할 것
정규식으로 kind: Secret 검사주석·문자열·kind: List 중첩에서 오탐/누락. yaml.safe_load_all 파싱이 정본
미분류 secret 파일을 경고 후 계속 진행로그를 놓치면 조용히 깨진 채 출하된다(§3)

예외

  • 파싱 실패 allowlist (PARSE_FAILURE_ALLOWLIST): Go 템플릿이 섞인 ArgoCD ApplicationSet 처럼 yaml.safe_load_all 로 못 읽는 파일은 사유와 함께 등재한다. 등재 항목이 나중에 정상 파싱되면 테스트가 stale 로 실패시켜 정리를 강제한다.
  • helm 차트 템플릿: infra/helm/**/templates/ 는 렌더 전 상태라 검사 대상에서 제외한다. 제외 매칭은 경로 아무 데나가 아니라 infra/ 기준 첫 세그먼트로만 한다(경로 위치를 이용한 우회 차단).

롤백 책임

  • 이 정책 자체의 롤백은 없다 — 가드를 끄면 사고가 재발한다.
  • 개별 배포에서 Secret 이 잘못 적용된 경우의 복구 절차(라이브 값으로 patch + rollout restart)는 MLflow Artifact 프록시 운영 에 있다.
  • 가드가 신규 파일을 막아 배포가 멈춘 경우, 우회(allowlist 무단 추가)가 아니라 §3 표에 따라 분류하는 것이 정상 경로다.

결과

  • 매니페스트 apply 로 라이브 자격증명이 덮이는 경로가 구조적으로 닫힌다.
  • 새 Secret 파일은 분류 없이는 배포에 실려 나갈 수 없다.
  • 가드 우회 시도(경로 위치 조작, kind: List 감싸기, 등재 키에 실값 덮어쓰기) 가 전부 테스트로 막힌다 — 셋 다 리뷰에서 실제로 지적돼 보강한 항목이다.