Skip to content

Instantly share code, notes, and snippets.

@vfarcic
Created March 24, 2025 20:58
Show Gist options
  • Save vfarcic/8e5bfe65738488ee9885dfd629093651 to your computer and use it in GitHub Desktop.
Save vfarcic/8e5bfe65738488ee9885dfd629093651 to your computer and use it in GitHub Desktop.

Intro

TODO: Volume is now lower

TODO: Show images conversation-* (6 of them) by adding them one on top or beside each other starting from when I say "like those" (it's like that one in the text) and keep them until the end of the paragraph. Show them starting from conversation-06 moving in descending order towards conversation-01 since that is the most important one.

During recent months I saw a lot of conversation and questions sparked by the release of kro, like the one in the picture. Many of them are related to comparison with Helm. Some people think that kro is, more or less, doing the same work as Helm. Others think that it is a different syntax that accomplishes the same result as Helm. Some are asking whether kro is a replacement for Helm.

There's hardly a day that I don't see a blog post or a Slack conversation or a question in Reddit around that subject.

I want to answer those questions today. I will show that some of those are completely mistaken while others are right. kro is very different from Helm and is based on very different ideas while, at the same time, it can, replace Helm, if we choose that path.

TODO: Logos: terraform.png, pulumi.png, cluster-api.png, crossplane.png

I feel that those questions and doubts are a repetition of the same conversations we had, and still have, in relation to differences between Instracture-as-Code tools like Terraform and Pulumi on one hand, and control plane tools like Cluster API and Crossplane. The major difference is that those same questions that were focused on infrastructure are now focused on applications.

That being said, this is a much wider conversation. It is a conversation about the differences between client and server side operations. It is about templates vs. operators. It is also about Kubernetes Pods vs. Deployments, no matter how strange that might sounds. It is about our own vs. third-party Custom Resources. It is about some tools not understanding the change brought to us with the introduction of Custom Resource Definitions and controllers some seven years ago and the evolution of tooling that help us create and manage those. Ultimately, it is about the evolution of Kubernetes itself and whether we should embrace it.

Today's post is a bit strange. It will sound, at time, that I lost my mind. You will likely think that I lost it and that I should have a conversation with psychologist. We'll see about that later.

Now, the important note is that I will be using Helm and kro today. However, everything I say about those can be applied to many other tools. Almost everything I say about Helm can be equally applied to Kustomize, or Timoni, or ytt, or KCL, or any other similar tool that does client-side templating or overlaying. Similarly, everything you'll hear about kro equally applies to Cluster API, Crossplane, KubeVela, or any other toold that help us manage resources through Kubernetes Custom Resources Definitions and controllers.

By the end of this post, you should have a clear or, at least, a clearer picture of the differences between Helm and kro and other similar tools. We'll see how they differ, which one you should use, and whether they are mutually exclusive.

Setup

git clone https://github.com/vfarcic/kro-vs-helm

cd kro-vs-helm

Make sure that Docker is up-and-running. We'll use it to create a KinD cluster.

Watch Nix for Everyone: Unleash Devbox for Simplified Development if you are not familiar with Devbox. Alternatively, you can skip Devbox and install all the tools listed in devbox.json yourself.

devbox shell

Watch The Future of Shells with Nushell! Shell + Data + Programming Language if you are not familiar with Nushell. Alternatively, you can inspect the dot.nu script and transform the instructions in it to Bash or ZShell if you prefer not to use that Nushell script.

chmod +x dot.nu

./dot.nu setup

source .env

FIXME:

FIXME: Add UpCloud link to the description. TODO: Lower-third: UpCloud, upcloud.com, upcloud.png

It all starts with a Kubernetes cluster which, in my case, is running in UpCloud.

I'm using UpCloud more and more since it's easy to and it is cheap. It providers Kubernetes-as-a-Service, Database-as-a-Service, and others. You can manage whatever you need through their Web UI, CLI, Terraform, Ansible (if you're desperate), and Crossplane, my favorite. If you haven't used it already, I invite you to check it up. You can use this signup link to get 50€ ($55) of free credits so it won't cost you anything to try it out.

Nevertheless, today we're not talking about UpCloud. We're talking about different ways we should manage resources in Kubernetes cluster.

What does matter, for today's topic, is that I would like to deploy an application into that cluster. How should I do that? Should I create a Helm chart? If I do, what should be inside that chart? Should it contain low-level resources like Pods? Should I resort to something else? Should I use templates with Helm? Are there better alternatives?

I have so many questions!

Actually, I don't have many questions. I am just imagining that I forgot everything I know and that I am acting as a newbee trying to make some decisions.

Let's say that we would like to have a single replica of an application. We can do that with the following manifest.

cat k8s/pod-01.yaml

The output is as follows.

---
apiVersion: v1
kind: Pod
metadata:
  name: silly-demo-01
spec:
  containers:
  - image: ghcr.io/vfarcic/silly-demo:v1.5.38
    livenessProbe:
      failureThreshold: 10
      httpGet:
        path: /
        port: 8080
    name: main
    ports:
    - containerPort: 8080
    readinessProbe:
      failureThreshold: 10
      httpGet:
        path: /
        port: 8080
    resources:
      limits:
        memory: '256Mi'
      requests:
        cpu: '125m'
        memory: '128Mi'

That's a definition of a Pod which will contain a single container based on the silly-demo image.

Now, if you have any experience with Kubernetes, you probably think that I lost my mind. Almost noone creates Pods directly. We have Deployments and StatefulSets and other types of resources that do a much better job at that.

I can safely say that I am not insane. My mother had me checked.

Bear with me. I'm trying to make a point which will, hopefully, become apparent soon. So, if you are familiar with Kubernetes, ignore everything you know for a few minutes. Imagine that neither you nor I know any better than to create Pods.

Now, if we would like to have that application up and running all we'd have to do is to apply that manifest.

kubectl --namespace a-team apply --filename k8s/pod-01.yaml

That was easy. Right?

Now, let's say that we would like to have a second replica of that application, so the question is: "How do we increase the number of replicas?"

How about having a definition of a second Pod?

cat k8s/pod-02.yaml

The output is as follows (truncated for brevity).

---
apiVersion: v1
kind: Pod
metadata:
  name: silly-demo-02
spec:
  containers:
  - image: ghcr.io/vfarcic/silly-demo:v1.5.38
    ...

That one is a copy of the other Pod. The only difference is that this one is named silly-demo-02. Everything else is the same since replicas of an app should, most of the time, are the same.

Let's apply it,...

kubectl --namespace a-team apply --filename k8s/pod-02.yaml

...and take a look at the Pods

kubectl --namespace a-team get pods
NAME            READY   STATUS    RESTARTS   AGE
silly-demo-01   1/1     Running   0          37s
silly-demo-02   1/1     Running   0          18s

Now we have two replicas.

At this point you probably do not believe that I am not insane. Why would I deploy two Pods separately? Bear with me. I know that what I'm doing is silly and that no one should be doing it. I want to make a point and you'll have to bear with my "insanity" for a while longer.

Now, let's say that we want to scale up our application by adding a third replica?

That's simple. We just add another Pod that is the same as the others but with a different name. There's no need to look at the manifest. Let's just apply it,...

kubectl --namespace a-team apply --filename k8s/pod-03.yaml

...and take a look at all of them.

kubectl --namespace a-team get pods

The output is as follows.

NAME            READY   STATUS    RESTARTS   AGE
silly-demo-01   1/1     Running   0          85s
silly-demo-02   1/1     Running   0          66s
silly-demo-03   1/1     Running   0          14s

Now we scale our application to three replicas.

Now, let's say that the usage of our application dropped and we would like to scale it down so that the app does not waste resources.

That's easy. We just delete one of those three Pods.

kubectl --namespace a-team delete --filename k8s/pod-03.yaml

Now we're back to two replicas of the application.

How about deploying a new release?

We don't want to replace all those Pods at once since that might result in downtime. Instead, we might want to do some sort of rolling updates by changing the image one one at a time.

Here's a modified version of the manifest of the first Pod.

cat k8s/pod-01-new.yaml

The output is as follows (truncated for brevity).

---
apiVersion: v1
kind: Pod
metadata:
  name: silly-demo-01
spec:
  containers:
  - image: ghcr.io/vfarcic/silly-demo:v1.5.39
    ...

The only change is in the image value of the first and the only container in that Pod.

Let's apply it,...

kubectl --namespace a-team apply --filename k8s/pod-01-new.yaml

...and take a look at the Pods.

kubectl --namespace a-team get pods

The output is as follows.

NAME            READY   STATUS    RESTARTS      AGE
silly-demo-01   1/1     Running   1 (18s ago)   2m59s
silly-demo-02   1/1     Running   0             2m40s

We can see that silly-demo-01 restarted once (1). That was necessary since we cannot change the image of a running container so the old Pod was shut down and started again with the new image.

Next we should repeat the same process for the other Pod and, once we're done, we'll have the new releases rolled out to all the replicas of the app.

*As a sign that I am not insane I will skip this sharade and get straigh to the point... right after we delete the silly mess I created.

kubectl --namespace a-team delete --filename k8s/pod-01-new.yaml

kubectl --namespace a-team delete --filename k8s/pod-02.yaml

Okay. We're done with charades and I promised that I'm doing all that not because I'm insane but because I want to make a point.

TODO: Diagram

Why was it silly of me to manage replicas of my application by applying Pods directly? The answer is simple. We have higher-level Resource Definitions and controllers that can manage Pods for us. That would be, for example, Kubernetes Deployment.

Do we all agree on that one? Do we all agree that operations like management of lower-level resources like Pods should be delegated to a Resource Definition and a controller that is specialized at performing such operations? I would say that a Kubernetes Deployment or a StatefulSet or something similar is a much better choice that managing Pods directly and performing such management from a laptop. Using Deployments to manage ReplicaSets which manage Pods is, in vast majority of cases, a better choice than working with Pods directly.

Please let me know in the comments if you dissagree and we'll have a fight which I am certainly going to win. I dare you.

Now, a Deployment consists of two parts. There is a Resource Definition and a controller. Resource Definitions extend Kubernetes API so that we can send a request to, let's say, apply a Deployment. Controllers, on the other hand, are processes that watch the events in the API and when someone, or something, does anything with a resource kind they're watching, they do whatever needs to be done.

If we created a Deployment instead of all those Pods, a single resource would manage them and it would do a much better job at doing that. If we would say that we want a Deployment with three replicas, it would ensure that three replicas are running forever and ever. If we would say that we want to upgrade those replicas, it would perform rolling updates. And so on and so forth.

All in all, a Deployment is, most of the time, a better choice than managing Pods ourselves. If we all agree with that statement, we can also say that using higher-level Resource Definitions and controllers is better than working on a lower level, as long as those higher level resources are doing what we need them to do. Managing resources on the server-side, like inside a cluster, is better than managing resources on the client-side, like a laptop.

All good so far? Are we all in agreement?

Now, many of you will say "Great! I agree. Let's put a Deployment into a Helm chart since that gives us the benefit of templating and, even more important, of value extrapolations.

By the way, do not yell at me if you prefer a solution other than Helm. I do not like it myself. I think it is the worst solution of its kind. I'm using it today only because it is the most commonly used tool and format. If you prefer something else like Kustomize, or ytt, or KCL, or cdk8s, or Timoni, be my guest. The point I'm about to make later applies to all of those.

I already prepared a simple chart that will help us using the Deployment instead of managing Pods individually.

Here's the values.yaml file.

cat helm/deployment/values.yaml

The output is as follows.

image:
  repository: ghcr.io/vfarcic/silly-demo
  tag: v1.5.38
replicas: 2

Those values are equivalent to what we did so far. We can use them to change the image and the number of replicas.

Here's the template of the deployment.yaml itself.

cat helm/deployment/templates/deployment.yaml

The output is as follows.

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ $.Release.Name }}
  labels:
    app.kubernetes.io/name: {{ $.Release.Name }}
