Keeping your HELM charts DRY

Introduction

In this post I’ll cover a basic overview of HELM and the DRY principal. Then finally how to keep your HELM charts DRY by making use of a HELM library chart.

What are HELM charts?

HELM is a popular package manager for Kubernetes. It provides the ability to define, install, and manage applications on a Kubernetes cluster.

The individual packages themselves are known as “charts”. Charts compromise of files which are describing Kubernetes resources such as deployments, services, ingress resources etc.

Additionally you can de-couple values from the chart so you can have separate values for different environments you are deploying the app to, things like which host name to use for ingress. HELM is a popular choice when it comes to organisations wanting to deploy their own applications.

What does a HELM chart look like?

HELM charts usually come packaged as TGZ files and contain a file structure like below.

chart
├── .helmignore
├── Chart.yaml
├── templates
│   ├── deployment.yaml
│   └── service.yaml
└── values.yaml

A quick run down of the different elements

ItemDescription
Chart.yamlFile that contains metadata about the chart, name, version etc etc
templatesDirectory that contains templates, these are YAML, describing Kubernetes resources like a standard Kubernetes manifest, but with ability to use Jinja templating language to make the content dynamic.
values.yamlDefault values for the chart to use.

Here is an example of the kind of thing you would find inside a file in the templates directory:

templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "test.fullname" . }}
  labels:
    {{- include "test.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "test.selectorLabels" . | nindent 4 }}

What is the DRY principal?

DRY is an acronym for “Don’t repeat yourself”. It is a principal of software development that aims to reduce repetition of patterns, and code duplication. The principal states “Every piece of knowledge or logic should have a single, unambiguous representation within a system.”

In the context of HELM charts. Lets say you have many products, or just many micro services of a solution. In this scenario you may have many HELM charts for the different products or services.

Imagine a scenario where someone working on Product A wants the chart to do something new, like deploy a new type of Kubernetes resource, or expose a value for something so you can optionally override the default. That person implements some changes to the templates in that chart. 6 months later, someone working on Product B wants similar, so also implements the same functionality in their chart but in a slightly different way. Before long your charts start to loose consistency and end up diverged.

A similar scenario occurs when the version of a Kubernetes API has been deprecated during a Kubernetes upgrade, and for example in one of the manifests maybe you just need a quick change like changing the version of an Kubernetes API the manifest is defined against. An example would be above where line 1 now needs to read apiVersion: v2 with 20 different HELM charts that 20 times to copy and paste the same change.

You can start to see how having a single shared source for the templates can help.

How to keep charts DRY

A library chart lets you define a central chart that contains a number of templates, this chart can then be used as a dependency by all of your application charts. The consuming charts can then pull in as many of these templates as it needs.

Example

I’ll use some example template content to give a basic guide of how to move some templates into a library chart so that they can be shared. I’ll assume that you already have your own templates that you have started to use.

If you are looking for inspiration DEFRA have a HELM library chart hosted in GitHub you can check out – GitHub – DEFRA/ffc-helm-library: FFC Helm chart library

Creating a library chart

Start by using the helm command to create a new chart that will be the base of our new library chart.
helm create name substitute name for the name you want to give your library chart.

Clear out any examples files in the templates folder if you already have templates you want to use.

Update Chart.yaml and change the type from application to library, and strip out anything in reference to appVersion.

Chart.yaml
apiVersion: v2
name: Example-Library
description: Example Library Chart

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: library

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

If you already have existing YAML templates you want to include in the new library chart, copy those files to the templates directory of the library chart.

All files in the templates folder need their file names tweaked. Ensure file names begins with _ and end with .tpl. As an example service.yaml would become _service.tpl.

Remove the values.yaml file from the library, since a library chart does not define values. Your application charts that will depend on this library chart will define the values.

Your directory structure should now look something like the below.

Example-Library
├── .helmignore
├── Chart.yaml
├── charts
└── templates
        ├── _deployment.tpl
        ├── _helpers.tpl
        ├── _hpa.tpl
        ├── _ingress.tpl
        ├── _service.tpl
        └── _serviceaccount.tpl

For each of the templates, wrap the content in a definition block by adding a new first and last line. The names in quotes is what you will later use to be able to include this particular template in an application chart. Here is an example:

templates/_service.tpl
{{- define "library.service" -}}
apiVersion: v1
kind: Service
metadata:
  name: {{ include "Example-Application.fullname" . }}
  labels:
    {{- include "Example-Application.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "Example-Application.selectorLabels" . | nindent 4 }}
{{- end }}

Changing an existing application chart to use the library chart

Modifying an existing application chart to use a library chart is relatively straight forward.

Update Chart.yaml to specify the Library chart as a dependency, in the example I have specified the repository as a local folder rather than a HELM repo, we will cover hosting the library in a repo in a later step.

Chart.yaml
apiVersion: v2
name: Example-Application
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

dependencies:
  - name: Example-Library
    version: 0.1.0
    repository: file://../Example-Library

Any templates you have inside the templates folder that are now part of the library chart you can get rid of from individual application charts.

The final step is to have the application charts make use of the templates that are in the library chart.

Create a file inside the template folder of the application chart and add a line for each one you want to include. Separate each template with the manifest separator “—“. E.g.

templates/template.yaml
{{ include "library.deployment" . }}
---
{{ include "library.hpa" . }}
---
{{ include "library.ingress" . }}
---
{{ include "library.service" . }}
---
{{ include "library.serviceaccount" . }}

Finally ensure your application chart has any needed values defined that the templates in the library chart require. If you have shifted existing templates from the application chart to the new library chart with no other changes to the templates then no changes for values will be needed.

Testing locally

You can attempt to render the chart locally to see if all is working well. Having the dependency repo set to a local file like we have so far, is good when it comes to testing changes locally to ensure it looks like all renders OK.

Because we are using the library as a dependency, there is an additional command that needs to be done before rendering the chart and that is helm dependency build.

To render the chart use the command helm template which will attempt to render the manifests from the chart and either show the output of the manifests or throw an error.

helm template .\Example-Application\
---
# Source: Example-Application/templates/template.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: release-name-Example-Application
  labels:
    helm.sh/chart: Example-Application-0.1.0
    app.kubernetes.io/name: Example-Application
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
automountServiceAccountToken: true
---
# Source: Example-Application/templates/template.yaml
apiVersion: v1
kind: Service
metadata:
  name: release-name-Example-Application
  labels:
    helm.sh/chart: Example-Application-0.1.0
    app.kubernetes.io/name: Example-Application
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: http
      protocol: TCP
      name: http
  selector:
    app.kubernetes.io/name: Example-Application
    app.kubernetes.io/instance: release-name
---
# Source: Example-Application/templates/template.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: release-name-Example-Application
  labels:
    helm.sh/chart: Example-Application-0.1.0
    app.kubernetes.io/name: Example-Application
    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: Example-Application
      app.kubernetes.io/instance: release-name
  template:
    metadata:
      labels:
        helm.sh/chart: Example-Application-0.1.0
        app.kubernetes.io/name: Example-Application
        app.kubernetes.io/instance: release-name
        app.kubernetes.io/version: "1.16.0"
        app.kubernetes.io/managed-by: Helm
    spec:
      serviceAccountName: release-name-Example-Application
      securityContext:
        {}
      containers:
        - name: Example-Application
          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:
            {}
---
# Source: Example-Application/templates/template.yaml
---

Hosting a library chart

So far our HELM library chart has been hosted as a local file. HELM charts are best hosted in a HELM repository. If you are already using HELM you will likely already have one. If not, its relatively easy to create one, it can be hosted anywhere that can serve HTML content. GitHub pages, and Azure blob storage are options.

The repository just consists of different versions of the chart in TGZ format, and an index.yaml file that contains some metadata and file name of each available version.

Running the helm package command will take your library chart and compress it into a TGZ file based on the metadata in Chart.yaml.

helm package .\Example-Library\
Successfully packaged chart and saved it to: C:\temp\Example-Library-0.1.0.tgz

Lasty the index will either need to be created or updated with the helm repo index command.

For a new repo just point it to where the generated TGZ file is and it will create you a new index.yaml.

helm repo index .

Once you are already running with the repo you will want to use the merge parameter so the existing index is updated, you will need a local copy of the existing index.

helm repo index . --merge .\index.yaml

The updated index.yaml and any new TGZ files can then be uploaded to the repo destination. Ideally all of the above will be handled by CI/CD process.

An example of the content of an index.yaml:

index.yaml
apiVersion: v1
entries:
  Example-Library:
  - apiVersion: v2
    appVersion: 1.16.0
    created: "2024-01-10T15:51:08.1785345Z"
    description: A Helm chart for Kubernetes
    digest: e8a2e38e03fd58bdfb8d004a62a6424ee5643419f858a249af57c30d8b20c42d
    name: Example-Library
    type: application
    urls:
    - Example-Library-0.1.1.tgz
    version: 0.1.1
  - apiVersion: v2
    appVersion: 1.16.0
    created: "2024-01-10T15:51:08.1779902Z"
    description: A Helm chart for Kubernetes
    digest: ed8efe3f750b180351ab9f4a67a4a908442a53122f3f755fa6844f6b90fb5489
    name: Example-Library
    type: application
    urls:
    - Example-Library-0.1.0.tgz
    version: 0.1.0
  library:
  - apiVersion: v2
    created: "2024-01-10T09:18:02.7791442Z"
    description: A Helm chart for Kubernetes
    digest: 965ee56c6011890fc65473f0b92a9482c36a14194b57ffe951289ff556737797
    name: library
    type: library
    urls:
    - library-0.1.0.tgz
    version: 0.1.0
generated: "2024-01-10T15:51:08.1774841Z"

Once you have the library hosted in a HELM repo you can change the repo location in the dependency of the application charts, e.g.

Chart.yaml
apiVersion: v2
name: Example-Application
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

dependencies:
  - name: Example-Library
    version: 0.1.0
    repository: https://jwhelmlib.blob.core.windows.net/repo

Using benefits of semver

Semantic versioning can be used with the library chart which offers the benefits of being able to test changes in isolation, and being able to manage breaking changes. More on semver here if you want a better understanding – Semantic Versioning 2.0.0 | Semantic Versioning (semver.org)

When defining the dependency to the library chart in your application charts you can specify if you want the application to get the latest patch, or minor version of the library chart during deployment. You can use this to manage breaking changes.

Example to get latest patch version of v1.1.0 – the version number just needs prefixing with a ~

Chart.yaml
apiVersion: v2
name: Example-Application
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

dependencies:
  - name: Example-Library
    version: ~1.1.0
    repository: https://jwhelmlib.blob.core.windows.net/repo

Example to get latest minor version of v1.1.0 – the version number just needs prefixing with a ^

Chart.yaml
apiVersion: v2
name: Example-Application
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

dependencies:
  - name: Example-Library
    version: ^1.1.0
    repository: https://jwhelmlib.blob.core.windows.net/repo

And if you want to publish the library but test the changes in isolation you can use a pre-release version number, no one will automatically pick this version up. In the below example someone would need to specify the dependency version exactly like below, the version number just needs suffixing with - and a number.

Chart.yaml
apiVersion: v2
name: Example-Application
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

dependencies:
  - name: Example-Library
    version: ^1.2.0-0
    repository: https://jwhelmlib.blob.core.windows.net/repo

Changes to application deployment process

One additional step is needed when deploying a chart that is using another chart as a dependency, and that is the helm dependency build command needs to be ran before the helm install command. Any CI/CD pipelines will need to have this additional step added.

helm dependency build .\Example-Application\
Getting updates for unmanaged Helm repositories...
...Successfully got an update from the "https://jwhelmlib.blob.core.windows.net/repo" chart repository
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "bitnami" chart repository
Update Complete. ⎈Happy Helming!⎈
Saving 1 charts
Downloading Example-Library from repo https://jwhelmlib.blob.core.windows.net/repo

This step will download the dependency based on the version constraints so it is ready for use during the helm install command.

Closing thoughts

While the initial effort may seem high in the long run my opinion is this pays off. I’ve personally gone through the process of amalgamating the content and functionality of many charts across different products and services into a single library chart with extensive documentation and generalised functionality.

Any new functionality to charts is now being contributed to by multiple sources, and everyone else can benefit from these changes immediately. Additionally maintenance is now a lot easier now there is one shared source of truth for all of the logic and abstraction.

Leave a Reply

Your email address will not be published. Required fields are marked *