PostgreSQL TLS (verify-full) 운영 가이드
GenD 의 PostgreSQL 클러스터는 transit TLS verify-full 로 보호됩니다 — client 가 서버 cert chain + hostname 까지 검증한 후에만 연결 성립. PG pg_hba.conf 는 hostssl only (#881 Phase 3b 완료) — 평문 fallback 차단. 이 페이지는 운영자가 (1) 현재 상태를 한눈에 보고, (2) 새 PG client 를 추가할 때 어떤 패턴을 적용해야 하는지, (3) 사고 시 어떻게 롤백하는지를 정리합니다.
7 PG client (gend-api / dagster×2 / mlflow / nessie / hive-metastore / keycloak) 가 verify-full 사용 중 + PG pg_hba.conf 는 hostssl only. 새 client 추가 시 세 driver 패턴 중 하나를 선택하고, CI 회귀 가드 가 manifest 의 marker 누락을 차단합니다. SSL 없이 연결 시도하면 PG 가 FATAL: no pg_hba.conf entry … SSL off 로 거부 → fail-fast (TCP connection 자체는 accept, 인증 단계에서 거부).
적용 상태
| Client | Driver | Phase / PR | 패턴 |
|---|---|---|---|
gend-api | asyncpg | #906 | Python ssl.SSLContext + connect_args |
dagster-daemon | psycopg2 (libpq) | #911 | PGSSLMODE / PGSSLROOTCERT env |
dagster-webserver | psycopg2 (libpq) | #911 | PGSSLMODE / PGSSLROOTCERT env |
mlflow | psycopg2 (libpq) | #911 | PGSSLMODE / PGSSLROOTCERT env |
nessie | Quarkus JDBC | #925 | JDBC URL query string |
hive-metastore | PostgreSQL JDBC | #925 | JDBC URL query string |
keycloak | Quarkus JDBC | #948 | JDBC URL query string (KC_DB_URL) |
hub (JupyterHub) | SQLite | — | PG 미사용 |
pg_stat_ssl 으로 prod 검증:
PG_POD=$(kubectl -n gend get pod -l app=postgresql -o jsonpath='{.items[0].metadata.name}')
kubectl -n gend exec "$PG_POD" -c postgresql -- \
psql -U gend -d gend -c "SELECT a.datname, s.ssl, s.version
FROM pg_stat_ssl s JOIN pg_stat_activity a ON s.pid = a.pid
WHERE a.client_addr IS NOT NULL ORDER BY a.datname;"
기대치: 모든 외부 IP 연결의 ssl = t (TLSv1.2 / TLSv1.3). 평문(ssl = f)이 transit 에 보이면 즉시 운영 사고.
Driver 별 패턴
세 패턴 모두 같은 cert 사용 — Secret postgresql-tls (cert-manager 발급, ca.crt 키 projected) 의 /etc/postgresql-tls/ca.crt. driver 차이로 표현법만 다릅니다.
Pattern A — asyncpg (Python)
asyncpg 는 DSN query string 의 sslrootcert 를 silent ignore 합니다 (PR #906 root cause). CA 번들은 Python ssl.SSLContext 로 만들어 connect_args={"ssl": ctx} 로 전달해야 합니다.
import ssl
ctx = ssl.create_default_context(cafile=settings.pg_ssl_ca_path)
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED
engine = create_async_engine(dsn, connect_args={"ssl": ctx})
env:
- name: GEND_DATABASE_URL
value: "postgresql+asyncpg://...?ssl=verify-full"
- name: GEND_PG_SSL_CA_PATH
value: /etc/postgresql-tls/ca.crt
volumeMounts:
- name: postgresql-tls-ca
mountPath: /etc/postgresql-tls
readOnly: true
DSN 의 ssl=verify-full 은 의도 표시 — 실제 검증은 SSLContext 가 강제합니다.
Pattern B — psycopg2 / libpq env
dagster, mlflow 처럼 분리된 PG env vars 를 쓰거나 SealedSecret 안에 통째 DSN 이 들어있어 수정 어려운 경우. libpq 가 PGSSLMODE / PGSSLROOTCERT 환경변수를 자동 인식 — DSN/Secret 미수정.
env:
- name: PGSSLMODE
value: verify-full
- name: PGSSLROOTCERT
value: /etc/postgresql-tls/ca.crt
volumeMounts:
- name: postgresql-tls-ca
mountPath: /etc/postgresql-tls
readOnly: true
DSN 에 이미 sslmode= 가 명시되어 있다면 env 가 override 되지 않습니다 — DSN 의 명시값이 우선. 그 경우 Pattern C 로 가야 합니다.
Pattern C — JDBC URL
PostgreSQL JDBC driver 는 libpq 가 아닌 Java native impl 이라 PGSSLMODE env 를 인식하지 않습니다. JDBC URL query string 에 명시가 유일한 표준.
<property>
<name>javax.jdo.option.ConnectionURL</name>
<value>jdbc:postgresql://postgresql.gend.svc.cluster.local:5432/hive_metastore?sslmode=verify-full&sslrootcert=/etc/postgresql-tls/ca.crt</value>
</property>
postgres:
jdbcUrl: jdbc:postgresql://postgresql.gend.svc.cluster.local:5432/nessie?sslmode=verify-full&sslrootcert=/etc/postgresql-tls/ca.crt
extraVolumes:
- name: postgresql-tls-ca
secret:
secretName: postgresql-tls
items:
- {key: ca.crt, path: ca.crt}
extraVolumeMounts:
- {name: postgresql-tls-ca, mountPath: /etc/postgresql-tls, readOnly: true}
새 PG client 추가 체크리스트
- Driver 확인 — asyncpg / libpq / JDBC 중 어느 것인지.
- DSN/Secret 상태 확인 — DSN 에
sslmode=또는ssl=가 이미 있는지. 있으면 env override 가 무력화되므로 DSN 직접 수정 (또는 SealedSecret 재발급) 필요. - SAN 확인 —
kubectl get certificate postgresql-tls -n gend -o jsonpath='{.spec.dnsNames}'의 SAN 목록에 client 가 사용할 PG host (DSN 의host) 가 포함되어 있는지. 없으면infra/cert-manager/certificates/postgresql-tls.yaml의dnsNames보강. - CA volume mount 추가 —
postgresql-tlsSecret 의ca.crt만 projected mount (items: [ca.crt],defaultMode: 0444,readOnly: true). - Linkerd skip-outbound — client Pod annotation 에
config.linkerd.io/skip-outbound-ports: "5432". server-first SSL handshake 가 mesh proxy 와 충돌하지 않도록 우회 (#790 outage 회피). - 회귀 가드 lint 등록 —
scripts/lint_pg_tls_verify_full.py의_RULES에 새 client 추가. CI 가 무심코 marker 누락을 차단. pg_stat_ssl로 검증 — 배포 후 새 client 의 연결이ssl=t인지 직접 확인.
CI 회귀 가드
Lint PG TLS verify-full job (scripts/lint_pg_tls_verify_full.py) 이 모든 PR 에서 7 client manifest 의 marker 존재를 검사합니다. 누구든 PGSSLMODE env, ssl=verify-full DSN, sslmode=verify-full JDBC URL 한 줄을 무심코 삭제하면 PR 머지가 차단됩니다.
# 로컬에서도 같은 lint 실행
python3 scripts/lint_pg_tls_verify_full.py
새 client 를 추가했다면 lint 도 동시에 갱신 (위 체크리스트 항목 6).
DSN 회귀 안전망
gend-api 만의 추가 안전망 (#874) — manifest lint 가 못 잡는 두 가지 경로를 부팅 시 거부합니다.
1. asyncpg DSN 의 silent plaintext fallback
asyncpg 는 libpq 의 sslmode= 키를 silent ignore 합니다. 누가 manifest 의 DSN 을 ?sslmode=require 로 잘못 적어도 asyncpg 는 평문으로 붙고 로그도 안 남깁니다 (#821 Phase 2 root cause).
gend_api.config.Settings 의 두 단계 검증:
- field_validator — asyncpg DSN 에
sslmode=가 있으면ValidationError→ 부팅 거부. - model_validator — asyncpg DSN 에
ssl=disable(또는 alias0/false/off/no) 이 있고GEND_ALLOW_PLAINTEXT_DB=true가 없으면ValidationError→ 부팅 거부. dev/staging 에서 의도적으로 평문 PG 로 띄울 때만 env 로 명시.
# 예시: 이 두 DSN 은 부팅을 막습니다 (allow_plaintext_db 미설정 가정).
GEND_DATABASE_URL = "postgresql+asyncpg://u:p@h/db?sslmode=require" # asyncpg 가 무시 → 거부
GEND_DATABASE_URL = "postgresql+asyncpg://u:p@h/db?ssl=disable" # opt-in 부재 → 거부
# 통과 패턴
GEND_DATABASE_URL = "postgresql+asyncpg://u:p@h/db?ssl=verify-full"
GEND_DATABASE_URL = "postgresql+asyncpg://u:p@h/db?ssl=require"
2. Startup health-check 가 실제 TLS / S3 활성 여부 검증
DSN 이 올바르게 들어가도 PG 자체가 평문으로 돌고 있으면 무의미. gend_api.main.lifespan 이 init_db 전에 두 probe 를 실행합니다.
_check_pg_tls_active—SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()가true가 아니면 거부 (단GEND_PG_REQUIRE_TLS=true일 때만 fail-fast, 그 외에는 WARNING)._check_s3_credentials—head_bucket(gend-ingestion)호출. 404/403 포함 모든 실패 시 거부 (GEND_S3_REQUIRE_CREDENTIALS=true게이트). 보조 옵트아웃:GEND_SKIP_S3_HEALTHCHECK=true이면 probe 자체 skip — DNS 가 없는 CI smoke / dev 환경.
prod 부팅 시 두 줄이 INFO 로그에 떠야 정상:
gend_api.main — Startup health-check: PostgreSQL transit TLS active.
gend_api.main — Startup health-check: S3 bucket gend-ingestion reachable.
테스트 커버리지: apps/api/tests/test_config_dsn.py (DSN validator 19 케이스 — sslmode= 거부 + 5종 ssl-off alias × opt-in/out parametrize) + apps/api/tests/test_startup_health.py (lifespan probe + skip 게이트 12 케이스).
롤백 시나리오
운영 사고 시 단계적으로 보수적으로 되돌립니다.
시나리오 1 — 특정 client 연결 실패 (cert mismatch 등)
해당 client manifest 만 Pattern B/C 의 verify-full → require 로 한 단계 후퇴:
# Pattern B 예시
- name: PGSSLMODE
value: require # ← verify-full 에서 require 로 복구
# Pattern C 예시 — JDBC URL
jdbcUrl: jdbc:postgresql://...?sslmode=require # sslrootcert= 제거 불필요
CA mount 는 유지해도 무해 — 다음 트라이 위해 남겨둡니다.
시나리오 2 — 전체 PG TLS off (최후 수단)
infra/helm/postgresql/deployment.yaml 의 PG args 에서 ssl=on 제거 + pg_hba.conf 의 hostssl 라인 임시 제거 → 서버가 TLS 응답을 중단.
verify-full 로 설정된 client (libpq sslmode=verify-full / JDBC sslmode=verify-full / asyncpg SSLContext) 는 서버가 TLS 를 거부하면 연결 실패 (fail-closed) 합니다. libpq + JDBC 공식 문서 기준으로 require 이상 모드에서는 자동 평문 fallback 이 일어나지 않으며, 그 상태로는 전 서비스가 PG 접근 불가입니다.
평문 fallback 까지 가야 한다면 시나리오 1 처럼 각 client 의 mode 를 require 또는 disable 로 동시 revert 필요. driver 별로:
- gend-api (asyncpg):
GEND_PG_SSL_CA_PATH=""env (SSLContext 분기 우회) + DSN?ssl=disable - dagster / mlflow (libpq):
PGSSLMODE=disableenv 로 override - nessie / hive-metastore (JDBC): JDBC URL 의
sslmode=verify-full→sslmode=disable(또는 query string 통째 제거)
평문 fallback 은 금융권 규제 위반 상태 — 30분 이내 재활성화 필수.
관련 자료
- linkerd-mesh —
skip-outbound-ports: 5432의 mesh 측 배경 (#790 outage) - Epic #821 — PG TLS 전체 계획
- Phase 1/2 — #823 / #826
- Phase 1/2 회귀 가드 — #885 (DSN validator + startup health-check + manifest FQDN lint)
- Phase 3a — #906 / #911 / #925 / #948 (keycloak)
- Phase 3b — #881 — PG
pg_hba.conf의hostnossl라인 제거 (transit 평문 차단)