본문으로 건너뛰기

PostgreSQL TLS (verify-full) 운영 가이드

GenD 의 PostgreSQL 클러스터는 transit TLS verify-full 로 보호됩니다 — client 가 서버 cert chain + hostname 까지 검증한 후에만 연결 성립. PG pg_hba.confhostssl 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.confhostssl only. 새 client 추가 시 세 driver 패턴 중 하나를 선택하고, CI 회귀 가드 가 manifest 의 marker 누락을 차단합니다. SSL 없이 연결 시도하면 PG 가 FATAL: no pg_hba.conf entry … SSL off 로 거부 → fail-fast (TCP connection 자체는 accept, 인증 단계에서 거부).

적용 상태

ClientDriverPhase / PR패턴
gend-apiasyncpg#906Python ssl.SSLContext + connect_args
dagster-daemonpsycopg2 (libpq)#911PGSSLMODE / PGSSLROOTCERT env
dagster-webserverpsycopg2 (libpq)#911PGSSLMODE / PGSSLROOTCERT env
mlflowpsycopg2 (libpq)#911PGSSLMODE / PGSSLROOTCERT env
nessieQuarkus JDBC#925JDBC URL query string
hive-metastorePostgreSQL JDBC#925JDBC URL query string
keycloakQuarkus JDBC#948JDBC URL query string (KC_DB_URL)
hub (JupyterHub)SQLitePG 미사용

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 의 sslrootcertsilent ignore 합니다 (PR #906 root cause). CA 번들은 Python ssl.SSLContext 로 만들어 connect_args={"ssl": ctx} 로 전달해야 합니다.

apps/api/src/gend_api/db/session.py
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})
manifest (gend-api 예시)
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 미수정.

manifest (mlflow 예시)
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 에 명시가 유일한 표준.

manifest (hive-metastore 예시 — XML escape 주의)
<property>
<name>javax.jdo.option.ConnectionURL</name>
<value>jdbc:postgresql://postgresql.gend.svc.cluster.local:5432/hive_metastore?sslmode=verify-full&amp;sslrootcert=/etc/postgresql-tls/ca.crt</value>
</property>
Helm values (nessie 예시 — YAML escape 불필요)
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 추가 체크리스트

  1. Driver 확인 — asyncpg / libpq / JDBC 중 어느 것인지.
  2. DSN/Secret 상태 확인 — DSN 에 sslmode= 또는 ssl= 가 이미 있는지. 있으면 env override 가 무력화되므로 DSN 직접 수정 (또는 SealedSecret 재발급) 필요.
  3. SAN 확인kubectl get certificate postgresql-tls -n gend -o jsonpath='{.spec.dnsNames}' 의 SAN 목록에 client 가 사용할 PG host (DSN 의 host) 가 포함되어 있는지. 없으면 infra/cert-manager/certificates/postgresql-tls.yamldnsNames 보강.
  4. CA volume mount 추가postgresql-tls Secret 의 ca.crt 만 projected mount (items: [ca.crt], defaultMode: 0444, readOnly: true).
  5. Linkerd skip-outbound — client Pod annotation 에 config.linkerd.io/skip-outbound-ports: "5432". server-first SSL handshake 가 mesh proxy 와 충돌하지 않도록 우회 (#790 outage 회피).
  6. 회귀 가드 lint 등록scripts/lint_pg_tls_verify_full.py_RULES 에 새 client 추가. CI 가 무심코 marker 누락을 차단.
  7. 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 (또는 alias 0/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_activeSELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()true 가 아니면 거부 (단 GEND_PG_REQUIRE_TLS=true 일 때만 fail-fast, 그 외에는 WARNING).
  • _check_s3_credentialshead_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-fullrequire 로 한 단계 후퇴:

# 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.confhostssl 라인 임시 제거 → 서버가 TLS 응답을 중단.

자동 평문 fallback 없음

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=disable env 로 override
  • nessie / hive-metastore (JDBC): JDBC URL 의 sslmode=verify-fullsslmode=disable (또는 query string 통째 제거)

평문 fallback 은 금융권 규제 위반 상태 — 30분 이내 재활성화 필수.

관련 자료

  • linkerd-meshskip-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.confhostnossl 라인 제거 (transit 평문 차단)