spec:
  replicas: {{ $.Values.replicas }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ $.Release.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ $.Release.Name }}
    spec:
      containers:
      - name: main
        image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag }}"
        ports:
        - containerPort: 8080
        resources:
          limits:
            cpu: 500m
            memory: 512Mi
          requests:
            cpu: 250m
            memory: 256Mi
        livenessProbe:
          httpGet:
            path: /
            port: 8080
        readinessProbe:
          httpGet:
            path: /
            port: 8080

That's a simple one. There is a Deployment with parts of it, like the name, the replicas, and the image being values. When we execute helm install or helm upgrade, Helm will, among other things, do variable extrapolation and, effectively, replace those with the actual values currently defined in values.yaml.

So far so good. If you have at least basic understanding of Helm, you did not learn anything new. That's okay. I'm trying to make a point that will, hopefully, reveal something new, and I'm not there yet.

Let's deploy our vastly improved version of the application by executing helm upgrade --install,...

helm upgrade --install silly-demo helm/deployment \
    --namespace a-team

...and take a look at what we got.

kubectl --namespace a-team get all

The output is as follows.

NAME                              READY   STATUS    RESTARTS   AGE
pod/silly-demo-676c96878d-2lrm8   1/1     Running   0          18s
pod/silly-demo-676c96878d-m8mz2   1/1     Running   0          18s

NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/silly-demo   2/2     2            2           18s

NAME                                    DESIRED   CURRENT   READY   AGE
replicaset.apps/silly-demo-676c96878d   2         2         2       18s

We applied a deployment which, in turn, created a replicaset which manages Pods (pod).

To be sure that we are on the same page, let's try the same operations we did with Pods, but, this time, with a Deployment defined as a template inside a Helm chart.

If we would like to scale the number of replicas, all we'd have to do is change the value of replicas.

Here's an example.

cat helm/deployment/values-up.yaml

The output is as follows.

image:
  repository: ghcr.io/vfarcic/silly-demo
  tag: v1.5.38
replicas: 3

The only difference is that, this time, the value of replicas is set to 3 while the previous version had it set to 2.

Let's apply that chart and start watching the changes to the Pods right away.

helm upgrade --install silly-demo helm/deployment \
    --namespace a-team --values helm/deployment/values-up.yaml \
    && kubectl --namespace a-team get pods --watch

The output is as follows.

Release "silly-demo" has been upgraded. Happy Helming!
NAME: silly-demo
LAST DEPLOYED: Sun Mar 16 15:06:51 2025
NAMESPACE: a-team
STATUS: deployed
REVISION: 2
TEST SUITE: None
NAME                          READY   STATUS              RESTARTS   AGE
silly-demo-676c96878d-2lrm8   1/1     Running             0          56s
silly-demo-676c96878d-khxwq   0/1     ContainerCreating   0          0s
silly-demo-676c96878d-m8mz2   1/1     Running             0          56s
silly-demo-676c96878d-khxwq   0/1     Running             0          1s
silly-demo-676c96878d-khxwq   1/1     Running             0          1s

We can see that it created a new Pod, then shut down one of the old Pod, and then continued with the same process with the second Pod.

Press ctrl+c to stop watching Pods.

If we would like to scale down to two replicas, all we'd have to do is to apply the same chart with the earlier version of the values.

helm upgrade --install silly-demo helm/deployment \
    --namespace a-team --values helm/deployment/values.yaml \
    && kubectl --namespace a-team get pods --watch

The output is as follows.

Release "silly-demo" has been upgraded. Happy Helming!
NAME: silly-demo
LAST DEPLOYED: Sun Mar 16 15:07:50 2025
NAMESPACE: a-team
STATUS: deployed
REVISION: 3
TEST SUITE: None
NAME                          READY   STATUS        RESTARTS   AGE
silly-demo-676c96878d-2lrm8   1/1     Running       0          115s
silly-demo-676c96878d-khxwq   1/1     Terminating   0          59s
silly-demo-676c96878d-m8mz2   1/1     Running       0          115s
silly-demo-676c96878d-khxwq   0/1     Completed     0          59s
silly-demo-676c96878d-khxwq   0/1     Completed     0          60s
silly-demo-676c96878d-khxwq   0/1     Completed     0          60s

