CloudNativePG
CloudNativePG (CNPG) can add an extension to a cluster without a custom PostgreSQL image. The extension ships as a small image that holds only its files. The operator mounts that image read-only into every instance pod and points PostgreSQL at it. CNPG calls these image volume extensions. The repository builds such an image next to the full Docker image, from the same compile:
| Image | What it is | For |
|---|---|---|
pg-automerge:<version> | the official postgres:18 image plus the extension | docker run, compose, local development, small deployments |
pg-automerge-cnpg:<version>-18-trixie | the extension's files only, FROM scratch (it cannot be run) | a CNPG Cluster's spec.postgresql.extensions |
mise run docker-build # both images
mise run docker-build-cnpg # only pg-automerge-cnpg:0.1.0-18-trixie
mise run cnpg-smoke # build it, then test it in CNPG's PostgreSQL image (no Kubernetes)
mise run cnpg-e2e # build it, then test it with the CNPG operator on kindThe name and tag follow CNPG's own extension images (<extension version>-<PostgreSQL major>-<Debian release>,
as in pgvector:0.8.6-18-trixie). The image has a repository of its own, so its
tags never get mixed up with the runnable image's.
Requirements#
- CloudNativePG 1.27 or later (
spec.postgresql.extensions). Defining the extension in an image catalog needs 1.29 or later. - Kubernetes with image volumes: on by default from Kubernetes 1.35, and
stable from 1.36. On 1.33 and 1.34, enable the
ImageVolumefeature gate. The nodes need containerd 2.1 or later, or CRI-O 1.31 or later. On a managed service this depends on the provider's node images. - A PostgreSQL 18 operand on Debian trixie, amd64 or arm64, for example
ghcr.io/cloudnative-pg/postgresql:18-minimal-trixieor18-standard-trixie. Both variants share one base, and the library needs onlylibcandlibgcc_s. The extension image must match the cluster's PostgreSQL major version, Debian release and CPU architecture.
No bookworm. A -bookworm operand cannot load the library: it is
built on trixie, and needs GLIBC_2.38, which bookworm's glibc 2.36 does
not have. CREATE EXTENSION fails with version 'GLIBC_2.38' not found.
The image is not built for bookworm either: the builder installs Rust
with Debian's rustup package, which bookworm does not have. Debian 12
also reached its end of life in June 2026, and CNPG supports its bookworm
images on a best-effort basis only.
A cluster with pg_automerge#
Every release publishes the image for amd64 and arm64 as
ghcr.io/theknarf/pg-automerge-cnpg:<version>-18-trixie (see
Releasing); a build of your own goes to a
registry your cluster can pull from with
scripts/docker-push.sh --cnpg. List it in the Cluster. A Database resource can create the extension
for you:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: automerge
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie # trixie, like the extension image
storage:
size: 10Gi
resources: # see Memory and pod limits
requests:
memory: 4Gi
limits:
memory: 4Gi
postgresql:
parameters:
shared_buffers: "1GB"
max_connections: "20" # a Pooler in front keeps this small
pg_automerge.max_load_memory: "512MB" # optional; see Configuration
extensions:
- name: pg-automerge # mounted at /extensions/pg-automerge
image:
reference: ghcr.io/theknarf/pg-automerge-cnpg:0.1.0-18-trixie
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: automerge-app
spec:
name: app
owner: app
cluster:
name: automerge
extensions:
- name: pg_automerge # the SQL name
version: "0.1.0"For each entry, the operator adds an image volume and mounts it at
/extensions/<name>. It also appends that path to two settings:
extension_control_path = '$system:/extensions/pg-automerge/share'
dynamic_library_path = '$libdir:/extensions/pg-automerge/lib'PostgreSQL 18 finds pg_automerge.control and the SQL scripts through the
first setting. It finds pg_automerge.so through the second, because the
control file names the library without a directory
(module_pathname = 'pg_automerge'). The extension needs no
shared_preload_libraries.
Mounting the image only makes the extension available. The Database
resource above runs CREATE EXTENSION pg_automerge as the superuser (it
is not trusted, see Install). Without it, a superuser
can run that command, for example through kubectl cnpg psql automerge -- -d app.
The app owner cannot. To check that the image was resolved:
kubectl get cluster automerge -o jsonpath='{.status.pgDataImageInfo.extensions}'Pick the name once (pg-automerge above) and keep it. Renaming it moves
the mount, which triggers a rolling update.
On kind (or any cluster), you can use a locally built image without a
registry. Run kind load docker-image pg-automerge-cnpg:0.1.0-18-trixie
and set pullPolicy: IfNotPresent (already the default for any tag but
latest) or Never on the image. tests/cnpg_e2e.sh does the same with kind load image-archive and
pullPolicy: Never.
Configuration#
The three settings of Configuration
(pg_automerge.max_load_memory, pg_automerge.verify_writes,
pg_automerge.trim_threshold) go in spec.postgresql.parameters, like
any other PostgreSQL setting. CNPG passes custom names with a dot through
to postgresql.conf (it only fixes or refuses a known list of its own),
and PostgreSQL keeps such a value as a placeholder until a backend loads
the library, which then applies it. All three can be changed at runtime:
CNPG reloads the configuration without a restart. To check:
kubectl cnpg psql automerge -- -d app -c 'SHOW pg_automerge.max_load_memory'A per-role or per-database value (ALTER ROLE app SET pg_automerge.verify_writes = off, say) works as on any server; run it as
the superuser.
Memory and pod limits#
Loading a document takes memory outside shared_buffers and work_mem,
and pg_automerge's memory is not counted by PostgreSQL. In a pod, the
container's memory limit is what the kernel enforces: a backend that goes
over it is OOM-killed, and PostgreSQL then restarts every session of that
instance (CNPG does not fail over for it, but every client reconnects).
So size the pod as Sizing memory
describes for a container, with resources.limits.memory as the limit:
limits.memory ≥ shared_buffers
+ max_connections × (20 MB + pg_automerge.trim_threshold)
+ concurrent loads × largest document's peak
+ room for the page cacheand set pg_automerge.max_load_memory above the estimate of the largest
document you accept and below (limits.memory − shared_buffers − max_connections × (20 MB + trim_threshold)) / concurrent writers. The
example above (4 GiB, 1 GB of shared_buffers, 20 connections, the
default 64 MB trim threshold) leaves about 3 GB − 20 × 84 MB ≈ 1.3 GB:
room for two concurrent loads at its 512 MB max_load_memory, before the
page cache. Connections are the expensive term: with 100 (PostgreSQL's
default) the same pod would have nothing left, so keep max_connections
small (CNPG's Pooler in front helps), lower trim_threshold, or raise
the limit. Measure your own largest documents with
automerge_memory_usage() (Monitoring)
rather than trusting these numbers.
Set requests.memory equal to limits.memory (CNPG's recommendation,
for the Guaranteed QoS class), so the node does not overcommit the memory
a large load relies on.
Updating#
0.1.0 is the first release; a later version is rolled out like this:
- Change
extensions[].image.referenceto the new tag. CNPG restarts the instances one by one (image volumes are resolved when a pod starts). Every image holds all the upgrade scripts, so any older version can update. - Bump
versionin theDatabaseresource. The operator then runsALTER EXTENSION pg_automerge UPDATE TO '<version>'. Without aDatabaseresource, runALTER EXTENSION pg_automerge UPDATEas a superuser in every database that has the extension.
Between the two steps the new library serves the old SQL definitions, exactly as after recreating a container (Updating). The Changelog says whether a version needs more. A rebuilt image of the same version under a new tag (a newer base, say) needs only step 1: the pods roll and the catalog stays as it is.
To remove the extension, first set ensure: absent in the Database
resource (or run DROP EXTENSION), then delete the extensions entry,
which triggers a rolling update.
Limitations#
- Superuser only. The extension is not trusted, so only the superuser
(the
Databaseresource's reconciler, orkubectl cnpg psql) can create, update or drop it. A database owner cannot. - Trixie, PostgreSQL 18, amd64 or arm64. No bookworm image (see Requirements), no other PostgreSQL major. A major upgrade to a future PostgreSQL 19 with image volume extensions needs CNPG 1.29 or later and a PostgreSQL 19 build of the extension, which does not exist yet.
- No image catalog of ours. With CNPG 1.29 or later you can list the
image in your own
ClusterImageCatalog(spec.images[].extensions, then just- name: pg-automergein eachCluster). The official CNPG catalogs only carry extensions packaged by PGDG, which pg_automerge is not. - Kubernetes image volumes. Clusters older than 1.33, or whose nodes
run an older containerd or CRI-O, cannot mount the image. There, build
your own operand image instead:
FROM ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie, then copy the image's/lib/pg_automerge.soto/usr/lib/postgresql/18/lib/and/share/extension/*to/usr/share/postgresql/18/extension/, and tag it starting with18so CNPG detects the major version. This repository does not build or test that variant. - Memory outside PostgreSQL's accounting, as everywhere: see Memory and pod limits, and Limitations.
What is tested#
tests/cnpg_smoke.sh (mise run cnpg-smoke, and part of
mise run docker-test and the CI docker job) needs no Kubernetes. It
runs CNPG's pinned 18-minimal-trixie operand image, mounts the extension
image read-only at /extensions/pg-automerge (a Docker image mount,
which is what an image volume is), and sets the two settings exactly as
the operator writes them. It checks:
- the image's labels and that it holds exactly CNPG's layout:
/lib/pg_automerge.so, the control file and SQL scripts in/share/extension, and the license in/licenses/pg_automerge; - that the operand has no copy of its own;
- that
CREATE EXTENSIONworks for the superuser and is refused for a plain role; - that the backend maps the library from the mount, and that a setting given before the library loads applies;
- merges, jsonb reads with a GIN index,
automerge_spans, and that the image offers exactly the versions of its SQL scripts, with an update path from each older one; - that the server log shows no crash.
tests/cnpg_e2e.sh (mise run cnpg-e2e, and the CI cnpg-e2e job for every release, nightly and on manual runs, on
the image the docker job built) covers the operator itself. It takes a
few minutes and needs kind, so it is not part of mise run ci or
docker-test. It creates a throwaway kind cluster (pinned kind and
Kubernetes 1.36 node image), installs the pinned CNPG release (1.30.1),
loads the images into the node (kind load, pullPolicy: Never, no
registry), and walks the steps of this page:
- A
Clusteron the same pinned operand, with the image under test inspec.postgresql.extensions. It checks the pod's image volume andstatus.pgDataImageInfo, the two settings, thepg_automerge.max_load_memoryparameter, and that theappowner cannotCREATE EXTENSION. - A
Databaseresource with the image'sversion: the operator creates the extension. Data is written, and the backend maps the library from/extensions/pg-automerge/lib. - The
Cluster's image changed to a rebuild of the same version under another tag (the image under test with one more label): the pod rolls (a new pod with the new image volume), the data survives, and the extension, still at its version, works: merges, jsonb reads with a GIN index,automerge_spansandautomerge_memory_usage()on the regress fixtures. - Removal as described above:
ensure: absentdrops the extension, and deleting theextensionsentry rolls the pod without the image volume. The server log shows no crash.
With only one version so far, the test does not run the update of
step 2 under Updating. For the first version after 0.1.0 it gets that
step back: the Cluster starts on the 0.1.0 image with version: "0.1.0" in the Database, rolls to the image under test, and bumps the
Database's version, so the operator runs ALTER EXTENSION ... UPDATE TO (the top of tests/cnpg_e2e.sh describes how).
kind's node runs as root of the Docker daemon, so its inotify instances
count against root's fs.inotify.max_user_instances. kind asks for 512,
and Debian's default is 128. Where root already uses most of them (a k3s
on the same host, for example), kube-proxy and the operator crash-loop
with too many open files, and the test prints that diagnosis. Raise the
limit (sudo sysctl -w fs.inotify.max_user_instances=512), or run kind on
a rootless Docker daemon of your own user, whose containers count against
your user instead. CNPG_E2E_DOCKER_HOST points the test at it; the
images are still built by the default daemon:
dockerd-rootless.sh --data-root ~/.local/share/docker-e2e \
-H unix://$XDG_RUNTIME_DIR/e2e/docker.sock &
CNPG_E2E_DOCKER_HOST=unix://$XDG_RUNTIME_DIR/e2e/docker.sock mise run cnpg-e2eNeither test covers more than one instance (replicas, switchover), image catalogs, or a managed Kubernetes service. Docker image has the packaging decisions.