On excessive templating with Helm

{{ merge .isThis.perhaps (dict “a” $bit) | include “too much?” }}

I’ve mentioned in other blogposts that I’ve been involved in a project making extensive use of Helm. It is known by some as the Kubernetes package manager, so you’ll naturally use it as you deploy applications to your cluster. Other than release management, its main advantage is generating resources based on user configurable values. Conditionals are a direct evolution of that, and loops come into play as need be. But it doesn’t stop there.

It would be a little boring if it did, especially since there’s repetition that can be easily eliminated through the use of partial templates. However, this is a bit of a slippery slope. Developers, mainly, are tempted to use partials to emulate functions. I myself call them pseudo-functions, and have been implementing algorithms with them.

In this blogpost, I hope to convince you templating can quickly become excessive, and that you’ll have an easier time if you keep it simple, adapting your charts as the situation calls for it. How? I’ll just show you.

No templating at all

We’re writing a chart for an application. A good place to start is to visualize a deployment of it, with all the resources we need. Let’s write the manifest for a Service, since we need to expose ports on the application we’re deploying.

apiVersion: v1
kind: Service
metadata:
  name: my-beautiful-service
spec:
  type: ClusterIP
  selector:
    app: my-beautiful-app
  ports:
    - name: http
      port: 80
      targetPort: 8080

Okay, looks alright. We’ll have to be careful when installing this, since the namespace will depend on the namespace for the context we’re currently in, or whatever we use as the argument for the --namespace / -n flag. Okay, pros and cons?

Pros:

One can argue those are all the same thing, huh? Time to list some cons.

Cons:

In other words, this is not a manifest you would or should find in any Helm chart. It’s just a manifest, which you can use with kubectl commands like apply, create and delete. Let’s start parameterizing some of its values.

Values thrown into the mix

The first change we’ll make here is add some values, and prefix the name of our resource with the name of the Helm release.

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-my-beautiful-service
spec:
  type: {{ .Values.service.type }}
  selector:
    app: {{ .Release.Name }}-my-beautiful-app
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}

It starts to become interesting here! We can now deploy multiple releases of our chart in a single namespace, since they’re bound to have different release names, and thus different Service resources. We can also customize our values all around! Assume the previously hardcoded values are now in the values.yaml file of our chart.

Pros:

Cons:

There’s an easy way to address these cons: partial templates. Let’s move the selector labels to a partial template in a _helpers.tpl file and also create a partial template for a naming prefix. We’ll give up on exposing multiple ports for the time being.

Partial templates come around

Let the following be our _helpers.tpl file:

{{- define "app.fullname" -}}
  {{- if contains .Chart.Name .Release.Name }}
    {{- trunc 63 .Release.Name | trimSuffix "-" }}
  {{- else }}
    {{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
  {{- end }}
{{- end -}}

{{- define "app.selectorLabels" -}}
app: {{ include "app.fullname" . }}
{{- end -}}

We have a partial template that creates a full name for our release, appending the chart name to it if need be. We also have a partial template for the selector labels that will be used throughout the chart, which we can {{ include }} where necessary, and then edit the template instead of editing each resource template.

Our new resource manifest template:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "app.fullname" . }}-service
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}

Pros:

Cons:

No way around those first two, they’re a tradeoff we have to make. The last one, however…

Our Service gains templating powers

The reader is already familiarizing themselves with the partial templates, right? Might as well throw some more templating in, to achieve the flexibility we want. Or so one would think. What about you? Do you think this is already excessive?

apiVersion: v1
kind: Service
metadata:
  name: {{ include "app.fullname" . }}-service
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
  ports:
    {{- range .Values.service.ports }}
    - {{- toYaml . | nindent 6 }}
    {{- end }}

Now we just add the ports as a list of objects in our values or value overrides and the manifest will be rendered with them. Magical, right? We are giving up the ability to template within them, but that’s not really necessary for Service ports, I would say.

Pros:

Cons:

Partial templates at the start of everything

This is such a simple and common pattern to reproduce, so why not just make it a partial template and use it?

{{- define "app.service" -}}
apiVersion: v1
kind: Service
metadata:
  name: {{ include "app.fullname" . }}-service
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
  ports:
    {{- range .Values.service.ports }}
    - {{- toYaml . | nindent 6 }}
    {{- end }}
{{- end -}}

There’s obviously more to a Service’s spec and metadata than we are using. Let’s add some more flare to it, for the sake of example.

{{- define "app.service" -}}
apiVersion: v1
kind: Service
metadata:
  annotations:
    {{- toYaml .Values.service.annotations | nindent 4 }}
  labels:
    {{- include "app.labels" . | nindent 4 }}
    {{- with .Values.service.labels }}
      {{- toYaml . | nindent 4 }}
    {{- end }}
  name: {{ include "app.fullname" . }}-service
spec:
  {{- with .Values.service.externalTrafficPolicy }}
  externalTrafficPolicy: {{ . }}
  {{- end }}

  {{- with .Values.service.internalTrafficPolicy }}
  internalTrafficPolicy: {{ . }}
  {{- end }}

  type: {{ .Values.service.type }}
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
  ports:
    {{- range .Values.service.ports }}
    - {{- toYaml . | nindent 6 }}
    {{- end }}
{{- end -}}

This is just a start. But hey, this is how easy it is to define our Service now, and it applies to every chart using our base template:

{{ include "app.service" . }}

In my opinion, this is where madness has been reached. I won’t bother writing lists of pros and cons for this one.