IT/DevSecOps

Vault Agent Injector annotation 충돌로 새벽에 일어난 이야기

gfrog 2026. 5. 31. 06:18
반응형

지난주 화요일 새벽 2시 40분. 전화 진동이 침대 옆에서 부르르 떨었다. 결제 서비스 일부 Pod가 CrashLoopBackOff에 걸려서 P0 알람이 떴다는 PagerDuty. 졸린 눈으로 노트북을 열었는데, 보자마자 머리가 띵해졌다. 어제 낮에 내가 mutating webhook 설정 하나를 손댄 게 떠올라서.

이 글은 그날 새벽 한 시간 동안 뭘 했는지에 대한 회고다. Vault Agent Injector를 쓰는 팀이면 한 번쯤은 밟을만한 지뢰라 기록을 남긴다.

상황: agent-inject-secret 어노테이션이 사라졌다

우리 팀은 1년 정도 Vault Agent Injector 1.4.x로 DB 자격증명을 주입해왔다. 패턴은 흔하다.

metadata:
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "payment-app"
    vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/payment-ro"
    vault.hashicorp.com/agent-inject-template-db-creds: |
      {{- with secret "database/creds/payment-ro" -}}
      DB_USER={{ .Data.username }}
      DB_PASSWORD={{ .Data.password }}
      {{- end }}

문제의 Pod 스펙을 kubectl get pod -o yaml로 들여다봤더니, vault-agent-init 사이드카가 멀쩡히 떠 있긴 한데 종료 코드가 1. 로그를 보니 403 permission denied였다. 어? Vault Role 변경 안 했는데?

근데 더 황당한 건, init 컨테이너의 args에 들어가는 자동생성 config(/vault/configs/config.hcl)에 agent-inject-secret-db-creds 항목 자체가 빠져 있었다. 다른 어노테이션들은 다 살아있는데 그것만 통째로 사라진 거다.

가장 먼저 의심한 것 (그리고 틀린 것)

새벽 2시 50분. 가장 빨리 의심한 건 Vault 정책이었다. vault read database/creds/payment-ro를 직접 때려보면 잘 나오는지 확인. 잘 나왔다. 정책 문제 아니다.

그 다음은 ServiceAccount 토큰. EKS 1.31 업그레이드 이후로 projected token 수명이 짧아졌다는 글을 본 기억이 있어서. 근데 이건 아예 다른 증상이다. 토큰 문제면 init 컨테이너가 부팅 자체를 못 한다. 우리 케이스는 init이 뜨긴 떴다.

3시 10분. 그제서야 어제 낮에 내가 뭘 했는지 차분히 생각해봤다. Pod Security Standards 강제 적용 준비하느라 Kyverno에 ClusterPolicy 하나를 추가했었다. 컨테이너에 readOnlyRootFilesystem를 강제하는 정책. 그리고 그 정책에 mutate 룰도 같이 넣었다. 누락된 Pod에는 자동으로 readOnly를 set 해주도록.

여기서 뭔가 걸렸다. mutate가 두 webhook을 동시에 통과하면서 어떻게 되는 거지?

진짜 원인: mutating webhook 실행 순서와 stripped annotation

Vault Agent Injector도 mutating admission webhook이다. Kyverno도 마찬가지. 두 webhook이 같은 Pod를 손대면 실행 순서대로 결과가 누적된다. 그런데 여기서 미묘한 함정이 있었다.

3시 25분에 webhook 설정을 dump 떠봤더니, Kyverno webhook의 reinvocationPolicyIfNeeded로 잡혀 있었다. Vault Injector의 reinvocationPolicy는 명시되지 않아 기본값 Never. 두 webhook의 objectSelector matchExpressions가 묘하게 겹쳤다.

순서가 이랬다:

  1. Pod 생성 요청 → Kyverno가 먼저 받음 (alphabetical 우선순위) → readOnlyRootFilesystem 추가, 그 과정에서 어노테이션 전체를 strategic merge로 다시 쓰는 strategic patch가 나감
  2. Vault Injector가 받음 → 어노테이션 보고 vault-agent-init 사이드카 주입 + config 생성
  3. Kyverno가 다시 호출됨 (reinvocation 트리거) → 새로 추가된 컨테이너에도 readOnly를 set 하기 위해 또 patch 발행
  4. 이 두 번째 Kyverno patch에서 어노테이션이 strategic merge가 아닌 JSON Patch 식으로 일부만 들어가면서 agent-inject-secret-db-creds 한 줄이 누락