This time, the Deployment detected that we want to have two replicas and that there are three of them running, so it modified the ReplicaSet which, in turn, removed one of the Pods and now we have only two.

That's brilliant, yet something you already know. Remember, I am not insane. My mother had me tested. I'm trying to make a point and I'm half-way through.

We could also change the image tag and apply the chart again. By doing that, we would update the Deployment which would create a new ReplicaSet with zero replicas. From there on, it would start scaling up the new ReplicaSet and scaling down the old one until the new release is rolled out. That would be rolling updates process in action that is baked into Deployment.

Press ctrl+c to stop watching Pods.

Now we can jump into questions.

TODO: Put those two in bold as a single question.

Do we agree that using a higher-level Resource Definition and a controller like a Deployment is better than using lower-level resources like Pods? I'm sure that I speak for everyone when I say "yes".

Do we agree that there was probably no need to have a Helm chart if all we have is a Deployment? We could have just as well replaced some values in the deployment directly instead of fiddling with values.yaml file.

Take a look at this.

cat k8s/deployment.yaml

The output is as follows.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: silly-demo
  labels:
    app.kubernetes.io/name: silly-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: silly-demo
  template:
    metadata:
      labels:
        app.kubernetes.io/name: silly-demo
    spec:
      containers:
      - image: ghcr.io/vfarcic/silly-demo:v1.5.38
        livenessProbe:
          failureThreshold: 10
          httpGet:
            path: /
            port: 8080
        name: main
        ports:
        - containerPort: 8080
        readinessProbe:
          failureThreshold: 10
          httpGet:
            path: /
            port: 8080
        resources:
          limits:
            memory: '256Mi'
          requests:
            cpu: '125m'
            memory: '128Mi'

That's a relatively simple definition that we could have used directly without a Helm chart. Just as we changed values.yaml file in case of Helm, we could have changed replicas or image fields in that Deployment directly. We did not get much, if any, value from using Helm with such a simple example. That leads me to the next question.

Do we agree that a Deployment is not all we need? We should probably add a Service, and an Ingress, and we might have a secret and a volume mount, and that we might have service mesh, and whatever else we need.

Once we realize that we might need to combine a bunch of resources Helm charts come in handy. They help us treat resources themselves as implementation detail and expose parts that matter, those that change, as values.yaml.

All that leads me to say that all resources baked into Kubernetes as low-level. Some, like Pods, are lower than others, like Deployment. Still, none of them alone gives us what we need. There is no such thing as Application, or a Database, or any other type of resource that gives us a something concrete and self-sufficient. That's why people love Helm. It allows us to have something meaningful. We can have a Helm chart that contains everything we might need to run a backend application, or a frotend application, or a database, or anything else. In those cases, values.yaml becomes the interface and files in the templates directory are, for many, an implementation detail. Helm, and similar tools and formats, are great since they provide a solution that enable us to create to work with Kubernetes as if it has resources that are meaningful instead of being low-level.

I believe that last sentence is wrong or, to be more precise, ignores some other details. We'll get to that.

Anyway... For now, I hope that we are all in agreement.

For now, let's move into a more complex scenario.

FIXME:

Let's explore a slightly more complex set of manifests by taking a look at ways to deploy a PostgreSQL database server into a Kubernetes cluster.

One approach we can take is to simply install a Helm chart like the one provided by Bitnami.

Now, it is important for the rest of the story to understand how Helm works. It is, essentially, a templating engine.

FIXME: Diagram

When we execute a command like helm install or helm upgrade does not send a chart to Kubernetes API. The API does not access a chart. Instead, Helm executes a process equivalent to helm template to convert the template into "raw" YAML and then sends it all to the API.

There's more going on there but, in a nutshel, that is what it does.

Here's what, for example, Helm would generate and send to Kubernetes API if we'd like to run a PostgreSQL through the Bitnami chart.

helm template my-db \
    oci://registry-1.docker.io/bitnamicharts/postgresql

The output is as follows (truncated for brevity).

---
# Source: postgresql/templates/primary/networkpolicy.yaml
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
...
apiVersion: policy/v1
kind: PodDisruptionBudget
...
apiVersion: v1
kind: ServiceAccount
...
apiVersion: v1
kind: Secret
...
apiVersion: v1
kind: Service
...
apiVersion: v1
kind: Service
...
apiVersion: apps/v1
kind: StatefulSet
...

Those are the resources that would be sent to Kubernetes API if we executed helm install. That is, in a way, equivalent to managing application by applying Pods instead of working with Deployments. Those are, in the case of a database, low-level resources. The main thing we're getting with that chart is that those resources are hidden from us, and that's okay. The problem, as I'll argue later, is that they are hiding in a wrong place.

Here's a better way to manage a PostgreSQL database server in Kubernetes.

cat k8s/cnpg.yaml

The output is as follows.

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  labels:
    app.kubernetes.io/name: silly-demo-db
  name: silly-demo-db
spec:
  instances: 1
  storage:
    size: 1Gi

That's simple, isn't it? It is, arguably, just as simple and just as intuitive as if we focused only on the values file in case of the Bitnami chart we saw earlier. The major difference is that we are now looking at Cloud-Native PostgreSQL or CNPG which is a Custom Resource Definition and a controller. It is, conceptually, the same as the Deployment we argued for earlier.

