사내 시크릿 관리 솔루션으로 OpenBao를 골랐다. VM 한 대에 Docker Compose로 노드 컨테이너 세 개를 올려 Raft HA를 구성하고 앞단의 nginx가 TLS를 처리한다. 사용자 인증은 사내 AD(LDAP)로 하고 쿠버네티스 클러스터에는 ESO(External Secrets Operator)로 시크릿을 밀어 넣는다. 이미지는 cosign으로 서명해서 Kyverno가 admission 단계에서 검증한다.
로컬(맥 + minikube)에서 검증한 구성을 프로덕션 VM으로 옮겼다. 같은 compose와 스크립트를 쓰는데도 프로덕션에서만 발생하는 문제가 연달아 있었다. 문제별로 증상과 원인, 수정 내용을 기록한다.
1. vi로 고친 unseal 키가 컨테이너에 반영되지 않았다
노드 1은 초기화와 unseal이 끝났는데 노드 2와 3이 Raft 클러스터에 합류하지 못했다. 로그에 이 에러가 반복됐다.
failed to retry join raft cluster:
err="failed to send answer to raft leader node:
error decrypting challenge: cipher: message authentication failed"
리더가 보낸 challenge를 조인하는 노드가 자기 키로 복호화하지 못했다는 뜻이다. 호스트의 env 파일에는 맞는 키가 들어 있었다. 그런데 unsealer 사이드카 컨테이너 안에서 같은 파일을 읽으면 다른 값이 나왔다. 호스트에서 cat으로 보는 값과 컨테이너 안에서 grep으로 보는 값이 서로 달랐다.
원인은 단일 파일 bind-mount와 vi의 저장 방식 조합이었다. compose에서 env 파일 하나를 파일 단위로 마운트했다. 그런데 vi는 파일을 저장할 때 새 파일을 쓰고 rename하기 때문에 inode가 바뀐다. 컨테이너는 마운트 시점의 옛 inode를 계속 붙들고 있어서 편집 이후의 내용을 보지 못한다.
sequenceDiagram
participant H as 호스트 (vi 편집)
participant I1 as inode A (옛 파일)
participant I2 as inode B (새 파일)
participant C as 컨테이너 (bind-mount)
H->>I2: 새 내용 저장 후 rename
Note over I1: 원본 경로에서 분리됨
C->>I1: 마운트가 여전히 inode A를 참조
Note over C: 편집 전 내용만 보임
vi로 저장하는 순간 경로는 새 inode를 가리키지만 컨테이너의 마운트는 옛 inode에 고정되어 있다. 그래서 편집한 내용이 컨테이너에 반영되지 않는다.
수정은 파일 대신 디렉터리를 마운트하고 컨테이너 안에서 그 디렉터리의 파일 경로를 가리키게 바꾸는 것이었다. 디렉터리 마운트는 경로 조회를 매번 하기 때문에 inode가 바뀌어도 항상 최신 파일을 읽는다. 이후로는 env 파일을 vi로 고쳐도 컨테이너 재생성 없이 반영된다.
노드 2와 3은 스테일 볼륨을 비우고 재기동하자 올바른 키로 challenge를 복호화해서 합류했고 voter 세 개짜리 클러스터가 완성됐다.
2. AD의 "팀"이 그룹이 아니라 OU였다
관리자 권한은 특정 팀 소속이면 자동으로 부여하기로 했다. LDAP 인증에는 그룹 리졸루션 기능이 있어서 groupdn과 groupfilter를 설정하면 로그인할 때 사용자의 memberOf 그룹을 읽어 그룹 단위로 정책을 붙일 수 있다. 로컬 openldap에서는 groupOfNames 그룹을 만들어 이 방식으로 검증까지 끝냈다.
프로덕션 AD에서 실제 사용자를 조회해 보니 memberOf에 팀이 없었다. 조직도의 팀은 그룹이 아니라 OU였다. 사용자 DN 자체가 다음 형태였다.
CN=<사용자>,OU=<팀>,OU=<본부>,OU=Person,DC=example,DC=com
OU는 memberOf로 조회되지 않기 때문에 그룹 리졸루션으로는 잡을 방법이 없다. 그래서 접근을 바꿨다. 대상 OU를 ldapsearch로 조회해 소속 사용자의 sAMAccountName을 얻고 각 계정을 admin 정책에 매핑하는 동기화 스크립트를 만들어 cron으로 15분마다 실행한다. 스크립트는 자기가 admin을 부여한 계정 목록을 상태 파일에 남기고 다음 실행 때 OU에서 빠진 계정의 admin을 회수한다. 신규 입사자와 팀 이동자가 자동으로 반영된다.
로컬과 프로덕션의 권한 부여 방식이 갈라진 셈인데, 그룹 기반 스크립트와 OU 기반 스크립트를 둘 다 저장소에 두고 환경에 맞는 쪽을 쓰기로 했다.
3. env 파일 source와 셸 문법으로 생긴 스크립트 오류
설정 스크립트가 env 파일을 통째로 source하고 있었다. 로컬에서는 문제가 없었는데 프로덕션에서 이 에러가 났다.
prod.env: line 25: openbao-2:8200: command not found
env 파일은 docker compose의 env-file 형식이라 값에 공백이 있어도 따옴표가 없다. UNSEAL_NODES=openbao:8200 openbao-2:8200 openbao-3:8200 같은 줄을 셸이 source하면 첫 토큰까지만 변수 대입으로 처리하고 두 번째 토큰부터는 명령으로 실행하려고 든다. 필요한 키만 grep과 cut으로 추출하는 방식으로 바꿨다.
같은 스크립트에서 셸 관련 문제가 두 개 더 나왔다.
- 추출 함수가 존재하지 않는 키를 grep하면 grep이 종료 코드 1을 반환한다. set -e와 pipefail이 걸린 스크립트는 그 지점에서 출력 없이 죽는다. 그룹 설정이 조용히 누락된 원인이 이것이었고
|| true를 붙여 빈 값을 반환하게 했다. - 쉼표로 구분된 목록을
read -ra로 배열에 담았는데 이건 bash 전용 문법이다. 실행 셸이 zsh인 환경에서 오작동해서 목록이 엉뚱한 값으로 쪼개졌다. case 문과 파라미터 확장으로 분리하는 POSIX 방식으로 바꿨다.
4. 지역 변수 누락으로 네임스페이스 작업이 root로 간 문제
테넌트 분리는 OpenBao 네임스페이스로 한다. 네임스페이스 다섯 개를 만들고 각각에 kv 엔진과 역할 정책을 넣는 부트스트랩 스크립트를 실행했다. 출력은 전부 성공이었지만 자식 네임스페이스에서 시크릿을 쓰려고 하니 404가 났다. 마운트가 없다는 것이다.
원인은 헬퍼 함수의 지역 변수 누락이었다.
bn() { ns="$1"; shift; docker exec ... -e BAO_NAMESPACE="$ns" ... } # 잘못
bn() { local ns="$1"; shift; ... } # 수정
for 루프가 for ns in $NAMESPACES로 루프 변수 ns를 쓴다. 그런데 루프 안에서 bn "" namespace create 형태로 root 네임스페이스 호출을 하면 함수의 전역 대입이 루프 변수를 빈 문자열로 덮는다. 그 뒤의 kv 활성화와 정책 작성이 전부 자식 네임스페이스가 아니라 root로 나갔다. 성공 메시지는 진짜였다. 대상이 틀렸을 뿐이다.
함수에 local을 추가해서 고쳤다. 스크립트가 멱등이라 재실행만으로 복구됐다. 이 버그는 로컬 E2E에서 이미 잡았던 것인데 서버에는 수정 전 버전이 배포된 상태였다. 그래서 프로덕션에서 같은 증상이 한 번 더 나왔고 수정 커밋을 배포한 뒤 재실행해서 정리됐다.
5. Kyverno 서명 검증 정책이 ESO 파드 생성을 막은 문제
이미지 서명 검증은 Kyverno ClusterPolicy의 verifyImages로 한다. 사내 레지스트리의 특정 경로 이미지는 cosign 서명이 있어야만 파드 생성을 허용하는 정책이다. 정책 적용 후 ESO 컨트롤러 파드를 재시작하자 파드가 다시 생성되지 않았다.
Error creating: admission webhook "mutate.kyverno.svc-fail" denied the request
verifyImages 웹훅은 fail-closed다. Kyverno가 응답하지 못하는 상태였는데 정책의 exclude 목록에 external-secrets 네임스페이스가 없어서 ESO 파드 생성 요청까지 웹훅에 걸렸다. 시크릿 동기화 컨트롤러가 통째로 내려간 것이다. 검증이 필요한 워크로드 네임스페이스만 대상으로 남기고 kube-system, kyverno, external-secrets 같은 인프라 네임스페이스는 전부 exclude에 넣어 해결했다.
verifyImages 웹훅은 fail-closed라 Kyverno가 응답하지 못하면 exclude 밖의 모든 파드 생성이 막힌다. 이번 경우처럼 시크릿 동기화 컨트롤러가 대상에 들어 있으면 장애가 다른 컴포넌트로 번지는 구조다.
결과와 남은 문제
위 문제들을 수정한 뒤의 상태다.
| 구성 | 상태 |
|---|---|
| 3노드 Raft HA | voter 3, 리더 1. unsealer 사이드카가 재기동 시 자동 unseal |
| 사용자 인증 | LDAP(AD). admin은 OU 동기화 cron이 부여와 회수를 자동 처리 |
| 서비스 시크릿 | 네임스페이스별 kv + read-only AppRole. 외부 클러스터의 ESO가 AppRole로 인증해 동기화 |
| 공급망 | transit 키로 cosign 서명, Kyverno가 공개키로 검증. 미서명 이미지는 admission에서 차단 |
| 백업 | Raft 스냅샷을 cron으로 매시간 저장, 48개 보존 |
남은 문제는 seal 방식이다. 지금은 shamir 정적 unseal 키를 env 파일에 두고 사이드카가 unseal하는 구조인데, 이 빌드(OpenBao 2.5.5)는 rekey API가 405 unsupported를 반환해서 키 교체도 막혀 있다. 정적 키를 아예 없애려면 KMS나 Transit 기반 auto-unseal로 seal을 마이그레이션해야 하고 이건 다음 작업으로 남겨 뒀다.