확인 방법은 API server audit log였다. --audit-policy-file에서 mutating webhook response patch를 기록하도록 metadata level로 켜두지 않았다면 보기 힘들다. 우리는 다행히 6개월 전에 SOC2 대응으로 audit를 켜둬서 추적이 가능했다.

# audit log에서 해당 Pod uid로 검색
kubectl get pod payment-api-7d4f9 -o jsonpath='{.metadata.uid}'
# audit 로그를 jq로 파싱
jq 'select(.objectRef.uid == "<uid>") | .responseObject.metadata.annotations' /var/log/audit.log

Kyverno 두 번째 호출의 응답에서 어노테이션 키가 누락된 게 명확히 보였다.

임시 조치 (새벽 3시 50분)

가장 먼저 한 건 Kyverno ClusterPolicy의 reinvocationPolicyNever로 바꾸는 거였다. 사실 reinvocation이 진짜 필요한 케이스가 아니었는데 기본 템플릿 복붙하다 들어간 옵션이었다.

spec:
  webhookConfiguration:
    reinvocationPolicy: Never

이거 하나 바꾸고 nodepool rolling restart 돌렸다. 4시 20분쯤 Pod들이 정상화됐다.

근본 수정은 다음날 페어로 했다. Kyverno의 mutate 룰을 foreach 기반으로 다시 짜서, 어노테이션을 건드리지 않고 spec.containers/initContainers의 securityContext만 정확히 patch 하도록 좁혔다. 그리고 Vault Injector의 webhook 우선순위를 명시적으로 지정하기 위해 failurePolicyFail로 바꾸고 (전엔 Ignore였다) ValidatingAdmissionPolicy로 어노테이션 보존 여부 자체를 검증하는 정책을 하나 더 깔았다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: vault-annotation-preservation
spec:
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE"]
      resources: ["pods"]
  validations:
  - expression: |
      !has(object.metadata.annotations) ||
      !object.metadata.annotations.exists(k, k.startsWith('vault.hashicorp.com/agent-inject-secret-')) ||
      object.spec.initContainers.exists(c, c.name == 'vault-agent-init')
    message: "Pod has vault inject annotations but vault-agent-init is missing"

CEL로 쓴 검증이다. inject 어노테이션이 있는데 사이드카가 안 붙어 있으면 차단. 임시 가드지만 같은 사고가 재현되면 admission 단계에서 막혀서 알람이 뜬다.

교훈 몇 가지

첫째, mutating webhook 두 개 이상이 같은 리소스에 손을 댈 때 reinvocationPolicy를 무심코 IfNeeded로 두면 안 된다. 진짜 reinvocation이 필요한 경우(다른 webhook이 추가한 컨테이너에도 patch가 필요한 경우)에만 쓰는 옵션인데, 우리는 그게 아니었다.

둘째, Vault Agent Injector 같이 "어노테이션 기반으로 결정적 동작을 하는" webhook이 있는 클러스터에서는, 어노테이션 키가 stable하게 보존되는지 ValidatingAdmissionPolicy로 가드를 깔아두는 게 안전하다. 사고가 난 뒤에 깨달았다.

셋째, audit log는 평소엔 비용만 잡아먹는 짐 같지만 이런 mutation chain 문제 추적엔 결정적이다. metadata level 정도는 켜둘 만하다고 다시 한번 느꼈다.

근데 솔직히 가장 큰 교훈은, 어드미션 컨트롤러 손댄 날 새벽에 자다 깰 각오는 해야 한다는 거다. 다음엔 변경 직후 staging에서 24시간 정도 burn-in 시간을 두는 걸로 팀 룰을 바꿨다.

혹시 비슷한 webhook chain 디버깅 경험 있으신 분 댓글 남겨주시면 감사하겠다. 우리는 이게 처음이라 더 좋은 패턴이 있는지 궁금하다.

반응형