Helm Best Practices and Recommendations

Helm Best Practices and Recommendations
Image source: Freepik

In this blog, we will see what are Helm’s best practices and recommendations.

As we develop our charts, we’ll notice we have multiple solutions to most problems. When we can solve something in, say, 8 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.

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). You can google this term if you want to learn more. 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, the third the patch number.

Let’s say you just created a totally 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, changing some functionalities. This way, you arrive at version 1.0.0.

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 better describe it. 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 this case, image is the parent variable that has three children values. But this can be rewritten to a flat format, like this:

imageRepository:
imagePullPolicy: IfNotPresent
imageTag: "1.16.0"

If possible, you should prefer the flat format. If you have a lot of related variables that could be nested, and you know that at least one of them will always be used, you should nest them. But if you either:

  • Have very few related variables, or
  • All your related variables are optional (as in, they might all have no values assigned, in some scenarios)

you should prefer the flat format.

Quote Strings

enabled: false and enabled: “false” will assign different types of values to the enabled variable. In the first case, a boolean type is assigned. In the second case, a string value is assigned. Always wrap your string values between “” quote signs.

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 (may happen especially with large numbers), you can define number as a string

number: "1234567"

Then, in your template files, when you need to use this variable, 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

When a user opens up a values.yaml file, if 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 one 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 as it makes searching easier and especially, it helps documentation tools to more easily automatically correlate these notes with the parameters that they describe.

Template Files

Names

Template file names should be all 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 that they define, so that users have a quick way to identify where they might look for something. 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 is in the form of

{{/*
Comment goes here
*/}}

You should document anything that is not immediately obvious. Often, your .tpl files (like _helpers.tpl) will need to be commented 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 -}}

Pretty hard to figure out what is going on here, and even if we understand what happens, we might wonder “But why use trunc 63 to truncate to 63 characters?” If we add some comments:

{{/*
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 -}}

it becomes much easier to be understood by our users.

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

Dependencies

Versions

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

version: 1.8.2

we can declare an approximate version, by prefixing 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

Use https:// repository URLs, instead of http:// when they are available. https’s encryption and certificate authentication has some positive side effects. For example, they make it harder for someone to perform a man-in-the-middle attack and poison you with their modified, malicious dependency charts.

Recommended Labels

Kubernetes or Kubernetes operators can find stuff by looking at metadata of some objects, more specifically, the labels in the metadata section. For example, there are lots of objects in the Kubernetes cluster. But only part of them are installed by and managed by Helm. Adding a label like 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:
            {}

To get an idea of what labels you should use and what each one represents, you can read more on this page.

If you’re confused and want to see see how such labels are actually 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.

Pods and PodTemplates

Whenever we declare Kubernetes objects such as:

  • Deployment
  • ReplicationController
  • ReplicaSet
  • DaemonSet
  • StatefulSet

we are including definitions of PodTemplates. Otherwise said, we tell Kubernetes the kind of pods that it will need to launch, hence a template for these pods since something like a deployment, although just one object, might launch tens or hundreds 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. latest 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). A chart should produce predictable results and this would be unpredictable, especially since many software components installed by the chart usually need to fit perfectly together. 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 do what we did in our exercises. Point to a specific image version and use values.yaml as a place where this can be modified.

In our exercise, we had this section in deployment.yaml:

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

So we pull the image name and version (tag) from the values.yaml file:

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

In case the user did not define a repository name in his values.yaml file, we use a default name for the chart, nginx. And, in our exercise, we didn’t do this, but in a real production-ready chart we should define a default version too. So

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

should become:

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, his desired features only exist in a newer software version).

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

Include Selectors in Your PodTemplates

Notice the highlighted sections in the following example (a manifest 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 might launch tens or hundreds of pods. But there will also be pods launched by other deployments and other objects, hundreds of pods mixed together. There needs to be a way to know which pods belong to a group and which belong to another group.

Your selectors should be the label, or labels (can be 1 or more) that you know should never change, remain stable for the entire duration of the deployment, daemonset, statefulset, whatever it is. We see that in this case, out of all our labels

  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

Easy to understand why we didn’t choose app.kubernetes.io/version: "1.16.0" as a selector, since the version label will definitely change in the future.

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. However, 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 Custom Resource Definition. 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 Custom Resource Definition, and, the manifest of this object. Now, this time, 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 has no knowledge of the Custom Resource Definition. It simply doesn’t know what this CoolObject is. To tackle this, Helm gives you a special location for CRDs, just add them to a new crds directory. If we would add this to the chart we have created, this directory would 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 take care to install your CRDs first, then your other objects.

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

You can’t have stuff like

name: {{ .Chart.Name }}

in your CRD, just 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 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.

Checkout the Helm for the Absolute Beginners course here

Checkout the Complete Kubernetes learning path here