7 Helm Best Practices with Examples

As we develop our charts, we’ll notice we have multiple solutions to most problems. When we can solve something in, say, eight different ways, as beginners, it may be hard to know which path would be best.

So let’s go through a list of general guidelines, requirements, and recommendations, just to get a sense of some optimal ways to deal with Helm charts.

#1. Chart Names and Versions

Chart Names

Chart names should only contain lowercase letters (and, if necessary, numbers). Also, if your name has to have more than one word, separate these words with hyphens '-', for example, my-nginx-app.

Valid names:

nginx
wordpress
wordpress-on-nginx

Invalid names:

Nginx
Wordpress
wordpressOnNginx
wordpress_on_nginx

Version Numbers

Helm recommends what is called SemVer2 notation (Semantic Versioning 2.0.0). The short explanation is this: version numbers are in the form of 1.2.3 (MAJOR.MINOR.PATCH). The first number represents the major version number, the second the minor, and the third is the patch number.

Let’s say you just created a new application last night. This could be version 0.1.0. You find a security bug, and you patch it. This would be version 0.1.1. Later, you find another bug, and you patch that too. You arrive at version 0.1.2. You make some very small changes to your app - like maybe you change a function so that it executes 2% faster - this could be version 0.2.0.

Notice how when you update the minor version number, the patch number gets reset to 0. And finally, you make a huge update to your app, adding a lot of new features and changing some functionalities. This way, you arrive at version 1.0.0.

#2. values.yaml file

Variable Names

All variable names in the values.yaml file should begin with lowercase letters.

Valid name:

enabled: false

Invalid name:

Enabled: false

Often, your variable will need to contain multiple words to describe it better. Use what is called camelcase: the first word starts with a lowercase letter, but the next ones all start with a capital letter.

Examples of valid names:

replicaCount: 3
wordpressUsername: user
wordpressSkipInstall: false

Invalid names:

ReplicaCount: 3
wordpress-username: user
wordpress_skip_install: false

Flat vs. Nested Values

We saw in our exercises that we can have nested values like this:

image:
  repository:
  pullPolicy: IfNotPresent
  tag: "1.16.0"

This provides some logical grouping. In the snippet above image is the parent variable with three children values. But this can be rewritten to a flat format, like this:

imageRepository:
imagePullPolicy: IfNotPresent
imageTag: "1.16.0"

When to use nested format:

  • If you have many related variables and at least one of them is always used.

When to use flat format:

  • If you have very few related variables
  • If all your related variables are optional

Quote Strings

Always wrap your string values between quote signs. For instance:

enabled: "false"

The configuration above assigns a string value 'false' to the enabled variable. Consider the value assignment below:

enabled: false

This will assign a boolean type to the enabled variable.

Large Integers

Assume you have this:

number: 1234567

When parsing your template files, this value might get converted to scientific notation and end up as 1.234567e+06. If you run into this issue (which may happen especially with large numbers), you can define a number as a string.

number: "1234567"

Then, when you need to use this variable in your template files, prefix it with the type conversion function int (or int64 for very large numbers).

number: {{ int .Values.number }}"

This way, the string “1234567” will get converted into the number 1234567 and won’t end up as 1.234567e+06.

You can see other type conversion functions here.

Documentation for the values.yaml File

If when a user opens up a values.yaml file all they get is a long list of variables, it can be very hard for them to figure out what each of them is used for. That’s why you should always document each by adding a comment above it. The comment starts with a #. Example:

# replicaCount sets the number of replicas to use if autoscaling is enabled
replicaCount: 3

Notice how we start the comment with the actual variable name. It’s recommended you do it the same way to make searching easier. It also helps automatic documentation tools correlate these notes with the parameters that they describe.

To learn more about adding comments to your YAML file, check out our blog post: How to Add YAML Comments with Examples.

How to Add YAML Comments with Examples
Learn how to add YAML comments and discover our best practices in this article today.

#3. Template Files

Names

Template file names should all be lowercase letters. If they contain multiple words, these should be separated with dashes, '-'.

Examples of correct file names:

deployment.yaml
tls-secrets.yaml

Incorrect file names:

Deployment.yaml
tls_secrets.yaml

These file names should indicate the kind of Kubernetes resource they define so that users can get an idea of what's in the file from a glance. For long resource names, like a Persistent Volume Claim, you might use their acronym (PVC in this case) and end up with pvc.yaml.

YAML Comments vs. Template Comments

A template comment follows this syntax:

{{/*
Comment goes here
*/}}

You should document anything that is not immediately obvious. Often, your .tpl files (like _helpers.tpl) will need to be commented on thoroughly, as it’s not easy to figure out what goes on there. Take a look at this:

{{- define "wordpress.memcached.fullname" -}}
{{- printf "%s-memcached" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "wordpress.image" -}}
{{- include "common.images.image" (dict "imageRoot" .Values.image "global" .Values.global) -}}
{{- end -}}

