9.5 extension 공급과 통제
PostgreSQL의 힘은 extension에서 나온다. 그런데 컨테이너 환경에서 "extension을 쓴다"는 한 단계가 아니라 세 단계다.
- 바이너리가 이미지 어딘가에 있어야 한다.
- 모듈이라면 서버 기동 시 미리 적재되어야 한다.
- 그 다음에야 database마다
CREATE EXTENSION을 실행한다.
이 셋은 서로 다른 상태다. 이미지에 들어 있다고 database에 설치된 것이 아니고, 한 database에 설치했다고 다른 database에서 쓸 수 있는 것도 아니다. 이 절은 CloudNativePG 1.30에서 이 세 단계를 어떻게 다루는지, 그리고 사용자가 임의로 extension을 설치하지 못하게 어떻게 통제하는지 정리한다. PostGIS를 예로 든 실전 절차는 12.3에 있다.
1단계: 바이너리를 어디서 가져오나
공식 operand 이미지(3.2)를 쓴다면 경로는 둘이다.
operand 이미지에 이미 들어 있는 것
모든 변종은 PGDG의 PostgreSQL 패키지로 만들어지므로 그 패키지에 포함된 contrib 모듈이 들어 있다. pg_stat_statements, pg_trgm, pgcrypto, hstore, postgres_fdw, pg_prewarm, uuid-ossp, amcheck 같은 것들이다. 정확한 목록은 쓰려는 이미지 안에서 pg_available_extensions를 조회해 확인한다. standard 변종은 여기에 pgaudit, pgvector, pg_failover_slots를 더 담는다. 반대로 PostGIS나 TimescaleDB 같은 third-party extension은 어느 공식 변종에도 없다. 이런 것은 아래 image volume으로 얹는다.
image volume extension으로 얹는 것
PostgreSQL 18이 extension_control_path GUC를 추가하면서 extension 파일을 PostgreSQL 설치 디렉토리 밖에 두는 길이 열렸다. CloudNativePG는 이를 Kubernetes ImageVolume과 결합해, extension 바이너리만 담은 별도 컨테이너 이미지를 Pod에 읽기 전용으로 마운트한다. operand 이미지를 다시 빌드하지 않고 extension을 더하고 빼는 방식이다. 기본 이미지를 minimal로 유지해 공격 면을 줄이고, 필요한 extension만 골라 붙인다.
요구 조건이 있다. 하나라도 빠지면 이 방식은 쓸 수 없고 PostGIS가 내장된 operand 이미지 같은 대안(12.3)으로 돌아가야 한다.
| 조건 | 이유 |
|---|---|
| PostgreSQL 18 이상 | extension_control_path GUC |
| Kubernetes 1.35 이상 | ImageVolume이 기본 활성. 1.33, 1.34는 feature gate를 직접 켜야 함 |
| containerd 2.1.0 이상 또는 CRI-O 1.31 이상 | 컨테이너 런타임의 ImageVolume 지원 |
| extension 이미지와 operand 이미지의 PostgreSQL major, OS 배포판, CPU 아키텍처 일치 | 공유 라이브러리가 그 조합으로 컴파일됨. 어긋나면 런타임에 실패 |
동작은 단순하다. spec.postgresql.extensions 목록의 각 항목이 /extensions/<이름>에 마운트되고, CloudNativePG가 extension_control_path에 /extensions/<이름>/share를, dynamic_library_path에 /extensions/<이름>/lib를 목록 순서대로 덧붙인다. Pod 안에서 손으로 설정할 것은 없다.
이미지를 가리키는 방법은 둘이다. 권장은 카탈로그 경유다. 1.29부터 ImageCatalog가 major별로 extension 이미지까지 함께 정의하므로, Cluster는 이름만 적으면 image.reference와 경로 설정을 카탈로그에서 물려받는다. 카탈로그를 안 쓰면 image.reference를 직접 적어야 한다.
# 카탈로그 경유 (권장)
spec:
imageCatalogRef:
apiGroup: postgresql.cnpg.io
kind: ClusterImageCatalog
name: postgresql-minimal-trixie
major: 18
postgresql:
extensions:
- name: pgvector
# 직접 지정
spec:
imageName: ghcr.io/cloudnative-pg/postgresql:18.4-minimal-trixie
postgresql:
extensions:
- name: pgvector
image:
reference: ghcr.io/cloudnative-pg/pgvector:<tag>
카탈로그와 Cluster에 같은 이름이 있으면 Cluster 쪽 값이 이긴다. 최종적으로 어떤 이미지가 쓰였는지는 status.pgDataImageInfo.extensions에 풀린(resolved) 형태로 남으므로, 카탈로그 상속이 의도대로 됐는지 여기서 확인한다.
이름은 소문자 영숫자, 밑줄, 하이픈으로 59자 이내다. Kubernetes 볼륨 이름으로 쓰기 위해 밑줄은 하이픈으로 바뀌므로(pg_ivm → pg-ivm), 변환 뒤 같아지는 두 이름은 함께 쓸 수 없다.
이미지 구조가 표준(/share, /lib)과 다르거나 시스템 라이브러리, 외부 바이너리, 환경 변수가 더 필요하면 항목마다 extension_control_path, dynamic_library_path, ld_library_path, bin_path, env를 직접 적어 맞춘다. 12.3의 PostGIS 예제가 ld_library_path를 쓰는 경우다.
공식 extension 이미지
CloudNativePG 커뮤니티는 postgres-extensions-containers 프로젝트로 extension 이미지를 빌드해 ghcr.io/cloudnative-pg/<이름>으로 배포한다. 신뢰할 수 있는 저장소(주로 PGDG)의 Debian main 컴포넌트 패키지만으로 빌드해 라이선스 요건(DFSG)을 맞추고, operand 이미지와 같은 OS(Debian stable, oldstable) 조합으로 제공한다. 1.30 문서 시점의 목록은 다음과 같고 계속 바뀌므로 최신 목록은 저장소에서 확인한다.
| 이미지 | 내용 |
|---|---|
pgaudit | 감사 로그 |
pgvector | 벡터 유사도 검색 |
postgis | 공간 데이터 |
pgrouting | PostGIS 위의 경로 탐색 |
pg_ivm | 증분 뷰 갱신 |
timescaledb-oss | 시계열(Apache-2 에디션) |
wal2json | logical decoding 출력 플러그인 |
pg_crash | 장애 주입(테스트 전용) |
tag는 <extension 버전>-<빌드 시각>-<PostgreSQL major>-<OS> 형식이다. 예를 들어 pgvector:0.8.1-202509101200-18-trixie는 pgvector 0.8.1을 PostgreSQL 18 trixie용으로 빌드한 것이다. 빌드 시각을 뺀 rolling tag(0.8.1-18-trixie)도 있다.
2단계: 모듈은 shared_preload_libraries에
extension 가운데 일부는 CREATE EXTENSION과 별개로 서버 기동 시 메모리에 적재되어야 한다. pg_stat_statements처럼 hook을 거는 것, pgaudit, pg_failover_slots, TimescaleDB가 그렇다. 바이너리를 image volume으로 얹었더라도 이 설정은 자동으로 붙지 않는다. 카탈로그를 써도 마찬가지다.
9.1에서 본 대로 auto_explain, pg_stat_statements, pgaudit, pg_failover_slots 넷은 그 파라미터를 parameters에 적기만 하면 Operator가 shared_preload_libraries에 넣어 준다. 이 가운데 database 안에 뷰나 함수 같은 객체가 필요한 것(pg_stat_statements, pgaudit 등)은 CREATE EXTENSION IF NOT EXISTS까지 Operator가 실행한다. auto_explain처럼 라이브러리만 있는 모듈은 적재로 끝난다. 그 밖의 모듈은 .spec.postgresql.shared_preload_libraries에 직접 적는다.
shared_preload_libraries에 적힌 라이브러리를 찾지 못하면 PostgreSQL이 기동하지 않고, CloudNativePG가 대신 고쳐 줄 방법도 없다. image volume 항목을 지우거나 이미지를 바꾸기 전에 이 목록에 남은 이름이 없는지 먼저 확인한다. 이 설정의 변경은 모든 인스턴스의 재시작을 유발한다.
3단계: database마다 설치
바이너리가 있고 모듈이 적재됐어도 database 안에는 아직 아무것도 없다. CREATE EXTENSION을 실행해야 pg_extension catalog에 등록되고 함수와 타입이 생긴다. CloudNativePG에서는 이 단계를 Database 리소스(9.3)의 spec.extensions로 선언한다.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: cluster-example-app
spec:
name: app
owner: app
cluster:
name: cluster-example
extensions:
- name: vector
version: "0.8.1"
Operator가 CREATE EXTENSION IF NOT EXISTS vector를 실행하고, 설치된 버전이 version보다 낮으면 ALTER EXTENSION ... UPDATE로 올린다. downgrade는 PostgreSQL 자체가 지원하지 않는다. 이미지 이름(pgvector)과 extension 이름(vector)이 다를 수 있다는 점에 주의한다. CREATE EXTENSION에 쓰는 이름은 control 파일 이름이다.
생명주기: 추가, 업그레이드, 제거
image volume은 Pod spec의 볼륨이다. Kubernetes에서 볼륨은 실행 중인 Pod에 추가하거나 제거할 수 없으므로 spec.postgresql.extensions의 추가, 제거, 이미지 tag 변경은 전부 rolling update(6.3)를 일으킨다. 운영 Cluster에 extension을 더하는 것은 재시작을 동반하는 변경이다.
버전을 올릴 때는 순서가 있다.
- 새 extension 이미지가 현재 버전에서 목표 버전으로 가는 upgrade 스크립트를 포함하는지 확인한다.
spec.postgresql.extensions의 이미지 tag를 바꾼다. rolling update가 수행된다.Database의version을 올린다. Operator가ALTER EXTENSION UPDATE를 실행한다.
제거는 정확히 역순이다. 바이너리를 먼저 빼면 "library not found" 오류가 나고, shared_preload_libraries에 이름이 남아 있으면 기동 자체가 실패한다.
Database에서ensure: absent로DROP EXTENSION을 실행한다.shared_preload_libraries에 넣었던 이름을 지운다.spec.postgresql.extensions에서 항목을 제거한다. rolling update가 수행되면서 볼륨이 제거된다.
설치 통제
멀티 테넌트로 운영하면 "사용자가 임의의 extension을 설치하지 못하게" 하는 요구가 나온다. CloudNativePG 1.30 문서에는 CREATE EXTENSION을 차단하거나 허용 목록을 두는 기능이 없다. 통제는 PostgreSQL 자체의 권한 모델로 한다.
PostgreSQL 13부터 extension은 trusted와 untrusted로 나뉜다. untrusted extension은 superuser만 설치할 수 있다. CloudNativePG는 기본적으로 superuser 접속을 꺼 두므로(enableSuperuserAccess: false) 애플리케이션 role은 untrusted extension을 설치할 수 없다. 문제는 trusted extension이다. pgcrypto, hstore, pg_trgm, citext, ltree, btree_gin처럼 control 파일에 trusted = true가 붙은 contrib extension은 그 database에 CREATE 권한이 있는 role이면 설치가 되고, database owner는 기본으로 이 권한을 갖는다.
그래서 통제 수단은 이렇게 된다.
- 테넌트 role을 database owner로 두지 않고,
REVOKE CREATE ON DATABASE로CREATE권한도 회수한다. 이러면 trusted extension도 설치할 수 없다. 대신 그 role은 schema도 만들 수 없으므로 schema는 미리 만들어 준다. - 허용하는 extension은
Database리소스로만 설치한다. Operator는 superuser로 실행하므로 role 권한과 무관하게 설치되고, 무엇이 설치됐는지가 매니페스트에 남는다. - 더 세밀한 통제가 필요하면 PostgreSQL event trigger로
CREATE EXTENSION실행을 감지해 허용 목록과 대조하는 방법이 있다. trigger를 superuser 소유로 만들면 테넌트가 해제할 수 없다. 이는 CloudNativePG 기능이 아니라 PostgreSQL 일반 기법이다.
extension_control_path를 통제 수단으로 쓰는 것은 권하지 않는다. 이 GUC의 $system 항목을 빼면 기본 경로의 extension이 검색에서 빠지기는 하지만, CloudNativePG가 image volume을 위해 이 GUC를 직접 관리하므로 값이 덮어써지거나 충돌할 수 있고, 접근 통제를 위해 설계된 기능도 아니다. 통제는 경로가 아니라 권한으로 한다.
정리
- extension은 바이너리 공급, 모듈 적재, database별 설치의 세 단계이고 각각 상태가 다르다. 이미지에 있다고 설치된 것이 아니다.
- 공급 경로는 operand 이미지 내장(contrib, standard 추가분)과 image volume이다. image volume은 PostgreSQL 18, Kubernetes 1.35, 최신 컨테이너 런타임, major/OS/arch 일치가 조건이다.
spec.postgresql.extensions의 변경은 rolling update를 부른다. 제거는DROP EXTENSION→ preload 해제 → 볼륨 제거 순서다.- 설치 통제 기능은 CloudNativePG에 없다.
CREATE권한 회수와Database리소스 단일 경로, 필요하면 event trigger로 PostgreSQL 층에서 통제한다.