Argo CD: Enable Helm Support in Kustomize

Argo CD: Enable Helm Support in Kustomize

TL;DR: The code

Nothing is more controversial in the Kubernetes community than whether to use Helm or Kustomize.

I always advocate the philosophy of using the right tool for the right job. It avoids the problem of everything looks like a nail when you have a hammer.

Yet, sometimes you need to combine similar tools to get the best results. Helm and Kustomize are two tools, when combined smartly, they can boost productivity and flexibility.

But, before we jump into the details, let's first understand what Helm and Kustomize are and what they do.

Helm

Helm is the de facto default package manager for Kubernetes applications. It provides an efficient way to package, share, and deploy your Kubernetes applications. It can be used via the CLI or through first-class support in CD tools like Argo CD or Flux. One of the key features I like about Helm is its powerful templating engine. It lets you create reusable templates for your Kubernetes resources. This keeps your YAML files DRY (Don't Repeat Yourself). It makes it easier to manage and maintain your Kubernetes resources.

Example of the template in Helm I took from the kube-prometheus-stack

{{- if and .Values.prometheus.enabled .Values.prometheus.serviceMonitor.selfMonitor}}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: {{template "kube-prometheus-stack.fullname" .}}-prometheus
  namespace: {{template "kube-prometheus-stack.namespace" .}}
  labels:
    app: {{template "kube-prometheus-stack.name" .}}-prometheus
  {{include "kube-prometheus-stack.labels" . | indent 4}}
  {{- with .Values.prometheus.serviceMonitor.additionalLabels}}
  {{- toYaml . | nindent 4}}
  {{- end}}
spec: {{- include "servicemonitor.scrapeLimits" .Values.prometheus.serviceMonitor | nindent 2}}
  selector:
    matchLabels:
      app: {{template "kube-prometheus-stack.name" .}}-prometheus
      release: {{$.Release.Name | quote}}
      self-monitor: "true"
  namespaceSelector:
    matchNames:
    - {{printf "%s" (include "kube-prometheus-stack.namespace" .) | quote}}
  endpoints:
  - port: {{.Values.prometheus.prometheusSpec.portName}}
    {{- if .Values.prometheus.serviceMonitor.interval}}
    interval: {{.Values.prometheus.serviceMonitor.interval}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.scheme}}
    scheme: {{.Values.prometheus.serviceMonitor.scheme}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.tlsConfig}}
    tlsConfig: {{- toYaml .Values.prometheus.serviceMonitor.tlsConfig | nindent 6}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.bearerTokenFile}}
    bearerTokenFile: {{.Values.prometheus.serviceMonitor.bearerTokenFile}}
    {{- end}}
    path: "{{ trimSuffix "/" .Values.prometheus.prometheusSpec.routePrefix }}/metrics"
    {{- if .Values.prometheus.serviceMonitor.metricRelabelings}}
    metricRelabelings: {{- tpl (toYaml .Values.prometheus.serviceMonitor.metricRelabelings | nindent 6) .}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.relabelings}}
    relabelings: {{- toYaml .Values.prometheus.serviceMonitor.relabelings | nindent 6}}
    {{- end}}
  - port: reloader-web
    {{- if .Values.prometheus.serviceMonitor.interval}}
    interval: {{.Values.prometheus.serviceMonitor.interval}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.scheme}}
    scheme: {{.Values.prometheus.serviceMonitor.scheme}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.tlsConfig}}
    tlsConfig: {{- toYaml .Values.prometheus.serviceMonitor.tlsConfig | nindent 6}}
    {{- end}}
    path: "/metrics"
    {{- if .Values.prometheus.serviceMonitor.metricRelabelings}}
    metricRelabelings: {{- tpl (toYaml .Values.prometheus.serviceMonitor.metricRelabelings | nindent 6) .}}
    {{- end}}
    {{- if .Values.prometheus.serviceMonitor.relabelings}}
    relabelings: {{- toYaml .Values.prometheus.serviceMonitor.relabelings | nindent 6}}
    {{- end}}
  {{- range .Values.prometheus.serviceMonitor.additionalEndpoints}}
  - port: {{.port}}
    {{- if or $.Values.prometheus.serviceMonitor.interval .interval}}
    interval: {{default $.Values.prometheus.serviceMonitor.interval .interval}}
    {{- end}}
    {{- if or $.Values.prometheus.serviceMonitor.proxyUrl .proxyUrl}}
    proxyUrl: {{default $.Values.prometheus.serviceMonitor.proxyUrl .proxyUrl}}
    {{- end}}
    {{- if or $.Values.prometheus.serviceMonitor.scheme .scheme}}
    scheme: {{default $.Values.prometheus.serviceMonitor.scheme .scheme}}
    {{- end}}
    {{- if or $.Values.prometheus.serviceMonitor.bearerTokenFile .bearerTokenFile}}
    bearerTokenFile: {{default $.Values.prometheus.serviceMonitor.bearerTokenFile .bearerTokenFile}}
    {{- end}}
    {{- if or $.Values.prometheus.serviceMonitor.tlsConfig .tlsConfig}}
    tlsConfig: {{- default $.Values.prometheus.serviceMonitor.tlsConfig .tlsConfig | toYaml | nindent 6}}
    {{- end}}
    path: {{.path}}
    {{- if or $.Values.prometheus.serviceMonitor.metricRelabelings .metricRelabelings}}
    metricRelabelings: {{- tpl (default $.Values.prometheus.serviceMonitor.metricRelabelings .metricRelabelings | toYaml | nindent 6) .}}
    {{- end}}
    {{- if or $.Values.prometheus.serviceMonitor.relabelings .relabelings}}
    relabelings: {{- default $.Values.prometheus.serviceMonitor.relabelings .relabelings | toYaml | nindent 6}}
    {{- end}}
  {{- end}}
  {{- end}}

As powerful as Helm is, it has its limitations. One of them is the customisation of third-party Helm charts. And this is where Kustomize comes in. But more on that later.

Kustomize

Kustomize lets you, as the name suggests, customise your raw, template-free YAML files. It does not alter the original YAML files but instead applies patches to them. Like Helm, Kustomize has a stand-alone CLI tool. It also has first-class integrations in kubectl and CD tools like Argo CD and Flux. There are different ways to use Kustomize. The most common variation is to use Kustomize with a base and overlay structure. The base has the raw, template-free YAML files. The overlay has the patches to apply to the base.

Example file structure:

~/someApp
├── base
│   ├── deployment.yaml
│   ├── kustomization.yaml
│   └── service.yaml
└── overlays
    ├── development
    │   ├── cpu_count.yaml
    │   ├── kustomization.yaml
    │   └── replica_count.yaml
    └── production
        ├── cpu_count.yaml
        ├── kustomization.yaml
        └── replica_count.yaml

Keep in mind, that you need to have the kustomization.yaml file in each directory to tell Kustomize which resources to apply and which patches to use.

Example of a kustomization.yaml file:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

labels:
- pairs:
    app: someApp

resources:
- deployment.yaml
- service.yaml

This example will apply the deployment.yaml and service.yaml files in the base directory and add the label app: someApp to each resource.

Combining Helm and Kustomize

Now that we know the basics of Helm and Kustomize, let's combine them to get the best of both worlds. Before we use Argo CD to deploy our application, let's create a demo use case.

Use Case

Think about a scenario where you would like to use the new Gateway API from Kubernetes. Unfortunately, most of the Helm charts do not support the Gateway API yet.

Now this is where Kustomize comes in. As an example of this use case, I am going to deploy a Minecraft server.

Create the Kustomize Project

First, let's create a new Kustomize project. We will use the kustomize create command to create a new project.

kustomize init

Now open the kustomization.yaml file and add the following content:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- base/gateway.yaml

helmCharts:
- name: minecraft
  releaseName: my
  repo: https://itzg.github.io/minecraft-server-charts/
  version: 4.23.2
  valuesInline:
    minecraftServer:
      eula: "true"

Create the base directory and add the gateway.yaml file with the following content:

kind: GatewayClass
apiVersion: gateway.networking.k8s.io/v1
metadata:
  name: eg
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: minecraft-gateway
spec:
  gatewayClassName: eg
  listeners:
  - name: minecraft
    protocol: TCP
    port: 8088
    allowedRoutes:
      kinds:
      - kind: TCPRoute
---
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
  name: minecraft-route
spec:
  parentRefs:
  - name: minecraft-gateway
    sectionName: minecraft
  rules:
  - backendRefs:
    - name: my-minecraft
      port: 25565

Now let's apply the Kustomize project:

kustomize build --enable-helm | kubectl apply -f -

Important to note is the --enable-helm flag. This flag tells Kustomize to enable Helm support.

So far, so good. We have deployed a Minecraft server using Helm. We added the missing Gateway API support using Kustomize.

The question now is, how can we deploy this application using Argo CD?

Deploy the Application using Argo CD

First we need to deploy Argo CD using Helm and following content in the values.yaml file:

configs:
  cm:
    kustomize.buildOptions: "--enable-helm"
  # the rest of the values are only for demonstration purposes
  secret:
    argocdServerAdminPassword: "$2a$10$RjjTokiJSaTQt8jAMOUTK.O0VIZ3.0AEs3/JxtaFKGZir93yFPEOG"
    argocdServerAdminPasswordMtime: "2023-11-13T09:23:16Z"
  params:
    "server.insecure": true

The most important part is the kustomize.buildOptions: "--enable-helm" configuration. This tells Argo CD to enable Helm support in Kustomize.

Now we can deploy Argo CD and the Envoy Gateway using Helm:

helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
helm upgrade -i my-argo-cd argo/argo-cd -f values.yaml --namespace argocd --create-namespace
helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.2.1 -n envoy-gateway-system --create-namespace

After the deployment is finished we can create a new Argo CD application using the following content:

kubectl apply -f minecraft-application.yaml

With the following content in the minecraft-application.yaml file:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: minecraft-application
  namespace: argocd
spec:
  destination:
    namespace: minecraft
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://github.com/dirien/quick-bites.git
    targetRevision: main
    path: argocd-kustomize-helms-support
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - ServerSideApply=true
    - CreateNamespace=true

With this setup, we have successfully deployed a Minecraft server using Helm and added the missing Gateway API support. You can check the TCPRoute and Gateway resources by running the following command:

kubectl get gateway,tcproute -n minecraft

NAME                                                  CLASS   ADDRESS   PROGRAMMED   AGE
gateway.gateway.networking.k8s.io/minecraft-gateway   eg                False        5m19s

NAME                                                 AGE
tcproute.gateway.networking.k8s.io/minecraft-route   5m19s

Start yor Minecraft client and connect to the server using the IP address of the minecraft-gateway resource and the port. In this case it would be 8088.

Note: Enabling Helm support in every app has a slight performance cost. If you want more control over which Applications to use, create an Argo CD ConfigurationManagementPlugin.

Conclusion

Combining Helm and Kustomize can be a powerful way to deploy your app. Use it when you need to customize third-party Helm charts. Or, if you prefer Kustomize and don't want to write Helm charts for your own deployments.