애플리케이션에 DB 비밀번호를 어떻게 넣어놓고 계셨나요. 우리 팀은 예전에 Kubernetes Secret에 base64로 넣어놓고, 분기마다 수동으로 로테이션했다. 근데 이게 몇 년 이어지다 보니 문제가 쌓였다. 누가 언제 그 시크릿을 봤는지 감사 로그가 없고, 로테이션 날에는 여러 서비스가 동시에 재기동되면서 P99 레이턴시가 튀었다. 결국 Vault Dynamic Secrets로 넘어갔는데, 처음 설정할 때 헤맨 부분이 좀 있어서 정리해둔다.
이 글은 PostgreSQL 기준이지만 MySQL이나 MongoDB도 큰 틀은 같다. 최근 Vault 1.19에서 rotation_schedule 필드로 크론 스타일 스케줄이 정식 지원되면서 static role 운용이 훨씬 편해졌다.
Dynamic Secrets가 뭐가 다른가
기존 static credential 방식은 DB에 계정 하나 만들어놓고 그걸 여러 서비스가 공유한다. Vault Dynamic Secrets는 요청이 올 때마다 DB에 새 계정을 만들고, TTL이 끝나면 자동으로 지워버린다.
이게 왜 좋냐면, 서비스마다 크리덴셜이 다르니까 감사 로그를 보면 어떤 서비스가 어떤 쿼리를 날렸는지 추적된다. 크리덴셜 유출이 나도 TTL이 짧으면 피해 반경이 좁다. 그리고 로테이션이란 개념 자체가 사라진다. 15분마다 새 계정이 발급되니까 굳이 로테이션할 게 없다.
단점도 있다. DB에 계정이 계속 생겼다 지워지니까 pg_roles가 지저분해진다. Vault가 죽으면 신규 크리덴셜 발급이 막힌다 (기존 커넥션은 살아있지만). 그리고 커넥션 풀 설정을 다시 손봐야 한다. 이건 뒤에서 다시 얘기하자.
최소 설정
Vault에 database secrets engine을 활성화하고, PostgreSQL 연결을 등록한다.
vault secrets enable database
vault write database/config/prod-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="app-readonly,app-readwrite" \
connection_url="postgresql://{{username}}:{{password}}@postgres.prod.svc:5432/appdb?sslmode=require" \
username="vault_admin" \
password="..." \
password_authentication="scram-sha-256"
여기서 vault_admin은 Vault가 다른 계정을 만들고 지울 수 있는 권한이 있어야 한다. 그러니까 CREATEROLE 권한이 필요하다. 슈퍼유저 주면 안 된다. 최소 권한 원칙.
CREATE ROLE vault_admin WITH LOGIN CREATEROLE PASSWORD '...';
GRANT CONNECT ON DATABASE appdb TO vault_admin;
그 다음 role을 정의한다.
vault write database/roles/app-readonly \
db_name=prod-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="15m" \
max_ttl="1h"
default_ttl 15분은 처음엔 짧아 보이는데, 커넥션 풀이 lease 갱신을 잘 해주면 문제 없다. 오히려 이 시간이 짧을수록 유출 대응이 편하다.
애플리케이션 붙이기
크리덴셜 요청은 이렇게 한다.
vault read database/creds/app-readonly
응답에 username, password, lease_id, lease_duration이 담겨온다. 애플리케이션이 이걸 받아서 커넥션 풀을 초기화하면 된다.
근데 이걸 직접 코드로 짜면 lease renewal 로직이 지저분해진다. 그래서 우리는 Kubernetes에서는 Vault Agent Injector를 쓴다. Pod annotation 몇 줄로 사이드카가 붙어서 자동으로 갱신해준다.
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "app"
vault.hashicorp.com/agent-inject-secret-db.env: "database/creds/app-readwrite"
vault.hashicorp.com/agent-inject-template-db.env: |
{{- with secret "database/creds/app-readwrite" -}}
DB_USER={{ .Data.username }}
DB_PASS={{ .Data.password }}
{{- end }}
여기서 애플리케이션이 파일 변경을 감지해서 커넥션 풀을 다시 만들어야 한다. HikariCP 같은 경우엔 maxLifetime을 lease TTL보다 짧게 잡아두는 게 안전하다. 그래야 만료 직전에 커넥션이 갈아엎히면서 새 크리덴셜로 재접속한다.
커넥션 풀 설정에서 삽질한 것
여기가 우리 팀이 제일 오래 헤맨 부분이다. HikariCP maxLifetime을 기본값(30분)으로 뒀는데 Vault lease는 15분이었다. 15분 지나면 DB 계정이 지워지는데 풀에는 그 계정으로 열린 커넥션이 남아있다. 트래픽이 튀는 시점에 그 커넥션을 잡은 요청이 role does not exist 에러를 뱉었다.
해결책은 두 가지가 있다. 첫째, maxLifetime을 lease TTL의 70% 정도로 잡는다. lease가 15분이면 maxLifetime을 10분으로. 둘째, Vault Agent가 새 크리덴셜을 파일에 쓸 때 SIGHUP을 애플리케이션에 보내서 강제로 풀을 재초기화한다.
우리는 둘 다 한다. 벨트 앤 서스펜더.
정리하면서
Static credential에서 Dynamic으로 넘어가는 건 생각보다 코드 수정보다 운영 마인드 셋 변경이 크다. 크리덴셜은 이제 "설정"이 아니라 "실시간 리소스"다. Vault가 SPOF가 되니까 HA 구성도 필수고, DR 시나리오도 새로 짜야 한다.
그래도 3개월 지나고 나서 감사 대응할 때 훨씬 편하더라. 이번 분기 SOC 2 audit에서 credential rotation 관련 질문에 로그 스크린샷 몇 장으로 끝났다.
다음에는 static role 스케줄 로테이션이랑 Vault Agent 없이 CSI driver로 붙이는 방법도 다뤄보려고 한다.
'IT > DevSecOps' 카테고리의 다른 글
| External Secrets Operator + AWS Secrets Manager, 실무 세팅 가이드 (0) | 2026.07.03 |
|---|---|
| Falco eBPF probe는 어떻게 syscall을 잡는가 — modern probe 내부 들여다보기 (1) | 2026.07.01 |
| Vault Agent sidecar의 init 컨테이너 캐시 공유 함정 (0) | 2026.06.25 |
| External Secrets Operator 실전 가이드: PushSecret과 ClusterSecretStore 제대로 쓰기 (0) | 2026.06.23 |
| External Secrets vs Vault Agent Injector, 2026년에 다시 고른다면 (0) | 2026.06.18 |