It's pretty hard to figure out what is going on in the file above, and even if we understand what happens, we might wonder, “Why use trunc 63 to truncate to 63 characters?” We can add some comments to clarify it:

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "wordpress.memcached.fullname" -}}
{{- printf "%s-memcached" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{/*
Return the proper WordPress image name
*/}}
{{- define "wordpress.image" -}}
{{- include "common.images.image" (dict "imageRoot" .Values.image "global" .Values.global) -}}
{{- end -}}

The file above becomes much easier to understand.

This type of comment (template comment) won’t be displayed if the user enters a command like helm install –debug to see what manifests this chart would generate. If you need these comments to be output in such commands, you can use YAML comments instead. These look like this:

# This comment would be seen in a helm install --debug command.
spec:
  type: ClusterIP
  ports:
    - name: metrics
      port: {{ .Values.metrics.service.port }}
      protocol: TCP
      targetPort: metrics

#4. Dependencies

Versions

When your chart depends on other charts, you must declare in Chart.yaml the versions of these dependency charts. Instead of using an exact version like:

version: 1.8.2

You should declare an approximate version by prefixing it with the tilde ~ sign.

version: ~1.8.2

This is equivalent to saying, “The version number starts with 1.8, and it can be anything higher than or equal to 1.8.2 but strictly lower than 1.9.0 (it can be 1.8.9 or 1.8.10, but not 1.9.0)”.

URLs

When available, use the more secure https:// for repository URLs instead of http://. HTTP’s encryption and certificate authentication provides extra security for your requests. For instance, they make it harder for someone to perform a man-in-the-middle attack, which can be used to inject malicious code into our dependency charts.

Kubernetes operators find objects by looking at the metadata - more specifically, the labels in the metadata section. There are many objects in the Kubernetes cluster, but only part of them are installed by and managed by Helm. Adding a label such as helm.sh/chart: nginx-0.1.0 makes it easy for operators to find all objects installed by the nginx chart, version 0.1.0.

Your templates should, preferably, generate templates with labels similar to this:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: RELEASE-NAME-nginx
  labels:
    helm.sh/chart: nginx-0.1.0
    app.kubernetes.io/name: nginx
    app.kubernetes.io/instance: RELEASE-NAME
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: nginx
      app.kubernetes.io/instance: RELEASE-NAME
  template:
    metadata:
      labels:
        app.kubernetes.io/name: nginx
        app.kubernetes.io/instance: RELEASE-NAME
    spec:
      serviceAccountName: RELEASE-NAME-nginx
      securityContext:
        {}
      containers:
        - name: nginx
          securityContext:
            {}
          image: "nginx:1.16.0"
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: http
          readinessProbe:
            httpGet:
              path: /
              port: http
          resources:
            {}

Check out this page to better understand how to use labels. To see an example of how labels are used in templates, use this command:

helm create sample

Then explore the sample/templates/ directory and sample/templates/_helpers.tpl file to see how these are defined in a clean, rather simple chart.

#6. Pods and PodTemplates

Whenever we declare Kubernetes objects such as Deployments, ReplicationControllers, ReplicaSets, DaemonSets, or StatefulSets, we include definitions of PodTemplates.

We tell Kubernetes the kind of pods that it will need to launch, hence a template for these pods. That is because something like a deployment, although just one object, might launch tens of pods. Here’s a list of best practices when we work with such sections.

Images Versions/Tags

Do not use an image tag such as latest or any other similar tag that doesn’t point to a specific, fixed image. The latest tag is what we’d call a “floating” tag, meaning the image version it points to is constantly changing; today, it might point to 1.2.3. Tomorrow, when a new version is launched, it might point to 1.2.4 or, even worse, to 2.0.1 (a major update with significant changes).

The components installed by the chart usually need to fit perfectly together, and the latest tag takes out the perfect fit guarantee. If there is a major update to one component, it might not work with the other pieces due to incompatible changes in the latest version.

The recommended practice is to point to a specific image version and use values.yaml as the place where it can be modified. See the sample values.yaml below:

image:
  repository: 
  pullPolicy: IfNotPresent
  tag: "1.16.0"

Here is the deployment.yaml that pulls the image name and version (tag) from the values.yaml file:

spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository | default "nginx" }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}

If the user did not define a repository name in his values.yaml file, we use a default name for the chart, nginx. We didn’t do this in our example, but we should define a default version in a real production-ready chart. The line:

image: "{{ .Values.image.repository | default "nginx" }}:{{ .Values.image.tag }}"

Should be:

image: "{{ .Values.image.repository | default "nginx" }}:{{ .Values.image.tag | default "1.16.0" }}"

