Migrate any Postgresql to CNPG¶
We will use logical replication to migrate any postgresql to CNPG cluster.
Preparation¶
we need to prepare postgresql outside of CNPG that will be used as subscriber. 1. create the cluster
2. check if wal_level is already logk exec -it postgresql-publisher-786dfc64f7-k6zdv -- bash
root@postgresql-publisher-786dfc64f7-k6zdv:/# psql -U echo_user -d echo
psql (15.14 (Debian 15.14-1.pgdg13+1))
Type "help" for help.
echo=# show wal_level;
wal_level
-----------
logical
(1 row)
CREATE USER logical_repl WITH REPLICATION LOGIN PASSWORD 'repl_password';
GRANT CONNECT ON DATABASE echo TO logical_repl;
GRANT USAGE ON SCHEMA public TO logical_repl;
\c echo;
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO logical_repl;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO logical_repl;
echo=# SELECT usename, usesuper, userepl FROM pg_user WHERE usename = 'logical_repl';
usename | usesuper | userepl
--------------+----------+---------
logical_repl | f | t
(1 row)
echo=# SELECT * FROM pg_publication;
oid | pubname | pubowner | puballtables | pubinsert | pubupdate | pubdelete | pubtruncate | pubviaroot
-------+----------------+----------+--------------+-----------+-----------+-----------+-------------+------------
16390 | all_tables_pub | 10 | t | t | t | t | t | f
(1 row)
echo=# SELECT * FROM pg_publication_tables;
pubname | schemaname | tablename | attnames | rowfilter
---------+------------+-----------+----------+-----------
(0 rows)
echo=# \c echo logical_repl
You are now connected to database "echo" as user "logical_repl".
echo=> SELECT * FROM pg_publication;
oid | pubname | pubowner | puballtables | pubinsert | pubupdate | pubdelete | pubtruncate | pubviaroot
-------+----------------+----------+--------------+-----------+-----------+-----------+-------------+------------
16390 | all_tables_pub | 10 | t | t | t | t | t | f
(1 row)
kubectl create -f deployment-before-migrate.yaml
kubectl port-forward svc/deployment-migrate 8080:8080
echo=> \dt
List of relations
Schema | Name | Type | Owner
--------+-------+-------+-----------
public | echos | table | echo_user
(1 row)
echo=> select * from echos;
created_at | updated_at | deleted_at | id | echo
------------+------------+------------+----+------
(0 rows)
curl http://localhost:8080/postgresql/test
58b152e8-8c96-4b3c-b9fb-194fdd3d7df4:test%
echo=> select * from echos;
created_at | updated_at | deleted_at | id | echo
-------------------------------+-------------------------------+------------+--------------------------------------+------
2025-11-09 08:59:58.212282+00 | 2025-11-09 08:59:58.210585+00 | | 58b152e8-8c96-4b3c-b9fb-194fdd3d7df4 | test
Create the Database with CNPG¶
- create the manifests
- the first thing that the CNPG do it will actually import the schema from old database from .spec.bootstrap and .spec.externalClusters
externalClusters will be used to configure access to one or more PostgreSQL clusters as sources. in this case, the external cluster is out database outside of CNPG, can be VM, or any type of PostgreSQL database.
spec: externalClusters: - name: legacy-postgresql-publisher connectionParameters: host: postgresql-publisher.cnpg-system.svc.cluster.local user: logical_repl dbname: echo password: name: cnpg-postgresql-publisher-creds key: password bootstrap: initdb: import: type: microservice schemaOnly: true databases: - echo source: externalCluster: legacy-postgresql-publisher
https://cloudnative-pg.io/documentation/1.26/bootstrap/#the-externalclusters-section
Bootstrap .initdb.import.schemaOnly set as true will tell CNPG to import the schema of database echo from the externalCluster defined. which mean when the CNPG cluster is created, the first thing it will do it importing the schema of database echo
https://cloudnative-pg.io/documentation/1.26/database_import/#the-microservice-type
you can follow this command to validate
k get pod | grep import
cnpg-postgresql-publisher-1-import-6b8ch 0/1 Init:0/1 0 10s
k logs cnpg-postgresql-publisher-1-import-6b8ch
{"level":"info","ts":"2025-11-09T13:34:02.310198507Z","msg":"dropping user-defined extensions from the target (empty) database","logging_pod":"cnpg-postgresql-publisher-1-import"}
{"level":"info","ts":"2025-11-09T13:34:02.315041387Z","msg":"temporarily granting superuser permission to owner user","logging_pod":"cnpg-postgresql-publisher-1-import","owner":"app"}
{"level":"info","ts":"2025-11-09T13:34:02.317266806Z","msg":"executing database importing section","logging_pod":"cnpg-postgresql-publisher-1-import","databaseName":"echo","section":"pre-data"}
{"level":"info","ts":"2025-11-09T13:34:02.317278636Z","msg":"Running pg_restore","logging_pod":"cnpg-postgresql-publisher-1-import","cmd":"pg_restore","options":["-U","postgres","--no-owner","--no-privileges","--role=app","-d","app","--section","pre-data","/var/lib/postgresql/data/pgdata/dumps/echo.dump"]}
k get pod
cnpg-postgresql-publisher-1 2/2 Running 0 3m7s
cnpg-postgresql-publisher-2 2/2 Running 0 73s
k get cluster
NAME AGE INSTANCES READY STATUS PRIMARY
cnpg-postgresql-publisher 8m33s 2 2 Cluster in healthy state cnpg-postgresql-publisher-1
kubectl port-forward svc/cnpg-postgresql-publisher-rw -n cnpg-system 5432:5432
export PG_PASSWORD=$(kubectl get secret cnpg-postgresql-publisher-app -o jsonpath='{.data.password}' | base64 -d)
psql -h 127.0.0.1 -p 5432 -d app -U app
psql (18.0, server 17.5 (Debian 17.5-1.pgdg110+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)
Type "help" for help.
app=> \dt
List of tables
Schema | Name | Type | Owner
--------+-------+-------+-------
public | echos | table | app
(1 row)
app=> \d+ echos
Table "public.echos"
Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description
------------+--------------------------+-----------+----------+---------+----------+-------------+--------------+-------------
created_at | timestamp with time zone | | | | plain | | |
updated_at | timestamp with time zone | | | | plain | | |
deleted_at | timestamp with time zone | | | | plain | | |
id | character varying(255) | | not null | | extended | | |
echo | character varying(255) | | not null | | extended | | |
Indexes:
"echos_pkey" PRIMARY KEY, btree (id)
"idx_echos_deleted_at" btree (deleted_at)
Publications:
"all_tables_pub"
Access method: heap
-
verify the schema on old database
k exec -it postgresql-publisher-786dfc64f7-k6zdv -- bash root@postgresql-publisher-786dfc64f7-k6zdv:/# psql -U echo_user -d echo psql (15.14 (Debian 15.14-1.pgdg13+1)) Type "help" for help. echo=# \d+ echos Table "public.echos" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description ------------+--------------------------+-----------+----------+---------+----------+-------------+--------------+------------- created_at | timestamp with time zone | | | | plain | | | updated_at | timestamp with time zone | | | | plain | | | deleted_at | timestamp with time zone | | | | plain | | | id | character varying(255) | | not null | | extended | | | echo | character varying(255) | | not null | | extended | | | Indexes: "echos_pkey" PRIMARY KEY, btree (id) "idx_echos_deleted_at" btree (deleted_at) Publications: "all_tables_pub" Access method: heap -
if you check, the schema is already same, but if we check the new database, there are no entry yet
Create subscriber¶
To start sync the data from old database to CNPG database, create subscriber object
apiVersion: postgresql.cnpg.io/v1
kind: Subscription
metadata:
name: cnpg-postgresql-publisher
namespace: cnpg-system
spec:
cluster:
name: cnpg-postgresql-publisher
dbname: app
name: subscriber
externalClusterName: legacy-postgresql-publisher
publicationName: all_tables_pub
spec.dbname is the database name of the CNPG postgresql. in this case, since we are not set the database name, the default will be app
-
apply the subscription object
-
check the database on the CNPG postgresql
the records is now sync with the old/legacy database.app=> select * from echos; created_at | updated_at | deleted_at | id | echo -------------------------------+-------------------------------+------------+--------------------------------------+------ 2025-11-09 08:59:58.212282+00 | 2025-11-09 08:59:58.210585+00 | | 58b152e8-8c96-4b3c-b9fb-194fdd3d7df4 | test -
let's try to call service that still on the old database
-
it will replicated directly to CNPG cluster
app=> select * from echos; created_at | updated_at | deleted_at | id | echo -------------------------------+-------------------------------+------------+--------------------------------------+------ 2025-11-09 08:59:58.212282+00 | 2025-11-09 08:59:58.210585+00 | | 58b152e8-8c96-4b3c-b9fb-194fdd3d7df4 | test 2025-11-09 14:11:15.477467+00 | 2025-11-09 14:11:15.474096+00 | | f56f9609-1542-4549-8c1d-f141ff7dc7df | test (2 rows)
Next Step¶
- Plan deployment freeze where you stop all traffic
- change your database configuration to new CNPG database
- remove the subscription CRDs
- completely deprecated your old/legacy database on VMs!
Source¶
Manifests¶
cluster.yaml
---
apiVersion: v1
kind: Secret
metadata:
name: cnpg-postgresql-publisher-creds
namespace: cnpg-system
data:
password: cmVwbF9wYXNzd29yZA==
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: cnpg-postgresql-publisher
namespace: cnpg-system
spec:
instances: 2
externalClusters:
- name: legacy-postgresql-publisher
connectionParameters:
host: postgresql-publisher.cnpg-system.svc.cluster.local
user: logical_repl
dbname: echo
password:
name: cnpg-postgresql-publisher-creds
key: password
bootstrap:
initdb:
import:
type: microservice
schemaOnly: true
databases:
- echo
source:
externalCluster: legacy-postgresql-publisher
storage:
size: 10Gi
storageClass: gtf-ack-essd-pl0-wait
walStorage:
size: 1Gi
storageClass: gtf-ack-essd-pl0-wait
primaryUpdateStrategy: unsupervised
primaryUpdateMethod: switchover
postgresql:
parameters:
archive_timeout: "10min"
synchronous:
method: any
number: 1
dataDurability: required
backup:
target: prefer-standby
volumeSnapshot:
className: alibabacloud-disk-snapshot
online: false
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: s3-object-store-wal-archival
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
deployment-before-migrate.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-migrate
namespace: cnpg-system
labels:
app: deployment-migrate
spec:
replicas: 1
selector:
matchLabels:
app: deployment-migrate
template:
metadata:
labels:
app: deployment-migrate
spec:
containers:
- name: deployment-migrate
image: ghcr.io/zufardhiyaulhaq/echo-postgresql:v1.0.1
ports:
- containerPort: 8080
name: http
env:
- name: HTTP_PORT
value: "8080"
- name: POSTGRESQL_HOST
value: "postgresql-publisher.cnpg-system.svc.cluster.local"
- name: POSTGRESQL_PORT
value: "5432"
- name: POSTGRESQL_DATABASE
value: "echo"
- name: POSTGRESQL_USER
value: "echo_user"
- name: POSTGRESQL_PASSWORD
value: "echo_password"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: deployment-migrate
namespace: cnpg-system
spec:
selector:
app: deployment-migrate
ports:
- protocol: TCP
port: 8080
targetPort: http
type: ClusterIP
postgresql.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgresql-publisher
namespace: cnpg-system
spec:
replicas: 1
selector:
matchLabels:
app: postgresql-publisher
template:
metadata:
labels:
app: postgresql-publisher
spec:
containers:
- name: postgresql
image: postgres:15
ports:
- containerPort: 5432
args: ["-c", "wal_level=logical"]
env:
- name: POSTGRES_DB
value: "echo"
- name: POSTGRES_USER
value: "echo_user"
- name: POSTGRES_PASSWORD
value: "echo_password"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
subPath: postgres-data
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgresql-publisher-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgresql-publisher-pvc
namespace: cnpg-system
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: postgresql-publisher
namespace: cnpg-system
spec:
selector:
app: postgresql-publisher
ports:
- port: 5432
targetPort: 5432
type: ClusterIP
subscriber.yaml
https://www.gabrielebartolini.it/articles/2024/03/cloudnativepg-recipe-5-how-to-migrate-your-postgresql-database-in-kubernetes-with-~0-downtime-from-anywhere/ https://cloudnative-pg.io/documentation/1.26/bootstrap/#bootstrap-from-another-cluster