That CRD extended Kubernetes API so that it now accepts resources based on a specific schema and that there is a controller that will manage all the low-level resources required to run the database. More importantly, that controller will take care of operations. It will do backups and restores, it will scale up and down, it will ensure the desired state, and so on and so for.

CNPG and other similar solutions are arguably much better choices than running a database server only through Kubernetes resources baked in. I doubt that many would argue in favor of using something like that Bitnami charge over a "proper" PostgreSQL operator. If we do argue, that would be whether to run databases in Kubernetes and, if we do, which operator to choose. Bitnami and similar Helm charts are not options in those arguments any more, at least not among those running databases seriously.

Both CNPG and Deployment are resource definitions and controllers that are designed to manage Kubernetes lower-level resources from inside a Kubernetes cluster.

There are two major differences though. First, and the most obvious one, is that CNPG is even higher level than Deployment. While Deployment is managing only ReplicaSets which manage Pods, CNPG manages much more. There are also Pods but also Services, and Roles, and Secrets, and PodDisruptionBugets, and backups and restores, and many other things.

The second difference is that Deployment is a Resource Definition and a controller that are baked into Kubernetes, while CNPG is a Custom Resource Definition and controller that need to be installed in a cluster. It would be silly for Kubernetes to come with everything baked in and we are supposed to extend it to fit our needs.

...and now we're back to the similar situation as with the Deployment. We have a single resource that defines everything we need to run a database and the controllers inside the cluster is doing the heavy lifting of managing everything that needs to be managed.

Here's the question. Is it the same using that Bitnami chart and CNPG? It's not. CNPG is better from the operational perspective and it is easier to use and define from the user's perspective. CNPG resources are, in a way, equivalent to Helm values file except that CNPG has a discoverable schema while the values file is just a file.

If we would like to "discover" how to operate that chart, we can execute helm show values.

helm show values \
    oci://registry-1.docker.io/bitnamicharts/postgresql

The output is as follows (truncated for brevity).

...
global:
  ## @param global.imageRegistry Global Docker image registry
  ##
  imageRegistry: ""
  ## @param global.imagePullSecrets Global Docker registry secret names as an array
  ## e.g.
  ## imagePullSecrets:
  ##   - myRegistryKeySecretName
  ##
  imagePullSecrets: []
  ## @param global.defaultStorageClass Global default StorageClass for Persistent Volume(s)
## @param global.storageClass DEPRECATED: use global.defaultStorageClass instead
  ##
  defaultStorageClass: ""
  storageClass: ""
  ...

That's not a "real" schema but a list of values we can provide to the templating engine and that chart.

Kuberentetes API, on the other hand, has a "real" schema and validations of that schema. We can get it for any resource definition in the cluster, including, in this case, for CNPG clusters.postgresql.cnpg.io.

kubectl explain clusters.postgresql.cnpg.io --recursive

The output is as follows (truncated for brevity).

GROUP:      postgresql.cnpg.io
KIND:       Cluster
VERSION:    v1

DESCRIPTION:
    Cluster is the Schema for the PostgreSQL API
    
FIELDS:
  apiVersion	<string>
  kind	<string>
  metadata	<ObjectMeta> -required-
    ...
    name	<string>
    namespace	<string>
    ...
  spec	<Object> -required-
    affinity	<Object>
      additionalPodAffinity	<Object>
        preferredDuringSchedulingIgnoredDuringExecution	<[]Object>
          podAffinityTerm	<Object> -required-
            labelSelector	<Object>
              matchExpressions	<[]Object>
                key	<string> -required-
                operator	<string> -required-
                values	<[]string>
              matchLabels	<map[string]string>
            matchLabelKeys	<[]string>
            mismatchLabelKeys	<[]string>
            namespaceSelector	<Object>
              matchExpressions	<[]Object>
                key	<string> -required-
                operator	<string> -required-
                values	<[]string>
              matchLabels	<map[string]string>
            namespaces	<[]string>
            topologyKey	<string> -required-
          weight	<integer> -required-
        ...

If we'd want to, we could retrieve descriptions, additional info, or anything else related to that definition and it works no matter whether we use Helm, Kustomize, KCL, or anything else. That is a proper schema enforced by the API.

Similarly, it would be very difficult to implement a solution for policies since, in case of Helm, we would be dealing with a bunch of low-level resources that are not unique to that chart. For example, it would be difficult to define a rule that is applied to Pods only if they come from a specific Chart since Pods are used all over. On the other hand, with the introduction of CNPG, we could easily create a policy that is applied only on the CNPG Cluster resources.

There are many other differences, but it all boils down to Helm being a templating engine that sends raw YAML to Kubernetes API while CNPG extends that API. Similarly, Helm is doing something only when we execute a command while a controller, like the one installed through CNPG, is providing in-cluster operations running continuously.

Let's apply that chart so that we see a few more differences.

helm upgrade --install silly-demo-db \
    oci://registry-1.docker.io/bitnamicharts/postgresql \
    --namespace a-team

We'll xapply CNPG resource as well.

kubectl --namespace a-team apply --filename k8s/cnpg.yaml

Now, let's say that we would like to list all the databases running in the a-team Namespace.

We cannot do that with Helm since we never created a database. We just applied a bunch of resources that together compose everything a database might need.

The best we can do is to list all Helm charts in the a-team Namespace.

helm list --namespace a-team

The output is as follows.

NAME         	NAMESPACE	REVISION	UPDATED                             	STATUS  	CHART                  	APP VERSION
silly-demo   	a-team   	4       	2025-03-16 15:08:35.092436 +0100 CET	deployed	silly-demo-helm-v1.5.38 
silly-demo-db	a-team   	1       	2025-03-16 15:11:03.12007 +0100 CET 	deployed	postgresql-16.5.0      	17.4.0

With Helm we got the list of all charts that were applied to that Namespace and would need to use some kind of a silly filter to retrieve only those coming from the postgresql chart.

With CNPG, it does not matter whether we use Helm or anything else. We extended the API and now we can get all clusters.

kubectl --namespace a-team get clusters.postgresql.cnpg.io

The output is as follows.

NAME            AGE   INSTANCES   READY   STATUS                     PRIMARY
silly-demo-db   43s   1           1       Cluster in healthy state   silly-demo-db-1

Now only that we could easily retrieve all PostgreSQL clusters, but the output is specific to it. We can see how many INSTANCES are running, whether it is READY, what the STATUS is, and which database is PRIMARY. We cannot do that with Helm since it treats all charts equally.

Now, let's say that we would like to find out which resources were composed by that database server.

In case of Helm, we cannot, at least not easily, ask it for all related resources and their statuses. What we can do is, for example, ask it to get the manifest of silly-demo-db.

helm get manifest silly-demo-db --namespace a-team

The output is as follows (truncated for brevity).

---
# Source: postgresql/templates/primary/networkpolicy.yaml
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: silly-demo-db-postgresql
  namespace: "a-team"
  labels:
    app.kubernetes.io/instance: silly-demo-db
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: postgresql
    app.kubernetes.io/version: 17.4.0
    helm.sh/chart: postgresql-16.5.0
    app.kubernetes.io/component: primary
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/instance: silly-demo-db
      app.kubernetes.io/name: postgresql
      app.kubernetes.io/component: primary
  policyTypes:
    - Ingress
    - Egress
  egress:
    - {}
  ingress:
    - ports:
        - port: 5432
---
# Source: postgresql/templates/primary/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
...

Now, that does not give us the status of those resources or any other runtime information but only the resources that were created by that chart. From here on, we would need to look for a Helm-specific addon or something similar to get the runtime information.

With CNPG, it does not matter how we applied it. It is a Kubernetes resource and any tool that works with Kubernetes can be used. So, for example, we can use kubectl tree plugin to display a tree-like relation between a specific resource and resources it composed.

kubectl tree --namespace a-team \
    clusters.postgresql.cnpg.io silly-demo-db

The output is as follows.

NAMESPACE  NAME                                         READY  REASON          AGE
a-team     Cluster/silly-demo-db                        True   ClusterIsReady  2m 
a-team     ├─PersistentVolumeClaim/silly-demo-db-1      -                      2m 
a-team     ├─Pod/silly-demo-db-1                        True                   98s
a-team     ├─PodDisruptionBudget/silly-demo-db-primary  -                      2m 
a-team     ├─Role/silly-demo-db                         -                      2m 
a-team     ├─RoleBinding/silly-demo-db                  -                      2m 
a-team     ├─Secret/silly-demo-db-app                   -                      2m 
a-team     ├─Secret/silly-demo-db-ca                    -                      2m 
a-team     ├─Secret/silly-demo-db-replication           -                      2m 
a-team     ├─Secret/silly-demo-db-server                -                      2m 
a-team     ├─Service/silly-demo-db-r                    -                      2m 
a-team     │ └─EndpointSlice/silly-demo-db-r-slg6g      -                      2m 
a-team     ├─Service/silly-demo-db-ro                   -                      2m 
a-team     │ └─EndpointSlice/silly-demo-db-ro-7vt7z     -                      2m 
a-team     ├─Service/silly-demo-db-rw                   -                      2m 
a-team     │ └─EndpointSlice/silly-demo-db-rw-fbf47     -                      2m 
a-team     └─ServiceAccount/silly-demo-db               -                      2m 

Now we can easily see that the Cluster created a PersistentVolumeClaim, and a Pod, and other resources. We can see their statuses, when they are available, the AGE, and so on and so forth.

We could not use kubectl-tree to retrieve all child resources of the Helm chart we installed simply because there is no parent resource, like in case of CNPG, that created them all. We could retrieve all the resources from that chart by filtering through labels, or through some Helm plugin, or through some dark magic and sorcery. It's possible, but not as simple.

Hopefully, I managed to convince you that CNPG is a better choice than a Bitnami Helm chart.

Assuming that we still agree that using a Deployment is better than managing individual Pods, we agreed that using higher-level Resource Definitions and controllers is the way to go.

Similarly, if I did convince you that CNPG or a similar operator is better than, let's say, PostgreSQL Helm chart released by Bitnami, I convinced you that using Custom Resource Definitions or CRDs and controllers is the way to go.

In both of those cases, Deployments alone and CNPG, you can wrap it all up into a Helm chart, but you don't have to since they are so short and simple that they can stay as "pure" YAML. That part is up to you.

Now that we are in agreement that RDs and third party CRDs and controllers are the way to go, the logic question is whether, when you "stuff" is concerned, we should keep defining Helm charts or create our own CRDs and controllers.

Assuming that we agreed about everything so far, it would be hard not to choose your own CRDs and controllers over, let's say, Helm, as long as defining them is not hard or time demanding.

