Writing Function-Based Templates in Helm

I introduced the concepts of named templates and library charts in my series, Reducing Helm Chart Boilerplate with Named Templates and Library Charts. To quickly recap:

  • Named templates (also called “helper templates”) can be thought of as functions that you can reuse throughout your Kubernetes templates.

  • Library charts consist of named templates and are designed to be imported and reused across multiple Helm charts.

I kept things simple in that series, keeping the scope limited to commonalities between different resources, such as common labels, resources, and health checks. Here’s the example Deployment from Part 1, which used helper templates to shorten the config required to create a Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "test-chart.fullname" . }}-frontend
  labels:
    {{- include "test-chart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.frontendReplicas }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ include "test-chart.name" . }}-frontend
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "test-chart.name" . }}-frontend
    spec:
      {{- include "test-chart.serviceAccountName" . | nindent 6 }}
      containers:
        - name: {{ .Chart.Name }}
          image: {{ .Values.frontend.imageName }}:{{ .Values.frontend.imageTag }}
          {{- include "test-chart.healthchecks" | nindent 10 }}
          {{- include "test-chart.resources | nindent 10 }}
      {{- include "test-chart.nodeselector" . | nindent 6 }}

The above template is great if you have just a couple of Deployments. You could copy-paste the template and update fields as needed. However, imagine you are deploying a rather large stack with 3 or more Deployments in your Helm chart. All of a sudden, the idea of copy-paste doesn’t sound as good. Imagine you could write a flexible and reusable template to capture Deployments as a whole so that instead of copy-pasting the above, you can instead create a Deployment by writing a single line:

{{ include “test-chart.deployment” (dict “root” . “component” .Values.frontend) }}

Interested? Read on, and I’ll explain how you can create powerful named templates to simplify your Helm charts.

Thinking of Named Templates as Functions

Let’s switch gears briefly and talk about functions in programming languages. Functions let you encapsulate common code or break code into logical pieces to improve readability. Consider the following Golang function:

func sayHello() {
  fmt.Println(“Hey, reader! How are you?”)
}

If you invoked the above Go function, you would see “Hey, reader! How are you?” displayed in your terminal.

However, let’s imagine you wanted to make this function more flexible so that you can greet anyone you want. The easiest way to do this is to parameterize the function, as shown below:

func sayHello(name string) {
  fmt.Println(“Hey, “ + name + “! How are you?”)
}

Now, when you invoke this function, you provide it a name so that Go knows who to greet. You could invoke this function like:

sayHello(“Austin”)

Now, the output will be “Hey, Austin! How are you?”.

I bring this up because you can use this same concept with your named templates in Helm. Suppose you wanted to write a template for creating Deployment resources. In that case, your named template is synonymous with the “sayHello” function, and the arguments you provide to the template are synonymous with the “name” string argument.

Let’s explore how you can create a function-based named template using this concept.

Creating a Function-Based Named Template

Continuing our example of creating a function-based Deployment template, you should consider two high-level criteria:

  • Which Deployment settings are going to be the same across all Deployments in your Helm chart?

  • Which Deployment settings are going to differ from Deployment to Deployment?

Once you know the answer to these questions, creating your named template is much easier. You can hardcode settings into your template that are consistent across each Deployment. For unique settings, you can parameterize your named template to accept arguments, whose values will change for each Deployment.

Let’s look at an example of how a function-based named template for creating Deployments can be written:

{{- define "test-chart.deployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "test-chart.fullname" .root }}-{{ .component.name }}
  labels:
    {{- include "test-chart.labels" .root | nindent 4 }}
spec:
  replicas: {{ .component.replicas }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ include "test-chart.name" .root }}-{{ .component.name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "test-chart.name" .root }}-{{ .component.name }}
    spec:
      {{- include "test-chart.serviceAccountName" .root | nindent 6 }}
      containers:
        - name: {{ .component.name }}
          image: {{ .component.imageName }}:{{ .component.imageTag }}
          {{- include "test-chart.healthchecks" .root | nindent 10 }}
          {{- include "test-chart.resources" .root | nindent 10 }}
      {{- include "test-chart.nodeselector" .root | nindent 6 }}
{{- end -}}

At first glance, this probably looks similar to the Deployment from the beginning of this post. But, looking closer, you can see two key differences:

  • Dot (.) has been replaced with “.root”

  • .Values has been replaced with “.component”

The .root represents the root scope. The root scope is normally represented simply by a dot (.) or a dollar sign ($), but as you’ll see later, you cannot refer to the root scope this way using function-based named templates since passing parameters to a template alters the scope. Use .root for common settings. In this case, .root is being used to configure common labels, healthchecks, resources, node selector, and to help adhere to a common naming scheme.

The .component represents a map consisting of component-specific details. Each component can be mapped to a unique Deployment. In this case, .component configures the naming suffix, replicas, unique selector label, container name, image name, and image tag. The usage of .component will be clearer when I explain how to invoke this template in the next section.

Before I discuss invocation, I do want to explain how to document a function-based named template. You don’t want your templates to look like “magic”. There should be clearly defined documentation highlighting the purpose of the template and the parameters that it accepts. To do this, I like to use Javadoc-style comments. Here’s an example of how the above function-based template could be documented:

{{/*
Common deployment template for test-chart

@param .root      The root scope
@param .component A map representing a unique component
                  The map contains at least the following fields:
                    .component.name: The name of the component
                    .component.imageName: The image name used to 
                                          deploy the component
                    .component.imageTag: The image tag used to
                                         deploy the component
*/}}

The above represents a template comment. I like to place this right above the “define” keyword of the template that the comment is describing. This clarifies what this template is doing and the parameters it takes.

Now that a function-based Deployment template is written, let’s explore how to invoke this template to keep boilerplate to a minimum.

Invoking a Function-Based Helm Template

Invoking the above function-based template is similar to invoking the named template we discussed in Reducing Helm Chart Boilerplate with Named Templates and Library Charts. However, the difference here is that we are going to use the dict template function to pass a dictionary as an argument to the named template. Passing a dictionary is required because templates only support a single argument, representing the template’s scope. We can simulate a typical function call by assigning each argument a key/value pair in the dictionary. Then, we can pass that dictionary to the template to execute within the scope of that dictionary.

Our named template above supports two parameters:

  • .root, which represents the root scope

  • .component, which represents the unique component being deployed

So, when we invoke this function, we can pass a dictionary with two keys – “root” and “component”. We can assign root the root scope at the time of invocation, which will simply be dot (.). But what will we pass for component? As described in the Javadoc-style comment, we know that our named template requires “component.name”, “component.imageName”, and “component.imageTag”. So, we can create a map in our values.yaml file that adheres to this same structure:

frontend:
  name: frontend
  imageName: example/frontend
  imageTag: “1.0”

Now, for the “component” parameter, we can simply pass .Values.frontend.

I already gave away what the invocation will look like at the beginning of this post, but hopefully, it will make more sense now. I’ll paste it again below:

{{ include “test-chart.deployment” (dict “root” . “component” .Values.frontend) }}

The “dict” function can take as many arguments as you require. Arguments are in “keyN” “valueN” format, so our first key is “root”, and the first value is “.”. Our second key is “component” and the second value is “.Values.frontend”. If you require additional parameters, you would follow this same pattern to add them here.

This “include” call would be contained in its own Deployment template file, such as “frontend-deployment.yaml”, and that’s all it would need. Just one line to create an entire Deployment! You can see how this is useful when you have other Deployments in your chart, such as “backend-deployment.yaml”, “backend2-deployment.yaml” (I’m not being very creative with the names, but hopefully you get the idea). Each Deployment that you add to your chart would have its own map in values.yaml, so “backend-deployment.yaml” would work very similarly. Its map in values.yaml would look like this:

backend:
  name: backend
  imageName: example/backend
  imageTag: “1.0”

And its template invocation in “backend-deployment.yaml” would look like this:

{{ include “test-chart.deployment” (dict “root” . “component” .Values.backend) }}

A Quick Word on Library Charts

In this article, to keep things simple, I focused on a single chart having many deployments, with each deployment using a named template scoped to a single Helm chart. However, if you have many Helm charts (each having one or more Deployment), you can also benefit from having a common Deployment template contained in a library chart. That way, each of your Helm charts can import your library chart and benefit from one-liner Deployment templates.

Thanks For Reading

Hopefully, this article helps you reduce even more boilerplate between common resources. If you find yourself copy-pasting similar resources, writing a function-based named template can make your Helm charts much cleaner!

Austin Dewey

Austin Dewey is a DevOps engineer focused on delivering a streamlined developer experience on cloud and container technologies. Austin started his career with Red Hat’s consulting organization, where he helped drive success at many different Fortune 500 companies by automating deployments on Red Hat’s Kubernetes-based PaaS, OpenShift Container Platform. Currently, Austin works at fintech startup Prime Trust, building automation to scale financial infrastructure and support developers on Kubernetes and AWS. Austin is the author of "Learn Helm", a book focused on packaging and delivering applications to Kubernetes, and he enjoys writing about open source technologies at his blog in his free time, austindewey.com.

Leave a Reply