This way, we achieve the best of both worlds:

  • The chart will produce predictable results; the same images will be installed in all default installs (when the user doesn’t bother to choose his own settings).
  • The user still has the power to choose other versions for his images if he has some specific needs. For example, if his desired features only exist in a newer software version).

So we give our users predictable results and the freedom to choose if they so desire.

Include Selectors in Your PodTemplates

Consider the manifest below that would get generated by the default nginx/templates/deployment.yaml file created by the helm create nginx command:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: RELEASE-NAME-nginx
  labels:
    helm.sh/chart: nginx-0.1.0
    app.kubernetes.io/name: nginx
    app.kubernetes.io/instance: RELEASE-NAME
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: nginx
      app.kubernetes.io/instance: RELEASE-NAME
  template:
    metadata:
      labels:
        app.kubernetes.io/name: nginx
        app.kubernetes.io/instance: RELEASE-NAME
    spec:
      serviceAccountName: RELEASE-NAME-nginx
      securityContext:
        {}
      containers:
        - name: nginx
          securityContext:
            {}
          image: "nginx:1.16.0"
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: http
          readinessProbe:
            httpGet:
              path: /
              port: http
          resources:
            {}

As we mentioned earlier, objects like Kubernetes deployments can create multiple pods. And there will also be pods launched by other objects, resulting in many pods that are mixed together. There needs to be a way to know which pods belong to a group and which belong to another group.

This is exactly what selector labels help in doing.  Your selectors should be the label, or labels (can be one or more) that you know will never change for the entire duration that the object is running. Let us see this in the example below:

  labels:
    helm.sh/chart: nginx-0.1.0
    app.kubernetes.io/name: nginx
    app.kubernetes.io/instance: RELEASE-NAME
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm

We chose these two as our selectors:

  selector:
    matchLabels:
      app.kubernetes.io/name: nginx
      app.kubernetes.io/instance: RELEASE-NAME

We can't choose app.kubernetes.io/version: "1.16.0" as a selector since the version label will definitely change in the future.

#7. Custom Resource Definitions

Custom Resource Definitions (CRDs) are basically a way to add extra features to Kubernetes. These require some special attention on our part when using them with Helm. To understand why to think about this:

Kubernetes is built to ingest definitions of many objects, even all at once. Say we send it some Deployment manifests for some MySQL pods, PersistentVolume manifests, PersistentVolumeClaims, and so on. We, as the users, don’t care in which order we send these to Kubernetes: But MySQL would not be able to work if it would not have a persistent volume where it can store its database.

Keep in mind that we do not specify anywhere, “Hey, first launch these persistent volumes, and only after that launch the MySQL pods.” But our cluster will know what to do. When we want to install CRDs with Helm, though, the story is different.

Let’s say we define a cool new object in our CRD. Now, we can use this new type of object in Kubernetes by starting our manifests with something like:

apiVersion: "stable.example.com/v1"
kind: CoolObject

But Helm needs to send both things to our cluster: the CRD and the manifest of this object. Now, the order matters, or more specifically, the timing. A few seconds might pass until this new CRD is registered. If Kubernetes receives this object before the CRD is registered, it will not know what this is and how to create it since it does not know the CRD. It simply doesn’t know what this CoolObject is.

To tackle this, Helm gives you a special location for CRDs. You need to add a crds directory to the chart we have created. This directory should exist in the same place where we have the templates and charts directory.

nginx
├── charts
├── Chart.yaml
├── crds
├── templates
│   ├── deployment.yaml
│   ├── _helpers.tpl
│   ├── NOTES.txt
│   ├── service.yaml
│   └── tests
│       └── test-nginx.yaml
└── values.yaml

Now Helm will install your CRDs first, then your other objects.

💡
CRDs cannot be templated: they should always be written as the exact manifest you want to send to your cluster.

You can’t have a line like this in your CRD.

name: {{ .Chart.Name }}

It should be the exact content you want to pass along:

name: someName

Disadvantages of Installing CRDs with Helm

You cannot upgrade or delete CRDs with Helm.

You also cannot perform a –dry-run install to test if your chart works with Kubernetes without actually installing anything. The dry run would require the CRD to actually get installed so that the other objects in your chart get recognized on a dry run. But that defeats the purpose of not installing anything. Without the CRD, any objects in your chart that want to use this Custom Resource will fail on a dry run test.

ENROLL in our Helm For Beginner's Course to learn other concepts, such as creating Helm Charts, adding dependencies in them, and distributing them.

Helm for Beginners | KodeKloud
Learn and get certified with simple and easy hands-on labs

Conclusion

Helm charts provide a standardized way of packaging and deploying applications, making it easier for teams to collaborate and share their work. Follow the best practices listed in this article to create reliable Charts. Also, ensure that you perform tests on your charts before deploying in a production environment.

If you want a quick intro to Helm charts - how to create and install them, check out this video:


More on Helm: