Write Helm chart from scratch
Tags: kubernetes, helm
Published: 2024-04-01 10:05:16

These days Kubernetes has become so popular and of course more complex. Deploying and managing it has become harder and requires lots of time and effort. One of the most useful tools that can help us to make it easy for us is **Helm**.

Helm is known as a package manager for Kubernetes. You can use it to deploy a large Kubernetes application with just one install command. It makes our deployments easier and more beneficial than just using simple the **kubectl** command to deploy everything by ourselves.

We usually use charts that are already available in Helm provided by other developers, but sometimes you need to write your own charts, That’s what this article is about. We’re going to write our chart from scratch and deploy it on our Kubernetes Cluster.
<br/>


## Prerequisites
For this article, we will need:

* Redhat-based or Debian-based OS

* K8s cluster version 1.24

* Helm version 3.9

* Nginx Ingress version 4.2

* Access to the internet
<br/>

**NOTE:** The requirements mentioned above aren’t mandatory, however, this article has been tested and done by those tools.
<br/>


## Verify requirements
The first thing we’re going to do is to check the version of the tools.


Run the commands and check the output.
```bash
# cat /etc/*-release
CentOS Linux release 7.9.2009 (Core)
NAME="CentOS Linux"
VERSION="7 (Core)"
ID="centos"
ID_LIKE="rhel fedora"
VERSION_ID="7"
.
.
.
CentOS Linux release 7.9.2009 (Core)
CentOS Linux release 7.9.2009 (Core)

kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"24", GitVersion:"v1.24.3", GitCommit:"aef86a93758dc3cb2c658dd9657ab4ad4afc21cb", GitTreeState:"clean", BuildDate:"2022-07-13T14:29:09Z", GoVersion:"go1.18.3", Compiler:"gc", Platform:"linux/amd64"}

#############################################################

helm version
version.BuildInfo{Version:"v3.9.2", GitCommit:"1addefbfe665c350f4daf868a9adc5600cc064fd", GitTreeState:"clean", GoVersion:"go1.17.12"}
```


Also, we have two nodes in our cluster.
```
#------------#----------#-----------------#
|    Host    |   Role   |   IP Address    |
#------------#----------#-----------------#
|   node-1   |  master  |  192.168.24.66  |
|   node-2   |  worker  |  192.168.24.118 |
#------------#----------#-----------------#
```
<br/>


## Create Helm files
First, create a directory and name it test. This is the name of our Helm project.
```bash
cd ~
mkdir test
```


Second, create two YAML files named **Chart.yaml** and **values.yaml**.
```bash
touch values.yaml Chart.yaml
```


Third, Create **“.helmignore”** file to ignore files we don’t need in our app.


```bash
touch .helmignore
```


Fourth, go to this test directory and create two other directories: **charts** and **templates**.
```bash
mkdir charts templates
```


Finally, create YAML files for our app inside the templates directory.
```bash
cd templates
touch deployment.yaml service.yaml ingress.yaml\
      configmap.yaml secret.yaml
```
<br/>


## Write files
<br/>

### Write deployment.yaml
Open **deployment.yaml** file inside the **templates** directory and add these lines to it.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/version: {{ .Chart.AppVersion }}
    app.kubernetes.io/managed-by: helm
spec:
  replicas: {{ .Values.replicas }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ .Chart.Name }}
    spec:
      containers:
        - name: python
          image: docker.io/armanshafiei/python-command-executor:latest
          ports:
           - containerPort: {{ .Values.config.port }}
             name: http
          livenessProbe:
            httpGet:
              path: "/"
              port: {{ .Values.config.port }}
            initialDelaySeconds: 5
            periodSeconds: 5
          {{- include "vol_conf_mount" . | nindent 10}}
      {{- include "vol_conf_define" . | nindent 6 }}
```


Assuming You know how to write Kubernetes manifests, let’s explain some parts:
* Line 4: **“.Chart.Name”** adds the value of the variable **name** we’re going to define in **Chart.yaml** file. It is the name of our Chart or App.

* Line 7: **“.Chart.AppVersion”** adds the value of variable **appVersion** from **Chart.yaml**. It is the version of our App.

* Line 8: Defines that this deployment is managed and created by Helm.

* Line 10: **“.Values.replicas”** Adds the value of the **replicas** variable that we’ll define in **values.yaml**.

* Line 32: This imports the function **vol_conf_mount** from **_helpers.tpl** and adds 10 spaces as indention. The reason is even though we’ve added indentions before **include**, it doesn’t have any effect. This function enables or disables the **volumeMounts** option for deployment.

* Line 33: This imports the function **vol_conf_define** from **_helpers.tpl** and adds 6 spaces as indention. This function enables or disables the **volumes** definition for deployment.
<br/>

### Write service.yaml
Open **service.yaml** inside the templates directory and these lines to it.
```yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .Chart.Name }}
spec:
  type: LoadBalancer
  selector:
    app.kubernetes.io/name: {{ .Chart.Name }}
  ports:
    - name: data
      protocol: TCP
      port: {{ .Values.config.port }}
      targetPort: {{ .Values.config.port }}
```


Let’s explain some lines:
* Line 6: This service is of type **LoadBalancer** So that it will be assigned an IP address, Either via a cloud provider or your external load balancer(e.g. Metallb).

* Line 8: Use the label of deployment to connect the service to it.
<br/>

### Write ingress.yaml
Edit the **ingress.yaml** file inside the templates directory and add these lines to it.
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Chart.Name }}
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/version: {{ .Chart.AppVersion }}
    app.kubernetes.io/managed-by: helm
spec:
  ingressClassName: nginx
  rules:
    - host: "my.example.com"
      http:
        paths:
          - path: "/"
            pathType: Prefix
            backend:
              service:
                name: {{ .Chart.Name }}
                port:
                  number: {{ .Values.config.port }}
```


In Line 10, we have defined the ingress class. It is necessary as we are using Nginx Ingress otherwise it won’t work as expected.
<br/>

### Write configmap.yaml
Edit **configmap.yaml** inside the templates directory and add these lines.
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Chart.Name }}
data:
  bind: {{ quote .Values.config.bind }}
  port: {{ quote .Values.config.port }
```


The **configmap.yaml** is not necessary and doesn’t have any effect on our app because our Python app is not using any environment variables. The only reason I added it is to demonstrate the usability of config maps in Helm.
<br/>

### Write _helpers.tpl
This file is where we define Helm functions that our charts can use. As you saw above, we used two functions in our **deployment.yaml**.

Edit **_helpers.tpl** inside the templates directory and add these lines.
```toml
{{/*
Put all functions here please!
*/}}
{{- define "vol_conf_mount" }}
{{- if eq .Values.custom_config "true" -}}
volumeMounts:
            - name: configs
              mountPath: "/usr/app/envs"
              readOnly: true
{{- end }}
{{- end }}
{{- define "vol_conf_define" }}
{{- if eq .Values.custom_config "true" -}}
volumes:
          - name: configs
            configMap:
              name: {{ .Chart.Name }}
{{- end }}
{{- end }}
```


As you see, there are two functions in our file: **vol_conf_mount** and **vol_conf_define**.

The **vol_conf_mount** function starting from lines 5 to 12, contains the **volumeMounts** block. If the value of **custom_config** in **values.yaml** equals **true** then this function returns **volumeMounts** block. So we can use it to import the **volumeMounts** to our manifest.

The **vol_conf_define** function starting from lines 14 to 21, contains the volumes block. If the value of **custom_config** in **values.yaml** equals **true**, then this function returns the **volumes** block. So we can use it to import the volumes to our manifest.

**NOTE:** The first block starting from lines 1 to 3, is used for comments and doesn’t have any effect on our application performance. You can write anything inside it depending on your charts or functions.
<br/>

### Write Chart.yaml
This file contains information about your Helm chart itself like the version, name, apiVersion, type, etc. Each of the variables can be referenced in the manifests by using **“.Chart.*”** like **“.Chart.Name”**.

Open **Chart.yaml** file and add these lines.
```yaml
apiVersion: v2
name: "python"
description: This helm chart is created from scratch
type: application
version: 1.0.0
appVersion: "1.0.0"
```


Let’s see what the values above are:

* Line 1: Specifies which API version Helm will use. For Helm 3 it must be v2.

* Line 2: The name of our Helm chart. It can be anything related to our app functionality.

* Line 3: A description of our chart.

* Line 4: Defines the type of chart. It can be either **application** or **library**. The library type can not be deployed and is just a dependency that can be injected into other charts while the application type can be deployed.

* Line 5: Specifies the version of the chart. Each time we change the app version or change the templates we should increase this value.

* Line 6: Specifies the version of the app. Each time we change the version of the app or change its code, we should increase this value. It’s recommended to use double quotes for this value.

### Write values.yaml
If we need to use a variable inside our templates it shall be defined here. We define the variables and assign them a value here, so this way we don’t need to change our templates each time we need to change our ports or environment variables.

Open the file **values.yaml** and add these lines.
```yaml
replicas: 2
config:
  bind: 0.0.0.0
  port: 5000
custom_config: "true"
```


As you saw earlier in the templates, we used some variables which are defined here.

Let's say you want to access the bind variable in line 3 inside our manifest. To do so, we go like this:
```yaml
{{ .values.config.bind }}
```
<br/>

### Write .helmignore
Let’s say you are using an editor like Vscode, of course, you don’t need files created by your editor. To ignore those files you need to create **".helmignore"** and add the files you want to ignore in your Helm. For example:
```bash
.git/
.gitignore
*.swp
*.bak
*.tmp
.idea/
.vscode/
```


We added files related to git, Vscode, and temporary files as you see above. This way we make sure our unnecessary files won’t be added to our Helm deployment.
<br/>


## Run the App
Now everything is ready, we can deploy our Helm chart. Deploy the chart with the following command
```bash
helm install --create-namespace -n python-app python-app\
     python-app/
```
<br/>


## Access the App
We configured our Ingress object with the hostname "my.example.com", therefore we need to access it via this domain. To do that we add this line to our `/etc/hosts` file:
```bash
192.168.20.2 my.example.com
```


Now if we curl the domain we should get the result:
```bash
curl "http://my.example.com/execute?command=touch&argument=/file1"
The command executed is: touch /file1
The exit status code is: 0
Machine-id: python-5f949b9dc8-9wvhh
```

If we do it again we see the machine-id value is changed.
```bash
curl "http://my.example.com/execute?command=touch&argument=/file1"
The command executed is: touch /file1
The exit status code is: 0
Machine-id: python-5f949b9dc8-ljpqk
```


And if we exec into our containers and check the hostnames we get the following:
 
 
Container 1:
![Container 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4jduxev6qa452jgkate7.png)
 
Container 2:
![Container 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wx5tqa2mq0vt9y5q0h7j.png)
 
 
The reason is we have two replicas of our app in a pod. So each time gets routed to a different container. That’s it. Our Helm chart is deployed and our application is running as expected.
<br/>


## Conclusion
So far we created a chart, deployed it on a server, and got a result. What we did for this Helm chart was naive and the simplest. There are lots more we could add to our chart like adding dependencies to inject other charts to our chart, notes to add guidelines about the application, and other Kubernetes objects, and etc. As we will learn more, we write perfect, complex charts and enhance them in the future. So keep learning
<br/>

I hope you find this article useful and please leave a comment or contact me via my page.
<br/>


For more information about Helm please visit: https://helm.sh/docs/