I don't see how you would dissagree with that last statement if you agreed with previous ones. On the other hand, if you do dissagree with most of what I claimed, you think not only that CNPG is not the way to go, but that CRDs and controllers like Argo CD, Kyverno, Istio, and others are not a good idea. Today, we might be discussing whether Argo CD is better than Flux, but not whether they should be CRDs and controllers.

FIXME:

This is the moment when kro jumps in. It allows us to easily create CRDs and controllers that will do what we need them to do and in the way we want them to do it. More specifically, it allows us to compose resources based on Custom Resources we apply.

Now, to be clear, today I am not advocating in favor of kro. I don't think it is a good solution. Crossplane is years ahead of kro and I strongly suggest adopting it instead of kro. The main reason why I'm using kro today is in the Helm vs. kro debate going on during the last couple of months.

FIXME: Diagram

So, we established that we cannot apply a chart to the Kubernetes cluster so Helm is an engine that converts a template into YAML and applies it to the cluster. Helm is not doing anything with the resources inside the cluster except when we execute a command. It is not operating those resources.

We also established that using CRDs and controllers is better than using low-level resources baked into Kubernetes and that it does not matter whether we define Custom Resources based on those CRDs as YAML or wrap them into a Helm chart that will convert a template into that same YAML.

All in all, using Custom Resource Definitions and Custom Resources is the way to go and the only thing left to discuss is whether we should use them only with third-party apps or we should create our own as well.

Creating CRDs was always easy. All we had to do is write a few lines of YAML. Writing controllers was challenging, but that changed with the emergence of tools like KubeVela, Crossplane, and others. One such tool is kro.

We can define kro manifests into two groups. There are ResourceGroups or ResourceGraphDefinitions that are typically written by people who want to define Something-as-a-Service. That can be the same people who were writing Helm charts.

TODO: The next paragraph is not in the video.

kro is a very young project that is changing all the time. As a result of those changes, the instructions in this post might not be valid any more.

Here's an example.

cat k8s/kro.yaml

The output is as follows.

apiVersion: kro.run/v1alpha1
kind: ResourceGroup
metadata:
  name: application
spec:
  schema:
    apiVersion: v1alpha1
    kind: Application
    spec:
      name: string
      image: string
      tag: string
      port: integer | default=8080
      ingress:
        enabled: boolean | default=false
        host: string | default="devopstoolkit.live"
      db:
        enabled: boolean | default=false
  resources:
    - id: deployment
      template:
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          labels:
            app.kubernetes.io/name: ${schema.spec.name}
          name: ${schema.spec.name}
        spec:
          replicas: 2
          selector:
            matchLabels:
              app.kubernetes.io/name: ${schema.spec.name}
          template:
            metadata:
              labels:
                app.kubernetes.io/name: ${schema.spec.name}
            spec:
              containers:
              - name: ${schema.spec.name}
                image: ${schema.spec.image}:${schema.spec.tag}
                livenessProbe:
                  httpGet:
                    path: /
                    port: ${schema.spec.port}
                ports:
                - containerPort: ${schema.spec.port}
                readinessProbe:
                  httpGet:
                    path: /
                    port: ${schema.spec.port}
                resources:
                  limits:
                    cpu: 500m
                    memory: 512Mi
                  requests:
                    cpu: 250m
                    memory: 256Mi
    - id: service
      template:
        apiVersion: v1
        kind: Service
        metadata:
          labels:
            app.kubernetes.io/name: ${schema.spec.name}
          name: ${schema.spec.name}
        spec:
          ports:
          - name: http
            port: ${schema.spec.port}
            protocol: TCP
            targetPort: ${schema.spec.port}
          selector:
            app.kubernetes.io/name: ${schema.spec.name}
          type: ClusterIP
    - id: ingress
      includeWhen:
        - ${schema.spec.ingress.enabled}
      template:
        apiVersion: networking.k8s.io/v1
        kind: Ingress
        metadata:
          labels:
            app.kubernetes.io/name: ${schema.spec.name}
          name: ${schema.spec.name}
        spec:
          ingressClassName: nginx
          rules:
          - host: ${schema.spec.ingress.host}
            http:
              paths:
              - backend:
                  service:
                    name: ${schema.spec.name}
                    port:
                      number: ${schema.spec.port}
                path: /
                pathType: ImplementationSpecific

Now, I won't go into details of that definition mainly because this is not a kro tutorial.

If you're not familiar with kro please watch the Is This the End of Crossplane? Compose Kubernetes Resources with kro video where I went through kro and sprinkled it all with a bit of criticism.

The only thing we should note is that the schema section will create a CRD and resources tell kro controller how to compose resources.

Let's apply it.

kubectl --namespace kro apply --filename k8s/kro.yaml

Next, let's switch to being a user who simply wants to deploy an application. That user would define something like the manifest that follows.

cat k8s/app.yaml

The output is as follows.

apiVersion: kro.run/v1alpha1
kind: Application
metadata:
  name: better-silly-demo
spec:
  name: better-silly-demo
  image: ghcr.io/vfarcic/silly-demo
  tag: "v1.5.39"
  ingress:
    enabled: true
    host: silly-demo.devopstoolkit.live

Over there, we have a Custom Resource Application. It could have been DB, or Cluster, or anything else. It all depends on which CRDs we created with kro through ResourceGroups.

