Using GitOps to Self-Manage Postgres in Kubernetes

Jonathan S. Katz
PostgreSQL Kubernetes PostgreSQL Operator GitOps

"GitOps" is a term that I've been seeing come up more and more. The concept was first put forward by the team at Weaveworks as a way to consolidate thought around deploying applications. In essence: your deployment topology lives in your git repository. You can update your deployment information by adding a new commit. Likewise, if you need to revert your system's state, you can rollback to the commit that you want to represent your production environment. Any changes to your deployment topology should be reconciled in your production environment.

A lot of the conversations around GitOps came around the Postgres Operator for Kubernetes and how to apply these principles. Platforms like Kubernetes make it relatively seamless to apply the ideas of GitOps to the stateless pieces of applications (e.g., your web application). More work needs to be done with a stateful service such as PostgreSQL, as each stateful application can have unique requirements. Let's take something like replication: PostgreSQL's replication system is both configured and managed differently than in other database systems, and any GitOps management tool, such as Helm or Kustomize, would have to account for this.

This is the beauty of the Operator pattern. It provides a generic framework for developers to allow for GitOps style management of a stateful application in a way that can be "simply" configured. I say "simply" because there is still a lot to consider when running a complex stateful application like a database in a production environment, but an Operator can make the overall management and application of changes easier.

There are many ways to support GitOps workflows in current versions of the PostgreSQL Operator, from Kubernetes YAML files to Helm charts to Kustomize manifests.

Let's work through a GitOps style workflow. The examples below will assume that you have installed the Postgres Operator.

Deploying a HA PostgreSQL Cluster

For the first example, let's create a HA PostgreSQL cluster called hippo using a Kubernetes YAML file. In your command-line environment, set the following environmental variables to where you want your PostgreSQL cluster deployed.

For this example, the namespace uses the same one created as part of the quickstart. You can copy and paste the below into your environment to try the example out. You can also modify the custom resource manifest to match your specific environment. Note that if your environment does not support environmental variables, you can find/replace the below values in your manifest file.

From your command line, execute the following:

# this variable is the namespace the cluster is being deployed into
export cluster_namespace=pgo
# this variable is the name of the cluster being created
export pgo_cluster_name=hippo
# this variable sets the default disk size
export cluster_disk_size=5Gi

cat <<-EOF > "${pgo_cluster_name}-pgcluster.yaml"
apiVersion: crunchydata.com/v1
kind: Pgcluster
metadata:
  annotations:
    current-primary: ${pgo_cluster_name}
  labels:
    crunchy-pgha-scope: ${pgo_cluster_name}
    deployment-name: ${pgo_cluster_name}
    name: ${pgo_cluster_name}
    pg-cluster: ${pgo_cluster_name}
    pgo-version: 4.6.0
  name: ${pgo_cluster_name}
  namespace: ${cluster_namespace}
spec:
  BackrestStorage:
    accessmode: ReadWriteOnce
    size: ${cluster_disk_size}
    storagetype: dynamic
  PrimaryStorage:
    accessmode: ReadWriteOnce
    name: ${pgo_cluster_name}
    size: ${cluster_disk_size}
    storagetype: dynamic
  ReplicaStorage:
    accessmode: ReadWriteOnce
    size: ${cluster_disk_size}
    storagetype: dynamic
  ccpimage: crunchy-postgres-ha
  ccpimageprefix: registry.developers.crunchydata.com/crunchydata
  ccpimagetag: centos8-13.1-4.6.0
  clustername: ${pgo_cluster_name}
  database: ${pgo_cluster_name}
  exporterport: "9187"
  limits: {}
  name: ${pgo_cluster_name}
  namespace: ${cluster_namespace}
  pgDataSource: {}
  pgbadgerport: "10000"
  pgoimageprefix: registry.developers.crunchydata.com/crunchydata
  podAntiAffinity:
    default: preferred
    pgBackRest: preferred
    pgBouncer: preferred
  port: "5432"
  replicas: "1"
  user: ${pgo_cluster_name}
  userlabels:
    pgo-version: 4.6.0
EOF

kubectl apply -f "${pgo_cluster_name}-pgcluster.yaml"

This manifest tells the PostgreSQL Operator to create a high availability PostgreSQL cluster. It will take a few moments to get everything provisioned. You can check on the status using kubectl get pods or using the pgo test command if you have installed the PostgreSQL Operator client. For example, the below demonstrates that there are two PostgreSQL instances in the hippo cluster available:

kubectl -n "${cluster_namespace}" get pods  --selector="pg-cluster=${pgo_cluster_name},pgo-pg-database"
NAME                                          READY   STATUS      RESTARTS   AGE
hippo-585bb4f797-f76bl                        1/1     Running     0          3m32s
hippo-gftf-7f55674d78-gsjx7                   1/1     Running     0          2m53s

Let's log into our newly provisioned database cluster. In our example above, we created a database named hippo along with a user called hippo. The Postgres Operator creates user credentials for several defaults users, including any users we specify. To get the credentials, assuming you still have the environmental variables set from the earlier step, you can use the following command:

kubectl -n "${cluster_namespace}" get secrets "${pgo_cluster_name}-${pgo_cluster_name}-secret" -o jsonpath="{.data.password}" | base64 -d

For convenience for connecting to the cluster, we can store the user's password directly in an environmental variable that psql recognizes:

export PGPASSWORD=$(kubectl -n jkatz get secrets "${pgo_cluster_name}-${pgo_cluster_name}-secret" -o jsonpath="{.data.password}" | base64 -d)

In a separate terminal window, open up a port-forward:

export cluster_namespace=pgo
export pgo_cluster_name=hippo
kubectl port-forward -n "${cluster_namespace}" "svc/${pgo_cluster_name}" 5432:5432

Now, back in the original window, you can now connect to the cluster:

psql -h localhost -U "${pgo_cluster_name}" "${pgo_cluster_name}"
psql (13.1)
Type "help" for help.

hippo=>

Success!

Adding More Resources to a Deployed Cluster

Part of the GitOps principle is the ability to modify (or version) a configuration file and have the changes reflected in your environment. This is typical of "Day 2" type of operations, such as requiring more memory / CPU resources as the workload on a database increases.

Let's say we want to raise our memory limits to 2Gi and our CPU limit to 2.0 cores. Open up the file you created in the previous step, and add the following block to the spec:

limits:
  memory: 2Gi
  cpu: 2.0

Your file should look similar to this:

apiVersion: crunchydata.com/v1
kind: Pgcluster
metadata:
  annotations:
    current-primary: hippo
  labels:
    crunchy-pgha-scope: hippo
    deployment-name: hippo
    name: hippo
    pg-cluster: hippo
    pgo-version: 4.6.0
  name: hippo
  namespace: pgo
spec:
  BackrestStorage:
    accessmode: ReadWriteOnce
    size: 5Gi
    storagetype: dynamic
  PrimaryStorage:
    accessmode: ReadWriteOnce
    name: hippo
    size: 5Gi
    storagetype: dynamic
  ReplicaStorage:
    accessmode: ReadWriteOnce
    size: 5Gi
    storagetype: dynamic
  ccpimage: crunchy-postgres-ha
  ccpimageprefix: registry.developers.crunchydata.com/crunchydata
  ccpimagetag: centos8-13.1-4.6.0
  clustername: hippo
  database: hippo
  exporterport: "9187"
  limits:
    cpu: 2.0
    memory: 2Gi
  name: hippo
  namespace: pgo
  pgDataSource: {}
  pgbadgerport: "10000"
  pgoimageprefix: registry.developers.crunchydata.com/crunchydata
  podAntiAffinity:
    default: preferred
    pgBackRest: preferred
    pgBouncer: preferred
  port: "5432"
  replicas: "1"
  user: hippo
  userlabels:
    pgo-version: 4.6.0

After saving your changes, apply the updates to your Kubernetes environment:

kubectl apply -f "${pgo_cluster_name}-pgcluster.yaml"

Upon detecting the change, the Postgres Operator uses a rolling update strategy to apply the resource changes to each PostgreSQL instance in a way that minimizes downtime. Wait a few moments for the changes to be apple, and then run a describe on the Pods to see the changes (output truncated):

kubectl -n jkatz describe pods --selector="pg-cluster=${pgo_cluster_name},pgo-pg-database"
Name:         hippo-585bb4f797-f76bl
Namespace:    pgo
Containers:
  database:
    Limits:
      cpu:     1000m
      memory:  2Gi
    Requests:
      cpu:      1000m
      memory:   2Gi

Name:         hippo-gftf-7f55674d78-gsjx7
Namespace:    pgo
Containers:
  database:
    Limits:
      cpu:     1000m
      memory:  2Gi
    Requests:
      cpu:      1000m
      memory:   2Gi

Excellent! And using GitOps principles, if I wanted to revert these changes, I could do so by removing the "limits" clause and the Postgres Operator would update the cluster as such.

For a full list of attributes that can be updated, please see the custom resources section of the Postgres Operator documentation.

Evolution of GitOps and Stateful Services

When coupled with a tool like the PostgreSQL Operator, GitOps principles can be extended to work for stateful services. Applying a GitOps mindset to managing PostgreSQL workloads can help make it easier to manage production-grade Postgres instances on Kubernetes. GitOps principles can certainly make it easier for deploying a range of PostgreSQL topologies, from single instances to multi-zone, fault tolerant clusters.

Upcoming posts will look at how some of the other Kubernetes toolsets can make it even easier to work with the PostgreSQL Operator in a GitOps manner.

Newsletter