본문으로 건너뛰기

LDAP 자격증명 회전

Kyuubi(Spark JDBC)의 인증 백엔드인 OpenLDAP 자격증명을 교체하는 절차입니다. 2026-08-09 에 실제로 수행했고(#3078), 그때 부딪힌 함정을 그대로 적었습니다.

:::danger 회전 전까지 비밀번호가 리포에 공개돼 있었다

infra/keycloak/base/openldap.yaml 의 seed ConfigMap 과 배포 스크립트 출력에 seed 사용자 3종과 LDAP root(cn=admin)의 비밀번호가 평문으로 있었습니다. 리포를 읽을 수 있으면 곧 Kyuubi 에 로그인할 수 있었다는 뜻이고, 실제로 prod 에서 그대로 바인딩됐습니다.

옛 값은 이 문서에 옮기지 않습니다 — 회전됐어도 문서에 다시 적으면 그 값의 수명이 늘어납니다. 필요하면 git log -p -- infra/keycloak/base/openldap.yaml 에서 확인하십시오. :::

구조 — 비밀번호는 어디에 있나

Secret openldap-credentials
├─ LDAP_ROOT_PASSWORD → 본 컨테이너 env → LDAP root (cn=admin,dc=gend,dc=local)
├─ LDAP_ADMIN_PASSWORD ─┐
├─ LDAP_ANALYST_PASSWORD ─┼→ initContainer copy-seed 가 LDIF 에 삽입
└─ LDAP_VIEWER_PASSWORD ─┘

seed ConfigMap 에는 userPassword 속성 자체가 없습니다. initContainer 가 emptyDir 사본에 그 줄을 삽입합니다 — 값은 LDIF base64 형식(userPassword::) 으로 넣습니다.

:::warning 평문을 sed 치환문에 그대로 넣으면 백슬래시가 조용히 사라진다

실측: 비밀번호 pw\back 을 넣으면 LDIF 에는 pwback 이 들어갑니다. 그러면 Secret 의 값과 계정의 값이 갈라지는데 아무 오류도 나지 않습니다 — 다음에 누가 바인딩을 시도할 때까지 아무도 모릅니다. base64 출력은 [A-Za-z0-9+/=] 뿐이라 sed 에도 LDIF 에도 안전하고, 선행 공백·개행 문제도 함께 사라집니다.

검증(백슬래시·&·|·선행 공백·내부 공백 5종 + 평범한 값): 전부 보존 확인. :::

initContainer 는 UID 1000 비루트로 돕니다. emptyDir 은 기본 root:root 0755 라 그것만으로는 쓸 수 없어 pod securityContext.fsGroup: 1000 이 짝입니다 — 둘 중 하나만 넣으면 cp 에서 Permission denied 로 죽습니다. (본 컨테이너는 slapd 부트스트랩에 root 가 필요해 그대로 둡니다 — #3079 범위.)

:::tip 왜 플레이스홀더가 아니라 "줄 자체를 제거" 인가

Trivy KSV-0109값이 아니라 userPassword 라는 키 이름에 반응합니다. __LDAP_ADMIN_PASSWORD__ 같은 플레이스홀더로 바꿔도 탐지는 그대로 남습니다 (실측: BEFORE 1건 → 플레이스홀더 1건 → 줄 제거 0건). 그래서 회귀 가드도 "평문이 아니다" 가 아니라 "그 속성이 없다" 로 검사합니다. :::

회전이 안전한지 먼저 재기

1. LDAP 데이터가 영속되는가

kubectl -n gend get pod -l app=openldap -o jsonpath='{.items[0].spec.volumes[*].name}'

PVC 가 없으면 /var/lib/ldap 이 컨테이너 파일시스템에 있고, 재시작할 때마다 seed LDIF 로 다시 만들어집니다. 그래서 매니페스트를 바꾸고 롤아웃하는 것만으로 회전이 끝납니다. PVC 가 붙어 있다면 이 절차는 성립하지 않습니다 — ldappasswd 로 기존 엔트리를 갱신해야 합니다.

2. 누가 이 자격증명을 쓰는가 — 소비 지점에서 잰다

POD=$(kubectl -n gend get pod -l app=openldap -o jsonpath='{.items[0].metadata.name}')
kubectl -n gend logs "$POD" -c openldap | grep BIND | sed -n 's/.*dn="\([^"]*\)".*/\1/p' | sort | uniq -c

-c openldap 을 빠뜨리지 마십시오. 기본 컨테이너가 linkerd-proxy 라 로그가 비어 "BIND 0건" 이라는 거짓 결론이 나옵니다.

:::warning 트래픽 0 ≠ 의존성 0

Kyuubi 는 JDBC 접속 시점에만 BIND 합니다. BIND 0 은 "관측 창 동안 접속이 없었다" 는 뜻이지 "쓰이지 않는다" 가 아닙니다. 중단 위험이 낮다는 근거는 되지만 삭제의 근거는 되지 않습니다.

그리고 이 프로브가 판별력이 있는지 먼저 확인하십시오 — 지금 한 번 바인딩해 보고 로그에 나타나는지 봅니다. 안 나타나면 BIND 0 은 "없다" 가 아니라 "측정 안 됨" 입니다. :::

3. Keycloak User Federation 이 bind DN 을 쓰는가

매니페스트 주석에 "keycloak User Federation 이 LDAP 으로 outbound" 라고 적혀 있지만, 2026-08-09 실측은 gend realm 컴포넌트 12개 중 LDAP 언급 0건 이었습니다 (master realm 은 gend-realm-sync 권한 부족으로 403 — 미측정이지 0 이 아닙니다. 위 §2 의 BIND 전수 조사가 그 공백을 덮습니다).

★ Kyuubi 는 userDNPattern=uid=%s,...최종 사용자 자격증명을 그대로 바인딩합니다. 즉 Kyuubi 자신은 비밀번호를 저장하지 않고, 회전해도 갱신할 서비스 설정이 없습니다. 영향은 JDBC 클라이언트를 쓰는 사람뿐입니다.

절차

1. Secret 생성

TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
for k in LDAP_ROOT_PASSWORD LDAP_ADMIN_PASSWORD LDAP_ANALYST_PASSWORD LDAP_VIEWER_PASSWORD; do
# ★ 개행 금지 — LDAP 은 후행 개행을 비밀번호의 일부로 삼는다
LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > "$TMP/$k"
done
kubectl -n gend create secret generic openldap-credentials --from-file="$TMP"

값을 --from-literal 로 넘기지 마십시오 — 인자가 프로세스 목록에 남습니다.

생성 직후 다이제스트로 대조합니다(개행 혼입이 여기서 잡힙니다):

for k in LDAP_ROOT_PASSWORD LDAP_ADMIN_PASSWORD LDAP_ANALYST_PASSWORD LDAP_VIEWER_PASSWORD; do
a=$(shasum -a 256 "$TMP/$k" | cut -c1-12)
b=$(kubectl -n gend get secret openldap-credentials -o jsonpath="{.data.$k}" \
| base64 -d | shasum -a 256 | cut -c1-12)
[ "$a" = "$b" ] && echo "✅ $k" || echo "❌ $k $a$b"
done

2. 매니페스트 적용 + 롤아웃

kubectl diff -f infra/keycloak/base/openldap.yaml # 선언 대 배포 드리프트 먼저
kubectl apply -f infra/keycloak/base/openldap.yaml
kubectl -n gend rollout status deployment/openldap --timeout=180s
kubectl -n gend logs -l app=openldap -c copy-seed # 빈 출력 = 삽입 성공

initContainer 는 fail-closed 입니다 — 비밀번호가 비었거나 LDIF 앵커가 어긋나 삽입이 3줄이 아니면 파드가 뜨지 않습니다. 조용히 통과하면 비밀번호 없는 엔트리가 만들어지고, slapd 는 그것을 모든 바인딩을 거부하는 계정으로 삼습니다.

3. 검증 — 신·구 양방향

새 값이 되는 것만 보면 "회전 안 됐는데 옛 값도 되는" 상태를 구분하지 못합니다.

POD=$(kubectl -n gend get pod -l app=openldap -o jsonpath='{.items[0].metadata.name}')

# ① 새 값 → 성공해야
kubectl -n gend get secret openldap-credentials -o jsonpath='{.data.LDAP_ANALYST_PASSWORD}' \
| base64 -d | kubectl -n gend exec -i "$POD" -c openldap -- sh -c '
umask 077; cat > /tmp/.pw
ldapwhoami -x -H ldap://localhost:389 \
-D "uid=ldap-analyst,ou=people,dc=gend,dc=local" -y /tmp/.pw; r=$?
rm -f /tmp/.pw; exit $r'

# ② 옛 값 → 거부돼야 (rc=49)
kubectl -n gend exec "$POD" -c openldap -- ldapwhoami -x -H ldap://localhost:389 \
-D "uid=ldap-analyst,ou=people,dc=gend,dc=local" -y /path/to/old-password-file

:::caution -y /dev/stdin 은 쓸 수 없다

ldapwhoami -y /dev/stdinrc=53 (unwilling to perform) 으로 실패합니다. 이걸 "새 비밀번호가 안 먹는다" 로 읽으면 오진입니다 — 프로브 결함입니다. 파드 안에 umask 077 로 파일을 만들어 -y 로 넘기십시오. :::

4. Kyuubi 실동작 확인

LDAP 바인딩이 되는 것과 JDBC 인증이 되는 것은 다릅니다. 소비 지점에서 잽니다.

IP=$(kubectl -n gend get pod kyuubi-0 -o jsonpath='{.status.podIP}')
kubectl -n gend get secret openldap-credentials -o jsonpath='{.data.LDAP_ANALYST_PASSWORD}' \
| base64 -d | kubectl -n gend exec -i kyuubi-0 -c kyuubi -- sh -c "
umask 077; cat > /tmp/.pw; PW=\$(cat /tmp/.pw); rm -f /tmp/.pw
\$KYUUBI_HOME/bin/beeline -u 'jdbc:hive2://$IP:10009/' -n ldap-analyst -p \"\$PW\" \
-e 'SELECT 1 AS ok'"

localhost:10009Connection refused 입니다(linkerd 메시). podIP 를 쓰십시오.

2026-08-09 수행 결과

항목결과
매니페스트 평문 자격증명4종 → 0
Trivy KSV-01091건 → 0 (BEFORE/AFTER 대조 측정)
회전 전 BIND 주체4일 창 11건 — 전부 검증용 프로브, 외부 소비자 0
새 자격증명 바인딩root·admin·analyst·viewer 4/4 성공
옛 자격증명 바인딩4/4 거부 (rc=49)
Kyuubi JDBC (새 값)SELECT 1 성공
Kyuubi JDBC (옛 값)Error validating the login 거부
서비스 중단없음 (RollingUpdate, replicas=1)
initContainerUID 1000 비루트 · readOnlyRootFilesystem · cap drop ALL
특수문자 내성base64 경로로 6종 전부 보존 (평문 sed 는 백슬래시 유실)

사용자에게 안내할 것

JDBC 클라이언트를 쓰는 분들은 비밀번호를 다시 받아야 합니다. 문서·채팅에 값을 적지 말고 조회 명령을 안내하십시오:

kubectl -n gend get secret openldap-credentials \
-o jsonpath='{.data.LDAP_ANALYST_PASSWORD}' | base64 -d

관련