That Application defines the image, the tag, and a few other things. It is, in a way, similar to what we would define through Helm values file except that this is now an endpoint, a CRD in Kubernetes API with all the benefits that gives us and users do not need to deal with templates, or charts, or anything else. That is a Kubernetes manifest just as any other.

Let's apply it...

kubectl --namespace a-team apply --filename k8s/app.yaml

...and see what we got.

kubectl --namespace a-team get all,ingresses

The output is as follows.

NAME                                     READY   STATUS    RESTARTS   AGE
pod/better-silly-demo-6b9849999c-5clx9   1/1     Running   0          87s
pod/better-silly-demo-6b9849999c-l9cpb   1/1     Running   0          87s
pod/silly-demo-676c96878d-8kr4j          1/1     Running   0          24m
pod/silly-demo-676c96878d-kphx2          1/1     Running   0          24m
pod/silly-demo-db-1                      1/1     Running   0          21m
pod/silly-demo-db-postgresql-0           1/1     Running   0          22m

NAME                                  TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/better-silly-demo             ClusterIP   10.96.244.89   <none>        8080/TCP   81s
service/silly-demo-db-postgresql      ClusterIP   10.96.77.171   <none>        5432/TCP   22m
service/silly-demo-db-postgresql-hl   ClusterIP   None           <none>        5432/TCP   22m
service/silly-demo-db-r               ClusterIP   10.96.72.221   <none>        5432/TCP   22m
service/silly-demo-db-ro              ClusterIP   10.96.31.10    <none>        5432/TCP   22m
service/silly-demo-db-rw              ClusterIP   10.96.128.63   <none>        5432/TCP   22m

NAME                                READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/better-silly-demo   2/2     2            2           87s
deployment.apps/silly-demo          2/2     2            2           24m

NAME                                           DESIRED   CURRENT   READY   AGE
replicaset.apps/better-silly-demo-6b9849999c   2         2         2       87s
replicaset.apps/silly-demo-676c96878d          2         2         2       24m

NAME                                        READY   AGE
statefulset.apps/silly-demo-db-postgresql   1/1     22m

NAME                                          CLASS   HOSTS                           ADDRESS   PORTS   AGE
ingress.networking.k8s.io/better-silly-demo   nginx   silly-demo.devopstoolkit.live             80      84s

Some of those resources are from the Helm chart and from CNPG while others are from the kro ResourceGroup. We can distinguish them through the names. Those from kro are called better-silly-demo, and we can see that the deployment, the service, and the ingress.

All in all, we defined a new CRD Application and a controller that is instructed to manage a few resources whenever someone creates a Custom Resource. There is nothing one needs to do to create those CRs except to define a resource in the same way one would define a Deployment, a Pod, or anything else in Kubernetes.

We already established that using Deployments is better than using Pods directly, and that using third-party operators like CNPG is better than dealing with third-party Helm charts like the one from Bitnami, and that it would be beneficial to use CRDs and controllers for our stuff if it was easy.

We just saw that it was indeed easy. Defining, in this case, kro ResourceGroup was just as easy or even easier to define than a Helm chart so it would be hard to complain that setup complexity is the deal breaker.

FIXME:

Let's reiterate.

Creating CRDs is just as easy as creating the default Helm values file and for users it is better to define high-level resources based on existing API and schema in Kubernetes than to create variations of Helm values file. Kubernetes Custom Resources are, in a way, equivalent to custom Helm values file, but just as easy and better.

Creating Kubernetes Controllers is just as easy as creating Helm templates. The major difference is that templates are on a laptop or wherever we're executing Helm commands from while controllers are inside a cluster. Those two are, in a way, similar in what they do. Both compose resources, with controllers working continuously and performing whichever operations they should perform while templates compose resources only when invoked.

In the past, the barrier to entry into controllers was high. Now they are just as easy as creating Helm charts. The provide many benefits, and we did not even go through the majority of them. At this point, I believe that almost everyone is familiar with Kubernetes so any benefits one might attach to any Kubernetes controller that is baked into Kubernetes, like, for example, Deployment, equally applies to Custom Controllers. A person that does not see the benefits of Custom Controllers will not see the benefits of, lets say, Deployments since both are based on the same principles and ideas.

So, move your Helm charts to CRDs and controllers. There is no excuse not to do it since it is fairly easy. We saw a simple example with kro, but there are many other solutions. I prefer Crossplane and can easily argue why it is the best currently available solution, but that is besides the point. The first step is not to pick one of those tools but to acknowledge that CRDs and controllers are the way to go.

Finally, I must say that Helm still plays an important role. Almost all third-party apps are available as Helm charts. If we need Argo CD, we'll set it up through Helm. If we need Istio, it's also a Helm chart. If we need Prometheus, it is a Helm chart as well.

However, more and more, those charts are used only to set up CRDs and controllers, not to operate those tools nor to create instances of whatever those are offering.

CNPG is a good example. I installed it through a Helm chart which, in turn, applied a few CRDs and a controller. When it comes to creating actualy database servers with CNPG, I did not use a Helm chart but applied Custom Resources.

Helm is here to stay, for a while longer, but not in the capacity you might be using it. We are not in the era of Custom Resource Definitions, controllers, and custom resources. They are replacing Helm and similar tools, but they are also very different in what they do and how they do it.

FIXME: Thank you for watching. FIXME: See you in the next one. FIXME: Cheers!

Destroy

./dot.nu destroy $PROVIDER

exit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment